让 chatgpt-web 支持按量计费与版本切换

我用 Chanzhaoyu/chatgpt-web 搭了一个 GPT 的网页端,小范围交流共享。本来这个项目没有计费功能,我也懒得加,GPT-3.5 价格不高,我都自己承担。GPT-4 公开开放以后,眼看费用骤增,于是我琢磨怎么计费。原作者实际上在推一个收费版,但我以成本价共享还是作罢。最终,我设计实现一套分用户、按量计费的简单方案。然后趁热打铁,顺势编写切换模型版本的功能,这样想便宜可以用 3.5,想有高质量用 4。

因为我仍然很懒,同时无意与前述收费版本竞争,所以也没编写用户管理。HTTP Basic Auth 够用了,用 Nginx 即可方便实现访问认证。需要添加用户的时候用 htpasswd 添加。本文预设前提即是这个。当然这只影响获取用户名,其他设计代码可以通用。假设有人用我改好的 fork,请注意这一点。

按量计费

实现按量计费的关键,在于统计输入、输出的 token 数。单价是已知的。如果阅读官方文档,API 的响应 JSON 包含一个 usage 字段,其中 completion_tokens 代表输出(结果)token,prompt_tokens 代表输入(提示词的)的 token。然而,如果设置了流式输出(stream mode,即打字机效果),则不会给出这些数值。寻觅一番,正当我打算自己写估算代码,发现已经有人做好了:chatgpt-web 依赖的底层库 chatgpt-api 已经支持流式输出下的 token 估算。

在 chatgpt-web 工程中,找到服务端的 function chatReplyProcess(options: RequestOptions),这里就是最终调用 chatgpt-api 发出请求的地方。定位 const response = await api.sendMessage(...),其中的 api 就是 chatgpt-api 的 ChatGPTAPI | ChatGPTUnofficialProxyAPI 类型,response 则是其响应返回。一路拿到 response.detail.usage.completion_tokens 的值就好啦,prompt_tokens 同理。

之前我看到过 api2d 这样的中间商,似乎有一个预扣费机制,我也学过来。大体上是最开始扣除单次最大开销,等到请求结束再依实际情况退还多收取的费用。因为实际费用在获取结果之后才能知道,这大概是某种防止余额变为负数的机制,封堵透支漏洞。而我这里后端,如果捕获到网络中断之类的异常,还是会全额返还。我也没有进一步考虑开启长回复(VITE_GLOB_OPEN_LONG_REPLY,如果本次返回收到因 token 问题截断,向 OpenAI API 重新发起请求,效果相当于帮用户说“继续说完”)的预扣费问题,反正目的是小范围使用。

引入 decimal.js 作精确小数运算。示意代码如下:

    if (!prePay(username)) {
        globalThis.console.error(username + "'s quota is not enough, need " + maxPrice);
        return sendResponse({ type: 'Fail', message: '[Shansing Helper] Insufficient pre-deduction quota, need ' + maxPrice })
    }
    //下面是原有代码
    const response = await api.sendMessage(message, {
      ...options,
      onProgress: (partialResponse) => {
        process?.(partialResponse)
      },
    })
    //上面是原有代码
    payback(username, response)
function prePay(username) {
    if (quotaEnabled && username) {
        // globalThis.console.log('prepay:', 'username', username, 'maxPrice', maxPrice)
        return decreaseUserQuota(username, new Decimal(maxPrice))
    }
    return true
}
function payback(username, response : ChatMessage) {
    if (username && quotaEnabled) {
        let plus;
        // globalThis.console.log('response.detail', response.detail)
        if (response && response.detail && response.detail.usage && response.detail.usage.completion_tokens != null) {
            let usage = response.detail.usage;
            //退还费用
            let thisBilling = (new Decimal(promptTokenPrice).mul(usage.prompt_tokens))
                .plus(new Decimal(completionTokenPrice).mul(usage.completion_tokens))
            plus = new Decimal(maxPrice).sub(thisBilling)
        } else {
            //退还所有费用
            plus = new Decimal(maxPrice)
        }
        // globalThis.console.log('payback:', 'username', username, 'plus', plus)
        increaseUserQuota(username, plus)
    }
}
function increaseUserQuota(username : string, delta : Decimal) {
    let quota = readUserQuota(username) //实现用户余额读取逻辑
    // globalThis.console.log(username + '\'s old quota: ' + quota.toFixed())
    let newQuota = quota.plus(delta)
    // globalThis.console.log(username + '\'s new quota: ' + newQuota.toFixed())
    if (newQuota.lt(0)) {
        return false;
    }
    //在此实现费用增加逻辑,负数为扣费
    return true;
}
function decreaseUserQuota(username : string, delta : Decimal) {
    return increaseUserQuota(username, new Decimal(-1).mul(delta))
}

其中的用户名需要在更外层取,具体是 service 下的 src/index.ts。可以让 ChatGPT 编写一个 getUsernameFromHttpBasicAuth 方法,从 req 拿到请求,其中的 authorization header 可以解析出用户名。

问题是计费信息存哪呢?正经的做法是用数据库,并且将每一次的计费记录存储起来。然而我懒,毕竟小范围用,你看我代码就知道,我其实只存了一个余额。而且我不想引入额外依赖,干脆写到文件里,反正 HTTP Basic Auth 的用户也是存到文件的。新建用户的时候也新建用户余额文件;如果需要修改余额,我就编辑余额文件。

具体代码不赘述,可以拉到文章末尾点链接看。注意流式传输下的 token 数是估计得来,可能偏小。

版本切换

要想版本在 GPT-3.5 与 GPT-4 间切换,调用 api.sendMessage 时注意 options.completionParams.model 的值就行了。事实上原项目已经提供了切换,不过是编译时手动在 .env 指定的,不能前端用户动态切换。你可能会想,那我在前端加个选项,后端这里根据不同选项传入不同的 model 值就行了。确实是,但还有一点。

不同的模型支持的 token 数不同。比如 gpt-3-turbo 支持 4k 上下文,gpt-4 支持 8k。严格来说,要判断不同的模型给不同的参数。这里就是代码中刁钻的地方了。翻看 maxModelTokensmaxResponseTokens,定义完后传给了 api = new ChatGPTAPI({ ...options }),也就是说 api 这个对象产生的时候就赋值好了,而模型名称要到每次用 api 请求 OpenAI 时才传入。我想了想,这里不能重构太多,我希望还能方便地从上游合并代码,于是我另外定义了跟 api 相同类型、相同作用的对象。并且由于模型是可选择的,我定义多个这样的对象,放到数组当中,同样预先赋值好。在每次请求 API 时拿出来用。

数组里顺便可以将计费价格相关的字段存起来。示意代码如下:

    if (modelChoices != null) {
        const metaMaxModelTokens = 1000
        for (let modelChoice of modelChoices) {
            let promptTokenPrice = new Decimal(modelChoice.promptTokenPrice)
            let completionTokenPrice = new Decimal(modelChoice.completionTokenPrice)
            let choiceOptions: ChatGPTAPIOptions = JSON.parse(JSON.stringify(options))
            let maxModelTokens
            let maxResponseTokens
            let lowercaseModel = modelChoice.model.toLowerCase()
            if (isNotEmptyString(MAX_TOKEN_TIMES)) {
                const maxTokenTimes = parseInt(MAX_TOKEN_TIMES);
                maxModelTokens = metaMaxModelTokens * maxTokenTimes
                maxResponseTokens = maxModelTokens / 4
            } else if (lowercaseModel.includes('16k')) {
                maxModelTokens = metaMaxModelTokens * 16
                maxResponseTokens = maxModelTokens / 4
            } else if (lowercaseModel.includes('32k')) {
                maxModelTokens = metaMaxModelTokens *32
                maxResponseTokens = maxModelTokens / 4
            } else if (lowercaseModel.includes('64k')) {
                maxModelTokens = metaMaxModelTokens * 64
                maxResponseTokens = maxModelTokens / 4
            } else if (lowercaseModel.includes('gpt-4')) {
                maxModelTokens = metaMaxModelTokens * 8
                maxResponseTokens = maxModelTokens / 4
            } else {
                maxModelTokens = metaMaxModelTokens * 4
                maxResponseTokens = maxModelTokens / 4
            }
            choiceOptions.maxModelTokens = maxModelTokens
            choiceOptions.maxResponseTokens = maxResponseTokens
            let maxPrice = quotaEnabled ? (promptTokenPrice.mul(maxModelTokens - maxResponseTokens)).plus(completionTokenPrice.mul(maxResponseTokens)) : null
            if (modelChoice.maxPrice == null && maxPrice != null)
                modelChoice.maxPrice = maxPrice.toFixed()
            modelChoice.api = new ChatGPTAPI({ ...choiceOptions })
        }
    }

(理论上所谓 4k tokens 应该是有 4096,但是底层库的估计似乎不总准确。实际使用偶尔会出现 OpenAI API 报错说超出数目,故我采用 1000 的倍数。)

请求接口时接收用户入参 modelName,相关代码为:

    let modelChoice = null;
    if (modelName && modelChoices) {
        modelChoice = modelChoices.find(choice => choice.name === modelName);
    }
    if (modelChoices && modelChoice == null) {
        return sendResponse({ type: 'Fail', message: '[Shansing Helper] Invalid model choice' })
    }

    ...

    let processApi = api;
    if (modelChoice) {
        processApi = modelChoice.api
        options.completionParams.model = modelChoice.model
        if (!prePay(username, modelChoice)) {
            globalThis.console.error(username + "'s quota is not enough, need " + modelChoice.maxPrice);
            return sendResponse({ type: 'Fail', message: '[Shansing Helper] Insufficient pre-deduction quota, need ' + modelChoice.maxPrice })
        }
    }
    const response = await processApi.sendMessage(message, {
        ...options,
        onProgress: (partialResponse) => {
            process?.(partialResponse)
        },
    })
    payback(username, response, modelChoice)

我也做了可配置的选择框,将相关 json 字符串写到 .env,如此修改模型可选项就不用再改代码。

#余额文件的目录(需要权限)
SHANSING_QUOTA_PATH=/opt/quota/
#模型数组,包含计费单价
SHANSING_MODEL_CHOICES='[{"name": "GPT-4", "model": "gpt-4", "promptTokenPrice": "0.0002", "completionTokenPrice": "0.0005"}, {"name": "GPT-3.5", "model": "gpt-3.5-turbo", "promptTokenPrice": "0.000013", "completionTokenPrice": "0.000018"}]'

完整代码

我维护了自用的 fork,上述改动可见于:https://github.com/shansing/chatgpt-web

README 就没写了,.env.example 我有提交改动。如果需要使用,可以参考范例。

2024-06-16 P.S.迟来的更新。OpenAI 官方已支持流式输出返回 usage,但格式需要注意。项目所用类库应该不直接支持。

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

2 条评论

  1. 开始变现了有点意思,不知道现在有没有现成的能调用gpt4vision的程序

    1. 看了下服务端接口其实不复杂,但是如果功能完善的话前端工作量大一些。我在观望上游库做不做,Vision API 正式发布以后如果还不做我就自己做了。

发表评论»

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

表情