mslxdff 0.1.232 → 0.1.234

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.
Files changed (114) hide show
  1. package/bin/mslxdff.js +3 -3
  2. package/docs/cli_help_mini.md +148 -0
  3. package/package.json +1 -1
  4. package/src/auto.js +264 -254
  5. package/src/bench/cline-bench.js +42 -42
  6. package/src/bench/probe.js +70 -70
  7. package/src/bench/report.js +162 -162
  8. package/src/bench/runner.js +77 -77
  9. package/src/bench/via-probe.js +124 -124
  10. package/src/bench/via-routes.js +87 -87
  11. package/src/bench/workbuddy-bench.js +54 -54
  12. package/src/chat/engine.js +160 -160
  13. package/src/chat/gateway.js +163 -163
  14. package/src/chat/orchestrator.js +234 -234
  15. package/src/chat/prompt.js +70 -70
  16. package/src/chat/repl.js +88 -88
  17. package/src/chat/terminal.js +135 -135
  18. package/src/chat/tools.js +306 -306
  19. package/src/chat-pipeline/index.js +155 -145
  20. package/src/chat-pipeline/policy.js +84 -84
  21. package/src/cli/commands/group.js +266 -266
  22. package/src/cli/commands/model/picks.js +50 -50
  23. package/src/cli/commands/provider/bench-via.js +247 -247
  24. package/src/cli/commands/provider/bench.js +141 -141
  25. package/src/cli/commands/provider/index.js +139 -139
  26. package/src/cli/commands/provider/models.js +149 -149
  27. package/src/cli/commands/provider/qwenwork-login.js +119 -119
  28. package/src/cli/commands/provider/raccoon-quota.js +156 -156
  29. package/src/cli/commands/sync.js +310 -310
  30. package/src/cli/status.js +279 -279
  31. package/src/daemon.js +246 -246
  32. package/src/model-capabilities/enrich.js +86 -86
  33. package/src/model-capabilities/index.js +183 -183
  34. package/src/model-capabilities/parse.js +70 -70
  35. package/src/model-trace.js +10 -2
  36. package/src/models.js +293 -226
  37. package/src/peers.js +345 -345
  38. package/src/providers/AGENTS.md +49 -0
  39. package/src/providers/agentrouter.js +238 -238
  40. package/src/providers/base.js +164 -164
  41. package/src/providers/cline/auth.js +228 -228
  42. package/src/providers/cline/chat.js +307 -307
  43. package/src/providers/cline.js +2 -2
  44. package/src/providers/codearts/chat.js +207 -207
  45. package/src/providers/dispatcher.js +147 -147
  46. package/src/providers/globalqwenwork/cosy.js +123 -123
  47. package/src/providers/globalqwenwork/crypto.js +218 -218
  48. package/src/providers/globalqwenwork/http.js +20 -20
  49. package/src/providers/globalqwenwork/rsa.js +54 -54
  50. package/src/providers/globalqwenwork/sse.js +268 -268
  51. package/src/providers/globalqwenwork/stream.js +130 -130
  52. package/src/providers/globalqwenwork/upstream.js +122 -122
  53. package/src/providers/keyring.js +60 -60
  54. package/src/providers/qoder/account-store.js +18 -0
  55. package/src/providers/qoder/chat.js +292 -203
  56. package/src/providers/qoder/checkin.js +25 -12
  57. package/src/providers/qoder/index.js +311 -249
  58. package/src/providers/qoder/models.js +64 -2
  59. package/src/providers/qoder/oauth.js +42 -1
  60. package/src/providers/qoder/payload.js +116 -80
  61. package/src/providers/qoder/request.js +7 -2
  62. package/src/providers/qoder/session.js +26 -10
  63. package/src/providers/qoder/sse.js +233 -145
  64. package/src/providers/qoder/sticky.js +5 -2
  65. package/src/providers/qoder/wire-messages.js +242 -0
  66. package/src/providers/qwenwork/account-store.js +133 -133
  67. package/src/providers/qwenwork/constants.js +67 -67
  68. package/src/providers/qwenwork/cosy.js +120 -120
  69. package/src/providers/qwenwork/crypto.js +218 -218
  70. package/src/providers/qwenwork/http.js +20 -20
  71. package/src/providers/qwenwork/index.js +337 -337
  72. package/src/providers/qwenwork/payload.js +144 -144
  73. package/src/providers/qwenwork/rsa.js +54 -54
  74. package/src/providers/qwenwork/sse.js +268 -268
  75. package/src/providers/qwenwork/stream.js +130 -130
  76. package/src/providers/qwenwork/upstream.js +120 -120
  77. package/src/providers/qwenwork.js +1 -1
  78. package/src/providers/raccoon/chat.js +1 -1
  79. package/src/providers/raccoon/const.js +219 -202
  80. package/src/providers/raccoon/credits.js +285 -285
  81. package/src/providers/raccoon/index.js +51 -6
  82. package/src/providers/raccoon/models.js +68 -17
  83. package/src/providers/raccoon/sticky.js +46 -0
  84. package/src/providers/registry.js +89 -89
  85. package/src/providers/workbuddy/chat.js +298 -298
  86. package/src/providers/workbuddy/reshape.js +152 -152
  87. package/src/providers/workbuddy.js +2 -2
  88. package/src/reasoning.js +32 -32
  89. package/src/routes/AGENTS.md +37 -0
  90. package/src/routes/chat/broadband-handler.js +80 -80
  91. package/src/routes/chat/gateway.js +46 -46
  92. package/src/routes/chat/relay-pipeline.js +282 -282
  93. package/src/routes/chat/via-route-handler.js +156 -156
  94. package/src/routes/hedge.js +260 -260
  95. package/src/routes/models-route.js +167 -167
  96. package/src/routes/peers.js +373 -373
  97. package/src/routes/stream.js +364 -347
  98. package/src/runtime/bootstrap.js +75 -75
  99. package/src/runtime/keep-warm.js +135 -135
  100. package/src/runtime/provider-gate.js +75 -75
  101. package/src/runtime/providers-setup.js +187 -187
  102. package/src/runtime/raccoon-checkin.js +175 -175
  103. package/src/server.js +85 -85
  104. package/src/state/schemas/allowlist.js +92 -92
  105. package/src/sync-opencode.js +280 -280
  106. package/src/transport/index.js +244 -244
  107. package/src/transport/pool.js +56 -56
  108. package/src/transport/retry.js +24 -24
  109. package/src/transport/sse.js +93 -93
  110. package/src/upstream-probe/display.js +52 -52
  111. package/src/upstream-probe/probe.js +49 -49
  112. package/src/upstream-probe/rotate.js +110 -110
  113. package/src/upstream-probe/start.js +45 -45
  114. package/src/upstream.js +355 -355
@@ -0,0 +1,49 @@
1
+ # src/providers — 供应商契约(改任何 provider 前必读)
2
+
3
+ > 从 `AGENTS.md` 下沉(2026-09-29 分层,就近生效)。改完跑:`npm run docs:check` + 相关 provider 测试 + 裸调验证 `-provider <id> models`。
4
+ > 相关 ADR:0007(前缀路由)/0010(allowlist)/0019(share-keys)/0023(key 直连)/0026(cline id)/0029(qoder)/0033(cline 自动同步)/0037(qwenwork)/0038(zcode)/0041(globalqwenwork)。
5
+
6
+ ## 通用机制(dispatcher / keyring / 门禁)
7
+
8
+ - `src/providers/` 每供应商一文件,前缀路由 `<provider>/raw`(裸 id 归 opencode),dispatcher 剥前缀转发;`providerKeys: {id:[k1,k2]}` round-robin 30s 冷却;`shareKeys` via `x-mslxdff-share-keys` 瞬时借用且 opencode 恒排除。
9
+ - `providerConfigs.allowAnyModels` 默认 `false`(`opencode:true` 例外)+ `allowlist` 空即 `403`;`isModelAllowed` 对未配置 provider 放行(兼容测试),`listModels` 同步过滤;`providerConfigs.{modelsPath,chatPath}` 可配异形路径(`workbuddy=/v3/config` & `/v2/chat/completions`,其余默认 `/models` & `/chat/completions`,`b.ai=/v1/models`、`cline=/api/v1/models` 已验证)。
10
+ - `src/models.js` 的 `aggLoad`/`get` 二次过滤:`!allowed.length && !allowAny → 跳过整供应商`;`src/routes/chat/index.js` 的 `isAllowlistBlock` 在 `useAuto` 时软跳 `continue`(显式请求才硬 `403`)。
11
+ - 定制 provider 启用门禁下沉为纯函数 `src/runtime/provider-gate.js` —— auth 号型(workbuddy/traework/qoder)**不要求 baseUrl**,旧门禁 `baseUrl && keys` 曾把 qoder 整家静默跳过(表现:`/v1/models` 零条目 + `Model qoder/x is not supported`,无日志)。
12
+ - CLI:`provider <id> models` 与 `model list --provider` 为直查一级答案(`--json` 供脚本),HTTP `GET /v1/providers/:id/models`;价格列 `x0.00/浮动/—` 对齐展示(workbuddy 按 credits 升序)。
13
+ - 新增 provider 三步走:registry 注册 + classify(local-only 判据见 ADR-0023)+ allowlist 只增不减补齐;必须裸调验证 `free` 是否免 Key;`free` 4 个仅目录免 Key ≠ 对话免 Key(cline 教训)。
14
+
15
+ ## qoder(原生 COSY 栈,ADR-0029/0036)
16
+
17
+ - 凭证单一源 `auths/qoder-<uid>.json`(0600,含每号 `region` —— `state.normalizeAuths` 会剥该字段,故 region 只能存 auths 文件);签到按每号 region 选域名(cn=`openapi.qoder.com.cn` 走 `daily-check-in/status→claim`,409=今日已领;global=`openapi.qoder.sh` 该端点 404 → 回落 `/sash/api/v1/me/campaigns→/{id}/claim`);认证只需 `Bearer <device_token>` + `cosy-clienttype: 10`(**无 COSY 签名、claim 空 body**);默认只领 `CLAIM_BENEFIT`,促销条目 `VIEW_DETAILS` 需 `-provider qoder checkin --any`(实测能 claim,但奖励是订阅折扣、不进 quota);余额 `GET /api/v2/quota/usage`;daemon 每日 09:00 自动(`MSLXDFF_QODER_CHECKIN=0` 关,`_CHECKIN_HOUR` 改点)。
18
+ - **流式坏号判决面有两条**:①首包 HTTP 非 200;②**HTTP 200 但 SSE 信封内 `statusCodeValue` 非 200**(限流裹成 `200` + `{"code":"403","message":{"isQueued":true,"retryAfterSeconds":30}}`,只盖①会漏掉②——实测后果:94 次 empty turn 502、0 次恢复)。②的取法:返回 Response **前预读上游首帧**取判决(`PEEK_MAX_FRAMES=8`,遇判决或首个有效载荷即停),预读字节原样回灌给 `reshapeQoderStream`(`prefetched` 参数),对外仍恒 200、状态码只走内部头 `x-mslxdff-qoder-upstream-status`(fetch 异常折 502)。`test/qoder-envelope-status.test.js` 4 例。
19
+ - **同请求粘号**(`scope=reqId`,`MSLXDFF_QODER_STICKY_MS` 默认 600s,`0`=关):一次客户端请求内复用同一号,仅 401/403/429/5xx 冷却才换;`pick=new|sticky|switch|forced` 经 `x-mslxdff-qoder-account` 回显、冷却经 `x-mslxdff-qoder-cooldown` 回显。`test/qoder-cooldown-stream.test.js` 3 例。
20
+ - **wire 四条硬规矩**(openspec qoder-p0-fixes,`test/qoder-p0-fixes.test.js` 19 例):① 推理模型 `model_config.is_reasoning` 与 `chat_context.extra.modelConfig` 必须**双写**(只写前者上游不吐 reasoning_content);数据源=`models.js peekModels` 快照(不吃 TTL、chat 路径恒 0 额外上游调用、冷目录降级 false 不阻断,preheat 灌缓存);② 客户端无 tools 发 `tools:[]`——baseprompt 模板内嵌 14 工具永不出门;③ 上游 `finish_reason` 如实透传(length 截断从此可见),仅全程未给才兜底 stop/tool_calls,stream 与 aggregate 收尾判定同构;④ SSE 未完结行超 2MiB 即断(完整行消费后判、不连坐合法帧;流式流内 error 收尾、聚合 502 走短冷却)。
21
+ - **判决三分类与冷却三档(ADR-0044)**:同一个 `10605` 容器既裹额度也裹排队,`sse.js` 判序固定「先 `findQuotaSignal`(内层 code 110 / Billing 措辞)→ 后 `findQueueSignal`(code 10605 / `isQueued:true` / `serviceAvailable:false`)」。排队**不新增 kind**(仍 `upstream`,只挂 `err.queued`),经内部头 `x-mslxdff-qoder-queued` 交出门面,对外仍恒 200。门面冷却分三档:额度 `quotaCooldownMs`(1h) > **排队 `queueCooldownMs`(300s)** > 其它 `cooldownMs`(30s),且额度与排队**都在同一请求内当场换号**(`isQuota || isQueued` 且 `ring.available()>0` → `continue`);空转(模型零输出)仍不换号不冷却(ADR-0036 否决方案 D 的口径不变,别把两者混为一谈)。全号都在冷却时 `pickKey` 的 `ring.next() || keys[0]` 仍会 `forced` 补一发(冷却是轮转偏好不是硬拒)。`test/qoder-queue-switch.test.js` 8 例;`test/qoder-envelope-status.test.js` 现为 5 例(前者首已按新行为改写)。
22
+ - `GET /v1/models` 在 `modelPicks` 非空时按勾选集裁剪(`filterByPicks`,空勾选=不裁剪,`?all=1` 绕过),`-models` 交互候选取数恒走 `?all=1`;交互选择器按终端行高开窗分页(`src/chooser.js`,多选行形状 `❯ [✓] <id>`)。
23
+
24
+
25
+ ## qwenwork(cn)与 globalqwenwork(国际站)— 同协议两租户(ADR-0037/0041)
26
+
27
+ - 千问办公两站 `gateway.qwenwork.cn` / `gateway.qwenwork.ai`:COSY 签名(**同一枚 RSA 模数**)、SSE 端点 `/algo/api/v2/service/pro/sse/agent_chat_generation`、chat body、refresh `{refresh_token}` **逐字同构**(现网双站实测 200),故 globalqwenwork 的协议模块从 qwenwork 逐字 vendor(仓库约定:provider 目录自包含、不跨目录 import)。**差异只在三处租户参数**:auth 域名、OAuth `client_id`(cn `e883ade2…` / intl `cc65e5fc…`)、深链 `redirect_uri`(`qwenwork-cn://` / `qwenwork://`);**凭证与积分池两站不互通**(cn token 打 .ai → 401 INVALID_TOKEN)。
28
+ - ⚠ **registry.js 子串陷阱**:qwenwork 的 matcher 是 `includes("qwenwork")`,而 `globalqwenwork` / `globalqwenwork://native` 都含该子串且 `getCustomProviderFactory` first-match-wins —— **globalqwenwork 条目必须排在 qwenwork 之前**,否则国际站 token 被 cn 工厂接管(乱 401 + refresh 污染 cn 号)。`test/globalqwenwork-provider.test.js` 第 2 例锁死该顺序;动 registry 顺序前先跑它。
29
+ - 模型池零交集:cn `flash`/`pro`/`qwen3.8-max-preview`(pf 0.1/1/1.8,reasoning)↔ intl 仅 `qwork-auto`(→qwen3.8-flash)/`qwork-advanced`(→gpt-5.6-sol)(pf 0,非 reasoning,is_vl,max_input 180000)。登录各自种 allowlist、`allowAnyModels=false`(额度池防 auto 烧分)。国际站官方客户端自身走 gRPC `model.chat.ChatService/ChatCompletionStream`(其 `region/endpoints` 在 .ai 404),本仓不走 —— 见 ADR-0041 决策 1。
30
+ ## workbuddy
31
+
32
+ - 专用 `POST /v2/chat/completions` **强制 `stream:true`** + `GET /v3/config` 按 credits 升序前缀 `workbuddy/`(**2026-10-10 换端点**:旧 `GET /console/enterprises/personal/models` 对多数 token 恒 401/500 → 目录 0 条 → `aggLoad` 判 `!allowed.length` 把整家跳过;`/v3/config` 返回**随 UA 变**,本仓 UA 与 chat 同身份故目录即"自己能调的那套";`listModels` 已改逐 key 轮换,单个坏 token 不再清空目录);401/403 自动 `POST /v2/plugin/auth/token/refresh` 回写 `state.json`+`auths/workbuddy-<uid>.json`(0600 tmp+rename)。
33
+ - 凭据目录 `authDirFor()` 跟账本走 + `authDirCandidates()` 只读兜底旧位置 + `listAccountDocs()` 单一读取出口(provider/CLI/token-auto 四处复制品合一);`-workbuddy remove` 扫全部候选目录防旧副本「复活」(ADR-0025,`test/workbuddy-authdir.test.js` 6 例)。
34
+ - 接入禁手填 `<key>`,必须 `node workbuddy-token-auto.js` 自动化(whistle MITM :8899;whistle/MITM 取 codex token 路径已证伪);桌面登录态跨平台发现 `src/providers/workbuddy/desktop-info.js`(显式 `--file`/env > 后缀快查 > 文件名 BFS depth≤6;Win `%LOCALAPPDATA%`/`%APPDATA%`、mac `~/Library/Application Support`、Linux `~/.config`);设备授权加号(0.1.93):`POST /v2/plugin/auth/state?platform=CLI` → 浏览器登录 → 轮询 `token?state=`,UA `CLI/2.63.2 CodeBuddy/2.63.2` 可 `MSLXDFF_WORKBUDDY_UA` 覆盖;每日 `workbuddy-checkin.js` 双域幂等 `code 10001` 视为成功。
35
+ - `share` 默认 `off` 需 `MSLXDFF_WORKBUDDY_SHARE_KEYS=1`;`-setto workbuddy [modelId]` 原子写 `~/.workbuddy/models.json`(仅 127.0.0.1/v1)。
36
+ - 出网 11128 指纹剥离(`payload.js` CC_SENTENCES 清单式):上游扫 system/assistant,命中行首 `x-anthropic-billing-header:` 整行或官方固定整句("You are Claude Code…" / "To give feedback…anthropics/claude-code/issues")即 400 "unapproved channel"(实测整句纯子串匹配、user/tool 不扫、换说法放行)——只剥固定形态,新拦句加清单。
37
+
38
+ ## cline
39
+
40
+ - 指纹头:UA `Cline/3.0.47` + `X-CLIENT-TYPE: cline-sdk` + `X-CLIENT-VERSION` 同 UA;对话链 = `POST /api/v1/auth/refresh`(**每次 refresh 轮换 refreshToken 必须回写**)→ `Bearer workos:<accessToken>` + 固定 `<base>/api/v1/chat/completions`(**baseUrl 已含 /api/v1 时只拼 `/chat/completions`,否则双 /api/v1 会 401/empty response**);deepseek/z-ai 免费通道必须 `stream:true` + SSE 聚合否则 `500 empty response content`;`X-Task-ID`/`session_id` 每次唯一。
41
+ - 发上游剥 `provider/` 前缀(`cline/deepseek/X→deepseek/X`)恢复 forceStream 聚合 + 非 JSON 响应体 TypeError 加固(`test/cline-chat-id.test.js` 4 用例,0.1.91)。
42
+ - 白名单自动同步(ADR-0033 → **ADR-0067 双向收敛**):`allowlist-sync.js` 的 `planSync` 按 free 目录增删 `allowedModels`;移除只限 free 命名空间(`isFreeChannelId`),`cline-pass/*` 与手配 id 不动(目录接口只回 free,拿它判 pass 会误删付费通道),细节见 ADR-0067。`models.js` 仅在上游成功路径调 `notifyRefresh`(兜底不写盘);无增删零写盘;`MSLXDFF_CLINE_AUTOSYNC=0` 关(含移除);**教训:`saveProviderAllowedModels`/`saveProviderConfig` 重建 cfg 时起手 `{baseUrl,keys}` 未带 `allowAnyModels` 会静默抹成 false**(已修,勿回退)。
43
+ - 登录走 WorkOS 设备授权流(client_id `client_01K3A541FN8TA3EPPHTD2325AR`),refreshToken 落 `providerConfigs.{cline}.keys`;bench 检测 keys 含 refreshToken 即走 refresh→指纹头→stream 测速专用分支。
44
+
45
+ ## bench 与额度保护(`src/bench/`)
46
+
47
+ - `bench/bench --via` 仅测(allowlist ∩ 全局 picks)交集(`filterBenchModels`,picks 空回退 allowlist);计费模型(无 `-free` 后缀)需显式点名才测(`max_tokens=5` 也计费);`--via` 额度保护默认跳过 `opencode` 需 `--include-opencode` 且 TTY 二次确认 `y/N`;0.1.84 起完全串行 `直连→逐 peer`(`MSLXDFF_BENCH_DELAY_MS=120`)逐模型实时打印(`--json` 进度走 `stderr`)。
48
+ - `aihubmix gemini 400 no_available_channel` 为上游无通道非 bench bug;peer name 统一短化 `host:port`。
49
+ - **烧额度的实测必须先经用户明确同意**:付费/订阅/按量计费上游的裸调、A/B 长文生成一律先问(2026-09-23 教训);免费池探针(public 免费模型、短 prompt 微量)可直接跑。
@@ -1,238 +1,238 @@
1
- // AgentRouter 定制供应商:OpenAI 兼容聚合网关,有四道硬门槛(2026-10-21 现网 curl 实测):
2
- // ① 客户端身份白名单 —— UA 为 `mslxdff`/`curl`/浏览器一律 401 `unauthorized client detected`,
3
- // 只放行 `opencode/*` 与 `claude-cli/<ver> (external…)`;
4
- // ② 模型分端点 —— `deepseek-v4-flash` 吃 /v1/chat/completions,`gpt-6.1-sol`/`gpt-6-astra`
5
- // 对该端点回 400「请改用 /v1/responses」,只在 responses 通道可用;
6
- // ③ 内容闸 —— 正文含 `x-anthropic` 字样稳定 500 `sensitive words detected`(连 user 消息也拦,
7
- // 与 workbuddy 只扫 system/assistant 不同),见 stripAgentRouterSensitive;
8
- // ④ 传输层 —— 上游对 Node/undici 的**新建 TCP 连接**选择性丢包(现网对照:undici 0/4~0/6
9
- // 全 connect 超时,curl 同刻 100% 200,走系统代理亦不稳)⇒ 本家 fetchImpl 缺省换
10
- // curl-fetch 子进程通道(`MSLXDFF_AGENTROUTER_FETCH=undici` 可切回)。
11
- // 故不复用 generic(其 UA 写死 `mslxdff`、responses 判定只认 models.dev npm 索引),这里自组装,
12
- // 底层仍复用 base.js 的 runner 与 responses-channel 的协议互转。
13
- import { createKeyRing } from "./keyring.js";
14
- import { loadProviderBaseUrl, loadProviderKeys } from "../state.js";
15
- import { createChatRunner, createAgent, envInt, getUndici, joinUrl, collectApiKeysGeneric } from "./base.js";
16
- import { createResponsesChannel, resolveResponsesPath } from "./responses-channel.js";
17
- import { createCurlFetch } from "./curl-fetch.js";
18
- import { compatFetch } from "../compat.js";
19
- import { opencodeUa } from "../opencode-identity.js";
20
- import { joinModelId } from "./model-id.js";
21
-
22
- const { UndiciFetch } = getUndici();
23
-
24
- export const DEFAULT_AGENTROUTER_BASE = "https://agentrouter.org";
25
- export const MODELS_PATH = "/v1/models";
26
- export const CHAT_PATH = "/v1/chat/completions";
27
- export const RESPONSES_PATH = "/v1/responses";
28
-
29
- // 只吃 responses 的模型族:现网实测 gpt-6* 打 chat 端点回 400 并显式要求改 /v1/responses。
30
- const RESPONSES_PREFIXES = ["gpt-6"];
31
-
32
- export function isAgentRouterResponsesModel(model) {
33
- const m = String(model || "").trim().toLowerCase();
34
- if (!m) return false;
35
- return RESPONSES_PREFIXES.some((p) => m.startsWith(p));
36
- }
37
-
38
- // baseUrl 归一:缺省取 state/env,剥掉尾部 `/v1`(路径常量自带 `/v1`,避免 /v1/v1)
39
- export function resolveAgentRouterBase(baseUrl) {
40
- const raw = baseUrl || loadProviderBaseUrl("agentrouter") || DEFAULT_AGENTROUTER_BASE;
41
- return String(raw).trim().replace(/\/+$/, "").replace(/\/v1$/i, "");
42
- }
43
-
44
- // responses 通道内部两处 stream 判据不一致(响应通道用 `!== false` 决定读法,body 转换用
45
- // `=== true` 决定发什么):客户端不显式给 stream 时,会去把未转换的上游 JSON 当 chat 形状读。
46
- // 这里先把意图钉成布尔,两条路径就同源了。
47
- export function normalizeResponsesBody(body) {
48
- if (!body || typeof body !== "object") return body;
49
- return { ...body, stream: body.stream === true };
50
- }
51
-
52
- // AgentRouter 内容闸:稳定拦截正文里含 `x-anthropic` 字样的请求(2026-10-21 现网实测:
53
- // `500 sensitive words detected` / `400 content-blocked`,连 `x-anthropic` 一个词也拦;
54
- // `x-google`/`x-meta`/`x-openai`/`anthropic` 均放行——这是对 Anthropic 内部计费头/代理指纹的反代理风控)。
55
- // 与 workbuddy 的 CC_SENTENCES 剥离同类,但**必须覆盖所有 role**(workbuddy 只扫 system/assistant,
56
- // 而 agentrouter 连 user/tool 消息里出现该串也拦)。用零宽空格断开子串匹配、不动其余内容。
57
- // 返回新对象(messages 浅拷贝),不污染调用方 body(dispatcher 可能复用同一 body 做兜底重试)。
58
- const AR_SENSITIVE = /x-anthropic/gi;
59
-
60
- export function stripAgentRouterSensitive(obj) {
61
- if (!obj || typeof obj !== "object") return obj;
62
- if (!Array.isArray(obj.messages)) return obj;
63
- const mask = (text) => (typeof text === "string" && AR_SENSITIVE.test(text) ? text.replace(AR_SENSITIVE, "x​anthropic") : text);
64
- const out = { ...obj, messages: obj.messages.map((m) => {
65
- if (!m || typeof m !== "object") return m;
66
- const nm = { ...m };
67
- if (typeof m.content === "string") nm.content = mask(m.content);
68
- else if (Array.isArray(m.content)) nm.content = m.content.map((p) => (p && typeof p === "object" && typeof p.text === "string" ? { ...p, text: mask(p.text) } : p));
69
- if (typeof m.name === "string") nm.name = mask(m.name);
70
- return nm;
71
- }) };
72
- return out;
73
- }
74
-
75
- export function createAgentRouterProvider({
76
- id = "agentrouter",
77
- baseUrl,
78
- apiKeys,
79
- apiKey,
80
- connectTimeoutMs = envInt("MSLXDFF_AGENTROUTER_TIMEOUT_MS", 60_000),
81
- cooldownMs = envInt("MSLXDFF_AGENTROUTER_COOLDOWN_MS", 30_000),
82
- fetchImpl,
83
- file,
84
- } = {}) {
85
- if (id !== "agentrouter") throw new Error(`agentrouter provider id mismatch: ${id}`);
86
- const resolvedBase = resolveAgentRouterBase(baseUrl);
87
- // 传输层缺省走 curl 子进程(见文件头门槛④):上游对 undici 新建连接选择性丢包,
88
- // 实测 curl 同刻稳定。`MSLXDFF_AGENTROUTER_FETCH=undici` 切回 undici(配好上游修复后或对照排查用)。
89
- const useCurl = !fetchImpl && String(process.env.MSLXDFF_AGENTROUTER_FETCH || "curl").toLowerCase() !== "undici";
90
- if (!fetchImpl) fetchImpl = useCurl ? createCurlFetch() : (UndiciFetch || compatFetch);
91
-
92
- const ring = createKeyRing(
93
- collectApiKeysGeneric(id, apiKeys, apiKey, (pid) => loadProviderKeys(pid, file ? { file } : {})),
94
- { cooldownMs },
95
- );
96
-
97
- let agent = null; let dispatcher = null;
98
- {
99
- const a = createAgent({
100
- keepAliveTimeout: envInt("MSLXDFF_AGENTROUTER_KEEPALIVE_TIMEOUT", 30_000),
101
- keepAliveMaxTimeout: envInt("MSLXDFF_AGENTROUTER_KEEPALIVE_MAX_TIMEOUT", 60_000),
102
- connections: envInt("MSLXDFF_AGENTROUTER_KEEPALIVE_CONNECTIONS", 20),
103
- });
104
- agent = a.agent; dispatcher = a.dispatcher;
105
- }
106
-
107
- // 身份 UA:白名单只认 opencode / claude-cli,缺了恒 401。单独 env 可覆盖,不联动全局。
108
- function identityUa() {
109
- return process.env.MSLXDFF_AGENTROUTER_UA || opencodeUa();
110
- }
111
-
112
- function buildHeaders(body, key) {
113
- const isStream = body?.stream !== false;
114
- const h = {
115
- "Content-Type": "application/json",
116
- Accept: isStream ? "text/event-stream" : "*/*",
117
- "User-Agent": identityUa(),
118
- };
119
- if (key) h.Authorization = `Bearer ${key}`;
120
- return h;
121
- }
122
-
123
- function jsonHeaders(key) {
124
- const h = { Accept: "application/json", "User-Agent": identityUa() };
125
- if (key) h.Authorization = `Bearer ${key}`;
126
- return h;
127
- }
128
-
129
- // 网络重试:agentrouter 只有 1 把 key,任何一次瞬时连接抖动(现网实测 ECONNRESET 偶发,
130
- // 上游高频时会触发)若直接 onError,= 立刻冷却 30s、把后续请求全挡成
131
- // "all keys in cooldown"。补 attempts=2(对齐 generic/cline/workbuddy),让网络层重试先扛一下。
132
- const chatRetry = { network: { attempts: 2, delayMs: 300 }, 429: { attempts: 1, delayMs: 100 }, 502: { attempts: 1, delayMs: 100 }, 503: { attempts: 1, delayMs: 100 }, 504: { attempts: 1, delayMs: 100 } };
133
-
134
- const { runChat } = createChatRunner({
135
- id, ring, cooldownMs, retry: chatRetry, fetchImpl, dispatcher, buildHeaders,
136
- getUrl: () => joinUrl(resolvedBase, CHAT_PATH),
137
- connectTimeoutMs,
138
- // 非流式走原生(SDK 通道只服务 opencode zen 的形状),responses 另走专用通道
139
- sdkDispatch: { enabled: false },
140
- });
141
-
142
- const responses = createResponsesChannel({
143
- id,
144
- url: joinUrl(resolvedBase, resolveResponsesPath(id, RESPONSES_PATH)),
145
- connectTimeoutMs,
146
- cooldownMs,
147
- retry: chatRetry,
148
- // 关 SDK:其缺省会强塞 include:["reasoning.encrypted_content"] / store:false,agentrouter 未验证支持
149
- sdkEnabled: false,
150
- buildHeaders,
151
- fetchImpl,
152
- dispatcher,
153
- });
154
-
155
- async function chat(body, opts) {
156
- const sourceKey = `MSLXDFF_AGENTROUTER_KEY`;
157
- const clean = stripAgentRouterSensitive(body);
158
- if (isAgentRouterResponsesModel(clean?.model)) return responses.run(normalizeResponsesBody(clean), ring, sourceKey, opts);
159
- return runChat(clean, ring, sourceKey);
160
- }
161
-
162
- async function chatWithKeys(body, keys, opts) {
163
- const tmp = createKeyRing(keys, { cooldownMs });
164
- const clean = stripAgentRouterSensitive(body);
165
- if (isAgentRouterResponsesModel(clean?.model)) return responses.run(normalizeResponsesBody(clean), tmp, "shared provider keys", opts);
166
- const scoped = createChatRunner({
167
- id, ring: tmp, cooldownMs, retry: chatRetry, fetchImpl, dispatcher, buildHeaders,
168
- getUrl: () => joinUrl(resolvedBase, CHAT_PATH),
169
- connectTimeoutMs,
170
- sdkDispatch: { enabled: false },
171
- });
172
- return scoped.runChat(clean, tmp, "shared provider keys");
173
- }
174
-
175
- let modelsCache = { at: 0, list: [] };
176
- const MODELS_TTL_MS = 10 * 60 * 1000;
177
-
178
- async function fetchModels() {
179
- const controller = new AbortController();
180
- const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
181
- try {
182
- const key = ring.next() || ring.keys[0];
183
- const opts = { headers: jsonHeaders(key), signal: controller.signal };
184
- if (dispatcher) opts.dispatcher = dispatcher;
185
- const res = await fetchImpl(joinUrl(resolvedBase, MODELS_PATH), opts);
186
- if (!res.ok) throw new Error(`${id} models: http ${res.status}`);
187
- const json = await res.json().catch(() => ({}));
188
- return Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : [];
189
- } finally {
190
- clearTimeout(timer);
191
- }
192
- }
193
-
194
- async function listModels() {
195
- if (modelsCache.list.length && Date.now() - modelsCache.at < MODELS_TTL_MS) return modelsCache.list;
196
- let raw = [];
197
- try {
198
- raw = await fetchModels();
199
- } catch {
200
- return modelsCache.list; // 拉取失败给上次缓存(首次为空),不阻断启动
201
- }
202
- const list = raw
203
- .filter((m) => m && typeof m.id === "string" && m.id.trim())
204
- .map((m) => ({
205
- id: joinModelId(id, m.id),
206
- object: "model",
207
- name: m.name || m.id,
208
- owned_by: m.owned_by || "agentrouter",
209
- ...(m.created ? { created: m.created } : {}),
210
- }));
211
- modelsCache = { at: Date.now(), list };
212
- return list;
213
- }
214
-
215
- async function preheat() {
216
- try {
217
- const m = await listModels();
218
- return { ok: m.length > 0, models: m.length };
219
- } catch (err) {
220
- return { ok: false, error: String(err?.message || err).slice(0, 120) };
221
- }
222
- }
223
-
224
- async function close() { if (agent?.close) try { await agent.close(); } catch {} }
225
-
226
- return {
227
- id,
228
- chat,
229
- chatWithKeys,
230
- listModels,
231
- preheat,
232
- close,
233
- agent,
234
- keyRing: ring,
235
- baseUrl: resolvedBase,
236
- responsesUrl: responses.url,
237
- };
238
- }
1
+ // AgentRouter 定制供应商:OpenAI 兼容聚合网关,有四道硬门槛(2026-10-21 现网 curl 实测):
2
+ // ① 客户端身份白名单 —— UA 为 `mslxdff`/`curl`/浏览器一律 401 `unauthorized client detected`,
3
+ // 只放行 `opencode/*` 与 `claude-cli/<ver> (external…)`;
4
+ // ② 模型分端点 —— `deepseek-v4-flash` 吃 /v1/chat/completions,`gpt-6.1-sol`/`gpt-6-astra`
5
+ // 对该端点回 400「请改用 /v1/responses」,只在 responses 通道可用;
6
+ // ③ 内容闸 —— 正文含 `x-anthropic` 字样稳定 500 `sensitive words detected`(连 user 消息也拦,
7
+ // 与 workbuddy 只扫 system/assistant 不同),见 stripAgentRouterSensitive;
8
+ // ④ 传输层 —— 上游对 Node/undici 的**新建 TCP 连接**选择性丢包(现网对照:undici 0/4~0/6
9
+ // 全 connect 超时,curl 同刻 100% 200,走系统代理亦不稳)⇒ 本家 fetchImpl 缺省换
10
+ // curl-fetch 子进程通道(`MSLXDFF_AGENTROUTER_FETCH=undici` 可切回)。
11
+ // 故不复用 generic(其 UA 写死 `mslxdff`、responses 判定只认 models.dev npm 索引),这里自组装,
12
+ // 底层仍复用 base.js 的 runner 与 responses-channel 的协议互转。
13
+ import { createKeyRing } from "./keyring.js";
14
+ import { loadProviderBaseUrl, loadProviderKeys } from "../state.js";
15
+ import { createChatRunner, createAgent, envInt, getUndici, joinUrl, collectApiKeysGeneric } from "./base.js";
16
+ import { createResponsesChannel, resolveResponsesPath } from "./responses-channel.js";
17
+ import { createCurlFetch } from "./curl-fetch.js";
18
+ import { compatFetch } from "../compat.js";
19
+ import { opencodeUa } from "../opencode-identity.js";
20
+ import { joinModelId } from "./model-id.js";
21
+
22
+ const { UndiciFetch } = getUndici();
23
+
24
+ export const DEFAULT_AGENTROUTER_BASE = "https://agentrouter.org";
25
+ export const MODELS_PATH = "/v1/models";
26
+ export const CHAT_PATH = "/v1/chat/completions";
27
+ export const RESPONSES_PATH = "/v1/responses";
28
+
29
+ // 只吃 responses 的模型族:现网实测 gpt-6* 打 chat 端点回 400 并显式要求改 /v1/responses。
30
+ const RESPONSES_PREFIXES = ["gpt-6"];
31
+
32
+ export function isAgentRouterResponsesModel(model) {
33
+ const m = String(model || "").trim().toLowerCase();
34
+ if (!m) return false;
35
+ return RESPONSES_PREFIXES.some((p) => m.startsWith(p));
36
+ }
37
+
38
+ // baseUrl 归一:缺省取 state/env,剥掉尾部 `/v1`(路径常量自带 `/v1`,避免 /v1/v1)
39
+ export function resolveAgentRouterBase(baseUrl) {
40
+ const raw = baseUrl || loadProviderBaseUrl("agentrouter") || DEFAULT_AGENTROUTER_BASE;
41
+ return String(raw).trim().replace(/\/+$/, "").replace(/\/v1$/i, "");
42
+ }
43
+
44
+ // responses 通道内部两处 stream 判据不一致(响应通道用 `!== false` 决定读法,body 转换用
45
+ // `=== true` 决定发什么):客户端不显式给 stream 时,会去把未转换的上游 JSON 当 chat 形状读。
46
+ // 这里先把意图钉成布尔,两条路径就同源了。
47
+ export function normalizeResponsesBody(body) {
48
+ if (!body || typeof body !== "object") return body;
49
+ return { ...body, stream: body.stream === true };
50
+ }
51
+
52
+ // AgentRouter 内容闸:稳定拦截正文里含 `x-anthropic` 字样的请求(2026-10-21 现网实测:
53
+ // `500 sensitive words detected` / `400 content-blocked`,连 `x-anthropic` 一个词也拦;
54
+ // `x-google`/`x-meta`/`x-openai`/`anthropic` 均放行——这是对 Anthropic 内部计费头/代理指纹的反代理风控)。
55
+ // 与 workbuddy 的 CC_SENTENCES 剥离同类,但**必须覆盖所有 role**(workbuddy 只扫 system/assistant,
56
+ // 而 agentrouter 连 user/tool 消息里出现该串也拦)。用零宽空格断开子串匹配、不动其余内容。
57
+ // 返回新对象(messages 浅拷贝),不污染调用方 body(dispatcher 可能复用同一 body 做兜底重试)。
58
+ const AR_SENSITIVE = /x-anthropic/gi;
59
+
60
+ export function stripAgentRouterSensitive(obj) {
61
+ if (!obj || typeof obj !== "object") return obj;
62
+ if (!Array.isArray(obj.messages)) return obj;
63
+ const mask = (text) => (typeof text === "string" && AR_SENSITIVE.test(text) ? text.replace(AR_SENSITIVE, "x​anthropic") : text);
64
+ const out = { ...obj, messages: obj.messages.map((m) => {
65
+ if (!m || typeof m !== "object") return m;
66
+ const nm = { ...m };
67
+ if (typeof m.content === "string") nm.content = mask(m.content);
68
+ else if (Array.isArray(m.content)) nm.content = m.content.map((p) => (p && typeof p === "object" && typeof p.text === "string" ? { ...p, text: mask(p.text) } : p));
69
+ if (typeof m.name === "string") nm.name = mask(m.name);
70
+ return nm;
71
+ }) };
72
+ return out;
73
+ }
74
+
75
+ export function createAgentRouterProvider({
76
+ id = "agentrouter",
77
+ baseUrl,
78
+ apiKeys,
79
+ apiKey,
80
+ connectTimeoutMs = envInt("MSLXDFF_AGENTROUTER_TIMEOUT_MS", 60_000),
81
+ cooldownMs = envInt("MSLXDFF_AGENTROUTER_COOLDOWN_MS", 30_000),
82
+ fetchImpl,
83
+ file,
84
+ } = {}) {
85
+ if (id !== "agentrouter") throw new Error(`agentrouter provider id mismatch: ${id}`);
86
+ const resolvedBase = resolveAgentRouterBase(baseUrl);
87
+ // 传输层缺省走 curl 子进程(见文件头门槛④):上游对 undici 新建连接选择性丢包,
88
+ // 实测 curl 同刻稳定。`MSLXDFF_AGENTROUTER_FETCH=undici` 切回 undici(配好上游修复后或对照排查用)。
89
+ const useCurl = !fetchImpl && String(process.env.MSLXDFF_AGENTROUTER_FETCH || "curl").toLowerCase() !== "undici";
90
+ if (!fetchImpl) fetchImpl = useCurl ? createCurlFetch() : (UndiciFetch || compatFetch);
91
+
92
+ const ring = createKeyRing(
93
+ collectApiKeysGeneric(id, apiKeys, apiKey, (pid) => loadProviderKeys(pid, file ? { file } : {})),
94
+ { cooldownMs },
95
+ );
96
+
97
+ let agent = null; let dispatcher = null;
98
+ {
99
+ const a = createAgent({
100
+ keepAliveTimeout: envInt("MSLXDFF_AGENTROUTER_KEEPALIVE_TIMEOUT", 30_000),
101
+ keepAliveMaxTimeout: envInt("MSLXDFF_AGENTROUTER_KEEPALIVE_MAX_TIMEOUT", 60_000),
102
+ connections: envInt("MSLXDFF_AGENTROUTER_KEEPALIVE_CONNECTIONS", 20),
103
+ });
104
+ agent = a.agent; dispatcher = a.dispatcher;
105
+ }
106
+
107
+ // 身份 UA:白名单只认 opencode / claude-cli,缺了恒 401。单独 env 可覆盖,不联动全局。
108
+ function identityUa() {
109
+ return process.env.MSLXDFF_AGENTROUTER_UA || opencodeUa();
110
+ }
111
+
112
+ function buildHeaders(body, key) {
113
+ const isStream = body?.stream !== false;
114
+ const h = {
115
+ "Content-Type": "application/json",
116
+ Accept: isStream ? "text/event-stream" : "*/*",
117
+ "User-Agent": identityUa(),
118
+ };
119
+ if (key) h.Authorization = `Bearer ${key}`;
120
+ return h;
121
+ }
122
+
123
+ function jsonHeaders(key) {
124
+ const h = { Accept: "application/json", "User-Agent": identityUa() };
125
+ if (key) h.Authorization = `Bearer ${key}`;
126
+ return h;
127
+ }
128
+
129
+ // 网络重试:agentrouter 只有 1 把 key,任何一次瞬时连接抖动(现网实测 ECONNRESET 偶发,
130
+ // 上游高频时会触发)若直接 onError,= 立刻冷却 30s、把后续请求全挡成
131
+ // "all keys in cooldown"。补 attempts=2(对齐 generic/cline/workbuddy),让网络层重试先扛一下。
132
+ const chatRetry = { network: { attempts: 2, delayMs: 300 }, 429: { attempts: 1, delayMs: 100 }, 502: { attempts: 1, delayMs: 100 }, 503: { attempts: 1, delayMs: 100 }, 504: { attempts: 1, delayMs: 100 } };
133
+
134
+ const { runChat } = createChatRunner({
135
+ id, ring, cooldownMs, retry: chatRetry, fetchImpl, dispatcher, buildHeaders,
136
+ getUrl: () => joinUrl(resolvedBase, CHAT_PATH),
137
+ connectTimeoutMs,
138
+ // 非流式走原生(SDK 通道只服务 opencode zen 的形状),responses 另走专用通道
139
+ sdkDispatch: { enabled: false },
140
+ });
141
+
142
+ const responses = createResponsesChannel({
143
+ id,
144
+ url: joinUrl(resolvedBase, resolveResponsesPath(id, RESPONSES_PATH)),
145
+ connectTimeoutMs,
146
+ cooldownMs,
147
+ retry: chatRetry,
148
+ // 关 SDK:其缺省会强塞 include:["reasoning.encrypted_content"] / store:false,agentrouter 未验证支持
149
+ sdkEnabled: false,
150
+ buildHeaders,
151
+ fetchImpl,
152
+ dispatcher,
153
+ });
154
+
155
+ async function chat(body, opts) {
156
+ const sourceKey = `MSLXDFF_AGENTROUTER_KEY`;
157
+ const clean = stripAgentRouterSensitive(body);
158
+ if (isAgentRouterResponsesModel(clean?.model)) return responses.run(normalizeResponsesBody(clean), ring, sourceKey, opts);
159
+ return runChat(clean, ring, sourceKey);
160
+ }
161
+
162
+ async function chatWithKeys(body, keys, opts) {
163
+ const tmp = createKeyRing(keys, { cooldownMs });
164
+ const clean = stripAgentRouterSensitive(body);
165
+ if (isAgentRouterResponsesModel(clean?.model)) return responses.run(normalizeResponsesBody(clean), tmp, "shared provider keys", opts);
166
+ const scoped = createChatRunner({
167
+ id, ring: tmp, cooldownMs, retry: chatRetry, fetchImpl, dispatcher, buildHeaders,
168
+ getUrl: () => joinUrl(resolvedBase, CHAT_PATH),
169
+ connectTimeoutMs,
170
+ sdkDispatch: { enabled: false },
171
+ });
172
+ return scoped.runChat(clean, tmp, "shared provider keys");
173
+ }
174
+
175
+ let modelsCache = { at: 0, list: [] };
176
+ const MODELS_TTL_MS = 10 * 60 * 1000;
177
+
178
+ async function fetchModels() {
179
+ const controller = new AbortController();
180
+ const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
181
+ try {
182
+ const key = ring.next() || ring.keys[0];
183
+ const opts = { headers: jsonHeaders(key), signal: controller.signal };
184
+ if (dispatcher) opts.dispatcher = dispatcher;
185
+ const res = await fetchImpl(joinUrl(resolvedBase, MODELS_PATH), opts);
186
+ if (!res.ok) throw new Error(`${id} models: http ${res.status}`);
187
+ const json = await res.json().catch(() => ({}));
188
+ return Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : [];
189
+ } finally {
190
+ clearTimeout(timer);
191
+ }
192
+ }
193
+
194
+ async function listModels() {
195
+ if (modelsCache.list.length && Date.now() - modelsCache.at < MODELS_TTL_MS) return modelsCache.list;
196
+ let raw = [];
197
+ try {
198
+ raw = await fetchModels();
199
+ } catch {
200
+ return modelsCache.list; // 拉取失败给上次缓存(首次为空),不阻断启动
201
+ }
202
+ const list = raw
203
+ .filter((m) => m && typeof m.id === "string" && m.id.trim())
204
+ .map((m) => ({
205
+ id: joinModelId(id, m.id),
206
+ object: "model",
207
+ name: m.name || m.id,
208
+ owned_by: m.owned_by || "agentrouter",
209
+ ...(m.created ? { created: m.created } : {}),
210
+ }));
211
+ modelsCache = { at: Date.now(), list };
212
+ return list;
213
+ }
214
+
215
+ async function preheat() {
216
+ try {
217
+ const m = await listModels();
218
+ return { ok: m.length > 0, models: m.length };
219
+ } catch (err) {
220
+ return { ok: false, error: String(err?.message || err).slice(0, 120) };
221
+ }
222
+ }
223
+
224
+ async function close() { if (agent?.close) try { await agent.close(); } catch {} }
225
+
226
+ return {
227
+ id,
228
+ chat,
229
+ chatWithKeys,
230
+ listModels,
231
+ preheat,
232
+ close,
233
+ agent,
234
+ keyRing: ring,
235
+ baseUrl: resolvedBase,
236
+ responsesUrl: responses.url,
237
+ };
238
+ }