我用 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。严格来说,要判断不同的模型给不同的参数。这里就是代码中刁钻的地方了。翻看 maxModelTokens
、maxResponseTokens
,定义完后传给了 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,但格式需要注意。项目所用类库应该不直接支持。
开始变现了有点意思,不知道现在有没有现成的能调用gpt4vision的程序
看了下服务端接口其实不复杂,但是如果功能完善的话前端工作量大一些。我在观望上游库做不做,Vision API 正式发布以后如果还不做我就自己做了。