Skip to content

Commit 7310ad1

Browse files
committed
fix(codearts): 并发上限 + 错误分类 + 一次性 token 轮转收口(修 77% 失败率)
CodeArts 2026-09-30 23:17 至 10-01 07:40 大量请求失败(135 次,整体约 77%) 的三条故障链,逐条收口: 1. 并发击穿(主因):上游硬限每账号并发会话数 3,聊天 pacer 声明 allow_concurrent=True 但无上限,桶内来多少放多少,第 4 个起全部 400 TM.00001041。Pacer 增按桶在途上限 max_concurrency(满则挂起等 release 让位,0/None 保持旧行为,支持热更、取消/异常回滚名额); CodeArts pacer 装配新热更项 CODEARTS_MAX_CONCURRENCY(默认 3)。 2. 分类错误:classify_status 只在 429 分支查 00001041,并发超限实际走 400,被判 INVALID(换号没用且不冷却)。改为 400/429 命中限流标记 (00001041/tpm/并发/rate limit/throttl)统一判 MODEL(可重试 503), 与流内 classify_error_code('TM.00001041') 一致。 3. refresh_token 被烧:probe_quota 的保活刷新与 RefreshTask 各消费一次 一次性 refresh_token,且前者不落库 → 后到的报 the refresh token has been used,游标耗尽后 APIG.0602 硬失效。probe_quota 改为只读余额, 轮转唯一归 RefreshTask(先落库再同步);TaskRunner.start 增加启动即 刷新,避免重启窗口内带病服务。 4. SSE 噪声容错:parse_line 跳过纯括号/空白心跳行(实测裸 { / [),不再 把一次心跳升级成 500。 同批并入工作区另一条进行中的工作流(均已过门禁):provider 探测失败原因 按基类分派并单列 network_unreachable(base.UpstreamProtocolViolation / UpstreamTransportError 统一,handlers 收敛重复处理器),以及 CodeArts 福利 模型按每日池 1:1 扣减的 credit 推算。 验证:ruff 干净;pytest 1788 passed、覆盖 100%;web pnpm tsc/vitest/build 全绿;src/ 已按部署约束重启,healthz ok。文档同步 TECHNICAL/README(en)/ PROPOSAL、compose 透传新字段。
1 parent d5016a8 commit 7310ad1

32 files changed

Lines changed: 532 additions & 147 deletions

‎PROPOSAL.md‎

Lines changed: 4 additions & 2 deletions
Large diffs are not rendered by default.

‎README.en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ credential pool, unified scheduling, and per-user usage stats.
1616
- **OpenCode Zen free tier** (third channel, `zen`): the free models at `opencode.ai/zen`, standard OpenAI protocol, no login. The upstream list mixes in paid models with no free/paid marker, so the gateway narrows by the `-free` suffix and probes each candidate, exposing **only the free models that actually answer anonymously** (fetched and probed live every time, no static allowlist). Requests automatically satisfy the free-tier gate; responses have the gate's injected pseudo-tool calls filtered out. Zen has no credentials — one virtual pool row lets it be scheduled, paused, and counted like any other channel.
1717
- **Kilo Gateway free tier** (fourth channel, `kilo`): the free models at `api.kilo.ai/api/gateway`, standard OpenAI protocol, no login. Filtered by the authoritative per-model `isFree` flag (no probing, to conserve the small free quota); free models are marked **x0**. Same credential-less virtual-row model as Zen.
1818
- **Qoder** (fifth channel, `qoder`): a **real-account** upstream ([Qoder](https://qoder.com)) reached via device-code PKCE login. The upstream speaks a private COSY protocol (custom Base64 body + envelope SSE); the gateway signs and unwraps it, so the surface stays standard OpenAI. Supports quota probing and daily check-in (credits accumulate). Since 2026-10 check-in is **campaign-based** (`/sash/api/v1/me/campaigns`, with a `Cosy-ClientType` header; the legacy `daily-check-in` endpoint now returns `DISABLED` and is kept only as a fallback). Qoder sometimes wraps its own inference-node failures in a 400 (`[FAIL]node:… msg:Execution failed`); the gateway classifies these as a **model-scoped transient fault** — it cools only that model and returns a "model temporarily unavailable" 503 instead of misreporting a missing model or an exhausted pool.
19-
- **CodeArts** (sixth channel, `codearts`): a **real-account** upstream ([Huawei Cloud CodeArts](https://codearts.huaweicloud.com)) reached via OAuth2 PKCE → STS (AK/SK signing + DPoP refresh). The portal redirects the authorization code to the *user's* `127.0.0.1` callback, which a server cannot listen on, so the login flow asks the user to paste that callback URL back and exchanges the code server-side. The upstream emits cumulative-text SSE, which the gateway reduces to incremental events. Supports quota probing and benefit-token claiming. **No daily check-in** — the free quota is a **daily pool of 10M free tokens that resets at midnight (no rollover)**, so token auto-refresh is the keep-alive. Because that pool is use-it-or-lose-it, the day's remainder is registered as quota expiring at the next local midnight, so scheduling **burns it first** and falls back to other channels only once it is exhausted.
19+
- **CodeArts** (sixth channel, `codearts`): a **real-account** upstream ([Huawei Cloud CodeArts](https://codearts.huaweicloud.com)) reached via OAuth2 PKCE → STS (AK/SK signing + DPoP refresh). The portal redirects the authorization code to the *user's* `127.0.0.1` callback, which a server cannot listen on, so the login flow asks the user to paste that callback URL back and exchanges the code server-side. The upstream emits cumulative-text SSE, which the gateway reduces to incremental events. Supports quota probing and benefit-token claiming. **No daily check-in** — the free quota is a **daily pool of 10M free tokens that resets at midnight (no rollover)**, so token auto-refresh is the keep-alive. Because that pool is use-it-or-lose-it, the day's remainder is registered as quota expiring at the next local midnight, so scheduling **burns it first** and falls back to other channels only once it is exhausted. Benefit models carry no rate (they consume the daily pool), so per-request usage records `credit = input + output tokens` (1:1 with the pool, marked ≈ as derived).
2020
- **Expiry-aware scheduling**: among credits expiring within `QUOTA_EXPIRY_WINDOW_SECONDS` (default 36h), the largest balance is burned first; ties fall to `QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS` (default 7 days), so near-expiry quota is not wasted. CodeArts' daily token pool lands in this ladder every day (its remainder expires at midnight), so it is consumed before other channels; units differ per channel (CodeArts counts tokens, the rest credits)
2121
- **Conversation stickiness**: a multi-turn conversation keeps one credential and only rotates on error. Identified by an explicit id when the client sends one (`conversation_id` / `conversationId` / `prompt_cache_key`, top-level or in `metadata`), otherwise by a message-prefix fingerprint. Pinned credentials always win
2222
- **Cached-token accounting**: TRAE's `cache_read_input_tokens` / `cache_creation_input_tokens` are mapped to per-request `cached_tokens` and surfaced in stats; the "Token usage" card also shows a **cache hit rate** (cached ÷ input, 1 decimal), or `—` when cache was never reported / input is 0

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
- **OpenCode Zen 免费层**(第三个渠道):`opencode.ai/zen` 的免费模型接成 `zen` 渠道,标准 OpenAI 协议、无需登录;上游清单混着付费模型且无免费标记,本服务按 `-free` 后缀收窄候选再逐个探活,**只展示匿名真正可用的免费模型**(每次动态拉取并探活,判活结果按 30 分钟缓存以避开每 5 分钟重探一次的开销,不设静态白名单;免费模型显式标注 **x0 倍率**);请求侧自动满足免费层门禁,响应侧过滤门禁注入的伪工具调用。无凭证概念——池里一条虚拟凭证让它和其它渠道一样可调度、可暂停、可统计
1515
- **Kilo Gateway 免费层**(第四个渠道):`api.kilo.ai/api/gateway` 的免费模型接成 `kilo` 渠道,**标准 OpenAI 协议**(`/chat/completions` + `/models`),无需登录、无门禁伪装;上游 `/models` 每个条目带权威 `isFree` 布尔(实测 395 个模型中 17 个为 true),据此直接过滤免费集,**不做探活**(探活会白耗本就极小的免费配额且结果不稳定);上游增删自动跟随,不设静态白名单;免费模型显式标注 **x0 倍率**。同为无凭证渠道——池里一条虚拟凭证即可调度/暂停/统计
1616
- **Qoder**(第五个渠道):[Qoder](https://qoder.com) 接成 `qoder` 渠道,**真实账号渠道**,设备码 PKCE 登录后复用公共凭证池;上游是私有 COSY 协议(自定义 Base64 + 信封式 SSE),本服务负责签名与解包,对外仍是标准 OpenAI;支持额度探测与每日签到(积分可累积)
17-
- **CodeArts**(第六个渠道):[华为云 CodeArts](https://codearts.huaweicloud.com) 盘古引擎接成 `codearts` 渠道,**真实账号渠道**,OAuth2 PKCE 登录换 STS(AK/SK 签名 + DPoP 刷新);上游是累计全文 SSE,本服务负责签名与还原为增量事件;支持额度探测与福利 Token 领取。**该渠道没有每日签到接口**,额度是**每日 1000 万免费 token(当日 0 点清零、不累计)**,保活由 token 自动刷新承担
17+
- **CodeArts**(第六个渠道):[华为云 CodeArts](https://codearts.huaweicloud.com) 盘古引擎接成 `codearts` 渠道,**真实账号渠道**,OAuth2 PKCE 登录换 STS(AK/SK 签名 + DPoP 刷新);上游是累计全文 SSE,本服务负责签名与还原为增量事件;支持额度探测与福利 Token 领取。**该渠道没有每日签到接口**,额度是**每日 1000 万免费 token(当日 0 点清零、不累计)**,保活由 token 自动刷新承担;福利模型不给倍率但**按每日池 1:1 扣减**,故请求按「输入+输出 token」记入统计的 credit(标 ≈ 推算)
1818
- **到期额度优先消化**:主窗口 36h 内将过期的额度多者先用(避免过期浪费),打平再比 7 天窗口;管理台凭证列表显示到期额度与逐个额度包明细。CodeArts 的每日 token 池 0 点清零、用完即弃,同样落此阶梯,故只要它还有额度就会被优先消耗;额度单位随渠道(CodeArts 是 token,其余是积分)
1919
- **会话粘性**:同一对话多轮粘住同一凭证,出错才轮换
2020
- **公共凭证池**:admin 集中维护、全员共享;按人统计用量
@@ -178,7 +178,7 @@ CodeBuddy / TRAE / Qoder / CodeArts 上**同一个模型**的内部代号互不
178178
- **优先消耗**:每日池用完即弃,故当日剩余被登记为「次日本地 0 点到期」的到期额度,调度器会优先把它排在其它渠道之前——只要 CodeArts 还有额度就先走它,用尽后自动回落。管理台「额度」列对此显示为 token(其余渠道是积分)。
179179
- **签名**:上游要求华为云 `SDK-HMAC-SHA256`(AK/SK + `X-Security-Token`)。白名单 `CODEARTS_ALLOWED_ENDPOINTS` 必须含 snap 引擎、STS、福利网关与门户四个主机;改 `CODEARTS_API_ENDPOINT` 时同步调整。
180180
- **累计全文 SSE**:上游流式 `text` 是**累计全文(替换语义)**而非增量,本服务在解析层还原为增量事件,对客户端透明。
181-
- **节流**:真实账号渠道,默认 `CODEARTS_CHAT_MIN_INTERVAL=5`(独立节流器),可在「任务与配置」热更。
181+
- **节流与并发**:真实账号渠道,默认 `CODEARTS_CHAT_MIN_INTERVAL=5`(独立节流器);上游硬限**每账号并发会话数 3**,故 pacer 另配在途上限 `CODEARTS_MAX_CONCURRENCY=3`(超出即 `400 TM.00001041`)。两者均可在「任务与配置」热更。
182182

183183
### Responses API(Codex CLI)
184184

@@ -349,6 +349,7 @@ CodeBuddy 成长中心的「连登天数 / 活跃地图」按日统计客户端
349349
| `KILO_CHAT_MIN_INTERVAL` | `0` | Kilo 聊天最小间隔(秒),独立于 zen / CB/TRAE 的节流器,默认关闭。同为匿名免费层,与 zen 各自独立、互不排队 |
350350
| `QODER_CHAT_MIN_INTERVAL` | `5` | Qoder 聊天最小间隔(秒),独立节流器(真实账号渠道,上游有账号级频率风控);`0` 关闭 |
351351
| `CODEARTS_CHAT_MIN_INTERVAL` | `5` | CodeArts 聊天最小间隔(秒),独立节流器(真实账号渠道);`0` 关闭 |
352+
| `CODEARTS_MAX_CONCURRENCY` | `3` | CodeArts 每账号**在途并发上限**(热更项)。上游硬限每账号并发会话数 3,超出即 `400 TM.00001041`;桶内名额满时请求挂起直到有请求结束;`0` 关闭上限(回到「有在途即放行」,会再次击穿) |
352353
| `CODEBUDDY_SANITIZE_CHANNEL_MARKERS` | `true` | 出站 `system`/`assistant` 正文命中「伪装其他厂商官方客户端」指纹串时替换为占位符(上游 11128 内容风控:换号无效、会话带入即持续报错);只改出站副本,客户端历史不受影响;`false` 关闭(见 TECHNICAL.md §3.2) |
353354
| `REFRESH_SKEW_HOURS` | `24` | token 到期前该小时数窗口内预刷新。到期时间取凭证显式 `expires_at`,缺失时回落 access token 的 JWT `exp`(CodeBuddy 实测不带显式到期字段) |
354355
| `TOKEN_EXPIRY_WARNING_SECONDS` | `3600` | 管理台 token 到期预警阈值:剩余低于该值时标红;`≤0` 关闭预警(仍显示剩余时间)。纯展示,不参与调度 |

‎TECHNICAL.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -660,16 +660,20 @@ UA 版本走 `ZEN_OPENCODE_VERSION` 配置(上游改阈值改 env,不硬编
660660

661661
**SSE 帧(2026-09-30 抓真实流核实)**:逐行 `data:` JSON(`data:` 行间有空行;也有不带 `data:` 前缀的裸 JSON 行),最后由 `data:[DONE]` 结束。**v2 `/api/v2/chat/completions` 实测是标准 OpenAI chunk**:`{"choices":[{"delta":{"content":…,"reasoning_content":…,"tool_calls":…},"finish_reason":…}]}`,增量在 `delta`(**不是**累计全文),收尾帧 `choices:[]` + `usage` 单独给 token 数;带 `tool_stream:true` 时工具调用分片在 `delta.tool_calls`。旧形状(逆向记录 §5 / legacy `/v1/chat/chat`)则是 `{"text":"<累计全文>"}`(替换语义,用 `TextSnapshot` 做差)+ 结束帧 `{"text":"[DONE]","error_code":"0"}`。解析器**两种形状同时兼容**,按字段分派。错误有两条路:HTTP 非 2xx,或流内 `error_code`(形如 `ChatAgent.*` / `TM.00001041`,HTTP 仍 200)。
662662

663-
**无每日签到**:额度是**每日 token 池**(实测 2026-09-30:`GET {opengw}/api/v1/user/tokens/balance` 返回 `daily_token_limit` 1000 万 / `daily_tokens_used`;官方口径「每日千万 Token 免费领,当日 0 点清零、不累计」),上游没有每日签到接口。因此本渠道**不实现 `checkin`**(`checkin_scope` 也一并省略,后台签到任务自动跳过它);「保活」由 token 自动 refresh 承担。`parse_balance` 有 `daily_token_limit` 时按**当日**口径算剩余(`total=daily_token_limit`、`remaining=daily_token_limit - daily_tokens_used`),拿不到该字段才退化到 `total_quota`/`total_balance`/`used_amount` 等通用键。福利模型发现(`{opengw}/api/v1/gateway/config`)与 Token 领取(`POST /api/v1/benefit/claim`,幂等)在探测时顺带完成。
663+
**无每日签到**:额度是**每日 token 池**(实测 2026-09-30:`GET {opengw}/api/v1/user/tokens/balance` 返回 `daily_token_limit` 1000 万 / `daily_tokens_used`;官方口径「每日千万 Token 免费领,当日 0 点清零、不累计」),上游没有每日签到接口。因此本渠道**不实现 `checkin`**(`checkin_scope` 也一并省略,后台签到任务自动跳过它);「保活」由 token 自动 refresh 承担——且**只由 `RefreshTask` 承担**(先落库再同步):`refresh_token` 是一次性的,额度探测等旁路若也顺手刷新,同一个 token 会被两处各消费一次,后到的报 `the refresh token has been used`,且旁路刷新结果不落库、库里 token 被烧成废票(实测由此把渠道打成 `APIG.0602 security token has expired`)。`probe_quota` 因此改为**只读余额**,不再保活刷新。`parse_balance` 有 `daily_token_limit` 时按**当日**口径算剩余(`total=daily_token_limit`、`remaining=daily_token_limit - daily_tokens_used`),拿不到该字段才退化到 `total_quota`/`total_balance`/`used_amount` 等通用键。福利模型发现(`{opengw}/api/v1/gateway/config`)与 Token 领取(`POST /api/v1/benefit/claim`,幂等)在探测时顺带完成。
664664

665665
**优先消耗(用完即弃)**:当日没用完的额度 0 点清零、不累计,所以该池必须**先用掉**。`parse_balance` 把它登记成与 CodeBuddy/TRAE 同构的 `expiry_ladder`:到期点=次日本地 0 点(上游不返回重置时间戳,按服务端时区推算,见 `_next_local_midnight`)、金额=当日剩余。这样调度器「窗口内即将到期额度多者先用」的一级指标恒把 CodeArts(1000 万量级)排在其它渠道之前——只要它还有额度就先走它,用尽(`remaining=0`,`expiry_ladder` 为空 → 指标归 0,健康度也归 0)则自然回落其它渠道。副作用:CodeArts 阶梯是 **token**、其余渠道是积分,跨渠道比较的是原始数值,量级差使 CodeArts 实际长期占据优先;这正是「每日池先用」的预期行为,管理台展示层用 `quotaUnit()` 把单位标成 token 而非积分。
666666

667667
**倍率(`credit_rate`)**:内置模型 `GET {snap}/v1/model/builtin` 的每个条目带 `credit[]`,其中 `ratio_display`(如 `"0.7x"`、`"0.32x"`)是官方对外展示的消耗倍率,取首档作为本渠道 `credit_rate`(`_parse_ratio` 容忍 `0.7x`/`0.7`/`0.7` 三种写法)。**福利模型不给倍率**(`credit_rate=None`):它走每日免费 token 池、上游不返回该字段,标 `0.0` 会被前端渲染成 zen/kilo 式的「免费」,而它实际消耗每日额度、用尽即不可用。
668668

669+
**单请求扣池(`credit`,2026-10-01)**:福利模型虽然不给倍率,但**确实消耗每日池**,故单请求用量不能留空。上游 usage 只给 token 数、不带额度字段,而福利模型实测按每日 token 池 **1:1** 扣减(`credit_events` 反解:一条输入 32 + 输出 694 = 726 token 的请求,池余额恰好 −726),故 `_fill_estimated_credit` 在流式事件上把 `credit` 补成「输入 + 输出 token」并标 `credit_estimated`(统计页加 ≈)。**只补福利模型**:内置模型不扣这条每日池(它走 `credit[]` 的付费倍率),补了会把 token 数误当池消耗。因此同一渠道内 `credit` 的字段语义随模型分档——福利=token 池消耗、内置=上游真值(若返回)。上游将来直接回传 `credit` 时不覆盖。
670+
669671
**登录**:OAuth2 PKCE → `POST {snap-manager}/v1/oauth2/tokens`(authorization_code)换 `{access_key_id, secret_access_key, security_token, expiration, refresh_token}`,DPoP 私钥随 credential 一起生成并加密入库。**门户把授权码 302 回 `http://127.0.0.1:{port}/oauth/callback`——这是用户本机地址,服务端监听不到**;因此本渠道不用 poll 轨道,而是「paste 轨道」:前端展示授权页后,让用户把浏览器地址栏里那条打不开的回调链接粘回,走 `POST /api/auth/upstream/complete` 由服务端用 code + 登录时登记的 PKCE `code_verifier`/DPoP 私钥换 token。(上游另有 `GET {snap-manager}/v1/login/ticket` 兜底轮询通道,但服务端取到时被回「无效 ticketId」,故不采用。)
670672

671673
**节点白名单**:`CODEARTS_ALLOWED_ENDPOINTS` 含 snap 引擎、STS、福利网关、门户四个主机;AK/SK 签名请求只发往白名单。`CODEARTS_CHAT_MIN_INTERVAL`(热更项,默认 5s)独立 pacer。
672674

675+
**并发上限(2026-10-01,`CODEARTS_MAX_CONCURRENCY`)**:上游对**每账号并发会话数**有硬限(实测 3),超出的请求直接 `HTTP 400` + `TM.00001041 并发会话数已达上限(3个)`。此前聊天 pacer 声明 `allow_concurrent=True` 但**无上限**(桶内来多少放多少),第 4 个起全部撞 400;又因 `classify_status` 只在 429 分支查 `00001041`,这些 400 被判成 `INVALID`(换号也没用、且不冷却),与客户端重试叠成风暴——2026-09-30 23:17 至 10-01 07:40 共 135 次,整体失败率约 77%。修复两处:`Pacer` 增按桶在途上限 `max_concurrency`(满则挂起等 `release` 让位;0/None 保持旧行为),CodeArts pacer 装配 `lambda: runtime.codearts_max_concurrency`(热更项,默认 3);`classify_status` 把 400/429 带并发/限流标记(`00001041`/`tpm`/`并发`/`rate limit`/`throttl`)统一判成 `MODEL`(可重试 503),与流内 `classify_error_code('TM.00001041')` 一致。
676+
673677
---
674678

675679
## 4. Provider 协议(Q16=A 细接口)
@@ -906,6 +910,7 @@ class Scheduler:
906910
| `upstream_rejected` | 其他 4xx | 检查账号状态 |
907911
| `upstream_response_invalid` | 响应结构不符 | 可能是官方接口变更 |
908912
| `upstream_timeout` | 请求超时 | 重试 |
913+
| `network_unreachable` | 连不上渠道服务器(DNS / 连接被拒 / 建连超时 / TLS 中断) | 检查本机网络或代理,确认渠道域名可达 |
909914
| `unknown_error` | 未归类 | 查 `detail` |
910915

911916
`detail` 保留原始错误摘要**仅供排查**,界面不得把它当作主提示展示。

‎docker-compose.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ services:
7272
# Qoder / CodeArts 聊天最小间隔(秒):真实账号渠道,独立节流器
7373
QODER_CHAT_MIN_INTERVAL: ${QODER_CHAT_MIN_INTERVAL:-5}
7474
CODEARTS_CHAT_MIN_INTERVAL: ${CODEARTS_CHAT_MIN_INTERVAL:-5}
75+
# CodeArts 每账号在途并发上限(上游硬限会话数 3);0 关闭上限
76+
CODEARTS_MAX_CONCURRENCY: ${CODEARTS_MAX_CONCURRENCY:-3}
7577
# 11128 内容风控自愈:中和出站正文里的伪装客户端指纹,false 关闭
7678
CODEBUDDY_SANITIZE_CHANNEL_MARKERS: ${CODEBUDDY_SANITIZE_CHANNEL_MARKERS:-true}
7779
# token 预刷新窗口(小时)

0 commit comments

Comments
 (0)