网页显示还有额度,opencode 却报限额:一个 provider 挂两个账号 key 的坑

十月 14, 2026 [AI] #opencode #agent #调试

现象

在终端里让 opencode 回一句话:

$ opencode run "reply with exactly: OK"
> build · glm-5.3
> build · glm-5.3
……(连续重试 11 次)
Error: 已达到 5 小时的使用上限。您的限额将在 2026-10-14 16:17:09 重置。

但登录智谱后台看用量,5 小时额度只用了 35%,还剩 65%。同一把 key 直接打模型接口,偶尔还能通:

$ curl -sS -X POST https://open.bigmodel.cn/api/coding/paas/v4/chat/completions \
    -H "Authorization: Bearer $KEY" \
    -d '{"model":"glm-5.3","messages":[{"role":"user","content":"hi"}],"max_tokens":8}'

{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}   ← 多数时候
{"choices":[{"message":{"content":"..."}}],...}                               ← 偶尔成功

矛盾点有两个:

  1. 有额度却报限额;
  2. 报的是「5 小时限额」(1308),而我手打接口拿到的是「速率限制」(1302)——两种完全不同的错误。

第一轮:查额度,结果对不上

先找 opencode 到底读哪把 key。常规位置是全局配置:

$ cat ~/.config/opencode/opencode.json
{
  "model": "zhipuai-coding-plan/glm-5.3",
  "small_model": "zhipuai-coding-plan/glm-5.3-flash",
  "provider": {
    "zhipuai-coding-plan": {
      "options": { "apiKey": "7583f11a……" }
    }
  }
}

~/.local/share/opencode/auth.json 里同名 provider 也是这把 7583f11a……。看起来毫无疑问。

智谱提供了官方的额度查询接口(注意 Authorization 是裸 key,不加 Bearer):

$ curl -sS -H "Authorization: $KEY" https://open.bigmodel.cn/api/monitor/usage/quota/limit
{"code":200,"data":{"limits":[
  {"type":"CREDIT_LIMIT","unit":3,"number":5,"usage":28000,"currentValue":9894,"remaining":18105,"percentage":35,"nextResetTime":1791963412620},
  {"type":"CREDIT_LIMIT","unit":6,"number":1,"usage":140000,"currentValue":101405,"remaining":38594,"percentage":72,"nextResetTime":1792406188985}
],"level":"max"},"success":true}

level: max,5 小时窗口用了 35%,下一次重置 2026-10-14 15:36:52。根本没到限额。

期间还推翻过一个猜测:本机正跑着 BugHunter(它内嵌的 opencode 也在调智谱),一度怀疑是它把账号的并发/速率抢光了。实测那是另一个号上的行为叠加,不是这次报错的成因——留个记录,防止后面再往这条路上想。

第二轮:两个「重置时间」对不上

真正的线索藏在报错文本里:

报错:      限额将在 2026-10-14 16:17:09 重置
额度接口:  nextResetTime = 2026-10-14 15:36:52

差 40 分钟。如果这俩说的是同一份额度,时间必须一致。说明它们压根不是同一份额度——报错来自另一把 key、另一个账号。

第三轮:去凭据库挖真正在用的那把

opencode 换到 v2 之后(本机 v2.0.28),凭据不再只存 auth.json,而是进了 SQLite:

$ sqlite3 ~/.local/share/opencode/opencode.db ".schema credential"
CREATE TABLE `credential` (
  `id` text PRIMARY KEY,
  `integration_id` text,
  `label` text NOT NULL,
  `value` text NOT NULL,
  `connector_id` text,
  `method_id` text,
  `active` integer,
  ...
);

一查就露馅了:

$ sqlite3 ~/.local/share/opencode/opencode.db \
    "select id,label,active from credential where integration_id='zhipuai-coding-plan';"
cred_0b34fab5e003sgLc73m1YE8RNC|API key|0
cred_12f7e5bcf001CEr7ffu3qF02M2|Zhipu AI Coding Plan|1

同一个 zhipuai-coding-plan 挂着两把 key,其中 active=1 的那把(67ca616f……)既不是配置文件里写的 7583f11a……,也不是我以为的任何一把。把两把分别拿去问额度接口:

凭据 labelactive账号套餐5h 用量5h 下次重置
API key04231775790108884max35%15:36:52
Zhipu AI Coding Plan158081771827682775lite2047 / 2000 = 100%16:17:09

active 那条是另一个智谱账号的 Lite 套餐,5 小时额度打满,重置时间 16:17:09——与报错一字不差。

实锤:opencode 用的是 active 那条,配置文件里的 options.apiKey 只是摆设。

为什么会并存两把 key

这是 v2 凭据模型的设计,不是配置写错了。integration(provider)与 credential 是 1 : N,但用唯一部分索引锁死只能有一条 active:

-- core/src/database/migration/20260611035744_credential.ts
CREATE UNIQUE INDEX `credential_connector_active_idx`
  ON `credential` (`connector_id`) WHERE "active" = 1;

运行时解析优先级:

Integration 的 active connection  →  provider.options.apiKey(opencode.json)  →  env(ZHIPU_API_KEY)

active 连接一旦存在就赢。而 auth login / TUI 里点「连接 provider」的语义是**「新增凭据并激活」**,不是覆盖旧的。label 默认取 integration 名,重名才自动追加 2、 3——也就是说「一个 provider 多条凭据」是被预期的场景。

本机的时间线正好踩上去:

旧的那条不会删,只是被置成 active=0。于是「配置写 A、实际跑 B」,而 v1 时代只有 auth.json、一个 provider 一把 key、后写覆盖,根本不会出现这种并存——这也是为什么 BugHunter 内嵌的那份 v1.18.33 一直安安静静。

修复

# 看清楚有几条、哪条 active
$ opencode auth list --format json
$ opencode auth switch zhipuai-coding-plan cred_0b34fab5e003sgLc73m1YE8RNC

# 验证
$ opencode run "reply with exactly: OK"
OK

切完之后日志里再没出现「5 小时限额」,nextReset 也和 Max 账号的 15:36:52 对上了。剩下的偶发 > build · glm-5.3 重试是账号级 1302 速率限制,重试即过,与额度无关。

不需要的话,那条外账号凭据可以删掉——随后也确实删了:

$ opencode auth logout zhipuai-coding-plan cred_12f7e5bcf001CEr7ffu3qF02M2
┌  Log out of an account
◇  Removed account from Zhipu AI Coding Plan
└  Done

# 删完自检:只剩一条
$ opencode auth list
Zhipu AI Coding Plan             API key                     stored

注意 logout 删的只是本机这条登记,智谱侧那把 67ca616f…… 依然有效(拿去 curl 照样通),要彻底作废得去智谱控制台删 key。

追问:credential id 和 provider 签发的 api-key 是什么关系

修完顺手把模型捋清。疑惑很自然:auth list 里那个 cred_0b34fab5…… 并不是认证时填的 key——对,它本来就不是,两者是「登记号」和「钥匙」的关系:

provider 签发的 API keySQLite 里的 credential id
签发方智谱控制台(「新建 API Key」得到的那串)opencode 本地生成(ID.create(),cred_ 前缀)
在哪有效你 ↔ 智谱之间,服务端按它反查账号/套餐/额度本机 ↔ 本地 DB 之间,只是表主键
出现在哪每个 HTTP 请求的 Authorization 头只在本地 DB、auth list --format json、switch/logout 参数里,从不上网络
生命周期控制台创建/吊销,与 opencode 无关随 auth login/logout 增删,与智谱无关

一行 credential = 登记号(id)+ 钥匙本体(value.key)+ 人读名字(label)+ 生效位(active):

$ sqlite3 opencode.db "select id, label, substr(json_extract(value,'\$.key'),1,10), active …"
cred_0b34fab5e003sgLc73m1YE8RNC | API key | a3feeb6656 | 1
        ↑ opencode 生成的主键               ↑ 认证时填的 key 在 value 列

由这个模型直接推出三件事:

  1. 两把不同的 key 必然占两行(两个 cred_id)——value.key 是单值;但反向不成立,两个 cred_id 未必是两把不同的 key(同一把 key 重复 login 会登记成两行)。
  2. 多把 key 可同属一个账号:实测 7583f11a / a3feeb66 / 9ca0e2a2 三把查额度返回同一账号、同一数值——额度跟账号走,跟哪把 key 无关。所以同账号多行并存无害;这次的事故需要三个条件同时成立:多行 + 跨账号 + active 恰好落在外账号那行。
  3. logout ≠ 吊销:auth logout 删登记号,智谱侧 key 照常可用;反过来控制台删了 key,本地登记也不会自动消失,只会开始 401。轮换 key 的正确顺序:控制台建新 key → opencode login 新 key 并 switch → 验证可用 → 控制台删旧 key → 本地 logout 旧行。

还有一个易踩点:auth list 默认输出三列是「provider 名 | label | 来源」,没有 ID。好在 auth logout 的两个参数本来就接受「ID 或名字」「ID 或 label」,默认输出够用:

opencode auth logout "Zhipu AI Coding Plan" "API key"     # 名字 + label

但 label 与 provider 名撞车时(这次那条的 label 恰好就叫 Zhipu AI Coding Plan)极易搞混,要 ID 就走 JSON:

$ opencode auth list --format json | jq -r \
    '.[] | select(.id=="zhipuai-coding-plan") | .connections[] | "\(.id)\t\(.label)"'
cred_0b34fab5e003sgLc73m1YE8RNC	API key

想看登记的 key 本体,用 opencode auth export(会打印明文,别往外贴)。

复盘