Skip to content

Commit 38728ae

Browse files
committed
fix(executor): 上游流提前结束时同步归还节流名额
`max_concurrency` 的名额在 provider `stream_chat` 的 `finally` 里归还,而 `async for ... break` 不关闭 async generator——CPython 只在耗尽 / 显式 `aclose()` / GC 的 asyncgen finalizer 时才跑 `finally`。 executor `_stream_loop` 遇 `EventKind.ERROR` 的两处 `break` 于是让名额推迟 归还;轮换重试每次重新 `wait_turn`,`_inflight` 单调累积,满 3 后新请求在 `wait_turn` 无限阻塞——表现为「用了三次就限制」而非「并发三」。 这推翻了 Q45 的原始判断(`max_concurrency=0` 时泄漏只是让节流变松,配上 上限后泄漏即永久丢失许可)。修复:新增 `provider.base.aclose_stream`, 在 `stream_guarded` / `stream` / `_stream_loop` / `complete` / `ContinuationStream.__aiter__` 五处提前结束消费点显式关闭上游流。 顺带修掉客户端断开路径的同类泄漏——此前生产环境靠 GC 及时回收侥幸未 暴露,换任何非 `with_keepalive` 的调用方就会攒满后永久卡死。 回归测试 tests/test_stream_slot_release.py(10 例,回退源码后 6 例失败)。
1 parent 6a3d02c commit 38728ae

9 files changed

Lines changed: 418 additions & 41 deletions

File tree

‎PROPOSAL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
| Q42 | TRAE tool_call 分片续块保留(issue #1 死循环根因) | TRAE 上游工具调用是**按 `index` 的分片流**:每个 index 首片带 `function_call.name`,后续片**只有 `arguments` 增量、没有 name**。`_normalize_solo_tool_call` 原按「无 name 即丢弃」过滤,把续片全部吃掉 → 客户端按 index 拼出**截断的参数 JSON** → 工具执行报错 → 原样重试同一轮 → 死循环(WorkBuddy / Cherry Studio 都复现)。改为与 CodeBuddy/Zen 同一条规则:**只丢「无 name 且 arguments 为空」的噪声**(`_is_blank_solo_tool_call`:name 空,且 arguments 为 `None`、空串、空 JSON 串或空对象),带实际 arguments 的续片保留并归一为 OpenAI `function{arguments}` 形状。此前 32d8cc8 的「空名噪声过滤」误伤续片,本轮把判据收敛到「空名且空参」。无 schema / 配置变更 |
5151
| Q43 | macOS 部署模板去本地路径(占位符 + 安装脚本渲染) | `deploy/launchd/com.coding2api.plist` 与 `deploy/newsyslog/coding2api.conf` 原把开发机家目录绝对路径与用户名(`<user>:staff` 属主)写死在仓库里——克隆到别处不可用,还泄露本机目录结构。launchd 与 newsyslog **都不展开 `$HOME` / 环境变量**,路径必须写死,无法像 systemd 模板那样用约定路径,所以改成模板占位符 `__PROJECT_ROOT__`(路径)+ `__LOG_OWNER__`(属主),由 `scripts/install-launchd.sh`(新增,渲染 plist → `~/Library/LaunchAgents` → bootout/bootstrap,bootout 异步需重试)与 `scripts/install-newsyslog.sh`(改为渲染后写入 `/etc/newsyslog.d/`)在安装时替换成本机实际值。`test_deployment_assets.py` 锁三条不变量:全仓库无个人家目录路径、模板含占位符、安装脚本渲染占位符;newsyslog 与 launchd 模板的路径一致性改为**模板对模板**比对(渲染后仍校验本机已装 plist)。systemd / logrotate 的 `/opt/coding2api`、`/var/log/coding2api` 是约定部署路径非个人路径,保留不动。无 schema / 配置变更 |
5252
| Q44 | Zen 独立聊天节流(不再与 CB/TRAE 共享 pacer) | 原先 zen 的 `ZenProvider(pacer=chat_pacer)` 与 CodeBuddy/TRAE 共用同一个全局 pacer(`codebuddy_chat_min_interval`,默认 5s,min=max=5 → 固定 5s)。该 pacer 的存在理由是避开 CB 11128 / TRAE 流内错误的**账号级频率风控**,而 zen 是匿名免费层、无账号、无此类约束。共享的后果是**自伤式延迟**:任何 CB/TRAE 请求刚发出,紧随的 zen 请求就要在 pacer 里空等满 5s 才打上游;单一用户连发或 IDE 并发多个 zen 请求时,第 2、3 个请求 TTFB 实测 +5s、+10s(并发 3 个 zen:9.2s / 13.5s / 18.1s,去掉节流后应基本齐平)。实测确认**不是网络问题**:首 token 直连与走本机代理(127.0.0.1:7897)互有胜负、无稳定收益(`GET /models` 直连 0.29s vs 代理 0.60s;chat TTFB 直连 ≈ 代理),故不引入代理。改为 zen 用独立 `Pacer`,新增热更项 `ZEN_CHAT_MIN_INTERVAL`(默认 **0** = 不节流);仍保留可调旋钮,若上游日后对匿名层限流可调大。CB/TRAE 继续共享原 pacer,互不影响。无 schema 变更 |
53-
| Q45 | 聊天节流按凭证分桶并允许桶内并发(同渠道同模型并发不再串行台阶) | Q44 给 zen 拆了独立 pacer 后,CB/TRAE 的 `chat_pacer` 仍是**一把全局 `asyncio.Lock` + 单个 `_last_started`**:任何两个请求(哪怕不同账号、不同模型)都串行排队,后到者按 `interval - elapsed` 补足等待。实测 3 个并发 CB 请求 TTFB ≈ 1.55 / 6.71 / 11.47s(正好 +5s、+10s 台阶);把间隔热更为 0 后 6 并发 TTFB ≈ 1.48–1.84s、总 1.84s → 延迟完全来自节流排队而非上游。**关键**:并发请求常被会话粘性/健康度排序收敛到**同一个凭证**(DB 里 6 条并发全部命中 `cred_75e8edcf`),所以只按凭证分桶、桶内继续排队并不能解决,必须同时允许桶内并发。改为 `Pacer(min, max, *, allow_concurrent=False)`:`allow_concurrent=True`(仅聊天 pacer)时按桶(渠道前缀 + 凭证身份摘要)维护**在途计数**——同桶已有在途请求则新请求**立即放行**,只有桶空闲、且距上次请求开始不足最小间隔时才补足等待(即只有「上一请求已结束、紧接着又来一个」的顺序连发才节流)。请求结束由 provider `stream_chat` 的 `finally` 调 `pacer.release(key)` 归还名额(async generator 被提前关闭时依赖 asyncio 的 asyncgen finalize,延迟归还只会让节流略松、不会误排队)。`allow_concurrent=False`(后台任务 pacer)保持原严格串行语义不变。桶键用 `stable_key(provider, identity)`:CB 取 `account_uid or user_id or bearer_token`、TRAE 取 `uid or access_token`、zen 用渠道常量;`identity` 缺失回落该渠道单桶。CB/TRAE 仍共享同一 pacer 实例,但桶键带渠道前缀 + 身份摘要,彼此不互堵;`CODEBUDDY_CHAT_MIN_INTERVAL` 语义从「跨渠道全局间隔」变为「同渠道同凭证的顺序连发间隔」(默认 5s 不变)。无 schema 变更 |
53+
| Q45 | 聊天节流按凭证分桶并允许桶内并发(同渠道同模型并发不再串行台阶) | Q44 给 zen 拆了独立 pacer 后,CB/TRAE 的 `chat_pacer` 仍是**一把全局 `asyncio.Lock` + 单个 `_last_started`**:任何两个请求(哪怕不同账号、不同模型)都串行排队,后到者按 `interval - elapsed` 补足等待。实测 3 个并发 CB 请求 TTFB ≈ 1.55 / 6.71 / 11.47s(正好 +5s、+10s 台阶);把间隔热更为 0 后 6 并发 TTFB ≈ 1.48–1.84s、总 1.84s → 延迟完全来自节流排队而非上游。**关键**:并发请求常被会话粘性/健康度排序收敛到**同一个凭证**(DB 里 6 条并发全部命中 `cred_75e8edcf`),所以只按凭证分桶、桶内继续排队并不能解决,必须同时允许桶内并发。改为 `Pacer(min, max, *, allow_concurrent=False)`:`allow_concurrent=True`(仅聊天 pacer)时按桶(渠道前缀 + 凭证身份摘要)维护**在途计数**——同桶已有在途请求则新请求**立即放行**,只有桶空闲、且距上次请求开始不足最小间隔时才补足等待(即只有「上一请求已结束、紧接着又来一个」的顺序连发才节流)。请求结束由 provider `stream_chat` 的 `finally` 调 `pacer.release(key)` 归还名额。**注意**:此处原写「async generator 被提前关闭时依赖 asyncio 的 asyncgen finalize,延迟归还只会让节流略松、不会误排队」——该结论在后来叠加 `max_concurrency`(见 Q48)后失效:`break` 不关闭 async generator,名额会推迟到 GC 才归还甚至永久丢失,桶停在满载使新请求在 `wait_turn` 无限阻塞(表现为「用了三次就限制」而非「并发三」)。现由 executor/continuation 在所有提前结束消费处显式 `aclose_stream`。`allow_concurrent=False`(后台任务 pacer)保持原严格串行语义不变。桶键用 `stable_key(provider, identity)`:CB 取 `account_uid or user_id or bearer_token`、TRAE 取 `uid or access_token`、zen 用渠道常量;`identity` 缺失回落该渠道单桶。CB/TRAE 仍共享同一 pacer 实例,但桶键带渠道前缀 + 身份摘要,彼此不互堵;`CODEBUDDY_CHAT_MIN_INTERVAL` 语义从「跨渠道全局间隔」变为「同渠道同凭证的顺序连发间隔」(默认 5s 不变)。无 schema 变更 |
5454
| Q46 | Kilo Gateway 免费层(第四渠道 `kilo`) | 把 [Kilo Gateway](https://kilo.ai)(`api.kilo.ai/api/gateway`)免费层接成第四个 provider(`KNOWN_PROVIDERS` 加 `"kilo"`,**无 schema 变更**)。**协议是标准 OpenAI 兼容**(`/chat/completions` + `/models`),既无私有信封也无门禁伪装——与 Zen 的关键差异正在此:Zen 要伪造 UA/session/tools 并过滤伪工具调用,Kilo 完全不需要。**免费模型有权威标记**:`/models` 每个条目带 `isFree` 布尔(实测 2026-09-29 共 395 个模型、17 个 `isFree=true`,含 `kilo-auto/free`、`stealth/space-bunny-alpha`、`openrouter/free` 等无 `:free` 后缀者),据此**直接过滤**免费集——**不做探活**(与 Zen 相反):探活会真发一次推理、白耗本就极小的免费配额(网关级约 200 req/h/IP),且结果随上游免费池波动不稳定,`isFree` 已足够权威。免费模型显式标 **x0 倍率**(`credit_rate=0.0`);`name`/`context_length`/`top_provider.max_completion_tokens`/`supported_parameters`(含 `tools`)/`architecture.input_modalities`(含 `image`)透传为中立 `Model` 元数据。思考字段是 **`delta.reasoning`**(**不是** Zen 的 `reasoning_content`)。**凭证模型用虚拟凭证行**(同 Zen):Kilo 无凭证/无额度接口,池里种一条空凭证复用现有调度/冷却/统计(`probe_quota` 恒 `probe_failed=True` → health NULL「未知」,**不是耗尽**);删除后重启复活,也可在凭证页「登录渠道账号」点「添加 Kilo Gateway」立即补回,永久停用请用「暂停」。**错误分类**:401→`INVALID`(无凭证,401 只表示该模型需要付费 key/BYOK,避免强制付费模型硬禁用整条渠道)、400/404/422→`INVALID`、403→`REQUEST`、**429 与 502/503/504→`MODEL`(模型级冷却)**。429 与上游 5xx 归模型级而非账号级:实测(2026-09-30)429 报错点名具体模型(`<model> is temporarily rate-limited upstream`,`limit_source: upstream_provider_shared_pool`),429 消退后同一模型转 503 `no endpoints available`,两种情况下**同一时刻其他免费模型仍 200**——免费池实为 OpenRouter 共享池转发,拥塞/端点缺失按模型隔离,归账号级会因单模型问题把整条 kilo 渠道冷却(429→60s;5xx 累计 3 次→10m;单虚拟凭证下均即 `all credentials unavailable`)。对齐 CB/TRAE 的 `429+6004 → MODEL` 口径。限流交引擎处理,**本包不自建熔断**。新增 `KILO_API_ENDPOINT` / `KILO_ALLOWED_ENDPOINTS`(端点白名单,Kilo 不带真实 Token)/ `KILO_CHAT_MIN_INTERVAL`(热更项,独立 pacer、默认 0 = 不节流,与 zen / CB / TRAE 互不排队) |
5555
| Q47 | Qoder(阿里,第五渠道 `qoder`) | 把 [Qoder](https://qoder.com) 接成第五个 provider(`KNOWN_PROVIDERS` 加 `"qoder"`,**无 schema 变更**)。**真实账号渠道**(区别于 zen/kilo 的匿名免费层),走设备码 PKCE 登录,凭证入加密列。协议为私有 COSY:推理 `POST {gateway}/algo/api/v2/service/pro/sse/agent_chat_generation`,body 用**自定义 Base64 变体**编码(三段轮转 + 自定义字母表 + `=`→`$`),头为整套 `cosy-*`,`Authorization: Bearer COSY.<payload_b64>.<md5sig>`,`x-model-key` 路由;签名为 `md5(payload_b64 \n cosy_key \n date \n body \n path)`(`path` 去 `/algo` 前缀,payload 为键排序紧凑 JSON),`cosy_key`/`info` 由临时 AES 密钥经服务端 RSA 公钥加密而来。响应是**信封式 SSE**(`data:{"headers":…,"body":"<内层 chunk>","statusCodeValue":200}`,`body=="[DONE]"` 结束,非 200 判上游错误)。模型发现 `GET {gateway}/algo/api/v2/model/list?Encode=1`(**必须带整套 COSY 签名头**,签名 body 为 `qoder_encode("")`;裸 GET 403、带头 POST/PUT 400,故固定 GET)。额度 `GET {openapi}/api/v2/quota/usage`(`userQuota`+`addOnQuota`),套餐 `/api/v2/user/plan`。签到 `/sash/api/v1/me/daily-check-in/{status,claim}`(409/`ALREADY_CLAIMED` → 已签;**国际版该端点 404 → 视为本区域无此接口,不算错误**)。域:CN `openapi.qoder.com.cn`/`gateway.qoder.com.cn`;Intl `openapi.qoder.sh`/`api1.qoder.sh`。密码学复用项目已有 `cryptography`(不移植参考仓库的手写纯 Python 实现)。新增 `QODER_API_ENDPOINT`(openapi)/`QODER_ALLOWED_ENDPOINTS`(含国内 openapi+gateway 与国际版)/`QODER_CHAT_MIN_INTERVAL`(热更项,独立 pacer、默认 5s) |
5656
| Q48 | CodeArts(华为云码道,第六渠道 `codearts`) | 把 [华为云 CodeArts](https://codearts.huaweicloud.com) 的盘古引擎接成第六个 provider(`KNOWN_PROVIDERS` 加 `"codearts"`,**无 schema 变更**)。**真实账号渠道**,走 OAuth2 PKCE 登录换 STS,凭证入加密列。推理 `POST /api/v2/chat/completions`(福利模型追加头 `maas_type: benefit`);鉴权为华为云 **`SDK-HMAC-SHA256`**(AK/SK + `X-Security-Token`,signedHeaders=请求全部头小写排序,CanonicalURI 每段 encode 且**末尾补 `/`**,payload hash 取 `X-Sdk-Content-Sha256`)。**令牌刷新与 `client_id=codearts-agent` + DPoP 私钥三者绑定、一次性**:`POST {sts}/v1/oauth2/tokens` `grant_type=refresh_token` + **DPoP(ES256/P-256)**,刷后**必须回写新 `refresh_token`**(DPoP 低 S 归一化用 `cryptography` 实现,不移植 Go/手写 ECDSA)。模型:内置 `GET {snap}/v1/model/builtin`(头 `Agent-Type: PromptCenter`)+ 福利 `GET {opengw}/api/v1/gateway/config`;领取 `POST {opengw}/api/v1/benefit/claim`(幂等,启动/定时保活);余额 `GET {opengw}/api/v1/user/tokens/balance`。SSE 为**逐行 `data:` JSON**(v2 实测为标准 OpenAI chunk:`choices[].delta` + `data:[DONE]`;旧形状/legacy 才是累计全文 `text`,用快照做差),错误 `error_code` 形如 `ChatAgent.*`。**CodeArts 没有每日签到接口**(额度为每日 1000 万免费 token、当日 0 点清零、用完即弃),故不实现 `checkin`,签到语义由 token 自动 refresh 续期承担(且只由 `RefreshTask` 独占轮转,一次性 `refresh_token` 不得被额度探测等旁路顺手消费);当日剩余登记为「次日本地 0 点到期」的 `expiry_ladder`,调度器据此优先消耗(用尽自动回落)。**福利模型单请求扣池计费**:福利模型虽不给倍率(`credit_rate=None`),但实测按每日池 1:1 扣减(输入 32 + 输出 694 = 726 token → 池余额 −726),故上游 usage 缺额度字段时按「输入 + 输出 token」补 `credit` 并标 `credit_estimated`(统计页加 ≈;**2026-10-01 起该值再折成积分**,见 Q52);内置模型不扣该池、不补。**并发上限**:上游硬限每账号并发会话数 3,超出即 `400 TM.00001041`,故 CodeArts pacer 在 `allow_concurrent` 之上加在途上限(`CODEARTS_MAX_CONCURRENCY`,热更、默认 3;`classify_status` 把带限流标记的 400/429 统一判成可重试的 `MODEL`)。新增 `CODEARTS_API_ENDPOINT` / `CODEARTS_ALLOWED_ENDPOINTS`(snap 引擎 + STS + 福利网关 + 门户)/ `CODEARTS_CHAT_MIN_INTERVAL`(热更项,独立 pacer、默认 5s)/ `CODEARTS_MAX_CONCURRENCY`(热更项,默认 3) |

‎README.md‎

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

‎TECHNICAL.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,8 @@ UA 版本走 `ZEN_OPENCODE_VERSION` 配置(上游改阈值改 env,不硬编
705705

706706
**并发上限(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 带并发/限流标记判成可重试的限流(与流内 `classify_error_code` 同判法;后续拆成两种类型,见本段末尾):并发打满(`00001041`/`并发`)判 `CONCURRENCY`(固定 60s 模型级短冷却,不翻倍),其余(`tpm`/`429`/`rate limit`/`throttl`)判 `MODEL`。背景:打满是瞬态信号(在途排空即恢复,实测平均 9s、最大 38s),套 `MODEL` 的 600s 起步、翻倍到 2h 会把一次瞬态打满变成 10min 起的长时间不可用。
707707

708+
**名额泄漏修复(2026-10-02)**:上一条引入的 `max_concurrency` 依赖 `release` 与 `wait_turn` 严格配对,而 `release` 在 provider `stream_chat` 的 `finally` 里——`async for ... break` **不关闭** async generator(CPython 只在耗尽 / 显式 `aclose()` / GC 的 asyncgen finalizer 时才跑 `finally`)。executor `_stream_loop` 遇 `EventKind.ERROR` 的两处 `break` 于是让名额推迟归还;轮换重试每次重新 `wait_turn`,`_inflight` 单调累积,满 3 后新请求在 `wait_turn` 无限阻塞——表现为**「用了三次就限制」而非「并发三」**(复现:3 次流内错误后 `inflight=3`,第 4 个请求永久阻塞)。注意这推翻了 Q45 的原始判断(「延迟归还只会让节流略松、不会误排队」):`max_concurrency=0` 时泄漏确实只是变松,配上上限后泄漏即**永久丢失许可**。修复:`provider.base.aclose_stream` 统一关闭,`stream_guarded` / `stream` / `_stream_loop` / `complete` / `ContinuationStream.__aiter__` 五处提前结束消费处全部显式关闭上游流。回归测试 `tests/test_stream_slot_release.py`(10 例,旧代码上 6 例失败)。
709+
708710
---
709711

710712
## 4. Provider 协议(Q16=A 细接口)

0 commit comments

Comments
 (0)