ChatGPT Next Web 计费魔改小记

眼看 GPT-4 系列正式开放(general available),OpenAI 还为流式(stream)输出增加 token 统计字段,我决定更新我的 GPT 网页。我一直用 chatgpt-web,自己修改代码加上的按量计费、版本切换(按量计费的 token 数还是估计的,对 GPT-4o 会高估),这时想再支持图片上传。想了几个方案,不是很妥:前端交互复杂,后端还依赖于 chatgpt-api 底层库。换一套“皮”是更好的选择。搜索 GitHub,就 ChatGPT Next Web 吧。

本来我没打算大改 ChatGPT Next Web。每个聊天对话能独立切换模型,它有了。我想要加上的图片输入,也有。我只需要加上计费。然而最终我还是动了很多地方,可以说是“魔改”吧,包括功能和外观的。我 fork 一份,提交了一个 bug 修复到上游,至今没有被合并。既然如此,我也没有后续提交的兴致。索性在这里稍微记录一下。

总的来说,我希望尽可能沿用之前我改的那套逻辑。即全站开启 HTTP Basic Auth,用于用户登录认证。自行定义 SHANSING_MODEL_CHOICES 用来配置模型单价等信息。不推荐直接使用我的项目,但如果要用请注意这些点。

ChatGPT Next Web 原生支持多种大模型,包括 OpenAI 的 GPT、Anthropic 的 Claude、Google 的 Gemini。在搭建过程中,我几乎都用过,所以下面我会顺便提到 GPT 以外的这些大模型。

为了使用 HTTP Basic Auth,需要修改 ./app/api/auth.ts 导出的 auth 函数,注释掉大部分用不到的逻辑,防止 Authorization header 冲突。

按量计费

首先当然是补上按量计费功能。如今,GPT、Claude、Gemini,官方都支持返回 token 用量,无论流式、非流式。OpenAI 的 Chat 接口,需要自行传入 stream_options,方可在流式输出时返回 usage 对象;其他两家无需额外设置。OpenAI 流式输出会在最后返回一个 trunk,这个 trunk 会在之前 finish reason 所在 trunk 的后面,实际报文如下所示:

...上面报文被省略...

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1719493000,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_sxx","choices":[{"index":0,"delta":{"content":" today"},"logprobs":null,"finish_reason":null}],"usage":null}

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1719493000,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_sxx","choices":[{"index":0,"delta":{"content":"?"},"logprobs":null,"finish_reason":null}],"usage":null}

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1719493000,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_sxx","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null}

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1719493000,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_sxx","choices":[],"usage":{"prompt_tokens":94,"completion_tokens":9,"total_tokens":103}}

data: [DONE]

在 DONE 之前的 data 段就是 usage 段。这个 usage 对象,实际上跟非流式输出时定义一样。于是可以编写一段通用的解析代码,从报文最后往前查找 "usage",然后解析后面的花括号内容。当然,这里原则上最优雅的做法是按照 HTTP 的 Server-Sent Events(SSE)去一条条解析消息,但是后面我们会看到查找字符串实现起来最简单。由于 GPT 响应 delta 报文中不会包含 "usage"(JSON 字符串里如果出现这个文本,引号会被转义),所以如此处理也不会有 bug。

在 GPT 的帮助下,解析 usage 对象的代码如下:

export function parseUsageObj(
  responseBody: string,
  key: string,
  fromStart: boolean,
) {
  const usageIndex = fromStart
    ? responseBody.indexOf('"' + key + '"')
    : responseBody.lastIndexOf('"' + key + '"');
  if (usageIndex !== -1) {
    const openBracket = responseBody.indexOf("{", usageIndex);
    const closeBracket = responseBody.indexOf("}", openBracket);
    if (openBracket !== -1 && closeBracket !== -1) {
      const jsonString = responseBody.substring(openBracket, closeBracket + 1);
      try {
        return JSON.parse(jsonString);
      } catch (e) {
        return null;
      }
    }
  }
  return null;
}

转到 ./app/api/openai/[...path]/route.ts,在 requestOpenai 调用后面,我们克隆一个 response,然后用异步的方法读取其中 body,交给上述函数解析,然后调用 pay 函数扣款:

response
      .clone()
      .text()
      .then((responseBody) => {
        //console.log("[responseBody]" + responseBody)
        const usage = parseUsageObj(responseBody, "usage", false);
        console.log(
          "[OpenAI Usage]<" + username + ">",
          JSON.stringify(usage),
        );
        if (
          usage &&
          usage.prompt_tokens != null &&
          usage.completion_tokens != null
        ) {
          return {
            promptTokenNumber: usage.prompt_tokens as number,
            completionTokenNumber: usage.completion_tokens as number,
          };
        }
        console.warn(
          "[ATTENTION][openai] unable to find usage, username=" +
            username +
            ", url=" +
            req.url +
            ", responseBody=" +
            responseBody,
        );
      })
      .then((obj) => {
        if (obj) {
          return pay(
            username,
            modelChoice,
            obj.promptTokenNumber + firstPromptTokenNumber,
            obj.completionTokenNumber + firstCompletionTokenNumber,
            config.shansingOnlineSearchSearchPrice
              .mul(searchCount + newsCount)
              .plus(config.shansingOnlineSearchCrawlerPrice.mul(crawlerCount)),
          );
        }
      });

这里需要注意的地方是,不可直接从 response 中读取 body。因为 body 只能读取一次,这边计费读了以后就不能返回给客户端了,所以我们调用 clone() 复制一份。之所以还强调异步,是因为这里的大概含义是,从 OpenAI API 读取到 response header,即可拿到 response 对象,但调用 .text().json() 这种取 body 的函数又是一个异步(promise)过程:header 读完了,body 可以慢慢读。在流式输出中,需要所有的 trunk 都接收完,才算接收完 body,后面的 .then() 才会执行;或者如果改成 await 阻塞的话,等到 body 都接收完毕才会继续执行后面的代码。显然这里不应该同步阻塞,那样流式输出就不是真流式、没有打字机效果了。而这里也可以看到,拿到作为 text 的 body 通过字符串匹配 usage 对象是最简单的,如果还交给 SSE 库的话显然会变复杂。

对于 Gemini,usage 对象名称叫 usageMetadata,其中关注 promptTokenCountcandidatesTokenCount。对于 Claude,关注 usage 对象的 input_tokensoutput_tokens。但是注意 Claude 的流式输出,input_tokens 会在报文开始不久给出,而 output_tokens 的最终值在报文结尾处。那么需要拼接两个对象。详细代码参见这里,不再赘述。

再提一嘴安全问题。GPT 流式输出如果不传 stream_options,将没有 usage 对象返回,所以应当强制要求传入。ChatGPT Next Web 的整体思路是后端聊天 API 几乎只是转发。也就是说,如果用户调用聊天接口,没有传入 stream_options,我们应该在转发时补上相应参数,而不是继续照原样转发。如果不想修改入参,也可以检测然后拒绝

眼神好、记忆棒的人会发现我这边扣费逻辑跟以前不一样。没错,我去除了预扣费。因为我是小范围分享,照预扣费的逻辑,有的用户用到最后会有一定余额不好花掉,所以索性去除。实际上我也没怎么限制并发。在意的人可以自行加上,在接口最开始时检查拥有足够余额,最保险。

流式输出动画

第二个我改动比较大的点,是流式输出动画帧。查找原项目中的 animate response to make it looks smooth,可以看到去年底作者对动画进行了修改,期望使它更平滑。然而,仔细阅读代码,发现所谓平滑的本质是强行压住已经拿到的增量消息(delta),再拆成一节节——最坏情况下,一个字一个字地——显示输出。我不是很喜欢这样。我觉得应该拿到什么 delta,就直接显示出来,看起来会更畅快,也不会一点点慢慢输出结果一会突然输出剩余字符(这是 finish 函数的作用)。

另一方面,可以看到 animateResponseText() 函数最后,再次通过 requestAnimationFrame 调用自身。我不是很懂前端,但我觉得即使函数最开始有条件判断,如果能避免这种不加间隔的类无限嵌套调用会更好。并且在使用过程中,我发现会偶然出现流式输出突然停止的问题,就好像打字机打到一半罢工了。此时查看浏览器控制台,可以看到“Maximum update depth exceeded”的未捕获报错,看着像是 Next.js 的防御机制,不排除跟嵌套调用有关。单单只是用 try catch 捕获其中 options.onUpdate 的报错,似乎就能解决这个问题,没有观察到什么副作用。

这块地方我也改了很多次,最终改成了接近引入“平滑”动画之前的样子,同时保留使用 requestAnimationFrame 这个现代方法。具体来说,接收到一个 delta,就调用一次 requestAnimationFrame 来请求动画帧,在一帧中就将所有增量文本 delta 输出显示。这样应该最直接、性能最好。

          const delta = choices[0]?.delta?.content;
          const textmoderation = json?.prompt_filter_results;

          if (delta) {
            responseText += delta;
            requestAnimationFrame(() => options.onUpdate?.(responseText));
          }

不过在使用时仍然偶发“Maximum update depth exceeded”问题,暂时不知如何进一步解决。

联网搜索

联网搜索是我一早就想加的功能。我知道可以用 function call 实现,但是想来还是有点复杂,没有底气。直到浏览国内大模型时,看到 Moonshot 的文档指示我们可以用 search2ai。看了一下,挺符合需求。

快速过一遍,search2ai 是利用大模型的 function call 工具调用搜索、爬虫,其中搜索、爬虫的核心逻辑均使用外部 API,旨在以透明方式提供 OpenAI 等接口代理。也就是说,将其独立部署,只需要修改 GPT 前端的 Base URL 就可以快速接入联网搜索。考虑过后,我决定采纳这种方式。

我对 search2ai 魔改挺多,基本只剩个架子跟搜索核心逻辑,也许以后另写一篇详谈。这里只简单说几点。一是原版爬取网页是用外部接口,我不是很欣赏,改成原生 fetch 请求,通过 @mozilla/readability 提取阅读模式一样的主体内容,再转换为 Markdown 格式。后来引入 Jina AI Reader 辅助 PDF 解析。二是自行完善、新增了 Gemini、Claude 支持。根据实际需要,我去除了关于非流式的多余分支,固定第一次请求非流式、第二次流式。而 Claude 很容易链式调用 tool,在第二次如果仍然 call function 则不能返回正确结果(貌似还会回落到非流式,我没有仔细研究),需要额外添加用户消息阻断,这里没有像 GPT 那样的 tool options。另外,计费需要统计两轮请求的费用,加和计算。实际上因为第二次是流式原样返回,简单的做法是此时将第一轮请求的 usage 放到 reponse header,由 GPT 前端加总计费。

接入 ChatGPT Next Web 时,考虑到后端 API 基本是转发,我引入一个自定义的 X-Shansing-Online-Search request header。当这个请求头存在并值为 true 时,就请求本地部署的 search2ai,将真正的 Base Url 以 X-Shansing-Base-Url 发送(因为要支持 OpenAI 兼容接口,如通义千问)。在聊天框上方加一个地球图标,给用户点击开启联网搜索。开启联网搜索就发送 X-Shansing-Online-Search: true 的 header,否则不发送。提取主题、压缩历史记录的请求总是不发送该 header。具体代码比较繁杂,可以自行拉取工程搜索关键词。

之后我还增加联网消息图标显示,用来确定一条回复消息到底有没有使用联网搜索。技术上说,就是到底大模型有没有调用 function call。需要跟 search2ai 的 response header 相配合。

Max token 参数

原项目不给大模型 API 传递 max_tokens 参数,并在注释中称其为 shit。但这样一来,前端界面的“单次回复限制”选项就形同虚设。于是我恢复传入这个参数。

然后我发现,其他地方又在使用(前端)设置的 max_tokens,并且含义跟 OpenAI 等聊天 API 不同。API 的 max_tokens 参数用来限制本次回复的 token 数(也就是输出 token 数)。但 ChatGPT Next Web 认为是总的 token 数,特别是输入 token 数,并以此来限制每次聊天所发送的上下文长度。经过权衡,我决定还是根据大模型 API 的含义修改逻辑。因为前端那个设置选项,默认值才 4000,不适应当前动不动 128K tokens 的上下文;4000 tokens 更像是单纯输出的长度。而且前端界面相应设置,标题和描述感觉自相矛盾;按照标题是控制回复(输出)的才对。

这边改动也有点大,见于 commit 067df37。先是需要理解大模型 API 的逻辑,一般需要我们自行管理上下文(先前我用的那个前端,底层库 chatgpt-api 似乎就干这个)。如果我们设置 4000 的 max token,说明我们期望这么长的输出,那对于一个 128K 上下文的模型,输入可用的就是 124000 tokens。注意输入包括系统消息(system message)、历史上下文(user、assistant)和本次需要发送的消息。统总计算,取合适轮次的上下文。我顺便修改了估计 token 数的方法 estimateTokenLength,采用一个 OpenAI 移植库真实计算 cl100k_base 的 token 数目。这是 GPT-3.5、GPT-4 采用的 tokenizer,GPT-4o 更新了使结果更小。但是没关系,历史上下文 token 计算出来宜多不宜少,宁愿携带更少的上下文也不要突然报错。事实上为了兼容其他模型,保险起见我还在结果乘了 1.1 的系数。

图片上传

所谓图片上传,ChatGPT Next Web 的实现其实是转为 base64 data url,然后传给大模型 API。因为 Local Storage 总大小极其有限,大约只有 5 MiB,所以图片会先经过本地压缩。

文首提到的我提交给上游的 pull request,是用来解决 HEIC 格式的问题。项目原本有 HEIC 支持。但用 Windows Chrome,选择 HEIC 格式的图片,file.type 值会为空导致走不到相应代码分支。用 iOS Safari,这个分支可以走到,但贡献者似乎漏写 else 导致后面会走原分支,抛出错误“上传”中止。另外,Windows Firefox 干脆在“打开”对话框选不到 .heic 文件,我自己把 fileInput.accept 的值从 MIME 类型改为扩展名就好了。

原有的图片压缩逻辑也不太行。压缩目标为 JPEG,采用 JPEG 质量等级,一步一步降低,降到一个阈值后转而缩小尺寸,直到文件大小为 256KiB。然而,根据 OpenAI 文档,GPT 支持的图片有最大尺寸限制,即短边 768px;其他模型类似。所以更好的做法是,不管三七二十一,首先缩小尺寸到短边 768px,然后才开始调降质量等级。我找到一个库,刚好可以同时设置缩放比例、目标文件大小。实测显示,甚至效率更好,肉眼可见变快很多。并且,在最初缩小尺寸之后,只调降质量等级,不再继续缩小图片,压到 256KiB 的图片也还能看。

代码可以在项目中查找 compressImage 函数,或者查看 commit #b26a9e2

通义千问

既然相比原来支持更多模型,我也看了国内的,考虑要不要新增支持。通义千问非常便宜,更重要的是支持 OpenAI 兼容格式。这样配置起来就非常方便。

不过为了共存,我没有直接修改 BaseUrl。而是仿照已有文件,新建 Alibaba 目录。参见 commit #61a1bd6

经过测试,通义千问-Max 的联网搜索效果不错。比 Gemini 之流强多了。不过中间突然改变过 API 行为,使得 tools 参数必须一直传下去,而不是在第二轮可以摘除不传(参见上面“联网搜索”章节),否则用 JSON 字符串作为 function response 会得到相当异常的回复。工单体验不太好,后面我会单独写一篇博客。

Qwen-Long 的回复质量也超出预期。像有的问题 GPT-4o 也不能很好回答,Qwen-Long 一枪命中。我前面说的“非常便宜”就是指降价后的 Qwen-Long,跟白送一样。如果我原价提供给朋友,甚至还要倒贴流量钱。Qwen-Long 这么便宜应该也是因为支持上传文件,鼓励大家多使用大文件、长 token。

文件上传支持起来比较简单,比 OpenAI 要额外调用 Assistant API 强太多。不过,貌似同时上传多个文件容易触发 429 Rate Limit,建议使用单选。有时候也会莫名触发,不是十分稳定。

小修改

接下来是一些小修改,想到哪写到哪。也不一一提供参考链接了。

自动刷新模型缓存。不知出于什么原因,ChatGPT Next Web 前端缓存了模型列表,导致服务端修改环境变量后用户很难看到更新。所以加了一个 resetModels() 函数及调用。

图片放文本前面。根据 Claude 文档,将 image 放到 text 上面有助于获得更好效果。

移除 Gemini 安全内容限制。代码传的 BLOCK HIGH。根据官方文档,相应参数指的是概率而不是严重程度。干脆改成 NONE。很多人觉得 Gemini 审查严格我估计跟这个设置有关。

正确处理 Claude 系统消息。原项目对 Claude 支持比较简略,比如没有正确传递 system 参数。这里要处理。注意这是外层参数,不是 messages / content,也不像 OpenAI 接口可以传递多个 system message。

移除 Claude 响应的 Content-Encoding header。对于非流式输出情况,Claude 似乎会错误地声明自己是 gzip 压缩,实际上并没有压缩。直接移除掉相关的 response header 就好。

所有模型都注入系统消息。虽然不确定 Claude 等模型用什么系统消息最好,但先按 ChatGPT 模板赋予一个,起码解决 LaTex 等问题。Claude 3 貌似是你不给定 LaTex 格式它就以为不支持,就不会吐出 LaTex 公式。

调用 API 时传递用户名 hash。GPT 和 Claude 支持传递用户名,未来如果发现滥用可以提醒我们。做一个 hash 后传过去就行。通义千问 OpenAI 兼容接口不支持,但传过去无害。Gemini 不支持。

为用户消息设定错误标志。相应处理逻辑本身有一句 userMessage.isError = ...,但这边 userMessage 算是一个拷贝对象,要赋给 savedUserMessage 才有用,可能是编码失误。标为错误的消息可以加个红圈圈 emoji,在之后的聊天中不会发送。用户消息也标为错误,就也不会作为后续上下文发送。比较符合直觉,也符合 Claude API 那种 user、assistant 必须一来一回的要求。

更新对话时尽量指定对话。分拆 updateCurrentSession 调用,如果不是确定要调整当前 session(对话),则需要指定。像更新对话主题(topic)的时机,实际上比最开始获取(当前)session 会延后一小段时间,不改可能出现更新到错误的对话上去的情况,因为“当前”session 已经切到另一个了。

改服务端 runtime 为默认。原项目使用 edge runtime,比较轻,但是缺乏很多 node API 支持。我部署 web 端,放心改成默认的 Node.js 运行时。这样可以继续用 fs 读写用户余额。

代理缓存 Emoji 和字体。将外部资源用 Nginx 代理并缓存,加快加载。

移除多余路径反代。可能是为打包成 app,ChatGPT Next Web 反向代理了很多接口,包括 OpenAI 聊天 API。我部署 web 端,这些看起来更像是安全漏洞,于是注释掉。我不想自己搭建一个 API 中转站。不过我建议你不要轻易试探访问,指不定有些实例背后是蜜罐。

结语

这个项目总体很好,节省很多工夫,感谢作者和贡献者们。这些修改多半是我个人需求,只是 pull request 能更积极 review、合并就好了。

在此插入一个提取错误信息的方法。从混杂 JSON 串或者 Nginx 默认错误页面中提取错误文案。对于 JSON 字符串,大概逻辑是优先取作为字符串的 messagemsg 值,如没有则取 key 包含“err”(如 errorerrMessageerror-message)的字符串类型的 value。会自动遍历嵌套对象。在这个项目的聊天、上传文件异常时能用到,算是小小万金油。由 Claude 辅助编写。代码比较长,搜索 extractErrorMessage 函数吧。

最后,尽管上面有些改动给了 commit 链接,仍然建议参考最新代码。链接:https://github.com/shansing/ChatGPT-Next-Web

若无特别说明,本文系原创,遵循 署名-非商业性使用 3.0 (CC BY-NC 3.0) 协议,转载文章请注明来自【闪星空间】,或链接上原文地址:http://shansing.com/read/542/

2 条评论

  1. 不明觉历呀。

  2. 看起来,最后没有被合并进去??

发表评论»

NO SPAMS! 不要发垃圾评论哦!

表情