mslxdff 0.1.158 → 0.1.159
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/docs/ARCHITECTURE.md +0 -426
- package/docs/FEATURE_TREE.md +0 -164
- package/docs/MOBILE.md +0 -82
- package/docs/adr/0001-reasoning-content-injection.md +0 -14
- package/docs/adr/0002-models-free-filter.md +0 -12
- package/docs/adr/0003-zero-state-no-auth.md +0 -10
- package/docs/adr/0004-bearer-token.md +0 -18
- package/docs/adr/0005-peer-mesh.md +0 -53
- package/docs/adr/0006-broadband-member.md +0 -103
- package/docs/adr/0007-multi-provider-prefix.md +0 -25
- package/docs/adr/0008-share-keys-to-peers.md +0 -52
- package/docs/adr/0009-chat-repl.md +0 -37
- package/docs/adr/0010-allowlist.md +0 -36
- package/docs/adr/0011-broadband-stream.md +0 -30
- package/docs/adr/0012-responses-endpoint-codex-sync.md +0 -48
- package/docs/adr/0013-node16-compat.md +0 -41
- package/docs/adr/0014-deepseek-provider.md +0 -52
- package/docs/adr/0015-upstream-probe-routing.md +0 -50
- package/docs/adr/0016-model-capabilities.md +0 -27
- package/docs/adr/0017-ai-sdk-upstream-engine.md +0 -59
- package/docs/adr/0018-zen-client-identity.md +0 -52
- package/docs/adr/0019-share-keys-always-lend.md +0 -59
- package/docs/adr/0020-zen-free-lane-agent-shape.md +0 -52
- package/docs/adr/0021-usage-report-jsonl.md +0 -47
- package/docs/adr/0022-models-capability-merge.md +0 -61
- package/docs/adr/0023-key-provider-default-direct.md +0 -58
- package/docs/adr/0024-node18-baseline.md +0 -63
- package/docs/adr/0025-workbuddy-authdir-follows-state.md +0 -71
- package/docs/adr/0026-cline-provider-id-unify.md +0 -67
- package/docs/adr/0027-codearts-provider.md +0 -48
- package/docs/adr/0028-traework-provider.md +0 -34
- package/docs/adr/0029-qoder-native-provider.md +0 -60
- package/docs/adr/0030-models-list-scoped-by-picks.md +0 -53
- package/docs/adr/0031-qoder-true-streaming.md +0 -50
- package/docs/adr/0032-generic-responses-channel.md +0 -72
- package/docs/adr/0033-cline-allowlist-auto-sync.md +0 -75
- package/docs/adr/0034-request-level-human-readable-observability.md +0 -60
- package/docs/adr/0035-sdk-channel-headers-timeout.md +0 -49
- package/docs/adr/0036-qoder-per-request-sticky-account.md +0 -82
- package/docs/adr/0037-qwenwork-independent-provider.md +0 -82
- package/docs/adr/0038-zcode-provider.md +0 -140
- package/docs/agents/domain.md +0 -51
- package/docs/agents/issue-tracker.md +0 -30
- package/docs/agents/triage-labels.md +0 -15
- package/docs/cli_help.md +0 -1391
- package/docs/plans/bench-via-latency-2026-09-01.md +0 -215
- package/docs/plugins.md +0 -187
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
# ADR-0032: 通用供应商 responses 通道(muse-spark* 改打 /responses)
|
|
2
|
-
|
|
3
|
-
日期:2026-09-22 · 状态:已采纳
|
|
4
|
-
|
|
5
|
-
## 背景
|
|
6
|
-
|
|
7
|
-
通用(带 key)供应商历来只走一条出口:`chatPath`(缺省 `/chat/completions`)。
|
|
8
|
-
但 `muse-spark*` 这类 **responses 类模型**在同一个 host 只挂 `/responses`,打 `/chat` 会得到
|
|
9
|
-
上游的 503:
|
|
10
|
-
|
|
11
|
-
```
|
|
12
|
-
503 {"error":{"type":"server_error","message":"Upstream request failed: Endpoint is unavailable."}}
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
ocgo(`https://opencode.ai/zen/go/v1`)实测 2026-09-22:
|
|
16
|
-
|
|
17
|
-
| 端点 | 模型 | 结果 |
|
|
18
|
-
|---|---|---|
|
|
19
|
-
| `/go/v1/chat/completions` | `muse-spark-1.3-contributor` | 503 Endpoint is unavailable |
|
|
20
|
-
| `/go/v1/responses` | `muse-spark-1.3-contributor` | 200(JSON 与 SSE 都可) |
|
|
21
|
-
| `/go/v1/chat/completions` | `mimo-v2.5`(同 key) | 200 |
|
|
22
|
-
|
|
23
|
-
同 key 的 chat 模型正常 → **key 有效,纯端点选错**。更糟的是 503 会把唯一那个 key 打进
|
|
24
|
-
30s 冷却,紧随其后的请求变成 `502 all API keys are in cooldown`,用户看到的是"整个供应商挂了"。
|
|
25
|
-
|
|
26
|
-
判定信息其实早就存在:`GET /v1/models` 对每个模型暴露 `capabilities.upstreamApi`
|
|
27
|
-
(`responses` / `chat`,ADR-0022 起),muse-spark 的条目明确写着 `upstreamApi=responses`——
|
|
28
|
-
但通用 provider 从未消费它。
|
|
29
|
-
|
|
30
|
-
## 决策
|
|
31
|
-
|
|
32
|
-
新增 `src/providers/responses-channel.js` 深模块,把 responses 类模型分流到独立通道:
|
|
33
|
-
|
|
34
|
-
- **判定单一来源**:复用既有 `isResponsesModel`(models.dev 模型级 `provider.npm` +
|
|
35
|
-
`muse-spark` 前缀兜底),与 `/v1/models` 的 `capabilities.upstreamApi` 同源。
|
|
36
|
-
新模型上线无需改码,元数据先到即自动生效。
|
|
37
|
-
- **端点**:`<baseUrl>/responses`,`MSLXDFF_<ID>_RESPONSES_PATH` 可覆盖异形上游。
|
|
38
|
-
- **出参恒为 chat 形状**(调用方零分支):
|
|
39
|
-
- 流式 → 优先 `@ai-sdk/openai` 的 responses 适配器(复用 ADR-0017,含加密思考往返、
|
|
40
|
-
跨 caller 400 降级重试、`_sdkLoadFailed` 落原生兜底);
|
|
41
|
-
- 非流式 / SDK 不可用 → 原生 `chatToResponsesBody` 正转换 + `toChatResponse` /
|
|
42
|
-
`reshapeResponsesSse` 反向整形。
|
|
43
|
-
- **语义对齐**:key 轮换、按状态码的退避重试表、全 key 冷却短路报错、`_t`
|
|
44
|
-
(attempts/waitMs/totalMs)计时全部与 `base.js` 的 `createChatRunner` 同形,
|
|
45
|
-
避免两条通道日后行为漂移。
|
|
46
|
-
- **按"先拆后写"落地**:`generic.js` 因此瘦回 6.0KB(<10KB 预算),
|
|
47
|
-
通道逻辑独立成 5.7KB 深模块,单一对外 `createResponsesChannel`。
|
|
48
|
-
|
|
49
|
-
## 备选方案
|
|
50
|
-
|
|
51
|
-
- **配置硬改 `chatPath=/responses`**:零代码。否决理由:该供应商的 chat 模型
|
|
52
|
-
(mimo/deepseek)会全部因 body 形状不符(`messages` vs `input`)挂掉,是把一个模型的问题
|
|
53
|
-
扩散成全供应商问题。
|
|
54
|
-
- **只改 `chatPath` 并让网关做形状转换,不建新模块**:改动最少。否决理由:`chatPath` 是
|
|
55
|
-
整条供应商的单一出口,转换只能在"按模型判定"处发生;判定点既然存在,就让出口也跟着判定走,
|
|
56
|
-
否则等于给同一供应商埋两条互斥语义。
|
|
57
|
-
- **把 responses 分支塞进 `generic.js`**:文件从 5.0KB 涨到 10.2KB,越过仓库 10KB 预算,
|
|
58
|
-
且把"端点选择 + 双向整形 + 重试语义"三种职责混进一个门面(违反先拆后写铁律)。
|
|
59
|
-
- **等上游把 muse-spark 挂回 `/chat`**:被动等待。否决理由:`/responses` 是 opencode zen 对
|
|
60
|
-
thinking 类模型的既定形态(同族的免费池 muse-spark 一直如此),不是临时故障。
|
|
61
|
-
|
|
62
|
-
## 后果
|
|
63
|
-
|
|
64
|
-
- 收益:`ocgo/muse-spark-1.3-contributor` 从 `503 → 502 all keys in cooldown` 变为 **200**
|
|
65
|
-
(实测 17:13/17:14 两条 200);同类 responses 模型在任何通用供应商上自动可用;
|
|
66
|
-
一次 503 不再因白等重试把 key 冷却成"看起来全挂"。
|
|
67
|
-
- 代价:通用供应商多一条出口,后续改请求/响应格式要同时看 `generic.js`(chat)与
|
|
68
|
-
`responses-channel.js`(responses);responses 通道的流式强依赖 `@ai-sdk/openai`
|
|
69
|
-
(optionalDependency,缺失时自动落原生转换,功能不减但少掉加密思考往返)。
|
|
70
|
-
- 已验证:`test/generic-responses.test.js` 10 用例全绿(端点选择、双向整形、
|
|
71
|
-
身份头跨端点保持、会话亲和、5xx/429 语义、组员借 key 不退化成 chat);
|
|
72
|
-
全量 `npm test` 1043 例 1041 pass / 0 fail。
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
# ADR-0033: Cline 白名单自动同步(上游列表 = 真相)
|
|
2
|
-
|
|
3
|
-
日期:2026-09-24 · 状态:已采纳
|
|
4
|
-
|
|
5
|
-
## 背景
|
|
6
|
-
|
|
7
|
-
`providerConfigs.cline.allowedModels` 是一份**静态快照**(由 `-provider cline free sync --yes` 一次性写入当前 free 目录)。
|
|
8
|
-
上游 `https://api.cline.bot/api/v1/ai/cline/recommended-models` 会漂移——新模型上架免费、同一模型从 `cline-pass/` 换到 `cline-free/` ——而静态快照不跟随变化,导致上游明明可用的免费模型被标记为 `× blocked — allowlist`。
|
|
9
|
-
|
|
10
|
-
实测案例(2026-09-24):
|
|
11
|
-
- `cline-free/gemini-3.8-flash`
|
|
12
|
-
- `stealth/space-bunny-alpha`
|
|
13
|
-
- `cline-free/mimo-v2.6-flash`(allowlist 里有 `cline-pass/mimo-v2.6-flash`,通道不同即视为不同裸 id)
|
|
14
|
-
|
|
15
|
-
问题根源是静态快照与上游脱节,需实现"上游列表即最新真相"的长期机制。
|
|
16
|
-
|
|
17
|
-
## 决策
|
|
18
|
-
|
|
19
|
-
实现每次成功读取上游 recommended-models 后立即把 free+clinePass 并入白名单的长期方案:
|
|
20
|
-
|
|
21
|
-
- **只增不减**:上游下架的模型保留在表内(回收走手动 prune,和 modelPicks 孤儿一致)。
|
|
22
|
-
- **仅在上游成功路径调用**:内置兜底常量不是上游真相,不触发 write。
|
|
23
|
-
- **幂等零写盘**:缓存命中不刷盘;无新增也不刷盘。
|
|
24
|
-
- **失败不影响请求**:异常吞掉并留日志,绝不打断 listModels/请求链路。
|
|
25
|
-
- **字段保留修复**:在 `saveProviderAllowedModels/saveProviderConfig` 重建 cfg 时显式恢复 `allowAnyModels`(实测这两函数会静默抹掉用户的允许任意设置)。
|
|
26
|
-
- **开关**:默认开启;禁闭用 `MSLXDFF_CLINE_AUTOSYNC=0`。
|
|
27
|
-
|
|
28
|
-
模块清单:
|
|
29
|
-
- `src/providers/cline/allowlist-sync.js`(纯函数 `planMerge` + `createAllowlistSync`)
|
|
30
|
-
- `src/providers/cline/models.js`(注入 `onModelsRefreshed` 回调)
|
|
31
|
-
- `src/providers/cline/index.js`(装配 state 读写)
|
|
32
|
-
- `src/state/schemas/allowlist.js`(`saveProviderAllowedModels` 恢复 `allowAnyModels`)
|
|
33
|
-
- `src/state/schemas/provider.js`(`saveProviderConfig` 恢复 `allowAnyModels`)
|
|
34
|
-
|
|
35
|
-
实测:三个曾被拦的模型从 22→25 个允许,原 blocked → ✓。
|
|
36
|
-
|
|
37
|
-
## 备选方案
|
|
38
|
-
|
|
39
|
-
| 备选 | 最强论据 | 否决理由 |
|
|
40
|
-
|----|----------|----------|
|
|
41
|
-
| **A. allowAny on**(零代码改动) | 用户可直接用,立即放开所有 | 失去白名单保护;对多供应商策略不友好。仅作临时过渡,不做长期。 |
|
|
42
|
-
| **B. isModelAllowed 二次校验上游缓存** | 不改动 state schema,动态判断 | 引入额外逻辑耦合;状态文件仍是脏数据源;不利于观测审计。 |
|
|
43
|
-
| **C. auto-sync upstream to allowlist**(当前选此) | "上游列表=真相",数据一致性最好;一次改动两处(allowlist+state-save 修复)。 | 有写盘风险(但幂等 + 异常隔离已覆盖),已通过端到端验证(tokens/auths 保留)。 |
|
|
44
|
-
|
|
45
|
-
## 后果
|
|
46
|
-
|
|
47
|
-
**收益**
|
|
48
|
-
- 上游上架的新免费模型不再被误拦(实测三个全放开)。
|
|
49
|
-
- 通道切换的模型也放行(如 pass→free,两通道并存不冲突)。
|
|
50
|
-
- 凭据安全:keys/auths 数量不变,从不打印内容。
|
|
51
|
-
- 幂等零写盘(缓存命中不刷盘)。
|
|
52
|
-
- 兜底不污染白名单(上游挂掉只用内置常量,不会把兜底条目写进 allowlist)。
|
|
53
|
-
|
|
54
|
-
**代价**
|
|
55
|
-
- state.json 变大(新增裸 id,实测 22→25,增量极小)。
|
|
56
|
-
- **状态变化**:allowlist 从静态快照变动态增长(实测 22→25 个裸 id);新增免费模型不再被误拦。
|
|
57
|
-
- **付费通道代价**:`cline-pass/*` 条目随之一并放权,意味着这些付费模型可被调用或被 `auto` 选中 → 消耗用户额度。此结果是用户明确要求(spec:"free 模型列 + cline-pass 列表"),故不撤销,但成本必须显式记录在案供后续审计。
|
|
58
|
-
- **require knowledge**:上游=最新真相,而非"用户手动 sync 那一刻的快照"。
|
|
59
|
-
- **持久化保护**:`saveProviderAllowedModels`/`saveProviderConfig` 重建 cfg 时静默抹 `allowAnyModels` 的既有缺陷已根治(本 ADR 同轮修复)。
|
|
60
|
-
**已验证**
|
|
61
|
-
- 单元测试:11 个用例(planMerge 纯函数 / syncIds 幂等与异常隔离 / mock fetch 集成 / 兜底不写盘 / 字段保留 / 幂等性),全部通过。
|
|
62
|
-
- 端到端:真实 state.json 副本 + 真实网络跑 listModels(),allowedModels 22→25,再拉一次仍为 25;真实文件未被修改。
|
|
63
|
-
- 回归:全量 1074 测试无中断。
|
|
64
|
-
- CLI:`mslxdff -provider cline models` 原 blocked 的三个现打✓。
|
|
65
|
-
|
|
66
|
-
**同轮历史缺陷修复**
|
|
67
|
-
`saveProviderAllowedModels` 与 `saveProviderConfig` 重建 cfg 时起手 `{baseUrl,keys}` 未带 `allowAnyModels`,实测 true 值会被静默抹成默认 false。本轮在两个 save* 中补充 `if (typeof cur.allowAnyModels === "boolean") configs[id].allowAnyModels = cur.allowAnyModels;`,彻底根治。
|
|
68
|
-
|
|
69
|
-
## 复核修正(code-review 指出,同轮落地)
|
|
70
|
-
|
|
71
|
-
- **自检也同步**:`checkFreeUpdates()` 原只写快照不调 `notifyRefresh`,开机后若无人访问 `/v1/models` 则白名单永不刷新。已在 `src/providers/cline/models.js` 自检成功路径补 `notifyRefresh(valid)`(只含 free,clinePass 由 `listModels` 补齐)。
|
|
72
|
-
- **delete 分支保留开关**:provider 仅有 `allowAnyModels:true` + 非空 allowlist 时,`allowlist clear` 会走 `delete configs[id]` 连带抹掉该开关,使供应商从"允许任意"降级为全拦 `403`。已在 `src/state/schemas/allowlist.js` 的删除判定中增加 `typeof cur.allowAnyModels !== "boolean"`。
|
|
73
|
-
- **CLI 兜底拒绝写盘**:`-provider cline free sync --yes` 在上游不可达时会把 5 个 `FALLBACK_FREE` 落成白名单,且 full-replace 会挤掉 auto-sync 并入的 clinePass,与"兜底不是上游真相"冲突。已在 `src/cli/commands/provider/cline-free.js` 拒绝写入并给出人话提示(确需手填改走 `allowlist set`)。
|
|
74
|
-
- **env 分支补测**:原用例走 `enabled:false` 参数捷径,未真正覆盖 `MSLXDFF_CLINE_AUTOSYNC=0` 读 env 分支。已补三条断言(设 0 关 / 设 1 开 / 未设默认开)。
|
|
75
|
-
- 复核后全量回归:**1076 tests / 1074 pass / 0 fail / 2 skipped**。
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
# ADR-0034: 请求级人读可观测性(timeline.log + 按模型链路日志 + 失败路径对账)
|
|
2
|
-
|
|
3
|
-
日期:2026-09-25 · 状态:已采纳
|
|
4
|
-
|
|
5
|
-
## 背景
|
|
6
|
-
|
|
7
|
-
网关的排障证据散在三处口径互不衔接的文件里:`events.log`(完整 JSON 事件,按时间混排所有请求)、`calls.log` / `errors.log`(按模型聚合的成败计数)。定位单个模型的问题时,无法直接打开「这个模型」的文件看到收请求 → 选路 → 转发 → 上游响应 → 回客户端的完整过程,只能手工跨文件按时间戳拼接。
|
|
8
|
-
|
|
9
|
-
更关键的是**失败请求没有留下「已回客户端」的证据**:成功路径在 `result` 之后必发 `client-response` 事件,而四个失败收尾分支(`handleExhaustedLocal` / `handleExhaustedAll` × 有/无 upstream)只发 `result`。实测 240 个请求的模型日志里 238 条 `result` 对 231 条 `client-response`,7 条缺口无从判断是「响应没写出去」还是「事件漏记」。
|
|
10
|
-
|
|
11
|
-
## 决策
|
|
12
|
-
|
|
13
|
-
建立两个互补的人读日志面,并让失败路径与成功路径的事件口径一致。
|
|
14
|
-
|
|
15
|
-
**1. `timeline.log`:每请求一行汇总**
|
|
16
|
-
纯函数 `src/timeline.js` 负责渲染,`src/logs.js` 的 `appendTimeline` 负责落盘,数据源是 `ChatPipeline` 的 `evt` 事件流(与 `events.log` 同源,不新增埋点)。一行含:时间、reqId、model、直连结果、组员逐个胜负与耗时、空转重试次数、最终 status/finish_reason/tools/chars、总耗时。
|
|
17
|
-
|
|
18
|
-
**2. `logDir/<provider>-<model>.log`:按模型链路日志**
|
|
19
|
-
`src/model-trace.js` 把模型 id 安全化为文件名(`/` 等非法字符转 `-`),按请求阶段记录 `request` / `ordered` / `upstream-try` / `upstream-done|upstream-error` / peer / `relay-done` / `result` / `client-response`。阶段白名单为 `TRACE_STAGES` 集合,排除 `peer-health` 一类心跳噪音。
|
|
20
|
-
|
|
21
|
-
**3. 失败路径补 `client-response`**
|
|
22
|
-
四个失败收尾分支在 `evt("result", ...)` 之后补 `evt("client-response", { requested, actual, via: "local"|"none", fallback: false, status, reqId })`,与 `relay-pipeline.js` 成功路径同形状;`via` 区分「本地 relay 收尾」与「完全无上游」。
|
|
23
|
-
|
|
24
|
-
**贯穿约束**
|
|
25
|
-
|
|
26
|
-
- **不落敏感面**:prompt、响应正文、headers、cookie、token/refreshToken/API key 一律不入盘;`upstream-try` 与 peer 事件只带 `summarizeRequest` 产出的安全摘要(stream / messages 数 / roles 计数 / tools / maxTokens);错误文本过 `safeText` 截断并对 URL query 中的凭据形态脱敏。`request` 事件的数据副本中显式 `delete prompt`。
|
|
27
|
-
- **行序保证**:`appendModelTrace` 用同步 `appendFileSync` 而非 `appendFile`——异步 append 在并发请求下会把同一请求的阶段行打乱,链路日志失去意义。
|
|
28
|
-
- **不拖慢请求**:日志 IO 失败一律吞掉,不影响聊天请求。
|
|
29
|
-
- **体积可控**:单文件超 1MB 时重写为最近 100 行。
|
|
30
|
-
|
|
31
|
-
## 备选方案
|
|
32
|
-
|
|
33
|
-
| 备选 | 最强论据 | 否决理由 |
|
|
34
|
-
|----|----------|----------|
|
|
35
|
-
| **A. 只加 timeline.log**(请求级一行) | 实现最省,`ChatPipeline` 已有全部数据,一个纯函数渲染即可 | 排障粒度不够:同一模型多次请求互相穿插,仍要手工按 reqId 拆行;模型专属问题(该模型是否总空转、是否总走 peer)看不出来 |
|
|
36
|
-
| **B. 只加按模型日志**(不做 timeline) | 直接解决"打开这个模型的文件"的核心诉求,且天然按请求分段 | 缺全局视角:一次请求先直连失败转组员成功、再回退重试的全貌,需要跨文件拼;也无法快速回答"最近整体成功率如何" |
|
|
37
|
-
| **C. A + B 双面并存**(当前选此) | 两个视角各回答一类问题:timeline 回答"这次请求经历了什么",model log 回答"这个模型长期表现如何";两者同源投影,无重复埋点 | 两份日志要维护一致性——由"同一 evt 流投影"保证,且失败路径补 `client-response` 后 `result` 与 `client-response` 条数可对账 |
|
|
38
|
-
|
|
39
|
-
## 后果
|
|
40
|
-
|
|
41
|
-
**收益**
|
|
42
|
-
|
|
43
|
-
- 排障从"跨文件手工拼接"变为"打开对应文件按 reqId 读一条完整链路"。
|
|
44
|
-
- 请求级成功率、组员命中分布、模型级健康度可直接从两个文件读出,无需解析 JSON。
|
|
45
|
-
- 失败请求同样留下回客户端证据,`result` 与 `client-response` 条数可对账。
|
|
46
|
-
- 凭据面零新增:不落 prompt / 正文 / headers / token。
|
|
47
|
-
|
|
48
|
-
**代价**
|
|
49
|
-
|
|
50
|
-
- 每天新增写入量随请求数线性增长(每请求 1 行 timeline + 若干行 model log)。
|
|
51
|
-
- 日志文件数随使用过的模型数增长;单文件 1MB 轮转保留最近 100 行,**长时间运行后早期请求记录会被截断**(重访信号:某模型排障需要几小时前的上下文时)。
|
|
52
|
-
- 同步 append 写入在高频小请求下比异步 append 多占一点事件循环时间。
|
|
53
|
-
|
|
54
|
-
**已验证**
|
|
55
|
-
|
|
56
|
-
- `test/timeline-log.test.js`(3 例)、`test/model-trace.test.js`(6 例)、`test/chat-pipeline.test.js` 集成投影(断言 `SECRET_PROMPT` 不出现在日志中)、`test/exhausted-client-response.test.js`(5 例锁四分支与事件顺序)全部通过。
|
|
57
|
-
- 全量 `1127 tests / 1125 pass / 0 fail / 2 skipped`。
|
|
58
|
-
- daemon 重启后真实请求验证:`mimo-v2.5-free` 返回 200,对应 `timeline.log` 有该请求行、`mimo-v2.5-free.log` 阶段完整;`ocgo-muse-spark-1.3-contributor.log` 622 行 / 64 请求逐阶段可读。
|
|
59
|
-
|
|
60
|
-
**相关**:SDK 通道挂死防护(`doStream` headers 超时)是独立决定,见 ADR-0035。
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# ADR-0035: SDK 通道 headers 超时(防上游半死连接挂死)
|
|
2
|
-
|
|
3
|
-
日期:2026-09-25 · 状态:已采纳
|
|
4
|
-
|
|
5
|
-
## 背景
|
|
6
|
-
|
|
7
|
-
`src/upstream-engine/sdk/attempt.js` 的 `attemptOnceSdk` 对 AI SDK 的 `model.doStream(...)` 是裸 `await`,没有任何超时闸门。legacy 通道的 `fetch` 走 undici Agent,连接级与首块级超时都有;但 SDK 通道一旦上游连接半死(TCP 建好、握手完成、上游永不回响应头),该 promise 永不 resolve。
|
|
8
|
-
|
|
9
|
-
表现是请求静默悬空:既没有 `upstream-done`,也没有 `upstream-error` / `result` / `timeline` 记录,网关侧看不出任何异常。实测 2026-09-25 17:48:24 的 `cline-free/muse-spark-1.3-contributor` 请求悬空 27 分钟以上(reqId `mugs2x0s-mon6`),日志里只剩开头几行;只能等客户端自己超时重发,服务端资源与上游连接一直被占着。
|
|
10
|
-
|
|
11
|
-
## 决策
|
|
12
|
-
|
|
13
|
-
给 SDK 通道的 headers 阶段加显式闸门,默认 120s。
|
|
14
|
-
|
|
15
|
-
- **单点来源**:`headersTimeoutMs(env)` 读 `MSLXDFF_SDK_HEADERS_TIMEOUT_MS`,缺省 `120_000`。取值与末位候选耐心档 `MSLXDFF_LAST_CANDIDATE_TIMEOUT_MS`(同为 120s)一致——能等到 120s 才放弃的链路,headers 阶段没有理由更早放弃。`0` 或非有限值语义为关闭(回裸 await 行为)。
|
|
16
|
-
- **包裹方式**:`withHeadersTimeout(promise, ms, mkErr)` 用 `Promise.race` 包裹 `doStream` 调用,先赢路径 `clearTimeout`;超时赢后原 promise 仍可能 reject,兜一层 `.catch(() => {})` 静默吞掉,避免 unhandled rejection 崩进程。
|
|
17
|
-
- **双保险中止**:`attemptOnceSdk` 在闸门启用时建 `AbortController`,把 `abortSignal` 作为参数传给 `doStream`;进入 catch 路径时 `aborter.abort()`,让底层连接真正断开而不只是不再等待。
|
|
18
|
-
- **错误文案刻意不含 "timed out"**:`cline` 客户端的 `runChat` 用 `msg.includes("timed out")` 判断网络故障并重试,命中会把一次挂死放大成 3 次。文案改为 `sdk-channel: headers timeout after <ms>ms(上游未返回响应头,防挂死;MSLXDFF_SDK_HEADERS_TIMEOUT_MS=0 关闭)`。
|
|
19
|
-
- **闸门定时器不 `unref()`**:unref 会让事件循环排空,node:test 把用例判成 cancelled。定时器必须保持引用,它正是超时生效的对象。
|
|
20
|
-
|
|
21
|
-
## 备选方案
|
|
22
|
-
|
|
23
|
-
| 备选 | 最强论据 | 否决理由 |
|
|
24
|
-
|----|----------|----------|
|
|
25
|
-
| **A. 给 `fetchImpl` 注入 undici `headersTimeout`** | 复用 undici 已有能力,与 legacy 通道机制一致,无需应用层包裹 | `doStream` 的挂起不一定发生在 fetch 层(SDK 内部 promise 链、响应解析都可能不返回),fetch 层超时拦不住 SDK 侧挂起;且 `fetchImpl` 仅在调用方注入时才存在 |
|
|
26
|
-
| **B. 复用流式首块闸门**(`MSLXDFF_STREAM_TIMEOUT_MS` / `LAST_CANDIDATE_TIMEOUT_MS`) | 复用既有开关与既有换路逻辑,不新增 env | 首块闸门在拿到响应头并进入 relay 之后才起作用,SDK 通道在 `doStream` 返回前根本到不了那里;挂死发生在更上游,闸门够不着 |
|
|
27
|
-
| **C. headers 阶段独立闸门 + AbortController**(当前选此) | 精确对准挂死点;默认 120s 与末位耐心档同量级,不误杀慢模型握手;`0` 可关;abortSignal 让连接真正释放 | 新增一个 env 与一层包裹逻辑;极端慢的握手(>120s 无响应头)会被判挂死——可调大或关闭 |
|
|
28
|
-
|
|
29
|
-
## 后果
|
|
30
|
-
|
|
31
|
-
**收益**
|
|
32
|
-
|
|
33
|
-
- SDK 通道不再存在"永不返回"的黑洞:最坏 120s 出结果并交由调用方按既有逻辑回退 / 换路。
|
|
34
|
-
- 挂死请求会留下完整的 `upstream-error` → `result` → `timeline` 记录,可观测。
|
|
35
|
-
- 底层连接在超时时被 `abort` 真正断开,不留僵尸 socket。
|
|
36
|
-
|
|
37
|
-
**代价**
|
|
38
|
-
|
|
39
|
-
- 极端慢的上游握手(超过 120s 仍未回响应头)会被判挂死。对更慢的上游需调大 `MSLXDFF_SDK_HEADERS_TIMEOUT_MS` 或设 `0` 关闭(重访信号:某供应商常态首响应超过 2 分钟且被误杀)。
|
|
40
|
-
- 每次 SDK 调用多一层 `Promise.race` 与一个 `AbortController`(开销可忽略)。
|
|
41
|
-
|
|
42
|
-
**已验证**
|
|
43
|
-
|
|
44
|
-
- `test/sdk-headers-timeout.test.js` 7 例:闸门必抛 / `0` 关闭不误杀 / 先赢路径不产生 unhandled rejection / `doStream` 永挂必抛且文案不含 "timed out" / `abortSignal` 确实传给 `doStream` / 正常 SSE 流不受影响 / 429 错误映射回归。
|
|
45
|
-
- 全量 `1127 tests / 1125 pass / 0 fail / 2 skipped`。
|
|
46
|
-
|
|
47
|
-
**已知未覆盖**:`src/upstream-engine/sdk/responses.js`(responses 类模型通道)本轮未接入该闸门,同类挂死风险待评估。
|
|
48
|
-
|
|
49
|
-
**相关**:请求级人读可观测性见 ADR-0034。
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
# ADR-0036: qoder 同请求粘号(切号只由冷却触发)
|
|
2
|
-
|
|
3
|
-
日期:2026-09-27 · 状态:已采纳
|
|
4
|
-
|
|
5
|
-
## 背景
|
|
6
|
-
|
|
7
|
-
qoder 的两个账号分属两个区:`qoder-019fa664-…` = cn(`gateway.qoder.com.cn`)、`qoder-d210f787-…` = global(`api1.qoder.sh`)。region 决定 URL(`request.js` 的 `getEndpoints(normalizeRegion(region))`),所以"换号"必然表现为"换 URL"。
|
|
8
|
-
|
|
9
|
-
而 `src/providers/qoder/index.js` 的 `pickSession()` 此前**每次上游调用**都 `ring.next()`。`next()` 是 round-robin,调用一次前进一格 —— 于是**任何重试都会换号换区**。
|
|
10
|
-
|
|
11
|
-
实测 `qoder-qfmodel.log`(58 个客户端请求 / 87 次上游调用):
|
|
12
|
-
|
|
13
|
-
- 29 个请求只调一次上游(严格 global/cn 交替);29 个请求调两次。
|
|
14
|
-
- `relay-done` 里 32/58 是 `chars=0`(其中 29 次被判空转:`upstream-error 502 empty turn`)。
|
|
15
|
-
- 空转由 `relay-pipeline.js` 的 `_emptyTurn` 判定(200 + `chars=0` + `tools=0` + 非 tool 轮),随后 `serial-trial.js` 同模型重试(默认 2 次 / 间隔 1s)。
|
|
16
|
-
- 重试再次进 `pickSession()` → 换号换 URL,日志里就是"同一 reqId 先 global 后 cn"。
|
|
17
|
-
|
|
18
|
-
排障后果:用户看到"URL 莫名在切",却找不到任何正当理由 —— 因为**切号的真实触发者是重试,而这个事件当时被模型日志的阶段白名单吞掉**(`empty-turn-retry` 不在 `TRACE_STAGES`,只进 timeline 计数)。
|
|
19
|
-
|
|
20
|
-
## 决策
|
|
21
|
-
|
|
22
|
-
**一次客户端请求内只选一个号;只有该号进入冷却(401/403/429/5xx)才换下一个。**
|
|
23
|
-
|
|
24
|
-
- **新模块** `src/providers/qoder/sticky.js` 导出 `createStickyPicker({pick, isCooling, now, ttlMs, maxEntries})`:按 `scope` 记住上次选中的 key,命中且未冷却则复用(并刷新 `at`);未命中或冷却中才调 `pick()` 前进轮转。表按 TTL + 上限淘汰。无 `scope` 时直接 `pick()`,等价旧行为。
|
|
25
|
-
- **scope = `reqId`**:管线在 `chatOpts.reqId` 传本次客户端请求身份(`serial-trial.js` 已有 `reqId`),provider 用 `req:${reqId}` 作 scope。TTL 默认 600s(`MSLXDFF_QODER_STICKY_MS`,`0` = 关即退回每次调用轮转)。
|
|
26
|
-
- **冷却单点不变**:`chat()` 只在 401/403/429/5xx 与 fetch 异常时 `ring.onError(key)`;空转(`empty turn`)**不冷却**(空转≠号坏,换号也救不了超大上下文)。
|
|
27
|
-
- **`keyring.js` 暴露 `isCooling`**:选择器必须能判定"上次这个号还在冷却吗"(在冷却就必须换)。`next()` 本就不记录 last,所以 provider 侧的 `onError(key)` 依赖 `pickSession()` 把 `key` 带出。
|
|
28
|
-
- **models 探活不受影响**:`eachCred()` 逐号遍历取并集,走的是另一条路径。
|
|
29
|
-
|
|
30
|
-
## 备选方案
|
|
31
|
-
|
|
32
|
-
| 备选 | 最强论据 | 否决理由 |
|
|
33
|
-
|----|----------|----------|
|
|
34
|
-
| **A. 时间窗粘号**(keyring 记住上次返回值,N 秒内复用) | 零管线改动,一处生效 | 把"不同客户端请求"也粘在一起,破坏多号分摊限流的本意;窗口边界靠时间猜,语义含混 |
|
|
35
|
-
| **B. 干脆把 round-robin 改成"粘死一个号直到冷却"** | 更简单,日志最干净 | 两号分属不同区、模型表不同(cn 14 / global 15),长粘会长期看不到另一区的独有模型;也放弃了多号分摊限流 |
|
|
36
|
-
| **C. 同请求粘号 + scope=reqId**(当前选此) | 语义精确(一次客户端请求 = 一次选号);新请求仍轮转分摊;无 scope 时行为与旧版一致 | 需在管线传 `reqId`、并向 keyring 暴露 `isCooling`(各一行改动) |
|
|
37
|
-
| **D. 空转时也 `onError` 换号** | 也许换个区能出内容 | 实测 29/29 个双调用请求的第一次都是空转,根因是上下文过大(`messages=409`,含 197 条 tool 结果),不是号坏;烧两倍额度也换不来内容 |
|
|
38
|
-
|
|
39
|
-
## 后果
|
|
40
|
-
|
|
41
|
-
**收益**
|
|
42
|
-
|
|
43
|
-
- 切号回到唯一正当理由(冷却)。同一 reqId 的重试在日志里是**同一个 `via=`/`account=`**,不再"URL 在切"。
|
|
44
|
-
- 空转重试不再把请求甩到另一个区,跨区行为可归因。
|
|
45
|
-
- 空转重试事件本身也已进模型日志(`retry N/M delay=Xms after=<host> account=<region> pick=<new|sticky|switch|rr>`)。
|
|
46
|
-
|
|
47
|
-
**代价**
|
|
48
|
-
|
|
49
|
-
- 管线多传一个 `reqId`;provider 多一张按 TTL 淘汰的小表(上限 200 条)。
|
|
50
|
-
- `MSLXDFF_QODER_STICKY_MS=0` 可退回旧行为(重访信号:某号对特定上下文持续空转而另一号稳定可用)。
|
|
51
|
-
- **粘号只解决"谁上的"**,不解决"空转"本身:55% 的空转率根因是上游对超大上下文返回零正文。
|
|
52
|
-
|
|
53
|
-
**已验证**
|
|
54
|
-
|
|
55
|
-
- `test/qoder-sticky-account.test.js` 5 例:同 scope 复用不换号 / 无 scope 退回轮转 / 401 冷却后必换且换后继续粘住(冷却过期不回跳)/ TTL 过期重新轮转 / 选号决定 `new|sticky|switch|rr` 上报。
|
|
56
|
-
- 全量 `1142 tests / 1140 pass / 0 fail / 2 skipped`;`npm run docs:check` 通过。
|
|
57
|
-
|
|
58
|
-
## 同轮修正:模型日志「白名单 → 黑名单」
|
|
59
|
-
|
|
60
|
-
起因就是本 ADR 记录的排障事故:`empty-turn-retry` 当时不在 `TRACE_STAGES` 白名单里,**切号的真实触发者在模型日志中根本不存在**,只剩"时间 +1s、URL 变了"。
|
|
61
|
-
|
|
62
|
-
- **语义反转**:`shouldTraceModel()` 由白名单改为黑名单(`TRACE_DENY`),新事件**默认可见**;只排除噪声(`peer-health` 秒级高频、`heartbeat`)与敏感面(`client-session`、`upstream-probe*`)。
|
|
63
|
-
- **「加日志」≠「倒数据」**:决定类事件的兜底渲染只打印 `DECISION_FIELDS` 登记的标量(status/from/to/reason/pick/cooled/...),payload/detail/请求正文一律不落。
|
|
64
|
-
- **终局也带回显**:`relay-done` / `result` 输出 `upstream=`/`account=`/`pick=`,一次客户端请求「谁上的、哪个号、为什么是它」在一行内可读。
|
|
65
|
-
- **单一来源**:`upstreamEcho(res)` 统一把 provider 回显头(`x-mslxdff-upstream` / `-workbuddy-uid` / `-qoder-region` / `-qoder-account` / `-qoder-cooldown`)转成日志字段,`serial-trial.js` 与 `relay-pipeline.js` 全部改走它,杜绝各处重复写法漂移。
|
|
66
|
-
|
|
67
|
-
### 同轮修复:流式路径的坏号冷却(否则粘号会放大故障)
|
|
68
|
-
|
|
69
|
-
流式请求按对外契约把上游非 200 整形成「200 + 流内 error」(ADR-0031),于是 `chat()` 看到的 `res.status` 恒为 200 —— **401/403/429/5xx 到不了门面,坏号永不冷却**。配合同请求粘号,重试会一直粘在这个坏号上:整单失败且不再换号(**比改动前更差**——以前每次调用都轮转,坏号会被自动绕开)。
|
|
70
|
-
|
|
71
|
-
修法(两处):
|
|
72
|
-
|
|
73
|
-
- `chat.js` 新增内部回显头 `x-mslxdff-qoder-upstream-status`,在「上游非 200」与「fetch 异常(折 502)」两条出口带上真实状态码;对外仍是 200 + 流内 error,契约不变。
|
|
74
|
-
- `index.js` 的冷却判定改为 `badAuth(res.status) || badAuth(该头)`,冷却时回显 `x-mslxdff-qoder-cooldown=<status>`(模型日志 `cooled=`);400 等业务错不冷却。
|
|
75
|
-
|
|
76
|
-
验证:`test/qoder-cooldown-stream.test.js` 3 例——流式 401 触发冷却且同请求重试 `pick=switch`(不再粘坏号)/ 流式 400 不冷却(不误伤好号)/ 非流式 401 老路径不回归。
|
|
77
|
-
|
|
78
|
-
**发现(未修,与本决策正交)**
|
|
79
|
-
|
|
80
|
-
上述 29 条"空转后重试"的请求,模型日志全部在**第二次尝试的 `relay-start` 处中断**:之后没有 `relay-done`、没有 `result`,`timeline.log` 里也**没有这些请求的行**(首次尝试的 29 条则有)。同期 `daemon.log` 有 88 次 `uncaughtException: ERR_STREAM_WRITE_AFTER_END`,堆栈为 `helpers.js json() → exhausted-handler.js handleExhaustedAll() → serial-trial.js`。指向一个独立缺陷:**流式响应已经写并 end 之后,管线仍按"未提交"路径写 JSON 收尾 / 继续 relay**。若成立,则"空转重试"在流式路径上永远不会被客户端看到(重试结果丢弃),只是白烧一次上游额度。需单独立项验证与修复。
|
|
81
|
-
|
|
82
|
-
**相关**:qoder 供应商总览见 ADR-0029;真流式管线见 ADR-0031;请求级可观测性见 ADR-0034。
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
# ADR-0037: qwenwork(千问办公)作独立 provider,而非 qoder 的新 region
|
|
2
|
-
|
|
3
|
-
日期:2026-09-29 · 状态:已采纳
|
|
4
|
-
|
|
5
|
-
## 背景
|
|
6
|
-
|
|
7
|
-
`github.com/nostalgia296/qwenwork2api-makers` 把千问办公(`gateway.qwenwork.cn`)转成了 OpenAI 兼容接口。
|
|
8
|
-
初判它「像 qoder 的另一个域名」——依据是两边**共用同一 OAuth client_id**(`e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb`)、
|
|
9
|
-
同一 COSY 签名算法(md5 五段拼接)、同一 `/algo/api/v2/*` 路径形状。
|
|
10
|
-
|
|
11
|
-
现网取证推翻了这个判断。取证方式:移植原作者实现做探针(`_shared/*.js` 逐字节下载核对),
|
|
12
|
-
用真实账号跑完整设备授权 + 一次对话(HTTP 200,1074ms 首包,返回 `model=qwork-openai-chat-mode-pool`、
|
|
13
|
-
`reasoning_content`、`usage`),并拉取原始模型目录与额度端点。
|
|
14
|
-
|
|
15
|
-
| 维度 | qoder | qwenwork |
|
|
16
|
-
|---|---|---|
|
|
17
|
-
| RSA 公钥模数 | `MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02L…` | `c0f22307e5cd362e296bb04470f6de8f…` |
|
|
18
|
-
| cosyVersion / clienttype / scene | 1.0.10 / 5 / assistant | 1.1.18 / 6 / qwork |
|
|
19
|
-
| business-product | `ide` | `qoder_work` |
|
|
20
|
-
| 请求体编码 | 自定义 base64 字母表(URL 带 `Encode=1`) | **纯 JSON**(URL 无 `Encode`) |
|
|
21
|
-
| 模型目录切片 | `chat`:`gmodel`/`qfmodel`/`qmodel_38max`… 15 个 | `qwork`:`flash`/`pro`/`qwen3.8-max-preview` 3 个 |
|
|
22
|
-
| 其余 10 个根切片 | — | `chat`/`developer`/`assistant`/`inline`/`quest`/`nap`/`experts`/`qwake` 全部 `n=0` |
|
|
23
|
-
| 额度语义 | `price_factor` + 每日签到积分 | `price_factor` + **账号积分池**(实测新免费号 `remaining=2099.277`,一次 hi 扣 0.0027) |
|
|
24
|
-
| 官方口径 | — | 阿里云 Qoder CN FAQ:「两条产品线的订阅和 Credits **相互独立、不互通**」 |
|
|
25
|
-
|
|
26
|
-
模型 key 集合零交集,`redirect_uri` 也不同(qwenwork 走 `qwenwork-cn://` 深链,qoder 走 Web OAuth 回调)。
|
|
27
|
-
|
|
28
|
-
## 决策
|
|
29
|
-
|
|
30
|
-
**新建独立 provider `src/providers/qwenwork/`(provider id `qwenwork`),与 `qoder` 完全隔离,不共用任何常量或账号存储。**
|
|
31
|
-
判据:**region 这个抽象承载的是「同一租户的不同站点」;租户换了就必须换 provider id。**
|
|
32
|
-
|
|
33
|
-
隔离必须落在四处,任一处共享都会出事:
|
|
34
|
-
|
|
35
|
-
1. **账号池**:`auths/qwenwork-<uid>.json` 独立命名空间。混用会让 qoder 的 deviceToken 被拿去签 qwenwork
|
|
36
|
-
→ 401 → refresh 轮换 refreshToken 回写 → **污染 qoder 账号状态**。
|
|
37
|
-
2. **allowlist**:`providerConfigs.<id>.allowlist` 是**按 provider 而非按 region** 存的(ADR-0033 的自动同步建在这一层)。
|
|
38
|
-
把 `pro/flash` 写进 `qoder` 的名单会与 `gmodel/qfmodel` 互相干扰,复现「静态快照拦掉新模型」那类坑。
|
|
39
|
-
3. **CLI 语义**:`-provider qoder models --region cn` 的承诺是返回 qoder 中国站模型,混入 qwenwork 即毁约。
|
|
40
|
-
4. **额度渲染**:CLI 价格列按供应商分语义渲染(qoder 看 `price_factor`+签到,workbuddy 看 `credits`),
|
|
41
|
-
qwenwork 是账号积分池,需要自己的展示与收口口径。
|
|
42
|
-
|
|
43
|
-
## 与 qoder 惯例的一处故意分歧
|
|
44
|
-
|
|
45
|
-
qoder 登录成功后设 `allowAnyModels=true`(免费福利供应商惯例)。**qwenwork 登录设 `allowAnyModels=false`
|
|
46
|
-
并把三个实测模型 id 种进 allowlist。**
|
|
47
|
-
|
|
48
|
-
理由:qoder 有每日签到回血(`qoder checkin`,实测可用),qwenwork 的「每日登录送积分」活动官方页明写
|
|
49
|
-
**已于 2026-08-09 结束**,剩余原始额度是否每日回血**未实测**。它的三个模型每次都真扣积分,
|
|
50
|
-
放开 `allowAny` 会让 `auto` 把它们当免费池无脑轮换烧分。对齐「上游供应商默认安全关闭」的既有偏好。
|
|
51
|
-
|
|
52
|
-
重访信号:实测确认 qwenwork 存在每日额度回血 → 可改回 `allowAny=true`。
|
|
53
|
-
|
|
54
|
-
## 备选方案
|
|
55
|
-
|
|
56
|
-
| 备选 | 最强论据 | 否决理由 |
|
|
57
|
-
|---|---|---|
|
|
58
|
-
| **A. 复用 qoder,加 `region: "qwenwork"`** | 签名算法同构,`oauth.js`/`account-store.js`/`models.js` 可整块复用,改动量最小,不必补功能树新叶 | 把不同租户塞进同一 provider id,上述四道边界(账号池/allowlist/CLI 语义/额度渲染)全部被破坏;region 的语义是「同租户不同站点」,不是「协议长得像」 |
|
|
59
|
-
| **B. 不接,维持 opencode 免费池** | 少一家少一分逆向封号风险;且该上游活动已终止、长期额度未知 | 三个模型带 `is_reasoning + is_vl + 1M 声明上下文`,是本仓目前没有的能力面;成本实测极低(2099 分≈上万次日常调用);风险级别与既有 qoder/traework/codearts/workbuddy 同类,不构成新风险类别 |
|
|
60
|
-
| **C. 部署原作者的 EdgeOne/Workers 服务,用 `generic` provider 指过去** | 零协议逆向工作量,一行配置,不碰 `src/**` | 账号凭据要托管进第三方边缘 KV(其 WebUI + apiKeys + admin 口令体系),违反本仓凭据只准落本地 gitignored 0600 文件的红线;且引入外部可用性依赖,与其余 provider 的本地直连范式不一致。**这是本轮真正放弃的能力**:若上游开始按 IP/UA 封杀本地客户端,可回退到此方案(届时仍自建、不外借) |
|
|
61
|
-
|
|
62
|
-
## 后果
|
|
63
|
-
|
|
64
|
-
**得到**:3 个 reasoning+视觉模型、独立可轮换的账号积分池、与 qoder 零耦合(一边被封不牵连另一边)。
|
|
65
|
-
|
|
66
|
-
**代价 / 变难了什么**:
|
|
67
|
-
|
|
68
|
-
- 新增 12 个源文件。协议常量(`cosyVersion 1.1.18`、`release-version 1.0.5-26090901`、RSA 模数、UA)
|
|
69
|
-
是**硬编码快照**,客户端一升级就可能 403。缓解:`MSLXDFF_QWENWORK_UA` / `_COSY_VERSION` /
|
|
70
|
-
`_RELEASE_VERSION` / `_BUILD` 可 env 覆盖,排障不必改代码;但**改后必须现网复测**,不能只过单测。
|
|
71
|
-
- 逆向私有协议,封号风险由本仓承担(合规分级与 qoder 同级,非官方开放 API)。
|
|
72
|
-
- 额度非无限、非「永久免费」:账号级积分池,烧完即 429。故额度错走长冷却并换号。
|
|
73
|
-
- 移植用手写 crypto(AES/RSA/MD5,原作者为 Workers 无原语而写)换成 `node:crypto` 原生 ——
|
|
74
|
-
单测只锁结构与签名拼接,**行为等价性由 `live-parity` 脚本用真号打现网兜底**(协议移植的典型失败模式是
|
|
75
|
-
「单测全绿但现网 403」,靠 mock 假装覆盖不住)。
|
|
76
|
-
|
|
77
|
-
**已知上限**(SPEC 的「明确不做」):v1 不做同请求粘号(单号无抖动,接 ≥2 号且重复扣费时重开)、
|
|
78
|
-
不做签到、不接图片输入(多模态 body 未取证)、`model_config.max_input_tokens` 沿用实测通过的 180000 而非声明的 1M、
|
|
79
|
-
不纳入 `bench --via`(会真烧积分,须用户点名)。
|
|
80
|
-
|
|
81
|
-
**相关**:qoder 供应商总览 ADR-0029;COSY 判决裹 200 的预读冷却 ADR-0036(本 provider 沿用其修法);
|
|
82
|
-
allowlist 自动同步 ADR-0033;auth 号型门禁见 `.agents/notes/implemented/bug-fix/2026-09-21-auth-doc-provider-gate.md`。
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
# ADR-0038: zcode 供应商(智谱 ZCode 工作台免费额度)
|
|
2
|
-
|
|
3
|
-
日期:2026-09-29 · 状态:已采纳
|
|
4
|
-
|
|
5
|
-
## 背景
|
|
6
|
-
|
|
7
|
-
ZCode(`github.com/zai-org/ZCode`,智谱官方编程工作台)的 Start/Coding Plan 免费额度绑定**智谱账号**而非客户端:
|
|
8
|
-
凭 OAuth 拿到的 zcode JWT 经官方 `zcode.z.ai` 网关即可查询与消费。调研(ZCode 源码 clone 全量 grep + 社区实现
|
|
9
|
-
`zcode-switch` 逐文件读透)当时结论:模型调用、额度查询、CLI 轮询登录三件事全程无验证码,验证码只出现在
|
|
10
|
-
`billing/claim`(领取套餐)。**2026-09-29 真号实测修正**:登录与额度查询确实无验证码,但**模型调用被上游
|
|
11
|
-
3007「captcha verify failed」安全门拦住**(详见下文「实测结论」节)——该门在官方客户端侧同样无解。
|
|
12
|
-
|
|
13
|
-
契约(实测/源码双向核对):
|
|
14
|
-
|
|
15
|
-
| 项 | 值 |
|
|
16
|
-
|---|---|
|
|
17
|
-
| 出站端点 | `POST https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages`(Anthropic 协议) |
|
|
18
|
-
| 鉴权 | `Authorization: Bearer <zcode JWT>`(**无 refresh token**,过期需重登) |
|
|
19
|
-
| 必带头 | 12 项 ZCode source headers(UA `ZCode/<ver>`、HTTP-Referer、`X-Title: Z Code@electron`、`X-ZCode-App-Version`、`X-Platform`、`X-Release-Channel`、`X-Client-Language`、`X-Client-Timezone`、`X-Os-Category`、`X-Os-Version`、`X-Device-Mid`、`x-request-id`) |
|
|
20
|
-
| 登录 | `POST /api/v1/oauth/cli/init` → 浏览器打开 `authorize_url` → `GET /api/v1/oauth/cli/poll/{flow_id}` 轮询(status pending/ready/failed;3004=过期) |
|
|
21
|
-
| 额度 | `GET /api/v1/zcode-plan/billing/balance?app_version=<ver>`(`data.plans[]` + `data.balances[]`,entitlement 粒度) |
|
|
22
|
-
| 业务码 | 401/1006 鉴权失效;1005 额度耗尽;3002/3008/3009/3010 限流;3007 安全拒绝;2007 服务端 |
|
|
23
|
-
|
|
24
|
-
## 决策
|
|
25
|
-
|
|
26
|
-
1. **出站选 Anthropic 协议**,不赌 OpenAI 形 base。`https://zcode.z.ai/api/v1/zcode-plan`(OpenAI 形)在官方源码里
|
|
27
|
-
**只有 URL 定义、无任何调用方**;官方 Start Plan 的 provider 配置就是 `kind: anthropic` +
|
|
28
|
-
`baseURL: .../zcode-plan/anthropic` + JWT,社区实现(zcode2api/zcode-proxy)也全走 Anthropic 形。
|
|
29
|
-
代价:本仓新增 OpenAI→Anthropic 请求转换 + Anthropic SSE→OpenAI chunk 整形(`chat.js`/`sse.js`)。
|
|
30
|
-
2. **登录用 CLI 轮询**(`cli/init` + `cli/poll`),不用 deep-link 回调:无需本地回调服务器、无需注册
|
|
31
|
-
`zcode://` 协议(避开 Windows 协议注册坑);zai/bigmodel 双入口共用同一流程,仅 init 的 `provider` 字段不同。
|
|
32
|
-
3. **deviceMid = per-account 持久 UUID,存账号文档**(`auths/zcode-<uid>.json`,0600),**不进 `providerConfigs`**——
|
|
33
|
-
规避「providerConfig 重建路径静默丢字段」这一既有故障类(同轮见 ADR-0033 侧记)。
|
|
34
|
-
4. **上游恒 `stream:true`**:非流式在本地聚合回 OpenAI JSON。Strictly 减少一条未验证的非流式上游路径。
|
|
35
|
-
5. **冷却分级**:401/1006 = 短冷 30s + 401 重登指引;1005 = 长冷 1h + 429 `quota_exhausted`;
|
|
36
|
-
3002/3008/3009/3010 = 短冷 429;3007/参数 = **不冷却**(安全拒绝,换号无意义)→ 403 如实透出。
|
|
37
|
-
失败响应带内部头 `x-mslxdff-zcode-kind`,由工厂(`index.js`)决策冷却与换号。
|
|
38
|
-
6. **登录即补 allowlist(只增不减)**:内置目录 canonical id 并入该 provider 白名单(对齐 cline 的
|
|
39
|
-
allowlist-sync 先例,ADR-0033)。否则 `allowAnyModels:false` + 空名单会让显式 `zcode/<model>` 直接 403——
|
|
40
|
-
「登录成功却处处 403」的断头体验。
|
|
41
|
-
7. **恒 local-only**:本机账号绑定型(JWT + deviceMid),加入 `NEVER_SHARE_IDS` 与 `classify.js` 的 local-only,
|
|
42
|
-
不借出 key、不走组员转发。
|
|
43
|
-
|
|
44
|
-
## 本轮踩到并修掉的坑(值得记住)
|
|
45
|
-
|
|
46
|
-
`anthropicToOpenAiStream` 的 `pull` 最初写成「读一片 → 若解析不出完整事件就返回」。按流规范,
|
|
47
|
-
**`pull` 不 enqueue 就不会再被触发**(`pullAgain` 只在 enqueue 后 desiredSize 仍 > 0 时才置位),
|
|
48
|
-
于是「首个分片恰好落在某个 SSE 事件中间」时消费端 `read()` **永不 settle**,测试表现为
|
|
49
|
-
`cancelledByParent: Promise resolution is still pending but the event loop has already resolved`。
|
|
50
|
-
修法:`pull` 内循环读到「有产出」或「上游结束」才返回。诊断路径(值得复用):把失败用例逐字复刻到独立
|
|
51
|
-
harness 里跑,确认是模块问题还是测试环境问题——本例模块单跑正常、测试挂起,最终定位在分片边界。
|
|
52
|
-
|
|
53
|
-
## 实测结论(2026-09-29 真号,含 3007 安全门)
|
|
54
|
-
|
|
55
|
-
真号全流程实测(uid `9748aec9…`,昵称「旅行者8856」,zai 入口):
|
|
56
|
-
|
|
57
|
-
- **登录通路** ✅:`oauth/cli/init` → 浏览器授权 → `oauth/cli/poll` 拿到 JWT 并落盘 `auths/zcode-<uid>.json`(0600),
|
|
58
|
-
`state.providerConfigs.zcode.keys=1`,allowlist 自动补齐 4 个 canonical id。**无验证码**。
|
|
59
|
-
- **额度通路** ✅:`GET /api/v1/zcode-plan/billing/balance?app_version=3.11.2` → 200,套餐
|
|
60
|
-
`zcode-v3-start-plan-trust-0929`("ZCode Trust Build",status `active`)+ entitlement `GLM-5.3-Flash`
|
|
61
|
-
(`capabilities: ["model:glm-5.3-flash"]`、grant/remaining = 100,000,000 token、`expires_at` ≈ 12h 后)。
|
|
62
|
-
同一 JWT + **本仓自生成的 deviceMid** 被接受 → **Q1(deviceMid 是否被风控)在读路径上未被拦**。
|
|
63
|
-
- **模型调用** ❌:`POST /api/v1/zcode-plan/anthropic/v1/messages` → **HTTP 400 `{"code":3007,"msg":"captcha verify failed"}`**
|
|
64
|
-
(本仓按 spec 映射为 403 `security_reject` 如实透出、**不冷却**、不误报额度)。对照实验:同 token 同头改打
|
|
65
|
-
`/api/v1/ultra-zai/anthropic/v1/messages` → 401 `1002 Invalid Authentication Token`(该路径属订阅套餐网关,
|
|
66
|
-
不适用于体验计划)→ **我们的路径选择正确,卡点不在路径/头/鉴权**。
|
|
67
|
-
|
|
68
|
-
**根因(社区实证:`a137460387/zcode2api` 的 NOTES/spec/源码,2026-09-29 逐文件核对)**:
|
|
69
|
-
|
|
70
|
-
- **受控实验:形态不是原因(本仓 2026-09-29 实测,决定性证据)**。用 zcode2api 复刻的官方头(20 项:`user-agent: ZCode/3.14.3 ai-sdk/anthropic/3.0.81`、
|
|
71
|
-
`x-title: Z Code@cli`、`x-release-channel: production`、`x-api-key: <jwt>` 与 `authorization: Bearer` 并存等)
|
|
72
|
-
+ 官方 `system` 身份块(cliPrefix「You are ZCode, an interactive coding agent」+ stable 段 + dynamic/Environment Info,各带 `cache_control`)
|
|
73
|
-
+ 小写模型名 `glm-5.3-flash`,**不带 captcha 参数** → 仍 **`400 {"code":3007,"msg":"captcha verify failed"}`**。
|
|
74
|
-
同路径带参数才通(zcode2api 实测)⇒ 3007 **不是**「形态不像官方客户端」的风控误判、也**不是** 3012 那类内容检查,而是**硬缺验证码参数**。
|
|
75
|
-
另:官方源码全仓 `aliyun|captcha` 仅 4 处命中且与验证码无关,CLI 独立模式的运行时头实现(`createStandaloneProviderRuntimeHeadersPort`)
|
|
76
|
-
只返回 `requestAuth.apiKey`、**不产生** captcha 参数 ⇒ **源码层面没有可照抄的「绕过浏览器」实现**;桌面端那条路(内嵌 webview)在闭源/宿主侧。
|
|
77
|
-
- **官方客户端行为的本机实证(决定性)**:① 官方 Electron app 的 `app.asar`(`D:\Program Files\ZCode\resources`)内含 `AliyunCaptcha.js`
|
|
78
|
-
与 `initAliyunCaptcha` —— **官方自己就是靠浏览器上下文跑阿里 SDK**;② 官方 CLI 的模型 I/O 日志
|
|
79
|
-
`~/.zcode/cli/rollout/model-io-sess_*.jsonl` 中 25 条模型请求(`builtin:bigmodel-start-plan` 通道,模型 GLM-5.3 / GLM-5.3-Flash)
|
|
80
|
-
**各带一个唯一** `x-aliyun-captcha-verify-param`(去重后仍 25 个),且日志中 `3007` 零命中;
|
|
81
|
-
③ 官方凭据库 `~/.zcode/v2/credentials.json` 只有 `zcodejwttoken` 与 `…coding-plan…:api-key`、**无 param 缓存** ⇒ param 是运行时按需产出。
|
|
82
|
-
⇒ 该额度**必须有真实浏览器上下文**持续供给一次性 param;「40s 时效」来自社区实测(zcode2api 参数池 `usableMs=40_000`),
|
|
83
|
-
官方侧只体现为「每条请求换一个新 param」——两者相容,但都指向同一结论。
|
|
84
|
-
- **3007 = 缺一次性阿里云验证码参数**。Start Plan 免费额度的 oauth 通道(即本仓所用 `…/zcode-plan/anthropic/…`)
|
|
85
|
-
要求每条模型请求带 `x-aliyun-captcha-verify-param: <param>` + `x-aliyun-captcha-verify-region: cn`;裸 JWT 请求恒返 3007。
|
|
86
|
-
**参数一次性、有效性仅 ~40s**(实测 48s 已被判 3007),故需常驻「验证码农场」:Playwright + **有头** Chrome
|
|
87
|
-
加载本地 farm 页跑阿里 captcha 2.0 无感验证(约 20s 产 5 个参数)持续刷参数池;**无头模式**自 2026-09-27 起稳定吃
|
|
88
|
-
`F011`、产出归零(UA 覆盖已不够)。
|
|
89
|
-
- **该额度没有免验证码的替代端点**:标准端点(`open.bigmodel.cn/api/anthropic` 等)持同一 token 一律
|
|
90
|
-
`1113 无资源包`——「Start Plan 免费额度只能在 captcha 通道消费」(对方实测,anthropic/openai 两形端点均试过)。
|
|
91
|
-
- **第二把锁(3012)**:上游对请求做**内容检查**,`system` 必须是官方身份块形态(`cliPrefix` 身份块 + stable 段 +
|
|
92
|
-
dynamic 段含 Environment Info,每块 `cache_control:{type:"ephemeral"}`;首个 user 消息前挂
|
|
93
|
-
`<system-reminder>…# currentDate…</system-reminder>`),否则 `3012 "method not allowed"`。官方请求细节还有:
|
|
94
|
-
UA `ZCode/<ver> ai-sdk/anthropic/3.0.81`、`x-title: Z Code@cli`(CLI 形态,非 `@electron`)、
|
|
95
|
-
`x-release-channel: production`、`x-api-key: <jwt>` 与 `authorization: Bearer <jwt>` 并存、模型名**小写**
|
|
96
|
-
(`glm-5.3-flash`)、模型请求**不带** `x-device-mid`(仅 balance 带)。本仓当前实现只覆盖「头」的一部分、
|
|
97
|
-
`system` 未做官方身份块 → 即便补上 captcha 参数也会先撞 3012。
|
|
98
|
-
- **官方客户端为何没有验证码代码**:3007 在官方码表里就是「客户端无法完成安全校验,提示联系支持」且无恢复动作
|
|
99
|
-
(`UI_ACTIONS["3007"]=null`)——桌面端靠在**内嵌 webview 里由人过验证码/领取**(`CodingPlanEmbeddedWebview*`),
|
|
100
|
-
该路径不与 CLI 共享。
|
|
101
|
-
|
|
102
|
-
**含义**:模型调用是**上游强制的验证码门**;本变更按既定范围(不做验证码/浏览器组件)**未实现农场**,
|
|
103
|
-
故额度「登录可见、当前不可消费」——属**范围已知缺口**,不是实现缺陷;登录 / 目录 / 额度三条通路与错误面
|
|
104
|
-
(403 + 业务码 + 人话 + 不冷却)均已真号实测通过。补齐需另立变更(见「备选方案」F/G/H)。
|
|
105
|
-
|
|
106
|
-
## 备选方案
|
|
107
|
-
|
|
108
|
-
| 备选 | 最强论据 | 否决理由 |
|
|
109
|
-
|---|---|---|
|
|
110
|
-
| **A. 走 OpenAI 形 base(`.../zcode-plan/v1/chat/completions`)** | 零协议转换代码,直接复用 `generic` provider | 官方源码无调用方、社区无先例,等于自创契约;首个 403/400 时无法判断是风控还是路径错。留作后续实验 |
|
|
111
|
-
| **B. deep-link 回调(`zcode://oauth/callback`,桌面端用法)** | 与官方桌面端完全同形 | 需 OS 协议注册 + 本地回调监听,Windows 上多一层坑,且 CLI 场景无增益 |
|
|
112
|
-
| **C. 复用 `generic` + 手工塞 JWT** | 一天能上 | 拿不到 login 体验(用户得自己抓 JWT),且 OpenAI↔Anthropic 转换、业务码冷却、deviceMid 三件事仍得自己写,等于少写一坨 shell 而已 |
|
|
113
|
-
| **D. 读官方客户端 `~/.zcode/v2/telemetry-state.json` 复用其 deviceMid** | 与官方客户端完全同设备指纹 | 独立服务不该耦合官方客户端安装;若官方客户端未装则功能不可用。若实测自生成 mid 被风控(403/3007 集中出现),回退到此方案 |
|
|
114
|
-
| **E. 只对齐「官方形态」、不做农场** | 零新依赖,能解掉 3012 那一半 | 解不掉 3007:captcha 参数一次性且 ~40s 失效,没有常驻产出就是每请求必挂——等于交付一个必然报错的通道 |
|
|
115
|
-
| **F. 自建完整验证码农场(对齐 zcode2api)** | 社区实证跑通的唯一路径:Playwright + **有头** Chrome 常驻跑 farm 页 → 参数池(约 20s 产 5 个)→ 每请求注入 + 3007 换参数重试(≤2 次、不换号)+ 3012 冷却 30min | 引入 playwright 依赖 + 常驻可见浏览器(无头自 09-27 起失效);必须同时落地官方 system 身份块(否则先撞 3012);与本期「不引入浏览器/验证码组件」的既定边界冲突 → 需另立变更并由用户批准范围 |
|
|
116
|
-
| **G. 轻量折中:农场页跑在用户自己的浏览器里** | 无 playwright 依赖:本地小服务只做「参数池 + 接收端」,用户在浏览器开一个标签页跑同一段阿里 SDK 验证码,参数经 POST 进池;官方形态 + 注入 + 重试仍由本仓做 | 仍是常驻标签页 + 每 ~40s 产参;页面一关池就空(503 `captcha pool empty`);组件只是挪到用户侧,形态上仍是「有浏览器组件」 |
|
|
117
|
-
| **H. 不自建:改用本地 zcode2api 作上游,本仓用 `generic` 指向它** | 复用已跑通的实现,本仓零验证码代码,最快恢复可用 | 强耦合外部进程(它挂/改我们就断),两套账号池与凭据管理并存;只宜作短期验证,不宜作长期形态 |
|
|
118
|
-
|
|
119
|
-
## 后果
|
|
120
|
-
|
|
121
|
-
**得到**:GLM-5.3 / GLM-5.3-Flash / GLM-5.2 / GLM-5-Turbo 的官方免费通道(**登录 / 目录 / 额度三条通路
|
|
122
|
-
已真号跑通**);零新增 npm 依赖(fetch/SSE 全用现有 compat 栈);与既有 provider 零耦合。
|
|
123
|
-
**当前未得到**:模型调用的实际消费能力——被上游验证码门拦住(见上节),需另立变更补齐农场(或 G/H 折中)后才可用。
|
|
124
|
-
|
|
125
|
-
**代价 / 变难了什么**:
|
|
126
|
-
|
|
127
|
-
- **JWT 无 refresh token**:过期就得重登(401/1006 → 人话指引);login 后 JWT 存活时间由服务端决定。
|
|
128
|
-
- **客户端版本/头是硬编码快照**(默认 `3.11.2`):上游按版本做能力/风控判断时,硬编码会随官方发版失效。
|
|
129
|
-
缓解:`MSLXDFF_ZCODE_APP_VERSION` 可覆盖,但**改后必须现网复测**。
|
|
130
|
-
- **风控策略可能演进**:请求形态严格对齐官方客户端;3007 如实透出不重试(避免放大风控信号)。
|
|
131
|
-
- 额度是**账号级体验额度**(Start Plan 首登 5 天每日发放),非永久免费;烧完走 429 `quota_exhausted`
|
|
132
|
-
与长冷却,话术明确「明日重置或添加账号」。
|
|
133
|
-
|
|
134
|
-
**本轮范围与决策(2026-09-29,用户拍板)**:不做领取套餐(`billing/claim`)、激活事件上报(`event/report`)、闲时任务,以及
|
|
135
|
-
**验证码农场 / 官方 `system` 身份块注入**。经用户决策**暂不做**验证码通道:本 provider 交付
|
|
136
|
-
「OAuth 登录 + 模型目录 + 额度查询 + 如实的 3007 错误面(403 `security_reject`、不冷却、不误报额度)」。
|
|
137
|
-
若日后要真正消费该额度,另立变更,并在备选 F(Playwright + 常驻有头浏览器)与 G(用户浏览器标签页 + 本地参数池,**零新依赖**)之间取舍。
|
|
138
|
-
|
|
139
|
-
**相关**:供应商门面范式 qoder ADR-0029 / traework ADR-0028 / codearts ADR-0027;allowlist 只增不减
|
|
140
|
-
ADR-0033;local-only 边界 ADR-0019;行为记账见 `.agents/notes/implemented/feature/2026-09-29-zcode-provider.md`。
|