水族馆

OPENAI API 代理折腾日记

两头墙,夹缝中生存

阅读时间 4 分钟


ChatGPT 作为生产力工具的首要条件是稳定可靠的使用。官网的网页版 ChatGPT 虽然免费,但速度和稳定性都不能满足需求,常常出现三天两头的故障,并且需要使用 OPENAI 服务地区的代理才能访问。

Telegram 机器人

为了更加稳定地使用 ChatGPT,一个好的方案是通过 API,将 Telegram Bot 与 ChatGPT API 结合,这样就可以方便地使用 ChatGPT。API 还有一个好处,就是可以设置“系统消息”,这种特殊类型的信息可以指示 ChatGPT 的角色或前置条件,例如“回答我的问题”,“把我的话翻译成英语”或者 “你是一个猫娘,你要模仿猫娘的语气说话”

但是,这需要手动编写代码来处理 多用户、多会话和身份验证 等逻辑,导致所有信息混杂在一个聊天窗口中,不是非常优雅。我也见过一些人将 ChatGPT API 挂在公众号或微信机器人上的情况。虽然这样做可以给自己使用,但也增加了被封号的风险,而且整体体验也不如 Telegram。

ChatGPT-API-Web

那怎么办?当然是造轮子 GitHub - heimoshuiyu/chatgpt-api-web。世界上又多了一个 ChatGPT 套壳应用。感谢伟大的 Web,现在我可以在电脑、手机甚至小而美上使用 ChatGPT。

调用 API 导致封号

从大约 3 月 10 日开始,OPENAI 开始大量封禁违规调用 API 的账号。违规原因只是模糊的违反使用条款。据网上信息显示,很多中国用户都或多或少有以下特点:使用大陆/香港 IP 地址发出的 API 请求,使用新注册账号,使用国内邮箱,使用国内厂商的 VPS,使用多人共享的机场,并且调用 API 的 IP 频繁变动。

用正向代理

最简单的解决方案是在客户端(如电脑、手机等)上使用代理。虽然自己可以使用这种方法,但无法分享给其他人使用,因为无法确保其他人使用相同的代理,另外自建的代理网络也可能不稳定,经常出现 Connection Reset 的情况。另外,经常切换代理也会导致 IP 频繁变动,可能会影响使用体验。

使用反向代理

反向代理是指在服务器端设置软件将 /v1/chat/completions 的请求反向代理到https://api.openai.com/。通过使用这种方法,可以避免 IP 被 OPENAI 封禁,并且多人共享的情况也可以得到有效解决。

chatgpt-api-web 只是一个网页,不关心后端在哪里。因此,只要指定 key 和 api 端点即可通过反向代理访问 OPENAI。例如可以使用部署在 AWS Lambda 上的后端: https://heimoshuiyu.github.io/chatgpt-api-web/?key=miku&api=https%3A%2F%2Flqiczgsk5q7u4taikle2bbpi4e0tzogo.lambda-url.us-east-1.on.aws%2F&mode=fetch

使用 Caddy 反向代理

这个很简单,只需要在服务器的 Caddy 配置文件中添加几行

example.com {
    reverse_proxy /v1/chat/completions https://api.openai.com {
        header_up Host {upstream_hostport}
    }
}

我之所以非常喜欢 Caddy 主要就是因为 Caddyfile 的语法非常简单,以至于我可以将整个配置文件倒着背出来。

接下来在 chatgpt-api-web 的设置中将 API Endpoint 替换成自己的服务器地址。

跨域请求

对于非常规的跨域请求(特指 非简单跨域请求),需要通过发送 method 为 OPTIONS 的预检请求来询问服务器能够接收哪些方法和头字段。默认情况下,Caddy 会将该请求转发到 OPENAI,然后 OPENAI 会返回正确的结果。然后 Caddy 再将结果返回给客户端,可以正常工作,只是稍微有点慢,因为浏览器必须在拿到预检请求的结果之后才能真正发起 API 请求。

可以让 Caddy 直接返回预检请求,从而处理这个问题。

example.com {
    @cors {
        method OPTIONS
        path /v1/chat/completions
    }
    respond @cors 200
    header @cors {
        Access-Control-Allow-Origin: "*"
        Access-Control-Allow-Methods: "*"
        Access-Control-Allow-Headers: "*"
    }
}

隐藏所有额外的请求头

随着我分享的链接越来越多,不断出现账号被封的情况。初步猜测是因为 HTTP 请求中多余的 headers,让 OPENAI 发现了这些请求来自中国大陆或者来自大量的不同客户端。

目前的发送 API 请求的路径是:

网页 -> Cloudflare -> VPS -> OPENAI

在各个环节抓包发现:

其他客户端或 CDN 可能会添加其他的不同请求头。虽然未知的请求头是无害的,但仍然有被封禁的风险。

所以有没有一种方法可以删除所有请求头,只保留特定的请求头呢?目前没有,但我提交了一个 Pull Request,维护者非常迅速的回复,比腾讯云和 AWS 的客服还要快,PR 已经被合并。感谢维护者,爱来自瓷器

v2.6.5 版本开始,可以使用 -* 语法保留特定的请求头。🥳

example.com {
    reverse_proxy /v1/chat/completions https://api.openai.com {
        header_up -*
        header_up Host {upstream_hostport}
        header_up Authorization {http.request.header.authorization}
        header_up Content-Type {http.request.header.content-type}
    }
}

完整配置文件

下面是我正在使用的完整 Caddyfile 配置文件内容。具体细节请参考 Caddy 文档

example.com {
  reverse_proxy /v2ray 127.0.0.1:2333

  handle /your/secret/api/path {
    rewrite /your/secret/api/path /v1/chat/completions
    @cors {
        method OPTIONS
    }
    header @cors {
        Access-Control-Allow-Origin: "*"
        Access-Control-Allow-Methods: "*"
        Access-Control-Allow-Headers: "*"
    }
    @post {
      method POST
    }
    reverse_proxy @post https://api.openai.com {
      header_up -*
      header_up User-Agent "Deno/1.31.2"
      header_up Host {upstream_hostport}
      header_up Authorization "Bearer sk-xxx (your TOKEN here)"
      header_up Content-Type {http.request.header.content-type}
      header_down Access-Control-Allow-Origin "*"
    }
  }

  reverse_proxy * https://a-luckly-website.com {
    header_up -*
  }
}

此文件配置了三个不同的反向代理。第一个反向代理处理路径 /v2ray,将请求转发到本地地址 127.0.0.1:2333。第二个反向代理处理路径 /your/secret/api/path,并为此路径设置 CORS 标头,然后将 POST 请求发送到 https://api.openai.com。最后一个反向代理将所有其他请求转发到 https://a-luckly-website.com,不做任何其他更改。

此 Caddyfile 允许您在访问后端服务时进行一些有用的更改,如添加 CORS 标头并更改 HTTP 标头。

这个配置文件还有一个很好的特性,是可以让您对外分享您的 API 路径,而不必担心泄露您的 API key。此外,该配置文件还允许您为特定 API 路径指定不同的反向代理规则,减少了被主动探测的风险。总的来说,这个 Caddyfile 可以大大提高您的 API 的可用性和安全性。

使用腾讯云函数反向代理

如果使用同一个 IP 地址调用多个 API,很可能会导致账号被封禁。那么如果我部署了 10 个 API,难道就需要维护 10 台服务器?显然使用像 Sass 之类的产品,是更好的选择。此处我选择了腾讯云函数,因为它支持自定义运行时。

云函数假设 HTTP 处理程序监听 9000 端口

:9000 {
  @cors {
    method OPTIONS
    path /v1/chat/completions
  }
  respond @cors 200
  header @cors {
    Access-Control-Allow-Origin "*"
    Access-Control-Allow-Methods: "*"
    Access-Control-Allow-Headers: "*"
  }
  @post {
    method POST
    path /v1/chat/completions
  }
  reverse_proxy @post https://api.openai.com {
    header_up -*
    header_up Host {upstream_hostport}
    header_up User-Agent "curl/7.88.1"
    header_up Authorization {http.request.header.authorization}
    header_up Content-Type {http.request.header.content-type}
    header_down Access-Control-Allow-Origin "*"
  }
}

可以使用 Docker 将 Caddy 和配置文件打包成镜像,然后上传到云函数。注意,Caddy 版本需要在 v2.6.5 或以上版本,以便支持 header_up -* 这个写法。

FROM caddy:latest

COPY ./Caddyfile /etc/caddy/Caddyfile

CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"]

在腾讯云函数的设置中,有一个固定的公网 IP 地址。

后来我询问了客服,确认云函数的固定公网出口 IP 是账户独享的。

设置并发数量、内存和超时时间,生成 800 个 tokens 大约需要 60 秒。

经过测试,可以正常稳定使用,但 stream 模式不可用,可能是因为腾讯云函数将 chunk 缓存,然后一起返回给客户端导致的。

使用 AWS Lambda 反向代理

每个厂商的云产品都有其独特的名字

AWS 在各种意义上的确是太复杂和 Over design 了。

同样地,Lambda 只能返回一次数据,因此“stream”模式不可用。

根据 AWS 的这篇教程: 使用 Lambda 函数、亚马逊 VPC 和无服务器架构生成静态出站 IP 地址 - AWS Prescriptive Guidance,教程中指出,创建 NAT 实例时会产生费用。我认为可能是 Elastic IP 的费用,但实际上,NAT 本身也会产生费用,约为 0.045 美元每小时,大约相当于 7 人民币每天。我创建了 3 个 NAT,两天后才发现这一点……血亏火锅钱 QAQ。后来研究后发现,实际上让 Lambda 通过静态 IP 出口走根本不需要 NAT。

分配移拉撕踢挨批

Elastic IP 将收取每小时 0.005 美元的费用,大约每月 25 人民币。但是似乎只有绑定到网络接口才不会产生费用?文档不太容易理解,但总之似乎没有产生费用。

为了让 Lambda 从特定的 IP 地址出口,需要在 Elastic IPs 控制台中创建一个 Elastic IP。

创建子网 Subnet

需要在 VPC 控制台中创建一个子网 Subnet,Lambda 将在其中启动。这个子网可以位于默认 VPC,也可以创建一个新的 VPC,但是自己创建的 VPC 还需要另外创建一个 Internet Gateway 并将其附加到新的 VPC 上,否则 Lambda 无法访问 Internet。

修改子网的路由表

在路由表列表中找到上面新建的子网的路由表。添加一个规则,将 0.0.0.0/0 路由到该 VPC 的 Internet Gateway。只有这样,运行时程序才能访问 Internet。

创建 Lambda

Lambda 不支持自定义运行时,因此需要编写符合 lambda 规范的 JavaScript 代码。需要使用fetch,因此需要 Node.js 的运行时版本 18.x。

然后

这些设置虽然可以在创建之后再修改,但是由于某个角色或权限等问题可能会失败。这太复杂了,因此我遇到这种情况时,选择炸掉整个函数并从头开始。

以下是 Lambda 代码

export const handler = async (event) => {
  // 验证自定义 token,方便提供链接给其他人时不泄露真正的 token
  if (event.headers.authorization !== `Bearer miku`) {
    return { statusCode: 403 };
  }
  const data = JSON.parse(event.body);
  data.stream = false;
  const req = await fetch("https://api.openai.com/v1/chat/completions", {
    method: event.requestContext.http.method,
    headers: {
      "content-Type": event.headers["content-type"],
      // 使用真正的 token 发送请求
      authorization: "Bearer sk-xxxxxx (your real token)",
    },
    body: JSON.stringify(data),
  });
  const text = await req.text();
  const response = {
    statusCode: req.status,
    headers: { "content-type": req.headers.get("content-type") },
    body: text,
  };
  return response;
};

更改 Lambda 设置

但还没完,Lambda 还有很多设置需要修改。

关联 Elastic IP 和 子网

你可能认为配置完毕了?但还有一步,记得之前创建的子网 Subnet 和申请到的 Elastic IP 吗?现在需要将它们关联起来,你猜这在哪设置?既不是 Subnet 也不是 Elastic IPs,而是 Network Interface!

在 Network Interface 控制台中,找到已关联到 Lambda 所在 Subnet 的 Network Interface,然后选择关联刚刚申请到的 Elastic IP。

至此,Lambda 配置完成。

写在最后

封号问题通常与 IP 有关,我个人猜测,这可能是因为新申请的 IP 恰好被上一个使用者使用过并被滥用,或者是与其他因素有关(例如注册时间、发送违规内容等)。随着时间的推移,OPENAI 可能会更改其检测策略,总之,因为互联网如此之大,总会有解决问题的办法。

最后,本篇文章使用了 ChatGPT 3.5 的协助(包括这句话)。