From c6dfe0b8458ebd1c7d707affb639bc39250c5797 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Fri, 23 Oct 2026 17:07:44 +0800 Subject: [PATCH 001/153] =?UTF-8?q?fix(web):=20=E9=94=81=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=8A=8A=20source-map-js=20=E6=8F=90=E5=88=B0=201.2.2=EF=BC=88?= =?UTF-8?q?GHSA-68fv-2mgg-jv7q=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @tailwindcss/node 声明 source-map-js ^1.2.1,lock 里冻结在 1.2.1, 受 2026-09-18 公布的 DoS 通告影响(event-loop DoS via indexed source-map section offsets),1.2.2 已修。`pnpm update source-map-js` 去重到 1.2.2, 无其他包版本漂移。 该路径属 devDependency,CI 的 `pnpm audit --prod` 不覆盖,故不会自动 被发现;`pnpm audit` 现仅剩 braces(shadcn→fast-glob,上游暂无补丁, 仅 CLI 本地 glob 可达)。 --- web/pnpm-lock.yaml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7bf29788..3f286f8b 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -2915,10 +2915,6 @@ packages: resolution: {integrity: sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - source-map-js@1.2.2: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} @@ -4469,7 +4465,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 - source-map-js: 1.2.1 + source-map-js: 1.2.2 tailwindcss: 4.3.3 '@tailwindcss/oxide-android-arm64@4.3.3': @@ -6062,8 +6058,6 @@ snapshots: ip-address: 10.7.4 smart-buffer: 4.2.0 - source-map-js@1.2.1: {} - source-map-js@1.2.2: {} source-map@0.6.1: {} From 6e6fa3037fb0908e98abb6a58334b13af951c95c Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Fri, 23 Oct 2026 17:10:37 +0800 Subject: [PATCH 002/153] =?UTF-8?q?fix(qoder):=20content=20=E4=B8=BA=20nul?= =?UTF-8?q?l=20=E7=9A=84=E6=B6=88=E6=81=AF=E5=87=BA=E7=AB=99=E5=89=8D?= =?UTF-8?q?=E5=BD=92=E4=B8=80=E4=B8=BA=E7=A9=BA=E4=B8=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qoder 上游把带 tool_calls、`content: null` 的 assistant 当「没有正文」跳过, 其后的 `role: "tool"` 于是成了孤儿,报文却报 `Messages with role 'tool' must be a response to a preceding message with 'tool_calls'`,把根因指向 tool 配对。 实测(2026-10-23):同一请求体只把 null 换成 "" 即 400 → 200,与 reasoning_content / 并行调用 / 轮数 / 上下文长度无关;qoder 敏感, codebuddy_intl / trae / officeace 不敏感。`build_chat_body` 出站前归一 assistant/tool 的 null content 并记改写条数,TECHNICAL.md 补坑位说明。 --- TECHNICAL.md | 2 ++ src/provider/qoder/client.py | 32 ++++++++++++++++++ tests/provider/qoder/test_client.py | 52 +++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/TECHNICAL.md b/TECHNICAL.md index 460d7847..37a13d67 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -289,6 +289,8 @@ Kilo(`api.kilo.ai/api/gateway`)对外是**标准 OpenAI 兼容协议**,既 **签到(活动制)**:`GET /sash/api/v1/me/campaigns`(**必须带 `Cosy-ClientType: 10`**,否则上游静默返回 `campaigns:[]`)→ 筛 `actionType=="CLAIM_BENEFIT"` 且 `claimStatus=="CLAIMABLE"`(`VIEW_DETAILS` 等展示位也是 `CLAIMABLE`,不筛 actionType 会领错活动)→ `POST /sash/api/v1/me/campaigns/{campaignId}/claim`(同样带 `Cosy-ClientType: 10`)。claim 响应 `replayed:true` 是幂等重放、`status=="BLOCKED" && failureCode=="SAME_PERSON_ALREADY_CLAIMED"` 是同一自然人已领,两者都归一为 `already_checked_in=True`;其它 `BLOCKED` 才是真失败。**新协议不提供连续天数**,`streak_days` 恒为 `None`。仅当活动制接口本身 404/405/410 时才整体回退旧的 `daily-check-in` 流程;活动制与旧接口都不可用才算「本区域无此接口」,记为 skipped(`ok=False` 但不算失败)——**国际版该端点 404**。**签到窗口 ≠ 自然日,故按窗口封账**:上游签到活动每日 **10:00(UTC+8)** 才轮换 `campaignId`,当前窗口 `startAt 10:00 → endAt 次日 09:59`。按自然日封账会在凌晨把上一轮窗口的 `CLAIMED` 判成「今日已签到」而封掉整天,故 `CheckinResult.seal_until`(epoch 秒,缺省 None = 当天结束)由 Qoder 的活动制路径回填 `checkin_window_end(payload)` = 签到类活动里**最晚**的 `endAt`(展示位 `VIEW_DETAILS` 的 `endAt` 不采信,缺失/非数值/0 → None 回落原口径)。`CheckinTask._done_scopes` 为 `dict[done_key → 截止时刻]`,跳过判据 `_sealed()`(`until > now`);旧 `daily-check-in` 回退路径与其它渠道仍走自然日封账。**额度**:`GET {openapi}/api/v2/quota/usage` 的 `userQuota` + `addOnQuota` 合成 `remaining`/`total`,`cycle_end` 取最早 `expiresAt`。 **上游节点故障归类**:Qoder 把**自身推理节点故障**包成 400——`{"code":"400","message":"[FAIL]node:… msg:Execution failed: null"}`(HTTP 与信封 `statusCodeValue` 都是 400),而同批其他模型正常。这与「模型不存在 / 请求无效」的 400 语义不同:换凭证不解决但会自愈。故 `classify_error_code` 对 400/404/422 增加响应体判据:命中 `NODE_FAILURE_MARKERS = ("[FAIL]node:", "Execution failed")` → `ErrKind.MODEL`(模型级瞬时冷却),否则维持 `INVALID`。安全前提:探测未知模型名(乱码/空串)上游既不返回 ERROR 也不带该标记,故不会误伤。耗尽轮换且**全部候选都因该模型处于模型级冷却**时,503 文案用 `model 'x' temporarily unavailable on upstream`(可附相近模型建议)而非 `all credentials unavailable`。**节点白名单**:`QODER_ALLOWED_ENDPOINTS` 同时含国内 openapi+gateway 与国际版;`_qoder_endpoint` 启动时校验。`QODER_CHAT_MIN_INTERVAL`(热更项,默认 5s)走独立 pacer。 +**`content: null` 必须归一为空串**:Qoder 拒收带 `tool_calls` 的 assistant 把 `content` 置为 `null`(OpenAI 允许)。上游把这条消息当「没有正文」跳过,紧随其后的 `role:"tool"` 就成了孤儿,报文却是 `Messages with role 'tool' must be a response to a preceding message with 'tool_calls'` —— 指向 tool 配对,真实原因在 assistant 的 null content。出站前 `build_chat_body` 的 `_normalize_null_content` 把 assistant/tool 的 `null` 改成 `""`(语义等价)。2026-10-23 实测:同一请求体只把 `null` 换成 `""` 即 400 → 200,与 `reasoning_content` / 并行调用 / 轮数无关;codebuddy_intl、trae、officeace 不敏感。 + ### 5.4 CodeArts(`codearts`) **华为云签名体系 + 私有 SSE 帧**。**SDK-HMAC-SHA256 签名**:鉴权用 AK/SK + `X-Security-Token`,`Authorization: SDK-HMAC-SHA256 Access=, SignedHeaders=…, Signature=…`。三处必须逐字节对齐:signedHeaders 是**请求全部头**(小写、字典序);CanonicalURI **每段单独 percent-encode 且末尾补 `/`**(漏尾斜杠是 APIG.0301 的常见原因);payload hash 取 `X-Sdk-Content-Sha256`。`signer.py` 写成纯函数(不含「当前时间」)以便固定向量测试。**DPoP 令牌刷新(刚性约束)**:`POST {sts}/v1/oauth2/tokens` `grant_type=refresh_token` 需带 **DPoP(ES256/P-256) JWS**。refresh_token 与 `client_id=codearts-agent` + DPoP 私钥**三者绑定、一次性**——刷新成功必须把新 `refresh_token` 回写凭证(`RefreshTask` 先落库再同步账号)。ES256 用 `cryptography`(`ec.SECP256R1` + `ECDSA(SHA256)`)并**低 S 归一化**(`s > n/2` 时取 `n - s`)——华为 STS 校验要求低 S,而 `cryptography` 不保证(实测 2000 次签名有 991 次 high-S)。私钥必须随 refresh_token 一起持久化并原样复用(STS 把 refresh_token 绑定在首次换取时的 DPoP 公钥上)。**刷新节流**:`REFRESH_SKEW_HOURS` 默认 24h ≫ STS 寿命 2h,而 `needs_refresh` 语义是「离到期还剩 skew 秒就刷」,故给 `CodeArtsCredential` 加 `refresh_skew_cap_seconds = 2700`,`needs_refresh` 取 `min(skew, cap)`:把「一进窗口每轮都刷」降成「离到期 45min 内才刷」。**窗口必须宽于轮询周期**(`REFRESH_INTERVAL_MINUTES`,默认 30min):窗口 ≤ 周期时轮询点整轮落在窗口之外,凭证拖到到期才刷(实测封顶 30min、周期 60min 时在距到期约 26s 才刷新,上游回 `APIG.0602`)。封顶 45min > 30min 周期留余量;轮询粒度下实际在**剩余约 30min** 处刷新。 diff --git a/src/provider/qoder/client.py b/src/provider/qoder/client.py index c8592027..f7875a1d 100644 --- a/src/provider/qoder/client.py +++ b/src/provider/qoder/client.py @@ -128,6 +128,31 @@ def _openapi_request_id(uid: str) -> str: return derive_request_id(uid) +def _normalize_null_content(messages: list[dict[str, Any]]) -> int: + """把 `content: null` 归一为空串(返回改写条数)。 + + OpenAI 允许「带 tool_calls 的 assistant 把 content 置为 null」,但 Qoder + 上游不接受:它把这条消息当成「没有内容」而跳过,紧随其后的 `role:"tool"` + 就成了孤儿,于是以 400 `Messages with role 'tool' must be a response to a + preceding message with 'tool_calls'` 拒绝——报文指向 tool 配对,真实原因 + 却是 assistant 的 null content(2026-10-23 实测:同一请求体只把 null 换成 + `""` 即从 400 变 200,且与 reasoning_content / 并行调用 / 轮数无关)。 + + `""` 与 `null` 语义等价(都表示「这条 assistant 没有正文」),对上游是 + 合法形状,所以这里就地归一,不改其他字段。 + """ + rewritten = 0 + for message in messages: + if not isinstance(message, dict): + continue + if message.get("role") not in ("assistant", "tool"): + continue + if message.get("content", "") is None: + message["content"] = "" + rewritten += 1 + return rewritten + + def _drop_orphan_tool_messages( messages: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]: """丢弃没有对应 `assistant.tool_calls` 的 `role:"tool"` 消息。 @@ -186,8 +211,15 @@ def build_chat_body(payload: dict[str, Any], model: str) -> dict[str, Any]: messages = json.loads(json.dumps(raw_messages)) if raw_messages is not None else [] # 非 list 形状(畸形请求)交给上游裁决,不做结构假设 dropped = 0 + rewritten = 0 if isinstance(messages, list): + # 必须先归一 null content:上游把 content:null 的 assistant 当空消息跳过, + # 其后的 tool 结果就成了孤儿并被报成 tool 配对错误(见函数 docstring) + rewritten = _normalize_null_content(messages) messages, dropped = _drop_orphan_tool_messages(messages) + if rewritten: + logger.info("Qoder 请求把 %d 条消息的 null content 归一为空串" + "(上游会把它当空消息跳过、再报 tool 配对 400)", rewritten) if dropped: # 只记条数:正文里可能有用户数据,日志不落消息内容 logger.warning("Qoder 请求含 %d 条孤立 tool 消息,已丢弃(上游会以 400 拒绝)", diff --git a/tests/provider/qoder/test_client.py b/tests/provider/qoder/test_client.py index 91392314..2f2b9c12 100644 --- a/tests/provider/qoder/test_client.py +++ b/tests/provider/qoder/test_client.py @@ -112,6 +112,58 @@ def test_build_chat_body_unions_consecutive_assistant_tool_calls(): body = build_chat_body({"messages": messages}, "m") assert body["messages"] == messages +def test_build_chat_body_normalizes_null_assistant_content(): + """带 tool_calls 的 assistant 的 `content: null` 归一为空串。 + + 上游把 null content 的 assistant 当成空消息跳过,其后的 tool 结果就成了 + 孤儿,并以 400「Messages with role 'tool' must be a response to a preceding + message with 'tool_calls'」拒绝——报文指向 tool 配对,真实原因是 null + content(2026-10-23 实测:只把 null 换成 "" 即从 400 变 200)。 + """ + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": None, "reasoning_content": "想想", + "tool_calls": [{"id": "a", "type": "function", "function": {"name": "f"}}]}, + {"role": "tool", "tool_call_id": "a", "content": "结果"}, + ] + body = build_chat_body({"messages": messages}, "m") + assert body["messages"][1]["content"] == "" + assert body["messages"][1]["reasoning_content"] == "想想" + assert body["messages"][2] == messages[2] + # 入参不被改写(build_chat_body 深拷贝) + assert messages[1]["content"] is None + +def test_build_chat_body_normalizes_null_tool_content(caplog): + """tool 消息的 null content 同样归一,且计入日志条数。""" + messages = [ + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "a", "type": "function", "function": {"name": "f"}}]}, + {"role": "tool", "tool_call_id": "a", "content": None}, + {"role": "assistant", "content": None}, + ] + with caplog.at_level("INFO", logger=qoder_client.__name__): + body = build_chat_body({"messages": messages}, "m") + assert [m["content"] for m in body["messages"]] == ["", "", ""] + assert any("null content" in r.getMessage() and "3" in r.getMessage() + for r in caplog.records) + +def test_build_chat_body_leaves_other_roles_content_untouched(): + """只归一 assistant/tool:user/system 的 null 原样交给上游裁决。""" + messages = [ + {"role": "system", "content": None}, + {"role": "user", "content": None}, + {"role": "assistant", "content": "有正文"}, + {"role": "junk", "content": None}, + ] + body = build_chat_body({"messages": messages}, "m") + assert [m["content"] for m in body["messages"]] == [None, None, "有正文", None] + +def test_normalize_null_content_skips_non_dict_entries(): + """畸形历史(非 dict 消息)跳过,不改写也不计数。""" + messages = ["junk", {"role": "assistant", "content": None}] + assert qoder_client._normalize_null_content(messages) == 1 + assert messages == ["junk", {"role": "assistant", "content": ""}] + def test_build_chat_body_plain_assistant_resets_declared_ids(): """中间夹了一条不带 tool_calls 的 assistant:它打断了配对,其后的 tool 孤立。""" messages = [ From e4b6ab0efae6eaf27cd89ef82dadb25546a7f422 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Fri, 23 Oct 2026 17:13:01 +0800 Subject: [PATCH 003/153] =?UTF-8?q?feat:=20=E6=8E=92=E9=98=9F=E9=87=8D?= =?UTF-8?q?=E5=BC=80=E3=80=81=E9=9D=9E=E6=B5=81=E5=BC=8F=E4=BF=9D=E6=B4=BB?= =?UTF-8?q?=E4=B8=8E=E5=85=BC=E5=AE=B9=E5=B1=82=E4=BF=AE=E8=A1=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 排队信号(Qoder/Trae 的 isQueued / RESOURCE_LIMIT):不冷却凭证、不换号, 等 retryAfterSeconds 后在原号重开;轮次与单轮等待都有上限,已出帧不重开 - 非流式出口保活(NONSTREAM_KEEPALIVE_SECONDS,默认关):宽限期内完成仍是 逐字节相同的 JSONResponse,超时才转 chunked 先吐空白心跳再补正文 - Anthropic 心跳改具名 event: ping;OpenAI 流式工具调用缺 id 时按 index 补 稳定 id(按 id 聚合的客户端不再丢分片) - trae 上下文窗口取 context_window_tokens.max,1M 档不再报 200k - 同步 PROPOSAL / README / TECHNICAL --- PROPOSAL.md | 285 ++++--- README.en.md | 841 ++++++++++++--------- README.md | 882 ++++++++++------------ TECHNICAL.md | 793 ++++++++++--------- docker-compose.yml | 2 + src/api/endpoints.py | 19 +- src/api/streaming.py | 123 ++- src/compat/anthropic/response.py | 9 +- src/compat/openai/response.py | 36 +- src/compat/responses/response.py | 7 +- src/config.py | 8 + src/engine/executor.py | 255 +++++-- src/engine/scheduler.py | 6 + src/provider/base.py | 90 +++ src/provider/qoder/client.py | 11 + src/provider/qoder/events.py | 9 +- src/provider/trae/client.py | 30 +- src/runtime_settings.py | 10 + tests/api/test_nonstream_keepalive.py | 297 ++++++++ tests/compat/test_anthropic.py | 30 + tests/compat/test_anthropic_translate.py | 12 +- tests/compat/test_openai_response.py | 30 + tests/compat/test_responses.py | 24 + tests/compat/test_responses_translate.py | 1 + tests/engine/_fallback_support.py | 30 + tests/engine/test_fallback.py | 26 +- tests/engine/test_fallback_stream.py | 27 +- tests/engine/test_queue_retry.py | 529 +++++++++++++ tests/provider/qoder/test_client.py | 26 + tests/provider/trae/test_client.py | 33 + tests/regression/test_hardening_stream.py | 15 + 31 files changed, 3082 insertions(+), 1414 deletions(-) create mode 100644 tests/api/test_nonstream_keepalive.py create mode 100644 tests/engine/test_queue_retry.py diff --git a/PROPOSAL.md b/PROPOSAL.md index 5a77a9b6..c6b0e213 100644 --- a/PROPOSAL.md +++ b/PROPOSAL.md @@ -1,253 +1,230 @@ # Coding2API 设计文档 -把 **CodeBuddy**(国内站 `codebuddy` + 国际版 `codebuddy_intl`)、**TRAE SOLO**、**OpenCode Zen**、**Kilo**、**Qoder**、**CodeArts**、**OfficeAce** 八个上游渠道封装为 OpenAI 兼容 API,提供公共凭证池、统一模型名与调度、按人用量统计。 +把 **CodeBuddy**(国内 `codebuddy` + 国际 `codebuddy_intl`)、**TRAE SOLO**、**OpenCode Zen**、**Kilo**、**Qoder**、**CodeArts**、**OfficeAce** 八个上游封装为 OpenAI 兼容 API,提供公共凭证池、统一模型名与调度、按人用量统计。 -> 现状文档:只记录仍然成立的目标、上游事实、架构与约束。实现细节见 [TECHNICAL.md](TECHNICAL.md),使用与部署见 [README.md](README.md)。 +> 只记录仍成立的目标、上游事实、架构与约束。实现细节见 [TECHNICAL.md](TECHNICAL.md),使用与部署见 [README.md](README.md)。 ## 1. 目标与非目标 -**目标**:单一 OpenAI 兼容出口后挂八个上游,支持 `模型@渠道` 精确指定;凭证由 admin 集中维护、全员共享,调度器自动挑健康号、上游死亡自动冷却;按人统计用量(请求数 / 成功率 / token / 耗时 / 首字延迟)与估算成本。 +**目标**:单一 OpenAI 兼容出口后挂八个上游,支持 `模型@渠道` 精确指定;凭证由 admin 集中维护、全员共享;调度器自动挑健康号,上游死亡自动冷却;按人统计用量与估算成本。 -**非目标(明确不做)**:**不做配额/限流**(上游是订阅制通道,成本不随 token 线性增长;小规模团队靠统计页可见性约束滥用);**不做通用 provider 网关**(只硬编码上述八个上游,不做插件系统);**不做货币换算**(上游积分单位互不通用,分开记录);不做旧项目数据迁移、不做自更新脚本、不做 mTLS。 +**非目标**:不做配额 / 限流(上游订阅制,成本不随 token 线性增长,靠统计页可见性约束滥用);不做通用 provider 网关(只硬编码这八个);不做货币换算(上游积分互不通用,分开记);不做数据迁移、自更新、mTLS。 ## 2. 上游可行性核实 ### 2.1 CodeBuddy 国内站(`codebuddy`) -- 端点 `https://copilot.tencent.com`;聊天 `POST /v2/chat/completions`,**只支持流式**(非流式由本地聚合);头 `Authorization` + `X-User-Id` + `X-Domain` + `X-Enterprise-Id` + `X-Department-Info`(部门名须 UTF-8 百分号编码)。认证为设备码:`POST /v2/plugin/auth/state?platform=CLI` 拿 `authUrl`/`state`,轮询 `POST /v2/plugin/auth/token?state=...`;账号切换 `/v2/plugin/login/account`、`/v2/plugin/accounts`。 +- 端点 `https://copilot.tencent.com`;聊天 `POST /v2/chat/completions`,**只支持流式**(非流式本地聚合);头 `Authorization` + `X-User-Id` + `X-Domain` + `X-Enterprise-Id` + `X-Department-Info`(部门名须 UTF-8 百分号编码)。设备码认证:`POST /v2/plugin/auth/state?platform=CLI` 拿 `authUrl`/`state`,轮询 `POST /v2/plugin/auth/token?state=`;账号切换 `/v2/plugin/login/account`、`/v2/plugin/accounts`。 - 额度:个人版 `POST /v2/billing/meter/get-user-resource`(`CycleCapacity*Precise`),企业版 `POST /v2/billing/meter/get-enterprise-user-usage`(`credit` 已用、`limitNum` 总额)。签到 `POST /billing/meter/daily-checkin`,状态 `POST /billing/meter/checkin-activity-status`。 -- **成长中心**(前缀 `/v2/activity/growth`):只读 `buddy/travel/status`、`buddy/travel/config`、`tasks`、`streak`、`redeem/summary`、`lottery/chances`、`buddy/quota`、`energy`;写入 `buddy/travel/claim`、`buddy/travel/depart`、`tasks/accept`、`/tasks/{code}/claim`、`makeup-cards/use`、`redeem`、`lottery/draw`、`buddy/open`。契约:`accept_status` 五态、`/tasks/accept` 收复数数组 `{"task_codes": [...]}`(传单数一律 400)、`/redeem` 的 `tier` 是档位标识字符串(`"7d"`/`"14d"`/`"28d"`,传档位名得 400)、实发字段为 `*_granted`;进程内 OAuth bearer 可直连,无需桌面端凭据文件。 -- **凭证身份可能为空**:OAuth 路径下上游未回填 `account_uid`/`user_id`,同账号隔离必须回落到 `credential_id`,否则第二个账号会被静默跳过。**活跃上报**(`POST /v2/report`,默认关闭):body 为事件数组(`eventCode=chat_request_send`),`userId` 必填——缺失时上游 HTTP 200 `code:0` 但静默丢弃;身份为空时回落 bearer JWT 的 `sub`。与积分、调度无关。 -- **reasoning 客户端给什么透传什么**(默认口径),`reasoning_content` 不剥离(实测 71 份真实 dump);`reasoning_effort` 缺失时补 `medium`、显式值不覆盖(不发该字段上游退化为「可见推演」:思考写进 `delta.content`、`reasoning_content` 恒空、`reasoning_tokens` 恒 0),`reasoning_tokens` 在 `completion_tokens_details` 里、顶层恒缺。**可选机制(默认全关,不改默认口径)**:出站 `REASONING_EFFORT_MAP`(按渠道改写客户端显式档位)、Anthropic 入站 `ANTHROPIC_THINKING_EFFORTS`(`thinking.budget_tokens` → 档位)、`FORCE_DISABLE_THINKING`(响应侧剥离思考内容,只过滤响应、不改请求体)。三者默认关 / 留空时行为与上面逐字节一致;内置默认矩阵仍不做(未核实上游接受性),见 TECHNICAL §3.11。**输出上限键名不对称**:CB 上游完全忽略 `max_completion_tokens`,只认 `max_tokens`;TRAE 对两个键都不生效——本网关不做键映射,客户端限额原样透传。 -- 错误码:`6004` 模型用量超限(模型级)、`14003` 模型饱和(模型级,国际版 `Model busy. Please switch models or retry later`)、`11102` 该后端无此模型(负缓存)、`14018` 积分耗尽、`1005` 权益/套餐耗尽、`11101/11115/11128/11135` 请求级(不罚号)。`6004` 与 `14003` 都是「换模型即可用」,必须落模型级;落账号级 SOFT 会让整张凭证的其他模型一起被 60s 冷却。 +- **成长中心**(`/v2/activity/growth`):只读 `buddy/travel/status|config`、`tasks`、`streak`、`redeem/summary`、`lottery/chances`、`buddy/quota`、`energy`;写入 `buddy/travel/claim|depart`、`tasks/accept`、`/tasks/{code}/claim`、`makeup-cards/use`、`redeem`、`lottery/draw`、`buddy/open`。契约:`accept_status` 五态、`/tasks/accept` 收复数数组、`/redeem` 的 `tier` 是档位标识(`"7d"`/`"14d"`/`"28d"`)、实发字段为 `*_granted`;进程内 OAuth bearer 可直连。 +- **凭证身份可能为空**:OAuth 路径下上游未回填 `account_uid`/`user_id`,同账号隔离必须回落 `credential_id`。**活跃上报**(`POST /v2/report`,默认关):body 为事件数组(`eventCode=chat_request_send`),`userId` 必填——缺失时 HTTP 200 `code:0` 但静默丢弃;身份为空时回落 JWT `sub`。 +- **reasoning 客户端给什么透传什么**;`reasoning_effort` 缺失时补 `medium`(不发则上游把思考写进 `delta.content`、`reasoning_content` 恒空、`reasoning_tokens` 恒 0)。**可选机制(默认全关)**:`REASONING_EFFORT_MAP`、`ANTHROPIC_THINKING_EFFORTS`、`FORCE_DISABLE_THINKING`,全关时行为逐字节不变。**输出上限键名不对称**:CB 忽略 `max_completion_tokens`,只认 `max_tokens`;TRAE 两个键都不生效——不做键映射。 +- 错误码:`6004` 模型用量超限、`14003` 模型饱和(均模型级)、`11102` 无此模型(负缓存)、`14018` 积分耗尽、`1005` 权益耗尽、`11101/11115/11128/11135` 请求级(不罚号)。`6004`/`14003` 落账号级会让整条凭证其他模型一起被冷却 60s。 ### 2.2 TRAE SOLO(`trae`) -- Host:Agent `https://trae-api-cn.mchost.guru`、UG `https://api.trae.cn`、OAuth `https://api.trae.com.cn`;聊天 `POST /api/agent/v3/llm_utils_chat`,模型 `POST /api/ide/v1/get_detail_param`,均仅流式。认证:浏览器登录 → 302 回调 → `ExchangeToken` → `GetUserInfo`;刷新 `POST /cloudide/api/v3/trae/oauth/ExchangeToken`(refreshToken 轮换);凭证 JSON 里的 `apiHost` 是用户可控输入,导入时按官方地址白名单校验。签到 `/trae/api/v2/ug/checkin_credits/{status,claim}`;额度 `/trae/api/v2/pay/ide_user_ent_usage`。 -- **签到成功必须「确认到账」,不能只看返回码**:`claim` 对当天已签过的账号也返回 `code:0 success`(幂等),判定需 `checked_in` 为真且回查 `credits` 确有增加。**签到 9074 按设备标识处理**:数字串是必要非充分条件;每次 claim 生成新的 16 位数字串,一轮内最多换号重试 2 次,其余交给 10 分钟周期。 -- SSE 事件序列 `metadata` → `timing_cost` → `output`×N → `extra_info` → `token_usage` → `done`;`token_usage` 含 `cache_read_input_tokens` / `cache_creation_input_tokens`,映射为统计的 `cached_tokens`,**无 per-request credit**;流内 `error` 码 `1005` = 权益不足,`4008` = 请求额度/配额耗尽(实测 `Your requests have exceeded the quota.`,与 `14018` 同为 CREDIT:冷却到次日签到),`4001` = 参数/模型不可用。接受客户端传来的 `reasoning_effort`,不认 `thinking` 对象;`developer` 角色上游不认(静默空流),已归一为 `system`。 +- Host:Agent `https://trae-api-cn.mchost.guru`、UG `https://api.trae.cn`、OAuth `https://api.trae.com.cn`;聊天 `POST /api/agent/v3/llm_utils_chat`,模型 `POST /api/ide/v1/get_detail_param`,均仅流式。认证:浏览器登录 → 302 回调 → `ExchangeToken` → `GetUserInfo`;刷新 `POST /cloudide/api/v3/trae/oauth/ExchangeToken`(refreshToken 轮换);凭证里的 `apiHost` 是用户可控输入,导入时按官方白名单校验。签到 `/trae/api/v2/ug/checkin_credits/{status,claim}`;额度 `/trae/api/v2/pay/ide_user_ent_usage`。 +- **签到成功必须确认到账**:`claim` 对当天已签也返回 `code:0`,判定需 `checked_in` 为真且回查 `credits` 有增加。**9074 按设备标识处理**:每次 claim 生成新 16 位数字串,一轮内最多换号重试 2 次。 +- SSE 序列 `metadata` → `timing_cost` → `output`×N → `extra_info` → `token_usage` → `done`;`token_usage` 含 `cache_read_input_tokens` / `cache_creation_input_tokens`,映射为 `cached_tokens`,**无 per-request credit**;流内 `1005` 权益不足、`4008` 配额耗尽(同为 CREDIT,冷却到次日签到)、`4001` 参数 / 模型不可用。接受 `reasoning_effort`,不认 `thinking` 对象;`developer` 角色归一为 `system`。 -### 2.3 Qoder(`qoder`,真实账号) +### 2.3 Qoder(`qoder`) -- 域:国内 `openapi.qoder.com.cn` / 网关 `gateway.qoder.com.cn`;国际 `openapi.qoder.sh` / `api1.qoder.sh`。聊天 `POST {gateway}/algo/api/v2/service/pro/sse/agent_chat_generation`,**只支持流式**(信封式 SSE)。签名为自定义 Base64 变体(三段轮转 + 自定义字母表 + `=`→`$`),`Authorization: Bearer COSY..`,整套 `cosy-*` 请求头(含按 `uid` 稳定派生的 `cosy-machineid`/`cosy-machinetoken`)。登录为**设备码 PKCE(S256)**:`{website}/device/selectAccounts` 生成 challenge/nonce → 轮询 `GET {openapi}/api/v1/deviceToken/poll`(404/202 = 待授权)→ `POST /api/v1/deviceToken/refresh` 续期 → `GET /api/v1/userinfo` 补 `uid`/昵称/组织。 -- 模型 `GET {gateway}/algo/api/v2/model/list?Encode=1`,**必须带整套 COSY 签名头**(裸 GET 403,带头 POST/PUT 被 400 拒绝,故方法固定 GET);额度 `GET {openapi}/api/v2/quota/usage`(`userQuota` + `addOnQuota`);套餐 `GET /api/v2/user/plan`。 -- **入站消息归一**:`build_chat_body` 在转发前丢弃**孤立 `tool` 消息**(没有紧邻的、声明了该 `tool_call_id` 的 `assistant.tool_calls`)——上游对这类历史一律 400 `Messages with role 'tool' must be a response to a preceding message with 'tool_calls'`。只丢弃、不重排、不合并、不改 content;丢弃条数记 warning,不落消息正文。并行 tool_calls(一个 assistant 多条 tool)与 `/v1/responses` 拆出的连续多条 assistant 声明都按并集识别,避免误删真实结果。流内错误正文保留到 2000 字符(200 会截在转义 `details` 中间,根因永久丢失)。 -- 签到(**活动制**):`GET /sash/api/v1/me/campaigns` → 筛 `actionType=="CLAIM_BENEFIT"` 且 `claimStatus=="CLAIMABLE"` → `POST /sash/api/v1/me/campaigns/{campaignId}/claim`;**两个请求都必须带 `Cosy-ClientType: 10`**(缺则上游静默返回空 campaign 列表);`replayed:true` 与 `BLOCKED`+`SAME_PERSON_ALREADY_CLAIMED` 归一为当日已签。旧 `/sash/api/v1/me/daily-check-in/{status,claim}` 仅作回退(当前旧 status 返回 `DISABLED`);新协议不提供连续天数。**活动每日 10:00(UTC+8)刷新**,故封账到当前窗口结束而非自然日。**国际版该端点 404 → 本区域无此接口,不算错误**。 -- **上游节点故障包成 400**:`[FAIL]node:… msg:Execution failed` 归模型级瞬时故障(只冷却该模型),不误判为模型不存在。 +- 域:国内 `openapi.qoder.com.cn` / 网关 `gateway.qoder.com.cn`;国际 `openapi.qoder.sh` / `api1.qoder.sh`。聊天 `POST {gateway}/algo/api/v2/service/pro/sse/agent_chat_generation`,**只支持流式**(信封式 SSE)。签名为自定义 Base64 变体(三段轮转 + 自定义字母表 + `=`→`$`),`Authorization: Bearer COSY..`,整套 `cosy-*` 头(含按 `uid` 稳定派生的 `cosy-machineid`/`cosy-machinetoken`)。登录为设备码 PKCE(S256):`{website}/device/selectAccounts` → 轮询 `GET {openapi}/api/v1/deviceToken/poll`(404/202 = 待授权)→ `POST /api/v1/deviceToken/refresh` → `GET /api/v1/userinfo`。 +- 模型 `GET {gateway}/algo/api/v2/model/list?Encode=1`,**必须带整套 COSY 签名头**(裸 GET 403,带头 POST/PUT 400,故方法固定 GET);额度 `GET {openapi}/api/v2/quota/usage`(`userQuota` + `addOnQuota`);套餐 `GET /api/v2/user/plan`。 +- **入站消息归一**:`build_chat_body` 丢弃孤立 `tool` 消息(没有紧邻声明该 `tool_call_id` 的 `assistant.tool_calls`)——上游对这类历史一律 400。只丢弃、不重排、不改 content;丢弃条数记 warning,不落正文。并行 tool_calls 与 `/v1/responses` 拆出的连续 assistant 声明按并集识别。流内错误正文保留到 2000 字符。 +- 签到(活动制):`GET /sash/api/v1/me/campaigns` → 筛 `actionType=="CLAIM_BENEFIT"` 且 `claimStatus=="CLAIMABLE"` → `POST .../{campaignId}/claim`;**两个请求都必须带 `Cosy-ClientType: 10`**(缺则静默返回空列表);`replayed:true` 与 `BLOCKED`+`SAME_PERSON_ALREADY_CLAIMED` 归一为已签。旧 `daily-check-in` 仅作回退。**活动每日 10:00(UTC+8)刷新**,封账到窗口结束而非自然日。**国际版该端点 404 → 本区域无此接口**。 +- **上游节点故障包成 400**(`[FAIL]node:…`)归模型级瞬时故障,不误判为模型不存在。 -### 2.4 CodeArts(`codearts`,真实账号) +### 2.4 CodeArts(`codearts`) -- 域:snap 引擎 `snap-access.cn-north-4.myhuaweicloud.com`、STS `sts.cn-north-4.myhuaweicloud.com`、门户 `codearts.huaweicloud.com`。聊天 `POST /api/v2/chat/completions`;鉴权为华为云 **`SDK-HMAC-SHA256`**(AK/SK + `X-Security-Token`)。OAuth2 PKCE → `POST {snap-manager}/v1/oauth2/tokens` 换 AK/SK/security_token/refresh_token。模型 `GET {snap}/v1/model/builtin`(头 `Agent-Type: PromptCenter`);额度 `GET {snap}/snap-manager/v1/statistics/plugin`(头 `Agent-Type: PromptCenter`)。 -- **令牌刷新是刚性的**:`refresh_token` 与 `client_id=codearts-agent` + DPoP 私钥三者绑定、**一次性**,刷新走 **DPoP ES256/P-256**,刷后必须回写新 `refresh_token`,由 token 预刷新任务独占轮转。续期凭据被上游作废时标记「需重新登录」并停止自动重试。 -- **每日签到**:`GET {snap}/v1/ops/delivery?channel=IDE` → `POST /v1/ops/claim`(`{campaignId:1, idempotentKey, channel:"IDE"}`)→ `POST /v1/ops/confirm`(`{campaignId:1}`);**`/v1/ops/*` 挂在 snap 引擎根、不带 `snap-manager` 前缀**。`status` 四态必须区分:`ELIGIBLE`→claim+confirm;`CLAIMED`(已领未确认,非终态)→只补 confirm;`CONFIRMED`/`CONSUMED`→已签不写。**claim 时积分即到账**,confirm 只推进状态;业务码走 HTTP 200,不能只看状态码。 -- **额度=积分**:额度探测打 `GET {snap}/snap-manager/v1/statistics/plugin`(前缀**必带** `snap-manager`,头 `Agent-Type: PromptCenter`)——内置模型(GLM-5.2 / OpenPangu 等)按积分计费,这是本渠道唯一的额度来源。上游数值本身即积分,**不做任何单位折算**;`usageTotalPackageCredit` 直接进 `quota_remaining` / `quota_total`,分项包(基础/按需/福利)走 `quota_packages` 明细,套餐 `end_date` 登记到期阶梯(积分用不完作废,故也参与「快过期的先用」)。签到发的积分也计入这本账。 -- **凭证页一个渠道一个组**(后端始终是单个 `codearts` provider):额度列=积分账,变动流水读 `ledger=credits`。**不拆成两条凭证行**:`refresh_token` 一次性且与 `client_id` + DPoP 私钥绑定,两条凭证行会各刷一次、第二条必报 `the refresh token has been used` 并烧掉票据。 -- **并发上限**:上游硬限**每账号每约 60s 最多 3 个会话**,超出即 `400 TM.00001041`;配在途上限 `CODEARTS_MAX_CONCURRENCY` 与滑动窗口 `CODEARTS_REQUEST_WINDOW_SECONDS`。流式 `text` 是**累计全文(替换语义)**,解析层还原为增量事件。 -- **流内业务错误分类**:业务错误内嵌在 HTTP 200 的 SSE 里(`InferHub.<码>`),由 executor 转成冷却 + 轮换。分类统一由 `Event.error_kind` 承载,`UpstreamStreamError.kind()` 与 `executor._event_kind` 共用 `provider.base.event_error_kind` 兜底(缺失 → `OTHER`),避免两处各写一份而漂移。非多模态模型收到图片(`InferHub.001001020.406 The request model is not multimodal`)归 `REQUEST`:请求侧错误不冷却、不累计、不落库,仍换号重试。 +- 域:snap 引擎 `snap-access.cn-north-4.myhuaweicloud.com`、STS `sts.cn-north-4.myhuaweicloud.com`、门户 `codearts.huaweicloud.com`。聊天 `POST /api/v2/chat/completions`;鉴权 `SDK-HMAC-SHA256`(AK/SK + `X-Security-Token`)。OAuth2 PKCE → `POST {snap-manager}/v1/oauth2/tokens` 换 AK/SK/security_token/refresh_token。模型 `GET {snap}/v1/model/builtin`(头 `Agent-Type: PromptCenter`);额度 `GET {snap}/snap-manager/v1/statistics/plugin`。 +- **令牌刷新刚性**:`refresh_token` 与 `client_id=codearts-agent` + DPoP 私钥三者绑定、一次性,走 DPoP ES256/P-256,刷后必须回写新 `refresh_token`,由预刷新任务独占轮转。被作废时标记「需重新登录」并停止自动重试。 +- **每日签到**:`GET {snap}/v1/ops/delivery?channel=IDE` → `POST /v1/ops/claim` → `POST /v1/ops/confirm`;`/v1/ops/*` 挂在 snap 引擎根、不带 `snap-manager`。`ELIGIBLE`→claim+confirm;`CLAIMED`(已领未确认,非终态)→只补 confirm;`CONFIRMED`/`CONSUMED`→已签。**claim 时积分即到账**,confirm 只推进状态;业务码走 HTTP 200。 +- **额度=积分**:`usageTotalPackageCredit` 直接进 `quota_remaining`/`quota_total`,不做单位折算;分项包走 `quota_packages`,套餐 `end_date` 进到期阶梯(积分用不完作废)。 +- **凭证页一个渠道一个组**:不拆两条凭证行——`refresh_token` 一次性,两条会各刷一次、第二条必报 `the refresh token has been used` 并烧掉票据。 +- **并发上限**:上游硬限每账号每约 60s 最多 3 个会话,超出 `400 TM.00001041`;配 `CODEARTS_MAX_CONCURRENCY` 与 `CODEARTS_REQUEST_WINDOW_SECONDS`。流式 `text` 是**累计全文(替换语义)**,解析层还原为增量。 +- **流内业务错误分类**:错误内嵌在 HTTP 200 的 SSE 里(`InferHub.<码>`),分类由 `Event.error_kind` 承载,`UpstreamStreamError.kind()` 与 `executor._event_kind` 共用 `provider.base.event_error_kind` 兜底。非多模态模型收到图片归 `REQUEST`:不冷却、不累计、不落库,仍换号重试。 ### 2.5 OpenCode Zen(`zen`,虚拟凭证) -- 端点 `https://opencode.ai`,`/zen/v1/chat/completions` 与 `/zen/v1/models`。协议是标准 OpenAI SSE,**不逆向**。 -- 渠道私有的是**免费层门禁伪装**:UA `opencode/` ≥1.18.0(否则 426;无版本号 403)、`x-opencode-session`、body `stream:true`、body `tools` 必须同时含 `bash` 与 `read`。对策:只在缺失时补骨架工具;模型调用注入名时改写为客户端声明的同义工具(`bash`→`shell`/`execute`、`read`→`read_file`)并复制其 schema,无同义工具才丢弃并记 WARNING。 -- **免费模型清单完全动态**:免鉴权 `/zen/v1/models` 返回全部模型(含付费)且不带免费标记,唯一权威信号是匿名可用性(付费恒 401 `Missing API key.`),按 `-free` 后缀收窄 + 并发探活只留 2xx,判活按 30 分钟缓存;免费模型标 **x0 倍率**(`credit_rate=0.0`)。服务层列表拉取**连续失败按 2^(n-1)×TTL 退避重试**(封顶 2h、成功即清零),日志首次 WARNING 其后降 INFO——上游按出口 IP 长期拦截时不至每 5 分钟重探十几秒再刷一条同样的告警。 -- **凭证用虚拟凭证行**:无凭证/无额度接口,`probe_quota` 恒 `probe_failed=True` → health「未知」而非耗尽;删除后重启复活,永久停用请用「暂停」。401 归 `INVALID`;**403 FreeTierError 抖动在客户端内退避重试一次**,否则单虚拟凭证下 REQUEST 类错误会耗尽轮换预算、直接 503。 +- 端点 `https://opencode.ai`,`/zen/v1/chat/completions` 与 `/zen/v1/models`。标准 OpenAI SSE,**不逆向**。 +- 渠道私有的是**免费层门禁伪装**:UA `opencode/` ≥1.18.0(否则 426;无版本号 403)、`x-opencode-session`、body `stream:true`、`tools` 必须同时含 `bash` 与 `read`。只在缺失时补骨架工具;模型调用注入名时改写为客户端声明的同义工具并复制 schema,无同义工具才丢弃并记 WARNING。 +- **免费清单完全动态**:免鉴权 `/zen/v1/models` 返回全部模型且不带免费标记,唯一权威信号是匿名可用性(付费恒 401),按 `-free` 后缀收窄 + 并发探活只留 2xx,缓存 30 分钟;免费模型标 **x0 倍率**。列表拉取**连续失败按 2^(n-1)×TTL 退避**(封顶 2h,成功清零),日志首次 WARNING 其后 INFO。 +- **虚拟凭证行**:无额度接口,`probe_quota` 恒 `probe_failed=True` → health「未知」而非耗尽;删除后重启复活,永久停用用「暂停」。401 归 `INVALID`;**403 FreeTierError 在客户端内退避重试一次**,否则单虚拟凭证下 REQUEST 类错误会耗尽轮换预算直接 503。 ### 2.6 Kilo(`kilo`,虚拟凭证) -- 端点 `https://api.kilo.ai/api/gateway`,`/chat/completions` + `/models`。**标准 OpenAI 兼容**,无私有信封、无门禁伪装。**免费模型有权威标记**:`/models` 每条带 `isFree` 布尔,据此直接过滤、**不做探活**(探活会真发推理、白耗网关级约 200 req/h/IP 的极小配额);免费模型标 **x0 倍率**。思考字段是 **`delta.reasoning`**(不是 Zen 的 `reasoning_content`)。凭证用虚拟凭证行(同 Zen)。 -- 错误分类:401→`INVALID`、400/404/422→`INVALID`、403→`REQUEST`、**429 与 502/503/504→`MODEL`(模型级冷却)**——实测 429/5xx 点名具体模型、同批其他免费模型仍 200,归账号级会因单模型问题冷却整条渠道;本包不自建熔断。 +- 端点 `https://api.kilo.ai/api/gateway`,`/chat/completions` + `/models`。标准 OpenAI 兼容,无私有信封、无门禁伪装。**免费模型有权威标记**:`/models` 每条带 `isFree`,据此过滤、**不探活**(网关级约 200 req/h/IP 的配额极小);免费模型标 **x0**。思考字段是 `delta.reasoning`。凭证用虚拟凭证行。 +- 错误分类:401 / 400 / 404 / 422→`INVALID`,403→`REQUEST`,**429 与 502/503/504→`MODEL`**(实测点名具体模型,同批其他免费模型仍 200)。本包不自建熔断。 ### 2.7 CodeBuddy 国际(`codebuddy_intl`) -- 端点 `https://www.codebuddy.ai`。**与国内站同协议**(同一套 `/v2/chat/completions`、`/v3/config`、`/v2/plugin/auth/*`、`/v2/billing/*` 路径,同一 Keycloak realm),但是**两套独立账号体系**(各自注册、各自计费),故作为独立渠道存在。**为什么不是「改环境变量切站点」**:`CODEBUDDY_API_ENDPOINT` 是进程级单值,切过去会让所有存量国内站凭证立刻打到国际站(额度错配 / 403);两站可同时持有凭证,各走各的端点、节流器与登录流程。 -- **白名单刻意互不含**:`CODEBUDDY_INTL_ALLOWED_ENDPOINTS` 默认只有国际站主机,**不含**国内站主机。两站 Token 由不同网关签发,跨站发送即凭证泄露,填错会在**启动时**报错。 -- **能力差异**:聊天、模型列表、额度探测、token 刷新、**活跃上报**全部启用;**每日签到、成长中心未启用**——签到是渠道侧就没有这个活动(`checkin-activity-status` 回 `active: false`),成长中心**无法验证**(整个 `/v2/activity/*` 前缀挂在网关认证插件后,连不存在的子路径也返回 401 而非 404)且含不可逆动作,不盲跑。活跃上报端点 `/v2/report` 与成长中心不是同一路径,实测国际站可用。 -- **模型列表按套餐探活**:上游 `/v3/config` 返回全量模型,免费号也会看到 `gpt-5.5` / `gpt-5.6-luna` 等调用时才回 `400 / 11158` 的模型。国际站列表用当前刷新凭证逐个发一次最小请求(只读状态行即断开),只保留可用模型;`11158`(套餐不允许)与 `11102`(无此模型)剔除,`429` / 5xx / 超时等不确定结果**保留模型**。结论按账号缓存 6 小时。 -- **唯一协议行为差异:首条 system 强制注入**——国际站网关要求 `messages[0]` 是 system,否则整单 400 + `{"code":11128,"msg":"first message is not system prompt"}`;国内站无此要求。同名模型在两站是**一条**、同时挂两个渠道。 +- 端点 `https://www.codebuddy.ai`。与国内站同协议(同一套 `/v2/chat/completions`、`/v3/config`、`/v2/plugin/auth/*`、`/v2/billing/*`,同一 Keycloak realm),但是两套独立账号体系,故独立渠道。**不合并的原因**:`CODEBUDDY_API_ENDPOINT` 是进程级单值,切过去会让存量国内站凭证打到国际站。 +- **白名单刻意互不含**:`CODEBUDDY_INTL_ALLOWED_ENDPOINTS` 默认只有国际站主机,填国内站会在启动时报错(跨站发送即凭证泄露)。 +- **能力差异**:聊天、模型列表、额度探测、token 刷新、活跃上报启用;**签到、成长中心未启用**(签到渠道侧无此活动;成长中心无法验证且含不可逆动作)。活跃上报端点 `/v2/report` 与成长中心不同路径,国际站可用。 +- **模型列表按套餐探活**:上游 `/v3/config` 返回全量模型,免费号也会看到调用时才回 `400/11158` 的模型。用当前凭证逐个发一次最小请求(只读状态行即断开),`11158` 与 `11102` 剔除,`429`/5xx/超时**保留**。结论按账号缓存 6 小时。 +- **唯一协议差异:首条 system 强制注入**——国际站要求 `messages[0]` 是 system,否则 400 + `11128`;国内站无此要求。同名模型在两站是一条、挂两个渠道。 -### 2.8 OfficeAce(`officeace`,真实账号) +### 2.8 OfficeAce(`officeace`) + +- 端点:控制面 `https://agentarts.cn-southwest-2.myhuaweicloud.com`(OAuth + 开通校验),模型网关 `modelgw-XXXX.officeace.*` **每账号一个**、由 `client-permission-validate` 下发并存在凭证里——不进配置、不进白名单。 +- **两套凭据、两套签名、两种寿命**: -- 端点:控制面 `https://agentarts.cn-southwest-2.myhuaweicloud.com`(OAuth + 开通校验),模型网关 `modelgw-XXXX.officeace.*` **每账号一个**、由 `client-permission-validate` 下发并存在凭证里——故它不进配置、也不进白名单(那是本服务写不错的值)。 -- **两套凭据、两套签名、两种寿命**(本渠道最容易踩错的地方): | 凭据 | 寿命 | 用途 | 签名 | |---|---|---|---| - | `Basic model_app_key:model_app_secret` | 不过期 | 聊天(`{网关}/v2/chat/completions`) | HTTP Basic | - | 临时 AK/SK + `security_token` + `project_id` | 约 2h | 积分 / 签到 / 费率(`/v1/subscription*`、`/v1/model-token-rates`) | `V11-HMAC-SHA256` | - | 同上,打控制面(`/v1/claw/*`) | 同上 | 开通校验 | `SDK-HMAC-SHA256` | - 混用得 `APIG.0303 app not found`;丢失区别的现象是「聊天正常但积分空白、签到一直失败」。 -- **V11 的三个必须原样**(写错任一处恒 401 `verify ak sk signature fail`,已逐项实测对照):① 派生密钥外层 key=AK、msg=SK;② `HMAC(HMAC(AK,SK), scope+0x01)[:32]` 要转 **hex 文本** 当下一轮密钥;③ **不要**加 `X-Sdk-Content-Sha256` 头(多一个签名头即失败)。另:query 必须参与签名,`CanonicalURI` 每段 percent-encode 且末尾补 `/`。 -- **登录(poll 轨道)**:`POST /v1/claw/auth/state` 取云端 state → 本地生成 PKCE + DPoP → 用户浏览器登录授权 → `GET /v1/claw/auth/code?state=` 轮询取码(202 = 还没登完)→ STS `POST /v1/oauth2/tokens` 换临时 AK/SK → `client-permission-validate` 取网关与 app key。**回调由云端承接,本服务不监听端口、也不需要粘链接**。`client_id=pdp5_for_agentarts`,refresh_token 与 CodeArts 的**互不通用**(混用回 `STS5.1806 invalid client id`),故 OAuth 与凭证类型独立实现,只复用 `codearts/dpop.py` 的 ES256 低 S 签名原语。 -- **未开通的账号要可行动**:`subscription: null` 时给「请先在 OfficeAce 客户端兑换邀请码完成开通」,而不是含糊的登录失败。 -- **三种上游特例**:① 限流走**伪 200**(HTTP 200 + `data:{"error":…}`,也出现过首帧正常、第二帧才错);② `81004`/`81009` 走 403/404 但属**模型级**,业务码判定必须先于状态码,否则会把整条凭据打死;③ `max_tokens` 是「推理 + 正文」总预算,`≥16` 追加 1024 再夹到模型硬上限(`glm-5*` 131072 / `qwen3*` 65536 / `deepseek-v4.1-flash` 393216),`<16` 原样透传,未给不补。 -- **积分即主账**:单位 `officeace_points`(新用户一次性 + 每日签到),**没有并行的 token 池**,故 `Quota.remaining`/`total` 直接是积分;每笔奖励独立到期,口径写「逐笔到期」。签到 `POST /v1/subscription/bonus/claim` 是**写操作**(调用即真发积分),body 必须给 {} + JSON `Content-Type`(空 body 会 500 `OfficeAce.11020001`),上游对当日重复领取幂等;收获按 `bonus_skus` **剩余量增量**计算(`points` 是活动累计发放量,由多渠道共同贡献)。 -- **不做主动模型探活**:目录 34 个里实测仅 11 个可用,探活要真发推理、消耗积分,改由真实请求按 `BLOCKED` 负缓存(成功即撤销)。**不做 credit 估算**:`cost_coefficient` 与 `tokens_per_point` 的叠乘关系官方未说明,折算会与真实扣点对不上。 -- **单账号单用户**:官方《套餐管理》原文「个人版面向个人用户,**限制单账号单用户使用**」,故不做多人共享,每人各加自己的凭证。 + | `Basic model_app_key:model_app_secret` | 不过期 | 聊天 | HTTP Basic | + | 临时 AK/SK + `security_token` + `project_id` | 约 2h | 积分 / 签到 / 费率 | `V11-HMAC-SHA256` | + | 同上,打控制面 | 同上 | 开通校验 | `SDK-HMAC-SHA256` | + + 混用得 `APIG.0303 app not found`;现象是「聊天正常但积分空白、签到一直失败」。 +- **V11 的三个必须原样**(写错任一处恒 401):① 派生密钥外层 key=AK、msg=SK;② `HMAC(HMAC(AK,SK), scope+0x01)[:32]` 转 **hex 文本** 当下一轮密钥;③ **不要**加 `X-Sdk-Content-Sha256` 头。query 必须参与签名,`CanonicalURI` 每段 percent-encode 且末尾补 `/`。 +- **登录(poll 轨道)**:`POST /v1/claw/auth/state` → 本地生成 PKCE + DPoP → 浏览器授权 → `GET /v1/claw/auth/code?state=`(202 = 未登完)→ STS `POST /v1/oauth2/tokens` → `client-permission-validate` 取网关与 app key。回调由云端承接,不监听端口。`client_id=pdp5_for_agentarts`,refresh_token 与 CodeArts **互不通用**(混用回 `STS5.1806`),只复用 `codearts/dpop.py` 的 ES256 低 S 签名原语。 +- **未开通的账号要可行动**:`subscription: null` 时给「请先在客户端兑换邀请码」,而不是含糊的登录失败。 +- **三种上游特例**:① 限流走伪 200(HTTP 200 + `data:{"error":…}`,也见过首帧正常、第二帧才错);② `81004`/`81009` 走 403/404 但属模型级,业务码判定先于状态码;③ `max_tokens` 是推理 + 正文总预算,`≥16` 追加 1024 再夹到模型硬上限,`<16` 原样透传,未给不补。 +- **积分即主账**:单位 `officeace_points`,`Quota.remaining`/`total` 直接是积分;每笔奖励独立到期。签到 `POST /v1/subscription/bonus/claim` 是写操作,body 必须给 `` + JSON `Content-Type`(空 body 会 500),上游对当日重复领取幂等;收获按 `bonus_skus` 剩余量增量算。 +- **不做主动探活 / 不做 credit 估算**:探活要真发推理消耗积分;`cost_coefficient` 与 `tokens_per_point` 的叠乘关系官方未说明。 +- **单账号单用户**:官方《套餐管理》限制单账号单用户,每人各加自己的凭证。 ### 2.9 冲突与陷阱 | 问题 | 事实 | 对策 | |---|---|---| -| 模型 ID 撞车 | 多渠道都有 `glm-5.2`、`DeepSeek-V4-Pro`、`kimi-k3` 等 | 扁平名 + 归一键合并 + 健康度路由 + `@渠道` 后缀 | -| 积分语义不同 | CB 有周期会重置;TRAE / CodeArts 各自权益包独立到期 | 健康分统一为百分比;展示层标注周期语义 | -| credit 可得性 | CB 有 per-request;TRAE 只有账户总额 | 统计表 credit 可空;TRAE 按官方单价推算并标 `credit_estimated` | -| 登录机制 | CB/Qoder/CodeArts/OfficeAce 轮询或粘贴(后端出网);TRAE 浏览器回调 | 三轨(poll / callback / paste),回调统一走主端口 | -| 同一厂商两套票 | OfficeAce 与 CodeArts 同属华为云 STS,但 client_id 不同,refresh_token 互不通用(混用回 `STS5.1806`) | 各自独立实现 OAuth 与凭证类型,只复用 DPoP 签名原语 | -| 上游 SSE 格式变更 | 各渠道 SSE 形状不同 | 各渠道解析成中立事件;样本存 fixture 做契约测试 | -| 图片能力标志不可信 | 八渠道出站均为 OpenAI `image_url` 内容块原样透传,上游全部接受(实测 2026-10-15);而模型目录的 `supportsImages` 与实际行为不一致(CB `glm-5.0` 标 false 却能识图;`hunyuan-chat` 标 false 却接受并自行降级) | 入站统一归一为 `image_url` 透传,**不做模型能力门禁**,能否识图交上游裁决 | +| 模型 ID 撞车 | 多渠道都有 `glm-5.2`、`DeepSeek-V4-Pro`、`kimi-k3` | 扁平名 + 归一键合并 + 健康度路由 + `@渠道` | +| 积分语义不同 | CB 有周期重置;TRAE / CodeArts 各自权益包独立到期 | 健康分统一为百分比;展示层标注周期 | +| credit 可得性 | CB 有 per-request;TRAE 只有账户总额 | credit 可空;TRAE 按官方单价推算并标 `credit_estimated` | +| 登录机制 | CB/Qoder/CodeArts/OfficeAce 轮询或粘贴;TRAE 浏览器回调 | 三轨(poll / callback / paste),回调统一走主端口 | +| 同一厂商两套票 | OfficeAce 与 CodeArts 同属华为云 STS,client_id 不同,refresh_token 互不通用 | 各自独立实现 OAuth 与凭证类型,只复用 DPoP 原语 | +| 上游 SSE 格式变更 | 各渠道形状不同 | 各渠道解析成中立事件;样本存 fixture 做契约测试 | +| 图片能力标志不可信 | 八渠道出站均为 `image_url` 原样透传(实测 2026-10-15);目录的 `supportsImages` 与实际不一致 | 入站统一归一为 `image_url`,**不做模型能力门禁**,能否识图交上游裁决 | ## 3. 架构 ### 3.1 分层 -一个 FastAPI 进程内按**三个平面**组织,三面共享同一份 SQLite(WAL + `busy_timeout=5000`:账号 / 加密凭证 / 统计 / 审计)与 Provider 客户端: +一个 FastAPI 进程内按三平面组织,共享同一份 SQLite(WAL + `busy_timeout=5000`)与 Provider 客户端: -- **请求面**(鉴权 = API Key):`/v1/chat/completions` · `/v1/responses` · `/v1/messages` · `/v1/models` · `/v1/user/balance` → 协议层(OpenAI / Responses / Anthropic 规范化与适配、模型名解析 `name` | `name@provider`)→ 执行引擎(调度器 · 冷却状态机 · 会话粘性 · 截断续写 · 统计采集)→ 中立事件流 → 八个 Provider。 -- **管理面**(鉴权 = 会话 Cookie + RBAC):React 管理台 → `/api/*`,含用户管理、审计、凭证运维、API Key、统计、任务与配置、上游状态、模型列表。 -- **后台面**(无 HTTP 入口,`TaskRunner` 起 11 类循环):额度探测 · token 预刷新 · 每日签到 · 成长中心 · 活跃上报 · 明细清理 · 数据库备份 · 渠道模型列表刷新 · 模型目录刷新(OpenRouter)· 运维告警 · 上游状态页聚合 → Provider 客户端 → 上游。 +- **请求面**(API Key 鉴权):`/v1/chat/completions` · `/v1/responses` · `/v1/messages` · `/v1/models` · `/v1/user/balance` → 协议层 → 执行引擎(调度 · 冷却 · 会话粘性 · 截断续写 · 统计采集)→ 中立事件流 → 八个 Provider。 +- **管理面**(会话 Cookie + RBAC):React 管理台 → `/api/*`。 +- **后台面**(无 HTTP 入口,`TaskRunner` 起 11 类循环):额度探测 · token 预刷新 · 签到 · 成长中心 · 活跃上报 · 明细清理 · 备份 · 渠道模型列表刷新 · 模型目录刷新 · 运维告警 · 上游状态聚合。 -**为什么按平面切**:请求面全程无状态、可并发,且不知道「人」是谁(只认 Key 的归属用户);管理面与请求面**共用同一个执行引擎与仓储**,不做独立服务——自托管小实例里进程隔离只换来部署复杂度。两个鉴权面互不替代:API Key 进不了管理台,会话 Cookie 也进不了 `/v1`。后台面与请求面**共用 Provider 客户端与节流器**:各渠道风控按最小间隔生效,不能因为「后台签到」与「前台对话」是两条代码路径就各发各的。三面共享一份 SQLite,故升级只需重启**一个**进程。 +**为什么按平面切**:请求面无状态、可并发,且不知道「人」是谁(只认 Key 的归属用户);管理面与请求面共用同一执行引擎与仓储,不做独立服务。两个鉴权面互不替代:API Key 进不了管理台,Cookie 也进不了 `/v1`。后台面与请求面共用 Provider 客户端与节流器——风控按最小间隔生效,不能因为代码路径不同就各发各的。三面共享一份 SQLite,升级只需重启一个进程。 ### 3.2 Provider 接口边界 -Provider 承担上游协议私有部分:发请求、解析事件、分类错误,以及凭证生命周期与额度探测;调度、冷却、重试、统计全在共享引擎。接口分三类——凭证生命周期(`start_auth` / `poll_auth` / `complete_callback` / `import_credential` / `refresh`)、额度探测(`probe_quota`)、执行与分类(`stream_chat` / `classify` / `list_models` / `aclose`)。 +Provider 承担上游私有部分:发请求、解析事件、分类错误、凭证生命周期与额度探测;调度、冷却、重试、统计全在共享引擎。接口三类:凭证生命周期(`start_auth` / `poll_auth` / `complete_callback` / `import_credential` / `refresh`)、额度探测(`probe_quota`)、执行与分类(`stream_chat` / `classify` / `list_models` / `aclose`)。 ### 3.3 调度器 -统一实现,各 provider 共用。**选号前先在 `Executor._selectable` 做两道过滤**(不进 `Candidate.is_selectable`):① 凭证限额护栏(触顶的号不参与,见 §限额护栏);② 在途租约 `MAX_INFLIGHT_PER_CREDENTIAL`(全局热更,默认 0 = 不限)——同一账号并发达到上限后**整号不参与选号**,避免把单账号打进上游限流/封号。在途计数进程内、租约在请求结束时释放(含断开/异常),另有 600s TTL 兜底漏释放;跨渠道全局,与 CodeArts 的渠道内 `CODEARTS_MAX_CONCURRENCY` 正交。**在途计数兼作负载键**:`Executor._selectable` 把在途数注入 `Candidate.inflight`,调度器按「最少连接」排(见下);上限为 0 时也照常登记,故不限并发时负载键仍可用。过滤后按下列排序链出号: +**选号前在 `Executor._selectable` 做两道过滤**(不进 `Candidate.is_selectable`):① 凭证限额护栏;② 在途租约 `MAX_INFLIGHT_PER_CREDENTIAL`——同账号并发达上限后整号不参与选号。在途计数进程内、请求结束释放(含断开 / 异常),600s TTL 兜底;跨渠道全局,与 `CODEARTS_MAX_CONCURRENCY` 正交。**在途计数兼作负载键**,上限为 0 时也照常登记。 +过滤后按序出号: -1. **免费优先档**:候选池存在 `credit_rate == 0` 的渠道时,免费渠道整体排在付费渠道之前。`credit_rate=None`(目录未就绪 / 缺该模型 / 上游未给倍率)**不算免费**,与 `0.0` 严格区分。**为什么排在到期额度之前**:省下的真金白银比「几百积分快过期先用」值钱。 +1. **免费优先**:候选池有 `credit_rate == 0` 时免费渠道整体排前。`credit_rate=None` **不算免费**,与 `0.0` 严格区分。**为什么排在到期额度之前**:省下的真金白银比「几百积分快过期先用」值钱。 2. **手动 pin 优先**;会话粘性命中且可选时直接复用,不参与排序。 -3. **在途最少优先**(最少连接负载均衡):在途数少的号排前,并发请求摊到空闲号。**为什么排在到期额度之前**:到期/健康度是静态指标,按它们排出的次序确定,所有并发会算出同一个最优号并全压上去(实测 codebuddy_intl 六号全打平、2 小时内一个号吃 180+ 次、其余闲置);在途把它拆开,顺序请求(在途恒 0)不受影响。免费档仍压过在途。 +3. **在途最少优先**:并发摊到空闲号。**为什么排在到期额度之前**:到期 / 健康度是静态指标,按它们排出的次序确定,并发会全压同一个号(实测 2 小时内一个号吃 180+ 次、其余闲置);在途把它拆开,顺序请求(在途恒 0)不受影响。免费档仍压过在途。 4. **到期额度两级字典序**:先比主窗口(`QUOTA_EXPIRY_WINDOW_SECONDS`,默认 36h)内将过期的额度,打平再比次窗口(`QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS`,默认 7 天)。 -5. **健康度三态**:`known` 降序 > `unknown` > `exhausted`;健康度打平时**账户剩余积分多者优先**,再同分按 `credential_id` 稳定。无可用返回 None。 +5. **健康度三态**:`known` > `unknown` > `exhausted`;打平按剩余积分多者优先,再按 `credential_id` 稳定。 -**为什么冷却分「账号级」与「模型级」两层**:上游拒绝语义不都是账号级问题——`429 + 6004` 是「这个模型在当前账号上用超了」,`400/404 + 11102` 是「当前账号没有这个模型」。一律记账号级会让一次模型级限流把整个账号踢出池。故账号级写 `credentials.cooling_until`,模型级另建 `credential_model_cooldowns`;账号级冷却出现时清空该凭证的模型级条目,防「切模型」绕过。业务码识别只认 `"code": N` 键值形态,不搜裸数字。冷却时长:`PLAN` 12h、`SOFT` 60s(不累计)、`OTHER` 连续 3 次→10m、`MODEL` 600s 起翻倍封顶 2h、`BLOCKED`(无此模型负缓存)6h 起翻倍封顶 24h、`CONCURRENCY` 固定 60s(不翻倍)、`CREDIT` 冷却到次日 04:00。**传输层失败归 `SOFT`**:`httpx.TransportError`(连接超时 / 写请求体超时 / 读超时)与 `UpstreamTransportError` 统一在 `executor._classify` 兜住——这类异常从 `client.stream()` 的 `__aenter__`、`aiter_bytes()`、写请求体等多个位置抛出,逐点包装必漏,集中在一处才不会让「链路瞬态」逃成 `internal_error` 帧(逃逸后既不换号也不冷却、不记账)。刻意不泛化到 `OSError` / `ConnectionError`:磁盘满、句柄耗尽也是它们的子类,误判会去冷却一个完全健康的凭证。**上游给出的重置时刻可覆盖上述常量**:响应头 `Retry-After`(整秒 / HTTP-date)或 `Try again in 17h 59m` 这类文本经 `parse_retry_after` 解析后,按该错误分类的作用域写入冷却,并**双端钳制**在 `[now + 该 ErrKind 现有常量, now + 24h]`——上游给 0 或过去时刻回落常量,否则一个 `Retry-After: 0` 就能把所有号瞬间打爆。无该头时行为与常量完全一致。 +**为什么冷却分两层**:`429 + 6004` 是「这个模型在当前账号用超了」,`400/404 + 11102` 是「当前账号没这个模型」,一律记账号级会让一次模型级限流把整个账号踢出池。账号级写 `credentials.cooling_until`,模型级另建 `credential_model_cooldowns`;账号级冷却出现时清空该凭证的模型级条目,防「切模型」绕过。业务码识别只认 `"code": N` 键值形态。冷却时长:`PLAN` 12h、`SOFT` 60s、`OTHER` 连续 3 次→10m、`MODEL` 600s 起翻倍封顶 2h、`BLOCKED` 6h 起封顶 24h、`CONCURRENCY` 固定 60s、`CREDIT` 到次日 04:00。**传输层失败归 `SOFT`**:`httpx.TransportError` 与 `UpstreamTransportError` 统一在 `executor._classify` 兜住(异常从 `__aenter__`、`aiter_bytes()`、写请求体多处抛出,逐点包装必漏);刻意不泛化到 `OSError` / `ConnectionError`。**上游重置时刻可覆盖常量**:`Retry-After`(整秒 / HTTP-date)或 `Try again in 17h 59m` 经 `parse_retry_after` 解析后按作用域写入,并双端钳制在 `[now + 常量, now + 24h]`。 -**为什么是「窗口内积分总量」而不是「是否即将过期」**:实测 CodeBuddy 额度不是整块周期,而是几十个各自独立到期的小包(每日 100 积分 × N),只存一个日期没有区分度。四个取舍:① 统计窗口内的到期积分总量,账号之间才有可比高低;② **落库到期阶梯**(`[(到期 epoch, 包剩余积分)]`)而非预计算数字,窗口是运行时参数,改阈值立刻生效;③ 过滤条件必须是 `end > now` 且排除已用完的包(上游会把已过期套餐一起返回);④ **两级窗口**,只比 36h 会大量账号同为 0、排序退化成健康度。主窗口 `≤0` 等于关闭整套到期排序。TRAE 的权益包各自独立到期,同构填 `expiry_ladder`;CodeArts 的积分包按各自的套餐 `end_date` 登记到期。**展示与调度指标分开存**:`quota_packages`(含包名/用量,仅展示)与 `quota_expiry_ladder`(只有 `[到期, 剩余]`,选号指标)。 +**为什么是「窗口内积分总量」**:实测 CB 额度是几十个各自独立到期的小包(每日 100 积分 × N),只存一个日期没有区分度。四个取舍:① 统计窗口内到期积分总量,账号间才可比;② 落库到期阶梯 `[(到期 epoch, 包剩余积分)]` 而非预计算数字;③ 过滤条件必须 `end > now` 且排除已用完的包;④ 两级窗口,只比 36h 会大量账号同为 0。**展示与调度指标分开存**:`quota_packages`(含包名 / 用量)与 `quota_expiry_ladder`(只有 `[到期, 剩余]`)。 -**健康度归一化**:`known(0-100) | unknown | exhausted`。CB 取 `CycleCapacityRemainPrecise`/`CycleCapacitySizePrecise`,TRAE 取 `credits_limit - credits_amount` / `credits_limit`。**不用响应顶层的 `TotalDosage`**:它 = Σ(size − CapacityUsed),而 `CapacityUsed` 是上周期口径,本周期消耗记在 `CycleCapacityUsed`/`CycleCapacityRemain`,用它会出现 `remaining == total`、健康度恒 100。**为什么必须三态**:探测失败被算成 0 分会让这类凭证在有健康号时永远轮不到。 +**健康度归一化**:`known(0-100) | unknown | exhausted`。CB 取 `CycleCapacityRemainPrecise`/`CycleCapacitySizePrecise`,TRAE 取 `credits_limit - credits_amount` / `credits_limit`。**不用 `TotalDosage`**:它 = Σ(size − CapacityUsed),而 `CapacityUsed` 是上周期口径,会出现 `remaining == total`、健康度恒 100。**必须三态**:探测失败被算成 0 分会让这类凭证永远轮不到。 -**会话粘性**:识别按可靠性分三级——① 请求体显式会话标识 `conversation_id` / `conversationId` / `prompt_cache_key`;② **请求头会话标识** `x-session-id` / `x-opencode-session` / `x-session-affinity` / `conversation-id`(按序取第一个合法值,须匹配 `[A-Za-z0-9._:-]{1,128}`,经 contextvar 传入不污染中间层签名,键带 `header:` 前缀避免与 body 同值撞键);③ 回落「消息增量前缀指纹」。**头为什么夹在中间**:body 显式键最精确;头是客户端主动声明的会话身份,比指纹猜测可靠;而指纹只是启发式兜底。头值与其他键一样掺 `username` 再哈希——头是客户端可写的,不掺等于允许一个用户靠猜头值占用别人的账号。TTL(`CONVERSATION_STICKY_SECONDS`,默认 1h)内固定复用,避免中途换号触发上游风控并丢提示词缓存。请求体带 `user_id` 时**不派生**第 3 级前缀兜底键(同用户并行对话前缀可能相同,会误钉;头照常生效)。手动 pin 优先于粘性;凭证报错仍走正常轮换;条目纯内存。`prompt_cache_key`(OpenAI 官方顶层参数)与 `metadata.user_id`(Anthropic 官方字段)已核实;`conversation_id` / `conversationId` / 顶层 `user_id` 非两家标准键,属客户端惯用约定,作为兼容探测接受。 +**会话粘性**:三级识别——① 请求体 `conversation_id` / `conversationId` / `prompt_cache_key`;② 请求头 `x-session-id` / `x-opencode-session` / `x-session-affinity` / `conversation-id`(须匹配 `[A-Za-z0-9._:-]{1,128}`,经 contextvar 传入,键带 `header:` 前缀避免与 body 撞键);③ 消息增量前缀指纹。**头为什么夹中间**:body 显式键最精确;头是客户端主动声明的身份,比指纹可靠。头值掺 `username` 再哈希——否则一个用户可靠猜头值占用别人的账号。TTL(`CONVERSATION_STICKY_SECONDS`)默认 1h。带 `user_id` 时**不派生**前缀兜底键(同用户并行对话前缀可能相同)。手动 pin 优先;凭证报错仍走轮换;条目纯内存。 ### 3.4 模型名三字段 -八条渠道每个模型统一为三个字段,同一条链派生:**原代号**(`raw_id`,渠道请求时真正发的 key,**永不改动**,转发时经别名表换回)、**归一键**(剥掉免费档标记 `-free` / 路径段 `free`、新版标记 `-new` 与命名空间前缀后 slug 化,模型列表按它判断同一模型并合并)、**展示名**(`name`,清洗后的可读文本,上游可读名优先、无则由原代号派生、品牌缩写按官方写法纠正)。 +同一链派生:**原代号**(`raw_id`,转发时经别名表换回,永不改动)、**归一键**(剥掉 `-free` / `free` 路径段、`-new` 与命名空间前缀后 slug 化,合并键)、**展示名**(`name`,上游可读名优先,无则从原代号派生)。 -**合并键 = 归一键**;`/v1/models` 对外 `id` 一律是归一键,原代号进 `by_provider.{渠道}.raw_id`;**原代号、展示名、归一键三种写法都能直接请求**。同模型异名由显式规范表 `MODEL_SYNONYMS` 收敛(键与展示名一起),**只登记人工核实的等价、不做模糊匹配**。哨兵名(`auto` / `default`)语义只在本渠道内成立,不跨渠道合并;同渠道内归一键重复或对外 id 撞车时退回原代号 / 加后缀。黑名单按**归一后的对外写法**匹配(原代号、其归一键、展示名、展示名归一键四种任一命中即滤)。 +`/v1/models` 对外 `id` 一律归一键,原代号进 `by_provider.{渠道}.raw_id`;三种写法都能直接请求。同模型异名由 `MODEL_SYNONYMS` 收敛,只登记人工核实的等价。哨兵名(`auto` / `default`)只在本渠道内成立。黑名单按归一后的对外写法匹配。 ### 3.5 登录三轨 -`AuthFlow` 三值:`poll`(后端轮询上游:CB / Qoder)、`callback`(浏览器 302 回本服务 `/authorize`:TRAE)、`paste`(用户把回调链接粘回:CodeArts,服务端监听不到本机回环端口)。 +`AuthFlow` 三值:`poll`(CB / Qoder)、`callback`(TRAE,302 回 `/authorize`)、`paste`(CodeArts,服务端监听不到本机回环端口)。 -- **回调统一走主端口** `/authorize`;远程部署只需暴露一个端口。回调地址要写进登录 URL,故必须可配:`PUBLIC_BASE_URL`。 -- 前端在「凭证管理」页内实现:**poll** 拿到 `authUrl` 开新窗、轮询 `/api/auth/upstream/poll`;**callback** 开授权窗、浏览器 302 回 `/authorize` 直接落库,前端轮询凭证列表出现新条目即视为完成(上游不回传 state)。`/authorize` 无鉴权(浏览器 302 不带 key),故按**发起人会话** + **`loginTraceID`**(TRAE 原样回显登录 URL 里的 `(machine_id + device_id)[-16:]`,攻击者拿不到)双重绑定,且待完成登录有 TTL(10 分钟)。 +- 回调统一走主端口 `/authorize`,远程部署只需暴露一个端口。回调地址写进登录 URL,故必须可配 `PUBLIC_BASE_URL`。 +- 前端在「凭证管理」页内实现:**poll** 拿 `authUrl` 开新窗、轮询 `/api/auth/upstream/poll`;**callback** 开授权窗、302 回 `/authorize` 落库,前端轮询列表出现新条目即完成。`/authorize` 无鉴权,故按**发起人会话** + **`loginTraceID`** 双重绑定,待完成登录有 TTL(10 分钟)。 ### 3.6 中立事件层 -上游 SSE 解析到「中立事件」(`EventKind`:`content` / `reasoning` / `tool_calls` / `usage` / `finish` / `error`)独立成层。出口协议适配器(OpenAI / Responses / Anthropic)只消费中立事件,不动上游逻辑。 +上游 SSE 解析到中立事件(`EventKind`:`content` / `reasoning` / `tool_calls` / `usage` / `finish` / `error`),出口适配器(OpenAI / Responses / Anthropic)只消费中立事件。 -**入口语义收口**(不静默降级,也不把畸形请求打到上游把凭证打凉):chat 入站的 `response_format` 只放行 `text` / `json_object` / `json_schema`(后者要求非空 `name` 与 `schema`,未知字段丢弃),Responses 的 `text.format` 复用**同一个**校验函数(`compat/openai/request.py: normalize_response_format`,`prefix` 只改报错路径)。Anthropic 的 `cache_control` **默认原样透传**(落点:`system` 块、user/assistant 块、`tool_result` 挂 tool 消息顶层、`tool_calls` 元素顶层、`tools` 元素顶层),只校验结构(对象 + 非空字符串 `type`,`ttl` 若给必须字符串),取值交给上游裁决;无标记时映射结果逐字节不变,会话粘性指纹与上游前缀缓存不受影响。 +**入口语义收口**:chat 的 `response_format` 只放行 `text` / `json_object` / `json_schema`(后者要求非空 `name` 与 `schema`,未知字段丢弃),Responses 的 `text.format` 复用同一个校验函数。Anthropic 的 `cache_control` **默认原样透传**,只校验结构,取值交上游裁决;无标记时映射结果逐字节不变。 ### 3.7 账号与权限面 -管理面身份来自 SQLite `users` 表。三角色:`admin`(用户管理、运行时配置、凭证写操作、全量统计、审计、运维告警、`require_admin`)、`operator`(凭证写操作:导入 / 删除 / 启停 / pin / 切换账号 / 签到 / 成长,全量统计,上游状态、模型列表,`require_operator`)、`viewer`(只读:仪表盘、凭证列表、Playground、统计;审计为 admin-only,登录即可)。 +三角色:`admin`(用户管理、运行时配置、凭证写、全量统计、审计、告警)、`operator`(凭证写、全量统计、上游状态、模型列表)、`viewer`(只读:仪表盘、凭证列表、Playground、统计)。 -- **鉴权链**:签名 Cookie(HMAC,payload 带用户名与 `session_epoch`)→ 用户仍存在且启用 → epoch 与 DB 一致 → 角色现读 DB。四者缺一即 401;**角色不进 Cookie**,降级立即生效。 -- **吊销不建会话表**:`users.session_epoch` 在改密 / 改角色 / 启停 / 硬删时 `+1`,旧 Cookie 立即失效。 -- **建号与重置走一次性激活令牌**:库里只存 SHA-256 摘要 + 过期时间,明文仅在响应里回显一次,用户在 `/activate` 自设密码。被重置的用户改密前只放行「看会话 / 改密 / 登出」。 -- **删除语义与审计**:**禁用是主路径**(可逆、保住用量归属),硬删只在 CLI(`scripts/create_user.py --delete --force`),管理台不暴露 `DELETE`;登录成败、账号变动、凭证写操作全部入 `audit_events`,**绝不记密码或令牌明文**。 -- **防锁死三层**:Web 端「最后一个活跃 admin」+ 自我降级 / 自禁用守卫;CLI 拒绝硬删最后一个活跃 admin;bootstrap 发现无活跃 admin 直接启动失败。**引导**:`users.txt` 启动时一次性导入(幂等、不覆盖已存在),`ADMIN_USERNAMES` 只标引导期提权。 +- **鉴权链**:签名 Cookie(HMAC,带用户名与 `session_epoch`)→ 用户仍存在且启用 → epoch 与 DB 一致 → 角色现读 DB。缺一即 401;**角色不进 Cookie**,降级立即生效。 +- **吊销不建会话表**:`users.session_epoch` 在改密 / 改角色 / 启停 / 硬删时 `+1`。 +- **建号与重置走一次性激活令牌**:库里只存 SHA-256 摘要 + 过期时间。被重置的用户改密前只放行「看会话 / 改密 / 登出」。 +- **删除语义**:禁用是主路径(可逆、保住用量归属),硬删只在 CLI,管理台不暴露 `DELETE`;登录成败、账号变动、凭证写操作全部入 `audit_events`,绝不记密码或令牌明文。 +- **引导与防锁死**:`ADMIN_USERNAMES` 只在引导期提权;三层防锁死 = Web 端守卫 + CLI 拒绝硬删最后一个活跃 admin + bootstrap 发现无活跃 admin 直接启动失败。 ## 4. 数据模型 -DDL 以 [src/db/schema.sql](src/db/schema.sql) 为准,共 **13 张表**,`SCHEMA_VERSION = 29`。变更纪律:schema.sql **只加不改**;新增列走 `_MIGRATION_COLUMNS` 幂等补列,删表须在 `_MIGRATION_DROPS` 补一条。 +DDL 以 [src/db/schema.sql](src/db/schema.sql) 为准,共 13 张表,`SCHEMA_VERSION = 29`。变更纪律:schema.sql **只加不改**;新增列走 `_MIGRATION_COLUMNS` 幂等补列,删表须在 `_MIGRATION_DROPS` 补一条。 | 表 | 用途 | |---|---| -| `api_keys` | API Key(SHA-256 摘要 + 预览 + 渠道绑定 `provider_binding` + 来源 IP 白名单 `allowed_ips` + 模型白名单 `allowed_models` + 到期 `expires_at` + 配额 `quota`) | -| `credentials` | 加密凭证(`data_enc` 走 Fernet)+ 调度状态(`enabled`/`disabled`/`pinned`/`health`/`cooling_until`/`err_count`)+ 额度与到期阶梯 + token 到期列 + 成长中心最近结果 + 运维标签 `tags`(仅展示筛选,不参与调度) | -| `credential_limits` | 凭证级限额护栏(保留积分 / 每日 token / 每日 credit / 按模型 token);**缺行 = 不受限** | -| `usage_events` | 请求明细(token / credit / 缓存命中 / 成本 / 耗时 / 首字延迟 / 请求 ID / 发起请求的 `api_key_id`),保留 90 天 | -| `usage_hourly` | 小时汇总(永久保留),渠道 / 模型 / 用户维度统计读此表 | -| `growth_events` | 成长中心每轮一行汇报(仅 CodeBuddy);活跃上报复用同表 | -| `credit_events` | 两次额度探测之间的**净变化**(含 `window_start`、归因已知度 `source` 与账本 `ledger`=`pool`/`credits`),不是动作归因 | +| `api_keys` | Key(SHA-256 摘要 + 预览 + 渠道绑定 + IP 白名单 + 模型白名单 + 到期 + 配额) | +| `credentials` | 加密凭证(Fernet)+ 调度状态 + 额度与到期阶梯 + token 到期 + 成长结果 + 运维标签 | +| `credential_limits` | 凭证级限额护栏;**缺行 = 不受限** | +| `usage_events` | 请求明细,保留 90 天 | +| `usage_hourly` | 小时汇总(永久),渠道 / 模型 / 用户维度读此表 | +| `growth_events` | 成长中心每轮一行(仅 CodeBuddy);活跃上报复用 | +| `credit_events` | 两次探测之间的**净变化**(`window_start`、`source`、`ledger`),不是动作归因 | | `credential_model_cooldowns` | (凭证, 模型) 级冷却与「无此模型」负缓存,含 `hits` 供指数退避 | -| `runtime_settings` | 运行时配置覆盖(纯 key/value,只存管理台改过的 key),白名单校验在 `runtime_settings.HOT_SETTINGS` | -| `users` | 账号(PBKDF2 密码哈希 + `role` + `enabled` + `must_change_password` + `session_epoch` + 一次性激活令牌摘要) | -| `audit_events` | 审计流水(登录 / 账号变动 / 凭证写操作) | -| `alert_events` | 运维告警事件(四类规则命中落一行;`resolved_at` 非空 = 迟滞确认恢复) | +| `runtime_settings` | 运行时配置覆盖(纯 key/value),白名单在 `runtime_settings.HOT_SETTINGS` | +| `users` | 账号(PBKDF2 + `role` + `enabled` + `must_change_password` + `session_epoch` + 激活令牌摘要) | +| `audit_events` | 审计流水 | +| `alert_events` | 告警事件(四类规则;`resolved_at` 非空 = 迟滞确认恢复) | -- 调度状态与到期阶梯**落库**,重启不丢冷却状态与到期阶梯。签到去重、模型列表缓存、后台任务运行态均**进程内实现、不建表**(运行态重启归零,不编造重启前记录)。 -- 模型目录落盘到 `DATA_DIR/model_catalog.json`(未过滤原始表),启动同步读回;OpenRouter 目录落盘 `data/openrouter_catalog.json`。 -- **脱敏纪律**:不存提示词、回答、请求头、Token、工具参数、原始错误体、会话 ID。唯一例外是诊断开关 `DUMP_REQUEST_BODIES=true`(默认关)把 `/v1` 原始请求体落盘到 `data/dumps/`(有界保留 200 份),**含完整对话内容**,不得长期开启、不得随库交付。 +- 调度状态与到期阶梯**落库**,重启不丢;签到去重、模型列表缓存、任务运行态**进程内**(重启归零,不编造记录)。 +- 模型目录落盘 `DATA_DIR/model_catalog.json`,OpenRouter 目录落盘 `data/openrouter_catalog.json`。 +- **脱敏纪律**:不存提示词、回答、请求头、Token、工具参数、原始错误体、会话 ID。唯一例外是 `DUMP_REQUEST_BODIES=true`(默认关,有界保留 200 份,含完整对话内容,不得长期开启)。 ## 5. API 契约 -**外部(API Key 鉴权)**:`POST /v1/chat/completions`(流式 + 非流式,`n` 只接受 1——出口结构只能产生单个 choice,其余值 400 `invalid_request`);`POST /v1/responses`(Responses 子集,Codex CLI);`POST /v1/messages` 与 `POST /v1/messages/count_tokens`(Anthropic Messages 子集,`count_tokens` 本地估算不转发上游)——三者共用同一调度链路;`GET /v1/models`(对外 `id` 为归一键 + `name` + `providers` + `by_provider.{渠道}.{raw_id, credit_rate}` + 可选 `benchmarks` / `cost`(`input`/`output`/`cache_read`,USD/1M,缺价不带该字段)/ `reasoning_efforts`(该渠道生效档位去重集合,未配置不带);自定义别名条目另带 `custom_alias: true`);`GET /v1/models/{id}`(单模型查询,`:path` 容纳带命名空间的渠道原代号;对外 id / 展示名 / 任一渠道原 id / 自定义别名四种写法、大小写不敏感;不存在**或不在该 Key 模型白名单内**一律 404 + `model_not_found`,不泄露池中模型);`GET /v1/usage?days=1|7|30`(自助用量:只查**该 Key 自己**的本地明细,不触发上游,返回 `requests/ok/failed/token/cost_usd` + `limits`/`remaining`/`rate`/`resets_at`,不含凭证 id、昵称或池级信息);`GET /v1/user/balance`(DeepSeek 兼容余额,读探测缓存聚合,不实时打上游);`GET /health`(纯存活)、`GET /healthz`(存活 + 凭证池五类计数,无鉴权)、`GET /metrics`(Prometheus text 格式指标,默认开、无鉴权,`METRICS_ENABLED=false` 关闭)。 +**外部(API Key)**:`POST /v1/chat/completions`(`n` 只接受 1)、`POST /v1/responses`、`POST /v1/messages` + `POST /v1/messages/count_tokens`(本地估算)——三者共用同一调度链路;`GET /v1/models`(`id` 为归一键 + `name` + `providers` + `by_provider` + 可选 `benchmarks` / `cost` / `reasoning_efforts`;自定义别名带 `custom_alias: true`);`GET /v1/models/{id}`(`:path` 容纳带命名空间的原代号;四种写法、大小写不敏感;不存在或不在 Key 白名单内一律 404 + `model_not_found`);`GET /v1/usage?days=1|7|30`(只查该 Key 自己的本地明细);`GET /v1/user/balance`(读探测缓存);`GET /health`、`GET /healthz`、`GET /metrics`(均无鉴权,`METRICS_ENABLED=false` 关闭)。 -**管理台(会话 Cookie + RBAC)**:凭证管理、API Key 管理(含 Key 级配额 `quota`:日请求 / 日 token / 日成本 / RPM / TPM / 并发,`0`=不限;超限 429 + `Retry-After`,判定异常一律**放行**并记 warning)、用量统计(`/api/stats/*`)、Playground、模型列表(operator,含 `GET /api/models/availability` 疑似下线只读视图)、用户管理(admin-only)、审计日志(admin-only)、运维告警(admin-only)、上游状态(operator)、任务与配置(admin-only)、**服务端日志(admin-only,只读)**。 +**管理台(Cookie + RBAC)**:凭证管理、API Key(含六项配额,超限 429 + `Retry-After`,判定异常一律放行并记 warning)、用量统计、Playground、模型列表、用户管理、审计、告警、上游状态、任务与配置、服务端日志。 -- 账号:`GET|POST /api/users`、`PATCH /api/users/{username}`、`POST /api/users/{username}/{disable|enable|reset-password}`(**不提供 DELETE**);自助改密 `POST /api/auth/password`;无鉴权的一次性激活流 `GET|POST /api/auth/activate`;审计查询 `GET /api/audit`。 -- 凭证运维:`POST /api/credentials/{id}/checkin|toggle|revive|probe|accounts/select`、`PUT /api/credentials/{id}/limits`(限额护栏整体替换,全关时删行)、`GET|POST /api/credentials/{id}/growth`、`GET /api/credentials/{id}/credit-events|accounts`。 -- 凭证批量运维:`POST /api/credentials/batch`(`{action: probe|toggle|checkin|revive, ids: [...]}`,可选 `enabled` 用于 `toggle`)。逐条隔离执行——单条失败不影响其余条,响应含 `{total, succeeded, failed, results[]}`;`toggle`/`revive` 落一条汇总审计,`probe`/`checkin` 与单条端点同为只读语义、不写审计。 -- 运行时配置与任务运行态:`GET|PUT /api/settings` + `GET /api/tasks` + `POST /api/tasks/{key}/run`(手动跑一轮)。列表端点接受统一 `sort`(白名单键)+ `order`(`asc`/`desc`);未知键 / 非法值回落到默认,不报错。回调(无鉴权):`GET /authorize`。 -- 运维告警:`GET /api/alerts`(倒序回看,分页)+ `POST /api/alerts/{id}/resolve`(人工标记一条已恢复,admin + CSRF + 审计;用于规则已下线 / 误报等状态机无法自动判定的情况)。后台正常路径会在规则不再命中时自动写 `resolved_at`,且 `AlertTask` 启动时从库里未恢复的行重建「告警中」集合,重启后仍能自愈。 -- 上游状态聚合:`GET /api/upstream-status`(operator)返回后台任务抓取的内存快照(`summary` + `sources`)与任务的当前 `enabled` / `interval_seconds` / `server_time`;**不落盘**,重启后为「尚未抓取」。四种解析器(Statuspage v2 / OpenCode / Better Stack / Atom)归一化到六档,加源只需在 `SOURCE_SPECS` 加一行。 -- 用量统计:`GET /api/stats/{overview,by-provider,by-model,by-user,by-credential,timeline,model-timeline}`(分组表的延迟列可选 P50/P90/P95/P99 四档,默认 P95;`p95_*` 字段与排序键保持兼容;另有输出速度 `tps_visible`(正文)/ `tps`(总产出含思考)/ `tps_estimated` 列,排序键 `tps_visible` / `tps`)、`GET /api/stats/errors`(**失败归因**:窗口内 `ok=0` 明细按 `(provider, model, error_type)` 分组计数 + 最近失败时刻,`limit` ≤ 5000)、`GET /api/stats/events`(rowid 游标 / LIMIT-OFFSET 双模式,`provider`/`model`/`credential_id` 钻取过滤,`ok=0` 只看失败,每行带逐请求速度 `tps` / `tps_visible`、排序键同名,`?format=csv` 导出)、`GET /api/stats/events/stream`(**SSE 实时明细**:`StatsCollector.record` 事务提交后由进程内 `LiveEventHub` 广播刚写入的行,不轮询数据库;每订阅者有界缓冲 200 条、并发上限 8 超出回 429 + `Retry-After`;15s 注释帧保活,`Cache-Control: no-store` + `X-Accel-Buffering: no`;作用域/过滤在订阅者侧逐条判定,与 `/api/stats/events` 同口径)。 -- 服务端日志:`GET /api/logs?limit=`(默认 200,上限 = 缓冲容量)+ `GET /api/logs/stream`(SSE,先回放缓冲再推实时,按 `seq` 去重)。**只读**、admin-only;`LOG_BUFFER_LINES=0` 时两端点均 404。只收本进程 `logging` 记录,不读文件;每行发出前过脱敏白名单。 -- 侧栏数据提醒:`GET /api/nav-badges` 一次返回侧栏各项的待办计数(凭证需重登/耗尽、未恢复告警、上次失败任务、异常上游源、待激活账号、本人将到期 Key、模型目录陈旧)。**按角色裁剪 payload**——缺失的键 = 无权看(admin-only 的告警/任务/用户不下发给 operator/viewer,operator 的上游/目录不下发给 viewer);这是聚合端点,**不做整体 403**(viewer 也要看自己的 API Key)。各计数与对应页面数据**不是同一口径**:只数「待办」(如告警只数 `resolved_at IS NULL`、任务用「上次失败」而非「连续失败达阈值」)。 +- 账号:`GET|POST /api/users`、`PATCH /api/users/{username}`、`POST /api/users/{username}/{disable|enable|reset-password}`(**无 DELETE**);`POST /api/auth/password`;`GET|POST /api/auth/activate`;`GET /api/audit`。 +- 凭证运维:`POST /api/credentials/{id}/checkin|toggle|revive|probe|accounts/select`、`PUT /api/credentials/{id}/limits`、`GET|POST /api/credentials/{id}/growth`、`GET /api/credentials/{id}/credit-events|accounts`。 +- 批量:`POST /api/credentials/batch`(`probe|toggle|checkin|revive`)。逐条隔离,响应 `{total, succeeded, failed, results[]}`;`toggle`/`revive` 落一条汇总审计。 +- 配置与任务:`GET|PUT /api/settings` + `GET /api/tasks` + `POST /api/tasks/{key}/run`。列表端点统一 `sort` + `order`;未知键回落默认。回调:`GET /authorize`。 +- 告警:`GET /api/alerts` + `POST /api/alerts/{id}/resolve`。`AlertTask` 启动时从库里未恢复的行重建集合。 +- 上游状态:`GET /api/upstream-status`(operator)返回内存快照 + 任务运行态;**不落盘**。四种解析器归一化到六档,加源只在 `SOURCE_SPECS` 加一行。 +- 统计:`GET /api/stats/{overview,by-provider,by-model,by-user,by-credential,timeline,model-timeline}`(延迟分位 P50/P90/P95/P99 可切,默认 P95;输出速度 `tps_visible` / `tps` / `tps_estimated`)、`GET /api/stats/errors`(失败归因,`limit` ≤ 5000)、`GET /api/stats/events`(游标 / OFFSET 双模式,钻取过滤,`ok=0`,`?format=csv`)、`GET /api/stats/events/stream`(SSE,订阅者有界缓冲 200、并发上限 8)。 +- 日志:`GET /api/logs` + `GET /api/logs/stream`(只读、admin-only,`LOG_BUFFER_LINES=0` 时 404)。 +- 侧栏提醒:`GET /api/nav-badges`,**按角色裁剪 payload**(缺失 = 无权看),不做整体 403。各计数只数「待办」,与页面数据不是同一口径。 实现以代码为准,使用说明见 [README.md](README.md)。 ## 6. 安全边界 -- **上游端点白名单**:真实 Token 只发往显式配置的地址。CB 启动时强制校验;国际版白名单刻意不含国内站主机;TRAE 的 `apiHost` 导入时按官方地址白名单校验;Zen / Kilo / Qoder / CodeArts 各有独立白名单。 -- **凭证加密**:`data_enc` 走 Fernet,密钥走 `APP_SECRET`(≥16 字符,拒绝低熵值);**丢失 = 已存凭证全部不可解,只能重录**,不做密钥轮换。解密失败返回可行动错误码,不暴露裸 500。 -- TLS 校验默认开启;Host / Origin 白名单;CSP `frame-ancestors 'none'`、`object-src 'none'`、`base-uri 'self'`、`form-action 'self'`。**请求体上限**:登录 8KB、其余 16MB(ASGI 层按实际字节计数,`chunked` 不能绕过)。登录三级限流(全局 / IP / 用户名)+ PBKDF2 并发上限;失败键有数量上限 + 过期淘汰(防内存 DoS)。 -- **API Key 仅存摘要**,明文只在创建时返回一次;可按 Key 限定渠道绑定、来源 IP 白名单(默认**不信** `X-Forwarded-For`,仅 `TRUST_PROXY=true` 时取 XFF 最后一个条目)、模型白名单(fnmatch,无法判定模型名时 fail closed)与到期时间。 -- **会话**:Cookie `SameSite=Lax` + 写操作 CSRF 校验(含 logout);`Secure` **按请求实际协议动态判定**(`X-Forwarded-Proto` 首个值优先,无该头则仅当请求 Host 命中 https 的 `PUBLIC_BASE_URL` 主机才判 Secure)——同一实例 LAN(http) 与隧道(https) 双入口写死任一侧都会打断另一侧;**无 Cookie 的写端点(登录 / `/authorize`)也做跨站防护**:登录按 Origin/Referer 同源校验(防 login CSRF),`/authorize` 按上游原样回显的 `loginTraceID` 绑定到**发起登录那一次**(TRAE 回调本就跨源,Origin/Referer 既拦不住真攻击也可能误杀真回调);会话与 API Key 除签名 / 摘要外校验用户仍存在、启用且 epoch 一致。 -- **错误响应不回流上游正文**:`/v1` 与外层的错误文案只带受控标识(HTTP 状态码 / 上游业务码 / 异常类名),上游错误体摘要只进服务端日志;未匹配的 `/api`、`/v1` 路径返回 JSON 404(不落到 SPA 的 200 + HTML)。**客户端读体中途断开不是服务端错误**:按网关惯例回 `499 client_disconnected`,不落 500 + ASGI Traceback(噪音 + 语义错)。**日志脱敏**:不打印 Token、完整请求体;审计写 INFO(含操作人),**绝不记密码/令牌明文**。 -- **请求 ID 是客户端可写输入**:入站 `X-Request-ID` 按白名单字符集与长度(`[A-Za-z0-9._:-]{1,128}`)校验后才采信与回显,不合法一律丢弃改用自生成——否则日志与统计列会被人塞进换行/超长串。响应头回显最终 ID(含自生成值),不伪装成上游追踪。 -- **服务端日志页同样只出不进**:`src/webapp/log_buffer.py` 的 `redact_or_drop` 在行离开进程前整行过滤——独立敏感词(token/secret/authorization/cookie/password/api_key 及复数和连字符形态)、键值形态(`access_token=`、`X-Api-Key:`、`set-cookie:`、JSON 键)、`sk-…` 形状、`Bearer <值>` 命中即丢弃;空行也丢。代价是 `token 已刷新` 这类正常行会被一起吞掉,取舍是**宁可漏一条日志,也不把凭证推进浏览器**。页面无任何写入/执行入口,订阅者只能读。 -- **请求级结构化日志**(`src/webapp/access_log.py`,`REQUEST_LOG_ENABLED` 可热更):每个业务请求(`/api/*`、`/v1/*`、`/authorize`)一行 logfmt,含 `request_id` / 路由**模板** / 状态码 / 耗时。**不写 query string**(可能被客户端塞 token)、**不写请求体 / 请求头 / 用户名**(身份靠 `request_id` 关联 `usage_events`);未匹配路由时回落为清洗并截断的原始路径,防换行注入。静态资源与探针不记(uvicorn 已覆盖)。 -- **租户隔离**:`/api/stats/events` 与 `/api/stats/by-credential` 的**凭证昵称**取自全局共享池(常含邮箱/手机),仅 admin/operator 可见;viewer 即便只看自己的记录也不下发该字段。`/api/stats/events?format=csv`(整份明细导出)同样仅 admin/operator;`/api/stats/events/stream` 的广播 payload 不含昵称,仅 admin/operator 的连接由服务端逐条补 `credential_name`。 -- **热更配置写入即校验**:float 拒 NaN/±inf;字符串类有长度上限;告警 webhook 只接受 `http(s)` 并做 **SSRF 校验**(写入期拒字面量内网 / 回环 / 链路本地 / 保留 / 多播 / 未指定地址,**投递前再按 DNS 解析结果复核一次**、解析失败即阻断),外发载荷对 `nickname` 打码,多地址并发投递并加总预算。告警出站 client `trust_env=False`,不吃宿主代理环境变量。 -- **上游流解析有界**:SSE 单行与单帧缓冲设上限,非 2xx 错误体有界读取;OAuth state store 的 owner/consume 有 TTL 判定。**入站解析把关**:三个出站协议在入口拒绝越界数值字段(`max_tokens` ≤0 或超上限、`temperature`/`top_p` 为 NaN/Inf)与 JSON 里的 `NaN`/`Infinity`。 -- **OAuth 登录弹窗**只允许 `http(s)` scheme,弹窗 `opener` 置空。**库版本守卫**:`user_version` 高于本程序时 `apply_schema` 拒绝执行。`scripts/hash_password` / `create_user` 支持 `--password-stdin`;拒绝含 `:` / 换行的用户名。 -- **不做**:mTLS;面向管理台与端口的 IP 限制(交给反向代理)。不做全量请求审计(统计表已是脱敏的请求级记录)。 +- **上游端点白名单**:真实 Token 只发往显式配置的地址。CB 启动时强制校验;国际版白名单刻意不含国内站;TRAE 的 `apiHost` 导入时校验;Zen / Kilo / Qoder / CodeArts 各有独立白名单。 +- **凭证加密**:Fernet,密钥走 `APP_SECRET`(≥16 字符,拒绝低熵);**丢失 = 凭证全部不可解**,不做轮换。解密失败返回可行动错误码。 +- TLS 校验默认开启;Host / Origin 白名单;CSP `frame-ancestors 'none'`、`object-src 'none'`、`base-uri 'self'`、`form-action 'self'`。**请求体上限**:登录 8KB、其余 16MB(ASGI 层按实际字节计数)。登录三级限流 + PBKDF2 并发上限;失败键有数量上限 + 过期淘汰。 +- **API Key 仅存摘要**,明文只在创建时返回一次;可限渠道、IP(默认不信 `X-Forwarded-For`,仅 `TRUST_PROXY=true` 时取最后一个)、模型(fnmatch,无法判定时 fail closed)、到期。 +- **会话**:`SameSite=Lax` + 写操作 CSRF(含 logout);`Secure` 按请求实际协议动态判定;无 Cookie 的写端点(登录 / `/authorize`)也做跨站防护(登录按 Origin/Referer 同源校验,`/authorize` 按 `loginTraceID` 绑定);会话与 Key 校验用户存在、启用且 epoch 一致。 +- **错误响应不回流上游正文**:只带受控标识,上游错误体摘要只进日志;未匹配的 `/api`、`/v1` 返回 JSON 404。**客户端读体断开不是服务端错误**:回 `499 client_disconnected`。**日志脱敏**:不打印 Token、完整请求体;审计写 INFO,绝不记明文。 +- **请求 ID 是客户端可写输入**:按 `[A-Za-z0-9._:-]{1,128}` 校验后才采信,不合法改用自生成。 +- **服务端日志页只出不进**:`redact_or_drop` 在行离开进程前整行过滤(独立敏感词、键值形态、`sk-…`、`Bearer <值>`),空行也丢。代价是「token 已刷新」这类正常行被一起吞掉——宁可漏一条,也不把凭证推进浏览器。 +- **请求级结构化日志**(`REQUEST_LOG_ENABLED`):一行 logfmt,含 `request_id` / 路由模板 / 状态码 / 耗时;**不写 query string、请求体、请求头、用户名**;未匹配路由回落为清洗截断的原始路径。 +- **租户隔离**:凭证昵称取自全局共享池,仅 admin/operator 可见;CSV 导出同样仅 admin/operator;SSE 广播 payload 不含昵称,仅 admin/operator 的连接由服务端补 `credential_name`。 +- **热更写入即校验**:float 拒 NaN/±inf;字符串有长度上限;webhook 只接受 `http(s)` 并做 SSRF 校验(写入期拒内网 / 回环 / 保留地址,**投递前再按 DNS 解析复核**),外发载荷对 `nickname` 打码。告警出站 `trust_env=False`。 +- **上游流解析有界**:SSE 单行与单帧缓冲设上限,非 2xx 错误体有界读取;OAuth state store 有 TTL。**入站解析把关**:拒绝越界数值字段与 JSON 里的 `NaN`/`Infinity`。 +- **OAuth 弹窗**只允许 `http(s)`,`opener` 置空。**库版本守卫**:`user_version` 高于本程序时拒绝执行。`--password-stdin` 支持;拒绝含 `:` / 换行的用户名。 +- **不做**:mTLS;管理台与端口的 IP 限制(交给反向代理);全量请求审计。 ## 7. 风险清单 | 风险 | 等级 | 对策 | |---|---|---| -| CB 协议复杂度高(私有信封 + 多业务码 + 成长中心) | 高 | 先 bearer-only 跑通再补 OAuth;成长中心不可逆动作用配置开关总控 | -| 上游 SSE / 私有协议格式变更 | 中 | 各 provider 的 SSE 样本存 fixture 做契约测试;解析失败不静默 | -| 模型 ID 撞车导致路由错误 | 中 | 扁平名 + 归一键合并 + `@渠道` 后门;统计行强制带 provider 字段 | +| CB 协议复杂度高 | 高 | 先 bearer-only 跑通再补 OAuth;不可逆动作用配置开关总控 | +| 上游 SSE / 私有协议变更 | 中 | 样本存 fixture 做契约测试;解析失败不静默 | +| 模型 ID 撞车导致路由错误 | 中 | 扁平名 + 归一键合并 + `@渠道`;统计行强制带 provider | | 上游 credit 不保证可得 | 低 | credit 仅辅助展示;健康度只依赖额度探测接口 | -| 积分语义混淆(周期 vs 账户余额 vs 权益包到期) | 低 | 健康分仅用于调度;展示层标注周期与到期语义 | -| 第三方能力分被误读为本服务实测 | 中 | 三处 UI 均标注「Artificial Analysis 指数,经 OpenRouter 公开接口;非本服务实测」;不做排序 | -| 能力分取了旧快照的评测变体 | 中 | 上游把浮动别名钉在旧快照上并换变体(实测 24.7 vs 39.5)。按 `canonical_slug` 日期归族,浮动别名取同族最新快照;显式快照 id 不改写;`source_model` 指向真正出分的上游 id | -| 上游模型写法差异导致错配 | 中 | 只登记显式等价规则 + 命中不唯一即拒配 | -| CodeArts 一次性 refresh_token 烧废即账号失效 | 中 | 刷新由 `RefreshTask` 独占轮转、刷后回写;终态异常标记「需重新登录」并停重试 | -| 一次性激活令牌 / API Key 明文泄露 | 中 | 明文仅响应回显一次,库里只存 SHA-256 摘要 | -| 部署契约:只改前端时后端可能仍是旧进程 | 中 | 改 `src/` 必须重启进程;升级后按 README「升级后必须重启后端」核对 schema 版本与路由 | -| License 溯源不全 | 中 | NOTICE 列明 7 个参考项目(含各自上游来源)的署名与协议 | - -## 8. NOTICE 三方溯源 - -署名清单以仓库根目录的 [`NOTICE`](NOTICE) 为唯一真源。当前列出的参考项目及其借鉴方向: - -- [IceeAn/codebuddy2api](https://github.com/IceeAn/codebuddy2api):CodeBuddy 上游协议、凭证管理、脱敏统计 -- [connectedGraph/trae2api-web](https://github.com/connectedGraph/trae2api-web):TRAE SOLO 上游协议、账号池冷却状态机 -- [88lin/workbuddy-auto-signin](https://github.com/88lin/workbuddy-auto-signin):成长中心与签到接口的逆向结论与运行经验 -- [Sliverkiss/workbuddy2api](https://github.com/Sliverkiss/workbuddy2api):活跃上报 `/v2/report` 的协议形状与实测结论 -- [ithtelab/workbuddy-manager](https://github.com/ithtelab/workbuddy-manager):后台任务可视化页面的设计参考 -- [shuishuipingan/qoder2api-hub](https://github.com/shuishuipingan/qoder2api-hub):Qoder COSY 签名、自定义 Base64、信封 SSE、设备码登录、签到与额度 -- [HITZY2002/codearts2api](https://github.com/HITZY2002/codearts2api):CodeArts SDK-HMAC-SHA256、DPoP(ES256) 刷新、累计全文 SSE、福利模型 - -均为 MIT License,完整版权行与「其上游」子项见 `NOTICE`。本项目代码为独立实现,不复制上述项目的源代码;上游服务的协议细节来自对客户端行为的观察,不属于上述项目的版权范围。 - -## 9. 其它文档 - -技术栈、模块规格、Provider 协议、请求时序与测试策略见 [`TECHNICAL.md`](TECHNICAL.md);使用与部署、配置清单见 [`README.md`](README.md);架构图 / 请求时序图 / 凭证调度状态机见 [`diagrams/`](diagrams/)。 diff --git a/README.en.md b/README.en.md index 689a0c69..1f1b7edd 100644 --- a/README.en.md +++ b/README.en.md @@ -1,27 +1,24 @@ # Coding2API -Unified OpenAI-compatible gateway for the **CodeBuddy** (CN + international), -**TRAE SOLO**, **OpenCode Zen**, **Kilo**, **Qoder**, **CodeArts**, and **OfficeAce** -coding-agent upstreams, with a shared credential pool, unified scheduling, and per-user usage stats. +Wraps **CodeBuddy** (CN + international), **TRAE SOLO**, **OpenCode Zen**, **Kilo**, **Qoder**, **CodeArts** and **OfficeAce** into an OpenAI-compatible API, with a shared credential pool, unified model names and scheduling, and per-user usage stats. > [!WARNING] -> This project bridges reverse-engineered third-party endpoints; study and research use -> only. Not security-audited. Do not expose it to the public internet without a reverse -> proxy, authentication, and an IP allowlist. +> Reverse-engineered endpoints; study and research only, not security-audited. Public deployment needs a reverse proxy, authentication and an IP allowlist. ## Features -- **OpenAI-compatible surface**: `/v1/chat/completions` (streaming + non-streaming), `/v1/responses` (for the Codex CLI), `/v1/messages` (Anthropic Messages / Claude Code), `/v1/models` plus `/v1/models/{id}` (single-model lookup), `/v1/user/balance` (DeepSeek-compatible). -- **Unified scheduling**: bare model names auto-route by health, `model@provider` pins an upstream. When free channels are candidates, **free wins**; then **least in-flight first** (concurrent requests spread across idle credentials), then expiring credits, then three-state health with tiered cooldowns, ties broken by **largest balance first**. A model-scoped limit sidelines only that model (the same account's other models stay available); a model held by a single cooling channel returns "model temporarily unavailable" instead of fanning out. A multi-turn conversation keeps one credential and rotates only on error. -- **Expiry-aware burn**: credits expiring within the main window (default 36h) burn largest-first (near-expiry quota not wasted), ties fall to a 7-day window. Units unify to credits. -- **Credential-gated model list**: only channels with a currently usable credential are fetched and shown, so a never-connected channel never shows phantom models; pausing or losing credentials hides a channel's models until it recovers. -- **Shared credential pool**: admins maintain credentials, everyone shares them, encrypted at rest (`APP_SECRET`); device-code login, account switching, quota probing, daily check-in, token pre-refresh. -- **Three-role accounts**: `admin` / `operator` / `viewer` in SQLite; one-time activation links (no shared initial secret), forced password change on first login, changing a role or disabling an account revokes its sessions immediately; logins and writes are audited. -- **Growth center** (CodeBuddy only): travel gifts, Buddy dispatch, task rewards, streak repair, streak redemption, blind boxes; irreversible steps off via `GROWTH_IRREVERSIBLE_ACTIONS=false`; runnable from the admin UI. -- **Privacy-preserving stats**: no conversation content stored; 90-day request detail, permanent hourly rollups; per user / channel / model / credential views; cost estimated from OpenRouter list prices (CNY primary); a **Live** toggle in the request-detail panel streams just-committed requests over SSE. -- **Sidebar to-dos**: count badges on sidebar entries for things you would otherwise only notice by visiting the page (credentials needing re-login or out of quota, unresolved alerts, failed background tasks, abnormal upstream sources, pending activations, your own expiring API keys, stale model catalog). One aggregate endpoint, role-filtered, counts only outstanding items. -- **Admin security**: login rate limiting, CSRF checks, body limits, Host allowlist. -- **Responsive tables**: wide tables (credentials, request detail, model list, …) turn into one card per row below 768px — column names collapse into a row of tappable sort chips and each cell shows its field name inline. The DOM is unchanged, so screen-reader semantics match the wide layout. +- **OpenAI-compatible surface**: `/v1/chat/completions` (streaming / non-streaming), `/v1/responses` (Codex CLI), `/v1/messages` (Claude Code), `/v1/models` (incl. single-model lookup), `/v1/user/balance`. +- **Unified scheduling**: bare model names route by health, `model@provider` pins an upstream. Free channels first → least in-flight → expiring credits → health with tiered cooldowns → largest balance. A model-scoped limit sidelines only that model; multi-turn conversations stick to one credential and rotate only on error. +- **Expiry-aware burn**: credits expiring within the main window (default 36h) burn first, ties fall to the 7-day window. Units unify to credits. +- **Credential-gated model list**: only channels with a usable credential appear; no phantom models. +- **Shared credential pool**: admins maintain, everyone shares, encrypted at rest (`APP_SECRET`); device-code login, account switching, quota probing, daily check-in, token pre-refresh. +- **Three roles**: `admin` / `operator` / `viewer` in SQLite; one-time activation links, forced password change on first login, role change or disable revokes sessions immediately; logins and writes audited. +- **Growth center** (CodeBuddy only): travel gifts, Buddy dispatch, task rewards, streak repair/redemption, blind boxes; irreversible steps off via `GROWTH_IRREVERSIBLE_ACTIONS=false`. +- **Privacy-preserving stats**: no conversation content; 90-day detail, permanent hourly rollups; per user / channel / model / credential views; cost from OpenRouter list prices; output speed in two scopes; SSE live stream in the detail panel. +- **Upstream status aggregation**: official status pages side by side, separating "upstream outage" from "our credential / quota problem". Explanation only, never scheduling; memory only. +- **Sidebar to-dos**: count badges (re-login / exhausted, unresolved alerts, failed tasks, abnormal upstreams, pending activations, expiring keys, stale catalog), role-filtered. See [below](#sidebar-to-dos-navigation-badges). +- **Admin hardening**: login rate limiting, CSRF checks, body limits, Host allowlist. +- **Responsive tables**: below 768px one row becomes one card; column names collapse into tappable sort chips. DOM unchanged. Protocol-level implementation (private envelopes, signing, gate spoofing) is in [`TECHNICAL.md`](TECHNICAL.md) (Chinese). @@ -31,41 +28,32 @@ Requirements: Python 3.12+, [uv](https://docs.astral.sh/uv/), Node.js 24+ and pn ```bash uv sync -# Create the first admin (add the rest from the "Users" page later) -uv run python scripts/create_user.py admin --role admin +uv run python scripts/create_user.py admin --role admin # first admin cd web && pnpm install && pnpm build && cd .. APP_SECRET="pick-a-random-string" \ uv run python -m uvicorn src.main:build_app --factory --port 8000 ``` -Open and sign in. +Open . Add the rest from the *Users* page. -> `scripts/create_user.py` writes directly to SQLite (`--db`, or `DATA_DIR`; default `data/coding2api.sqlite3`). On startup, an existing `secrets/users.txt` is imported once (existing usernames are never overwritten). +`scripts/create_user.py` writes directly to SQLite (`--db`, or `DATA_DIR`; default `data/coding2api.sqlite3`). An existing `secrets/users.txt` is imported once on startup (never overwritten). -### Docker +Docker: ```bash cat > .env <<'EOF' APP_SECRET=pick-a-random-string PUBLIC_BASE_URL=http://127.0.0.1:8000 EOF -docker compose build -docker compose up -d +docker compose up -d --build docker compose exec coding2api python scripts/create_user.py admin --role admin ``` -Pull the GHCR image instead of building: - -```bash -docker compose pull -# or pin a version: docker pull ghcr.io/robbsluo/coding2api:v0.9.1 -``` - -Pushing a `v*` tag to main triggers the publish workflow (`.github/workflows/publish.yml`), tagging both `` and `:latest` on GHCR. +Pull the image: `docker compose pull` (or `docker pull ghcr.io/robbsluo/coding2api:v0.9.1`). Pushing a `v*` tag to main triggers `.github/workflows/publish.yml`, publishing `` and `:latest`. ## Users and roles -Accounts live in SQLite's `users` table; the *Users* page (admins only) handles day-to-day management. +Accounts live in SQLite's `users` table; the *Users* page (admins only) handles management. | Role | Can do | |---|---| @@ -73,46 +61,44 @@ Accounts live in SQLite's `users` table; the *Users* page (admins only) handles | `operator` | Credential read/write (import / delete / pause / check-in / growth / account switch), all stats, upstream status, model list | | `viewer` | Read-only: dashboard, credential list, stats, Playground | -**Adding users**: an admin sets a username + role and gets a **one-time activation link** (valid 24h, shown once). The user opens `/activate` and sets their own password — no "admin knows an initial password" secret exists. "Reset password" issues a new link. - -**Role change / disable**: effective immediately — all issued session cookies for that account are invalidated (re-login required). Disabling is the default way to "remove" someone (reversible, usage still attributed to the username); **hard delete is CLI-only** (it would leave orphan `usage_events` rows). - -**Self-service password change**: user menu → *Change password*, requires the current password. +- **Adding users**: set username + role, get a one-time activation link (24h, shown once); the user opens `/activate` and sets their own password. No "admin knows an initial password" secret exists. Reset works the same way. +- **Role change / disable**: all session cookies for that account are invalidated immediately. Disabling is the reversible default way to "remove" someone (usage stays attributed); hard delete is CLI-only. +- **Self-service password change**: user menu → *Change password*, current password required. +- **Lockout guard**: cannot disable or demote the last active admin, nor demote or disable yourself. -**Lockout guard**: you cannot disable or demote the **last active admin**, nor demote or disable yourself. Add another `admin` first. - -**CLI fallback**: +CLI fallback: ```bash python3 scripts/create_user.py alice --role admin # interactive password python3 scripts/create_user.py --list python3 scripts/create_user.py alice --delete --force # hard delete, irreversible -echo "$PASSWORD" | python3 scripts/create_user.py alice --role admin --password-stdin # password off argv / shell history +python3 scripts/create_user.py --db data/x.sqlite3 ... +echo "$PASSWORD" | python3 scripts/create_user.py alice --role admin --password-stdin ``` -`ADMIN_USERNAMES` and `secrets/users.txt` are **bootstrap-only**: on startup, `users.txt` is imported into the DB and the named users are promoted to `admin`; afterwards roles come from the DB alone. +`ADMIN_USERNAMES` and `secrets/users.txt` are bootstrap-only; afterwards roles come from the DB alone. ## Audit log -The *Audit log* page (admins only) records logins and writes, filterable by actor/action and paginated: **logins** (success and failure), **account changes** (create, activate, role change, enable/disable, password change/reset, hard delete), **credential writes** (import, delete, pause, pin, revive, account switch) and **manual alert resolution**. Each row has time, actor, action, target, detail and source IP. **No passwords or token plaintext** (the one-time activation token appears once in the create response; only its SHA-256 digest is stored). Retention matches request detail (90 days), trimmed by the background cleanup task. +The *Audit log* page (admins only) records logins and writes, filterable by actor / action and paginated: logins (success and failure), account changes, credential writes, manual alert resolution. Each row has time, actor, action, target, detail and source IP. **No passwords or token plaintext** (activation tokens store only a SHA-256 digest). Retention matches request detail (90 days). ## Server logs -The *Server logs* page (`/logs`, admins only) is a **read-only** live view of this process's own logging: `GET /api/logs?limit=` returns recent lines, `GET /api/logs/stream` is SSE (buffer replay, then live). The page offers level filtering, keyword search, follow-latest, pause and clear — **no write or execute entry point**, subscribers can only read. +The *Server logs* page (`/logs`, admins only) is a read-only live view of this process's logging: `GET /api/logs` returns recent lines, `GET /api/logs/stream` is SSE (replay then live). Level filtering, keyword search, follow-latest, pause and clear — **no write entry point**. -- **This process only**: it attaches a `logging.Handler`, it never tails a file. Under multiple processes or containers each one sees its own slice; that is deliberate, since log rotation and permissions make a file view lie. -- **Redaction is a hard precondition**: every line passes an allow-list before leaving the process. Anything matching `token` / `secret` / `authorization` / `cookie` / `password` / `api_key` (including `X-Api-Key:`, `client_secret=` and JSON key forms), an `sk-…` shape or `Bearer ` is **dropped whole**. Losing a log line beats pushing a credential into a browser. -- **Bounded buffer**: size is `LOG_BUFFER_LINES` (default 500, hot-reloadable), oldest overwritten first; lowering it trims immediately. `0` disables the page (both endpoints 404 and the handler is detached), matching how `/metrics` turns off. -- Each line is capped at 4000 chars and carries `seq` (replay/live dedupe), timestamp, level, logger and `request_id` when available. Subscriber limit is 4; beyond that 429 + `Retry-After`. +- This process only; it never tails a file. Under multiple processes each sees its own slice. +- **Redaction is a hard precondition**: lines matching `token` / `secret` / `authorization` / `cookie` / `password` / `api_key`, an `sk-…` shape or `Bearer ` are dropped whole. +- Buffer size `LOG_BUFFER_LINES` (default 500, hot-reloadable), oldest overwritten first; `0` disables the page (both endpoints 404). +- 4000 chars per line; each carries `seq`, timestamp, level, logger, `request_id`. Subscriber limit 4, beyond that 429 + `Retry-After`. ## Usage -1. **Credentials** page — add credentials: +1. **Credentials** — add credentials: - **CodeBuddy** (incl. international): device-code login, or paste `{"token":"..."}`. - - **TRAE**: paste the credential JSON (`accessToken`/`uid`/`refreshToken`) or the callback URL (`apiHost` must be an official address). - - **Qoder / CodeArts**: device-code / OAuth2 authorization-code login (CodeArts: paste the unreachable local callback URL back into the page). - - **OfficeAce**: click *Log in to OfficeAce* for the Huawei Cloud authorization (the callback is handled in the cloud — no URL to paste, no local port). - - **OpenCode Zen / Kilo**: nothing to add — a virtual credential is seeded at startup (re-added from the same panel if deleted). + - **TRAE**: paste the credential JSON (`accessToken`/`uid`/`refreshToken`) or the callback URL (`apiHost` must be official). + - **Qoder / CodeArts**: device-code / OAuth2 authorization-code login (CodeArts: paste the unreachable local callback URL back). + - **OfficeAce**: *Log in to OfficeAce*, Huawei Cloud authorization (callback handled in the cloud). + - **OpenCode Zen / Kilo**: nothing to add — a virtual credential is seeded at startup (re-addable from the same panel). 2. **API keys** — create an `sk-...` (plaintext shown once). 3. Call it: @@ -123,560 +109,701 @@ curl http://127.0.0.1:8000/v1/chat/completions \ -d '{"model":"glm-5.2","messages":[{"role":"user","content":"hello"}]}' ``` -`model@provider` pins an upstream (`glm-5.2@trae`, `mimo-v2.5-free@zen`); bare model names route automatically. Any OpenAI-compatible client can connect (Base URL `http://127.0.0.1:8000/v1`, key `sk-...`, model names per `GET /v1/models`); the *Playground* page tests with the session login, no API key. +`model@provider` pins an upstream (`glm-5.2@trae`, `mimo-v2.5-free@zen`); bare names route automatically. Any OpenAI-compatible client connects (Base URL `http://127.0.0.1:8000/v1`). *Playground* tests with the session login, no API key. ### Three model-name fields -Every model on the eight channels is unified into three fields: +1. **Raw id** (`raw_id`): the key actually sent upstream, never changed. Raw ids differ per channel (Qoder `kmodel_latest` = TRAE `kimi-k3-1`) and may carry a namespace prefix (Kilo's `kilo-auto/free`). +2. **Canonical key**: free-tier markers (`-free` / `free` path segment) and namespace prefixes stripped, then slugified (`kilo-auto`). The list merges by it. +3. **Display name** (`name`): cleaned readable text, from upstream when given, otherwise derived; brand casing normalized (`DeepSeek` / `MiMo` / `GLM` / `GPT`). + +Every `id` in `GET /v1/models` is the canonical key; requests translate back to the raw id (`by_provider.{channel}.raw_id`). **All three spellings are directly requestable.** Duplicate canonical keys within a channel fall back to the raw id. + +**Cost and levels**: entries may carry `cost {input, output, cache_read}` (USD / 1M tokens, same price table as cost estimation) and `reasoning_efforts` (effective levels for that channel). Present only when known. -1. **Raw id** (`raw_id`): the key actually sent upstream, **never changed**. Raw ids differ per channel (Qoder `kmodel_latest` = TRAE `kimi-k3-1`) and may carry a namespace prefix (Kilo's `kilo-auto/free`). -2. **Canonical key**: free-tier markers (`-free` / path segment `free`) and namespace prefixes stripped, then slugified — `kilo-auto`, `longcat-2.5-preview`. The list merges entries by it, so Zen's free tier and TRAE's paid tier collapse into one row. -3. **Display name** (`name`): cleaned readable text, from upstream when given (`Qwen3.8-Max` → `Qwen3.8 Max`), otherwise derived; brand casing normalized (`DeepSeek` / `MiMo` / `GLM` / `GPT`). +**Custom aliases** (`MODEL_ALIASES`, hot-reloadable): `alias=model@channel;alias2=model`; `@channel` optional. List and request resolution share one table; entries carry `custom_alias: true`. Collisions, missing targets, or a channel not holding the model log ERROR and skip that segment; no alias chains. -Every `id` in `GET /v1/models` is the canonical key; requests are translated back to the channel's raw id (`by_provider.{channel}.raw_id`). **All three spellings are directly requestable** (`kmodel_latest`, `Kimi K3`, `kimi-k3` all resolve to the same model). +**Single-model lookup** (`GET /v1/models/{id}`): all three spellings plus custom aliases, case-insensitive, namespaced raw ids work. Missing or outside the key's allowlist → 404 (`code=model_not_found`), so a client cannot probe the pool. -**`GET /v1/models/{id}` single-model lookup** (same shape as OpenAI): all three spellings resolve, case-insensitively. The route uses `:path`, so namespaced raw ids (`dots-studio/dots-3-note-preview:free`) work too. A model that does not exist **or is outside the calling key's model allowlist** returns the same 404 with an OpenAI error body (`code=model_not_found`) — allowlisted-out and missing look identical, so a client cannot probe "what else is in the pool". Benchmarks and refresh timing come from the same merged snapshot as the list, so a model shown in the list never 404s on lookup. +**`n` only accepts 1**: the outbound shape produces a single choice; anything else is 400. ### Streaming usage and reasoning tokens -- **Streaming usage frame**: after the finish chunk and before `data: [DONE]`, a `choices: []` usage frame (standard OpenAI shape) lets stream-reading clients show tok/s, context occupancy and session totals; nothing is sent when the upstream reported no usage (never a fake `0`), and unreported fields are `null`. -- **Reasoning tokens need `reasoning_effort`**: CodeBuddy defaults to `medium` when the client omits it (the upstream would otherwise write the whole reasoning pass into the visible answer); explicit values pass through untouched. +- After the finish chunk and before `data: [DONE]`, a `choices: []` usage frame is appended; nothing when the upstream reported no usage, and unreported fields are `null`. +- Reasoning tokens need `reasoning_effort`; CodeBuddy defaults to `medium` when absent (otherwise reasoning lands in the visible answer). Explicit values pass through. + +### Streaming timeouts (off by default) + +Upstream reads use `read=None`, so the gateway supplies its own: + +- **First-event timeout** (`STREAM_FIRST_EVENT_TIMEOUT_SECONDS`, default 0): fires before any content reaches the client, so retrying on another credential is safe. Comment frames / heartbeats do not reset it. +- **Idle timeout** (`STREAM_IDLE_TIMEOUT_SECONDS`, default 0): after the first frame, the stream is closed with an error frame and **not** retried (partial output cannot be rolled back). + +Both default to off on purpose: slow contexts measured 40–71s to first token. Enable per deployment (recommended 60 / 300). ### Playground advanced parameters -The Playground request form carries optional `temperature` / `max_tokens` / `reasoning_effort` inputs; **leaving one empty omits the key entirely** (each channel then applies its own default). Client-side validation matches `compat/openai/request.py` (`temperature` up to 1000, `max_tokens` a positive integer up to 4,000,000) so nothing passes the UI only to be rejected as a 400. The `reasoning_effort` control appears only when the model metadata reports `supports_reasoning` or a `default_effort`, offers the standard levels (`low`/`medium`/`high`, plus the upstream-declared default if it is outside that set), and starts at "not sent" — the upstream decides whether a given level is acceptable. +Optional `temperature` / `max_tokens` / `reasoning_effort`; **leaving one empty omits the key**. Client-side validation matches the backend (`temperature` ≤ 1000, `max_tokens` ≤ 4,000,000). The `reasoning_effort` control appears only for reasoning models and starts at "not sent". ## Channels -> CodeBuddy (incl. international) / TRAE / Qoder / CodeArts / OfficeAce need credentials; **OpenCode Zen and Kilo are credential-less free tiers** — the pool holds one virtual placeholder row (so scheduling / cooldown / stats work as usual), the quota column reads "free tier (no quota API)", and health stays "no probe" (unknown ≠ exhausted). The virtual row **re-seeds on restart after deletion**; use *pause* to disable permanently. - -### OpenCode Zen free tier (`zen`) +> CodeBuddy (incl. international) / TRAE / Qoder / CodeArts / OfficeAce need credentials; **OpenCode Zen and Kilo are credential-less free tiers** — one virtual placeholder row (scheduling / cooldown / stats work as usual), quota reads "free tier (no quota API)", health stays "no probe". The virtual row re-seeds on restart after deletion; use *pause* to disable permanently. -Free models at [opencode.ai/zen](https://opencode.ai), no account or login. The upstream list mixes unmarked paid models, so the gateway narrows by the `-free` suffix and probes each candidate, exposing **only free models that answer anonymously** (liveness cached 30 min, no static allowlist; free models marked **x0**). The upstream list has no display names, so names are derived from the id. +### OpenCode Zen (`zen`) -**Free-tier gate**: the upstream wants to believe it is talking to the official client (UA version, session header, `stream:true`, `tools` with `bash`/`read`). The gateway satisfies this; the injected `bash`/`read` are empty shells and their calls are filtered out, so you never see functions you did not declare. UA version via `ZEN_OPENCODE_VERSION`, endpoint via `ZEN_API_ENDPOINT` (must be inside `ZEN_ALLOWED_ENDPOINTS`). Paid models return "unavailable" even when pinned. +Free models at [opencode.ai/zen](https://opencode.ai), no login. The upstream list mixes unmarked paid models, so the gateway narrows by the `-free` suffix and probes each candidate, exposing only what answers anonymously (liveness cached 30 min; free models marked **x0**). -### Kilo free tier (`kilo`) +**Free-tier gate**: the upstream wants the official client (UA version, session header, `stream:true`, `tools` with `bash`/`read`). The gateway satisfies this; injected `bash`/`read` are empty shells and their calls are filtered out. UA via `ZEN_OPENCODE_VERSION`, endpoint via `ZEN_API_ENDPOINT` (inside `ZEN_ALLOWED_ENDPOINTS`). Paid models return unavailable even when pinned. -Free models at [Kilo](https://kilo.ai) (`api.kilo.ai/api/gateway`), no account or login. Standard OpenAI protocol, no private envelope or gate spoofing; the upstream `/models` carries an authoritative `isFree` flag, so the gateway filters on it directly and **does not probe** (probing burns the tiny quota). Free models marked **x0**. +### Kilo (`kilo`) -**Quota and limits**: the free tier is small (gateway-wide ~200 requests/hour/IP) and is a forward of OpenRouter's free pool; upstream **429 and 502/503/504 are model-scoped** — only the named model cools, and the gateway adds no circuit breaker. Endpoint via `KILO_API_ENDPOINT` (must be inside `KILO_ALLOWED_ENDPOINTS`). +Free models at [Kilo](https://kilo.ai) (`api.kilo.ai/api/gateway`), no login. Standard OpenAI protocol; `/models` carries `isFree`, so the gateway filters directly and **does not probe** (saves the tiny quota). -### Qoder (`qoder`, real account) +**Quota**: gateway-wide ~200 requests/hour/IP. Upstream 429 and 502/503/504 are model-scoped — only the named model cools. Endpoint via `KILO_API_ENDPOINT` (inside the allowlist). -[Qoder](https://qoder.com), **real account** required: click *Log in to Qoder*, complete device-code authorization in the browser (the backend polls; the token never touches the browser). The upstream speaks a private COSY protocol (custom Base64 + envelope SSE), signed and unwrapped by the gateway; the surface stays standard OpenAI. +### Qoder (`qoder`) -**Quota and check-in**: quota probing (`GET /api/v2/quota/usage`) and campaign-based daily check-in. Check-in uses campaigns (`/sash/api/v1/me/campaigns`, needs `Cosy-ClientType`) with the old `daily-check-in` as fallback; claims run automatically and can be triggered from the admin UI. Campaigns **refresh daily at 10:00 (UTC+8)** (valid 30 days after claiming) and the task seals per that window, so an "already claimed" just after midnight seals only until 09:59 and the new campaign is claimed at 10:00. No streak count is shown. **The international site has no check-in** (404), reported as "no such endpoint in this region", not an error. +[Qoder](https://qoder.com), real account: *Log in to Qoder* for device-code authorization (the token never touches the browser). The upstream speaks a private COSY protocol, signed and unwrapped here; the surface stays OpenAI. -**Endpoints and pacing**: CN site by default (`openapi.qoder.com.cn` + `gateway.qoder.com.cn`); international via `QODER_API_ENDPOINT=https://openapi.qoder.sh` with the whitelist extended (signing carries full `cosy-*` headers, so the whitelist prevents misdelivery). Default `QODER_CHAT_MIN_INTERVAL=5` (own pacer), hot-updatable. +**Quota and check-in**: quota probing (`/api/v2/quota/usage`) and campaign-based daily check-in (credits accumulate). Campaigns (`/sash/api/v1/me/campaigns`), old `daily-check-in` as fallback. Campaigns **refresh daily at 10:00 (UTC+8)** (valid 30 days) and the task seals per that window. No streak count. **The international site has no check-in** (404), reported as "no such endpoint in this region". -**Upstream node failures**: Qoder sometimes wraps its own inference-node failures in a 400 (`[FAIL]node:… msg:Execution failed`). The gateway treats these as a **model-scoped transient fault** — only that model cools, and an exhausted candidate set returns a "model temporarily unavailable" 503 (`no_healthy_credential`), not a missing-model error. +**Endpoints and pacing**: CN by default (`openapi.qoder.com.cn` + `gateway.qoder.com.cn`); international via `QODER_API_ENDPOINT=https://openapi.qoder.sh` with the allowlist extended. `QODER_CHAT_MIN_INTERVAL` default 5, hot-updatable. -### CodeArts (`codearts`, real account) +**Upstream node failures**: Qoder sometimes wraps its own node failures in a 400 (`[FAIL]node:…`). Treated as a model-scoped transient fault — only that model cools; an exhausted candidate set returns 503 (`no_healthy_credential`). -[Huawei Cloud CodeArts](https://codearts.huaweicloud.com), **real account** required: *Log in to CodeArts*, complete OAuth2 authorization, then paste the **unreachable local URL** (`http://127.0.0.1:12800/oauth/callback?code=…`) back into the page; the server exchanges it for STS credentials (AK/SK + security_token + refresh_token + DPoP key) and stores them encrypted. +### CodeArts (`codearts`) -**Token refresh is rigid**: refresh_token, `client_id=codearts-agent` and the DPoP key are bound together and **single-use** — the new refresh_token must be written back or the account breaks; the pre-refresh task does this automatically (DPoP ES256/P-256). The pre-refresh window is capped at 45 minutes and must be **wider** than the polling interval (`REFRESH_INTERVAL_MINUTES`, default 30 min). A revoked renewal marks the credential "re-login required" and stops automatic retries until restored. +[Huawei Cloud CodeArts](https://codearts.huaweicloud.com), real account: *Log in to CodeArts*, authorize, then paste the **unreachable local URL** back; the server exchanges it for STS credentials (AK/SK + security_token + refresh_token + DPoP key) and stores them encrypted. -**Daily check-in**: a "1000 credits/day" campaign (`GET /v1/ops/delivery` → `POST /v1/ops/claim` → `POST /v1/ops/confirm`). The task claims automatically and can be triggered from the admin UI; `claim` alone is **not** enough (`CLAIMED` is a "claimed but unconfirmed" state), so both steps always run. The credits go into the channel's single ledger (see below). +**Token refresh is rigid**: refresh_token, `client_id=codearts-agent` and the DPoP key are bound and single-use; the new refresh_token must be written back. Handled by the pre-refresh task (DPoP ES256/P-256). The window is capped at 45 minutes and must be wider than the polling interval. A revoked credential is marked "re-login required" and stops automatic retries. -**Quota = credit balance**: probing hits `GET {snap}/snap-manager/v1/statistics/plugin` (the `snap-manager` prefix is **mandatory**; header `Agent-Type: PromptCenter`), because the built-in models (GLM-5.2 / OpenPangu) are billed in credits. The upstream values **are** credits — no unit conversion is applied. Sub-packages (`基础积分` / `按需积分` / `福利积分`) go into the "套餐 N 个" detail popover, and the plan's expiry date (`end_date`, verified to differ per account) is registered in the expiry ladder — unused credits are voided too, so "burn near-expiry first" also burns credits first. A failing endpoint only logs; it never breaks the rest of the pipeline. +**Daily check-in**: `GET /v1/ops/delivery` → `POST /v1/ops/claim` → `POST /v1/ops/confirm`. `CLAIMED` means claimed-but-unconfirmed, so both steps always run. -**Change events**: each probe diff writes one `credit_events` row with `ledger=credits` (see below). +**Quota = credit balance**: probing hits `GET {snap}/snap-manager/v1/statistics/plugin` (the `snap-manager` prefix is mandatory; header `Agent-Type: PromptCenter`). Upstream values are credits, no conversion; sub-packages go into a popover and the plan expiry date is registered (unused credits are voided too). -**Signing / cumulative SSE / pacing**: the upstream requires Huawei `SDK-HMAC-SHA256` (AK/SK + `X-Security-Token`), and `CODEARTS_ALLOWED_ENDPOINTS` must include the snap engine, STS and portal hosts. Streaming `text` is **cumulative (replace semantics)**, reduced to incremental events for clients. Default `CODEARTS_CHAT_MIN_INTERVAL=5`; the upstream hard-limits **3 concurrent sessions per account**, so an in-flight cap (`CODEARTS_MAX_CONCURRENCY`) and a sliding window (`CODEARTS_REQUEST_WINDOW_SECONDS`, 3 starts per 60s) keep bursts ahead of `400 TM.00001041`. All hot-updatable. +**Signing / cumulative SSE / pacing**: Huawei `SDK-HMAC-SHA256` (AK/SK + `X-Security-Token`); the allowlist needs the snap engine, STS and portal. Streaming `text` is cumulative (replace semantics), reduced to incremental events. The upstream hard-limits 3 concurrent sessions per account, hence `CODEARTS_MAX_CONCURRENCY` and `CODEARTS_REQUEST_WINDOW_SECONDS`. -### OfficeAce (`officeace`, real account) +### OfficeAce (`officeace`) -[Huawei Cloud OfficeAce](https://www.huaweicloud.com/product/agentarts.html) (果办 / AgentArts office agents), **real account** required: click *Log in to OfficeAce* and finish the Huawei Cloud authorization in the browser — **the callback is handled in the cloud** (`/v1/claw/auth/callback`), so this service listens on no port and the user never pastes a URL; the backend polls `/v1/claw/auth/code` every 2.5s for the authorization code (the same mechanism the official desktop client uses). +[Huawei Cloud OfficeAce](https://www.huaweicloud.com/product/agentarts.html), real account: *Log in to OfficeAce* and finish the Huawei Cloud authorization — **the callback is handled in the cloud**, so no local port and no URL to paste. The backend polls `/v1/claw/auth/code` every 2.5s. -**Provisioning prerequisite**: the account must first sign in to the OfficeAce client and redeem an invitation code. An unprovisioned account completes the login but gets no model key (`subscription: null` from the cloud); the service names the account and reports it has not activated OfficeAce instead of a vague login failure. +**Provisioning prerequisite**: the account must first sign in to the OfficeAce client and redeem an invitation code. An unprovisioned account completes the login but gets no model key; the service names the account and says it has not activated OfficeAce. -**Two credential sets with very different lifetimes** (the most common mistake on this channel): +**Two credential sets, very different lifetimes**: | Credential | Lifetime | Used for | |---|---|---| -| `Basic model_app_key:model_app_secret` | **never expires** | chat forwarding (`{gateway}/v2/chat/completions`) | -| temporary AK/SK + `security_token` + `project_id` | **~2 hours** | quota lookup, check-in (`/v1/subscription*`) | +| `Basic model_app_key:model_app_secret` | never expires | chat forwarding | +| temporary AK/SK + `security_token` + `project_id` | ~2 hours | quota lookup, check-in | -Mixing them yields `APIG.0303 app not found`. The failure is confusing either way: chat keeps working (the Basic key never expires) while the quota column goes blank and check-in keeps failing (the temporary credential expired). The pre-refresh task renews the temporary half automatically — `refresh_token` is single-use and bound to `client_id=pdp5_for_agentarts` plus the DPoP key, and **is not interchangeable with the CodeArts ticket** (`STS5.1806 invalid client id`). +Mixing them yields `APIG.0303 app not found`. The temporary half is renewed by the pre-refresh task; `refresh_token` is single-use and bound to `client_id=pdp5_for_agentarts` plus the DPoP key (not interchangeable with the CodeArts ticket). -**The upstream is plain OpenAI protocol**, so no private envelope parsing is needed; three upstream quirks do need handling: +The upstream is plain OpenAI protocol, with three quirks: -- **Rate limits arrive as fake 200s**: HTTP 200 + `data:{"error":…}`, and "first frame fine, second frame is the error" has been observed. Error frames flow to the engine as usual; `81111`/`81114` cool down only that credential × model. -- **Model-level rejection must not kill the credential**: `81004` (account lacks the model) / `81009` (unknown name) would otherwise be read from the status code as a dead credential. Business codes are checked before status codes and classified as a model-level negative cache (cleared on first success). -- **`max_tokens` is a combined "reasoning + answer" budget**: 9 of the 11 usable models are reasoning models, and a small budget leaves the answer empty. Values `≥16` get 1024 extra headroom clamped to the model ceiling (`glm-5*` 131072 / `qwen3*` 65536 / `deepseek-v4.1-flash` 393216); `<16` passes through untouched (probes / exact tests); absent stays absent. +- **Rate limits arrive as fake 200s**: HTTP 200 + `data:{"error":…}`. Error frames flow to the engine as usual; `81111`/`81114` cool only that credential × model. +- **Model-level rejection must not kill the credential**: `81004` / `81009` arrive as 403/404; business codes are checked before status codes and classified as a model-level negative cache (cleared on success). +- **`max_tokens` is a combined reasoning + answer budget**: `≥16` gets 1024 extra headroom clamped to the model ceiling; `<16` passes through; absent stays absent. -**Credits are the primary ledger**: the unit is `officeace_points` (one-off new-user grant + a daily check-in grant). Unlike CodeArts there is no parallel token pool — `remaining`/`total` are simply credits. Each grant **expires independently**, hence the quota label reads "per-grant expiry"; scheduling still burns near-expiry first. +**Credits are the main ledger**: unit `officeace_points`; `remaining`/`total` are credits. Each reward expires independently, so the quota reads "per-reward expiry". -**Daily check-in**: `POST /v1/subscription/bonus/claim` (**a write — it really grants credits**; the body must be {} with a JSON `Content-Type`, an empty body returns 500 `OfficeAce.11020001`). Re-claiming on the same day is idempotent upstream, so no local "already claimed" flag is needed. The gain is computed from the **remaining-quantity delta** of `bonus_skus` (`points` is the campaign's cumulative total, contributed to by several channels, so using it as "what I just earned" would overstate). The seal runs to the next Beijing midnight: the next day's campaign triggers an automatic catch-up. +**Daily check-in**: `POST /v1/subscription/bonus/claim` (a write — it really grants credits; body must be `` with JSON `Content-Type`). The upstream is idempotent for repeat claims on the same day. Gains are computed as a remainder delta. Sealed until the next Beijing midnight. -**No active model probing**: only 11 of the 34 catalog entries actually work, but probing sends real inference and burns credits, which are scarce here. Model-level rejection is handled by the `BLOCKED` negative cache from real requests instead. **No credit estimation either**: upstream exposes `cost_coefficient` and `tokens_per_point`, but their multiplication is undocumented, and a guess would desynchronize the whole cross-channel quota ordering. +**No active probing / no credit estimation**: probing burns credits; the `cost_coefficient` × `tokens_per_point` relation is undocumented. Model-level rejections are handled by real requests via the `BLOCKED` negative cache. -**Signing and endpoints**: the control plane (`/v1/claw/*`) uses Huawei `SDK-HMAC-SHA256`; the model gateway (`/v1/subscription*`, `/v1/model-token-rates`) accepts only **`V11-HMAC-SHA256`** (an extra region scope plus key derivation) and answers `APIG.0624` or 401 otherwise. `OFFICEACE_ALLOWED_ENDPOINTS` only needs the control-plane host — the model gateway differs per account and is issued by `client-permission-validate` into the credential, so it is not something this service can misconfigure. `OFFICEACE_CHAT_MIN_INTERVAL` defaults to `0` (no CodeArts-style hard concurrency limit) and is hot-updatable. +**Signing and endpoints**: control plane `/v1/claw/*` uses `SDK-HMAC-SHA256`; the model gateway accepts only `V11-HMAC-SHA256` (wrong one → `APIG.0624` or 401). `OFFICEACE_ALLOWED_ENDPOINTS` only needs the control-plane host (the model gateway is per-account and issued by the upstream). `OFFICEACE_CHAT_MIN_INTERVAL` default 0. -**One account, one user**: the official plan page states the personal edition is "limited to a single user per account", so this channel is not shared between people — each user adds their own credential. +**Single account, single user**: the official plan limits one user per account; no sharing. -**Switching accounts**: the Huawei Cloud session is **per browser**. If a previous account's session is still alive, the cloud silently re-approves it and "add a credential" becomes "log the same account in again". The login row therefore has a second **Switch account** action (`force_login=true`) that hits `/authui/logout?service=…` first. An account that has not opened OfficeAce is reported by name instead of a generic login failure. +**Switching accounts**: a *Switch account* button (`force_login=true`) clears the Huawei Cloud browser session first. -### CodeBuddy international (`codebuddy_intl`, real account) +### CodeBuddy international (`codebuddy_intl`) -The international site [www.codebuddy.ai](https://www.codebuddy.ai), **same protocol** as the CN site (same `/v2/chat/completions`, `/v3/config`, `/v2/plugin/auth/*`, `/v2/billing/*` routes, same Keycloak realm) but a **separate account system** (separate signup, billing) — hence a **separate channel**. Login is identical to the CN site (device code, token never in the browser). +[www.codebuddy.ai](https://www.codebuddy.ai), same protocol as the CN site but a separate account system (separate registration and billing), hence a separate channel. Same device-code login. -**Not a site switch**: `CODEBUDDY_API_ENDPOINT` is process-wide, so flipping it would send every CN-site credential to the international site (quota mismatch / 403). Both sites can hold credentials, each with its own endpoint, pacer and login flow. +Why not one channel: `CODEBUDDY_API_ENDPOINT` is a single process-wide value, so switching would send every existing CN credential to the international site. Both sites can hold credentials simultaneously with their own endpoints, pacers and login flows. -**Whitelists deliberately exclude each other**: `CODEBUDDY_INTL_ALLOWED_ENDPOINTS` holds only `https://www.codebuddy.ai`; the two sites' tokens are issued by different gateways, so a cross-site send is credential leakage and pointing the international endpoint at the CN host fails **at startup**. +**Allowlists deliberately exclude each other**: `CODEBUDDY_INTL_ALLOWED_ENDPOINTS` defaults to the international host only; adding the CN host fails at startup (cross-site sending leaks the credential). -**Feature split**: chat, model list, quota probe, token refresh and **activity reporting** are enabled; **daily check-in and the growth center are off** — check-in simply **doesn't exist on that site** (`checkin-activity-status` returns `active: false`), and the growth center **cannot be verified** (the international gateway puts the whole `/v2/activity/*` prefix behind its auth plugin, answering 401 rather than 404) while performing irreversible actions. Both menu entries are hidden for this channel. Activity reporting posts to `/v2/report`, a different path, verified working, so it stays enabled. +**Capability differences**: chat, model list, quota probing, token refresh and activity reporting are enabled; **check-in and growth center are not** (no such campaign on this channel; the growth center cannot be verified and includes irreversible actions). -**No channel suffix on shared model names**: models on both sites are listed **once** with both providers attached; genuinely different upstream models (`-x`, `-sg`) still get their own entry. +**Model list probes the plan**: each credential sends one minimal request per model and keeps only usable ones; `11158` (plan not allowed) and `11102` (no such model) are dropped, `429` / 5xx / timeouts keep the model. Results cached per account for 6 hours. -**Pacing**: `CODEBUDDY_INTL_CHAT_MIN_INTERVAL` defaults to 5s on its **own** pacer (sharing one would make the two sites queue behind each other), hot-updatable. +**Same-name models carry no channel suffix**: models on both sites are one row attached to both channels; genuinely different models (`-x` / `-sg`) get their own rows. -### Per-channel outbound proxy (optional) +**Pacing**: `CODEBUDDY_INTL_CHAT_MIN_INTERVAL` default 5s, an independent pacer, hot-updatable. -`PROVIDER_PROXIES` gives each channel its own egress proxy: +### Per-channel outbound proxy (optional) ``` PROVIDER_PROXIES="codebuddy=http://127.0.0.1:7890;qoder=socks5://127.0.0.1:1080" ``` -Format `channel=url;channel2=url2`, schemes `http/https/socks5/socks5h`, empty = all direct. It covers **all** of that channel's outbound requests (chat streaming, quota/model fetches, background tasks and OAuth login). **Startup-only** (restart required). Parsing is strict: an unknown channel, unsupported scheme or missing `=` fails startup — "thought it used a proxy but went direct" is worse than a startup error. Environment proxies (`HTTP_PROXY`, …) are **never honoured** (`trust_env=False`), so a deployment-wide proxy cannot silently hijack token-bearing requests. +Format `channel=proxyURL;channel2=proxyURL2`; `http/https/socks5/socks5h` supported; empty = all direct. Applies to every outbound request of that channel (chat, quota, model fetch, background tasks, OAuth). **Startup-time setting.** Unknown channel / bad scheme / missing `=` fails startup. `HTTP_PROXY` and friends are ignored (`trust_env=False`). + +> Inside containers `127.0.0.1` is the container itself. Use `host.docker.internal` for a host proxy: +> `PROVIDER_PROXIES="codebuddy=http://host.docker.internal:7897"` +> compose declares `extra_hosts`. Parsing validates format only, so a wrong address does not fail startup. ## Outbound protocols ### Responses API (Codex CLI) -`POST /v1/responses` serves a Responses subset for clients that only speak the Responses API, such as the [Codex CLI](https://github.com/openai/codex). It shares the same credential selection, cooldown, rotation, accounting and session affinity as `/v1/chat/completions`; only the inbound mapping and outbound translation differ: +`POST /v1/responses` provides a Responses subset, sharing scheduling / cooldown / rotation / stats / stickiness with chat: ```bash export CODING2API_KEY=sk-your-key codex -c "model_providers.coding2api={ name='coding2api', base_url='http://127.0.0.1:8000/v1', wire_api='responses', env_key='CODING2API_KEY' }" \ - -c model_provider=coding2api \ - -c model='glm-5.2' \ - 'your task' + -c model_provider=coding2api -c model='glm-5.2' 'your task' ``` -Supports streaming text, reasoning summaries, function tool calls and `finish_reason=length` → `response.incomplete`. `text.format` maps to chat's `response_format` (`text` / `json_object` pass through; `json_schema` is expanded). Content parts map as `input_text`/`text`/`output_text` → chat text and `input_image` → chat `image_url` (the URL is passed through as-is). **Not supported**: `store=true`, `previous_response_id` (stateless server, not faked), `input_file` (needs server-side file storage), Responses-only tools — explicit 400, never silently degraded. `include=["reasoning.encrypted_content"]` is accepted and ignored. +Supports text, streaming output, reasoning summaries, function tool calls and `finish_reason=length` → `response.incomplete`. `text.format` maps to chat's `response_format`. Content parts: `input_text` / `text` / `output_text` → text, `input_image` → `image_url`. **Unsupported**: `store=true`, `previous_response_id`, `input_file`, Responses-private tools — all explicit 400. `include=["reasoning.encrypted_content"]` is accepted and ignored. ### chat `response_format` validation -`POST /v1/chat/completions` no longer forwards `response_format` verbatim: only the three OpenAI-defined shapes pass, and malformed or unknown shapes are an **explicit 400** (never silently degraded, and never sent upstream where a 4xx would cool the credential): - -- `{"type": "text"}` -- `{"type": "json_object"}` -- `{"type": "json_schema", "json_schema": {"name": , "schema": , "description"?: , "strict"?: }}` — unknown keys inside `json_schema` are dropped (not part of the chat spec, not honored upstream); a missing `name`, a missing `schema` and an unknown `type` each get their own 400 message. - -The Responses `text.format` → `response_format` mapping shares **the same validator** (`normalize_response_format` in `src/compat/openai/request.py`), so the two inbound protocols cannot drift; the `prefix` argument only changes the field path in error messages (Responses passes `text.format`). +Only the three OpenAI shapes pass; malformed or unknown ones are explicit 400: `{"type":"text"}`, `{"type":"json_object"}`, `{"type":"json_schema","json_schema":{name, schema, description?, strict?}}` (unknown inner fields dropped). The Responses `text.format` path reuses the same validation. ### Anthropic Messages API (Claude Code) -`POST /v1/messages` serves an Anthropic Messages subset for [Claude Code](https://docs.anthropic.com/en/docs/claude-code): - ```bash export ANTHROPIC_BASE_URL=http://127.0.0.1:8000 -export ANTHROPIC_AUTH_TOKEN=sk-your-key # or ANTHROPIC_API_KEY (sent via x-api-key) +export ANTHROPIC_AUTH_TOKEN=sk-your-key # or ANTHROPIC_API_KEY (x-api-key header) claude ``` -**Auth**: both `x-api-key` and `Authorization: Bearer` are accepted. **Streaming**: `message_start` → `content_block_start/delta/stop` → `message_delta` (with `stop_reason` and usage) → `message_stop`; no `[DONE]` sentinel, and thinking blocks get a `signature_delta` before `content_block_stop`. **Cache hit**: usage is split Anthropic-style — `input_tokens` counts only misses, hits are reported separately as `cache_read_input_tokens` (the internal `input_tokens` is OpenAI's `prompt_tokens`, which includes hits), so Claude Code shows cached tokens instead of always 0. **`count_tokens`**: `POST /v1/messages/count_tokens` estimates input tokens locally. **Images**: `image` blocks (`source.type=base64` becomes a `data:` URL, `source.type=url` passes through) are normalized to chat `image_url` parts, and images inside `tool_result.content` are kept too — pure-text content is still folded into a string, so image-free requests map byte-for-byte as before. **Not supported**: `document` blocks (PDF, no chat equivalent), `source.type=file` and Anthropic-only server tools — explicit 400. - -**`cache_control` pass-through** (always on, no flag): inbound cache markers are forwarded verbatim, at the same places Anthropic accepts them — `system` content blocks, user/assistant content blocks, `tool_result` (attached to the **tool message top level**, avoiding a synthetic empty text block), `tool_calls` elements, and `tools` elements. Only **structure** is validated (must be an object with a non-empty string `type`; `ttl`, if present, must be a string); values are not interpreted (an unknown `type` still passes through — a gateway should not gatekeep cache types upstream may add later). Malformed markers are an explicit 400 rather than a silent drop. **Without markers the mapping is byte-for-byte unchanged** (pure text still folds into a string), so session-affinity fingerprints and upstream prefix caching are unaffected. Six of the eight channels accept the markers in testing (codebuddy / codebuddy_intl / trae / qoder / codearts / kilo); **zen and OfficeAce are unverified** (zen's upstream returns `FreeTierError` 403 even with `--plain`, unrelated to the markers; OfficeAce was not probed). Probe script: `scripts/probe_cache_control.py`. +- **Auth**: both `x-api-key` and `Authorization: Bearer`. +- **Streaming**: `message_start` → `content_block_*` → `message_delta` → `message_stop`; no `[DONE]`; thinking blocks get a `signature_delta` before `content_block_stop`. +- **Cache hits**: usage splits in two — `input_tokens` counts misses only, hits go to `cache_read_input_tokens`. +- **`count_tokens`**: estimated locally, never forwarded. +- **Images**: `image` blocks normalize to chat `image_url`; images inside `tool_result.content` are preserved. +- **Unsupported**: `document` blocks, `source.type=file`, Anthropic server tools — explicit 400. +- **`cache_control` passthrough** (on by default): landing spots match Anthropic (`system`, message content blocks, `tool_result` top level, `tool_calls` top level, `tools` top level). Structure validated only, values not interpreted. 6 of 8 channels accept it (codebuddy / codebuddy_intl / trae / qoder / codearts / kilo); zen unverified, OfficeAce not separately probed. Script `scripts/probe_cache_control.py`. ### CC Switch one-click import -The API Keys page can generate [CC Switch](https://github.com/farion1231/cc-switch) `ccswitch://` import deep links (one button each for Claude Code / Codex CLI / OpenCode) that write the endpoint, key and model into the desktop client after a confirmation dialog. There are two entry points: the **dialog shown right after a key is created** (using the freshly generated once-only cleartext key; closing the dialog drops it) and the **"Import to CC Switch" button on each key-list row** (the list only stores a masked `preview`, so you paste the full key you saved at creation time — it stays in dialog memory only). Both offer a "default model" dropdown sourced from the same list as Playground (`/api/playground/models`), grouped by channel. A deep link **only fires on a user click** (browsers block automatic redirects) and **carries the key in cleartext**, so it is generated only when the user opens the dialog and supplies a key. Protocol and `app` / `apiFormat` values: see CC Switch's "Deep Link Protocol" manual; requires CC Switch v4.0.10+. Note the **endpoint form depends on `apiFormat`**: the Anthropic format (Claude Code) takes the bare origin **without `/v1`**, the two OpenAI formats take `/v1`. This gateway has no Gemini egress, so no `app=gemini` entry is offered. +The *API keys* page generates `ccswitch://` deep links (Claude Code / Codex CLI / OpenCode). Two entry points: the post-creation dialog and the per-row *Import to CC Switch* action. Links contain the plaintext key, so they are generated only on explicit user action. Requires CC Switch v4.0.10+. The endpoint form depends on `apiFormat`: Anthropic goes to the root (no `/v1`), OpenAI forms to `/v1`. No Gemini surface. ### Balance query -`GET /v1/user/balance` is DeepSeek-shaped, Bearer `sk-...`, for clients like Cherry Studio / cc-switch. Balances come from the credential pool's quota-probe cache (`QUOTA_PROBE_MINUTES`), aggregated over usable credentials, in upstream credits. `balance_known` is `false` when no credential has ever probed successfully (unknown, not 0). +`GET /v1/user/balance` is DeepSeek-shaped, Bearer `sk-...`, for Cherry Studio / cc-switch. Values come from the cached quota probe, aggregated over usable credentials: + +```json +{ + "is_available": true, + "balance_infos": [{"currency": "credits", "total_balance": "60.00", + "granted_balance": "0.00", "topped_up_balance": "0.00"}], + "balance_known": true, + "providers": [{"provider": "codebuddy", "remaining": 50.0, "total": 150.0, "credentials": 2}] +} +``` + +`balance_known` is `false` when nothing has ever been probed (unknown, not 0). ### Pool health -`GET /health` is a pure liveness probe. `GET /healthz` adds credential-pool counts (`total` / `ready` / `cooling` / `paused` / `disabled`), so monitors can alert when the pool is exhausted (`ready=0`: alive but unusable). Both are unauthenticated; buckets are mutually exclusive and sum to `total`. +- `GET /health`: `{"status":"ok"}`, for liveness probes. +- `GET /healthz`: adds pool counts and returns 503 when `ready == 0` (readiness). -### Prometheus metrics (`GET /metrics`) +Neither is authenticated and both accept `HEAD`. The five counts are mutually exclusive and sum to `total`, same as the scheduler. **Point liveness at `/health`**; use `/healthz` for readiness / alerting. -`GET /metrics` serves the Prometheus text format (`text/plain; version=0.0.4`) for direct scraping. On by default, **unauthenticated** (same stance as `/healthz`); `METRICS_ENABLED=false` (hot-updatable) makes the endpoint return 404. +> Upgrade note: `/healthz` changed from always-200 to 503 when `ready=0`. -Five metric families: +### Prometheus metrics (`GET /metrics`) + +On by default, **unauthenticated**; `METRICS_ENABLED=false` (hot-reloadable) returns 404. | Metric | Type | Labels | Source | | --- | --- | --- | --- | -| `coding2api_requests_total` | counter | `provider` / `model` / `status`(ok\|error) | `usage_hourly` (permanent, monotonic across restarts) | -| `coding2api_tokens_total` | counter | `provider` / `type`(input\|output\|reasoning\|cached) | `usage_hourly` | -| `coding2api_credentials` | gauge | `state`(ready\|cooling\|paused\|disabled) | credential pool (same stance as `/healthz`) | -| `coding2api_upstream_errors_total` | counter | `provider` / `kind` | in-process counter (resets on restart) | -| `coding2api_task_failures_total` | counter | `task` | in-process task state (resets on restart) | +| `coding2api_requests_total` | counter | `provider` / `model` / `status` | `usage_hourly` | +| `coding2api_tokens_total` | counter | `provider` / `type` | `usage_hourly` | +| `coding2api_credentials` | gauge | `state` | credential pool | +| `coding2api_upstream_errors_total` | counter | `provider` / `kind` | in-process (resets on restart) | +| `coding2api_task_failures_total` | counter | `task` | in-process task state | -**No high-cardinality labels**: no `credential_id` / `api_key_id` / `username`. `requests_total` / `tokens_total` read the **permanent** hourly rollup (not the 90-day detail table), so scraping never scans details and counts stay monotonic; the in-process counters reset on restart, which Prometheus handles as a counter reset. +No `credential_id` / `api_key_id` / `username` (high cardinality). `requests_total` / `tokens_total` read the permanently retained hourly rollup and stay monotonic across restarts. Hourly rollups have no `error_type` dimension, so `kind` comes from an in-process counter. ## API key policy -A key can be restricted by channel, model, source IP and expiry: - -- **Channel binding**: pins the key to one channel; a model owned by another channel gets a 400 naming the real owner (no silent reroute). Empty = automatic. A `model@channel` conflicting with the binding is also a 400. -- **Source IP allowlist**: comma-separated IP/CIDR (empty = unrestricted), normalized on write; an off-list source gets 403. `X-Forwarded-For` is **ignored by default**; only with `TRUST_PROXY=true` is the *last* entry used (exactly one trusted reverse proxy). -- **Model allowlist**: comma-separated names or fnmatch globs (e.g. `glm-*,kimi-k3`), case-insensitive, `@channel` suffix excluded; out-of-list models get a 400 and `/v1/models` lists only allowlisted models. -- **Expiry**: epoch seconds; empty = never; an expired key gets a 401 (same copy as "key not found", so nothing is enumerable). +- **Channel binding**: the key only picks credentials from that channel; a model owned by another channel returns 400 naming the actual owner. Empty = cross-channel. +- **Source IP allowlist**: comma-separated IPs or CIDR blocks, normalized on write (`10.0.0.1` → `10.0.0.1/32`); outside the list → 403. `X-Forwarded-For` is not trusted by default; only with `TRUST_PROXY=true`, taking the last entry. +- **Model allowlist**: comma-separated names or fnmatch globs (`glm-*,kimi-k3`), case-insensitive, `@channel` suffix ignored. Outside → 400; `/v1/models` filters too. +- **Expiry**: epoch seconds; empty = never. Expired → 401 (same wording as "key not found"). +- **Quota**: six independent limits, `0` or empty = unlimited — `daily_requests` / `daily_tokens` / `daily_cost_usd` / `rpm` / `tpm` (estimated) / `max_concurrency`. Daily ones use the Beijing calendar day; rate and concurrency are in-process sliding windows. Over the limit → 429 + `quota_exceeded`, `Retry-After` points at the retry moment. A failing check always allows the request and logs a warning. +- **Self-service**: `GET /v1/usage?days=1|7|30` returns your own usage, remaining quota and current rate usage (local detail only). ## Credential pause -The per-row *Pause* action removes a credential from **chat traffic only**: quota probing, token pre-refresh, daily check-in, growth center and activity reporting keep running (they honour only the system hard-disable `disabled`). Use it to "stop chatting but keep earning". *Resume* returns it immediately. Different from *Disabled* (the upstream rejected the session; needs re-login + *Restore*). Same toggle at `POST /api/credentials/{id}/toggle`. +*Pause* removes a credential from chat traffic and skips it in daily check-in / token pre-refresh; other background tasks (quota probe, growth center, activity reporting) keep running. Useful for "no chat, keep collecting credits". *Unpause* returns it to the pool immediately. Different from *disabled*, which is the channel-driven session-invalid state requiring *revive* after re-login. Same switch at `POST /api/credentials/{id}/toggle`. -> Zen / Kilo are credential-less: *pause* is the only way to disable them permanently (deletion re-seeds on restart). Pausing also hides the channel's models. +> Zen / Kilo are virtual rows; **pause is the only permanent disable** (deletion re-seeds on restart). Pausing also removes the channel from the model list. -## Per-credential in-flight cap +## Credential batch operations -`MAX_INFLIGHT_PER_CREDENTIAL` (default `0` = unlimited) stops a credential from being selected once its concurrent request count reaches the cap, keeping a single account from being hammered into upstream rate limits or bans; it is global across channels (orthogonal to the per-channel `CODEARTS_MAX_CONCURRENCY`). The in-flight count doubles as the **load key**: the executor injects it into `Candidate.inflight` and the scheduler sorts by **least in-flight first** (after the free tier, before expiring credits), so concurrent requests spread across idle credentials; with the cap at `0` leases are still registered, so the load key stays live even when concurrency is unlimited. +The credentials page offers multi-select plus a batch bar (probe / check-in / pause / unpause / revive). -- The counter is **in-process** (single-instance semantics, like the `/api/tasks` state). -- Leases release when the request ends (success, rotation, error, client disconnect), with a **600s TTL** reclaiming any that leak—better to occasionally admit one extra request than to silently retire a credential. -- When every candidate is at its cap, the response is the same "no healthy credential" 503 as the limits guard. -- Hot-updatable on the *Tasks & settings* page; the next request picks it up (no snapshot cache). +- **Endpoint**: `POST /api/credentials/batch`, body `{action: "probe"|"toggle"|"checkin"|"revive", ids: [...], enabled?}`. Up to 200 ids per request, duplicates deduped. +- **Per-item isolation**: one failure never blocks the rest. Response `{action, total, succeeded, failed, results: [{id, ok, error?, detail?}]}`. +- **Pause is explicit state, not a toggle-flip**: `enabled` comes from the body, so the bar has separate *Pause* and *Unpause* buttons. +- **Revive and unpause differ**: revive clears the system hard-disable (needs re-login first); unpause sets `toggle enabled=true`. +- Only actions with existing single-item endpoints: `refresh` and `delete` are excluded. +- **Audit writes one summary row** (`toggle` / `revive`); an audit failure logs a warning and does not fail the operation. -## Credential pool: filtering and grouping +## Credential limit guards -Once the pool grows, the page splits "find that one account" into three layers, while the first screen shows only the channel-level overview: +Optional per-credential guards, configured from the row menu. Four independent limits; all empty = unlimited. Judged before selection; background tasks keep running. -- **Filter bar**: search (nickname / channel / id, case-insensitive) × channel × state × tag. The four are **AND**-combined and purely client-side — the backend still returns the full set ordered by `sort` / `order`. When any filter is active it shows "showing N / total" plus a *Clear* action; no match yields an empty state rather than a blank table. Counts in the channel / state / tag dropdowns come from the full set, so narrowing one filter never re-narrows the others. The **tag dropdown renders only when some credential actually carries a tag** (no permanent "all tags" control otherwise), and tags are a structured dimension that **does not participate in the search term** — otherwise finding a nickname that happens to be "prod" would be impossible. -- **Tags**: each credential can carry any number of ops tags (comma-separated, up to 16 tags of ≤ 24 chars; half-width and full-width commas and semicolons all separate), edited via the row's *More → Set tags*. Tags are **display/filter only** — they never affect selection and never change request behaviour, since using them as policy would make a rename silently change traffic. A row shows at most two tags, collapsing the rest into `+N` with the full list in the hover title. -- **Group sort**: a separate control reorders only the channel *groups* (never the rows inside them, which still follow the column header / backend `sort`). Default is **quota high→low**, ranked by the group's summed remaining quota so the channels that can still serve the most surface first; switchable to **channel order** (fixed, so 30s polling cannot make headers jump). Channels with no quota API (free tier) sort last rather than being treated as `0`, and ties fall back to channel order. -- **Grouped by channel**: rows aggregate per channel; each group header is a compact **channel dashboard** — brand icon + channel name + account count, plus a **state breakdown** (icon + count, listing only states that occur, using the same `credentialState` as the state badge), a **total quota** figure (a share ring plus the number; the ring is a pure gauge and the exact percentage sits on the "total quota / total credits" label line; sums the group's non-null values, and the free tier zen / kilo shows `—` rather than `0` with no ring), and an **expiring credits** figure (one block for the primary 1.5-day window; the secondary 7-day window appears only when it holds **more** than the primary — the 7-day window is a superset, so an equal number means nothing expires beyond 1.5 days). Clicking a header collapses / expands that channel — **collapsed by default** (see structure first, expand to inspect accounts); an active filter auto-expands matching channels, and clearing it returns to the collapsed overview. Group order is "quota high→low" by default and switchable from the toolbar to the fixed site-wide channel order (CodeBuddy, TRAE first); it does not follow the sort key. The channel name appears only in the header, never repeated per row. **One real channel = one group**: CodeArts quota *is* credits, so its group totals label the figure "total credits"; other channels label it "total quota". -- **Column headers follow the expanded region**: there is no global table header; the column row (with sort buttons and column hints) renders inside each expanded channel group and sticks while the container scrolls. When collapsed, the group header *is* the overview and the column names have nothing to label; only an expanded group needs the "what is this column" anchor. Several groups can be expanded at once, and every group's header shares one sort state (clicking any of them writes the same backend `sort` parameter). The **"growth center" column appears only for channels that support it** (CodeBuddy); other channels get an empty placeholder cell — the column count stays constant, otherwise auto layout recomputes the widths and groups expanded below misalign. +- **Reserve credits**: account remaining ≤ the value disables the whole credential. Unknown balance is never guessed. +- **Daily token cap** / **daily credit cap**: whole credential disabled when the day's total is reached. +- **Per-model token cap**: `{model: limit}` disables only that model. +- **"Day" = Beijing calendar day**, resetting at midnight. +- When every candidate is filtered out, the response is `no_healthy_credential` 503; a model-level cooldown returns `model_unavailable`. +- Written via `PUT /api/credentials/{id}/limits` (full replace; all four off deletes the row). Effective immediately. Audit action `credential.limits`. -- **One group per channel**: the header label is just the channel display name and `ProviderGroup.key === provider`; CodeArts is no longer split into "内置 / 福利" groups (the benefit path is gone — its quota is simply the credit ledger). Each row reads its own channel's ledger: CodeArts is credits (`ledger=credits`), every other channel is plan / daily balance (`ledger=pool`), derived by `providerLedger()`. -- **Scrollable long list**: the table is capped at 70vh, and the group's sticky column header and row actions stay visible deep in the list. +## Per-credential in-flight cap -The quota number is dotted-underlined: hovering reveals **that row's cycle semantics** (this-cycle remainder / account balance / credit balance / free tier with no quota API); the per-row caption lives in that hover hint. +`MAX_INFLIGHT_PER_CREDENTIAL` (default 0 = unlimited): once a credential's concurrency reaches the cap it stops being selected. -## Model list loading +- In-process counting (single-instance semantics). +- Leases release at request end, with a 600s TTL backstop. +- All candidates saturated → 503, same as the limit guards. +- Hot-reloadable. +- **The count doubles as the load key**: ordered after "free first" and before expiring credits, so concurrency spreads to idle credentials. -`GET /v1/models` and the Playground dropdown merge only channels with a **currently usable credential** (at least one not-paused, not-dead row). The check runs before the cache, so a never-connected channel never shows phantom models; pausing or losing credentials hides a channel's models until it recovers; a cold start lists only `zen` / `kilo` (their virtual credentials are seeded). A cooling credential still counts — it is only temporarily limited, so the list does not flicker. +## Credential pool: filtering and grouping -**Ordering**: CodeBuddy, TRAE, Qoder and CodeArts models come first, dictionary-ordered within a group; multi-channel models take their highest-priority channel's slot. Display-only — it does not change scheduling. +- **Filter bar**: search (nickname / channel / id) × channel × state × tag, all AND, front-end only. Shows "N of total after filter" and a *Clear* action. The tag dropdown renders only when tags exist. Tags do not participate in text search. +- **Tags**: comma-separated, up to 16, each ≤ 24 chars. Used for filtering and display only, never scheduling. Two shown per row, the rest collapse to `+N`. +- **Group sort**: reorders channel groups only, never rows within a group. Default by total quota descending, switchable to fixed channel order. Free tiers without a quota API sink to the bottom. +- **Grouping by channel**: each header is a compact dashboard — icon + channel + account count, models (count + lowest rate, paid models only; "all free" only when the whole group is free; custom aliases are not separate models), state distribution, quota total, expiring credits (one block per 1.5 days in the main window; the 7-day window only when larger). Collapsed by default; filtering auto-expands matches. Click the header to collapse / expand. +- **Column headers follow the expanded area**: headers render inside each expanded group and stick on scroll, sharing one sort state. The *Growth* column appears only for CodeBuddy; other channels keep an empty placeholder to preserve column widths. +- **Long lists scroll**: table height 70vh, sticky headers and the action button stay visible. -**Routing honours this cache**: when the catalog proves which channels own a model, those are the only candidates — if all are unavailable (cooling / paused) the request gets a 503 instead of fanning out (each upstream would only answer 11102 / 4001 / 401 and write hours-long "no such model" negative caches). A model absent from the catalog still routes to all candidates; pinned `model@channel` and key channel binding are unaffected. +Quota numbers are underlined with a dotted line; hover shows the period semantics for that row. + +## Model list loading + +`GET /v1/models` and the Playground dropdown merge only channels with a **currently usable credential** (at least one not paused, not session-invalid). Cooling credentials still count. Display order: CodeBuddy, TRAE, Qoder, CodeArts, OfficeAce first, then alphabetical within a group; multi-channel models take the highest-priority channel. + +**Request routing trusts this cache**: when the catalog proves ownership, only those channels are candidates; if all are unavailable the response is 503 instead of trying each channel. Models absent from the catalog still route across all candidates; explicit pinning and key channel binding are unaffected. ### Catalog snapshot and startup -Besides the in-process TTL cache (300s), every successful fetch is snapshotted to `DATA_DIR/model_catalog.json` and **read back synchronously at startup**, so the model→channel table is ready in the first second (flat model names no longer fan out while zen's 10+ second probes run). A corrupt or >7-day-old snapshot is just dropped. `MODEL_CATALOG_MINUTES` provides a background refresh so the catalog stays fresh even when nobody calls `/v1/models`. +Besides the in-process TTL cache (300s), every successful fetch writes `DATA_DIR/model_catalog.json` and reads it back synchronously at startup, so ownership is ready the moment the process restarts. A corrupt or >7-day-old snapshot just loses the cache. A `MODEL_CATALOG_MINUTES` background task keeps it fresh. -Playground and `GET /v1/models` use **stale-while-revalidate**: with a cache they return the old list immediately and refresh in the background; only a channel with **no cache at all** is awaited synchronously. The list lags by at most one TTL. +Playground and `/v1/models` use stale-while-revalidate: cached lists return immediately and refresh in the background; only a channel with no cache at all waits synchronously. Repeated failures back off at 2^(n-1)×TTL (capped at 2 hours), logging WARNING on the first failure only. ### Admin *Model list* page -The admin *Model list* page (`GET /api/model-catalog`, visible to admins and operators) is a read-only view of the public OpenRouter catalog: prices (input / output / cache read / cache write, USD per million tokens, switchable to CNY), context/output limits, input→output modalities, capabilities (reasoning / tool calling / attachments / structured output), knowledge cutoff, and the **Artificial Analysis indices** (intelligence / coding / agentic, via the public OpenRouter API — **not measured by this service**, never sorted). A missing catalog shows an empty state and does not affect chat or cost. +(`GET /api/model-catalog`, admin / operator) read-only view of the OpenRouter public catalog: list prices, context / output limits, modalities, capabilities, knowledge cutoff, and the three Artificial Analysis indices (not measured here, no sorting). Each row carries an `available` flag; a default-off *only current channels* switch narrows locally. + +**Suspected-offline view**: `GET /api/models/availability` (operator) aggregates `upstream_blocked` / `invalid_request` failures over the last N days (default 7, 1–30) by `(channel, model)`. *Add to blocklist* appends to `model_blocklist` through the settings endpoint and writes an audit row. + +**Floating aliases take the newest snapshot score**: OpenRouter attaches both a floating alias and dated snapshots to one model, declaring them one release via `canonical_slug`. This gateway runs reasoning on by default, matching the Max variant, so it takes the newest snapshot in the family; ids with an explicit date stamp keep their own score. Families derive only from canonicals carrying `-YYYYMMDD`. + +### Active probing and manual takeover + +The probe panel (`GET /api/models/probes`, operator) only probes `(channel, model)` pairs still locked by the `blocked` negative cache. + +Three outcomes: + +- **Success** → clears that channel's `blocked` cache immediately (the only early-recovery path). +- **`failed`** (upstream says `BLOCKED` / `INVALID`) → keeps the cache and increments consecutive failures. +- **`inconclusive`** (quota, rate limit, network, timeout, empty stream, any exception) → never disables, and resets the consecutive counter. -OpenRouter lists hundreds of models and most are not connected here. Each row carries an `available` boolean (whether the model is **currently served by a channel with a usable credential**, same rule as `/v1/models`), and the page offers an **off-by-default** "only current channels" toggle that narrows the table locally to models fetched from current channels (no extra request). +Probing is fully isolated from production: it calls `provider.stream_chat` directly, writes no `usage_events`, touches no cooldown table, changes no health. Results go to the separate `model_probes` table. The panel hides models matched by `MODEL_BLOCKLIST`. -The 能力分 (capability score) header is sortable by the **intelligence index** (智); rows without a score sort last (never treated as 0). Only this one sub-score has a sort entry — one header column maps to one entry, and sorting all three would crowd the header into a row of buttons. +**Dead models also drop out of the model list**: while `failed` and still cache-locked, that `(channel, model)` pair is hidden; when the cache expires it returns automatically. + +**Manual takeover** (`POST /api/models/probes/manual`): skips automatic probing for that row without touching the cache. Use *Probe now* to recover immediately. Background interval `MODEL_PROBE_MINUTES` (default 0 = off). + +### Admin *Upstream status* page + +(`GET /api/upstream-status`, admin / operator) aggregates official status pages to answer "is the upstream down, or is it my credential / quota". + +- **Sources**: OpenCode Zen (per-model TTFT / tps), Kilo, Moonshot / Kimi, MiniMax, OpenAI, Anthropic, Together AI, DeepSeek. Grouped by `scope`: `channel` first, `model` after. +- **Six normalized levels**: `operational` / `degraded` / `partial` / `major` / `maintenance` / `unknown`. The overall level is the worst source, with `unknown` last. +- **Explanation only, never decisions**; not part of scheduling or circuit breaking. +- **Memory only**; a background task (`UPSTREAM_STATUS_MINUTES`, default 5) fetches, and a single failing source only marks its own row. +- **Conclusion first, detail after**: a summary bar on top, then two scope groups with abnormal sources expanded; a *needs attention* strip hoists non-normal sources. Component detail beyond 6 items truncates (abnormal items first). +- **Not covered**: sources that only offer HTML (Groq, Tencent Cloud, Alibaba Cloud, Huawei Cloud, SiliconFlow, Qoder, Zhipu, TRAE). ### Token expiry visibility -A "token remaining" column, red below `TOKEN_EXPIRY_WARNING_SECONDS` (default 1h). Expiry uses explicit `expires_at`, falling back to the JWT `exp`; both missing → `—`, **never guessed**. +The **token remaining** column shows how long the access token lasts, red below `TOKEN_EXPIRY_WARNING_SECONDS` (default 1 hour). Prefers the upstream `expires_at`, falls back to JWT `exp`; neither → `—`, never a guessed local TTL. + +## Sidebar to-dos (navigation badges) + +The sidebar is on every page. Several to-dos you would only notice by visiting a page show as count badges, fed by one aggregate endpoint `GET /api/nav-badges`, polled every 60s. + +| Entry | Threshold | Badge | +|---|---|---| +| Credentials | operator+ | credentials with `disabled` or `health < 0` | +| Alerts | admin | unresolved alerts | +| Tasks & settings | admin | tasks whose last run failed | +| Upstream status | operator+ | abnormal sources (incl. maintenance) | +| Users | admin | accounts with an activation token | +| API keys | everyone (own only) | keys expired or expiring within 7 days | +| Model list | operator+ | stale snapshot (boolean text) | + +- **Role-filtered**: the backend sends only keys the role may see; a missing key means no permission (not 0). The credential badge is withheld from viewers. +- **Counts only to-dos**: e.g. the alerts page `total` includes resolved history, the badge does not; tasks use "last run failed", not "consecutive failures ≥ threshold". +- **Credential badge ≠ `/healthz` ready count**: the latter ignores health, so an out-of-quota credential still counts as `ready`. +- **Alert badges self-heal**: `AlertTask` rebuilds its unresolved set from the DB at startup. +- **Stale catalog**: never fetched is not stale; stale means 2× `OPENROUTER_CATALOG_MINUTES` without a refresh. ## Stats ### Scope -The time range defaults to the **last 24 hours** (also 7 days / 30 days / all): the first question when opening the stats page is "how is the pool doing right now", and 7 days flattens out the day's variation. +Default range is **the last 24 hours** (also 7 days / 30 days / all / custom). Custom takes two `datetime-local` inputs (browser-local timezone), one side may be empty; a start not earlier than the end voids the window with a message. Every stats endpoint accepts `until` (epoch seconds, half-open `[since, until)`). + +**Failure types** are a controlled enum: exhausted model-level cooldown → `model_unavailable`, genuinely empty pool → `no_healthy_credential` (same outbound error code, different stats meaning). + +**Request ID**: every response carries `X-Request-ID`. A valid inbound value is reused, otherwise one is generated; allowed charset `[A-Za-z0-9._:-]`, length 1–128. It lands in `usage_events.request_id` and shows on hover in the detail time column. **Never forwarded upstream** (outbound header semantics differ per channel). + +**Credit**: CodeBuddy is the upstream truth; TRAE is derived from official unit prices and marked `≈` (table in `src/provider/trae/pricing.py`; unlisted models are not derived). Backfill history with `scripts/backfill_trae_credit.py`. -### Credit, cost and cache hit rate +**Cost (estimated, ≈)**: tokens × public list price, not the real charge. Prices come from the OpenRouter public catalog: `(input − cached) × input + cached × cache price + output × output price`, converted with the `USD_CNY_RATE` in effect at write time. -The **Credit** column: CodeBuddy is the real upstream value; TRAE's `token_usage` frame carries only tokens, so its credit is **estimated** from official unit prices (`≈`). The price table lives in `src/provider/trae/pricing.py`; unmatched models show `—`. +- **Fixed at write time**: cost is computed when the detail row is written and never recalculated. Pre-release rows are `NULL`; backfill with `scripts/backfill_cost.py --apply`. +- **No match, no cost**: local names match OpenRouter `id` / display name exactly (ambiguous matches are skipped); aggregate shows `—`, so cost is a lower bound. Channel-specific ids map to the base model only through explicit equivalence rules (verified ids only, no generic date/suffix stripping). -**Cost (est.)** estimates tokens × public list prices, not the real charge. Prices come from the **OpenRouter public catalog** (USD per million tokens), computed as `(input − cached) × input + cached × cache_read + output × output`, converted to CNY at the **rate in effect when the row was written** (`USD_CNY_RATE`). Costs are **fixed at write time** (`usage_events.cost_usd` / `cost_cny`) and **never recomputed**; pre-upgrade rows are `NULL` and can be backfilled with `scripts/backfill_cost.py --apply`. Matching compares canonical keys for equality and **drops ambiguous hits**; unmatched models or requests without input tokens contribute nothing and aggregates render `—` (never a fake `0`), so the figure is a **lower bound**. +**Cache hit rate**: cached ÷ input tokens (1 decimal). `—` when unreported or input is 0. Group and detail tables show it next to tokens. -**Cache hit rate**: the *Token usage* card adds cached ÷ input (1 decimal), from the upstream `cached_tokens`; `—` when never reported or input is 0. The **Grouped stats** table (after *cached*) and the **Request detail** table (after *hit*) each add a display-only **hit rate** column on the same formula. +**Output speed (tok/s)**: total tokens ÷ total generation time, not an average of per-request ratios. Same denominator, different numerators: **visible speed** uses `output_tokens - reasoning_tokens`, **total output speed** includes reasoning. The UI shows `visible / total`. Only successful requests routed here that produced ≥ 1 token. Generation time = `latency_ms - ttfb_ms`; windows under 200 ms or single samples over 1000 tok/s are dropped as unmeasurable. `ttfb_ms` of `NULL` is excluded from the primary scope; when a row or group has no primary sample, it falls back to end-to-end time (prefixed `≈`). No samples at all → `—`. + +Aggregate speed can only be computed from per-request detail, so it covers the last 90 days (the overview card too; other cards read the hourly rollup). The detail table shows each request's own speed without the fallback; rows without a generation window show `—`. Speed columns sort via `sort=tps_visible` / `sort=tps`. ### Credit record -The drawer under a credential's quota number lists the **net change between consecutive quota probes**, attributing nothing to check-in / growth / chat (the upstream logs nothing), so the UI says "net change". The first probe records only a baseline; unchanged balances are skipped; a balance going unknown still records a row. 90-day retention. +The arrow after the quota number expands the change ledger, recording the **net change between two quota probes**, not an action attribution: check-in, growth and chat all change the balance and the upstream logs nothing, so the UI always says "net change". First probe only sets a baseline; unchanged balances are not recorded; an unknown balance is recorded without a delta; 90-day retention. -**Split per ledger**: the table carries a `ledger` column (`pool` / `credits`) and each probe writes one row according to the credential's own channel — CodeArts quota comes from the credit endpoint, so it records `credits`; other channels record `pool`. A CodeArts row expands the credit history (`ledger=credits`), other channels expand the plan / daily-balance history (`ledger=pool`); `GET /api/credentials/{id}/credit-events` takes a `ledger` query parameter (default `pool`, unknown values fall back to `pool`). **They must be read separately**: the two ledgers differ in magnitude and meaning, and mixing them reads as consecutive samples of one ledger (on an old database a CodeArts `pool` row is the retired daily token pool, an order of magnitude away from credits). On upgrading an old database every historical row lands in `pool` — those rows were produced by the then-current balance diff and are no longer shown. +**Split by ledger**: rows carry `ledger` (`pool` / `credits`). CodeArts uses `credits`, others `pool`. Endpoint `GET /api/credentials/{id}/credit-events?ledger=` (default `pool`). The two ledgers differ in magnitude and meaning and must be read separately. ### Grouping and re-rollup -The **Grouped stats** panel tabs four dimensions (channel / model / user / credential) over one table (requests / ok / success rate / input / output / cached / hit rate / credit / cost), endpoints `GET /api/stats/by-provider|by-model|by-user|by-credential`. Default sort is requests descending; **user and credential tabs are admin/operator only**. **Scope differs**: channel / model / user read the **hourly rollup** (permanent; the last 5 minutes are not yet counted); the **credential** dimension reads request detail (the rollup key has no `credential_id`), covering the **last 90 days**. +Four tabs (channel / model / user / credential) share one table (channel / requests / credit / cost / tokens / speed / average latency / P95 latency). Endpoints `GET /api/stats/by-provider|by-model|by-user|by-credential`. Default sort by requests descending; merged columns open a sub-column menu. **Latency percentile switches between P50 / P90 / P95 / P99** (default P95, nearest-rank, n = samples with a value). User and credential views are admin/operator only. + +**Scope differences**: channel / model / user read the hourly rollup (permanent, last 5 minutes not yet counted); credential reads the detail table (90 days only). + +**Two latency scopes**: successful requests only. Average latency = `latency_sum ÷ successes` (hourly rollup); percentile latency comes from per-request detail (90 days). `NULL` `ttfb_ms` ranks independently in each column. No samples → `—`. -**Click-to-drill**: clicking any group row drills the **Request detail** panel below into that dimension (channel / model / credential filter by the same-named column; the user dimension goes through the scope parameter). Conditions stack across dimensions, the active filters show as chips above the panel, and one click clears them. `GET /api/stats/events` therefore accepts `provider` / `model` / `credential_id` filters (AND semantics). +**Failure attribution**: a panel below the group table, `GET /api/stats/errors` (operator+), aggregates `ok=0` detail by `(channel, model, error_type)` with failure count and last failure time (descending, `limit` ≤ 5000). Clicking a row drills into the detail with *failed only* (`GET /api/stats/events?ok=0`). Rendered for admin/operator only. `/metrics` `upstream_errors_total` still uses the in-process counter; the two are not merged. -**Merged columns**: the Request detail table merges same-kind columns into one, showing a readable default and the full breakdown on hover: **Token** (defaults to "input / output · hit rate", hover adds cached tokens) and **Latency** (defaults to "duration / TTFB"). Column count drops from 14 to about 10, and the narrow-screen cards carry half as many fields. Sorting is preserved: clicking a merged header opens a dropdown to pick the sub-column (Token → input / output / cached / hit rate; Latency → duration / TTFB), and selecting one sorts by it. The **hit rate** sub-column uses backend `sort=cache_rate` (cached ÷ input; `NULL` when input is 0 or never reported, matching the sort semantics of other nullable columns). +**Row drill-down**: clicking a group row filters the detail by that row (channel / model / credential; the user dimension uses scope), stacking conditions. `GET /api/stats/events` accepts `provider` / `model` / `credential_id` (AND) and `ok=0`. -**CSV export**: the **Export CSV** button (admin/operator only) exports request detail for the **current time range and drill filters**, via `GET /api/stats/events?format=csv`. The file carries a UTF-8 BOM (so Excel detects the encoding), `\r\n` line endings, ISO 8601 (UTC) timestamps, and prefixes a leading apostrophe on **text** cells starting with `= + - @` (or tab/CR) to block spreadsheet formula injection (numbers, negatives included, are left as-is). Export is capped at 10000 rows; beyond that it truncates and flags `X-Export-Truncated: 1` in the response headers, and the page tells the user to narrow the conditions. +**Merged detail columns**: tokens, latency, speed and channel / credential each merge into one column with a readable default and a hover breakdown. The time column splits into two lines (time / date). Clicking a merged header opens a sub-column sort menu (`sort=cache_rate` / `tps_visible` / `tps` / `provider` / `credential`). -**Live stream (SSE)**: the **Live** toggle in the request-detail panel subscribes with `EventSource` to `GET /api/stats/events/stream` and shows requests **just committed** within the current filter range. Live rows render in their own panel above the paginated history, so the table you are paging through never jumps. Events are broadcast after the `StatsCollector.record` transaction commits via an in-process `LiveEventHub` (no DB polling); the client keeps the newest 50, newest first, and closing the toggle closes the connection. +**CSV export**: `GET /api/stats/events?format=csv` (admin / operator only), honoring the current range and drill-down filters. UTF-8 BOM, `\r\n`, ISO 8601 timestamps (UTC), and text cells starting with `= + - @` get a leading quote to prevent formula injection. Capped at 10000 rows with `X-Export-Truncated: 1`. -- **Scope and filters**: identical to `/api/stats/events` — admin/operator may pass `username` (omitted = everything), viewers are pinned to themselves; `provider` / `model` / `credential_id` stack. Changing a filter reopens the connection (`EventSource` cannot change its query string). -- **Credential names**: the broadcast payload carries no nickname (same redacted field set as the detail endpoint, plus `rowid`); only admin/operator connections get `credential_name` filled in per event **server-side**, so viewers never see pool nicknames. -- **Backpressure and limits**: each subscriber gets a **bounded** buffer (200 events); when full the **oldest** is dropped (a live view cares about now). At most 8 concurrent subscribers; beyond that the endpoint answers 429 with `Retry-After` instead of queueing or quietly degrading the service. -- **Keep-alive and caching**: a comment frame (`:`) every 15s keeps proxies from dropping an idle connection; the response sets `Cache-Control: no-store` and `X-Accel-Buffering: no` so no middle layer caches or fully buffers the stream. Browsers auto-reconnect; the page reports a disconnect once the browser gives up (401 / 429). +**Live stream (SSE)**: `GET /api/stats/events/stream` shows requests that just landed within the current filter. Events are broadcast by the in-process `LiveEventHub` after `StatsCollector.record` commits; the UI keeps the latest 50. -If history looks light (pre-upgrade detail not covered, or detail backfilled without re-rolling), run `python3 scripts/rollup_hourly.py --apply` (preview by default, backs up before writing, idempotent). It reports gaps and warns about intervals whose detail has passed the 90-day retention and cannot be restored. +- Same scope and filters as `/api/stats/events`; changing filters reconnects. +- The broadcast payload has no credential nickname; only admin/operator connections get `credential_name` filled in server-side. +- Bounded buffer of 200 per subscriber (oldest dropped); 8 concurrent subscribers, beyond that 429. +- 15s comment frames keep the connection alive; `Cache-Control: no-store` + `X-Accel-Buffering: no`. + +If history looks small, recompute with `python3 scripts/rollup_hourly.py --apply` (preview by default, backs up first, idempotent). ### List sorting -Admin lists are **sortable by clicking column headers**: the first click uses the column's natural direction (numbers descending, text ascending), subsequent clicks toggle. Sorting is done by the **backend** — every `GET` list endpoint accepts `sort` (whitelist key) and `order` (`asc` / `desc`). An unknown `sort` key or invalid `order` falls back to the endpoint's default (no error); `sort` is a whitelist key, never a SQL column, so the injection surface is zero. +Click a header to sort: first click uses the column's natural direction (numeric descending, text ascending), clicking again flips. Sorting happens server-side; list endpoints accept `sort` (allowlisted key) and `order` (`asc` / `desc`). Unknown `sort` or invalid `order` falls back to the default without an error. + +Applies to `/api/credentials`, `/api/users`, `/api/api-keys`, `/api/audit`, `/api/alerts`, `/api/model-catalog`, `/api/credentials/{id}/credit-events`, `/api/credentials/{id}/accounts`, `/api/stats/by-*`, `/api/stats/events`. + +### Pagination + +Shared pagination: `page N / M`, 20 / 50 / 100 per page (default 50). Flat lists paginate server-side — endpoints accept `limit` + `offset` and return the filtered `total`. `/api/model-catalog` sorts in memory because its sort keys include derived fields, and also accepts `q` and `only_available`. + +Grouped tables (credentials by channel, stats group table) are not paginated. ## Background tasks -Nine tasks — quota probe, token pre-refresh, daily check-in, growth center, activity reporting, detail cleanup, channel catalog refresh, OpenRouter catalog refresh, and operations alerts — are scheduled by `TaskRunner`, failing independently. Each task's last run, latest result and error appear on the *Tasks & settings* page (30s refresh); task state is **in-process only** and resets on restart. +Twelve task types — quota probe, token pre-refresh, daily check-in, growth center, activity reporting, detail cleanup, database backup, channel model refresh, model catalog refresh, operations alerts, upstream status, model probing — scheduled by `TaskRunner`, failures isolated. The *Tasks & settings* page shows last run time, result and error (refreshed every 30s); runtime state lives only in this process. + +Each card has *Run now* (admin + CSRF): it bypasses the time window but not the on/off switch; if already running it returns `busy=true` without queueing. -**Channel catalog refresh** (`MODEL_CATALOG_MINUTES`, default 30, floor 5) keeps the catalog fresh when nobody visits `/v1/models`; 30 minutes aligns with zen's 30-minute liveness cache, so going denser would only hit other channels' `/models` for nothing. +**Channel model refresh** (`MODEL_CATALOG_MINUTES`, default 30) is a safety net so models update even with no traffic. -**Growth center** is CodeBuddy-only; Buddy travels return every 1–4 hours, so the default cycle is 60 minutes (`GROWTH_INTERVAL_MINUTES`). Lottery / streak redemption / blind boxes / streak-repair cards are **irreversible** and skipped when `GROWTH_IRREVERSIBLE_ACTIONS=false`. +**Growth center** is CodeBuddy-only. Default period 60 minutes (`GROWTH_INTERVAL_MINUTES`). Irreversible actions are gated by `GROWTH_IRREVERSIBLE_ACTIONS=false`. + +### Activity reporting (optional, off by default) + +With `ACTIVITY_REPORT_ENABLED=true`, each account gets one `chat_request_send` event per day in the `ACTIVITY_REPORT_HOUR` (default 10) window to keep the streak alive. Unrelated to credits or scheduling. Applies to CodeBuddy and the international site. + +> **Risk**: the official terms forbid scripted tampering with activity data; penalties include disqualification and clawing back gifts. Off by default — assess the account risk first. Breaks as soon as the upstream changes. A missing `userId` makes the upstream silently discard the event; empty OAuth `user_id` falls back to the JWT `sub`, and skips when still missing. ### Operations alerts -A periodic task evaluates four risks and persists hits to `alert_events`, reviewed on the *Operations alerts* page (admins only): +Four rules are evaluated periodically and written to `alert_events` (the *Alerts* page, admin-only): -| Rule | Fires when | +| Rule | Trigger | |---|---| -| `pool_empty` | The pool has credentials but fewer than `ALERT_POOL_READY_MIN` are usable | -| `task_failed` | A background task fails `ALERT_TASK_FAILURES` times in a row (a success resets it) | -| `token_expiring` | A credential's token has less than `ALERT_TOKEN_EXPIRY_HOURS` left | -| `error_rate` | The window's upstream failure ratio reaches `ALERT_ERROR_RATE_THRESHOLD` with enough samples | +| `pool_empty` | ready count below `ALERT_POOL_READY_MIN` | +| `task_failed` | a task fails `ALERT_TASK_FAILURES` times consecutively | +| `token_expiring` | token lifetime within `ALERT_TOKEN_EXPIRY_HOURS` | +| `error_rate` | failure share reaches `ALERT_ERROR_RATE_THRESHOLD` with enough samples | -- **Silence window** (`ALERT_SILENCE_MINUTES`): the same `(rule, target)` is recorded / pushed only once per window. -- **Hysteresis** (`ALERT_FOR_TICKS` / `ALERT_CLEAR_TICKS`): consecutive hits before firing, consecutive misses before confirming recovery. On recovery every **still-unresolved** row for that `(rule, target)` gets `resolved_at` (the reminder rows of the same incident close together), and a `status="resolved"` webhook is emitted. -- **Restart self-healing**: hysteresis **counters** live in-process and restart from zero, but "which alerts are still open" is a persisted fact — `AlertTask` rebuilds it from the unresolved rows at startup, so an alert recorded before a restart whose condition has since cleared is still marked resolved. -- **Manual resolution**: each unresolved row has a *Mark resolved* button (`POST /api/alerts/{id}/resolve`, admin + CSRF + audit) for cases the state machine cannot judge (rule retired, false positive). It touches that row only. -- **Retention**: same as request detail (90 days), trimmed by the cleanup task. +- **Silence window**: one row / push per `(rule, target)` per window. +- **Hysteresis**: alerts fire after N consecutive hits and resolve after M consecutive misses. Resolution stamps `resolved_at` on every unresolved row for that `(rule, target)` and emits a `status="resolved"` webhook. +- **Restart self-heal**: hysteresis counters are in-process; the unresolved set is rebuilt from the DB. +- **Manual resolution**: *Mark resolved* (admin + CSRF + audit) marks that row only. +- **Retention**: same as request detail (90 days). -### Activity reporting (optional, off by default) +### Database backup and restore -The growth center's streak counts daily client chat events; an account driven only by the gateway would break the streak. With `ACTIVITY_REPORT_ENABLED=true`, a task posts **one** `chat_request_send` event per account per day at `ACTIVITY_REPORT_HOUR` (default 10:00) to keep the streak alive — unrelated to credits or scheduling, and failures do not affect chat. Applies to CodeBuddy and CodeBuddy international. +**Automatic** (`BACKUP_INTERVAL_MINUTES`, default daily) writes a consistent snapshot into `DATA_DIR/backups/`, keeping the newest `BACKUP_KEEP` (default 7). -> ⚠️ **Risk**: the official terms forbid scripted tampering with activity data (disqualification + gift clawback). Off by default; evaluate the account risk first. Event names and request shapes depend on the upstream and **break without notice** — not a reliability feature. Measured (2026-09-21): without `userId` the upstream returns HTTP 200 `{"code":0}` and **silently drops** the event; when a credential's `user_id`/`account_uid` is empty, the gateway falls back to the bearer JWT `sub`, and skips if still missing (never fabricated). +- Uses `VACUUM INTO`, not a file copy (WAL would lose uncheckpointed transactions). +- Runs `PRAGMA integrity_check` immediately and deletes a bad snapshot. Files `0600`, directory `0700`. +- Failures only log; the service keeps running. + +Manual backup: + +```bash +python3 scripts/backup_db.py # into DATA_DIR/backups/, keep 7 +python3 scripts/backup_db.py --keep 14 # keep 14 (0 = never prune) +python3 scripts/backup_db.py --out /path/dir +``` + +Restore (**stop the service first**): + +```bash +python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 # preview: verify only +python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 --apply # verify → safety copy → atomic replace +``` + +The flow: verify integrity → take a `.pre-restore-` copy → replace atomically → remove stale `-wal`/`-shm`. **Restart afterwards.** ## Configuration -Key variables below; the full list is in `src/config.py` (authoritative), and **every one is plumbed through `docker-compose.yml`**. In containers use `HOST`/`PORT` to change the listen address (`PORT` also sets the host mapping). +Full list in `src/config.py` (authoritative); every item is wired through `docker-compose.yml`. Inside containers use `HOST`/`PORT` to change the listen address. | Variable | Default | Notes | |---|---|---| -| `APP_SECRET` | **required** | Credential encryption key, ≥16 chars; loss = all credentials void, no rotation. Low-entropy values are rejected at startup | -| `ADMIN_USERNAMES` | empty | **Bootstrap-only**: users promoted to `admin` on first start; inert once the DB has roles | -| `USERS_FILE` | `secrets/users.txt` | **Bootstrap-only**: legacy user file imported once (idempotent) | -| `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` | Browser-reachable address; TRAE login callback depends on it | -| `DATA_DIR` | `./data` | SQLite and runtime data directory | -| `DEFAULT_MODEL` | `deepseek-v4.1-flash` | Model when empty/`auto` | -| `QUOTA_PROBE_MINUTES` | `60` | Quota probe interval (floor 1) | -| `REFRESH_INTERVAL_MINUTES` | `30` | Token pre-refresh polling interval (floor 5); short-lived channels cap their window wider than this | -| `MODEL_CATALOG_MINUTES` | `30` | Channel catalog fallback refresh (floor 5) | -| `OPENROUTER_CATALOG_MINUTES` | `1440` | Catalog refresh (floor 60): one fetch from the OpenRouter public API covers prices, metadata and the Artificial Analysis capability indices | -| `OPENROUTER_MODELS_URL` | `https://openrouter.ai/api/v1/models` | Catalog source (anonymous, no key); restart required | -| `USD_CNY_RATE` | `6.70` | Cost-estimate FX rate; hot-updatable, affects only later writes | -| `GROWTH_INTERVAL_MINUTES` | `60` | Growth-center cycle (CodeBuddy only; floor 5) | -| `GROWTH_IRREVERSIBLE_ACTIONS` | `true` | Allow lottery, streak redemption, blind boxes, repair cards | -| `ACTIVITY_REPORT_ENABLED` | `false` | Post one chat event per account per day. **Off by default** — terms forbid tampering | -| `ACTIVITY_REPORT_HOUR` | `10` | Activity hour (local Beijing time) | -| `CHECKIN_TIMES` | `""` | Daily check-in times: local `HH:MM`, comma separated (e.g. `09:37,21:10`). A round runs only at those times; **empty = every 10 minutes all day** (the old behaviour, unchanged after upgrade). Bad segments are ignored with a warning; all-bad is treated as empty. Check-in is idempotent upstream, so extra times only add retry opportunities. Once times are set the loop wakes **every 60 seconds** — otherwise a zero-jitter window lasts one minute and the day is silently missed. Hot-reloadable | -| `CHECKIN_JITTER_MINUTES` | `0` | Check-in jitter (minutes): run a random `0..N` minutes **before** the configured time so multiple deployments do not hit upstream in the same minute. Deduplication keys on *date + time*, so jitter never makes the same slot run twice. `0` = off. Hot-reloadable | -| `LOG_BUFFER_LINES` | `500` | In-process ring-buffer size for the admin *Server logs* page (collects this process's `logging` records only, never tails a file). `0` disables the page (both `GET /api/logs` and `/api/logs/stream` return 404). Every line passes a redaction allow-list first: anything matching `token` / `secret` / `authorization` / `cookie` / `password` / `api_key`, an `sk-…` shape or `Bearer ` is **dropped entirely**. Hot-reloadable | -| `ALERT_ENABLED` | `true` | Operations-alert evaluation (pool exhausted / repeated task failures / token near expiry / error-rate spike), persisted to `alert_events` | -| `ALERT_WEBHOOK_URL` | empty | Webhook POST (JSON, body carries `status`: `firing` / `resolved`), comma-separated (≤16, `http(s)` only, private/loopback/link-local blocked against SSRF, re-checked against DNS resolution right before delivery and skipped if it fails to resolve), concurrent delivery, all must succeed (15s budget); requests always carry `X-Coding2API-Signature: sha256=`; empty = in-app records only | -| `ALERT_INTERVAL_MINUTES` | `5` | Alert evaluation cycle (floor 1) | +| `APP_SECRET` | **required** | Credential encryption key, ≥16 chars; losing it voids every credential. Low-entropy values rejected | +| `ADMIN_USERNAMES` | empty | Bootstrap only: promoted to `admin` on first start | +| `USERS_FILE` | `secrets/users.txt` | Bootstrap only: legacy user file imported once | +| `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` | Browser-reachable address; TRAE login callback; HSTS when https. Does not decide the session cookie `Secure` flag | +| `DATA_DIR` | `./data` | SQLite and runtime data | +| `DEFAULT_MODEL` | `deepseek-v4.1-flash` | Used when the model is empty / `auto` | +| `QUOTA_PROBE_MINUTES` | `60` | Quota probe period (min 1) | +| `REFRESH_INTERVAL_MINUTES` | `30` | Token pre-refresh poll period (min 5) | +| `MODEL_CATALOG_MINUTES` | `30` | Channel model refresh period (min 5) | +| `OPENROUTER_CATALOG_MINUTES` | `1440` | Model catalog refresh period (min 60) | +| `OPENROUTER_MODELS_URL` | `https://openrouter.ai/api/v1/models` | Catalog source (anonymous); restart required | +| `USD_CNY_RATE` | `6.70` | Cost estimation rate; hot-reloadable, affects new rows only | +| `GROWTH_INTERVAL_MINUTES` | `60` | Growth center period (min 5) | +| `GROWTH_IRREVERSIBLE_ACTIONS` | `true` | Allow lottery / streak redemption / blind boxes / makeup cards | +| `ACTIVITY_REPORT_ENABLED` | `false` | Activity reporting, off by default | +| `ACTIVITY_REPORT_HOUR` | `10` | Local hour window for activity reporting | +| `CHECKIN_TIMES` | `""` | Check-in times `HH:MM`, comma-separated; empty = every 10 minutes. Hot-reloadable | +| `CHECKIN_JITTER_MINUTES` | `0` | Jitter before the time point. Hot-reloadable | +| `LOG_BUFFER_LINES` | `500` | Server log buffer lines; `0` disables the page. Hot-reloadable | +| `ALERT_ENABLED` | `true` | Operations alerts on/off | +| `ALERT_WEBHOOK_URL` | empty | Webhooks, comma-separated (≤16, http(s) only, internal / loopback rejected), signed with `X-Coding2API-Signature` | +| `ALERT_INTERVAL_MINUTES` | `5` | Alert evaluation period | | `ALERT_SILENCE_MINUTES` | `30` | Silence window per `(rule, target)`; `0` disables | -| `ALERT_FOR_TICKS` | `1` | Enter-hysteresis: consecutive hits before actually alerting; `1` = fire immediately | -| `ALERT_CLEAR_TICKS` | `2` | Clear-hysteresis: consecutive misses before confirming recovery (writes `resolved_at`, pushes a `status="resolved"` notice) | -| `ALERT_POOL_READY_MIN` | `1` | Usable-credential threshold; `0` disables the rule | -| `ALERT_TASK_FAILURES` | `3` | Consecutive task failures before alerting; `0` disables | -| `ALERT_TOKEN_EXPIRY_HOURS` | `24` | Alert when a token's remaining lifetime is below this; `0` disables | -| `ALERT_ERROR_RATE_THRESHOLD` | `0.5` | Upstream error-rate threshold; `0` disables | -| `ALERT_ERROR_RATE_MIN_REQUESTS` | `20` | Minimum sample size | -| `ALERT_ERROR_RATE_WINDOW_MINUTES` | `15` | Error-rate window (from `usage_events` detail) | -| `BACKUP_INTERVAL_MINUTES` | `1440` | Background DB backup cycle (minutes, floor 60); `VACUUM INTO` consistency snapshot into `DATA_DIR/backups/` | -| `BACKUP_KEEP` | `7` | Backup retention: only the newest N are kept, older ones deleted by time; `0` = never prune | -| `UPSTREAM_STATUS_ENABLED` | `true` | Upstream-status aggregation: a background job concurrently fetches the official status pages of the model / gateway providers and normalizes them; the admin *Upstream status* page is read-only. **Explains, never decides**; the snapshot lives in memory and resets on restart | -| `UPSTREAM_STATUS_MINUTES` | `10` | Aggregation cycle (floor 1) | -| `UPSTREAM_STATUS_TIMEOUT_SECONDS` | `10.0` | Per-source HTTP timeout (seconds): 8 sources are fetched concurrently, so one slow source must not stall the round | -| `MODEL_PROBE_MINUTES` | `0` | Active model probing cycle (floor 1 minute, `0` = off). Probes only `(channel, model)` pairs still locked by the `blocked` negative cache (11102 "no such backend model"), using one minimal chat to tell "the model is really gone" from "quota / rate limit / network". A success clears that channel's negative cache (the only early-recovery path); only an explicit upstream "no such model" counts as failure, everything else is "undecidable" and **never disables** anything. Probes hit providers directly, write no `usage_events`, touch no cooldown and take no part in selection; results land in the separate `model_probes` table. Enabling it sends real upstream requests and burns quota — decide by remaining balance; the negative cache itself starts at 6h, so ≥60 minutes is the practical floor. Hot-reloadable, and any single model can be probed from the *Model list* page | -| `QUOTA_EXPIRY_WINDOW_SECONDS` | `129600` | Main expiry window: credits expiring within it burn account-first; `≤0` disables expiry sorting | -| `QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS` | `604800` | Tie-breaking window; `≤0` disables | -| `CONVERSATION_STICKY_SECONDS` | `3600` | Session-affinity TTL. Precedence: **explicit request-body session id** (`conversation_id` / `prompt_cache_key`, `metadata` first) > **session header** (`x-session-id` / `x-opencode-session` / `x-session-affinity` / `conversation-id`) > **message-prefix fingerprint** fallback; a manual pin beats all. Header and fingerprint values are salted with `username` before hashing, so two users sending the same value never share an account; headers must match `[A-Za-z0-9._:-]{1,128}` or they are dropped. `user_id` still suppresses the prefix fallback (headers keep working); `≤0` disables | -| `MODEL_BLOCKLIST` | `custom_model_*,*sub*agent*,summary,browser_use_*,file_search_agent,default,seed-code-pro-*,sagitta,aquila,glm-4.*,glm-5,glm-5-turbo,glm-5.0*,glm-5.1,glm-5v-*,kimi-k2.5,kimi-k2.6,kimi-k2-thinking,minimax-m2.5,deepseek-v3-*,hunyuan-*,hy3*,deepseek-v4-*,qwen3.7-*,auto,nemotron-3-*,glm-5.2*,nemotron-3*,lfm2*,north-mini-code,openrouter-models-router,fast,balanced,deep,primary,hy4-preview,inkling-small,minimax-m2.7,kimi-k2.*,minimax-m3,gpt-5.4,gpt-5.5,laguna*,qwen3-vl-235b,seed-2.1*,xing4.0-29b-a4,glyph-cluster,qwen3.8-flash,seed-evolving` | Model-list blocklist (fnmatch, display only), matched on the outward spellings | -| `MODEL_ALIASES` | `""` | Custom model aliases: `outward=model@channel;outward2=model`, `@channel` optional (auto-picked). The list and request parsing share one table; a segment is skipped with an ERROR when it collides with a real id or an auto alias, the target does not exist, or the pinned channel does not hold the model. Empty = disabled. Hot-reloadable | -| `MODEL_FALLBACK_GROUPS` | `""` | Cross-channel fallback groups: `name=m1,m2;name2=m3`; members may carry `@channel`; switches only **before any frame is emitted** | -| `STREAM_FIRST_EVENT_TIMEOUT_SECONDS` | `0` | Streaming **first-event** timeout (seconds): if the upstream accepts the connection but never emits the first data frame, switch accounts and retry **before any frame is out** (safe, the client has nothing yet). Comment frames / heartbeats do not reset it. `0` disables. **Must stay off by default** — slow contexts measured 40–71s to first byte; use `60` if needed. Hot-reloadable | -| `STREAM_IDLE_TIMEOUT_SECONDS` | `0` | Streaming **idle** timeout (seconds): after frames started, a stalled upstream ends the stream with one error frame — half output cannot be rolled back, so **no account switch**. `0` disables (recommended `300`). Hot-reloadable | -| `SHORT_COOLDOWN_WAIT_SECONDS` | `0` | Short-cooldown "wait and retry" (seconds): when the whole pool is in short cooldown with no immediately usable candidate, and the shortest remaining cooldown is within this value, wait and re-select once instead of returning 503. The wait holds no in-flight lease and does not count against the rotation budget. `0` disables (fail fast, old behaviour). Hot-reloadable | -| `ALLOWED_HOSTS` | empty | Host allowlist (DNS rebinding) | -| `ALLOW_TEST_HOST` | `false` | Also allow the TestClient host `testserver`. **Tests only** | -| `TRUST_PROXY` | `false` | Honour `X-Forwarded-For` (last entry); exactly one trusted proxy only | +| `ALERT_FOR_TICKS` | `1` | Hits before firing | +| `ALERT_CLEAR_TICKS` | `2` | Misses before resolving | +| `ALERT_POOL_READY_MIN` | `1` | Pool-empty threshold; `0` disables | +| `ALERT_TASK_FAILURES` | `3` | Consecutive task failures; `0` disables | +| `ALERT_TOKEN_EXPIRY_HOURS` | `24` | Token remaining hours; `0` disables | +| `ALERT_ERROR_RATE_THRESHOLD` | `0.5` | Error rate threshold; `0` disables | +| `ALERT_ERROR_RATE_MIN_REQUESTS` | `20` | Minimum samples | +| `ALERT_ERROR_RATE_WINDOW_MINUTES` | `15` | Error rate window | +| `BACKUP_INTERVAL_MINUTES` | `1440` | Backup period (min 60) | +| `BACKUP_KEEP` | `7` | Snapshots retained; `0` = never prune | +| `UPSTREAM_STATUS_ENABLED` | `true` | Upstream status page on/off | +| `UPSTREAM_STATUS_MINUTES` | `5` | Fetch period (min 1) | +| `UPSTREAM_STATUS_TIMEOUT_SECONDS` | `10.0` | Per-source HTTP timeout | +| `MODEL_PROBE_MINUTES` | `0` | Probe period (`0` = off). Only cache-locked `(channel, model)` pairs; success clears the cache, everything else never disables. Hot-reloadable | +| `QUOTA_EXPIRY_WINDOW_SECONDS` | `129600` | Main expiry window; `≤0` disables | +| `QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS` | `604800` | Secondary window; `≤0` disables | +| `CONVERSATION_STICKY_SECONDS` | `3600` | Stickiness TTL. Priority: body key > header > message-prefix fingerprint; pin wins. `≤0` disables | +| `MODEL_BLOCKLIST` | `custom_model_*,*sub*agent*,summary,browser_use_*,file_search_agent,default,seed-code-pro-*,sagitta,aquila,glm-4.*,glm-5,glm-5-turbo,glm-5.0*,glm-5.1,glm-5v-*,kimi-k2.5,kimi-k2.6,kimi-k2-thinking,minimax-m2.5,deepseek-v3-*,hunyuan-*,hy3*,deepseek-v4-*,qwen3.7-*,auto,nemotron-3-*,glm-5.2*,nemotron-3*,lfm2*,north-mini-code,openrouter-models-router,fast,balanced,deep,primary,hy4-preview,inkling-small,minimax-m2.7,kimi-k2.*,minimax-m3,gpt-5.4,gpt-5.5,laguna*,qwen3-vl-235b,seed-2.1*,xing4.0-29b-a4,glyph-cluster,qwen3.8-flash,seed-evolving` | Model list blocklist (fnmatch, display only), matched against normalized spellings | +| `MODEL_FALLBACK_GROUPS` | `""` | Cross-channel fallback groups: `name=member1,member2;…`. Empty = off | +| `ALLOWED_HOSTS` | empty | Host allowlist against DNS rebinding | +| `ALLOW_TEST_HOST` | `false` | Allow `testserver`; tests only | +| `TRUST_PROXY` | `false` | Trust `X-Forwarded-For`; only behind exactly one trusted proxy | | `ENABLE_DOCS` | `false` | Expose `/docs` and `/openapi.json` | -| `DUMP_REQUEST_BODIES` | `false` | Diagnostic: dump `/v1` bodies to `data/dumps/` (**contains conversation content**) | -| `LOG_LEVEL` | `INFO` | Audit log is INFO — switching to `WARNING` turns it off | -| `HOST` / `PORT` | `127.0.0.1` / `8000` | Listen address/port (compose defaults to `0.0.0.0`) | -| `CODEBUDDY_API_ENDPOINT` | `https://copilot.tencent.com` | CodeBuddy upstream; add it to `CODEBUDDY_ALLOWED_ENDPOINTS` when changed | -| `CODEBUDDY_ALLOWED_ENDPOINTS` | both official sites (see compose) | Token only goes to allowlisted hosts | -| `CODEBUDDY_INTL_API_ENDPOINT` | `https://www.codebuddy.ai` | CodeBuddy international upstream; add it to its own allowlist | -| `CODEBUDDY_INTL_ALLOWED_ENDPOINTS` | `https://www.codebuddy.ai` | **Deliberately excludes** the CN host (cross-site = leakage; misconfig fails startup) | -| `ZEN_API_ENDPOINT` / `ZEN_ALLOWED_ENDPOINTS` | `https://opencode.ai` | Zen endpoint + allowlist | -| `ZEN_OPENCODE_VERSION` | `1.18.0` | Spoofed `opencode/` UA (below threshold → 426) | -| `KILO_API_ENDPOINT` / `KILO_ALLOWED_ENDPOINTS` | `https://api.kilo.ai/api/gateway` | Kilo endpoint + allowlist | -| `QODER_API_ENDPOINT` / `QODER_GATEWAY_ENDPOINT` | `https://openapi.qoder.com.cn` / `https://gateway.qoder.com.cn` | Qoder openapi (quota/login) + gateway (chat); international: `openapi.qoder.sh` / `api1.qoder.sh` | -| `QODER_ALLOWED_ENDPOINTS` | CN + international (see compose) | COSY-signed requests only to allowlisted hosts | -| `CODEARTS_API_ENDPOINT` | `https://snap-access.cn-north-4.myhuaweicloud.com` | CodeArts snap engine; add it to its allowlist | -| `OFFICEACE_API_ENDPOINT` | `https://agentarts.cn-southwest-2.myhuaweicloud.com` | OfficeAce control plane (OAuth + provisioning); add it to its allowlist | -| `OFFICEACE_ALLOWED_ENDPOINTS` | the control-plane host only | The model gateway differs per account and is issued into the credential, so it is **not** allowlisted here | -| `CODEARTS_ALLOWED_ENDPOINTS` | snap + STS + portal (see compose) | AK/SK-signed requests only to allowlisted hosts | -| `PROVIDER_PROXIES` | `""` | Per-channel egress proxy (startup-only); empty = all direct | -| `CODEBUDDY_CHAT_MIN_INTERVAL` | `5` | CB pacer (seconds), per-credential bucket; `0` disables | -| `CODEBUDDY_INTL_CHAT_MIN_INTERVAL` | `5` | CB international pacer, **independent**; `0` disables | -| `ZEN_CHAT_MIN_INTERVAL` / `KILO_CHAT_MIN_INTERVAL` | `0` | Zen / Kilo pacers, independent, off by default | -| `QODER_CHAT_MIN_INTERVAL` / `CODEARTS_CHAT_MIN_INTERVAL` | `5` | Qoder / CodeArts pacers; `0` disables | -| `OFFICEACE_CHAT_MIN_INTERVAL` | `0` | OfficeAce pacer, independent; `0` disables | +| `DUMP_REQUEST_BODIES` | `false` | Dump raw `/v1` bodies (contains conversations; diagnostics only) | +| `LOG_LEVEL` | `INFO` | Log level; audit logging is INFO and disappears at WARNING | +| `HOST` / `PORT` | `127.0.0.1` / `8000` | Listen address and port (compose defaults to `0.0.0.0`); `PORT` also sets the host mapping | +| `CODEBUDDY_API_ENDPOINT` | `https://copilot.tencent.com` | Upstream address; update the allowlist too | +| `CODEBUDDY_ALLOWED_ENDPOINTS` | both official sites (see compose) | Allowlist; real tokens only go to listed hosts | +| `CODEBUDDY_INTL_API_ENDPOINT` | `https://www.codebuddy.ai` | International address | +| `CODEBUDDY_INTL_ALLOWED_ENDPOINTS` | `https://www.codebuddy.ai` | International allowlist, deliberately excluding the CN host | +| `ZEN_API_ENDPOINT` | `https://opencode.ai` | Zen upstream | +| `ZEN_ALLOWED_ENDPOINTS` | `https://opencode.ai` | Zen allowlist | +| `ZEN_OPENCODE_VERSION` | `1.18.0` | Gate-spoofing UA version (below threshold → 426) | +| `KILO_API_ENDPOINT` | `https://api.kilo.ai/api/gateway` | Kilo upstream | +| `KILO_ALLOWED_ENDPOINTS` | same | Kilo allowlist | +| `QODER_API_ENDPOINT` | `https://openapi.qoder.com.cn` | Qoder openapi (quota / login); international `https://openapi.qoder.sh` | +| `QODER_GATEWAY_ENDPOINT` | `https://gateway.qoder.com.cn` | Qoder inference gateway; international `https://api1.qoder.sh` | +| `QODER_ALLOWED_ENDPOINTS` | CN + international (see compose) | Qoder allowlist | +| `CODEARTS_API_ENDPOINT` | `https://snap-access.cn-north-4.myhuaweicloud.com` | CodeArts snap engine | +| `CODEARTS_ALLOWED_ENDPOINTS` | snap + STS + portal (see compose) | CodeArts allowlist | +| `OFFICEACE_API_ENDPOINT` | `https://agentarts.cn-southwest-2.myhuaweicloud.com` | OfficeAce control plane | +| `OFFICEACE_ALLOWED_ENDPOINTS` | the control-plane host only | Control plane only (the model gateway is issued upstream) | +| `OFFICEACE_CHAT_MIN_INTERVAL` | `0` | OfficeAce chat pacing | +| `PROVIDER_PROXIES` | `""` | Per-channel outbound proxy; startup-time | +| `CODEBUDDY_CHAT_MIN_INTERVAL` | `5` | CB chat pacing (per-credential buckets) | +| `CODEBUDDY_INTL_CHAT_MIN_INTERVAL` | `5` | CB international pacing, independent | +| `ZEN_CHAT_MIN_INTERVAL` | `0` | Zen pacing | +| `KILO_CHAT_MIN_INTERVAL` | `0` | Kilo pacing | +| `QODER_CHAT_MIN_INTERVAL` | `5` | Qoder pacing | +| `CODEARTS_CHAT_MIN_INTERVAL` | `5` | CodeArts pacing | | `CODEARTS_MAX_CONCURRENCY` | `3` | CodeArts in-flight cap per account; `0` disables | -| `CODEARTS_REQUEST_WINDOW_SECONDS` | `60` | CodeArts sliding window (3 starts / 60s with the cap above); `0` disables | -| `CODEBUDDY_SANITIZE_CHANNEL_MARKERS` | `true` | Neutralize official-client fingerprints in outbound `system`/`assistant` text (upstream 11128 content filter); outbound copy only | -| `REFRESH_SKEW_HOURS` | `24` | Pre-refresh window; short-lived channels cap it (CodeArts 45 min) | -| `TOKEN_EXPIRY_WARNING_SECONDS` | `3600` | Admin warning threshold; `≤0` disables (display only) | -| `PACER_MIN_SECONDS` / `PACER_MAX_SECONDS` | `5` / `20` | Global pacer random wait (seconds). When the bounds are equal (the `*_CHAT_MIN_INTERVAL` shape) the wait is additionally jittered **upwards** by ±25% so instances do not fire in lockstep; the lower bound is never breached | +| `CODEARTS_REQUEST_WINDOW_SECONDS` | `60` | CodeArts sliding window; `0` disables | +| `CODEBUDDY_SANITIZE_CHANNEL_MARKERS` | `true` | Replace "spoofed official client" fingerprints in outbound text (upstream 11128 risk control) | +| `REFRESH_SKEW_HOURS` | `24` | Pre-refresh window before expiry; short-lived channels cap it themselves | +| `TOKEN_EXPIRY_WARNING_SECONDS` | `3600` | Token expiry warning threshold; `≤0` disables. Display only | +| `PACER_MIN_SECONDS` / `PACER_MAX_SECONDS` | `5` / `20` | Global pacer random wait range | | `AUTO_CONTINUE_MAX` | `10` | Auto-continue attempts on `finish_reason=length`; `0` disables | -| `CONTEXT_COMPRESS_ENABLED` | `true` | Trim over-long conversations to the catalog's input limit; models without a known limit are untouched | -| `CONTEXT_COMPRESS_RESERVE_TOKENS` | `4096` | Output tokens reserved by the compression budget | +| `CONTEXT_COMPRESS_ENABLED` | `true` | Trim long conversations to the model input limit | +| `CONTEXT_COMPRESS_RESERVE_TOKENS` | `4096` | Output tokens reserved in the compression budget | | `CONTEXT_COMPRESS_MIN_KEEP_MESSAGES` | `4` | Recent messages always kept | -| `CONTEXT_COMPRESS_SAFETY_RATIO` | `0.95` | Budget as a fraction of the model limit | -| `UPSTREAM_COMPLETE_TIMEOUT_SECONDS` | `600` | Non-streaming aggregate timeout (retried as transient); `≤0` disables | -| `MAX_INFLIGHT_PER_CREDENTIAL` | `0` | Per-credential in-flight cap (global across channels): a credential at its cap stops being selected; `0` = unlimited. The count also serves as the scheduler's least-in-flight load key. Leases release when the request ends, with a 600s TTL guarding leaked ones | -| `METRICS_ENABLED` | `true` | Expose `GET /metrics` (Prometheus text format, **unauthenticated**, same stance as `/healthz`); `false` disables it (404). Hot-updatable | -| `REQUEST_LOG_ENABLED` | `true` | Request-level structured log (logfmt): one line per business request (`/api/*`, `/v1/*`, `/authorize`) — `event=request request_id=… method=… route=… status=… duration_ms=…`. The route is the template (`/api/credentials/{credential_id}`), never the real ID; **no query string, no body, no username** — correlate via `request_id` against `usage_events`. 5xx at WARNING, the rest at INFO; static assets and probes are not logged. `false` falls back to uvicorn's plain access log. Hot-updatable | -| `FORCE_DISABLE_THINKING` | `false` | Strip reasoning from outbound responses: no more `reasoning_content` / Anthropic `thinking` blocks / Responses reasoning items. **Response-side only — the request body is untouched** (upstream still reasons). Applies to all three exits, streaming and non-streaming. Hot-updatable | -| `REASONING_EFFORT_MAP` | `""` | Outbound `reasoning_effort` remap: `channel=from:to,from:to;*=from:to` (channel is a `KNOWN_PROVIDERS` id, `*` is the fallback); a target of `none` deletes the key and lets the channel default apply. Only rewrites values the client **explicitly** sent — a channel's own `setdefault` is untouched. Empty = pass through. Hot-updatable | -| `ANTHROPIC_THINKING_EFFORTS` | `""` | Anthropic inbound `thinking.budget_tokens` → upstream `reasoning_effort`: `budget:effort,budget:effort,disabled:effort` (`disabled` matches `thinking.type=="disabled"`; otherwise the largest threshold ≤ `budget_tokens` wins, and budgets below the smallest threshold are not mapped). Empty = no mapping at all (`thinking` stays ignored). Hot-updatable | +| `CONTEXT_COMPRESS_SAFETY_RATIO` | `0.95` | Budget share of the model limit | +| `UPSTREAM_COMPLETE_TIMEOUT_SECONDS` | `600` | Non-streaming aggregate timeout; `≤0` disables | +| `STREAM_FIRST_EVENT_TIMEOUT_SECONDS` | `0` | First-event timeout (retryable before any frame). **Keep off by default**, recommended 60. Hot-reloadable | +| `STREAM_IDLE_TIMEOUT_SECONDS` | `0` | Idle timeout (closes, never retries), recommended 300. Hot-reloadable | +| `SHORT_COOLDOWN_WAIT_SECONDS` | `0` | Wait when the whole pool is in short cooldown; `0` = fail fast. Hot-reloadable | +| `MODEL_ALIASES` | `""` | Custom aliases; empty = off. Hot-reloadable | +| `MAX_INFLIGHT_PER_CREDENTIAL` | `0` | Per-account in-flight cap; `0` = unlimited. 600s TTL backstop | +| `METRICS_ENABLED` | `true` | Expose `/metrics` (unauthenticated). Hot-reloadable | +| `REQUEST_LOG_ENABLED` | `true` | Request-level logfmt logging; no query / body / username. Hot-reloadable | +| `FORCE_DISABLE_THINKING` | `false` | Strip reasoning from responses only. Hot-reloadable | +| `REASONING_EFFORT_MAP` | `""` | Outbound level mapping `channel=from:to;*=from:to`; `none` deletes the key. Hot-reloadable | +| `ANTHROPIC_THINKING_EFFORTS` | `""` | Anthropic `thinking.budget_tokens` → upstream `reasoning_effort`. Hot-reloadable | ### Runtime settings (*Tasks & settings* page) -**60 settings** apply immediately without editing `.env` or restarting: default model, blocklist, model aliases, context-compression knobs, fallback groups, both expiry windows, sticky TTL, stream timeouts, short-cooldown wait, the per-credential in-flight cap, growth toggles/interval, refresh/probe/catalog intervals, `USD_CNY_RATE`, every per-channel chat interval, CodeArts concurrency/window, pacer bounds, activity toggle/hour, check-in times/jitter, all alerting rules, backup interval/retention, upstream-status toggle/interval, `METRICS_ENABLED`, `REQUEST_LOG_ENABLED`, `LOG_BUFFER_LINES` and the three reasoning knobs (`FORCE_DISABLE_THINKING` / `REASONING_EFFORT_MAP` / `ANTHROPIC_THINKING_EFFORTS`). +The **60** items marked hot-reloadable above can be changed there without touching `.env` or restarting (the page lists them all). -**DB overrides beat `.env`**: once changed, `.env` no longer applies to that key; the row is marked "DB override" and *Reset* removes the override. Values live in the `runtime_settings` table (plain key/value), so adding a hot-updatable key needs no migration; unknown keys and invalid/out-of-range values are rejected on write. Startup-only knobs (`APP_SECRET`, `PORT`, `DATA_DIR`, `USERS_FILE`, `OPENROUTER_MODELS_URL`, endpoint allowlists) are **excluded**. API: `GET /api/settings`, `PUT /api/settings` (admin + CSRF; `null` resets). The page also shows the 12 tasks' state (`GET /api/tasks`, admin, 30s refresh), in-process only. Each task card has a **Run now** button (`POST /api/tasks/{key}/run`, admin + CSRF): it bypasses the due-time window but **not** the enable switch — a disabled task honestly returns `ran=false` instead of pretending it ran; if the same task is already running it returns `busy=true` immediately (no queueing, no concurrent upstream calls); the response is the finished round. +Key points: **DB override beats `.env`** — once overridden, `.env` no longer applies to that item; the page marks it "DB override" and *Restore default* deletes the row. Values live in `runtime_settings` (plain key/value), so new hot settings need no migration; unknown keys and invalid types / out-of-range values are rejected before writing. Startup-time items (`APP_SECRET` / `PORT` / `DATA_DIR` / `USERS_FILE` / `OPENROUTER_MODELS_URL` / upstream allowlists) are not hot. Endpoints `GET /api/settings` and `PUT /api/settings` (admin + CSRF); `null` restores the default. The page also shows task runtime state (`GET /api/tasks`, in-process, refreshed every 30s). ## Deployment notes -- **Mount ownership**: the container runs as uid 1001 (`appuser`); `./data` and `./secrets` must be writable/readable or SQLite cannot open: +- **Mount ownership**: the container runs as uid 1001 (`appuser`), so `./data` and `./secrets` must be writable / readable: ```bash mkdir -p data secrets && sudo chown -R 1001:1001 data secrets ``` -- **Timezone**: the image defaults to `TZ=Asia/Shanghai`. +- **Timezone**: the image defaults to `TZ=Asia/Shanghai`; `.env` `TZ` is passed through. Check-in and quota cycles depend on "today at 9", so keep it aligned with the host. +- **Port**: in compose `PORT` sets both the container listen port and the host mapping. +- **Rebuild after code changes**: `docker compose up -d` without `--build` reuses the local image and runs old code. Use `docker compose up -d --build` after changing `src/` or the frontend. ### Restart the backend after upgrading -**Changing anything under `src/` requires a process restart, or you get a "new frontend against an old backend" mismatch.** Typical symptom: the admin UI loads (frontend assets are static) but new endpoints fail — the old process lacks the route, unmatched `/api/*` returns a JSON `404`, and the client collapses that into an unrelated error. The frontend never needs a restart (the backend reads `web/dist` per request), but the **backend does** — a process manager restarts on *exit*, not on source change. +**Changes under `src/` require a process restart**, otherwise you get "new frontend + old backend": pages load but new endpoints 404. The frontend does not need a restart (the backend reads `web/dist` per request); the backend does. ```bash docker compose up -d --force-recreate # Docker / compose sudo systemctl restart coding2api # systemd ``` -Confirm the upgrade took effect (version first, then routes): +Confirm the upgrade: ```bash -sqlite3 data/coding2api.sqlite3 "PRAGMA user_version; SELECT username, role, enabled FROM users;" # expect user_version 29 +sqlite3 data/coding2api.sqlite3 "PRAGMA user_version; SELECT username, role, enabled FROM users;" curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/users # expect 401; 404 = old backend ``` ### Frontend assets on the same port -The backend serves the frontend build at `/` (reading from disk per request), so a frontend-only change just needs a rebuild and a browser refresh; `localhost:5173` is the Vite dev server. `scripts/build-web.sh` builds only when stale, or unconditionally with `--force`. +The backend serves the built frontend at `/` (reading from disk per request), so **frontend-only changes just need a build and a refresh**. `localhost:5173` is the Vite dev server; both can coexist. -> **Do not delete `web/.npmrc`, and do not change it only locally**: it disables `auto-install-peers`. `@lobehub/icons` declares `@lobehub/ui` / `antd` as peers, and auto-installing them drags in 400+ unused packages (this project only deep-imports its static icon components, which never reference `@lobehub/ui`); disabling it roughly halves the lockfile and takes `pnpm audit --prod` to zero vulnerabilities. The cost is that peers actually needed **must be declared explicitly** (that is why `@testing-library/dom` is listed in `devDependencies`). The setting is tightly coupled to `settings.autoInstallPeers` in the lockfile, so `Dockerfile` must `COPY web/.npmrc` as well — otherwise `--frozen-lockfile` fails with `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`. +```bash +./scripts/build-web.sh # build only when stale +./scripts/build-web.sh --force # unconditional rebuild +``` -> Deployment specifics for the development machine (process management, paths, tool versions) live in `docs/local-environment.md`, which is gitignored and absent from clones. +> **`web/.npmrc` must not be deleted**: it disables `auto-install-peers` (otherwise `@lobehub/icons` drags in `@lobehub/ui` / `antd`). The setting is coupled to `settings.autoInstallPeers` in the lockfile, so the `Dockerfile` must `COPY web/.npmrc` or `--frozen-lockfile` fails with `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`. + +> **Deployment notes for this machine**: process management, paths and tool versions are in `docs/local-environment.md` (gitignored). ## Public entry point (VPS + frp reverse tunnel) -Home broadband behind CGNAT has no inbound address, so the public entry point must be built by **dialing out** from the private side. The free Cloudflare Tunnel tier routes mainland traffic to the LAX edge (measured 1.1–2.4s end-to-end, ~60% packet loss); replacing it with an overseas VPS entry plus an frp tunnel drops that to 80–150ms. **Only the inbound path changes**: outbound calls to upstreams still use the local `PROVIDER_PROXIES`. +Behind CGNAT there is no inbound address, so the public entry must be an outbound tunnel. Cloudflare Tunnel's free tier routes mainland traffic via Los Angeles (measured 1.1–2.4s, ~60% loss); "offshore VPS entry + frp back to origin" gets to 80–150ms. Inbound only — outbound to upstreams still uses `PROVIDER_PROXIES`. ``` -client ──443/TLS──> VPS (nginx) ──frp tunnel──> home Mac:8000 +client ──443/TLS──> VPS(nginx) ──frp tunnel──> home Mac:8000 ``` -Artifacts: `deploy/frp/` (frps/frpc configs), `deploy/nginx/` (reverse proxy + TLS), `deploy/systemd/frps.service` (VPS side), `deploy/launchd/com.coding2api.frpc.plist` (Mac side). Config format targets frp v0.71.1 TOML. +Artifacts in `deploy/frp/`, `deploy/nginx/`, `deploy/systemd/frps.service`, `deploy/launchd/com.coding2api.frpc.plist` (frp v0.71.1 TOML). -### VPS side (Linux + systemd) +VPS side: ```bash sudo apt install -y nginx certbot python3-certbot-nginx -# grab the linux_amd64 archive from https://github.com/fatedier/frp/releases, extract frps -sudo install -m 755 frps /usr/local/bin/frps - +sudo install -m 755 frps /usr/local/bin/frps # from the frp release archive sudo useradd --system --no-create-home --shell /usr/sbin/nologin frps -sudo install -d -m 755 /etc/frp -sudo install -m 644 deploy/frp/frps.toml /etc/frp/frps.toml -# shared token (use the same value when installing the client) +sudo install -d -m 755 /etc/frp && sudo install -m 644 deploy/frp/frps.toml /etc/frp/ openssl rand -hex 32 | sudo tee /etc/frp/frps-token >/dev/null sudo chmod 600 /etc/frp/frps-token && sudo chown frps:frps /etc/frp/frps-token - sudo install -m 644 deploy/systemd/frps.service /etc/systemd/system/ sudo systemctl daemon-reload && sudo systemctl enable --now frps - -# nginx: replace __TUNNEL_DOMAIN__ with your domain sed 's/__TUNNEL_DOMAIN__/ai-api.example.com/g' deploy/nginx/coding2api.conf \ | sudo tee /etc/nginx/sites-available/coding2api.conf >/dev/null sudo ln -sf /etc/nginx/sites-available/coding2api.conf /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx -sudo certbot --nginx -d ai-api.example.com # issues the cert and sets up renewal +sudo certbot --nginx -d ai-api.example.com ``` -Point the domain's A record at the VPS public IP. An overseas VPS needs no ICP filing. +Point the DNS A record at the VPS public IP; an offshore VPS needs no ICP filing. -### Home Mac side (launchd) +Home Mac side: ```bash -brew install frp # or download it and set FRPC_BIN -./scripts/install-frpc.sh --server --token +brew install frp +./scripts/install-frpc.sh --server --token launchctl print gui/$(id -u)/com.coding2api.frpc | grep -E 'state|pid' -./scripts/install-frpc.sh --uninstall # uninstall +./scripts/install-frpc.sh --uninstall ``` -`deploy/frp/frpc.toml` reads values through frp's native `{{ .Envs.FRP_SERVER_ADDR }}` templating, so the repo holds nothing machine-specific; the address and token file path are injected by the plist, and the token lives in `secrets/frpc-token` (mode 600, gitignored) rather than in the plist — `launchctl print` dumps the plist verbatim. +`frpc.toml` reads `{{ .Envs.FRP_SERVER_ADDR }}`; address and token come from the plist, and the token lives in `secrets/frpc-token` (600, gitignored). -### Three `.env` values to update +Three `.env` values to update: -| Variable | Value | What breaks if missed | +| Variable | Value | If missed | |---|---|---| -| `ALLOWED_HOSTS` | append the tunnel domain | every tunnel request gets 400 `invalid host header` | -| `PUBLIC_BASE_URL` | `https://` | TRAE login callback base is wrong; no HSTS over https | -| `HOST` | keep `127.0.0.1` | the tunnel loops back locally; there is **no need** to bind `0.0.0.0` | - -`TRUST_PROXY`: there is exactly one nginx hop on the VPS, so `true` is fine and lets an API key's `allowed_ips` see the real client IP (nginx forwards `X-Forwarded-For`). If you still want a direct LAN entry point, keep it `false` — otherwise peers on the same subnet can spoof XFF and bypass the allowlist. +| `ALLOWED_HOSTS` | add the tunnel domain | every tunnel request returns 400 `invalid host header` | +| `PUBLIC_BASE_URL` | `https://` | wrong TRAE callback base, no HSTS | +| `HOST` | keep `127.0.0.1` | the tunnel only needs to reach loopback | -> Restart the service after changing `.env` (see "Deployment notes" above). -> Both frpc and frps log to stdout → `logs/frpc.*.log` on the Mac (rotated by -> newsyslog; `deploy/newsyslog/coding2api.conf` already has the two rules) and -> journald on the VPS. +`TRUST_PROXY`: with exactly one nginx hop on the VPS you may set `true` so key `allowed_ips` sees real client IPs. Keep `false` if LAN direct access must stay safe. ## Logs -The app writes **stdout / stderr** only, with no file logging or self-rotation, so the logs never stop growing on their own — configure rotation once per deployment shape. +The app writes **stdout / stderr** only, never files, and does not rotate. -| Deployment | Where | Rotation | Action | +| Deployment | Where logs go | Rotation | Action | |---|---|---|---| -| **Docker / compose** | `docker logs coding2api` | json-file (`10m × 5`, configured) | **none** | -| **Linux (systemd)** | `journalctl -u coding2api` | journald | **none** (template in `deploy/systemd/`) | -| **Linux (non-systemd)** | `/var/log/coding2api/*.log` | `logrotate` | `sudo cp deploy/logrotate/coding2api /etc/logrotate.d/` | -| **Bare** | terminal stderr | none | redirect and rotate yourself | +| Docker / compose | `docker logs coding2api` | json-file (10m × 5) | none | +| Linux (systemd) | `journalctl -u coding2api` | journald | none | +| Linux (non-systemd) | redirected to files | `logrotate` | `sudo cp deploy/logrotate/coding2api /etc/logrotate.d/` | +| Bare process | terminal stderr | none | redirect and rotate yourself | -Level via `LOG_LEVEL` (default `INFO`); the audit log is INFO, so `WARNING` turns it off. +Level via `LOG_LEVEL` (default `INFO`). **Audit logging is INFO** and disappears at `WARNING`. ### Request-level structured log -Besides uvicorn's plain access log, every business request (`/api/*`, `/v1/*`, `/authorize`) emits one logfmt line: +Every business request (`/api/*`, `/v1/*`, `/authorize`) writes one logfmt line: ``` event=request request_id=9f2c… method=POST route=/v1/chat/completions status=200 duration_ms=1234 ``` -- **`route` is the template** (`/api/credentials/{credential_id}`), not the real path — resource IDs never enter the log and the log doesn't fan out per ID. Unmatched routes (404, rejected by the security middleware) fall back to a sanitized raw path (whitelisted characters, 120-char cap). -- **No query string, body, headers or username.** Query strings may carry client-supplied tokens; identity is correlated through `request_id` against `usage_events.request_id` (which has the `username` column) — logs are kept longer and read by more people, so less is better. +- `route` is the route template (`/api/credentials/{credential_id}`); unmatched requests fall back to a sanitized raw path. +- No query string, request body, response body, headers or username; identity is recovered via `request_id` into `usage_events`. - `request_id` matches the `X-Request-ID` response header. -- **Levels**: `5xx` at WARNING (our own fault, worth catching), everything else at INFO (otherwise 401 probes and typo'd paths drown the alert log). -- **Static assets and probes are not logged** (`assets/*.js`, `/health`, `/healthz`, `/metrics`) — uvicorn already covers them, and duplicating only pushes business lines out of view. -- Toggle `REQUEST_LOG_ENABLED` (default `true`, hot-updatable); turning it off falls back to uvicorn's plain access log. +- `5xx` logs WARNING, everything else INFO. +- Static assets and probes (`/health`, `/healthz`, `/metrics`) are skipped. +- uvicorn's access log is disabled. +- Switch `REQUEST_LOG_ENABLED` (default `true`, hot-reloadable). + +> `data/dumps/` (written by `DUMP_REQUEST_BODIES=true`) is outside the docker log system; the app keeps at most 200 files. ## Development @@ -694,16 +821,14 @@ pnpm build ## Documentation -Detailed documentation is written in Chinese: - -| Document | Content | +| Document | Contents | |---|---| -| [`PROPOSAL.md`](PROPOSAL.md) | Scope, upstream feasibility findings, architecture, data model, security boundary | -| [`TECHNICAL.md`](TECHNICAL.md) | Stack, module specs, provider protocol, request flow, testing | +| [`PROPOSAL.md`](PROPOSAL.md) | Goals and non-goals, upstream feasibility, architecture, data model, security boundary | +| [`TECHNICAL.md`](TECHNICAL.md) | Stack, module specs, provider protocols, request sequence, test strategy | | [`diagrams/coding2api-architecture.html`](diagrams/coding2api-architecture.html) | Architecture diagram | -| [`diagrams/coding2api-request-sequence.html`](diagrams/coding2api-request-sequence.html) | Request main-chain sequence diagram | -| [`diagrams/coding2api-credential-lifecycle.html`](diagrams/coding2api-credential-lifecycle.html) | Credential scheduling lifecycle | +| [`diagrams/coding2api-request-sequence.html`](diagrams/coding2api-request-sequence.html) | Main request sequence | +| [`diagrams/coding2api-credential-lifecycle.html`](diagrams/coding2api-credential-lifecycle.html) | Credential scheduling state machine | ## License -MIT — see [LICENSE](LICENSE). Attribution for the upstream projects this work learned from is in [NOTICE](NOTICE). +MIT, see [LICENSE](LICENSE). Upstream attributions in [NOTICE](NOTICE). diff --git a/README.md b/README.md index 3af4de1c..ad260a4f 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,95 @@ # Coding2API -把 **CodeBuddy**(国内站 + 国际)、**TRAE SOLO**、**OpenCode Zen**、**Kilo**、**Qoder**、**CodeArts**、**OfficeAce** 八个上游渠道封装为 OpenAI 兼容 API,提供公共凭证池、统一模型名与调度、按人用量统计。 +把 **CodeBuddy**(国内 + 国际)、**TRAE SOLO**、**OpenCode Zen**、**Kilo**、**Qoder**、**CodeArts**、**OfficeAce** 八个上游封装成 OpenAI 兼容 API,提供公共凭证池、统一模型名与调度、按人用量统计。 > [!WARNING] > 逆向接口,仅供学习研究,未做安全审计;公网部署需反向代理 + 鉴权 + IP 白名单。 ## 特性 -- **OpenAI 兼容出口**:`/v1/chat/completions`(流式 + 非流式)、`/v1/responses`(Codex CLI)、`/v1/messages`(Anthropic / Claude Code)、`/v1/models` 与 `/v1/models/{id}`(单模型查询)、`/v1/user/balance`(DeepSeek 兼容余额)。 -- **统一调度**:扁平模型名按健康度自动选号,`模型@渠道` 强制指定。候选含免费渠道时**免费优先**,之后**在途最少优先**(并发摊到空闲号),再按到期额度、三态健康度 + 分级冷却避开坏号,打平则**余额多者优先**。模型级限流只避让该模型(同账号其他模型立即可用);某模型只有一个渠道持有且它在模型级冷却时,直接回「该模型暂不可用」。同一对话多轮粘住同一凭证,出错才轮换。 -- **到期额度优先消化**:主窗口(默认 36h)内将过期的额度多者先用,避免过期浪费;打平再比 7 天窗口。额度单位统一为积分。 -- **模型列表按渠道凭证加载**:只展示**当前有可用凭证**的渠道,未接入不出现幽灵模型;暂停或凭证失效时其模型暂时消失,恢复即回来。 +- **OpenAI 兼容出口**:`/v1/chat/completions`(流式 / 非流式)、`/v1/responses`(Codex CLI)、`/v1/messages`(Claude Code)、`/v1/models`(含单模型查询)、`/v1/user/balance`。 +- **统一调度**:扁平模型名按健康度选号,`模型@渠道` 强制指定。免费渠道优先 → 在途最少 → 到期额度 → 健康度与分级冷却 → 余额多者优先。模型级限流只避让该模型;多轮对话粘同一凭证,出错才轮换。 +- **到期额度优先**:主窗口(默认 36h)内将过期的额度先用,打平再比 7 天窗口。额度单位统一为积分。 +- **模型列表按凭证加载**:只展示当前有可用凭证的渠道,未接入不出现幽灵模型。 - **公共凭证池**:admin 集中维护、全员共享、加密入库(`APP_SECRET`);设备码登录、多账号切换、额度探测、每日签到、token 预刷新。 -- **三角色账号体系**:`admin` / `operator` / `viewer`,用户存 SQLite;一次性激活链接自设密码、首登强制改密、改角色/停用即时吊销会话;登录与写操作留审计。 -- **成长中心**(仅 CodeBuddy):自动领 Buddy 旅行礼物、派 Buddy、领任务奖、断登补登、连登兑换、开盲盒;不可逆动作可用 `GROWTH_IRREVERSIBLE_ACTIONS=false` 关停;管理台可手动执行。 -- **脱敏统计**:不存对话内容;明细 90 天、小时汇总永久;按人/渠道/模型/凭证可视化;按 OpenRouter 刊例价估算成本(人民币为主);按「正文 token ÷ 生成时间总和」与「总产出 token ÷ 生成时间总和」两口径给出输出速度(tok/s,剔除批到达的不可测样本;概览卡片、分组表与逐请求明细同源);明细面板带「实时」开关(SSE 推送刚落库的请求)。 -- **上游状态聚合**:把各模型 / 网关提供方的官方状态页(OpenCode Zen、Kilo、DeepSeek、Together、Moonshot、MiniMax、OpenAI、Anthropic)归一化后并排展示,用于区分「上游整体故障」与「本服务凭证 / 额度问题」。只做解释、不参与选号 / 熔断;快照只存内存、重启归零。 -- **侧栏数据提醒**:侧栏对应项上显示待办计数角标(凭证需重登/耗尽、未恢复告警、上次失败任务、异常上游源、待激活账号、本人将到期 Key、模型目录陈旧),不点进去也能看到;只数「待办」、按角色裁剪,见[下文](#侧栏数据提醒导航角标)。 +- **三角色账号**:`admin` / `operator` / `viewer`,存 SQLite;一次性激活链接、首登强制改密、改角色或停用即时吊销会话;登录与写操作留审计。 +- **成长中心**(仅 CodeBuddy):自动领礼、派 Buddy、领任务奖、断登补登、连登兑换、开盲盒;不可逆动作可用 `GROWTH_IRREVERSIBLE_ACTIONS=false` 关停。 +- **脱敏统计**:不存对话内容;明细 90 天、小时汇总永久;按人 / 渠道 / 模型 / 凭证可视化;成本按 OpenRouter 刊例价估算;输出速度分正文 / 总产出两口径;明细支持 SSE 实时流。 +- **上游状态聚合**:并排展示各提供方官方状态页,区分「上游故障」与「本服务凭证 / 额度问题」。只做解释、不参与调度;快照只存内存。 +- **侧栏数据提醒**:侧栏显示待办计数角标(需重登 / 耗尽、未恢复告警、任务失败、异常上游、待激活账号、将到期 Key、目录陈旧),按角色裁剪。见[下文](#侧栏数据提醒导航角标)。 - **管理台安全加固**:登录限流、CSRF 校验、请求体上限、Host 白名单。 -- **窄屏表格转卡片**:列多的表格(凭证 / 明细 / 模型列表等)在 <768px 自动由「一行 = 一张卡」呈现——列名收成一排可点的排序片,每个字段在自己行首显示字段名;DOM 不变,读屏语义与宽屏一致。 +- **窄屏表格转卡片**:<768px 时一行 = 一张卡,列名收成可点的排序片;DOM 不变,读屏语义一致。 协议层实现(私有信封、签名、门禁伪装等)见 [`TECHNICAL.md`](TECHNICAL.md)。 ## 快速开始 -### 前置要求 - -Python 3.12+、[uv](https://docs.astral.sh/uv/)、Node.js 24+ 与 pnpm 10+(仅构建前端需要)。 - -### 本地运行 +前置:Python 3.12+、[uv](https://docs.astral.sh/uv/)、Node.js 24+ 与 pnpm 10+(仅构建前端需要)。 ```bash uv sync -# 建首个管理员(之后都在管理台「用户管理」里加人) -uv run python scripts/create_user.py admin --role admin +uv run python scripts/create_user.py admin --role admin # 建首个管理员 cd web && pnpm install && pnpm build && cd .. APP_SECRET="换成你自己的随机字符串" \ uv run python -m uvicorn src.main:build_app --factory --port 8000 ``` -打开 登录管理台。 +打开 登录管理台。之后加人都在「用户管理」页。 -> `scripts/create_user.py` 直接写 SQLite(`--db` 或 `DATA_DIR`,默认 `data/coding2api.sqlite3`)。老部署若已有 `secrets/users.txt`,启动时自动导入一次(已存在的不覆盖)。 +`scripts/create_user.py` 直接写 SQLite(`--db` 或 `DATA_DIR`,默认 `data/coding2api.sqlite3`)。老部署的 `secrets/users.txt` 启动时导入一次(不覆盖已存在的)。 -### Docker +Docker: ```bash cat > .env <<'EOF' APP_SECRET=换成你自己的随机字符串 PUBLIC_BASE_URL=http://127.0.0.1:8000 EOF -docker compose build -docker compose up -d -# 建首个管理员(容器内执行;之后在管理台加人,无需再进容器) +docker compose up -d --build docker compose exec coding2api python scripts/create_user.py admin --role admin ``` -拉取 GHCR 镜像(推荐,跳过本地构建): - -```bash -docker compose pull -# 或指定版本:docker pull ghcr.io/robbsluo/coding2api:v0.9.1 -``` - -推送新版本:打 tag `v*` 推到 main 即触发 publish workflow(`.github/workflows/publish.yml`),同时打 `` 与 `:latest` 到 GHCR;也可在 Actions 页面手动触发。 +拉取镜像:`docker compose pull`(或 `docker pull ghcr.io/robbsluo/coding2api:v0.9.1`)。打 tag `v*` 推到 main 即触发 `.github/workflows/publish.yml`,同时发布 `` 与 `:latest`。 ## 用户与角色 -账号存在 SQLite 的 `users` 表里,管理台「用户管理」页(仅管理员可见)负责日常增删改。 +账号存在 SQLite 的 `users` 表,管理台「用户管理」页(仅管理员)负责增删改。 | 角色 | 能做什么 | |---|---| | `admin` | 用户管理、任务与配置、凭证读写、全部统计、审计日志、服务端日志、上游状态、模型列表 | -| `operator` | 凭证读写(含导入 / 删除 / 启停 / 签到 / 成长 / 切换账号)、全部统计、上游状态、模型列表 | -| `viewer` | 只读:看仪表盘、凭证列表、统计与 Playground | +| `operator` | 凭证读写(导入 / 删除 / 启停 / 签到 / 成长 / 切换账号)、全部统计、上游状态、模型列表 | +| `viewer` | 只读:仪表盘、凭证列表、统计、Playground | -**加人**:管理员填用户名 + 角色,创建后得到**一次性激活链接**(有效 24 小时,只显示这一次)。把链接经可信渠道发给本人,对方打开 `/activate` 自设密码即可登录——系统里不存在「管理员知道但用户不知道」的初始密码。忘记密码同理:点「重置密码」生成新链接。 +- **加人**:填用户名 + 角色,生成一次性激活链接(24 小时有效,只显示一次),对方打开 `/activate` 自设密码。系统里不存在「管理员知道但用户不知道」的初始密码。重置密码同理。 +- **改角色 / 停用**:即时作废该账号所有会话 Cookie。停用是可逆的默认「删人」方式(用量统计仍归属该用户名);硬删只在 CLI。 +- **自助改密**:右上角用户菜单 →「修改密码」,需当前密码。 +- **防锁死**:不能停用 / 降级最后一个活跃管理员,也不能对自己降级或停用。 -**改角色 / 停用**:即时生效——立刻作废该账号所有已签发会话 Cookie(重新登录即可)。停用是默认的「删人」方式(可逆,用量统计仍归属该用户名);**硬删只在 CLI**(会让 `usage_events` 留下查不到用户名的孤儿记录)。 - -**自助改密**:右上角用户菜单 →「修改密码」,需要当前密码。 - -**防锁死**:不能停用/降级**最后一个活跃管理员**,也不能对自己降级或停用。要交接权限,先加另一个 `admin` 再降自己。 - -**CLI 兜底**(无 Web 场景 / 全部管理员失联时): +CLI 兜底(无 Web / 全部管理员失联): ```bash python3 scripts/create_user.py alice --role admin # 交互输入密码 python3 scripts/create_user.py --list python3 scripts/create_user.py alice --delete --force # 硬删,不可逆 -python3 scripts/create_user.py --db data/x.sqlite3 ... # 指定库 -echo "$PASSWORD" | python3 scripts/create_user.py alice --role admin --password-stdin # 密码不进 argv / shell 历史 +python3 scripts/create_user.py --db data/x.sqlite3 ... +echo "$PASSWORD" | python3 scripts/create_user.py alice --role admin --password-stdin ``` -`ADMIN_USERNAMES` 与 `secrets/users.txt` 只在**引导期**起作用(首个管理员、老部署升级):启动时把 `users.txt` 里的用户导入 DB(已存在的不覆盖),再把 `ADMIN_USERNAMES` 点名的人提权为 `admin`;此后角色只来自 DB。 +`ADMIN_USERNAMES` 与 `secrets/users.txt` 只在引导期生效(首个管理员、老部署升级),此后角色只来自 DB。 ## 审计 -「审计日志」页(仅管理员可见)记录登录与写操作,可按操作者/动作筛选、分页查看:**登录**(成败都记)、**账号变动**(新建、激活、改角色、禁用/启用、改密、重置密码、硬删)、**凭证写操作**(导入、删除、启停、指定 pin、复活、切换账号)与**告警人工处置**(标记恢复)。每条含时间、操作者、动作、对象、说明与来源 IP。**绝不包含密码或令牌明文**(一次性激活令牌明文只在创建响应出现一次,库里只有 SHA-256 摘要)。保留期与请求明细一致(默认 90 天),由后台清理任务裁剪。 +「审计日志」页(仅管理员)记录登录与写操作,可按操作者 / 动作筛选、分页:登录(成败都记)、账号变动、凭证写操作、告警人工处置。每条含时间、操作者、动作、对象、说明与来源 IP。**不含密码或令牌明文**(激活令牌只存 SHA-256 摘要)。保留期同请求明细(默认 90 天)。 ## 服务端日志 -「服务端日志」页(`/logs`,仅管理员可见)是**只读**的进程内实时日志视图:`GET /api/logs?limit=` 取最近若干行,`GET /api/logs/stream` 走 SSE 先回放缓冲、再推新行。页面上可做级别筛选、关键字过滤、跟随最新、暂停与清空——**没有任何写入或执行入口**,订阅者只能读。 +「服务端日志」页(`/logs`,仅管理员)是只读的进程内实时日志视图:`GET /api/logs` 取最近若干行,`GET /api/logs/stream` 走 SSE 先回放再推新行。支持级别筛选、关键字过滤、跟随最新、暂停、清空,**无任何写入入口**。 -- **只收本进程 `logging` 记录**,不读文件、不 tail:多进程 / 容器下各进程只看得见自己那份,这是刻意的(日志文件轮转与权限差异会让「看文件」变成一个会骗人的视图)。 -- **脱敏是硬前提**:每行发出前过白名单,命中 `token` / `secret` / `authorization` / `cookie` / `password` / `api_key`(含 `X-Api-Key:`、`client_secret=`、JSON 键形态)、`sk-…` 形状或 `Bearer <值>` 的**整行丢弃**。宁可漏一条日志,也不把凭证推进浏览器。 -- **缓冲有界**:容量是 `LOG_BUFFER_LINES`(默认 500,可热更),环形覆盖最旧;调小立即裁掉多余行。`0` 关闭整页(两个端点都 404,handler 也摘下),与 `/metrics` 关闭同口径。 -- 单行上限 4000 字符;每行带 `seq`(回放与实时去重)、时间、级别、logger、`request_id`(有则带上)。订阅者上限 4,超限 429 + `Retry-After`。 +- 只收本进程 `logging` 记录,不读文件、不 tail;多进程下各看各的。 +- **脱敏是硬前提**:每行发出前过白名单,命中 `token` / `secret` / `authorization` / `cookie` / `password` / `api_key`、`sk-…` 形状或 `Bearer <值>` 的整行丢弃。 +- 缓冲容量 `LOG_BUFFER_LINES`(默认 500,可热更),环形覆盖最旧;`0` 关闭整页(两个端点 404)。 +- 单行上限 4000 字符;每行带 `seq`、时间、级别、logger、`request_id`。订阅者上限 4,超限 429 + `Retry-After`。 ## 使用 @@ -114,8 +97,8 @@ echo "$PASSWORD" | python3 scripts/create_user.py alice --role admin --password- - **CodeBuddy**(含国际):设备码登录,或粘贴 `{"token":"..."}`。 - **TRAE**:粘贴凭证 JSON(`accessToken`/`uid`/`refreshToken`)或回调链接(`apiHost` 只接受官方地址)。 - **Qoder / CodeArts**:设备码 / OAuth2 授权码登录(CodeArts 授权后把打不开的本地回调链接粘回页面)。 - - **OfficeAce**:点「登录 OfficeAce」走华为云授权(回调由云端承接,无需粘链接、无需开本地端口)。 - - **OpenCode Zen / Kilo**:无需添加——启动时自动出现虚拟凭证(被删可在同面板点「添加」补回)。 + - **OfficeAce**:点「登录 OfficeAce」走华为云授权(回调由云端承接)。 + - **OpenCode Zen / Kilo**:无需添加,启动时自动出现虚拟凭证(删了可在同面板补回)。 2. 「API Key」创建 `sk-...`(明文仅显示一次)。 3. 调用: @@ -126,199 +109,184 @@ curl http://127.0.0.1:8000/v1/chat/completions \ -d '{"model":"glm-5.2","messages":[{"role":"user","content":"你好"}]}' ``` -`模型@渠道` 强制指定上游(如 `glm-5.2@trae`、`mimo-v2.5-free@zen`),不写则自动选健康渠道。任意 OpenAI 兼容客户端可直接接入(Base URL `http://127.0.0.1:8000/v1`、Key 用 `sk-...`、模型名以 `GET /v1/models` 为准);「Playground」页用登录会话直接测试,无需 API Key。 +`模型@渠道` 强制指定上游(如 `glm-5.2@trae`、`mimo-v2.5-free@zen`),不写则自动选。任意 OpenAI 兼容客户端可直接接入(Base URL `http://127.0.0.1:8000/v1`)。「Playground」页用登录会话直接测试,无需 API Key。 ### 模型名三字段 -八条渠道每个模型统一为三个字段: +每个模型统一三个字段: -1. **原代号**(`raw_id`):渠道请求时真正发的 key,**永不改动**。各渠道内部代号互不相同(Qoder `kmodel_latest` = TRAE `kimi-k3-1`),也可能带命名空间前缀(Kilo 的 `kilo-auto/free`)。 -2. **归一键**:剥掉免费档标记(`-free` / 路径段 `free`)与命名空间前缀,再 slug 化——`kilo-auto`、`longcat-2.5-preview`。模型列表按它判断**哪个是同一个模型**并合并,故 Zen 的免费档能与 TRAE 的付费档并成一条。 -3. **展示名**(`name`):清洗后的可读文本,如 `LongCat 2.5 Preview`、`Kimi K3`。上游给了可读名就用(`Qwen3.8-Max` → `Qwen3.8 Max`),没有就从原代号派生;品牌与缩写按官方写法纠正(`DeepSeek` / `MiMo` / `GLM` / `GPT`)。 +1. **原代号**(`raw_id`):渠道真正发的 key,永不改动。各渠道内部代号不同(Qoder `kmodel_latest` = TRAE `kimi-k3-1`),可能带命名空间前缀(Kilo 的 `kilo-auto/free`)。 +2. **归一键**:剥掉免费档标记(`-free` / `free` 路径段)与命名空间前缀后 slug 化,如 `kilo-auto`。模型列表按它判断「是不是同一个模型」并合并。 +3. **展示名**(`name`):清洗后的可读文本,如 `LongCat 2.5 Preview`。上游给了可读名就用,否则从原代号派生;品牌写法按官方纠正(`DeepSeek` / `MiMo` / `GLM` / `GPT`)。 -`GET /v1/models` 里**所有条目的 `id` 都是归一键**,转发到某渠道时自动换回该渠道原代号,`by_provider.{渠道}.raw_id` 可见。**原代号、展示名、归一键三种写法都能直接请求**(`kmodel_latest`、`Kimi K3`、`kimi-k3` 指向同一模型)。同渠道内归一键重复时退回原代号,避免撞名误并。 +`GET /v1/models` 里所有 `id` 都是归一键,转发时自动换回原代号,`by_provider.{渠道}.raw_id` 可见。**三种写法都能直接请求**。同渠道内归一键重复时退回原代号。 -**价格与档位**:条目可选带 `cost {input, output, cache_read}`(USD / 百万 token,与统计侧成本估算同一份 OpenRouter 价表)与 `reasoning_efforts`(该渠道生效中的 `REASONING_EFFORT_MAP` 目标档位去重集合)。两者都是**有才给**:缺价、未配置档位映射时**不带该字段**,不填 0 也不编造。 +**价格与档位**:条目可选带 `cost {input, output, cache_read}`(USD / 百万 token,与成本估算同一份价表)与 `reasoning_efforts`(该渠道生效的档位集合)。有才给,缺价不带该字段。 -**自定义模型别名**(`MODEL_ALIASES`,可热更):给客户端一个稳定的对外名,上游改 id 时只改配置。格式 `对外名=模型@渠道;对外名2=模型`,`@渠道` 省略则自动选。**列表与请求解析用同一份别名表**(不会出现「列表有、请求 404」),条目带 `custom_alias: true`。与真实模型 id 或自动别名撞车、目标模型不存在、钉的渠道不持有该模型时,该段记 ERROR 并**跳过**(不静默覆盖真实 id,也不因一条笔误让整份配置失效);不支持别名链。`GET /v1/models/{id}` 同样认别名。 +**自定义别名**(`MODEL_ALIASES`,可热更):格式 `对外名=模型@渠道;对外名2=模型`,`@渠道` 省略则自动选。列表与请求解析用同一份,条目带 `custom_alias: true`。撞名、目标不存在、渠道不持有该模型时该段记 ERROR 并跳过;不支持别名链。 -**`GET /v1/models/{id}` 单模型查询**(OpenAI 同形):上面三种写法都认(外加自定义别名),大小写不敏感,路径参数用 `:path` 故带命名空间的原代号(`dots-studio/dots-3-note-preview:free`)也能查。找不到、或该模型**不在当前 Key 的模型白名单内**,一律 404 + OpenAI 错误形状(`code=model_not_found`)——白名单外与不存在同形,客户端无法用单查探测「池子里还有哪些模型」。能力分与列表条目同源(同一份合并结果),刷新时机也一致(列表有、单查不会 404)。 +**单模型查询**(`GET /v1/models/{id}`):三种写法 + 自定义别名都认,大小写不敏感,带命名空间的原代号也能查。找不到或不在 Key 白名单内一律 404(`code=model_not_found`),不泄露池里有哪些模型。 -**`n` 参数只接受 1**:本网关的出口结构(`choices[0]` + 单 choice 响应体)只能产生一个候选,`n != 1` 一律 400 `invalid_request` 并说明原因,不做「假装支持、静默只返回一个」。 +**`n` 参数只接受 1**:出口结构只能产生一个候选,`n != 1` 一律 400。 ### 流式 usage 与思考 token -- **流式带 usage 帧**:收尾时在 finish chunk 之后、`data: [DONE]` 之前补一帧 `choices: []` 的 usage(OpenAI 标准形态),只从流里读用量的客户端据此显示 tok/s、上下文占用率与会话 token 统计;上游没上报时不补(不发 0 占位),非流式出口的 `usage` 同理,未上报字段一律为 `null`。 -- **思考 token 需客户端带 `reasoning_effort`**:CodeBuddy 渠道在客户端**未给**时补 `medium`(上游缺此字段会把整段思考以「可见推演」写进正文);显式给的值原样透传、不被覆盖。 +- 收尾时在 finish chunk 后、`data: [DONE]` 前补一帧 `choices: []` 的 usage;上游没上报就不补,未上报字段一律 `null`。 +- 思考 token 需客户端带 `reasoning_effort`;CodeBuddy 渠道未给时补 `medium`(否则思考会写进正文),显式给的值原样透传。 ### 流式超时(默认关闭) -八条渠道的上游流读取都设了 `read=None`(长回答不能被读超时打断),因此「上游接了连接却一直不吐数据」只能由网关自己兜: +八条渠道的上游流读取都是 `read=None`,网关自己兜: -- **首事件超时**(`STREAM_FIRST_EVENT_TIMEOUT_SECONDS`,默认 `0`):客户端还**没收到任何内容**时超时,安全地换号重试——记 `upstream_first_event_timeout`,按瞬态错误处理(短冷却、不累计)。注释帧 / 心跳**不**重置计时。 -- **流内空闲超时**(`STREAM_IDLE_TIMEOUT_SECONDS`,默认 `0`):**已出帧**后超时只能断流并以错误帧收尾(记 `upstream_idle_timeout`),**不换号**——半截输出无法回滚,换号会把两个账号的内容拼给客户端。 -- 实现上**不能**用 `asyncio.wait_for(anext(...))`:超时会 cancel 上游生成器直接断流。这里用可复用的 Task + `asyncio.wait`,超时只放弃等待、任务继续跑,下一次继续等同一个任务。 +- **首事件超时**(`STREAM_FIRST_EVENT_TIMEOUT_SECONDS`,默认 0):客户端还没收到内容时超时,可安全换号重试,记 `upstream_first_event_timeout`。注释帧 / 心跳不重置计时。 +- **流内空闲超时**(`STREAM_IDLE_TIMEOUT_SECONDS`,默认 0):已出帧后超时只能断流并以错误帧收尾,**不换号**(半截输出无法回滚)。 -两项都默认关闭是刻意的:实测慢上下文首字可达 40–71s,给一个「看起来合理」的默认值会静默杀掉正常长请求。要用请在管理台按部署实际打开(推荐 `60` / `300`)。 +两项默认关闭是刻意的:实测慢上下文首字可达 40–71s。要用请在管理台按部署实际打开(推荐 60 / 300)。 ### Playground 高级参数 -Playground 请求表单可选填 `temperature` / `max_tokens` / `reasoning_effort`,**留空即不发送该键**(各渠道自取其默认)。前端校验边界与 `compat/openai/request.py` 一致(`temperature` ≤ 1000、`max_tokens` 为 ≤ 4,000,000 的正整数),不会出现「界面放行、后端 400」。`reasoning_effort` 控件只在模型元数据给出 `supports_reasoning` 或 `default_effort` 时出现,候选为标准三档(`low`/`medium`/`high`,上游声明的默认档若不在其中则追加),默认选中「不发送」——某个档位是否被接受由上游裁决。 +可选填 `temperature` / `max_tokens` / `reasoning_effort`,**留空即不发送该键**。前端校验边界与后端一致(`temperature` ≤ 1000、`max_tokens` ≤ 4,000,000)。`reasoning_effort` 只在模型支持推理时出现,默认「不发送」。 ## 渠道 -> CodeBuddy(含国际)/ TRAE / Qoder / CodeArts / OfficeAce 需填凭证;**OpenCode Zen 与 Kilo 是无凭证的免费层渠道**——池里是一条虚拟占位行(让调度 / 冷却 / 统计照常工作),额度列显示「免费层(无额度接口)」,健康度恒为「无探测」(未知 ≠ 耗尽)。虚拟行**删除后重启会复活**,永久停用请用「暂停」。 +> CodeBuddy(含国际)/ TRAE / Qoder / CodeArts / OfficeAce 需填凭证;**OpenCode Zen 与 Kilo 是无凭证免费层**——池里是一条虚拟占位行(调度 / 冷却 / 统计照常),额度列显示「免费层(无额度接口)」,健康度恒为「无探测」。虚拟行删除后重启会复活,永久停用请用「暂停」。 -### OpenCode Zen 免费层(`zen`) +### OpenCode Zen(`zen`) -接 [opencode.ai/zen](https://opencode.ai) 的**免费模型**,无需账号或登录。上游清单混着付费模型且不带免费标记,本服务按 `-free` 后缀收窄候选、再逐个探活,**只把匿名真正可用的免费模型**放进 `GET /v1/models`(判活缓存 30 分钟,不维护静态白名单;免费模型标 **x0**)。上游清单不提供模型名,展示名由模型 id 派生。 +接 [opencode.ai/zen](https://opencode.ai) 的免费模型,无需登录。上游清单混着付费模型且不带免费标记,本服务按 `-free` 后缀收窄再逐个探活,只把匿名真正可用的放进 `/v1/models`(判活缓存 30 分钟,免费模型标 **x0**)。 -**免费层门禁**:上游要求伪装成官方客户端(UA 版本、会话头、`stream:true`、tools 含 `bash`/`read`),本服务自动满足;注入的 `bash`/`read` 是空壳,模型真调用时这类 tool_call 被过滤掉,不泄漏你没声明过的函数。UA 版本用 `ZEN_OPENCODE_VERSION` 调,端点用 `ZEN_API_ENDPOINT`(须在 `ZEN_ALLOWED_ENDPOINTS` 内);付费模型即使强制 `模型@zen` 也只报「不可用」。 +**免费层门禁**:上游要求伪装官方客户端(UA 版本、会话头、`stream:true`、tools 含 `bash`/`read`),本服务自动满足;注入的 `bash`/`read` 是空壳,模型真调用时被过滤。UA 用 `ZEN_OPENCODE_VERSION`,端点用 `ZEN_API_ENDPOINT`(须在 `ZEN_ALLOWED_ENDPOINTS` 内)。付费模型强制 `模型@zen` 也只报不可用。 -### Kilo 免费层(`kilo`) +### Kilo(`kilo`) -接 [Kilo](https://kilo.ai)(`api.kilo.ai/api/gateway`)的**免费模型**,无需账号或登录。对外是**标准 OpenAI 兼容协议**,无私有信封、无门禁伪装;上游 `/models` 每条带权威 `isFree` 布尔,本服务**据此直接过滤**免费集、**不做探活**(探活白耗本就极小的免费配额)。免费模型标 **x0**。 +接 [Kilo](https://kilo.ai)(`api.kilo.ai/api/gateway`)的免费模型,无需登录。标准 OpenAI 协议,上游 `/models` 每条带 `isFree`,据此直接过滤,**不做探活**(省免费配额)。 -**额度与限流**:免费层额度很小(网关级约 200 请求/小时/IP),实为 OpenRouter 免费池的转发;上游 **429 与 502/503/504 都是模型级**——只冷却被点名的那个模型,本服务不自建熔断。端点用 `KILO_API_ENDPOINT`(须在 `KILO_ALLOWED_ENDPOINTS` 内)。 +**额度与限流**:网关级约 200 请求/小时/IP。上游 429 与 502/503/504 都是模型级——只冷却被点名的模型。端点用 `KILO_API_ENDPOINT`(须在白名单内)。 -### Qoder(`qoder`,真实账号) +### Qoder(`qoder`) -接 [Qoder](https://qoder.com),**需要真实账号**:在「凭证管理 → 登录渠道账号」点「登录 Qoder」,按提示在浏览器完成设备码授权(登录全程后端轮询完成,Token 不经过浏览器)。上游是私有 COSY 协议(自定义 Base64 编码 + 信封式 SSE),本服务负责签名与解包,对外仍是标准 OpenAI 协议。 +接 [Qoder](https://qoder.com),需真实账号:点「登录 Qoder」完成设备码授权(Token 不经过浏览器)。上游是私有 COSY 协议,本服务负责签名与解包,对外仍是标准 OpenAI。 -**额度与签到**:支持额度探测(`GET /api/v2/quota/usage`)与每日签到(积分可累积)。签到为**活动制**(`/sash/api/v1/me/campaigns`,需带 `Cosy-ClientType`),旧 `daily-check-in` 仅作回退;本服务自动查询并领取可领活动,管理台可手动触发。**签到活动每日 10:00(UTC+8)刷新**(领取后 30 天有效),后台任务按该窗口封账:凌晨看到的「已领」只封到当天 09:59,10:00 新一轮出现后自动补签。新协议不提供连续天数,界面不再显示「连续 N 天」。**国际站没有签到接口**(该端点 404),记为「本区域无此接口」,不是错误。 +**额度与签到**:支持额度探测(`/api/v2/quota/usage`)与每日签到(积分可累积)。签到为活动制(`/sash/api/v1/me/campaigns`),旧 `daily-check-in` 仅作回退。**签到活动每日 10:00(UTC+8)刷新**(领取后 30 天有效),后台按该窗口封账。新协议不提供连续天数。**国际站没有签到接口**(404),记为「本区域无此接口」。 -**端点与节流**:默认国内版(`openapi.qoder.com.cn` + `gateway.qoder.com.cn`),国际站改 `QODER_API_ENDPOINT=https://openapi.qoder.sh` 并确认白名单含国际站域名(签名携带完整 `cosy-*` 头,白名单防误发)。默认 `QODER_CHAT_MIN_INTERVAL=5`(独立节流器),可在「任务与配置」热更。 +**端点与节流**:默认国内版(`openapi.qoder.com.cn` + `gateway.qoder.com.cn`),国际站改 `QODER_API_ENDPOINT=https://openapi.qoder.sh` 并把域名加进白名单。`QODER_CHAT_MIN_INTERVAL` 默认 5,可热更。 -**上游节点故障**:Qoder 有时把自身推理节点故障包成 400(`[FAIL]node:… msg:Execution failed`)。本服务识别为**模型级瞬时故障**——只冷却该模型,耗尽候选时返回「该模型暂时不可用」的 503(`no_healthy_credential`),而非误报「模型不存在」;稍后重试通常自愈。 +**上游节点故障**:Qoder 有时把自身节点故障包成 400(`[FAIL]node:…`),本服务识别为模型级瞬时故障,只冷却该模型,耗尽候选时回 503(`no_healthy_credential`)。 -### CodeArts(`codearts`,真实账号) +### CodeArts(`codearts`) -接 [华为云 CodeArts](https://codearts.huaweicloud.com),**需要真实账号**:点「登录 CodeArts」,浏览器完成 OAuth2 授权后会跳到打不开的本地地址(`http://127.0.0.1:12800/oauth/callback?code=…`)——把**整条链接**粘回页面,服务端换取 STS 凭证(AK/SK + security_token + refresh_token + DPoP 私钥)并加密入库。 +接 [华为云 CodeArts](https://codearts.huaweicloud.com),需真实账号:点「登录 CodeArts」,授权后浏览器跳到打不开的本地地址,把**整条链接**粘回页面换取 STS 凭证(AK/SK + security_token + refresh_token + DPoP 私钥)并加密入库。 -**令牌刷新是刚性的**:refresh_token 与 `client_id=codearts-agent`、DPoP 私钥三者绑定且**一次性**,刷新后必须回写新 refresh_token,否则账号失效;由 token 预刷新任务自动完成(DPoP ES256/P-256 签名)。预刷新窗口封顶 45 分钟且必须**宽于**轮询周期(`REFRESH_INTERVAL_MINUTES`,默认 30 分钟)。续期凭据被上游作废时,该凭证标记「需重新登录」并停止自动重试,重新登录后按「恢复」解除。 +**令牌刷新是刚性的**:refresh_token 与 `client_id=codearts-agent`、DPoP 私钥三者绑定且一次性,刷新后必须回写新 refresh_token。由预刷新任务自动完成(DPoP ES256/P-256)。预刷新窗口封顶 45 分钟且必须宽于轮询周期。凭据被作废时标记「需重新登录」并停止自动重试。 -**每日签到**:上游有「每日签到领 1000 积分」活动(`GET /v1/ops/delivery` → `POST /v1/ops/claim` → `POST /v1/ops/confirm`)。后台任务每日自动领取,管理台可手动触发;`claim` 后**必须** `confirm`(`CLAIMED` 是「已领未确认」而非终态),本服务两步一起做。签到发的积分计入本渠道唯一的账本(见下)。 +**每日签到**:`GET /v1/ops/delivery` → `POST /v1/ops/claim` → `POST /v1/ops/confirm`,后台每日自动领取。`CLAIMED` 是「已领未确认」,两步一起做。 -**额度=积分余额**:额度探测走 `GET {snap}/snap-manager/v1/statistics/plugin`(前缀**必须**带 `snap-manager`,头 `Agent-Type: PromptCenter`),内置模型(GLM-5.2 / OpenPangu 等)按积分计费。上游数值本身即积分,**不做任何单位折算**;分项包(`基础积分` / `按需积分` / `福利积分`)走「套餐 N 个」悬浮明细,并登记**套餐到期日**(`end_date`,实测每账号不同)的到期阶梯——积分用不完同样作废,故调度「快过期的先用」也会先烧积分。该接口失败只记日志、不影响其余链路。 +**额度=积分余额**:探测走 `GET {snap}/snap-manager/v1/statistics/plugin`(前缀必须带 `snap-manager`,头 `Agent-Type: PromptCenter`)。上游数值即积分,不做折算;分项包走悬浮明细,并登记套餐到期日(积分过期同样作废)。 -**变动流水**:每轮探测按余额 diff 记一条 `credit_events`(`ledger=credits`,见下)。 +**签名 / 累计全文 SSE / 节流**:华为云 `SDK-HMAC-SHA256`(AK/SK + `X-Security-Token`),白名单须含 snap 引擎、STS 与门户。流式 `text` 是累计全文(替换语义),本服务还原为增量。上游硬限每账号并发 3,故有 `CODEARTS_MAX_CONCURRENCY` 与 `CODEARTS_REQUEST_WINDOW_SECONDS`。 -**签名 / 累计全文 SSE / 节流与并发**:上游要求华为云 `SDK-HMAC-SHA256`(AK/SK + `X-Security-Token`),白名单 `CODEARTS_ALLOWED_ENDPOINTS` 必须含 snap 引擎、STS 与门户三个主机。流式 `text` 是**累计全文(替换语义)**而非增量,本服务在解析层还原为增量事件,对客户端透明。默认 `CODEARTS_CHAT_MIN_INTERVAL=5`;上游硬限**每账号并发会话数 3**,故配在途上限 `CODEARTS_MAX_CONCURRENCY` 与滑动窗口 `CODEARTS_REQUEST_WINDOW_SECONDS`(每账号每 60s 最多启动 3 次),把突发挡在 `400 TM.00001041` 之前。均可热更。 +### OfficeAce(`officeace`) -### OfficeAce(`officeace`,真实账号) +接 [华为云 OfficeAce](https://www.huaweicloud.com/product/agentarts.html),需真实账号:点「登录 OfficeAce」完成华为云授权即可,回调由云端承接,不监听本地端口。后端按 2.5s 轮询 `/v1/claw/auth/code` 取回授权码。 -接 [华为云 OfficeAce](https://www.huaweicloud.com/product/agentarts.html)(果办 / AgentArts 办公智能体),**需要真实账号**:点「登录 OfficeAce」,浏览器完成华为云登录与授权即可——**回调由云端承接**(`/v1/claw/auth/callback`),本服务不监听任何端口、用户也不需要粘链接,后端按 2.5s 轮询 `/v1/claw/auth/code` 取回授权码(与官方桌面端同一机制)。 +**开通前提**:账号必须先在 OfficeAce 客户端登录并兑换邀请码。未开通的账号登录「成功」但拿不到模型密钥,本服务会点名报出「华为云账号 xxx 还没开通 OfficeAce 服务」。 -**开通前提**:账号必须先在 OfficeAce 客户端里登录并兑换邀请码完成开通。未开通的账号登录会「成功」但拿不到模型密钥(云端 `subscription: null`),本服务据此点名报出「华为云账号 xxx 还没开通 OfficeAce 服务」而不是含糊的登录失败。 - -**两套凭据,寿命完全不同**(这是本渠道最容易踩错的地方): +**两套凭据,寿命不同**: | 凭据 | 寿命 | 用途 | |---|---|---| -| `Basic model_app_key:model_app_secret` | **不过期** | 聊天转发(`{网关}/v2/chat/completions`) | -| 临时 AK/SK + `security_token` + `project_id` | **约 2 小时** | 积分查询、签到(`/v1/subscription*`) | +| `Basic model_app_key:model_app_secret` | 不过期 | 聊天转发 | +| 临时 AK/SK + `security_token` + `project_id` | 约 2 小时 | 积分查询、签到 | -混用会得到 `APIG.0303 app not found`。丢了区别的现象也很迷惑:聊天一直正常(Basic 不过期),但积分列空白、签到一直失败(临时凭据过期)。临时凭据由预刷新任务自动续期,`refresh_token` 一次性轮换且与 `client_id=pdp5_for_agentarts` + DPoP 私钥三者绑定——**与 CodeArts 的票互不通用**(混用回 `STS5.1806 invalid client id`)。 +混用会得到 `APIG.0303 app not found`。临时凭据由预刷新任务续期,`refresh_token` 一次性轮换且与 `client_id=pdp5_for_agentarts` + DPoP 私钥绑定(与 CodeArts 票不通用)。 -**上游就是标准 OpenAI 协议**,故对话层不写私有信封解析;但有三处上游特例必须处理: +上游就是标准 OpenAI 协议,但有三处特例: -- **限流走伪 200**:HTTP 200 + `data:{"error":…}`,且实测出现过「首帧正常、第二帧才是错误」。错误帧照常产出交引擎分类,`81111/81114` 只冷却「该凭据 × 该模型」,其它模型不受影响。 -- **模型级拒绝不牵连整条凭据**:`81004`(该号没这个模型)/ `81009`(名字不认)走 403/404,若按状态码判会把整条凭据打死。故业务码判定先于状态码,归类为模型级负缓存(成功即撤销)。 -- **`max_tokens` 是「推理 + 正文」总预算**:可用模型里 9/11 是推理模型,给小了正文直接为空。`≥16` 时自动追加 1024 余量再夹到该模型硬上限(`glm-5*` 131072 / `qwen3*` 65536 / `deepseek-v4.1-flash` 393216),`<16` 原样透传(探活/测通道的精确请求),未给则不补。 +- **限流走伪 200**:HTTP 200 + `data:{"error":…}`,错误帧照常交引擎分类,`81111/81114` 只冷却「该凭据 × 该模型」。 +- **模型级拒绝不牵连整条凭据**:`81004` / `81009` 走 403/404,业务码判定先于状态码,归为模型级负缓存(成功即撤销)。 +- **`max_tokens` 是推理 + 正文总预算**:`≥16` 时自动追加 1024 余量再夹到该模型硬上限,`<16` 原样透传,未给不补。 -**积分即主账**:额度单位是 `officeace_points`(新用户一次性 + 每日签到各发一笔),`remaining`/`total` 直接就是积分。每笔奖励**有独立到期时间、不一并重置**,故额度口径写作「逐笔到期」,调度仍按到期阶梯「快过期的先用」。 +**积分即主账**:单位是 `officeace_points`,`remaining`/`total` 直接是积分。每笔奖励独立到期、不合并重置,故额度口径写作「逐笔到期」。 -**每日签到**:`POST /v1/subscription/bonus/claim`(**写操作,调用即真发积分**;body 必须给 {} + JSON `Content-Type`,空 body 会回 500 `OfficeAce.11020001`)。上游对当日重复领取是幂等的,故不需要本地记「今天领过」。领取收获按**剩余量增量**计算(`points` 是活动累计发放量,由多个渠道共同贡献,拿它当本次收获会虚高)。封账到下个北京时间 0 点:次日新活动一出现就自动补签。 +**每日签到**:`POST /v1/subscription/bonus/claim`(写操作,调用即真发积分;body 必须 `` + JSON `Content-Type`)。上游对当日重复领取幂等。收获按剩余量增量计算。封账到下个北京时间 0 点。 -**不做主动模型探活**:目录 34 个里实测只有 11 个真能用,但探活要真发推理、消耗积分,而额度是稀缺资源。模型级拒绝改由真实请求按 `BLOCKED` 负缓存处理,比开机烧 34 次推理省得多。同理**不做 credit 估算**:`cost_coefficient` 与 `tokens_per_point` 的叠乘关系官方未说明,凭空折算会与真实扣点对不上。 +**不做主动探活 / 不做 credit 估算**:探活要真发推理消耗积分;`cost_coefficient` 与 `tokens_per_point` 的叠乘关系官方未说明。模型级拒绝改由真实请求按 `BLOCKED` 负缓存处理。 -**签名与端点**:控制面 `/v1/claw/*` 用华为云 `SDK-HMAC-SHA256`;模型网关 `/v1/subscription*`、`/v1/model-token-rates` 只认 **`V11-HMAC-SHA256`**(多一层 region 作用域 + 签名密钥派生),用错回 `APIG.0624` 或 401。`OFFICEACE_ALLOWED_ENDPOINTS` 只需含控制面主机——模型网关每个账号不同,是上游 `client-permission-validate` 签发并存在凭证里的值,不是本服务能配错的东西。默认 `OFFICEACE_CHAT_MIN_INTERVAL=0`(上游没有 CodeArts 那种硬并发限),可热更。 +**签名与端点**:控制面 `/v1/claw/*` 用 `SDK-HMAC-SHA256`;模型网关只认 `V11-HMAC-SHA256`,用错回 `APIG.0624` 或 401。`OFFICEACE_ALLOWED_ENDPOINTS` 只需控制面主机(模型网关每账号不同,由上游签发存在凭证里)。`OFFICEACE_CHAT_MIN_INTERVAL` 默认 0。 -**单账号单用户**:官方《套餐管理》明确「个人版面向个人用户,限制单账号单用户使用」,故本渠道不做多人共用,每个使用者各自添加自己的凭证。 +**单账号单用户**:官方《套餐管理》限制单账号单用户,不做多人共用。 -**换账号**:华为云会话按**浏览器**走——浏览器里还留着上一个账号时,云端会直接放行、把「新增账号」变成「同一个账号再登一遍」。故登录按钮旁额外给一个**「换账号」**(`force_login=true`),它先走 `/authui/logout?service=…` 清掉会话再授权。未开通的账号会明确报出是哪个号没开通,而不是笼统的「登录失败」。 +**换账号**:登录按钮旁有「换账号」(`force_login=true`),先清掉浏览器里的华为云会话再授权。 -### CodeBuddy 国际(`codebuddy_intl`,真实账号) +### CodeBuddy 国际(`codebuddy_intl`) -接 CodeBuddy 国际站 [www.codebuddy.ai](https://www.codebuddy.ai),**与国内站同协议**(同一套 `/v2/chat/completions`、`/v3/config`、`/v2/plugin/auth/*`、`/v2/billing/*` 路径,同一 Keycloak realm),但**是两套独立账号体系**:各自注册、各自计费,故作为**独立渠道**存在。登录链路与国内版完全一致(设备码,Token 不经过浏览器)。 +接 [www.codebuddy.ai](https://www.codebuddy.ai),与国内站同协议,但是两套独立账号体系(各自注册、各自计费),故为独立渠道。登录链路与国内版一致(设备码)。 -**为什么不是「改一个环境变量切站点」**:`CODEBUDDY_API_ENDPOINT` 是进程级单值,切过去会让所有存量国内站凭证立刻打到国际站(额度错配 / 403)。两个站点可同时持有凭证,各走各的端点、节流器与登录流程。 +不合并成一个渠道的原因:`CODEBUDDY_API_ENDPOINT` 是进程级单值,切过去会让存量国内站凭证打到国际站。两站可同时持有凭证,各走各的端点、节流器与登录流程。 -**白名单刻意互不含**:`CODEBUDDY_INTL_ALLOWED_ENDPOINTS` 默认只有 `https://www.codebuddy.ai`,**不含**国内站主机。两站 Token 由不同网关签发,跨站发送即凭证泄露,因此把国内站地址填进来会在**启动时**直接报错。 +**白名单刻意互不含**:`CODEBUDDY_INTL_ALLOWED_ENDPOINTS` 默认只有国际站主机,填国内站地址会在启动时报错(跨站发送即凭证泄露)。 -**能力差异**:聊天、模型列表、额度探测、token 刷新、**活跃上报**全部启用;**每日签到、成长中心未启用**——签到是**渠道侧就没有这个活动**(`checkin-activity-status` 回 `active: false`),成长中心则**无法验证**(国际站把整个 `/v2/activity/*` 前缀挂在网关认证插件后,连不存在的子路径也返回 401 而非 404),而其含抽奖 / 连登兑换 / 盲盒等**不可逆**动作,不盲跑。管理台的「签到」「成长中心」按钮对该渠道不显示。活跃上报端点 `/v2/report` 与成长中心不是同一路径,实测国际站可用,故照常启用。 +**能力差异**:聊天、模型列表、额度探测、token 刷新、活跃上报全部启用;**每日签到、成长中心未启用**(签到渠道侧就没有该活动,成长中心无法验证且含不可逆动作)。 -**模型列表按套餐探活**:上游 `/v3/config` 返回全量模型,免费号也会看到 `gpt-5.5` / `gpt-5.6-luna` 等调用时才回 `400 / 11158` 的模型。国际站列表会用当前刷新凭证逐个发一次最小请求(拿到状态行即断开),只保留真正可用的模型;`11158`(套餐不允许)与 `11102`(无此模型)剔除,`429`、5xx、超时等不确定结果**保留模型**。结论按账号缓存 6 小时,避免按 5 分钟的列表 TTL 重复探测。 +**模型列表按套餐探活**:用当前凭证逐个发一次最小请求,只保留真正可用的模型;`11158`(套餐不允许)与 `11102`(无此模型)剔除,`429` / 5xx / 超时保留。结论按账号缓存 6 小时。 -**同名模型不带渠道后缀**:两站共有的模型在 `/v1/models` 里是**一条**、同时挂两个渠道;`-x` / `-sg` 这类**真实存在的不同上游模型**仍各占一条。 +**同名模型不带渠道后缀**:两站共有的模型在 `/v1/models` 里是一条、挂两个渠道;`-x` / `-sg` 这类真实不同的模型各占一条。 -**节流**:`CODEBUDDY_INTL_CHAT_MIN_INTERVAL` 默认 5s,**独立节流器**,与国内站互不排队,可热更。 +**节流**:`CODEBUDDY_INTL_CHAT_MIN_INTERVAL` 默认 5s,独立节流器,可热更。 ### 按渠道出站代理(可选) -`PROVIDER_PROXIES` 给每个渠道单独指定出站代理: - ``` PROVIDER_PROXIES="codebuddy=http://127.0.0.1:7890;qoder=socks5://127.0.0.1:1080" ``` -格式 `渠道=代理URL;渠道2=代理URL2`,渠道取八个渠道名,协议支持 `http/https/socks5/socks5h`,留空 = 全部直连。作用于该渠道**全部出站请求**(聊天流、额度/模型拉取、后台任务与 OAuth 登录)。**启动期项**,改后需重启。**严格解析**:未知渠道 / 非法协议 / 缺 `=` 一律启动失败——「以为走了代理其实直连」比启动报错更糟。环境变量 `HTTP_PROXY` 等一律**不采信**(`trust_env=False`,防部署环境全局代理意外劫持带 Token 的上游请求)。 +格式 `渠道=代理URL;渠道2=代理URL2`,协议支持 `http/https/socks5/socks5h`,留空 = 全部直连。作用于该渠道全部出站请求(聊天、额度、模型拉取、后台任务、OAuth)。**启动期项**,改后需重启。未知渠道 / 非法协议 / 缺 `=` 一律启动失败。环境变量 `HTTP_PROXY` 等一律不采信(`trust_env=False`)。 -> ⚠️ **容器部署时 `127.0.0.1` 指容器自身,不是宿主**。代理跑在宿主上(Clash / mihomo 等)时必须写成 `host.docker.internal`: -> -> ``` -> PROVIDER_PROXIES="codebuddy=http://host.docker.internal:7897" -> ``` -> -> compose 已声明 `extra_hosts: host.docker.internal:host-gateway`(Docker Desktop 自带,Linux / Colima 需显式加)。解析只校验格式、**不校验可达**,所以地址写错不会启动失败,只会让该渠道全部请求连不上——务必用 `docker logs` 确认没有 `All connection attempts failed`。 +> 容器内 `127.0.0.1` 指容器自身。代理跑在宿主时写 `host.docker.internal`: +> `PROVIDER_PROXIES="codebuddy=http://host.docker.internal:7897"` +> compose 已声明 `extra_hosts`。解析只校验格式不校验可达,写错不会启动失败。 ## 出口协议 ### Responses API(Codex CLI) -`POST /v1/responses` 提供 Responses 子集,供 [Codex CLI](https://github.com/openai/codex) 这类只走 Responses 的客户端接入。与 `/v1/chat/completions` 共用同一套选号 / 冷却 / 轮换 / 统计与会话粘性,只换入站映射与出口翻译: +`POST /v1/responses` 提供 Responses 子集,与 chat 共用同一套选号 / 冷却 / 轮换 / 统计与会话粘性: ```bash export CODING2API_KEY=sk-你的key codex -c "model_providers.coding2api={ name='coding2api', base_url='http://127.0.0.1:8000/v1', wire_api='responses', env_key='CODING2API_KEY' }" \ - -c model_provider=coding2api \ - -c model='glm-5.2' \ - '你的任务' + -c model_provider=coding2api -c model='glm-5.2' '你的任务' ``` -支持文本、流式正文、思考摘要(`reasoning` item)、函数工具调用与 `finish_reason=length` → `response.incomplete`。`text.format` 映射到 chat 的 `response_format`(`text` / `json_object` 直通,`json_schema` 展开成 `{type, json_schema:{name, description?, schema, strict?}}`)。内容 part 映射为:`input_text` / `text` / `output_text` → chat text,`input_image` → chat `image_url`(URL 原样透传,含 `detail` 时一并带上)。**不支持** `store=true`、`previous_response_id`(服务端无状态,不假装支持)、`input_file`(需服务端文件存储)、Responses 私有工具——一律显式 400,不静默降级。`include=["reasoning.encrypted_content"]`(Codex 每轮必带)接受但忽略。 +支持文本、流式正文、思考摘要、函数工具调用、`finish_reason=length` → `response.incomplete`。`text.format` 映射到 chat 的 `response_format`。内容 part 映射:`input_text` / `text` / `output_text` → text,`input_image` → `image_url`。**不支持** `store=true`、`previous_response_id`、`input_file`、Responses 私有工具——一律显式 400。`include=["reasoning.encrypted_content"]` 接受但忽略。 ### chat `response_format` 收口 -`POST /v1/chat/completions` 的 `response_format` 不再整包透传,只放行 OpenAI 定义的三种形状,畸形或未知形状**显式 400**(不静默降级、也不打到上游把凭证打凉): - -- `{"type": "text"}` -- `{"type": "json_object"}` -- `{"type": "json_schema", "json_schema": {"name": <非空字符串>, "schema": <任意 JSON Schema>, "description"?: <字符串>, "strict"?: <布尔>}}` —— `json_schema` 内的未知字段丢弃(既非 chat 规范、上游也不认),`name` 缺失 / `schema` 缺失 / `type` 未知分别回对应的 400 文案。 - -Responses 出口的 `text.format` → `response_format` 映射与 chat 入站**共用同一段校验**(`src/compat/openai/request.py: normalize_response_format`),两条入站协议不会各写一份而漂移;`prefix` 参数只改报错里的字段路径(Responses 侧是 `text.format`)。 +只放行 OpenAI 定义的三种形状,畸形或未知显式 400:`{"type":"text"}`、`{"type":"json_object"}`、`{"type":"json_schema","json_schema":{name, schema, description?, strict?}}`(内部未知字段丢弃)。Responses 出口的 `text.format` 与 chat 入站共用同一段校验。 ### Anthropic Messages API(Claude Code) -`POST /v1/messages` 提供 Anthropic Messages 子集,供 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 接入。与其它出口共用同一套选号 / 冷却 / 轮换 / 统计与会话粘性: - ```bash export ANTHROPIC_BASE_URL=http://127.0.0.1:8000 export ANTHROPIC_AUTH_TOKEN=sk-你的key # 或 ANTHROPIC_API_KEY(走 x-api-key 头) claude ``` -**鉴权**:`x-api-key` 与 `Authorization: Bearer` 都接受。**流式**:`message_start` → `content_block_start/delta/stop` → `message_delta`(含 `stop_reason` 与 usage)→ `message_stop`;无 `[DONE]` 哨兵,thinking 块在 `content_block_stop` 前补 `signature_delta`。**缓存命中**:出口 usage 按 Anthropic 语义拆两笔——`input_tokens` 只计未命中,命中另记 `cache_read_input_tokens`(内部 `input_tokens` 是 OpenAI 口径的 `prompt_tokens`,含命中),Claude Code 据此显示缓存读 token。**`count_tokens`**:`POST /v1/messages/count_tokens` 本地估算输入 token(不转发上游)。**图片**:`image` 块(`source.type=base64` 拼成 `data:` URL、`source.type=url` 直传)归一为 chat 的 `image_url` 内容块,`tool_result.content` 内的图片同样保留——纯文本内容仍折成字符串,故无图请求的映射结果与旧版逐字节一致。**不支持**:`document` 块(PDF,chat 上游无对应表达)、`source.type=file`、Anthropic 服务端工具一律显式 400。 - -**`cache_control` 透传**(默认开启,无开关):入站的缓存提示标记原样带给上游,落点与 Anthropic 一致——`system` 内容块、user/assistant 消息内容块、`tool_result`(挂 tool 消息**顶层**,不造空 text 块)、assistant `tool_calls` 元素顶层、`tools` 元素顶层。只做**结构**校验(必须是对象、必须有非空字符串 `type`、`ttl` 若给必须是字符串),取值不解释(未知 `type` 也透传,上游将来新增的缓存类型不该被本网关挡掉);结构不合法显式 400 而不是静默丢弃。**不带标记时映射结果与旧版逐字节一致**(纯文本仍折成字符串),故会话粘性指纹与上游前缀缓存不受影响。八渠道实测 6 个接受(codebuddy / codebuddy_intl / trae / qoder / codearts / kilo);**zen 未验证**(上游返 `FreeTierError` 403,`--plain` 对照同样失败,与标记无关)、**OfficeAce 未单独验证**(未跑该探针);实测脚本 `scripts/probe_cache_control.py`。 +- **鉴权**:`x-api-key` 与 `Authorization: Bearer` 都接受。 +- **流式**:`message_start` → `content_block_*` → `message_delta` → `message_stop`;无 `[DONE]`,thinking 块在 `content_block_stop` 前补 `signature_delta`。 +- **缓存命中**:出口 usage 拆两笔——`input_tokens` 只计未命中,命中另记 `cache_read_input_tokens`。 +- **`count_tokens`**:本地估算,不转发上游。 +- **图片**:`image` 块归一为 chat `image_url`;`tool_result.content` 内的图片同样保留。 +- **不支持**:`document` 块、`source.type=file`、Anthropic 服务端工具,一律显式 400。 +- **`cache_control` 透传**(默认开启):落点与 Anthropic 一致(`system`、消息内容块、`tool_result` 顶层、`tool_calls` 顶层、`tools` 顶层)。只做结构校验,取值不解释。八渠道实测 6 个接受(codebuddy / codebuddy_intl / trae / qoder / codearts / kilo);zen 未验证、OfficeAce 未单独验证。脚本 `scripts/probe_cache_control.py`。 ### CC Switch 一键导入 -「API Key 管理」页可生成 [CC Switch](https://github.com/farion1231/cc-switch) 的 `ccswitch://` 导入深链(Claude Code / Codex CLI / OpenCode 三个目标各一个按钮),点一下即由桌面端弹确认框写入地址、Key 与模型。两处入口:**创建 Key 成功后的对话框**(直接用刚生成的一次性明文 Key,关闭对话框即不再保留)与 **Key 列表每行的「导入 CC Switch」**(列表只存脱敏 `preview`,需粘贴创建时保存的完整 Key,只留在对话框内存里)。两处都可用「默认模型」下拉选择要写入的模型——与 Playground 同源(`/api/playground/models`),按渠道分组。深链**只能由点击触发**(浏览器拦截自动跳转),且**含 Key 明文**,故仅在用户主动打开、且已给出 Key 时生成。协议与 `app` / `apiFormat` 取值见 CC Switch 用户手册「Deep Link Protocol」,需 CC Switch v4.0.10+。注意 **endpoint 形态随 `apiFormat` 而变**:Anthropic 格式(Claude Code)填到根、**不带 `/v1`**;两个 OpenAI 格式填到 `/v1`。本服务没有 Gemini 出口,故不提供 `app=gemini` 入口。 +「API Key 管理」页可生成 `ccswitch://` 深链(Claude Code / Codex CLI / OpenCode 各一个按钮)。入口两处:创建 Key 成功后的对话框、Key 列表每行的「导入 CC Switch」。深链含 Key 明文,只在用户主动打开时生成。需 CC Switch v4.0.10+。注意 endpoint 形态随 `apiFormat` 变:Anthropic 填到根(不带 `/v1`),OpenAI 填到 `/v1`。本服务无 Gemini 出口。 ### 余额查询 -`GET /v1/user/balance` 兼容 DeepSeek 余额接口形状,Bearer `sk-...` 鉴权,供 Cherry Studio / cc-switch 等客户端显示余额。余额来自凭证池的额度探测缓存(`QUOTA_PROBE_MINUTES` 周期刷新),按可用凭证汇总,单位为上游 credits: +`GET /v1/user/balance` 兼容 DeepSeek 形状,Bearer `sk-...` 鉴权,供 Cherry Studio / cc-switch 显示余额。数据来自额度探测缓存,按可用凭证汇总: ```json { @@ -330,572 +298,514 @@ claude } ``` -池内凭证从未探测成功时 `balance_known` 为 `false`(余额未知,不是 0)。 +从未探测成功时 `balance_known` 为 `false`(未知,不是 0)。 ### 池健康检查 -`GET /health` 只回 `{"status":"ok"}`(进程存活,适合容器存活探针);`GET /healthz` 额外给出凭证池计数,**并在 `ready == 0` 时回 HTTP 503**(就绪探针)——那是「进程活着但没有可用凭证」的状态,对话请求必然 503,负载均衡/外部监控应据此摘流量。计数在两种状态下都返回,监控可读到「为什么不可用」。两个端点都不鉴权。**两者同时接受 `HEAD`**(`curl -I` / 多数监控默认发 HEAD):FastAPI 的 `APIRoute` 不像 Starlette 那样自动给 GET 补 HEAD,只注册 GET 时探针会一直吃 405,表现为「服务正常但监控永远红灯」。 +- `GET /health`:只回 `{"status":"ok"}`,适合存活探针。 +- `GET /healthz`:额外给凭证池计数,`ready == 0` 时回 503(就绪探针)。 -```json -{ - "status": "ok", "service": "coding2api", "version": "0.9.1", - "credentials": {"total": 5, "ready": 4, "cooling": 1, "paused": 0, "disabled": 0} -} -``` - -五类计数互斥且合计 = `total`,与调度器同一口径。`ready=0` 时 `/healthz` 返回 503 且 `status` 为 `unavailable`;请把**存活**探针配在 `/health`(恒 200),把 `/healthz` 用作就绪/告警——把容器 healthcheck 指到 `/healthz` 会在池子耗尽时把容器标成 unhealthy,而重启并不能修好一个没有可用凭证的池子。 +两个端点都不鉴权,且都接受 `HEAD`。五类计数互斥且合计 = `total`,与调度器同口径。**把存活探针配在 `/health`**,`/healthz` 用作就绪 / 告警。 -> 升级注意:`/healthz` 的 HTTP 状态从「恒 200」变为「ready=0 时 503」。只判 HTTP 200 的外部监控会在此后开始报警,这是预期行为;若你只想要计数、不想被状态码影响,改读 `/health` + 自行解析计数即可。 +> 升级注意:`/healthz` 的状态从「恒 200」变为「ready=0 时 503」。 ### Prometheus 指标(`GET /metrics`) -`GET /metrics` 输出 Prometheus text 格式(`text/plain; version=0.0.4`),供 Prometheus/Grafana 直接抓取。默认开启,**不鉴权**——与同样裸暴露的 `/healthz` 同口径;`METRICS_ENABLED=false`(可热更)时端点返回 404。 - -导出五个指标家族: +默认开启、**不鉴权**;`METRICS_ENABLED=false`(可热更)时 404。 | 指标 | 类型 | 标签 | 数据源 | | --- | --- | --- | --- | -| `coding2api_requests_total` | counter | `provider` / `model` / `status`(ok\|error) | `usage_hourly`(永久,跨重启单调) | -| `coding2api_tokens_total` | counter | `provider` / `type`(input\|output\|reasoning\|cached) | `usage_hourly` | -| `coding2api_credentials` | gauge | `state`(ready\|cooling\|paused\|disabled) | 凭证池(与 `/healthz` 同口径) | -| `coding2api_upstream_errors_total` | counter | `provider` / `kind`(受控失败原因) | 进程内计数(重启归零) | +| `coding2api_requests_total` | counter | `provider` / `model` / `status` | `usage_hourly` | +| `coding2api_tokens_total` | counter | `provider` / `type` | `usage_hourly` | +| `coding2api_credentials` | gauge | `state` | 凭证池 | +| `coding2api_upstream_errors_total` | counter | `provider` / `kind` | 进程内计数(重启归零) | | `coding2api_task_failures_total` | counter | `task` | 进程内任务运行态(重启归零) | -**禁用高基数标签**:不含 `credential_id` / `api_key_id` / `username` —— 一个账号一条时间序列会让 Prometheus 侧内存与查询失控。`requests_total` / `tokens_total` 读**永久保留**的小时汇总(不是 90 天的明细),因此 scrape 时不做明细全表扫描、且计数跨重启单调;进程内计数(失败原因、任务失败)重启归零,由 Prometheus 的 counter reset 语义吸收。 - -> 已知偏离:扫描报告列了 `upstream_errors_total{provider,kind}` 且数据源写 `usage_hourly`,但小时汇总没有 `error_type` 维度,用 90 天明细当 `_total` 会随裁剪下降——故 `kind` 改由进程内计数器提供(重启归零),其余家族仍取小时汇总。 +不含 `credential_id` / `api_key_id` / `username`(高基数)。`requests_total` / `tokens_total` 读永久保留的小时汇总,跨重启单调。小时汇总没有 `error_type` 维度,故 `kind` 由进程内计数器提供。 ## API Key 的绑定、白名单、来源 IP 与到期时间 -创建 Key 时可限定渠道、模型、来源 IP 与到期时间,适合「按出口分发 Key」。 - -- **渠道绑定**:选定渠道后该 Key 只在该渠道凭证里选号;模型属另一渠道时直接 400 并指出实际归属(不静默改道)。留空 = 自动(跨渠道选健康凭证)。`模型@渠道` 与绑定冲突时同样 400。 -- **来源 IP 白名单**:逗号分隔的 IP 或 CIDR(如 `203.0.113.9,10.0.0.0/8`),留空 = 不限制;写入时校验并规范化(`10.0.0.1` → `10.0.0.1/32`),来源不在白名单返回 403。**默认不采信 `X-Forwarded-For`**(客户端可写);仅 `TRUST_PROXY=true` 时按 XFF 判定、取**最后一个**条目,故只适用于「本服务前恰好一层受信反代」。 -- **模型白名单**:逗号分隔的模型名或 fnmatch glob(如 `glm-*,kimi-k3`),留空 = 不限制;匹配不区分大小写,`模型@渠道` 后缀不参与匹配,白名单外返回 400,`/v1/models` 也只列白名单内模型。 -- **到期时间**:epoch 秒,留空 = 永不过期,到期后立即 401(与「Key 不存在」统一文案,不泄露可枚举信息)。 -- **配额**:一个 JSON 字段,六项各自独立、`0` 或留空 = 不限制:`daily_requests`(当日请求数)、`daily_tokens`(当日 token)、`daily_cost_usd`(当日成本)、`rpm`(每分钟请求)、`tpm`(每分钟 token,用**预估**值判定——真实用量要等响应结束才知道)、`max_concurrency`(在途并发)。当日类按**北京时间自然日**统计(与凭证护栏同一口径),速率与并发是**进程内**滑动窗口(重启清零,不落库)。超限返回 429 + `{"error":{"type":"quota_exceeded",...}}`,`Retry-After` 指向可重试时刻(当日类 = 距次日 0 点;RPM/TPM = 最早一条滑出窗口;并发 = 1 秒)。**判定本身出异常时一律放行并记 warning**——配额是拒绝语义,但计量故障不能把正常请求打死。持 Key 的调用方可用 `GET /v1/usage?days=1|7|30` 自助查询自己的用量、剩余额度与当前速率占用(只读本地明细,不触发上游,不含凭证昵称或池级信息)。 - -**导入 CC Switch**:Key 列表每行的「导入 CC Switch」按钮打开对话框,粘贴创建时保存的完整 Key(列表只存脱敏 `preview`,服务端不再保留明文,故必须由用户提供),再选一个默认模型,即可生成 Claude Code / Codex CLI / OpenCode 三个 `ccswitch://` 导入深链;默认模型下拉与 Playground 同源(`/api/playground/models`),按渠道分组。粘贴的 Key 只留在对话框内存中,关闭即清除。创建 Key 成功后的对话框内也提供同样的导入按钮(那里直接用刚生成的明文 Key)。 +- **渠道绑定**:该 Key 只在该渠道凭证里选号;模型属另一渠道时 400 并指出实际归属。留空 = 跨渠道自动。 +- **来源 IP 白名单**:逗号分隔 IP 或 CIDR,写入时规范化(`10.0.0.1` → `10.0.0.1/32`),不在白名单返回 403。默认不采信 `X-Forwarded-For`;仅 `TRUST_PROXY=true` 时按 XFF 取最后一个条目。 +- **模型白名单**:逗号分隔模型名或 fnmatch glob(如 `glm-*,kimi-k3`),不区分大小写,`模型@渠道` 后缀不参与匹配。白名单外 400,`/v1/models` 也只列白名单内模型。 +- **到期时间**:epoch 秒,留空 = 永不过期,到期后 401(与「Key 不存在」统一文案)。 +- **配额**:六项独立,`0` 或留空 = 不限制——`daily_requests` / `daily_tokens` / `daily_cost_usd` / `rpm` / `tpm`(用预估值判定)/ `max_concurrency`。当日类按北京时间自然日,速率与并发是进程内滑动窗口。超限 429 + `quota_exceeded`,`Retry-After` 指向可重试时刻。判定出异常时一律放行并记 warning。 +- **自助查询**:`GET /v1/usage?days=1|7|30` 返回自己的用量、剩余额度与当前速率占用(只读本地明细)。 ## 凭证暂停 -凭证列表行内菜单的「暂停」把该凭证摘出**对话流量**,并让**每日签到 / token 预刷新**跳过它(这两类任务对 `disabled` 与 `enabled=0` 一视同仁)。其余后台任务(额度探测、成长中心、活跃上报)仍照常运行(只认系统硬禁用 `disabled`),适合「先不接聊天、但积分继续领」;「取消暂停」立即放回池子。与状态列的「已禁用」不同——那是渠道判定会话失效后的系统禁用,需重新登录后用「恢复」解除。同一开关也在 `POST /api/credentials/{id}/toggle`。 +「暂停」把该凭证摘出对话流量,并让每日签到 / token 预刷新跳过它;其余后台任务(额度探测、成长中心、活跃上报)照常运行。适合「先不接聊天、但积分继续领」。「取消暂停」立即放回池子。与「已禁用」不同——后者是渠道判定会话失效后的系统禁用,需重新登录后「恢复」。同一开关也在 `POST /api/credentials/{id}/toggle`。 -> OpenCode Zen / Kilo 是无凭证渠道,池里是一条虚拟占位行。**暂停它是永久停用的唯一方式**——删除后下次重启会被种子重新补上。暂停也会把该渠道从模型列表摘掉。 +> Zen / Kilo 是虚拟占位行,**暂停是永久停用的唯一方式**(删除后重启会补回)。暂停也会把该渠道从模型列表摘掉。 ## 凭证批量运维 -池子一大,「逐个点签到 / 探测」就不现实。凭证页提供多选 + 批量操作条:每行一个 checkbox、组头一个「全选本渠道」,勾选后顶部出现操作条(探测额度 / 每日签到 / 暂停 / 取消暂停 / 恢复 / 取消选择)。 +凭证页支持多选 + 批量操作条(探测额度 / 每日签到 / 暂停 / 取消暂停 / 恢复)。 -- **后端批量端点**:`POST /api/credentials/batch`,body `{action: "probe"|"toggle"|"checkin"|"revive", ids: [...], enabled?}`(`enabled` 仅 `toggle` 用)。一次请求最多 200 条,重复 id 自动去重。 -- **逐条隔离**:单条失败不影响其余条——凭证不存在、渠道不支持该能力、上游报错都只体现在该条结果里,其余条照常执行。响应为 `{action, total, succeeded, failed, results: [{id, ok, error?, detail?}]}`:`ok` 表示该条动作是否成功;`error` 表示这条根本没执行到上游(`credential not found` / `credential does not support ...` / `action_failed`),`detail` 是动作自身的返回体(probe 的剩余额度、checkin 的积分与状态)。 -- **暂停是显式状态而非取反**:`toggle` 的 `enabled` 由请求体显式给出,不会让「已启用 + 已暂停」的混选走向相反方向。因此操作条把 `toggle` 拆成**「暂停」(`enabled=false`)与「取消暂停」(`enabled=true`)两个按钮**——只给一个按钮的话,批量暂停后再无入口放回池子(`revive` 清的是硬禁用 `disabled`,不碰 `enabled`)。 -- **「恢复」与「取消暂停」是两件事**:前者是 `revive`(解除渠道判定会话失效后的系统硬禁用,需先重新登录),后者是 `toggle enabled=true`(把管理员手动暂停的号放回调度)。 -- **只收已有单条端点的动作**:`refresh`(token 预刷新)没有单条入口且由后台按到期窗口驱动,批量强刷会白耗一次性票据;`delete` 破坏性太强,需要单独的二次确认流,都不走这个通道。 -- **审计只记一条汇总**(`toggle` / `revive`):批量暂停 50 个号是一次操作、一个意图,逐条落库会把审计流水刷成噪声。`probe` / `checkin` 与单条端点同为只读语义,不写审计。`toggle` 的 detail 带方向(`批量 toggle(暂停):N/M 成功`),否则暂停与取消暂停在流水里无法区分。 -- **审计写失败不影响操作结果**:审计落库失败只记 warning,端点仍返回逐条结果。逐条变更此时已提交生效,因留痕失败返回 500 会让操作员误以为整批没跑。 +- **端点**:`POST /api/credentials/batch`,body `{action: "probe"|"toggle"|"checkin"|"revive", ids: [...], enabled?}`。一次最多 200 条,重复 id 去重。 +- **逐条隔离**:单条失败不影响其余。响应 `{action, total, succeeded, failed, results: [{id, ok, error?, detail?}]}`。 +- **暂停是显式状态而非取反**:`toggle` 的 `enabled` 由请求体显式给出,故操作条拆成「暂停」与「取消暂停」两个按钮。 +- **「恢复」与「取消暂停」是两件事**:前者 `revive`(解系统硬禁用,需先重新登录),后者 `toggle enabled=true`。 +- 只收已有单条端点的动作:`refresh` 与 `delete` 不走这个通道。 +- **审计只记一条汇总**(`toggle` / `revive`);审计写失败只记 warning,不影响操作结果。 ## 凭证限额护栏 -凭证级**可选**护栏,给「这个号别用超」一个显式开关:行内菜单「设置限额」配置,四个护栏各自独立、全部留空 = 不受限(老库升级后行为不变)。判定在**选号前**完成,触顶的凭证不再参与对话流量;后台任务(签到 / 探测 / 成长中心)照常运行——限额只管「对话用量」,与「暂停」(连签到/预刷新一并停掉)不是一回事。 - -- **保留积分**:账户剩余积分 **≤** 该值时整号停用——给号留底,避免把额度跑空。与当日用量无关;余额未知(渠道不上报)时不猜、不停用。 -- **每日 token 上限**:当日累计 `输入 + 输出` token **达到**该值时整号停用。 -- **每日积分上限**:当日累计 credit 达到该值时整号停用。 -- **按模型 token 上限**:`{模型: 上限}`,某模型当日累计达到上限时**只停该模型**,其余模型照常可用。 -- **当日 = 北京时间自然日**(与签到、活跃上报同一时区),0 点刷新计数,避免与上游「每日重置」出现半天错位。 -- **无可用凭证 503**:当所有候选都因限额被跳过时,复用 `no_healthy_credential`(账号级原因,换个模型也未必能用);与「模型级冷却」的 `model_unavailable` 区分开。 -- 写入 `PUT /api/credentials/{id}/limits`(整体替换:只传要开的护栏,缺省字段 = 关闭该护栏;四个全关时删除配置行)。改动即时生效(写入后主动失效选号侧快照,不等 TTL)。审计动作 `credential.limits`。 +凭证级可选护栏,行内菜单「设置限额」。四项独立,全部留空 = 不受限。判定在选号前完成;后台任务照常运行。 -> 实现注:判定需要「当日聚合用量」,那是每请求都变的动态数据,因此不进 `Candidate.is_selectable`(纯函数,只吃凭证自身状态),而在 executor 选号前用 `LimitGuard` 过滤;`LimitGuard` 对「限额表 + 当日用量」做进程内快照缓存(默认 15s),避免每次选号都查一次聚合。 +- **保留积分**:剩余积分 ≤ 该值时整号停用。余额未知时不猜。 +- **每日 token 上限** / **每日积分上限**:当日累计达到即整号停用。 +- **按模型 token 上限**:`{模型: 上限}`,只停该模型。 +- **当日 = 北京时间自然日**,0 点刷新。 +- 全部候选因限额被跳过时回 `no_healthy_credential` 503;模型级冷却回 `model_unavailable`。 +- 写入 `PUT /api/credentials/{id}/limits`(整体替换,四个全关时删除配置行)。即时生效。审计动作 `credential.limits`。 ## 凭证在途上限 -`MAX_INFLIGHT_PER_CREDENTIAL`(默认 `0` = 不限):同一凭证的**并发请求**达到该值后不再参与选号,避免把单账号打进上游限流 / 封号——多配几个号本就是为了分摊,把一个号打满不如把请求分出去。跨渠道全局生效(与 CodeArts 的渠道内 `CODEARTS_MAX_CONCURRENCY` 正交)。 - -- 计数是**进程内**的:单实例语义,多实例部署各自计数(与 `/api/tasks` 运行态同口径)。 -- 租约在请求结束时释放(含成功、轮换、异常、客户端断开);另有 **600s TTL 兜底**——漏释放的租约过期后被回收,宁可偶尔多放一个请求,也不能让号被静默占死。 -- 全部候选都占满时与限额护栏同口径报「无可用凭证」503。 -- 可在管理台「任务与配置」页热更,下一请求即生效(无快照缓存)。 +`MAX_INFLIGHT_PER_CREDENTIAL`(默认 0 = 不限):同一凭证并发达到该值后不再参与选号。 -**在途计数兼作负载键**(不限并发时也生效):在途数是调度器的「最少连接」排序键,插在「免费优先」之后、到期额度之前——并发请求自动摊到空闲号,避免全部压到排序确定的同一个号上(`Scheduler.select`)。上限为 `0`(不限)时 `blocked()` 恒 False,但租约**照常登记**,负载键照常可用;只有顺序请求(在途恒 0)才退回纯到期/健康度排序。 +- 计数是进程内的(单实例语义)。 +- 租约在请求结束时释放,另有 600s TTL 兜底。 +- 全部占满时与限额护栏同口径回 503。 +- 可热更。 +- **在途计数兼作负载键**:插在「免费优先」之后、到期额度之前,并发自动摊到空闲号。 ## 凭证池的筛选与分组 -凭证多了以后,列表页把「找到某个号」这件事拆成三层,默认首屏只铺渠道级总览: +- **筛选工具条**:搜索(昵称 / 渠道 / id)× 渠道 × 状态 × 标签,四者 AND,纯前端收敛。显示「筛选后 N / 总数」并提供「清空」。标签下拉只在池里真出现过标签时渲染。标签不参与搜索词匹配。 +- **标签**:逗号分隔,最多 16 个、单个 ≤ 24 字符。只用于筛选与展示,不参与选号。行内最多列两个,多余收成 `+N`。 +- **组排序**:只重排渠道组,不动组内行序。默认额度从高到低,可切回固定渠道顺序。无额度接口的免费层垫底。 +- **按渠道分组**:组头是紧凑仪表盘——图标 + 渠道名 + 账号数、模型(数量 + 最低倍率,只算收费模型;整组免费才写「全免」;自定义别名不算独立模型)、状态分布、额度合计、到期积分(主窗口 1.5 天一块,次窗口 7 天仅在更多时再出一块)。默认全折叠,筛选生效时自动展开。点组头折叠 / 展开。 +- **列头跟着展开区走**:列头渲染在每个展开组内部并吸顶,各组共用同一套排序状态。「成长中心」列只在 CodeBuddy 出现,其余渠道留空占位以保持列宽。 +- **长列表可滚**:表格限高 70vh,吸顶列头与「操作」按钮常驻。 -- **筛选工具条**:搜索(昵称 / 渠道 / id,忽略大小写)× 渠道 × 状态 × 标签四件套。四者是 **AND** 关系,纯前端收敛,不改后端排序(后端仍按 `sort` / `order` 给全量)。命中时显示「筛选后 N / 总数」,并提供「清空」;无匹配给空态而非空白表格。渠道 / 状态 / 标签下拉里的计数来自当前全量,切换筛选不会自我缩窄。**标签下拉只在池里真的出现过标签时渲染**(没有标签时不留一个恒为「全部标签」的空控件);标签是结构化维度,**不参与搜索词匹配**——否则「查一个恰好叫『生产』的昵称」会变得不可能。 -- **标签**:每条凭证可贴任意个运维标签(逗号分隔,最多 16 个、单个 ≤ 24 字符;半角/全角逗号与分号都当分隔符),入口在行内「更多操作 → 设置标签」。标签**只用于列表筛选与展示**,不参与选号、不改变任何请求行为——把标签当策略用会让改名静默改变线上流量。行内最多列两个标签,多余收成 `+N` 并在悬浮里给全量。 -- **组排序**:单独一个控件,只重排「渠道组」,不动组内行序(行序仍由列头 / 后端 `sort` 决定)。默认**额度从高到低**,按组内额度剩余合计排名,让还能打最多的渠道排前面;可切回**渠道顺序**(固定,30s 轮询不会让组头乱跳)。无额度接口的免费层垫底(不当 `0` 参与比较),额度相同(含都为 null)时回落到渠道顺序。 -- **按渠道分组**:列表按渠道聚合,组头做成一张紧凑的「渠道仪表盘」——品牌图标 + 渠道名 + 账号数,加上**模型**(该渠道当前能提供的模型数量 + **最低倍率**,数据源与 Playground / `/v1/models` 同源,只算有可用凭证且过当前黑名单的渠道;**最低倍率只统计收费模型**——免费额度(0)不参与取最小,否则渠道里混着 1~2 个免费模型就会显示「最低 免」,读起来像整个渠道免费;整组都免费时才写「全免」;自定义别名不算独立模型(后端克隆目标条目,算进来会虚高);组头只给数量与最低倍率,hover 展开**卡片式浮层**——头部是渠道身份 + 条数胶囊,中部是限高内滚的逐条「模型名 + 该渠道倍率」清单,底部是倍率口径脚注。几十条模型铺开会把组头撑成十几行,而「几个模型、最便宜多少」正是判断这渠道值不值得展开的第一信息。倍率按渠道取(`by_provider` 优先,单渠道回退合并值),多渠道模型各渠道倍率不同,不能拿合并值冒充;`0` 显示成「免」并着色,未给倍率的显示 `—` 而不是当免费;模型列表未取到时整块不渲染,不显示「0 个」。模型块是**独立的可聚焦按钮**(放在折叠按钮外——button 套 button 会被 HTML 解析器拆开),键盘 Tab 到它同样能打开清单,不依赖鼠标 hover)、**状态分布**(图标 + 计数,只列出现过的态,口径与行内状态徽章同一套 `credentialState`)、**额度合计**(占比环 + 数字;环是纯图形仪表,精确百分比写在「额度合计 / 积分合计」标签行,对组内非 null 值求和,免费层 zen / kilo 无额度接口时显示 `—` 而不是 `0`,也不出环),以及**到期积分**(主窗口 1.5 天一块,次窗口 7 天仅在**比主窗口多**时再出一块——次窗口是主窗口的超集,数字相同说明没有超出 1.5 天的部分)。点组头折叠 / 展开该渠道,**默认全折叠**(首屏先看结构,要看具体账号再展开);筛选生效时自动展开命中渠道,清空筛选回到折叠总览。组顺序默认「额度从高到低」,也可用工具条切到固定的全站渠道顺序(CodeBuddy、TRAE 在前),不随行排序键变化。渠道名只出现在组头,行内不重复。**一个真实渠道一个组**:CodeArts 额度就是积分,组头额度合计标签写「积分合计」,其余渠道写「额度合计」。 -- **列头跟着展开区走**:没有全局表头,列头(含排序按钮与列说明)渲染在每个展开的渠道组内部,并在滚动容器里吸顶——折叠态下组头本身就是总览,列名没有对应内容,只有展开某组时才需要「这一列是什么」的锚点。同一时间可能有多个组展开,各组列头共用同一套排序状态(点任一处都写回同一个后端 `sort` 参数)。**「成长中心」列只在支持的渠道(CodeBuddy)出现**,其余渠道留一个空占位格——列数保持恒定,否则 auto 布局会重算列宽、下面展开的组整列错位。 -- **一个渠道一个组**:组头 label 就是渠道展示名,`ProviderGroup.key === provider`;CodeArts 不再拆「内置 / 福利」两组(福利链路已下线,额度就是积分账)。行内额度列读的是同一个渠道账本:CodeArts 是积分(`ledger=credits`),其余渠道是套餐 / 每日余额(`ledger=pool`),由 `providerLedger()` 派生。 -- **长列表可滚**:表格限高(70vh),滚到第 20 行时该组的吸顶列头与「操作」按钮仍在。 - -额度列的数字带虚线下划线:悬浮显示**本行的周期口径**(本周期剩余 / 账户剩余 / 积分余额 / 免费层无额度接口),逐行的同款文案收进悬浮说明。 +额度列数字带虚线下划线,悬浮显示本行的周期口径。 ## 模型列表加载 -`GET /v1/models` 与 Playground 的模型下拉只合并**当前有可用凭证**的渠道:该渠道至少有一条**未暂停、未会话失效**的记录。判定在缓存之前——没凭证的渠道不读缓存、不拉上游、不展示,故**从未接入**的渠道不出现「幽灵模型」;**全部暂停 / 凭证失效**时其模型暂时消失,恢复或补一条凭证后立刻回来;冷启动默认只有 `zen` / `kilo`(自带虚拟凭证)。冷却中的凭证仍算「有凭证」——只是暂时限流,列表不闪没。 - -**展示顺序**:CodeBuddy、TRAE、Qoder、CodeArts、OfficeAce 的模型依次排在前面,组内按模型名字典序;多渠道模型按最高优先级渠道归位。纯展示排序,不改变调度选号。 +`GET /v1/models` 与 Playground 下拉只合并**当前有可用凭证**的渠道(至少一条未暂停、未会话失效的记录)。冷却中的凭证仍算有凭证。展示顺序:CodeBuddy、TRAE、Qoder、CodeArts、OfficeAce 在前,组内按名字典序,多渠道模型按最高优先级渠道归位。 -**请求路由只认这份模型缓存列表**:模型目录能证明某模型归属哪些渠道时,候选渠道就只有它们——登记了该模型的渠道全部不可用(都在冷却 / 都被暂停)时直接返回 503,不再把请求逐个渠道试过去(各上游只会回 11102 / 4001 / 401,还会给渠道写下数小时的「该后端无此模型」负缓存)。模型完全不在目录里时仍按全部候选渠道路由;直连指定(`模型@渠道`)与 Key 渠道绑定不受影响。 +**请求路由只认这份模型缓存列表**:目录能证明归属时,候选只有那些渠道;全部不可用时直接 503,不再逐渠道试。模型完全不在目录里时仍按全部候选路由;直连指定与 Key 渠道绑定不受影响。 ### 模型目录落盘与启动即用 -模型表除进程内 TTL 缓存(300s)外,每次拉取成功还写快照到 `DATA_DIR/model_catalog.json` 并在**启动时同步读回**:重启那一刻「模型 → 渠道」归属表就已就绪,扁平名请求不会在预热(zen 免费模型探活要十几秒)跑完前扇出打错渠道。快照存未过滤原始表,损坏 / 超 7 天只丢缓存、不影响启动。另有 `MODEL_CATALOG_MINUTES` 后台任务兜底刷新。 +模型表除进程内 TTL 缓存(300s)外,每次拉取成功还写快照到 `DATA_DIR/model_catalog.json` 并在启动时同步读回,重启那一刻归属表就已就绪。快照损坏或超 7 天只丢缓存。另有 `MODEL_CATALOG_MINUTES` 后台任务兜底。 -Playground 与 `GET /v1/models` 走 **stale-while-revalidate**:有缓存立刻回旧列表,TTL 到期的渠道丢后台异步刷新;只有某渠道**一条缓存都没有**时才同步等一次。代价是列表最多滞后一个 TTL。拉取**连续失败**时重试间隔按 2^(n-1)×TTL 退避(封顶 2 小时,成功一次即恢复),日志只在首次失败打 WARNING、其后降 INFO——上游长期不可用(如 zen 免费层按出口 IP 拦截)时不会每 5 分钟重探十几秒再刷一条同样的告警。 +Playground 与 `/v1/models` 走 stale-while-revalidate:有缓存立刻回旧列表,TTL 到期后台刷新;某渠道一条缓存都没有时才同步等。连续失败时重试间隔按 2^(n-1)×TTL 退避(封顶 2 小时),日志只在首次失败打 WARNING。 ### 管理台「模型列表」页 -管理台「模型列表」页(`GET /api/model-catalog`,管理员 / 操作员可见)只读展示 OpenRouter 公开目录:刊例价(输入 / 输出 / 缓存读 / 缓存写,USD / 百万 token,可切人民币)、上下文·输出上限、输入→输出模态、能力(推理 / 工具调用 / 附件 / 结构化输出)、知识截止,以及 **Artificial Analysis 三项指数**(综合智能 / 编程 / 智能体,经 OpenRouter 公开接口,**非本服务实测**,不做排序)。目录缺失时页面显示空态,不影响聊天与成本。 - -OpenRouter 收录数百条,其中多数本服务没接。每行带 `available` 布尔(该模型是否**当前渠道真能提供**,口径与 `/v1/models` 一致),页面提供**默认关闭**的「只看当前渠道」开关,勾选后本地收窄到当前渠道获取到的模型(不重新请求)。 +(`GET /api/model-catalog`,管理员 / 操作员)只读展示 OpenRouter 公开目录:刊例价、上下文 / 输出上限、模态、能力、知识截止、Artificial Analysis 三项指数(非本服务实测,不做排序)。每行带 `available` 布尔,页面提供默认关闭的「只看当前渠道」开关。 -**疑似下线视图**:同页可切到「疑似下线」筛选——`GET /api/models/availability`(operator)把最近 N 天(默认 7、可调 1–30)`usage_events` 里 `error_type` 为 `upstream_blocked` / `invalid_request` 的失败按 `(渠道, 模型)` 聚合,给出失败次数、最近失败时刻与「是否已在黑名单」。行内「加入黑名单」把该模型**追加**到现行 `model_blocklist`(读现值再写,不覆盖既有项;已命中的行按钮置灰),写入走既有设置接口并落审计 `settings.update`,下一次 `/v1/models` 即生效。这个视图本身**只读聚合**(真发请求的那部分见下节「主动探活与人工接管」)。 +**疑似下线视图**:`GET /api/models/availability`(operator)把最近 N 天(默认 7,可调 1–30)`upstream_blocked` / `invalid_request` 的失败按 `(渠道, 模型)` 聚合。行内「加入黑名单」把该模型追加到 `model_blocklist`,走既有设置接口并落审计。 -「能力分」列头可点排序,按**综合智能指数**(智)排;无分数的行排末尾(不冒充 0 分)。只给这一个分项排序入口——表头一列对应一个入口,三项各排会把表头挤成一排按钮。 - -**浮动别名取同族最新快照的分**:OpenRouter 对同一个模型会同时挂**浮动别名**(`deepseek/deepseek-v4.1-flash`)与**带日期快照**(`deepseek/deepseek-v4.1-flash-1017`),并用 `canonical_slug` 声明同属一次发布。上游会把浮动别名钉在旧快照上、并对同一快照只发布一个评测变体(2026-10-19 实测:浮动别名标 `Non-reasoning` 24.7,`-1017` 标 `Max` 39.5)——分数看着「掉」了 15 分,其实只是换了变体。本项目转发时推理默认开着(渠道 `default_effort` 多为 `high`),与 Max 变体同口径,故按 `canonical_slug` 的日期取同族最新快照的分;显式带日期戳的 id(`deepseek-v4-pro-0813`)仍用自己的分。`source_model` 一律指向真正提供分数的上游 id。家族只从**带 `-YYYYMMDD` 的 canonical** 派生:`mistralai/mistral-large`(Feb '24)与 `mistralai/mistral-large-2512`(Mistral Large 3)削完日期同基却是两个模型,不互相借分。 +**浮动别名取同族最新快照的分**:OpenRouter 对同一模型同时挂浮动别名与带日期快照,并用 `canonical_slug` 声明同属一次发布。本项目转发时推理默认开着,与 Max 变体同口径,故按 `canonical_slug` 的日期取同族最新快照的分;显式带日期戳的 id 仍用自己的分。家族只从带 `-YYYYMMDD` 的 canonical 派生。 ### 主动探活与人工接管 -真实请求失败只能证明「这次没打通」,分不清「上游真没这个模型」与「额度耗尽 / 限流 / 网络抖动」。同页的**主动探活**面板(`GET /api/models/probes`,operator)只探**仍被 `blocked` 负缓存锁住**的 (渠道, 模型)——那是唯一会静默避让一个模型 6–24 小时的状态,也是唯一值得花额度去确认的目标;不遍历全量模型表。 +主动探活面板(`GET /api/models/probes`,operator)只探**仍被 `blocked` 负缓存锁住**的 (渠道, 模型)。 -三态判定:**成功**(拿到内容或正常收尾)→ 立即清该渠道的 `blocked` 负缓存,模型马上回到池子,这是 TTL 走完之外的**唯一提前恢复入口**;**`failed`**(上游明确 `BLOCKED` / `INVALID`)→ 保留负缓存并累加连续失败;**`inconclusive`**(额度、限流、并发打满、网络、超时、空流、任何异常)→ **绝不禁用**,并把连续失败归零——一串无关故障不该凑成「连续失败」把好模型判死。 +三态判定: -探活与生产完全隔离:直连 `provider.stream_chat`,不经 executor,不写 `usage_events`、不动冷却表(除成功时清 `blocked`)、不改健康度、不参与任何出口判断;结论落独立的 `model_probes` 表,只供页面展示(探测失败 ≠ 上游拒绝)。`reason='model'`(6004 限流)不探——它有自己的重置墙钟。 +- **成功** → 立即清该渠道的 `blocked` 负缓存(唯一的提前恢复入口)。 +- **`failed`**(上游明确 `BLOCKED` / `INVALID`)→ 保留负缓存并累加连续失败。 +- **`inconclusive`**(额度、限流、网络、超时、空流、任何异常)→ 绝不禁用,并把连续失败归零。 -面板**不列出命中 `MODEL_BLOCKLIST` 的模型**(口径同 `/v1/models`:原样名与归一键任一命中即滤,现读热更值):黑名单是管理员「这个模型我不要了」的显式决定,它已被排除在模型列表外,再在探活面板冒出来只会诱导管理员反复去救一个已主动弃用的模型。 +探活与生产完全隔离:直连 `provider.stream_chat`,不写 `usage_events`、不动冷却表、不改健康度。结论落独立的 `model_probes` 表。面板不列出命中 `MODEL_BLOCKLIST` 的模型。 -**判死的模型同时从模型列表里暂时摘掉**:`failed` 且该 (渠道, 模型) 仍被 `blocked` 负缓存锁着时,`/v1/models` 与别名表把**这一对**抽掉——只抽该渠道,别的渠道正常时同一模型照常展示(多渠道条目退化成单渠道条目)。到期口径与负缓存同一个 `cooling_until`(6–24h 起步的退避):负缓存一过期就自动回列表,因为此后没人再探它,只看 `failed` 会让它永久消失。与黑名单同口径——用户点名直连指定模型不受影响。 +**判死的模型同时从模型列表摘掉**:`failed` 且仍被负缓存锁着时,只抽该 (渠道, 模型);负缓存过期即自动回列表。 -**人工接管**(`POST /api/models/probes/manual`,`manual=on|off`):接管后自动探活整行跳过,避免后台与人工结论互相打架;接管**不**改负缓存——要立刻恢复请点「立即探活」(`POST /api/models/probes/probe`,不受接管标记限制)。后台周期由 `MODEL_PROBE_MINUTES` 控制(默认 `0` = 关闭,可热更)。 +**人工接管**(`POST /api/models/probes/manual`):接管后自动探活跳过该行,但不改负缓存。要立刻恢复点「立即探活」。后台周期由 `MODEL_PROBE_MINUTES` 控制(默认 0 = 关闭)。 ### 管理台「上游状态」页 -管理台「上游状态」页(`GET /api/upstream-status`,管理员 / 操作员可见)聚合各模型 / 网关提供方的**官方状态页**,回答「这个报错是上游整体挂了,还是我这边凭证 / 额度的问题」——本服务自己的 429/5xx 冷却只能回答「这个号现在不行」,官方状态页补上另一半。 +(`GET /api/upstream-status`,管理员 / 操作员)聚合各提供方官方状态页,回答「这个报错是上游挂了,还是我这边凭证 / 额度问题」。 -- **来源**:OpenCode Zen(自定义 JSON,**逐模型 TTFT / tps**)、Kilo、Moonshot / Kimi、MiniMax、OpenAI、Anthropic(Statuspage v2)、Together AI(Better Stack JSON:API)、DeepSeek(Atom 事故史)。按 `scope` 分两组展示:`channel`(与本站渠道一一对应,如 zen / kilo)排在 `model`(经渠道间接使用)之前。 -- **归一化六档**:`operational` / `degraded`(性能下降)/ `partial`(部分中断)/ `major`(重大故障)/ `maintenance`(维护中)/ `unknown`(抓取失败)。Statuspage 的 `critical` 并入 `major`。整体档位取**最差**的源,但 `unknown` 排最后——一个源连不上不该压过其余源报出的真实故障。 -- **只做解释、不做决策**:官方状态页滞后于本服务的实时冷却(模型级冷却见 `src/provider/kilo/client.py`),把它并入选号 / 熔断只会让调度更慢更糊涂。 -- **只存内存、不落盘**:状态数据分钟级易变,重启后拿一份几小时前的「一切正常」比显示「尚未抓取」危险得多。抓取由后台任务(`UPSTREAM_STATUS_MINUTES`,默认 5 分钟)负责,单源失败只在该源那一行标错误原因,绝不让整轮抛异常。 -- **版式按「先结论、后明细」收敛**:顶部概况条给整体档位 + 档位分布 + 抓取运行态;下面按 `scope` 分两组、一行一个源(**异常源默认展开**,正常源折成一行摘要),另有「需要关注」条把非正常源提到最前面并锚点跳转。OpenCode Zen 逐模型展开 TTFT / tps,其余源给逐组件档位;单源组件明细超过 6 项时截断(**异常项排前面**,不会被折在「显示全部」后面),事故列表里进行中默认铺开、已恢复折进计数按钮。 -- **本页不覆盖**仅提供 HTML、无稳定机器可读端点的源(Groq、腾讯云、阿里云、华为云、SiliconFlow、Qoder、智谱、TRAE)——加源前先确认端点能稳定返回 JSON / XML,否则等于每天修一次 HTML 解析。 +- **来源**:OpenCode Zen(逐模型 TTFT / tps)、Kilo、Moonshot / Kimi、MiniMax、OpenAI、Anthropic、Together AI、DeepSeek。按 `scope` 分两组:`channel` 在前,`model` 在后。 +- **归一化六档**:`operational` / `degraded` / `partial` / `major` / `maintenance` / `unknown`。整体取最差源,`unknown` 排最后。 +- **只做解释、不做决策**,不参与选号 / 熔断。 +- **只存内存、不落盘**;抓取由后台任务(`UPSTREAM_STATUS_MINUTES`,默认 5 分钟)负责,单源失败只标该行。 +- **版式先结论后明细**:顶部概况条,下面按 scope 分两组,异常源默认展开;「需要关注」条把非正常源提前。组件明细超过 6 项时截断(异常项排前)。 +- **不覆盖**仅提供 HTML 的源(Groq、腾讯云、阿里云、华为云、SiliconFlow、Qoder、智谱、TRAE)。 ### token 到期展示 -凭证列表的 **token 剩余**列显示 access token 距到期还有多久,低于 `TOKEN_EXPIRY_WARNING_SECONDS`(默认 1 小时)标红。到期时间优先取上游 `expires_at`,缺失时回落 JWT `exp`;都取不到显示 `—`,**不猜本地 TTL**(否则是凭空捏造的预警)。 +凭证列表的 **token 剩余**列显示 access token 距到期还有多久,低于 `TOKEN_EXPIRY_WARNING_SECONDS`(默认 1 小时)标红。优先取上游 `expires_at`,缺失回落 JWT `exp`;都取不到显示 `—`,不猜本地 TTL。 ## 侧栏数据提醒(导航角标) -管理台侧栏(`web/src/components/Sidebar.tsx`)在每个页面都在。多个**不点进去就发现不了**的待办态(告警未恢复 / 后台任务失败 / 凭证需重登)在这里显示成计数角标,由单个聚合端点 `GET /api/nav-badges` 一次返回,侧栏 60s 轮询(不挂 7 个页面级 hook,避免反复拉全量列表)。 +侧栏在每个页面都在。多个不点进去就发现不了的待办态在这里显示成计数角标,由单个聚合端点 `GET /api/nav-badges` 返回,侧栏 60s 轮询。 | 导航项 | 门槛 | 角标口径 | |---|---|---| -| 凭证管理 | operator+ | `disabled`(需重新登录)**或** `health < 0`(额度耗尽)的凭证数 | -| 运维告警 | admin | `resolved_at IS NULL`(**仍在告警中**)的条数 | -| 任务与配置 | admin | `enabled` 且**上次执行失败**(`last_ok === false`)的任务数 | -| 上游状态 | operator+ | `summary.abnormal`(含维护中);色调随整体档位(维护=黄,部分/整体故障=红) | -| 用户管理 | admin | 有激活令牌(`activation_digest IS NOT NULL`)的账号数 | -| API Key | 全员(仅本人) | 已过期或 **7 天内**到期的 Key 数 | -| 模型列表 | operator+ | 快照陈旧标记(布尔文字「陈旧」,非计数) | - -- **按角色裁剪**:后端只下发当前角色有权看的键,**缺失 = 无权看**(不是 0),前端据此不渲染。凭证角标刻意不下发给 viewer——能看列表但无写权限,露出角标只会误导。 -- **与各页面数据不是同一口径**:角标只数「待办」。例如告警页 `total` 含已恢复历史,角标只数未恢复的;任务用「上次失败」(首次失败即提示)而非告警规则的「连续失败 ≥ 阈值」;`last_ok === null`(从未运行)与已关闭的任务都不计。 -- **凭证角标 ≠ `/healthz` 的可用数**:后者复用调度器 `is_selectable`,只看 disabled / enabled / cooling、**不看 health**,所以额度耗尽的号在 `/healthz` 里仍算 `ready`。两者不一致是刻意的。 -- **告警角标会自愈**:`AlertTask` 启动时从库里未恢复的行重建「告警中」集合,所以重启前落库、条件已消失的告警仍会被正常标记恢复;同一次故障期间的「提醒」行在恢复时一并收尾。详见 TECHNICAL.md 的「迟滞与恢复」。 -- **模型目录陈旧**:从未拉取(`price_saved_at` 为空)不算陈旧(新装部署不该一上来就报警);已拉过但超过 **2 倍** `OPENROUTER_CATALOG_MINUTES` 未刷新才算。 -- 角标窗口(API Key 7 天)是后端模块常量,不做热更;不点进去也能看到待办,点进去即是可行动页面。 +| 凭证管理 | operator+ | `disabled` 或 `health < 0` 的凭证数 | +| 运维告警 | admin | 仍在告警中的条数 | +| 任务与配置 | admin | 上次执行失败的任务数 | +| 上游状态 | operator+ | 异常源数(含维护中) | +| 用户管理 | admin | 有激活令牌的账号数 | +| API Key | 全员(仅本人) | 已过期或 7 天内到期的 Key 数 | +| 模型列表 | operator+ | 快照陈旧(布尔文字) | + +- **按角色裁剪**:后端只下发有权看的键,缺失 = 无权看(不是 0)。凭证角标不下发给 viewer。 +- **只数「待办」**:例如告警页 `total` 含已恢复历史,角标只数未恢复的;任务用「上次失败」而非「连续失败 ≥ 阈值」。 +- **凭证角标 ≠ `/healthz` 的可用数**:后者不看 health,额度耗尽的号仍算 `ready`。 +- **告警角标会自愈**:`AlertTask` 启动时从库里未恢复的行重建集合。 +- **模型目录陈旧**:从未拉取不算陈旧;超过 2 倍 `OPENROUTER_CATALOG_MINUTES` 未刷新才算。 ## 统计 ### 口径 -时间范围默认**最近 24 小时**(另有 7 天 / 30 天 / 全部 / **自定义**):打开统计页的第一问题是「这个池子现在怎么样」,7 天会把当天的波动摊平。**自定义**给两个 `datetime-local` 输入(精确到分钟、按**浏览器本地时区**解析),两端可只填一边(另一边不限);起点不早于终点时整窗作废并提示,不静默退回全量。所有统计端点因此多接受一个 `until`(epoch 秒,半开上界):`[since, until)`,`until` 本身不计入——用户填的「结束时间」是瞬时右端点,两次相邻查询不重叠。 +时间范围默认**最近 24 小时**(另有 7 天 / 30 天 / 全部 / 自定义)。自定义给两个 `datetime-local`(按浏览器本地时区),可只填一边;起点不早于终点时整窗作废并提示。所有统计端点接受 `until`(epoch 秒,半开上界 `[since, until)`)。 + +**明细失败类型**是受控枚举(后端 `CONTROLLED_ERROR_TYPES`):模型级冷却耗尽记 `model_unavailable`,凭证池真耗尽记 `no_healthy_credential`(对外错误码相同,仅统计口径区分)。 -**明细失败类型**是受控枚举(后端 `CONTROLLED_ERROR_TYPES`,界面只展示中文翻译):模型级冷却耗尽记「该模型暂时不可用」(`model_unavailable`,换个模型即可用),凭证池真耗尽才记「无可用凭证」(`no_healthy_credential`)——两者对外错误码相同(`no_healthy_credential` 503),仅统计口径区分。 +**请求 ID**:每个响应都带 `X-Request-ID`。入站合法则沿用,否则自生成;合法字符集 `[A-Za-z0-9._:-]`、长度 1–128。该 ID 落进 `usage_events.request_id`,明细时间列悬停可见。**不透传到上游**(各渠道出站头语义不同)。 -**请求 ID(`X-Request-ID`)**:每个请求都在响应头带一个 `X-Request-ID`(任何响应都带,含被拒的 413 / 400)。入站 `X-Request-ID` 合法则沿用(便于与客户端日志对齐),否则自生成;合法字符集 `[A-Za-z0-9._:-]`、长度 1–128,越界即丢弃改用自生成(客户端可写,不校验会被塞入换行/超长串)。该 ID 随请求落进 `usage_events.request_id`,统计页「请求明细」的**时间列悬停**可见——用于把用户报的一个请求与这行明细对齐。老库历史行为 `NULL`(不回填)。**不透传到上游**:各渠道出站头语义不同(qoder 的 `X-Request-ID` 是 uid 派生的防重放值,trae/codearts 有指纹敏感头),统一改写会破坏既有契约。 +**Credit 消耗**:CodeBuddy 是上游真值;TRAE 按官方单价推算并标 `≈`(单价表在 `src/provider/trae/pricing.py`,未收录不推算)。历史可用 `scripts/backfill_trae_credit.py` 补齐。 -统计页 **Credit 消耗**列:CodeBuddy 是上游**真值**;TRAE 的 `token_usage` 帧只有 token 数,credit 按官方单价**推算**并标 `≈`。单价表集中在 `src/provider/trae/pricing.py`,未收录的模型不推算(显示 `—`)。历史明细可用 `scripts/backfill_trae_credit.py` 补齐。 +**成本(估算,≈)**:token × 公开刊例价,不是上游真实扣费。单价取自 OpenRouter 公开目录,按 `(输入−命中)×输入价 + 命中×缓存价 + 输出×输出价` 折美元,再按写入时生效的 `USD_CNY_RATE` 折人民币。 -**成本(估算,≈)**:按 token × 公开刊例价估算,不是上游真实扣费。单价取自 **OpenRouter 公开模型目录**(`pricing.prompt` / `completion` / `input_cache_read`,USD / 百万 token),按 `(输入−命中)×输入价 + 命中×缓存价 + 输出×输出价` 折算美元,再按**写入时生效的汇率**(`USD_CNY_RATE`)折人民币。 +- **写入时定值**:成本在写明细时算好落库,历史行不随价表或汇率重算。上线前历史两列为 `NULL`,可用 `scripts/backfill_cost.py --apply` 补齐。 +- **匹配不到就不计**:本地模型名按 OpenRouter `id` / 展示名等值匹配(命中不唯一就不配),聚合值 `null` 显示 `—`,故成本是下限。渠道分发代号按显式等价规则归到基础模型(只登记实测过的 id,不做通用剥日期 / 剥区域尾缀)。 -- **写入时定值**:成本在写明细时算好落库(`usage_events.cost_usd` / `cost_cny`),历史行**不随价表或汇率重算**;上线前历史两列为 `NULL`,可用 `scripts/backfill_cost.py --apply` 补齐(默认预览、写库前自动备份、幂等)。 -- **匹配不到就不计**:本地模型名按 OpenRouter 的 `id` / 展示名等值匹配(`src/model_match.py`,**命中不唯一就不配**),未匹配或未上报输入 token 的不计入,聚合值 `null` 显示 `—`(**不拿 0 冒充免费**),故成本是**下限**。渠道分发代号按显式等价规则归到基础模型:OfficeAce 的 `deepseek-v4.1-flash-0930`(快照日)与 CodeBuddy 国际站的 `deepseek-v4.1-flash-sg`(区域)都按 `deepseek-v4.1-flash` 的刊例价算;只登记实测过的 id,**不做通用「剥日期 / 剥区域尾缀」**——OpenRouter 的 `deepseek-v4.1-flash-1017` 是另一个价位的快照,剥尾会把两套价格混成一个。 -- **改价表后补历史**:等价规则改动只影响**新写入**的明细;已有明细两列不会自动重算,`scripts/backfill_cost.py --apply`(默认预览、写库前自动备份、幂等)可按当前价表补上。 +**缓存命中率**:命中 token ÷ 输入 token(1 位小数)。未上报或输入为 0 时显示 `—`。分组表与明细表把命中率与 token 放同一格。 -**缓存命中率**:**Token 消耗**卡片在 token 分项后追加缓存命中率 = 命中 token ÷ 输入 token(保留 1 位小数);**未上报或输入为 0 时显示 `—`**。**分组统计**表的 Token 合并列与**请求明细**表的 Token 合并列都把命中率与 token 放同一格(「输入 / 输出 · 命中率」),同公式、同占位规则(派生列不参与排序)。 +**输出速度(tok/s)**:`token 总量 ÷ 生成时间总和`,不是逐条比值再平均。两个口径分母相同、分子不同:**正文速度**用 `output_tokens - reasoning_tokens`,**总产出速度**含思考 token。前端并成一格显示 `正文 / 总产出`。只算经本服务路由且产出 ≥ 1 token 的成功请求。生成时间 = `latency_ms - ttfb_ms`;生成窗口 < 200 ms 或单条 > 1000 tok/s 的按不可测剔除。`ttfb_ms` 为 `NULL` 时不进主口径;某行 / 组在主口径下没有样本时回落估算口径(端到端耗时当分母,展示前置 `≈`)。两口径都没样本时显示 `—`。 -**输出速度(tok/s)**:**输出速度**卡片、分组表的「速度」合并列与**请求明细**表的速度列都按 `token 总量 ÷ 生成时间总和` 算,**不是逐条比值再平均**——短请求的逐条比值噪声大,逐条平均会把整体数字带歪;总量相除才是池级吞吐。聚合口径的两个分子**分母相同**,只是分子不同:**正文速度**的分子是 `output_tokens - reasoning_tokens`(贴近屏幕上看到的正文刷新速度),**总产出速度**含思考 token(思考成批快速吐出,数字会明显更高——实测思考占输出 50–67% 时两者约 190 与 63 tok/s);前端把它们并成一格显示成 `正文 / 总产出`(`88.2 / 198.8 tok/s`),列头下拉仍可分别排序。总览卡片给正文速度,总产出速度写在卡片 hint 里。**只算经本服务路由的成功请求,且该请求产出了至少 1 个 token**(**不设输出量下限**——短输出也是真实产出,剔掉会让短问答为主的渠道永远没有速度;输出为 0 / 未上报的请求没有可测产出,不算样本,不是「慢」)。生成时间 = 明细的 `latency_ms - ttfb_ms`(扣掉首字等待),并加两道物理合理性下限:**生成窗口 < 200 ms** 或**单条样本 > 1000 tok/s** 的按「生成时长不可测」剔除、不算样本——上游把整段内容攒成一批吐时首帧与末帧几乎同时到达,分母趋近 0 会把比值放大成 30 万 tok/s 这种天文数字(本地实测 901 token / 3 ms),它不是「很快」而是测不出来。`ttfb_ms` 为 `NULL`(非流式请求等)时没有可分离的生成时间,**该请求不进主口径**;若某行/某组在主口径下**一条样本都没有**,回落估算口径:拿端到端耗时当分母(**含首字等待**),样本门槛与主口径一致(同样只要「有输出 token」),同样套用上面两道下限,展示前置 `≈`——提醒它不是同一口径,别与不带 `≈` 的值直接比。两口径都没有样本(或输出全是思考 token,正文速度为 0)时该侧显示 `—`(不拿 0 冒充)。 -与分位列同源:聚合速度也只能从**逐请求明细**算(小时汇总没有单请求输出量、思考 token 数与逐请求耗时,无法按「单请求输出量 / 生成窗口」筛样本),故**只覆盖最近 90 天**;总览卡片同样如此(其余卡片读小时汇总)。**请求明细**给的是**每一条请求自己**的速度:同样按生成窗口(`latency - ttfb`)与那两道物理下限算,但**不回落估算口径**——逐行没有相邻样本摊平首字等待,拿端到端耗时除出来的单条比值只会误导,故无生成窗口的行直接显示 `—`(`tps` / `tps_visible` 字段)。分组表速度列与明细表速度列都可点表头排序(正文 `sort=tps_visible`、总产出 `sort=tps`,无样本的行/组按可空列语义排最后 / 升序时最前)。 +聚合速度只能从逐请求明细算,故只覆盖最近 90 天(总览卡片同样如此,其余卡片读小时汇总)。明细表给每条请求自己的速度,不回落估算口径,无生成窗口的行显示 `—`。速度列可点表头排序(`sort=tps_visible` / `sort=tps`)。 ### 积分记录 -凭证**额度**列数字后的下箭头展开「变动流水」,记录**两次额度探测之间的净变化**,不是动作归因:签到、成长、对话都会改余额而上游不打日志,探测只能看到区间净变化,界面一律写「净变化」而非「签到 +5」。首次探测只建基线;余额没变不记;余额变「未知」仍记但不填变化量;保留 90 天。 +凭证额度列数字后的下箭头展开「变动流水」,记录**两次额度探测之间的净变化**,不是动作归因:签到、成长、对话都会改余额而上游不打日志,界面一律写「净变化」。首次探测只建基线;余额没变不记;余额变「未知」仍记但不填变化量;保留 90 天。 -**按账本分流**:流水表带 `ledger` 列(`pool` / `credits`),每轮探测按凭证自己的渠道记一条——CodeArts 的额度来自积分接口,记 `credits`;其余渠道记 `pool`。CodeArts 行展开的是积分账流水(`ledger=credits`),其余渠道是套餐 / 每日余额流水(`ledger=pool`);接口 `GET /api/credentials/{id}/credit-events` 用 `ledger` 查询参数选账本(缺省 `pool`,未知取值回落 `pool`)。**必须分开读**:两本账数值量级与语义都不同,混在一个列表里会被当成同一本账的连续读数(老库里 CodeArts 的历史 `pool` 行是已下线的每日 token 池口径,与积分差一个数量级)。老库升级时历史流水一律归 `pool`——它们本就由当时的余额探测 diff 产生,不再展示。 +**按账本分流**:流水带 `ledger` 列(`pool` / `credits`)。CodeArts 记 `credits`,其余渠道记 `pool`。接口 `GET /api/credentials/{id}/credit-events?ledger=`(缺省 `pool`)。两本账量级与语义不同,必须分开读。 ### 分组维度与重新汇总 -**分组统计**面板用 tabs 切四个维度(按渠道 / 按模型 / 按用户 / 按凭证),共用同一张表(渠道 / 请求(请求数 · 成功率,悬停看成功数)/ Credit / 成本 / Token(输入 ÷ 输出 · 命中率,悬停看命中 token)/ 速度(正文 ÷ 总产出,tok/s,悬停看两口径)/ 平均延迟(耗时 ÷ 首字)/ P95 延迟(耗时 ÷ 首字)),共 8 列。对应端点 `GET /api/stats/by-provider|by-model|by-user|by-credential`。默认按请求数降序,点列头可切换排序;请求 / Token / 速度 / 平均延迟 / P95 延迟五个合并列点列头弹菜单选子列再排序(`sort=requests|ok_count|input_tokens|output_tokens|cached_tokens|avg_latency_ms|avg_ttfb_ms|p50_latency_ms|p90_latency_ms|p95_latency_ms|p99_latency_ms|p50_ttfb_ms|p90_ttfb_ms|p95_ttfb_ms|p99_ttfb_ms|tps_visible|tps`),成功率与命中率是派生只读值不参与排序。**延迟分位可切换**:P95 列的表头有 P50 / P90 / P95 / P99 四档切换(默认 P95,不铺开四列以免表头拥挤);四档由同一次窗口函数子查询算出,取整口径一致(nearest-rank,n 为**有值**样本数)。**按用户 / 按凭证**只对 admin 与 operator 展示。**口径差异**:渠道 / 模型 / 用户三维读**小时汇总**(永久保留,最近 5 分钟内的请求尚未计入);**凭证维度读明细表**(小时汇总主键里没有 `credential_id`),故只覆盖**最近 90 天**。 +分组统计用 tabs 切四个维度(渠道 / 模型 / 用户 / 凭证),共用一张表(渠道 / 请求 / Credit / 成本 / Token / 速度 / 平均延迟 / P95 延迟)。端点 `GET /api/stats/by-provider|by-model|by-user|by-credential`。默认按请求数降序,点列头切换排序;合并列点列头弹菜单选子列。**延迟分位可切 P50 / P90 / P95 / P99**(默认 P95,nearest-rank,n 为有值样本数)。按用户 / 按凭证只对 admin 与 operator 展示。 + +**口径差异**:渠道 / 模型 / 用户三维读小时汇总(永久,最近 5 分钟尚未计入);凭证维度读明细表(只覆盖 90 天)。 -**延迟两类口径**:**只统计成功请求**(失败请求的耗时会拉偏「典型耗时」)。**平均延迟列** = 平均耗时 / 平均首字 = `latency_sum ÷ 成功数`(读小时汇总,与总览卡片、图表同口径,永久保留);**P95 延迟列** = P95 耗时 / P95 首字 = 第 95 百分位,按 **nearest-rank**(第 `⌈0.95·n⌉` 小的值,n = 该组有值样本数)从**逐请求明细**算出——小时汇总只有和与计数,数学上还原不出分布,故分位列只覆盖**最近 90 天**。P50 / P90 / P99 与 P95 同一套 SQL(一次子查询算完四档),口径完全一致,只是百分位参数不同。`ttfb_ms` 对非流式 / 未产出首字的成功请求为 `NULL`,两列各自独立排名(只数有值样本)。无成功样本 / 未上报延迟时显示 `—`(不拿 0 冒充)。 +**延迟两类口径**:只统计成功请求。平均延迟 = `latency_sum ÷ 成功数`(小时汇总);分位延迟从逐请求明细算(只覆盖 90 天)。`ttfb_ms` 为 `NULL` 的两列各自独立排名。无样本显示 `—`。 -**失败归因**:分组表下方另有「失败归因」面板,`GET /api/stats/errors`(operator+)按 `(渠道, 模型, 错误类型)` 聚合窗口内 `ok=0` 的明细,给出失败次数与最近失败时刻(按失败数降序,`limit` ≤ 5000)。点任意一行即**钻取**到下方明细并自动加上「只看失败」(`GET /api/stats/events?ok=0`,与 `provider`/`model`/`credential_id` 叠加);键盘 Enter / Space 同样触发。该面板只在 admin/operator 下渲染与请求。`/metrics` 的 `upstream_errors_total` 仍取进程内计数(重启归零),与这里的 90 天明细口径不同,两者不合并。 +**失败归因**:分组表下方的面板,`GET /api/stats/errors`(operator+)按 `(渠道, 模型, 错误类型)` 聚合窗口内 `ok=0` 的明细,给失败次数与最近失败时刻(按失败数降序,`limit` ≤ 5000)。点行即钻取到明细并加「只看失败」(`GET /api/stats/events?ok=0`)。只在 admin/operator 下渲染。`/metrics` 的 `upstream_errors_total` 仍取进程内计数,两者不合并。 -**点行钻取**:点分组表任意一行,下方「请求明细」即按该行钻取(渠道 / 模型 / 凭证按同名列过滤,用户维度走作用域参数),条件叠加、可多个维度层层收窄,面板上方显示当前过滤标签并可一键清除。`GET /api/stats/events` 因此接受 `provider` / `model` / `credential_id` 三个过滤参数(`AND` 语义),另有 `ok=0` 只看失败。 +**点行钻取**:点分组表任意行,明细即按该行过滤(渠道 / 模型 / 凭证,用户维度走作用域参数),条件可叠加。`GET /api/stats/events` 接受 `provider` / `model` / `credential_id`(AND)与 `ok=0`。 -**明细表列合并**:请求明细把同类列并成一列,默认值一眼可读、悬停展开全部子项:**Token**(默认「输入 / 输出 · 命中率」,悬停含命中 token)、**延迟**(默认「耗时 / 首字」)、**速度**(默认「正文 / 总产出」,tok/s,悬停写全两口径)、**渠道 / 凭证**(凭证名在上、渠道 icon + 名在下,悬停看完整凭证 ID——同渠道常挂多个账号,先认账号)。**时间列拆成两行**(`20:17:09` / `2026-10-21`,**具体时刻优先**、日期次要色,等宽数字各自纵向对齐,窄屏不折行),请求 ID 提示仍挂在这一格。列数从 15 压到约 10,窄屏卡片也少一半字段。排序能力不丢:点合并列头弹出下拉选子列(Token → 输入 / 输出 / 命中 / 命中率,延迟 → 耗时 / 首字,速度 → 正文 / 总产出,渠道 / 凭证 → 渠道 / 凭证),选中即按该子列排序;**命中率**子列走后端 `sort=cache_rate`(命中 ÷ 输入,输入为 0 或未上报时为 `NULL`,与其它可空列同排序语义),速度子列走 `sort=tps_visible` / `sort=tps`,渠道子列走 `sort=provider` / `sort=credential`。 +**明细表列合并**:Token、延迟、速度、渠道 / 凭证各并成一列,默认值一眼可读、悬停展开子项。时间列拆成两行(时刻 / 日期)。点合并列头弹出下拉选子列排序(`sort=cache_rate` / `tps_visible` / `tps` / `provider` / `credential`)。 -**导出 CSV**:明细面板右上「导出 CSV」(仅 admin / operator)按**当前时间范围 + 当前钻取过滤**导出逐请求明细,`GET /api/stats/events?format=csv`。列与页面明细表同口径,含**逐请求速度**(`正文速度 tok/s` / `总产出速度 tok/s`,与页面同规则:只算生成窗口可测的成功请求,不可测的行留空——空不是 0)。文件带 UTF-8 BOM(Excel 认编码)、`\r\n` 换行、时间列 ISO 8601(UTC),并对以 `= + - @`(及制表符/回车)开头的**文本**单元格加前导单引号防公式注入(数值含负数原样)。单次导出上限 10000 行,超出截断并在响应头 `X-Export-Truncated: 1` 标出,页面提示收窄条件。 +**导出 CSV**:`GET /api/stats/events?format=csv`(仅 admin / operator),按当前时间范围 + 钻取过滤导出。UTF-8 BOM、`\r\n`、时间 ISO 8601(UTC),对以 `= + - @` 开头的文本单元格加前导单引号防公式注入。单次上限 10000 行,超出截断并带 `X-Export-Truncated: 1`。 -**实时流(SSE)**:明细面板右上「实时」开关打开后,`EventSource` 订阅 `GET /api/stats/events/stream`,展示**当前筛选范围内刚落库**的请求(与下方翻页的历史明细分开展示,避免混成一张会跳动的表)。事件在 `StatsCollector.record` **事务提交后**由进程内 `LiveEventHub` 广播(不轮询数据库),前端只保留最近 50 条、新→旧排列;关闭开关即断开连接。 +**实时流(SSE)**:`GET /api/stats/events/stream`,展示当前筛选范围内刚落库的请求。事件在 `StatsCollector.record` 事务提交后由进程内 `LiveEventHub` 广播,前端保留最近 50 条。 -- **作用域与过滤**:与 `/api/stats/events` 同口径——admin/operator 可传 `username`(不传 = 全量),viewer 恒回落到本人;`provider` / `model` / `credential_id` 三个过滤可叠加。换过滤会重建连接(`EventSource` 无法改查询串)。 -- **凭证昵称**:广播 payload 不含昵称(与明细端点一致,只给已脱敏字段 + `rowid`);仅 admin/operator 的连接在**服务端逐条按需补** `credential_name`,viewer 拿不到池里他人凭证的昵称。 -- **背压与上限**:每个订阅者一个**有界**缓冲(200 条),满了丢**最旧**的一条(实时流看的是「现在」);并发订阅上限 8,超出回 429 + `Retry-After`,不排队、不静默拖垮服务。 -- **保活与缓存**:15 秒一条 SSE 注释帧(`:` 开头,`EventSource` 忽略)防止反向代理断掉静默连接;响应带 `Cache-Control: no-store` 与 `X-Accel-Buffering: no`,避免中间层缓存或整段缓冲把「实时」变成批量。断线由浏览器自动重连,页面在浏览器放弃重连(如 401 / 429)时提示断开。 +- 作用域与过滤同 `/api/stats/events`;换过滤会重建连接。 +- 广播 payload 不含凭证昵称;仅 admin/operator 的连接在服务端按需补 `credential_name`。 +- 每订阅者有界缓冲 200 条(满则丢最旧);并发上限 8,超出 429。 +- 15 秒注释帧保活;`Cache-Control: no-store` + `X-Accel-Buffering: no`。 -若历史数据偏小(老库升级前落的明细没被覆盖、或补过明细后没重算),用 `python3 scripts/rollup_hourly.py --apply` 一次性重算(默认预览、**写库前自动备份**、幂等)。脚本先报告缺口与两侧时间跨度,并提示**明细已过保留期无法还原**的区间。 +历史数据偏小时用 `python3 scripts/rollup_hourly.py --apply` 重算(默认预览、写库前备份、幂等)。 ### 列表排序 -管理台各列表**列头可点排序**:点一次用该列的「首次点击方向」(数值列先降序、文本列先升序),再点同一列在升序 / 降序间切换。排序一律由**后端**完成——所有 `GET` 列表端点接受统一的 `sort`(白名单键)与 `order`(`asc` / `desc`)参数。**未知 `sort` 键或非法 `order` 值回落到默认排序,不报错**;`sort` 是白名单键而非 SQL 列名,注入面为零。 +列头可点排序:点一次用该列首次方向(数值降序、文本升序),再点切换。排序由后端完成,所有列表端点接受 `sort`(白名单键)与 `order`(`asc` / `desc`)。未知 `sort` 或非法 `order` 回落到默认排序,不报错。 -适用端点:`/api/credentials`、`/api/users`、`/api/api-keys`、`/api/audit`、`/api/alerts`、`/api/model-catalog`、`/api/credentials/{id}/credit-events`、`/api/credentials/{id}/accounts`、`/api/stats/by-*`、`/api/stats/events`(逐请求明细:默认按 rowid 游标分页返回 `next_before`;按其它列排序或时间升序时改用 `LIMIT/OFFSET` 并返回 `total`)。各端点的白名单键与默认排序见 `src/api/`。 +适用:`/api/credentials`、`/api/users`、`/api/api-keys`、`/api/audit`、`/api/alerts`、`/api/model-catalog`、`/api/credentials/{id}/credit-events`、`/api/credentials/{id}/accounts`、`/api/stats/by-*`、`/api/stats/events`。 ### 列表分页 -管理台各列表**统一分页**:共享同一套页码文案(`第 N / M 页`)、每页选项(`20 / 50 / 100`,默认 `50`)与上一页 / 下一页按钮。扁平列表一律由**后端**分页——`GET` 列表端点接受 `limit`(每页条数)+ `offset`(偏移)并返回 `total`(**筛选后**的总条数,供前端算页数);原列表键名不变,只新增 `total`,向后兼容。`/api/users`、`/api/api-keys`、`/api/alerts`、`/api/audit` 在 SQL 层 `LIMIT/OFFSET`;`/api/model-catalog` 因排序键含派生字段(能力分 / 可用性)先内存排序再切片。 +统一分页:`第 N / M 页`、每页 20 / 50 / 100(默认 50)。扁平列表由后端分页——端点接受 `limit` + `offset` 并返回筛选后的 `total`。`/api/model-catalog` 因排序键含派生字段,先内存排序再切片,另接受 `q` 与 `only_available`。 -`/api/model-catalog` 另接受两个筛选参数(服务端生效,前端搜索框去抖 250ms 后回第一页):`q`(大小写不敏感地匹配模型 `id` 与名称)与 `only_available`(`true` 时只留 `available=true` 的行)。`total` 是筛选后的条数,顶层 `count` 仍是目录全量(页头展示「命中 N / 共 M」)。 - -**分组类表**(凭证页按渠道分组、用量统计分组表)不分页,保持一次性全量展示——它们的行数由渠道 / 分组维度决定,本就不受明细规模影响。 +分组类表(凭证页按渠道分组、用量统计分组表)不分页。 ## 后台任务 -额度探测、token 预刷新、每日签到、成长中心、活跃上报、明细清理、数据库备份、渠道模型列表刷新、模型目录刷新(OpenRouter)、运维告警、上游状态页聚合、模型主动探活共 12 类,由 `TaskRunner` 自动调度,失败互不影响。每类任务的上次执行时间、最近结果与错误在管理台**「任务与配置」页**查看(30 秒自动刷新),运行态只存在于**本次进程**内,重启归零。每个任务卡片带「立即运行」按钮(`POST /api/tasks/{key}/run`,admin + CSRF):**绕过时点窗口但绕过不了开关**——`due()` 未到点也照跑,而任务被开关关掉时如实回 `ran=false`(不把「关了」谎报成「跑过了」);同一任务已在跑时立刻回 `busy=true`、不排队、不并发打上游;同步等这一轮跑完再返回结果。 +额度探测、token 预刷新、每日签到、成长中心、活跃上报、明细清理、数据库备份、渠道模型列表刷新、模型目录刷新、运维告警、上游状态页聚合、模型主动探活共 12 类,由 `TaskRunner` 调度,失败互不影响。「任务与配置」页查看上次执行时间、最近结果与错误(30 秒刷新),运行态只存在于本次进程。 + +每个任务卡片带「立即运行」(admin + CSRF):绕过时点窗口但绕过不了开关;已在跑时回 `busy=true` 不排队。 -**渠道模型列表刷新**(`MODEL_CATALOG_MINUTES`,默认 30、下限 5)是兜底保鲜:模型表本身按「谁访问 `/v1/models` 或 Playground 谁刷新、TTL 300s」更新,这条只保证**没人访问时也会更新**。周期取 30 分钟是跟 zen 免费模型判活缓存对齐——再密也不会让 zen 多探活。 +**渠道模型列表刷新**(`MODEL_CATALOG_MINUTES`,默认 30)是兜底保鲜,保证没人访问时也会更新。 -**成长中心**仅 CodeBuddy 有。Buddy 旅行 1–4 小时回来一次,故周期默认 60 分钟(`GROWTH_INTERVAL_MINUTES`)。抽奖 / 连登兑换 / 开 Buddy 盲盒 / 消耗补登卡属**不可逆动作**,`GROWTH_IRREVERSIBLE_ACTIONS=false` 可全部跳过。凭证列表「成长中心」列显示每个账号最近一轮结果,行内菜单可手动执行一次。 +**成长中心**仅 CodeBuddy。周期默认 60 分钟(`GROWTH_INTERVAL_MINUTES`)。不可逆动作由 `GROWTH_IRREVERSIBLE_ACTIONS=false` 关停。 ### 活跃上报(可选,默认关闭) -CodeBuddy 成长中心的「连登天数 / 活跃地图」按日统计客户端对话事件,账号长期只被本网关自动调用时会断连登。`ACTIVITY_REPORT_ENABLED=true` 时,后台任务在每天 `ACTIVITY_REPORT_HOUR`(默认 10 点)所在整点窗口为每个账号补发**一条** `chat_request_send` 事件续上连登——与积分、调度无关,上报失败不影响聊天请求。凭证行内菜单也可手动补报一次。适用于 CodeBuddy 与 CodeBuddy 国际。 +`ACTIVITY_REPORT_ENABLED=true` 时,每天 `ACTIVITY_REPORT_HOUR`(默认 10 点)整点窗口为每个账号补发一条 `chat_request_send` 事件续连登。与积分、调度无关。适用于 CodeBuddy 与国际站。 -> ⚠️ **风险与限制**:官方活动条款禁止用模拟器 / 脚本篡改活动数据,处罚为**取消资格并追回已发礼品**。默认关闭,开启前请自行评估账号风险。事件名与请求形状依赖上游实现,**上游改版即失效**,不承诺任何收益,不作为可靠性功能。实测(2026-09-21):缺少 `userId` 时上游返回 HTTP 200 `{"code":0}` 却**静默丢弃**;OAuth 凭证的 `user_id`/`account_uid` 可能为空,此时回落取 bearer JWT 的 `sub`,仍缺失则跳过(不编造)。 +> **风险**:官方条款禁止用脚本篡改活动数据,处罚为取消资格并追回礼品。默认关闭,开启前自行评估。上游改版即失效。缺少 `userId` 时上游会静默丢弃;OAuth 凭证的 `user_id` 为空时回落取 JWT `sub`,仍缺失则跳过。 ### 运维告警 -后台周期评估四类风险并落 `alert_events`(管理台「运维告警」页回看,admin-only): +后台周期评估四类风险并落 `alert_events`(「运维告警」页,admin-only): | 规则 | 触发条件 | |---|---| -| `pool_empty` | 池里有凭证但可用数少于 `ALERT_POOL_READY_MIN` | -| `task_failed` | 某后台任务连续失败达到 `ALERT_TASK_FAILURES` 次(成功一轮清零) | -| `token_expiring` | 某凭证 token 剩余寿命落在 `ALERT_TOKEN_EXPIRY_HOURS` 内 | -| `error_rate` | 统计窗内上游失败占比达到 `ALERT_ERROR_RATE_THRESHOLD` 且样本足够 | - -- **静默窗**(`ALERT_SILENCE_MINUTES`):同一 `(规则, 对象)` 窗口内只落库 / 推送一次,避免周期性评估把记录刷屏。 -- **迟滞**(`ALERT_FOR_TICKS` / `ALERT_CLEAR_TICKS`):连续命中若干轮才真正告警,连续未命中若干轮才确认恢复。恢复时给该 `(规则, 对象)` **全部尚未恢复**的行写 `resolved_at`(同一次故障的「提醒」行一起收尾),并外推一条 `status="resolved"` 的 webhook;页面把 `resolved_at` 非空的行显示为「已恢复」。 -- **重启自愈**:迟滞**计数**进程内维护、重启归零重新累积;但「当前哪些告警未恢复」是已落库的事实,`AlertTask` 启动时从库里未恢复的行重建,所以重启前落库、条件已消失的告警仍会被正常标记恢复。 -- **人工处置**:页面每行未恢复的告警带「标记恢复」按钮(`POST /api/alerts/{id}/resolve`,admin + CSRF + 审计),用于规则已下线 / 误报等后台无法自动判定的情况。只标该行,不按 `(规则, 对象)` 批量——同组可能还有仍在告警的记录。 -- **保留期**:与请求明细一致(默认 90 天),由后台清理任务裁剪。 -- webhook 地址与各阈值见「任务与配置」页的「运维告警」卡片。 +| `pool_empty` | 可用数少于 `ALERT_POOL_READY_MIN` | +| `task_failed` | 某任务连续失败达到 `ALERT_TASK_FAILURES` 次 | +| `token_expiring` | token 剩余寿命落在 `ALERT_TOKEN_EXPIRY_HOURS` 内 | +| `error_rate` | 统计窗内失败占比达到 `ALERT_ERROR_RATE_THRESHOLD` 且样本足够 | + +- **静默窗**:同一 `(规则, 对象)` 窗口内只落库 / 推送一次。 +- **迟滞**:连续命中若干轮才告警,连续未命中若干轮才确认恢复。恢复时给该 `(规则, 对象)` 全部未恢复的行写 `resolved_at`,并外推 `status="resolved"` 的 webhook。 +- **重启自愈**:迟滞计数进程内维护;「哪些告警未恢复」从库里重建。 +- **人工处置**:「标记恢复」按钮(admin + CSRF + 审计),只标该行。 +- **保留期**:同请求明细(默认 90 天)。 ### 数据库备份与恢复 -**后台自动备份**(`BACKUP_INTERVAL_MINUTES`,默认 1440 分钟 = 每日一次)把主库一致性快照写进 `DATA_DIR/backups/`,并只保留最近 `BACKUP_KEEP` 份(默认 7)。 +**自动备份**(`BACKUP_INTERVAL_MINUTES`,默认每日)把一致性快照写进 `DATA_DIR/backups/`,只保留最近 `BACKUP_KEEP` 份(默认 7)。 -- **用 `VACUUM INTO` 而不是复制文件**:WAL 模式下主库文件不含尚未 checkpoint 的已提交事务,直接 `cp` 会丢数据。`VACUUM INTO` 读一份一致性快照并重写成**紧凑、无 WAL 的独立库**,恢复时只需原子替换主库。 -- **落盘即校验**:写完立刻 `PRAGMA integrity_check`,不过则删掉半成品并记日志——一个写坏的备份比没有备份更危险。产物 `0600`、目录 `0700`(库内是凭证密文与明文用量/审计)。 -- **失败不影响服务**:备份是普通后台任务,异常只记日志、进任务卡片,不拖垮聊天请求。 +- 用 `VACUUM INTO` 而不是复制文件(WAL 模式下直接 `cp` 会丢未 checkpoint 的事务)。 +- 落盘即 `PRAGMA integrity_check`,不过则删掉半成品。产物 `0600`、目录 `0700`。 +- 失败只记日志,不影响服务。 -**手动备份**(升级/迁移前留一份): +手动备份: ```bash python3 scripts/backup_db.py # 备份到 DATA_DIR/backups/,保留 7 份 python3 scripts/backup_db.py --keep 14 # 保留 14 份(0 = 不清理) -python3 scripts/backup_db.py --out /path/dir # 指定备份目录 +python3 scripts/backup_db.py --out /path/dir ``` -**恢复**(**先停服**,脚本不做「服务是否在跑」探测——容器/权限都可能误判): +恢复(**先停服**): ```bash -python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 # 预览:只校验,不改动 +python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 # 预览:只校验 python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 --apply # 校验 → 安全副本 → 原子替换 ``` -恢复流程:校验备份完整性(不过直接拒绝,绝不把坏文件换进生产库)→ 给现有主库做一份 `.pre-restore-<时间戳>` 安全副本(同样走 `VACUUM INTO`,不漏 WAL 中未 checkpoint 的事务)→ 原子替换主库 → 删除 `-wal`/`-shm` 残留(它们属于旧库,留着会损坏新库)。**恢复后重启服务。** +恢复流程:校验完整性 → 给现有主库做 `.pre-restore-<时间戳>` 副本 → 原子替换 → 删除 `-wal`/`-shm` 残留。**恢复后重启服务。** ## 配置 -常用项如下;完整的可配置项见 `src/config.py`(权威),且**每一个都已透传到 `docker-compose.yml`**。容器里改监听地址用 `HOST`/`PORT`(`PORT` 同时决定宿主机映射端口)。 +完整项见 `src/config.py`(权威),每一项都已透传到 `docker-compose.yml`。容器里改监听地址用 `HOST`/`PORT`。 | 变量 | 默认 | 说明 | |---|---|---| -| `APP_SECRET` | **必填** | 凭证加密密钥,≥16 字符;丢失 = 凭证全部作废,无密钥轮换。启动时拒绝低熵值(全同字符 / 周期重复 / 字符集过小) | -| `ADMIN_USERNAMES` | 空 | **仅引导期**:逗号分隔的用户名,首次启动时被提权为 `admin`;DB 里已有角色后不再生效 | -| `USERS_FILE` | `secrets/users.txt` | **仅引导期**:老式用户文件路径,启动时一次性导入 SQLite(幂等)。账号唯一源是 `users` 表 | -| `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` | 浏览器可达地址;TRAE 登录回调(`+/authorize`)依赖它,scheme 为 https 时额外下发 HSTS。**不决定会话 Cookie 的 `Secure`**——那按请求实际协议动态判定(`X-Forwarded-Proto` 优先,无该头则看 Host),同一实例 LAN(http) 与隧道(https) 双入口才能都登录 | +| `APP_SECRET` | **必填** | 凭证加密密钥,≥16 字符;丢失 = 凭证全部作废。拒绝低熵值 | +| `ADMIN_USERNAMES` | 空 | 仅引导期:逗号分隔,首次启动提权为 `admin` | +| `USERS_FILE` | `secrets/users.txt` | 仅引导期:老式用户文件,启动时一次性导入 | +| `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` | 浏览器可达地址;TRAE 登录回调用;https 时下发 HSTS。不决定会话 Cookie 的 `Secure` | | `DATA_DIR` | `./data` | SQLite 与运行数据目录 | -| `DEFAULT_MODEL` | `deepseek-v4.1-flash` | 模型为空/`auto` 时的默认 | -| `QUOTA_PROBE_MINUTES` | `60` | 额度探测周期(下限 1 分钟) | -| `REFRESH_INTERVAL_MINUTES` | `30` | token 预刷新轮询周期(下限 5 分钟);短寿命渠道的预刷新窗口封顶必须**宽于**它 | -| `MODEL_CATALOG_MINUTES` | `30` | 渠道模型列表兜底刷新周期(下限 5 分钟) | -| `OPENROUTER_CATALOG_MINUTES` | `1440` | 模型目录刷新周期(下限 60 分钟):后台从 OpenRouter 公开接口一次抓取刊例价(成本估算)、明细元数据(「模型列表」页)与 Artificial Analysis 三项指数(Playground /「模型列表」页 / `/v1/models` 能力分)并落盘;上游变动少,默认每日一次 | -| `OPENROUTER_MODELS_URL` | `https://openrouter.ai/api/v1/models` | 模型目录来源地址(匿名可读、无需 key);改后需重启 | -| `USD_CNY_RATE` | `6.70` | 成本估算的美元兑人民币汇率;可热更,只影响之后写入的请求 | -| `GROWTH_INTERVAL_MINUTES` | `60` | 成长中心(仅 CodeBuddy)一轮领取的周期(下限 5 分钟) | -| `GROWTH_IRREVERSIBLE_ACTIONS` | `true` | 是否允许成长中心的不可逆动作:抽奖、连登兑换、开盲盒、消耗补登卡 | -| `ACTIVITY_REPORT_ENABLED` | `false` | 活跃上报(CodeBuddy 与国际):每天为账号补发一条对话事件续连登。**默认关闭**——条款禁止篡改活动数据,上游改版即失效 | -| `ACTIVITY_REPORT_HOUR` | `10` | 活跃上报的本地(北京)时间整点窗口;仅在开关开启时生效 | -| `CHECKIN_TIMES` | `""` | 每日签到执行时点:本地时间 `HH:MM`,逗号分隔(如 `09:37,21:10`)。命中时点才跑一轮;**留空 = 全天每 10 分钟检查一次**(旧行为,升级后不做任何配置也不变节奏)。坏段忽略并记 warning,全是坏段等同留空。上游签到幂等,多设几个时点只是多几次重试机会。配了时点后循环改为**每 60 秒**醒一次(否则抖动=0 时窗只有一分钟,必然整天漏签)。可热更 | -| `CHECKIN_JITTER_MINUTES` | `0` | 签到抖动(分钟):在时点**之前**提前一个 `0..N` 的随机偏移执行,把多部署摊开、避免同一分钟齐打上游。按「日期 + 时点」记去重键,抖动只改执行时刻,不会让同一时点跑两次。`0` = 不抖。可热更 | -| `LOG_BUFFER_LINES` | `500` | 管理台「服务端日志」页的进程内环形缓冲行数(只收本进程 `logging` 记录,不读文件)。`0` = 关闭该页(`GET /api/logs` 与 `/api/logs/stream` 均 404)。每行发出前过脱敏白名单,命中 `token` / `secret` / `authorization` / `cookie` / `password` / `api_key` 或 `sk-…` 形状的**整行丢弃**。可热更 | -| `ALERT_ENABLED` | `true` | 运维告警开关:后台周期评估四类风险(池耗尽 / 任务连续失败 / token 临近到期 / 上游错误率骤升),命中落 `alert_events` | -| `ALERT_WEBHOOK_URL` | 空 | 告警 Webhook:命中时 POST JSON(body 带 `status`:`firing` / `resolved`),多个用逗号分隔(≤16 个,仅 `http(s)`,拒内网/回环/链路本地/保留地址防 SSRF,投递前再按 DNS 解析复核一次、解析失败即跳过),并发投递、全部成功才算成功(总预算 15s);请求恒带 `X-Coding2API-Signature: sha256=` 供收端验真;留空 = 只留站内记录 | -| `ALERT_INTERVAL_MINUTES` | `5` | 运维告警评估周期(下限 1 分钟) | -| `ALERT_SILENCE_MINUTES` | `30` | 同一 `(规则, 对象)` 的静默窗:窗口内只落库 / 推送一次;`0` 关闭静默 | -| `ALERT_FOR_TICKS` | `1` | 进入迟滞:同一告警连续命中这么多轮才真正告警(滤掉单轮毛刺);`1` = 首轮即报 | -| `ALERT_CLEAR_TICKS` | `2` | 恢复迟滞:告警中的条目连续这么多轮不再命中才确认恢复(写 `resolved_at` 并外推 `status="resolved"` 通知),避免单轮抖动误撤 | -| `ALERT_POOL_READY_MIN` | `1` | 池耗尽阈值:可用凭证数少于该值时告警;`0` 关闭该规则 | -| `ALERT_TASK_FAILURES` | `3` | 后台任务连续失败达到该次数时告警(成功一轮清零);`0` 关闭 | -| `ALERT_TOKEN_EXPIRY_HOURS` | `24` | token 剩余时间少于该小时数时告警;`0` 关闭 | -| `ALERT_ERROR_RATE_THRESHOLD` | `0.5` | 上游错误率阈值:统计窗内失败占比达到该值且样本足够时告警;`0` 关闭 | -| `ALERT_ERROR_RATE_MIN_REQUESTS` | `20` | 错误率最小样本数:窗内请求数少于该值不判 | -| `ALERT_ERROR_RATE_WINDOW_MINUTES` | `15` | 错误率统计窗(分钟),数据源为 `usage_events` 明细 | -| `BACKUP_INTERVAL_MINUTES` | `1440` | 数据库备份后台任务周期(分钟),下限 60;`VACUUM INTO` 一致性快照落 `DATA_DIR/backups/` | -| `BACKUP_KEEP` | `7` | 备份保留份数:只留最近这么多份,更旧的按时间删除;`0` 表示不清理 | -| `UPSTREAM_STATUS_ENABLED` | `true` | 上游状态页聚合开关:后台并发抓各模型 / 网关提供方的官方状态接口并归一化,管理台「上游状态」页只读展示。**只做解释不做决策**;快照只存内存、重启归零 | -| `UPSTREAM_STATUS_MINUTES` | `5` | 上游状态页抓取周期(下限 1 分钟) | -| `UPSTREAM_STATUS_TIMEOUT_SECONDS` | `10.0` | 单源 HTTP 超时(秒):8 个源并发抓,慢的那个不该拖住整轮 | -| `MODEL_PROBE_MINUTES` | `0` | 模型主动探活周期(下限 1 分钟,`0` = 关闭)。只探仍被 `blocked` 负缓存(11102「该后端无此模型」)锁住的 (渠道, 模型),用一条最小对话区分「模型真没了」与「额度/限流/网络」:成功即清该渠道的负缓存(唯一的提前恢复入口),上游明确说没这个模型才算失败,其余一律「无法判定」且**绝不禁用**。探活直连 provider、不写 `usage_events`、不动冷却、不参与选号;结论落独立的 `model_probes` 表。开启会真发上游请求、消耗额度,按余量决定;负缓存本身 6h 起步,实际建议 ≥60 分钟。可热更,也可在「模型列表」页对单个模型点「立即探活」 | -| `QUOTA_EXPIRY_WINDOW_SECONDS` | `129600` | 主到期排序窗口:把距到期 ≤ 该秒数的积分加总,多的账号先用;CodeBuddy 与 TRAE 都按包独立到期、都参与;`≤0` 关闭整套到期排序 | -| `QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS` | `604800` | 次到期排序窗口:主窗口打平时才比较;`≤0` 关闭该级 | -| `CONVERSATION_STICKY_SECONDS` | `3600` | 会话粘性 TTL:优先级 **请求体显式会话标识**(`conversation_id` / `prompt_cache_key`,`metadata` 优先)> **请求头会话标识**(`x-session-id` / `x-opencode-session` / `x-session-affinity` / `conversation-id`)> **消息前缀指纹**兜底;手动 pin 优先于全部。头与指纹值都掺 `username` 再哈希,两用户同值不串号;头必须过字符白名单(`[A-Za-z0-9._:-]`,≤128),非法则丢弃退回兜底。带 `user_id` 时不派生前缀兜底键(头照常生效);`≤0` 关闭 | -| `MODEL_BLOCKLIST` | `custom_model_*,*sub*agent*,summary,browser_use_*,file_search_agent,default,seed-code-pro-*,sagitta,aquila,glm-4.*,glm-5,glm-5-turbo,glm-5.0*,glm-5.1,glm-5v-*,kimi-k2.5,kimi-k2.6,kimi-k2-thinking,minimax-m2.5,deepseek-v3-*,hunyuan-*,hy3*,deepseek-v4-*,qwen3.7-*,auto,nemotron-3-*,glm-5.2*,nemotron-3*,lfm2*,north-mini-code,openrouter-models-router,fast,balanced,deep,primary,hy4-preview,inkling-small,minimax-m2.7,kimi-k2.*,minimax-m3,gpt-5.4,gpt-5.5,laguna*,qwen3-vl-235b,seed-2.1*,xing4.0-29b-a4,glyph-cluster,qwen3.8-flash,seed-evolving` | 模型列表黑名单(fnmatch,仅影响列表展示);按**归一后的对外写法**匹配(原代号、归一键、展示名、展示名归一键四种任一命中即滤) | -| `MODEL_FALLBACK_GROUPS` | `""` | 跨渠道 fallback 兼容组:格式 `组名=成员1,成员2;组名2=成员3`;成员可带 `@渠道`。请求任一成员时该成员置首、组内其余依次回退;主渠道全不可用才回退,**仅在本次尝试尚未产出任何响应帧前**切换。留空 = 不启用 | +| `DEFAULT_MODEL` | `deepseek-v4.1-flash` | 模型为空 / `auto` 时的默认 | +| `QUOTA_PROBE_MINUTES` | `60` | 额度探测周期(下限 1) | +| `REFRESH_INTERVAL_MINUTES` | `30` | token 预刷新轮询周期(下限 5) | +| `MODEL_CATALOG_MINUTES` | `30` | 渠道模型列表兜底刷新周期(下限 5) | +| `OPENROUTER_CATALOG_MINUTES` | `1440` | 模型目录刷新周期(下限 60) | +| `OPENROUTER_MODELS_URL` | `https://openrouter.ai/api/v1/models` | 模型目录来源(匿名可读);改后需重启 | +| `USD_CNY_RATE` | `6.70` | 成本估算汇率;可热更,只影响之后写入的请求 | +| `GROWTH_INTERVAL_MINUTES` | `60` | 成长中心周期(下限 5) | +| `GROWTH_IRREVERSIBLE_ACTIONS` | `true` | 是否允许抽奖 / 连登兑换 / 开盲盒 / 消耗补登卡 | +| `ACTIVITY_REPORT_ENABLED` | `false` | 活跃上报,默认关闭 | +| `ACTIVITY_REPORT_HOUR` | `10` | 活跃上报的本地整点窗口 | +| `CHECKIN_TIMES` | `""` | 签到时点 `HH:MM` 逗号分隔;留空 = 全天每 10 分钟检查一次。可热更 | +| `CHECKIN_JITTER_MINUTES` | `0` | 签到抖动(分钟),在时点之前随机提前。可热更 | +| `LOG_BUFFER_LINES` | `500` | 服务端日志页缓冲行数;`0` 关闭该页。可热更 | +| `ALERT_ENABLED` | `true` | 运维告警开关 | +| `ALERT_WEBHOOK_URL` | 空 | 告警 Webhook,逗号分隔(≤16,仅 http(s),拒内网 / 回环防 SSRF),带 `X-Coding2API-Signature` | +| `ALERT_INTERVAL_MINUTES` | `5` | 告警评估周期 | +| `ALERT_SILENCE_MINUTES` | `30` | 同 `(规则, 对象)` 静默窗;`0` 关闭 | +| `ALERT_FOR_TICKS` | `1` | 进入迟滞轮数 | +| `ALERT_CLEAR_TICKS` | `2` | 恢复迟滞轮数 | +| `ALERT_POOL_READY_MIN` | `1` | 池耗尽阈值;`0` 关闭 | +| `ALERT_TASK_FAILURES` | `3` | 任务连续失败次数阈值;`0` 关闭 | +| `ALERT_TOKEN_EXPIRY_HOURS` | `24` | token 剩余小时阈值;`0` 关闭 | +| `ALERT_ERROR_RATE_THRESHOLD` | `0.5` | 错误率阈值;`0` 关闭 | +| `ALERT_ERROR_RATE_MIN_REQUESTS` | `20` | 错误率最小样本数 | +| `ALERT_ERROR_RATE_WINDOW_MINUTES` | `15` | 错误率统计窗 | +| `BACKUP_INTERVAL_MINUTES` | `1440` | 备份周期(下限 60) | +| `BACKUP_KEEP` | `7` | 备份保留份数;`0` = 不清理 | +| `UPSTREAM_STATUS_ENABLED` | `true` | 上游状态页开关 | +| `UPSTREAM_STATUS_MINUTES` | `5` | 状态抓取周期(下限 1) | +| `UPSTREAM_STATUS_TIMEOUT_SECONDS` | `10.0` | 单源 HTTP 超时 | +| `MODEL_PROBE_MINUTES` | `0` | 主动探活周期(`0` = 关闭)。只探被 `blocked` 负缓存锁住的 (渠道, 模型);成功即清负缓存,其余一律不禁用。可热更 | +| `QUOTA_EXPIRY_WINDOW_SECONDS` | `129600` | 主到期排序窗口;`≤0` 关闭 | +| `QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS` | `604800` | 次到期窗口;`≤0` 关闭 | +| `CONVERSATION_STICKY_SECONDS` | `3600` | 会话粘性 TTL。优先级:请求体会话标识 > 请求头 > 消息前缀指纹;pin 最高。`≤0` 关闭 | +| `MODEL_BLOCKLIST` | `custom_model_*,*sub*agent*,summary,browser_use_*,file_search_agent,default,seed-code-pro-*,sagitta,aquila,glm-4.*,glm-5,glm-5-turbo,glm-5.0*,glm-5.1,glm-5v-*,kimi-k2.5,kimi-k2.6,kimi-k2-thinking,minimax-m2.5,deepseek-v3-*,hunyuan-*,hy3*,deepseek-v4-*,qwen3.7-*,auto,nemotron-3-*,glm-5.2*,nemotron-3*,lfm2*,north-mini-code,openrouter-models-router,fast,balanced,deep,primary,hy4-preview,inkling-small,minimax-m2.7,kimi-k2.*,minimax-m3,gpt-5.4,gpt-5.5,laguna*,qwen3-vl-235b,seed-2.1*,xing4.0-29b-a4,glyph-cluster,qwen3.8-flash,seed-evolving` | 模型列表黑名单(fnmatch,仅影响展示),按归一后写法匹配 | +| `MODEL_FALLBACK_GROUPS` | `""` | 跨渠道 fallback 组:`组名=成员1,成员2;…`。留空不启用 | | `ALLOWED_HOSTS` | 空 | Host 白名单,防 DNS rebinding | -| `ALLOW_TEST_HOST` | `false` | 额外放行 FastAPI TestClient 的默认 Host `testserver`。**仅测试用**,生产保持 `false` | -| `TRUST_PROXY` | `false` | 是否采信 `X-Forwarded-For` 判定来源 IP(白名单、登录限流与审计共用)。仅「本服务前恰好一层受信反代」时开启 | -| `ENABLE_DOCS` | `false` | 是否暴露 `/docs` 与 `/openapi.json`(默认关闭) | -| `DUMP_REQUEST_BODIES` | `false` | 诊断:把 `/v1` 原始请求体落盘到 `data/dumps/`(**含对话内容**,仅排查用) | -| `LOG_LEVEL` | `INFO` | 日志级别;审计日志是 INFO 级,调到 `WARNING` 会一并关掉 | -| `HOST` / `PORT` | `127.0.0.1` / `8000` | 监听地址与端口(compose 默认 `0.0.0.0`)。compose 里 `PORT` 同时决定**容器内监听端口与宿主机映射端口**(两侧一致,改一个即可) | -| `CODEBUDDY_API_ENDPOINT` | `https://copilot.tencent.com` | CodeBuddy 上游地址;改动时必须同时加入 `CODEBUDDY_ALLOWED_ENDPOINTS` | -| `CODEBUDDY_ALLOWED_ENDPOINTS` | 官方两站(见 compose) | 上游端点白名单,真实 Token 只发往白名单内地址 | -| `CODEBUDDY_INTL_API_ENDPOINT` | `https://www.codebuddy.ai` | CodeBuddy 国际上游地址;改动时必须同时加入 `CODEBUDDY_INTL_ALLOWED_ENDPOINTS` | -| `CODEBUDDY_INTL_ALLOWED_ENDPOINTS` | `https://www.codebuddy.ai` | 国际站白名单,**刻意不含**国内站主机(跨站发送即凭证泄露,填错启动失败) | -| `ZEN_API_ENDPOINT` | `https://opencode.ai` | OpenCode Zen 上游地址;改动时必须同时加入 `ZEN_ALLOWED_ENDPOINTS` | -| `ZEN_ALLOWED_ENDPOINTS` | `https://opencode.ai` | Zen 端点白名单 | -| `ZEN_OPENCODE_VERSION` | `1.18.0` | 门禁伪装用的 `opencode/` UA 版本(低于阈值会被 426 拒绝) | -| `KILO_API_ENDPOINT` | `https://api.kilo.ai/api/gateway` | Kilo 上游地址;改动时必须同时加入 `KILO_ALLOWED_ENDPOINTS` | -| `KILO_ALLOWED_ENDPOINTS` | `https://api.kilo.ai/api/gateway` | Kilo 端点白名单 | -| `QODER_API_ENDPOINT` | `https://openapi.qoder.com.cn` | Qoder openapi 地址(额度/登录);国际站设为 `https://openapi.qoder.sh` | -| `QODER_GATEWAY_ENDPOINT` | `https://gateway.qoder.com.cn` | Qoder 推理网关地址(聊天/模型发现);国际站设为 `https://api1.qoder.sh` | -| `QODER_ALLOWED_ENDPOINTS` | 国内 openapi+gateway + 国际站(见 compose) | Qoder 端点白名单,带 COSY 签名的请求只发往白名单内地址 | -| `CODEARTS_API_ENDPOINT` | `https://snap-access.cn-north-4.myhuaweicloud.com` | CodeArts snap 引擎地址;改动时必须同时加入 `CODEARTS_ALLOWED_ENDPOINTS` | -| `CODEARTS_ALLOWED_ENDPOINTS` | snap 引擎 + STS + 门户(见 compose) | CodeArts 端点白名单,AK/SK 签名请求只发往白名单内地址 | -| `OFFICEACE_API_ENDPOINT` | `https://agentarts.cn-southwest-2.myhuaweicloud.com` | OfficeAce 控制面地址(OAuth / 开通校验);改动时必须同时加入 `OFFICEACE_ALLOWED_ENDPOINTS` | -| `OFFICEACE_ALLOWED_ENDPOINTS` | `https://agentarts.cn-southwest-2.myhuaweicloud.com` | OfficeAce 端点白名单。**只需控制面**:模型网关每账号不同,由上游下发并存在凭证里 | -| `OFFICEACE_CHAT_MIN_INTERVAL` | `0` | OfficeAce 聊天最小间隔(秒),独立节流器;`0` 关闭 | -| `PROVIDER_PROXIES` | `""` | **按渠道出站代理**(启动期项,改后需重启);留空 = 全部直连。容器内 `127.0.0.1` 指容器自身,代理跑在宿主时须写 `host.docker.internal` | -| `CODEBUDDY_CHAT_MIN_INTERVAL` | `5` | CB 聊天节流器最小间隔(秒):按凭证分桶、**桶内允许并发**,只在同凭证顺序连发时补足间隔;`0` 关闭 | -| `CODEBUDDY_INTL_CHAT_MIN_INTERVAL` | `5` | CB 国际聊天最小间隔(秒),**独立**节流器;`0` 关闭 | -| `ZEN_CHAT_MIN_INTERVAL` | `0` | Zen 聊天最小间隔(秒),独立节流器,默认关闭 | -| `KILO_CHAT_MIN_INTERVAL` | `0` | Kilo 聊天最小间隔(秒),独立节流器,默认关闭 | -| `QODER_CHAT_MIN_INTERVAL` | `5` | Qoder 聊天最小间隔(秒),独立节流器;`0` 关闭 | -| `CODEARTS_CHAT_MIN_INTERVAL` | `5` | CodeArts 聊天最小间隔(秒),独立节流器;`0` 关闭 | -| `CODEARTS_MAX_CONCURRENCY` | `3` | CodeArts 每账号**在途并发上限**:上升沿满额时请求挂起直到有请求结束;`0` 关闭 | -| `CODEARTS_REQUEST_WINDOW_SECONDS` | `60` | CodeArts **账号滑动窗口**(秒):与上一项组合成「每账号每 60s 最多启动 3 次」;`0` 关闭窗口口径 | -| `CODEBUDDY_SANITIZE_CHANNEL_MARKERS` | `true` | 出站 `system`/`assistant` 正文命中「伪装其他厂商官方客户端」指纹串时替换为占位符(上游 11128 内容风控),只改出站副本;`false` 关闭 | -| `REFRESH_SKEW_HOURS` | `24` | token 到期前该小时数窗口内预刷新。短寿命渠道自行封顶(CodeArts STS 只有 2h,封顶 45min) | -| `TOKEN_EXPIRY_WARNING_SECONDS` | `3600` | 管理台 token 到期预警阈值;`≤0` 关闭预警。纯展示,不参与调度 | -| `PACER_MIN_SECONDS` / `PACER_MAX_SECONDS` | `5` / `20` | 全局节流器随机等待区间(秒)。区间相等时(聊天节流器的 `*_CHAT_MIN_INTERVAL` 形态)额外**向上**抖动 ±25%,避免多实例精确同拍;下限不击穿 | -| `AUTO_CONTINUE_MAX` | `10` | 上游以 `finish_reason=length` 截断时同凭证自动续写的最多次数;`0` 关闭 | -| `CONTEXT_COMPRESS_ENABLED` | `true` | 按模型目录里的输入上限裁剪过长对话,避免撞上游硬限制;目录里查不到上限的模型不受影响 | -| `CONTEXT_COMPRESS_RESERVE_TOKENS` | `4096` | 压缩预算里为模型回复预留的输出 token 数 | +| `ALLOW_TEST_HOST` | `false` | 放行 `testserver`,仅测试用 | +| `TRUST_PROXY` | `false` | 是否采信 `X-Forwarded-For`;仅「前恰好一层受信反代」时开 | +| `ENABLE_DOCS` | `false` | 是否暴露 `/docs` 与 `/openapi.json` | +| `DUMP_REQUEST_BODIES` | `false` | 把 `/v1` 原始请求体落盘(含对话内容,仅排查) | +| `LOG_LEVEL` | `INFO` | 日志级别;审计日志是 INFO,调到 WARNING 会一并关掉 | +| `HOST` / `PORT` | `127.0.0.1` / `8000` | 监听地址与端口(compose 默认 `0.0.0.0`);`PORT` 同时决定宿主映射 | +| `CODEBUDDY_API_ENDPOINT` | `https://copilot.tencent.com` | 上游地址,改动须同步白名单 | +| `CODEBUDDY_ALLOWED_ENDPOINTS` | 官方两站(见 compose) | 端点白名单,真实 Token 只发往白名单内 | +| `CODEBUDDY_INTL_API_ENDPOINT` | `https://www.codebuddy.ai` | 国际站地址 | +| `CODEBUDDY_INTL_ALLOWED_ENDPOINTS` | `https://www.codebuddy.ai` | 国际站白名单,刻意不含国内站 | +| `ZEN_API_ENDPOINT` | `https://opencode.ai` | Zen 上游地址 | +| `ZEN_ALLOWED_ENDPOINTS` | `https://opencode.ai` | Zen 白名单 | +| `ZEN_OPENCODE_VERSION` | `1.18.0` | 门禁伪装 UA 版本(低于阈值会被 426 拒绝) | +| `KILO_API_ENDPOINT` | `https://api.kilo.ai/api/gateway` | Kilo 上游地址 | +| `KILO_ALLOWED_ENDPOINTS` | `https://api.kilo.ai/api/gateway` | Kilo 白名单 | +| `QODER_API_ENDPOINT` | `https://openapi.qoder.com.cn` | Qoder openapi(额度 / 登录);国际站 `https://openapi.qoder.sh` | +| `QODER_GATEWAY_ENDPOINT` | `https://gateway.qoder.com.cn` | Qoder 推理网关;国际站 `https://api1.qoder.sh` | +| `QODER_ALLOWED_ENDPOINTS` | 国内 openapi+gateway + 国际站(见 compose) | Qoder 白名单 | +| `CODEARTS_API_ENDPOINT` | `https://snap-access.cn-north-4.myhuaweicloud.com` | CodeArts snap 引擎 | +| `CODEARTS_ALLOWED_ENDPOINTS` | snap 引擎 + STS + 门户(见 compose) | CodeArts 白名单 | +| `OFFICEACE_API_ENDPOINT` | `https://agentarts.cn-southwest-2.myhuaweicloud.com` | OfficeAce 控制面 | +| `OFFICEACE_ALLOWED_ENDPOINTS` | `https://agentarts.cn-southwest-2.myhuaweicloud.com` | 只需控制面(模型网关由上游下发) | +| `OFFICEACE_CHAT_MIN_INTERVAL` | `0` | OfficeAce 聊天最小间隔 | +| `PROVIDER_PROXIES` | `""` | 按渠道出站代理;启动期项 | +| `CODEBUDDY_CHAT_MIN_INTERVAL` | `5` | CB 聊天节流(按凭证分桶) | +| `CODEBUDDY_INTL_CHAT_MIN_INTERVAL` | `5` | CB 国际节流,独立 | +| `ZEN_CHAT_MIN_INTERVAL` | `0` | Zen 节流 | +| `KILO_CHAT_MIN_INTERVAL` | `0` | Kilo 节流 | +| `QODER_CHAT_MIN_INTERVAL` | `5` | Qoder 节流 | +| `CODEARTS_CHAT_MIN_INTERVAL` | `5` | CodeArts 节流 | +| `CODEARTS_MAX_CONCURRENCY` | `3` | CodeArts 每账号在途并发上限;`0` 关闭 | +| `CODEARTS_REQUEST_WINDOW_SECONDS` | `60` | CodeArts 账号滑动窗口;`0` 关闭 | +| `CODEBUDDY_SANITIZE_CHANNEL_MARKERS` | `true` | 出站正文命中「伪装官方客户端」指纹串时替换(上游 11128 风控) | +| `REFRESH_SKEW_HOURS` | `24` | token 到期前该窗口内预刷新;短寿命渠道自行封顶 | +| `TOKEN_EXPIRY_WARNING_SECONDS` | `3600` | token 到期预警阈值;`≤0` 关闭。纯展示 | +| `PACER_MIN_SECONDS` / `PACER_MAX_SECONDS` | `5` / `20` | 全局节流随机等待区间 | +| `AUTO_CONTINUE_MAX` | `10` | `finish_reason=length` 时同凭证自动续写次数;`0` 关闭 | +| `CONTEXT_COMPRESS_ENABLED` | `true` | 按模型输入上限裁剪过长对话 | +| `CONTEXT_COMPRESS_RESERVE_TOKENS` | `4096` | 为回复预留的输出 token | | `CONTEXT_COMPRESS_MIN_KEEP_MESSAGES` | `4` | 无论多长都保留的最近消息条数 | -| `CONTEXT_COMPRESS_SAFETY_RATIO` | `0.95` | 按模型上限的百分比计算压缩预算,给 token 估算误差留余量 | -| `UPSTREAM_COMPLETE_TIMEOUT_SECONDS` | `600` | 非流式聚合整体超时(秒):超时按瞬态错误换号重试;`≤0` 关闭 | -| `STREAM_FIRST_EVENT_TIMEOUT_SECONDS` | `0` | 流式**首事件**超时(秒):上游接了连接却长时间不吐第一个数据帧时,在**出帧前**换号重试(此时切换安全,客户端没收到任何内容)。注释帧 / 心跳不重置该计时。`0` = 关闭。**默认必须保持关闭**——实测慢上下文首字可达 40–71s,默认值会误杀正常请求;要用请按部署实际填(推荐 `60`)。可热更 | -| `STREAM_IDLE_TIMEOUT_SECONDS` | `0` | 流式**空闲**超时(秒):**已出帧**后上游长时间不再吐数据时断流,只发一帧错误收尾——半截输出不可回滚,换号会把两个账号的内容拼给客户端。`0` = 关闭(推荐 `300`)。可热更 | -| `SHORT_COOLDOWN_WAIT_SECONDS` | `0` | 短冷却「等待续上」(秒):全池都在短冷却、没有任何立即可用候选时,最短剩余冷却不超过该值则等待后重取一次,而不是直接 503。等待期间不占在途租约、不计入轮换预算。`0` = 关闭(快速失败,旧行为)。可热更 | -| `MODEL_ALIASES` | `""` | 自定义模型别名:`对外名=模型@渠道;对外名2=模型`,`@渠道` 省略则自动选。列表与请求解析用同一份;与真实 id / 自动别名撞车、目标不存在、渠道不持有该模型时该段跳过并记 ERROR。留空 = 不启用。可热更 | -| `MAX_INFLIGHT_PER_CREDENTIAL` | `0` | 单账号在途请求上限(跨渠道全局):并发占满的号不再参与选号;`0` = 不限。租约请求结束释放,另有 600s TTL 兜底漏释放 | -| `METRICS_ENABLED` | `true` | 是否暴露 `GET /metrics`(Prometheus text 格式,**不鉴权**,与 `/healthz` 同口径);`false` 关闭(返回 404)。可热更 | -| `REQUEST_LOG_ENABLED` | `true` | 请求级结构化日志(logfmt):每个业务请求(`/api/*`、`/v1/*`、`/authorize`)一行 `event=request request_id=… method=… route=… status=… duration_ms=…`。路由用模板(`/api/credentials/{credential_id}`)不用实际 ID;**不写 query string、请求体、用户名**,靠 `request_id` 关联 `usage_events`。5xx 记 WARNING、其余 INFO;静态资源与探针不记(uvicorn 自带 access log 已关闭)。`false` 关闭请求级日志。可热更 | -| `FORCE_DISABLE_THINKING` | `false` | 出站剥离思考内容:开启后响应里不再有 `reasoning_content` / Anthropic `thinking` 块 / Responses reasoning item,**只过滤响应、不改请求体**(上游仍照常思考)。三个出口、流式与非流式一致生效。可热更 | -| `REASONING_EFFORT_MAP` | `""` | 出站 `reasoning_effort` 档位映射:格式 `渠道=from:to,from:to;*=from:to`(渠道取 `KNOWN_PROVIDERS`,`*` 兜底);目标写 `none` = 删除该键、走渠道默认。只改写客户端**显式**给的档位,渠道自身 `setdefault` 补的默认档不受影响。留空 = 原样透传。可热更 | -| `ANTHROPIC_THINKING_EFFORTS` | `""` | Anthropic 入站 `thinking.budget_tokens` → 上游 `reasoning_effort`:格式 `预算:档位,预算:档位,disabled:档位`(`disabled` 匹配 `thinking.type=="disabled"`;其余取「阈值 ≤ budget_tokens 中最大的那一档」,低于最小阈值不映射)。留空 = 完全不映射(`thinking` 照旧忽略)。可热更 | +| `CONTEXT_COMPRESS_SAFETY_RATIO` | `0.95` | 压缩预算占模型上限的比例 | +| `UPSTREAM_COMPLETE_TIMEOUT_SECONDS` | `600` | 非流式聚合整体超时;`≤0` 关闭 | +| `STREAM_FIRST_EVENT_TIMEOUT_SECONDS` | `0` | 首事件超时(出帧前可换号)。**默认必须保持关闭**,推荐 60。可热更 | +| `STREAM_IDLE_TIMEOUT_SECONDS` | `0` | 流内空闲超时(已出帧只断流不换号),推荐 300。可热更 | +| `SHORT_COOLDOWN_WAIT_SECONDS` | `0` | 全池短冷却时等待续上;`0` = 快速失败。可热更 | +| `MODEL_ALIASES` | `""` | 自定义别名;留空不启用。可热更 | +| `MAX_INFLIGHT_PER_CREDENTIAL` | `0` | 单账号在途上限;`0` = 不限。600s TTL 兜底 | +| `METRICS_ENABLED` | `true` | 是否暴露 `/metrics`(不鉴权)。可热更 | +| `REQUEST_LOG_ENABLED` | `true` | 请求级结构化日志(logfmt);不写 query / 请求体 / 用户名。可热更 | +| `FORCE_DISABLE_THINKING` | `false` | 出站剥离思考内容,只过滤响应不改请求。可热更 | +| `REASONING_EFFORT_MAP` | `""` | 出站档位映射 `渠道=from:to;*=from:to`(渠道取 `KNOWN_PROVIDERS`);目标写 `none` = 删键。可热更 | +| `ANTHROPIC_THINKING_EFFORTS` | `""` | Anthropic `thinking.budget_tokens` → 上游 `reasoning_effort`。可热更 | ### 管理台热更(「任务与配置」页) -上表中带「可热更」语义的 **60 项**可不改 `.env`、不重启,直接在管理台「任务与配置」页修改: +上表中带「可热更」语义的 **60 项**可不改 `.env`、不重启,直接在「任务与配置」页修改(完整列表见该页)。 -`DEFAULT_MODEL`、`MODEL_BLOCKLIST`、`MODEL_ALIASES`、`CONTEXT_COMPRESS_ENABLED`、`CONTEXT_COMPRESS_RESERVE_TOKENS`、`CONTEXT_COMPRESS_MIN_KEEP_MESSAGES`、`CONTEXT_COMPRESS_SAFETY_RATIO`、`MODEL_FALLBACK_GROUPS`、`STREAM_FIRST_EVENT_TIMEOUT_SECONDS`、`STREAM_IDLE_TIMEOUT_SECONDS`、`SHORT_COOLDOWN_WAIT_SECONDS`、`QUOTA_EXPIRY_WINDOW_SECONDS`、`QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS`、`CONVERSATION_STICKY_SECONDS`、`MAX_INFLIGHT_PER_CREDENTIAL`、`GROWTH_IRREVERSIBLE_ACTIONS`、`GROWTH_INTERVAL_MINUTES`、`REFRESH_INTERVAL_MINUTES`、`QUOTA_PROBE_MINUTES`、`MODEL_CATALOG_MINUTES`、`OPENROUTER_CATALOG_MINUTES`、`USD_CNY_RATE`、`CODEBUDDY_CHAT_MIN_INTERVAL`、`CODEBUDDY_INTL_CHAT_MIN_INTERVAL`、`ZEN_CHAT_MIN_INTERVAL`、`KILO_CHAT_MIN_INTERVAL`、`QODER_CHAT_MIN_INTERVAL`、`CODEARTS_CHAT_MIN_INTERVAL`、`CODEARTS_MAX_CONCURRENCY`、`CODEARTS_REQUEST_WINDOW_SECONDS`、`PACER_MIN_SECONDS`、`PACER_MAX_SECONDS`、`ACTIVITY_REPORT_ENABLED`、`ACTIVITY_REPORT_HOUR`、`ALERT_ENABLED`、`ALERT_WEBHOOK_URL`、`ALERT_INTERVAL_MINUTES`、`ALERT_SILENCE_MINUTES`、`ALERT_FOR_TICKS`、`ALERT_CLEAR_TICKS`、`ALERT_POOL_READY_MIN`、`ALERT_TASK_FAILURES`、`ALERT_TOKEN_EXPIRY_HOURS`、`ALERT_ERROR_RATE_THRESHOLD`、`ALERT_ERROR_RATE_MIN_REQUESTS`、`ALERT_ERROR_RATE_WINDOW_MINUTES`、`BACKUP_INTERVAL_MINUTES`、`BACKUP_KEEP`、`UPSTREAM_STATUS_ENABLED`、`UPSTREAM_STATUS_MINUTES`、`MODEL_PROBE_MINUTES`、`METRICS_ENABLED`、`REQUEST_LOG_ENABLED`、`FORCE_DISABLE_THINKING`、`REASONING_EFFORT_MAP`、`ANTHROPIC_THINKING_EFFORTS`、`CHECKIN_TIMES`、`CHECKIN_JITTER_MINUTES`、`LOG_BUFFER_LINES`。 - -要点:**优先级 `DB 覆盖值 > .env`**——改过后 .env 对该项不再生效,页面标「DB 覆盖」,点「恢复默认」删掉覆盖行才回落 .env。值存 `runtime_settings` 表(纯 key/value),新增可热更项无需迁移;白名单外的 key、非法类型 / 越界值写入前即拒。启动期项(`APP_SECRET` / `PORT` / `DATA_DIR` / `USERS_FILE` / `OPENROUTER_MODELS_URL` / 上游端点白名单)**不在**白名单。接口 `GET /api/settings` 读快照、`PUT /api/settings` 写(admin + CSRF),传 `null` 恢复默认。该页同时展示**后台任务运行态**,页面按 tab 组织;运行态是**进程内**的(`GET /api/tasks`,admin,页面每 30 秒刷新),重启归零。 +要点:**优先级 `DB 覆盖值 > .env`**——改过后 .env 对该项不再生效,页面标「DB 覆盖」,点「恢复默认」删掉覆盖行才回落 .env。值存 `runtime_settings` 表,新增可热更项无需迁移;白名单外的 key、非法类型 / 越界值写入前即拒。启动期项(`APP_SECRET` / `PORT` / `DATA_DIR` / `USERS_FILE` / `OPENROUTER_MODELS_URL` / 上游端点白名单)不在白名单。接口 `GET /api/settings` 读快照、`PUT /api/settings` 写(admin + CSRF),传 `null` 恢复默认。该页同时展示后台任务运行态(`GET /api/tasks`,进程内,30 秒刷新)。 ## 部署注意 -- **挂载目录属主**:容器内以 uid 1001(`appuser`)运行,`./data` 与 `./secrets` 必须可写/可读,否则 SQLite 打不开: +- **挂载目录属主**:容器内以 uid 1001(`appuser`)运行,`./data` 与 `./secrets` 必须可写 / 可读: ```bash mkdir -p data secrets && sudo chown -R 1001:1001 data secrets ``` -- **时区**:镜像默认 `TZ=Asia/Shanghai`,`.env` 里的 `TZ` 会透传进容器(compose 已声明该变量)。签到 / 额度周期按「今天 9 点」判断,务必与宿主一致;基础镜像已装 `tzdata`,否则 `TZ` 会被静默忽略、退回 UTC。 - -- **端口**:compose 里 `PORT` 同时决定容器内监听端口与宿主机映射端口,改一个即可(如 `PORT=9000` → 宿主 9000、容器内也监听 9000)。健康检查同步跟随,无需手工改。 - -- **改代码后重建镜像**:compose 同时声明 `build: .` 与 `image:`,`docker compose up -d` **不带 `--build`** 会直接用本地已存在的 `ghcr.io/robbsluo/coding2api:latest`,跑的是旧代码。改 `src/` 或前端后务必: - - ```bash - docker compose up -d --build - ``` +- **时区**:镜像默认 `TZ=Asia/Shanghai`,`.env` 里的 `TZ` 会透传。签到 / 额度周期按「今天 9 点」判断,务必与宿主一致。 +- **端口**:compose 里 `PORT` 同时决定容器内监听端口与宿主机映射端口。 +- **改代码后重建镜像**:`docker compose up -d` 不带 `--build` 会用本地已有镜像,跑旧代码。改 `src/` 或前端后务必 `docker compose up -d --build`。 ### 升级后必须重启后端 -**改动 `src/` 后必须重启进程,否则出现「新前端 + 旧后端」错配。** 典型症状:管理台页面能打开(前端产物本就是静态文件),但调用新端点全部失败——旧后端没有该路由,未匹配的 `/api/*` 按约定返回 JSON `404`,前端当成通用失败,弹出与真实原因无关的提示。前端不需重启(后端每次请求现读 `web/dist`),**后端不是**——进程管理器只在进程**退出**时重新拉起,不监听源码变化。 +**改动 `src/` 后必须重启进程**,否则出现「新前端 + 旧后端」错配:页面能打开,但新端点全部 404。前端不需重启(后端每次请求现读 `web/dist`),后端不是。 ```bash docker compose up -d --force-recreate # Docker / compose sudo systemctl restart coding2api # systemd ``` -**确认升级已生效**(先查版本,再查路由): +确认升级已生效: ```bash -# 1) schema 版本已迁移(期望 29)且账号已导入 sqlite3 data/coding2api.sqlite3 "PRAGMA user_version; SELECT username, role, enabled FROM users;" -# 2) 路由存在:期望 401(未登录),404 = 旧后端进程 -curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/users +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/users # 期望 401;404 = 旧后端 ``` ### 前端产物与后端在同一端口 -后端会把前端产物放在 `/` 直接服务(`src/webapp/static.py` 每次请求现读磁盘),因此**只改前端时构建完刷新即可,无需重启后端**;`localhost:5173` 是 Vite 开发态(HMR),两者可并存。**这条只对前端成立**——改了 `src/` 下后端代码必须重启进程。 - -`scripts/build-web.sh` 是构建入口,会处理「依赖未装」与「node 不在 PATH」两类常见情况: +后端把前端产物放在 `/` 直接服务(每次请求现读磁盘),**只改前端时构建完刷新即可**。`localhost:5173` 是 Vite 开发态,两者可并存。 ```bash ./scripts/build-web.sh # 仅在产物过期时构建 ./scripts/build-web.sh --force # 无条件重建 ``` -> **`web/.npmrc` 不能删,也不能只在本地改**:它把 `auto-install-peers` 关掉了。`@lobehub/icons` 把 `@lobehub/ui` / `antd` 声明为 peer,自动安装会拖进 400+ 个用不到的包(本项目只 import 它的静态图标组件,不引用 `@lobehub/ui`);关掉后 lock 里少一半包,`pnpm audit --prod` 也是 0 漏洞。代价是**真正需要的 peer 必须显式声明**(`@testing-library/dom` 就是这么加进 `devDependencies` 的)。该设置与 lockfile 里的 `settings.autoInstallPeers` 强耦合,所以 `Dockerfile` 也必须 `COPY web/.npmrc`,否则 `--frozen-lockfile` 直接报 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`。 +> **`web/.npmrc` 不能删**:它关掉了 `auto-install-peers`(`@lobehub/icons` 会把 `@lobehub/ui` / `antd` 拖进来)。该设置与 lockfile 的 `settings.autoInstallPeers` 强耦合,`Dockerfile` 也必须 `COPY web/.npmrc`,否则 `--frozen-lockfile` 报 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`。 -> **部署相关操作记在本地文档**:当前开发机的进程管理方式、路径与工具版本等见 `docs/local-environment.md`。该目录已在 `.gitignore` 中,**克隆仓库的人不会有这个文件**。 +> **部署相关操作记在本地文档**:进程管理方式、路径与工具版本见 `docs/local-environment.md`(已 gitignore)。 ## 公网入口(VPS + frp 反向隧道) -家宽在 CGNAT 后没有可入站地址,公网入口只能由内网侧**主动连出**建隧道。 -Cloudflare Tunnel 免费版会把大陆流量绕到洛杉矶边缘(实测端到端 1.1–2.4s、丢包约 60%), -换成「境外 VPS 做入口 + frp 隧道回源」后,端到端降到 80–150ms。**只改入站链路**: -出站到上游仍走本机 `PROVIDER_PROXIES`,不受影响。 +家宽在 CGNAT 后没有可入站地址,公网入口只能由内网侧主动连出。Cloudflare Tunnel 免费版会把大陆流量绕到洛杉矶(实测 1.1–2.4s、丢包约 60%);「境外 VPS 做入口 + frp 隧道回源」可降到 80–150ms。只改入站链路,出站仍走 `PROVIDER_PROXIES`。 ``` 客户端 ──443/TLS──> VPS(nginx) ──frp 隧道──> 家中 Mac:8000 ``` -产物在 `deploy/frp/`(frps/frpc 配置)、`deploy/nginx/`(反代 + TLS)、 -`deploy/systemd/frps.service`(VPS 侧)、`deploy/launchd/com.coding2api.frpc.plist` -(Mac 侧)。配置格式为 frp v0.71.1 的 TOML。 +产物在 `deploy/frp/`、`deploy/nginx/`、`deploy/systemd/frps.service`、`deploy/launchd/com.coding2api.frpc.plist`(frp v0.71.1 TOML 格式)。 -### VPS 侧(Linux + systemd) +VPS 侧: ```bash sudo apt install -y nginx certbot python3-certbot-nginx -# 从 https://github.com/fatedier/frp/releases 取 linux_amd64 包,解出 frps -sudo install -m 755 frps /usr/local/bin/frps - +sudo install -m 755 frps /usr/local/bin/frps # 从 frp release 解出 sudo useradd --system --no-create-home --shell /usr/sbin/nologin frps -sudo install -d -m 755 /etc/frp -sudo install -m 644 deploy/frp/frps.toml /etc/frp/frps.toml -# 两侧共用的 token(客户端安装时用同一个值) +sudo install -d -m 755 /etc/frp && sudo install -m 644 deploy/frp/frps.toml /etc/frp/ openssl rand -hex 32 | sudo tee /etc/frp/frps-token >/dev/null sudo chmod 600 /etc/frp/frps-token && sudo chown frps:frps /etc/frp/frps-token - sudo install -m 644 deploy/systemd/frps.service /etc/systemd/system/ sudo systemctl daemon-reload && sudo systemctl enable --now frps - -# nginx:把 __TUNNEL_DOMAIN__ 换成实际域名后安装 sed 's/__TUNNEL_DOMAIN__/ai-api.example.com/g' deploy/nginx/coding2api.conf \ | sudo tee /etc/nginx/sites-available/coding2api.conf >/dev/null sudo ln -sf /etc/nginx/sites-available/coding2api.conf /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx -sudo certbot --nginx -d ai-api.example.com # 签发证书并自动配置续期 +sudo certbot --nginx -d ai-api.example.com ``` -DNS 侧把域名 A 记录指向 VPS 公网 IP;境外 VPS 无需 ICP 备案。 +DNS 把域名 A 记录指向 VPS 公网 IP;境外 VPS 无需 ICP 备案。 -### 家中 Mac 侧(launchd) +家中 Mac 侧: ```bash -brew install frp # 或自行下载并设置 FRPC_BIN +brew install frp ./scripts/install-frpc.sh --server --token <与 VPS 一致的 token> launchctl print gui/$(id -u)/com.coding2api.frpc | grep -E 'state|pid' -./scripts/install-frpc.sh --uninstall # 卸载 +./scripts/install-frpc.sh --uninstall ``` -`deploy/frp/frpc.toml` 用 frp 原生的 `{{ .Envs.FRP_SERVER_ADDR }}` 模板取值, -仓库里不含任何机器特定值;地址与 token 文件路径由 plist 注入,token 落在 -`secrets/frpc-token`(600,已在 `.gitignore` 中),不进 plist——`launchctl print` -会把 plist 原样打印出来。 +`frpc.toml` 用 `{{ .Envs.FRP_SERVER_ADDR }}` 模板取值;地址与 token 由 plist 注入,token 落在 `secrets/frpc-token`(600,已 gitignore)。 -### `.env` 要同步的三处 +`.env` 要同步三处: | 变量 | 值 | 漏了会怎样 | |---|---|---| -| `ALLOWED_HOSTS` | 追加隧道域名 | 隧道侧所有请求 400 `invalid host header` | -| `PUBLIC_BASE_URL` | `https://<隧道域名>` | TRAE 登录回调基址错、https 下不下发 HSTS | -| `HOST` | 保持 `127.0.0.1` | 隧道回源到本机回环即可,**不必**绑 `0.0.0.0` | - -`TRUST_PROXY`:VPS 上恰好一层 nginx,可置 `true`,让 API Key 的 `allowed_ips` -看到真实客户端 IP(nginx 已转发 `X-Forwarded-For`)。若仍需保留局域网直连入口, -保持 `false`——否则同网段可伪造 XFF 绕过白名单。 +| `ALLOWED_HOSTS` | 追加隧道域名 | 隧道侧全部 400 `invalid host header` | +| `PUBLIC_BASE_URL` | `https://<隧道域名>` | TRAE 登录回调基址错、不下发 HSTS | +| `HOST` | 保持 `127.0.0.1` | 隧道回源到本机回环即可 | -> 改完 `.env` 必须重启服务才算生效(见上「部署注意」)。 -> frpc 与 frps 的日志都走 stdout → Mac 侧 `logs/frpc.*.log`(newsyslog 轮转, -> `deploy/newsyslog/coding2api.conf` 已含两条规则)/ VPS 侧 journald。 +`TRUST_PROXY`:VPS 上恰好一层 nginx 可置 `true`,让 Key 的 `allowed_ips` 看到真实 IP。若仍需局域网直连入口,保持 `false`。 ## 日志 -应用只写 **stdout / stderr**,不写文件、不自行轮转:部署形态与采集方式各异,都靠这两个 fd 对接,轮转交给平台工具。因此**日志不会自行停止增长**——按下面对应形态配一次即可。 +应用只写 **stdout / stderr**,不写文件、不自行轮转。 -| 部署形态 | 日志到哪 | 轮转机制 | 需做什么 | +| 部署形态 | 日志到哪 | 轮转 | 需做什么 | |---|---|---|---| -| **Docker / compose** | `docker logs coding2api` | json-file 驱动(compose 配好 `10m × 5`) | **无需操作** | -| **Linux(systemd)** | `journalctl -u coding2api` | journald 自带 | **无需操作**(模板见 `deploy/systemd/`) | -| **Linux(非 systemd)** | 重定向到 `/var/log/coding2api/*.log` | `logrotate` | `sudo cp deploy/logrotate/coding2api /etc/logrotate.d/` | -| **裸跑** | 终端 stderr | 无 | 自己重定向并自备轮转 | +| Docker / compose | `docker logs coding2api` | json-file(10m × 5) | 无需操作 | +| Linux(systemd) | `journalctl -u coding2api` | journald 自带 | 无需操作 | +| Linux(非 systemd) | 重定向到文件 | `logrotate` | `sudo cp deploy/logrotate/coding2api /etc/logrotate.d/` | +| 裸跑 | 终端 stderr | 无 | 自行重定向并轮转 | -级别用 `LOG_LEVEL`(默认 `INFO`)。**审计日志是 INFO 级**,调到 `WARNING` 会把它一并关掉。 +级别用 `LOG_LEVEL`(默认 `INFO`)。**审计日志是 INFO 级**,调到 `WARNING` 会一并关掉。 ### 请求级结构化日志 -除 uvicorn 自带的裸 access log 外,每个业务请求(`/api/*`、`/v1/*`、`/authorize`)额外写一行 logfmt: +每个业务请求(`/api/*`、`/v1/*`、`/authorize`)额外写一行 logfmt: ``` event=request request_id=9f2c… method=POST route=/v1/chat/completions status=200 duration_ms=1234 ``` -- **`route` 是路由模板**(`/api/credentials/{credential_id}`),不是实际路径——资源 ID 不进日志,日志也不会按 ID 无限分叉;未匹配到路由(404、被安全中间件拒掉)时回落为清洗过的原始路径(白名单字符 + 120 字符截断)。 -- **不写 query string、请求体、响应体、请求头、用户名**。query 里可能被客户端塞 token;身份靠 `request_id` 去 `usage_events.request_id` 关联(那里已有 `username` 列),日志系统保留更久、权限更宽,能少放就少放。 -- `request_id` 与响应头 `X-Request-ID` 同值,可双向对照。 -- **级别**:`5xx` 记 WARNING(服务端自己的问题,值得抓),`4xx` 与成功记 INFO(否则 401 探测、拼错路径会把告警日志淹没)。 -- **不记**静态资源(`assets/*.js`)与探针(`/health`、`/healthz`、`/metrics`):探针已有 `/healthz` 计数与 metrics 覆盖,静态资源量级大且无排障价值,记下来只会把业务行挤出视野。 -- **uvicorn 自带 access log 已关闭**(`run()` 传 `access_log=False`):它没有时间戳、没有 request_id、带完整 query string,与上面这层完全重复,只是把同样的请求再写满另一个轮转文件。排查探针/静态资源请用 `/healthz` 计数与 metrics。 -- 开关 `REQUEST_LOG_ENABLED`(默认 `true`,可热更),关掉后不再有请求级日志。 +- `route` 是路由模板(`/api/credentials/{credential_id}`),资源 ID 不进日志;未匹配到路由时回落为清洗过的原始路径。 +- 不写 query string、请求体、响应体、请求头、用户名;身份靠 `request_id` 去 `usage_events` 关联。 +- `request_id` 与响应头 `X-Request-ID` 同值。 +- 级别:`5xx` 记 WARNING,其余 INFO。 +- 不记静态资源与探针(`/health`、`/healthz`、`/metrics`)。 +- uvicorn 自带 access log 已关闭。 +- 开关 `REQUEST_LOG_ENABLED`(默认 `true`,可热更)。 -> 容器里的 `data/dumps/`(诊断开关 `DUMP_REQUEST_BODIES=true` 写入)不在 docker 日志体系内,由应用自行保持最多 200 份。 +> 容器里 `data/dumps/`(`DUMP_REQUEST_BODIES=true` 写入)不在 docker 日志体系内,应用自行保持最多 200 份。 ## 开发 @@ -915,11 +825,11 @@ pnpm build | 文档 | 内容 | |---|---| -| [`PROPOSAL.md`](PROPOSAL.md) | 目标与非目标、上游可行性核实、架构、数据模型、安全边界 | +| [`PROPOSAL.md`](PROPOSAL.md) | 目标与非目标、上游可行性、架构、数据模型、安全边界 | | [`TECHNICAL.md`](TECHNICAL.md) | 技术栈、模块规格、Provider 协议、请求时序、测试策略 | -| [`diagrams/coding2api-architecture.html`](diagrams/coding2api-architecture.html) | 系统架构图(浏览器打开) | -| [`diagrams/coding2api-request-sequence.html`](diagrams/coding2api-request-sequence.html) | 请求主链路时序图(浏览器打开) | -| [`diagrams/coding2api-credential-lifecycle.html`](diagrams/coding2api-credential-lifecycle.html) | 凭证调度状态机(浏览器打开) | +| [`diagrams/coding2api-architecture.html`](diagrams/coding2api-architecture.html) | 系统架构图 | +| [`diagrams/coding2api-request-sequence.html`](diagrams/coding2api-request-sequence.html) | 请求主链路时序图 | +| [`diagrams/coding2api-credential-lifecycle.html`](diagrams/coding2api-credential-lifecycle.html) | 凭证调度状态机 | ## 授权协议 diff --git a/TECHNICAL.md b/TECHNICAL.md index 37a13d67..c162daeb 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -1,54 +1,55 @@ # Coding2API 技术方案 -PROPOSAL.md 定方向,本文档定实现。描述**当前代码的现状**,不含历史沿革。 -## 1. 技术栈定稿 +PROPOSAL.md 定方向,本文定实现。只描述**当前代码现状**。 + +## 1. 技术栈 | 层 | 选型 | 版本 | 说明 | |---|---|---|---| -| 运行时 | Python | 3.12(`requires-python >=3.12`;CI 3.12,Dockerfile 运行时基础镜像 3.14-slim) | | -| 包管理 | uv(venv + pyproject.toml + uv.lock) | 锁定版本见 `uv.lock` | | +| 运行时 | Python | 3.12(`requires-python >=3.12`;CI 3.12,Dockerfile 3.14-slim) | | +| 包管理 | uv(venv + pyproject.toml + uv.lock) | 见 `uv.lock` | | | Web | FastAPI + Uvicorn | 0.143.3 / 0.54.0 | | | 配置 | pydantic-settings | 2.16.0 | `Settings` 为 frozen | -| HTTP | httpx 双客户端(流式/短请求分离);`httpx[socks]` 提供按渠道 SOCKS5 代理 | 0.28.1 / socksio | 共享 `trust_env=False` | +| HTTP | httpx 双客户端(流式/短请求分离);`httpx[socks]` 提供按渠道 SOCKS5 | 0.28.1 / socksio | 共享 `trust_env=False` | | 数据库 | 标准库 sqlite3(WAL)+ 手写 SQL | 内置 | | -| 加密 | cryptography Fernet(凭证列) | 50.0.2 | | -| 测试 | pytest + pytest-cov + respx + 文件化 fixture | pytest 9.1.1 / pytest-cov 7.1.0 / respx 0.23.1 | | -| 前端 | React 19 + Tailwind 4 + shadcn/ui + @lobehub/icons + Vite 8 + TypeScript 7 | 实例版本见 `web/package.json` | | +| 加密 | cryptography Fernet | 50.0.2 | | +| 测试 | pytest + pytest-cov + respx + 文件化 fixture | 9.1.1 / 7.1.0 / 0.23.1 | | +| 前端 | React 19 + Tailwind 4 + shadcn/ui + @lobehub/icons + Vite 8 + TypeScript 7 | 见 `web/package.json` | | -> 上表是选型定稿;小版本随依赖漂移,实际以 `pyproject.toml` / `uv.lock` / `web/package.json` 为准;仅主版本或兼容边界(`requires-python`、Node 主版本)变化时回改本表。 +> 小版本随依赖漂移,以 `pyproject.toml` / `uv.lock` / `web/package.json` 为准;仅主版本或兼容边界变化时回改本表。 -## 2. 目录结构与模块规格 -按目录分组,行内列出该目录全部文件;描述按「/」与文件顺序对应。 +## 2. 目录结构与模块 +行内列出该目录全部文件;职责按「/」与文件顺序对应。 | 路径 | 文件 | 职责 | |---|---|---| | `pyproject.toml` | — | uv 项目;`[tool.pytest.ini_options]` 设 coverage 目标 | | 仓库根 | `Dockerfile`、`docker-compose.yml` | compose build context 依赖根目录 | -| `src/` | `main.py` `config.py` `runtime_settings.py` `version.py` | FastAPI 组装·lifespan·路由挂载(只做接线)/pydantic-settings 全部 env + `live()` 归一化标量·取值器/运行时配置覆盖层(DB 覆盖 > env + 白名单校验 + snapshot)/版本号(`pyproject.toml` 唯一真源,读不到回落常量) | -| `src/webapp/` | `limits.py` `security.py` `request_id.py` `access_log.py` `log_buffer.py` `handlers.py` `logging.py` `static.py` | 请求体上限 ASGI 中间件(登录 8KB / 其余 16MB)/Host 白名单 + 安全响应头(CSP·nosniff)/请求 ID ASGI 中间件(最外层,任意响应含 413/400 都带 `X-Request-ID`,并设置会话标识 contextvar)/请求级 logfmt 日志/日志环形缓冲 + 脱敏白名单 `redact_or_drop`(整行丢弃)+ root handler 挂/摘/异常 → HTTP 响应(稳定机器可读错误码)/root logger 配置/前端产物定位 + SPA catch-all(排除 `/api`、`/v1`) | -| `src/db/` | `schema.sql` `conn.py` `migrate.py` `repo.py` `limits.py` `crypto.py` `backup.py` | DDL 定稿(只加不改)/线程本地连接 + WAL + busy_timeout + `transaction()`/启动执行 `schema.sql` + `_MIGRATION_COLUMNS`·`_MIGRATION_DROPS`·`_MIGRATION_INDEXES`(**补列之后**才建索引,否则老库 `no such column`)/凭证·API Key·用户·审计·告警·积分流水持久化(手写 SQL)/凭证限额护栏配置 CRUD + 当日用量聚合/Fernet 加解密/备份·恢复核心(`VACUUM INTO` 一致性快照 + `integrity_check` + 0600 + 保留 N 份,任务与 `scripts/` 共用) | -| `src/auth/` | `users.py` `bootstrap.py` `session.py` `api_key.py` `csrf.py` `throttle.py` `rbac.py` `access.py` | DbUserStore + UsersFileStore(users.txt 导入)/启动引导(导入 → `ADMIN_USERNAMES` 提权 → 无活跃 admin 报错)/会话 Cookie 签发校验(手写 HMAC,payload 带 `session_epoch`)/`sk-` 生成 + SHA-256 摘要 + 常量时间校验/写操作 CSRF/登录限流(三级窗口 + PBKDF2 并发上限)/三角色 `admin`·`operator`·`viewer`/来源 IP 与模型白名单(纯函数) | -| `src/provider/` | `base.py` `proxy.py` `naming.py` `token_expiry.py` `openai_chunk.py` `fixtures/` | Provider 协议·Event·ErrKind·Quota·HealthScore·异常族/按渠道出站代理(解析 `PROVIDER_PROXIES` + `build_client`)/模型名归一(八渠道三字段)/到期提取(`expires_at` → JWT `exp`)/标准 OpenAI chunk 共享原语(choices 校验 + 空 tool_call 判定)/真实样本(`codebuddy/*.sse`、`trae/*.sse`) | -| `src/provider/codebuddy/` | `client.py` `intl.py` `events.py` `credential.py` `headers.py` `oauth.py` `checkin.py` `activity.py` `growth.py` `growth_runner.py` `refresh.py` | 上游 HTTP + SSE 流 + 额度探测 + 内容风控指纹清洗/国际版子类(仅覆渠道 id 与能力 None)/OpenAI 风格 SSE → Event + 状态与业务码分类/凭证类型与解析/上游技术常量与请求头/设备码轮询/签到 + 连续天数/活跃上报协议层/成长中心协议层(15 端点)/成长中心编排(7 类领取的顺序与失败判定)/token 刷新 + 多账号切换 | -| `src/provider/trae/` | `client.py` `events.py` `credential.py` `pricing.py` `backfill.py` `callback.py` | SOLO 上游 + 额度探测/自定义 SSE → Event/凭证解析(嵌套与扁平)+ 原子写回 + 签到设备号/单请求积分推算(上游 `token_usage` 不给积分)/历史明细推算积分回填与重算/登录 URL 构造 + 回调解析 | -| `src/provider/zen/` | `client.py` `events.py` | 上游 + 免费层门禁伪装 + 注入工具回包过滤 + `ZenProvider`/标准 OpenAI SSE → Event(无私有信封) | -| `src/provider/kilo/` | `client.py` `events.py` | 上游(标准 OpenAI,无门禁)+ `isFree` 过滤(不探活)/标准 OpenAI SSE → Event(`delta.reasoning` 思考通道) | -| `src/provider/qoder/` | `cosy.py` `client.py` `events.py` `credential.py` `auth.py` | COSY(自定义 Base64、AES/RSA、签名与会话头缓存)/上游 + 信封 SSE 解包 + 额度·签到/信封 SSE → 内层 chunk → Event + 状态与业务码分类/凭证解析(realm·组织·到期)/设备码 PKCE 登录(start/poll) | -| `src/provider/codearts/` | `signer.py` `dpop.py` `client.py` `events.py` `credential.py` `auth.py` `oauth.py` | 华为云 SDK-HMAC-SHA256(纯函数,便于固定向量测试)/ES256 P-256 DPoP JWS(低 S 归一化)/上游 + 私有 SSE 还原 + 额度(积分)探测 + 模型目录/SSE → 增量 Event(OpenAI chunk 与累计全文双形状)/凭证解析(AK/SK/STS/DPoP/refresh)/OAuth2 PKCE 登录/回调链接粘贴换 token 适配 | -| `src/provider/officeace/` | `signer.py` `client.py` `events.py` `credential.py` `oauth.py` | 华为云双签名(V11 派生密钥 + SDK-HMAC,纯函数便于固定向量测试)/上游 + 标准 OpenAI SSE + 积分·签到·费率(V11 签名)/错误分类(伪 200 · 81004/81009 模型级 · 81111/81114 模型级限流)+ 订阅积分解析 + `max_tokens` 预算改写/两套凭据(不过期 Basic app key / 约 2h 临时 AK-SK)与一次性 refresh 轮转/云端授权码轮询登录(PKCE + DPoP + 开通校验) | -| `src/engine/` | `scheduler.py` `executor.py` `limits.py` `inflight.py` `affinity.py` `continuation.py` `compress.py` `model_resolver.py` `sse.py` | 选号 + 冷却状态机 + pin(纯逻辑,操作 Candidate)/执行 + 模型目录收窄 + 轮换重试 + fallback 链 + 统计埋点/凭证限额护栏判定 + 选号前快照缓存/凭证在途租约(进程内计数 + TTL 兜底 + 选号前过滤)/会话粘性/截断续写/上下文压缩裁剪(纯函数)/`"glm-5.2"`·`"glm-5.2@trae"`·`auto` → 候选集 + fallback 组解析/SSE 帧解析 + 行长上限 | -| `src/compat/` | `openai/{request,response,errors}.py` `responses/{request,response}.py` `anthropic/{request,response}.py` | ChatRequest 校验·chunk 聚合·OpenAI error shape/Responses 出口(Codex CLI 子集)/Anthropic Messages 出口(Claude Code 子集) | -| `src/tasks/` | `pacer.py` `quota_probe.py` `checkin.py` `growth.py` `activity.py` `refresh.py` `retention.py` `backup.py` `alerting.py` `upstream_status.py` `model_probe.py` `status.py` `runner.py` | 全局节流器(随机区间 + 按桶并发上限 + 滑动窗口)/启动立即一轮 + 每 `QUOTA_PROBE_MINUTES`/签到:`CHECKIN_TIMES` 留空时全天每 10 分钟一轮(旧行为),配了时点则按「时点 − 抖动」窗口判定、轮询周期自动收到 60 秒,成功即封账(支持渠道回填 `seal_until`)/成长中心(仅 CB)落 `growth_events`/活跃上报(CB 与 CB 国际版,默认关闭)/每 `REFRESH_INTERVAL_MINUTES` 预刷新/每 5 分钟小时汇总重算 + 90 天前明细清理/每 `BACKUP_INTERVAL_MINUTES` 备份主库(`VACUUM INTO`,落线程池)/运维告警(四类规则 + webhook + 落库)/上游状态页聚合(并发抓各源 → 原子替换内存快照,单源失败只记 `error`、整轮永不抛)/模型主动探活(只探 blocked 负缓存,三态判定,成功即清负缓存)/任务清单 `TASK_SPECS` + 进程内运行态/后台任务调度,接入应用生命周期 | -| `src/stats/` | `backfill.py` `collector.py` `live.py` `query.py` `csv.py` | 历史成本一次性回填与重算(`scripts/backfill_cost.py` 复用)/`usage_events` 写入(脱敏)+ 小时汇总双写·重算 + 事务提交后 `on_record` 广播/进程内实时广播中心(有界缓冲、订阅上限)/`overview`·`by-*`·`timeline`·`model-timeline`·`events`(含钻取过滤与导出取数)/CSV 导出纯函数(列定义 + 公式注入防护 + 行编码) | -| `src/` 顶层 | `sorting.py` `benchmarks.py` `pricing.py` `model_match.py` `request_context.py` `upstream_status.py` | 列表排序 `sort`/`order` 白名单解析(db 与 api 共用)/OpenRouter 单一源:刊例价 + 明细元数据 + 能力指数 + 落盘快照/单请求成本估算(USD→CNY)/本地模型名 ↔ OpenRouter 条目匹配(仅 benchmarks·pricing 用)/请求 ID 的 contextvar + 白名单校验 + 自生成(中间件设置、统计写入现读)/上游状态页聚合(`SOURCE_SPECS` + 四种解析器 + 并发抓取 + 内存快照 store) | -| `src/api/` | `deps.py` `endpoints.py` `chat.py` `responses.py` `messages.py` `context.py` `models.py` `model_merge.py` `model_catalog.py` `balance.py` `authorize.py` `admin_credentials.py` `admin_keys.py` `admin_settings.py` `admin_users.py` `admin_audit.py` `admin_alerts.py` `admin_badges.py` `admin_upstream.py` `admin_model_catalog.py` `admin_model_availability.py` `logs.py` `activate.py` `admin_stats.py` `admin_auth.py` `playground.py` `streaming.py` `metrics.py` | Services 容器 + 鉴权·会话·CSRF 依赖/三个 /v1 出口共用的 `dispatch_compat` 骨架/`POST /v1/chat/completions`·`/v1/responses`·`/v1/messages`/请求入口的上下文压缩接线/`GET /v1/models`(按凭证加载 + SWR + 缓存·TTL + 落盘 + 黑名单 + 白名单)/模型归一·合并·命名·对外 id 消歧·别名表(纯函数)/模型目录落盘快照 + 别名预热/`GET /v1/user/balance`/`GET /authorize`(TRAE 回调落点)/凭证 CRUD·toggle·pin·probe·checkin·成长·账号切换/API Key CRUD(渠道绑定 + 双白名单 + 到期)/`GET`/`PUT /api/settings` + `GET /api/tasks` + `POST /api/tasks/{key}/run`/用户管理/审计查询/告警回看 + 人工标记恢复(`GET /api/alerts`、`POST /api/alerts/{id}/resolve`)/侧栏角标聚合(`GET /api/nav-badges`,按角色裁剪 payload)/上游状态聚合只读回看(`GET /api/upstream-status`)/模型目录只读查看 + 模型级失败聚合 + 探活结论·立即探活·人工接管/一次性令牌激活流/统计查询/登录·登出·会话·自助改密 + 上游登录 start·poll·complete·cancel/会话调试端点(无需 API Key)/SSE 流包装(长空隙插心跳帧)/`GET /metrics`(Prometheus text 格式) | -| `src/audit/actions.py` | — | 审计动作常量 + 中文标签(含 `alert.resolve`) | -| `web/` | — | React 前端。**`.npmrc` 的 `auto-install-peers=false` 与 `pnpm-lock.yaml` 的 `settings.autoInstallPeers` 强耦合**:关掉是为了不装 `@lobehub/icons` 声明的 `@lobehub/ui`/`antd` peer 子树(400+ 包,本项目只深引用静态图标组件,不引用 `@lobehub/ui`);代价是真实需要的 peer 必须显式进 `devDependencies`(如 `@testing-library/dom`)。`Dockerfile` 必须 `COPY web/.npmrc`,否则 `--frozen-lockfile` 报 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` | -| `deploy/` | — | 各部署形态模板(launchd / newsyslog / logrotate / systemd) | -| `diagrams/` | — | 架构图 / 请求时序图 / 调度状态机(HTML 成品 + 源 JSON) | -| `scripts/` | — | hash_password / create_user / 回填与运维脚本 | +| `src/` | `main.py` `config.py` `runtime_settings.py` `version.py` | 组装·lifespan·路由挂载/pydantic-settings 全部 env + `live()` 归一化/运行时配置覆盖层(DB > env + 白名单 + snapshot)/版本号(`pyproject.toml` 真源) | +| `src/webapp/` | `limits.py` `security.py` `request_id.py` `access_log.py` `log_buffer.py` `handlers.py` `logging.py` `static.py` | 请求体上限 ASGI(登录 8KB / 其余 16MB)/Host 白名单 + CSP·nosniff/请求 ID(最外层,含 413/400)+ 会话 contextvar/请求级 logfmt/日志环形缓冲 + `redact_or_drop`/异常 → 稳定错误码/root logger/SPA catch-all(排除 `/api`、`/v1`) | +| `src/db/` | `schema.sql` `conn.py` `migrate.py` `repo.py` `limits.py` `crypto.py` `backup.py` | DDL(只加不改)/线程本地连接 + WAL + busy_timeout + `transaction()`/迁移(**补列之后**建索引)/持久化手写 SQL/限额 CRUD + 当日聚合/Fernet/备份恢复(`VACUUM INTO` + integrity_check + 0600) | +| `src/auth/` | `users.py` `bootstrap.py` `session.py` `api_key.py` `csrf.py` `throttle.py` `rbac.py` `access.py` | DbUserStore + users.txt 导入/引导(导入 → `ADMIN_USERNAMES` 提权 → 无活跃 admin 报错)/会话 HMAC(含 `session_epoch`)/`sk-` 摘要 + 常量时间校验/CSRF/登录三级限流 + PBKDF2 并发上限/三角色/IP 与模型白名单(纯函数) | +| `src/provider/` | `base.py` `proxy.py` `naming.py` `token_expiry.py` `openai_chunk.py` `fixtures/` | Provider 协议·Event·ErrKind·Quota·HealthScore/按渠道代理(`PROVIDER_PROXIES`)/模型名归一/到期提取(`expires_at` → JWT `exp`)/OpenAI chunk 共享原语/真实样本 | +| `src/provider/codebuddy/` | `client.py` `intl.py` `events.py` `credential.py` `headers.py` `oauth.py` `checkin.py` `activity.py` `growth.py` `growth_runner.py` `refresh.py` | 上游 + SSE + 额度探测 + 风控指纹清洗/国际版子类(覆渠道 id 与能力 None)/SSE → Event + 分类/凭证解析/常量与头/设备码/签到/活跃上报/成长中心(15 端点)/成长编排(7 类领取)/刷新 + 多账号切换 | +| `src/provider/trae/` | `client.py` `events.py` `credential.py` `pricing.py` `backfill.py` `callback.py` | 上游 + 额度/自定义 SSE → Event/凭证解析 + 原子写回 + 签到设备号/单请求积分推算/历史回填/登录 URL + 回调解析 | +| `src/provider/zen/` | `client.py` `events.py` | 上游 + 门禁伪装 + 注入工具回包过滤/标准 SSE → Event | +| `src/provider/kilo/` | `client.py` `events.py` | 上游(标准 OpenAI)+ `isFree` 过滤(不探活)/SSE → Event(`delta.reasoning`) | +| `src/provider/qoder/` | `cosy.py` `client.py` `events.py` `credential.py` `auth.py` | COSY(自定义 Base64、AES/RSA、签名与会话头缓存)/上游 + 信封解包 + 额度·签到/信封 → chunk → Event/凭证解析/设备码 PKCE | +| `src/provider/codearts/` | `signer.py` `dpop.py` `client.py` `events.py` `credential.py` `auth.py` `oauth.py` | SDK-HMAC-SHA256(纯函数)/ES256 DPoP(低 S)/上游 + 私有 SSE 还原 + 积分额度/SSE → 增量 Event(双形状)/凭证解析/OAuth2 PKCE/回调粘贴换 token | +| `src/provider/officeace/` | `signer.py` `client.py` `events.py` `credential.py` `oauth.py` | 双签名(V11 + SDK-HMAC,纯函数)/上游 + 积分·签到·费率/错误分类 + `max_tokens` 改写/两套凭据 + 一次性 refresh/云端授权轮询(PKCE + DPoP + 开通校验) | +| `src/engine/` | `scheduler.py` `executor.py` `limits.py` `inflight.py` `affinity.py` `continuation.py` `compress.py` `model_resolver.py` `sse.py` | 选号 + 冷却状态机 + pin/执行 + 目录收窄 + 轮换 + fallback + 统计埋点/限额判定 + 快照缓存/在途租约/会话粘性/截断续写/上下文压缩/模型名解析/SSE 帧解析 | +| `src/compat/` | `openai/{request,response,errors}.py` `responses/{request,response}.py` `anthropic/{request,response}.py` | Chat 校验·聚合·error shape/Responses 出口/Anthropic Messages 出口 | +| `src/tasks/` | `pacer.py` `quota_probe.py` `checkin.py` `growth.py` `activity.py` `refresh.py` `retention.py` `backup.py` `alerting.py` `upstream_status.py` `model_probe.py` `status.py` `runner.py` | 全局节流器/额度探测/签到(`CHECKIN_TIMES` 空则每 10 分钟)/成长(仅 CB)/活跃上报/预刷新/小时汇总 + 90 天清理/备份/告警(四规则 + webhook)/上游状态聚合/模型探活/`TASK_SPECS` + 运行态/调度接入 lifespan | +| `src/stats/` | `backfill.py` `collector.py` `live.py` `query.py` `csv.py` | 历史回填/明细写入 + 小时双写 + 提交后广播/进程内实时广播/各类统计查询/CSV(公式注入防护) | +| `src/` 顶层 | `sorting.py` `benchmarks.py` `pricing.py` `model_match.py` `request_context.py` `upstream_status.py` | `sort`/`order` 白名单/OpenRouter 目录(价 + 元数据 + 能力分 + 快照)/成本估算(USD→CNY)/模型名匹配/请求 ID contextvar/状态页聚合 | +| `src/api/` | `deps.py` `endpoints.py` `chat.py` `responses.py` `messages.py` `context.py` `models.py` `model_merge.py` `model_catalog.py` `balance.py` `authorize.py` `admin_credentials.py` `admin_keys.py` `admin_settings.py` `admin_users.py` `admin_audit.py` `admin_alerts.py` `admin_badges.py` `admin_upstream.py` `admin_model_catalog.py` `admin_model_availability.py` `logs.py` `activate.py` `admin_stats.py` `admin_auth.py` `playground.py` `streaming.py` `metrics.py` | Services 容器 + 鉴权依赖/`dispatch_compat` 骨架/三个 /v1 出口/压缩接线/`GET /v1/models`(SWR + 落盘 + 白名单)/归一·合并·别名(纯函数)/目录快照/余额/TRAE 回调/凭证运维/Key CRUD/配置与任务/用户/审计/告警/角标/上游状态/模型目录 + 探活/激活/统计/登录 + 上游登录/Playground/SSE 包装/`/metrics` | +| `src/audit/actions.py` | — | 审计动作常量 + 中文标签 | +| `web/` | — | React 前端。**`.npmrc` 的 `auto-install-peers=false` 与 lock 的 `settings.autoInstallPeers` 强耦合**(避免拉 `@lobehub/ui`/`antd` 子树);`Dockerfile` 必须 `COPY web/.npmrc`,否则 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` | +| `deploy/` | — | launchd / newsyslog / logrotate / systemd 模板 | +| `diagrams/` | — | 架构图 / 时序图 / 状态机(HTML + 源 JSON) | +| `scripts/` | — | 建号、回填、运维脚本 | | `tests/` | — | 按 `src/` 子包镜像 | -## 3. 核心类型定义 +## 3. 核心类型 ### 3.1 中立事件层(`provider/base.py`) ```python class EventKind(StrEnum): @@ -80,7 +81,10 @@ class Usage: credit_estimated: bool = False # credit 是否为本服务推算(True 时展示标 ≈) ``` -**事件名过滤**:TRAE 上游会发 `metadata` / `timing_cost` / `extra_info` / `progress_notice` 等无下游语义事件,`events.py` 的 `KNOWN_EVENT_NAMES` 之外的名字直接跳过。「解析失败不静默」针对的是**已知事件的畸形数据**:归一集内 JSON 非对象 / 字段类型不对仍抛 `UpstreamProtocolViolation`,不退回默认值。**tool_call 分片续块必须保留**:TRAE 上游工具调用按 `index` 分片,首片带 `function_call.name`、后续片只有 `arguments` 增量且**没有 name**。归一为 OpenAI 形状时只丢「无 name **且** arguments 为空」的噪声(`_is_blank_solo_tool_call`);带实际 arguments 的续片保留,交下游 `ToolIndexState` 按 index 合并。若按「无 name 即丢弃」过滤,续片全被吃掉 → 客户端拼出截断 JSON → 工具报错 → 原样重试同轮 → 死循环。CodeBuddy/Zen/Kilo 的 `is_blank_tool_call` 同一判据(`provider/openai_chunk.py`)。 +- **事件名过滤**:TRAE 的 `metadata` / `timing_cost` / `extra_info` / `progress_notice` 等无语义事件,不在 `KNOWN_EVENT_NAMES` 内直接跳过。 +- **解析失败不静默**:已知事件里 JSON 非对象 / 字段类型错 → `UpstreamProtocolViolation`,不退回默认值。 +- **tool_call 续块必须保留**:TRAE 按 `index` 分片,首片带 `function_call.name`,续片只有 `arguments`。只丢「无 name **且** arguments 为空」的噪声(`_is_blank_solo_tool_call`);按「无 name 即丢」会把续片吃光 → 客户端拼出截断 JSON → 死循环。`provider/openai_chunk.py` 的 `is_blank_tool_call` 同一判据。 + ### 3.2 错误分类(决定冷却时长与作用域) ```python class ErrKind(StrEnum): @@ -98,7 +102,7 @@ class ErrKind(StrEnum): MODEL_SCOPED_KINDS = frozenset({MODEL, BLOCKED, CONCURRENCY}) ``` -`MODEL_SCOPED_KINDS` 只写 `credential_model_cooldowns`(见 §6.3),不碰账号级 `cooling_until`,故同账号其他模型仍可选——这是两层冷却的核心:单个模型打满不能连累同账号其他模型。反之账号级冷却出现时会清空该凭证的模型级条目,否则「切模型」能绕过账号级限流。 +`MODEL_SCOPED_KINDS` 只写 `credential_model_cooldowns`(§6.3),不碰账号级 `cooling_until`。反之账号级冷却出现时清空该凭证的模型级条目,否则切模型能绕过账号级限流。 | 上游信号 | CB | TRAE | Kilo | ErrKind | |---|---|---|---|---| @@ -113,15 +117,16 @@ MODEL_SCOPED_KINDS = frozenset({MODEL, BLOCKED, CONCURRENCY}) | 会话失效 | 401/403 | 401 | — | DEAD | | 服务端错误 | 5xx | 5xx | 500 | OTHER | | 请求自身无效 | 400 | 400(含 4001) | 400/404/422 | INVALID | -| 流内错误事件 | SSE error 事件 | `event:error` 业务码 | `{"error":{...}}` 帧 | 同上映射(单一来源:provider 解析时写 `Event.error_kind`,executor 直接消费;缺失回落 OTHER) | +| 流内错误事件 | SSE error 事件 | `event:error` 业务码 | `{"error":{...}}` 帧 | 同上映射(单一来源 `Event.error_kind`,缺失回落 OTHER) | -> **zen / kilo 的 401 例外**:两者都无凭证,`401 Missing API key.` 只说明该模型需要付费 key,故 `classify_status(401)` 归 `INVALID` 而非 `DEAD`,避免一次强制付费模型请求把整条免费渠道硬禁用。 +- **zen / kilo 的 401 → `INVALID`**:两者无凭证,`401 Missing API key.` 只说明该模型要付费 key;归 `DEAD` 会让一次强制付费请求硬禁用整条免费渠道。 +- **kilo 429 与 502/503/504 → `MODEL`**:免费池是 OpenRouter 共享池,报错点名具体模型(`limit_source: upstream_provider_shared_pool`),同时刻其他免费模型仍可用。 +- **CodeArts 临时凭证过期 → `SOFT`**:`HTTP 400` + `APIG.0602 security token has expired`,识别在 `401/403 → DEAD` 之前(`_STS_EXPIRED_MARKERS`)。判 `DEAD` 会让 `RefreshTask` 跳过该凭证,断掉唯一自愈路径。 -> **kilo 的 429 与 502/503/504 归 `MODEL`**:免费池是 OpenRouter 共享池转发,429 报错点名具体模型(`limit_source: upstream_provider_shared_pool`),429 消退后同一模型转 503 `no endpoints available`,两种情况下同一时刻其他免费模型仍可用,属模型级而非账号级。 +**业务码只从 `"code": N` 键值形态提取**(`provider.base.business_codes`):裸搜数字会让 `"code":111020` 命中 `11102`。判定顺序即优先级,`429+6004` 先于 `429`,`402/14018` 先于状态码兜底。 -> **CodeArts 的临时凭证过期归 SOFT**:`HTTP 400` + `APIG.0602 security token has expired`,识别在 `401/403 → DEAD` 之前(标记 `_STS_EXPIRED_MARKERS`),判 `SOFT`(账号级可恢复故障:下一轮预刷新换上新 STS 即自愈)。不能判 `DEAD`——硬禁用会让 `RefreshTask` 跳过该凭证,反而断掉唯一自愈路径。 +**内容风控指纹(11128)**:`400 + 11128 Illegal API invocation from an unapproved channel` → REQUEST。主因是 `system`/`assistant` 正文出现「伪装其他厂商官方客户端」的指纹串(身份声明行、billing 头字段名、git 上下文行)。特征:与凭证无关、确定性复现、只在这两个角色的 `content` 命中(`tool_calls` 与 reasoning 不触发);一旦写进历史,后续每轮都 11128。处置:出站前 `sanitize_channel_markers` 替换为占位符(`CODEBUDDY_SANITIZE_CHANNEL_MARKERS=false` 关闭),客户端历史不受影响;文本块列表逐块处理。清单在 `provider/codebuddy/client.py::CHANNEL_MARKERS`,此处刻意不写原文以免污染阅读本文件的会话。 -**业务码一律从 `"code": N` 键值形态提取**(`provider.base.business_codes`),不搜裸数字:`"code":111020` 含 `11102` 子串,裸匹配会把无关响应判成模型错误。判定顺序即优先级,`429+6004` 必须先于 `429`、`402/14018` 必须先于状态码兜底。**内容风控指纹(11128)**:`400 + 11128 Illegal API invocation from an unapproved channel` 归 REQUEST,主因是**内容风控**——`system`/`assistant` 消息正文出现「伪装其他厂商官方客户端」的指纹串(如 Claude Code 系统提示身份声明行、billing 头字段名、git 上下文行)时整单拒绝。特征:与凭证无关(换号无效)、确定性复现、仅这两个角色的 `content` 命中(`tool_calls` 参数与 reasoning 均不触发);会话一旦把指纹写进历史,后续每轮(含压缩请求本身)都持续 11128。处理:出站前 `sanitize_channel_markers` 把它替换为占位符(`CODEBUDDY_SANITIZE_CHANNEL_MARKERS=false` 关闭),客户端历史不受影响;`content` 为文本块列表时逐块处理。指纹清单在 `provider/codebuddy/client.py::CHANNEL_MARKERS`,此处刻意不写原文以免污染阅读本文件的 agent 会话。 ### 3.3 三态健康度 ```python @dataclass(slots=True) @@ -146,86 +151,114 @@ def health_score(q: Quota | None) -> int | None: return max(0, min(100, round((q.remaining or 0.0) / q.total * 100))) ``` -调度排序:`known DESC > unknown(中性参与)> exhausted(-1)`。`probe_failed` 与「探测到 0」必须区分——Zen/Kilo 虚拟凭证恒返回 `probe_failed=True`(未知,**不是耗尽**)。**CB `remaining` 的取值口径**:个人版额度探测一律取逐包 `CycleCapacityRemainPrecise` 之和,**不得**用响应顶层的 `TotalDosage`——后者是上周期口径(本周期消耗记在 `CycleCapacityUsed`),采用它会出现 `remaining == total`、健康度恒 100、并与到期阶梯 / 额度明细自相矛盾。企业版仍走 `limitNum - credit`(另一接口,无此问题)。 +排序:`known DESC > unknown > exhausted(-1)`。`probe_failed` 与「探测到 0」必须分开——Zen/Kilo 虚拟凭证恒 `probe_failed=True`。**CB 个人版 `remaining` 取逐包 `CycleCapacityRemainPrecise` 之和,不用顶层 `TotalDosage`**:后者是上周期口径,会出现 `remaining == total`、健康度恒 100、与到期阶梯自相矛盾。企业版走 `limitNum - credit`。 + ### 3.4 截断续写 -上游以 `finish_reason == "length"` 结束本轮流时,同凭证自动续写,最多 `AUTO_CONTINUE_MAX`(默认 10,0 关闭)。实现在 `src/engine/continuation.py` 的 `ContinuationStream`:包装上游事件流,截断则追加「已产出正文(含 reasoning)+ 续写指令」重发,并把输出上限两键归零(否则在同一处再次截断),跨轮累计 usage,末端补发一条累计 usage + 最后一轮真实 `finish_reason`(上游整轮未报 usage 时不补发,避免出口把「未上报」误报成 0)。做成事件流包装器而非 executor 内重跑:凭证固定,轮换 / 记账 / 统计零改动。**只实现 `length` 一个判据**:「仅 reasoning 无正文」只在客户端下发 `max_tokens` 时出现,而生产路径只发 `reasoning_effort`、本网关也不做 max 键映射,故该形态不可达;「空正文无工具调用」「代码块未闭合」无证据支撑,均不做——凭空加启发式会把「模型就是想空回」误判成截断。**输出上限键行为**:`max_completion_tokens` 被 CB 上游**完全忽略**,`max_tokens` 才生效(精确截断 + `length`);两键同发时后者胜出;TRAE 对两个键**都不生效**;`enable_thinking: false` 亦被上游忽略(仍产 reasoning 且计入 `max_tokens`)。**`reasoning_effort` 缺失时补 `medium`**:不带该字段时 CB 上游把整段思考以「可见推演」写进 `delta.content`(`delta.reasoning_content` 恒空、`usage.reasoning_tokens` 恒 0),正文里混着自我质疑;带上任意档位即刻恢复独立思考通道。官方 CLI 的 dump 都带该字段,非官方 CLI 客户端不发,是唯一触发面。`stream_chat` 用 `setdefault` 补缺省、**不覆盖客户端显式值**。 +`finish_reason == "length"` 时同凭证续写,最多 `AUTO_CONTINUE_MAX`(默认 10,0 关闭)。实现为 `engine/continuation.py::ContinuationStream`:追加「已产出正文 + 续写指令」重发,输出上限两键归零,跨轮累计 usage,末端补一条累计 usage + 最后一轮真实 `finish_reason`(上游未报 usage 时不补,避免误报 0)。做成事件流包装器而非 executor 重跑:凭证固定,轮换 / 记账 / 统计零改动。 + +只做 `length` 一个判据:「仅 reasoning 无正文」只在客户端下发 `max_tokens` 时出现,而本网关不做 max 键映射,不可达;「空正文」「代码块未闭合」无证据,不做。 + +**输出上限键**:CB 忽略 `max_completion_tokens`、只认 `max_tokens`(精确截断 + `length`);两键同发后者胜。TRAE 两键都不生效。`enable_thinking:false` 亦被 CB 忽略。 + +**`reasoning_effort` 缺失补 `medium`**:不发该字段时 CB 把思考写进 `delta.content`(`reasoning_content` 恒空、`reasoning_tokens` 恒 0)。`stream_chat` 用 `setdefault`,不覆盖显式值。 + ### 3.5 会话粘性键 -(`src/engine/affinity.py`)键优先级 **① 请求体显式键** `conversation_id` > `conversationId` > `prompt_cache_key`(同键名先 `metadata` 对象再请求体顶层)→ **② 请求头会话标识**(`x-session-id` / `x-opencode-session` / `x-session-affinity` / `conversation-id` / `conversation_id`,按此顺序取第一个合法值,须匹配 `[A-Za-z0-9._:-]{1,128}`)→ **③ 消息增量前缀指纹**兜底;头来源经 `request_context` 的 contextvar 传入(`RequestIdMiddleware` 在入口设置、请求结束复原),不污染中间层签名,键带 `header:` 前缀避免与 body 同值撞键。头排在 body 之后是因为 body 显式键最精确、头次之(客户端声明的会话身份仍强于指纹猜测),排在指纹之前是因为指纹只是启发式。带 `user_id`(顶层或 `metadata` 内)时不派生**前缀兜底**键(头照常生效)(否则同用户的并行对话前缀可能相同,会被误钉到同一凭证)。`prompt_cache_key` 是 OpenAI 官方顶层参数、`metadata.user_id` 是 Anthropic Messages API 官方字段;`conversation_id`/`conversationId`/顶层 `user_id` 非标准键,作兼容探测接受(命中即用、未命中无害)。键里掺入用户名防止不同 API 用户串号(**头值同样掺 username**——头是客户端可写的,不掺就等于允许A用户靠猜头值占用B用户的账号);TTL(`CONVERSATION_STICKY_SECONDS`)内存态,重启后丢粘性只影响一轮选号,条目数上限 512 超量先淘汰过期再按最旧淘汰。 -### 3.6 模型元数据与黑名单 -**模型元数据**:两边上游的模型配置接口都直接给出推理元数据,网关透传为 `/v1/models` 的 OpenAI 额外字段: +(`engine/affinity.py`)优先级:① 请求体 `conversation_id` > `conversationId` > `prompt_cache_key`(同键名先 `metadata` 再顶层)→ ② 请求头 `x-session-id` / `x-opencode-session` / `x-session-affinity` / `conversation-id` / `conversation_id`(首个合法值,须匹配 `[A-Za-z0-9._:-]{1,128}`)→ ③ 消息增量前缀指纹。 + +- 头经 `request_context` 的 contextvar 传入(`RequestIdMiddleware` 设置、请求结束复原),键带 `header:` 前缀避免与 body 撞键。 +- 带 `user_id`(顶层或 `metadata`)时**不派生前缀兜底键**(头照常生效),否则同用户并行对话会被误钉到同一凭证。 +- 键里掺用户名(**头值同样掺**,头是客户端可写输入,不掺就能靠猜占用他人账号)。 +- TTL `CONVERSATION_STICKY_SECONDS` 内存态,重启只影响一轮选号;条目上限 512,超量先淘汰过期再按最旧。 +### 3.6 模型元数据与黑名单 | 字段 | CB 来源 | TRAE 来源 | 取值 | |---|---|---|---| | `supports_reasoning` | `supportsReasoning` | `display_config.model_capability`(`reasoning_model`→true、`chat_model`→false),缺失回落 `reasoning_effort_config.support_thinking` | true / false / null | | `default_effort` | `reasoning.effort` | 无对应字段 | CB: `high` / `medium`;TRAE 恒 null | -`supported_efforts`(可接受档位集合)**不实现**:上游只给「默认档位」不给档位清单,凭空枚举 ChatGPT 三档属编造。CB 的 `onlyReasoning`/`canDisableThinking` 同样**不透传**(语义未经核实)。**唯一例外是 Playground 的入参控件**(`web/src/lib/playgroundParams.ts`):它只帮用户拼请求体,不对外声明能力——档位候选固定为 OpenAI 标准三档(`low`/`medium`/`high`),上游声明的 `default_effort` 若不在其中则追加进候选;控件在 `supports_reasoning !== true` 且无 `default_effort` 时**不渲染**,默认选中「不发送」(渠道各自补默认,如 CB 补 `medium`)。三个高级参数(`temperature` / `max_tokens` / `reasoning_effort`)均**留空即不发送该键**,前端校验边界与 `compat/openai/request.py` 对齐(`temperature` ≤ 1000、`max_tokens` 为 1–4e6 的正整数),避免前端放过、后端 400。逐字段补缺(多上游同名模型先到先填、后到只补 None)。**黑名单**(`MODEL_BLOCKLIST`,fnmatch glob,只影响列表展示、直连不受影响)。默认口径是**只展示当前在售的新模型**,三类一律滤掉:① 上游内部/代理模型——`custom_model_*` / `*sub*agent*` / `summary` / `browser_use_*` / `file_search_agent`,以及各渠道的「自动路由」占位 `default` / `default-model` / `fast-model` / `balanced-model` / `primary-model` / `deep-model` / `auto`;② 已被新一代取代的老模型——`glm-4.*` / `glm-5` / `glm-5.0*` / `glm-5.1` / `glm-5.2*` / `glm-5v-*` / `glm-5-turbo`、`kimi-k2.*`、`minimax-m2.5`/`m2.7`/`m3`、`deepseek-v3-*` / `deepseek-v4-*`(`v4.1` 不受影响)、`qwen3.7-*`、`gpt-5.4`/`gpt-5.5`、`hy3*` / `hy4-preview`、`hunyuan-*`、`seed-2.1*` / `seed-code-pro-*` / `seed-evolving`、`qwen3-vl-235b`、`nemotron-3*` / `lfm2*` / `laguna*` / `north-mini-code` / `inkling-small` / `xing4.0-29b-a4` / `openrouter-models-router`;③ 实测不可用的噪音——`glyph-cluster`(Kilo 两次 121s 后 408)、`qwen3.8-flash`(Qoder `qfmodel` 400「Execution failed: null」,CB/TRAE 的 `qwen3.8-max` 不受影响)。在售主力刻意不加:`deepseek-v4.1-flash` / `kimi-k3` / `glm-5.3*` / `mimo-v2.6*` / `qwen3.8-max` / `openpangu-2.0-*`。**按归一后的对外写法匹配**(`_block_names`):原代号、原代号归一键、展示名、展示名归一键四种写法取并集,用户照列表里看到的 `kimi-k3` / `Kimi K3` 写也能生效;保留原代号是因为默认规则带下划线(`custom_model_*`),归一键把下划线换成连字符后不命中;清洗后退化为空的名字不参与匹配。**非模型级问题不进黑名单**:CodeArts `deepseek-v4-*` / `glm-5.3-flash` 的 `InferHub.4291.200 insufficient quota` 与 `TM.00001041` 是账号级,Kilo `poolside/laguna-xs-2.1` / `nemotron-3-nano-omni` 的 429·502 与 `thinkingmachines/inkling-small` 的 `limit_rpd` 日配额都会自行恢复。**两条存疑未加**:Qoder `mmodel`(MiniMax-M2.7)回 inner body `null` 触发协议异常,但与 CB 正常的同名模型合并成一条,滤掉会连带隐藏可用渠道;zen `ling-3.1-flash-free` 两次 429,同样与 Kilo 可用的同名模型合并。**黑名单热更与列表缓存**:`MODEL_BLOCKLIST` 是热更项,但 `list_models` 的缓存只存**未过滤原始表**,过滤在每个出口现做(`_visible()`,命中缓存 / 成功拉取 / 失败兜底三条路径都过);前端保存后显式 `invalidateQueries(["playground-models"])`,黑名单与列表两条缓存都立即生效,不依赖 TTL。**失败也进 TTL(负缓存)**:`model_list_fetched_at` 记**上次尝试**时间(成功或失败都刷新),TTL(300s)内不再打上游——有缓存用缓存,没缓存跳过该渠道。 +- `supported_efforts` **不实现**:上游只给默认档不给清单。CB 的 `onlyReasoning` / `canDisableThinking` 同样不透传(语义未核实)。 +- **唯一例外是 Playground 入参控件**(`web/src/lib/playgroundParams.ts`):档位候选固定 OpenAI 三档(`low`/`medium`/`high`),上游 `default_effort` 不在其中则追加;`supports_reasoning !== true` 且无 `default_effort` 时不渲染,默认「不发送」。三个高级参数留空即不发键,前端校验边界与 `compat/openai/request.py` 对齐(`temperature` ≤ 1000、`max_tokens` 1–4e6)。 +- 逐字段补缺:多上游同名模型先到先填,后到只补 None。 +- **黑名单**(`MODEL_BLOCKLIST`,fnmatch glob,只影响展示,直连不受影响)。默认只展示当前在售新模型,三类滤掉:① 上游内部/代理模型——`custom_model_*` / `*sub*agent*` / `summary` / `browser_use_*` / `file_search_agent`,及各渠道自动路由占位 `default` / `default-model` / `fast-model` / `balanced-model` / `primary-model` / `deep-model` / `auto`;② 已被取代的老模型——`glm-4.*` / `glm-5` / `glm-5.0*` / `glm-5.1` / `glm-5.2*` / `glm-5v-*` / `glm-5-turbo`、`kimi-k2.*`、`minimax-m2.5`/`m2.7`/`m3`、`deepseek-v3-*` / `deepseek-v4-*`(`v4.1` 不受影响)、`qwen3.7-*`、`gpt-5.4`/`gpt-5.5`、`hy3*` / `hy4-preview`、`hunyuan-*`、`seed-2.1*` / `seed-code-pro-*` / `seed-evolving`、`qwen3-vl-235b`、`nemotron-3*` / `lfm2*` / `laguna*` / `north-mini-code` / `inkling-small` / `xing4.0-29b-a4` / `openrouter-models-router`;③ 实测不可用——`glyph-cluster`(Kilo 121s 后 408)、`qwen3.8-flash`(Qoder `qfmodel` 400,CB/TRAE 的 `qwen3.8-max` 不受影响)。在售主力刻意不加:`deepseek-v4.1-flash` / `kimi-k3` / `glm-5.3*` / `mimo-v2.6*` / `qwen3.8-max` / `openpangu-2.0-*`。 +- **按归一后的对外写法匹配**(`_block_names`):原代号、原代号归一键、展示名、展示名归一键四种取并集(默认规则带下划线,归一键把下划线换成连字符后不命中);清洗后为空的名字不参与。 +- **非模型级问题不进黑名单**:CodeArts `InferHub.4291.200 insufficient quota` 与 `TM.00001041` 是账号级;Kilo `poolside/laguna-xs-2.1` / `nemotron-3-nano-omni` 的 429·502 与 `thinkingmachines/inkling-small` 的 `limit_rpd` 会自行恢复。 + ### 3.7 Responses 出口(Codex CLI) -`POST /v1/responses`:`src/api/responses.py`(端点)+ `src/compat/responses/request.py`(入站映射)+ `src/compat/responses/response.py`(出站翻译)。与 `/v1/chat/completions` **共用同一个 executor**,选号 / 冷却 / 轮换 / 记账 / 会话粘性全部零改动;引擎侧唯一改动是 `Executor.stream(..., translator=...)` 注入缝——出口形状由 translator 决定,引擎不再硬编码 chat 形状的 SSE 帧。字段形状全部取自官方 `openai` Python SDK(3.x)由 OpenAPI 生成的类型定义,并用该 SDK 作客户端做端到端契约验证。**入站映射**(Responses → 内部 chat 载荷): +`src/api/responses.py` + `compat/responses/{request,response}.py`。与 chat **共用同一 executor**,唯一改动是 `Executor.stream(..., translator=...)` 注入缝。字段形状取自官方 `openai` SDK(3.x),并用该 SDK 做端到端契约验证。 | Responses | chat | |---|---| -| `instructions` | 首条 `system` 消息 | +| `instructions` | 首条 `system` | | `input` 字符串 | `user` 消息 | -| `input[].type=message`(`input_text`/`output_text` part) | `messages[]`;`developer` 角色改写为 `system` | -| `input[].content[].type=input_image` | chat 的 `image_url` part(`image_url` 直传,`detail` 丢弃);纯文本 part 折成字符串,含图时保留数组 | -| `input[].type=function_call` | assistant 消息的 `tool_calls[]` | -| `input[].type=function_call_output` | `tool` 消息(part 数组可含 `input_image`) | -| `input[].type=reasoning` | assistant 消息的 `reasoning_content`(仅明文部分) | -| `tools[].type=function`(**扁平** `{name, description, parameters}`) | 嵌套 `{type:function, function:{...}}` | -| `tool_choice` 字符串 / `{type:function,name}` | 同名 / 嵌套形式 | -| `max_output_tokens` | `max_tokens`(CB 上游只认这个键) | +| `input[].type=message`(`input_text`/`output_text`) | `messages[]`;`developer` 改写为 `system` | +| `input[].content[].type=input_image` | `image_url` part(`detail` 丢弃);纯文本折成字符串 | +| `input[].type=function_call` | assistant `tool_calls[]` | +| `input[].type=function_call_output` | `tool` 消息(part 可含 `input_image`) | +| `input[].type=reasoning` | assistant `reasoning_content`(仅明文) | +| `tools[].type=function`(扁平) | 嵌套 `{type:function, function:{...}}` | +| `tool_choice` 字符串 / `{type:function,name}` | 同名 / 嵌套 | +| `max_output_tokens` | `max_tokens` | | `reasoning.effort` | `reasoning_effort` | -| `text.format`(`{type,name,description?,schema,strict?}` 扁平) | `response_format`(`text`/`json_object` 直通;`json_schema` 展开成嵌套;缺 name/schema、未知 type 显式 400)——校验委托 `compat/openai/request.py: normalize_response_format`(chat 入站共用同一段,`prefix="text.format"` 只改报错路径) | +| `text.format` | `response_format`(委托 `normalize_response_format`,`prefix="text.format"` 只改报错路径) | | `temperature` / `top_p` / `parallel_tool_calls` / `prompt_cache_key` / `text.verbosity` | 同名透传 | -**出站事件序列**(流式):`response.created` → `response.output_item.added`(message / reasoning / function_call)→ `response.content_part.added`(output_text)→ `response.output_text.delta × N` → `response.output_text.done` → `response.content_part.done` → `response.output_item.done` → `response.completed | response.incomplete`。思考内容用独立 `reasoning` item + `response.reasoning_summary_text.delta/.done`(不混进正文);工具调用用 `function_call` item + `response.function_call_arguments.delta/.done`。**Responses 协议没有 `[DONE]` 哨兵**,终止事件本身就是流结束(`done_sent` 与之对齐,供 executor 区分「正常收尾」与「中途断开」)。流式已完成后再来的 FINISH/ERROR 一律忽略。`finish_reason=length` → `response.incomplete`(`incomplete_details.reason=max_output_tokens`),`content_filter` 同理;其余为 `completed`。流内错误以 `response.failed` 表达,错误码映射成 Codex CLI 认识的取值(`no_healthy_credential` → `server_is_overloaded`)。**刻意接受的取舍**:`include=["reasoning.encrypted_content"]`(Codex CLI 每轮必带)**接受并忽略**——400 会直接打死主客户端;其他 `include` 值才 400。`previous_response_id`(Codex 不发该字段)与 `store=true` 保持 400(本网关无状态)。Codex CLI 按 SSE 的 `event:` 行分派事件,故 `event:` 与 `data.type` 两个字段都必须发;`response.completed.response` 在它那边是强类型解析(`id` 必填、`usage` 含三个 token 字段),故按官方必填字段完整发出。Codex 回传历史里的 `reasoning`/`compaction` 只有密文、没有 chat 等价物,**有意无损丢弃**;其余 Responses 私有 item 类型(`local_shell_call`/`custom_tool_call` 等)显式 400。**验证状态**:以官方 `openai` SDK(流式 / 非流式 / 工具调用)作权威客户端跑通全部契约,并对真实 CB 上游冒烟;**未经真实 Codex CLI 端到端验证**(开发环境无 CLI)。 +**出站序列**(流式):`response.created` → `output_item.added`(message / reasoning / function_call)→ `content_part.added` → `output_text.delta × N` → `output_text.done` → `content_part.done` → `output_item.done` → `response.completed | incomplete`。思考用独立 `reasoning` item(`response.reasoning_summary_text.delta/.done`),工具用 `function_call` item(`response.function_call_arguments.delta/.done`)。**无 `[DONE]` 哨兵**,终止事件即流结束(`done_sent` 对齐)。`finish_reason=length` → `response.incomplete`(`max_output_tokens`),`content_filter` 同理;流内错误 → `response.failed`(`no_healthy_credential` 映射成 Codex 认识的 `server_is_overloaded`)。 + +取舍:`include=["reasoning.encrypted_content"]` **接受并忽略**(400 会打死 Codex CLI),其他 `include` 值 400;`previous_response_id` 与 `store=true` 保持 400(无状态);`event:` 与 `data.type` 双发;回传历史里的 `reasoning`/`compaction` 只有密文,**有意丢弃**;其余私有 item(`local_shell_call`/`custom_tool_call`)显式 400。**未经真实 Codex CLI 端到端验证**(开发环境无 CLI)。 + ### 3.8 Anthropic Messages 出口(Claude Code) -`POST /v1/messages`(流式 + 非流式)与 `POST /v1/messages/count_tokens`,供只走 Anthropic 协议的客户端接入。与 `/v1/responses` 同一模式:新增 `compat/anthropic/{request,response}.py` + `api/messages.py`,**复用同一 executor**。**入站映射**(`request.py`,映射成内部 `ChatRequest.raw`,只实现 chat 子集): -- `system`(字符串或 text 块数组)→ 一条 `system` 消息,置于最前;空则丢弃。 -- `messages[].content` 为字符串时按角色直传;为块数组时:`text` → 文本、`tool_result` → `tool` 消息(`tool_use_id` → `tool_call_id`;其 `content` 内的图片同样归一)、`image` → chat 的 `image_url` part(`source.type=base64` 拼成 `data:{media_type};base64,{data}`、`source.type=url` 直传)、`document` / `source.type=file` → 显式 400(chat 上游无对应表达)。user 的连续块按类型**拆成多条**消息(文本与 tool_result 交替),assistant 的块**合并成一条**(`thinking` → `reasoning_content`、`tool_use` → `tool_calls`、`redacted_thinking` 丢弃)——与上游 chat 的消息模型对齐。**纯文本内容仍折成字符串**(只有含图时才输出内容块数组),故无图请求的映射结果与加图片支持前**逐字节一致**,不会因本次改动击穿会话粘性指纹与上游前缀缓存。 -- `tools[].input_schema` → `function.parameters`;`tool_choice`:`auto`/`none` 直传,`any` → `required`,`tool` → `{"type":"function",...}`;`stop_sequences` → `stop`;`max_tokens`/`temperature`/`top_p` 直传,`top_k` 丢弃(chat 无等价物)。 -- **`cache_control` 原样透传**(默认开启、无开关;落点见 `compat/anthropic/request.py` 的 `_cache_control`):`system` 内容块、user/assistant 内容块、`tool_result`(挂 tool 消息**顶层**,避免造一个空 text 块进上下文)、assistant `tool_calls` 元素顶层、`tools` 元素顶层。**只校验结构**(对象 + 非空字符串 `type`;`ttl` 若给必须字符串),取值不解释——本网关是管道,未知 `type` 交给上游裁决,结构不合法显式 400 而非静默丢弃。**无标记时映射结果逐字节不变**:`_merge_parts` / `_system_content` / `_assistant_message` 只在「全 text 且无 `cache_control`」时折字符串,故会话粘性指纹与上游前缀缓存不受影响。八渠道实测 6 个接受(codebuddy / codebuddy_intl / trae / qoder / codearts / kilo),zen 上游 `FreeTierError` 403 无法验证、OfficeAce 未跑探针(`--plain` 对照同样失败);探针 `scripts/probe_cache_control.py`。 +`POST /v1/messages` + `/v1/messages/count_tokens`,同模式复用 executor。 + +- `system`(字符串或 text 块)→ 首条 `system`;空则丢。 +- `messages[].content` 字符串直传;块数组时 `text` → 文本、`tool_result` → `tool` 消息(`tool_use_id` → `tool_call_id`,内含图片同样归一)、`image` → `image_url`(`base64` 拼 `data:{media_type};base64,{data}`,`url` 直传)、`document` / `source.type=file` → 显式 400。user 连续块按类型**拆多条**,assistant 块**合并一条**(`thinking` → `reasoning_content`、`tool_use` → `tool_calls`、`redacted_thinking` 丢弃)。**纯文本仍折成字符串**,无图请求映射结果与加图片支持前逐字节一致。 +- `tools[].input_schema` → `function.parameters`;`tool_choice` `auto`/`none` 直传、`any` → `required`、`tool` → 嵌套;`stop_sequences` → `stop`;`max_tokens`/`temperature`/`top_p` 直传;`top_k` 丢弃。 +- **`cache_control` 原样透传**(默认开、无开关):`system` 块、user/assistant 块、`tool_result`(挂 tool 消息顶层)、assistant `tool_calls` 元素、`tools` 元素。只校验结构(对象 + 非空 `type`,`ttl` 若给须字符串),取值不解释。无标记时映射逐字节不变。实测 6/8 渠道接受(codebuddy / codebuddy_intl / trae / qoder / codearts / kilo),zen 因 `FreeTierError` 无法验证、OfficeAce 未跑探针(`scripts/probe_cache_control.py`)。 + +**出口翻译**(`response.py`,实现 `StreamSink`):`message_start` → `content_block_start`/`delta`/`stop`(`text_delta` / `thinking_delta` / `input_json_delta`)→ `message_delta`(`stop_reason` + usage)→ `message_stop`。无 `[DONE]`;thinking 块在 `content_block_stop` 前补占位 `signature_delta`(本网关不产真签名)。错误走 `event: error`。 + +**心跳**:`StreamSink.keepalive()` 由出口协议各自实现,`dispatch_compat` 把它交给 `with_keepalive` 作空闲填充帧。Anthropic 返回具名事件 `event: ping` + `data: {"type": "ping"}`(官方规范;只解析 `event:` 行的客户端会忽略注释帧);chat / Responses 返回 SSE 注释帧 `: keepalive`——Responses **不能**改 ping,该协议无此事件类型,严格解析器遇未知事件直接报错。 + +**usage 缓存字段**:内部 `input_tokens` 是 OpenAI 口径(含缓存命中),Anthropic 语义互斥。故命中时 `input_tokens = max(0, prompt_tokens − cached_tokens)` 并另记 `cache_read_input_tokens`;命中为 0/None 不补占位。 + +**鉴权**:先 `x-api-key`(`ANTHROPIC_API_KEY`),空则回落 `Authorization: Bearer`(`ANTHROPIC_AUTH_TOKEN`),共用 `_api_key_principal`。Base URL 填到根,SDK 自拼 `/v1/messages`。`count_tokens` 用本地启发式估算,不转发。 + +**CC Switch 深链**(`web/src/lib/ccSwitch.ts`):`ccswitch://v1/import?resource=provider&…`,`app` = `claude`/`codex`/`opencode`。`endpoint` 形态随 `apiFormat`:`anthropic` 填根不带 `/v1`,`openai_chat`/`openai_responses` 填 `/v1`。深链含 Key 明文,只在用户主动操作时生成(创建成功对话框 / 每行「导入 CC Switch」,后者需粘贴完整 Key,仅存内存)。需 CC Switch v4.0.10+。无 Gemini 出口。 -**出口翻译**(`response.py`,实现 executor 的 `StreamSink` 协议):`message_start` → `content_block_start`/`content_block_delta`/`content_block_stop`(文本 `text_delta`、思考 `thinking_delta`、工具 `input_json_delta`)→ `message_delta`(`stop_reason` + usage)→ `message_stop`。Anthropic 协议**没有 `[DONE]` 哨兵**,`message_stop` 即流结束;thinking 块在 `content_block_stop` 前补一个占位 `signature_delta`(客户端要求,本网关不产真签名)。错误用 `event: error`(`_error_type_for` 把上游错误码映射成 Anthropic 错误类型)。**usage 缓存字段**:内部 `Usage.input_tokens` 是 OpenAI 口径的 `prompt_tokens`(**含**缓存命中),而 Anthropic 语义里 `input_tokens` 与 `cache_read_input_tokens` / `cache_creation_input_tokens` 互斥(`input_tokens` 只计未命中)。故 `_usage_payload` 与 `completion_to_message` 统一拆分:命中时 `input_tokens = max(0, prompt_tokens − cached_tokens)` 并另记 `cache_read_input_tokens = cached_tokens`,不减会让 Claude Code 重复计费;命中为 0/None 时不补占位字段。**鉴权**:`deps.api_key_user_anthropic` 先读 `x-api-key`(Anthropic SDK 的 `ANTHROPIC_API_KEY`),为空再回落 `Authorization: Bearer`(`ANTHROPIC_AUTH_TOKEN`),两者共用 `_api_key_principal`。客户端 Base URL 填到根,SDK 自行拼 `/v1/messages`。`count_tokens` 用与上下文压缩同一套启发式估算本地计数(不转发上游)。**CC Switch 一键导入深链**(前端 `web/src/lib/ccSwitch.ts`):在「API Key 管理」页生成 `ccswitch://v1/import?resource=provider&…` 链接(`app` = `claude`/`codex`/`opencode`,对应三个出口协议),由用户点击触发桌面端导入确认。**endpoint 形态随 `apiFormat` 变**:`anthropic`(Claude Code)填到根、不带 `/v1`(与上面的约定一致),`openai_chat`/`openai_responses` 填到 `/v1`。深链含 Key 明文,故按钮只在用户主动打开、且已给出 Key 时生成:一处是**创建成功后的对话框**内(`CcSwitchImportButtons`,与「明文只显示一次」同生命周期),另一处是 **Key 列表每行的「导入 CC Switch」对话框**(列表只存脱敏 `preview`,需用户粘贴完整 Key,仅存于对话框内存)。两处的「默认模型」下拉与 Playground 同源(`/api/playground/models`,经 `usePlaygroundModels` 共用缓存键)。本网关无 Gemini 出口,故不提供 `app=gemini`。协议与取值依官方文档(CC Switch 用户手册「Deep Link Protocol」),需 v4.0.10+。 ### 3.9 上下文压缩 -**动机**:上游对输入长度有硬限制(CodeBuddy `11115 prompt is too long: … tokens > … maximum`)。反代若原样转发,长会话必然撞墙,客户端只看到裸的 400,而换号无意义(每个号上限一样)。**只做确定性的「估算 → 裁剪」**,不做「超限后压缩再重试」的放大路径。实现分两层:纯函数 `engine/compress.py` + 装配闭包 `api/context.py`(`build_context_compressor`,由 main.py 注入 `ExecutorDeps.context_compress`)。 +动机:上游输入硬限(CB `11115 prompt is too long`),换号无意义。只做确定性「估算 → 裁剪」,不做超限后压缩重试。纯函数 `engine/compress.py` + 装配闭包 `api/context.py`。 -- **token 估算**:中文 0.55 tok/字、数字 0.33、其他 0.25。刻意不用「3 字符 ≈ 1 token」的英文口径——它会把中文低估约 1.6 倍,于是「以为装得下、其实装不下」,压缩根本不触发。**图片 part 按 URL/base64 正文长度计入**:早期实现对非文本 part 一律记 100 字符,而一张 1 MB 的图 base64 后约 1.4 M 字符,按 100 估等于「图片会话永不触发压缩」,会一路撞上游输入上限。 -- **预算**:`模型上限 × safety_ratio − reserve_for_output`。 -- **裁剪**:`system` 永久保留(丢了会改变模型行为);`assistant.tool_calls` 与其后连续的 `tool` 结果**同组同生共死**(只删一半会让上游报 tool_call_id 找不到);至少保留最近 `min_keep_messages` 条;其外从最新往最老贪心回填。裁剪后仍超限则截断最长的**非 system** 消息内容(留头尾 + 标记)。 -- **未知上限不压缩**:模型目录里查不到 `max_input_tokens` 时直接跳过——宁可不裁剪,也不拿一个猜的数字去砍用户上下文。故本功能对未知模型**零副作用**。 -- **声明值 ≠ 实际可用量**:目录里的 `max_input_tokens` 是模型自报的声明值,个别渠道严重低估。TRAE 声明 200000/232768,实测 1,450,000 字符(约 1,035,544 token)仍能正确读到开头,1,470,000 字符才报 `4022`/`4026`(针在开头、问题在结尾、中间填随机 ASCII,答对暗号即证明窗口横跨整段)。按声明值算压缩预算只有 217K,用户发 30 万 token 时上游明明能吃却被本地静默压掉。故 `api/context.py` 的 `_CHANNEL_WINDOW_FLOOR` 把压缩预算按渠道抬到实测下限(TRAE `1_048_576`),**只抬不压**(声明值更大时保留声明值),且**只影响压缩预算**——`/v1/models` 对外宣告仍用声明值,客户端保持保守认知,不会主动发超限请求。 -- **多渠道按实际渠道取值**:同名模型可能挂多个渠道、各自上限不同(qoder 180K vs codebuddy 1M)。压缩从 API 入口移到 **executor 选号之后**(`ExecutorDeps.context_compress`),按实际服务渠道的上限裁剪——路由前只能取 min 的口径会把大窗口渠道的会话反复误裁,前缀不稳定击穿上游前缀缓存。压缩实现无副作用:每轮从原文重压(换号/回退可能落到不同窗口的渠道),affinity 指纹读未压缩原文。 +- **估算**:中文 0.55 tok/字、数字 0.33、其他 0.25(英文「3 字符 ≈ 1 token」会把中文低估 1.6 倍)。**图片 part 按 URL/base64 长度计入**(按 100 字符估会让图片会话永不触发压缩)。 +- **预算** = `模型上限 × safety_ratio − reserve_for_output`。 +- **裁剪**:`system` 永久保留;`assistant.tool_calls` 与其后连续 `tool` 结果**同生共死**;至少留最近 `min_keep_messages` 条;其外从新到老贪心回填。仍超限则截断最长**非 system** 消息(留头尾 + 标记)。 +- **未知上限不压缩**:目录查不到 `max_input_tokens` 直接跳过,零副作用。 +- **声明值 ≠ 可用量**:TRAE 声明 200000/232768,实测 1,450,000 字符(约 1,035,544 token)仍可读,1,470,000 才报 `4022`/`4026`。故 `_CHANNEL_WINDOW_FLOOR` 按渠道抬到实测下限(TRAE `1_048_576`),**只抬不压**,且只影响压缩预算(`/v1/models` 仍宣告声明值)。TRAE 的 `context_window_tokens` 有 `{dev, max}` 两档,`dev` 是保守档:取 `max`(`max > dev` 时),否则 1M 上下文的模型会被报成 200K。 +- **按实际渠道取值**:压缩在 executor **选号之后**执行(`ExecutorDeps.context_compress`);路由前只能取 min 口径,会反复误裁大窗口渠道。每轮从原文重压;affinity 指纹读未压缩原文。 -热更项 `context_compress_enabled`(默认 true)/ `context_compress_reserve_tokens`(4096)/ `context_compress_min_keep_messages`(4)/ `context_compress_safety_ratio`(0.95)。 -### 3.10 API Key 的绑定、白名单、来源 IP 与到期时间 -在 Key 级策略上叠加四项(**不做配额**),`api_keys` 增四列: +热更:`CONTEXT_COMPRESS_ENABLED`(true)/ `CONTEXT_COMPRESS_RESERVE_TOKENS`(4096)/ `CONTEXT_COMPRESS_MIN_KEEP_MESSAGES`(4)/ `CONTEXT_COMPRESS_SAFETY_RATIO`(0.95)。 +### 3.10 API Key 策略 | 列 | 取值 | 空值语义 | |---|---|---| -| `provider_binding` | 见 `KNOWN_PROVIDERS`(八渠道) | 空 = 自动(跨渠道选健康凭证) | -| `allowed_ips` | 逗号分隔 IP/CIDR | 空 = 不限制来源 IP | -| `allowed_models` | fnmatch glob、逗号分隔 | 空 = 不限制 | +| `provider_binding` | 见 `KNOWN_PROVIDERS` | 空 = 跨渠道 | +| `allowed_ips` | 逗号分隔 IP/CIDR | 空 = 不限 | +| `allowed_models` | fnmatch glob、逗号分隔 | 空 = 不限 | | `expires_at` | epoch 秒 | NULL = 永不过期 | -- **渠道绑定**:选定渠道后该 Key 只在该渠道凭证里选号;模型属另一渠道时直接 400 并指出实际归属(不静默改道)。`模型@渠道` 与绑定冲突时同样 400。执行在 `Executor._apply_binding`(`resolve_target` 内),与 `@provider` 强制指定共用收窄路径;绑定把候选固定为该渠道并置 `forced`(跳过模型目录收窄)。目录未就绪时不做归属判断,保守放行。 -- **来源 IP 判定**在 `deps.api_key_user`(鉴权当场判掉):**默认不采信 `X-Forwarded-For`**(客户端可写);仅 `TRUST_PROXY=true` 时按 XFF 判定、取**最后一个**条目。策略是纯函数(`auth/access.py`):写入时 `normalize_allowed_ips` 校验规范化(`10.0.0.1` → `10.0.0.1/32`)、非法值 400;读取宽松解析、脏条目丢弃,整份白名单一条都解析不出则**拒绝**(fail closed)。命中失败返回 **403**(与 401「凭证无效」区分)。 -- **模型白名单**:三个 /v1 出口(chat / responses / messages)解析后校验;省略模型名(空 / `auto`)时按 `default_model` 判定,**不能借空名绕过**;匹配大小写不敏感,`模型@渠道` 后缀不参与匹配;`/v1/models` 也按白名单过滤展示。写入时 `normalize_allowed_models` 校验。 -- **到期时间**:`deps._api_key_principal` 在鉴权当场判过期(过期与「Key 不存在」**统一 401 文案**,不泄露可枚举信息)。 - -`ApiKeyPrincipal` 返回 `(username, key_id, provider_binding, allowed_models)`;`ApiKeyRepository.verify` 保留为只回用户名的薄封装。 +- **渠道绑定**:只在绑定渠道选号;模型属他渠道直接 400 并指出归属;`模型@渠道` 冲突同样 400。执行在 `Executor._apply_binding`,与 `@provider` 共用收窄路径并置 `forced`(跳过目录收窄)。目录未就绪时保守放行。 +- **来源 IP**:`deps.api_key_user` 当场判。**默认不采信 `X-Forwarded-For`**;仅 `TRUST_PROXY=true` 时取**最后一个**条目。写入时 `normalize_allowed_ips` 规范化(`10.0.0.1` → `10.0.0.1/32`);读取宽松,整份白名单解析不出则**拒绝**(fail closed)。失败 403。 +- **模型白名单**:三个 /v1 出口解析后校验;空名 / `auto` 按 `default_model` 判定(不能借空名绕过);大小写不敏感,`@渠道` 后缀不参与;`/v1/models` 同样过滤。 +- **到期**:过期与「Key 不存在」**统一 401 文案**。 -### 3.11 reasoning 控制(档位映射 + 思考内容剥离) -**定位**:客户端与上游对「思考」的表达方式不一致——Anthropic 用 `thinking.budget_tokens`,OpenAI 系用 `reasoning_effort`,各上游对档位的接受度也不同。这里只提供**机制**,不给策略:三个热更项默认全关 / 留空,生效形态完全由使用者配置决定。内置默认矩阵(「哪个渠道认哪些档位」「多少预算算高档」)**一律不做**——未逐一实测上游接受性,写了就是编造(与 `MODEL_FALLBACK_GROUPS`、`supported_efforts` 同一取舍)。 +### 3.11 reasoning 控制 +只提供机制不给策略:三项默认关 / 留空。内置默认矩阵(哪个渠道认哪些档位)一律不做(未实测即编造)。 -**① `force_disable_thinking`(bool,默认 false)**:出站**响应侧**剥离思考内容。过滤点是 executor 消费上游事件的循环(`_stream_loop` 的 `async for event in iterator` 与 `_complete_model` 的聚合前),丢掉 `EventKind.REASONING` 事件——三个出口(chat / Anthropic / Responses)、流式与非流式因此一致生效。**刻意不放在 `_stream_source` 里**:截断续写包装器 `ContinuationStream` 在那一层内,续写要靠 reasoning 原文拼上下文,剥在包装器外层才能既保住续写质量又对客户端隐藏思考。**只过滤响应、不改写请求体**:实测 CB `enable_thinking:false` 被上游忽略(仍产 reasoning 且计入 `max_tokens`),而**不发** `reasoning_effort` 反而退化成「可见推演」(思考混进 `delta.content`);其余渠道的请求侧接受性未核实,写请求体就是凭猜测改客户端意图。`usage.reasoning_tokens` 不受影响(思考照常计费,只是不展示)。 +**① `FORCE_DISABLE_THINKING`**(默认 false):响应侧剥离,过滤点是 executor 消费事件的循环(`_stream_loop` 与 `_complete_model` 聚合前),三个出口一致生效。**刻意不放进 `_stream_source`**:`ContinuationStream` 在那层内,续写要靠 reasoning 原文,剥在外层才既保质量又对客户端隐藏。只过滤响应不改请求体(CB `enable_thinking:false` 被忽略,不发 `reasoning_effort` 反退化成可见推演)。`usage.reasoning_tokens` 不受影响。 -**② `reasoning_effort_map`(str,默认空)**:出站把客户端**显式**给的 `reasoning_effort` 改写成渠道期望的档位。格式 `渠道=from:to,from:to;*=from:to`(渠道取 `KNOWN_PROVIDERS`,`*` 兜底;目标写 `none` = 删除该键、走渠道默认)。应用点在 executor **选号之后**(`_map_effort`,`provider_id` 已知)——同名模型跨渠道接受的档位不同,路由前无从判断。**只动客户端显式值**:渠道各自的 `setdefault` 补默认档(如 CB 补 `medium`)发生在 provider 侧、晚于这里,故不受影响;客户端没给档位时一个字段都不加。需要改写时返回**新 dict**,不改 `request.raw`——后者是会话粘性指纹的输入,原地改会污染指纹链、也会把改写泄漏到下一轮轮换。 +**② `REASONING_EFFORT_MAP`**(默认空):格式 `渠道=from:to,from:to;*=from:to`,目标写 `none` 即删键。应用点在选号之后(`_map_effort`,`provider_id` 已知)。**只动客户端显式值**(渠道 `setdefault` 在 provider 侧、晚于此处)。需改写时返回**新 dict**,不改 `request.raw`(后者是粘性指纹输入)。 -**③ `anthropic_thinking_efforts`(str,默认空)**:Anthropic 入站 `thinking.budget_tokens` → 上游 `reasoning_effort`。格式 `预算:档位,预算:档位,disabled:档位`:`disabled` 匹配 `thinking.type == "disabled"`;其余取「阈值 ≤ `budget_tokens` 中最大的那一档」,低于最小阈值则不映射。**留空 = 完全不映射**,`thinking` 与改动前一样被忽略(`parse_messages_request` 一个字段都不写)。Anthropic 官方只规定 `budget_tokens ≥ 1024`、**未给「预算 ↔ 档位」对应表**,故阈值表必须由使用者显式给出。未知 `thinking.type`(如 `adaptive`)与非法 `budget_tokens` 一律不映射——猜错会把客户端「不要思考」的意图翻成「高预算思考」。映射结果写进 `ChatRequest.raw["reasoning_effort"]`,因此**会**再经过 ② 的渠道映射(两个机制可组合)。 +**③ `ANTHROPIC_THINKING_EFFORTS`**(默认空):`budget_tokens` → `reasoning_effort`,格式 `预算:档位,…,disabled:档位`;取「阈值 ≤ budget 的最大档」,低于最小阈值不映射。**留空 = 完全不映射**。Anthropic 只规定 `budget_tokens ≥ 1024`、未给对应表,故阈值必须显式配置。未知 `thinking.type` 与非法预算不映射。结果写入 `ChatRequest.raw`,会再经 ② 映射。 -**解析宽容**:`engine/reasoning.py` 的三个解析函数忽略空段 / 缺分隔符 / 非整数阈值的坏段,一行笔误只丢自己、不连累同配置里的其他合法条目,也不抛异常——坏配置不能让整个网关起不来。 +解析宽容:`engine/reasoning.py` 忽略空段 / 缺分隔符 / 非整数阈值,一行笔误不连累其他条目,不抛异常。 ## 4. Provider 协议 ```python @@ -250,51 +283,70 @@ class Provider(Protocol): async def aclose(self) -> None: ... ``` -> 上表与 `src/provider/base.py` 的 `Provider` 协议严格一致;两处定义必须同步(改任一处必须同步另一处)。 +> 与 `src/provider/base.py` 严格一致,改一处必须同步另一处。 -约定: -- 凭证在引擎与 provider 之间以 dict 传递(解密后的 `data_enc`);`credential_from` 等能力把 dict 还原为 provider 私有 dataclass,provider 不接触 sqlite。 -- `stream_chat` 只产出 `Event`,产出前先做 HTTP 状态码检查;`classify` 由 executor 调用。 -- 可选能力**不实现即不定义**(不是抛 `NotImplementedError`):调用方用 `getattr`/`hasattr` 探测,缺失时返回 400「该凭证不支持此操作」,而不是 500。当前可选集:`start_auth`/`poll_auth`(仅支持 poll 的 provider)、`complete_callback`(仅 TRAE)、`list_accounts`/`switch_account`(仅 CodeBuddy)、`credential_from`/`checkin_scope`(刷新与签到任务的能力探测)、`checkin`/`checkin_status`(签到)、`growth`(成长中心,仅 CodeBuddy)、`host`(展示用)。 -- 八条渠道中 CodeBuddy / TRAE / Zen / Kilo / Qoder / OfficeAce 共用 `engine/sse.py` 的帧解析器(SSE 规范层),事件语义各自映射;CodeArts 的私有「累计全文」形状无法用 `FrameAssembler` 表达,自实现 `iter_data_lines`,只复用 `engine/sse` 的 `MAX_SSE_LINE_BYTES` / `SSEFrameTooLarge`。 -- Zen 与 Kilo 是最「薄」的两个 provider:标准 OpenAI SSE,`events.py` 无需私有信封解析。两者都**只实现** `import_credential`/`classify`/`probe_quota`/`list_models`/`stream_chat`/`aclose`,签到 / 成长 / 刷新一律靠「方法缺失」自然跳过。 +- 凭证以 dict 传递(解密后的 `data_enc`);`credential_from` 等能力还原为私有 dataclass,provider 不接触 sqlite。 +- `stream_chat` 只产 `Event`,产出前先查 HTTP 状态码;`classify` 由 executor 调用。 +- 可选能力**不实现即不定义**(不抛 `NotImplementedError`):调用方 `getattr` 探测,缺失回 400「该凭证不支持此操作」。当前可选集:`start_auth`/`poll_auth`、`complete_callback`(仅 TRAE)、`list_accounts`/`switch_account`(仅 CB)、`credential_from`/`checkin_scope`、`checkin`/`checkin_status`、`growth`(仅 CB)、`host`。 +- CodeBuddy / TRAE / Zen / Kilo / Qoder / OfficeAce 共用 `engine/sse.py` 帧解析;CodeArts 的累计全文形状自实现 `iter_data_lines`,只复用 `MAX_SSE_LINE_BYTES` / `SSEFrameTooLarge`。 +- Zen 与 Kilo 最薄:只实现 `import_credential`/`classify`/`probe_quota`/`list_models`/`stream_chat`/`aclose`,签到 / 成长 / 刷新靠方法缺失自然跳过。 ## 5. 各渠道协议实现 -### 5.1 OpenCode Zen 免费层(`zen`) -Zen 免费层是**标准 OpenAI 流式协议**(`chat.completion.chunk` + `data: [DONE]`),`provider/zen/events.py` 很薄:`content` / `reasoning_content` / `tool_calls` / `usage` / `finish_reason` / 流内 `error` 各一条映射。唯一要留意的是**最后一段正文与 `finish_reason` 会同帧**,故 `parse_all_events` 必须能在同一帧里同时产出 CONTENT 与 FINISH。上游 `[DONE]` 之后还跟一帧 `{"choices": [], "cost": "0"}`,无下游语义,跳过。**渠道私有的只有门禁伪装**(集中在 `zen/client.py`): +### 5.1 OpenCode Zen(`zen`) +标准 OpenAI 流式(`chat.completion.chunk` + `[DONE]`)。`events.py` 很薄,唯一要点是**最后一段正文与 `finish_reason` 同帧**,`parse_all_events` 需同帧产出 CONTENT 与 FINISH。`[DONE]` 后跟的 `{"choices": [], "cost": "0"}` 跳过。 + +渠道私有的只有门禁伪装(`zen/client.py`): | 条件 | 要求 | 违反后果 | |---|---|---| | `User-Agent` | `opencode/`,version ≥ 1.18.0 | 低于阈值 → 426;无版本号 → 403 | -| `x-opencode-session` | `^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$`,且**前 12 位 hex 须解码回近期时间戳** | 403 | +| `x-opencode-session` | `^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$`,**前 12 位 hex 须解码回近期时间戳** | 403 | | body `stream` | 必须 `true` | 403 | -| body `tools` | 必须**同时**含 name 为 `bash` 与 `read` 的工具(参数可为空壳) | 403 | -| `Authorization` | `Bearer public` 可带可不带(匿名也通) | 带伪造 key → 401 | +| body `tools` | 必须同时含 `bash` 与 `read`(参数可空壳) | 403 | +| `Authorization` | `Bearer public` 可带可不带 | 带伪造 key → 401 | -> **会话前缀必须编码近期时间**:门禁不只校验 session 的**形态**,还要求 12 位 hex 前缀 -> 解码回**近期时间**,否则一律 `403 FreeTierError`(随机 hex 形态合规但语义不对)。 -> 编码照官方 `Identifier.create`(`sst/opencode` `src/id/id.ts`):把毫秒时间戳左移 -> 12 位(`ms * 0x1000`)再 `+1`,**按位取反**,取低 48 位写成 12 位小写十六进制; -> 后 14 位内容自由。复刻见 `new_session_id` / `_session_time_prefix`。**门禁在鉴权之前**: -> 带真实 `oc_sk_*` key 反而 401,`Bearer public` 才 200,故本渠道始终不带用户 Token。 +会话前缀编码照官方 `Identifier.create`(`sst/opencode` `src/id/id.ts`):`ms * 0x1000 + 1`,按位取反,取低 48 位写 12 位小写 hex;后 14 位自由。复刻见 `new_session_id` / `_session_time_prefix`。**门禁在鉴权之前**:带真实 `oc_sk_*` 反而 401。 -UA 版本走 `ZEN_OPENCODE_VERSION`(上游改阈值改 env,不硬编码);`ZEN_API_ENDPOINT` 走端点白名单(`ZEN_ALLOWED_ENDPOINTS`)。Zen **不携带任何用户 Token**,白名单只为防误配。**403 FreeTierError 抖动在客户端内重试一次**:上游偶发对同一请求回 `403 {"type":"error","error":{"type":"FreeTierError"}}`(门禁四项每次都满足,数秒后的同请求即 200)。403 归 `ErrKind.REQUEST` → 不冷却也不跳过渠道,但 zen 只有**一条虚拟凭证**,轮换候选随即耗尽 → 对外 503。故 `stream_chat` 在客户端内部先消化抖动:退避 `GATE_403_RETRY_DELAY`(1s)后换新 session id、关旧连接重发一次(`GATE_403_RETRIES=1`),重试仍 403 才上抛。只对 403 重试,其余状态码仍一次上抛。**注入空壳 + 改写/过滤回包**:门禁要求 tools 含 `bash`/`read`,但这两个可能是**我们伪造的**、客户端从没声明过(如 OpenCode 的执行工具叫 `shell`)。模型偏好调 `bash` 这个名字,直接吞掉会让客户端收到「空正文 + stop」提前结束回合。故两级处理:① 客户端声明了同义工具(`TOOL_SYNONYMS`,`bash`→`shell`/`execute`、`read`→`read_file`)→ 把注入名的 tool_call 函数名**改写**成同义工具原样透传,注入时还复制同义工具的 description/parameters;② 没有同义工具 → 才整块丢弃并记 WARNING。实现只在**缺失时**补骨架工具(用户自带同名工具 → 不注入、不改写、不过滤,绝不误伤);处理按 `index` 跟踪流式 tool_call 分片;若整条流无任何保留的真 tool_call,则把 `finish_reason=tool_calls` 收敛为 `stop`。**免费模型清单完全动态(现拉现探)**:上游 `/zen/v1/models` 免鉴权,但返回**全部**模型(含 70 多个付费模型),且**不带任何免费/付费标记**(`owned_by` 恒 `opencode`;亦无 cost 字段)。唯一权威信号是**匿名可用性**:付费模型恒 401 `Missing API key.`,免费模型永不 401。故两步过滤:① 按 `-free` 后缀收窄候选;② 对候选并发探活,**只保留 2xx**(已下线 400、区域限制 403、上游故障 5xx、超时都剔除)。无静态白名单,上游增删免费模型自动跟随;探活结果为空时抛协议错,让 `/v1/models` 用上次成功的缓存兜底。**判活结果缓存 30 分钟**(`MODELS_CACHE_TTL_SECONDS`,比服务层 `MODEL_LIST_TTL_SECONDS` 300s 长一档):探活是列表链路里最贵的一步(逐个真发一次推理,总耗时等于最慢那个),服务层每 5 分钟到期重拉列表时直接复用。**免费模型显式 x0 倍率**:`fetch_models` 给每个存活候选 `credit_rate=0.0`(而非 `None`)。 +- UA 走 `ZEN_OPENCODE_VERSION`,端点走 `ZEN_API_ENDPOINT`(受 `ZEN_ALLOWED_ENDPOINTS` 校验)。Zen **不携带任何用户 Token**,白名单只为防误配。 +- **403 FreeTierError 抖动客户端内消化**:403 归 `REQUEST`(不冷却不跳渠道),但 zen 只有一条虚拟凭证,轮换立刻耗尽 → 503。故 `stream_chat` 退避 `GATE_403_RETRY_DELAY`(1s)后换新 session id 重发一次(`GATE_403_RETRIES=1`),仍 403 才上抛;其余状态码不重试。 +- **注入空壳 + 改写/过滤回包**:门禁要求 `bash`/`read`,可能是我们伪造的(客户端执行工具叫 `shell`)。① 客户端有同义工具(`TOOL_SYNONYMS`:`bash`→`shell`/`execute`、`read`→`read_file`)→ 把注入名的 tool_call **改写**成同义工具透传,注入时复制其 description/parameters;② 无同义工具 → 整块丢弃并记 WARNING。只在**缺失时**补骨架(用户自带同名工具则不动);按 `index` 跟踪流式分片;若整条流无保留的真 tool_call,把 `finish_reason=tool_calls` 收敛为 `stop`。 +- **免费清单完全动态**:`/zen/v1/models` 免鉴权但返回全部模型且**无免费标记**(`owned_by` 恒 `opencode`,无 cost)。唯一权威信号是匿名可用性(付费恒 401 `Missing API key.`)。两步过滤:`-free` 后缀收窄 → 并发探活只留 2xx。无静态白名单;探活为空抛协议错,让列表用上次缓存兜底。**判活缓存 30 分钟**(`MODELS_CACHE_TTL_SECONDS`,比服务层 300s 长一档)。免费模型标 `credit_rate=0.0`(非 `None`)。 -**虚拟凭证行**:Zen 无凭证、无额度接口。池里种一条空凭证(`credential_data={}`,`added_by="system"`),复用现有调度 / 冷却 / 统计 / 会话粘性。`probe_quota` 恒返回 `Quota(probe_failed=True)` → `health_score` 返回 `None`(**未知**,不是 `EXHAUSTED=-1`)——这点很关键:若返回 `total=None`,`health_score` 会判定为「已耗尽」而把这条免费渠道错误降级。种子幂等(已有 zen 凭证则不补),**用户删除后重启会复活**,永久停用请用「暂停」。只为默认装配路径种子(测试注入自定义 registry 时不多出凭证行)。 -### 5.2 Kilo 免费层(`kilo`) -Kilo(`api.kilo.ai/api/gateway`)对外是**标准 OpenAI 兼容协议**,既无私有信封,也**没有 Zen 那种门禁伪装**。`provider/kilo/events.py` 与 Zen 一样薄,唯一区别是**思考字段在 `delta.reasoning`**(Zen 是 `reasoning_content`),另带 `reasoning_details` 结构,忽略即可。`parse_all_events` 同样处理同帧 finish,并额外补 usage(Kilo 的 `usage` 常与收尾 `choices` 同帧)。**免费模型有权威标记、故不做探活(与 Zen 相反)**:`/models` 每个条目带 `isFree` 布尔(含 `kilo-auto/free`、`stealth/space-bunny-alpha` 等**无 `:free` 后缀**者)。故直接按 `isFree is True` 过滤,**不做探活**——探活会白耗本就极小的免费配额(网关级约 200 req/h/IP),且结果随上游免费池(实为 OpenRouter 免费池转发)波动不稳定。无静态白名单;过滤结果为空时抛协议错,让 `/v1/models` 用上次成功的缓存兜底。元数据(`name` / `context_length` / `top_provider.max_completion_tokens` / `supported_parameters` 含 `tools` / `architecture.input_modalities` 含 `image`)透传为中立 `Model`;免费模型显式标 **x0 倍率**。**虚拟凭证行**(同 Zen):`credential_data={}`、`added_by="system"`,`probe_quota` 恒 `Quota(probe_failed=True)` → `None`。种子幂等、**删除后重启复活**、永久停用请「暂停」。**错误分类**:401→`INVALID`(无凭证,401 只表示该模型需要付费 key/BYOK)、400/404/422→`INVALID`、403→`REQUEST`、**429 与 502/503/504→`MODEL`**(见 §3.2)。限流交引擎处理,本包不自建熔断。**独立 pacer**:`KILO_CHAT_MIN_INTERVAL`(热更项,默认 0 = 不节流)走独立 `Pacer`,与 zen / CB / TRAE 互不排队;`stream_chat` 在 `finally` 里 `pacer.release("kilo")` 归还并发名额。 -### 5.3 Qoder(`qoder`) -**与 zen/kilo 的本质区别**:Qoder 是**真实账号渠道**,凭证必须登录获得、加密入库,走与 CB 相同的设备码轮询登录轨道,但**不种虚拟凭证**——没有 token 就是不可用。**私有 COSY 协议**:推理 `POST {gateway}/algo/api/v2/service/pro/sse/agent_chat_generation`。请求体不是 JSON,而是**自定义 Base64 变体**(三段轮转 + 自定义字母表 + `=`→`$`);认证不是标准 Bearer,而是 `Authorization: Bearer COSY..` 加上整套 `cosy-*` 头。签名串为 `md5(payload_b64 + "\n" + cosy_key + "\n" + date + "\n" + body + "\n" + path)`(`path` 去掉 `/algo` 前缀,query 不参与签名);`payload` 是键排序紧凑 JSON `{cosyVersion,ideVersion,info,requestId,version}`;`cosy_key` 由临时 AES 密钥经服务端 RSA 公钥加密得到,`info` 是该临时密钥 AES-128-CBC 加密的身份 JSON。密码学复用项目已有 `cryptography`(AES-128-CBC + RSA PKCS1v15)。`cosy-machineid`/`cosy-machinetoken` 按 `uid` 稳定派生(同一账号长期同一虚拟设备,避免随机机器码触发风控)。**信封式 SSE**:响应每行是 `data:{"headers":…,"body":"<内层 OpenAI chunk>","statusCodeValue":200}`——即**外层信封包内层 chunk**。解析器逐帧解出 `body`,`body=="[DONE]"` 结束,`statusCodeValue != 200` 判上游错误(HTTP 仍可能 200)。内层仍是标准 OpenAI chunk,事件映射与 kilo 近似(`clean_delta` 顺带剔除网关补的空噪声 delta:`extra_fields`/`refusal`/空 `reasoning_content`/空 `tool_calls`)。**模型发现**:`GET {gateway}/algo/api/v2/model/list?Encode=1`,**必须带整套 COSY 签名头**(签名 body 为 `qoder_encode("")`)。裸 GET(无 COSY 头)会 403;方法固定 GET(带头用 POST/PUT 会被上游 400「Request method ... not supported」拒绝)。清单条目带 `key`(内部代号,如 `qmodel_38max`)与 `display_name`(可读名,如 `Qwen3.8-Max`):`id` 用 `key`、`name` 用 `display_name`。**`price_factor` 即「Credit 消耗倍率」**:来自服务端模型列表的 `price_factor`,与全站 `credit_rate` 同义(越小越省),直接映射;免费模型上游给 `0.0`。注意它是**相对倍率**,不是每次任务的固定积分。 +**虚拟凭证行**:`credential_data={}`、`added_by="system"`,复用调度 / 冷却 / 统计 / 粘性。`probe_quota` 恒 `Quota(probe_failed=True)` → health `None`(**未知**,不是 `EXHAUSTED`)。种子幂等,**删除后重启复活**,永久停用用「暂停」。只在默认装配路径种子。 -**签到(活动制)**:`GET /sash/api/v1/me/campaigns`(**必须带 `Cosy-ClientType: 10`**,否则上游静默返回 `campaigns:[]`)→ 筛 `actionType=="CLAIM_BENEFIT"` 且 `claimStatus=="CLAIMABLE"`(`VIEW_DETAILS` 等展示位也是 `CLAIMABLE`,不筛 actionType 会领错活动)→ `POST /sash/api/v1/me/campaigns/{campaignId}/claim`(同样带 `Cosy-ClientType: 10`)。claim 响应 `replayed:true` 是幂等重放、`status=="BLOCKED" && failureCode=="SAME_PERSON_ALREADY_CLAIMED"` 是同一自然人已领,两者都归一为 `already_checked_in=True`;其它 `BLOCKED` 才是真失败。**新协议不提供连续天数**,`streak_days` 恒为 `None`。仅当活动制接口本身 404/405/410 时才整体回退旧的 `daily-check-in` 流程;活动制与旧接口都不可用才算「本区域无此接口」,记为 skipped(`ok=False` 但不算失败)——**国际版该端点 404**。**签到窗口 ≠ 自然日,故按窗口封账**:上游签到活动每日 **10:00(UTC+8)** 才轮换 `campaignId`,当前窗口 `startAt 10:00 → endAt 次日 09:59`。按自然日封账会在凌晨把上一轮窗口的 `CLAIMED` 判成「今日已签到」而封掉整天,故 `CheckinResult.seal_until`(epoch 秒,缺省 None = 当天结束)由 Qoder 的活动制路径回填 `checkin_window_end(payload)` = 签到类活动里**最晚**的 `endAt`(展示位 `VIEW_DETAILS` 的 `endAt` 不采信,缺失/非数值/0 → None 回落原口径)。`CheckinTask._done_scopes` 为 `dict[done_key → 截止时刻]`,跳过判据 `_sealed()`(`until > now`);旧 `daily-check-in` 回退路径与其它渠道仍走自然日封账。**额度**:`GET {openapi}/api/v2/quota/usage` 的 `userQuota` + `addOnQuota` 合成 `remaining`/`total`,`cycle_end` 取最早 `expiresAt`。 +### 5.2 Kilo(`kilo`) +标准 OpenAI 兼容,无私有信封、无门禁。`events.py` 与 Zen 同薄,区别是**思考在 `delta.reasoning`**(另带 `reasoning_details`,忽略)。同帧 finish 处理,并补 usage(常与收尾 `choices` 同帧)。 -**上游节点故障归类**:Qoder 把**自身推理节点故障**包成 400——`{"code":"400","message":"[FAIL]node:… msg:Execution failed: null"}`(HTTP 与信封 `statusCodeValue` 都是 400),而同批其他模型正常。这与「模型不存在 / 请求无效」的 400 语义不同:换凭证不解决但会自愈。故 `classify_error_code` 对 400/404/422 增加响应体判据:命中 `NODE_FAILURE_MARKERS = ("[FAIL]node:", "Execution failed")` → `ErrKind.MODEL`(模型级瞬时冷却),否则维持 `INVALID`。安全前提:探测未知模型名(乱码/空串)上游既不返回 ERROR 也不带该标记,故不会误伤。耗尽轮换且**全部候选都因该模型处于模型级冷却**时,503 文案用 `model 'x' temporarily unavailable on upstream`(可附相近模型建议)而非 `all credentials unavailable`。**节点白名单**:`QODER_ALLOWED_ENDPOINTS` 同时含国内 openapi+gateway 与国际版;`_qoder_endpoint` 启动时校验。`QODER_CHAT_MIN_INTERVAL`(热更项,默认 5s)走独立 pacer。 -**`content: null` 必须归一为空串**:Qoder 拒收带 `tool_calls` 的 assistant 把 `content` 置为 `null`(OpenAI 允许)。上游把这条消息当「没有正文」跳过,紧随其后的 `role:"tool"` 就成了孤儿,报文却是 `Messages with role 'tool' must be a response to a preceding message with 'tool_calls'` —— 指向 tool 配对,真实原因在 assistant 的 null content。出站前 `build_chat_body` 的 `_normalize_null_content` 把 assistant/tool 的 `null` 改成 `""`(语义等价)。2026-10-23 实测:同一请求体只把 `null` 换成 `""` 即 400 → 200,与 `reasoning_content` / 并行调用 / 轮数无关;codebuddy_intl、trae、officeace 不敏感。 +- **免费有权威标记故不探活**:`/models` 每条带 `isFree`(含无 `:free` 后缀的 `kilo-auto/free`、`stealth/space-bunny-alpha`)。探活会白耗极小配额(网关级约 200 req/h/IP)且结果波动。过滤为空抛协议错,列表用缓存兜底。 +- 元数据透传:`name` / `context_length` / `top_provider.max_completion_tokens` / `supported_parameters` / `architecture.input_modalities`。免费模型标 x0。 +- 虚拟凭证行同 Zen。**错误分类**:401 / 400 / 404 / 422 → `INVALID`(401 只表示该模型需付费 key / BYOK),403 → `REQUEST`,**429 与 502/503/504 → `MODEL`**。限流交引擎,本包不自建熔断。 +- 独立 pacer `KILO_CHAT_MIN_INTERVAL`(热更,默认 0),`finally` 里 `pacer.release("kilo")`。 + +### 5.3 Qoder(`qoder`) +真实账号渠道,凭证必须登录获得、加密入库,不种虚拟凭证。**私有 COSY 协议**: + +- 推理 `POST {gateway}/algo/api/v2/service/pro/sse/agent_chat_generation`。请求体是**自定义 Base64 变体**(三段轮转 + 自定义字母表 + `=`→`$`);认证 `Authorization: Bearer COSY..` + 整套 `cosy-*` 头。 +- 签名 `md5(payload_b64 + "\n" + cosy_key + "\n" + date + "\n" + body + "\n" + path)`(`path` 去 `/algo`,query 不参与)。`payload` 是键排序紧凑 JSON `{cosyVersion,ideVersion,info,requestId,version}`;`cosy_key` 由临时 AES 密钥经服务端 RSA 公钥加密,`info` 是 AES-128-CBC 加密的身份 JSON。复用 `cryptography`。 +- `cosy-machineid`/`cosy-machinetoken` 按 `uid` 稳定派生(随机机器码触发风控)。 +- **信封式 SSE**:`data:{"headers":…,"body":"<内层 OpenAI chunk>","statusCodeValue":200}`。逐帧解 `body`,`body=="[DONE]"` 结束,`statusCodeValue != 200` 判上游错误(HTTP 仍可能 200)。`clean_delta` 剔除空噪声 delta。 +- **模型发现** `GET {gateway}/algo/api/v2/model/list?Encode=1`,**必须带整套 COSY 头**(签名 body 为 `qoder_encode("")`);裸 GET 403,方法固定 GET(POST/PUT 400)。`id` 用 `key`(如 `qmodel_38max`)、`name` 用 `display_name`。`price_factor` 即 credit 消耗倍率(相对值,免费给 `0.0`)。 +- **签到(活动制)**:`GET /sash/api/v1/me/campaigns`(**必须带 `Cosy-ClientType: 10`**,否则静默空列表)→ 筛 `actionType=="CLAIM_BENEFIT"` 且 `claimStatus=="CLAIMABLE"` → `POST .../{campaignId}/claim`(同样带头)。`replayed:true` 与 `BLOCKED`+`SAME_PERSON_ALREADY_CLAIMED` 归一为已签,其它 `BLOCKED` 才是失败。新协议无连续天数(`streak_days` 恒 None)。仅活动制接口 404/405/410 时回退旧 `daily-check-in`;两者都不可用记 skipped(`ok=False` 但不算失败)。**国际版该端点 404**。 +- **签到窗口 ≠ 自然日**:上游每日 **10:00(UTC+8)** 轮换 `campaignId`,窗口 `10:00 → 次日 09:59`。按自然日封账会在凌晨封掉整天,故 `CheckinResult.seal_until` 回填 `checkin_window_end(payload)`(签到类活动里**最晚**的 `endAt`,不采信 `VIEW_DETAILS`)。`CheckinTask._done_scopes` 为 `dict[done_key → 截止时刻]`,判据 `_sealed()`(`until > now`)。 +- **额度** `GET {openapi}/api/v2/quota/usage`:`userQuota` + `addOnQuota` 合成 `remaining`/`total`,`cycle_end` 取最早 `expiresAt`。 +- **节点故障归类**:Qoder 把自身节点故障包成 400(`{"code":"400","message":"[FAIL]node:… msg:Execution failed: null"}`,HTTP 与 `statusCodeValue` 都是 400)。`classify_error_code` 对 400/404/422 加响应体判据:命中 `NODE_FAILURE_MARKERS = ("[FAIL]node:", "Execution failed")` → `ErrKind.MODEL`,否则 `INVALID`。乱码模型名不带该标记,不误伤。全候选只因该模型冷却时 503 文案用 `model 'x' temporarily unavailable on upstream`。 +- **`content: null` 必须归一为空串**:Qoder 拒收带 `tool_calls` 的 assistant 把 `content` 置为 `null`(OpenAI 允许)。上游把这条消息当「没有正文」跳过,紧随其后的 `role:"tool"` 就成了孤儿,报文却是 `Messages with role 'tool' must be a response to a preceding message with 'tool_calls'` —— 指向 tool 配对,真实原因在 assistant 的 null content。出站前 `build_chat_body` 的 `_normalize_null_content` 把 assistant/tool 的 `null` 改成 `""`(语义等价)。2026-10-23 实测:同一请求体只把 `null` 换成 `""` 即 400 → 200,与 `reasoning_content` / 并行调用 / 轮数无关;codebuddy_intl、trae、officeace 不敏感。 +- 白名单 `QODER_ALLOWED_ENDPOINTS` 含国内 openapi+gateway 与国际版,启动时校验;`QODER_CHAT_MIN_INTERVAL`(热更,默认 5s)独立 pacer。 ### 5.4 CodeArts(`codearts`) -**华为云签名体系 + 私有 SSE 帧**。**SDK-HMAC-SHA256 签名**:鉴权用 AK/SK + `X-Security-Token`,`Authorization: SDK-HMAC-SHA256 Access=, SignedHeaders=…, Signature=…`。三处必须逐字节对齐:signedHeaders 是**请求全部头**(小写、字典序);CanonicalURI **每段单独 percent-encode 且末尾补 `/`**(漏尾斜杠是 APIG.0301 的常见原因);payload hash 取 `X-Sdk-Content-Sha256`。`signer.py` 写成纯函数(不含「当前时间」)以便固定向量测试。**DPoP 令牌刷新(刚性约束)**:`POST {sts}/v1/oauth2/tokens` `grant_type=refresh_token` 需带 **DPoP(ES256/P-256) JWS**。refresh_token 与 `client_id=codearts-agent` + DPoP 私钥**三者绑定、一次性**——刷新成功必须把新 `refresh_token` 回写凭证(`RefreshTask` 先落库再同步账号)。ES256 用 `cryptography`(`ec.SECP256R1` + `ECDSA(SHA256)`)并**低 S 归一化**(`s > n/2` 时取 `n - s`)——华为 STS 校验要求低 S,而 `cryptography` 不保证(实测 2000 次签名有 991 次 high-S)。私钥必须随 refresh_token 一起持久化并原样复用(STS 把 refresh_token 绑定在首次换取时的 DPoP 公钥上)。**刷新节流**:`REFRESH_SKEW_HOURS` 默认 24h ≫ STS 寿命 2h,而 `needs_refresh` 语义是「离到期还剩 skew 秒就刷」,故给 `CodeArtsCredential` 加 `refresh_skew_cap_seconds = 2700`,`needs_refresh` 取 `min(skew, cap)`:把「一进窗口每轮都刷」降成「离到期 45min 内才刷」。**窗口必须宽于轮询周期**(`REFRESH_INTERVAL_MINUTES`,默认 30min):窗口 ≤ 周期时轮询点整轮落在窗口之外,凭证拖到到期才刷(实测封顶 30min、周期 60min 时在距到期约 26s 才刷新,上游回 `APIG.0602`)。封顶 45min > 30min 周期留余量;轮询粒度下实际在**剩余约 30min** 处刷新。 +**SDK-HMAC-SHA256**:AK/SK + `X-Security-Token`,`Authorization: SDK-HMAC-SHA256 Access=, SignedHeaders=…, Signature=…`。三处逐字节对齐:signedHeaders 为**全部头**(小写、字典序);CanonicalURI **每段 percent-encode 且末尾补 `/`**(漏尾斜杠是 `APIG.0301` 常见原因);payload hash 取 `X-Sdk-Content-Sha256`。`signer.py` 为纯函数(不含当前时间)以便固定向量测试。 + +**DPoP 刷新(刚性)**:`POST {sts}/v1/oauth2/tokens` `grant_type=refresh_token` 需带 ES256/P-256 JWS。refresh_token 与 `client_id=codearts-agent` + DPoP 私钥**三者绑定、一次性**——刷后必须回写新 `refresh_token`(`RefreshTask` 先落库再同步账号)。ES256 用 `cryptography`(`ec.SECP256R1` + `ECDSA(SHA256)`)并需**低 S 归一化**(`s > n/2` 取 `n - s`;`cryptography` 实测 2000 次有 991 次 high-S)。私钥随 refresh_token 持久化复用。 + +**刷新节流**:`REFRESH_SKEW_HOURS` 默认 24h ≫ STS 寿命 2h,故 `CodeArtsCredential.refresh_skew_cap_seconds = 2700`,`needs_refresh` 取 `min(skew, cap)`。**窗口必须宽于轮询周期**(`REFRESH_INTERVAL_MINUTES` 默认 30):窗口 ≤ 周期时整轮落在窗口外,会拖到到期才刷(实测 30min 封顶 + 60min 周期 → 距到期 26s 才刷,上游回 `APIG.0602`)。45min 封顶 > 30min 周期,实际在剩余约 30min 处刷。 + +**SSE 双形状**:逐行 `data:` JSON(也有裸 JSON 行),`data:[DONE]` 结束。v2 `/api/v2/chat/completions` 是标准 OpenAI chunk(增量在 `delta`,收尾帧 `choices:[]` + `usage`,`tool_stream:true` 时分片在 `delta.tool_calls`)。旧形状 `{"text":"<累计全文>"}` 为替换语义,用 `TextSnapshot` 做差(新文本以旧为前缀则只发后缀),结束帧 `{"text":"[DONE]","error_code":"0"}`。错误两条路:HTTP 非 2xx,或流内 `error_code`(`ChatAgent.*` / `TM.00001041`,HTTP 仍 200)。业务码是字符串,`Event.error_code` 只有 int 槽位,故原始码进 `error_message`、分类进 `error_kind`。 -**SSE 帧(两种形状同时兼容,按字段分派)**:逐行 `data:` JSON(行间有空行;也有不带 `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`。旧形状则是 `{"text":"<累计全文>"}`(**替换语义**,用 `TextSnapshot` 做差:新文本以旧文本为前缀 → 只发新增后缀;否则整段重发)+ 结束帧 `{"text":"[DONE]","error_code":"0"}`。错误有两条路:HTTP 非 2xx,或流内 `error_code`(形如 `ChatAgent.*` / `TM.00001041`,HTTP 仍 200)。业务码是**字符串**,中立 `Event.error_code` 只有 int 槽位,故原始码进 `error_message`、分类进 `error_kind`。**每日签到**:三个端点都挂 **snap 引擎根上、不带 `snap-manager` 前缀**(`/v1/statistics/plugin`、`/v1/current/user` 反而必须带——两套前缀混用容易扫不到): +**签到**:三个端点挂在 snap 引擎根、**不带 `snap-manager`**(`/v1/statistics/plugin`、`/v1/current/user` 反之必须带): | 用途 | 端点 | 备注 | |---|---|---| @@ -302,33 +354,54 @@ Kilo(`api.kilo.ai/api/gateway`)对外是**标准 OpenAI 兼容协议**,既 | 领取 | `POST {snap}/v1/ops/claim` | body `{campaignId:1, idempotentKey:"claim_1_", channel:"IDE"}` | | 确认 | `POST {snap}/v1/ops/confirm` | body `{campaignId:1}` | -`idempotentKey` 格式由前端 JS 定死(`claim__`);请求头 `agent-type: PromptCenter`。**四种 status 必须区分**:`ELIGIBLE`(可领)→ claim + confirm;**`CLAIMED`(已领未确认,非终态)→ 只补 confirm**;`CONFIRMED` / `CONSUMED` → 已签,不碰写接口。**claim 时积分即到账**,confirm 只推进状态,故 confirm 失败不丢积分、但 `checkin()` 仍返回 `ok=False` 让任务下一轮补。活动不存在 / 已过期一律 `ok=True`(不是错误);`ELIGIBLE` 但 `claimable=false` 或未知 status 归 `ok=False` 重试。业务码走 HTTP 200(如 `40001 尚未到达权益刷新时间`),不能只看状态码。`seal_until` 留空(上游只给活动总 `endTime`、无每日刷新窗口)。`checkin_scope` 用 `uid` 隔离,身份未知回落空串。**签到之外的活动操作未接入**。**额度=积分**(`parse_credits`):`GET {snap}/snap-manager/v1/statistics/plugin` 回 `metrics[]`,其中 `usageTotalPackageCredit` 是合计、其余(Basic/OnDemand/Bonus)是分项包,各含 `package_credit_amount/used/remain`;数值**本身即积分**(内置模型 GLM-5.2 / OpenPangu 等按积分计费,这是本渠道唯一的额度来源),**不做任何单位折算**。合计项直接进 `Quota.remaining/total`(健康度由它派生),分项包写进 `Quota.packages`(展示),顶层 `end_date`(实测每账号不同,如 `2026-11-02`)是套餐到期日、登记进 `Quota.expiry_ladder`(到期点=`end_date`)——积分用不完同样作废,故调度「快过期的先用」也会先烧积分。`metrics` 里取不到合计项、或合计项无有效数值时判**未探测**(`probe_failed=True`,绝不填 `remaining=0` 被误判「已耗尽」);积分接口失败只记 warning、不把整次探测判失败。**前缀必须带 `snap-manager`**(不带回 APIG.0101,与 `/v1/ops/*` 相反),头 `Agent-Type: PromptCenter`。官方只给「模型积分消耗系数」(GLM-5.2 0.7 / OpenPangu-2.0-Flash 0.32),**没给公式**,无法从 `input_price`/`ratio` 归约 → **不猜**,逐请求 `credit` 保持 `None`,真实消耗以积分余额为准。「保活」**只由 `RefreshTask` 承担**(先落库再同步):`refresh_token` 一次性,额度探测等旁路若也顺手刷新,同一 token 会被两处各消费一次、且旁路结果不落库 → 库里 token 被烧成废票(实测由此把渠道打成 `APIG.0602`)。`probe_quota` 因此为**只读余额**,不做保活刷新。 +`idempotentKey` 格式 `claim__`;头 `agent-type: PromptCenter`。**四态区分**:`ELIGIBLE` → claim + confirm;**`CLAIMED`(已领未确认)→ 只补 confirm**;`CONFIRMED`/`CONSUMED` → 已签。**claim 时积分即到账**,confirm 只推进状态,故 confirm 失败不丢积分但仍返回 `ok=False` 让下轮补。活动不存在 / 已过期 `ok=True`;`ELIGIBLE` 但 `claimable=false` 或未知 status → `ok=False`。业务码走 HTTP 200(如 `40001`)。`seal_until` 留空。`checkin_scope` 用 `uid`。**签到之外的活动操作未接入**。 -**倍率(`credit_rate`)**:模型目录 `GET {snap}/v1/model/builtin`(头 `Agent-Type: PromptCenter`)的每条目带 `credit[]`,取首档 `ratio_display`(如 `"0.7x"`)。模型列表**只走该内置目录**:空列表按协议违规上抛(`UpstreamProtocolViolation`,对外「上游无可用模型」),HTTP / 协议错误直接上抛由调用方退避。**登录**: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。登录后补身份:`CodeArtsOAuth._finish` 用签名身份接口(`caller_identity`,按区域不可用时回落 `current_user`)尽力补 `uid`/`user_name`/`nickname`(只补缺失项);失败不阻断登录。 +**额度=积分**(`parse_credits`):`GET {snap}/snap-manager/v1/statistics/plugin`(**前缀必须带 `snap-manager`**,头 `Agent-Type: PromptCenter`)回 `metrics[]`,`usageTotalPackageCredit` 是合计、其余(Basic/OnDemand/Bonus)是分项包。数值本身即积分,**不做单位折算**;合计进 `Quota.remaining/total`,分项进 `Quota.packages`,顶层 `end_date`(如 `2026-11-02`)登记进 `expiry_ladder`。取不到合计项或数值无效 → `probe_failed=True`(绝不填 0)。官方只给积分系数(GLM-5.2 0.7 / OpenPangu-2.0-Flash 0.32)无公式 → **不猜**,逐请求 `credit` 保持 `None`。「保活」只由 `RefreshTask` 承担(一次性 token 两处消费会烧成废票),`probe_quota` 只读余额。 + +**倍率**:`GET {snap}/v1/model/builtin`(头 `Agent-Type: PromptCenter`)每条带 `credit[]`,取首档 `ratio_display`(如 `"0.7x"`)。空列表按协议违规上抛。 + +**登录**: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`(服务端监听不到)→ **paste 轨道**:用户把打不开的回调链接粘回,`POST /api/auth/upstream/complete` 用 code + `code_verifier` + DPoP 私钥换 token。登录后用 `caller_identity`(不可用回落 `current_user`)尽力补 `uid`/`user_name`/`nickname`,失败不阻断。 + +**一次性票终态**:`client.refresh_token` 捕获 `TokenEndpointError` 后经 `events.relogin_required(body)` 判定,只认 `_RELOGIN_MARKERS`(`STS5.1806 the refresh token has been used` / `invalid client id` / `InvalidDPoPHeader` / `invalid_grant`)。网络 / 5xx / 限流 / 普通 4xx 仍可重试(误判会让偶发故障永久废掉好凭证)。`RefreshTask` 捕获后 `mark_relogin_required`:写可操作 `disabled_reason`、硬禁用、清模型级冷却。 + +**并发上限与滑动窗口**:上游每账号每约 60s 最多 3 个会话,超出 `HTTP 400` + `TM.00001041 并发会话数已达上限(3个)`(会话在 HTTP 结束后仍滞留,实测约 68s 恢复)。`Pacer` 按桶在途上限 `max_concurrency`(满则挂起等 `release`)+ 滑动窗口 `window_seconds`(同桶最近 N 秒最多放行 M 次)。装配 `lambda: runtime.codearts_max_concurrency`(默认 3)与 `lambda: runtime.codearts_request_window_seconds`(默认 60s,`0` 关闭窗口)。窗口模式配 `max_concurrency=0` 不生效。 + +**名额归还必须显式关流**:`async for ... break` 不关闭 async generator(只在耗尽 / `aclose()` / GC 时跑 `finally`)。executor 遇 `ERROR` 的 `break` 会让名额推迟归还,`_inflight` 单调累积 → 表现为「用了三次就限制」。故 `provider.base.aclose_stream` 统一关闭,`stream_guarded` / `stream` / `_stream_loop` / `complete` / `ContinuationStream.__aiter__` 五处提前结束处全部显式关闭。 + +白名单 `CODEARTS_ALLOWED_ENDPOINTS` 含 snap 引擎、STS、门户;`CODEARTS_CHAT_MIN_INTERVAL`(热更,默认 5s)独立 pacer。 -**一次性票终态(`UpstreamReloginRequired`)**:`refresh_token` 被消费 / 绑定项不符后,每次重试都是同一张废票。CodeArts 令牌端点在 `client.refresh_token` 捕获 `TokenEndpointError` 后经 `events.relogin_required(body)` 判定——只认 `_RELOGIN_MARKERS`(`STS5.1806 the refresh token has been used` / `invalid client id` / `InvalidDPoPHeader` / `invalid_grant`)这类**上游点名**终态;网络 / 5xx / 限流 / 绑定项齐全的普通 4xx 仍走可重试路径(误判终态会让一次偶发故障永久废掉一条好凭证)。`RefreshTask` 捕获后调 `mark_relogin_required`:写可操作的 `disabled_reason`(「刷新令牌已失效,需重新登录」)、硬禁用、清模型级冷却;下轮 `candidates()` 计入 skipped 不再重试。**并发上限与滑动窗口**:上游对**每账号并发会话数**有硬限(实测 3),超出直接 `HTTP 400` + `TM.00001041 并发会话数已达上限(3个)`。`Pacer` 按桶在途上限 `max_concurrency`(满则挂起等 `release` 让位;0/None 保持旧行为),CodeArts pacer 装配 `lambda: runtime.codearts_max_concurrency`(热更项,默认 3)。但上游限制的实为「**每账号每约 60s 最多 3 个会话**」(会话在 HTTP 流结束后仍滞留数十秒,实测约 68s 才恢复),纯在途上限挡不住突发,故 `Pacer` 再加**滑动窗口**口径:`window_seconds > 0` 时与 `max_concurrency` 组合成「同桶最近 `window_seconds` 秒内最多放行 N 次启动」,窗口内满额则挂起到最早一次启动滑出窗口。CodeArts pacer 装配 `window_seconds=lambda: runtime.codearts_request_window_seconds`(默认 60s;`0` 关闭窗口口径退回纯在途上限)。窗口准入**同时**受窗口与在途两个闸门约束;窗口模式配 `max_concurrency=0` 时不生效。**名额归还必须显式关闭上游流**:`async for ... break` **不关闭** async generator(CPython 只在耗尽 / 显式 `aclose()` / GC 的 asyncgen finalizer 时才跑 `finally`)。executor 遇 `EventKind.ERROR` 的 `break` 于是让节流名额推迟归还;轮换重试每次重新 `wait_turn`,`_inflight` 单调累积,满 `max_concurrency` 后新请求在 `wait_turn` 无限阻塞——表现为「用了三次就限制」而非「并发三」。故 `provider.base.aclose_stream` 统一关闭,`stream_guarded` / `stream` / `_stream_loop` / `complete` / `ContinuationStream.__aiter__` 五处提前结束消费处全部显式关闭上游流。**节点白名单**:`CODEARTS_ALLOWED_ENDPOINTS` 含 snap 引擎、STS、门户三个主机;AK/SK 签名请求只发往白名单。`CODEARTS_CHAT_MIN_INTERVAL`(热更项,默认 5s)独立 pacer。 ### 5.5 CodeBuddy 国际(`codebuddy_intl`) -**独立渠道而非「改 `CODEBUDDY_API_ENDPOINT` 切站点」**:endpoint 在本项目是**进程级单值**(`Settings` 单字段,装配时注入一个 client)。切过去会让所有存量国内站凭证立刻打到国际站——两站是各自注册的账号体系,额度与模型清单都不通用,表现为额度错配或 403。国际站作为并列渠道存在,两站凭证可同时在池里,各走各的端点、pacer 与登录流程。**协议零复制**:两站是**同一套后端**——同一 Keycloak realm(`copilot`)、同一组路径(`/v2/chat/completions`、`/v3/config`、`/v2/plugin/auth/*`、`/v2/billing/*`、`/billing/meter/*`)。故 `CodeBuddyIntlProvider` 是 `CodeBuddyProvider` 的**数据类子类**(`src/provider/codebuddy/intl.py`),只覆盖渠道 id 与三项能力;协议解析、凭证规范化、事件分类、签到、刷新全部共用国内站代码。**路由存在性的判据**:国际站网关对已注册路径回 `401`(openresty/APISIX 认证插件),对未注册路径回应用层 JSON `{"error_msg":"404 Route Not Found"}`,两者可区分。**但前缀级挂认证时 401 不携带信息**(`/v2/activity/*` 整个前缀挂认证,不存在的子路径也返回 401),此时必须另找同前缀下的对照路径。**唯一的行为差异:首条 system 强制注入**。国际站网关要求 `messages[0]` 是 system prompt,否则整单 `400` + `{"code":11128,"msg":"first message is not system prompt"}`。国内站无此要求。处置是 client 构造参数 `require_leading_system`,**只在国际站装配时开**:`ensure_leading_system` 仅在首条非 system 时补一条 `DEFAULT_LEADING_SYSTEM = "You are a helpful assistant."`。三条约束:① 判首条必须在 `developer → system` 归一**之后**(PI 等客户端把 system 转成 developer,归一已满足要求);② 客户端自带 system 时不注入、不覆盖、不重排;③ 正文取中性最小句且不带任何厂商指纹,否则撞 11128 内容风控。注入只改 deepcopy 出的出站副本,不穿透回 `request.raw`(会话粘性按原文算指纹)。**能力边界**: -- **签到在渠道侧就不存在**:路径存在(`/billing/meter/checkin-activity-status` 回 `code=0` 而非 404),但活动本身没有(`active: false`,`season`/`theme_name`/`start_time`/`end_time`/`activity_name` 全空;CLAIM 回 `{"code":10001,"msg":"签到活动未开启或已过期"}`)。全空是关键判据——这是**渠道级缺席**。处置:`checkin` / `checkin_scope` / `checkin_status` 三项置 `None`(三个探测点都要关)。**为什么必须关**:保留只会让后台任务每 10 分钟失败一次,连续失败触发 `task_failed` 噪音告警,把真实告警淹掉。 -- **成长中心未验证**:整个 `/v2/activity/*` 前缀挂在认证插件后(不存在的子路径也返回 401 而非 404),401 无法证明子路由存在;而成长中心含抽奖 / 连登兑换 / 盲盒等**不可逆**动作,在未验证的端点上盲跑会真实消耗账号权益。处置:`growth` 置 `None`——调用方一律用 `getattr(provider, "growth", None)` 探测,置 `None` 即自动跳过,无需在任务层加渠道分支;管理台的菜单项本就按 `credential.provider === "codebuddy"` 判定,对本渠道天然不出现。 -- **活跃上报启用**:上报打的端点是 `/v2/report`,与成长中心的 `/v2/activity/*` **不是同一路径**——国际站 `/v2/report` 带凭证回 `200 {"code":0}`,同批探针里 `/v2/report-nope` 回应用层 `404 Route Not Found`(两者可区分)。`activity_scope` 含 endpoint(`https://www.codebuddy.ai|`),故两站不会互相占位。 +**独立渠道而非切 `CODEBUDDY_API_ENDPOINT`**:endpoint 是进程级单值(`Settings` 单字段),切过去会让存量国内凭证立刻打到国际站(两站独立账号体系,额度与模型清单不通用)。 + +**协议零复制**:同一套后端(同一 Keycloak realm `copilot`、同一组路径 `/v2/chat/completions`、`/v3/config`、`/v2/plugin/auth/*`、`/v2/billing/*`、`/billing/meter/*`)。故 `CodeBuddyIntlProvider` 是 `CodeBuddyProvider` 的数据类子类(`provider/codebuddy/intl.py`),只覆渠道 id 与三项能力。**路由存在性判据**:已注册路径回 401(openresty/APISIX),未注册回 `{"error_msg":"404 Route Not Found"}`;但前缀级挂认证时 401 不带信息(`/v2/activity/*` 整前缀挂认证),须另找同前缀对照路径。 + +**唯一行为差异:首条 system 强制注入**。国际站要求 `messages[0]` 是 system,否则 `400` + `{"code":11128,"msg":"first message is not system prompt"}`。client 构造参数 `require_leading_system` 只在国际站开;`ensure_leading_system` 仅在首条非 system 时补 `DEFAULT_LEADING_SYSTEM = "You are a helpful assistant."`。三条约束:① 判首条必须在 `developer → system` 归一**之后**;② 客户端自带 system 不注入、不覆盖、不重排;③ 正文不带厂商指纹(否则撞 11128)。只改 deepcopy 出的出站副本,不穿透回 `request.raw`。 + +**能力边界**: +- **签到渠道级缺席**:路径存在(`/billing/meter/checkin-activity-status` 回 `code=0`),但活动没有(`active: false`,`season`/`theme_name`/`start_time`/`end_time`/`activity_name` 全空;CLAIM 回 `{"code":10001,"msg":"签到活动未开启或已过期"}`)。故 `checkin` / `checkin_scope` / `checkin_status` 三项置 `None`(保留只会每 10 分钟失败一次,触发 `task_failed` 噪音)。 +- **成长中心未验证**:`/v2/activity/*` 整前缀挂认证,401 无法证明子路由存在,而成长含不可逆动作。`growth` 置 `None`,调用方 `getattr(..., None)` 自动跳过;管理台菜单按 `credential.provider === "codebuddy"` 判定,天然不出现。 +- **活跃上报启用**:`/v2/report` 与 `/v2/activity/*` 不同路径,国际站回 `200 {"code":0}`(`/v2/report-nope` 回 404)。`activity_scope` 含 endpoint,两站不互相占位。 -**模型合并:修渠道内重名导致的伪渠道后缀**:上游把多个模型共用一个展示名是常态(CB 的 `hy4-preview` / `hy4-preview-x` 都叫「Hy4 preview」),`_merge_key` 一遇到渠道内展示名重名就退回**渠道限定键**,代价是这些模型**在本渠道内**就断了跨渠道合并。改判据:重名时若本渠道**存在某个 id 归一后等于该 label key 的模型**,就用这个归一键当合并键(重名者 id 不同故自然分开,且不算「按名入键」故不登记歧义别名);没有 id 自洽候选时(TRAE 的 `sagitta` / `aquila` 组)仍退回限定键。注意 `-x` / `-sg` 是**真实存在的不同上游模型**(id 不同、`max_input_tokens` 有差异),不能被合并掉。**白名单互不含**:`CODEBUDDY_INTL_ALLOWED_ENDPOINTS` 默认只有 `https://www.codebuddy.ai`,**不含**国内站主机。两站 Token 由不同网关签发,跨站发送即凭证泄露,故把国内站地址填进 `CODEBUDDY_INTL_API_ENDPOINT` 会在**启动时**抛 `ValueError`。**pacer 与 OAuth 各自独立**:`CODEBUDDY_INTL_CHAT_MIN_INTERVAL`(热更项,默认 5s)装一个独立 `Pacer`——共用会让两站请求排进同一条队列互相空等最小间隔。`_upstream_auth` 对两个渠道各建一套 `CodeBuddyOAuth`(登录域名不同)。`_pacer_key` 带渠道 id 构造,即便将来共用 Pacer 也不会互相占位。 +**模型合并:修渠道内重名导致的伪渠道后缀**:上游常共用展示名(`hy4-preview` / `hy4-preview-x` 都叫「Hy4 preview」)。改判据:重名时若本渠道**存在 id 归一后等于该 label key 的模型**,就用该归一键当合并键;无 id 自洽候选时(TRAE 的 `sagitta`/`aquila`)仍退回限定键。`-x` / `-sg` 是**真实不同模型**,不可合并。 -### 5.6 OfficeAce(`officeace`,华为云果办 / AgentArts) -**两套凭据、两个面、两种签名**——这是本渠道唯一的复杂度来源。聊天面(`{gateway}/v2/chat/completions`、`/v2/models`)用**永不过期的** `Authorization: Basic :`;控制面(`/v1/subscription*`、`/v1/model-token-rates`、`/v1/claw/*`)用**约 2h 有效期**的临时 AK/SK + `X-Security-Token`。故凭证结构里两组字段并存,`has_control_plane` 判定控制面能力(只有 app key 的凭证能聊天但不能签到 / 探额度,`probe_quota` 回 `probe_failed` 而非 0,否则会被调度器当耗尽)。**两条签名轨道不能互换**:控制面 `/v1/claw/*` 是华为云标准 `SDK-HMAC-SHA256`(复用 `codearts.signer`);模型网关的 `/v1/subscription*` 与 `/v1/model-token-rates` 只认 **`V11-HMAC-SHA256`**(多一层 CredentialScope `YYYYMMDD/{region}/apic`,且签名密钥要派生)。V11 三处必须原样:① 派生外层 key=**AK**、msg=**SK**(与直觉相反);② `HMAC(HMAC(AK,SK), scope+0x01)[:32]` 截断后转 **hex 文本**当下一轮密钥(不是原始字节);③ **不要**加 `X-Sdk-Content-Sha256` 头。`CanonicalURI` 每段 percent-encode 且末尾补 `/`,**query 必须参与签名**(`/v1/subscription/usage?sku_attr_code=…`),`X-Project-ID` 有则计入 SignedHeaders。写错任一处的回包都是恒 401 `verify ak sk signature fail` / `APIG.0301`,不告诉你哪一步错。 +**白名单互不含**:`CODEBUDDY_INTL_ALLOWED_ENDPOINTS` 默认只有国际站;填国内站地址会在**启动时**抛 `ValueError`(跨站发送即凭证泄露)。**pacer 与 OAuth 各自独立**:`CODEBUDDY_INTL_CHAT_MIN_INTERVAL`(热更,默认 5s)独立 `Pacer`,`_pacer_key` 带渠道 id;两渠道各建一套 `CodeBuddyOAuth`。 -**网关地址归一**:`client-permission-validate` 下发的 `api_base` 带 `/v2`(`https://modelgw-XXXX.…/v2`),而 `models.json` 里存的不带。`_gateway_of()` 统一去掉尾部 `/v2` 再拼端点,否则聊天路径拼出 `/v2/v2/chat/completions`。**每账号一个网关主机**,故它不进 `OFFICEACE_ALLOWED_ENDPOINTS`(白名单只管控制面,带临时 AK/SK 签名的请求只发往白名单主机;网关是上游签发并存在凭证里的值,不是本服务能配错的东西)。 +### 5.6 OfficeAce(`officeace`) +**两套凭据、两个面、两种签名**。聊天面(`{gateway}/v2/chat/completions`、`/v2/models`)用**永不过期**的 `Authorization: Basic :`;控制面(`/v1/subscription*`、`/v1/model-token-rates`、`/v1/claw/*`)用**约 2h** 的临时 AK/SK + `X-Security-Token`。`has_control_plane` 判定控制面能力(只有 app key 的凭证能聊天但不能签到 / 探额度,`probe_quota` 回 `probe_failed` 而非 0)。 -**错误分类**:限流走**伪 200**(HTTP 200 + `data:{"error":{...}}`),且有第二种形态——首帧正常、第二帧才 error,故不能在首帧成功后停止检查。`81004`(无该模型权限)/ `81009`(模型名不认)→ `ErrKind.BLOCKED`,登记**模型级**负缓存(同渠道同模型的其余凭证一并跳过,成功一次即撤销);`81111`/`81112`/`81114` → 限流;`14018` → `ErrKind.CREDIT`。**不做主动模型探活**(探活消耗积分);模型目录只按 `/v2/models` 列表 + 用户实际请求的成败收敛。 +**两条签名轨道不能互换**:控制面 `/v1/claw/*` 用 `SDK-HMAC-SHA256`(复用 `codearts.signer`);模型网关的 `/v1/subscription*` 与 `/v1/model-token-rates` 只认 **`V11-HMAC-SHA256`**(多一层 CredentialScope `YYYYMMDD/{region}/apic`,密钥要派生)。V11 三处必须原样:① 派生外层 key=**AK**、msg=**SK**;② `HMAC(HMAC(AK,SK), scope+0x01)[:32]` 转 **hex 文本**当下一轮密钥;③ **不加** `X-Sdk-Content-Sha256` 头。`CanonicalURI` 每段 percent-encode 且末尾补 `/`,**query 必须参与签名**,`X-Project-ID` 有则计入 SignedHeaders。写错恒 401 `verify ak sk signature fail` / `APIG.0301`。 -**`max_tokens` 改写**(`prepare_body` → `clamp_max_tokens`):上游对推理模型有「思考预算不进 `max_tokens`」的行为,客户端给 64 会被思考吃光、回空正文。故 `>= 16` 一律追加 1024 headroom 再夹到模型上限;`< 16` 原样透传(用户明确要极短);未给不补。`max_completion_tokens` 归一成 `max_tokens`(上游只认后者)。上限表:`glm-5*` 131072、`qwen3*` 65536、其余 393216。 +**网关地址归一**:`client-permission-validate` 下发的 `api_base` 带 `/v2`,`_gateway_of()` 统一去掉尾部 `/v2` 再拼端点(否则 `/v2/v2/chat/completions`)。每账号一个网关主机,故不进 `OFFICEACE_ALLOWED_ENDPOINTS`(白名单只管控制面)。 -**积分即主账**:额度单位 `officeace_points`,`Quota.remaining`/`total` 直接是积分(没有并行的 token 池)。来源两笔:新用户一次性 4000 + **每日签到 1000**,各自独立到期,故口径写「逐笔到期」而非本周期。**不做 credit 估算**:上游给了 `cost_coefficient` 与 `tokens_per_point`,但二者的叠乘关系官方未说明,猜错会让跨渠道额度排序整体失真。签到 `POST {gateway}/v1/subscription/bonus/claim` 是**写操作**(调用即真发积分),body 必须是空 JSON 对象 {} + `Content-Type: application/json`——官方调用点是无 body 的 POST,但实测那样回 500 `OfficeAce.11020001`。上游对当日重复领取幂等。收获只能按 `bonus_skus` 的**剩余量增量**算:`points` 是活动累计发放量,由多个渠道(桌面端签到、活动页手动领)共同贡献,拿它当本次收获会虚高。`seal_until` = 下个北京时间 0 点,次日新活动出现即自动补签。`checkin_scope` 用 `account_id`,身份未知返回**空串**(返回 `officeace|` 会让同渠道所有未知身份凭证算同一 scope,任务只跑第一个)。 +**错误分类**:限流走**伪 200**(HTTP 200 + `data:{"error":{...}}`),且有「首帧正常、第二帧才 error」形态,故不能首帧成功后停检查。`81004`(无模型权限)/ `81009`(模型名不认)→ `BLOCKED` 模型级负缓存;`81111`/`81112`/`81114` → 限流;`14018` → `CREDIT`。不做主动探活(耗积分),模型目录按 `/v2/models` + 实际请求成败收敛。 -**登录走 poll 轨道**:授权回调由**云端**承接(`/v1/claw/auth/callback`),服务端不需要开本地端口、也不需要用户粘链接——先在 `POST /v1/claw/auth/state` 要 state(**本地造的不被认**,回 `AgentArts.11000004`),再把 PKCE challenge + DPoP 公钥拼成 authorize URL,套一层 `{auth_host}/authui/login.html?service=`;随后轮询 `GET /v1/claw/auth/code?state=`,202(`AgentArts.11000009`)表示用户还没点完,拿到 code 后走 `POST /v1/oauth2/tokens`(authorization_code + DPoP)换临时 AK/SK,再调 `client-permission-validate` 校验是否**真的开通了模型网关**(未开通回 `subscription: null`,此时明确报错而不是存一条永远打不通的凭证),最后取 `model_app_key`/`model_app_secret` 与网关地址一并入库。`AuthStateStore` 按 **username** 分键做 reservation(`begin`/`owner`/`session_entry`/`touch`/`consume`/`cancel`/`cleanup`,带 TTL 与 pending 上限):单槽全局态会让两个管理员的并发登录互相覆盖,把 A 的凭证记到 B 名下。并发轮询同一 state 时 `consume` 只有一路成功,另一路抛错——否则同一账号会出现两条重复凭证。`force_login` 走 `/authui/logout?service=…`(浏览器里还有华为云会话时云端会直接放行当前账号,用户看不到登录页);默认 False,管理台「新增」不传。 +**`max_tokens` 改写**(`clamp_max_tokens`):`>= 16` 追加 1024 headroom 再夹到模型上限;`< 16` 原样;未给不补。`max_completion_tokens` 归一成 `max_tokens`。上限表:`glm-5*` 131072、`qwen3*` 65536、其余 393216。 -**配置**:`OFFICEACE_API_ENDPOINT` / `OFFICEACE_ALLOWED_ENDPOINTS` 只管控制面;`OFFICEACE_CHAT_MIN_INTERVAL`(热更,默认 0——上游没有 CodeArts 那种硬并发限)装独立 `Pacer`,与其余渠道互不排队。登录流量(每次登录几十次短轮询)与聊天流量用**独立连接池**,但共用同一个出站代理设置。**单账号单用户**:官方《套餐管理》写明「个人版面向个人用户,限制单账号单用户使用」,故不做多人共用。 +**积分即主账**:单位 `officeace_points`,`Quota.remaining`/`total` 直接是积分。来源:新用户一次性 4000 + 每日签到 1000,各自独立到期(口径写「逐笔到期」)。**不做 credit 估算**:`cost_coefficient` 与 `tokens_per_point` 的叠乘关系官方未说明。签到 `POST {gateway}/v1/subscription/bonus/claim` 是**写操作**,body 必须是空 JSON `` + `Content-Type: application/json`(无 body 回 500 `OfficeAce.11020001`);当日重复领取幂等。收获按 `bonus_skus` 的**剩余量增量**算(`points` 是活动累计发放量,会虚高)。`seal_until` = 下个北京时间 0 点。`checkin_scope` 用 `account_id`,身份未知返回**空串**。 + +**登录走 poll 轨道**:回调由**云端**承接(`/v1/claw/auth/callback`),无需本地端口或粘链接。先在 `POST /v1/claw/auth/state` 要 state(本地造的回 `AgentArts.11000004`),把 PKCE challenge + DPoP 公钥拼成 authorize URL,套 `{auth_host}/authui/login.html?service=`;轮询 `GET /v1/claw/auth/code?state=`(202 = 未完成),拿 code 后 `POST /v1/oauth2/tokens`(authorization_code + DPoP)换临时 AK/SK,再调 `client-permission-validate` 校验是否真开通(未开通 `subscription: null` 时明确报错),最后存 `model_app_key`/`model_app_secret` 与网关地址。`AuthStateStore` 按 **username** 分键做 reservation(带 TTL 与 pending 上限):单槽全局态会让两个管理员并发登录互相覆盖。并发轮询同一 state 只有一路 `consume` 成功,否则会出现重复凭证。`force_login` 走 `/authui/logout?service=…`,默认 False。 + +**配置**:`OFFICEACE_API_ENDPOINT` / `OFFICEACE_ALLOWED_ENDPOINTS` 只管控制面;`OFFICEACE_CHAT_MIN_INTERVAL`(热更,默认 0)独立 pacer。登录流量与聊天用**独立连接池**,共用出站代理。**单账号单用户**(官方《套餐管理》限制)。 ## 6. 调度器 ### 6.1 选号规格 @@ -346,61 +419,59 @@ class Scheduler: ``` 排序键(`_free_first_rank` 起,字典序): - -1. **免费优先档**:候选中存在 `credit_rate == 0` 的渠道时,免费渠道整体排到所有付费渠道之前(`None` = 未知不算免费)。仅当本次候选带有倍率数据(executor 从模型目录现查注入)才生效;候选池无免费渠道时该档恒 0。 -2. **到期额度排序(两级字典序)**:`expiry_ladder` 中「距到期 ≤ 主窗口」(`QUOTA_EXPIRY_WINDOW_SECONDS`,默认 36h)的额度加总,多的先用;打平再比次窗口(默认 7 天);渠道无到期信息(如 CB 企业版)计 0;主窗口 ≤0 时次窗口一并失效(`expiry_windows()` 统一折算)。CodeBuddy、TRAE(按包独立到期)与 CodeArts(积分包按套餐 `end_date` 到期)均落阶梯参与此排序。 +1. **免费优先档**:候选存在 `credit_rate == 0` 时免费渠道整体排前(`None` 不算免费)。仅当候选带倍率数据才生效。 +2. **到期额度(两级字典序)**:`expiry_ladder` 中「距到期 ≤ 主窗口」(`QUOTA_EXPIRY_WINDOW_SECONDS`,默认 36h)的额度加总,多者先用;打平比次窗口(默认 7 天);无到期信息计 0;主窗口 ≤0 时次窗口一并失效。CB、TRAE、CodeArts(积分包按 `end_date`)均落阶梯。 3. **健康度三态**:`known(0-100) DESC > unknown > exhausted(-1)`。 -4. **账户剩余积分**(`quota_remaining`)多者优先——只作健康度的**打平键**,不会越级压过低健康度的高余额号。 +4. **账户剩余积分**多者优先——只作健康度**打平键**。 5. 仍同分按 `credential_id` 稳定。 -**手动 pin 优先**:存在 `pinned` 凭证时只在 pinned 之间排序(管理员显式指定优先于其余排序)。到期积分只算一处:`expiring_credits()`。选号走 `Candidate.expiry_credits()`(主/次两级各调一次),管理台列表走 `GET /api/credentials` 的 `quota_expiring_credits` / `quota_expiring_credits_secondary`(两个窗口值随响应返回),两处共用同一实现与同一个 `expiry_windows()` 折算,界面数字与选号顺序不会漂移。渠道无到期信息时返回 `null`(不显示);窗口关闭或确实无积分临近过期时返回 `0`(同样不显示)。CodeArts 的到期积分来自它自己的 `expiry_ladder`(积分包按套餐 `end_date` 到期),与其它渠道同一口径,`quota_expiring_credits` / `_secondary` 即两个窗口值。 +**手动 pin 优先**:存在 `pinned` 时只在 pinned 之间排序。到期积分只算一处 `expiring_credits()`:选号走 `Candidate.expiry_credits()`,列表走 `GET /api/credentials` 的 `quota_expiring_credits` / `_secondary`,共用 `expiry_windows()` 折算。无到期信息返回 `null`(不显示),窗口关闭或确实无临近过期返回 `0`(同样不显示)。 + +> **TRAE 也落 `quota_expiry_ladder`**(权益包各自独立到期),两列同时填,口径「未过期 + 有余额」。`quota_cycle_end` 仍为 `NULL`(各包未必共享重置点)。 -> **TRAE 也落 `quota_expiry_ladder`**:权益包各自独立到期(每月登录积分按月、签到奖励各有到期日),与 CodeBuddy 同构,两列同时填、口径统一为「未过期 + 有余额」。`quota_cycle_end`(单值「最早到期」)TRAE 仍为 `NULL`:各包未必共享一个重置点,阶梯已表达「哪些包何时到期」。 ### 6.2 候选收窄 -`Executor._narrow_providers`:**候选只信模型目录缓存列表**。目录能证明某模型归属哪些渠道时,候选渠道就只有它们——登记了该模型的渠道全部不可用(都在冷却 / 都被暂停)时直接返回 503,不再把请求逐个渠道试过去(各上游只会回 11102 / 4001 / 401,还会给渠道写下数小时的「该后端无此模型」负缓存)。目录未就绪 / 模型完全不在目录里时仍按全部候选渠道路由(保守);`模型@渠道` 强制指定与 Key 渠道绑定不受影响。新鲜度上限是模型列表 TTL 300s + 后台兜底刷新——新渠道最坏 300s 后进候选,不存在「漏试真正持有它的新渠道」。 +`Executor._narrow_providers`:**候选只信模型目录缓存**。目录能证明归属时,候选渠道只有它们——全部不可用直接 503,不再逐渠道试(各上游只会回 11102 / 4001 / 401,还会写下数小时负缓存)。目录未就绪或模型不在目录里按全部候选路由;`模型@渠道` 与 Key 绑定不受影响。新鲜度上限为 TTL 300s(`MODEL_LIST_TTL_SECONDS`)+ 后台兜底刷新。 + ### 6.3 模型级冷却 -`credential_model_cooldowns(credential_id, model, cooling_until, hits, reason)` 按 **(凭证, 模型)** 独立建表——账号级 `cooling_until` 放不下「同账号其他模型仍可用」这层语义。`model_probes(provider, model, state, failures, last_probe_at, last_ok_at, last_error, manual, updated_at)` 是**观测记录**,与冷却表严格分工:冷却表是调度权威状态(真实请求的失败驱动,直接决定选号),探活表只供管理台展示——把探测结论写进冷却表会让一次探测直接改变选号结果,而「探测失败」未必是模型本身的问题(额度、限流、网络都会失败)。 - -- 登记的名字是**该凭证所属上游的原始模型名**(`_upstream_model(provider_id, model)`,未知则原样),因为 CB 与 TRAE 的注册名大小写变体不同,用归一名会漏判。 -- 选号路径:`executor._select` 逐凭证过滤 `is_selectable(now, 该凭证的模型名)` 并把模型目录里当前模型的倍率注入候选快照(`Candidate.credit_rate`),再交给 `Scheduler.select`;同时完成模型收窄、粘性命中与排序(`_sticky(...) or scheduler.select(...)`)。 -- 退避:`MODEL` 基数 10m 起翻倍、封顶 2h;`BLOCKED` 6h 起翻倍、封顶 24h;`CONCURRENCY` 固定 60s 短冷却、不翻倍(打满是瞬态信号)。换 reason 重新计数(基数与封顶不同,沿用对方 hits 会得到错误的第三种时长)。 -- 清除:`save_success(..., model=)` 只删 `reason='blocked'`(模型限流按上游重置,成功一次不代表限制解除);`revive` / 删除凭证 / 账号级冷却出现都清模型条目。 -- 回流:留存任务每轮 `purge_expired_model_cooldowns()` 回收过期行;管理台列表只下发未过期条目。 -- 无模型名可归因时(流内事件未带 model)退化为账号级 SOFT,不写孤儿记录。 -- **进入模型级冷却的来源不止「模型限流」**:Qoder 把自己节点故障包成 400(见 §5.3)也归 `MODEL`,同样只锁该模型。耗尽轮换时若**所有候选都只因该模型的模型级冷却被排除**(`_all_model_cooled`),503 文案用 `model 'x' temporarily unavailable on upstream` 而非 `all credentials unavailable`——避免把「换个模型就好」误导成「整池凭证挂了」。**统计同口径分流**(`_exhaust_error_type`,与文案同一判据):该情形落 `error_type=model_unavailable`(界面「该模型暂时不可用」),其余耗尽仍 `no_healthy_credential`(「无可用凭证」);**对外错误码不变**(仍 `no_healthy_credential`,Anthropic/Responses 出口的错误码映射依赖它)。 +`credential_model_cooldowns(credential_id, model, cooling_until, hits, reason)` 按 **(凭证, 模型)** 建表——账号级 `cooling_until` 放不下「同账号其他模型仍可用」。`model_probes(...)` 是**观测记录**:冷却表是调度权威(真实失败驱动),探活表只供展示(探测失败未必是模型问题)。 + +- 登记名是**该凭证所属上游的原始模型名**(`_upstream_model`),CB 与 TRAE 大小写变体不同,归一名会漏判。 +- 选号路径:`executor._select` 逐凭证过滤 `is_selectable(now, 模型名)` 并注入 `Candidate.credit_rate`,再交 `Scheduler.select`;同时完成收窄、粘性命中与排序(`_sticky(...) or scheduler.select(...)`)。 +- 退避:`MODEL` 10m 起翻倍封顶 2h;`BLOCKED` 6h 起翻倍封顶 24h;`CONCURRENCY` 固定 60s 不翻倍。换 reason 重新计数。 +- 清除:`save_success(..., model=)` 只删 `reason='blocked'`;`revive` / 删除凭证 / 账号级冷却出现都清模型条目。留存任务每轮 `purge_expired_model_cooldowns()`;列表只下发未过期。 +- 无模型名可归因时退化为账号级 SOFT。 +- **进入模型级冷却的来源不止模型限流**:Qoder 节点故障(§5.3)也归 `MODEL`。全部候选只因该模型被排除时(`_all_model_cooled`)503 文案用 `model 'x' temporarily unavailable on upstream`;统计落 `error_type=model_unavailable`,其余耗尽仍 `no_healthy_credential`;**对外错误码不变**。 + ### 6.4 跨渠道 fallback 兼容组 -**配置**(热更项 `MODEL_FALLBACK_GROUPS`,默认 `""` 关闭):`组名=成员1,成员2;组名2=成员3`。 +热更 `MODEL_FALLBACK_GROUPS`(默认 `""` 关闭):`组名=成员1,成员2;组名2=成员3`。 -- **纯函数层**(`model_resolver.py`):`parse_fallback_groups` 宽容解析(空段 / 缺 `=` / 空组名 / 空成员一律跳过,一行坏配置不会让网关起不来);`ordered_fallback_chain(model, groups)` 只做**映射**不校验模型是否存在:组名是**入口别名、不进链**,请求成员时该成员置首、组内其余成员按配置顺序跟随(大小写不敏感)。 -- **执行层**(`executor.py`):`_fallback_chain(target)` 把组解析成 `ModelTarget` 元组。 - - `@渠道` 与 API Key 渠道绑定(`target.forced`)**不参与回退**。 - - 目录可用时**剔除不在任何候选渠道登记的回退成员**(「兼容组白名单」);目录未就绪则全部放行。成员按名去重。 - - 链首就是用户请求的那个模型时无论目录是否认识都保留。 -- **非流式** `complete` 逐链项走完整的选号 / 冷却 / 轮换 / 会话粘性 / 统计,捕获 `NoHealthyCredential` / `InvalidRequest` 继续下一项,整条链耗尽才以最后一项的错误抛出。 -- **流式** `stream` 逐链项跑 `_stream_loop`,**只有在本链项尚未产出任何响应帧之前**才允许切换——已出帧后换模型会让客户端看到两个模型的混合输出。用私有信号 `_ModelExhausted` 在链内传递「本项未出帧即用尽」;整条链都用尽时用**最后链项**的 `translator` 产出终帧。 -- `preflight` 按**整条链**判断是否有注册上游。 +- **纯函数层**(`model_resolver.py`):`parse_fallback_groups` 宽容解析(空段 / 缺 `=` / 空组名 / 空成员跳过);`ordered_fallback_chain` 只做映射不校验存在性:组名是**入口别名、不进链**,请求成员置首、其余按配置顺序跟随(大小写不敏感)。 +- **执行层**:`@渠道` 与 Key 绑定(`target.forced`)不参与回退;目录可用时剔除不在任何候选渠道登记的回退成员,未就绪全放行;成员按名去重;链首即请求模型时无条件保留。 +- **非流式** `complete` 逐链项走完整选号 / 冷却 / 轮换 / 粘性 / 统计,捕获 `NoHealthyCredential` / `InvalidRequest` 继续,整链耗尽才抛最后一项错误。 +- **流式** `stream` 逐链项跑 `_stream_loop`,**只有在本项尚未产出任何响应帧前**才允许切换(已出帧会混合两个模型输出)。用 `_ModelExhausted` 传递「未出帧即用尽」;整链用尽用**最后链项**的 `translator` 产终帧。 +- `preflight` 按整条链判断是否有注册上游。 ### 6.5 凭证限额护栏 -`credential_limits(credential_id PK, keep_credits, daily_tokens, daily_credits, per_model_tokens JSON, updated_at, updated_by)`——**缺行 = 不受限**(老库升级后行为不变)。 +`credential_limits(credential_id PK, keep_credits, daily_tokens, daily_credits, per_model_tokens JSON, updated_at, updated_by)`——**缺行 = 不受限**。 -- **判定**(`src/engine/limits.py::evaluate`,纯函数):`keep_credits` 用 `quota_remaining ≤ keep_credits` 停用整号(与当日用量无关,余额未知不猜);`daily_tokens` / `daily_credits` 用当日累计 `≥ 上限` 停用整号;`per_model_tokens` 命中的模型只停该模型(`blocked_models`)。`None` / `≤0` = 该护栏关闭。 -- **当日口径**:北京自然日 0 点(`day_start`,`ZoneInfo("Asia/Shanghai")`),与签到 / 活跃上报同一时区。 -- **用量聚合**(`src/db/limits.py::day_usage`):按 `credential_id` 聚合 `usage_events`(`input+output` token、`credit`、按模型 token)。**不用 `usage_hourly`**:小时汇总主键没有 `credential_id`,无法还原到凭证维度。失败请求也计入。 -- **挂钩点**:`Executor._selectable` 在 `is_selectable` 之后按 `deps.limits.blocked(candidate, target.model)` 二次过滤。**刻意不写进 `Candidate.is_selectable`**:判定依赖每请求都变的「当日聚合用量」,而 `is_selectable` 是纯函数(只吃凭证自身状态);塞进去会让 `pool_counts` / 池健康计数也去查一遍用量。按模型上限比对的是**归一名**(`usage_events.model` 就是归一名),不是上游原始名。 -- **快照缓存**:`LimitGuard` 对「限额表 + 当日用量」做进程内快照(TTL 15s);无任何限额配置时**不查**用量聚合。管理台写入后调用 `invalidate()` 立即生效,不等 TTL。 -- **错误语义**:全部候选都因限额被跳过时走「无可用凭证」`no_healthy_credential`(账号级原因)——`_all_model_cooled` 判据不含限额,故不会被误判成 `model_unavailable`。 +- **判定**(`engine/limits.py::evaluate`,纯函数):`keep_credits` 用 `quota_remaining ≤ keep_credits` 停整号(余额未知不猜);`daily_tokens` / `daily_credits` 用当日累计 `≥ 上限` 停整号;`per_model_tokens` 只停该模型。`None` / `≤0` = 关闭。 +- **当日口径**:北京自然日 0 点(`ZoneInfo("Asia/Shanghai")`)。 +- **用量聚合**(`db/limits.py::day_usage`):按 `credential_id` 聚合 `usage_events`(token、credit、按模型 token)。**不用 `usage_hourly`**(主键无 `credential_id`)。失败请求也计入。 +- **挂钩点**:`Executor._selectable` 在 `is_selectable` 之后按 `deps.limits.blocked(...)` 二次过滤。**不写进 `Candidate.is_selectable`**(后者是纯函数,塞进去会让 `pool_counts` 也查用量)。按模型比对的是**归一名**。 +- **快照缓存**:`LimitGuard` 对「限额表 + 当日用量」做进程内快照(TTL 15s);无配置不查用量。写入后 `invalidate()` 立即生效。 +- **错误语义**:全部因限额跳过 → `no_healthy_credential`(`_all_model_cooled` 判据不含限额)。 ### 6.6 凭证在途租约 -`MAX_INFLIGHT_PER_CREDENTIAL`(全局热更,默认 `0` = 不限):同一凭证的并发请求达到上限后不再参与选号,避免把单账号打进上游限流 / 封号(同类项目的 `pool.max_in_flight` / `QODER_MAX_CONCURRENCY`)。**在途计数兼作负载键**:`Executor._selectable` 把在途数注入 `Candidate.inflight`,调度器按「最少连接」排(在 `Scheduler.select` 排序链里,免费档之后、到期额度之前),并发请求自动摊到空闲号;上限为 0(不限)时 `blocked()` 恒 False 但租约照常登记,故不限并发时负载键仍可用。 +`MAX_INFLIGHT_PER_CREDENTIAL`(全局热更,默认 `0` 不限):达到上限后该凭证不再参与选号。**在途计数兼作负载键**:`Executor._selectable` 注入 `Candidate.inflight`,调度器按「最少连接」排(在免费档之后、到期额度之前)。上限为 0 时 `blocked()` 恒 False 但租约照常登记。 -- **实现**(`src/engine/inflight.py`):`InflightTracker` 是 `credential_id → 活跃租约开始时刻列表` 的**进程内**计数;`InflightGuard` 包一层热更上限。租约只记开始时刻、不绑请求标识——同一凭证的多个租约彼此等价,释放任意一个即可(先入先出),调用方在请求两侧各调一次,不必在轮换 / fallback 的多层 `finally` 里传递句柄。 -- **为什么同步(不用 `asyncio.Lock`)**:`acquire` / `release` / `count` 只在 executor 的事件循环里调用,且「选号 → 登记」之间没有任何 `await`(选号、压缩、创建上游迭代器都是同步的),单线程下不会交错。加异步锁反而会要求 `_selectable` 变成 async,把一个纯过滤函数染上异步。 -- **TTL 兜底**(`LEASE_TTL_SECONDS = 600`):正常路径由 `finally` 释放;生成器被 GC、异常路径漏走释放时租约会滞留并慢慢占满号。超过 TTL 的租约在计数 / 登记时视为过期并回收——宁可偶尔多放一个请求,也不能让号被永久占住。 -- **挂钩点**:`Executor._selectable` 在 `is_selectable` / 限额之后按 `deps.inflight.blocked(credential_id)` 过滤;`_stream_loop` 与 `_complete_model` 在创建上游迭代器后 `_lease`、统一在 `finally` 里 `_release`(覆盖成功、轮换、异常、客户端断开)。全部候选都占满时与限额同口径报 `no_healthy_credential`。 -- **与 `tasks/pacer.py` 的 `max_concurrency` 的区别**:pacer 的桶按渠道分、只覆盖走 pacer 的渠道;这里是**跨渠道的凭证级**上限,语义是「同一个账号别被我方并发压垮」,与渠道节流正交。 +- **实现**(`engine/inflight.py`):`InflightTracker` 是 `credential_id → 活跃租约开始时刻列表` 的进程内计数;`InflightGuard` 包一层热更上限。租约只记开始时刻、不绑请求标识,先入先出,释放任意一个即可。 +- **为什么同步**:`acquire` / `release` / `count` 只在事件循环里调用,且「选号 → 登记」之间无 `await`;加异步锁会让 `_selectable` 变 async。 +- **TTL 兜底**(`LEASE_TTL_SECONDS = 600`):正常由 `finally` 释放;漏走时租约滞留,超 TTL 在计数 / 登记时回收。 +- **挂钩点**:`_selectable` 在 `is_selectable` / 限额之后过滤;`_stream_loop` 与 `_complete_model` 在创建上游迭代器后 `_lease`,`finally` 里 `_release`。全满时与限额同口径 `no_healthy_credential`。 +- **与 pacer `max_concurrency` 的区别**:pacer 桶按渠道分、只覆盖走 pacer 的渠道;这里是**跨渠道的凭证级**上限,与渠道节流正交。 ## 7. 请求时序(chat completions 主链路) -> 可视化版本:[`diagrams/coding2api-request-sequence.html`](diagrams/coding2api-request-sequence.html)。`POST /v1/responses` 与主链路同:先把请求体映射成等价的 `ChatRequest`,其后选号 / 轮换 / 记账 / 粘性完全一致,出口形状由注入的 translator 决定。 +> 可视化:[`diagrams/coding2api-request-sequence.html`](diagrams/coding2api-request-sequence.html)。`POST /v1/responses` 同链路:先映射成 `ChatRequest`,其后完全一致,出口形状由 translator 决定。 ``` 客户端 → POST /v1/chat/completions (Bearer sk-) @@ -423,7 +494,9 @@ class Scheduler: 全拒时 400 invalid_request(未知模型名 ≡ 无上游提供,不走 503) - REQUEST 类换号但不落库:既不冷却也不累计,否则会顺手把已有的 cooling_until 写成 NULL 5. response.py:Event → OpenAI chunk(流式)或聚合(非流式) - - 首块补 role:assistant;上游无 index 的 tool_calls 补稳定 index + - 首块补 role:assistant;上游无 index 的 tool_calls 补稳定 index, + 无 `id` 的补稳定 `id`(`call_{index}_{seq}` 记忆化)——OpenAI 客户端按 + `id` 聚合流式分片,缺 id 的分片会被丢弃,表现为工具调用静默消失 - 收尾帧序列:finish chunk → usage 帧(choices: [])→ [DONE];上游未报 usage 则不补该帧(不发 0 占位)。非流式/流式收尾共用 usage_payload() - 直连 CodeArts v2 每 chunk 都带 usage,取收尾帧真值(取第一个会记成 0/空) @@ -431,10 +504,10 @@ class Scheduler: 7. scheduler.note_success:清 err_count,并把本对话重新粘到实际服务的凭证 ``` -客户端断连:生成器被关闭/取消时统计 `error_type=client_disconnect`,关闭上游流;例外:`[DONE]` 已产出后的收尾断开(客户端拿到回调即关连接,框架在结束帧 `more_body=False` 前有一拍竞态会把它判为断开)按成功记账。 +客户端断连:生成器关闭 / 取消时统计 `error_type=client_disconnect` 并关闭上游流;例外是 `[DONE]` 已产出后的收尾断开(框架在结束帧 `more_body=False` 前有一拍竞态),按成功记账。 ## 8. 数据库 -DDL 以 `src/db/schema.sql` 为准。当前 `SCHEMA_VERSION = 29`,共 13 张表:`api_keys` / `credentials` / `credential_limits` / `users` / `audit_events` / `usage_events` / `usage_hourly` / `growth_events` / `credit_events` / `credential_model_cooldowns` / `model_probes` / `runtime_settings` / `alert_events`。 +DDL 以 `src/db/schema.sql` 为准。`SCHEMA_VERSION = 29`,13 张表:`api_keys` / `credentials` / `credential_limits` / `users` / `audit_events` / `usage_events` / `usage_hourly` / `growth_events` / `credit_events` / `credential_model_cooldowns` / `model_probes` / `runtime_settings` / `alert_events`。 ```sql -- conn.py 打开时执行 @@ -443,27 +516,27 @@ PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_keys 仍在应用层校验) ``` -- 连接:`threading.local()` 每线程一个 `sqlite3.Connection(row_factory=sqlite3.Row)`,引擎与 FastAPI 线程池各持自己的连接。写入统一走 `Database.transaction()`(正常提交、异常回滚),无应用层写锁,并发由 SQLite 串行化(WAL + `busy_timeout=5000` 下短写足够)。 -- 加密:`Fernet(base64.urlsafe_b64encode(sha256(APP_SECRET).digest()))`;APP_SECRET 丢失 = 凭证全部不可解,只能重录。启动时按熵拒掉弱密钥(长度 ≥16、不同字符数 ≥8,另拒重复字符 / 短周期重复串)。 -- 运行时配置覆盖层 `runtime_settings(key, value, updated_at)`:纯 key/value,只存「管理台改过」的 key,未出现的 key 回落 env。键集合随版本演进(新增可热更项无需迁移);值的类型与范围由 `HOT_SETTINGS` 白名单校验,白名单外/类型非法的行在读取时被忽略并记日志。 -- 模型列表与模型目录不占表:模型列表是「进程内 TTL 缓存 + `DATA_DIR/model_catalog.json` 落盘快照」,OpenRouter 目录是 `DATA_DIR/openrouter_catalog.json`(见 §9.4);签到去重由 `CheckinTask` 的当日作用域集合实现。 +- 连接:`threading.local()` 每线程一个 `sqlite3.Connection(row_factory=sqlite3.Row)`。写入统一走 `Database.transaction()`,无应用层写锁,并发由 SQLite 串行化。 +- 加密:`Fernet(base64.urlsafe_b64encode(sha256(APP_SECRET).digest()))`;APP_SECRET 丢失 = 凭证全部不可解。启动时按熵拒弱密钥(长度 ≥16、不同字符数 ≥8,拒重复字符 / 短周期重复串)。 +- `runtime_settings(key, value, updated_at)`:纯 key/value,只存管理台改过的 key,未出现回落 env;值由 `HOT_SETTINGS` 白名单校验,非法行读取时忽略并记日志。 +- 模型列表与目录不占表:进程内 TTL 缓存 + `DATA_DIR/model_catalog.json` / `openrouter_catalog.json`(§9.4);签到去重由 `CheckinTask` 的当日作用域集合实现。 -**备份与恢复**(`src/db/backup.py`,任务与 `scripts/` 共用):`VACUUM INTO` 把主库写成一致性快照(WAL 下直接 `cp` 会丢未 checkpoint 的事务),落盘后立即 `PRAGMA integrity_check`(不过则删半成品并抛错),产物 `0600` 目录 `0700`;后台任务每 `BACKUP_INTERVAL_MINUTES` 备份一次、按 mtime 保留最近 `BACKUP_KEEP` 份。恢复 `scripts/restore_db.py`:先校验备份 → 给现有主库做 `.pre-restore-` 一致性安全副本 → 同目录临时文件 `os.replace` 原子替换 → 删 `-wal`/`-shm` 残留(属旧库,留着会损坏新库);默认预览,`--apply` 才写。**不做「服务在跑」探测**(容器/权限都可能误判),文档要求恢复前先停服、恢复后重启。 +**备份与恢复**(`db/backup.py`,任务与 `scripts/` 共用):`VACUUM INTO` 写一致性快照(WAL 下直接 `cp` 会丢未 checkpoint 的事务),落盘后立即 `PRAGMA integrity_check`(不过则删半成品并抛错),产物 `0600` 目录 `0700`;每 `BACKUP_INTERVAL_MINUTES` 备份、按 mtime 保留 `BACKUP_KEEP` 份。恢复 `scripts/restore_db.py`:校验 → `.pre-restore-` 安全副本 → `os.replace` 原子替换 → 删 `-wal`/`-shm` 残留;默认预览,`--apply` 才写。**不做「服务在跑」探测**,文档要求恢复前停服、恢复后重启。 -**凭证标签**(`credentials.tags`,v22 新增列;纯函数在 `src/tags.py`):逗号分隔文本,`MAX_TAGS=16` / `MAX_TAG_LENGTH=24`。**写入口校验、读路径容错**——`parse_tags` 对超长/超量/非字符串项抛 `ValueError`(端点转成 400),`tags_value` 对库里手工改坏的越界项只丢弃、不抛错,避免一条脏标签让整页凭证列表失败。去重按**大小写不敏感**但保留首次写法。半角/全角逗号与分号都当分隔符(中文输入法极易打出全角逗号);含分隔符的**列表项**在写入前被拒(存进去会被读回时拆成两条,读写不等价)。**标签不参与调度与鉴权**——它是给人看的收敛维度,一旦影响选号,改名就会静默改变线上流量。端点 `PUT /api/credentials/{id}/tags`(整体替换,空数组 = 清空,`require_operator`),审计动作 `credential.tags`;导入 `POST /api/credentials` 也可选带 `tags`。列表响应里 `tags` 是**数组**(不是存储文本),前端筛选与徽章直接用。 +**凭证标签**(`credentials.tags`,v22;纯函数 `src/tags.py`):逗号分隔,`MAX_TAGS=16` / `MAX_TAG_LENGTH=24`。**写入校验、读取容错**——`parse_tags` 对越界项抛 `ValueError`(端点转 400),`tags_value` 只丢弃坏项不抛错。去重大小写不敏感、保留首次写法。半角/全角逗号与分号都当分隔符;含分隔符的列表项写入前被拒。**标签不参与调度与鉴权**。端点 `PUT /api/credentials/{id}/tags`(整体替换,`require_operator`),审计 `credential.tags`。列表响应 `tags` 是数组。 **迁移纪律**(`migrate.py`): - 启动时读 `schema.sql` 逐条 `CREATE TABLE IF NOT EXISTS`(只加不改)。 -- 新增**列**写进 `migrate._MIGRATION_COLUMNS`,走 `ALTER TABLE ... ADD COLUMN`(列已存在时忽略,老库幂等补列);同时 `SCHEMA_VERSION + 1`。 -- 新增**表**不需要 `_MIGRATION_COLUMNS`:`CREATE TABLE IF NOT EXISTS` 对老库同样执行,建表即完成迁移,只需 `SCHEMA_VERSION + 1` 并补一条老库升级测试。 -- 删除**表**写进 `migrate._MIGRATION_DROPS`,走 `DROP TABLE IF EXISTS`(schema.sql 里删定义不会作用于老库,遗留表必须显式删);同时 `SCHEMA_VERSION + 1`。 -- 版本记在 `PRAGMA user_version`;库版本高于本程序支持时启动报错。 -- 删除凭证时显式清理其模型级冷却行(无外键级联),否则重建同 id 凭证会继承旧的模型冷却。 -- **升级只在启动时发生**:迁移与 bootstrap 都挂在 `build_app` 里,所以「改了 schema 但没重启」=老库 + 新前端。 +- 新增**列**写进 `_MIGRATION_COLUMNS`,走 `ALTER TABLE ... ADD COLUMN`;`SCHEMA_VERSION + 1`。 +- 新增**表**不需要 `_MIGRATION_COLUMNS`:建表即完成迁移,只需 `SCHEMA_VERSION + 1` 并补老库升级测试。 +- 删除**表**写进 `_MIGRATION_DROPS`,走 `DROP TABLE IF EXISTS`;`SCHEMA_VERSION + 1`。 +- 版本记在 `PRAGMA user_version`;库版本高于本程序时启动报错。 +- 删除凭证时显式清理其模型级冷却行(无外键级联),否则重建同 id 会继承旧冷却。 +- **升级只在启动时发生**:迁移与 bootstrap 挂在 `build_app`,「改了 schema 但没重启」= 老库 + 新前端。 ## 9. 模型目录与模型名 ### 9.1 三字段与归一键 -八条渠道(codebuddy / codebuddy_intl / trae / zen / kilo / qoder / codearts / officeace)的每个模型统一成三个字段,规则集中在渠道中立的 `src/provider/naming.py`(纯函数,不 import 任何 provider 子模块): +八渠道每个模型统一三字段,规则在 `src/provider/naming.py`(纯函数,不 import provider 子模块): | 字段 | 函数 | 例 | |---|---|---| @@ -471,82 +544,112 @@ PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_k | 归一键(合并键 / 多渠道对外 id) | `normalize_model_key` | `kilo-auto`、`longcat-2.5-preview`、`nemotron-3-ultra` | | 展示名 `name` | `display_model_name` | `LongCat 2.5 Preview`、`Qwen3.8 Max` | -`normalize_model_key` 剥掉两类上游噪声再 slug 化(空格/下划线/冒号/括号 → 连字符、小写)。**免费标记**是可用性命名约定、不是模型名的一部分,四种形态:尾缀 `-free`/`_free`(Zen)、路径段 `.../free`(Kilo)、冒号尾缀 `:free`、展示名括号词 `(free)`;同性质还有新版标记 `new`。**命名空间前缀**不是模型身份:路径式 `厂商/模型` 取**最后一段**,冒号式 `厂商: 模型`(要求冒号后有空白)削掉前缀。只删「整段/整词」标记(`freeplay`/`newson` 内嵌形式不动)。点号**不是**分隔(`glm-5.2` 压成 `glm-5-2` 就换了模型);非 ASCII 字母数字保留。剥完只剩噪声时回退原串小写,调用方据此回退展示原 id,不产出空键。`display_model_name` 清洗上游可读名(`Qwen3.8-Max` → `Qwen3.8 Max`、`NVIDIA: Nemotron 3 Ultra (free)` → `Nemotron 3 Ultra`)或由 id 派生;品牌与缩写按表纠正(机械 title-case 会把 `DeepSeek`/`MiMo`/`GLM`/`GPT` 写错)。**幂等**:清洗结果再洗不变。**合并键 = 归一键,八渠道统一**:同一模型各渠道内部代号互不相同(Qoder `kmodel_latest` = TRAE `kimi-k3-1` = CodeBuddy `kimi-k3`),纯 id 规则无法对齐,只能靠展示名——归一键即展示名再 slug 化,两路径永远同源。zen / kilo 也参与合并:两条免费渠道各用不同命名约定与**不同 id 规格**(Zen `nemotron-3-ultra-free` vs Kilo `nvidia/nemotron-3-ultra-550b-a55b:free`),id 对不上,但展示名都清洗成「Nemotron 3 Ultra」,走展示名能并成一条。**同模型异名显式规范**(`MODEL_SYNONYMS`):清洗规则对齐不了「上游给同一模型起了不带版本号的名字」(Qoder `dfmodel` 上游名只叫 `DeepSeek-Flash`,别家叫 `DeepSeek-V4.1-Flash`)。表把「异名归一键 → (规范归一键, 规范展示名)」,键与展示名一起收敛。另一类是**空格式厂商前缀**(CB 的 `Xiaomi MiMo-V2.6-Pro` vs TRAE 的 `MiMo-V2.6-Pro`,冒号式规则削不到,归一键会多出 `xiaomi-`)→ 登记 `xiaomi-mimo-v2.6-pro` → `mimo-v2.6-pro`。**只登记人工核实的同模型异名,不做模糊匹配**。**哨兵名不跨渠道合并**(`_SENTINEL_LABELS` = `auto` / `default`):这些是各上游自己的「自动路由 / 默认模型」占位,语义只在本渠道内成立(Kilo 的 `kilo-auto/free` 与 Qoder 的 `auto` 都归一成 `auto` 却是完全不同的东西),故退回原 id 的归一键、各自单列。**同渠道内归一键重复 → 退回原 id 的归一键**;该渠道也不登记歧义的名字别名。 +- `normalize_model_key` 剥两类上游噪声再 slug 化(空格/下划线/冒号/括号 → 连字符、小写)。**免费标记**四种形态:尾缀 `-free`/`_free`、路径段 `.../free`、冒号尾缀 `:free`、括号词 `(free)`;同类还有 `new`。**命名空间前缀**不是身份:路径式取最后一段,冒号式(要求冒号后有空白)削前缀。只删整段/整词标记(`freeplay`/`newson` 不动)。点号不是分隔(`glm-5.2` → `glm-5-2` 就换了模型);非 ASCII 字母数字保留。剥完只剩噪声时回退原串小写。 +- `display_model_name` 清洗可读名(`Qwen3.8-Max` → `Qwen3.8 Max`)或由 id 派生;品牌按表纠正(机械 title-case 会写错 `DeepSeek`/`MiMo`/`GLM`/`GPT`)。**幂等**。 +- **合并键 = 归一键,八渠道统一**:同一模型各渠道代号互不相同(Qoder `kmodel_latest` = TRAE `kimi-k3-1` = CB `kimi-k3`),纯 id 规则无法对齐,只能靠展示名。zen / kilo 也参与合并(Zen `nemotron-3-ultra-free` vs Kilo `nvidia/nemotron-3-ultra-550b-a55b:free`,展示名都清洗成「Nemotron 3 Ultra」)。 +- **同模型异名显式规范**(`MODEL_SYNONYMS`):如 Qoder `dfmodel` 上游名 `DeepSeek-Flash` vs 别家 `DeepSeek-V4.1-Flash`;表把「异名归一键 → (规范归一键, 规范展示名)」。另一类是空格式厂商前缀(`Xiaomi MiMo-V2.6-Pro` → `mimo-v2.6-pro`)。**只登记人工核实的,不做模糊匹配**。 +- **哨兵名不跨渠道合并**(`_SENTINEL_LABELS` = `auto` / `default`):语义只在本渠道内成立,退回原 id 的归一键、各自单列。**同渠道内归一键重复 → 退回原 id 的归一键**,且不登记歧义别名。 +- **对外 id 一律归一键**:多渠道取最短展示名的归一键;单渠道取该条;兜底/哨兵取原代号的归一键。原代号不露面,转发时经 `services.model_aliases` 映射回(`executor.upstream_model_name` 消费);每渠道登记三条键(对外 id、原代号、展示名及 slug 形式)。 +- **撞车消歧**(`_disambiguate`):按 `_sort_key` 序保留先到者,后者依次取 `_fallback_ids`(原代号归一键 → 加渠道后缀 → 数字后缀)。兜底/哨兵条目用 `_scoped_key`(`渠道\0归一原代号`)隔离,对外 id 仍是原代号归一键。 +- **展示顺序**:`_PROVIDER_RANK`(codebuddy 0 → codebuddy_intl 1 → trae 2 → qoder 3 → codearts 4 → officeace 5 → 其余 6),多渠道取 `min`,同级按归一键字典序。仅影响展示,调度选号顺序(`KNOWN_PROVIDERS`)不变。 -**对外 id 一律是归一键**(`_finalize` / `_disambiguate` / `_register_aliases`):多渠道取最短展示名的归一键;单渠道取该条展示名的归一键;兜底/哨兵条目取**原代号的归一键**。**原代号不在对外 id 里露面**,但转发时一律经 `services.model_aliases` 映射回该渠道自己登记的原代号(由 `executor.upstream_model_name` 消费);每渠道登记三条键——对外 id、原代号、展示名(原样与 slug 形式),用户按任一种写法都能直连命中。**对外 id 撞车的消歧**(`_disambiguate`):按 `_sort_key` 序保留先到者,后者按 `_fallback_ids` 依次取(原代号归一键 → 加渠道名后缀 → 数字后缀)。**兜底/哨兵条目用渠道限定键隔离**(`_merge_key` 的 `_scoped_key` = `渠道\0归一原代号`),但对外 id 仍是原代号的归一键。**展示顺序(CB / TR 优先)**:合并后按 `_sort_key(entry)` 排序——渠道权重 `_PROVIDER_RANK`(codebuddy 0 → codebuddy_intl 1 → trae 2 → qoder 3 → codearts 4 → officeace 5 → 其余 6),多渠道条目取 `min` 权重,同级再按**归一键**字典序(`_sort_key` 在 `_finalize` 之前跑,此时对外 id 未算出,而 `_disambiguate` 需要确定顺序决定撞 id 时谁保留)。仅影响展示顺序,调度选号(`model_resolver` 的 `KNOWN_PROVIDERS` 顺序)不变。 ### 9.2 按凭证加载与 SWR -`list_models` 先用 `credential_providers()` 求「当前有可用凭证」的渠道集合(`candidates(selectable_only=True)`:未暂停、未硬禁用;冷却中的仍算有凭证,避免限流时列表闪没),循环里 `provider_id not in connected` 直接 `continue`——**没凭证的渠道不读缓存、不拉上游、不展示**。zen / kilo 自带虚拟凭证,不受影响。**失败也进 TTL(负缓存)**:`model_list_fetched_at` 记上次尝试时间(成功或失败都刷新),TTL 内不再打上游。**连续失败再按 2^(n-1)×TTL 退避**(`model_list_failures` 计数,封顶 `MODEL_LIST_BACKOFF_MAX_SECONDS` 2h,一次成功即清零):上游被长期封禁(zen 免费层按出口 IP 拦)时每 5 分钟重探一遍(十几秒真推理)纯属刷屏;日志只在**首次**失败打 WARNING、其后降 INFO,连续次数留在消息里。**HTTP 出口 stale-while-revalidate**:TTL 到期后 `GET /v1/models` 与 `GET /api/playground/models` 走 `serve_models`:**有缓存直接回旧列表**,过期渠道丢后台(`_schedule_refresh`)异步刷新。两条纪律:① **某渠道一条缓存都没有时才同步等它**(冷启动无快照 / 新接入渠道),否则列表会缺一条渠道;② 后台刷新收敛在 `services.model_refresh_tasks`,`model_refreshing` 去重,`lifespan` 关闭时逐个 `cancel()` + `await`。代价:列表最多滞后一个 TTL。后台预热 `_warm_model_list` 与兜底循环仍走同步 `list_models`。**逐渠道增量 publish**:每拉完一条渠道就 `publish_aliases()` 一次(从 `model_list_cache` 重建 + 就地更新,executor 闭包引用同一 dict),不再等最慢那条(zen 逐个免费模型真发探活可占 12–15s,期间别名表为空会让 `executor._narrow_providers` 拿不到归属而按「全部渠道」放行)。合并逻辑收敛到 `merged_entries()`,缓存兜底 / TTL 复用 / 落盘恢复三条路径共用。 +`list_models` 先用 `credential_providers()` 求「当前有可用凭证」的渠道(`candidates(selectable_only=True)`:未暂停、未硬禁用;冷却中仍算),`provider_id not in connected` 直接 `continue`——**没凭证不读缓存、不拉上游、不展示**。zen / kilo 有虚拟凭证。 + +- **失败也进 TTL**(负缓存):`model_list_fetched_at` 记上次尝试(成功或失败)。**连续失败按 2^(n-1)×TTL 退避**(`model_list_failures`,封顶 `MODEL_LIST_BACKOFF_MAX_SECONDS` 2h,成功清零);日志首次 WARNING、其后 INFO。 +- **HTTP 出口 SWR**:TTL 到期后 `/v1/models` 与 `/api/playground/models` 走 `serve_models`:有缓存直接回旧列表,过期渠道丢后台(`_schedule_refresh`)。两条纪律:① 某渠道一条缓存都没有时才同步等(冷启动 / 新渠道);② 后台刷新收敛在 `services.model_refresh_tasks`,`model_refreshing` 去重,`lifespan` 关闭时 `cancel()` + `await`。代价:列表最多滞后一个 TTL。 +- **逐渠道增量 publish**:每拉完一条渠道 `publish_aliases()` 一次(就地更新,executor 闭包引用同一 dict),不等最慢那条(zen 逐个探活可占 12–15s,期间别名表为空会让 `_narrow_providers` 按全部渠道放行)。合并逻辑收敛到 `merged_entries()`。 + +**单模型查询** `GET /v1/models/{model_id:path}`:与列表**共用** `_revalidated_providers` + `_served_entries`。命中三种写法(对外 id / 展示名 / 任一渠道 `raw_ids`),`strip().lower()` 比较。`:path` 容纳带命名空间的原代号(`dots-studio/dots-3-note-preview:free`)。**白名单外与不存在同形 404**(`model_not_found`),客户端无法探测池子。能力分与列表同源。 -**单模型查询 `GET /v1/models/{model_id:path}`**(`api/models.py: serve_model`):与列表**共用** `_revalidated_providers`(同一 SWR 刷新时机)+ `_served_entries`(同一合并/定稿/别名 publish/白名单过滤)——两条出口看到同一份条目,避免「列表里有、单查 404」的错觉。命中口径三种写法(对外 id / 展示名 / 任一渠道 `raw_ids`),统一 `strip().lower()` 比较,故大小写不敏感;路径参数用 `:path` 以容纳带命名空间的 kilo 原代号(`dots-studio/dots-3-note-preview:free`)。**白名单外与不存在同形 404**(`error_payload(..., "model_not_found", 404)`):`_served_entries` 已按 Key 的 `allowed_models` 过滤,查不到即 404,客户端无法用单查探测「池子里还有哪些模型」。能力分与列表同源(`_benchmarks_of(request)`),刷新后下一请求即生效。 ### 9.3 落盘快照与启动回灌 -进程内缓存重启即丢,故每次成功拉取后把**未过滤原始表**原子写进 `DATA_DIR/model_catalog.json`(tmp + `os.replace`,格式 `{"version":1,"providers":{pid:{"saved_at":…,"models":[…]}}}`,序列化字段取 `dataclasses.fields(Model)`)。启动时 `main._restore_model_list` **同步**读回并立即 publish 别名表(`restore_model_catalog`,零上游请求、异常只记日志),预热退化为纯后台刷新;快照 `saved_at` 随表交回、折进 `model_list_fetched_at`,故预热(TTL 门禁)不会重拉刚恢复的表。三条纪律:① 落盘/读回一律宽容(损坏、版本不符、字段缺失、记录非 dict 只丢该渠道,`saved_at` 超 7 天整条丢弃);② 存原始表不过滤(黑名单热更立即生效);③ 恢复只覆盖「已注册且当前有可用凭证」的渠道——用户暂停的渠道不因快照复活。**启动预热不再阻塞启动**:`lifespan` 把预热丢给后台任务,不在 `yield` 前 `await`(zen 最慢探活可占十几秒,内联会让应用不响应 `/health`)。另有 `MODEL_CATALOG_MINUTES` 后台任务兜底刷新(保证纯 API 部署也真刷新)。 -### 9.4 模型目录(OpenRouter 单一来源:价格 + 明细 + 能力分) -**数据源**:`https://openrouter.ai/api/v1/models`(`OPENROUTER_MODELS_URL`,匿名可读、无需 key)。一次抓取同时得到刊例价(`pricing`)、明细元数据(`architecture` / `top_provider` / `supported_parameters`)与 Artificial Analysis 三项指数(`benchmarks.artificial_analysis`),供成本估算、「模型列表」页与 Playground 能力分三处使用。**架构**(后台抓 → 落盘 → `app.state` → 每请求现读): -- 后台任务 `openrouter_catalog`,周期由 `OPENROUTER_CATALOG_MINUTES` 控制(默认 1440 分钟、下限 60,可热更)。一轮抓取建三张表:价表(`build_price_table`)、明细目录(`build_model_catalog`)、能力表(`build_benchmark_table`)。拉到空价表视为失败,不覆盖已有三张表。 +每次成功拉取后把**未过滤原始表**原子写进 `DATA_DIR/model_catalog.json`(tmp + `os.replace`,格式 `{"version":1,"providers":{pid:{"saved_at":…,"models":[…]}}}`)。启动时 `main._restore_model_list` **同步**读回并立即 publish 别名表(零上游请求),预热退化为后台刷新;`saved_at` 折进 `model_list_fetched_at`,故不会重拉刚恢复的表。 -**能力分口径**:Artificial Analysis 的三项指数(`intelligence_index` 综合智能 / `coding_index` 编程 / `agentic_index` 智能体),经 OpenRouter 公开接口分发。AA 官方 API 需 key,故不采用;上游字段类型意外时安静跳过,绝不让模型列表挂掉。 +三条纪律:① 落盘/读回一律宽容(损坏、版本不符、字段缺失、记录非 dict 只丢该渠道,`saved_at` 超 7 天整条丢弃);② 存原始表不过滤(黑名单热更立即生效);③ 恢复只覆盖「已注册且当前有可用凭证」的渠道。**启动预热不阻塞**:`lifespan` 把预热丢后台(zen 最慢探活十几秒,内联会让 `/health` 不响应)。另有 `MODEL_CATALOG_MINUTES` 兜底刷新。 -**浮动别名取同族最新快照的分**(2026-10-19,`_snapshot_info`):OpenRouter 对同一模型会同时挂浮动别名(`deepseek/deepseek-v4.1-flash`)与带日期快照(`deepseek/deepseek-v4.1-flash-1017`),并用 `canonical_slug` 声明同属一次发布。上游把浮动别名钉在旧快照上、且对同一快照只发布一个评测变体(实测浮动别名标 `Non-reasoning` 24.7,`-1017` 标 `Max` 39.5),分数看着掉了 15 分其实只是换了变体。本项目转发默认开推理,与 Max 同口径,故 `build_benchmark_table` 按 `canonical_slug` 的日期取同族最新快照的分;显式带日期戳的 id(`deepseek-v4-pro-0813`)仍用自己的分。`source_model` 指向真正提供分数的上游 id。家族只从**带 `-YYYYMMDD` 的 canonical** 派生——`mistralai/mistral-large`(Feb '24)与 `mistralai/mistral-large-2512`(Mistral Large 3)削完日期同基却是两个模型,不互相借分。 -- **失败安静降级**:网络 / 结构坏 / 空表 / 快照损坏 / 版本不符 / 超龄,一律退化为空表,条目**不带** `benchmarks` 字段(不是 `null`),页面显示 `—`;模型列表与聊天不受影响。 -- 落盘 `data/openrouter_catalog.json`(`CATALOG_VERSION=2`,三张表同一个文件、同一个 `saved_at`,超 `MAX_AGE_SECONDS`=7 天视为过期 → 全退空),启动时同步回灌(`load_catalog_snapshot`,零上游请求);**无快照时**启动后台补拉一次(`_warm_model_catalog`)。 -- 挂 `app.state.price_table` / `app.state.models_dev_catalog` / `app.state.model_benchmarks` / `app.state.price_saved_at`。刷新是**就地 `clear()` + `update()`**,不换引用(每请求现读,换引用会让路由读到旧表)。 +### 9.4 模型目录(OpenRouter 单一来源) +**数据源** `https://openrouter.ai/api/v1/models`(`OPENROUTER_MODELS_URL`,匿名)。一次抓取同时得刊例价、明细元数据(`architecture` / `top_provider` / `supported_parameters`)与 Artificial Analysis 三项指数,供成本估算、模型列表页与 Playground 能力分。 + +- 后台任务 `openrouter_catalog`(`OPENROUTER_CATALOG_MINUTES`,默认 1440、下限 60、可热更)一轮建三张表:`build_price_table` / `build_model_catalog` / `build_benchmark_table`。空价表视为失败,不覆盖已有表。 +- **能力分**:`intelligence_index` / `coding_index` / `agentic_index`,经 OpenRouter 公开接口分发(AA 官方 API 需 key)。字段类型意外时安静跳过。 +- **浮动别名取同族最新快照的分**(`_snapshot_info`):OpenRouter 同时挂浮动别名与带日期快照,用 `canonical_slug` 声明同属一次发布,且只对同一快照发布一个评测变体(实测浮动别名 `Non-reasoning` 24.7、`-1017` `Max` 39.5)。本项目默认开推理,与 Max 同口径,故按 `canonical_slug` 日期取同族最新快照;显式带日期戳的 id 仍用自己的分。家族只从带 `-YYYYMMDD` 的 canonical 派生(`mistral-large` vs `mistral-large-2512` 是不同模型,不借分)。 +- **失败安静降级**:网络 / 结构坏 / 空表 / 快照损坏 / 版本不符 / 超龄一律退化为空表,条目不**带** `benchmarks` 字段(不是 `null`),页面显示 `—`。 +- 落盘 `data/openrouter_catalog.json`(`CATALOG_VERSION=2`,三表同文件同 `saved_at`,超 `MAX_AGE_SECONDS`=7 天过期 → 全退空),启动同步回灌;无快照时后台补拉(`_warm_model_catalog`)。 +- 挂 `app.state.price_table` / `models_dev_catalog` / `model_benchmarks` / `price_saved_at`。刷新是**就地 `clear()` + `update()`**,不换引用。 + +**匹配纪律**(`src/model_match.py`,三处共用):`match_keys(*texts)` 把任意写法展开成候选键集合(归一键 + `_ALIAS_RULES` 显式等价 + 剥 `_DROP_SUFFIXES` + 版本号点/连字符互换),`lookup` **只做等值匹配**,命中不唯一返回 `None`(`glm-5.3` 绝不匹配 `glm-5.3-flash`)。规则全部显式登记并附实测来源——渠道分发代号逐 id 登记(`deepseek-v4.1-flash-0930` / `-sg` → `deepseek-v4.1-flash`),因为 OpenRouter 的 `-1017` 快照有自己的价位,通用剥日期会把两套价格混成一个。 + +**展示范围**(同一份表):`/v1/models` 每行可选 `benchmarks`;`/api/playground/models` 同上;`/api/model-catalog` 每行可选 `benchmarks` + `available` + 顶层 `saved_at`。**`available`** = 当前渠道真能提供(口径同 `/v1/models`),供默认关闭的「只看当前渠道」过滤。**排序**:`sort=intelligence` 按综合智能排(无分排末尾,不冒充 0 分),只给这一个分项入口。**不做排序**的地方标注「Artificial Analysis 指数,经 OpenRouter 公开接口;非本服务实测」。**已知取舍**:`family` / `open_weights` / `release_date` 上游不提供(后端固定 `null` / `false`)。 -**匹配纪律**(`src/model_match.py`,价表 / 明细 / 能力排行共用同一套):本项目对外的模型 id 是归一键(`kimi-k3`),OpenRouter 用 `moonshotai/kimi-k3` + 展示名 `MoonshotAI: Kimi K3`。`match_keys(*texts)` 把任意写法展开成候选键集合(归一键 + `_ALIAS_RULES` 显式等价 + 剥 `_DROP_SUFFIXES` 尾缀 + 版本号点/连字符互换),`lookup` **只做等值匹配**、命中不唯一返回 `None`(`glm-5.3` 绝不匹配 `glm-5.3-flash`)。规则全部显式登记,每条附实测来源,不做启发式推断——例如渠道分发代号逐 id 登记(`deepseek-v4.1-flash-0930` / `-sg` → `deepseek-v4.1-flash`),因为 OpenRouter 的 `-1017` 快照**有自己的价位**,通用剥日期尾缀会把两套价格混成一个。**快照家族**(`src/benchmarks.py:_snapshot_info`)是这层之上的另一条显式规则:同一次发布的多条快照记录按 `canonical_slug` 的 `-YYYYMMDD` 归族,浮动别名取最新一条的分(见上节),族内不改写显式快照 id。**展示范围**(三处,同一份表同一口径):`GET /v1/models` 每行可选 `benchmarks` 字段;`GET /api/playground/models` 同上;`GET /api/model-catalog` 每行可选 `benchmarks` + `available` + 顶层 `saved_at`。**`available`** 是「当前渠道真能提供」的布尔(口径与 `/v1/models` 一致:只算有可用凭证、过当前黑名单的渠道),用同一套候选键与渠道侧条目求交集——「模型列表」页据此提供默认关闭的「只看当前渠道」过滤。**排序**:`sort=intelligence` 按综合智能指数排(无分数的行排末尾,不冒充 0 分),只给这一个分项入口(一列对应一个入口,三项各排会挤爆表头)。**UI**:Playground 模型选择器行内徽章 + 选中模型的能力分卡;「模型列表」页「能力分」列(三行:智 / 编 / 体,列头可点排序)+ 「能力分来源」指标卡。**不做排序**——分数是第三方成绩,排序等于替用户下结论。每处都标注「Artificial Analysis 指数,经 OpenRouter 公开接口;非本服务实测」。**已知取舍**:`family` / `open_weights` / `release_date` 上游不提供(后端固定给 `null` / `false`,页面只展示「知识截止」)。**降级**:拉取失败 / 快照损坏 / 版本不符 / 超龄,一律退化为空表,条目**不带** `benchmarks` 字段(不是 `null`),页面显示 `—`。 ### 9.5 token 到期展示与预警 -**到期时间的来源**(决定实现,不能只读凭证字段):TRAE 凭证 JSON 的 `expires_at` 有值(= JWT `exp`);**CodeBuddy 恒为 0**(OAuth 登录与刷新响应都不带 `expires_at`),只能从 bearer token 的 JWT `exp` 解析;CodeArts 用 `expiration`(不是 `expires_at`),签发时间借 `refresh_token` JWT 的 `iat`。实现集中在 `provider/token_expiry.py`(渠道中立,刻意不 import provider 子模块,否则 `provider.codebuddy.events → engine.sse` 会被拖进 `db` 层): +**来源**:TRAE 的 `expires_at` 有值(= JWT `exp`);**CB 恒为 0**(OAuth 登录与刷新都不带),只能从 JWT `exp` 解析;CodeArts 用 `expiration`,签发时间借 `refresh_token` JWT 的 `iat`。实现集中在 `provider/token_expiry.py`(渠道中立,不 import provider 子模块,否则会把 `db` 层拖进来)。 -- `credential_token_times(data)`:返回 `(签发, 到期)` 两个 epoch。到期优先显式 `expires_at`/`expiresAt`/`expiration`,缺失 / 非法时遍历可能的 token 键解析 JWT `exp`;签发时间只来自 JWT `iat`。各自拿不到时返回 **0 = 未知**。**不猜本地 TTL**——捏造的到期时间会让管理台显示假预警。**只借 `iat` 的兜底**:CodeArts 的 access token 是 STS AK/SK(非 JWT),但签发它的 STS 响应同时轮转出一张 `refresh_token` JWT,其 `iat` 就是这组临时凭证的签发时刻,故 access token 键都取不到信息时再遍历 `_ISSUED_ONLY_KEYS` **只借 `iat`、不借 `exp`**(后者的 30 天寿命会把 2h 的临时凭证画成 30 天)。 -- `jwt_times(token)` / `jwt_expiry(token)`:只 base64url 解码不验签;非 JWT / 结构异常 / claim 非法一律 0。`normalize_epoch()`:毫秒时间戳归一。 +- `credential_token_times(data)` 返回 `(签发, 到期)` epoch。到期优先显式 `expires_at`/`expiresAt`/`expiration`,缺失 / 非法时遍历 token 键解析 JWT `exp`;签发只来自 `iat`。拿不到返回 **0 = 未知**,**不猜本地 TTL**。**只借 `iat` 的兜底**:CodeArts access token 是 STS AK/SK(非 JWT),但 STS 响应同时轮转出 `refresh_token` JWT,其 `iat` 即签发时刻,故 `_ISSUED_ONLY_KEYS` **只借 `iat`、不借 `exp`**(后者 30 天寿命会把 2h 凭证画成 30 天)。 +- `jwt_times` / `jwt_expiry` 只 base64url 解码不验签;异常一律 0。`normalize_epoch()` 归一毫秒。 + +**落库**:`token_expires_at`(`add()` 与 `save_credential_data()` 都写回;JWT 派生值**不写回凭证 JSON**,否则刷新后旧值残留成「权威」)与 `token_issued_at`(仅诊断)。**老库不批量回填**:列表读到时按需从 `data_enc` 派生(`_token_times_from_blob()`,失败返回 `(0,0)`),写回后只读列值。**读路径**只下发绝对 epoch,**不代前端算剩余秒数**;前端 `tokenExpiryView()` 用同一时钟现算,阈值经 `TOKEN_EXPIRY_WARNING_SECONDS` 下发。**短寿命凭证不标「即将到期」**:整段寿命 ≤ 预警窗口 2 倍时(CodeArts STS 2h、窗口 1h)恒标红会淹掉真告警;真过期(剩余 ≤0)仍标红。未知显示 `—`,绝不当成已过期。接口:`GET /api/credentials` 响应带 `token_expiry_warning_seconds`,每条带 `token_expires_at` / `token_issued_at`。 -**落库**:`credentials` 两列——`token_expires_at`(`add()` 与 `save_credential_data()` 都写回;JWT 派生值**不写回凭证 JSON**,否则刷新换到新 token 后旧派生值会残留成「权威」到期时间)与 `token_issued_at`(仅落库供诊断)。**老库升级不批量回填**:改在列表读到时按需从 `data_enc` 派生(`_token_times_from_blob()`,解密 / 解析失败返回 `(0, 0)`);一旦写回就只读列值。**读路径**:`list_all()` 只下发绝对 epoch,**不代前端算剩余秒数**(服务端算好的「剩余」不随页面 tick 更新)。剩余时间与预警判定由前端 `tokenExpiryView()` 用同一时钟现算,阈值由 `TOKEN_EXPIRY_WARNING_SECONDS` 经列表接口下发。**短寿命凭证不标「即将到期」**:整段寿命(`expires − issued`)≤ 预警窗口的 2 倍时,该 token 天生就在窗口附近(CodeArts STS 寿命 2h、窗口 1h),恒标红只会淹掉真告警;**真过期(剩余 ≤0)仍标红**。到期未知(0)时显示 `—`,**绝不当成已过期**。接口:`GET /api/credentials` 响应新增 `token_expiry_warning_seconds`,每条凭证新增 `token_expires_at` 与 `token_issued_at`(均为 0 表示未知)。 ### 9.6 积分变动流水 -**做法:在额度探测写回时比对余额,记一条流水**(`credit_events`)。写入放在 `save_quota` 的**同一个事务**里:分两次读改写会与并发探测交错,记出「before 是别人写过的值」的错行。**账本由凭证自己的渠道决定**(`quota_ledger`):`pool`=套餐 / 每日余额(CodeArts 之外的渠道),`credits`=积分(CodeArts 的额度来自积分接口,内置模型按积分计费)。两本账的数值量级与语义都不同,混读会被当成同一本账的连续读数。**归因纪律**:diff 只能看到 `[上次探测, 本次探测]` 区间的**净变化**,其间签到、成长领取与对话消耗可能同时发生,无法区分谁贡献了多少。所以 `source` 列**只表达归因已知度**:`observed` = 两次探测之间的净变化(delta 可正可负或 NULL);`sync` = 首次建立基线、没有对照(delta 为 NULL)。前端文案一律说「净变化」,不写「签到 +5」。**只记有信息量的行**:首次探测记 `sync`(delta NULL);余额未变**不记**;余额变化记 `observed`;任一端未知时记(delta NULL,「余额变未知」本身是该追的异常);两端都未知不记;凭证被并发删除不记。`window_start` 记的是**上次探测时刻**(不是「上次有变动」)。接口:`GET /api/credentials/{id}/credit-events?limit=20&ledger=pool|credits`(管理员;未知凭证 400 而不是空列表;未知 `ledger` 回落 `pool`)。保留期由 `RetentionTask` 按 `usage_events` 同一保留期(90 天)回收,报告里体现为 `purged_credit_events`。 +**做法:额度探测写回时比对余额,记一条流水**(`credit_events`),写入放在 `save_quota` **同一事务**(分两次读改写会与并发探测交错)。 + +- **账本由渠道决定**(`quota_ledger`):`pool` = 套餐/每日余额(CodeArts 之外),`credits` = 积分(CodeArts)。两本账量级与语义不同,混读会被当成同一本账。 +- **归因纪律**:diff 只能看到 `[上次探测, 本次探测]` 的**净变化**(其间签到、成长、对话可能同时发生)。`source` 只表达归因已知度:`observed`(净变化,delta 可正可负或 NULL)、`sync`(首次建基线,delta NULL)。前端文案一律「净变化」,不写「签到 +5」。 +- **只记有信息量的行**:首次记 `sync`;余额未变不记;变化记 `observed`;任一端未知记(delta NULL);两端都未知不记;凭证被并发删除不记。`window_start` 是**上次探测时刻**。 +- 接口 `GET /api/credentials/{id}/credit-events?limit=20&ledger=pool|credits`(未知凭证 400,未知 `ledger` 回落 `pool`)。保留期 90 天(`purged_credit_events`)。 + ### 9.7 用量统计与成本 -- **口径**:`overview` / `by_provider` / `by_model` / `by_user` / `timeline` / `model-timeline` 均读**小时汇总**(`usage_hourly`,永久保留),只有 `events`(逐请求明细)读 `usage_events`(90 天)。统一口径让选「全部」时总览与图表同值。代价:最近 ≤5 分钟未进汇总的请求不计入。**凭证维度读明细表**(小时汇总主键里没有 `credential_id`),故只覆盖最近 90 天。 -- **小时汇总双写**:`record()` 写明细的同时增量累加当前小时行,新请求立即可见于统计页;`rollup_hourly`(`RetentionTask` 每 5 分钟)全量重算作对账,两者幂等一致。 -- **TRAE credit 为推算值**(`credit_estimated`):TRAE 上游 `token_usage` 只给 token 数、无单请求积分,按官方计费公式(`(输入−缓存)×输入价 + 输出×输出价 + 缓存×缓存价`,单价为积分/百万,见 `src/provider/trae/pricing.py`)折算;展示层加 `≈`。CodeBuddy 的 credit 是上游真值,恒不标推算。与「刊例 × 折扣」不符的模型走 `pricing.MEASURED_EFFECTIVE_OVERRIDE`(缓存命中价需单独实测,如 DeepSeek-V4.1-Flash 实测缓存价高于刊例)。 -- **成本(估算)**:按 `token × 公开刊例价` 估算,不是上游真实扣费。单价取自 OpenRouter 公开目录(`pricing.prompt` / `completion` / `input_cache_read`,抓取时乘 1e6 转 USD / 百万 token),按 `(输入−命中)×输入价 + 命中×缓存价 + 输出×输出价` 折算美元,再按**写入时生效的汇率**(`USD_CNY_RATE`)折人民币。**写入时定值**(`usage_events.cost_usd` / `cost_cny`),历史行不随价表 / 汇率重算。**匹配不到就不计**(未匹配或未上报输入 token 的 `cost_known=0` → 回 `None`、显示 `—`,不拿 0 冒充免费),故成本是**下限**。 -- **缓存命中率** = 命中 token ÷ 输入 token(保留 1 位小数);未上报或输入为 0 时显示 `—`。前端 `formatCacheRate` 是唯一实现,三处共用:概览「Token 消耗」卡片 hint、分组统计表 Token 合并列(「输入 / 输出 · 命中率」)、请求明细 Token 合并列(悬停详情含命中率)。分组表那列是**派生只读列**(不给排序键,与「成功率」同例):比值随行内两个 token 字段现算。 -- **分组表合并列**:分组统计表把同类指标并成五列——`请求`(请求数 · 成功率,悬停含成功数)、`Token`(输入 / 输出 · 命中率,悬停含命中 token)、`速度`(正文 / 总产出,tok/s,悬停写全两口径)、`平均延迟`(平均耗时 / 平均首字)、`P95 延迟`(P95 耗时 / P95 首字),列数 14 → 8。排序仍走 `SortMenuHead`,子列键即 `GROUP_SORT_COLUMNS` 的白名单键;延迟拆成两列而不是一列四个子项,是因为均值读小时汇总、P95 读明细,两套口径/覆盖期不同,需在列名与表尾文案上分开说明。速度并成一列同理:分子分别是「正文」(`output_tokens - reasoning_tokens`)与「总产出」(含思考 token),分母相同,并成一格 `正文 / 总产出`;排序键仍分开(`tps_visible` / `tps`)。 -- **明细合并列与命中率排序**:请求明细把同类列并成一列——`Token`(默认「输入 / 输出 · 命中率」,悬停含命中 token)、`延迟`(默认「耗时 / 首字」)、`速度`(默认「正文 / 总产出」,tok/s)与 `渠道 / 凭证`(凭证名在上、渠道 icon + 名在下,悬停看完整凭证 ID),列数 15 → 约 10。`时间`列拆成两行(`formatTimeParts`:`HH:mm:ss` 在上、`YYYY-MM-DD` 在下,具体时刻优先;等宽数字纵向对齐,请求 ID 提示仍挂这一格),不占两列。排序不丢:`SortMenuHead`(`web/src/components/SortMenuHead.tsx`)让一个列头承载多个排序键,点列头弹下拉选子列(渠道 / 凭证列的子列即 `provider` / `credential`)。其中命中率是**派生列**,走 `EVENT_SORT_COLUMNS["cache_rate"]`:`CASE WHEN e.input_tokens > 0 THEN CAST(e.cached_tokens AS REAL) / e.input_tokens ELSE NULL END`(输入为 0 / 未上报给 `NULL`,与其它可空列同 SQLite 排序语义;`CAST ... REAL` 避免整数除法截断成 0)。速度两列同理由 `_row_speed_sql("e.")` 生成(`EVENT_SORT_COLUMNS["tps_visible"] / ["tps"]`),与 `events()` 的 SELECT 列**同一 SQL 片段**,避免「排序按 A 算、展示按 B 算」。 -- **请求 ID(`request_id`)**:每个请求带一个 `X-Request-ID`(`src/webapp/request_id.py` 挂在最外层,任意响应含 413/400 都带头)。入站头合法(`[A-Za-z0-9._:-]{1,128}`)则沿用,否则 `uuid4().hex` 自生成;同一中间件还把会话标识头(`x-session-id` 等)经同一套白名单校验后写进 contextvar 供会话粘性读取,请求结束复原;ID 经 `src/request_context.py` 的 contextvar 传递,`StatsCollector.record` 现读落进 `usage_events.request_id`(跨引擎层,不为一个可观测性字段给所有中间层加参数)。`events` 端点透出该列,统计页时间列悬停展示。**不透传上游**(各渠道出站头语义不同,见 `request_context.py` 模块注释)。 -- **延迟均值只算成功请求**(分子 `SUM(latency_ms WHERE ok=1)` 与分母 `ok_count` 配对)。 -- **分组表延迟四列**(`avg_latency_ms` / `avg_ttfb_ms` / `p95_latency_ms` / `p95_ttfb_ms`,四个维度一致下发):均值读小时汇总(与 `overview()` 同口径,永久保留);**分位值只能从明细表算**——`usage_hourly` 只有 `latency_sum` / `ttfb_sum`(和与成功计数),数学上还原不出分布,故 `_percentile_join()` 生成一个「按维度分组在 `usage_events` 上排名」的 LEFT JOIN 子查询,**独立**应用作用域与时间窗(取值列 `ts`,与主查询的 `hour_utc` 区分)。用 LEFT JOIN 而非 INNER:全失败 / 无成功样本的组仍要出现在表里(请求数 > 0、延迟列 NULL)。**排名用 SQLite 窗口函数**(`ROW_NUMBER() OVER (PARTITION BY key ORDER BY (col IS NULL), col)` + `COUNT(col) OVER (PARTITION BY key)`,要求 SQLite ≥3.25;Python 3.12 内置 3.54、Docker `python:3.14-slim` 更高),**nearest-rank** 名次用整数运算写 `(95 * n + 99) / 100`(= `⌈0.95n⌉`,避开浮点误差把名次算偏)。`ttfb_ms` 对非流式 / 未产出首字的成功请求为 NULL,故 latency 与 ttfb **各自独立排名**(`COUNT(col)` 忽略 NULL,天然只数有值样本)。排序键进 `GROUP_SORT_COLUMNS`:均值映射到 `CAST(latency_sum AS REAL) / ok_count`(SQLite 允许 `ORDER BY` 引用 SELECT 别名并做表达式,`ok_count = 0` 除零得 NULL、降序自然排最后),分位直接映射 JOIN 列别名。代价:分位列只覆盖明细保留期(90 天),选「全部」时比均值列旧——表尾文案写明,不假装全覆盖。实测(本地 5.7 万行明细)按渠道算 P95 约 0.09s,加 24h 时间窗后约 0.006s。 -- **输出速度(`tps` / `tps_visible` / `tps_estimated`)**:`tokens ÷ 生成时间总和`,**不是逐条比值平均**(短请求噪声大,逐条平均会把整体带歪)。两列**同分母**:`tps_visible`(正文速度)= `MAX(output_tokens - reasoning_tokens, 0)` 之和,`tps`(总产出速度)= `output_tokens` 之和——思考 token 也计入总产出行(它同样是模型产出),但成批快速吐出,与正文混算会得到远高于屏幕刷新速度的数字(实测思考占输出 50–67% 时两者约 190 与 63 tok/s)。主口径只算经路由的成功请求且该请求**有输出 token**(`output_tokens > 0`,**不设输出量下限**——短输出也是真实产出),分母是 `latency_ms - ttfb_ms`(扣首字等待),并加两道物理合理性下限:生成窗口 < `SPEED_MIN_GEN_MS`(200ms)或单条样本 > `SPEED_MAX_TPS`(1000 tok/s)的样本按「生成时长不可测」剔除——上游把整段内容攒成一批吐时首帧与末帧几乎同时到达,分母趋近 0 会把比值放大成天文数字(实测 901 token / 3ms = 30 万 tok/s),这是测量失效而非「很快」。`ttfb_ms` 为 NULL 的行(非流式等)无生成时间可分,不进主口径。某组主口径**无样本**时回落估算口径:分母用端到端 `latency_ms`(含首字等待)、样本门槛与主口径一致(同样 `output_tokens > 0`,两口径只差在分母),并套用同样两道下限,`tps_estimated=1`(前端加 `≈`)。两套条件聚合在 `_percentile_join()` 的同一次明细扫描里顺带算完(`ranked` 子查询多带 `output_tokens` / `reasoning_tokens` 两列),不另扫一遍;`overview()` 因无法按单请求输出量过滤小时汇总,另在 `usage_events` 上跑同一段聚合(`_speed_aggregates(ref="")`)。排序键 `GROUP_SORT_COLUMNS["tps"]` / `["tps_visible"]` 是「有路由样本用路由、否则用估算」的 CASE 表达式(tokens/ms 与 tok/s 单调同序)。`_speed_value()` 对每个分子单独判空:正文分子为 0(整段输出都是思考)时 `tps_visible=None`,前端显示 `—` 而不是 0。代价与分位同:只覆盖明细保留期(90 天)。 -- **逐请求速度(明细 / 实时流)**:`events()` 下发的每行带 `tps` / `tps_visible`,由 `_row_speed_sql("e.")` 一个 SQL 片段同时供 SELECT 列与排序键(`EVENT_SORT_COLUMNS`)使用。规则与聚合口径同源(`_routed_guard`),但**不回落估算口径**:逐行没有相邻样本摊平首字等待,拿端到端耗时除出来的单条比值只会误导,故无生成窗口(`ttfb_ms` 为 NULL、输出 < 100、窗口 < 200ms 或 > 1000 tok/s)的行给 `NULL`(前端 `—`);正文为 0 时正文列给 `NULL`(`NULLIF(…, 0)`),不拿 0 冒充速度。实时流不查库,`StatsCollector._notify` 广播前调 `row_speed()`(`query.py` 的 Python 版,同一套判定)补这两个字段——否则 SSE 那几行速度列恒为 `—`。 -- **回填脚本**:`scripts/backfill_trae_credit.py`(TRAE 推算积分)、`scripts/backfill_cost.py`(成本,默认预览、写库前自动备份、幂等)、`scripts/rollup_hourly.py`(小时汇总重算)。均先报告缺口,再 `--apply` 写库。 -- **自定义时间窗(`since` / `until`)**:所有统计端点(`overview` / `by-*` / `timeline` / `model-timeline` / `events` / 导出)接受 `since` + `until`(epoch 秒),语义是**半开区间** `[since, until)`:`since` 闭下界、`until` 开上界。两者可单独给(单边不限)或都不给(全部,与历史一致)。取值列随数据源分流:小时汇总用 `hour_utc`、明细用 `ts`——都在 `StatsQuery._where` 里按 `since_col` / `until_col` 指定,与已有的 `before`(rowid 游标,**不是**时间)分开。前端「自定义」给两个 `datetime-local`(`src/lib/customRange.ts`:本地时区解析、`toLocalInput` 手工拼串避开 `toISOString` 的 UTC 归一),起点不早于终点时整窗作废并提示;`overview` 查询挂 `keepPreviousData`,避免改时间窗时骨架屏把正在编辑的输入框卸载。 -- **钻取与导出**:`events` 端点接受 `provider` / `model` / `credential_id` 三个明细过滤(`AND`),管理台分组表点行即按该维度钻取(用户维度走作用域参数 `username`),条件可叠加。`?format=csv` 走导出:仅 admin/operator(`require_operator`),固定旧→新按 `rowid ASC`,多取一条判截断(上限 `EXPORT_MAX_ROWS = 10000`,超限截断并置响应头 `X-Export-Truncated: 1`)。CSV 生成收敛在 `src/stats/csv.py` 纯函数:**公式注入防护**给以 `= + - @`(及制表符/回车)开头的**文本**单元格加前导单引号,数值(含负数)原样(`-1.5` 在 Excel 里本就是数字,加引号反而毁掉可计算性);带 UTF-8 BOM 供 Excel 认编码;时间列 ISO 8601 UTC;`\r\n` 换行(RFC 4180)。**列与页面明细表同口径**:`export_events()` 与 `events()` 共用 `_ROW_SPEED_VISIBLE` / `_ROW_SPEED_TOTAL`,CSV 因此多出「正文速度 tok/s」「总产出速度 tok/s」两列——不可测的行留空(`format_cell(None) == ""`),不是 0。 -- **实时流(SSE)**:`GET /api/stats/events/stream` 推送**刚落库**的明细。**进程内广播**而非轮询:写入点最清楚「刚刚发生了什么」,`StatsCollector.record` 在**事务提交后**调用 `LiveEventHub.publish`(`src/stats/live.py`),订阅者拿到的是已落库行(可拿 `rowid` 接着翻页),事务回滚的行不会漏出。**背压**:每个订阅者一个**有界 deque**(`MAX_BUFFERED = 200`),满了丢**最旧**的一条——实时流看的是「现在」,慢客户端不能拖住写入路径,也不能让缓冲无界增长;唤醒用 `asyncio.Event`。**并发上限** `MAX_SUBSCRIBERS = 8`,超出 `TooManySubscribers` → 429 + `Retry-After: 10`(不排队、不踢已有订阅)。**作用域与过滤在订阅者侧**:一条事件可能被多个条件不同的订阅者同时看到,故 `_scope`(admin/operator 可传 `username`、viewer 恒本人)与 `provider`/`model`/`credential_id` 过滤都在生成器里逐条判定,`None` = 不限制;`since`/`until` 不参与(实时流没有时间窗语义)。**脱敏**:广播 payload = `/api/stats/events` 行 + `rowid`,不含 `credential_name`;仅 admin/operator 的连接由端点逐条调 `CredentialRepository.nickname_of` 补名字(与明细端点同口径,viewer 看不到池里他人昵称)。**保活与中间层**:15s 一条注释帧(`:` 开头,`EventSource` 忽略)防止代理断掉静默连接;响应头 `Cache-Control: no-store` + `X-Accel-Buffering: no` 防止反向代理缓存或整段缓冲把「实时」变成批量。生成器 `finally` 里注销订阅——客户端关标签页时生成器被取消,不注销会让槽位泄漏、8 次后全员 429。前端 `useStatsEventStream`(`web/src/api/hooks.ts`)只管连接生命周期,开销是「依赖变化即重建连接」(`EventSource` 无法改查询串);页面缓冲最近 50 条、新→旧,与下方翻页历史分开展示。**单进程限制**:广播是进程内的,多进程 / 多副本部署时每个进程只推自己写入的行(本项目是单进程 SQLite 部署,故成立)。 +- **口径**:`overview` / `by_provider` / `by_model` / `by_user` / `timeline` / `model-timeline` 读**小时汇总**(`usage_hourly`,永久);只有 `events` 读明细(90 天)。代价:最近 ≤5 分钟未进汇总的不计入。**凭证维度读明细表**(小时汇总主键无 `credential_id`),只覆盖 90 天。 +- **双写**:`record()` 写明细同时增量累加当前小时行;`rollup_hourly`(每 5 分钟)全量重算对账,两者幂等。 +- **TRAE credit 为推算值**(`credit_estimated`):上游 `token_usage` 只给 token,按官方公式折算(`src/provider/trae/pricing.py`),展示加 `≈`。CB 是上游真值。与刊例不符的走 `pricing.MEASURED_EFFECTIVE_OVERRIDE`。 +- **成本(估算)**:`token × 公开刊例价`,单价取自 OpenRouter(抓取时乘 1e6 转 USD/百万),按 `(输入−命中)×输入价 + 命中×缓存价 + 输出×输出价` 折美元,再按**写入时汇率**(`USD_CNY_RATE`)折人民币。**写入时定值**,历史不重算。**匹配不到就不计**(`cost_known=0` → `None`、显示 `—`),故成本是下限。 +- **缓存命中率** = 命中 ÷ 输入 token(1 位小数),未上报或输入 0 显示 `—`。`formatCacheRate` 唯一实现,三处共用。分组表那列是**派生只读列**(不给排序键)。 +- **分组表合并列**:`请求`(数 · 成功率)、`Token`(输入/输出 · 命中率)、`速度`(正文/总产出)、`平均延迟`(耗时/首字)、`P95 延迟`,14 → 8 列。排序走 `SortMenuHead`,子列键即 `GROUP_SORT_COLUMNS` 白名单键。延迟拆两列是因为均值读小时汇总、P95 读明细,口径与覆盖期不同。 +- **明细合并列**:`Token` / `延迟` / `速度` / `渠道 / 凭证`,15 → 约 10 列。`时间` 拆两行(`HH:mm:ss` 在上、日期在下),请求 ID 提示挂该格。`SortMenuHead` 让一个列头承载多个排序键。命中率走 `EVENT_SORT_COLUMNS["cache_rate"]`:`CASE WHEN e.input_tokens > 0 THEN CAST(e.cached_tokens AS REAL) / e.input_tokens ELSE NULL END`;速度两列由 `_row_speed_sql("e.")` 生成,与 `events()` 的 SELECT **同一片段**。 +- **请求 ID**:`X-Request-ID`(`webapp/request_id.py` 最外层)。入站合法(`[A-Za-z0-9._:-]{1,128}`)则沿用,否则 `uuid4().hex`;同一中间件把会话标识头写进 contextvar 供粘性读取;ID 经 `request_context` 传进 `usage_events.request_id`。**不透传上游**。 +- **延迟均值只算成功请求**(`SUM(latency_ms WHERE ok=1)` / `ok_count`)。 +- **分组表延迟四列**(`avg_latency_ms` / `avg_ttfb_ms` / `p95_latency_ms` / `p95_ttfb_ms`):均值读小时汇总,**分位只能从明细算**(`usage_hourly` 只有 `latency_sum` / `ttfb_sum`,还原不出分布)。`_percentile_join()` 生成「按维度分组在 `usage_events` 上排名」的 LEFT JOIN 子查询,**独立**应用作用域与时间窗(列 `ts`)。用 LEFT JOIN 而非 INNER(全失败组仍要出现)。排名用窗口函数 `ROW_NUMBER() OVER (PARTITION BY key ORDER BY (col IS NULL), col)` + `COUNT(col) OVER (...)`(SQLite ≥3.25);nearest-rank 用整数 `(95 * n + 99) / 100`。`ttfb_ms` 为 NULL 时 latency 与 ttfb **各自独立排名**。实测本地 5.7 万行按渠道算 P95 约 0.09s,加 24h 窗约 0.006s。 +- **输出速度**(`tps` / `tps_visible` / `tps_estimated`):`tokens ÷ 生成时间总和`,**不是逐条比值平均**。两列同分母:`tps_visible` = `MAX(output_tokens - reasoning_tokens, 0)` 之和,`tps` = `output_tokens` 之和(思考也计入总产出行;思考占 50–67% 时两者约 190 与 63 tok/s)。主口径只算经路由的成功请求且 `output_tokens > 0`,分母 `latency_ms - ttfb_ms`,两道下限:窗口 < `SPEED_MIN_GEN_MS`(200ms)或单样本 > `SPEED_MAX_TPS`(1000 tok/s)剔除(实测 901 token / 3ms = 30 万 tok/s 是测量失效)。`ttfb_ms` 为 NULL 不进主口径。某组无样本时回落端到端 `latency_ms` 并置 `tps_estimated=1`(前端加 `≈`)。两套聚合在 `_percentile_join()` 同一次扫描算完;`overview()` 另在 `usage_events` 跑 `_speed_aggregates(ref="")`。`_speed_value()` 对每个分子单独判空(正文为 0 → `None`,显示 `—`)。 +- **逐请求速度**:`events()` 每行带 `tps` / `tps_visible`,由 `_row_speed_sql("e.")` 同时供 SELECT 与排序键。规则同源(`_routed_guard`)但**不回落估算**:无生成窗口(`ttfb_ms` NULL、输出 < 100、窗口 < 200ms 或 > 1000 tok/s)给 `NULL`;正文 0 用 `NULLIF(…, 0)` 给 `NULL`,不拿 0 冒充速度。实时流不查库,`StatsCollector._notify` 广播前调 `row_speed()`(Python 版,同一判定)。 +- **回填脚本**:`scripts/backfill_trae_credit.py` / `backfill_cost.py` / `rollup_hourly.py`,均先报缺口再 `--apply`。 +- **自定义时间窗**:所有统计端点接受 `since` + `until`(epoch 秒),**半开区间** `[since, until)`;可单边或都不给。取值列分流:小时汇总 `hour_utc`、明细 `ts`(`_where` 里按 `since_col` / `until_col` 指定),与 `before`(rowid 游标)分开。前端「自定义」两个 `datetime-local`(`src/lib/customRange.ts` 本地时区解析),起点不早于终点整窗作废;`overview` 挂 `keepPreviousData`。 +- **钻取与导出**:`events` 接受 `provider` / `model` / `credential_id`(AND),分组表点行即钻取(用户维度走 `username`)。`?format=csv` 仅 admin/operator,固定 `rowid ASC`,多取一条判截断(`EXPORT_MAX_ROWS = 10000`,超限置 `X-Export-Truncated: 1`)。`src/stats/csv.py` 纯函数:以 `= + - @`(及制表符/回车)开头的**文本**单元格加前导单引号(数值原样);UTF-8 BOM;ISO 8601 UTC;`\r\n`。CSV 与页面共用 `_ROW_SPEED_VISIBLE` / `_ROW_SPEED_TOTAL`,不可测行留空。 +- **实时流(SSE)** `GET /api/stats/events/stream`:**进程内广播**而非轮询,`StatsCollector.record` 在**事务提交后**调 `LiveEventHub.publish`,事务回滚的行不会漏出。**背压**:每订阅者有界 deque(`MAX_BUFFERED = 200`),满了丢**最旧**;唤醒用 `asyncio.Event`。**并发上限** `MAX_SUBSCRIBERS = 8`,超出 429 + `Retry-After: 10`。**作用域与过滤在订阅者侧**(`_scope` + 三过滤,`None` 不限制);`since`/`until` 不参与。**脱敏**:payload 不含 `credential_name`,仅 admin/operator 连接由端点逐条补。15s 注释帧保活;`Cache-Control: no-store` + `X-Accel-Buffering: no`。生成器 `finally` 注销订阅(漏注销会泄漏槽位)。**单进程限制**:多进程各推自己写入的行。 + ### 9.8 列表排序 -所有 `GET` 列表端点接受统一的 `sort`(白名单键)+ `order`(`asc`/`desc`)。**未知 `sort` 键或非法 `order` 值回落到默认排序,不报错**;`sort` 是白名单键而非 SQL 列名,SQL 片段只由受控常量拼出,用户输入永不进 SQL,注入面为零。契约在 `src/sorting.py`(db 与 api 共用,避免 db → api 反向依赖):`parse_sort_order`(唯一解析入口)、`sql_order`(`tiebreak` 兜底稳定次序)、`sort_rows`(组装后再排,`None` 值无论升降序都排**最后**)。逐请求明细默认(`time` 降序)走 rowid 游标分页返回 `next_before`;按其它列排序 / 时间升序 / 显式 `offset` 时改用 `LIMIT/OFFSET` 并返回 `total`。适用端点:`/api/credentials`、`/api/users`、`/api/api-keys`、`/api/audit`、`/api/alerts`、`/api/model-catalog`、`/api/credentials/{id}/credit-events`、`/api/credentials/{id}/accounts`、`/api/stats/by-*`、`/api/stats/events`。 +所有 `GET` 列表接受 `sort`(白名单键)+ `order`(`asc`/`desc`)。**未知 `sort` 或非法 `order` 回落默认,不报错**;`sort` 是白名单键而非 SQL 列名,用户输入永不进 SQL。契约在 `src/sorting.py`:`parse_sort_order`(唯一入口)、`sql_order`(`tiebreak` 稳定次序)、`sort_rows`(`None` 值无论升降序都排最后)。明细默认(`time` 降序)走 rowid 游标返回 `next_before`;按其它列 / 时间升序 / 显式 `offset` 时改 `LIMIT/OFFSET` 并返回 `total`。适用:`/api/credentials`、`/api/users`、`/api/api-keys`、`/api/audit`、`/api/alerts`、`/api/model-catalog`、`/api/credentials/{id}/credit-events`、`/api/credentials/{id}/accounts`、`/api/stats/by-*`、`/api/stats/events`。 ### 9.9 列表分页 -扁平列表**统一后端分页**,前端共享 `Pagination` 组件(`web/src/components/Pagination.tsx`)——同一套页码文案(`第 N / M 页`)、每页选项(`PAGE_SIZE_OPTIONS = [20, 50, 100]`,默认 `DEFAULT_PAGE_SIZE = 50`)与上下页按钮,`data-testid` 前缀按表区分(`${prefix}-page-info` / `-page-size` / `-prev` / `-next`)。 - -契约:`GET` 列表端点接受 `limit` + `offset`,响应在**原列表键旁**新增 `total`(**筛选后**条数,前端据此算页数;键名不变、向后兼容)。仓储层 `limit=None` 表示不分页返回全量,保持内部调用点旧行为。实现分流:`users` / `api-keys` / `alerts` / `audit` 在 SQL 层 `LIMIT/OFFSET` + 独立 `count`;`model-catalog` 排序键含派生字段(能力分 / `available`),先 `sort_rows` 再内存切片。`/api/model-catalog` 的服务端筛选收敛在 `_filter_catalog`(`q` 大小写不敏感匹配 `id` / 名称,`only_available` 只留 `available=true`),`total` 是筛选后条数、顶层 `count` 仍是目录全量。游标分页的明细端点(`/api/stats/events` 默认 `time` 降序)无总页数,页码文案退化为 `第 N 页`(未知总数)。分组类表(凭证按渠道分组、用量统计分组表)不分页。 +扁平列表统一后端分页,前端共享 `Pagination`(`PAGE_SIZE_OPTIONS = [20, 50, 100]`,`DEFAULT_PAGE_SIZE = 50`)。契约:`GET` 接受 `limit` + `offset`,响应在原列表键旁新增 `total`(**筛选后**条数)。仓储 `limit=None` 表示不分页。实现分流:`users` / `api-keys` / `alerts` / `audit` 走 SQL `LIMIT/OFFSET` + 独立 `count`;`model-catalog` 排序键含派生字段,先 `sort_rows` 再内存切片(`_filter_catalog` 支持 `q` 与 `only_available`,顶层 `count` 仍是目录全量)。游标分页的明细端点无总页数,文案退化为 `第 N 页`。分组类表不分页。 ## 10. 后台任务 -`TaskRunner`(`runner.py`)每类任务一个独立 asyncio 循环,失败只记日志不拖垮服务;间隔有安全下限,避免打爆上游。`Pacer` 有两种模式:后台任务用**严格模式**(默认,单锁串行化跨任务/跨凭证的相邻请求);聊天用**并发模式**(`allow_concurrent=True`),按「渠道 + 凭证身份」分桶并在桶内放行并发——同渠道同模型的并发请求不再被逐级串行化,只有同凭证「上一请求已结束、紧接着又来一个」的顺序连发才补足最小间隔。请求结束由 provider `stream_chat` 的 `finally` 调 `release(key)` 归还名额。**间隔抖动(`JITTER_RATIO = 0.25`)**:定值间隔(`min == max`,聊天节流器的形态,如 `Pacer(5, 5)`)逐个账号精确同拍,多实例 / 多账号会同时刻撞上游,故 `next_interval()` 对定值**只向上**抖 `[low, low × 1.25]`——`low` 是频率风控下限,不可击穿;`min < max` 的区间本就均匀随机(文档承诺的 `[min, max]`),不再叠加。抖动源是构造期的 `random.Random(0)`(测试可复现),非热更项。 +`TaskRunner`(`runner.py`)每类任务一个独立 asyncio 循环,失败只记日志;间隔有安全下限。`Pacer` 两种模式:后台任务**严格模式**(单锁串行化);聊天**并发模式**(`allow_concurrent=True`,按「渠道 + 凭证身份」分桶放行并发,只有同凭证顺序连发才补最小间隔)。`stream_chat` 的 `finally` 调 `release(key)`。**抖动(`JITTER_RATIO = 0.25`)**:定值间隔(`min == max`)只向上抖 `[low, low × 1.25]`——`low` 是频率风控下限不可击穿;`min < max` 的区间本就均匀随机,不再叠加。抖动源是构造期 `random.Random(0)`(测试可复现)。 | 任务 | 周期 | 行为 | |---|---|---| -| 额度探测(quota_probe) | 启动立即一轮(不节流)+ 每 `QUOTA_PROBE_MINUTES`(默认 60) | 探测剩余额度 → 写 `credentials.quota_*` / `quota_expiry_ladder` / `quota_packages` / `health`(CodeArts 走积分接口,见 §5.4) | -| token 预刷新(refresh) | 每 `REFRESH_INTERVAL_MINUTES`(默认 30,下限 5) | 到期前 `REFRESH_SKEW_HOURS`(默认 24h)窗口内轮换 refresh token;短寿命渠道自行封顶窗口(CodeArts `refresh_skew_cap_seconds=2700`)——封顶值必须**严格宽于**本周期;上游明确回 `UpstreamReloginRequired` 时标记「需重新登录」并硬禁用 | -| 每日签到(checkin) | 每 10 分钟(全天) | 成功即封账该凭证(`日期:scope` → 截止时刻,进程内内存态,跨天清理);默认封到本地当日结束,渠道可回填 `CheckinResult.seal_until`(Qoder 每日 10:00 换窗口);失败持续重试 | -| 成长中心(growth) | 每 `GROWTH_INTERVAL_MINUTES`(默认 60,下限 5) | 仅 CodeBuddy:7 类领取;结果落 `growth_events` + 回写 `credentials.growth_last_result` | -| 活跃上报(activity,默认关闭) | 每 10 分钟醒一次,仅 `ACTIVITY_REPORT_HOUR`(默认 10 点)窗口内执行 | CodeBuddy 与 CodeBuddy 国际版:补发一条 `chat_request_send` 续连登;按「endpoint + userId」隔离、当日封账;成功落一行 `growth_events` | -| 明细清理(retention) | 每 5 分钟 | `usage_events` 全量重算小时汇总(幂等 upsert,与 record 的增量双写对账)+ 90 天前明细清理;同期限回收 `credit_events` / `audit_events` / `alert_events` / `growth_events`(否则无界增长)。整轮跑在 `asyncio.to_thread`(全表 GROUP BY 是同步 SQLite,直接在事件循环执行会卡住并发请求) | -| 数据库备份(backup) | 每 `BACKUP_INTERVAL_MINUTES`(默认 1440,下限 60) | `VACUUM INTO` 写主库一致性快照到 `DATA_DIR/backups/` + `integrity_check` + 0600,保留最近 `BACKUP_KEEP`(默认 7)份。整轮跑在 `asyncio.to_thread`(同步磁盘 IO) | -| 渠道模型列表刷新(注入协程,`api/models.py::list_models`) | 每 `MODEL_CATALOG_MINUTES`(默认 30,下限 5) | 兜底重拉各渠道模型表(TTL 门禁 + 逐渠道 publish + 落盘快照)。周期跟 zen 判活缓存(30 分钟)对齐——更密不会让 zen 多探一次 | -| 模型目录刷新(OpenRouter,注入协程) | 每 `OPENROUTER_CATALOG_MINUTES`(默认 1440,下限 60) | 拉 OpenRouter → 建三张表 → 落盘 → 就地换入 | -| 运维告警(alert) | 每 `ALERT_INTERVAL_MINUTES`(默认 5,下限 1) | 评估四类风险(池耗尽 / 任务连续失败 / token 临近到期 / 上游错误率骤升),命中落 `alert_events` 并可推送 webhook;同 `(规则, 对象)` 在静默窗内只报一次 | -| 上游状态页聚合(upstream_status) | 每 `UPSTREAM_STATUS_MINUTES`(默认 5,下限 1) | 并发抓各模型 / 网关提供方的官方状态接口(单源超时 `UPSTREAM_STATUS_TIMEOUT_SECONDS`)→ 归一化 → 原子替换内存快照;单源失败只记该源 `error`,**整轮永不抛异常**。启动时后台预热一轮 | -| 模型主动探活(model_probe) | 每 `MODEL_PROBE_MINUTES`(默认 0 = 关闭,下限 1) | 只探仍被 `blocked` 负缓存锁住的 (渠道, 模型):直连 `provider.stream_chat` 发一条最小对话(`max_tokens=16`)→ 三态判定(成功清该渠道负缓存 / `BLOCKED`·`INVALID` 记 `failed` / 其余 `inconclusive` 且绝不禁用)→ 结论落 `model_probes`。不写 `usage_events`、不动冷却(除成功时清)、不改健康度。`manual != ''` 的行整条跳过 | - -**渠道模型列表刷新与模型目录刷新是「注入协程」而非 tasks 模块**:`tasks/` 不 import `api/` / `benchmarks`(反向依赖会把 HTTP 层拖进后台任务),所以 `TaskRunner` 接的是 `Callable[[], Awaitable[object]]`,由 `main.lifespan` 闭包注入;`None` 时不装配这条循环,管理台也不展示该卡片。**并发保护**:HTTP 出口与后台循环都会调 `list_models`,故 `api/models.py` 里 `_refresh_providers` 整段持模块级 `asyncio.Lock`(粒度是「整次列表刷新」而非单渠道——跨渠道合并与别名表 publish 需要看到一致的全集)。**签到 / 成长中心的「同账号」隔离键**:`checkin_scope(data) or f"credential|{credential_id}"`(provider 在身份未知时返回空串,共享空 scope 会让第二个账号被 `seen` 集合永久跳过)。**记录口径**:`_guarded` 在任务返回 `None` 时**不入账**(`sleep` 到点但 `due()=False` 是 no-op,若当成一次成功执行,页面会显示「签到刚跑过」而当天其实一次都没签)。`TaskRun` 只在真实执行(返回非 `None`)或抛异常时写入;异常也入账(`last_error`)。计数 `runs` 同样只涨真实执行。运行态只存在于**本次进程**内,重启归零。**任务清单**:`tasks/status.py` 的 `TASK_SPECS` 是静态描述(key / 名称 / 一句话说明),与 `TaskRunner.start()` 建立的循环一一对应;周期与开关**运行时现算**(`TaskRunner.task_status()` 读热更值)。未装配的任务不出现在清单里。接口 `GET /api/tasks`(admin)返回 `{tasks: [...], server_time}`,每条含 `key`/`name`/`description`/`interval_seconds`/`enabled`/`running`/`runs`/`last_started_at`/`last_finished_at`/`last_ok`/`last_report`/`last_error`。带 `server_time` 是为了让前端用**服务端时钟**算「距今多久」。**手动触发**:`POST /api/tasks/{key}/run`(admin + CSRF)返回 `{...同一条运行态, ran, busy}`,未知 / 未装配 key 回 400。它复用 `_loop_specs()`(周期循环与手动触发**共用一份清单**,两处各写一份会让「手动能跑但定时没接线」),差别只有两点:`manual=True` 时 `activity` 走**绕过 `due()`** 的分支(手动就是「立刻跑」,不再问「到点了没」),以及开关**不绕**——`run_once` 返回 `None` 的 no-op 轮次记 `ran=false`。**并发**:`self._running: set[str]` 在真跑期间占住 key(asyncio 单线程下「查 + 加」原子,不用锁),`_run_guarded` 撞上已占用的 key 直接跳过本轮;手动触发在 `_run_guarded` **之前**先查一次,命中即回 `busy=true` 且不等结果。`ran` 的口径是「真跑了一轮(含失败)」——用 `status.runs(key)` 的增量判定,no-op 不入账;失败看 `last_ok`/`last_error`。 - -**成长中心的失败判定**(三层分开):协议层(`growth.py`)非 2xx 抛 `GrowthRejected`;业务码非 0 / 缺 data / 非 JSON 抛 `UpstreamProtocolViolation`(HTTP 200 也可能是失败)。编排层(`growth_runner.py`)4xx 业务规则(名额用完、未解锁、抽奖没次数、能量不足)记 `IDLE` 而非 `FAILED`;5xx 与协议违规记 `FAILED`;401/403 立即置 `session_dead` 并停止后续请求。结论:`failed 且 gained=False` 才算整体失败(部分成功仍是成功)。接单失败分三类:`prerequisite not met: ` 按原因归并成一条并标 `reportable=True`(用户需知道去做什么),记 IDLE;`task does not require acceptance` 正常应答,不产生任何步骤;其余逐条记 FAILED。`GrowthStep.reportable` 控制该步是否进「一行汇报」。**任务契约是五态**:`not_accepted` → 接单、`accepted`/`in_progress` → 等用户完成、`completed` → 领奖、`claimed` → 跳过。接单 `POST /tasks/accept` body 是**复数数组** `{"task_codes": [...]}`;领奖 `POST /tasks/{task_code}/claim`。**403 不总是登录失效**:未解锁档位返回 403 +「连续登录天数不足」,必须先于 session 判定处理。不可逆动作(抽奖 / 连登兑换 / 开 Buddy 盲盒 / 消耗补登卡)由 `GROWTH_IRREVERSIBLE_ACTIONS` 总开关控制,**手动入口与定时任务读同一个开关**;开关只挡「消耗」,不挡「查询」(`/streak` 是只读接口)。**清理切点必顶对齐到小时边界**(`purge_expired`):`rollup_hourly` 对整行是 REPLACE 语义,只有保证「仍有明细的小时保有全部明细」重算才精确;若边界小时只删一半,下一轮 rollup 会把汇总行覆盖成剩下那一半,被删部分永久丢失。代价:明细最多多留 1 小时。**运维告警**:规则判定是纯函数(`tasks/alerting.evaluate_alerts`),阈值统一用「≤0 即关闭该规则」表达。 +| 额度探测(quota_probe) | 启动立即一轮(不节流)+ 每 `QUOTA_PROBE_MINUTES`(默认 60) | 写 `credentials.quota_*` / `quota_expiry_ladder` / `quota_packages` / `health` | +| token 预刷新(refresh) | 每 `REFRESH_INTERVAL_MINUTES`(默认 30,下限 5) | 到期前 `REFRESH_SKEW_HOURS`(默认 24h)窗口内轮换;短寿命渠道自行封顶(CodeArts 2700s,必须**严格宽于**本周期);`UpstreamReloginRequired` 时标记并硬禁用 | +| 每日签到(checkin) | 每 10 分钟 | 成功即封账(`日期:scope` → 截止时刻,内存态,跨天清理);渠道可回填 `seal_until`(Qoder 10:00 换窗口);失败重试 | +| 成长中心(growth) | 每 `GROWTH_INTERVAL_MINUTES`(默认 60,下限 5) | 仅 CB:7 类领取;落 `growth_events` + 回写 `growth_last_result` | +| 活跃上报(activity,默认关) | 每 10 分钟醒,仅 `ACTIVITY_REPORT_HOUR`(默认 10 点)窗口内执行 | CB + 国际版:补发 `chat_request_send`;按「endpoint + userId」隔离、当日封账 | +| 明细清理(retention) | 每 5 分钟 | `usage_events` 全量重算小时汇总(幂等 upsert)+ 90 天清理;同期限回收 `credit_events` / `audit_events` / `alert_events` / `growth_events`。跑在 `asyncio.to_thread` | +| 数据库备份(backup) | 每 `BACKUP_INTERVAL_MINUTES`(默认 1440,下限 60) | `VACUUM INTO` + `integrity_check` + 0600,保留 `BACKUP_KEEP`(默认 7)。跑在 `asyncio.to_thread` | +| 渠道模型列表刷新(注入协程 `api/models.py::list_models`) | 每 `MODEL_CATALOG_MINUTES`(默认 30,下限 5) | 兜底重拉(TTL 门禁 + 逐渠道 publish + 落盘)。周期跟 zen 判活缓存(30 分钟)对齐 | +| 模型目录刷新(注入协程) | 每 `OPENROUTER_CATALOG_MINUTES`(默认 1440,下限 60) | 拉 OpenRouter → 三张表 → 落盘 → 就地换入 | +| 运维告警(alert) | 每 `ALERT_INTERVAL_MINUTES`(默认 5,下限 1) | 评估四类风险,命中落 `alert_events` 并可推 webhook;同 `(规则, 对象)` 静默窗内只报一次 | +| 上游状态聚合(upstream_status) | 每 `UPSTREAM_STATUS_MINUTES`(默认 5,下限 1) | 并发抓官方状态(单源超时 `UPSTREAM_STATUS_TIMEOUT_SECONDS`)→ 归一 → 原子替换内存快照;单源失败只记该源 `error`,**整轮永不抛**。启动预热一轮 | +| 模型主动探活(model_probe) | 每 `MODEL_PROBE_MINUTES`(默认 0 关闭,下限 1) | 只探仍被 `blocked` 锁住的 (渠道, 模型):`max_tokens=16` 最小对话 → 三态判定 → 落 `model_probes`。不写 `usage_events`、不动冷却、不改健康度。`manual != ''` 跳过 | + +**两个刷新任务是「注入协程」而非 tasks 模块**:`tasks/` 不 import `api/` / `benchmarks`(会形成反向依赖),故 `TaskRunner` 接 `Callable[[], Awaitable[object]]`,由 `main.lifespan` 闭包注入;`None` 时不装配、管理台不展示。 + +**并发保护**:HTTP 出口与后台循环都调 `list_models`,故 `_refresh_providers` 持模块级 `asyncio.Lock`(粒度是整次列表刷新,跨渠道合并与别名 publish 需看到一致全集)。 + +**签到 / 成长的同账号隔离键**:`checkin_scope(data) or f"credential|{credential_id}"`(provider 身份未知时返回空串,共享空 scope 会让第二个账号被 `seen` 永久跳过)。 + +**记录口径**:`_guarded` 在任务返回 `None` 时**不入账**(`sleep` 到点但 `due()=False` 是 no-op,当成成功会让页面显示「签到刚跑过」而当天一次没签)。`TaskRun` 只在真实执行或抛异常时写入;计数 `runs` 同样只涨真实执行。运行态只在本进程内,重启归零。`TASK_SPECS` 是静态描述,与 `TaskRunner.start()` 的循环一一对应;周期与开关运行时现算。`GET /api/tasks` 返回带 `server_time`(前端用服务端时钟算「距今多久」)。 + +**手动触发** `POST /api/tasks/{key}/run`(admin + CSRF)返回 `{...运行态, ran, busy}`,未知 key 400。复用 `_loop_specs()`;差别只有 `manual=True` 时 `activity` 绕过 `due()`,以及开关**不绕**(`run_once` 返回 `None` 记 `ran=false`)。**并发**:`self._running: set[str]` 占 key(asyncio 单线程下「查 + 加」原子),撞上直接跳过本轮;手动触发在 `_run_guarded` 之前先查,命中回 `busy=true` 不等结果。`ran` 口径是「真跑了一轮(含失败)」。 + +**成长中心失败判定**(三层):协议层非 2xx 抛 `GrowthRejected`;业务码非 0 / 缺 data / 非 JSON 抛 `UpstreamProtocolViolation`(HTTP 200 也可能失败)。编排层 4xx 业务规则(名额用完、未解锁、抽奖没次数、能量不足)记 `IDLE`;5xx 与协议违规记 `FAILED`;401/403 置 `session_dead` 并停止后续。结论:`failed 且 gained=False` 才算整体失败。接单失败三类:`prerequisite not met: ` 归并一条并标 `reportable=True`(记 IDLE);`task does not require acceptance` 正常应答;其余逐条 `FAILED`。**任务契约五态**:`not_accepted` → 接单、`accepted`/`in_progress` → 等完成、`completed` → 领奖、`claimed` → 跳过。接单 body 是**复数数组** `{"task_codes": [...]}`。**403 不总是登录失效**:未解锁档位返回 403 +「连续登录天数不足」,必须先于 session 判定。不可逆动作由 `GROWTH_IRREVERSIBLE_ACTIONS` 总控,手动与定时读同一开关;开关只挡消耗不挡查询。**清理切点必须对齐到小时边界**(`purge_expired`):`rollup_hourly` 对整行是 REPLACE 语义,边界小时只删一半会让下一轮把汇总覆盖成剩下那一半,被删部分永久丢失;代价是明细最多多留 1 小时。**告警规则判定是纯函数**(`tasks/alerting.evaluate_alerts`),阈值统一「≤0 即关闭」。 | 规则 | 触发 | 信号来源 | |---|---|---| @@ -555,31 +658,31 @@ PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_k | `token_expiring` | token 剩余落在 `(now, now+ALERT_TOKEN_EXPIRY_HOURS]` | `CredentialRepository.expiring_tokens` | | `error_rate` | 窗内失败占比 ≥ `ALERT_ERROR_RATE_THRESHOLD` 且样本 ≥ `ALERT_ERROR_RATE_MIN_REQUESTS` | `StatsCollector.window_error_rate` | -- `pool_empty` 取 `total>0`:池里一个凭证都没有是「没配」而非「耗尽」,不报。 -- `task_failed` 只列**真跑过且达阈值**的 key:从未运行的任务不在 `_failure_streaks` 里,不会被误报。 -- `token_expiring`:`token_expires_at` 为 NULL 时从密文按需派生;派生不出(0)视为未知、不算「即将到期」;硬禁用凭证排除;**寿命本就短于窗口**的凭证排除(CodeArts STS 只有 2h,而默认窗口 24h,恒「即将到期」只会把真告警淹掉)。 -- `error_rate` 读 `usage_events` **明细**而非小时汇总(告警窗是分钟级,小时粒度要么整点才更新、要么跨小时口径错乱)。 +- `pool_empty` 取 `total>0`:一个凭证都没有是「没配」而非「耗尽」。 +- `task_failed` 只列真跑过且达阈值的 key。 +- `token_expiring`:NULL 时从密文派生,派生不出视为未知;硬禁用排除;**寿命本就短于窗口**的排除(CodeArts STS 2h vs 默认窗口 24h)。 +- `error_rate` 读 `usage_events` **明细**(告警窗是分钟级)。 -**投递**:站内每条命中落 `alert_events`(管理台倒序回看;落库而非只留内存,因为告警的价值恰在「错过的那段时间发生了什么」,保留期由 `RetentionTask` 按 90 天清理);webhook 配置 `ALERT_WEBHOOK_URL` 时逐地址 POST JSON(多个逗号分隔,全部成功才算 delivered),留空只留站内记录,**投递失败只记 `delivery_error`、绝不抛错**。**静默去重**:同一 `(rule, scope)` 在 `ALERT_SILENCE_MINUTES` 窗内只落库 / 推送一次。`AlertTask` **与运行态共享同一个 `TaskStatusStore`**——若自建 store,「任务连续失败」规则会永远读到 0 而静默失效。 +**投递**:每条命中落 `alert_events`(落库而非只留内存),webhook 配置 `ALERT_WEBHOOK_URL` 时逐地址 POST(多个逗号分隔,全部成功才算 delivered),**投递失败只记 `delivery_error`**。**静默去重**:同 `(rule, scope)` 在 `ALERT_SILENCE_MINUTES` 内只落库 / 推送一次。`AlertTask` **与运行态共享同一个 `TaskStatusStore`**(自建 store 会让「任务连续失败」永远读到 0)。 -**迟滞与恢复**(`ALERT_FOR_TICKS` / `ALERT_CLEAR_TICKS`):单轮采样可能只是毛刺,故「连续 `for_ticks` 轮命中」才进入告警(默认 1 = 不迟滞,保持向后兼容),「连续 `clear_ticks` 轮不再命中」才确认恢复(默认 2)。恢复时给该 `(rule, scope)` **全部尚未恢复**的行写 `resolved_at`(同一次故障期间静默窗过后会再落「提醒」行,恢复时一起收尾;否则更早的提醒永远停在未恢复,角标数 `resolved_at IS NULL` 就永远清不干净),并外推一条 `status="resolved"` 的 webhook;管理台把 `resolved_at` 非空的行显示为「已恢复」而非严重度。**迟滞计数**(连击/恢复轮数)进程内维护、重启归零重新累积,与签到去重同范式;但「当前哪些告警仍未恢复」是**已落库的事实**,`AlertTask` 构造时从 `AlertRepository.unresolved()` 重建进程内 `_active`——不重建的话重启后恢复分支只遍历空集合,历史未恢复行永远等不到 `resolve()`(规则早已不再命中却无人写 `resolved_at`),角标与「已恢复」标记会永久卡住。重建只恢复「告警中」集合与恢复通知所需的文案/detail(PII 片段按 `_PII_DETAIL_KEYS` 从 detail 反推,保证外发仍打码),不恢复连击计数。 +**迟滞与恢复**(`ALERT_FOR_TICKS` / `ALERT_CLEAR_TICKS`):「连续 for_ticks 轮命中」才告警(默认 1),「连续 clear_ticks 轮不命中」才确认恢复(默认 2)。恢复时给该 `(rule, scope)` **全部未恢复**的行写 `resolved_at` 并外推 `status="resolved"` webhook;管理台把 `resolved_at` 非空显示为「已恢复」。**迟滞计数**进程内、重启归零;但「哪些仍未恢复」是已落库事实,`AlertTask` 构造时从 `unresolved()` 重建 `_active`(不重建则重启后恢复分支遍历空集合,历史行永远等不到 `resolved_at`)。重建只恢复集合与通知所需文案(PII 片段按 `_PII_DETAIL_KEYS` 反推),不恢复连击计数。 -**签名**:webhook 请求恒带 `X-Coding2API-Signature: sha256=`,签名算在**实际发送的字节**上(故发送预序列化 `content` 而非 `json=`,否则 httpx 二次序列化后无法对账),收端用同一 `APP_SECRET` 复算即可验真。不设开关——多一个开关只会多一种「以为开了其实没开」的静默状态。 +**签名**:webhook 恒带 `X-Coding2API-Signature: sha256=`,签名算在**实际发送字节**上(故发预序列化 `content` 而非 `json=`)。不设开关。 -**上游状态页聚合**(`src/upstream_status.py` + `src/tasks/upstream_status.py`):并发抓各模型 / 网关提供方的官方状态接口并归一化,管理台「上游状态」页只读展示。定位是**只做解释不做决策**——本服务自己的 429/5xx 冷却只能回答「这个号现在不行」,官方状态页补上「是上游整体挂了还是我这边的问题」;但状态页滞后于本服务的模型级冷却(`src/provider/kilo/client.py`),把它并入选号 / 熔断只会让调度更慢更糊涂。**只存内存、不落盘**:状态数据分钟级易变,重启后拿一份几小时前的「一切正常」比显示「尚未抓取」危险得多。 +**上游状态聚合**(`src/upstream_status.py` + `src/tasks/upstream_status.py`):**只做解释不做决策**,不进选号 / 熔断。**只存内存、不落盘**(重启后拿几小时前的「一切正常」比显示「尚未抓取」更危险)。 -- **声明式源清单**:`SOURCE_SPECS` 是 `SourceSpec` 的元组(key / 展示名 / `api_url` / `page_url` / `kind` / `scope` / `related_providers`),加源只需加一行。`scope=channel` 表示与本站渠道一一对应(opencode→zen、kilo→kilo);`scope=model` 表示模型 / 上游方,经渠道间接使用,靠 `related_providers` 标注关联,**不做模型名模糊匹配**(必然出错的启发式)。 -- **四种解析器**(各自纯函数,`ValueError` 表示顶层形状不可用):`statuspage`(Atlassian Statuspage v2 `/api/v2/summary.json`,Kilo / Moonshot / MiniMax / OpenAI / Anthropic 共用)、`opencode`(OpenCode Zen `/api/status`,自定义 JSON,**逐模型 TTFT / tps**,`breakdown` 展开成多行)、`betterstack`(Better Stack `/index.json`,JSON:API,逐资源可用率)、`atom`(DeepSeek `/history.atom`,Flashduty 产出,只有事故史、无当前组件态)。 -- **六档归一**:`operational` / `degraded` / `partial` / `major` / `maintenance` / `unknown`;Statuspage `critical` → `major`(多一档只让配色更碎)。`aggregate_level` 取最差,但 `unknown` 权重 -1——**一个源连不上不该压过其余源报出的真实故障**;空输入回 `unknown`。 -- **失败隔离**:`fetch_source` 捕获一切(网络 / HTTP / 解析)→ `SourceStatus(level=unknown, error=...)`;`run_once` 全源失败也只返回 `{"sources","abnormal","failed"}` 而不抛——断网不值得触发「后台任务连续失败」告警。页面用 `summary.abnormal`(除 operational / unknown 外的一切)与 `summary.unknown`(抓取失败)分开展示。 -- **事故归一**:各源侧生命周期写法不同(Statuspage 的 investigating/identified/monitoring、OpenCode 的 `resolvedAt`、DeepSeek 正文里的 `Status: resolved`),统一成 `Incident.ongoing` 布尔;先过滤再截断到 `MAX_INCIDENTS = 5`(OpenAI 这类源会把卡片撑到几百行)。 -- **启动预热**:`_warm_upstream_status` 后台跑一轮(与 `_warm_model_catalog` 同范式),失败仅记日志、不阻断服务;关闭时取消任务,避免 in-flight 的 httpx 请求被带出事件循环。 +- **声明式源清单** `SOURCE_SPECS`(key / 展示名 / `api_url` / `page_url` / `kind` / `scope` / `related_providers`),加源加一行。`scope=channel` 与本站渠道一一对应(opencode→zen、kilo→kilo);`scope=model` 靠 `related_providers` 标注,**不做模型名模糊匹配**。 +- **四种解析器**(纯函数,`ValueError` 表示顶层形状不可用):`statuspage`(`/api/v2/summary.json`,Kilo / Moonshot / MiniMax / OpenAI / Anthropic)、`opencode`(`/api/status`,逐模型 TTFT / tps,`breakdown` 展开多行)、`betterstack`(`/index.json`,JSON:API)、`atom`(DeepSeek `/history.atom`,只有事故史)。 +- **六档归一**:`operational` / `degraded` / `partial` / `major` / `maintenance` / `unknown`;Statuspage `critical` → `major`。`aggregate_level` 取最差,`unknown` 权重 -1(一个源连不上不该压过真实故障);空输入 `unknown`。 +- **失败隔离**:`fetch_source` 捕获一切 → `SourceStatus(level=unknown, error=...)`;`run_once` 全源失败也只返回不抛。页面用 `summary.abnormal`(除 operational / unknown)与 `summary.unknown`(抓取失败)分开展示。 +- **事故归一**:各源生命周期写法不同,统一成 `Incident.ongoing` 布尔;先过滤再截断到 `MAX_INCIDENTS = 5`。 +- **启动预热**:`_warm_upstream_status` 后台跑一轮,失败仅记日志;关闭时取消任务。 ## 11. 出口辅助端点与错误语义 ### 11.1 余额与健康检查 -- `GET /v1/user/balance`:兼容 DeepSeek 余额接口形状,Bearer `sk-...` 鉴权,供 Cherry Studio / cc-switch 等客户端显示余额。余额来自凭证池的额度探测缓存(`QUOTA_PROBE_MINUTES` 周期刷新),按可用凭证汇总,单位为上游 credits。池内凭证从未探测成功时 `balance_known` 为 `false`(余额未知,不是 0)。 -- `GET /health`:只回 `{"status":"ok"}`(进程存活,适合容器存活探针),不鉴权。 -- `GET /healthz`:额外给出凭证池计数,供外部监控在池子耗尽时提前告警——那是「服务活着但用不了」的状态。两个端点都不鉴权。 +- `GET /v1/user/balance`:DeepSeek 形状,Bearer `sk-...`,供 Cherry Studio / cc-switch。余额来自额度探测缓存,按可用凭证汇总,单位上游 credits。从未探测成功时 `balance_known` 为 `false`(未知,不是 0)。 +- `GET /health`:`{"status":"ok"}`,存活探针,不鉴权。 +- `GET /healthz`:额外给池计数,`ready=0` 时 503(就绪)。两者都不鉴权,都接受 `HEAD`。 ```json { @@ -588,52 +691,51 @@ PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_k } ``` -五类计数互斥且合计 = `total`,判定优先级 `disabled → paused → cooling → ready`(系统禁用 / 用户暂停优先于冷却)。口径**复用调度器的 `Candidate.is_selectable`**(`CredentialRepository.pool_counts`),不另写 SQL——两套口径一旦分叉,健康检查会报出与实际选号不符的「可用数」。`ready=0` 时对话请求直接返回 503。 +五类计数互斥、合计 = `total`,判定优先级 `disabled → paused → cooling → ready`。口径**复用调度器的 `Candidate.is_selectable`**(`CredentialRepository.pool_counts`),不另写 SQL。 -> ⚠️ **`/healthz` 的 `ready` 不看 `health`**:`is_selectable` 只判 disabled / enabled / cooling,额度耗尽的号(`health < 0`)仍算 `ready`。侧栏「凭证管理」角标是**另一套口径**(`attention_counts`:`disabled + health < 0`),刻意与 `ready` 不同——一个是「能不能拿去打上游」,一个是「有没有待办」。两者不一致不是 bug。 +> ⚠️ **`/healthz` 的 `ready` 不看 `health`**:额度耗尽的号(`health < 0`)仍算 `ready`。侧栏「凭证管理」角标是**另一套口径**(`attention_counts`:`disabled + health < 0`)——一个问「能不能打上游」,一个问「有没有待办」,不一致不是 bug。 ### 11.2 侧栏数据提醒(`GET /api/nav-badges`) +一个聚合端点覆盖整个侧栏(避免 `Layout` 挂 7 个页面级 hook),60s 轮询。 -一个聚合端点覆盖整个侧栏,避免 `Layout` 挂 7 个页面级 hook(那些返回全量列表,路由切换会重拉重数据)。侧栏 60s 轮询。 - -- **按角色裁剪 payload**,**缺失的键 = 无权看**(不是 0):admin-only 的 `alerts`/`tasks`/`users` 不下发给 operator/viewer;operator 的 `credentials`/`upstream`/`upstream_level`/`model_catalog_stale` 不下发给 viewer;`api_keys` 全员但只数本人。这是聚合端点,**不做整体 403**(viewer 也要看自己的 API Key)。前端 `lib/navBadges.ts` 先判 `undefined` 再判值,越权与「无待办」都不渲染。 -- **计数与页面数据不是同一口径**:`alerts` 只数 `resolved_at IS NULL`(页面 `total` 含已恢复历史);`tasks` 用 `last_ok is False`(首次失败即提示,不等告警规则的「连续失败 ≥ 阈值」),`last_ok is None`(从未运行)与已关闭的不计;`users` 数 `activation_digest IS NOT NULL`(含已过期令牌——需管理员重发,仍是待办);`api_keys` 数 `expires_at <= now + 7d`(含已过期,`NULL`=永不过期不计)。 -- **上游角标**直接读 `summary.abnormal`(含 maintenance),不另立「异常」口径;`upstream_level`(`aggregate_level` 最差档)只用于定色调(maintenance → 黄,partial/major → 红)。 -- **模型目录陈旧**:`price_saved_at` 为空 → `false`(从未拉取,新装不该报警);否则 `now - price_saved_at > 2 × OPENROUTER_CATALOG_MINUTES` 才算。四元组同生共死,「从未拉取」与「目录丢了」在事实上同一态。 -- **未装配回退**:`task_runner` / `upstream_status` 未挂时回 0 / `unknown` 而非 500(与 `admin_upstream.py` 的空快照同范式)。 -- **前端防御**:角标挂在全局侧栏,脏 payload 会被当 React child 渲染并打崩整棵树(页面测试的 catch-all stub 撞到过),故 `count()` 显式校验 `typeof === "number"` + 有限正数,非数字静默不渲染。 +- **按角色裁剪 payload**,**缺失的键 = 无权看**(不是 0):admin-only 的 `alerts`/`tasks`/`users` 不下发给 operator/viewer;operator 的 `credentials`/`upstream`/`upstream_level`/`model_catalog_stale` 不下发给 viewer;`api_keys` 全员但只数本人。**不做整体 403**(viewer 也要看自己的 Key)。前端 `lib/navBadges.ts` 先判 `undefined` 再判值。 +- **计数与页面数据不是同一口径**:`alerts` 只数 `resolved_at IS NULL`;`tasks` 用 `last_ok is False`(`last_ok is None` 与已关闭不计);`users` 数 `activation_digest IS NOT NULL`(含已过期);`api_keys` 数 `expires_at <= now + 7d`(含已过期,NULL 不计)。 +- **上游角标**读 `summary.abnormal`(含 maintenance);`upstream_level` 只用于定色调。 +- **模型目录陈旧**:`price_saved_at` 为空 → `false`;否则 `now - price_saved_at > 2 × OPENROUTER_CATALOG_MINUTES` 才算。 +- **未装配回退**:`task_runner` / `upstream_status` 未挂时回 0 / `unknown` 而非 500。 +- **前端防御**:`count()` 显式校验 `typeof === "number"` + 有限正数,非数字静默不渲染。 ### 11.3 Prometheus 指标(`GET /metrics`) -`src/api/metrics.py` 零依赖手写 Prometheus text 格式(不引 `prometheus_client`:本服务只暴露一组固定小指标,官方客户端会带进整套注册表与默认采集器)。默认开启、**不鉴权**(与 `/healthz` 同口径),`METRICS_ENABLED=false`(可热更)时返回 404。路由注册在 SPA catch-all 之前,否则会被通配路由吞掉。 +`src/api/metrics.py` 零依赖手写 text 格式(不引 `prometheus_client`)。默认开、**不鉴权**,`METRICS_ENABLED=false`(可热更)时 404。路由注册在 SPA catch-all 之前。 | 指标 | 类型 | 标签 | 数据源 | |---|---|---|---| -| `coding2api_requests_total` | counter | `provider` / `model` / `status`(ok\|error) | `usage_hourly` 永久小时汇总(`StatsQuery.metrics_provider_model`),`error = requests − ok_count` | -| `coding2api_tokens_total` | counter | `provider` / `type`(input\|output\|reasoning\|cached) | `usage_hourly`(`StatsQuery.metrics_provider_tokens`) | -| `coding2api_credentials` | gauge | `state`(ready\|cooling\|paused\|disabled) | `CredentialRepository.pool_counts()`(与 `/healthz` 同口径) | -| `coding2api_upstream_errors_total` | counter | `provider` / `kind`(受控 `error_type`) | `StatsCollector.error_totals()`(进程内计数,重启归零) | -| `coding2api_task_failures_total` | counter | `task` | `TaskStatusStore.failures()`(进程内累计失败数,重启归零) | +| `coding2api_requests_total` | counter | `provider` / `model` / `status`(ok / error) | `usage_hourly`(`error = requests − ok_count`) | +| `coding2api_tokens_total` | counter | `provider` / `type`(input / output / reasoning / cached) | `usage_hourly` | +| `coding2api_credentials` | gauge | `state`(ready / cooling / paused / disabled) | `pool_counts()`(与 `/healthz` 同口径) | +| `coding2api_upstream_errors_total` | counter | `provider` / `kind` | `StatsCollector.error_totals()`(进程内,重启归零) | +| `coding2api_task_failures_total` | counter | `task` | `TaskStatusStore.failures()`(进程内) | -三个口径要点:① **禁高基数标签**——不打 `credential_id` / `api_key_id` / `username` / `request_id`,一个账号一条时间序列会让 Prometheus 内存与查询失控;② `requests_total` / `tokens_total` 读**永久**小时汇总而非 90 天明细,故 scrape 不做明细全表扫描、且计数跨重启单调;③ 进程内计数器(失败原因、任务失败)随重启归零,Prometheus 原生按 counter reset 处理。文本转义只处理 `\` / `"` / 换行(模型名可能含引号,不转义会拼坏整份响应)。**已知偏离扫描报告**:报告写 `upstream_errors_total` 数据源为 `usage_hourly`,但小时汇总没有 `error_type` 维度,用明细当 `_total` 会随裁剪下降,故 `kind` 改用进程内计数。 +要点:① **禁高基数标签**(不打 `credential_id` / `api_key_id` / `username` / `request_id`);② 前两项读**永久**小时汇总,计数跨重启单调;③ 进程内计数器随重启归零,Prometheus 按 counter reset 处理。文本转义只处理 `\` / `"` / 换行。**已知偏离**:`kind` 用进程内计数(小时汇总无 `error_type` 维度,用明细会随裁剪下降)。 ### 11.3b 请求级结构化日志(`src/webapp/access_log.py`) -`RequestLogMiddleware`(纯 ASGI)在每个**业务请求**结束时写一行 logfmt:`event=request request_id=… method=… route=… status=… duration_ms=…`。它是 uvicorn 裸 access log 的补充而非替代——后者的行里带完整 query string(客户端可能塞 token)、没有请求 ID、没有耗时,也没法和 `usage_events.request_id` 对上。 +`RequestLogMiddleware`(纯 ASGI)每个**业务请求**结束写一行 logfmt:`event=request request_id=… method=… route=… status=… duration_ms=…`。补充而非替代 uvicorn access log(后者带 query string、无请求 ID、无法与 `usage_events.request_id` 对上)。 -- **脱敏口径**:只取 `scope["path"]`,**从不读 `query_string`**;不碰 body / 请求头;**不写用户名**(靠 `request_id` 关联 `usage_events`,那里才有身份列)。路由用**模板**(`scope["route"].path`)而不是实际路径,资源 ID 不进日志也不会让日志按 ID 分叉;命中 SPA catch-all `/​{path:path}` 或根本没匹配到路由时回落为清洗过的原始路径(`[A-Za-z0-9._~/%:@-]` 之外替换为 `?`、截断 120 字符,防换行注入)。 -- **只记业务请求**:`/api/*`、`/v1/*`、`/authorize`。静态资源与 `/health`、`/healthz`、`/metrics` 不记——uvicorn 已覆盖,重复记只会把业务行挤出视野。 -- **级别**:`5xx` → WARNING,其余(含 4xx)→ INFO。`finally` 里发日志,故下游异常冒泡(服务器回 500)也留痕,`status` 取不到时按 500 记。 -- **装配顺序**:在 `RequestIdMiddleware` 内侧(要用它生成的 ID)、在 `BodySizeLimitMiddleware` 外侧(413 也是请求结果,要记)。热更开关 `REQUEST_LOG_ENABLED` 走取值器,改完下一请求生效。 +- **脱敏**:只取 `scope["path"]`,**从不读 `query_string`**;不碰 body / 头;**不写用户名**(靠 `request_id` 关联)。路由用**模板**(`scope["route"].path`),未匹配回落清洗过的原始路径(`[A-Za-z0-9._~/%:@-]` 之外替换 `?`、截断 120 字符)。 +- **只记业务请求**:`/api/*`、`/v1/*`、`/authorize`;静态资源与 `/health`、`/healthz`、`/metrics` 不记。 +- **级别**:`5xx` → WARNING,其余 INFO。`finally` 里发,`status` 取不到按 500。 +- **装配顺序**:`RequestIdMiddleware` 内侧、`BodySizeLimitMiddleware` 外侧。热更 `REQUEST_LOG_ENABLED`。 ### 11.3c 服务端日志页(`src/webapp/log_buffer.py` + `src/api/logs.py`) -`LogBufferHandler` 是个 `logging.Handler`,把本进程的记录写进**有界环形缓冲**(容量现读 `LOG_BUFFER_LINES`,热更调小立即裁掉最旧)。数据出口只有两个 admin 端点:`GET /api/logs?limit=`(默认 200,上限 = 缓冲容量)与 `GET /api/logs/stream`(SSE)。`LOG_BUFFER_LINES == 0` 时两个端点都 404,root 上的 handler 也摘掉——「页面关了」与「确实没有日志」必须能区分。 +`LogBufferHandler` 把本进程记录写进**有界环形缓冲**(容量现读 `LOG_BUFFER_LINES`,调小立即裁最旧)。出口只有两个 admin 端点:`GET /api/logs?limit=`(默认 200)与 `GET /api/logs/stream`(SSE)。`LOG_BUFFER_LINES == 0` 时两者 404 且 root handler 摘掉。 -- **脱敏是硬前提**:`redact_or_drop(line)` 是纯函数,命中即**整行丢弃**(返回 `None`):独立敏感词(`token` / `secret` / `authorization` / `cookie` / `password` / `passwd` / `api_key` 及其 `apikey` / `api-key` / 复数形态,任意大小写)、键值形态(`access_token=`、`X-Api-Key:`、`set-cookie:`、`client_secret =`、JSON 键 `"access_token": "…"`)、密钥形状(`sk-…`,前面不能是词字符,故 `task-run` 不误伤)、裸凭证(`Bearer <值>`)。空行/纯空白也丢。代价是 `token 已刷新` 这类正常行会一起被吞——**宁可漏一条日志,也不把凭证推进浏览器**。 -- **只收本进程**:attach 到 root logger,不读文件、不 tail。多进程 / 容器下各看各的,这是刻意的(轮转与权限会让文件视图骗人)。 -- **SSE 与 `/api/stats/events/stream` 同一范式**:先发注释帧让 EventSource 进 OPEN,再回放快照、转实时;回放与实时用每行 `seq` 去重(订阅先建、快照后取,交叠的行只发一次)。15s 注释保活,订阅者上限 4,超限 429 + `Retry-After: 10`,响应带 `Cache-Control: no-store` 与 `X-Accel-Buffering: no`。`finally` 注销订阅(关标签页会取消生成器,漏注销会把槽位耗到全员 429)。 -- **行内容**:`seq` / `ts`(epoch 秒)/ `level` / `logger` / `message`(已脱敏、单行截断 4000 字符)/ `request_id`(上下文有则带)。前端只做客户端侧的级别筛选、关键字过滤、跟随最新、暂停与清空,**没有任何写入入口**。 +- **脱敏是硬前提**:`redact_or_drop(line)` 命中即**整行丢弃**:独立敏感词(`token` / `secret` / `authorization` / `cookie` / `password` / `passwd` / `api_key` 及 `apikey` / `api-key` / 复数,任意大小写)、键值形态(`access_token=`、`X-Api-Key:`、`set-cookie:`、`client_secret =`、JSON 键)、密钥形状(`sk-…`,前面不能是词字符故 `task-run` 不误伤)、裸凭证(`Bearer <值>`)。空行也丢。代价是「token 已刷新」这类正常行一起被吞——**宁可漏一条,也不把凭证推进浏览器**。 +- **只收本进程**:attach 到 root logger,不读文件、不 tail。 +- **SSE 同范式**:先发注释帧,再回放快照转实时;回放与实时按 `seq` 去重。15s 保活,订阅者上限 4,超限 429 + `Retry-After: 10`,响应带 `Cache-Control: no-store` 与 `X-Accel-Buffering: no`。`finally` 注销订阅。 +- **行内容**:`seq` / `ts` / `level` / `logger` / `message`(已脱敏、截断 4000 字符)/ `request_id`。前端只做筛选、搜索、跟随最新、暂停与清空,**无写入入口**。 ### 11.4 面向用户的错误语义 -管理端点返回的失败原因必须是**稳定的机器可读枚举**,不能是 Python 异常类名(类名是实现细节,重构时会漂移)。`POST /api/credentials/{id}/probe` 的失败响应: +管理端点失败原因必须是**稳定的机器可读枚举**,不能是异常类名。`POST /api/credentials/{id}/probe` 的失败响应: ```json { "probed": false, "reason": "credential_rejected", "detail": "upstream http 401" } @@ -641,63 +743,64 @@ PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_k | `reason` | 含义 | 用户该做什么 | |---|---|---| -| `credential_rejected` | 上游 401/403 拒绝凭证 | 重新登录该账号 | +| `credential_rejected` | 上游 401/403 拒绝凭证 | 重新登录 | | `rate_limited` | 上游 429 | 稍后重试 | -| `upstream_unavailable` | 上游 5xx | 等待上游恢复,与本账号无关 | +| `upstream_unavailable` | 上游 5xx | 等上游恢复 | | `upstream_rejected` | 其他 4xx | 检查账号状态 | -| `upstream_response_invalid` | 响应结构不符 | 可能是官方接口变更 | +| `upstream_response_invalid` | 响应结构不符 | 可能官方接口变更 | | `upstream_timeout` | 请求超时 | 重试 | -| `network_unreachable` | 连不上渠道服务器(DNS / 连接被拒 / 建连超时 / TLS 中断) | 检查本机网络或代理,确认渠道域名可达 | +| `network_unreachable` | 连不上(DNS / 拒绝 / 建连超时 / TLS 中断) | 检查网络或代理 | | `unknown_error` | 未归类 | 查 `detail` | -**客户端读体中途断开**(`starlette.requests.ClientDisconnect`)不是服务端错误:异常处理器按网关惯例回 `499` + `error_payload(..., "client_disconnected", 499)`,不落 500 + ASGI Traceback——以前它会冒泡到 `ServerErrorMiddleware`,既刷屏日志又把「客户端自己取消」混进服务错误统计。 +**客户端读体中途断开**(`ClientDisconnect`)不是服务端错误:回 `499` + `client_disconnected`,不落 500(以前会冒泡到 `ServerErrorMiddleware`,刷屏且污染错误统计)。 -`detail` 保留原始错误摘要**仅供排查**,界面不得把它当作主提示展示。前端 `probeFailureLabel()` 把 `reason` 翻成中文并对未知值兜底到 `unknown_error`,不得透传原始字符串。对外 400/503 文案同理只带受控标识(HTTP 状态码 / 业务码 / 类名),**不回显上游正文**(可能含上游内部 schema,甚至回显的凭证片段)。 +`detail` 仅供排查,界面不得当主提示。前端 `probeFailureLabel()` 翻中文并对未知值兜底。对外 400/503 文案只带受控标识,**不回显上游正文**。 ## 12. 测试策略 -**目录组织**:`tests/` 按 `src/` 子包镜像(`api/ auth/ compat/ config/ db/ deploy/ engine/ provider/<渠道>/ scripts/ stats/ tasks/ webapp/`),每个目录一个 `__init__.py`。跨模块回归与纯补覆盖用例集中在 `tests/regression/`。逐字节等价的常量/纯函数收敛到 `tests/helpers/`;夹具按作用域放在 `tests/conftest.py` 或目录级 `conftest.py`;测试私有替身类放非收集模块 `_support.py`(不得以 `test_` 开头)。**真连上游豁免**:默认全部用例走 mock;需要真实探活上游的用例用 marker 显式标注(`@pytest.mark.real_upstream("zen" | "kilo" | "openrouter")`),`conftest` 据 marker 放行对应网络访问。marker 已在 `pyproject.toml` 注册(`--strict-markers` 生效,新增必须注册)。 +**目录**:`tests/` 按 `src/` 子包镜像(`api/ auth/ compat/ config/ db/ deploy/ engine/ provider/<渠道>/ scripts/ stats/ tasks/ webapp/`),每目录一个 `__init__.py`。跨模块回归集中 `tests/regression/`;逐字节等价的常量/纯函数收敛 `tests/helpers/`;夹具按作用域放 `conftest.py`;私有替身放非收集模块 `_support.py`。**真连上游豁免**:默认全 mock;需真探活的用例标 `@pytest.mark.real_upstream("zen" | "kilo" | "openrouter")`(已注册,`--strict-markers` 生效)。 | 目标 | 覆盖 | 方式 | |---|---|---| -| 调度器(到期指标/冷却/三态排序/轮换/pin) | 100% | 纯单元,注入假 provider | -| 鉴权(users/apikey/session/rbac) | 100% | 单元 + FastAPI TestClient | -| SSE 帧解析、provider 事件映射、OpenAI 协议适配、OAuth/回调解析 | 100% | 单元 + 真实样本 fixture 契约测试(codebuddy / trae) | -| HTTP 客户端 | 100% | respx mock 状态码 + body → classify 断言 | +| 调度器(到期/冷却/三态排序/轮换/pin) | 100% | 纯单元,注入假 provider | +| 鉴权(users/apikey/session/rbac) | 100% | 单元 + TestClient | +| SSE 解析、事件映射、OpenAI 适配、OAuth/回调 | 100% | 单元 + 真实样本 fixture(codebuddy / trae) | +| HTTP 客户端 | 100% | respx mock 状态码 + body → classify | | 统计/查询 | 100% | sqlite 内存库集成 | | 其余 | 100% | — | -**全量 100%(行 + 分支)是硬门槛**:CI 里 `pytest --cov-fail-under=100`,新增/修改的代码必须带测试,缺口一律补测试解决,不用 pragma / 排除达标。允许的例外(必须带注释说明为何不可达):src/ 中「按构造不可达」的防御兜底(`__main__` 入口、协议演进防御分支、前置 return 已排除的死分支);tests/ 中测试替身实现了宽于本用例所需的接口方法。**平台差异分支不用 pragma,必须 monkeypatch 测全**(`platform.system`/`machine` 等;本地 macOS 通过不代表 CI 的 ubuntu-latest 通过)。**测试不得依赖本地 `.env`**:CI 从不带 `.env`,而本地 `.env` 会经 pydantic-settings 补上 `APP_SECRET` 等必填项。任何构造 `Settings()` 的测试都要**显式传值**(`Settings(_env_file=None, APP_SECRET=...)` 或 `setenv`),否则本地绿、CI 红。**验证手法**:临时把 `.env` 移走再跑全量,通过才算数。**fixture**:存于 `src/provider/fixtures/`(真实 SSE/JSON 样本,覆盖正文、思考、工具调用、错误码与额度),断言两个方向:**解析正确**(样本 → 期望 Event)与**不静默**(畸形样本 → `UpstreamProtocolViolation`)。当前只有 codebuddy / trae 有落盘样本;zen / kilo / qoder / codearts 的协议已用逐帧单测覆盖(含畸形帧),但**尚未补真实样本文件**——需拿到上游实际抓包后按同一契约补,避免用合成样本冒充「真实样本」。 +**全量 100%(行 + 分支)是硬门槛**:`--cov-fail-under=100`,缺口一律补测试,不用 pragma 达标。例外(须注释说明不可达):src/ 的按构造不可达兜底(`__main__`、协议演进防御、前置 return 死分支);tests/ 的替身实现了宽于本用例的方法。**平台差异分支不用 pragma,必须 monkeypatch 测全**。**测试不得依赖本地 `.env`**:构造 `Settings()` 必须显式传值(`Settings(_env_file=None, APP_SECRET=...)`),验证时临时移走 `.env` 再跑全量。**fixture** 存 `src/provider/fixtures/`,断言两个方向:解析正确与**不静默**(畸形 → `UpstreamProtocolViolation`)。当前只有 codebuddy / trae 有落盘样本;zen / kilo / qoder / codearts 已用逐帧单测覆盖(含畸形帧),**尚未补真实样本**——不用合成样本冒充。 -## 13. 部署与运行时配置热更 +## 13. 部署与配置热更 ### 13.1 运行时配置覆盖层 -**分层**: - | 类别 | 例子 | 位置 | 变更方式 | |---|---|---|---| -| 启动期不可变项 | `APP_SECRET` / `HOST` / `PORT` / `DATA_DIR` / `USERS_FILE` / 上游端点白名单 / `PROVIDER_PROXIES` | `config.Settings`(frozen) | 改 env + 重启;**不进白名单** | -| 运行时可覆盖项 | `runtime_settings.HOT_SETTINGS`(51 项) | `RuntimeSettings` 覆盖层 | 管理台改,立即生效 | +| 启动期不可变 | `APP_SECRET` / `HOST` / `PORT` / `DATA_DIR` / `USERS_FILE` / 上游端点白名单 / `PROVIDER_PROXIES` | `config.Settings`(frozen) | 改 env + 重启;**不进白名单** | +| 运行时可覆盖 | `runtime_settings.HOT_SETTINGS`(60 项) | `RuntimeSettings` 覆盖层 | 管理台改,立即生效 | + +启动期项拒绝热更的原因:它们决定进程如何启动或作用于连接池,运行期变更只会让「当前进程」与「磁盘配置」静默分叉。**优先级**:`runtime_settings` 表 > `.env`。覆盖行只有被改过的 key;「恢复默认」= 删行。**读取**:`RuntimeSettings` 对热更 key 返回覆盖值,其余 `__getattr__` 委托给 `Settings`。覆盖值内存缓存,写入后 `reload()`,读取路径不查库。**消费端** `config.live(value)` 把标量或零参 callable 统一成取值器;生产传 `lambda: runtime.xxx`。已接线点:`Scheduler.expiry_window` / `secondary_expiry_window`、`ConversationAffinity.ttl_seconds`、`Pacer.min/max_seconds`、`Executor.default_model` / `max_auto_continues` / `force_disable_thinking` / `reasoning_effort_map`、`GrowthTask.allow_irreversible`、`TaskRunner` 周期与活跃上报时点、上下文压缩四项、`USD_CNY_RATE`、`anthropic_thinking_efforts`。 + +**校验**:白名单外 key 拒绝;类型与范围写入前校验;跨字段组合(`pacer_min ≤ pacer_max`)用「这批写完之后」的对端值比较,失败整批不落库。坏行读取时跳过并记警告。**接口**:`GET /api/settings` 返回 `snapshot()`(`key`/`env_name`/`label`/`description`/`kind`/`value`/`default`/`overridden`/`task`/`group`)+ 覆盖计数 + `groups`;`PUT /api/settings` body `{"values": {key: 标量 | null}}`,`null` 恢复默认,写操作记审计。网关卡组表即 `GATEWAY_GROUPS`。不变量:每项**要么**属于一个任务、**要么**落在一个网关卡组,两者互斥且都有归属(测试守住,否则前端漏渲染)。 + +### 13.2 后台任务可视化 +`SettingsPage` 一排 tabs:每任务一个 tab(运行态 + 配置项),无任务归属的按网关卡组各占一个。没有配置项的分组不出 tab;归属对不上的落「其他」,绝不吞掉配置。保存按钮与「N 项待保存」常驻顶部,草稿跨 tab 保留。`useTasks` 以 `refetchInterval: 30_000` 刷新,`/api/settings` 不自动刷新。任务卡片「立即运行」,运行中 / 已关闭时禁用;结果文案三态——**已跑一轮**(带 `last_report`)、**没排上**(`busy=true`)、**跑了但 no-op**(`ran=false`)。把第三种说成「已运行」就是谎报。 -启动期项拒绝热更的原因:它们决定进程如何启动(监听地址、加密密钥、上游白名单)或作用于连接池;运行期变更只会让「当前进程」与「磁盘配置」静默分叉。**生效优先级**:`runtime_settings` 表(DB 覆盖)> `.env`(默认值来源)。覆盖行只有被管理台改过的 key;「恢复默认」= 删除该行,不是写入 env 当前值。**读取机制**:`RuntimeSettings` 对热更 key 返回覆盖值,其余属性 `__getattr__` 透明委托给 `Settings`——调用方仍写 `settings.default_model`。覆盖值在内存缓存,写入后 `reload()` 刷新;读取路径不查库。**消费端**:`config.live(value)` 把「标量或零参 callable」统一成取值器;生产装配传 `lambda: runtime.xxx`(每次读当前值),测试与一次性任务传标量。已接线的热更点:`Scheduler.expiry_window` / `secondary_expiry_window`(读取时跑 `expiry_windows()` 归一化)、`ConversationAffinity.ttl_seconds`、`Pacer.min/max_seconds`(校验从构造期挪到读取期)、`Executor.default_model` / `max_auto_continues` / `force_disable_thinking` / `reasoning_effort_map`、`GrowthTask.allow_irreversible`、`TaskRunner` 周期与活跃上报开关时点、上下文压缩四项、`USD_CNY_RATE`、Anthropic 出口的 `anthropic_thinking_efforts`(在 `api/messages.py` 的 parse 闭包里现读)。**校验**:白名单外 key 直接拒绝;类型与范围(min/max)写入前校验;跨字段组合(`pacer_min ≤ pacer_max`)用「这一批写完之后」的对端值比较,失败则整批不落库。表里坏行读取时跳过并记警告。**接口**:`GET /api/settings`(admin)返回 `snapshot()`(`key`/`env_name`/`label`/`description`/`kind`/`value`/`default`/`overridden`/`task`/`group`)+ 覆盖计数 + 网关卡组表 `groups`;`PUT /api/settings`(admin + CSRF)body `{"values": {key: 标量 | null}}`,`null` 表示恢复默认。写操作记审计日志。`snapshot()` 里的 `task` 是**配置项 → 后台任务 key** 的归属(`null` = 非任务项),`group` 是**非任务项 → 网关卡组 key** 的归属(`GATEWAY_GROUPS`:模型路由 / 选号与会话 / 渠道节流 / 后台任务节流 / 可观测性)。不变量:每项**要么**属于一个任务,**要么**落在一个网关卡组,两者互斥且都有归属(测试守住,否则前端会漏渲染该配置项)。 -### 13.2 后台任务可视化(管理台「任务与配置」页) -`SettingsPage` 用一排 tabs 控制信息量:每个任务一个 tab(该任务运行态 + 配置项),无任务归属的配置按后端下发的网关卡组各占一个 tab;一屏只呈现一块。没有配置项的分组不出 tab;归属对不上的项落进「其他」tab,绝不吞掉配置。保存按钮与「N 项待保存」常驻面板顶部,草稿跨 tab 保留、一次提交全部改动。`useTasks` 以 `refetchInterval: 30_000` 自动刷新(运行态是随时间变化的观测量),`/api/settings` 不自动刷新。任务卡片带「立即运行」按钮(`POST /api/tasks/{key}/run`),运行中 / 任务已关闭时禁用;结果文案区分三态——**已跑一轮**(带 `last_report`)、**没排上**(`busy=true`,另一轮在跑)、**跑了但 no-op**(`ran=false`,任务被开关跳过)。把第三种说成「已运行」就是在谎报,任务其实一步没动。 ### 13.3 按渠道出站代理 -`PROVIDER_PROXIES`(启动期项,默认 `""` 直连):`渠道=代理URL;渠道2=代理URL2`,渠道取 `KNOWN_PROVIDERS`,协议 `http/https/socks5/socks5h`。作用于该渠道**全部出站请求**(聊天流、额度/模型拉取、后台任务与 OAuth 登录)。**严格解析**:未知渠道 / 非法协议 / 缺 `=` / 空 URL 一律 `ValueError`,只忽略空段——代理常带合规 / 隐私意图,「以为走了代理其实直连」是静默的安全问题,启动报错远好过静默直连。工厂 `build_client(timeout, proxy)` 统一构造 `httpx.AsyncClient(..., trust_env=False, proxy=proxy)`;各 provider client 与三个 OAuth 流(CodeBuddy/Qoder/CodeArts)增 `proxy` 参数。**为什么启动期而非热更**:代理作用于连接池,运行中改值需重建在途连接池(6 个客户端 + 3 个 OAuth 流)。 +`PROVIDER_PROXIES`(启动期,默认 `""` 直连):`渠道=代理URL;渠道2=代理URL2`,渠道取 `KNOWN_PROVIDERS`,协议 `http/https/socks5/socks5h`。作用于该渠道**全部出站请求**(聊天、额度/模型、后台任务、OAuth)。**严格解析**:未知渠道 / 非法协议 / 缺 `=` / 空 URL 一律 `ValueError`,只忽略空段(「以为走了代理其实直连」是静默安全问题)。工厂 `build_client(timeout, proxy)` 统一构造 `httpx.AsyncClient(..., trust_env=False, proxy=proxy)`。**为什么启动期**:代理作用于连接池,运行中改需重建 6 个客户端 + 3 个 OAuth 流。 -**`trust_env=False` 是全项目纪律,不只 provider 客户端**:httpx 默认 `trust_env=True` 会读宿主环境与 **macOS 系统代理**(`scutil --proxy`),本机开着 Clash(`127.0.0.1:7897`)时状态页 / OpenRouter 目录这类**公共只读**出站会被交给该代理,代理握手失败即整批失败——实测「上游状态全部抓取失败」「模型目录全空」。因此凡自建 `httpx.AsyncClient` 的公共出站点都必须显式 `trust_env=False`:`upstream_status.fetch_all`、`benchmarks.fetch_openrouter_models`、`tasks/alerting` 的 webhook(`src/provider/proxy.build_client` 已内建)。 -### 13.4 重启是必须的 -**静态产物与进程内代码的刷新方式不同**: +**`trust_env=False` 是全项目纪律**:httpx 默认会读宿主环境与 macOS 系统代理(`scutil --proxy`),本机开着 Clash 时状态页 / OpenRouter 目录这类公共只读出站会被交给该代理,握手失败即整批失败(实测「上游状态全部抓取失败」「模型目录全空」)。凡自建 `httpx.AsyncClient` 的公共出站点都必须显式关闭:`upstream_status.fetch_all`、`benchmarks.fetch_openrouter_models`、`tasks/alerting` 的 webhook(`provider/proxy.build_client` 已内建)。 -- **前端**:后端对 `web/dist` 用 `FileResponse` **每次请求现读磁盘**,重新构建后刷新浏览器即生效,无需重启。 -- **后端**:`src/` 下的一切(路由、依赖、`build_app` 装配)只在**进程启动时**装载。进程管理器只在进程**退出**时重新拉起、**不监听源码变化**。 +### 13.4 重启是必须的 +- **前端**:后端对 `web/dist` 用 `FileResponse` **每次请求现读磁盘**,重建后刷新浏览器即生效。 +- **后端**:`src/` 下一切只在**进程启动时**装载;进程管理器只在进程退出时拉起、不监听源码变化。 -两者可独立更新,于是会出现 **「新前端 + 旧后端」**的错配:页面能打开(静态文件是新的),但新端点全部失败——旧后端没有该路由,未匹配的 `/api/*` 按约定返回 JSON `404`(`webapp/static.py` 的 SPA catch-all 排除 `/api` 与 `/v1`),前端把它归一成**无 `message` 的通用失败**,弹出与实际原因无关的兜底文案。 +于是会出现 **「新前端 + 旧后端」**:页面能打开,但新端点全 404(`webapp/static.py` 的 catch-all 排除 `/api` 与 `/v1`),前端归一成通用失败,弹出与真实原因无关的兜底文案。 ```bash docker compose up -d --force-recreate # Docker / compose sudo systemctl restart coding2api # systemd ``` -**诊断顺序**(先确认版本,再查业务): +诊断顺序(先确认版本再查业务): ```bash # 1) schema 版本已迁移(期望 29)且账号已导入 @@ -706,30 +809,36 @@ sqlite3 data/coding2api.sqlite3 "PRAGMA user_version; SELECT username, role, ena curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/users ``` -**结论**:改 `src/` 必须重启进程;改 `web/` 只需重新构建。升级 schema 时重启同时完成迁移与引导(bootstrap 幂等)。 +**结论**:改 `src/` 必须重启;改 `web/` 只需重建。升级 schema 时重启同时完成迁移与引导(幂等)。 + ### 13.5 日志 -应用只写 **stdout / stderr**,不写文件、不自行轮转:部署形态与采集方式各异,都靠这两个 fd 对接,轮转交给平台工具(Docker json-file 驱动 / journald 自带 / logrotate,模板见 `deploy/`)。**必须在 `build_app` 里配 root logger**:uvicorn 默认 `LOGGING_CONFIG` 只配 `uvicorn` / `uvicorn.access`(`propagate=false`),**从不配 root**;root 默认 `WARNING` 且无 handler,导致 `logging.getLogger(__name__)` 的 INFO 静默丢失。生产路径 `uvicorn src.main:build_app --factory` 不经过 `run()`,所以配置必须挂在 `build_app`(幂等,见 `src/webapp/logging.py`)。级别用 `LOG_LEVEL`(默认 `INFO`);**审计日志是 INFO 级**,调到 `WARNING` 会把它一并关掉。 +只写 **stdout / stderr**,不写文件、不自行轮转(轮转交给 Docker json-file / journald / logrotate,模板见 `deploy/`)。**必须在 `build_app` 里配 root logger**:uvicorn 默认 `LOGGING_CONFIG` 只配 `uvicorn` / `uvicorn.access`(`propagate=false`),root 默认 WARNING 且无 handler,会让 `logging.getLogger(__name__)` 的 INFO 静默丢失;生产路径不经过 `run()`,故配置挂 `build_app`(幂等)。级别 `LOG_LEVEL`(默认 `INFO`);**审计日志是 INFO**,调到 WARNING 会一并关掉。 + ### 13.6 账号体系与审计 -账号存在 SQLite 的 `users` 表(`admin` / `operator` / `viewer` 三角色)。**三层引导**:① 导入 `users.txt`;② 把 `ADMIN_USERNAMES` 里点名的用户提权为 `admin`(该 env 只剩**引导期**语义);③ **仍无活跃 admin 则启动失败**。`require_admin` 管用户管理、配置、审计、运维告警;`require_operator` 管凭证写操作与只读运维页(上游状态 / 模型列表,admin 与 operator 都放行);`Principal.is_operator` 是派生 property,统计口径同样以 `is_operator` 为界。**会话吊销不建会话表**:`users.session_epoch` 进签名 Cookie 的 `ep` 声明,改密 / 降级 / 禁用/启用 / 硬删一律 `session_epoch + 1`,`principal_from_request` 每请求比一次,旧 Cookie 当场失效。**角色不进 Cookie,每请求现读 DB**(塞进已签名 Cookie 会让降级延迟生效,与「降级必须立即可信」冲突)。向后兼容:老 Cookie 无 `ep` 按 0 处理。**一次性激活令牌**:管理台建号/重置密码时写随机占位密码 + 32 字节 URL-safe 令牌,库里只存 SHA-256 摘要 + 过期时间(TTL 24h),明文只在创建/重置响应里回显一次,前端拼成 `/activate?token=...`。用户在 `/activate` 自设密码即完成激活。**首登强制改密**:`must_change_password=1` 时 `principal_from_request` 用**精确白名单**(`GET /api/auth/session`、`POST /api/auth/password`、`POST /api/auth/logout`)放行,其余端点返回 403 `password_change_required`。**审计**:`audit_events(id, ts, actor, action, target, detail, ip, ok)`,动作常量集中在 `audit/actions.py`。覆盖登录成功/失败、账号建/激活/改角色/启停/改密/重置/硬删、凭证导入/删除/启停/pin/复活/切换账号、告警人工标记恢复。**绝不记密码或令牌明文**。保留期与 `usage_events` 同入口,由 `RetentionTask` 统一裁剪。**防锁死**三层:Web 端 `_guard_last_admin` + `SelfTargetError`;CLI 端删最后活跃 admin 拒绝;bootstrap 无活跃 admin 启动失败。硬删只在 `scripts/create_user.py --delete --force`(`usage_events.username` 是裸文本无外键,硬删会留下查不到用户名的孤儿统计,故 UI 不暴露 DELETE)。 +三层引导:① 导入 `users.txt`;② `ADMIN_USERNAMES` 点名提权为 `admin`(只剩引导期语义);③ **仍无活跃 admin 则启动失败**。`require_admin` 管用户管理、配置、审计、告警;`require_operator` 管凭证写与只读运维页;`Principal.is_operator` 是派生 property,统计口径同界。 + +**会话吊销不建会话表**:`users.session_epoch` 进 Cookie 的 `ep` 声明,改密 / 降级 / 启停 / 硬删一律 `+1`,`principal_from_request` 每请求比一次。**角色不进 Cookie,每请求现读 DB**(塞进已签名 Cookie 会让降级延迟生效)。老 Cookie 无 `ep` 按 0 处理。 + +**一次性激活令牌**:建号 / 重置写随机占位密码 + 32 字节 URL-safe 令牌,库里只存 SHA-256 摘要 + 过期时间(TTL 24h),明文只在响应里回显一次,前端拼 `/activate?token=...`。**首登强制改密**:`must_change_password=1` 时精确白名单放行(`GET /api/auth/session`、`POST /api/auth/password`、`POST /api/auth/logout`),其余 403 `password_change_required`。 + +**审计** `audit_events(id, ts, actor, action, target, detail, ip, ok)`,动作常量在 `audit/actions.py`。覆盖登录成功/失败、账号建/激活/改角色/启停/改密/重置/硬删、凭证导入/删除/启停/pin/复活/切换账号、告警人工恢复。**绝不记密码或令牌明文**。保留期同 `usage_events`。**防锁死三层**:Web 端 `_guard_last_admin` + `SelfTargetError`;CLI 删最后活跃 admin 拒绝;bootstrap 无活跃 admin 启动失败。硬删只在 `scripts/create_user.py --delete --force`(`usage_events.username` 是裸文本无外键,硬删会留孤儿统计,故 UI 不暴露 DELETE)。 -## 14. 已知取舍备忘 +## 14. 已知取舍 - **同步 sqlite3 而非 aiosqlite**:本地微秒级操作,asyncio 封装开销大于收益。 -- **双 httpx 客户端**:聊天流 `read=None` 防长流截断;短请求总超时 30s 防悬挂;共享 `trust_env=False`。非流式由引擎聚合同一流式上游(无独立 HTTP),悬挂兜底是引擎层聚合整体超时(`UPSTREAM_COMPLETE_TIMEOUT_SECONDS`,默认 600s,超时按瞬态错误换号重试)与事件条数上限(`complete_max_events`,防「快而无穷」)。 +- **双 httpx 客户端**:聊天流 `read=None` 防长流截断;短请求总超时 30s 防悬挂;共享 `trust_env=False`。非流式由引擎聚合同一流式上游,悬挂兜底是 `UPSTREAM_COMPLETE_TIMEOUT_SECONDS`(默认 600s,超时按瞬态错误换号)与事件条数上限(`complete_max_events`)。 - **手写 SQL 而非 ORM**:11 张表规模下 ORM 收益为负。 -- **polling OAuth 不转回调**:上游协议决定;TRAE 回调走主端口 + `PUBLIC_BASE_URL`。`/authorize` 无鉴权(浏览器 302 不带 key),防滥用靠三条:无进行中登录一律拒绝;待完成登录 600s TTL(长期挂着的 pending 会被同网络任何人用自己的 refreshToken 兑换——凭证入池、归属记为发起登录的管理员);回调必须带**与本次 pending 匹配的 `loginTraceID`**(TRAE 原样回显登录 URL 里的 `(machine_id + device_id)[-16:]`,只有发起那次拿得到)。**刻意不用 Origin/Referer 判同源**:TRAE 回调本就由 `www.trae.cn` 跨源 302 跳来,https 部署下还会因协议降级 / `Referrer-Policy` 丢头,按 Referer 判既拦不住真攻击(加 `no-referrer` 即绕过)又可能误杀真回调。`app.state` 只保留 pending 的 state,不驻留含 refreshToken 的完整回调 URL。 -- **Responses / Anthropic 出口只做各自客户端的子集**:不做 `store=true` / `previous_response_id`(服务端无状态);Responses 的 `include=["reasoning.encrypted_content"]` 接受并忽略,终止事件不发 `[DONE]` 哨兵。Responses 出口经官方 SDK 契约验证并对真实 CB 上游冒烟,**未经真实 Codex CLI 端到端验证**。 -- **effort 档位映射默认不做,但缺省补值 + 可选机制**:客户端给什么档位上游都接受,硬编码模型白名单与实际命名无关;但客户端**完全不发** `reasoning_effort` 时上游退化成「可见推演」,故 `stream_chat` 用 `setdefault` 补 `medium`——只补缺省、不覆盖显式值。**内置默认矩阵仍然不做**(未核实上游接受性);改为提供默认关闭的机制:出站 `REASONING_EFFORT_MAP`(按渠道改写客户端显式档位)、Anthropic 入站 `ANTHROPIC_THINKING_EFFORTS`(`thinking.budget_tokens` → 档位)、`FORCE_DISABLE_THINKING`(响应侧剥离思考内容)。三者默认关 / 留空 = 现状逐字节不变,见 §3.11。 -- **Zen / Kilo 无凭证渠道用「虚拟凭证行」而非特判**:调度 / 冷却 / 统计 / 粘性全部按 `credentials` 行工作,种一条空凭证(`added_by="system"`)比在每个环节加「无凭证 provider」分支代价小,也让它天然出现在管理台凭证池里可暂停 / 探测 / 删除。`probe_quota` 恒 `probe_failed=True`(health `None` = 未知,**不是耗尽**)。种子只在默认装配路径执行且幂等;删除后重启复活是刻意的。 -- **Zen 门禁伪装注入 `bash`/`read` 是「对上游撒谎、对客户端诚实」**:上游要求 tools 含这两者,客户端未必声明过,所以注入只补缺。回包对**本次注入的名字**按 `index` 跟踪分片:客户端有同义工具就改写成同名透传,没有才过滤并收敛 `finish_reason`。用户自带同名工具时既不注入也不处理。 -- **内容风控指纹清洗只改出站副本**:`system`/`assistant` 正文命中指纹串时替换为占位符,客户端历史不受影响,避免 11128 持续拒绝。 -- **统计一律以 `usage_hourly` 为准**:`overview` / `by_provider` / `timeline` / `model-timeline` 均读小时汇总,只有 `events` 读明细。代价:最近 ≤5 分钟未进汇总的请求不计入,刷新一次即可。 -- **成本是下限**:匹配不到刊例价或未上报输入 token 的不计入(聚合值 `null` 显示 `—`,不拿 0 冒充免费),历史行不随价表 / 汇率重算。 -- **应用日志只写 stderr,轮转交给平台**:应用自己写文件会与平台轮转争抢同一文件,容器里还会写进镜像层。 -- **批量运维端点收「已有单条端点」的动作,不做通用批量通道**:`POST /api/credentials/batch` 只支持 `probe` / `toggle` / `checkin` / `revive`。`refresh`(token 预刷新)没有单条入口、且由后台按到期窗口驱动,批量强刷会白耗一次性票据;`delete` 破坏性太强,需要单独的二次确认流。批量动作与单条端点共用同一份实现(`probe_credential_once` / `checkin_credential_once`),避免「批量版」与「单条版」各写一套后探测失败的 reason 翻译只在一处修好。 -- **批量 `toggle` 传显式 `enabled` 而非「各自取反」**:混选「已启用 + 已暂停」时取反会让一半号走向相反方向,而操作员点「暂停」的意图只有一个。 -- **批量审计只记一条汇总**(`toggle` / `revive`):50 个号的批量暂停是一次操作、一个意图,逐条落库会把审计流水刷成噪声。`probe` / `checkin` 与单条端点同为只读语义、不写审计。 -- **批量 `probe` / `checkin` 用信号量限制在途数,不复用后台 Pacer**:Pacer 是跨后台任务的严格串行化节流,批量端点由操作员手动触发,完全串行会让 50 个号等上几分钟才返回。折中是 `BATCH_CONCURRENCY=4` 的在途上限——既不打爆上游,也不长时间挂住请求。`toggle` / `revive` 是本地 DB 写,无上游压力,不受该上限约束。 -- **窄屏表格卡片化改样式不改 DOM**(`Table cards` + `styles.css`「窄屏表 → 卡片」):列多的表格(凭证 7 列、明细 13 列)在 <768px 从横向滚动改为「一行 = 一张卡」。**刻意不做「按视口渲染两套 JSX」**——六张表被页面测试按 `xxx-table` / 行 testid 断言,DOM 一变「同一行」的语义就没了,读屏也会拿到一份重复内容。字段名走 `TableCell label` → `data-label` + CSS `::before`(不新增节点)。**`display:block` 会让 Chromium/WebKit 丢掉隐式表格语义**,故卡片模式打开时由 `TableCardsContext` 给 table/thead/tbody/tr/th/td 补显式 `role`(`table`/`rowgroup`/`row`/`columnheader`/`cell`);不开卡片模式的表格不加任何 `role`,沿用浏览器隐式语义。列宽约束(`w-8` / `max-w-*`)在卡片模式下被统一放开,否则「字段名 : 值」会被挤成两行。断点与 Tailwind `md` 对齐(`767.98px`)。 -- **一个渠道一个组**:`web/src/lib/credentialList.ts` 的 `groupByProvider()` 按真实 `provider` 聚合——`ProviderGroup.key === provider`,`label` 就是渠道展示名(曾有的 CodeArts「内置 / 福利」虚拟渠道拆分随福利链路一起下线)。 - - 组上的 `ledger` 由 `providerLedger()` 派生:`codearts` → `credits`(额度来自积分接口,内置模型按积分计费),其余 → `pool`(套餐 / 每日余额);组头额度合计标签因此对 CodeArts 写「积分合计」、其余写「额度合计」。行内额度数字、口径悬浮说明(`quotaSemantics`)与变动流水入口(`GET /api/credentials/{id}/credit-events?ledger=`)都按 `ledger` 取;`credentialState(credential, now?)` 只认主账 `health`。**为什么后端不真拆**:`refresh_token` 一次性且与 `client_id` + DPoP 私钥绑定,两条凭证行会各刷一次、第二条必报 `the refresh token has been used` 并烧掉票据。 +- **polling OAuth 不转回调**:上游协议决定;TRAE 回调走主端口 + `PUBLIC_BASE_URL`。`/authorize` 无鉴权,防滥用三条:无进行中登录一律拒绝;pending 600s TTL;回调必须带匹配的 `loginTraceID`。**刻意不用 Origin/Referer 判同源**(TRAE 回调本就跨源 302,且 `no-referrer` 可绕过)。`app.state` 只保留 pending 的 state。 +- **Responses / Anthropic 出口只做各自客户端子集**:不做 `store=true` / `previous_response_id`;`include=["reasoning.encrypted_content"]` 接受并忽略;不发 `[DONE]`。**未经真实 Codex CLI 端到端验证**。 +- **effort 映射默认不做,但缺省补值 + 可选机制**:`setdefault` 补 `medium`,只补缺省不覆盖显式值;内置默认矩阵不做,改为默认关闭的三项机制(§3.11)。 +- **Zen / Kilo 用虚拟凭证行而非特判**:调度 / 冷却 / 统计 / 粘性全按 `credentials` 行工作,种一条空凭证比在每个环节加「无凭证 provider」分支代价小。`probe_quota` 恒 `probe_failed=True`。种子只在默认装配路径且幂等;删除后重启复活是刻意的。 +- **Zen 注入 `bash`/`read` 是「对上游撒谎、对客户端诚实」**:只补缺;回包按 `index` 跟踪分片,有同义工具就改写透传,没有才过滤并收敛 `finish_reason`。 +- **内容风控指纹清洗只改出站副本**:客户端历史不受影响。 +- **统计一律以 `usage_hourly` 为准**:代价是最近 ≤5 分钟未进汇总的不计入。 +- **成本是下限**:匹配不到或未上报输入 token 的不计入(`null` 显示 `—`,不拿 0 冒充免费)。 +- **应用日志只写 stderr**:自己写文件会与平台轮转争抢,容器里还会写进镜像层。 +- **批量运维只收「已有单条端点」的动作**:`probe` / `toggle` / `checkin` / `revive`。`refresh` 无单条入口且强刷会白耗一次性票据;`delete` 破坏性太强需二次确认。批量与单条共用同一实现(`probe_credential_once` / `checkin_credential_once`)。 +- **批量 `toggle` 传显式 `enabled` 而非各自取反**:混选时取反会让一半号走向相反方向。 +- **批量审计只记一条汇总**(`toggle` / `revive`):50 个号是一次操作、一个意图。`probe` / `checkin` 只读、不写审计。 +- **批量 `probe` / `checkin` 用信号量限制在途**(`BATCH_CONCURRENCY=4`),不复用后台 Pacer:完全串行会让 50 个号等几分钟。`toggle` / `revive` 是本地 DB 写,不受限。 +- **窄屏表格卡片化改样式不改 DOM**:`<768px` 一行 = 一张卡;不做「按视口渲染两套 JSX」(页面测试按 testid 断言,DOM 一变语义就没了)。字段名走 `TableCell label` → `data-label` + CSS `::before`(不新增节点)。`display:block` 会让 Chromium/WebKit 丢隐式表格语义,故卡片模式打开时由 `TableCardsContext` 补显式 `role`;不开卡片模式的表格不加任何 `role`。断点与 Tailwind `md` 对齐(`767.98px`)。 +- **一个渠道一个组**:`web/src/lib/credentialList.ts` 的 `groupByProvider()` 按真实 `provider` 聚合。组上 `ledger` 由 `providerLedger()` 派生:`codearts` → `credits`,其余 → `pool`;组头额度合计标签因此对 CodeArts 写「积分合计」。行内额度、口径悬浮(`quotaSemantics`)与变动流水入口都按 `ledger` 取;`credentialState()` 只认主账 `health`。**为什么后端不真拆**:`refresh_token` 一次性且与 `client_id` + DPoP 私钥绑定,两条凭证行会各刷一次、第二条必报 `the refresh token has been used` 并烧掉票据。 diff --git a/docker-compose.yml b/docker-compose.yml index 79d04711..3024e8db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -159,6 +159,8 @@ services: STREAM_IDLE_TIMEOUT_SECONDS: ${STREAM_IDLE_TIMEOUT_SECONDS:-0} # 短冷却「等待续上」(秒):全池都在短冷却时最多等这么久再取一次;0 关闭 SHORT_COOLDOWN_WAIT_SECONDS: ${SHORT_COOLDOWN_WAIT_SECONDS:-0} + # 非流式出口保活(秒):N 秒内没聚合完就转 chunked 吐空白心跳;0 关闭 + NONSTREAM_KEEPALIVE_SECONDS: ${NONSTREAM_KEEPALIVE_SECONDS:-0} # 单账号在途请求上限(跨渠道全局);0 = 不限 MAX_INFLIGHT_PER_CREDENTIAL: ${MAX_INFLIGHT_PER_CREDENTIAL:-0} # Prometheus 指标出口(GET /metrics,无鉴权,与 /healthz 同口径);false 关闭 diff --git a/src/api/endpoints.py b/src/api/endpoints.py index f494318a..46b9e504 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -19,10 +19,11 @@ from ..config import Settings from ..engine.compress import estimate_messages from ..engine.executor import Executor, StreamSink +from ..engine.sse import SSE_COMMENT from ..key_quota import Lease, QuotaExceeded from ..runtime_settings import RuntimeSettings from .deps import ApiKeyPrincipal, get_services, read_json_body -from .streaming import with_keepalive +from .streaming import respond_with_keepalive, with_keepalive def ensure_model_allowed(chat_request: ChatRequest, principal: ApiKeyPrincipal, @@ -98,12 +99,26 @@ async def dispatch_compat( lease.release() # 400 不该占着并发名额 raise translator = translator_for(target.model) if translator_for else None + # 心跳帧随出口协议走:Anthropic 要 `event: ping` 具名事件(只解析 + # `event:` 行的客户端会忽略注释帧),chat / Responses 保持注释帧 + # (Responses 无 ping 事件,未知事件类型会让严格解析器报错)。 + keepalive_frame = (translator.keepalive() if translator is not None + else SSE_COMMENT) frames = with_keepalive(executor.stream_guarded(chat_request, username=principal.username, translator=translator, - provider_binding=binding)) + provider_binding=binding), + keepalive_frame=keepalive_frame) return StreamingResponse( _release_when_done(frames, lease), media_type="text/event-stream") + # 非流式保活(`nonstream_keepalive_seconds`,默认 0 = 关闭):宽限期内 + # 完成仍是逐字节相同的 JSONResponse,超时才转 chunked 吐空白心跳。 + keepalive = float(getattr(settings, "nonstream_keepalive_seconds", 0) or 0) + if keepalive > 0: + return await respond_with_keepalive( + lambda: executor.complete(chat_request, username=principal.username, + provider_binding=binding), + grace=keepalive, convert=convert, release=lease.release) try: completion = await executor.complete(chat_request, username=principal.username, provider_binding=binding) diff --git a/src/api/streaming.py b/src/api/streaming.py index 1c9f8d64..2c7c401d 100644 --- a/src/api/streaming.py +++ b/src/api/streaming.py @@ -3,24 +3,46 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterator +import json +import logging +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Any + +from starlette.responses import JSONResponse, Response, StreamingResponse from ..engine.sse import SSE_COMMENT +logger = logging.getLogger(__name__) + # 上游思考/排队时可能长时间不吐字节;nginx proxy_read_timeout 默认 60s, # 空闲超过它就会断开连接。SSE 注释帧被客户端忽略,只为保活。 KEEPALIVE_SECONDS = 15.0 +# 非流式保活字节:JSON 允许任意前导空白(RFC 8259 §2 `JSON-text = ws value ws`), +# 解析器(Python json / JS JSON.parse / Go encoding/json)都照常接受 +# `"\n\n{\"a\":1}"`。发换行比发空格更不容易被中间盒做行首嗅探改写。 +JSON_KEEPALIVE_BYTES = b"\n" + async def with_keepalive(frames: AsyncIterator[bytes], - interval: float = KEEPALIVE_SECONDS) -> AsyncIterator[bytes]: - """在上游无输出的空隙插入 SSE 注释帧。 + interval: float | None = None, + keepalive_frame: bytes = SSE_COMMENT) -> AsyncIterator[bytes]: + """在上游无输出的空隙插入心跳帧。 + + `keepalive_frame` 由出口协议决定:chat / Responses 用 SSE 注释帧(被客户端 + 忽略),Anthropic 用具名 `event: ping`(部分客户端只解析 `event:` 行,注释帧 + 对它们等于没有字节,长静默下仍会判超时)。 + + `interval=None` 读模块常量 `KEEPALIVE_SECONDS`(运行期读而非默认参数, + 否则 import 时就定死,测试无法缩短)。 实现要点:取下一帧用**可复用的 Task** 而不是 `wait_for(anext(...))`。 后者在超时时会 cancel 掉 `anext` 协程,取消会穿透进上游生成器并把 它关闭——心跳反而变成了断流。这里用 `asyncio.wait` 与定时器竞争, - 超时只发注释、待取帧的 Task 继续存活。 + 超时只发心跳、待取帧的 Task 继续存活。 """ + if interval is None: + interval = KEEPALIVE_SECONDS iterator = frames.__aiter__() pending: asyncio.Task[bytes] | None = None try: @@ -41,7 +63,7 @@ async def with_keepalive(frames: AsyncIterator[bytes], continue # 定时器先到:上游暂无输出 → 发心跳,保留 pending 继续等 ticker.cancel() - yield SSE_COMMENT + yield keepalive_frame finally: # 客户端断开 / 生成器关闭:清掉在途任务,避免 "Task was destroyed"。 # cancel() 只是「请求取消」,必须 await 到任务真正结束再退出——否则 @@ -49,3 +71,94 @@ async def with_keepalive(frames: AsyncIterator[bytes], if pending is not None and not pending.done(): pending.cancel() await asyncio.gather(pending, return_exceptions=True) + + +# 保活已提交 200 之后上游才失败时的正文:状态码已经发出去了,改不回来,只能 +# 给一个可解析的 JSON 错误体(比截断强:截断的 JSON 在客户端是解码异常, +# 完全看不出是上游问题)。文案固定,不回显上游正文。 +LATE_FAILURE_PAYLOAD: dict[str, Any] = { + "error": { + "message": "upstream request failed after the response had started", + "type": "api_error", + "code": "upstream_error", + "status": 502, + } +} + + +def _dumps(payload: Any) -> bytes: + """与 `JSONResponse.render` 逐字节一致(分隔符/allow_nan 都对齐)。""" + return json.dumps(payload, ensure_ascii=False, allow_nan=False, + indent=None, separators=(",", ":")).encode("utf-8") + + +async def respond_with_keepalive( + work: Callable[[], Awaitable[dict[str, Any]]], + *, grace: float, + convert: Callable[[dict[str, Any]], Any] | None = None, + release: Callable[[], None] | None = None) -> Response: + """非流式出口的保活(`nonstream_keepalive_seconds`,默认 0 关闭)。 + + **宽限期设计**:先等 `grace` 秒。 + + - 期间完成 → 返回普通 `JSONResponse`:状态码、`Content-Length`、字节内容 + 与不开保活时**完全一致**,异常也照常抛给上层异常处理器(400/503 不变)。 + - 超时 → 才提交 200 + chunked,先吐空白字节再补正文。 + + 为什么不一开始就用 `StreamingResponse`:那样 200 在聚合前就发出去了, + 之后任何失败(无可用凭证 503 / 请求无效 400)都改不回来。宽限期把 + 「改不回来」的窗口压到只有真正超过 `grace` 的请求。 + + 空白字节合法:RFC 8259 §2 规定 `JSON-text = ws value ws`,值前允许任意 + 空白,故 `"\\n\\n{...}"` 仍是合法 JSON(Python `json.loads` / JS + `JSON.parse` 均已实测接受)。 + """ + task = asyncio.ensure_future(work()) + try: + done, _ = await asyncio.wait({task}, timeout=grace) + except BaseException: + # 客户端断开 / 上层取消:必须把工作协程一起收掉,否则它继续跑完整个 + # 上游调用(真实计费)却没人接收结果。 + task.cancel() + await asyncio.gather(task, return_exceptions=True) + if release is not None: + release() + raise + if task in done: + try: + completion = task.result() + finally: + if release is not None: + release() + return JSONResponse(convert(completion) if convert else completion) + return StreamingResponse( + _json_frames(task, grace, convert, release), + media_type="application/json", + headers={"X-Accel-Buffering": "no", "Cache-Control": "no-store"}) + + +async def _json_frames(task: asyncio.Task[dict[str, Any]], interval: float, + convert: Callable[[dict[str, Any]], Any] | None, + release: Callable[[], None] | None) -> AsyncIterator[bytes]: + """慢请求的正文生成器:空闲 `interval` 秒就吐一个空白字节。""" + try: + while not task.done(): + await asyncio.wait({task}, timeout=interval) + if not task.done(): + yield JSON_KEEPALIVE_BYTES + try: + completion = task.result() + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - 已提交 200,只能写进正文 + logger.warning("非流式保活:200 已提交后上游才失败,正文降级为错误体", + exc_info=True) + yield _dumps(LATE_FAILURE_PAYLOAD) + return + yield _dumps(convert(completion) if convert else completion) + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + if release is not None: + release() diff --git a/src/compat/anthropic/response.py b/src/compat/anthropic/response.py index 026cf77e..9b0aba68 100644 --- a/src/compat/anthropic/response.py +++ b/src/compat/anthropic/response.py @@ -157,10 +157,13 @@ def finish(self) -> Iterator[bytes]: yield from self._close("stop") def keepalive(self) -> bytes: - """SSE 注释帧心跳(与 chat / Responses 出口同一约定)。""" - from ...engine.sse import SSE_COMMENT + """Anthropic 心跳:具名 `ping` 事件(不是 SSE 注释帧)。 - return SSE_COMMENT + 官方流式规范里的 `ping` 是**具名事件**(`event: ping` + + `data: {"type": "ping"}`),客户端只解析 `event:` 行时注释帧等于没有 + 字节,长静默(推理模型 TTFT 可达 40s)下仍会判超时断开。 + """ + return b'event: ping\ndata: {"type": "ping"}\n\n' def error_frame(self, message: str, code: str) -> bytes: """出口错误帧:Anthropic 用 `event: error`(无 [DONE] 哨兵)。""" diff --git a/src/compat/openai/response.py b/src/compat/openai/response.py index 964bc769..a340b09c 100644 --- a/src/compat/openai/response.py +++ b/src/compat/openai/response.py @@ -2,7 +2,8 @@ 约定(继承 codebuddy2api 的语义): - 首块补 role:assistant -- 上游缺失 index 的 tool_calls 补稳定 index,不重生成 id +- 上游缺失 index 的 tool_calls 补稳定 index,不重生成已有 id;`id` 缺失时 + 按 index 补一个稳定值(OpenAI 客户端按 id 聚合分片,缺 id 会丢工具调用) - reasonng 走 delta.reasoning_content - 流以 data: [DONE] 结束 """ @@ -58,10 +59,18 @@ def _completion_id() -> str: @dataclass class ToolIndexState: - """优先沿用上游 index,缺失时按出现顺序补稳定位置。""" + """优先沿用上游 index,缺失时按出现顺序补稳定位置;`id` 缺失时补稳定 id。 + + 上游(尤其聚合型中转)偶尔只发 `function.arguments` 分片而不发 `id`。 + OpenAI 客户端按 `id` 聚合流式工具调用分片,缺 id 的分片会被丢弃,表现为 + 「模型调了工具但客户端收不到」。故按 index 生成并记忆 id,后续同 index 的 + 分片沿用同一个值(每次现生成会让同一调用被拆成多个)。 + """ id_to_index: dict[str, int] = field(default_factory=dict) + index_to_id: dict[int, str] = field(default_factory=dict) used: set[int] = field(default_factory=set) + _seq: int = 0 def resolve(self, tool_call: dict[str, Any]) -> tuple[dict[str, Any], int]: item = dict(tool_call) @@ -69,8 +78,7 @@ def resolve(self, tool_call: dict[str, Any]) -> tuple[dict[str, Any], int]: tool_id = item.get("id") if isinstance(index, int) and not isinstance(index, bool): self.used.add(index) - if isinstance(tool_id, str) and tool_id: - self.id_to_index[tool_id] = index + self._bind(index, tool_id, item) return item, index if isinstance(tool_id, str) and tool_id and tool_id in self.id_to_index: resolved = self.id_to_index[tool_id] @@ -80,11 +88,27 @@ def resolve(self, tool_call: dict[str, Any]) -> tuple[dict[str, Any], int]: while candidate in self.used: candidate += 1 self.used.add(candidate) - if isinstance(tool_id, str) and tool_id: - self.id_to_index[tool_id] = candidate + self._bind(candidate, tool_id, item) item["index"] = candidate return item, candidate + def _bind(self, index: int, tool_id: Any, item: dict[str, Any]) -> None: + """登记 id↔index 双向映射;`id` 缺失时按 index 补一个稳定值。 + + 每片都补同一个 id(而不是只补首片):按 id 聚合的客户端若在续片上 + 拿到空 id,会把一次调用拆成两条;重复同一个 id 则各种聚合方式都安全。 + """ + if isinstance(tool_id, str) and tool_id: + self.id_to_index[tool_id] = index + self.index_to_id.setdefault(index, tool_id) + return + existing = self.index_to_id.get(index) + if existing is None: + self._seq += 1 + existing = f"call_{index}_{self._seq}" + self.index_to_id[index] = existing + item["id"] = existing + class StreamTranslator: """把 Event 序列翻译为 OpenAI SSE 帧序列。""" diff --git a/src/compat/responses/response.py b/src/compat/responses/response.py index 7aebb726..65bdb1a2 100644 --- a/src/compat/responses/response.py +++ b/src/compat/responses/response.py @@ -190,7 +190,12 @@ def finish(self) -> Iterator[bytes]: yield from self._close("stop") def keepalive(self) -> bytes: - """SSE 注释帧心跳(与 chat 出口同一约定)。""" + """SSE 注释帧心跳(与 chat 出口同一约定)。 + + **不能改成 `event: ping`**:Responses API 没有 ping 事件类型,严格 + 解析器收到未知事件会直接报错(`MAXeaglet/commandcode-proxy` 的实测 + 结论,与 Anthropic 出口的处理相反)。 + """ from ...engine.sse import SSE_COMMENT return SSE_COMMENT diff --git a/src/config.py b/src/config.py index 041e4e3f..9f5aeb12 100644 --- a/src/config.py +++ b/src/config.py @@ -303,6 +303,14 @@ class Settings(BaseSettings): # 剩余冷却不超过该值,则等待后重取一次(而不是直接 503)。0 = 关闭(快速 # 失败)。等待期间不占在途租约,避免把并发请求堆成连接洪峰。可热更。 short_cooldown_wait_seconds: float = 0 + # 非流式出口保活(秒):`stream=false` 的请求在聚合完成前一个字节都不发, + # 中间盒(CDN / 反代)的空闲超时会直接切连接。设为 N > 0 时,N 秒内没 + # 完成就改走 chunked,先吐空白字节再补 JSON 正文——空白是合法 JSON 前导 + # (RFC 8259 §2 `JSON-text = ws value ws`),解析器照常工作。 + # 默认 0 = 关闭:本部署 nginx `proxy_read_timeout 3600s` + frp TCP, + # 链路上没有短空闲超时,实测 30 天 >60s 的成功请求 1741 个全部成功。 + # 只有在 CDN 后面(Cloudflare 100s 等)才需要打开,推荐 15。可热更。 + nonstream_keepalive_seconds: float = 0 # 单账号在途请求上限(全局,跨渠道):同一凭证并发达到该值后不再参与选号, # 避免把单账号打进上游限流/封号;0 = 不限。租约在请求结束后释放,另有 # 600s TTL 兜底(见 src/engine/inflight.py)。 diff --git a/src/engine/executor.py b/src/engine/executor.py index 8e7fcaee..84788542 100644 --- a/src/engine/executor.py +++ b/src/engine/executor.py @@ -21,6 +21,9 @@ from ..config import live from ..db.repo import CredentialRepository from ..provider.base import ( + QUEUE_DEFAULT_WAIT_SECONDS, + QUEUE_MAX_ROUNDS, + QUEUE_MAX_WAIT_SECONDS, ErrKind, Event, EventKind, @@ -57,6 +60,8 @@ def finish(self) -> Iterator[bytes]: ... def error_frame(self, message: str, code: str) -> bytes: ... + def keepalive(self) -> bytes: ... + class NoHealthyCredential(Exception): pass @@ -90,6 +95,19 @@ def __init__(self, limit: int) -> None: self.limit = limit +class _Queued(Exception): + """上游排队中(`ErrKind.QUEUE`):内部信号,等 `wait` 秒后原号重开。 + + 用异常而不是 `break`+标志位,是因为非流式的读流包在 `try/except/else` 里, + `break` 出来会落到 `else` 分支把**半截** events 拿去聚合。异常能干净地 + 跳过 `else`,走 finally 释放租约后再等待。 + """ + + def __init__(self, wait: float) -> None: + super().__init__(f"upstream queued {wait}s") + self.wait = wait + + class _StreamTimeout(Exception): """流式取下一事件的计时超时(A1)。 @@ -558,6 +576,13 @@ async def _stream_loop(self, request: ChatRequest, target: ModelTarget, # 外层既保住续写质量,又对三个出口(chat/Anthropic/Responses)一致。 hide_reasoning = self._thinking_disabled() + # 排队重开(F3):上游回「排队中 + retryAfterSeconds」时,等它点名的 + # 秒数后在**原号**上重开,不冷却、不换号、不占轮换预算。 + # `pinned` 是被点名的凭证(下一轮跳过 `_pick` 直接用), + # `queued_wait` 是本轮要等的秒数(等完 `continue`)。 + queue_rounds = 0 + pinned: tuple[str, dict[str, Any]] | None = None + def terminal(message: str, code: str) -> bytes: """终帧:本项未出帧时抛信号让外层试下一链项;已出帧则写错误帧收尾。""" if not emitted: @@ -565,31 +590,43 @@ def terminal(message: str, code: str) -> bytes: return state.translator.error_frame(message, code) while True: - pick = await self._pick(target, tried, state.affinity_id) - if pick is None: - # 走到这里 emitted 必为 False:已出帧的路径在上面 `if emitted or - # not should_rotate` 就 return 了,所以 `terminal()` 在这里**必定** - # 抛 `_ModelExhausted`(交给回退链下一项)。直接抛,不留永远 - # 走不到的 `yield`+`return`。 - if last_kind is ErrKind.INVALID: - # 所有候选上游都拒绝了该模型:400 语义而非 503 + if pinned is not None: + # 排队重开:复用被点名的凭证,不走 `_pick`(否则可能选到别的号, + # 排队语义就丢了)。凭证可能已被并发删除,删了就退回正常选号。 + # **不计入 attempts**:排队不是一次「换号尝试」,吃掉轮换预算 + # 会让三次排队就把整个请求打成 503。 + credential_id, credential_data = pinned + pinned = None + if self._deps.credentials.credential_data(credential_id) is None: + continue + provider_id = self._deps.credentials.provider_of(credential_id) + state.provider, state.credential_id = provider_id or "-", credential_id + else: + pick = await self._pick(target, tried, state.affinity_id) + if pick is None: + # 走到这里 emitted 必为 False:已出帧的路径在上面 `if emitted or + # not should_rotate` 就 return 了,所以 `terminal()` 在这里**必定** + # 抛 `_ModelExhausted`(交给回退链下一项)。直接抛,不留永远 + # 走不到的 `yield`+`return`。 + if last_kind is ErrKind.INVALID: + # 所有候选上游都拒绝了该模型:400 语义而非 503 + raise _ModelExhausted( + _reject_message(target.model, last_error, + self._suggestions(target.model)), + "invalid_request") + self._deps.record( + username=state.username, provider=state.provider, + credential_id=state.credential_id, model=target.model, ok=False, + error_type=self._exhaust_error_type(target), + latency_ms=_elapsed_ms(state.started)) raise _ModelExhausted( - _reject_message(target.model, last_error, - self._suggestions(target.model)), - "invalid_request") - self._deps.record( - username=state.username, provider=state.provider, - credential_id=state.credential_id, model=target.model, ok=False, - error_type=self._exhaust_error_type(target), - latency_ms=_elapsed_ms(state.started)) - raise _ModelExhausted( - self._unavailable_text(target, last_error), - "no_healthy_credential") - credential_id, credential_data = pick - provider_id = self._deps.credentials.provider_of(credential_id) - state.provider, state.credential_id = provider_id or "-", credential_id - tried.add(credential_id) - attempts += 1 + self._unavailable_text(target, last_error), + "no_healthy_credential") + credential_id, credential_data = pick + provider_id = self._deps.credentials.provider_of(credential_id) + state.provider, state.credential_id = provider_id or "-", credential_id + tried.add(credential_id) + attempts += 1 # 按实际服务渠道压缩(未启用/未知窗口/未超限时原样返回):窗口 # 取该渠道自己的上限,不再被更小窗口的兄弟渠道拖累误裁 payload = self._compress_payload(request, provider_id, target.model) @@ -609,6 +646,7 @@ def terminal(message: str, code: str) -> bytes: reader = _TimedReader(iterator) state.deadline = None broken = False + queued_wait: float | None = None try: while True: event = await self._next_event(reader, state, emitted) @@ -619,6 +657,27 @@ def terminal(message: str, code: str) -> bytes: continue if event.kind is EventKind.ERROR: kind = _event_kind(event) + if kind is ErrKind.QUEUE: + # 排队:上游点名「等 N 秒再来」。未出帧时在原号上 + # 重开,不冷却、不换号、不占轮换预算;已出帧则 + # 无法重开(半截输出不可回滚),按 SOFT 收尾。 + wait = self._queue_wait(event.retry_after_seconds, + emitted, queue_rounds) + if wait is not None: + queue_rounds += 1 + # 归还轮换预算:排队不是「换号尝试」。不归还的话 + # 三次排队就把 max_rotate 吃满,请求直接 503。 + attempts -= 1 + pinned = (credential_id, credential_data) + queued_wait = wait + logger.info( + "上游 %s 排队中(凭证 %s,第 %s/%s 轮)," + "%.1fs 后原号重开", + provider_id, credential_id, queue_rounds, + QUEUE_MAX_ROUNDS, wait) + broken = True + break + kind = ErrKind.SOFT if kind is ErrKind.INVALID: # 流内 4001 等参数/模型错误:换凭证没用, # 跳过该上游继续试其他上游;全部拒绝才以 400 结束 @@ -660,7 +719,29 @@ def terminal(message: str, code: str) -> bytes: kind = _classify(error) if kind is None: raise - if kind is ErrKind.INVALID: + if kind is ErrKind.QUEUE: + # 排队也可能以 HTTP 403 + 排队 body 的形式抛出(见 + # qoder.events.classify_status):同样在原号上等后重开。 + wait = self._queue_wait( + getattr(error, "retry_after_seconds", None), + emitted, queue_rounds) + if wait is not None: + queue_rounds += 1 + attempts -= 1 + pinned = (credential_id, credential_data) + queued_wait = wait + logger.info( + "上游 %s 排队中(凭证 %s,第 %s/%s 轮),%.1fs 后原号重开", + provider_id, credential_id, queue_rounds, + QUEUE_MAX_ROUNDS, wait) + else: + kind = ErrKind.SOFT + logger.warning("上游 %s 排队但无法重开(凭证 %s): %s", + provider_id, credential_id, error) + self._note_upstream_error(credential_id, kind, provider_id, + target.model, error) + last_error = error + elif kind is ErrKind.INVALID: # 请求无效(如该上游不认识模型):不冷却凭证, # 跳过该上游继续试其他上游;全部拒绝才以 400 结束 logger.warning("上游 %s 拒绝模型 %s(凭证 %s 跳过): %s", @@ -682,6 +763,11 @@ def terminal(message: str, code: str) -> bytes: await reader.aclose() await aclose_stream(iterator) self._release(credential_id) + if queued_wait is not None: + # 在租约与上游流都释放**之后**才等:等待期间不占并发名额, + # 也不让上游连接白挂着。等完回到 while 顶部用 `pinned` 重开。 + await asyncio.sleep(queued_wait) + continue if not self._deps.scheduler.should_rotate(attempts) or emitted: if last_kind is ErrKind.INVALID: # 流已开始(200 已发出),以 invalid_request 错误帧结束 @@ -884,25 +970,39 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, # `reasoning_content`;三个出口共用 complete 路径,故一致生效。 hide_reasoning = self._thinking_disabled() + # 排队重开:与 `_stream_loop` 同一套语义(不冷却、不换号、不占轮换预算)。 + # 非流式同样要处理:排队信号在上游流里,聚合前不拦就会被 `aggregate` + # 当成流内错误,直接冷却换号。 + queue_rounds = 0 + pinned: tuple[str, dict[str, Any]] | None = None + while True: - pick = await self._pick(target, tried, affinity_id) - if pick is None: - if last_kind is ErrKind.INVALID: - # 所有候选上游都拒绝了该模型:400 而非 503 - raise InvalidRequest(_reject_message( - target.model, last_error, self._suggestions(target.model))) - self._deps.record( - username=username, provider=last_provider, credential_id=last_credential, - model=target.model, ok=False, - error_type=self._exhaust_error_type(target), - latency_ms=int((time.monotonic() - started) * 1000)) - raise NoHealthyCredential( - self._unavailable_text(target, last_error)) - credential_id, credential_data = pick - tried.add(credential_id) - attempts += 1 - provider_id = self._deps.credentials.provider_of(credential_id) - last_provider, last_credential = provider_id or "-", credential_id + if pinned is not None: + credential_id, credential_data = pinned + pinned = None + if self._deps.credentials.credential_data(credential_id) is None: + continue + provider_id = self._deps.credentials.provider_of(credential_id) + last_provider, last_credential = provider_id or "-", credential_id + else: + pick = await self._pick(target, tried, affinity_id) + if pick is None: + if last_kind is ErrKind.INVALID: + # 所有候选上游都拒绝了该模型:400 而非 503 + raise InvalidRequest(_reject_message( + target.model, last_error, self._suggestions(target.model))) + self._deps.record( + username=username, provider=last_provider, credential_id=last_credential, + model=target.model, ok=False, + error_type=self._exhaust_error_type(target), + latency_ms=int((time.monotonic() - started) * 1000)) + raise NoHealthyCredential( + self._unavailable_text(target, last_error)) + credential_id, credential_data = pick + tried.add(credential_id) + attempts += 1 + provider_id = self._deps.credentials.provider_of(credential_id) + last_provider, last_credential = provider_id or "-", credential_id # 与 _stream_loop 同理:按实际服务渠道压缩(对同一请求可能压多次, # 但实现必须无副作用——每轮都从原文出发),再按实际服务渠道映射档位 payload = self._compress_payload(request, provider_id, target.model) @@ -914,6 +1014,7 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, provider_id, credential_data, payload, target.model).__aiter__() # 与 _stream_loop 同理:在途登记 + finally 释放(含超时/断开) self._lease(credential_id) + queued_wait: float | None = None try: # 聚合整体超时兜底(见 ExecutorDeps.complete_timeout_seconds) async with (asyncio.timeout(self._deps.complete_timeout_seconds) @@ -925,6 +1026,16 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, if hide_reasoning and event.kind is EventKind.REASONING: # 出站剥离思考:上游仍照常生成,只是不交给客户端 continue + if (event.kind is EventKind.ERROR + and _event_kind(event) is ErrKind.QUEUE): + # 排队信号必须在入 events **之前**拦下:进了 events + # 就会被 `aggregate` 当成流内错误,直接冷却换号。 + wait = self._queue_wait(event.retry_after_seconds, + False, queue_rounds) + if wait is not None: + raise _Queued(wait) + # 轮次用尽:降级为普通瞬态错误,交给既有路径处理 + event = replace(event, error_kind=ErrKind.SOFT) events.append(event) # 事件条数上限:超长/死循环输出下 events 会无界 # 增长直到 OOM,超时兜不住「快而无穷」。按瞬态错误换号。 @@ -932,6 +1043,15 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, and len(events) > self._deps.complete_max_events): raise _AggregateOverflow( self._deps.complete_max_events) + except _Queued as queued: + queue_rounds += 1 + attempts -= 1 + pinned = (credential_id, credential_data) + queued_wait = queued.wait + logger.info( + "上游 %s 排队中(凭证 %s,第 %s/%s 轮),%.1fs 后原号重开", + provider_id, credential_id, queue_rounds, + QUEUE_MAX_ROUNDS, queued.wait) except TimeoutError: logger.warning( "上游 %s 非流式聚合超过 %ss(凭证 %s,按瞬态错误换号)", @@ -956,7 +1076,27 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, kind = _classify(error) if kind is None: raise - if kind is ErrKind.INVALID: + if kind is ErrKind.QUEUE: + # HTTP 级排队(403 + 排队 body):与流内排队同一套语义 + wait = self._queue_wait( + getattr(error, "retry_after_seconds", None), + False, queue_rounds) + if wait is not None: + queue_rounds += 1 + attempts -= 1 + pinned = (credential_id, credential_data) + queued_wait = wait + logger.info( + "上游 %s 排队中(凭证 %s,第 %s/%s 轮),%.1fs 后原号重开", + provider_id, credential_id, queue_rounds, + QUEUE_MAX_ROUNDS, wait) + else: + logger.warning("上游 %s 排队但无法重开(凭证 %s): %s", + provider_id, credential_id, error) + self._note_upstream_error(credential_id, ErrKind.SOFT, + provider_id, target.model, error) + last_error = error + elif kind is ErrKind.INVALID: # 请求无效(如该上游不认识模型):不冷却凭证, # 跳过该上游继续试其他上游;全部拒绝才以 400 结束 logger.warning("上游 %s 拒绝模型 %s(凭证 %s 跳过): %s", @@ -1022,6 +1162,10 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, finally: await aclose_stream(iterator) self._release(credential_id) + if queued_wait is not None: + # 租约与上游流都释放**之后**才等(同 _stream_loop) + await asyncio.sleep(queued_wait) + continue if not self._deps.scheduler.should_rotate(attempts): if last_kind is ErrKind.INVALID: raise InvalidRequest(_reject_message( @@ -1305,11 +1449,28 @@ def _note_upstream_error(self, credential_id: str, kind: ErrKind, retry_at=getattr(error, "retry_at", None)) self._deps.credentials.save_error(credential_id, outcome) + def _queue_wait(self, retry_after: float | None, emitted: bool, + rounds: int) -> float | None: + """排队信号 → 本轮等待秒数;不该重开时返回 None(调用方降级为 SOFT)。 + + 三条约束: + + - **已出帧不重开**:半截输出不可回滚,重开会把两个号的内容拼进同一条 + 回复。此时返回 None,让调用方按 SOFT 收尾。 + - **轮次上限**:`QUEUE_MAX_ROUNDS` 用尽后返回 None,否则持续排队的 + 上游会把客户端连接吊死到它自己的超时。 + - **单轮封顶**:上游可能点名「2 小时后再来」,照等等于把请求挂死; + 按 `QUEUE_MAX_WAIT_SECONDS` 封顶,超了先等一轮再说。 + """ + if emitted or rounds >= QUEUE_MAX_ROUNDS: + return None + wait = QUEUE_DEFAULT_WAIT_SECONDS if retry_after is None else float(retry_after) + return min(max(wait, 0.0), QUEUE_MAX_WAIT_SECONDS) + def _model_scope(self, provider_id: str, model: str) -> str: """冷却表登记的模型名:用上游原始名(与 provider 实际调用的名字一致)。""" return self._upstream_model(provider_id, model) - @dataclass(slots=True) class _StreamState: """流式轮换过程中需要跨尝试保留的状态(统计用)。""" @@ -1478,8 +1639,10 @@ def _stream_error_type(error: Exception | None) -> str: def _error_type_for(kind: ErrKind) -> str: """ErrKind → 受控的统计失败类型(web/src/api/display.ts 同步维护)。""" - if kind in (ErrKind.PLAN, ErrKind.CREDIT, ErrKind.MODEL, ErrKind.CONCURRENCY): + if kind in (ErrKind.PLAN, ErrKind.CREDIT, ErrKind.MODEL, + ErrKind.CONCURRENCY, ErrKind.QUEUE): # MODEL / CONCURRENCY 都是模型级限流,展示口径同为额度类; + # QUEUE 是「上游排队中」,同属容量不足,与额度类同档展示。 # 冷却作用域与时长差异在 schema 层 return "rate_limit" if kind is ErrKind.DEAD: diff --git a/src/engine/scheduler.py b/src/engine/scheduler.py index b80ac7c1..9f614ba2 100644 --- a/src/engine/scheduler.py +++ b/src/engine/scheduler.py @@ -325,6 +325,8 @@ def note_error(self, candidate: Candidate, kind: ErrKind, now: int, *, 其中 CONCURRENCY 用固定短冷却,不翻倍 账号级冷却出现时清空模型级条目(防豁免泄漏) REQUEST → 零动作:不是账号的问题,换号但绝不惩罚凭证 + QUEUE → 零动作:上游只是排队,等 `retryAfterSeconds` 后原号重开; + 写冷却会让「等 5 秒」变成「锁 60 秒并换号」 `retry_at` 是上游 `Retry-After` 解析出的恢复时刻(epoch 秒,见 `provider.base.parse_retry_after`)。命中时用 `clamp_retry_at` 双端 @@ -335,6 +337,10 @@ def note_error(self, candidate: Candidate, kind: ErrKind, now: int, *, """ if kind is ErrKind.REQUEST: return ErrorOutcome(err_count=candidate.err_count) + if kind is ErrKind.QUEUE: + # 排队不是失败:不冷却、不累计。真正的「等多久再打」由 executor + # 按上游点名的秒数在原号上等,调度器只负责别惩罚这个号。 + return ErrorOutcome(err_count=candidate.err_count) if kind is ErrKind.DEAD: return ErrorOutcome(disabled=True, err_count=0) if kind is ErrKind.CREDIT: diff --git a/src/provider/base.py b/src/provider/base.py index 07dbd9c7..f2334ef5 100644 --- a/src/provider/base.py +++ b/src/provider/base.py @@ -6,6 +6,7 @@ from __future__ import annotations +import json import math import re import time @@ -52,6 +53,11 @@ class ErrKind(StrEnum): # 图片无效 11135 / CodeArts 非多模态模型收到图片 001001020):不是账号 # 的问题,不冷却、不累计;仍会换号重试 REQUEST = "request" + # 排队中(上游明确回 `isQueued` / `RESOURCE_LIMIT` + `retryAfterSeconds`): + # 既不是账号故障也不是请求错误,是「这个号现在满了,等 N 秒再打它」。 + # 不冷却、不累计、不占轮换预算,等在原号上重开(见 executor 的排队重开)。 + # 与 SOFT 的关键差别:SOFT 冷却 60s 且换号,排队按上游点名的秒数等在原号。 + QUEUE = "queue" # 冷却作用域是「(凭证, 模型)」而非整个凭证的错误类别。 @@ -194,6 +200,87 @@ async def read_body_bounded(response: Any, limit: int = BOUNDED_BODY_LIMIT) -> b return bytes(collected[:limit]) +# 上游「排队中」信号:`isQueued` 为真,或子码点名 RESOURCE_LIMIT。 +# 这是跨渠道的**协议形状**而非某家私有码,故放中立层。 +QUEUE_FLAG_KEYS = ("isQueued", "is_queued", "queued") +QUEUE_WAIT_KEYS = ("retryAfterSeconds", "retry_after_seconds", "retryAfter", "retry_after") +QUEUE_SUB_CODES = ("RESOURCE_LIMIT", "RESOURCE_LIMIT_EXCEEDED") +# 上游只说「排队」不给秒数时的兜底等待(秒)。取 5s:比 SOFT 的 60s 短得多, +# 又足以让排队轮次真正错开,不至于变成忙等。 +QUEUE_DEFAULT_WAIT_SECONDS = 5.0 +# 单轮最长等待(秒):上游可能点名「2 小时后再来」,照等会把客户端连接吊死。 +# 超过上限就按上限等一轮,下一轮仍排队则轮次用尽、退回普通错误处理。 +QUEUE_MAX_WAIT_SECONDS = 60.0 +# 单次请求最多在原号上重开几轮。上限存在的意义:上游若持续排队,无限重开 +# 会把客户端连接吊死;3 轮 + 兜底 5s 最坏多等 ~15s,比直接 503 更友好。 +QUEUE_MAX_ROUNDS = 3 + + +def parse_queue_hint(body: bytes | str) -> float | None: + """上游 body 是否表示「排队中」→ 建议等待秒数;非排队返回 None。 + + 形状来自上游信封:`{"isQueued": true, "retryAfterSeconds": 30, + "subCode": "RESOURCE_LIMIT"}`。三者**任一**成立即认定排队——只认 + `isQueued` 会漏掉只给子码的上游,只认子码又会把「配额真的耗尽」误判成 + 排队(故子码只认 `RESOURCE_LIMIT` 这一族,不含 `QUOTA_EXCEEDED`)。 + + 宁可漏判不可误判:识别不出就返回 None,走原有的错误分类路径(最坏是 + 一次 SOFT 冷却换号),绝不能把普通失败当成排队而无限等下去。 + """ + text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else body + if not text.strip(): + return None + try: + payload = json.loads(text) + except json.JSONDecodeError: + return None + return _queue_wait_in(payload, depth=0) + + +def _queue_wait_in(node: Any, depth: int) -> float | None: + """在(可能嵌套的)信封里找排队标记;命中返回等待秒数。 + + 只下钻 3 层:上游信封常见 `{"data": {"error": {...}}}`,再深就不是 + 协议结构而是业务正文了,继续递归只会在长正文上白烧 CPU。 + """ + if depth > 3: + return None + if isinstance(node, dict): + if _is_queue_flag(node): + return _queue_wait_value(node) + for value in node.values(): + found = _queue_wait_in(value, depth + 1) + if found is not None: + return found + elif isinstance(node, list): + for item in node: + found = _queue_wait_in(item, depth + 1) + if found is not None: + return found + return None + + +def _is_queue_flag(node: dict[str, Any]) -> bool: + for key in QUEUE_FLAG_KEYS: + if node.get(key) is True: + return True + for key in ("subCode", "sub_code", "errorCode", "code"): + value = node.get(key) + if isinstance(value, str) and value.upper() in QUEUE_SUB_CODES: + return True + return False + + +def _queue_wait_value(node: dict[str, Any]) -> float: + for key in QUEUE_WAIT_KEYS: + value = node.get(key) + if isinstance(value, bool): + continue + if isinstance(value, (int, float)) and math.isfinite(value) and value >= 0: + return float(value) + return QUEUE_DEFAULT_WAIT_SECONDS + + _BUSINESS_CODE_RE = re.compile(r'"code"\s*:\s*(-?\d+)') @@ -330,6 +417,9 @@ class Event: # 流内 error 事件的业务分类:provider 解析时按自己的错误码语义定好, # executor 不再维护第二份 code→kind 映射(两份必然漂移) error_kind: ErrKind | None = None + # 上游点名「过多久再来」(秒):目前只有排队信号带它。executor 据此决定 + # 在原号上等多久再重开;None 表示上游没给,按 `QUEUE_DEFAULT_WAIT_SECONDS`。 + retry_after_seconds: float | None = None def event_error_kind(event: Event) -> ErrKind: diff --git a/src/provider/qoder/client.py b/src/provider/qoder/client.py index f7875a1d..59f2d950 100644 --- a/src/provider/qoder/client.py +++ b/src/provider/qoder/client.py @@ -39,6 +39,7 @@ from ...provider.base import ( CheckinResult, CheckinStatus, + ErrKind, Event, EventKind, Model, @@ -356,6 +357,16 @@ def _decode_frame(self, frame: Any) -> list[Event]: if envelope is None: return [] if envelope.status != 200: + # 排队信号必须在分类**之前**拦下:上游把「排队中」包成 403,而 + # 403 在这里会被 classify_error_code 判成 DEAD 直接硬禁用凭证—— + # 排队恰恰说明号是好的,只是忙。同时把上游点名的等待秒数带给 + # executor,它据此在原号上重开(见 executor 的排队重开)。 + queue_wait = base.parse_queue_hint(envelope.body) + if queue_wait is not None: + return [Event(kind=EventKind.ERROR, error_code=envelope.status, + error_message=envelope.body[:2000], + error_kind=ErrKind.QUEUE, + retry_after_seconds=queue_wait)] kind = qoder_events.classify_error_code(envelope.status, envelope.body) # 200 字符只够看到外层 `"details":"{\"error\":{\"message\":...`——上游把 # 真实原因嵌在转义的 details 里,截断后根因(如缺哪个 tool_call_id) diff --git a/src/provider/qoder/events.py b/src/provider/qoder/events.py index 5afb5c00..5d26eb31 100644 --- a/src/provider/qoder/events.py +++ b/src/provider/qoder/events.py @@ -37,6 +37,7 @@ Quota, UpstreamProtocolViolation, Usage, + parse_queue_hint, ) from ..openai_chunk import first_choice from ..token_expiry import normalize_epoch @@ -221,8 +222,14 @@ def classify_status(status: int, body: bytes = b"") -> ErrKind: 401/403 只有**明确带会话失效标记**时才归 DEAD(硬禁用);否则按 SOFT 短冷却轮换——实测 Qoder 的 401/403 多数是上游瞬时/风控抖动,直接硬禁用 会把可自愈的凭证打进冷宫。 + + 排队信号(`isQueued` / `RESOURCE_LIMIT`)优先于状态码:上游把「排队中」 + 也包成 403,按状态码分类会硬禁用凭证——排队恰恰说明号是好的,只是忙。 """ - if status in (401, 403) and not is_session_dead(body.decode("utf-8", errors="replace")): + text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else body + if parse_queue_hint(text) is not None: + return ErrKind.QUEUE + if status in (401, 403) and not is_session_dead(text): return ErrKind.SOFT return classify_error_code(status, body) diff --git a/src/provider/trae/client.py b/src/provider/trae/client.py index 841c6534..f03f9062 100644 --- a/src/provider/trae/client.py +++ b/src/provider/trae/client.py @@ -399,10 +399,7 @@ def _to_model(config: dict) -> Model | None: rate = rate_info["data"].get("rate") if isinstance(rate, (int, float)) and not isinstance(rate, bool): credit_rate = float(rate) - context_window = config.get("context_window_tokens") or {} - max_input = context_window.get("dev") if isinstance(context_window, dict) else None - if not isinstance(max_input, int) or isinstance(max_input, bool): - max_input = None + max_input = _context_window(config) return Model( id=str(config["config_name"]), name=str(display.get("display_name") or ""), @@ -556,6 +553,31 @@ def _trae_supports_reasoning(config: dict[str, Any]) -> bool | None: return None +def _context_window(config: dict[str, Any]) -> int | None: + """上下文窗口:取 `max`,缺失或非法时回落 `dev`。 + + 上游 `context_window_tokens` 是 `{dev, max}` 两档:`max` 是模型真实上限 + (如 glm-5.3 的 1M、mimo 的 981504),`dev` 是保守档(200k 量级)。 + 只读 `dev` 会让列表里 1M 上下文的模型报 200k,客户端的上下文压缩提前 + 触发,把本可以装下的对话截断。`max` 不大于 `dev` 时没有信息量,按 `dev`。 + """ + window = config.get("context_window_tokens") + if not isinstance(window, dict): + return None + dev = _positive_int(window.get("dev")) + top = _positive_int(window.get("max")) + if top is not None and (dev is None or top > dev): + return top + return dev + + +def _positive_int(value: Any) -> int | None: + """严格正整数:bool / 非 int / ≤0 一律视为缺失。""" + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + return None + return value + + def _pack_name(pack: dict[str, Any]) -> str: """权益包名称:优先 package_extra 的具体包名,逐级回落到描述。 diff --git a/src/runtime_settings.py b/src/runtime_settings.py index 5f1be3c7..176625fa 100644 --- a/src/runtime_settings.py +++ b/src/runtime_settings.py @@ -116,6 +116,12 @@ def env_name(self) -> str: "全池都在短冷却、没有任何立即可用候选时,最短剩余冷却不超过该值" "则等待后续上(而非直接 503)。等待期间不占在途租约。0 = 关闭" "(快速失败,保持旧行为)。", minimum=0.0, group="affinity"), + HotSetting("nonstream_keepalive_seconds", float, "非流式保活(秒)", + "`stream=false` 的请求在聚合完成前不发任何字节,CDN / 反代的空闲" + "超时会切连接。设为 N>0 时,N 秒内没完成就转 chunked,先吐空白" + "再补 JSON 正文——空白是合法 JSON 前导(RFC 8259),解析器照常" + "工作。0 = 关闭(本部署 nginx 3600s + frp TCP 无此问题);" + "只在 CDN 后面需要,推荐 15。", minimum=0.0, group="routing"), HotSetting("quota_expiry_window_seconds", int, "到期积分主窗口(秒)", "把「距到期 ≤ 该秒数」的积分加总作为选号第一排序指标;" "≤0 关闭整套到期排序(次窗口一并失效)。", minimum=0, @@ -650,6 +656,10 @@ def stream_idle_timeout_seconds(self) -> float: def short_cooldown_wait_seconds(self) -> float: return float(self.get("short_cooldown_wait_seconds")) + @property + def nonstream_keepalive_seconds(self) -> float: + return float(self.get("nonstream_keepalive_seconds")) + @property def blocklist_patterns(self) -> tuple[str, ...]: """与 `Settings.blocklist_patterns` 同口径,但基于生效值重算。 diff --git a/tests/api/test_nonstream_keepalive.py b/tests/api/test_nonstream_keepalive.py new file mode 100644 index 00000000..c7c5aa18 --- /dev/null +++ b/tests/api/test_nonstream_keepalive.py @@ -0,0 +1,297 @@ +"""F5:非流式出口保活(`nonstream_keepalive_seconds`,默认 0 = 关闭)。 + +口径: + +- **宽限期**:`grace` 秒内完成 → 普通 `JSONResponse`,状态码 / `Content-Length` + / 字节内容与不开保活时**完全一致**,异常照常抛给异常处理器; +- **超时才提交 200**:转 chunked,先吐空白字节(RFC 8259 §2 允许 JSON 前导 + 空白)再补正文; +- **默认关**:本部署 nginx `proxy_read_timeout 3600s` + frp TCP,链路上没有 + 短空闲超时,实测 30 天 >60s 的成功请求 1741 个全部成功。 +""" + +from __future__ import annotations + +import asyncio +import json + +import pytest +from starlette.responses import JSONResponse + +from src.api.streaming import ( + JSON_KEEPALIVE_BYTES, + LATE_FAILURE_PAYLOAD, + respond_with_keepalive, +) + + +def _collect(response) -> list[bytes]: + async def run(): + frames: list[bytes] = [] + async for chunk in response.body_iterator: + frames.append(chunk if isinstance(chunk, bytes) else chunk.encode()) + return frames + + return run() + + +async def test_fast_path_returns_plain_json_response(): + async def work(): + return {"ok": 1} + + response = await respond_with_keepalive(work, grace=5) + assert isinstance(response, JSONResponse) + assert response.status_code == 200 + assert json.loads(bytes(response.body)) == {"ok": 1} + # 有 Content-Length:字节级与不开保活完全一致 + assert b"content-length" in b"".join( + k.lower() + b": " + v for k, v in response.headers.raw) + + +async def test_fast_path_applies_convert(): + async def work(): + return {"ok": 1} + + response = await respond_with_keepalive(work, grace=5, + convert=lambda d: {"wrapped": d}) + assert json.loads(bytes(response.body)) == {"wrapped": {"ok": 1}} + + +async def test_fast_path_releases_once(): + released: list[int] = [] + + async def work(): + return {"ok": 1} + + await respond_with_keepalive(work, grace=5, release=lambda: released.append(1)) + assert released == [1] + + +async def test_fast_path_propagates_exception(): + """宽限期内失败:异常照常抛给上层,状态码仍能正确(400/503 不变)。""" + + async def work(): + raise RuntimeError("boom") + + with pytest.raises(RuntimeError): + await respond_with_keepalive(work, grace=5) + + +async def test_fast_path_releases_on_exception(): + released: list[int] = [] + + async def work(): + raise RuntimeError("boom") + + with pytest.raises(RuntimeError): + await respond_with_keepalive(work, grace=5, + release=lambda: released.append(1)) + assert released == [1] + + +async def test_slow_path_streams_whitespace_then_body(): + """超时后才转 chunked:先空白,再合法 JSON。""" + gate = asyncio.Event() + + async def work(): + await gate.wait() + return {"ok": 1} + + response = await respond_with_keepalive(work, grace=0.01) + assert not isinstance(response, JSONResponse) + assert response.media_type == "application/json" + assert response.headers["x-accel-buffering"] == "no" + + frames: list[bytes] = [] + async for chunk in response.body_iterator: + frames.append(chunk if isinstance(chunk, bytes) else chunk.encode()) + if len(frames) == 2: # 先收到心跳,再放行上游 + gate.set() + assert frames[0] == JSON_KEEPALIVE_BYTES + assert frames[-1] == b'{"ok":1}' + # 前导空白不影响解析:这是整套设计的合法性依据 + assert json.loads(b"".join(frames)) == {"ok": 1} + + +async def test_slow_path_applies_convert(): + gate = asyncio.Event() + + async def work(): + await gate.wait() + return {"ok": 1} + + response = await respond_with_keepalive( + work, grace=0.01, convert=lambda d: {"wrapped": d}) + frames: list[bytes] = [] + async for chunk in response.body_iterator: + frames.append(chunk if isinstance(chunk, bytes) else chunk.encode()) + if len(frames) == 2: + gate.set() + assert json.loads(b"".join(frames)) == {"wrapped": {"ok": 1}} + + +async def test_slow_path_late_failure_yields_error_payload(): + """200 已提交后上游才失败:正文降级为可解析的错误体(改不回状态码)。""" + gate = asyncio.Event() + + async def work(): + await gate.wait() + raise RuntimeError("late boom") + + response = await respond_with_keepalive(work, grace=0.01) + frames: list[bytes] = [] + async for chunk in response.body_iterator: + frames.append(chunk if isinstance(chunk, bytes) else chunk.encode()) + if len(frames) == 2: + gate.set() + payload = json.loads(b"".join(frames)) + assert payload == LATE_FAILURE_PAYLOAD + + +async def test_slow_path_releases_when_done(): + released: list[int] = [] + gate = asyncio.Event() + + async def work(): + await gate.wait() + return {"ok": 1} + + response = await respond_with_keepalive( + work, grace=0.01, release=lambda: released.append(1)) + assert released == [] # 流还没跑完,不能提前释放 + frames: list[bytes] = [] + async for chunk in response.body_iterator: + frames.append(chunk if isinstance(chunk, bytes) else chunk.encode()) + if len(frames) == 2: + gate.set() + assert released == [1] + + +async def test_slow_path_client_disconnect_cancels_work(): + """客户端断开(生成器提前关闭):取消工作协程并释放租约,不留孤儿。""" + cancelled: list[int] = [] + released: list[int] = [] + + async def work(): + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + cancelled.append(1) + raise + return {"ok": 1} + + response = await respond_with_keepalive( + work, grace=0.01, release=lambda: released.append(1)) + iterator = response.body_iterator.__aiter__() + assert await anext(iterator) == JSON_KEEPALIVE_BYTES + await response.body_iterator.aclose() + await asyncio.sleep(0) + assert cancelled == [1] + assert released == [1] + + +async def test_outer_cancel_cancels_work(): + """宽限期内被取消(客户端断开 / 上层取消):工作协程一并收掉。""" + cancelled: list[int] = [] + released: list[int] = [] + + async def work(): + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + cancelled.append(1) + raise + return {"ok": 1} + + task = asyncio.ensure_future( + respond_with_keepalive(work, grace=10, + release=lambda: released.append(1))) + await asyncio.sleep(0.01) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert cancelled == [1] + assert released == [1] + + +async def test_outer_cancel_without_release_is_silent(): + """宽限期内被取消且没传 release:只收掉工作协程,不额外做什么。""" + cancelled: list[int] = [] + + async def work(): + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + cancelled.append(1) + raise + return {"ok": 1} + + task = asyncio.ensure_future(respond_with_keepalive(work, grace=10)) + await asyncio.sleep(0.01) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert cancelled == [1] + + +async def test_slow_path_cancelled_work_still_raises(monkeypatch): + """200 已提交后工作协程被取消:照常抛 CancelledError,不冒充上游失败。""" + captured: dict[str, asyncio.Future] = {} + real_ensure = asyncio.ensure_future + + def capture(coro, *args, **kwargs): + future = real_ensure(coro, *args, **kwargs) + captured.setdefault("task", future) + return future + + monkeypatch.setattr(asyncio, "ensure_future", capture) + released: list[int] = [] + gate = asyncio.Event() + + async def work(): + await gate.wait() + return {"ok": 1} + + response = await respond_with_keepalive( + work, grace=0.01, release=lambda: released.append(1)) + iterator = response.body_iterator.__aiter__() + assert await anext(iterator) == JSON_KEEPALIVE_BYTES + captured["task"].cancel() + with pytest.raises(asyncio.CancelledError): + await anext(iterator) + assert released == [1] + + +def test_dumps_matches_json_response_rendering(): + """保活正文的序列化必须与 `JSONResponse.render` 逐字节一致。""" + from src.api.streaming import _dumps + + payload = {"a": 1, "中文": "值", "b": [1, 2]} + assert _dumps(payload) == JSONResponse(payload).render(payload) + + +def test_endpoint_uses_keepalive_when_enabled(tmp_path): + """配置打开后非流式出口走保活包装;宽限期内完成仍返回普通 JSON。""" + from fastapi.testclient import TestClient + + from src.config import Settings + from src.main import build_app + from tests.helpers.constants import SECRET + from tests.helpers.events import TRAE_GOOD + from tests.helpers.providers import ScriptedProvider + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root", nonstream_keepalive_seconds=5) + app = build_app(settings, providers={"trae": ScriptedProvider([TRAE_GOOD])}) + with TestClient(app) as client: + app.state.credentials.add(provider="trae", + credential_data={"accessToken": "a"}) + key = app.state.api_keys.create("root")["api_key"] + response = client.post( + "/v1/chat/completions", + headers={"Authorization": f"Bearer {key}"}, + json={"messages": [{"role": "user", "content": "hi"}]}) + assert response.status_code == 200 + assert response.json()["choices"][0]["message"]["content"] == "hi" + # 宽限期内完成:普通 JSONResponse,带 Content-Length(chunked 才没有) + assert response.headers.get("content-length") diff --git a/tests/compat/test_anthropic.py b/tests/compat/test_anthropic.py index cb825fdd..5758bab3 100644 --- a/tests/compat/test_anthropic.py +++ b/tests/compat/test_anthropic.py @@ -116,6 +116,36 @@ def test_endpoint_non_stream(tmp_path): assert body["usage"] == {"input_tokens": 2, "output_tokens": 3} assert provider.last_payload["messages"][0] == {"role": "user", "content": "hi"} +def test_endpoint_stream_keepalive_uses_ping_event(tmp_path, monkeypatch): + """空闲心跳走 `event: ping`(不是注释帧):只解析 event 行的客户端才收得到。 + + 这是端到端契约:`dispatch_compat` 必须把 translator 的心跳帧交给 + `with_keepalive`,否则翻译器上的 `keepalive()` 永远不会被调用。 + """ + import asyncio + + from src.api import streaming + + monkeypatch.setattr(streaming, "KEEPALIVE_SECONDS", 0.01) + + class SlowProvider(_Provider): + async def stream_chat(self, credential_data, payload, model): + await asyncio.sleep(0.08) + async for event in super().stream_chat(credential_data, payload, model): + yield event + + app = _app(tmp_path, SlowProvider()) + with TestClient(app) as client: + response = client.post("/v1/messages", headers=_auth(app), + json=_messages(stream=True)) + assert response.status_code == 200 + assert "event: ping" in response.text + assert ": keepalive" not in response.text + # ping 必须是合法 JSON 事件,不能只有事件名 + assert any(name == "ping" and data == {"type": "ping"} + for name, data in _events(response.text)) + + def test_endpoint_stream_reports_cache_read(tmp_path): app = _app(tmp_path, _Provider("cache")) with TestClient(app) as client: diff --git a/tests/compat/test_anthropic_translate.py b/tests/compat/test_anthropic_translate.py index 7f22f90f..c3e0304f 100644 --- a/tests/compat/test_anthropic_translate.py +++ b/tests/compat/test_anthropic_translate.py @@ -93,10 +93,14 @@ def test_translator_error_without_message_uses_default(): frames = list(translator.translate(Event(kind=EventKind.ERROR))) assert b"upstream error" in frames[0] -def test_translator_keepalive_is_comment_frame(): - from src.engine.sse import SSE_COMMENT - - assert AnthropicStreamTranslator("m").keepalive() == SSE_COMMENT +def test_translator_keepalive_is_ping_event(): + """Anthropic 心跳是具名 `ping` 事件,不是 SSE 注释帧。 + + 官方流式规范里 ping 带 `event: ping` + `data: {"type": "ping"}`;只解析 + `event:` 行的客户端会忽略注释帧,长静默下仍会判超时断开。 + """ + assert (AnthropicStreamTranslator("m").keepalive() + == b'event: ping\ndata: {"type": "ping"}\n\n') def test_translator_stop_reason_mapping(): from src.compat.anthropic.response import _stop_reason diff --git a/tests/compat/test_openai_response.py b/tests/compat/test_openai_response.py index 858acc3a..77bbb1f8 100644 --- a/tests/compat/test_openai_response.py +++ b/tests/compat/test_openai_response.py @@ -50,6 +50,36 @@ def test_stream_translator_missing_tool_index_gets_stable_position(): ["choices"][0]["delta"]["tool_calls"]] assert indices == [0, 1] +def test_stream_translator_missing_tool_id_gets_stable_id(): + """`id` 缺失时按 index 补稳定 id:OpenAI 客户端按 id 聚合分片。 + + 同一个 index 的后续分片必须沿用同一个 id,否则一次调用会被拆成多次。 + """ + t = StreamTranslator("m") + list(t.translate(Event(kind=EventKind.CONTENT, content="x"))) + first = list(t.translate(Event(kind=EventKind.TOOL_CALLS, + tool_calls=[{"index": 0, + "function": {"name": "f", + "arguments": "{"}}]))) + second = list(t.translate(Event(kind=EventKind.TOOL_CALLS, + tool_calls=[{"index": 0, + "function": {"arguments": "}"}}]))) + id_a = json.loads(first[0].decode()[6:])["choices"][0]["delta"]["tool_calls"][0]["id"] + id_b = json.loads(second[0].decode()[6:])["choices"][0]["delta"]["tool_calls"][0]["id"] + assert id_a == id_b + assert id_a.startswith("call_0_") + + +def test_stream_translator_keeps_upstream_tool_id(): + """上游给了 id 就沿用,不覆盖。""" + t = StreamTranslator("m") + frames = list(t.translate(Event(kind=EventKind.TOOL_CALLS, + tool_calls=[{"id": "call_up", "index": 0, + "function": {"name": "f"}}]))) + assert json.loads(frames[0].decode()[6:])["choices"][0]["delta"][ + "tool_calls"][0]["id"] == "call_up" + + def test_stream_translator_error_frame(): t = StreamTranslator("m") frames = list(t.translate(Event(kind=EventKind.ERROR, error_code=1005, diff --git a/tests/compat/test_responses.py b/tests/compat/test_responses.py index 95a4d4c8..7d7a6b96 100644 --- a/tests/compat/test_responses.py +++ b/tests/compat/test_responses.py @@ -67,6 +67,30 @@ def test_endpoint_stream_events(tmp_path): assert names[-1] == "response.completed" assert "response.output_text.delta" in names +def test_endpoint_stream_keepalive_stays_comment(tmp_path, monkeypatch): + """Responses 出口的心跳必须保持注释帧:该协议没有 ping 事件类型。""" + import asyncio + + from src.api import streaming + + monkeypatch.setattr(streaming, "KEEPALIVE_SECONDS", 0.01) + + class SlowProvider(_Provider): + async def stream_chat(self, credential_data, payload, model): + await asyncio.sleep(0.08) + async for event in super().stream_chat(credential_data, payload, model): + yield event + + app = _app(tmp_path, SlowProvider()) + with TestClient(app) as client: + response = client.post("/v1/responses", headers=_auth(app), json={ + "model": "glm-5.2@codebuddy", "input": "hi", "stream": True, + }) + assert response.status_code == 200 + assert ": keepalive" in response.text + assert "event: ping" not in response.text + + def test_endpoint_non_stream(tmp_path): provider = _Provider() app = _app(tmp_path, provider) diff --git a/tests/compat/test_responses_translate.py b/tests/compat/test_responses_translate.py index 97097cd1..eb5a63dd 100644 --- a/tests/compat/test_responses_translate.py +++ b/tests/compat/test_responses_translate.py @@ -210,6 +210,7 @@ def test_empty_events_do_not_create_items(): assert frames[-1][1]["response"]["usage"]["input_tokens"] == 1 def test_keepalive_is_sse_comment(): + """Responses 出口必须用注释帧:该协议没有 ping 事件类型。""" from src.engine.sse import SSE_COMMENT assert ResponsesStreamTranslator("m").keepalive() is SSE_COMMENT diff --git a/tests/engine/_fallback_support.py b/tests/engine/_fallback_support.py index ab93a245..99938afa 100644 --- a/tests/engine/_fallback_support.py +++ b/tests/engine/_fallback_support.py @@ -14,7 +14,9 @@ __all__ = [ "GOOD", "INVALID", + "HttpInvalid", "ModelAwareProvider", + "RaisingProvider", "ScriptedProvider", "add_credential", "aliases", @@ -25,6 +27,34 @@ error_message="service info not found", error_kind=ErrKind.INVALID)] +class HttpInvalid(Exception): + """HTTP 级请求无效(如 400 模型不存在)。 + + 真实 provider 的 `UpstreamHTTPError` 带 `kind()`:同样是 INVALID,但走 + executor 的 `except` 分支而不是流内事件分支,两条路径的收尾逻辑不同。 + """ + + def kind(self) -> ErrKind: + return ErrKind.INVALID + + +class RaisingProvider: + """首次取流就抛出指定异常(模拟 HTTP 非 2xx 在建立流时失败)。""" + + def __init__(self, provider_id: str, error: Exception) -> None: + self.id = provider_id + self.error = error + self.calls = 0 + + async def stream_chat(self, _credential_data, _payload, _model): + self.calls += 1 + raise self.error + yield # 让本方法成为异步生成器:异常在首次 anext 时抛出 + + def list_models(self, _credential_data): + return [] + + class ScriptedProvider: """每次调用按顺序回放 `script` 里的事件列表(越界后重复最后一个)。""" diff --git a/tests/engine/test_fallback.py b/tests/engine/test_fallback.py index 064ff04e..45a98791 100644 --- a/tests/engine/test_fallback.py +++ b/tests/engine/test_fallback.py @@ -7,7 +7,14 @@ from src.compat.openai.request import InvalidRequest from src.engine.model_resolver import ModelTarget from src.provider.base import ErrKind, Event, EventKind -from tests.engine._fallback_support import GOOD, INVALID, ModelAwareProvider, ScriptedProvider +from tests.engine._fallback_support import ( + GOOD, + INVALID, + HttpInvalid, + ModelAwareProvider, + RaisingProvider, + ScriptedProvider, +) from tests.engine._fallback_support import add_credential as _add from tests.engine._fallback_support import aliases as _aliases from tests.engine._fallback_support import make_executor as _executor @@ -33,6 +40,23 @@ async def test_complete_falls_back_when_primary_all_invalid(repo): assert tr.models_seen == ["glm-4.6"] db.close() +@pytest.mark.asyncio +async def test_complete_http_invalid_exception_skips_provider(repo): + """HTTP 级 INVALID(异常)与非流内 INVALID 同语义:跳过该上游,不冷却凭证。""" + credentials, db = repo + _add(credentials, "codebuddy", "cb") + _add(credentials, "trae", "tr") + cb = RaisingProvider("codebuddy", HttpInvalid("400 model not found")) + tr = ScriptedProvider("trae", [GOOD]) + executor = _executor({"codebuddy": cb, "trae": tr}, credentials, + {"chat": ("glm-5.2", "glm-4.6")}, + _aliases("glm-5.2", "glm-4.6")) + result = await executor.complete(_request("glm-5.2")) + assert result["choices"][0]["message"]["content"] == "ok" + assert cb.calls == 1 and tr.calls == 1 + assert tr.models_seen == ["glm-4.6"] + db.close() + @pytest.mark.asyncio async def test_complete_group_name_entry(repo): """请求兼容组名:以组内首成员发起。""" diff --git a/tests/engine/test_fallback_stream.py b/tests/engine/test_fallback_stream.py index 342a4fbd..d0da4ae1 100644 --- a/tests/engine/test_fallback_stream.py +++ b/tests/engine/test_fallback_stream.py @@ -6,7 +6,14 @@ from src.engine.executor import NoProviderForModel from src.provider.base import ErrKind, Event, EventKind -from tests.engine._fallback_support import GOOD, INVALID, ModelAwareProvider, ScriptedProvider +from tests.engine._fallback_support import ( + GOOD, + INVALID, + HttpInvalid, + ModelAwareProvider, + RaisingProvider, + ScriptedProvider, +) from tests.engine._fallback_support import add_credential as _add from tests.engine._fallback_support import aliases as _aliases from tests.engine._fallback_support import make_executor as _executor @@ -32,6 +39,24 @@ async def test_stream_falls_back_before_first_frame(repo): assert tr.models_seen == ["glm-4.6"] db.close() +@pytest.mark.asyncio +async def test_stream_http_invalid_exception_falls_back(repo): + """HTTP 级 INVALID(异常,不是流内事件)同样在首帧前回退。""" + credentials, db = repo + _add(credentials, "codebuddy", "cb") + _add(credentials, "trae", "tr") + cb = RaisingProvider("codebuddy", HttpInvalid("400 model not found")) + tr = ScriptedProvider("trae", [GOOD]) + executor = _executor({"codebuddy": cb, "trae": tr}, credentials, + {"chat": ("glm-5.2", "glm-4.6")}, + _aliases("glm-5.2", "glm-4.6")) + frames = [f async for f in executor.stream(_request("glm-5.2"), username="u")] + joined = b"".join(frames).decode() + assert "ok" in joined and "error" not in joined + assert cb.calls == 1 and tr.calls == 1 + assert tr.models_seen == ["glm-4.6"] + db.close() + @pytest.mark.asyncio async def test_stream_exhausted_chain_emits_single_error_frame(repo): """整条链都失败:只发一帧错误,且不再继续尝试。""" diff --git a/tests/engine/test_queue_retry.py b/tests/engine/test_queue_retry.py new file mode 100644 index 00000000..ec45128e --- /dev/null +++ b/tests/engine/test_queue_retry.py @@ -0,0 +1,529 @@ +"""F3:排队语义(上游回 `isQueued` / `RESOURCE_LIMIT` + `retryAfterSeconds`)。 + +口径: + +- **不冷却**:排队不是故障,`scheduler.note_error` 对 `ErrKind.QUEUE` 零动作; +- **不换号**:等 `retryAfterSeconds` 后在**原号**上重开; +- **不占轮换预算**:排队轮次不计入 `attempts`,否则三次排队就把请求打成 503; +- **有界**:轮次上限 `QUEUE_MAX_ROUNDS`、单轮封顶 `QUEUE_MAX_WAIT_SECONDS`; +- **已出帧不重开**:半截输出不可回滚,降级为 SOFT 收尾。 + +全部用假时钟(`asyncio.sleep` 换掉),文件毫秒级跑完。 +""" + +from __future__ import annotations + +import asyncio +import json + +import pytest + +from src.compat.openai.request import parse_chat_request +from src.engine.executor import ( + QUEUE_MAX_ROUNDS, + QUEUE_MAX_WAIT_SECONDS, + Executor, + ExecutorDeps, + NoHealthyCredential, +) +from src.engine.model_resolver import resolve +from src.engine.scheduler import ErrorOutcome, Scheduler +from src.provider.base import ( + QUEUE_DEFAULT_WAIT_SECONDS, + ErrKind, + Event, + EventKind, + parse_queue_hint, +) +from tests.helpers.events import GOOD + +_TARGET = resolve("glm-5.2@trae", "glm-5.2") + +_REAL_SLEEP = asyncio.sleep + + +@pytest.fixture() +def sleeps(monkeypatch): + """把 `asyncio.sleep` 换成记账版本(不真等)。""" + recorded: list[float] = [] + + async def fake(delay, result=None): + recorded.append(delay) + await _REAL_SLEEP(0) + return result + + monkeypatch.setattr(asyncio, "sleep", fake) + return recorded + + +def _request(stream: bool = True): + return parse_chat_request({"messages": [{"role": "user", "content": "hi"}], + "stream": stream}) + + +def _executor(credentials, provider, **deps) -> Executor: + return Executor(ExecutorDeps(providers={"trae": provider}, credentials=credentials, + scheduler=deps.pop("scheduler", Scheduler()), + default_model="glm-5.2", **deps)) + + +class _QueuedProvider: + """前 N 次上游回排队事件,之后正常吐 GOOD。""" + + id = "trae" + + def __init__(self, queue_times: int, retry_after: float | None = 0) -> None: + self.queue_times = queue_times + self.retry_after = retry_after + self.calls = 0 + + async def stream_chat(self, _credential_data, _payload, _model): + self.calls += 1 + if self.calls <= self.queue_times: + yield Event(kind=EventKind.ERROR, error_code=403, + error_message='{"isQueued":true}', + error_kind=ErrKind.QUEUE, + retry_after_seconds=self.retry_after) + return + for event in GOOD: + yield event + + +# --------------------------------------------------------------- 协议解析 + + +def test_parse_queue_hint_is_queued_flag(): + assert parse_queue_hint(json.dumps({"isQueued": True, + "retryAfterSeconds": 7})) == 7.0 + + +def test_parse_queue_hint_sub_code(): + assert parse_queue_hint(json.dumps({"subCode": "RESOURCE_LIMIT", + "retryAfterSeconds": 3})) == 3.0 + + +def test_parse_queue_hint_nested_envelope(): + body = json.dumps({"data": {"error": {"isQueued": True, + "retryAfterSeconds": 11}}}) + assert parse_queue_hint(body) == 11.0 + + +def test_parse_queue_hint_list_node(): + body = json.dumps([{"ok": 1}, {"isQueued": True, "retryAfterSeconds": 2}]) + assert parse_queue_hint(body) == 2.0 + + +def test_parse_queue_hint_list_without_queue(): + assert parse_queue_hint(json.dumps([1, "x", {"ok": 1}])) is None + + +def test_parse_queue_hint_list_without_flag(): + """列表节点整体没有排队标记:遍历完返回 None(而不是把列表本身当队列)。""" + assert parse_queue_hint(json.dumps([{"ok": 1}, {"code": 0}])) is None + + +def test_parse_queue_hint_depth_limit(): + """超过 3 层就不再下钻:再深是业务正文,继续递归只白烧 CPU。""" + node: dict = {"isQueued": True} + for _ in range(6): + node = {"wrap": node} + assert parse_queue_hint(json.dumps(node)) is None + + +def test_parse_queue_hint_defaults_when_no_seconds(): + assert parse_queue_hint('{"isQueued": true}') == QUEUE_DEFAULT_WAIT_SECONDS + + +def test_parse_queue_hint_ignores_bool_wait(): + """`retryAfterSeconds: true` 不是秒数,回落到兜底值。""" + assert parse_queue_hint('{"isQueued": true, "retryAfterSeconds": true}') == ( + QUEUE_DEFAULT_WAIT_SECONDS) + + +@pytest.mark.parametrize("body", [ + b"", + b" ", + b"not json", + b'"just a string"', + b'{"isQueued": false}', + b'{"code": 500}', +]) +def test_parse_queue_hint_not_queued(body): + assert parse_queue_hint(body) is None + + +def test_parse_queue_hint_accepts_bytes(): + assert parse_queue_hint(b'{"isQueued": true, "retryAfterSeconds": 4}') == 4.0 + + +# ------------------------------------------------------- 调度器零动作 + + +def test_scheduler_queue_writes_nothing(repo): + """排队绝不惩罚凭证:err_count 不变、不写冷却。""" + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + scheduler = Scheduler() + candidate = next(c for c in credentials.candidates(["trae"]) + if c.credential_id == cid) + outcome = scheduler.note_error(candidate, ErrKind.QUEUE, 1_700_000_000) + assert outcome == ErrorOutcome(err_count=candidate.err_count) + assert outcome.cooling_until is None + db.close() + + +# ------------------------------------------------------------ 流式排队重开 + + +async def test_stream_requeues_on_same_credential(repo, sleeps): + """排队 1 次后成功:等 retryAfter 秒、用同一个号、只打 2 次上游。""" + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + provider = _QueuedProvider(queue_times=1, retry_after=2) + executor = _executor(credentials, provider) + frames = [f async for f in executor.stream(_request())] + assert b"ok" in b"".join(frames) + assert provider.calls == 2 + assert sleeps == [2.0] + # 不写冷却:凭证仍然可立即选用 + candidate = next(c for c in credentials.candidates(["trae"]) + if c.credential_id == cid) + assert candidate.cooling_until is None + db.close() + + +async def test_stream_queue_rounds_are_bounded(repo, sleeps): + """上游一直排队:最多重开 `QUEUE_MAX_ROUNDS` 轮,然后 503。""" + credentials, db = repo + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + provider = _QueuedProvider(queue_times=99, retry_after=0) + executor = _executor(credentials, provider) + frames = [f async for f in executor.stream(_request())] + assert b"no_healthy_credential" in b"".join(frames) + # 首次 + 3 轮重开 = 4 次上游调用 + assert provider.calls == QUEUE_MAX_ROUNDS + 1 + assert len(sleeps) == QUEUE_MAX_ROUNDS + db.close() + + +async def test_stream_queue_wait_is_capped(repo, sleeps): + """上游点名 2 小时:单轮等待封顶到 `QUEUE_MAX_WAIT_SECONDS`。""" + credentials, db = repo + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + provider = _QueuedProvider(queue_times=1, retry_after=7200) + executor = _executor(credentials, provider) + frames = [f async for f in executor.stream(_request())] + assert b"ok" in b"".join(frames) + assert sleeps == [QUEUE_MAX_WAIT_SECONDS] + db.close() + + +async def test_stream_queue_without_seconds_uses_default(repo, sleeps): + credentials, db = repo + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + provider = _QueuedProvider(queue_times=1, retry_after=None) + executor = _executor(credentials, provider) + frames = [f async for f in executor.stream(_request())] + assert b"ok" in b"".join(frames) + assert sleeps == [QUEUE_DEFAULT_WAIT_SECONDS] + db.close() + + +async def test_stream_queue_after_emit_degrades_to_soft(repo, sleeps): + """已出帧后再排队:不能重开(半截输出),按 SOFT 收尾。""" + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + + class Provider: + id = "trae" + + async def stream_chat(self, _credential_data, _payload, _model): + yield Event(kind=EventKind.CONTENT, content="half") + yield Event(kind=EventKind.ERROR, error_code=403, + error_kind=ErrKind.QUEUE, retry_after_seconds=1) + + executor = _executor(credentials, Provider()) + frames = [f async for f in executor.stream(_request())] + body = b"".join(frames) + assert b"half" in body + assert b"no_healthy_credential" in body + assert sleeps == [] + candidate = next(c for c in credentials.candidates(["trae"]) + if c.credential_id == cid) + assert candidate.cooling_until is not None # SOFT 冷却已写 + db.close() + + +async def test_stream_queue_pinned_credential_deleted(repo, sleeps): + """排队等待期间凭证被并发删除:退回正常选号,不崩。""" + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + + class Provider: + id = "trae" + + def __init__(self) -> None: + self.calls = 0 + + async def stream_chat(self, _credential_data, _payload, _model): + self.calls += 1 + if self.calls == 1: + yield Event(kind=EventKind.ERROR, error_code=403, + error_kind=ErrKind.QUEUE, retry_after_seconds=0) + return + for event in GOOD: + yield event + + provider = Provider() + executor = _executor(credentials, provider) + + async def _drop_then_run(): + frames = [] + async for frame in executor.stream(_request()): + frames.append(frame) + return frames + + # 在第一次 sleep 里删掉凭证(假 sleep 会走到这里) + original = asyncio.sleep + fired = False + + async def deleting_sleep(delay, result=None): + nonlocal fired + if not fired: + fired = True + credentials.delete(cid) + await original(0) + + import src.engine.executor as executor_module + + real = executor_module.asyncio.sleep + executor_module.asyncio.sleep = deleting_sleep + try: + frames = await _drop_then_run() + finally: + executor_module.asyncio.sleep = real + assert b"no_healthy_credential" in b"".join(frames) + db.close() + + +async def test_stream_http_queue_error_requeues(repo, sleeps): + """HTTP 级排队(403 + 排队 body 抛出的 UpstreamHTTPError)同样原号重开。""" + credentials, db = repo + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + + from src.provider.base import UpstreamHTTPError + + class QueuedHTTPError(UpstreamHTTPError): + classify_status = staticmethod(lambda status, body: ErrKind.QUEUE) + + class Provider: + id = "trae" + + def __init__(self) -> None: + self.calls = 0 + + async def stream_chat(self, _credential_data, _payload, _model): + self.calls += 1 + if self.calls == 1: + raise QueuedHTTPError(403, b'{"isQueued": true}', + retry_after_seconds=4.0) + for event in GOOD: + yield event + + provider = Provider() + executor = _executor(credentials, provider) + frames = [f async for f in executor.stream(_request())] + assert b"ok" in b"".join(frames) + assert provider.calls == 2 + assert sleeps == [4.0] + db.close() + + +async def test_stream_http_queue_exhausted_degrades_to_soft(repo, sleeps): + """HTTP 级排队但轮次已用尽:按 SOFT 冷却换号,不无限等。""" + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + + from src.provider.base import UpstreamHTTPError + + class QueuedHTTPError(UpstreamHTTPError): + classify_status = staticmethod(lambda status, body: ErrKind.QUEUE) + + class Provider: + id = "trae" + + async def stream_chat(self, _credential_data, _payload, _model): + raise QueuedHTTPError(403, b'{"isQueued": true}') + yield # pragma: no cover - 让它成为 async generator + + executor = _executor(credentials, Provider()) + frames = [f async for f in executor.stream(_request())] + assert b"no_healthy_credential" in b"".join(frames) + assert len(sleeps) == QUEUE_MAX_ROUNDS + candidate = next(c for c in credentials.candidates(["trae"]) + if c.credential_id == cid) + assert candidate.cooling_until is not None + db.close() + + +# --------------------------------------------------------- 非流式排队重开 + + +async def test_complete_requeues_on_same_credential(repo, sleeps): + credentials, db = repo + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + provider = _QueuedProvider(queue_times=1, retry_after=1) + executor = _executor(credentials, provider) + result = await executor.complete(_request(stream=False)) + assert result["choices"][0]["message"]["content"] == "ok" + assert provider.calls == 2 + assert sleeps == [1.0] + db.close() + + +async def test_complete_queue_rounds_bounded(repo, sleeps): + credentials, db = repo + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + provider = _QueuedProvider(queue_times=99, retry_after=0) + executor = _executor(credentials, provider) + with pytest.raises(NoHealthyCredential): + await executor.complete(_request(stream=False)) + assert provider.calls == QUEUE_MAX_ROUNDS + 1 + db.close() + + +async def test_complete_queue_pinned_credential_deleted(repo, sleeps): + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + + class Provider: + id = "trae" + + def __init__(self) -> None: + self.calls = 0 + + async def stream_chat(self, _credential_data, _payload, _model): + self.calls += 1 + if self.calls == 1: + yield Event(kind=EventKind.ERROR, error_code=403, + error_kind=ErrKind.QUEUE, retry_after_seconds=0) + return + for event in GOOD: + yield event + + provider = Provider() + executor = _executor(credentials, provider) + + import src.engine.executor as executor_module + + original = executor_module.asyncio.sleep + fired = False + + async def deleting_sleep(delay, result=None): + nonlocal fired + if not fired: + fired = True + credentials.delete(cid) + await _REAL_SLEEP(0) + + executor_module.asyncio.sleep = deleting_sleep + try: + with pytest.raises(NoHealthyCredential): + await executor.complete(_request(stream=False)) + finally: + executor_module.asyncio.sleep = original + db.close() + + +async def test_complete_http_queue_requeues(repo, sleeps): + credentials, db = repo + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + + from src.provider.base import UpstreamHTTPError + + class QueuedHTTPError(UpstreamHTTPError): + classify_status = staticmethod(lambda status, body: ErrKind.QUEUE) + + class Provider: + id = "trae" + + def __init__(self) -> None: + self.calls = 0 + + async def stream_chat(self, _credential_data, _payload, _model): + self.calls += 1 + if self.calls == 1: + raise QueuedHTTPError(403, b'{"isQueued": true}', + retry_after_seconds=2.0) + for event in GOOD: + yield event + + provider = Provider() + executor = _executor(credentials, provider) + result = await executor.complete(_request(stream=False)) + assert result["choices"][0]["message"]["content"] == "ok" + assert sleeps == [2.0] + db.close() + + +async def test_complete_http_queue_exhausted_degrades_to_soft(repo, sleeps): + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + + from src.provider.base import UpstreamHTTPError + + class QueuedHTTPError(UpstreamHTTPError): + classify_status = staticmethod(lambda status, body: ErrKind.QUEUE) + + class Provider: + id = "trae" + + async def stream_chat(self, _credential_data, _payload, _model): + raise QueuedHTTPError(403, b'{"isQueued": true}') + yield # pragma: no cover - 让它成为 async generator + + executor = _executor(credentials, Provider()) + with pytest.raises(NoHealthyCredential): + await executor.complete(_request(stream=False)) + assert len(sleeps) == QUEUE_MAX_ROUNDS + candidate = next(c for c in credentials.candidates(["trae"]) + if c.credential_id == cid) + assert candidate.cooling_until is not None + db.close() + + +# ------------------------------------------------------------- _queue_wait + + +def test_queue_wait_disabled_when_emitted(repo): + credentials, db = repo + executor = _executor(credentials, object()) + assert executor._queue_wait(1.0, True, 0) is None + db.close() + + +def test_queue_wait_disabled_when_rounds_exhausted(repo): + credentials, db = repo + executor = _executor(credentials, object()) + assert executor._queue_wait(1.0, False, QUEUE_MAX_ROUNDS) is None + db.close() + + +def test_queue_wait_clamps_negative(repo): + credentials, db = repo + executor = _executor(credentials, object()) + assert executor._queue_wait(-5.0, False, 0) == 0.0 + db.close() + + +def test_queue_wait_uses_value(repo): + credentials, db = repo + executor = _executor(credentials, object()) + assert executor._queue_wait(12.5, False, 0) == 12.5 + db.close() + + +def test_error_type_for_queue_is_rate_limit(): + from src.engine.executor import _error_type_for + + assert _error_type_for(ErrKind.QUEUE) == "rate_limit" diff --git a/tests/provider/qoder/test_client.py b/tests/provider/qoder/test_client.py index 2f2b9c12..33ec8074 100644 --- a/tests/provider/qoder/test_client.py +++ b/tests/provider/qoder/test_client.py @@ -261,6 +261,24 @@ def handler(_request: httpx.Request) -> httpx.Response: assert events[1].error_code == 418 assert events[1].error_kind is ErrKind.SOFT +async def test_stream_chat_queue_envelope_yields_queue_event(): + """排队信封(403 + `isQueued`)必须归 QUEUE。 + + 按状态码分类会把 403 当事务级失败硬禁用凭证,而排队恰恰说明号是好的; + 上游点名的等待秒数要原样带给 executor(原号重开)。 + """ + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=envelope( + {"isQueued": True, "retryAfterSeconds": 6}, status=403)) + + events = [e async for e in handler_for(handler).stream_chat(cred(), {}, "m")] + assert len(events) == 1 + assert events[0].kind is EventKind.ERROR + assert events[0].error_kind is ErrKind.QUEUE + assert events[0].error_code == 403 + assert events[0].retry_after_seconds == 6 + + async def test_inner_error_message_keeps_nested_details(): """错误正文放宽到 2000:200 字符会截在转义的 details 中间,根因丢失。""" detail = json.dumps({"error": {"message": "Messages with role 'tool' must be a " @@ -518,6 +536,14 @@ def test_node_failure_400_is_model_scoped_transient(): assert qoder_events.classify_error_code(400, "bad request") is ErrKind.INVALID assert qoder_events.classify_status(400, body.encode()) is ErrKind.MODEL +def test_classify_status_queue_hint_wins_over_403(): + """排队标记优先于状态码:403 不再当硬失败(会把健康凭证打进冷宫)。""" + body = b'{"isQueued": true, "retryAfterSeconds": 9}' + assert qoder_events.classify_status(403, body) is ErrKind.QUEUE + # 没有排队标记的 403 仍是 SOFT:实测多为瞬时/风控抖动,可自愈 + assert qoder_events.classify_status(403, b"") is ErrKind.SOFT + + def test_to_status_variants(): assert qoder_events._to_status(None) == 200 assert qoder_events._to_status(True) == 502 diff --git a/tests/provider/trae/test_client.py b/tests/provider/trae/test_client.py index e6b0532a..221b57e3 100644 --- a/tests/provider/trae/test_client.py +++ b/tests/provider/trae/test_client.py @@ -91,6 +91,39 @@ def handler(request: httpx.Request) -> httpx.Response: assert models == [Model(id="glm-5.2", name="GLM")] assert quota.remaining == 70 and quota.total == 100 +async def test_client_fetch_models_context_window_prefers_max(): + """上下文窗口取 `max`(真实上限),缺失/非法/不大于 dev 时回落 `dev`。 + + 只读 `dev` 会让 1M 上下文的模型报 200k,客户端据此提前压缩上下文。 + """ + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"config_info_list": [ + {"config_name": "glm-5.3", + "context_window_tokens": {"dev": 200000, "max": 1000000}}, + {"config_name": "mimo", + "context_window_tokens": {"dev": 200000, "max": 981504}}, + # max 不大于 dev → 没有信息量,按 dev + {"config_name": "flat", + "context_window_tokens": {"dev": 200000, "max": 200000}}, + # max 非法 → 回落 dev + {"config_name": "bad-max", + "context_window_tokens": {"dev": 128000, "max": "1M"}}, + # 只有 max + {"config_name": "max-only", + "context_window_tokens": {"max": 400000}}, + # max 为 bool(int 子类,必须排除) + {"config_name": "bool-max", + "context_window_tokens": {"dev": 64000, "max": True}}, + # max 非正 + {"config_name": "zero-max", + "context_window_tokens": {"dev": 64000, "max": 0}}, + ]}) + + models = await _client(handler).fetch_models(TraeCredential(access_token="a")) + assert [m.max_input_tokens for m in models] == [ + 1000000, 981504, 200000, 128000, 400000, 64000, 64000] + + async def test_client_fetch_models_metadata(): """倍率 / 上下文窗口 / 推理能力透传。""" def handler(_request: httpx.Request) -> httpx.Response: diff --git a/tests/regression/test_hardening_stream.py b/tests/regression/test_hardening_stream.py index 81185920..972f4928 100644 --- a/tests/regression/test_hardening_stream.py +++ b/tests/regression/test_hardening_stream.py @@ -84,6 +84,21 @@ async def fast(): assert [f async for f in with_keepalive(fast(), interval=5)] == [b"a", b"b"] + +@pytest.mark.asyncio +async def test_with_keepalive_uses_protocol_specific_frame(): + """心跳帧随出口协议走:Anthropic 的 ping 事件替换默认注释帧。""" + ping = b'event: ping\ndata: {"type": "ping"}\n\n' + + async def slow(): + await asyncio.sleep(0.05) + yield b"data: x\n\n" + + frames = [f async for f in with_keepalive(slow(), interval=0.01, + keepalive_frame=ping)] + assert ping in frames + assert SSE_COMMENT not in frames + @pytest.mark.asyncio async def test_stream_disconnect_records_usage(): """客户端断开时把已知用量记账(client_disconnect),不再静默丢弃。 From fbe04e4094d2515b1cea55d3e677a9fd50b73b59 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Fri, 23 Oct 2026 18:12:46 +0800 Subject: [PATCH 004/153] =?UTF-8?q?docs:=20=E8=A1=A5=20NONSTREAM=5FKEEPALI?= =?UTF-8?q?VE=5FSECONDS=20=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E8=A1=A8?= =?UTF-8?q?=E4=B8=8E=E4=BF=9D=E6=B4=BB=E6=8E=A5=E7=BA=BF=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README / README.en 环境变量表补一行(默认 0 = 关闭,CDN 后推荐 15) - TECHNICAL §3.2 补 QUEUE 分类与排队语义,§3.8 补非流式保活设计 - 测试补出口接线三例:默认关、开启但快、开启且超宽限期 --- README.en.md | 1 + README.md | 1 + TECHNICAL.md | 19 ++++++ tests/api/test_nonstream_keepalive.py | 89 +++++++++++++++++++++++++++ 4 files changed, 110 insertions(+) diff --git a/README.en.md b/README.en.md index 1f1b7edd..398cf5f5 100644 --- a/README.en.md +++ b/README.en.md @@ -668,6 +668,7 @@ Full list in `src/config.py` (authoritative); every item is wired through `docke | `STREAM_FIRST_EVENT_TIMEOUT_SECONDS` | `0` | First-event timeout (retryable before any frame). **Keep off by default**, recommended 60. Hot-reloadable | | `STREAM_IDLE_TIMEOUT_SECONDS` | `0` | Idle timeout (closes, never retries), recommended 300. Hot-reloadable | | `SHORT_COOLDOWN_WAIT_SECONDS` | `0` | Wait when the whole pool is in short cooldown; `0` = fail fast. Hot-reloadable | +| `NONSTREAM_KEEPALIVE_SECONDS` | `0` | Non-streaming responses switch to chunked with whitespace heartbeats after N seconds (leading whitespace is legal JSON); `0` = off, only needed behind a CDN, recommended 15. Hot-reloadable | | `MODEL_ALIASES` | `""` | Custom aliases; empty = off. Hot-reloadable | | `MAX_INFLIGHT_PER_CREDENTIAL` | `0` | Per-account in-flight cap; `0` = unlimited. 600s TTL backstop | | `METRICS_ENABLED` | `true` | Expose `/metrics` (unauthenticated). Hot-reloadable | diff --git a/README.md b/README.md index ad260a4f..39b0cad3 100644 --- a/README.md +++ b/README.md @@ -670,6 +670,7 @@ python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 -- | `STREAM_FIRST_EVENT_TIMEOUT_SECONDS` | `0` | 首事件超时(出帧前可换号)。**默认必须保持关闭**,推荐 60。可热更 | | `STREAM_IDLE_TIMEOUT_SECONDS` | `0` | 流内空闲超时(已出帧只断流不换号),推荐 300。可热更 | | `SHORT_COOLDOWN_WAIT_SECONDS` | `0` | 全池短冷却时等待续上;`0` = 快速失败。可热更 | +| `NONSTREAM_KEEPALIVE_SECONDS` | `0` | 非流式出口 N 秒未完成则转 chunked 吐空白心跳(JSON 前导空白合法);`0` = 关闭,仅在 CDN 后需要,推荐 15。可热更 | | `MODEL_ALIASES` | `""` | 自定义别名;留空不启用。可热更 | | `MAX_INFLIGHT_PER_CREDENTIAL` | `0` | 单账号在途上限;`0` = 不限。600s TTL 兜底 | | `METRICS_ENABLED` | `true` | 是否暴露 `/metrics`(不鉴权)。可热更 | diff --git a/TECHNICAL.md b/TECHNICAL.md index c162daeb..823044ef 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -98,12 +98,23 @@ class ErrKind(StrEnum): CONCURRENCY = "concurrency" # 并发会话打满 → 固定 60s 模型级短冷却,不翻倍 BLOCKED = "blocked" # 该账号无此模型(400/404+11102)→ (凭证, 模型) 负缓存,6h 起翻倍封顶 24h REQUEST = "request" # 请求级错误(11101/11115/11128/11135)→ 零动作:不冷却、不累计,仅换号 + QUEUE = "queue" # 排队中(isQueued / RESOURCE_LIMIT)→ 零动作:等 retryAfterSeconds 后原号重开 MODEL_SCOPED_KINDS = frozenset({MODEL, BLOCKED, CONCURRENCY}) ``` `MODEL_SCOPED_KINDS` 只写 `credential_model_cooldowns`(§6.3),不碰账号级 `cooling_until`。反之账号级冷却出现时清空该凭证的模型级条目,否则切模型能绕过账号级限流。 +**排队语义(QUEUE)**:上游明确回「排队中 + 建议等待秒数」时,既不是账号故障也不是请求错误,而是「这个号现在满了,等 N 秒再打它」。处理口径: + +- **不冷却**:`scheduler.note_error` 对 QUEUE 零动作(与 REQUEST 同); +- **不换号**:`executor` 用 `pinned` 记住被点名的凭证,等完在原号上重开; +- **不占轮换预算**:排队轮次不计入 `attempts`,否则三次排队就把 `max_rotate` 吃满、请求直接 503; +- **有界**:`QUEUE_MAX_ROUNDS = 3` 轮、单轮封顶 `QUEUE_MAX_WAIT_SECONDS = 60s`; +- **已出帧不重开**:半截输出不可回滚,降级为 SOFT 收尾。 + +识别在 `provider.base.parse_queue_hint`(信封可嵌套,最多下钻 3 层):`isQueued: true` / `subCode ∈ {RESOURCE_LIMIT, RESOURCE_LIMIT_EXCEEDED}` 任一成立即认定。**宁可漏判不可误判**——识别不出就走原有分类,绝不把普通失败当排队无限等。Qoder 的 `_decode_frame` 必须在 `classify_error_code` **之前**拦下:上游把排队也包成 403,按状态码分类会判成 DEAD 硬禁用凭证。`Event.retry_after_seconds` 承载上游点名的秒数。 + | 上游信号 | CB | TRAE | Kilo | ErrKind | |---|---|---|---|---| | 权益耗尽 | `code=1005` | `code:1005` | — | PLAN | @@ -117,6 +128,7 @@ MODEL_SCOPED_KINDS = frozenset({MODEL, BLOCKED, CONCURRENCY}) | 会话失效 | 401/403 | 401 | — | DEAD | | 服务端错误 | 5xx | 5xx | 500 | OTHER | | 请求自身无效 | 400 | 400(含 4001) | 400/404/422 | INVALID | +| 排队中 | — | — | — | QUEUE(Qoder `isQueued` / `RESOURCE_LIMIT`) | | 流内错误事件 | SSE error 事件 | `event:error` 业务码 | `{"error":{...}}` 帧 | 同上映射(单一来源 `Event.error_kind`,缺失回落 OTHER) | - **zen / kilo 的 401 → `INVALID`**:两者无凭证,`401 Missing API key.` 只说明该模型要付费 key;归 `DEAD` 会让一次强制付费请求硬禁用整条免费渠道。 @@ -218,6 +230,13 @@ def health_score(q: Quota | None) -> int | None: **心跳**:`StreamSink.keepalive()` 由出口协议各自实现,`dispatch_compat` 把它交给 `with_keepalive` 作空闲填充帧。Anthropic 返回具名事件 `event: ping` + `data: {"type": "ping"}`(官方规范;只解析 `event:` 行的客户端会忽略注释帧);chat / Responses 返回 SSE 注释帧 `: keepalive`——Responses **不能**改 ping,该协议无此事件类型,严格解析器遇未知事件直接报错。 +**非流式保活**(`NONSTREAM_KEEPALIVE_SECONDS`,默认 0 = 关闭):`stream=false` 的请求在聚合完成前一个字节都不发,CDN / 反代的空闲超时会直接切连接。`api/streaming.py::respond_with_keepalive` 采用**宽限期**设计——先等 N 秒: + +- 期间完成 → 返回普通 `JSONResponse`,状态码 / `Content-Length` / 字节内容与不开保活**完全一致**,异常照常抛给异常处理器(400/503 不变); +- 超时 → 才提交 200 + chunked,先吐 `b"\n"` 再补正文。 + +空白字节合法:RFC 8259 §2 规定 `JSON-text = ws value ws`,值前允许任意空白,故 `"\n\n{...}"` 仍是合法 JSON(Python `json.loads` / JS `JSON.parse` 均已实测接受)。**为什么不一开始就用 `StreamingResponse`**:那样 200 在聚合前就发出去了,之后任何失败都改不回来;宽限期把「改不回来」的窗口压到只有真正超时的请求。代价:提交 200 之后才失败只能把错误写进正文(`LATE_FAILURE_PAYLOAD`),因此**默认关闭**——本部署 nginx `proxy_read_timeout 3600s` + frp TCP,链路上没有短空闲超时,30 天实测 >60s 的成功请求 1741 个全部成功。 + **usage 缓存字段**:内部 `input_tokens` 是 OpenAI 口径(含缓存命中),Anthropic 语义互斥。故命中时 `input_tokens = max(0, prompt_tokens − cached_tokens)` 并另记 `cache_read_input_tokens`;命中为 0/None 不补占位。 **鉴权**:先 `x-api-key`(`ANTHROPIC_API_KEY`),空则回落 `Authorization: Bearer`(`ANTHROPIC_AUTH_TOKEN`),共用 `_api_key_principal`。Base URL 填到根,SDK 自拼 `/v1/messages`。`count_tokens` 用本地启发式估算,不转发。 diff --git a/tests/api/test_nonstream_keepalive.py b/tests/api/test_nonstream_keepalive.py index c7c5aa18..381c10ae 100644 --- a/tests/api/test_nonstream_keepalive.py +++ b/tests/api/test_nonstream_keepalive.py @@ -270,6 +270,95 @@ def test_dumps_matches_json_response_rendering(): assert _dumps(payload) == JSONResponse(payload).render(payload) +# ------------------------------------------------------------ 出口接线 + + +def _client(tmp_path, keepalive: float, script=None): + from fastapi.testclient import TestClient + + from src.config import Settings + from src.main import build_app + from tests.helpers.constants import SECRET + from tests.helpers.events import TRAE_GOOD + from tests.helpers.providers import ScriptedProvider + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root", + nonstream_keepalive_seconds=keepalive) + app = build_app(settings, + providers={"trae": ScriptedProvider(script or [TRAE_GOOD])}) + return TestClient(app) + + +def _headers(client) -> dict[str, str]: + client.app.state.credentials.add(provider="trae", credential_data={"accessToken": "a"}) + created = client.app.state.api_keys.create("root", "test") + return {"Authorization": f"Bearer {created['api_key']}"} + + +def test_endpoint_keepalive_disabled_by_default(tmp_path): + """默认 0 = 关闭:走原来的 `JSONResponse` 分支(有 Content-Length)。""" + with _client(tmp_path, 0) as client: + headers = _headers(client) + response = client.post("/v1/chat/completions", headers=headers, + json={"messages": [{"role": "user", "content": "hi"}], + "model": "glm-5.2"}) + assert response.status_code == 200 + assert "content-length" in response.headers + assert response.json()["choices"][0]["message"]["content"] == "hi" + + +def test_endpoint_keepalive_enabled_fast_request_stays_plain(tmp_path): + """打开保活但请求很快完成:仍是普通 JSON,字节级不变。""" + with _client(tmp_path, 5) as client: + headers = _headers(client) + response = client.post("/v1/chat/completions", headers=headers, + json={"messages": [{"role": "user", "content": "hi"}], + "model": "glm-5.2"}) + assert response.status_code == 200 + assert "content-length" in response.headers + assert response.json()["choices"][0]["message"]["content"] == "hi" + + +def test_endpoint_keepalive_enabled_slow_request_streams(tmp_path): + """打开保活且超过宽限期:200 + chunked + 空白前导,正文仍是合法 JSON。""" + import asyncio as aio + + from tests.helpers.events import TRAE_GOOD + + class SlowProvider: + id = "trae" + + async def stream_chat(self, _credential_data, _payload, _model): + await aio.sleep(0.05) + for event in TRAE_GOOD: + yield event + + async def list_models(self, _credential_data): + return [] + + from fastapi.testclient import TestClient + + from src.config import Settings + from src.main import build_app + from tests.helpers.constants import SECRET + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root", nonstream_keepalive_seconds=0.01) + app = build_app(settings, providers={"trae": SlowProvider()}) + with TestClient(app) as client: + headers = _headers(client) + response = client.post("/v1/chat/completions", headers=headers, + json={"messages": [{"role": "user", "content": "hi"}], + "model": "glm-5.2"}) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/json") + assert response.headers["x-accel-buffering"] == "no" + assert "content-length" not in response.headers + assert response.content.startswith(JSON_KEEPALIVE_BYTES) + assert json.loads(response.content)["choices"][0]["message"]["content"] == "hi" + + def test_endpoint_uses_keepalive_when_enabled(tmp_path): """配置打开后非流式出口走保活包装;宽限期内完成仍返回普通 JSON。""" from fastapi.testclient import TestClient From c3b295da6fd2260378521d4074fd703e1ee32aea Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sat, 24 Oct 2026 23:12:01 +0800 Subject: [PATCH 005/153] =?UTF-8?q?fix(trae):=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=9B=9E=E8=B0=83=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1=EF=BC=88issue=20#21=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个成因都在「授权完成后没回调,也没报错」这条链上: 1. 回调基址指向浏览器打不开的主机。PUBLIC_BASE_URL 默认 http://127.0.0.1:8000,远程 / compose 部署忘改时回调跳到用户自己的 机器。现在基址落在回环 / 0.0.0.0 时改用当前请求的 origin(LAN IP 或 隧道域名,采信 X-Forwarded-Proto);请求已过 host_allowed 白名单。 2. 回调落在另一条入口时浏览器不带管理台 Cookie(LAN 发起、隧道回调), 原先一律 400。现在 Cookie 不可用时按 loginTraceID 反查发起人:trace 是 (machine_id + device_id)[-16:],只出现在发起那次 auth_url 里, 仍能把回调钉死在那一次 pending 登录上,归属也仍是发起人。 3. 浏览器拿到一坨 JSON 等于看不出发生了什么。Accept: text/html 的顶层 导航改回中文说明页(成功 / 失败原因),程序化客户端契约不变。 前端把实际回调地址透出到登录提示,超时提示点名检查 PUBLIC_BASE_URL。 测试:新增 7 个用例(基址三条分支、无 Cookie 按 trace 绑定、畸形 pending、TTL 过期、HTML 成功/失败页),后端 3844 passed / 100%, 前端 tsc + 643 vitest + build 全绿;本机重启后端实测 /authorize 返回 400 中文说明页。文档同步 README / TECHNICAL / PROPOSAL。 --- PROPOSAL.md | 4 +- README.md | 3 +- TECHNICAL.md | 2 +- src/api/admin_auth.py | 47 ++++- src/api/authorize.py | 110 +++++++++-- tests/api/test_authorize.py | 172 ++++++++++++++++++ web/src/api/types.ts | 3 + web/src/components/AddCredentialDialog.tsx | 12 +- .../__tests__/CredentialsPage.login.test.tsx | 39 ++++ 9 files changed, 367 insertions(+), 25 deletions(-) diff --git a/PROPOSAL.md b/PROPOSAL.md index c6b0e213..50c95b68 100644 --- a/PROPOSAL.md +++ b/PROPOSAL.md @@ -143,8 +143,8 @@ Provider 承担上游私有部分:发请求、解析事件、分类错误、 `AuthFlow` 三值:`poll`(CB / Qoder)、`callback`(TRAE,302 回 `/authorize`)、`paste`(CodeArts,服务端监听不到本机回环端口)。 -- 回调统一走主端口 `/authorize`,远程部署只需暴露一个端口。回调地址写进登录 URL,故必须可配 `PUBLIC_BASE_URL`。 -- 前端在「凭证管理」页内实现:**poll** 拿 `authUrl` 开新窗、轮询 `/api/auth/upstream/poll`;**callback** 开授权窗、302 回 `/authorize` 落库,前端轮询列表出现新条目即完成。`/authorize` 无鉴权,故按**发起人会话** + **`loginTraceID`** 双重绑定,待完成登录有 TTL(10 分钟)。 +- 回调统一走主端口 `/authorize`,远程部署只需暴露一个端口。回调地址写进登录 URL,故必须可配 `PUBLIC_BASE_URL`;基址是回环 / `0.0.0.0`(默认值)时改用当前请求的 origin,远程与容器部署忘配也不会「授权完成却不回调」。 +- 前端在「凭证管理」页内实现:**poll** 拿 `authUrl` 开新窗、轮询 `/api/auth/upstream/poll`;**callback** 开授权窗、302 回 `/authorize` 落库,前端轮询列表出现新条目即完成(发起时把实际回调地址写进提示,超时提示指向 `PUBLIC_BASE_URL`)。`/authorize` 无鉴权,故按**发起人会话** + **`loginTraceID`** 双重绑定(Cookie 不在回调这条入口时按 trace 反查发起人),待完成登录有 TTL(10 分钟);浏览器收到的是一张中文说明页而非 JSON。 ### 3.6 中立事件层 diff --git a/README.md b/README.md index 39b0cad3..00a94bfa 100644 --- a/README.md +++ b/README.md @@ -585,7 +585,7 @@ python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 -- | `APP_SECRET` | **必填** | 凭证加密密钥,≥16 字符;丢失 = 凭证全部作废。拒绝低熵值 | | `ADMIN_USERNAMES` | 空 | 仅引导期:逗号分隔,首次启动提权为 `admin` | | `USERS_FILE` | `secrets/users.txt` | 仅引导期:老式用户文件,启动时一次性导入 | -| `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` | 浏览器可达地址;TRAE 登录回调用;https 时下发 HSTS。不决定会话 Cookie 的 `Secure` | +| `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` | 浏览器可达地址;TRAE 登录回调用;https 时下发 HSTS。不决定会话 Cookie 的 `Secure`。配成回环 / `0.0.0.0` 时回调基址改用当前请求的 origin(远程与容器部署忘改也能回调,见「部署注意」) | | `DATA_DIR` | `./data` | SQLite 与运行数据目录 | | `DEFAULT_MODEL` | `deepseek-v4.1-flash` | 模型为空 / `auto` 时的默认 | | `QUOTA_PROBE_MINUTES` | `60` | 额度探测周期(下限 1) | @@ -694,6 +694,7 @@ python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 -- ``` - **时区**:镜像默认 `TZ=Asia/Shanghai`,`.env` 里的 `TZ` 会透传。签到 / 额度周期按「今天 9 点」判断,务必与宿主一致。 +- **TRAE 登录回调地址**:默认 `PUBLIC_BASE_URL=http://127.0.0.1:8000` 只在本机访问时可用。远程 / 容器部署请改成浏览器能打开的地址;**忘记改也不会静默失败**——基址落在回环 / `0.0.0.0` 时,回调改用当前请求的 origin(LAN IP 或隧道域名),并在「添加凭证」对话框里显示实际回调地址,授权超时也会点名检查 `PUBLIC_BASE_URL`。 - **端口**:compose 里 `PORT` 同时决定容器内监听端口与宿主机映射端口。 - **改代码后重建镜像**:`docker compose up -d` 不带 `--build` 会用本地已有镜像,跑旧代码。改 `src/` 或前端后务必 `docker compose up -d --build`。 diff --git a/TECHNICAL.md b/TECHNICAL.md index 823044ef..9686b34f 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -846,7 +846,7 @@ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/users - **同步 sqlite3 而非 aiosqlite**:本地微秒级操作,asyncio 封装开销大于收益。 - **双 httpx 客户端**:聊天流 `read=None` 防长流截断;短请求总超时 30s 防悬挂;共享 `trust_env=False`。非流式由引擎聚合同一流式上游,悬挂兜底是 `UPSTREAM_COMPLETE_TIMEOUT_SECONDS`(默认 600s,超时按瞬态错误换号)与事件条数上限(`complete_max_events`)。 - **手写 SQL 而非 ORM**:11 张表规模下 ORM 收益为负。 -- **polling OAuth 不转回调**:上游协议决定;TRAE 回调走主端口 + `PUBLIC_BASE_URL`。`/authorize` 无鉴权,防滥用三条:无进行中登录一律拒绝;pending 600s TTL;回调必须带匹配的 `loginTraceID`。**刻意不用 Origin/Referer 判同源**(TRAE 回调本就跨源 302,且 `no-referrer` 可绕过)。`app.state` 只保留 pending 的 state。 +- **polling OAuth 不转回调**:上游协议决定;TRAE 回调走主端口 + `PUBLIC_BASE_URL`(基址是回环 / `0.0.0.0` 时改用请求自身 origin,避免远程部署忘配时回调指向用户自己的机器)。`/authorize` 无鉴权,防滥用三条:无进行中登录一律拒绝;pending 600s TTL;回调必须带匹配的 `loginTraceID`。绑定优先用会话 Cookie(发起人),Cookie 不在回调这条入口(LAN 发起 / 隧道回调)时退回按 `loginTraceID` 反查发起人——trace 只出现在发起那次 auth_url 里,等价于一次性凭证。浏览器顶层导航(`Accept: text/html`)返回中文说明页(成功 / 失败原因),程序化客户端仍拿 JSON。**刻意不用 Origin/Referer 判同源**(TRAE 回调本就跨源 302,且 `no-referrer` 可绕过)。`app.state` 只保留 pending 的 state。 - **Responses / Anthropic 出口只做各自客户端子集**:不做 `store=true` / `previous_response_id`;`include=["reasoning.encrypted_content"]` 接受并忽略;不发 `[DONE]`。**未经真实 Codex CLI 端到端验证**。 - **effort 映射默认不做,但缺省补值 + 可选机制**:`setdefault` 补 `medium`,只补缺省不覆盖显式值;内置默认矩阵不做,改为默认关闭的三项机制(§3.11)。 - **Zen / Kilo 用虚拟凭证行而非特判**:调度 / 冷却 / 统计 / 粘性全按 `credentials` 行工作,种一条空凭证比在每个环节加「无凭证 provider」分支代价小。`probe_quota` 恒 `probe_failed=True`。种子只在默认装配路径且幂等;删除后重启复活是刻意的。 diff --git a/src/api/admin_auth.py b/src/api/admin_auth.py index 3a5d76ff..7e4c63a2 100644 --- a/src/api/admin_auth.py +++ b/src/api/admin_auth.py @@ -36,9 +36,48 @@ ACTIVATION_TTL_SECONDS = 24 * 3600 -def resolve_public_callback_url(settings: Settings) -> str: - """PUBLIC_BASE_URL + /authorize(远程部署必须可被浏览器访问)。""" - return settings.public_base_url.rstrip("/") + "/authorize" +# 浏览器打不开的基址主机:回环与通配监听。PUBLIC_BASE_URL 默认值就是 +# 127.0.0.1:8000,远程/容器部署忘了改时,回调会跳到**用户自己这台机器**, +# 浏览器既回不到服务也不报错(issue #21)。 +_UNREACHABLE_BASE_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "0.0.0.0"}) + + +def _host_of(authority: str) -> str: + """从 `host[:port]` / `scheme://host[:port]/path` 取出小写主机名。""" + text = authority.strip().split("://", 1)[-1].split("/", 1)[0] + if text.startswith("["): + return text.partition("]")[0].lstrip("[").lower() + return text.split(":", 1)[0].lower() + + +def _request_origin(request: Request) -> str: + """当前请求的浏览器 origin(采信反代/隧道的 X-Forwarded-Proto)。""" + proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower() + if proto not in ("http", "https"): + proto = request.url.scheme + authority = request.headers.get("host", "") or request.url.netloc + return f"{proto}://{authority}" + + +def resolve_public_callback_url(settings: Settings, + request: Request | None = None) -> str: + """回调基址 + /authorize。 + + `PUBLIC_BASE_URL` 是权威值,但它有两个已知的「静默失效」形态: + 远程部署忘了改(默认 `http://127.0.0.1:8000` 指向用户自己的机器)、 + docker-compose 只透传了默认值。这两种情况下回调地址由**浏览器**打开, + 必然是坏的,而用户只会看到「授权完成但没回调」。 + + 因此基址落在回环/通配主机、而当前请求来自别的主机时,改用请求自身的 + origin:能打到本服务的那个地址,浏览器同样能打到。请求已通过 + `host_allowed` 白名单,故这里采信 Host 头不会放大攻击面。 + """ + base = settings.public_base_url.rstrip("/") + if request is not None and _host_of(base) in _UNREACHABLE_BASE_HOSTS: + request_host = _host_of(request.headers.get("host", "") or request.url.netloc) + if request_host and request_host not in _UNREACHABLE_BASE_HOSTS: + base = _request_origin(request).rstrip("/") + return base + "/authorize" def issue_session(response: JSONResponse, services: Services, username: str, *, @@ -169,7 +208,7 @@ async def upstream_auth_start(request: Request, payload: dict, builder = getattr(provider, "start_auth", None) if not callable(builder): raise InvalidRequest(f"provider {provider_id!r} does not support login") - session = builder(resolve_public_callback_url(services.settings)) + session = builder(resolve_public_callback_url(services.settings, request)) # 按发起人分键登记待完成回调:单槽全局态会让两个管理员的 # 并发登录互相覆盖,把 A 的凭证记到 B 名下;按用户名分键后, # 同网段的人也无法用自己的 refreshToken 顶掉别人进行中的登录。 diff --git a/src/api/authorize.py b/src/api/authorize.py index 650c38ee..e66e2593 100644 --- a/src/api/authorize.py +++ b/src/api/authorize.py @@ -3,9 +3,10 @@ from __future__ import annotations import time +from html import escape from fastapi import APIRouter, Request -from fastapi.responses import JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse from ..auth.session import SESSION_COOKIE, verify_session_token from ..compat.openai.errors import error_payload @@ -19,6 +20,70 @@ # 10 分钟足够走完一次浏览器登录。 PENDING_CALLBACK_TTL_SECONDS = 600 +# 浏览器回调落在 JSON 上会被当成「什么也没发生」:用户看不到成功也看不到 +# 失败原因(issue #21 的「不回调,也没报错」)。顶层导航带 `Accept: text/html` +# 时改回一张说明页,程序化客户端仍拿到原来的 JSON。 +_RESULT_PAGE = """ + + + + +{title} · Coding2API + + +

{title}

{message}

+""" + + +def _wants_html(request: Request) -> bool: + """浏览器顶层导航要人读的页面,程序化客户端(无 Accept 或 */*)继续拿 JSON。""" + return "text/html" in request.headers.get("accept", "") + + +def _page(status_code: int, title: str, message: str) -> HTMLResponse: + return HTMLResponse( + _RESULT_PAGE.format(title=escape(title), message=escape(message)), + status_code=status_code) + + +def _failure(request: Request, message: str, hint: str, *, + code: str = "invalid_request") -> JSONResponse | HTMLResponse: + """失败响应:JSON 契约给程序,中文原因给人(浏览器只看得到后者)。""" + if _wants_html(request): + return _page(400, "TRAE 登录未完成", hint) + return JSONResponse(status_code=400, content=error_payload(message, code, 400)) + + +def _pending_by_trace(pending: dict[str, tuple[float, str]], + trace: str) -> tuple[str, tuple[float, str]] | None: + """按 loginTraceID 反查发起人。 + + 管理台可能同时有 LAN 直连与 https 隧道两条入口:在 A 主机登录、回调 + 落在 B 主机时浏览器**不会**带 A 的会话 Cookie。`loginTraceID` 是 + `(machine_id + device_id)[-16:]`、只出现在发起那次登录的 auth_url 里, + 它本身就是一次性凭证,据此仍能把回调钉死在**那一次** pending 登录上。 + """ + if not trace: + return None + for username, entry in pending.items(): + machine_id, _, device_id = entry[1].partition(":") + if machine_id and device_id and login_trace_id(machine_id, device_id) == trace: + return username, entry + return None + def create_router(services: Services) -> APIRouter: router = APIRouter() @@ -41,7 +106,8 @@ async def authorize(request: Request): - **loginTraceID**(主):TRAE 原样回显 `login_trace_id`,值是 `(machine_id + device_id)[-16:]`,只有发起登录那次拿得到; - **发起人会话**(辅):回调是顶层浏览器导航,SameSite=Lax 会带上 - 管理台的会话 Cookie,据会话用户名找到其待完成登录。 + 管理台的会话 Cookie,据会话用户名找到其待完成登录;Cookie 缺失 + (例如 LAN 发起、隧道回调)时退回按 loginTraceID 反查发起人。 这样: - 两个管理员并发发起登录不会互相覆盖(各按用户名分键); @@ -60,8 +126,8 @@ async def authorize(request: Request): provider = services.registry.get("trae") pending: dict[str, tuple[float, str]] = request.app.state.pending_callbacks if provider is None: - return JSONResponse(status_code=400, content=error_payload( - "no pending TRAE login in progress", "invalid_request", 400)) + return _failure(request, "no pending TRAE login in progress", + "服务端没有启用 TRAE 渠道,无法完成登录。") # 会话 Cookie → 发起人(回调为顶层导航,Lax Cookie 会带上)。 # 与 deps.principal_from_request 同一套活跃性校验:签名有效还不够, # 发起人必须仍存在且未被禁用、会话 epoch 未被改密/禁用作废, @@ -76,36 +142,50 @@ async def authorize(request: Request): username = candidate entry = pending.get(username) if username else None if entry is None: - return JSONResponse(status_code=400, content=error_payload( - "no pending TRAE login in progress", "invalid_request", 400)) + # Cookie 不在回调这条入口上(LAN 发起 / 隧道回调):按 trace 反查。 + found = _pending_by_trace(pending, + request.query_params.get("loginTraceID") or "") + if found is None: + return _failure( + request, "no pending TRAE login in progress", + "没有进行中的 TRAE 登录(可能已过期,或不是在当前浏览器发起的)。" + "请回到管理台重新点击「登录 TRAE」。") + username, entry = found started_at, state = entry if time.monotonic() - started_at > PENDING_CALLBACK_TTL_SECONDS: pending.pop(username, None) - return JSONResponse(status_code=400, content=error_payload( - "pending TRAE login expired, start a new one", - "invalid_request", 400)) + return _failure( + request, "pending TRAE login expired, start a new one", + "这次登录已超过 10 分钟有效期,请回到管理台重新发起登录。") # 回调绑定:loginTraceID 必须等于本次 pending 登录的 trace id # (machine/device id 在 state 里)。缺失/不符一律拒绝——这才是 # 「只接受发起人本人那次登录的回调」,与 Referer 无关。 machine_id, _, device_id = state.partition(":") if request.query_params.get("loginTraceID") != login_trace_id(machine_id, device_id): - return JSONResponse(status_code=400, content=error_payload( - "callback does not match the pending login", "invalid_request", 400)) + return _failure( + request, "callback does not match the pending login", + "这次回调与发起登录的那一次不匹配,请回到管理台重新登录。") if not request.query_params.get("refreshToken") and not request.query_params.get("userJwt"): - return JSONResponse(status_code=400, content=error_payload( - "callback missing refreshToken", "invalid_request", 400)) + return _failure( + request, "callback missing refreshToken", + "回调参数里没有 refreshToken,TRAE 没有把登录结果带回来,请重新登录。") # 先消费 pending,防止并发回调重复兑换同一登录 pending.pop(username, None) try: credential_data = await provider.complete_callback(raw, state) except UpstreamProtocolViolation as error: - return JSONResponse(status_code=400, content=error_payload( - error.public_message, "invalid_credential", 400)) + return _failure( + request, error.public_message, + f"换取 TRAE 凭证失败:{error.public_message}", + code="invalid_credential") credential_id = services.credentials.add( provider="trae", credential_data=credential_data, nickname=str(credential_data.get("nickname") or ""), added_by=username) services.schedule_probe(credential_id) + if _wants_html(request): + return _page(200, "TRAE 登录成功", + "凭证已保存,可以关闭此页面回到管理台。") return {"ok": True, "captured": True, "at": int(time.time())} return router diff --git a/tests/api/test_authorize.py b/tests/api/test_authorize.py index 99ec1851..f53ad2e0 100644 --- a/tests/api/test_authorize.py +++ b/tests/api/test_authorize.py @@ -6,6 +6,7 @@ import pytest from fastapi.testclient import TestClient +from starlette.requests import Request as StarletteRequest from src.config import Settings from src.main import build_app @@ -352,3 +353,174 @@ def test_authorize_without_trae_provider_is_rejected(tmp_path): response = client.get("/authorize?refreshToken=RT") assert response.status_code == 400 assert response.json()["error"]["code"] == "invalid_request" + + +# ---------------------------------------------------------------- 回调可发现性 +# issue #21:「授权完成后不回调,也没报错」。两个成因都在这里固定: +# 回调基址指向浏览器打不开的主机(默认 127.0.0.1)、回调落在另一条入口而 +# 浏览器不带管理台 Cookie;以及浏览器只拿到 JSON 看不出发生了什么。 + + +def _request(host: str, *, scheme: str = "http", + xfp: str | None = None) -> StarletteRequest: + """构造一个最小 ASGI Request(只用到 host / scheme / 转发头)。""" + headers = [(b"host", host.encode())] + if xfp is not None: + headers.append((b"x-forwarded-proto", xfp.encode())) + return StarletteRequest({ + "type": "http", "method": "GET", "path": "/api/auth/upstream/start", + "query_string": b"", "root_path": "", "scheme": scheme, + "server": (host.split(":", 1)[0], 8000), "headers": headers, + }) + + +def test_resolve_callback_url_uses_request_origin_for_unreachable_base(): + """PUBLIC_BASE_URL 还是默认回环时,回调改用手上这条入口的 origin。 + + 默认值 http://127.0.0.1:8000 在远程/容器部署里指向**用户自己的机器**, + 浏览器跳过去连不上,授权完成也回不来。 + """ + from src.api.admin_auth import resolve_public_callback_url + + settings = Settings(_env_file=None, APP_SECRET=SECRET, + PUBLIC_BASE_URL="http://127.0.0.1:8000") + # 从 LAN 地址打开管理台 → 回调用 LAN 地址 + assert resolve_public_callback_url(settings, _request("192.168.1.5:8000")) == \ + "http://192.168.1.5:8000/authorize" + # 隧道入口:采信 X-Forwarded-Proto + assert resolve_public_callback_url( + settings, _request("ai.example.com", xfp="https")) == \ + "https://ai.example.com/authorize" + # 非法 XFP 回落 socket 层协议 + assert resolve_public_callback_url( + settings, _request("ai.example.com", xfp="ftp")) == \ + "http://ai.example.com/authorize" + # 通配监听同样不可达 + wildcard = Settings(_env_file=None, APP_SECRET=SECRET, + PUBLIC_BASE_URL="http://0.0.0.0:8000") + assert resolve_public_callback_url(wildcard, _request("10.0.0.2:8000")) == \ + "http://10.0.0.2:8000/authorize" + + +def test_resolve_callback_url_keeps_configured_base(): + """本机访问、或显式配了可达基址时,不为请求 origin 让路。""" + from src.api.admin_auth import resolve_public_callback_url + + loopback = Settings(_env_file=None, APP_SECRET=SECRET, + PUBLIC_BASE_URL="http://127.0.0.1:8000/") + assert resolve_public_callback_url(loopback, _request("127.0.0.1:8000")) == \ + "http://127.0.0.1:8000/authorize" + assert resolve_public_callback_url(loopback, _request("[::1]:8000")) == \ + "http://127.0.0.1:8000/authorize" + explicit = Settings(_env_file=None, APP_SECRET=SECRET, + PUBLIC_BASE_URL="https://gw.example") + assert resolve_public_callback_url(explicit, _request("192.168.1.5:8000")) == \ + "https://gw.example/authorize" + + +def _mock_trae() -> TraeProvider: + import httpx as _httpx + + from src.provider.trae.client import TraeClient + + def handler(request: _httpx.Request) -> _httpx.Response: + if request.url.path.endswith("ExchangeToken"): + return _httpx.Response(200, json={"Result": {"Token": "ACCESS", + "RefreshToken": "RT2"}}) + return _httpx.Response(200, json={"Result": {"UserID": "u1", "ScreenName": "n"}}) + + transport = _httpx.MockTransport(handler) + return TraeProvider(client=TraeClient( + stream_client=_httpx.AsyncClient(transport=transport, timeout=None), + short_client=_httpx.AsyncClient(transport=transport, timeout=None))) + + +def test_authorize_binds_callback_by_trace_without_session_cookie(tmp_path): + """回调落在另一条入口时不带管理台 Cookie,靠 loginTraceID 仍能完成。 + + 同一实例常有 LAN 与隧道两条入口:在 LAN 发起、隧道回调时浏览器不会带 + LAN 域的会话 Cookie。trace 只出现在发起那次 auth_url 里,足以钉死登录。 + """ + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + + with TestClient(app) as client: + client.post("/api/auth/login", json={"username": "root", "password": "rootpw"}) + started = client.post("/api/auth/upstream/start", json={"provider": "trae"}).json() + trace = login_trace_id(*started["state"].split(":")) + + client.cookies.clear() # 换一条入口:浏览器没有管理台会话 + response = client.get(f"/authorize?refreshToken=RT1&loginTraceID={trace}") + assert response.status_code == 200, response.text + assert response.json()["captured"] is True + assert "root" not in app.state.pending_callbacks + + listed = app.state.services.credentials.list_all() + assert len(listed) == 1 + assert listed[0]["added_by"] == "root" # 归属仍是发起登录的管理员 + + +def test_authorize_ignores_malformed_pending_entries_when_matching_trace(tmp_path): + """pending 里的 state 损坏(缺 device_id)时按 trace 反查直接跳过。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + app.state.pending_callbacks["root"] = (time.monotonic(), "broken") + response = client.get("/authorize?refreshToken=RT&loginTraceID=abc") + assert response.status_code == 400 + assert response.json()["error"]["code"] == "invalid_request" + + +def test_authorize_rejects_expired_pending_login(tmp_path): + """pending 超过 TTL:作废并清槽,提示重新发起(不再无限期挂着)。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + client.post("/api/auth/login", json={"username": "root", "password": "rootpw"}) + started = client.post("/api/auth/upstream/start", json={"provider": "trae"}).json() + trace = login_trace_id(*started["state"].split(":")) + app.state.pending_callbacks["root"] = (time.monotonic() - 601, started["state"]) + response = client.get(f"/authorize?refreshToken=RT&loginTraceID={trace}") + assert response.status_code == 400 + assert response.json()["error"]["message"] == "pending TRAE login expired, start a new one" + assert "root" not in app.state.pending_callbacks + + +def test_authorize_returns_html_page_for_browser_navigation(tmp_path): + """浏览器顶层导航(Accept: text/html)拿到说明页,不是一坨 JSON。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + + with TestClient(app) as client: + client.post("/api/auth/login", json={"username": "root", "password": "rootpw"}) + started = client.post("/api/auth/upstream/start", json={"provider": "trae"}).json() + trace = login_trace_id(*started["state"].split(":")) + response = client.get( + f"/authorize?refreshToken=RT1&loginTraceID={trace}", + headers={"accept": "text/html,application/xhtml+xml"}) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/html") + assert "TRAE 登录成功" in response.text + assert "可以关闭此页面回到管理台" in response.text + + +def test_authorize_browser_failure_page_states_the_reason(tmp_path): + """失败也要有中文原因——「没回调也没报错」的另一半在这里补齐。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path)) + app = build_app(settings) + with TestClient(app) as client: + # 没有任何登录进行中(且请求不带 trace) + response = client.get("/authorize?refreshToken=RT", + headers={"accept": "text/html"}) + assert response.status_code == 400 + assert response.headers["content-type"].startswith("text/html") + assert "没有进行中的 TRAE 登录" in response.text + + # 结构化失败:JSON 契约不变 + json_response = client.get("/authorize?refreshToken=RT") + assert json_response.json()["error"]["message"] == "no pending TRAE login in progress" diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 6f135bec..75e28227 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -404,6 +404,9 @@ export interface UpstreamAuthStart { state: string; auth_url: string | null; interval: number | null; + /** 回调轨道的落点(后端 PUBLIC_BASE_URL + /authorize);poll 轨道为 null。 + * 透出到界面是为了让「回调地址指错主机」这类静默失败可见。 */ + callback_url?: string | null; } export interface UpstreamAuthPoll { diff --git a/web/src/components/AddCredentialDialog.tsx b/web/src/components/AddCredentialDialog.tsx index 838ad79b..2ca8e3c1 100644 --- a/web/src/components/AddCredentialDialog.tsx +++ b/web/src/components/AddCredentialDialog.tsx @@ -136,14 +136,22 @@ export function AddCredentialDialog({ if (started.flow === "callback") { // TRAE:浏览器完成授权后 302 回本服务的 /authorize,那里直接落库。 // 前端无法轮询渠道,改为轮询凭证列表,出现新凭证即视为完成。 - onNotice("已在新标签页打开授权页,完成后本页自动刷新出凭证。"); + // 回调地址一并透出:它由服务端 PUBLIC_BASE_URL 决定,指错主机时 + // 浏览器会静默跳不回来(用户只看到「授权完成但没反应」)。 + onNotice(started.callback_url + ? `已在新标签页打开授权页,完成后本页自动刷新出凭证。` + + `若一直没有反应,请确认浏览器能打开回调地址 ${started.callback_url}。` + : "已在新标签页打开授权页,完成后本页自动刷新出凭证。"); const deadline = Date.now() + 5 * 60 * 1000; const baseline = credentialCountRef.current; const timer = window.setInterval(async () => { const after = (await api.credentials()).credentials.length; if (after > baseline || Date.now() > deadline) { stopPolling(provider); - onNotice(after > baseline ? "登录成功,凭证已保存。" : "授权超时,请重新登录。"); + onNotice(after > baseline + ? "登录成功,凭证已保存。" + : "授权超时:浏览器没有回到本服务。请检查授权后地址栏是否跳转到回调地址," + + "以及服务端 PUBLIC_BASE_URL 是否为浏览器可访问的地址。"); } }, 3000); loginTimersRef.current[provider] = timer; diff --git a/web/src/pages/__tests__/CredentialsPage.login.test.tsx b/web/src/pages/__tests__/CredentialsPage.login.test.tsx index 21240b2e..8dc9b489 100644 --- a/web/src/pages/__tests__/CredentialsPage.login.test.tsx +++ b/web/src/pages/__tests__/CredentialsPage.login.test.tsx @@ -181,6 +181,9 @@ describe("渠道登录入口", () => { await openAddDialog(); await userEvent.click(screen.getByTestId("start-login-trae")); expect(await screen.findByTestId("cancel-login-trae")).toBeInTheDocument(); + // 回调地址一并透出:指错主机时用户能自己看出「跳不回来」的原因 + expect(screen.getByTestId("credentials-notice")) + .toHaveTextContent("http://127.0.0.1:8000/authorize"); // 浏览器授权完成后服务端落库 → 下一次轮询发现凭证变多 credentialCount = 2; @@ -192,6 +195,42 @@ describe("渠道登录入口", () => { vi.useRealTimers(); }); + it("TRAE 回调超时:提示检查回调地址与 PUBLIC_BASE_URL", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.stubGlobal("open", () => null); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/upstream/start")) { + return jsonResponse({ + flow: "callback", + state: "machine:device", + auth_url: "https://trae.example/login", + interval: null, + callback_url: "https://gw.example/authorize", + }); + } + return jsonResponse(listBody([makeCredential({ id: "c0" })])); + }), + ); + + renderPage(, ADMIN); + await settle(); + await expandCredentialGroups(); + await openAddDialog(); + await userEvent.click(screen.getByTestId("start-login-trae")); + await screen.findByTestId("cancel-login-trae"); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 4000); + await waitFor(() => + expect(screen.getByTestId("credentials-notice")).toHaveTextContent("授权超时"), + ); + expect(screen.getByTestId("credentials-notice")) + .toHaveTextContent("PUBLIC_BASE_URL"); + vi.useRealTimers(); + }); + it("CodeArts 走 paste:展示粘贴框,提交回调链接完成登录", async () => { const opened: string[] = []; vi.stubGlobal("open", () => { From 3068ae772e25dcdb0c20c404241160336fd17d2a Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sat, 24 Oct 2026 23:20:21 +0800 Subject: [PATCH 006/153] =?UTF-8?q?fix(trae):=20=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E5=9F=BA=E5=9D=80=E5=88=A4=E5=AE=9A=E7=94=A8=20ipaddress?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=BC=80=20bandit=20B104=20=E8=AF=AF?= =?UTF-8?q?=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '0.0.0.0' 字面量在 bandit 眼里是「绑定全网卡」(B104),这里只是主机 字符串比对。改为 ipaddress 判定回环 / 未指定地址,同时把 ::(IPv6 通配) 也纳入,测试补 localhost 与非回环 IP 两条分支。 --- src/api/admin_auth.py | 18 +++++++++++++++--- tests/api/test_authorize.py | 10 ++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/api/admin_auth.py b/src/api/admin_auth.py index 7e4c63a2..c3a2b583 100644 --- a/src/api/admin_auth.py +++ b/src/api/admin_auth.py @@ -7,6 +7,7 @@ from __future__ import annotations import inspect +import ipaddress import time from fastapi import APIRouter, Depends, Request @@ -39,7 +40,7 @@ # 浏览器打不开的基址主机:回环与通配监听。PUBLIC_BASE_URL 默认值就是 # 127.0.0.1:8000,远程/容器部署忘了改时,回调会跳到**用户自己这台机器**, # 浏览器既回不到服务也不报错(issue #21)。 -_UNREACHABLE_BASE_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "0.0.0.0"}) +_LOCALHOST_NAMES = frozenset({"localhost"}) def _host_of(authority: str) -> str: @@ -50,6 +51,17 @@ def _host_of(authority: str) -> str: return text.split(":", 1)[0].lower() +def _browser_cannot_reach(host: str) -> bool: + """回环 / 通配监听:浏览器打开它到不了本服务(或到不了发请求的那个人)。""" + if host in _LOCALHOST_NAMES: + return True + try: + address = ipaddress.ip_address(host) + except ValueError: # 普通域名 / 无效字面量:按可达处理 + return False + return address.is_loopback or address.is_unspecified + + def _request_origin(request: Request) -> str: """当前请求的浏览器 origin(采信反代/隧道的 X-Forwarded-Proto)。""" proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower() @@ -73,9 +85,9 @@ def resolve_public_callback_url(settings: Settings, `host_allowed` 白名单,故这里采信 Host 头不会放大攻击面。 """ base = settings.public_base_url.rstrip("/") - if request is not None and _host_of(base) in _UNREACHABLE_BASE_HOSTS: + if request is not None and _browser_cannot_reach(_host_of(base)): request_host = _host_of(request.headers.get("host", "") or request.url.netloc) - if request_host and request_host not in _UNREACHABLE_BASE_HOSTS: + if request_host and not _browser_cannot_reach(request_host): base = _request_origin(request).rstrip("/") return base + "/authorize" diff --git a/tests/api/test_authorize.py b/tests/api/test_authorize.py index f53ad2e0..dfbd7996 100644 --- a/tests/api/test_authorize.py +++ b/tests/api/test_authorize.py @@ -400,6 +400,11 @@ def test_resolve_callback_url_uses_request_origin_for_unreachable_base(): PUBLIC_BASE_URL="http://0.0.0.0:8000") assert resolve_public_callback_url(wildcard, _request("10.0.0.2:8000")) == \ "http://10.0.0.2:8000/authorize" + # localhost 字面量同理 + named = Settings(_env_file=None, APP_SECRET=SECRET, + PUBLIC_BASE_URL="http://localhost:8000") + assert resolve_public_callback_url(named, _request("10.0.0.2:8000")) == \ + "http://10.0.0.2:8000/authorize" def test_resolve_callback_url_keeps_configured_base(): @@ -416,6 +421,11 @@ def test_resolve_callback_url_keeps_configured_base(): PUBLIC_BASE_URL="https://gw.example") assert resolve_public_callback_url(explicit, _request("192.168.1.5:8000")) == \ "https://gw.example/authorize" + # 非回环 IP 基址:本来就是浏览器可达的,不为请求 origin 让路 + lan_base = Settings(_env_file=None, APP_SECRET=SECRET, + PUBLIC_BASE_URL="http://10.0.0.9:8000") + assert resolve_public_callback_url(lan_base, _request("192.168.1.5:8000")) == \ + "http://10.0.0.9:8000/authorize" def _mock_trae() -> TraeProvider: From 6772a4f540c90cab4f33cc1540ba647bd731884d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:55:40 +0000 Subject: [PATCH 007/153] chore(deps): bump certifi from 2026.7.22 to 2026.10.19 Bumps [certifi](https://github.com/certifi/python-certifi) from 2026.7.22 to 2026.10.19. - [Commits](https://github.com/certifi/python-certifi/compare/2026.07.22...2026.10.19) --- updated-dependencies: - dependency-name: certifi dependency-version: 2026.10.19 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- uv.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index 7b06c000..0f0ebc67 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" [[package]] @@ -35,11 +35,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.7.22" +version = "2026.10.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, + { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, ] [[package]] From 4b578b56ebf912391992d4a20b9613f8b2c4816e Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 07:07:25 +0800 Subject: [PATCH 008/153] =?UTF-8?q?fix(stats):=20=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=BD=92=E5=9B=A0=E5=8C=BA=E5=88=86=E3=80=8C=E5=B7=B2=E6=95=91?= =?UTF-8?q?=E5=9B=9E=E3=80=8D=EF=BC=8C=E6=95=91=E6=8F=B4=E5=85=B3=E8=81=94?= =?UTF-8?q?=E9=99=90=E5=AE=9A=E5=90=8C=E7=94=A8=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 失败数是尝试口径:换号重试每次上游拒绝都落一行,只看它会把「已救回的 尝试」读成「用户受损」。新增 `rescued`(同用户同 request_id 另有成功行的 行数),前端展示「未救回 = failures − rescued」。 - 救援子查询加 `s.username = e.username`:request_id 取自入站 X-Request-ID,客户端可伪造,只按 ID 关联会跨用户误判(已复现) - RequestIdMiddleware 的 finally 复原 request_id:超时存活的子任务不能 继承旧 ID 写统计 - DEAD(硬禁用)额外落一行 credential_unavailable,否则 401 在统计页零 痕迹;轮换耗尽时不再重复落收尾行,避免 requests 与 Key 日额度双计 - 错误日志统一走 _error_detail:httpx 传输异常 str() 常为空串 - idx_usage_request 走 _MIGRATION_INDEXES(request_id 是第 19 版补的列) - 新增 scripts/purge_failure_stats.py 清理失败明细;sync_hourly 提到 scripts/_common.py,孤儿清理限定本次删除覆盖的小时,不再删掉永久保留 的历史汇总;--all 需 --yes 二次确认且禁止叠加收窄条件 文档同步:README(中英)/ PROPOSAL / TECHNICAL §9.7。 --- PROPOSAL.md | 2 +- README.en.md | 2 +- README.md | 2 +- TECHNICAL.md | 1 + scripts/_common.py | 34 ++++ scripts/cleanup_invalid_stats.py | 46 +++-- scripts/purge_failure_stats.py | 147 +++++++++++++++ src/db/migrate.py | 3 + src/db/schema.sql | 3 + src/engine/executor.py | 124 +++++++++--- src/stats/query.py | 31 ++- src/webapp/request_id.py | 3 + tests/api/test_stats_errors.py | 2 +- tests/db/test_repo_migrate.py | 2 + tests/engine/test_executor_stats.py | 99 +++++++++- tests/scripts/test_cleanup_invalid_stats.py | 7 +- tests/scripts/test_purge_failure_stats.py | 176 ++++++++++++++++++ tests/stats/test_query_percentiles.py | 100 +++++++++- tests/webapp/test_request_id.py | 18 ++ web/src/api/statsErrors.ts | 9 + web/src/pages/StatsPage.tsx | 17 +- .../pages/__tests__/StatsPage.errors.test.tsx | 7 + web/src/test-utils/stats.ts | 6 +- 23 files changed, 764 insertions(+), 77 deletions(-) create mode 100644 scripts/purge_failure_stats.py create mode 100644 tests/scripts/test_purge_failure_stats.py diff --git a/PROPOSAL.md b/PROPOSAL.md index 50c95b68..2e35d4c5 100644 --- a/PROPOSAL.md +++ b/PROPOSAL.md @@ -197,7 +197,7 @@ DDL 以 [src/db/schema.sql](src/db/schema.sql) 为准,共 13 张表,`SCHEMA_ - 配置与任务:`GET|PUT /api/settings` + `GET /api/tasks` + `POST /api/tasks/{key}/run`。列表端点统一 `sort` + `order`;未知键回落默认。回调:`GET /authorize`。 - 告警:`GET /api/alerts` + `POST /api/alerts/{id}/resolve`。`AlertTask` 启动时从库里未恢复的行重建集合。 - 上游状态:`GET /api/upstream-status`(operator)返回内存快照 + 任务运行态;**不落盘**。四种解析器归一化到六档,加源只在 `SOURCE_SPECS` 加一行。 -- 统计:`GET /api/stats/{overview,by-provider,by-model,by-user,by-credential,timeline,model-timeline}`(延迟分位 P50/P90/P95/P99 可切,默认 P95;输出速度 `tps_visible` / `tps` / `tps_estimated`)、`GET /api/stats/errors`(失败归因,`limit` ≤ 5000)、`GET /api/stats/events`(游标 / OFFSET 双模式,钻取过滤,`ok=0`,`?format=csv`)、`GET /api/stats/events/stream`(SSE,订阅者有界缓冲 200、并发上限 8)。 +- 统计:`GET /api/stats/{overview,by-provider,by-model,by-user,by-credential,timeline,model-timeline}`(延迟分位 P50/P90/P95/P99 可切,默认 P95;输出速度 `tps_visible` / `tps` / `tps_estimated`)、`GET /api/stats/errors`(失败归因,`limit` ≤ 5000;`failures` 为尝试口径,`rescued` 为同用户同 `request_id` 另有成功行的次数,`failures − rescued` 是未被救回的失败尝试数)、`GET /api/stats/events`(游标 / OFFSET 双模式,钻取过滤,`ok=0`,`?format=csv`)、`GET /api/stats/events/stream`(SSE,订阅者有界缓冲 200、并发上限 8)。 - 日志:`GET /api/logs` + `GET /api/logs/stream`(只读、admin-only,`LOG_BUFFER_LINES=0` 时 404)。 - 侧栏提醒:`GET /api/nav-badges`,**按角色裁剪 payload**(缺失 = 无权看),不做整体 403。各计数只数「待办」,与页面数据不是同一口径。 diff --git a/README.en.md b/README.en.md index 398cf5f5..c0f7888a 100644 --- a/README.en.md +++ b/README.en.md @@ -487,7 +487,7 @@ Four tabs (channel / model / user / credential) share one table (channel / reque **Two latency scopes**: successful requests only. Average latency = `latency_sum ÷ successes` (hourly rollup); percentile latency comes from per-request detail (90 days). `NULL` `ttfb_ms` ranks independently in each column. No samples → `—`. -**Failure attribution**: a panel below the group table, `GET /api/stats/errors` (operator+), aggregates `ok=0` detail by `(channel, model, error_type)` with failure count and last failure time (descending, `limit` ≤ 5000). Clicking a row drills into the detail with *failed only* (`GET /api/stats/events?ok=0`). Rendered for admin/operator only. `/metrics` `upstream_errors_total` still uses the in-process counter; the two are not merged. +**Failure attribution**: a panel below the group table, `GET /api/stats/errors` (operator+), aggregates `ok=0` detail by `(channel, model, error_type)` with failure count, **unrescued count** and last failure time (descending, `limit` ≤ 5000). **The failure count is per-attempt**: every upstream rejection lands a row even when a retry on another credential succeeds; a failure row whose `request_id` also has a success row for the **same user** counts as rescued, and `unrescued = failures − rescued` is the **number of failure attempts that were never rescued** (a request failing twice counts twice; this is not the number of lost requests). A hard credential failure (DEAD, e.g. 401) also lands a `credential_unavailable` row, counted as rescued if a retry succeeds elsewhere; the terminal row is not duplicated once rotation is exhausted. Clicking a row drills into the detail with *failed only* (`GET /api/stats/events?ok=0`). Rendered for admin/operator only. `/metrics` `upstream_errors_total` still uses the in-process counter; the two are not merged. **Row drill-down**: clicking a group row filters the detail by that row (channel / model / credential; the user dimension uses scope), stacking conditions. `GET /api/stats/events` accepts `provider` / `model` / `credential_id` (AND) and `ok=0`. diff --git a/README.md b/README.md index 00a94bfa..3a6a2355 100644 --- a/README.md +++ b/README.md @@ -489,7 +489,7 @@ Playground 与 `/v1/models` 走 stale-while-revalidate:有缓存立刻回旧 **延迟两类口径**:只统计成功请求。平均延迟 = `latency_sum ÷ 成功数`(小时汇总);分位延迟从逐请求明细算(只覆盖 90 天)。`ttfb_ms` 为 `NULL` 的两列各自独立排名。无样本显示 `—`。 -**失败归因**:分组表下方的面板,`GET /api/stats/errors`(operator+)按 `(渠道, 模型, 错误类型)` 聚合窗口内 `ok=0` 的明细,给失败次数与最近失败时刻(按失败数降序,`limit` ≤ 5000)。点行即钻取到明细并加「只看失败」(`GET /api/stats/events?ok=0`)。只在 admin/operator 下渲染。`/metrics` 的 `upstream_errors_total` 仍取进程内计数,两者不合并。 +**失败归因**:分组表下方的面板,`GET /api/stats/errors`(operator+)按 `(渠道, 模型, 错误类型)` 聚合窗口内 `ok=0` 的明细,给失败次数、**未救回数**与最近失败时刻(按失败数降序,`limit` ≤ 5000)。**失败数是尝试口径**:换号重试每次上游拒绝都落一行,同一用户同一 `request_id` 另有成功行即算「已救回」,`未救回 = 失败数 − 救回数` 是**未被救回的失败尝试数**(同一请求多次失败会算多次,不等于受损请求数)。凭证 401 这类硬禁用(DEAD)也会落一行 `credential_unavailable`,换号成功后同样标为已救回;轮换耗尽时不再重复落收尾行。点行即钻取到明细并加「只看失败」(`GET /api/stats/events?ok=0`)。只在 admin/operator 下渲染。`/metrics` 的 `upstream_errors_total` 仍取进程内计数,两者不合并。 **点行钻取**:点分组表任意行,明细即按该行过滤(渠道 / 模型 / 凭证,用户维度走作用域参数),条件可叠加。`GET /api/stats/events` 接受 `provider` / `model` / `credential_id`(AND)与 `ok=0`。 diff --git a/TECHNICAL.md b/TECHNICAL.md index 9686b34f..cd36324f 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -625,6 +625,7 @@ PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_k - **分组表合并列**:`请求`(数 · 成功率)、`Token`(输入/输出 · 命中率)、`速度`(正文/总产出)、`平均延迟`(耗时/首字)、`P95 延迟`,14 → 8 列。排序走 `SortMenuHead`,子列键即 `GROUP_SORT_COLUMNS` 白名单键。延迟拆两列是因为均值读小时汇总、P95 读明细,口径与覆盖期不同。 - **明细合并列**:`Token` / `延迟` / `速度` / `渠道 / 凭证`,15 → 约 10 列。`时间` 拆两行(`HH:mm:ss` 在上、日期在下),请求 ID 提示挂该格。`SortMenuHead` 让一个列头承载多个排序键。命中率走 `EVENT_SORT_COLUMNS["cache_rate"]`:`CASE WHEN e.input_tokens > 0 THEN CAST(e.cached_tokens AS REAL) / e.input_tokens ELSE NULL END`;速度两列由 `_row_speed_sql("e.")` 生成,与 `events()` 的 SELECT **同一片段**。 - **请求 ID**:`X-Request-ID`(`webapp/request_id.py` 最外层)。入站合法(`[A-Za-z0-9._:-]{1,128}`)则沿用,否则 `uuid4().hex`;同一中间件把会话标识头写进 contextvar 供粘性读取;ID 经 `request_context` 传进 `usage_events.request_id`。**不透传上游**。 +- **失败归因**(`GET /api/stats/errors`,operator+):按 `(渠道, 模型, 错误类型)` 聚合窗口内 `ok=0` 明细。`failures` 是**尝试**口径(每次上游拒绝一行);`rescued` 为同用户同 `request_id` 另有成功行的行数(不叠加时间窗,走 `idx_usage_request`),`failures − rescued` = 未被救回的失败尝试数(同一请求多次失败算多次)。DEAD(硬禁用)额外落一行 `credential_unavailable`,轮换耗尽时不再重复落收尾行,避免 `requests` 与 Key 日额度双计。 - **延迟均值只算成功请求**(`SUM(latency_ms WHERE ok=1)` / `ok_count`)。 - **分组表延迟四列**(`avg_latency_ms` / `avg_ttfb_ms` / `p95_latency_ms` / `p95_ttfb_ms`):均值读小时汇总,**分位只能从明细算**(`usage_hourly` 只有 `latency_sum` / `ttfb_sum`,还原不出分布)。`_percentile_join()` 生成「按维度分组在 `usage_events` 上排名」的 LEFT JOIN 子查询,**独立**应用作用域与时间窗(列 `ts`)。用 LEFT JOIN 而非 INNER(全失败组仍要出现)。排名用窗口函数 `ROW_NUMBER() OVER (PARTITION BY key ORDER BY (col IS NULL), col)` + `COUNT(col) OVER (...)`(SQLite ≥3.25);nearest-rank 用整数 `(95 * n + 99) / 100`。`ttfb_ms` 为 NULL 时 latency 与 ttfb **各自独立排名**。实测本地 5.7 万行按渠道算 P95 约 0.09s,加 24h 窗约 0.006s。 - **输出速度**(`tps` / `tps_visible` / `tps_estimated`):`tokens ÷ 生成时间总和`,**不是逐条比值平均**。两列同分母:`tps_visible` = `MAX(output_tokens - reasoning_tokens, 0)` 之和,`tps` = `output_tokens` 之和(思考也计入总产出行;思考占 50–67% 时两者约 190 与 63 tok/s)。主口径只算经路由的成功请求且 `output_tokens > 0`,分母 `latency_ms - ttfb_ms`,两道下限:窗口 < `SPEED_MIN_GEN_MS`(200ms)或单样本 > `SPEED_MAX_TPS`(1000 tok/s)剔除(实测 901 token / 3ms = 30 万 tok/s 是测量失效)。`ttfb_ms` 为 NULL 不进主口径。某组无样本时回落端到端 `latency_ms` 并置 `tps_estimated=1`(前端加 `≈`)。两套聚合在 `_percentile_join()` 同一次扫描算完;`overview()` 另在 `usage_events` 跑 `_speed_aggregates(ref="")`。`_speed_value()` 对每个分子单独判空(正文为 0 → `None`,显示 `—`)。 diff --git a/scripts/_common.py b/scripts/_common.py index 07b6f659..19c42671 100644 --- a/scripts/_common.py +++ b/scripts/_common.py @@ -68,3 +68,37 @@ def transaction(self): self._depth = depth if depth == 0: self._conn.commit() + + +def hour_range(conn, where: str, params: list[object]) -> tuple[int, int] | None: + """`where` 命中行覆盖的小时区间(整点对齐、闭区间);无行返回 None。 + + 删除失败明细前先取这个区间,`sync_hourly` 只在该区间清孤儿行:区间外的 + 小时**本来就没有明细**(`purge_expired` 删 90 天前明细,小时汇总永久保留), + 无条件清会把历史汇总一起删掉。 + """ + row = conn.execute( + f"SELECT MIN(ts), MAX(ts) FROM usage_events WHERE {where}", params).fetchone() + if row[0] is None: + return None + return (int(row[0]) // 3600) * 3600, (int(row[1]) // 3600) * 3600 + + +def sync_hourly(conn, hours: tuple[int, int] | None) -> None: + """全量重算小时汇总,并清掉 `hours` 区间内已无明细对应的孤儿行。 + + `hours` 必须是**本次删除覆盖**的小时区间(见 `hour_range`);None = 没有 + 待删行,只重算不清孤儿。 + """ + from src.stats.collector import StatsCollector + + StatsCollector(DbAdapter(conn)).rollup_hourly() + if hours is None: + return + conn.execute( + "DELETE FROM usage_hourly WHERE hour_utc BETWEEN ? AND ? AND NOT EXISTS (" + " SELECT 1 FROM usage_events e" + " WHERE (e.ts / 3600) * 3600 = usage_hourly.hour_utc" + " AND e.username = usage_hourly.username" + " AND e.provider = usage_hourly.provider" + " AND e.model = usage_hourly.model)", hours) diff --git a/scripts/cleanup_invalid_stats.py b/scripts/cleanup_invalid_stats.py index d5f60ef9..bc917ca9 100644 --- a/scripts/cleanup_invalid_stats.py +++ b/scripts/cleanup_invalid_stats.py @@ -11,8 +11,10 @@ 时认定为白打,整组删除。两边都没成功过的模型名(如手滑输错的请求)保留; 本上游有成功记录的(偶发拒绝)保留;非 invalid_request 的错误保留。 -汇总同步:usage_hourly 由明细全量重算(StatsCollector.rollup_hourly 幂等), -删除后重跑 rollup,再清掉不再有明细对应的小时行,保证明细与汇总严格一致。 +汇总同步:`usage_hourly` 由明细全量重算(`StatsCollector.rollup_hourly` 幂等), +删除后再清掉**本次删除覆盖的小时**里不再有明细对应的孤儿行,保证明细与汇总 +严格一致。只清这些小时:小时汇总永久保留,90 天前的明细已被 `purge_expired` +删掉,无条件清孤儿会把历史汇总一起删掉。 用法: python3 scripts/cleanup_invalid_stats.py # 预览,不写库 @@ -28,34 +30,33 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from scripts._common import DbAdapter, backup # noqa: E402 +from scripts._common import backup, sync_hourly # noqa: E402 from src.db.conn import Database # noqa: E402 -from src.stats.collector import StatsCollector # noqa: E402 -def stale_groups(conn) -> list[tuple[str, str, int]]: - """白打组列表:(provider, model 原样, 待删条数)。""" +def stale_groups(conn) -> list[tuple[str, str, int, int, int]]: + """白打组列表:(provider, model 原样, 待删条数, 最早 ts, 最晚 ts)。""" success: dict[tuple[str, str], int] = {} for provider, model, count in conn.execute( "SELECT provider, LOWER(model), COUNT(*) FROM usage_events " "WHERE ok = 1 GROUP BY provider, LOWER(model)"): success[(provider, model)] = count - stale: list[tuple[str, str, int]] = [] - for provider, model, count in conn.execute( - "SELECT provider, model, COUNT(*) FROM usage_events " + stale: list[tuple[str, str, int, int, int]] = [] + for provider, model, count, low, high in conn.execute( + "SELECT provider, model, COUNT(*), MIN(ts), MAX(ts) FROM usage_events " "WHERE ok = 0 AND error_type = 'invalid_request' " "GROUP BY provider, model"): if success.get((provider, model.lower()), 0) == 0 and any( ok_count for (pid, lower), ok_count in success.items() if pid != provider and lower == model.lower()): - stale.append((provider, model, count)) + stale.append((provider, model, count, low, high)) return stale -def delete_stale(conn, stale: list[tuple[str, str, int]]) -> int: +def delete_stale(conn, stale: list[tuple[str, str, int, int, int]]) -> int: """删除白打明细,返回总条数。""" total = 0 - for provider, model, _count in stale: + for provider, model, _count, _low, _high in stale: cursor = conn.execute( "DELETE FROM usage_events WHERE provider = ? AND model = ? " "AND ok = 0 AND error_type = 'invalid_request'", (provider, model)) @@ -63,16 +64,10 @@ def delete_stale(conn, stale: list[tuple[str, str, int]]) -> int: return total -def sync_hourly(conn) -> None: - """小时汇总与明细对齐:全量重算 + 清孤儿行。""" - StatsCollector(DbAdapter(conn)).rollup_hourly() - conn.execute( - "DELETE FROM usage_hourly WHERE NOT EXISTS (" - " SELECT 1 FROM usage_events e" - " WHERE (e.ts / 3600) * 3600 = usage_hourly.hour_utc" - " AND e.username = usage_hourly.username" - " AND e.provider = usage_hourly.provider" - " AND e.model = usage_hourly.model)") +def _deleted_hours(stale: list[tuple[str, str, int, int, int]]) -> tuple[int, int]: + """待删白打行覆盖的小时区间(整点对齐);只让这些小时参与孤儿清理。""" + return (min(group[3] for group in stale) // 3600 * 3600, + max(group[4] for group in stale) // 3600 * 3600) def main(argv: list[str] | None = None) -> int: @@ -94,9 +89,9 @@ def main(argv: list[str] | None = None) -> int: if not stale: print("没有需要清理的记录") return 0 - for provider, model, count in sorted(stale): + for provider, model, count, _low, _high in sorted(stale): print(f" {provider:<10} {model:<24} x{count}") - total = sum(count for _, _, count in stale) + total = sum(group[2] for group in stale) print(f"共 {total} 条白打明细({len(stale)} 组)") if not args.apply: @@ -104,8 +99,9 @@ def main(argv: list[str] | None = None) -> int: return 0 target = backup(db_path) + hours = _deleted_hours(stale) delete_stale(conn, stale) - sync_hourly(conn) + sync_hourly(conn, hours) conn.commit() print(f"已删除 {total} 条明细,小时汇总已同步(备份: {target})") return 0 diff --git a/scripts/purge_failure_stats.py b/scripts/purge_failure_stats.py new file mode 100644 index 00000000..6da92e11 --- /dev/null +++ b/scripts/purge_failure_stats.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""清理失败明细(`usage_events.ok = 0`),即「失败归因」面板的数据源。 + +背景:失败归因(`GET /api/stats/errors`)直接聚合**明细表**里 `ok=0` 的行, +没有独立表可清。想让面板归零,只能删明细。本脚本与 +`cleanup_invalid_stats.py` 的区别:后者只删「跨上游白打」这一种保守判定, +本脚本按**显式过滤条件**删,用于「自己压测/联调打出来的一堆失败」这类 +确认过没有诊断价值的数据。 + +判定(必须显式给条件之一,避免误删全部历史): + - `--provider` 只删该渠道 + - `--error-type` 只删该错误类型 + - `--days N` 只删最近 N 天 + - `--all` 无条件删全部失败明细(最危险,需 `--apply --yes` 同时确认, + 且不能再叠加 `--provider` / `--error-type` / `--days`) +成功行(`ok=1`)一律不动。 + +汇总同步:`usage_hourly` 由明细全量重算(`StatsCollector.rollup_hourly` +幂等),删除后再清掉**本次删除覆盖的小时**里不再有明细对应的孤儿行,保证明细 +与汇总严格一致。只清这些小时是关键:小时汇总永久保留,而 90 天前的明细已被 +`purge_expired` 删掉,无条件清孤儿会把历史汇总一起删掉。注意副作用:失败明细 +删掉后,该小时的 `requests` 会下降、成功率会「变好」——这是删数据的必然结果, +不是统计口径变了。 + +用法: + python3 scripts/purge_failure_stats.py --provider qoder # 预览 + python3 scripts/purge_failure_stats.py --provider qoder --apply + python3 scripts/purge_failure_stats.py --all --apply --yes # 全部失败明细 + python3 scripts/purge_failure_stats.py --db PATH ... +""" + +from __future__ import annotations + +import argparse +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from scripts._common import backup, hour_range, sync_hourly # noqa: E402 +from src.db.conn import Database # noqa: E402 + + +def _filters(*, provider: str | None, error_type: str | None, + days: int | None, now: int | None) -> tuple[str, list[object]]: + """构造 `ok=0` 之外的过滤条件(只允许 AND 收窄)。""" + clauses = ["ok = 0"] + params: list[object] = [] + if provider is not None: + clauses.append("provider = ?") + params.append(provider) + if error_type is not None: + clauses.append("error_type = ?") + params.append(error_type) + if days is not None: + clauses.append("ts >= ?") + params.append((now if now is not None else int(time.time())) - days * 86400) + return " AND ".join(clauses), params + + +def failure_groups(conn, *, provider: str | None = None, + error_type: str | None = None, days: int | None = None, + now: int | None = None) -> list[tuple[str, str, str | None, int, int]]: + """待删分组:`(provider, model, error_type, 条数, 最近时刻)`,按条数降序。""" + where, params = _filters(provider=provider, error_type=error_type, + days=days, now=now) + rows = conn.execute( + f"SELECT provider, model, error_type, COUNT(*) AS n, MAX(ts) AS last_ts " + f"FROM usage_events WHERE {where} " + f"GROUP BY provider, model, error_type ORDER BY n DESC, provider, model", + params).fetchall() + return [(row[0], row[1], row[2], int(row[3]), int(row[4])) for row in rows] + + +def delete_groups(conn, *, provider: str | None = None, + error_type: str | None = None, days: int | None = None, + now: int | None = None) -> int: + """按同一组条件删除失败明细,返回删除条数。""" + where, params = _filters(provider=provider, error_type=error_type, + days=days, now=now) + return conn.execute(f"DELETE FROM usage_events WHERE {where}", params).rowcount + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="清理失败明细(失败归因数据源)") + parser.add_argument("--db", help="SQLite 路径(默认 DATA_DIR/coding2api.sqlite3)") + parser.add_argument("--provider", help="只删该渠道的失败明细") + parser.add_argument("--error-type", dest="error_type", + help="只删该错误类型的失败明细") + parser.add_argument("--days", type=int, help="只删最近 N 天的失败明细") + parser.add_argument("--all", action="store_true", + help="删除全部失败明细(必须再叠加 --yes)") + parser.add_argument("--yes", action="store_true", + help="确认执行 --all(没有它只预览/拒绝)") + parser.add_argument("--apply", action="store_true", help="实际删除(默认只预览)") + args = parser.parse_args(argv) + + if not (args.all or args.provider or args.error_type or args.days): + parser.error("必须给出 --provider / --error-type / --days / --all 之一") + if args.days is not None and args.days <= 0: + parser.error("--days 必须为正整数") + if args.all and (args.provider or args.error_type or args.days): + # --all 写着「无条件删全部」,叠加收窄条件会静默只删一部分 + parser.error("--all 不能与 --provider / --error-type / --days 叠加") + if args.all and args.apply and not args.yes: + parser.error("--all --apply 需要 --yes 二次确认") + + from src.config import load_settings + + db_path = Path(args.db) if args.db else Path(load_settings().db_path) + if not db_path.is_file(): + parser.error(f"数据库不存在: {db_path}") + + database = Database(db_path) + conn = database.connect() + groups = failure_groups(conn, provider=args.provider, + error_type=args.error_type, days=args.days) + if not groups: + print("没有需要清理的记录") + return 0 + for provider, model, error_type, count, last_ts in groups: + stamp = time.strftime("%Y-%m-%d %H:%M", time.localtime(last_ts)) + print(f" {provider:<14} {model:<24} {error_type or '(未上报)':<22} " + f"x{count:<4} 最近 {stamp}") + total = sum(group[3] for group in groups) + print(f"共 {total} 条失败明细({len(groups)} 组)") + + if not args.apply: + print("预览模式,未修改数据库;确认无误后加 --apply 执行") + return 0 + + target = backup(db_path) + # 先取区间再删:删完就查不出自己动过哪些小时了 + hours = hour_range(conn, *_filters(provider=args.provider, + error_type=args.error_type, days=args.days, + now=None)) + deleted = delete_groups(conn, provider=args.provider, + error_type=args.error_type, days=args.days) + sync_hourly(conn, hours) + conn.commit() + print(f"已删除 {deleted} 条明细,小时汇总已同步(备份: {target})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/db/migrate.py b/src/db/migrate.py index 318c2b57..2f475d52 100644 --- a/src/db/migrate.py +++ b/src/db/migrate.py @@ -153,6 +153,9 @@ # 按 Key 计量 / 自助用量只查「某把 Key + 时间窗」,必须走索引不能全表扫; # api_key_id 是第 26 版才补的列,索引只能在这里建。 ("idx_usage_key", "usage_events", "api_key_id, ts"), + # 失败归因的「换号救回」按 request_id 关联同一请求的成功行(见 + # stats.query.errors):request_id 是第 19 版补的列,索引只能在这里建。 + ("idx_usage_request", "usage_events", "request_id"), ) diff --git a/src/db/schema.sql b/src/db/schema.sql index ba6411c6..94a9cf82 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -121,6 +121,9 @@ CREATE TABLE IF NOT EXISTS usage_events ( CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_events(ts); CREATE INDEX IF NOT EXISTS idx_usage_user ON usage_events(username, ts); +-- `idx_usage_request`(失败归因按 request_id 关联成功行)不在这里建: +-- request_id 是老库后补的列,`executescript` 先于补列执行会报 +-- "no such column",只能走 `migrate.py` 的 `_MIGRATION_INDEXES`。 CREATE TABLE IF NOT EXISTS usage_hourly ( hour_utc INTEGER NOT NULL, diff --git a/src/engine/executor.py b/src/engine/executor.py index 84788542..cd5ac6c1 100644 --- a/src/engine/executor.py +++ b/src/engine/executor.py @@ -567,6 +567,9 @@ async def _stream_loop(self, request: ChatRequest, target: ModelTarget, attempts = 0 last_error: Exception | None = None last_kind: ErrKind | None = None + # 本链项是否已落过尝试级明细(DEAD):是则选号耗尽时不再落收尾行, + # 否则同一请求在 requests 里算两次、Key 配额扣两次。 + attributed = False # 本链项是否已向客户端出过响应帧:出了就绝不再换模型**或换号** # (半截输出不可回滚,换号会让两个凭证的内容拼进同一条回复) emitted = False @@ -614,11 +617,12 @@ def terminal(message: str, code: str) -> bytes: _reject_message(target.model, last_error, self._suggestions(target.model)), "invalid_request") - self._deps.record( - username=state.username, provider=state.provider, - credential_id=state.credential_id, model=target.model, ok=False, - error_type=self._exhaust_error_type(target), - latency_ms=_elapsed_ms(state.started)) + if not attributed: + self._deps.record( + username=state.username, provider=state.provider, + credential_id=state.credential_id, model=target.model, ok=False, + error_type=self._exhaust_error_type(target), + latency_ms=_elapsed_ms(state.started)) raise _ModelExhausted( self._unavailable_text(target, last_error), "no_healthy_credential") @@ -696,8 +700,9 @@ def terminal(message: str, code: str) -> bytes: "上游 %s 流内错误(凭证 %s,kind=%s): code=%s %s", provider_id, credential_id, kind, event.error_code, event.error_message) - self._note_upstream_error(credential_id, kind, provider_id, - target.model, event) + attributed = self._note_upstream_error( + credential_id, kind, provider_id, target.model, event, + username=state.username, started=state.started) or attributed last_error = UpstreamStreamError(event) broken = True break @@ -737,23 +742,30 @@ def terminal(message: str, code: str) -> bytes: else: kind = ErrKind.SOFT logger.warning("上游 %s 排队但无法重开(凭证 %s): %s", - provider_id, credential_id, error) + provider_id, credential_id, _error_detail(error)) self._note_upstream_error(credential_id, kind, provider_id, - target.model, error) + target.model, error, + username=state.username, + started=state.started) last_error = error elif kind is ErrKind.INVALID: # 请求无效(如该上游不认识模型):不冷却凭证, # 跳过该上游继续试其他上游;全部拒绝才以 400 结束 logger.warning("上游 %s 拒绝模型 %s(凭证 %s 跳过): %s", - provider_id, target.model, credential_id, error) + provider_id, target.model, credential_id, + _error_detail(error)) self._record_invalid(state.username, provider_id, credential_id, target.model, state.started, error) last_error, last_kind = error, ErrKind.INVALID self._skip_provider(provider_id, tried) else: logger.warning("上游 %s 错误(凭证 %s,kind=%s): %s", - provider_id, credential_id, kind, error) - self._note_upstream_error(credential_id, kind, provider_id, target.model, error) + provider_id, credential_id, kind, + _error_detail(error)) + attributed = self._note_upstream_error( + credential_id, kind, provider_id, target.model, + error, username=state.username, + started=state.started) or attributed last_error = error finally: # break / 异常 / 客户端断开都走到这里:先清掉在途的取帧任务 @@ -957,6 +969,9 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, attempts = 0 last_error: Exception | None = None last_kind: ErrKind | None = None + # 本次请求是否已落过尝试级明细(DEAD):是则选号耗尽时不再落收尾行, + # 否则同一请求在 requests 里算两次、Key 配额扣两次。 + attributed = False started = time.monotonic() last_provider = "-" last_credential: str | None = None @@ -991,11 +1006,13 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, # 所有候选上游都拒绝了该模型:400 而非 503 raise InvalidRequest(_reject_message( target.model, last_error, self._suggestions(target.model))) - self._deps.record( - username=username, provider=last_provider, credential_id=last_credential, - model=target.model, ok=False, - error_type=self._exhaust_error_type(target), - latency_ms=int((time.monotonic() - started) * 1000)) + if not attributed: + self._deps.record( + username=username, provider=last_provider, + credential_id=last_credential, + model=target.model, ok=False, + error_type=self._exhaust_error_type(target), + latency_ms=int((time.monotonic() - started) * 1000)) raise NoHealthyCredential( self._unavailable_text(target, last_error)) credential_id, credential_data = pick @@ -1057,7 +1074,8 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, "上游 %s 非流式聚合超过 %ss(凭证 %s,按瞬态错误换号)", provider_id, self._deps.complete_timeout_seconds, credential_id) self._note_upstream_error(credential_id, ErrKind.SOFT, - provider_id, target.model) + provider_id, target.model, + username=username, started=started) last_error = UpstreamStreamError(Event( kind=EventKind.ERROR, error_message=(f"upstream complete timed out after " @@ -1067,7 +1085,8 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, "上游 %s 非流式聚合事件超过 %s 条(凭证 %s,按瞬态错误换号)", provider_id, overflow.limit, credential_id) self._note_upstream_error(credential_id, ErrKind.SOFT, - provider_id, target.model) + provider_id, target.model, + username=username, started=started) last_error = UpstreamStreamError(Event( kind=EventKind.ERROR, error_message=(f"upstream complete exceeded " @@ -1092,23 +1111,28 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, QUEUE_MAX_ROUNDS, wait) else: logger.warning("上游 %s 排队但无法重开(凭证 %s): %s", - provider_id, credential_id, error) + provider_id, credential_id, _error_detail(error)) self._note_upstream_error(credential_id, ErrKind.SOFT, - provider_id, target.model, error) + provider_id, target.model, error, + username=username, started=started) last_error = error elif kind is ErrKind.INVALID: # 请求无效(如该上游不认识模型):不冷却凭证, # 跳过该上游继续试其他上游;全部拒绝才以 400 结束 logger.warning("上游 %s 拒绝模型 %s(凭证 %s 跳过): %s", - provider_id, target.model, credential_id, error) + provider_id, target.model, credential_id, + _error_detail(error)) self._record_invalid(username, provider_id, credential_id, target.model, started, error) last_error, last_kind = error, ErrKind.INVALID self._skip_provider(provider_id, tried) else: logger.warning("上游 %s 错误(凭证 %s,kind=%s): %s", - provider_id, credential_id, kind, error) - self._note_upstream_error(credential_id, kind, provider_id, target.model, error) + provider_id, credential_id, kind, + _error_detail(error)) + attributed = self._note_upstream_error( + credential_id, kind, provider_id, target.model, + error, username=username, started=started) or attributed last_error = error else: try: @@ -1126,8 +1150,9 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, last_error, last_kind = error, ErrKind.INVALID self._skip_provider(provider_id, tried) else: - self._note_upstream_error(credential_id, kind, provider_id, - target.model, error) + attributed = self._note_upstream_error( + credential_id, kind, provider_id, target.model, error, + username=username, started=started) or attributed last_error = error else: self._deps.credentials.save_success( @@ -1429,8 +1454,13 @@ def _release(self, credential_id: str) -> None: def _note_upstream_error(self, credential_id: str, kind: ErrKind, provider_id: str, model: str, - error: Exception | None = None) -> None: - """对一次上游错误作出反应:记账 + 落库,REQUEST 类零动作。 + error: Exception | None = None, *, + username: str, started: float) -> bool: + """对一次上游错误作出反应:记账 + 落库(DEAD 另落一条统计),REQUEST 类零动作。 + + 返回值 = **本次是否落了尝试级明细**(仅 DEAD 为真)。调用方据此在轮换 + 耗尽时跳过收尾行,避免同一个请求在 `requests` 里被算两次、Key 配额被 + 扣两次。 请求级错误(11101 请求体坏 / 11115 上下文超限 / 11128 渠道风控 / 11135 图片无效)不是账号的问题:冷却或累计错误数都会在阈值处把健康 @@ -1438,16 +1468,35 @@ def _note_upstream_error(self, credential_id: str, kind: ErrKind, 也绝不落库——save_error 会把已有 cooling_until 写成 NULL, 等于顺手解掉别的错误留下的冷却。调用方只换号重试。 + **DEAD 额外落一条 `credential_unavailable` 明细**:硬禁用只写 + `credentials.disabled`,归因卡片读的是 `usage_events`,不落明细就完全 + 看不到(实测 2026-10-23 officeace 两次 401 `AppKey or AppSecret is + invalid` 在统计页零痕迹)。与 `invalid_request` 同口径:**每次尝试** + 落一行,换号成功后由 `errors()` 的 `rescued` 标为「已救回」。其他分类 + (SOFT / CREDIT / PLAN / MODEL)不在此落行——它们每次请求可能触发多次, + 落行会把明细表灌满噪声;这些请求的失败仍由轮换耗尽时的收尾行覆盖。 + + **换号耗尽时收尾行不再落**(调用方看返回值):否则一次请求两行, + `usage_hourly.requests` 虚高、成功率虚低,按 Key 计的日请求额度也会 + 被扣两次。DEAD 尝试行已经是这次失败的完整归因。 + `error` 带 `retry_at` 时(上游 `Retry-After`,见 provider.base)交给 调度器在**该错误分类的作用域**内覆盖冷却时长;缺失/过去时刻回落常量。 """ if kind is ErrKind.REQUEST: - return + return False outcome = self._deps.scheduler.note_error( self._candidate(credential_id), kind, int(time.time()), model=self._model_scope(provider_id, model), retry_at=getattr(error, "retry_at", None)) self._deps.credentials.save_error(credential_id, outcome) + if kind is not ErrKind.DEAD: + return False + self._deps.record( + username=username, provider=provider_id, credential_id=credential_id, + model=model, ok=False, error_type=_error_type_for(kind), + latency_ms=int((time.monotonic() - started) * 1000)) + return True def _queue_wait(self, retry_after: float | None, emitted: bool, rounds: int) -> float | None: @@ -1552,6 +1601,23 @@ def _classify(error: Exception) -> ErrKind | None: return None +def _error_detail(error: Exception | None) -> str: + """服务端日志用的错误描述:`异常类名: 消息`。 + + 为什么不能只 `%s` 格式化异常:`httpx` 的传输异常经常 `str()` 为空 + (`Server disconnected without sending a response.` 之外的大量读超时 / + 写超时都是空串),日志只剩「kind=soft): 」,排障时分不清是读断、写超时 + 还是连接重置(实测 2026-10-23 codebuddy_intl 两次 158s/162s 的 SOFT 错误 + 都是空正文)。类名本身不含用户数据与上游正文,与既有 `body_hint` 截断 + 正文的口径一致,只进服务端日志。 + """ + if error is None: + return "-" + name = type(error).__name__ + text = str(error) + return f"{name}: {text}" if text else name + + def _error_summary(error: Exception | None) -> str: """对外错误文案里的错误标识:**只给受控分类,不回显上游正文**。 diff --git a/src/stats/query.py b/src/stats/query.py index af52d221..a66d27d7 100644 --- a/src/stats/query.py +++ b/src/stats/query.py @@ -968,23 +968,42 @@ def errors(self, *, username: str | None = None, since: int | None = None, `limit` 按失败数降序取前 N 组:归因卡片是「谁最该修」的排行榜,长尾 组合(同渠道同模型的不同错误类型各算一组)容易把响应撑大。 + **`rescued`(换号救回数)**:同一用户同一 `request_id` 另有成功行的 + 失败**行**数。换号重试是常态——`invalid_request` 在每次上游拒绝时都落 + 一行,即使随后换了另一个渠道成功(实测 2026-10-23:qoder 53 次失败里 + 36 次最终成功)。只看 `failures` 会把「已救回的尝试」读成「用户受损」, + 故单列下发:`failures - rescued` 是**未被救回的失败尝试数**(同一请求多 + 次失败会计多次,不等于受损请求数)。救援判定**不叠加时间窗**:成功行 + 可能落在窗口右界之后(同一请求的重试),按 `request_id` 全表关联,走 + `idx_usage_request`。 + + **必须加 `s.username = e.username`**:`request_id` 取自入站 + `X-Request-ID`,客户端可伪造;只按 ID 关联会让别的用户的成功行把本用户 + 的失败标成「已救回」,也会让传了 `username=` 的查询读到跨租户数据。 + `now` 可注入(测试确定性):只在 `since` 缺省时用于推算默认窗口。 """ if since is None: since = (now if now is not None else int(time.time())) - ERROR_WINDOW_SECONDS where, params = self._where(username=username, since=since, since_col="ts", - until=until, until_col="ts", ok=0) + until=until, until_col="ts", ok=0, alias="e.") rows = self._db.connect().execute( f""" - SELECT provider, model, error_type, COUNT(*) AS failures, - MAX(ts) AS last_ts - FROM usage_events {where} - GROUP BY provider, model, error_type - ORDER BY failures DESC, provider, model, error_type + SELECT e.provider, e.model, e.error_type, COUNT(*) AS failures, + SUM(CASE WHEN EXISTS ( + SELECT 1 FROM usage_events s + WHERE s.request_id = e.request_id + AND s.username = e.username AND s.ok = 1) + THEN 1 ELSE 0 END) AS rescued, + MAX(e.ts) AS last_ts + FROM usage_events e {where} + GROUP BY e.provider, e.model, e.error_type + ORDER BY failures DESC, e.provider, e.model, e.error_type LIMIT ? """, [*params, max(1, limit)]).fetchall() return [{"provider": row["provider"], "model": row["model"], "error_type": row["error_type"], "failures": int(row["failures"]), + "rescued": int(row["rescued"]), "last_ts": int(row["last_ts"])} for row in rows] def model_timeline(self, *, username: str | None = None, diff --git a/src/webapp/request_id.py b/src/webapp/request_id.py index b085ba88..1c4edd7c 100644 --- a/src/webapp/request_id.py +++ b/src/webapp/request_id.py @@ -62,3 +62,6 @@ async def send_with_header(message): # 调用,最可靠。 set_api_key_id(None) set_session_id(None) + # request_id 也要复原:超时/后台任务可能比请求活得久,残留旧 ID 会 + # 让后续写入落进别人的 request_id(归因的「换号救回」按它关联)。 + set_request_id(None) diff --git a/tests/api/test_stats_errors.py b/tests/api/test_stats_errors.py index 80cffda9..4f13257c 100644 --- a/tests/api/test_stats_errors.py +++ b/tests/api/test_stats_errors.py @@ -50,7 +50,7 @@ def test_errors_groups_and_orders_by_failures(admin_client): now = _seed_failures(app) rows = client.get(PATH, params={"since": now - 3600}).json()["errors"] assert rows[0] == {"provider": "trae", "model": "glm", "error_type": "rate_limit", - "failures": 3, "last_ts": now - 8} + "failures": 3, "rescued": 0, "last_ts": now - 8} assert rows[1]["error_type"] == "upstream_error" and rows[1]["failures"] == 1 # 成功请求不进归因 assert sum(row["failures"] for row in rows) == 4 diff --git a/tests/db/test_repo_migrate.py b/tests/db/test_repo_migrate.py index 00e84423..4cd7e4be 100644 --- a/tests/db/test_repo_migrate.py +++ b/tests/db/test_repo_migrate.py @@ -134,6 +134,8 @@ def test_migrate_adds_cached_tokens_to_legacy_db(tmp_path): event_indexes = {row[1] for row in db.connect().execute( "PRAGMA index_list(usage_events)")} assert "idx_usage_key" in event_indexes + # 失败归因的「换号救回」按 request_id 关联(request_id 是第 19 版补的列) + assert "idx_usage_request" in event_indexes alert_columns = {row[1] for row in db.connect().execute("PRAGMA table_info(alert_events)")} # 告警恢复时刻:老库补列后历史行 NULL(视为未恢复,不影响既有记录展示) assert "resolved_at" in alert_columns diff --git a/tests/engine/test_executor_stats.py b/tests/engine/test_executor_stats.py index 2dfe3ca1..a13e5dd9 100644 --- a/tests/engine/test_executor_stats.py +++ b/tests/engine/test_executor_stats.py @@ -284,8 +284,9 @@ def kind(self): with pytest.raises(NoHealthyCredential): await executor.complete(parse_chat_request( {"messages": [{"role": "user", "content": "hi"}]})) - # 会话失效 → 硬禁用,轮换耗尽后归类为无可用凭证 - assert collector.events[-1]["error_type"] == "no_healthy_credential" + # 会话失效 → 硬禁用;尝试行已给出真实归因(凭证失效),轮换耗尽的收尾行 + # 不再重复落(否则一次请求两行,requests 与 Key 日额度双计) + assert collector.events[-1]["error_type"] == "credential_unavailable" assert credentials.candidates()[0].disabled is True def test_error_type_mapping_covers_all_kinds(): @@ -299,6 +300,100 @@ def test_error_type_mapping_covers_all_kinds(): assert _error_type_for(ErrKind.SOFT) == "upstream_error" assert _error_type_for(ErrKind.OTHER) == "upstream_error" + +async def test_dead_credential_also_records_attribution_row(repo): + """DEAD 除硬禁用外必须落一条 `credential_unavailable` 明细。 + + 硬禁用只写 `credentials.disabled`,归因卡片读的是 `usage_events`——不落 + 明细就完全看不到(实测 2026-10-23 officeace 两次 401 `AppKey or + AppSecret is invalid` 在统计页零痕迹)。 + """ + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + + class Dead(Exception): + def kind(self): + return ErrKind.DEAD + + executor = _exec_with_stats(repo, [[Dead()]], collector) + from src.compat.openai.request import parse_chat_request + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + + kinds = [event["error_type"] for event in collector.events] + # 只落尝试级(DEAD 归因):轮换耗尽的收尾行不再重复落,否则同一请求 + # 在 requests 里算两次、成功率虚低,按 Key 计的日额度也会被扣两次。 + assert kinds == ["credential_unavailable"] + dead_row = collector.events[0] + assert dead_row["ok"] is False + assert dead_row["provider"] == "codebuddy" + assert dead_row["latency_ms"] is not None + + +async def test_stream_dead_error_does_not_duplicate_terminal_row(repo): + """流式同理:DEAD 已落尝试行,选号耗尽时不再落收尾行(一次请求一行)。""" + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + + class Dead(Exception): + def kind(self): + return ErrKind.DEAD + + executor = _exec_with_stats(repo, [[Dead()]], collector) + from src.compat.openai.request import parse_chat_request + + chunks = [chunk async for chunk in executor.stream(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}], "stream": True}))] + assert chunks # 以错误帧收尾,不是静默断开 + assert [event["error_type"] for event in collector.events] == [ + "credential_unavailable"] + + +async def test_soft_error_does_not_record_attribution_row(repo): + """SOFT 等其它分类不落尝试级明细:一次请求可能触发多次,落行会灌满噪声。 + + 它们的失败仍由轮换耗尽时的收尾行覆盖(见上一个用例的末尾断言)。 + """ + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + + class Soft(Exception): + def kind(self): + return ErrKind.SOFT + + executor = _exec_with_stats(repo, [[Soft()]], collector) + from src.compat.openai.request import parse_chat_request + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + + kinds = [event["error_type"] for event in collector.events] + assert "upstream_error" not in kinds # 尝试级不落行 + assert kinds[-1] == "no_healthy_credential" + + +def test_error_detail_includes_exception_class_for_empty_message(): + """日志要能分辨传输异常的种类:httpx 的读超时 / 写超时 `str()` 常为空串。 + + 实测 2026-10-23 codebuddy_intl 两次 158s/162s 的 SOFT 错误日志只剩 + 「kind=soft): 」,分不清读断、写超时还是连接重置。 + """ + from src.engine.executor import _error_detail + + class Blank(Exception): + pass + + assert _error_detail(Blank()) == "Blank" # 空消息只给类名 + assert _error_detail(ValueError("boom")) == "ValueError: boom" + assert _error_detail(None) == "-" + + def test_usage_field_tolerates_missing_usage(): from src.engine.executor import _usage_field diff --git a/tests/scripts/test_cleanup_invalid_stats.py b/tests/scripts/test_cleanup_invalid_stats.py index 10e56b76..b4be671f 100644 --- a/tests/scripts/test_cleanup_invalid_stats.py +++ b/tests/scripts/test_cleanup_invalid_stats.py @@ -48,7 +48,7 @@ def test_stale_only_when_other_provider_succeeded(db): collector.record(username="u", provider="trae", model="solo", ok=False, error_type="upstream_error") # 非 invalid → 不删 stale = CLEANUP.stale_groups(collector._db.connect()) - assert [(p, m, c) for p, m, c in stale] == [("trae", "solo", 1)] + assert [(p, m, c) for p, m, c, _low, _high in stale] == [("trae", "solo", 1)] def test_delete_and_sync_hourly_aligns_aggregate(db): @@ -58,9 +58,10 @@ def test_delete_and_sync_hourly_aligns_aggregate(db): error_type="invalid_request") collector.record(username="u", provider="codebuddy", model="solo", ok=True) conn = database.connect() - assert CLEANUP.delete_stale(conn, CLEANUP.stale_groups(conn)) == 1 + stale = CLEANUP.stale_groups(conn) + assert CLEANUP.delete_stale(conn, stale) == 1 conn.commit() - CLEANUP.sync_hourly(conn) + CLEANUP.sync_hourly(conn, CLEANUP._deleted_hours(stale)) rows = [tuple(r) for r in conn.execute( "SELECT provider, model, requests FROM usage_hourly ORDER BY provider")] assert rows == [("codebuddy", "solo", 1)] diff --git a/tests/scripts/test_purge_failure_stats.py b/tests/scripts/test_purge_failure_stats.py new file mode 100644 index 00000000..82a695f1 --- /dev/null +++ b/tests/scripts/test_purge_failure_stats.py @@ -0,0 +1,176 @@ +"""scripts/purge_failure_stats.py 的过滤、删除与汇总同步测试。""" + +from __future__ import annotations + +import sqlite3 +import time +from pathlib import Path + +import pytest + +from src.db.conn import Database +from src.db.migrate import apply_schema +from src.stats.collector import StatsCollector +from tests.helpers.paths import load_script + +PURGE = load_script("purge_failure_stats") + + +@pytest.fixture() +def db(tmp_path): + database = Database(tmp_path / "t.sqlite3") + apply_schema(database.connect()) + yield database, StatsCollector(database) + database.close() + + +def _seed(collector: StatsCollector) -> None: + """两条 qoder 失败 + 一条 zen 失败 + 一条成功(成功行永不被删)。""" + collector.record(username="u", provider="qoder", model="m", ok=False, + error_type="invalid_request") + collector.record(username="u", provider="qoder", model="m", ok=False, + error_type="invalid_request") + collector.record(username="u", provider="zen", model="m", ok=False, + error_type="upstream_error") + collector.record(username="u", provider="qoder", model="m", ok=True) + + +def test_failure_groups_group_and_order(db): + """分组口径:provider+model+error_type,按条数降序。""" + _database, collector = db + _seed(collector) + groups = PURGE.failure_groups(collector._db.connect()) + assert [(g[0], g[2], g[3]) for g in groups] == [ + ("qoder", "invalid_request", 2), ("zen", "upstream_error", 1)] + + +def test_filters_narrow_by_provider_and_error_type(db): + """过滤条件只能收窄,成功行永不进入待删集合。""" + _database, collector = db + _seed(collector) + conn = collector._db.connect() + assert [g[3] for g in PURGE.failure_groups(conn, provider="qoder")] == [2] + assert [g[3] for g in PURGE.failure_groups(conn, error_type="upstream_error")] == [1] + assert PURGE.failure_groups(conn, provider="trae") == [] + + +def test_days_filter_uses_window(db): + """`--days` 只覆盖窗口内的失败明细。""" + _database, collector = db + conn = collector._db.connect() + old = int(time.time()) - 3 * 86400 + conn.execute( + "INSERT INTO usage_events (id, ts, username, provider, model, ok, error_type) " + "VALUES ('old', ?, 'u', 'qoder', 'm', 0, 'invalid_request')", (old,)) + conn.commit() + assert PURGE.failure_groups(conn, days=1) == [] + assert [g[3] for g in PURGE.failure_groups(conn, days=7)] == [1] + + +def test_delete_groups_keeps_success_rows(db): + """删除只命中 ok=0,成功明细与其它渠道保持原样。""" + database, collector = db + _seed(collector) + conn = database.connect() + assert PURGE.delete_groups(conn, provider="qoder") == 2 + conn.commit() + remaining = [tuple(r) for r in conn.execute( + "SELECT provider, ok FROM usage_events ORDER BY provider")] + assert remaining == [("qoder", 1), ("zen", 0)] + + +def test_sync_hourly_aligns_aggregate(db): + """删完重算:该小时 requests 下降,孤儿小时行被清掉。""" + database, collector = db + _seed(collector) + conn = database.connect() + hours = PURGE.hour_range(conn, "ok = 0", []) + PURGE.delete_groups(conn, provider="qoder", error_type="invalid_request") + conn.commit() + PURGE.sync_hourly(conn, hours) + rows = [tuple(r) for r in conn.execute( + "SELECT provider, requests, ok_count FROM usage_hourly ORDER BY provider")] + assert rows == [("qoder", 1, 1), ("zen", 1, 0)] + + +def test_sync_hourly_keeps_permanent_hours_outside_deleted_range(db): + """删除区间的孤儿清理不能碰历史小时:90 天前明细已被 retention 删掉, + 那些小时行是永久汇总(PROPOSAL §6),无条件清孤儿会整批删掉。""" + database, collector = db + _seed(collector) + old = int(time.time()) - 200 * 86400 + collector.record(username="u", provider="qoder", model="m", ok=True, now=old) + collector.rollup_hourly() + conn = database.connect() + collector.purge_expired(90) # 模拟保留期任务:明细没了,汇总还在 + old_hour = (old // 3600) * 3600 + assert conn.execute("SELECT COUNT(*) FROM usage_hourly WHERE hour_utc = ?", + (old_hour,)).fetchone()[0] == 1 + + hours = PURGE.hour_range(conn, "ok = 0", []) + PURGE.delete_groups(conn, provider="qoder") + conn.commit() + PURGE.sync_hourly(conn, hours) + assert conn.execute("SELECT COUNT(*) FROM usage_hourly WHERE hour_utc = ?", + (old_hour,)).fetchone()[0] == 1 + + +def test_main_preview_and_apply(db, tmp_path, capsys): + database, collector = db + _seed(collector) + db_path = Path(database.path) + database.close() + + assert PURGE.main(["--db", str(db_path), "--provider", "qoder"]) == 0 + assert "共 2 条失败明细" in capsys.readouterr().out + assert sqlite3.connect(str(db_path)).execute( + "SELECT COUNT(*) FROM usage_events").fetchone()[0] == 4 + + assert PURGE.main(["--db", str(db_path), "--provider", "qoder", "--apply"]) == 0 + assert "已删除 2 条明细" in capsys.readouterr().out + assert sqlite3.connect(str(db_path)).execute( + "SELECT COUNT(*) FROM usage_events").fetchone()[0] == 2 + assert list(tmp_path.glob("*.bak-*")), "apply 前必须留下备份" + + +def test_main_all_flag_purges_every_failure(db, capsys): + database, collector = db + _seed(collector) + db_path = Path(database.path) + database.close() + assert PURGE.main(["--db", str(db_path), "--all", "--apply", "--yes"]) == 0 + assert "已删除 3 条明细" in capsys.readouterr().out + assert sqlite3.connect(str(db_path)).execute( + "SELECT COUNT(*) FROM usage_events WHERE ok = 0").fetchone()[0] == 0 + + +def test_main_reports_nothing_to_clean(db, capsys): + database, _collector = db + db_path = Path(database.path) + database.close() + assert PURGE.main(["--db", str(db_path), "--all"]) == 0 + assert "没有需要清理的记录" in capsys.readouterr().out + + +def test_main_requires_filter_and_rejects_missing_db(db, tmp_path): + database, _collector = db + db_path = Path(database.path) + database.close() + with pytest.raises(SystemExit): + PURGE.main(["--db", str(db_path)]) # 没给条件 → 拒绝 + with pytest.raises(SystemExit): + PURGE.main(["--db", str(db_path), "--days", "0"]) # 非正窗口 → 拒绝 + with pytest.raises(SystemExit): + PURGE.main(["--db", str(tmp_path / "nope.sqlite3"), "--all"]) + + +def test_main_all_requires_yes_and_rejects_narrowing_flags(db): + """`--all` 是「无条件删全部」:必须 --yes 二次确认,且不能叠加收窄条件。""" + database, collector = db + _seed(collector) + db_path = Path(database.path) + database.close() + with pytest.raises(SystemExit): + PURGE.main(["--db", str(db_path), "--all", "--apply"]) # 缺 --yes + with pytest.raises(SystemExit): + PURGE.main(["--db", str(db_path), "--all", "--days", "7"]) # 静默收窄 diff --git a/tests/stats/test_query_percentiles.py b/tests/stats/test_query_percentiles.py index 067e9ebc..301ae05d 100644 --- a/tests/stats/test_query_percentiles.py +++ b/tests/stats/test_query_percentiles.py @@ -122,11 +122,11 @@ def test_errors_groups_by_provider_model_and_error_type(stats): rows = query.errors(since=now - 100, now=now) assert rows == [ {"provider": "trae", "model": "glm", "error_type": "rate_limit", - "failures": 3, "last_ts": now + 2}, + "failures": 3, "rescued": 0, "last_ts": now + 2}, {"provider": "codebuddy", "model": "kimi", "error_type": "rate_limit", - "failures": 1, "last_ts": now + 20}, + "failures": 1, "rescued": 0, "last_ts": now + 20}, {"provider": "trae", "model": "glm", "error_type": "upstream_error", - "failures": 1, "last_ts": now + 10}, + "failures": 1, "rescued": 0, "last_ts": now + 10}, ] @@ -174,3 +174,97 @@ def test_errors_limit_is_floored_at_one(stats): def test_errors_empty(stats): _collector, query = stats assert query.errors(now=int(time.time())) == [] + + +def test_errors_counts_rescued_attempts_by_request_id(stats): + """同 request_id 另有成功行 = 换号救回,单列下发(failures − rescued 才是受损数)。 + + 换号重试是常态:`invalid_request` 每次上游拒绝都落一行,随后换渠道成功 + 也留着这条失败。不区分会把「已救回的尝试」读成「用户没拿到结果」。 + """ + from src.request_context import set_request_id + + collector, query = stats + now = 1_700_000_000 + try: + # req-a:先失败后成功(救回) + set_request_id("req-a") + collector.record(username="u", provider="trae", model="glm", ok=False, + error_type="invalid_request", now=now) + collector.record(username="u", provider="codebuddy", model="glm", ok=True, + now=now + 1) + # req-b:只失败(未救回) + set_request_id("req-b") + collector.record(username="u", provider="trae", model="glm", ok=False, + error_type="invalid_request", now=now + 2) + # req-c:同一渠道两次失败 + 一次成功,两行都算救回 + set_request_id("req-c") + collector.record(username="u", provider="trae", model="glm", ok=False, + error_type="invalid_request", now=now + 3) + collector.record(username="u", provider="trae", model="glm", ok=False, + error_type="invalid_request", now=now + 4) + collector.record(username="u", provider="trae", model="glm", ok=True, now=now + 5) + finally: + set_request_id(None) + + rows = query.errors(since=now - 10, now=now) + assert len(rows) == 1 + row = rows[0] + assert row["failures"] == 4 # req-a 1 + req-b 1 + req-c 2 + assert row["rescued"] == 3 # req-a 1 + req-c 2 + assert row["failures"] - row["rescued"] == 1 # 只有 req-b 真正受损 + + +def test_errors_rescued_ignores_rows_without_request_id(stats): + """老库历史行 request_id 为 NULL:无法关联成功行,恒算未救回(不误报救回)。""" + collector, query = stats + now = 1_700_000_000 + collector.record(username="u", provider="trae", model="glm", ok=False, + error_type="rate_limit", now=now) + row = query.errors(since=now - 10, now=now)[0] + assert row["failures"] == 1 and row["rescued"] == 0 + + +def test_errors_rescued_is_scoped_to_same_user(stats): + """request_id 来自入站 `X-Request-ID`(客户端可伪造):跨用户不能互相「救回」。 + + 只按 ID 关联时,bob 的成功行会把 alice 的失败标成已救回(实测可复现), + 传了 `username=` 的查询同样会读到跨租户数据。 + """ + from src.request_context import set_request_id + + collector, query = stats + now = 1_700_000_000 + try: + set_request_id("shared-id") + collector.record(username="alice", provider="trae", model="glm", ok=False, + error_type="rate_limit", now=now) + collector.record(username="bob", provider="codebuddy", model="glm", ok=True, + now=now + 1) + finally: + set_request_id(None) + + alice = query.errors(username="alice", since=now - 10, now=now) + assert alice[0]["failures"] == 1 and alice[0]["rescued"] == 0 + everyone = query.errors(since=now - 10, now=now) + assert sum(row["rescued"] for row in everyone) == 0 + + +def test_errors_rescued_sees_success_after_window_end(stats): + """救援判定不叠加时间窗:成功行可能落在窗口右界之后(同一请求的重试)。""" + from src.request_context import set_request_id + + collector, query = stats + now = 1_700_000_000 + try: + set_request_id("req-late") + collector.record(username="u", provider="trae", model="glm", ok=False, + error_type="invalid_request", now=now) + collector.record(username="u", provider="codebuddy", model="glm", ok=True, + now=now + 500) + finally: + set_request_id(None) + + # 窗口 [now-10, now+100):成功行在窗口外,仍应算救回 + rows = query.errors(since=now - 10, until=now + 100, now=now) + assert rows[0]["failures"] == 1 and rows[0]["rescued"] == 1 diff --git a/tests/webapp/test_request_id.py b/tests/webapp/test_request_id.py index 6d5bb6c9..2f42afec 100644 --- a/tests/webapp/test_request_id.py +++ b/tests/webapp/test_request_id.py @@ -172,6 +172,24 @@ async def keyed(): assert get_api_key_id() is None +def test_middleware_resets_request_id_after_request(): + """request_id 随请求结束复原:超时存活的子任务不能继承旧 ID 写统计。""" + from src.request_context import get_request_id + + app = FastAPI() + app.add_middleware(RequestIdMiddleware) + + @app.get("/rid") + async def rid(): + from src.request_context import set_request_id + set_request_id("stale-after-this") + return {"rid": get_request_id()} + + with TestClient(app) as client: + assert client.get("/rid").json() == {"rid": "stale-after-this"} + assert get_request_id() is None + + def test_middleware_resets_api_key_id_even_when_handler_raises(): from src.request_context import get_api_key_id, set_api_key_id diff --git a/web/src/api/statsErrors.ts b/web/src/api/statsErrors.ts index b0976d6f..9ff859a6 100644 --- a/web/src/api/statsErrors.ts +++ b/web/src/api/statsErrors.ts @@ -15,6 +15,15 @@ export interface StatsErrorRow { error_type: string | null; /** 该 (渠道, 模型, 错误类型) 组合的失败次数 */ failures: number; + /** + * 其中「换号已救回」的次数:同一用户同一 request_id 另有成功行。 + * + * 换号重试是常态——`invalid_request` 每次上游拒绝都落一行,随后换渠道成功 + * 也留着这条失败。`failures` 是**尝试**口径,`failures - rescued` 是 + * **未被救回的失败尝试数**(同一请求多次失败会算多次,不等于受损请求数)。 + * 老库历史行 request_id 为 NULL,恒算未救回。 + */ + rescued: number; /** 最近一次失败的时刻(UTC 秒) */ last_ts: number; } diff --git a/web/src/pages/StatsPage.tsx b/web/src/pages/StatsPage.tsx index 54d67434..6736c1bd 100644 --- a/web/src/pages/StatsPage.tsx +++ b/web/src/pages/StatsPage.tsx @@ -14,7 +14,7 @@ import { useStatsTimeline, } from "../api/hooks"; import { formatCacheRate, formatCompact, formatCredit, formatLatency, formatMoney, formatNumber, formatSpeed, formatSpeedPair, formatTime, formatTimeParts, usageErrorLabel } from "../api/display"; -import { useStatsErrors } from "../api/statsErrors"; +import { useStatsErrors, type StatsErrorRow } from "../api/statsErrors"; import { ModelTrendChart } from "../components/ModelTrendChart"; import { PageHeader } from "../components/PageHeader"; import { PageSkeleton } from "../components/PageSkeleton"; @@ -468,6 +468,8 @@ export function StatsPage() { // 计数条按本批最大值归一(相对长度才有意义);全 0 时给 1 避免除零 const maxFailures = Math.max(1, ...errorRows.map((row) => row.failures)); const barWidth = (failures: number) => Math.round((failures / maxFailures) * 100); + /** 失败数减去换号救回数 = 未被救回的失败尝试数(rescued 恒 ≤ failures)。 */ + const unrescued = (row: StatsErrorRow) => row.failures - row.rescued; // 四个维度一次性取数(各维度行结构一致),切 tab 只换渲染、不再发请求; // viewer 看不到用户/凭证 tab,若状态残留则回落到渠道维度 @@ -889,6 +891,7 @@ export function StatsPage() { 模型 错误类型 失败数 + 未救回 最近失败 @@ -930,6 +933,14 @@ export function StatsPage() { {formatNumber(row.failures)} + {/* 失败数是「尝试」口径:换号成功后失败行照样留着。 + 未救回 = failures − rescued,是未被救回的失败尝试数。 */} + + 0 ? "text-destructive" : "text-muted-foreground"}> + {formatNumber(unrescued(row))} + + {formatTime(row.last_ts)} @@ -941,7 +952,9 @@ export function StatsPage() { )}

窗口缺省为最近 24 小时(与上方时间范围一致时按所选范围统计); - 未上报错误类型的历史行显示为「失败」。点任意一行可钻取到下方「请求明细」(只看失败)。 + 未上报错误类型的历史行显示为「失败」。失败数是尝试口径: + 换号重试每次上游拒绝都记一行,「未救回」是未被救回的失败尝试数 + (同一请求多次失败会算多次;同请求另有成功行即算救回)。点任意一行可钻取到下方「请求明细」(只看失败)。

)} diff --git a/web/src/pages/__tests__/StatsPage.errors.test.tsx b/web/src/pages/__tests__/StatsPage.errors.test.tsx index c6e66840..46edbbb3 100644 --- a/web/src/pages/__tests__/StatsPage.errors.test.tsx +++ b/web/src/pages/__tests__/StatsPage.errors.test.tsx @@ -54,6 +54,13 @@ describe("StatsPage 失败归因", () => { // 未上报错误类型 → 「失败」(不把 NULL 原样透传) const unknownRow = within(table).getByTestId("error-row-trae-deepseek-v4-unknown"); expect(unknownRow).toHaveTextContent("失败"); + // 未救回 = failures − rescued:12−4=8 / 4−4=0 / 2−0=2 + expect(screen.getByTestId("error-unrescued-trae-glm-5.2-rate_limit")) + .toHaveTextContent("8"); + expect(screen.getByTestId("error-unrescued-codebuddy-kimi-k3-upstream_error")) + .toHaveTextContent("0"); + expect(screen.getByTestId("error-unrescued-trae-deepseek-v4-unknown")) + .toHaveTextContent("2"); }); it("点归因行 → 明细按 provider+model+ok=0 重新取数并显示过滤标签", async () => { diff --git a/web/src/test-utils/stats.ts b/web/src/test-utils/stats.ts index 223ff00d..6c72cb12 100644 --- a/web/src/test-utils/stats.ts +++ b/web/src/test-utils/stats.ts @@ -99,10 +99,10 @@ export const EVENTS_PAGE2 = { export const ERRORS = { errors: [ { provider: "trae", model: "glm-5.2", error_type: "rate_limit", failures: 12, - last_ts: BASE_TS + 300 }, + rescued: 4, last_ts: BASE_TS + 300 }, { provider: "codebuddy", model: "kimi-k3", error_type: "upstream_error", failures: 4, - last_ts: BASE_TS + 200 }, + rescued: 4, last_ts: BASE_TS + 200 }, { provider: "trae", model: "deepseek-v4", error_type: null, failures: 2, - last_ts: BASE_TS + 100 }, + rescued: 0, last_ts: BASE_TS + 100 }, ], }; From 4a2f7959f108f113719e1759a8be0e7eeeb06899 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 07:57:55 +0800 Subject: [PATCH 009/153] =?UTF-8?q?fix(web):=20=E9=94=81=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=8A=8A=20fast-uri=20=E6=8F=90=E5=88=B0=203.1.9=EF=BC=884=20?= =?UTF-8?q?=E4=B8=AA=20Dependabot=20=E5=91=8A=E8=AD=A6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fast-uri 3.1.8 命中 GHSA-wjr9-j795-jxcr(host 混淆,medium)、 GHSA-w74x-v6f9-j3xg(scheme 伪装,medium)、GHSA-xhq7-xmqj-769h(路径等价, low)、CVE-2026-110725(scheme 注入,low),修复版均为 3.1.9。 依赖链是 devDependencies:shadcn → @dotenvx/dotenvx / @modelcontextprotocol/sdk → ajv 8.20.0 → fast-uri。`pnpm update fast-uri` 在 ajv 的 `^3.0.1` 范围内升到 3.1.9,lock 只动这一条。autoInstallPeers=false 与 lock 的 settings.autoInstallPeers 保持一致,Dockerfile 已 COPY web/.npmrc。 --- web/pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 3f286f8b..778175ee 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -1980,8 +1980,8 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fast-uri@3.1.8: - resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} fastq@1.20.3: resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} @@ -4737,7 +4737,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.8 + fast-uri: 3.1.9 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -5134,7 +5134,7 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-uri@3.1.8: {} + fast-uri@3.1.9: {} fastq@1.20.3: dependencies: From f2b0389aa63a069df272aa2b80af50724d23d551 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 08:21:02 +0800 Subject: [PATCH 010/153] =?UTF-8?q?fix(web):=20=E9=94=81=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=8A=8A=20body-parser=20=E6=8F=90=E5=88=B0=202.4.0=EF=BC=88GH?= =?UTF-8?q?SA-27r3-jxmh-p828=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm audit` 全量(含 dev)报 body-parser 2.3.0 命中「压缩请求体尾部字节 DoS」,high,修复版 2.4.0。依赖链同样是 devDependencies: shadcn → @modelcontextprotocol/sdk → express 5.3.0 → body-parser。 express 声明 `^2.3.0`,`pnpm update body-parser` 落在范围内。 CI 的 `pnpm audit --prod` 看不到它(dev 依赖),但本地全量审计能, 锁文件升级无行为风险。 --- web/pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 778175ee..16894514 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -1608,8 +1608,8 @@ packages: bidi-js@1.1.0: resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} - body-parser@2.3.0: - resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + body-parser@2.4.0: + resolution: {integrity: sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==} engines: {node: '>=18'} brace-expansion@5.0.12: @@ -4779,7 +4779,7 @@ snapshots: dependencies: require-from-string: 2.0.2 - body-parser@2.3.0: + body-parser@2.4.0: dependencies: bytes: 3.1.2 content-type: 2.1.0 @@ -5094,7 +5094,7 @@ snapshots: express@5.3.0: dependencies: accepts: 2.0.0 - body-parser: 2.3.0 + body-parser: 2.4.0 content-disposition: 2.0.1 content-type: 2.1.0 cookie: 0.7.2 From d3996a6a9cf9bb29df0fc8a7741d634b097f97f8 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 16:45:57 +0800 Subject: [PATCH 011/153] =?UTF-8?q?refactor(backend):=20=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=20provider=20=E5=85=AC=E5=85=B1=E8=A7=A3=E6=9E=90=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=203=20=E4=B8=AA=E4=BB=BB=E5=8A=A1/=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E4=BD=93=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provider 层去重(净减 ~210 行): - openai_chunk: 新增 usage_from_openai / details_int,7 份 _usage 合一。 语义取并集:details 优先、顶层兜底,TRAE 的 cache_read_input_tokens 与 Zen/CB 的 credit 走参数,不猜键。 - auth_state: 新增泛型 AuthStateStore,4 份 owner/consume/cancel/cleanup 合一;各渠道只保留 begin 载荷包装与取值方法名。 - credential_fields: 华为云系(CodeArts/OfficeAce)的 text_field / expiration_from_raw / jwk_from_raw / epoch_from_iso / relogin_required 合一,渠道保留同名薄包装(测试仍可 import)。 - proxy: STREAM_TIMEOUT / SHORT_TIMEOUT 由 7 份常量收敛为一份。 - codearts/signer: sdk_date 收口,officeace 两处改用它。 顺带修复: - tasks/activity: _done_scopes 补 prune。此前只增不减,长驻进程每天每 账号攒一条(checkin 早已有 prune)。 - tasks/runner: 手动「立即运行签到」此前不绕过 due(),非时点窗口返回 ran=False,与 run_task 文档契约不符。checkin 补 manual 分支。 - api/logs: 关闭态 404 错误体缺 type 字段,改用 error_payload 与 metrics 等端点同形状。 两处核实后判定不改: - codebuddy _message_of 的 response.json():非流式 httpx 已把 body 全读进 内存,read_body_bounded 在该路径用不上(要用得先改 stream())。 - DEAD 硬禁用不清模型冷却:凭证已不可选,残留行由 revive() 与 purge_expired_model_cooldowns 兜底,与 mark_relogin_required 等价。 新增 tests/provider/{test_openai_chunk,test_auth_state,test_credential_fields}.py。 覆盖率 100%,3889 后端 + 644 前端全绿。 --- src/api/logs.py | 2 +- src/provider/auth_state.py | 113 ++++++++++++++++++++ src/provider/codearts/client.py | 10 +- src/provider/codearts/credential.py | 37 +------ src/provider/codearts/oauth.py | 54 ++-------- src/provider/codearts/signer.py | 7 ++ src/provider/codebuddy/activity.py | 4 +- src/provider/codebuddy/checkin.py | 4 +- src/provider/codebuddy/client.py | 5 +- src/provider/codebuddy/events.py | 30 +----- src/provider/codebuddy/growth.py | 4 +- src/provider/codebuddy/oauth.py | 65 +++-------- src/provider/codebuddy/refresh.py | 4 +- src/provider/credential_fields.py | 79 ++++++++++++++ src/provider/kilo/client.py | 5 +- src/provider/kilo/events.py | 19 +--- src/provider/officeace/client.py | 12 +-- src/provider/officeace/credential.py | 43 +------- src/provider/officeace/events.py | 22 +--- src/provider/officeace/oauth.py | 91 +++------------- src/provider/openai_chunk.py | 53 ++++++++- src/provider/proxy.py | 8 +- src/provider/qoder/auth.py | 54 ++-------- src/provider/qoder/client.py | 5 +- src/provider/qoder/events.py | 17 +-- src/provider/trae/client.py | 5 +- src/provider/zen/client.py | 5 +- src/provider/zen/events.py | 22 +--- src/tasks/activity.py | 13 ++- src/tasks/runner.py | 9 +- tests/provider/officeace/test_oauth.py | 3 +- tests/provider/test_auth_state.py | 101 +++++++++++++++++ tests/provider/test_credential_fields.py | 80 ++++++++++++++ tests/provider/test_openai_chunk.py | 70 ++++++++++++ tests/provider/test_stream_write_timeout.py | 3 +- tests/tasks/test_activity.py | 12 +++ tests/tasks/test_status.py | 24 +++++ 37 files changed, 662 insertions(+), 432 deletions(-) create mode 100644 src/provider/auth_state.py create mode 100644 src/provider/credential_fields.py create mode 100644 tests/provider/test_auth_state.py create mode 100644 tests/provider/test_credential_fields.py create mode 100644 tests/provider/test_openai_chunk.py diff --git a/src/api/logs.py b/src/api/logs.py index 1f2bedf2..f5e3db10 100644 --- a/src/api/logs.py +++ b/src/api/logs.py @@ -40,7 +40,7 @@ # 非流式端点默认返回行数;上限由缓冲容量决定(不能超过实际保留的行)。 DEFAULT_LOG_LIMIT = 200 -_DISABLED = {"error": {"message": "log page disabled", "code": "not_found", "status": 404}} +_DISABLED = error_payload("log page disabled", "not_found", 404) def _sse_frame(line: dict[str, Any]) -> str: diff --git a/src/provider/auth_state.py b/src/provider/auth_state.py new file mode 100644 index 00000000..2b82bc37 --- /dev/null +++ b/src/provider/auth_state.py @@ -0,0 +1,113 @@ +"""OAuth pending 登录会话的共享登记表。 + +四个渠道(CodeBuddy / Qoder / CodeArts / OfficeAce)的 `AuthStateStore` 曾各写 +一份,其中 `owner` / `consume` / `cancel` / `cleanup` 逐字相同。差异只在 +`begin` 的载荷(上游 state / verifier+nonce / LoginSession)与取值方法名, +故基类只做「登记 + 归属校验 + 消费 + 过期清理」,载荷由泛型承载。 + +对外 `state` 一律是**本地生成的 reservation**,不是上游签发的 state:上游 +state 会写进授权链接(浏览器可见),拿它当会话键等于让人凭 URL 消费别人的 +登录。 +""" + +from __future__ import annotations + +import base64 +import hashlib +import secrets +import time +from dataclasses import dataclass + + +@dataclass(slots=True) +class Reservation[T]: + """一条 pending 登录:归属人 + 创建时刻 + 渠道私有载荷。""" + + username: str + created_at: int + payload: T + + +class AuthStateStore[T]: + """state 的归属校验、消费与过期清理。 + + `max_pending` 非 None 时在 `begin` 里顶掉最老的会话:攒了太多没登完的 + 就拒绝会让人以为功能坏了,而每条 pending 会话都在轮询上游,留一堆只是 + 浪费(OfficeAce 的实测需求)。 + """ + + def __init__(self, ttl_seconds: int, *, + max_pending: int | None = None) -> None: + self._ttl = ttl_seconds + self._max_pending = max_pending + self._entries: dict[str, Reservation[T]] = {} + + def begin(self, username: str, payload: T, now: int | None = None) -> str: + self.cleanup(now) + if self._max_pending is not None: + while len(self._entries) >= self._max_pending: + oldest = min(self._entries, + key=lambda key: self._entries[key].created_at) + del self._entries[oldest] + reservation = secrets.token_urlsafe(18) + self._entries[reservation] = Reservation( + username=username, + created_at=int(now if now is not None else time.time()), + payload=payload) + return reservation + + def owner(self, auth_state: str, username: str, now: int | None = None) -> bool: + """归属校验,**含 TTL**:过期 reservation 不得再轮询/重放。 + + `cleanup()` 只在 `begin()` 时触发;若 owner 不判过期,一个过期的 + state 在下次 begin 之前会一直可被轮询(每次轮询都打上游)。 + """ + entry = self._entries.get(auth_state) + if entry is None or entry.username != username: + return False + current = int(now if now is not None else time.time()) + if current - entry.created_at >= self._ttl: + del self._entries[auth_state] # 惰性清理,避免字典堆积 + return False + return True + + def payload(self, auth_state: str, username: str, + now: int | None = None) -> T | None: + """归属校验通过才返回载荷(调用方据此轮询上游 / 取私钥)。""" + if not self.owner(auth_state, username, now): + return None + return self._entries[auth_state].payload + + def reservation(self, auth_state: str, username: str, + now: int | None = None) -> Reservation[T] | None: + """归属校验通过才返回整条登记(含归属人 / 创建时刻 / 载荷)。""" + if not self.owner(auth_state, username, now): + return None + return self._entries[auth_state] + + def consume(self, auth_state: str, username: str, now: int | None = None) -> bool: + """消费:成功后该 state 不可再次轮询或重放。""" + if not self.owner(auth_state, username, now): + return False + del self._entries[auth_state] + return True + + def cancel(self, auth_state: str, username: str, now: int | None = None) -> bool: + return self.consume(auth_state, username, now) + + def cleanup(self, now: int | None = None) -> None: + current = int(now if now is not None else time.time()) + for key in [k for k, v in self._entries.items() + if current - v.created_at >= self._ttl]: + del self._entries[key] + + def __len__(self) -> int: + return len(self._entries) + + +def pkce_pair(verifier: str | None = None) -> tuple[str, str]: + """PKCE `(code_verifier, code_challenge)`;challenge = BASE64URL(SHA256(v))。""" + value = verifier or secrets.token_urlsafe(32) + digest = hashlib.sha256(value.encode("ascii")).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return value, challenge diff --git a/src/provider/codearts/client.py b/src/provider/codearts/client.py index 115719aa..1df01f46 100644 --- a/src/provider/codearts/client.py +++ b/src/provider/codearts/client.py @@ -33,7 +33,7 @@ from ...provider import base from ...provider.base import Event, Model, Quota, UpstreamReloginRequired -from ...provider.proxy import build_client +from ...provider.proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client from . import auth as codearts_auth from . import events as codearts_events from . import signer @@ -61,13 +61,11 @@ # 写超时覆盖的是上传请求体阶段,不是首字等待:大上下文请求体可达数 MB, # 经代理链路 10s 会稳定超时;首字等待由 read=None + # stream_first_event_timeout_seconds 负责,两者不要混为一谈。 -STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) -SHORT_TIMEOUT = httpx.Timeout(30.0) -def sdk_date(now: float | None = None) -> str: - """`X-Sdk-Date` 格式(UTC,`YYYYMMDDTHHMMSSZ`)。""" - return time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(now if now is not None else time.time())) + +# 签名与请求必须同值,实现归 signer(officeace 共用同一份) +sdk_date = signer.sdk_date def new_chat_id() -> str: diff --git a/src/provider/codearts/credential.py b/src/provider/codearts/credential.py index ccee3cd6..7b975c36 100644 --- a/src/provider/codearts/credential.py +++ b/src/provider/codearts/credential.py @@ -13,12 +13,11 @@ from __future__ import annotations -import json import time from dataclasses import dataclass, field from typing import Any, ClassVar -from ...provider.token_expiry import normalize_epoch +from .. import credential_fields from .events import CLIENT_ID, UpstreamProtocolViolation @@ -120,37 +119,9 @@ def from_dict(cls, raw: dict[str, Any]) -> CodeArtsCredential: ) -def _expiration_from_raw(raw: Any) -> int: - """到期时间归一:数字(epoch,可为毫秒)/ ISO8601 / 数字字符串 → 秒。""" - if isinstance(raw, bool): - return 0 - if isinstance(raw, (int, float)): - return normalize_epoch(int(raw)) - if isinstance(raw, str) and raw.strip(): - text = raw.strip() - try: - return normalize_epoch(int(text)) - except ValueError: - pass - try: - from datetime import datetime - - return int(datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()) - except ValueError: - return 0 - return 0 - - -def _jwk_from_raw(raw: Any) -> dict[str, str]: - """DPoP 私钥 JWK:只收字符串字段,畸形输入返回空(由刷新时报错兜底)。""" - if isinstance(raw, str): - try: - raw = json.loads(raw) - except json.JSONDecodeError: - return {} - if not isinstance(raw, dict): - return {} - return {str(key): value for key, value in raw.items() if isinstance(value, str)} +# 两家华为云系渠道共用同一份解析(测试与旧调用方沿用原名) +_expiration_from_raw = credential_fields.expiration_from_raw +_jwk_from_raw = credential_fields.jwk_from_raw def parse_credentials(raw: dict[str, Any]) -> CodeArtsCredential: diff --git a/src/provider/codearts/oauth.py b/src/provider/codearts/oauth.py index 6d0f4871..3e7d378c 100644 --- a/src/provider/codearts/oauth.py +++ b/src/provider/codearts/oauth.py @@ -13,14 +13,13 @@ from __future__ import annotations import logging -import secrets -import time from dataclasses import dataclass from typing import TYPE_CHECKING from urllib.parse import parse_qs, urlsplit import httpx +from ...provider.auth_state import AuthStateStore as AuthStateStoreBase from ...provider.base import AuthFlow, AuthResult, AuthSession from ...provider.proxy import build_client from . import auth as codearts_auth @@ -90,63 +89,28 @@ def parse_first_stage_callback(raw: str) -> tuple[str, str]: @dataclass class _Reservation: - username: str - created_at: int + """本渠道载荷:登录会话 + 门户首次回调下发的 secret。""" + session: LoginSession # 门户首次回调下发的 secret(ticket 轮询必须用这份——本地生成的那份上游 # 不认,实测恒回「无效 ticketId」);""=尚未收到首次回调。 portal_secret: str = "" -class AuthStateStore: +class AuthStateStore(AuthStateStoreBase[_Reservation]): """state 的归属校验、消费与过期清理(对齐 CodeBuddy 的同名实现语义)。""" def __init__(self, ttl_seconds: int = AUTH_STATE_TTL_SECONDS) -> None: - self._ttl = ttl_seconds - self._entries: dict[str, _Reservation] = {} + super().__init__(ttl_seconds) def begin(self, username: str, session: LoginSession, now: int | None = None) -> str: - self.cleanup(now) - reservation = secrets.token_urlsafe(18) - self._entries[reservation] = _Reservation( - username=username, - created_at=int(now if now is not None else time.time()), - session=session) - return reservation - - def owner(self, auth_state: str, username: str, now: int | None = None) -> bool: - """归属校验,**含 TTL**(同 CodeBuddy):过期 state 不得再轮询/重放。""" - entry = self._entries.get(auth_state) - if entry is None or entry.username != username: - return False - current = int(now if now is not None else time.time()) - if current - entry.created_at >= self._ttl: - del self._entries[auth_state] - return False - return True + return super().begin(username, _Reservation(session=session), now) def session_entry(self, auth_state: str, username: str, now: int | None = None) -> _Reservation | None: - if not self.owner(auth_state, username, now): - return None - return self._entries[auth_state] - - def consume(self, auth_state: str, username: str, now: int | None = None) -> bool: - """消费:成功后该 state 不可再次轮询或重放。""" - if not self.owner(auth_state, username, now): - return False - del self._entries[auth_state] - return True - - def cancel(self, auth_state: str, username: str, now: int | None = None) -> bool: - return self.consume(auth_state, username, now) - - def cleanup(self, now: int | None = None) -> None: - current = int(now if now is not None else time.time()) - for key in [k for k, v in self._entries.items() - if current - v.created_at >= self._ttl]: - del self._entries[key] + entry = self.reservation(auth_state, username, now) + return None if entry is None else entry.payload class CodeArtsOAuth: @@ -157,7 +121,7 @@ def __init__(self, config: LoginConfig, *, port: int = DEFAULT_CALLBACK_PORT, proxy: str | None = None) -> None: self.config = config self.port = port - self.store = store or AuthStateStore() + self.store = store if store is not None else AuthStateStore() self._client = client # 登录后补账号身份(uid/user_name/nickname)的签名客户端:token 响应 # 通常不带用户名,不补则凭证昵称为空、统计明细的凭证列空白。生产装配 diff --git a/src/provider/codearts/signer.py b/src/provider/codearts/signer.py index 8f9170a7..20d05cd1 100644 --- a/src/provider/codearts/signer.py +++ b/src/provider/codearts/signer.py @@ -18,6 +18,7 @@ import hashlib import hmac +import time from collections.abc import Mapping from dataclasses import dataclass from urllib.parse import quote @@ -41,6 +42,12 @@ def sha256_hex(payload: bytes) -> str: return hashlib.sha256(payload).hexdigest() +def sdk_date(now: float | None = None) -> str: + """`X-Sdk-Date`:UTC `YYYYMMDDTHHMMSSZ`(签名与请求必须同值)。""" + return time.strftime("%Y%m%dT%H%M%SZ", + time.gmtime(now if now is not None else time.time())) + + def hmac_sha256_hex(key: str, message: str) -> str: return hmac.new(key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest() diff --git a/src/provider/codebuddy/activity.py b/src/provider/codebuddy/activity.py index 84540648..e95a4cbd 100644 --- a/src/provider/codebuddy/activity.py +++ b/src/provider/codebuddy/activity.py @@ -35,6 +35,7 @@ import httpx +from ..proxy import build_client from .client import build_headers from .credential import CodeBuddyCredential from .events import UpstreamProtocolViolation @@ -166,8 +167,7 @@ def __init__(self, endpoint: str, *, client: httpx.AsyncClient | None = None, @property def _http(self) -> httpx.AsyncClient: if self._client is None: - self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0), trust_env=False, - proxy=self._proxy) + self._client = build_client(timeout=httpx.Timeout(30.0), proxy=self._proxy) return self._client async def aclose(self) -> None: diff --git a/src/provider/codebuddy/checkin.py b/src/provider/codebuddy/checkin.py index aea06992..e5ae7f56 100644 --- a/src/provider/codebuddy/checkin.py +++ b/src/provider/codebuddy/checkin.py @@ -15,6 +15,7 @@ import httpx from ...provider.base import CheckinResult +from ..proxy import build_client from .client import build_headers from .credential import CodeBuddyCredential from .events import UpstreamProtocolViolation @@ -153,8 +154,7 @@ async def fetch_status(self, credential: CodeBuddyCredential) -> CheckinStatus: @property def _http(self) -> httpx.AsyncClient: if self._client is None: - self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0), trust_env=False, - proxy=self._proxy) + self._client = build_client(timeout=httpx.Timeout(30.0), proxy=self._proxy) return self._client async def aclose(self) -> None: diff --git a/src/provider/codebuddy/client.py b/src/provider/codebuddy/client.py index 43d5cfa9..8020961a 100644 --- a/src/provider/codebuddy/client.py +++ b/src/provider/codebuddy/client.py @@ -25,7 +25,7 @@ from ...engine.sse import iter_frames from ...provider import base from ...provider.base import ErrKind, Event, GrowthResult, Model, Quota -from ...provider.proxy import build_client +from ...provider.proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client from . import events as cb_events from .credential import CodeBuddyCredential, parse_credential from .events import UpstreamProtocolViolation @@ -42,8 +42,7 @@ # 写超时覆盖的是上传请求体阶段,不是首字等待:大上下文请求体可达数 MB, # 经代理链路 10s 会稳定超时;首字等待由 read=None + # stream_first_event_timeout_seconds 负责,两者不要混为一谈。 -STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) -SHORT_TIMEOUT = httpx.Timeout(30.0) + DEFAULT_MODELS: tuple[str, ...] = ("glm-5.2", "deepseek-v4-pro") EP_CONFIG = "/v3/config" diff --git a/src/provider/codebuddy/events.py b/src/provider/codebuddy/events.py index f85e52a4..ed828f92 100644 --- a/src/provider/codebuddy/events.py +++ b/src/provider/codebuddy/events.py @@ -20,7 +20,7 @@ body_hint, business_codes, ) -from ..openai_chunk import first_choice, is_blank_tool_call +from ..openai_chunk import first_choice, is_blank_tool_call, usage_from_openai def parse_frame(frame: SSEFrame) -> Event | None: @@ -94,33 +94,7 @@ def parse_all_events(frame: SSEFrame) -> list[Event]: def _usage(raw: dict[str, Any]) -> Usage: - def as_int(key: str) -> int | None: - value = raw.get(key) - return value if isinstance(value, int) and not isinstance(value, bool) else None - - def details_int(details_key: str, key: str) -> int | None: - """OpenAI 惯例把这两个字段放在 details 子对象里,顶层同名字段兜底。 - - CB 实测顶层 `reasoning_tokens` 恒缺(早先据此误判为「CB 不回思考 - token」),真值在 `completion_tokens_details`;`cached_tokens` 同构。 - details 值非法(非数值 / bool)时回落顶层,仍缺则 None。 - """ - details = raw.get(details_key) - value = details.get(key) if isinstance(details, dict) else None - if isinstance(value, int) and not isinstance(value, bool): - return value - return as_int(key) - - credit = raw.get("credit") - - return Usage( - input_tokens=as_int("prompt_tokens"), - output_tokens=as_int("completion_tokens"), - reasoning_tokens=details_int("completion_tokens_details", "reasoning_tokens"), - cached_tokens=details_int("prompt_tokens_details", "cached_tokens"), - credit=float(credit) if isinstance(credit, (int, float)) and not isinstance(credit, bool) - else None, - ) + return usage_from_openai(raw, credit=True) _MODEL_RATE_LIMIT_CODE = 6004 # 该模型用量超限 → 模型级软冷却 diff --git a/src/provider/codebuddy/growth.py b/src/provider/codebuddy/growth.py index d644ae6c..de30969b 100644 --- a/src/provider/codebuddy/growth.py +++ b/src/provider/codebuddy/growth.py @@ -24,6 +24,7 @@ import httpx +from ..proxy import build_client from .client import build_headers from .credential import CodeBuddyCredential from .events import UpstreamProtocolViolation @@ -317,8 +318,7 @@ def __init__(self, endpoint: str, *, client: httpx.AsyncClient | None = None, @property def _http(self) -> httpx.AsyncClient: if self._client is None: - self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0), trust_env=False, - proxy=self._proxy) + self._client = build_client(timeout=httpx.Timeout(30.0), proxy=self._proxy) return self._client async def aclose(self) -> None: diff --git a/src/provider/codebuddy/oauth.py b/src/provider/codebuddy/oauth.py index d65e1624..3de0425b 100644 --- a/src/provider/codebuddy/oauth.py +++ b/src/provider/codebuddy/oauth.py @@ -12,13 +12,12 @@ from __future__ import annotations -import secrets -import time from dataclasses import dataclass, field from typing import Any import httpx +from ...provider.auth_state import AuthStateStore as AuthStateStoreBase from ...provider.base import AuthFlow, AuthResult, AuthSession from ...provider.proxy import build_client from .credential import CodeBuddyCredential, validate_domain @@ -43,74 +42,36 @@ class AuthProgress: @dataclass -class _Reservation: - username: str - created_at: int +class _Payload: + """本渠道载荷:上游 state(轮询必用)+ 分阶段进度(可变)。""" + upstream_state: str progress: AuthProgress = field(default_factory=AuthProgress) -class AuthStateStore: +class AuthStateStore(AuthStateStoreBase[_Payload]): """state 的归属校验、消费与过期清理。""" def __init__(self, ttl_seconds: int = AUTH_STATE_TTL_SECONDS) -> None: - self._ttl = ttl_seconds - self._entries: dict[str, _Reservation] = {} + super().__init__(ttl_seconds) def begin(self, username: str, upstream_state: str, now: int | None = None) -> str: - self.cleanup(now) - reservation = secrets.token_urlsafe(18) - self._entries[reservation] = _Reservation( - username=username, created_at=int(now if now is not None else time.time()), - upstream_state=upstream_state) - return reservation + return super().begin(username, _Payload(upstream_state=upstream_state), now) def upstream_state(self, auth_state: str, username: str, now: int | None = None) -> str | None: """轮询上游时必须用上游发的 state,而不是本地的 reservation。""" - if not self.owner(auth_state, username, now): - return None - return self._entries[auth_state].upstream_state - - def owner(self, auth_state: str, username: str, now: int | None = None) -> bool: - """归属校验,**含 TTL**:过期 reservation 不得再轮询/重放。 - - `cleanup()` 只在 `begin()` 时触发;若 owner 不判过期,一个过期的 - state 在下次 begin 之前会一直可被轮询(每次轮询都打上游)。 - """ - entry = self._entries.get(auth_state) - if entry is None or entry.username != username: - return False - current = int(now if now is not None else time.time()) - if current - entry.created_at >= self._ttl: - del self._entries[auth_state] # 惰性清理,避免字典堆积 - return False - return True + payload = self.payload(auth_state, username, now) + return None if payload is None else payload.upstream_state def progress(self, auth_state: str, username: str, now: int | None = None) -> AuthProgress | None: - if not self.owner(auth_state, username, now): - return None - return self._entries[auth_state].progress + payload = self.payload(auth_state, username, now) + return None if payload is None else payload.progress def set_progress(self, auth_state: str, progress: AuthProgress) -> None: if auth_state in self._entries: - self._entries[auth_state].progress = progress - - def consume(self, auth_state: str, username: str, now: int | None = None) -> bool: - """消费:成功后该 state 不可再次轮询或重放。""" - if not self.owner(auth_state, username, now): - return False - del self._entries[auth_state] - return True - - def cancel(self, auth_state: str, username: str, now: int | None = None) -> bool: - return self.consume(auth_state, username, now) - - def cleanup(self, now: int | None = None) -> None: - current = int(now if now is not None else time.time()) - for key in [k for k, v in self._entries.items() if current - v.created_at >= self._ttl]: - del self._entries[key] + self._entries[auth_state].payload.progress = progress class CodeBuddyOAuth: @@ -118,7 +79,7 @@ def __init__(self, endpoint: str, *, client: httpx.AsyncClient | None = None, store: AuthStateStore | None = None, proxy: str | None = None) -> None: self.endpoint = endpoint - self.store = store or AuthStateStore() + self.store = store if store is not None else AuthStateStore() self._client = client self._proxy = proxy diff --git a/src/provider/codebuddy/refresh.py b/src/provider/codebuddy/refresh.py index 0bf8a53e..608306db 100644 --- a/src/provider/codebuddy/refresh.py +++ b/src/provider/codebuddy/refresh.py @@ -14,6 +14,7 @@ import httpx from ..base import UpstreamReloginRequired +from ..proxy import build_client from .credential import CodeBuddyCredential, validate_domain from .events import UpstreamProtocolViolation from .headers import EP_ACCOUNTS, EP_SWITCH_ENTERPRISE, EP_TOKEN_REFRESH, host_of @@ -49,8 +50,7 @@ def __init__(self, endpoint: str, *, client: httpx.AsyncClient | None = None, @property def _http(self) -> httpx.AsyncClient: if self._client is None: - self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0), trust_env=False, - proxy=self._proxy) + self._client = build_client(timeout=httpx.Timeout(30.0), proxy=self._proxy) return self._client async def aclose(self) -> None: diff --git a/src/provider/credential_fields.py b/src/provider/credential_fields.py new file mode 100644 index 00000000..703d09e1 --- /dev/null +++ b/src/provider/credential_fields.py @@ -0,0 +1,79 @@ +"""华为云系渠道(CodeArts / OfficeAce)共用的凭证字段解析。 + +两家凭证落盘形状一致(STS 临时 AK/SK + DPoP 私钥 + ISO8601 到期时间), +解析规则曾各写一份且字节级相同,统一到这里。协议私有部分(字段名清单、 +client_id 默认值、刷新绑定字段)仍留在各渠道,本模块只做「取值/归一」。 +""" + +from __future__ import annotations + +import json +from typing import Any + +from .token_expiry import normalize_epoch + + +def text_field(raw: dict[str, Any], *keys: str) -> str: + """按优先级取第一个非空字符串字段。""" + for key in keys: + value = raw.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def epoch_from_iso(value: Any) -> int: + """ISO8601(带 `Z` 或偏移)→ epoch 秒;坏值 / 缺失返回 0。""" + if not isinstance(value, str) or not value.strip(): + return 0 + from datetime import datetime + + text = value.strip().replace("Z", "+00:00") + try: + return int(datetime.fromisoformat(text).timestamp()) + except ValueError: + return 0 + + +def expiration_from_raw(raw: Any) -> int: + """到期时间归一:数字(epoch,可为毫秒)/ ISO8601 / 数字字符串 → 秒。""" + if isinstance(raw, bool): + return 0 + if isinstance(raw, (int, float)): + return normalize_epoch(int(raw)) + if isinstance(raw, str) and raw.strip(): + text = raw.strip() + try: + return normalize_epoch(int(text)) + except ValueError: + pass + return epoch_from_iso(text) + return 0 + + +def jwk_from_raw(raw: Any) -> dict[str, str]: + """DPoP 私钥 JWK:只收字符串字段,畸形输入返回空(由刷新时报错兜底)。""" + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return {} + if not isinstance(raw, dict): + return {} + return {str(key): value for key, value in raw.items() if isinstance(value, str)} + + +def relogin_required(body: bytes, markers: tuple[str, ...]) -> str: + """令牌端点响应体 → 该凭证是否已**不可自愈**,需要重新登录。 + + 返回可直接展示的原因(不含上游正文,符合不回显上游响应的约束); + 可重试的失败(网络、5xx、限流、绑定项齐全的普通 4xx)返回空串。 + + 为什么必须在这里判终态而不是让 RefreshTask 重试:refresh_token 一次性, + 被消费/失配之后每次重试都是同一张废票。判错的代价是「一次偶发故障永久 + 废掉一条好凭证」,所以只认各渠道 `_RELOGIN_MARKERS` 里上游点名的那几类。 + """ + text = body.decode("utf-8", errors="replace").lower() + if any(marker in text for marker in markers): + return "刷新令牌已失效,需重新登录" + return "" diff --git a/src/provider/kilo/client.py b/src/provider/kilo/client.py index 7546cfec..3320e7db 100644 --- a/src/provider/kilo/client.py +++ b/src/provider/kilo/client.py @@ -36,7 +36,7 @@ Model, Quota, ) -from ...provider.proxy import build_client +from ...provider.proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client from . import events as kilo_events # 上游端点(公开事实,非用户可配置输入) @@ -48,8 +48,7 @@ # 写超时覆盖的是上传请求体阶段,不是首字等待:大上下文请求体可达数 MB, # 经代理链路 10s 会稳定超时;首字等待由 read=None + # stream_first_event_timeout_seconds 负责,两者不要混为一谈。 -STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) -SHORT_TIMEOUT = httpx.Timeout(30.0) + def prepare_body(payload: dict[str, Any], model: str) -> dict[str, Any]: diff --git a/src/provider/kilo/events.py b/src/provider/kilo/events.py index 301c6000..22aaaa82 100644 --- a/src/provider/kilo/events.py +++ b/src/provider/kilo/events.py @@ -31,7 +31,7 @@ UpstreamProtocolViolation, Usage, ) -from ..openai_chunk import first_choice, is_blank_tool_call +from ..openai_chunk import first_choice, is_blank_tool_call, usage_from_openai def parse_frame(frame: SSEFrame) -> Event | None: @@ -140,22 +140,7 @@ def _error_event(error: dict[str, Any]) -> Event: def _usage(raw: dict[str, Any]) -> Usage: - def as_int(key: str) -> int | None: - value = raw.get(key) - return value if isinstance(value, int) and not isinstance(value, bool) else None - - # 缓存命中:OpenAI 惯例在 prompt_tokens_details.cached_tokens,顶层兜底 - details = raw.get("prompt_tokens_details") - cached = (details or {}).get("cached_tokens") if isinstance(details, dict) else None - if not isinstance(cached, int) or isinstance(cached, bool): - cached = as_int("cached_tokens") - - return Usage( - input_tokens=as_int("prompt_tokens"), - output_tokens=as_int("completion_tokens"), - reasoning_tokens=as_int("reasoning_tokens"), - cached_tokens=cached, - ) + return usage_from_openai(raw) def classify_status(status: int, body: bytes = b"") -> ErrKind: diff --git a/src/provider/officeace/client.py b/src/provider/officeace/client.py index 4f9d9573..bae507dd 100644 --- a/src/provider/officeace/client.py +++ b/src/provider/officeace/client.py @@ -39,8 +39,9 @@ UpstreamProtocolViolation, UpstreamReloginRequired, ) -from ...provider.proxy import build_client +from ...provider.proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client from .. import naming +from ..codearts.signer import sdk_date from . import events as officeace_events from .credential import OfficeAceCredential, merge_refreshed from .events import ( @@ -58,8 +59,7 @@ # 流式无总超时防长流截断;短请求 30s 防悬挂(与其余渠道同策略)。 # 写超时覆盖上传请求体阶段,不覆盖首字等待(大上下文请求体可达数 MB)。 -STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) -SHORT_TIMEOUT = httpx.Timeout(30.0) + #: 签到活动展示名(上游没给活动名时的兜底)。 DEFAULT_CHECKIN_NAME = "每日签到有礼" @@ -481,10 +481,8 @@ def as_bool(*keys: str) -> bool | None: ) -def _sdk_date(moment: int | None = None) -> str: - """`X-Sdk-Date`:UTC `YYYYMMDDTHHMMSSZ`。""" - return time.strftime("%Y%m%dT%H%M%SZ", - time.gmtime(moment if moment is not None else time.time())) +# 签名与请求必须同值,实现归 codearts.signer(本渠道共用同一份) +_sdk_date = sdk_date def _next_beijing_midnight(moment: int) -> int: diff --git a/src/provider/officeace/credential.py b/src/provider/officeace/credential.py index 47f75fee..13b9927d 100644 --- a/src/provider/officeace/credential.py +++ b/src/provider/officeace/credential.py @@ -26,7 +26,7 @@ from dataclasses import dataclass, field from typing import Any, ClassVar -from ...provider.token_expiry import normalize_epoch +from .. import credential_fields from ..base import UpstreamProtocolViolation from .events import CLIENT_ID @@ -147,43 +147,10 @@ def has_chat(self) -> bool: return bool(self.model_app_key and self.model_app_secret) -def _text(raw: dict[str, Any], *keys: str) -> str: - """按优先级取第一个非空字符串字段。""" - for key in keys: - value = raw.get(key) - if isinstance(value, str) and value.strip(): - return value.strip() - return "" - - -def _expiration_from_raw(raw: Any) -> int: - """到期时间归一:数字(epoch,可为毫秒)/ ISO8601 / 数字字符串 → 秒。""" - if isinstance(raw, bool): - return 0 - if isinstance(raw, (int, float)): - return normalize_epoch(int(raw)) - if isinstance(raw, str) and raw.strip(): - text = raw.strip() - try: - return normalize_epoch(int(text)) - except ValueError: - pass - from .events import epoch_from_iso - - return epoch_from_iso(text) - return 0 - - -def _jwk_from_raw(raw: Any) -> dict[str, str]: - """DPoP 私钥 JWK:只收字符串字段,畸形输入返回空(由刷新时报错兜底)。""" - if isinstance(raw, str): - try: - raw = json.loads(raw) - except json.JSONDecodeError: - return {} - if not isinstance(raw, dict): - return {} - return {str(key): value for key, value in raw.items() if isinstance(value, str)} +# 两家华为云系渠道共用同一份解析(测试与旧调用方沿用原名) +_text = credential_fields.text_field +_expiration_from_raw = credential_fields.expiration_from_raw +_jwk_from_raw = credential_fields.jwk_from_raw def parse_tokens(raw: dict[str, Any]) -> OfficeAceCredential: diff --git a/src/provider/officeace/events.py b/src/provider/officeace/events.py index ea635dec..bc068cb5 100644 --- a/src/provider/officeace/events.py +++ b/src/provider/officeace/events.py @@ -44,7 +44,7 @@ Usage, business_codes, ) -from ..openai_chunk import first_choice, is_blank_tool_call +from ..openai_chunk import first_choice, is_blank_tool_call, usage_from_openai # ---------------------------------------------------------------- 域与端点 @@ -310,25 +310,7 @@ def _error_event(error: dict[str, Any]) -> Event: def _usage(raw: dict[str, Any]) -> Usage: - def as_int(key: str) -> int | None: - value = raw.get(key) - return value if isinstance(value, int) and not isinstance(value, bool) else None - - details = raw.get("prompt_tokens_details") - cached = (details or {}).get("cached_tokens") if isinstance(details, dict) else None - if not isinstance(cached, int) or isinstance(cached, bool): - cached = as_int("cached_tokens") - completion_details = raw.get("completion_tokens_details") - reasoning = (completion_details or {}).get("reasoning_tokens") \ - if isinstance(completion_details, dict) else None - if not isinstance(reasoning, int) or isinstance(reasoning, bool): - reasoning = as_int("reasoning_tokens") - return Usage( - input_tokens=as_int("prompt_tokens"), - output_tokens=as_int("completion_tokens"), - reasoning_tokens=reasoning, - cached_tokens=cached, - ) + return usage_from_openai(raw) # ------------------------------------------------------------ 订阅 / 积分 diff --git a/src/provider/officeace/oauth.py b/src/provider/officeace/oauth.py index b0f5df21..28174e46 100644 --- a/src/provider/officeace/oauth.py +++ b/src/provider/officeace/oauth.py @@ -27,10 +27,7 @@ from __future__ import annotations -import base64 -import hashlib import logging -import secrets import time from collections.abc import Callable from dataclasses import dataclass, field @@ -38,6 +35,8 @@ import httpx +from ..auth_state import AuthStateStore as AuthStateStoreBase +from ..auth_state import pkce_pair from ..base import ( AuthFlow, AuthResult, @@ -46,6 +45,7 @@ read_body_bounded, ) from ..codearts import dpop +from ..codearts.signer import sdk_date from ..proxy import build_client from . import events as officeace_events from .credential import OfficeAceCredential, parse_tokens @@ -116,28 +116,18 @@ class LoginSession: polls: int = 0 -@dataclass(slots=True) +@dataclass class _Reservation: - """一条 pending 登录:归属人 + 创建时刻 + 会话。 + """本渠道载荷:登录会话(轮询计数就地 +1)。 归属按用户名校验是安全约束而非风格:单槽全局态会让两个管理员的并发登录 - 互相覆盖,把 A 的凭证记到 B 名下。 + 互相覆盖,把 A 的凭证记到 B 名下(归属校验在基类 `AuthStateStore.owner`)。 """ - username: str - created_at: int session: LoginSession -def pkce_pair(verifier: str | None = None) -> tuple[str, str]: - """PKCE `(code_verifier, code_challenge)`;challenge = BASE64URL(SHA256(v))。""" - value = verifier or secrets.token_urlsafe(32) - digest = hashlib.sha256(value.encode("ascii")).digest() - challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") - return value, challenge - - -class AuthStateStore: +class AuthStateStore(AuthStateStoreBase[_Reservation]): """pending 登录会话的登记、归属校验、消费与过期清理。 对外 `state` 是**本地生成的 reservation**,不是云端签发的 state: @@ -147,76 +137,29 @@ class AuthStateStore: def __init__(self, ttl_seconds: int = LOGIN_TTL_SECONDS, max_pending: int = MAX_PENDING_SESSIONS) -> None: - self._ttl = ttl_seconds - self._max_pending = max_pending - self._entries: dict[str, _Reservation] = {} + super().__init__(ttl_seconds, max_pending=max_pending) def begin(self, username: str, session: LoginSession, now: int | None = None) -> str: - self.cleanup(now) - # 攒了太多没登完的就顶掉最老的:拒绝会让人以为功能坏了,而每条 - # pending 会话都在轮询云端,留一堆也只是浪费。 - while len(self._entries) >= self._max_pending: - oldest = min(self._entries, key=lambda key: self._entries[key].created_at) - del self._entries[oldest] - reservation = secrets.token_urlsafe(18) - self._entries[reservation] = _Reservation( - username=username, - created_at=int(now if now is not None else time.time()), - session=session) - return reservation - - def owner(self, auth_state: str, username: str, - now: int | None = None) -> bool: - """归属校验,**含 TTL**:过期 state 不得再轮询或重放。""" - entry = self._entries.get(auth_state) - if entry is None or entry.username != username: - return False - current = int(now if now is not None else time.time()) - if current - entry.created_at >= self._ttl: - del self._entries[auth_state] - return False - return True + return super().begin(username, _Reservation(session=session), now) def session_entry(self, auth_state: str, username: str, now: int | None = None) -> _Reservation | None: - if not self.owner(auth_state, username, now): - return None - return self._entries[auth_state] + entry = self.reservation(auth_state, username, now) + return None if entry is None else entry.payload def touch(self, auth_state: str, now: int | None = None) -> None: """记一次轮询(排障用;会话可能已被并发消费,故容忍缺失)。""" entry = self._entries.get(auth_state) if entry is None: return - session = entry.session - entry.session = LoginSession( + session = entry.payload.session + entry.payload.session = LoginSession( state=session.state, code_verifier=session.code_verifier, code_challenge=session.code_challenge, auth_url=session.auth_url, dpop_private_jwk=session.dpop_private_jwk, created_at=session.created_at, polls=session.polls + 1) - def consume(self, auth_state: str, username: str, - now: int | None = None) -> bool: - """消费:成功后该 state 不可再次轮询或重放。""" - if not self.owner(auth_state, username, now): - return False - del self._entries[auth_state] - return True - - def cancel(self, auth_state: str, username: str, - now: int | None = None) -> bool: - return self.consume(auth_state, username, now) - - def cleanup(self, now: int | None = None) -> None: - current = int(now if now is not None else time.time()) - for key in [k for k, v in self._entries.items() - if current - v.created_at >= self._ttl]: - del self._entries[key] - - def __len__(self) -> int: - return len(self._entries) - class OfficeAceOAuth: """登录流程实现(**poll 轨道**:云端承接回调,服务端轮询取码)。""" @@ -227,7 +170,7 @@ def __init__(self, config: LoginConfig | None = None, *, proxy: str | None = None, now: Callable[[], int] | None = None) -> None: self.config = config or LoginConfig() - self.store = store or AuthStateStore() + self.store = store if store is not None else AuthStateStore() self._client = client self._proxy = proxy self._now = now or (lambda: int(time.time())) @@ -468,7 +411,5 @@ async def fetch_model_info(self, credential: OfficeAceCredential) -> dict[str, s return officeace_events.parse_model_info(payload) -def _sdk_date(moment: int | None = None) -> str: - """`X-Sdk-Date`:UTC `YYYYMMDDTHHMMSSZ`(签名与请求必须同值)。""" - return time.strftime("%Y%m%dT%H%M%SZ", - time.gmtime(moment if moment is not None else time.time())) +# 签名与请求必须同值,实现归 codearts.signer(本渠道共用同一份) +_sdk_date = sdk_date diff --git a/src/provider/openai_chunk.py b/src/provider/openai_chunk.py index 2610ea1e..ce2558b4 100644 --- a/src/provider/openai_chunk.py +++ b/src/provider/openai_chunk.py @@ -10,7 +10,7 @@ from typing import Any -from .base import UpstreamProtocolViolation +from .base import UpstreamProtocolViolation, Usage # 空 JSON 对象串(`arguments` 有时是未解析的原始字符串);用拼接构造以避开 # 编辑期对 `` 字面量的意外改写。 @@ -49,3 +49,54 @@ def is_blank_tool_call(tc: dict[str, Any]) -> bool: return False arguments = function.get("arguments") return arguments in (None, "", _EMPTY_ARGUMENTS_JSON) or arguments == {} + + +def _opt_int(value: Any) -> int | None: + """整数取值:非 int / bool 一律 None(绝不把「未上报」量化成 0)。""" + return value if isinstance(value, int) and not isinstance(value, bool) else None + + +def details_int(raw: dict[str, Any], details_key: str, key: str) -> int | None: + """details 子对象优先,顶层同名字段兜底。 + + OpenAI 惯例把 `cached_tokens` / `reasoning_tokens` 放在 details 里(未命中时 + 报 0,是有效值而非缺失),顶层只是兼容形状。details 值非法(非数值 / + bool)时回落顶层,仍缺则 None。 + """ + details = raw.get(details_key) + value = details.get(key) if isinstance(details, dict) else None + if isinstance(value, int) and not isinstance(value, bool): + return value + return _opt_int(raw.get(key)) + + +def usage_from_openai( + raw: dict[str, Any], + *, + credit: bool = False, + cached_fallbacks: tuple[str, ...] = (), +) -> Usage: + """标准 OpenAI `usage` 对象 → 中立 `Usage`。 + + 各渠道差异只有两处,参数化即可,不猜键: + * `credit`:上游是否真给 credit 字段(Zen / CodeBuddy 有); + * `cached_fallbacks`:details 与顶层都缺时的额外候选键(TRAE 的 + `cache_read_input_tokens`)。 + + `reasoning_tokens` / `cached_tokens` 一律 details 优先、顶层兜底:这是 + 7 份原实现的**并集**——details 缺失时回落顶层,绝不丢顶层值。 + """ + cached = details_int(raw, "prompt_tokens_details", "cached_tokens") + for key in cached_fallbacks: + if cached is None: + cached = _opt_int(raw.get(key)) + credit_value = raw.get("credit") if credit else None + return Usage( + input_tokens=_opt_int(raw.get("prompt_tokens")), + output_tokens=_opt_int(raw.get("completion_tokens")), + reasoning_tokens=details_int(raw, "completion_tokens_details", "reasoning_tokens"), + cached_tokens=cached, + credit=(float(credit_value) + if isinstance(credit_value, (int, float)) + and not isinstance(credit_value, bool) else None), + ) diff --git a/src/provider/proxy.py b/src/provider/proxy.py index d1abc386..264ce6ae 100644 --- a/src/provider/proxy.py +++ b/src/provider/proxy.py @@ -56,4 +56,10 @@ def build_client(*, timeout: httpx.Timeout, proxy: str | None = None) -> httpx.A `trust_env=False` 是既有约定——不吃 `HTTP_PROXY` 等环境变量,避免部署环境 的全局代理意外劫持带 Token 的上游请求;按渠道代理只经 `proxy=` 显式生效。 """ - return httpx.AsyncClient(timeout=timeout, trust_env=False, proxy=proxy) \ No newline at end of file + return httpx.AsyncClient(timeout=timeout, trust_env=False, proxy=proxy) + + +# 流式请求:读无上限(SSE 长连接),写 60s(大 payload 也要发完),连接/取池各 10s。 +STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) +# 短请求(探活 / 额度 / 模型列表):整体 30s。 +SHORT_TIMEOUT = httpx.Timeout(30.0) \ No newline at end of file diff --git a/src/provider/qoder/auth.py b/src/provider/qoder/auth.py index d0fd2f6d..60ff2e51 100644 --- a/src/provider/qoder/auth.py +++ b/src/provider/qoder/auth.py @@ -20,7 +20,6 @@ import hashlib import logging import os -import secrets import time import uuid from dataclasses import dataclass @@ -29,6 +28,7 @@ import httpx +from ...provider.auth_state import AuthStateStore as AuthStateStoreBase from ...provider.base import AuthFlow, AuthResult, AuthSession from ...provider.proxy import build_client from .credential import QoderCredential, credential_from_device @@ -118,62 +118,29 @@ def build_auth_url(realm: str, *, challenge: str, nonce: str, @dataclass class _Reservation: - username: str - created_at: int + """本渠道载荷:PKCE verifier + 设备码 nonce + 组织/机器标识。""" + verifier: str nonce: str realm: str machine_id: str -class AuthStateStore: +class AuthStateStore(AuthStateStoreBase[_Reservation]): """state 的归属校验、消费与过期清理(与 CodeBuddy 同语义,不同存储载荷)。""" def __init__(self, ttl_seconds: int = AUTH_STATE_TTL_SECONDS) -> None: - self._ttl = ttl_seconds - self._entries: dict[str, _Reservation] = {} + super().__init__(ttl_seconds) def begin(self, username: str, *, verifier: str, nonce: str, realm: str, machine_id: str, now: int | None = None) -> str: - self.cleanup(now) - reservation = secrets.token_urlsafe(18) - self._entries[reservation] = _Reservation( - username=username, created_at=int(now if now is not None else time.time()), - verifier=verifier, nonce=nonce, realm=realm, machine_id=machine_id) - return reservation - - def owner(self, auth_state: str, username: str, now: int | None = None) -> bool: - """归属校验,**含 TTL**(同 CodeBuddy):过期 state 不得再轮询/重放。""" - entry = self._entries.get(auth_state) - if entry is None or entry.username != username: - return False - current = int(now if now is not None else time.time()) - if current - entry.created_at >= self._ttl: - del self._entries[auth_state] - return False - return True + return super().begin(username, _Reservation( + verifier=verifier, nonce=nonce, realm=realm, + machine_id=machine_id), now) def reservation(self, auth_state: str, username: str, now: int | None = None) -> _Reservation | None: - if not self.owner(auth_state, username, now): - return None - return self._entries[auth_state] - - def consume(self, auth_state: str, username: str, now: int | None = None) -> bool: - """消费:成功后该 state 不可再次轮询或重放。""" - if not self.owner(auth_state, username, now): - return False - del self._entries[auth_state] - return True - - def cancel(self, auth_state: str, username: str, now: int | None = None) -> bool: - return self.consume(auth_state, username, now) - - def cleanup(self, now: int | None = None) -> None: - current = int(now if now is not None else time.time()) - for key in [k for k, v in self._entries.items() - if current - v.created_at >= self._ttl]: - del self._entries[key] + return self.payload(auth_state, username, now) class QoderOAuth: @@ -201,7 +168,8 @@ def __init__( config = get_realm_config(realm) self.openapi = (openapi or config.openapi).rstrip("/") self.website = (website or config.website).rstrip("/") - self.store = store or AuthStateStore(ttl_seconds=ttl_seconds) + self.store = (store if store is not None + else AuthStateStore(ttl_seconds=ttl_seconds)) self._machine_id = machine_id self._client = client self._now = now or (lambda: int(time.time())) diff --git a/src/provider/qoder/client.py b/src/provider/qoder/client.py index 59f2d950..43953d4e 100644 --- a/src/provider/qoder/client.py +++ b/src/provider/qoder/client.py @@ -46,7 +46,7 @@ Quota, UpstreamTransportError, ) -from ...provider.proxy import build_client +from ...provider.proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client from . import events as qoder_events from .cosy import CosySession, CosySessionCache, qoder_encode from .credential import QoderCredential, merge_refreshed @@ -70,8 +70,7 @@ # 写超时覆盖的是上传请求体阶段,不是首字等待:大上下文请求体可达数 MB, # 经代理链路 10s 会稳定超时;首字等待由 read=None + # stream_first_event_timeout_seconds 负责,两者不要混为一谈。 -STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) -SHORT_TIMEOUT = httpx.Timeout(30.0) + # 本区域无签到接口的 HTTP 状态(国际版实测 404;405/410 同义)。活动制与旧接口 # 共用:两者都不可用才算「本区域无签到」。 diff --git a/src/provider/qoder/events.py b/src/provider/qoder/events.py index 5d26eb31..543b2d34 100644 --- a/src/provider/qoder/events.py +++ b/src/provider/qoder/events.py @@ -39,7 +39,7 @@ Usage, parse_queue_hint, ) -from ..openai_chunk import first_choice +from ..openai_chunk import first_choice, usage_from_openai from ..token_expiry import normalize_epoch # --------------------------------------------------------------------------- @@ -376,20 +376,7 @@ def parse_inner_chunk(body: str) -> list[Event]: def _usage(raw: dict[str, Any]) -> Usage: - def as_int(key: str) -> int | None: - value = raw.get(key) - return value if isinstance(value, int) and not isinstance(value, bool) else None - - details = raw.get("prompt_tokens_details") - cached = (details or {}).get("cached_tokens") if isinstance(details, dict) else None - if not isinstance(cached, int) or isinstance(cached, bool): - cached = as_int("cached_tokens") - return Usage( - input_tokens=as_int("prompt_tokens"), - output_tokens=as_int("completion_tokens"), - reasoning_tokens=as_int("reasoning_tokens"), - cached_tokens=cached, - ) + return usage_from_openai(raw) # --------------------------------------------------------------------------- diff --git a/src/provider/trae/client.py b/src/provider/trae/client.py index f03f9062..7a075fa5 100644 --- a/src/provider/trae/client.py +++ b/src/provider/trae/client.py @@ -27,7 +27,7 @@ Model, Quota, ) -from ...provider.proxy import build_client +from ...provider.proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client from ...provider.token_expiry import normalize_epoch from . import events as trae_events from . import pricing @@ -63,8 +63,7 @@ # 写超时覆盖的是上传请求体阶段,不是首字等待:大上下文请求体可达数 MB, # 经代理链路 10s 会稳定超时;首字等待由 read=None + # stream_first_event_timeout_seconds 负责,两者不要混为一谈。 -STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) -SHORT_TIMEOUT = httpx.Timeout(30.0) + # SOLO 静态模型表(继承 trae2api-web;动态拉取失败时回退) STATIC_MODELS: tuple[str, ...] = ( diff --git a/src/provider/zen/client.py b/src/provider/zen/client.py index cd9eb043..ba58ae3c 100644 --- a/src/provider/zen/client.py +++ b/src/provider/zen/client.py @@ -47,7 +47,7 @@ Model, Quota, ) -from ...provider.proxy import build_client +from ...provider.proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client from . import events as zen_events logger = logging.getLogger(__name__) @@ -102,8 +102,7 @@ # 写超时覆盖的是上传请求体阶段,不是首字等待:大上下文请求体可达数 MB, # 经代理链路 10s 会稳定超时;首字等待由 read=None + # stream_first_event_timeout_seconds 负责,两者不要混为一谈。 -STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) -SHORT_TIMEOUT = httpx.Timeout(30.0) + # 403 门禁抖动重试(实测 2026-10-09/12 共 7 次):上游**偶发**对同一请求回 # FreeTierError 403,数秒后的同请求即 200(门禁条件每次都满足,抖动来自上游)。 diff --git a/src/provider/zen/events.py b/src/provider/zen/events.py index 59552735..099b5989 100644 --- a/src/provider/zen/events.py +++ b/src/provider/zen/events.py @@ -27,7 +27,7 @@ UpstreamProtocolViolation, Usage, ) -from ..openai_chunk import first_choice, is_blank_tool_call +from ..openai_chunk import first_choice, is_blank_tool_call, usage_from_openai def parse_frame(frame: SSEFrame) -> Event | None: @@ -134,25 +134,7 @@ def _error_event(error: dict[str, Any]) -> Event: def _usage(raw: dict[str, Any]) -> Usage: - def as_int(key: str) -> int | None: - value = raw.get(key) - return value if isinstance(value, int) and not isinstance(value, bool) else None - - # 缓存命中:OpenAI 惯例在 prompt_tokens_details.cached_tokens,顶层兜底 - details = raw.get("prompt_tokens_details") - cached = (details or {}).get("cached_tokens") if isinstance(details, dict) else None - if not isinstance(cached, int) or isinstance(cached, bool): - cached = as_int("cached_tokens") - - credit = raw.get("credit") - return Usage( - input_tokens=as_int("prompt_tokens"), - output_tokens=as_int("completion_tokens"), - reasoning_tokens=as_int("reasoning_tokens"), - cached_tokens=cached, - credit=float(credit) if isinstance(credit, (int, float)) and not isinstance(credit, bool) - else None, - ) + return usage_from_openai(raw, credit=True) def classify_status(status: int, body: bytes = b"") -> ErrKind: diff --git a/src/tasks/activity.py b/src/tasks/activity.py index bf0b6637..3d41cb6f 100644 --- a/src/tasks/activity.py +++ b/src/tasks/activity.py @@ -65,8 +65,17 @@ def due(self) -> bool: def _day_key(self) -> str: return self._current().strftime("%Y-%m-%d") + def _prune(self, day: str) -> None: + """丢掉非当天的封账记录:键以日期开头,跨天后不可能再命中, + 长驻进程否则每天每账号各攒一条(无上限)。""" + prefix = f"{day}:" + self._done_scopes = {key for key in self._done_scopes + if key.startswith(prefix)} + async def run_once(self, *, trigger: str = "auto") -> TaskReport: report = TaskReport() + day = self._day_key() + self._prune(day) # 跨天后旧封账记录不再有用 seen: set[str] = set() for candidate in self._credentials.candidates(): if candidate.disabled: @@ -79,7 +88,7 @@ async def run_once(self, *, trigger: str = "auto") -> TaskReport: report.skipped += 1 continue scope = activity_scope(data) or f"credential|{candidate.credential_id}" - if f"{self._day_key()}:{scope}" in self._done_scopes: + if f"{day}:{scope}" in self._done_scopes: report.skipped += 1 # 当日已成功,不再调上游 continue if scope in seen: @@ -96,7 +105,7 @@ async def run_once(self, *, trigger: str = "auto") -> TaskReport: continue if result.ok: report.succeeded += 1 - self._done_scopes.add(f"{self._day_key()}:{scope}") + self._done_scopes.add(f"{day}:{scope}") self._record(candidate.credential_id, result, trigger) else: report.failed += 1 diff --git a/src/tasks/runner.py b/src/tasks/runner.py index b0dede4f..160829d1 100644 --- a/src/tasks/runner.py +++ b/src/tasks/runner.py @@ -248,7 +248,9 @@ def _loop_specs(self, *, manual: bool = False) -> list[_LoopSpec]: # 一次,由 `due()` 自己判定是否落在抖动窗口内。**不能**把周期改成 # 「按最大时点间隔算」——服务恰在两个时点之间重启会直接漏签,而 # 10 分钟空转的代价只是读一次配置(与 activity 的取舍同理)。 - _LoopSpec("checkin", self._sync_checkin, + _LoopSpec("checkin", + ((lambda: self._sync_checkin(manual=True)) if manual + else self._sync_checkin), lambda: self._interval_seconds("checkin")), ] if self._growth is not None: @@ -320,12 +322,13 @@ async def _sync_activity(self, *, manual: bool = False) -> object: return None return await self._activity.run_once() - async def _sync_checkin(self) -> object: + async def _sync_checkin(self, *, manual: bool = False) -> object: """签到一轮:有配置时点时只在窗口内跑,留空则全天每 10 分钟一轮。 成功凭证当日封账(run_once 内跳过),失败凭证继续重试。 + `manual=True`(管理台「立即运行」)绕过时点窗口:手动就是立刻跑。 """ - if not self._checkin.due(): + if not manual and not self._checkin.due(): return None return await self._checkin.run_once() diff --git a/tests/provider/officeace/test_oauth.py b/tests/provider/officeace/test_oauth.py index 46ddcfce..8703f9cc 100644 --- a/tests/provider/officeace/test_oauth.py +++ b/tests/provider/officeace/test_oauth.py @@ -150,7 +150,8 @@ def test_store_session_entry_returns_reservation(): store = AuthStateStore() state = store.begin("alice", _session(), now=MOMENT) entry = store.session_entry(state, "alice", now=MOMENT) - assert entry is not None and entry.username == "alice" + # 归属校验:只有登记人拿得到载荷(归属人本身在基类 Reservation 上) + assert entry is not None and entry.session is not None assert store.session_entry(state, "bob", now=MOMENT) is None diff --git a/tests/provider/test_auth_state.py b/tests/provider/test_auth_state.py new file mode 100644 index 00000000..93a82171 --- /dev/null +++ b/tests/provider/test_auth_state.py @@ -0,0 +1,101 @@ +"""共享 OAuth pending 会话登记表。 + +四个渠道原先各写一份 `AuthStateStore`(owner/consume/cancel/cleanup 逐字相同), +现由基类承担;这里直接测基类语义,各渠道的 `begin` 包装由各自测试覆盖。 +""" + +from __future__ import annotations + +import pytest + +from src.provider.auth_state import AuthStateStore, pkce_pair + +MOMENT = 1_700_000_000 + + +def test_begin_registers_and_owner_checks_username(): + store: AuthStateStore[str] = AuthStateStore(600) + state = store.begin("alice", "payload", now=MOMENT) + assert store.owner(state, "alice", now=MOMENT) is True + # 别人拿不到(归属校验是安全约束,不是风格) + assert store.owner(state, "bob", now=MOMENT) is False + assert store.payload(state, "bob", now=MOMENT) is None + + +def test_owner_is_false_for_unknown_state(): + store: AuthStateStore[str] = AuthStateStore(600) + assert store.owner("nope", "alice", now=MOMENT) is False + assert store.payload("nope", "alice", now=MOMENT) is None + assert store.reservation("nope", "alice", now=MOMENT) is None + + +def test_owner_expires_lazily_and_drops_entry(): + """过期即判否并从表里删掉(惰性清理,避免字典堆积)。""" + store: AuthStateStore[str] = AuthStateStore(10) + state = store.begin("alice", "p", now=MOMENT) + assert store.owner(state, "alice", now=MOMENT + 9) is True + assert store.owner(state, "alice", now=MOMENT + 10) is False + assert len(store) == 0 + + +def test_consume_is_single_use_and_cancel_aliases_it(): + store: AuthStateStore[str] = AuthStateStore(600) + state = store.begin("alice", "p", now=MOMENT) + assert store.consume(state, "alice", now=MOMENT) is True + assert store.consume(state, "alice", now=MOMENT) is False + + other = store.begin("alice", "p", now=MOMENT) + assert store.cancel(other, "alice", now=MOMENT) is True + assert store.cancel(other, "alice", now=MOMENT) is False + + +def test_cleanup_removes_only_expired(): + store: AuthStateStore[str] = AuthStateStore(10) + old = store.begin("alice", "p", now=MOMENT) + fresh = store.begin("alice", "p", now=MOMENT + 5) + store.cleanup(now=MOMENT + 10) + assert store.owner(old, "alice", now=MOMENT + 10) is False + assert store.owner(fresh, "alice", now=MOMENT + 10) is True + + +def test_max_pending_evicts_oldest(): + """攒太多没登完的顶掉最老的(OfficeAce 的实测需求)。""" + store: AuthStateStore[str] = AuthStateStore(600, max_pending=2) + first = store.begin("alice", "a", now=MOMENT) + second = store.begin("alice", "b", now=MOMENT + 1) + third = store.begin("alice", "c", now=MOMENT + 2) + assert store.owner(first, "alice", now=MOMENT + 2) is False + assert store.owner(second, "alice", now=MOMENT + 2) is True + assert store.owner(third, "alice", now=MOMENT + 2) is True + assert len(store) == 2 + + +def test_reservation_returns_owner_and_payload(): + store: AuthStateStore[str] = AuthStateStore(600) + state = store.begin("alice", "payload", now=MOMENT) + entry = store.reservation(state, "alice", now=MOMENT) + assert entry is not None + assert entry.username == "alice" + assert entry.created_at == MOMENT + assert entry.payload == "payload" + + +def test_pkce_pair_challenge_is_s256_of_verifier(): + import base64 + import hashlib + + verifier, challenge = pkce_pair() + digest = hashlib.sha256(verifier.encode("ascii")).digest() + assert challenge == base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def test_pkce_pair_accepts_explicit_verifier(): + """调用方自带 verifier(Qoder 的自造 64 位字母表)时不再随机。""" + assert pkce_pair("fixed-verifier")[0] == "fixed-verifier" + + +@pytest.mark.parametrize("length", [1, 32, 64]) +def test_pkce_pair_roundtrip_length(length): + verifier, challenge = pkce_pair("v" * length) + assert verifier == "v" * length + assert "=" not in challenge diff --git a/tests/provider/test_credential_fields.py b/tests/provider/test_credential_fields.py new file mode 100644 index 00000000..ebd9f424 --- /dev/null +++ b/tests/provider/test_credential_fields.py @@ -0,0 +1,80 @@ +"""华为云系渠道共用的凭证字段解析。 + +两家(CodeArts / OfficeAce)落盘形状一致,解析规则曾各写一份且字节级相同。 +这里直接测共享实现本身;各渠道仍保留同名薄包装,其行为由各自的 +`test_credential.py` 覆盖。 +""" + +from __future__ import annotations + +import json + +import pytest + +from src.provider.credential_fields import ( + epoch_from_iso, + expiration_from_raw, + jwk_from_raw, + relogin_required, + text_field, +) + + +def test_text_field_picks_first_non_empty_string(): + raw = {"a": " ", "b": " x ", "c": "y"} + assert text_field(raw, "a", "b", "c") == "x" + assert text_field(raw, "a", "missing") == "" + # 非字符串一律跳过(bool / 数字不是合法字段值) + assert text_field({"a": 5, "b": True}, "a", "b") == "" + + +def test_epoch_from_iso_rejects_non_string_and_bad_value(): + assert epoch_from_iso(None) == 0 + assert epoch_from_iso("") == 0 + assert epoch_from_iso(" ") == 0 + assert epoch_from_iso(1234) == 0 + assert epoch_from_iso("not-a-date") == 0 + assert epoch_from_iso("2026-10-21T00:00:00Z") > 0 + + +@pytest.mark.parametrize("raw,expected", [ + (True, 0), # bool 不是时间戳 + (0, 0), + (1700000000, 1700000000), # 秒 + (1700000000000, 1700000000), # 毫秒归一到秒 + ("1700000000", 1700000000), # 数字字符串 + ("1700000000000", 1700000000), + ("2026-10-21T00:00:00Z", None), # ISO8601(只断言非 0) + ("bad", 0), + (None, 0), + (1.5, 1), +]) +def test_expiration_from_raw(raw, expected): + got = expiration_from_raw(raw) + if expected is None: + assert got > 0 + else: + assert got == expected + + +def test_jwk_from_raw_accepts_string_and_dict(): + assert jwk_from_raw('{"kty": "EC", "d": "abc"}') == {"kty": "EC", "d": "abc"} + assert jwk_from_raw({"kty": "EC", "n": 5}) == {"kty": "EC"} # 非字符串值丢弃 + assert jwk_from_raw("{bad json") == {} + assert jwk_from_raw(None) == {} + assert jwk_from_raw(5) == {} + + +def test_relogin_required_matches_markers_only(): + markers = ("invalid refresh token", "sts5.1806") + assert relogin_required(b'{"error":"invalid refresh token"}', markers) != "" + assert relogin_required(b'{"code":"STS5.1806"}', markers) != "" # 大小写不敏感 + assert relogin_required(b'{"error":"internal server error"}', markers) == "" + # 可重试类失败绝不能判成终态(判错=永久废掉一条好凭证) + assert relogin_required(b"", markers) == "" + assert relogin_required(b"\xff\xfe invalid refresh token", markers) != "" + + +def test_jwk_from_raw_roundtrip_json_string(): + payload = {"kty": "EC", "crv": "P-256", "d": "s"} + assert jwk_from_raw(json.dumps(payload)) == payload diff --git a/tests/provider/test_openai_chunk.py b/tests/provider/test_openai_chunk.py new file mode 100644 index 00000000..36a9543e --- /dev/null +++ b/tests/provider/test_openai_chunk.py @@ -0,0 +1,70 @@ +"""共享 OpenAI chunk 解析原语:usage 合并后的并集语义。 + +7 个渠道原先各写一份 `_usage`,抽到 `usage_from_openai` 后必须保证 +「details 优先、顶层兜底」的并集行为不丢——尤其顶层 `reasoning_tokens` +(TRAE / Zen / Kilo / Qoder 实测会给)不能因 details 缺失而归 None。 +""" + +from __future__ import annotations + +import pytest + +from src.provider.openai_chunk import details_int, usage_from_openai + + +def test_reasoning_tokens_falls_back_to_top_level(): + """details 缺失时顶层 reasoning_tokens 必须保留(TRAE 等渠道的真实形状)。""" + usage = usage_from_openai({"prompt_tokens": 1, "reasoning_tokens": 148}) + assert usage.reasoning_tokens == 148 + + +def test_details_win_over_top_level(): + """details 与顶层同时存在时以 details 为准(CB / OfficeAce / CodeArts 实测)。""" + usage = usage_from_openai({ + "prompt_tokens": 1, + "reasoning_tokens": 9, + "completion_tokens_details": {"reasoning_tokens": 100}, + }) + assert usage.reasoning_tokens == 100 + + +@pytest.mark.parametrize("bad", [True, "5", None, 1.5]) +def test_details_rejects_non_int(bad): + """details 里的非法值(bool / 字符串 / 浮点)回落顶层,不当成 0。""" + usage = usage_from_openai({ + "completion_tokens_details": {"reasoning_tokens": bad}, + "reasoning_tokens": 7, + }) + assert usage.reasoning_tokens == 7 + + +def test_credit_only_when_channel_reports_it(): + """`credit=True` 的渠道才解析 credit;其余渠道忽略该字段。""" + assert usage_from_openai({"credit": 1.5}, credit=True).credit == 1.5 + assert usage_from_openai({"credit": 1.5}).credit is None + # bool 不是有效 credit(True 会被当成 1.0) + assert usage_from_openai({"credit": True}, credit=True).credit is None + + +def test_cached_fallbacks_tried_in_order(): + """details → 顶层 → 渠道私有兜底键,逐级回落;0 是有效值不算缺失。""" + assert usage_from_openai( + {"cache_read_input_tokens": 7}, + cached_fallbacks=("cache_read_input_tokens",)).cached_tokens == 7 + # 顶层 cached_tokens 优先于私有兜底键 + assert usage_from_openai( + {"cached_tokens": 2, "cache_read_input_tokens": 5}, + cached_fallbacks=("cache_read_input_tokens",)).cached_tokens == 2 + # 0 必须保留(TRAE 未命中报 0) + assert usage_from_openai( + {"cache_read_input_tokens": 0}, + cached_fallbacks=("cache_read_input_tokens",)).cached_tokens == 0 + assert usage_from_openai({}).cached_tokens is None + + +def test_details_int_handles_non_dict_details(): + """details 不是对象时直接回落顶层,不崩。""" + assert details_int({"prompt_tokens_details": "nope", "cached_tokens": 3}, + "prompt_tokens_details", "cached_tokens") == 3 + assert details_int({"prompt_tokens_details": None, "cached_tokens": 3}, + "prompt_tokens_details", "cached_tokens") == 3 diff --git a/tests/provider/test_stream_write_timeout.py b/tests/provider/test_stream_write_timeout.py index f357c404..29ef4c91 100644 --- a/tests/provider/test_stream_write_timeout.py +++ b/tests/provider/test_stream_write_timeout.py @@ -11,11 +11,12 @@ import httpx import pytest -# 6 个渠道客户端模块,均导出 STREAM_TIMEOUT / SHORT_TIMEOUT +# 7 个渠道客户端模块,均导出 STREAM_TIMEOUT / SHORT_TIMEOUT(同一份常量,见 proxy.py) CLIENT_MODULES = [ "src.provider.codebuddy.client", "src.provider.codearts.client", "src.provider.kilo.client", + "src.provider.officeace.client", "src.provider.qoder.client", "src.provider.trae.client", "src.provider.zen.client", diff --git a/tests/tasks/test_activity.py b/tests/tasks/test_activity.py index 7ef8d59f..6d776145 100644 --- a/tests/tasks/test_activity.py +++ b/tests/tasks/test_activity.py @@ -171,6 +171,18 @@ def test_activity_task_day_key_is_cn_local(): task._now = _cn(0, 30) assert task._day_key() == "2026-09-21" +async def test_activity_task_prunes_stale_done_scopes(activity_repo): + """跨天后清掉前一天的封账记录:长驻进程否则每天每账号各攒一条(无上限)。""" + _add(activity_repo, token="a") + task = ActivityTask(activity_repo, {"codebuddy": _FakeProvider()}, + hour=10, now=_cn(10, 5)) + task._done_scopes = {"2026-09-20:s1", "2026-09-21:s1", "2026-09-19:s1"} + + await task.run_once() + + # 非当天两条被清掉;当天那条保留(本轮已成功会重新写入同键) + assert task._done_scopes == {"2026-09-21:s1"} + def test_build_runner_activity_toggle(tmp_path): """活跃上报恒建对象,开关只决定跑不跑(开关可热更)。 diff --git a/tests/tasks/test_status.py b/tests/tasks/test_status.py index e0490b0e..eba8773c 100644 --- a/tests/tasks/test_status.py +++ b/tests/tasks/test_status.py @@ -446,6 +446,30 @@ async def test_run_task_bypasses_activity_hour_window(repo): assert provider.activity_calls == 1 +async def test_run_task_bypasses_checkin_window(repo): + """手动触发签到同样绕过 due() 时点窗口:窗口外也真跑。 + + 回归:此前只有 activity 特判 manual,签到恒走 `_sync_checkin`, + 管理台点「立即运行签到」在非时点窗口返回 ran=False(与文档契约不符)。 + """ + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + runner = _runner(repo, StubProvider()) + calls: list[str] = [] + + async def fake_run_once(**kwargs): + calls.append("ran") + return {"succeeded": 1} + + runner._checkin.due = lambda: False + runner._checkin.run_once = fake_run_once # type: ignore[method-assign] + + result = await runner.run_task("checkin") + + assert result["ran"] is True + assert calls == ["ran"] + + async def test_run_task_skips_when_already_running(repo): """同一任务已在跑时立刻返回 busy,不排队、不并发打上游。""" credentials, _db = repo From 822734aeb2c67d7ed7a6b547a763aeceb85294e9 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 21:05:37 +0800 Subject: [PATCH 012/153] =?UTF-8?q?refactor:=20=E6=94=B6=E6=95=9B=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E8=B0=83=E5=BA=A6/=E4=BB=93=E5=82=A8=E5=B1=82?= =?UTF-8?q?=E7=9A=84=E6=9C=BA=E6=A2=B0=E9=87=8D=E5=A4=8D=EF=BC=88T2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tasks/runner: - _interval_seconds 的 12 分支 if-chain 改字典派发,property 仍是唯一真源。 新增任务漏接线时落进保留期兜底,而不是静默返回别的任务的周期。 - _loop_specs 的 12 个 lambda 走 _spec(),周期取值器只有一处拼装。 - 6 处 `if self._X is None: raise RuntimeError(...)` 收进 _require()。 db/repo: - 4 处「清该凭证全部模型冷却」收进 _clear_model_cooldowns()。 模型级冷却没有外键级联,删除凭证/硬禁用/账号级冷却/显式恢复都要清。 - 27 处 `int(now if now is not None else time.time())` 收进 _now()。 保留 list_all 的 `now or time.time()` 语义并加注释说明差异。 engine/executor: - 3 处 `[pid for pid in _narrow_providers(x) if pid in providers]` 收进 _registered()。 - 5 处内联 latency 计算改用已有的 _elapsed_ms()。 未做:clamp_int(14 处都是 max(1, min(N, x)) 一行可读,抽函数反增跳转); _sync_retention/_sync_backup 的 to_thread(只 2 处,各自 docstring 解释了 为何必须放线程池,合并没有收益)。 覆盖率 100%,3889 后端全绿。 --- src/db/repo.py | 84 +++++++++++++---------- src/engine/executor.py | 29 ++++---- src/tasks/runner.py | 152 ++++++++++++++++++++--------------------- 3 files changed, 139 insertions(+), 126 deletions(-) diff --git a/src/db/repo.py b/src/db/repo.py index 45e1a00f..6faa282d 100644 --- a/src/db/repo.py +++ b/src/db/repo.py @@ -27,6 +27,22 @@ from ..tags import tags_text, tags_value +def _now(now: int | None) -> int: + """调用方缺省时取当前 epoch 秒;显式传 0 时保留 0(不被当成缺省)。""" + return int(now if now is not None else time.time()) + + +def _clear_model_cooldowns(conn: Any, credential_id: str) -> None: + """清掉该凭证的全部 (凭证, 模型) 冷却行。 + + 模型级冷却没有外键级联,所以删除凭证 / 硬禁用 / 账号级冷却 / 显式恢复 + 都必须显式清理——否则重建同 id 凭证会继承旧的模型冷却,残留行还会拖慢 + 留存清理。 + """ + conn.execute("DELETE FROM credential_model_cooldowns WHERE credential_id = ?", + (credential_id,)) + + def _new_id(prefix: str) -> str: return f"{prefix}_{uuid.uuid4().hex[:16]}" @@ -214,7 +230,7 @@ def add(self, *, provider: str, credential_data: dict, nickname: str = "", tags: list[str] | None = None) -> str: credential_id = _new_id("cred") payload = json.dumps(credential_data, ensure_ascii=False).encode("utf-8") - created = int(now if now is not None else time.time()) + created = _now(now) issued_at, expires_at = credential_token_times(credential_data) with self._db.transaction() as conn: conn.execute( @@ -237,8 +253,7 @@ def delete(self, credential_id: str) -> bool: cursor = conn.execute("DELETE FROM credentials WHERE id = ?", (credential_id,)) # 模型级冷却没有外键级联,凭证删除后必须显式清理, # 否则重建同 id 凭证会继承旧的模型冷却(残留行还会拖慢留存清理) - conn.execute("DELETE FROM credential_model_cooldowns WHERE credential_id = ?", - (credential_id,)) + _clear_model_cooldowns(conn, credential_id) return cursor.rowcount > 0 def set_enabled(self, credential_id: str, enabled: bool) -> bool: @@ -265,8 +280,7 @@ def revive(self, credential_id: str) -> bool: cursor = conn.execute( "UPDATE credentials SET disabled = 0, disabled_reason = NULL, " "cooling_until = NULL, err_count = 0 WHERE id = ?", (credential_id,)) - conn.execute("DELETE FROM credential_model_cooldowns WHERE credential_id = ?", - (credential_id,)) + _clear_model_cooldowns(conn, credential_id) return cursor.rowcount > 0 def mark_relogin_required(self, credential_id: str, reason: str) -> None: @@ -284,8 +298,7 @@ def mark_relogin_required(self, credential_id: str, reason: str) -> None: conn.execute( "UPDATE credentials SET disabled = 1, disabled_reason = ?, err_count = 0, " "cooling_until = NULL WHERE id = ?", (reason, credential_id)) - conn.execute("DELETE FROM credential_model_cooldowns WHERE credential_id = ?", - (credential_id,)) + _clear_model_cooldowns(conn, credential_id) def save_error(self, credential_id: str, outcome: ErrorOutcome) -> None: """落库一次错误结果。 @@ -316,8 +329,7 @@ def save_error(self, credential_id: str, outcome: ErrorOutcome) -> None: "UPDATE credentials SET cooling_until = ?, err_count = ? WHERE id = ?", (outcome.cooling_until, outcome.err_count, credential_id)) if outcome.cooling_until is not None: - conn.execute("DELETE FROM credential_model_cooldowns WHERE credential_id = ?", - (credential_id,)) + _clear_model_cooldowns(conn, credential_id) def save_success(self, credential_id: str, *, model: str | None = None) -> None: """成功:清账号级错误累计;`model` 命中负缓存条目时一并清除。 @@ -338,7 +350,7 @@ def purge_expired_model_cooldowns(self, now: int | None = None) -> int: with self._db.transaction() as conn: cursor = conn.execute( "DELETE FROM credential_model_cooldowns WHERE cooling_until <= ?", - (int(now if now is not None else time.time()),)) + (_now(now),)) return cursor.rowcount def model_cooldowns_for(self, credential_id: str, @@ -347,7 +359,7 @@ def model_cooldowns_for(self, credential_id: str, rows = self._db.connect().execute( "SELECT model, cooling_until FROM credential_model_cooldowns " "WHERE credential_id = ? AND cooling_until > ?", - (credential_id, int(now if now is not None else time.time()))).fetchall() + (credential_id, _now(now))).fetchall() return {row["model"]: row["cooling_until"] for row in rows} def blocked_cooldowns(self, now: int | None = None) -> list[dict[str, Any]]: @@ -362,7 +374,7 @@ def blocked_cooldowns(self, now: int | None = None) -> list[dict[str, Any]]: "FROM credential_model_cooldowns c " "JOIN credentials cred ON cred.id = c.credential_id " "WHERE c.reason = 'blocked' AND c.cooling_until > ? " - "ORDER BY c.cooling_until", (int(now if now is not None else time.time()),) + "ORDER BY c.cooling_until", (_now(now),) ).fetchall() return [dict(row) for row in rows] @@ -475,7 +487,7 @@ def _record_credit_event(conn, credential_id: str, *, ledger: str, before, after def mark_probe_failed(self, credential_id: str, now: int | None = None) -> None: with self._db.transaction() as conn: conn.execute("UPDATE credentials SET quota_probed_at = ?, health = NULL WHERE id = ?", - (int(now if now is not None else time.time()), credential_id)) + (_now(now), credential_id)) def save_growth_result(self, credential_id: str, report: str, now: int | None = None) -> None: @@ -484,7 +496,7 @@ def save_growth_result(self, credential_id: str, report: str, conn.execute( "UPDATE credentials SET growth_last_run_at = ?, growth_last_result = ? " "WHERE id = ?", - (int(now if now is not None else time.time()), report, credential_id)) + (_now(now), report, credential_id)) # ------------------------------------------------------------- 读取 @@ -542,7 +554,7 @@ def pool_counts(self, now: int | None = None) -> dict[str, int]: 实际选号不一致的「可用数」。判定优先级:disabled → paused → cooling → ready,与调度器一致(禁用/暂停优先于冷却)。 """ - moment = int(now if now is not None else time.time()) + moment = _now(now) counts = {"total": 0, "ready": 0, "cooling": 0, "paused": 0, "disabled": 0} for candidate in self.candidates(): counts["total"] += 1 @@ -620,6 +632,8 @@ def list_all( 服务端算好的「剩余秒数」不会随页面 tick 更新,两处口径还会漂移。 到期时间未知时为 0(展示层隐藏,绝不当成已过期)。 """ + # 注意:这里用 `or` 而非 `is not None`——list_all 的调用方从不传 0, + # 而 `now=0`(epoch 起点)对它等同于「取当前时刻」是可接受的。 now = int(now or time.time()) rows = self._db.connect().execute( "SELECT id, provider, nickname, enabled, disabled, disabled_reason, pinned, " @@ -688,7 +702,7 @@ def expiring_tokens(self, *, within_seconds: int, now: int | None = None, """ if within_seconds <= 0: return [] - moment = int(now if now is not None else time.time()) + moment = _now(now) deadline = moment + within_seconds rows = self._db.connect().execute( "SELECT id, provider, nickname, data_enc, token_expires_at, token_issued_at " @@ -719,7 +733,7 @@ def create(self, username: str, name: str = "", now: int | None = None, quota: str = "") -> dict[str, Any]: plaintext = generate_api_key() key_id = _new_id("key") - created_at = int(now if now is not None else time.time()) + created_at = _now(now) with self._db.transaction() as conn: conn.execute( "INSERT INTO api_keys (id, username, name, key_digest, preview, created_at, " @@ -844,7 +858,7 @@ def record(self, *, credential_id: str, result, trigger: str = "auto", conn.execute( "INSERT INTO growth_events (id, credential_id, ts, ok, session_dead, " "report, credit, energy, streak_days, trigger) VALUES (?,?,?,?,?,?,?,?,?,?)", - (event_id, credential_id, int(now if now is not None else time.time()), + (event_id, credential_id, _now(now), 1 if result.ok else 0, 1 if result.session_dead else 0, result.report, result.credit, result.energy, result.streak_days, trigger), ) @@ -856,7 +870,7 @@ def prune(self, *, keep_days: int, now: int | None = None) -> int: 每轮探测都可能落一行,不回收会无界增长(读侧有 LIMIT,但表本身会 一直变大);保留期与请求明细一致,避免「成长中心记录比请求明细还久」。 """ - cutoff = int(now if now is not None else time.time()) - keep_days * 86400 + cutoff = _now(now) - keep_days * 86400 with self._db.transaction() as conn: cursor = conn.execute("DELETE FROM growth_events WHERE ts < ?", (cutoff,)) return cursor.rowcount @@ -909,7 +923,7 @@ def recent(self, credential_id: str, limit: int = 20, sort: str | None = None, def prune(self, *, keep_days: int, now: int | None = None) -> int: """删除超过保留期的流水(与 usage_events 同一保留策略入口)。""" - cutoff = int(now if now is not None else time.time()) - keep_days * 86400 + cutoff = _now(now) - keep_days * 86400 with self._db.transaction() as conn: cursor = conn.execute("DELETE FROM credit_events WHERE ts < ?", (cutoff,)) return cursor.rowcount @@ -936,7 +950,7 @@ def record(self, *, rule: str, severity: str, scope: str, message: str, conn.execute( "INSERT INTO alert_events (id, ts, rule, severity, scope, message, " "detail, delivered, delivery_error) VALUES (?,?,?,?,?,?,?,?,?)", - (alert_id, int(now if now is not None else time.time()), rule, + (alert_id, _now(now), rule, severity, scope, message, detail, 1 if delivered else 0, delivery_error), ) @@ -1003,7 +1017,7 @@ def resolve_by_id(self, alert_id: str, now: int | None = None) -> int: 只动这一行——不做「按 (rule, scope) 批量」,免得手滑一次把仍在告警的 同组记录一起收掉。 """ - resolved_at = int(now if now is not None else time.time()) + resolved_at = _now(now) with self._db.transaction() as conn: cursor = conn.execute( "UPDATE alert_events SET resolved_at = ? " @@ -1020,7 +1034,7 @@ def resolve(self, rule: str, scope: str, now: int | None = None) -> int: 返回受影响行数(0 = 没有待恢复的行,例如告警已被清理)。 """ - resolved_at = int(now if now is not None else time.time()) + resolved_at = _now(now) with self._db.transaction() as conn: cursor = conn.execute( "UPDATE alert_events SET resolved_at = ? " @@ -1030,7 +1044,7 @@ def resolve(self, rule: str, scope: str, now: int | None = None) -> int: def prune(self, *, keep_days: int, now: int | None = None) -> int: """删除超过保留期的告警(与 usage_events / 审计同一保留策略入口)。""" - cutoff = int(now if now is not None else time.time()) - keep_days * 86400 + cutoff = _now(now) - keep_days * 86400 with self._db.transaction() as conn: cursor = conn.execute("DELETE FROM alert_events WHERE ts < ?", (cutoff,)) return cursor.rowcount @@ -1058,7 +1072,7 @@ def set(self, key: str, value: str, now: int | None = None) -> None: "INSERT INTO runtime_settings (key, value, updated_at) VALUES (?,?,?) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value, " "updated_at = excluded.updated_at", - (key, value, int(now if now is not None else time.time())), + (key, value, _now(now)), ) def delete(self, key: str) -> None: @@ -1138,7 +1152,7 @@ def create(self, username: str, password_hash: str, *, role: str = "viewer", enabled: bool = True, must_change_password: bool = False, created_by: str | None = None, activation_digest: str | None = None, activation_expires_at: int | None = None, now: int | None = None) -> None: - timestamp = int(now if now is not None else time.time()) + timestamp = _now(now) with self._db.transaction() as conn: conn.execute( "INSERT INTO users (username, password_hash, role, enabled, " @@ -1170,7 +1184,7 @@ def update_role(self, username: str, role: str, *, bump_epoch: bool = True, "UPDATE users SET role = ?, session_epoch = session_epoch + ?, " "updated_at = ? WHERE username = ?", (role, 1 if bump_epoch else 0, - int(now if now is not None else time.time()), username), + _now(now), username), ) return cursor.rowcount > 0 @@ -1182,7 +1196,7 @@ def set_enabled(self, username: str, enabled: bool, *, "UPDATE users SET enabled = ?, session_epoch = session_epoch + 1, " "updated_at = ? WHERE username = ?", (1 if enabled else 0, - int(now if now is not None else time.time()), username), + _now(now), username), ) return cursor.rowcount > 0 @@ -1195,7 +1209,7 @@ def set_password(self, username: str, password_hash: str, *, "session_epoch = session_epoch + 1, activation_digest = NULL, " "activation_expires_at = NULL, updated_at = ? WHERE username = ?", (password_hash, 1 if must_change_password else 0, - int(now if now is not None else time.time()), username), + _now(now), username), ) return cursor.rowcount > 0 @@ -1206,7 +1220,7 @@ def set_activation_token(self, username: str, digest: str, expires_at: int, *, cursor = conn.execute( "UPDATE users SET activation_digest = ?, activation_expires_at = ?, " "updated_at = ? WHERE username = ?", - (digest, expires_at, int(now if now is not None else time.time()), username), + (digest, expires_at, _now(now), username), ) return cursor.rowcount > 0 @@ -1247,7 +1261,7 @@ def record(self, *, actor: str, action: str, target: str | None = None, conn.execute( "INSERT INTO audit_events (id, ts, actor, action, target, detail, ip, ok) " "VALUES (?,?,?,?,?,?,?,?)", - (event_id, int(now if now is not None else time.time()), actor, action, + (event_id, _now(now), actor, action, target, detail, ip, 1 if ok else 0), ) return event_id @@ -1301,7 +1315,7 @@ def _filters(*, actor: str | None, action: str | None, return where, params def prune(self, *, keep_days: int, now: int | None = None) -> int: - cutoff = int(now if now is not None else time.time()) - keep_days * 86400 + cutoff = _now(now) - keep_days * 86400 with self._db.transaction() as conn: cursor = conn.execute("DELETE FROM audit_events WHERE ts < ?", (cutoff,)) return cursor.rowcount @@ -1349,7 +1363,7 @@ def record(self, *, provider: str, model: str, state: str, 证据,继续累计会让一串无关故障凑成「连续失败」,把好模型判死。 `last_ok_at` 只在 `ok` 时前移,其余状态保留上一次成功时刻。 """ - stamp = int(now if now is not None else time.time()) + stamp = _now(now) with self._db.transaction() as conn: conn.execute( f"INSERT INTO model_probes ({self._RECORD_COLUMNS}) " @@ -1370,7 +1384,7 @@ def record(self, *, provider: str, model: str, state: str, def set_manual(self, *, provider: str, model: str, manual: str, now: int | None = None) -> None: """人工接管标记(`''` = 交还自动探活)。行不存在时先建一条空结论行。""" - stamp = int(now if now is not None else time.time()) + stamp = _now(now) with self._db.transaction() as conn: conn.execute( "INSERT INTO model_probes (provider, model, manual, updated_at) " @@ -1400,7 +1414,7 @@ def failed_and_blocked(self, now: int | None = None "JOIN credentials cred ON cred.id = c.credential_id " "JOIN model_probes p ON p.provider = cred.provider AND p.model = c.model " "WHERE c.reason = 'blocked' AND c.cooling_until > ? AND p.state = 'failed'", - (int(now if now is not None else time.time()),)).fetchall() + (_now(now),)).fetchall() return {(row["provider"], row["model"]) for row in rows} def manual_marked(self) -> set[tuple[str, str]]: diff --git a/src/engine/executor.py b/src/engine/executor.py index cd5ac6c1..1de21c01 100644 --- a/src/engine/executor.py +++ b/src/engine/executor.py @@ -263,7 +263,7 @@ def _record_invalid(self, username: str, provider_id: str, credential_id: str, self._deps.record( username=username, provider=provider_id, credential_id=credential_id, model=model, ok=False, error_type="invalid_request", - latency_ms=int((time.monotonic() - started) * 1000)) + latency_ms=_elapsed_ms(started)) def _suggestions(self, model: str) -> list[str] | None: """400 时给用户的相近模型建议(未注入或无候选则 None)。""" @@ -332,9 +332,8 @@ def _all_model_cooled(self, target: ModelTarget) -> bool: 调用点在 `_pick` 返回 None 之后,此时 `_select` 已保证至少注册了一个 上游(全无注册时它会先抛 NoProviderForModel),故无需再判空上游。 """ - registered = [pid for pid in self._narrow_providers(target) - if pid in self._deps.providers] - return self._model_cooled_only(registered, target, int(time.time())) + return self._model_cooled_only(self._registered(target), target, + int(time.time())) def _skip_provider(self, provider_id: str, tried: set[str]) -> None: """INVALID 后跳过该上游:把它的全部凭证都标记为已试。""" @@ -465,8 +464,7 @@ def preflight(self, request: ChatRequest, """ target = self.resolve_target(request, provider_binding) for attempt in self._fallback_chain(target): - if [pid for pid in self._narrow_providers(attempt) - if pid in self._deps.providers]: + if self._registered(attempt): return target raise NoProviderForModel(f"no provider registered for model {target.model!r}") @@ -1012,7 +1010,7 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, credential_id=last_credential, model=target.model, ok=False, error_type=self._exhaust_error_type(target), - latency_ms=int((time.monotonic() - started) * 1000)) + latency_ms=_elapsed_ms(started)) raise NoHealthyCredential( self._unavailable_text(target, last_error)) credential_id, credential_data = pick @@ -1182,7 +1180,7 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, if usage_event else False, ttfb_ms=(int((first_event_at - started) * 1000) if first_event_at is not None else None), - latency_ms=int((time.monotonic() - started) * 1000)) + latency_ms=_elapsed_ms(started)) return result finally: await aclose_stream(iterator) @@ -1200,7 +1198,7 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, username=username, provider=provider_id, credential_id=credential_id, model=target.model, ok=False, error_type=_error_type_for(kind) if kind else "upstream_protocol", - latency_ms=int((time.monotonic() - started) * 1000)) + latency_ms=_elapsed_ms(started)) raise NoHealthyCredential( self._unavailable_text(target, last_error)) @@ -1265,8 +1263,7 @@ async def _wait_short_cooldown(self, target: ModelTarget, limit = float(live(self._deps.short_cooldown_wait_seconds)() or 0) if limit <= 0: return False - registered = [pid for pid in self._narrow_providers(target) - if pid in self._deps.providers] + registered = self._registered(target) if not registered: return False now = int(time.time()) @@ -1329,6 +1326,11 @@ def _sticky(self, candidates: list, tried: set[str], return None return affinity_id + def _registered(self, target: ModelTarget) -> list[str]: + """收窄后的候选渠道 ∩ 已注册渠道(决定「这个模型能不能发出去」)。""" + return [pid for pid in self._narrow_providers(target) + if pid in self._deps.providers] + def _narrow_providers(self, target: ModelTarget) -> tuple[str, ...]: """模型目录能证明归属时,把候选上游收窄到登记了该模型的上游。 @@ -1395,8 +1397,7 @@ def _select(self, target: ModelTarget, tried: set[str], 的登记表里)`_narrow_providers` 本就放行全部候选,不存在「漏试真正 持有它的新渠道」——模型列表 TTL 300s + 后台兜底刷新即新鲜度上限。 """ - registered = [pid for pid in self._narrow_providers(target) - if pid in self._deps.providers] + registered = self._registered(target) if not registered: raise NoProviderForModel(f"no provider registered for model {target.model!r}") now = int(time.time()) @@ -1495,7 +1496,7 @@ def _note_upstream_error(self, credential_id: str, kind: ErrKind, self._deps.record( username=username, provider=provider_id, credential_id=credential_id, model=model, ok=False, error_type=_error_type_for(kind), - latency_ms=int((time.monotonic() - started) * 1000)) + latency_ms=_elapsed_ms(started)) return True def _queue_wait(self, retry_after: float | None, emitted: bool, diff --git a/src/tasks/runner.py b/src/tasks/runner.py index 160829d1..986c6c4b 100644 --- a/src/tasks/runner.py +++ b/src/tasks/runner.py @@ -238,49 +238,42 @@ def _loop_specs(self, *, manual: bool = False) -> list[_LoopSpec]: `run_once` 自身返回 None,记 no-op。 """ specs: list[_LoopSpec] = [ - _LoopSpec("quota_probe", lambda: self._quota_probe.run_once(), - lambda: self._interval_seconds("quota_probe")), - _LoopSpec("refresh", self._refresh.run_once, - lambda: self._interval_seconds("refresh")), - _LoopSpec("retention", self._sync_retention, - lambda: self._interval_seconds("retention")), + self._spec("quota_probe", lambda: self._quota_probe.run_once()), + self._spec("refresh", self._refresh.run_once), + self._spec("retention", self._sync_retention), # 签到:留空时点 = 全天每 10 分钟一轮;有配置时点时仍按 10 分钟醒 # 一次,由 `due()` 自己判定是否落在抖动窗口内。**不能**把周期改成 # 「按最大时点间隔算」——服务恰在两个时点之间重启会直接漏签,而 # 10 分钟空转的代价只是读一次配置(与 activity 的取舍同理)。 - _LoopSpec("checkin", - ((lambda: self._sync_checkin(manual=True)) if manual - else self._sync_checkin), - lambda: self._interval_seconds("checkin")), + self._spec("checkin", + ((lambda: self._sync_checkin(manual=True)) if manual + else self._sync_checkin)), ] if self._growth is not None: - specs.append(_LoopSpec("growth", self._sync_growth, - lambda: self._interval_seconds("growth"))) + specs.append(self._spec("growth", self._sync_growth)) if self._activity is not None: activity = ((lambda: self._sync_activity(manual=True)) if manual else self._sync_activity) - specs.append(_LoopSpec("activity", activity, - lambda: self._interval_seconds("activity"))) + specs.append(self._spec("activity", activity)) if self._model_catalog is not None: - specs.append(_LoopSpec("model_catalog", self._model_catalog, - lambda: self._interval_seconds("model_catalog"))) + specs.append(self._spec("model_catalog", self._model_catalog)) if self._model_probe is not None: - specs.append(_LoopSpec("model_probe", self._sync_model_probe, - lambda: self._interval_seconds("model_probe"))) + specs.append(self._spec("model_probe", self._sync_model_probe)) if self._openrouter_catalog is not None: - specs.append(_LoopSpec("openrouter_catalog", self._openrouter_catalog, - lambda: self._interval_seconds("openrouter_catalog"))) + specs.append(self._spec("openrouter_catalog", self._openrouter_catalog)) if self._alert is not None: - specs.append(_LoopSpec("alert", self._sync_alert, - lambda: self._interval_seconds("alert"))) + specs.append(self._spec("alert", self._sync_alert)) if self._backup is not None: - specs.append(_LoopSpec("backup", self._sync_backup, - lambda: self._interval_seconds("backup"))) + specs.append(self._spec("backup", self._sync_backup)) if self._upstream_status is not None: - specs.append(_LoopSpec("upstream_status", self._sync_upstream_status, - lambda: self._interval_seconds("upstream_status"))) + specs.append(self._spec("upstream_status", self._sync_upstream_status)) return specs + def _spec(self, key: str, + runner: Callable[[], Awaitable[object]]) -> _LoopSpec: + """拼一条循环描述:周期取值器统一走 `_interval_seconds`(热更现读)。""" + return _LoopSpec(key, runner, lambda: self._interval_seconds(key)) + async def run_task(self, key: str) -> dict[str, Any]: """手动运行一个后台任务一轮,返回该任务的最新运行态。 @@ -302,11 +295,21 @@ async def run_task(self, key: str) -> dict[str, Any]: _ok, ran = await self._run_guarded(spec.runner, TASK_BY_KEY[key].name, key=key) return {**self._task_item(key), "ran": ran, "busy": False} + @staticmethod + def _require(task: object | None, label: str) -> Any: + """取已装配的任务对象;未装配时显式报错(而不是静默 no-op)。 + + 这类失败只可能来自装配错误(可选任务未接线却出现在清单里), + 必须让 `_guarded` 记成失败而不是「未运行」。 + """ + if task is None: + raise RuntimeError(f"{label} task not configured") + return task + async def _sync_growth(self) -> object: """成长中心一轮:领礼物 / 派 Buddy / 任务 / 补登 / 兑换 / 抽奖 / 盲盒。""" - if self._growth is None: - raise RuntimeError("growth task not configured") - return await self._growth.run_once() + task = self._require(self._growth, "growth") + return await task.run_once() async def _sync_activity(self, *, manual: bool = False) -> object: """活跃上报:仅启用且处于配置小时窗口内才执行,成功凭证当日封账。 @@ -314,13 +317,12 @@ async def _sync_activity(self, *, manual: bool = False) -> object: 「是否启用」每轮现读:关掉再打开不需要重启,也不必重建循环。 `manual=True`(管理台「立即运行」)绕过时点窗口:手动就是立刻跑。 """ - if self._activity is None: - raise RuntimeError("activity task not configured") + task = self._require(self._activity, "activity") if not self._activity_enabled(): return None if not manual and not self._activity.due(): return None - return await self._activity.run_once() + return await task.run_once() async def _sync_checkin(self, *, manual: bool = False) -> object: """签到一轮:有配置时点时只在窗口内跑,留空则全天每 10 分钟一轮。 @@ -347,11 +349,10 @@ async def _sync_model_probe(self) -> object: 「模型探活周期」填上即生效,不必重启。关着时返回 None,`_guarded` 不记运行态——页面显示「未运行」,而不是「刚跑过」。 """ - if self._model_probe is None: - raise RuntimeError("model probe task not configured") + task = self._require(self._model_probe, "model probe") if int(self._model_probe_minutes()) <= 0: return None - return await self._model_probe.run_once() + return await task.run_once() async def _sync_alert(self) -> object: """运维告警一轮:评估四类规则、静默去重后落库 + 推送。 @@ -359,18 +360,16 @@ async def _sync_alert(self) -> object: 是否启用每轮现读:AlertTask.run_once 关着时返回 None, `_guarded` 不记运行态——页面显示「未运行」,而不是「刚跑过」。 """ - if self._alert is None: - raise RuntimeError("alert task not configured") - return await self._alert.run_once() + task = self._require(self._alert, "alert") + return await task.run_once() async def _sync_backup(self) -> object: """数据库备份一轮:VACUUM INTO 是同步磁盘 IO,放线程池跑。 直接在事件循环里执行会卡住所有并发请求(库越大越久)。 """ - if self._backup is None: - raise RuntimeError("backup task not configured") - return await asyncio.to_thread(self._backup.run_once) + task = self._require(self._backup, "backup") + return await asyncio.to_thread(task.run_once) async def _sync_upstream_status(self) -> object: """上游状态页一轮:并发抓全部源并原子替换内存快照。 @@ -378,9 +377,8 @@ async def _sync_upstream_status(self) -> object: 是否启用每轮现读:任务关着时 `run_once` 返回 None,`_guarded` 不记 运行态——页面显示「未运行」,而不是「刚跑过」。 """ - if self._upstream_status is None: - raise RuntimeError("upstream status task not configured") - return await self._upstream_status.run_once() + task = self._require(self._upstream_status, "upstream status") + return await task.run_once() async def _loop(self, name: str, runner: Callable[[], Awaitable[object]], interval: float | Callable[[], float], @@ -486,41 +484,41 @@ def _task_item(self, key: str) -> dict[str, Any]: } def _interval_seconds(self, key: str) -> float: - if key == "quota_probe": - return self._quota_interval - if key == "refresh": - return self._refresh_interval - if key == "checkin": - # 留空(旧行为)= 10 分钟醒一次即可;配了时点则必须 1 分钟醒一次—— - # 抖动=0 时窗口只有「恰好那一分钟」,10 分钟周期会大概率整天落空。 - # 醒来只是读一次配置 + 比较几个整数,代价可忽略。 - return self._checkin_interval - if key == "growth": - return self._growth_interval - if key == "activity": - return float(self._activity_interval) - if key == "model_catalog": - return self._model_catalog_interval - if key == "model_probe": - return self._model_probe_interval - if key == "openrouter_catalog": - return self._openrouter_catalog_interval - if key == "alert": - return self._alert_interval - if key == "backup": - return self._backup_interval - if key == "upstream_status": - return self._upstream_status_interval - return self._retention_interval + """按 key 取当前生效周期(秒)。property 是唯一真源,这里只做派发。""" + getter = self._interval_getters().get(key) + return float(getter()) if getter is not None else self._retention_interval + + def _interval_getters(self) -> dict[str, Callable[[], float]]: + """key → 周期 property 的派发表。 + + 与 `_task_enabled` 分开维护:周期是每个任务必有的一项,开关只有 + activity / alert / upstream_status 三个可关。用字典而非 if-chain, + 新增任务时漏接线会直接落进 `_interval_seconds` 的兜底分支(保留期), + 而不是静默返回别的任务的周期。 + """ + return { + "quota_probe": lambda: self._quota_interval, + "refresh": lambda: self._refresh_interval, + "retention": lambda: self._retention_interval, + "checkin": lambda: self._checkin_interval, + "growth": lambda: self._growth_interval, + "activity": lambda: self._activity_interval, + "model_catalog": lambda: self._model_catalog_interval, + "model_probe": lambda: self._model_probe_interval, + "openrouter_catalog": lambda: self._openrouter_catalog_interval, + "alert": lambda: self._alert_interval, + "backup": lambda: self._backup_interval, + "upstream_status": lambda: self._upstream_status_interval, + } def _task_enabled(self, key: str) -> bool: - if key == "activity": - return bool(self._activity_enabled()) - if key == "alert": - return bool(self._alert_enabled()) - if key == "upstream_status": - return bool(self._upstream_status_enabled()) - return True + """任务是否启用;只有三个可关的任务需要现读开关,其余恒 True。""" + getter = { + "activity": self._activity_enabled, + "alert": self._alert_enabled, + "upstream_status": self._upstream_status_enabled, + }.get(key) + return True if getter is None else bool(getter()) async def stop(self) -> None: for task in self._tasks: From c72c3777ea1e5a1b2410d701eec9ec4e6aea814f Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 21:21:16 +0800 Subject: [PATCH 013/153] =?UTF-8?q?refactor(engine):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=20executor=20=E6=8E=92=E9=98=9F=E9=87=8D=E5=BC=80=E7=9A=84=204?= =?UTF-8?q?=20=E4=BB=BD=E9=87=8D=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四个入口(流式事件 / 流式异常 / 非流式 _Queued / 非流式异常)原本各写一份 「queue_rounds+1 / attempts-1 / 记 pinned / 打日志」,收敛成 _plan_queue_restart() -> _QueueRestart。 关键点: - attempts -= 1 的口径只有一处了。排队不是「换号尝试」,不归还预算的话 三次排队就把 max_rotate 吃满、请求直接 503。 - pinned 记下被点名的凭证,下一轮跳过 _pick —— 否则可能选到别的号, 排队语义就丢了。 - _Queued 改为携带完整 plan 而非只有秒数:计数与 pin 在抛出点算好, except 分支直接落账,避免两处各算一遍而漂移。 补 _plan_queue_restart 的直接测试(预算口径 + 两种不该重开的情形)。 覆盖 4 个入口的既有端到端测试(test_queue_retry.py 529 行)全部保持通过。 覆盖率 100%,3891 后端全绿。 --- src/engine/executor.py | 130 +++++++++++++++++++------------ tests/engine/test_queue_retry.py | 33 ++++++++ 2 files changed, 112 insertions(+), 51 deletions(-) diff --git a/src/engine/executor.py b/src/engine/executor.py index 1de21c01..751bde40 100644 --- a/src/engine/executor.py +++ b/src/engine/executor.py @@ -101,11 +101,24 @@ class _Queued(Exception): 用异常而不是 `break`+标志位,是因为非流式的读流包在 `try/except/else` 里, `break` 出来会落到 `else` 分支把**半截** events 拿去聚合。异常能干净地 跳过 `else`,走 finally 释放租约后再等待。 + + 携带完整的 `plan`(而不只是秒数):计数与 pinned 在抛出点已经算好, + `except` 分支直接落账,避免两处各算一遍而漂移。 """ - def __init__(self, wait: float) -> None: - super().__init__(f"upstream queued {wait}s") - self.wait = wait + def __init__(self, plan: _QueueRestart) -> None: + super().__init__(f"upstream queued {plan.wait}s") + self.plan = plan + + +@dataclass(slots=True) +class _QueueRestart: + """一次排队重开的计划(四个入口共用,见 `_plan_queue_restart`)。""" + + wait: float + rounds: int + attempts: int + pinned: tuple[str, dict[str, Any]] class _StreamTimeout(Exception): @@ -663,20 +676,16 @@ def terminal(message: str, code: str) -> bytes: # 排队:上游点名「等 N 秒再来」。未出帧时在原号上 # 重开,不冷却、不换号、不占轮换预算;已出帧则 # 无法重开(半截输出不可回滚),按 SOFT 收尾。 - wait = self._queue_wait(event.retry_after_seconds, - emitted, queue_rounds) - if wait is not None: - queue_rounds += 1 - # 归还轮换预算:排队不是「换号尝试」。不归还的话 - # 三次排队就把 max_rotate 吃满,请求直接 503。 - attempts -= 1 - pinned = (credential_id, credential_data) - queued_wait = wait - logger.info( - "上游 %s 排队中(凭证 %s,第 %s/%s 轮)," - "%.1fs 后原号重开", - provider_id, credential_id, queue_rounds, - QUEUE_MAX_ROUNDS, wait) + plan = self._plan_queue_restart( + event.retry_after_seconds, emitted=emitted, + rounds=queue_rounds, attempts=attempts, + provider_id=provider_id, credential_id=credential_id, + credential_data=credential_data) + if plan is not None: + queue_rounds = plan.rounds + attempts = plan.attempts + pinned = plan.pinned + queued_wait = plan.wait broken = True break kind = ErrKind.SOFT @@ -725,18 +734,16 @@ def terminal(message: str, code: str) -> bytes: if kind is ErrKind.QUEUE: # 排队也可能以 HTTP 403 + 排队 body 的形式抛出(见 # qoder.events.classify_status):同样在原号上等后重开。 - wait = self._queue_wait( + plan = self._plan_queue_restart( getattr(error, "retry_after_seconds", None), - emitted, queue_rounds) - if wait is not None: - queue_rounds += 1 - attempts -= 1 - pinned = (credential_id, credential_data) - queued_wait = wait - logger.info( - "上游 %s 排队中(凭证 %s,第 %s/%s 轮),%.1fs 后原号重开", - provider_id, credential_id, queue_rounds, - QUEUE_MAX_ROUNDS, wait) + emitted=emitted, rounds=queue_rounds, attempts=attempts, + provider_id=provider_id, credential_id=credential_id, + credential_data=credential_data) + if plan is not None: + queue_rounds = plan.rounds + attempts = plan.attempts + pinned = plan.pinned + queued_wait = plan.wait else: kind = ErrKind.SOFT logger.warning("上游 %s 排队但无法重开(凭证 %s): %s", @@ -1045,10 +1052,13 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, and _event_kind(event) is ErrKind.QUEUE): # 排队信号必须在入 events **之前**拦下:进了 events # 就会被 `aggregate` 当成流内错误,直接冷却换号。 - wait = self._queue_wait(event.retry_after_seconds, - False, queue_rounds) - if wait is not None: - raise _Queued(wait) + plan = self._plan_queue_restart( + event.retry_after_seconds, emitted=False, + rounds=queue_rounds, attempts=attempts, + provider_id=provider_id, credential_id=credential_id, + credential_data=credential_data) + if plan is not None: + raise _Queued(plan) # 轮次用尽:降级为普通瞬态错误,交给既有路径处理 event = replace(event, error_kind=ErrKind.SOFT) events.append(event) @@ -1059,14 +1069,10 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, raise _AggregateOverflow( self._deps.complete_max_events) except _Queued as queued: - queue_rounds += 1 - attempts -= 1 - pinned = (credential_id, credential_data) - queued_wait = queued.wait - logger.info( - "上游 %s 排队中(凭证 %s,第 %s/%s 轮),%.1fs 后原号重开", - provider_id, credential_id, queue_rounds, - QUEUE_MAX_ROUNDS, queued.wait) + queue_rounds = queued.plan.rounds + attempts = queued.plan.attempts + pinned = queued.plan.pinned + queued_wait = queued.plan.wait except TimeoutError: logger.warning( "上游 %s 非流式聚合超过 %ss(凭证 %s,按瞬态错误换号)", @@ -1095,18 +1101,16 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, raise if kind is ErrKind.QUEUE: # HTTP 级排队(403 + 排队 body):与流内排队同一套语义 - wait = self._queue_wait( + plan = self._plan_queue_restart( getattr(error, "retry_after_seconds", None), - False, queue_rounds) - if wait is not None: - queue_rounds += 1 - attempts -= 1 - pinned = (credential_id, credential_data) - queued_wait = wait - logger.info( - "上游 %s 排队中(凭证 %s,第 %s/%s 轮),%.1fs 后原号重开", - provider_id, credential_id, queue_rounds, - QUEUE_MAX_ROUNDS, wait) + emitted=False, rounds=queue_rounds, attempts=attempts, + provider_id=provider_id, credential_id=credential_id, + credential_data=credential_data) + if plan is not None: + queue_rounds = plan.rounds + attempts = plan.attempts + pinned = plan.pinned + queued_wait = plan.wait else: logger.warning("上游 %s 排队但无法重开(凭证 %s): %s", provider_id, credential_id, _error_detail(error)) @@ -1517,6 +1521,30 @@ def _queue_wait(self, retry_after: float | None, emitted: bool, wait = QUEUE_DEFAULT_WAIT_SECONDS if retry_after is None else float(retry_after) return min(max(wait, 0.0), QUEUE_MAX_WAIT_SECONDS) + def _plan_queue_restart( + self, retry_after: float | None, *, emitted: bool, rounds: int, + attempts: int, provider_id: str, credential_id: str, + credential_data: dict[str, Any], + ) -> _QueueRestart | None: + """排队信号 → 重开计划;不该重开时返回 None(调用方降级为 SOFT)。 + + 四个入口(流式事件 / 流式异常 / 非流式 `_Queued` / 非流式异常)原本各 + 写一份「计数 + 记 pinned + 打日志」,收敛到这里保证四处的预算口径一致: + + - `attempts -= 1`:排队不是「换号尝试」。不归还的话三次排队就把 + `max_rotate` 吃满,请求直接 503。 + - `pinned` 记下被点名的凭证,下一轮跳过 `_pick` 直接用——否则可能选到 + 别的号,排队语义就丢了。 + """ + wait = self._queue_wait(retry_after, emitted, rounds) + if wait is None: + return None + rounds += 1 + logger.info("上游 %s 排队中(凭证 %s,第 %s/%s 轮),%.1fs 后原号重开", + provider_id, credential_id, rounds, QUEUE_MAX_ROUNDS, wait) + return _QueueRestart(wait=wait, rounds=rounds, attempts=attempts - 1, + pinned=(credential_id, credential_data)) + def _model_scope(self, provider_id: str, model: str) -> str: """冷却表登记的模型名:用上游原始名(与 provider 实际调用的名字一致)。""" return self._upstream_model(provider_id, model) diff --git a/tests/engine/test_queue_retry.py b/tests/engine/test_queue_retry.py index ec45128e..43a799b8 100644 --- a/tests/engine/test_queue_retry.py +++ b/tests/engine/test_queue_retry.py @@ -523,6 +523,39 @@ def test_queue_wait_uses_value(repo): db.close() +def test_plan_queue_restart_returns_budget_and_pin(repo): + """四个入口共用同一份预算口径:轮次 +1、attempts -1、pin 原号。""" + credentials, db = repo + executor = _executor(credentials, object()) + data = {"access_token": "t"} + + plan = executor._plan_queue_restart( + 7.0, emitted=False, rounds=0, attempts=3, + provider_id="trae", credential_id="cid-1", credential_data=data) + + assert plan is not None + assert plan.wait == 7.0 + assert plan.rounds == 1 + # 排队不是换号尝试:不归还预算的话三次排队就把 max_rotate 吃满 + assert plan.attempts == 2 + # pin 原号:下一轮跳过 _pick,排队语义才不会丢 + assert plan.pinned == ("cid-1", data) + db.close() + + +def test_plan_queue_restart_none_when_emitted_or_exhausted(repo): + """不该重开的两种情形都返回 None,由调用方降级为 SOFT。""" + credentials, db = repo + executor = _executor(credentials, object()) + kwargs = dict(attempts=3, provider_id="trae", credential_id="cid-1", + credential_data={}) + + assert executor._plan_queue_restart(1.0, emitted=True, rounds=0, **kwargs) is None + assert executor._plan_queue_restart( + 1.0, emitted=False, rounds=QUEUE_MAX_ROUNDS, **kwargs) is None + db.close() + + def test_error_type_for_queue_is_rate_limit(): from src.engine.executor import _error_type_for From 91679f2285683da040d95158cf42dc1cf126c0bd Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 21:46:43 +0800 Subject: [PATCH 014/153] =?UTF-8?q?refactor(provider):=20=E5=9B=9B?= =?UTF-8?q?=E5=AE=B6=20OpenAI=20=E5=85=BC=E5=AE=B9=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E7=9A=84=20SSE=20=E8=A7=A3=E6=9E=90=E9=AA=A8=E6=9E=B6=E5=90=88?= =?UTF-8?q?=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zen / kilo / officeace / codebuddy 的 parse_frame / parse_all_events / _event_from_payload / _payload 是同一份骨架,差异只有三处,全部参数化: - openai_chunk.payload_of(frame, label):帧 → JSON,label 只进报错文案。 - openai_chunk.openai_event(payload, *, reasoning_keys, error_event, error_as_string, usage):代表性事件,优先级 tool_calls > 正文 > 思考 > 错误 > usage > finish。 - openai_chunk.openai_events(...):合并帧补 usage/finish(丢 finish 会让 客户端永远收不到结束信号)。 不猜键,差异显式传参: - reasoning_keys:Zen/CodeBuddy/OfficeAce 用 reasoning_content,Kilo 用 reasoning。 - error_as_string:只有 OfficeAce 实测有 `{"error": "文本"}`。 - usage:Zen/CodeBuddy 的 credit=True 走各自的 _usage 薄包装(默认值会 静默吞掉 credit —— 已补 test_usage_parses_credit_when_present 锁住)。 - error_event:错误分类是各渠道私有码表,只注入回调。 顺带修掉 CodeBuddy 的重复解析:原先 parse_all_events 自己再 json.loads 一遍再调 parse_frame(后者又解析一次)。 四家 events.py 净减 177 行;openai_chunk 新增 114 行(含 docstring)。 保留 UpstreamProtocolViolation 的模块级 re-export,测试与 credential/ client 仍经各渠道 events 访问。 覆盖率 100%,3902 后端 + 644 前端全绿。 --- src/provider/codebuddy/events.py | 71 ++--------------- src/provider/kilo/events.py | 92 +++------------------- src/provider/officeace/events.py | 91 ++-------------------- src/provider/openai_chunk.py | 114 +++++++++++++++++++++++++++- src/provider/zen/events.py | 89 ++-------------------- tests/provider/test_openai_chunk.py | 92 +++++++++++++++++++++- tests/provider/zen/test_events.py | 7 ++ 7 files changed, 239 insertions(+), 317 deletions(-) diff --git a/src/provider/codebuddy/events.py b/src/provider/codebuddy/events.py index ed828f92..9c75f158 100644 --- a/src/provider/codebuddy/events.py +++ b/src/provider/codebuddy/events.py @@ -7,90 +7,29 @@ from __future__ import annotations -import json from typing import Any from ...engine.sse import SSEFrame from ...provider.base import ( ErrKind, Event, - EventKind, - UpstreamProtocolViolation, + UpstreamProtocolViolation, # noqa: F401 - credential/client 经本模块导入 Usage, body_hint, business_codes, ) -from ..openai_chunk import first_choice, is_blank_tool_call, usage_from_openai +from ..openai_chunk import openai_event, openai_events, payload_of, usage_from_openai def parse_frame(frame: SSEFrame) -> Event | None: """单帧 → 中立事件;[DONE] 与无 delta 的帧返回 None。""" - if not frame.data: - return None - if frame.data.strip() == "[DONE]": - return None - try: - payload = json.loads(frame.data) - except json.JSONDecodeError as error: - raise UpstreamProtocolViolation("unparsable CodeBuddy SSE data") from error - if not isinstance(payload, dict): - raise UpstreamProtocolViolation("CodeBuddy SSE data is not an object") - - choice = first_choice(payload) - delta = choice.get("delta") if choice else None - delta = delta if isinstance(delta, dict) else {} - finish_reason = choice.get("finish_reason") if choice else None - - tool_calls = delta.get("tool_calls") - if isinstance(tool_calls, list): - # 上游偶发噪声调用:无 name 且 arguments 为空/{}(客户端聚合后 - # 显示 "Tool not found")。正常分片块无 name 但带实际 arguments, - # 必须保留。空名噪声整条丢弃。 - kept = [tc for tc in tool_calls if isinstance(tc, dict) and not is_blank_tool_call(tc)] - if kept: - return Event(kind=EventKind.TOOL_CALLS, tool_calls=kept) - - content = delta.get("content") - if isinstance(content, str) and content: - return Event(kind=EventKind.CONTENT, content=content) - - reasoning = delta.get("reasoning_content") - if isinstance(reasoning, str) and reasoning: - return Event(kind=EventKind.REASONING, content=reasoning) - - usage = payload.get("usage") - if isinstance(usage, dict): - return Event(kind=EventKind.USAGE, usage=_usage(usage)) - - if isinstance(finish_reason, str) and finish_reason: - return Event(kind=EventKind.FINISH, finish_reason=finish_reason) - return None + payload = payload_of(frame, "CodeBuddy") + return None if payload is None else openai_event(payload, usage=_usage) def parse_all_events(frame: SSEFrame) -> list[Event]: """一帧拆成多个事件:带 usage 的收尾帧同时给出 finish_reason 与 usage。""" - if not frame.data: - return [] - if frame.data.strip() == "[DONE]": - return [] - try: - payload = json.loads(frame.data) - except json.JSONDecodeError as error: - raise UpstreamProtocolViolation("unparsable CodeBuddy SSE data") from error - if not isinstance(payload, dict): - raise UpstreamProtocolViolation("CodeBuddy SSE data is not an object") - - events: list[Event] = [e for e in (parse_frame(frame),) if e is not None] - has_usage = any(e.kind is EventKind.USAGE for e in events) - has_finish = any(e.kind is EventKind.FINISH for e in events) - usage = payload.get("usage") - if isinstance(usage, dict) and not has_usage: - events.append(Event(kind=EventKind.USAGE, usage=_usage(usage))) - choice = first_choice(payload) - finish_reason = choice.get("finish_reason") if choice else None - if isinstance(finish_reason, str) and finish_reason and not has_finish: - events.append(Event(kind=EventKind.FINISH, finish_reason=finish_reason)) - return events + return openai_events(frame, "CodeBuddy", usage=_usage) def _usage(raw: dict[str, Any]) -> Usage: diff --git a/src/provider/kilo/events.py b/src/provider/kilo/events.py index 22aaaa82..e6312ef7 100644 --- a/src/provider/kilo/events.py +++ b/src/provider/kilo/events.py @@ -20,7 +20,6 @@ from __future__ import annotations -import json from typing import Any from ...engine.sse import SSEFrame @@ -28,97 +27,24 @@ ErrKind, Event, EventKind, - UpstreamProtocolViolation, + UpstreamProtocolViolation, # noqa: F401 - 测试经本模块访问 Usage, ) -from ..openai_chunk import first_choice, is_blank_tool_call, usage_from_openai +from ..openai_chunk import openai_event, openai_events, payload_of, usage_from_openai def parse_frame(frame: SSEFrame) -> Event | None: """单帧 → 单个代表性事件;`[DONE]` 与无内容帧返回 None。""" - payload = _payload(frame) - if payload is None: - return None - return _event_from_payload(payload) - - -def _event_from_payload(payload: dict[str, Any]) -> Event | None: - """已解析 JSON → 代表性事件。 - - 优先级:tool_calls > 正文 > 思考 > 错误 > usage > finish。 - """ - choice = first_choice(payload) - delta = choice.get("delta") if choice else None - delta = delta if isinstance(delta, dict) else {} - finish_reason = choice.get("finish_reason") if choice else None - - tool_calls = delta.get("tool_calls") - if isinstance(tool_calls, list): - # 空名且空参的噪声调用(客户端聚合后显示 "Tool not found"): - # 正常分片块无 name 但带实际 arguments,必须保留。 - kept = [tc for tc in tool_calls - if isinstance(tc, dict) and not is_blank_tool_call(tc)] - if kept: - return Event(kind=EventKind.TOOL_CALLS, tool_calls=kept) - - content = delta.get("content") - if isinstance(content, str) and content: - return Event(kind=EventKind.CONTENT, content=content) - - # Kilo 用 delta.reasoning(而非 reasoning_content) - reasoning = delta.get("reasoning") - if isinstance(reasoning, str) and reasoning: - return Event(kind=EventKind.REASONING, content=reasoning) - - error = payload.get("error") - if isinstance(error, dict): - return _error_event(error) - - usage = payload.get("usage") - if isinstance(usage, dict): - return Event(kind=EventKind.USAGE, usage=_usage(usage)) - - if isinstance(finish_reason, str) and finish_reason: - return Event(kind=EventKind.FINISH, finish_reason=finish_reason) - return None + payload = payload_of(frame, "Kilo") + return None if payload is None else openai_event( + payload, reasoning_keys=("reasoning",), error_event=_error_event, + usage=_usage) def parse_all_events(frame: SSEFrame) -> list[Event]: - """一帧拆成多个事件:同一 chunk 可同时携带正文/思考与 finish_reason。 - - 必须基于代表性事件再补 usage/finish,否则「最后一段正文 + finish」 - 这种合并帧会丢掉 finish(客户端永远收不到结束信号)。 - """ - payload = _payload(frame) - if payload is None: - return [] - events: list[Event] = [e for e in (_event_from_payload(payload),) if e is not None] - has_usage = any(e.kind is EventKind.USAGE for e in events) - has_finish = any(e.kind is EventKind.FINISH for e in events) - - usage = payload.get("usage") - if isinstance(usage, dict) and not has_usage: - events.append(Event(kind=EventKind.USAGE, usage=_usage(usage))) - choice = first_choice(payload) - finish_reason = choice.get("finish_reason") if choice else None - if isinstance(finish_reason, str) and finish_reason and not has_finish: - events.append(Event(kind=EventKind.FINISH, finish_reason=finish_reason)) - return events - - -def _payload(frame: SSEFrame) -> dict[str, Any] | None: - """帧 → JSON 对象;空帧 / `[DONE]` 返回 None,坏帧显式失败。""" - if not frame.data: - return None - if frame.data.strip() == "[DONE]": - return None - try: - payload = json.loads(frame.data) - except json.JSONDecodeError as error: - raise UpstreamProtocolViolation("unparsable Kilo SSE data") from error - if not isinstance(payload, dict): - raise UpstreamProtocolViolation("Kilo SSE data is not an object") - return payload + """一帧拆成多个事件:同一 chunk 可同时携带正文/思考与 finish_reason。""" + return openai_events(frame, "Kilo", reasoning_keys=("reasoning",), + error_event=_error_event, usage=_usage) def _error_event(error: dict[str, Any]) -> Event: diff --git a/src/provider/officeace/events.py b/src/provider/officeace/events.py index bc068cb5..308cccd3 100644 --- a/src/provider/officeace/events.py +++ b/src/provider/officeace/events.py @@ -31,7 +31,6 @@ from __future__ import annotations -import json import time from typing import Any @@ -44,7 +43,7 @@ Usage, business_codes, ) -from ..openai_chunk import first_choice, is_blank_tool_call, usage_from_openai +from ..openai_chunk import openai_event, openai_events, payload_of, usage_from_openai # ---------------------------------------------------------------- 域与端点 @@ -198,91 +197,15 @@ def classify_error_code(code: int | None, message: str = "") -> ErrKind: def parse_frame(frame: SSEFrame) -> Event | None: """单帧 → 代表性事件;`[DONE]` 与无内容帧返回 None。""" - payload = _payload(frame) - if payload is None: - return None - return _event_from_payload(payload) + payload = payload_of(frame, "officeAce") + return None if payload is None else openai_event( + payload, error_event=_error_event, error_as_string=True) def parse_all_events(frame: SSEFrame) -> list[Event]: - """一帧拆成多个事件:同一 chunk 可同时携带正文与 finish_reason。 - - 必须基于代表性事件再补 usage/finish,否则「最后一段正文 + finish」 - 这种合并帧会丢掉 finish(客户端永远收不到结束信号)。 - """ - payload = _payload(frame) - if payload is None: - return [] - events: list[Event] = [e for e in (_event_from_payload(payload),) if e is not None] - has_usage = any(e.kind is EventKind.USAGE for e in events) - has_finish = any(e.kind is EventKind.FINISH for e in events) - - usage = payload.get("usage") - if isinstance(usage, dict) and not has_usage: - events.append(Event(kind=EventKind.USAGE, usage=_usage(usage))) - choice = first_choice(payload) - finish_reason = choice.get("finish_reason") if choice else None - if isinstance(finish_reason, str) and finish_reason and not has_finish: - events.append(Event(kind=EventKind.FINISH, finish_reason=finish_reason)) - return events - - -def _event_from_payload(payload: dict[str, Any]) -> Event | None: - """已解析 JSON → 代表性事件。 - - 优先级:tool_calls > 正文 > 思考 > 错误 > usage > finish。 - 错误排在正文之后是有意的:伪 200 的第二帧常常「无正文 + error」, - 而带正文的帧不该被错误覆盖。 - """ - choice = first_choice(payload) - delta = choice.get("delta") if choice else None - delta = delta if isinstance(delta, dict) else {} - finish_reason = choice.get("finish_reason") if choice else None - - tool_calls = delta.get("tool_calls") - if isinstance(tool_calls, list): - kept = [tc for tc in tool_calls - if isinstance(tc, dict) and not is_blank_tool_call(tc)] - if kept: - return Event(kind=EventKind.TOOL_CALLS, tool_calls=kept) - - content = delta.get("content") - if isinstance(content, str) and content: - return Event(kind=EventKind.CONTENT, content=content) - - # 上游推理模型给 `reasoning_content`(OpenAI 兼容惯例) - reasoning = delta.get("reasoning_content") - if isinstance(reasoning, str) and reasoning: - return Event(kind=EventKind.REASONING, content=reasoning) - - error = payload.get("error") - if isinstance(error, dict): - return _error_event(error) - if isinstance(error, str) and error: - return _error_event({"message": error}) - - usage = payload.get("usage") - if isinstance(usage, dict): - return Event(kind=EventKind.USAGE, usage=_usage(usage)) - - if isinstance(finish_reason, str) and finish_reason: - return Event(kind=EventKind.FINISH, finish_reason=finish_reason) - return None - - -def _payload(frame: SSEFrame) -> dict[str, Any] | None: - """帧 → JSON 对象;空帧 / `[DONE]` 返回 None,坏帧显式失败。""" - if not frame.data: - return None - if frame.data.strip() == "[DONE]": - return None - try: - payload = json.loads(frame.data) - except json.JSONDecodeError as error: - raise UpstreamProtocolViolation("unparsable officeAce SSE data") from error - if not isinstance(payload, dict): - raise UpstreamProtocolViolation("officeAce SSE data is not an object") - return payload + """一帧拆成多个事件:同一 chunk 可同时携带正文与 finish_reason。""" + return openai_events(frame, "officeAce", error_event=_error_event, + error_as_string=True) def _error_event(error: dict[str, Any]) -> Event: diff --git a/src/provider/openai_chunk.py b/src/provider/openai_chunk.py index ce2558b4..49938d70 100644 --- a/src/provider/openai_chunk.py +++ b/src/provider/openai_chunk.py @@ -8,9 +8,12 @@ from __future__ import annotations +import json +from collections.abc import Callable from typing import Any -from .base import UpstreamProtocolViolation, Usage +from ..engine.sse import SSEFrame +from .base import Event, EventKind, UpstreamProtocolViolation, Usage # 空 JSON 对象串(`arguments` 有时是未解析的原始字符串);用拼接构造以避开 # 编辑期对 `` 字面量的意外改写。 @@ -100,3 +103,112 @@ def usage_from_openai( if isinstance(credit_value, (int, float)) and not isinstance(credit_value, bool) else None), ) + + +def payload_of(frame: SSEFrame, label: str) -> dict[str, Any] | None: + """SSE 帧 → JSON 对象;空帧 / `[DONE]` 返回 None,坏帧显式失败。 + + `label` 只进报错文案(便于定位是哪条渠道的帧坏了)。 + """ + if not frame.data: + return None + if frame.data.strip() == "[DONE]": + return None + try: + payload = json.loads(frame.data) + except json.JSONDecodeError as error: + raise UpstreamProtocolViolation(f"unparsable {label} SSE data") from error + if not isinstance(payload, dict): + raise UpstreamProtocolViolation(f"{label} SSE data is not an object") + return payload + + +def openai_event( + payload: dict[str, Any], + *, + reasoning_keys: tuple[str, ...] = ("reasoning_content",), + error_event: Callable[[dict[str, Any]], Event] | None = None, + error_as_string: bool = False, + usage: Callable[[dict[str, Any]], Usage] = usage_from_openai, +) -> Event | None: + """已解析 OpenAI chunk → 代表性事件。 + + 优先级:tool_calls > 正文 > 思考 > 错误 > usage > finish。 + + 差异参数化,不猜键: + * `reasoning_keys`:Zen / CodeBuddy / OfficeAce 用 `reasoning_content`, + Kilo 用 `reasoning`。按顺序取第一个非空串。 + * `error_event`:错误信封的分类是各渠道私有码表,只注入回调。 + * `error_as_string`:OfficeAce 实测有 `{"error": "文本"}` 形态。 + """ + choice = first_choice(payload) + delta = choice.get("delta") if choice else None + delta = delta if isinstance(delta, dict) else {} + finish_reason = choice.get("finish_reason") if choice else None + + tool_calls = delta.get("tool_calls") + if isinstance(tool_calls, list): + # 无 name 且 arguments 为空的噪声调用(客户端聚合后显示 + # "Tool not found"):正常分片块无 name 但带实际 arguments,必须保留。 + kept = [tc for tc in tool_calls + if isinstance(tc, dict) and not is_blank_tool_call(tc)] + if kept: + return Event(kind=EventKind.TOOL_CALLS, tool_calls=kept) + + content = delta.get("content") + if isinstance(content, str) and content: + return Event(kind=EventKind.CONTENT, content=content) + + for key in reasoning_keys: + reasoning = delta.get(key) + if isinstance(reasoning, str) and reasoning: + return Event(kind=EventKind.REASONING, content=reasoning) + + if error_event is not None: + error = payload.get("error") + if isinstance(error, dict): + return error_event(error) + if error_as_string and isinstance(error, str) and error: + return error_event({"message": error}) + + usage_raw = payload.get("usage") + if isinstance(usage_raw, dict): + return Event(kind=EventKind.USAGE, usage=usage(usage_raw)) + + if isinstance(finish_reason, str) and finish_reason: + return Event(kind=EventKind.FINISH, finish_reason=finish_reason) + return None + + +def openai_events( + frame: SSEFrame, + label: str, + *, + reasoning_keys: tuple[str, ...] = ("reasoning_content",), + error_event: Callable[[dict[str, Any]], Event] | None = None, + error_as_string: bool = False, + usage: Callable[[dict[str, Any]], Usage] = usage_from_openai, +) -> list[Event]: + """一帧拆成多个事件:同一 chunk 可同时携带正文与 finish_reason。 + + 必须基于代表性事件再补 usage/finish,否则「最后一段正文 + finish」 + 这种合并帧会丢掉 finish(客户端永远收不到结束信号)。 + """ + payload = payload_of(frame, label) + if payload is None: + return [] + representative = openai_event( + payload, reasoning_keys=reasoning_keys, error_event=error_event, + error_as_string=error_as_string, usage=usage) + events: list[Event] = [e for e in (representative,) if e is not None] + has_usage = any(e.kind is EventKind.USAGE for e in events) + has_finish = any(e.kind is EventKind.FINISH for e in events) + + usage_raw = payload.get("usage") + if isinstance(usage_raw, dict) and not has_usage: + events.append(Event(kind=EventKind.USAGE, usage=usage(usage_raw))) + choice = first_choice(payload) + finish_reason = choice.get("finish_reason") if choice else None + if isinstance(finish_reason, str) and finish_reason and not has_finish: + events.append(Event(kind=EventKind.FINISH, finish_reason=finish_reason)) + return events diff --git a/src/provider/zen/events.py b/src/provider/zen/events.py index 099b5989..e0d2d13c 100644 --- a/src/provider/zen/events.py +++ b/src/provider/zen/events.py @@ -16,7 +16,6 @@ from __future__ import annotations -import json from typing import Any from ...engine.sse import SSEFrame @@ -24,96 +23,22 @@ ErrKind, Event, EventKind, - UpstreamProtocolViolation, + UpstreamProtocolViolation, # noqa: F401 - 测试经本模块访问 Usage, ) -from ..openai_chunk import first_choice, is_blank_tool_call, usage_from_openai +from ..openai_chunk import openai_event, openai_events, payload_of, usage_from_openai def parse_frame(frame: SSEFrame) -> Event | None: """单帧 → 单个代表性事件;`[DONE]` 与无内容帧返回 None。""" - payload = _payload(frame) - if payload is None: - return None - return _event_from_payload(payload) - - -def _event_from_payload(payload: dict[str, Any]) -> Event | None: - """已解析 JSON → 代表性事件。 - - 优先级:tool_calls > 正文 > 思考 > 错误 > usage > finish。 - """ - choice = first_choice(payload) - delta = choice.get("delta") if choice else None - delta = delta if isinstance(delta, dict) else {} - finish_reason = choice.get("finish_reason") if choice else None - - tool_calls = delta.get("tool_calls") - if isinstance(tool_calls, list): - # 无 name 且 arguments 为空的噪声调用(客户端聚合后显示 - # "Tool not found"):正常分片块无 name 但带实际 arguments,必须保留。 - kept = [tc for tc in tool_calls - if isinstance(tc, dict) and not is_blank_tool_call(tc)] - if kept: - return Event(kind=EventKind.TOOL_CALLS, tool_calls=kept) - - content = delta.get("content") - if isinstance(content, str) and content: - return Event(kind=EventKind.CONTENT, content=content) - - reasoning = delta.get("reasoning_content") - if isinstance(reasoning, str) and reasoning: - return Event(kind=EventKind.REASONING, content=reasoning) - - error = payload.get("error") - if isinstance(error, dict): - return _error_event(error) - - usage = payload.get("usage") - if isinstance(usage, dict): - return Event(kind=EventKind.USAGE, usage=_usage(usage)) - - if isinstance(finish_reason, str) and finish_reason: - return Event(kind=EventKind.FINISH, finish_reason=finish_reason) - return None + payload = payload_of(frame, "Zen") + return None if payload is None else openai_event( + payload, error_event=_error_event, usage=_usage) def parse_all_events(frame: SSEFrame) -> list[Event]: - """一帧拆成多个事件:同一 chunk 可同时携带正文与 finish_reason。 - - 必须基于代表性事件再补 usage/finish,否则「最后一段正文 + finish」 - 这种合并帧会丢掉 finish(客户端永远收不到结束信号)。 - """ - payload = _payload(frame) - if payload is None: - return [] - events: list[Event] = [e for e in (_event_from_payload(payload),) if e is not None] - has_usage = any(e.kind is EventKind.USAGE for e in events) - has_finish = any(e.kind is EventKind.FINISH for e in events) - - usage = payload.get("usage") - if isinstance(usage, dict) and not has_usage: - events.append(Event(kind=EventKind.USAGE, usage=_usage(usage))) - choice = first_choice(payload) - finish_reason = choice.get("finish_reason") if choice else None - if isinstance(finish_reason, str) and finish_reason and not has_finish: - events.append(Event(kind=EventKind.FINISH, finish_reason=finish_reason)) - return events - - -def _payload(frame: SSEFrame) -> dict[str, Any] | None: - """帧 → JSON 对象;空帧 / `[DONE]` 返回 None,坏帧显式失败。""" - if not frame.data: - return None - if frame.data.strip() == "[DONE]": - return None - try: - payload = json.loads(frame.data) - except json.JSONDecodeError as error: - raise UpstreamProtocolViolation("unparsable Zen SSE data") from error - if not isinstance(payload, dict): - raise UpstreamProtocolViolation("Zen SSE data is not an object") - return payload + """一帧拆成多个事件:同一 chunk 可同时携带正文与 finish_reason。""" + return openai_events(frame, "Zen", error_event=_error_event, usage=_usage) def _error_event(error: dict[str, Any]) -> Event: diff --git a/tests/provider/test_openai_chunk.py b/tests/provider/test_openai_chunk.py index 36a9543e..98074042 100644 --- a/tests/provider/test_openai_chunk.py +++ b/tests/provider/test_openai_chunk.py @@ -7,9 +7,18 @@ from __future__ import annotations +import json + import pytest -from src.provider.openai_chunk import details_int, usage_from_openai +from src.provider.base import Event, EventKind, UpstreamProtocolViolation +from src.provider.openai_chunk import ( + details_int, + openai_event, + openai_events, + payload_of, + usage_from_openai, +) def test_reasoning_tokens_falls_back_to_top_level(): @@ -68,3 +77,84 @@ def test_details_int_handles_non_dict_details(): "prompt_tokens_details", "cached_tokens") == 3 assert details_int({"prompt_tokens_details": None, "cached_tokens": 3}, "prompt_tokens_details", "cached_tokens") == 3 + + +# ---------------------------------------------------------------- 帧解析 + + +def _frame(data: str): + from src.engine.sse import SSEFrame + + return SSEFrame(event="message", data=data) + + +def test_payload_of_skips_empty_and_done(): + assert payload_of(_frame(""), "X") is None + assert payload_of(_frame("[DONE]"), "X") is None + assert payload_of(_frame('{"a":1}'), "X") == {"a": 1} + + +def test_payload_of_rejects_bad_json_and_non_object(): + """坏帧必须显式失败,不能静默返回空回复;报错文案带渠道名便于定位。""" + with pytest.raises(UpstreamProtocolViolation, match="unparsable X SSE data"): + payload_of(_frame("{bad"), "X") + with pytest.raises(UpstreamProtocolViolation, match="X SSE data is not an object"): + payload_of(_frame("[1,2]"), "X") + + +def test_openai_event_priority_tool_calls_over_content(): + """tool_calls > 正文:同一 delta 两者都有时只产出一个代表性事件。""" + event = openai_event({"choices": [{"delta": { + "tool_calls": [{"function": {"name": "f", "arguments": ""}}], + "content": "文本"}}]}) + assert event is not None and event.kind is EventKind.TOOL_CALLS + + +def test_openai_event_drops_blank_tool_calls_and_falls_through(): + """空名空参的噪声 tool_call 整条丢弃,继续看正文。""" + event = openai_event({"choices": [{"delta": { + "tool_calls": [{"function": {"name": "", "arguments": ""}}], + "content": "正文"}}]}) + assert event is not None and event.kind is EventKind.CONTENT + + +@pytest.mark.parametrize("key", ["reasoning_content", "reasoning"]) +def test_openai_event_reasoning_keys_are_parameterised(key): + """不猜键:只认调用方点名的 reasoning 字段。""" + payload = {"choices": [{"delta": {key: "思考"}}]} + assert openai_event(payload, reasoning_keys=(key,)).kind is EventKind.REASONING + # 点错键就读不到(默认只认 reasoning_content,Kilo 必须显式传 reasoning) + assert openai_event(payload, reasoning_keys=("other",)) is None + + +def test_openai_event_error_as_string_only_when_asked(): + """OfficeAce 实测有 `{"error": "文本"}`;其他渠道不该认这个形态。""" + + def classify(error: dict) -> Event: + return Event(kind=EventKind.ERROR, error_message=str(error.get("message"))) + + payload = {"error": "炸了"} + assert openai_event(payload, error_event=classify, + error_as_string=True).kind is EventKind.ERROR + assert openai_event(payload, error_event=classify) is None + + +def test_openai_events_appends_usage_and_finish_on_merged_frame(): + """合并帧(正文 + usage + finish)三者都要出,否则客户端收不到结束信号。""" + events = openai_events(_frame(json.dumps({ + "choices": [{"delta": {"content": "尾"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 3}})), "X") + kinds = [e.kind for e in events] + assert kinds == [EventKind.CONTENT, EventKind.USAGE, EventKind.FINISH] + + +def test_openai_events_does_not_duplicate_usage_or_finish(): + """代表性事件已经是 usage / finish 时不再补一条。""" + assert [e.kind for e in openai_events(_frame(json.dumps({ + "choices": [], "usage": {"prompt_tokens": 1}})), "X")] == [EventKind.USAGE] + assert [e.kind for e in openai_events(_frame(json.dumps({ + "choices": [{"delta": {}, "finish_reason": "stop"}]})), "X")] == [EventKind.FINISH] + + +def test_openai_events_empty_frame_returns_no_events(): + assert openai_events(_frame("[DONE]"), "X") == [] diff --git a/tests/provider/zen/test_events.py b/tests/provider/zen/test_events.py index e8fe9ca7..85304f46 100644 --- a/tests/provider/zen/test_events.py +++ b/tests/provider/zen/test_events.py @@ -116,6 +116,13 @@ def test_usage_tolerates_bad_types(): assert event.usage.cached_tokens is None assert event.usage.credit is None +def test_usage_parses_credit_when_present(): + """Zen 是上游真给 credit 的渠道之一:数值必须解析出来(不是默认值吞掉)。""" + event = zen_events.parse_frame(frame( + {"choices": [], "usage": {"prompt_tokens": 5, "credit": 0.25}})) + assert event is not None and event.usage is not None + assert event.usage.credit == 0.25 + def test_classify_status_maps_each_branch(): # 401 只代表「该模型需要付费 key」,不是虚拟凭证失效;归 DEAD 会因一次 # 强制 @zen 的付费模型请求把整条渠道硬禁用。 From 57c929b8a8539240a3ce1b69e8c8890410820d92 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 22:13:51 +0800 Subject: [PATCH 015/153] =?UTF-8?q?refactor(provider):=20=E6=87=92?= =?UTF-8?q?=E5=BB=BA=E8=BF=9E=E6=8E=A5=E6=B1=A0=E4=B8=8E=20pacer=20?= =?UTF-8?q?=E9=85=8D=E5=AF=B9=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 懒建连接池(7 家 → LazyClients): - 新增 provider/lazy_clients.py:`_stream` / `_short` 两条按需建立的连接池 + 统一 aclose。原先 7 份实现差异只有 `@property` 与方法两种写法,统一成 property(调用点一律 `self._stream` / `self._short`)。 - STREAM_TIMEOUT / SHORT_TIMEOUT 只由 proxy.py 定义,client 不再各自 re-export。 - 顺带消掉 test_proxy.py 里为两种写法保留的 `is_property` 分支。 pacer 配对(7 处 → paced()): - 新增 tasks/pacer.py::paced(pacer, key) 上下文管理器,封装 `wait_turn` + `finally release`。手写时最容易漏 finally:`async for ... break` 不关闭 async generator,finally 推迟到 GC 才跑,名额丢失会让该桶 永久满载(「用了三次就限制」而非「并发三」)。 - 做成模块级函数而非 Pacer 方法:测试替身只需实现既有的 wait_turn / release,不必跟着实现新接口(试过 Pacer.turn,8 个替身全要改)。 新增 tests/provider/test_lazy_clients.py 与 tests/tasks/test_pacer.py 的 paced() 用例(含异常路径与生成器提前关闭的归还)。 覆盖率 100%,3913 后端全绿。 --- src/provider/codearts/__init__.py | 9 +-- src/provider/codearts/client.py | 25 ++---- src/provider/codebuddy/client.py | 36 +++------ src/provider/kilo/client.py | 31 ++------ src/provider/lazy_clients.py | 48 ++++++++++++ src/provider/officeace/__init__.py | 9 +-- src/provider/officeace/client.py | 26 ++----- src/provider/qoder/__init__.py | 9 +-- src/provider/qoder/client.py | 21 +---- src/provider/trae/client.py | 32 ++------ src/provider/zen/client.py | 33 ++------ src/tasks/pacer.py | 27 ++++++- tests/provider/codearts/test_client.py | 4 +- tests/provider/kilo/test_client.py | 4 +- tests/provider/officeace/test_client.py | 8 +- tests/provider/officeace/test_provider.py | 2 +- tests/provider/test_lazy_clients.py | 86 +++++++++++++++++++++ tests/provider/test_proxy.py | 29 +++---- tests/provider/test_stream_write_timeout.py | 29 +++---- tests/provider/trae/test_client.py | 4 +- tests/provider/zen/test_client.py | 4 +- tests/regression/test_coverage_edge_trae.py | 6 +- tests/tasks/test_pacer.py | 70 ++++++++++++++++- tests/tasks/test_runner.py | 2 +- 24 files changed, 318 insertions(+), 236 deletions(-) create mode 100644 src/provider/lazy_clients.py create mode 100644 tests/provider/test_lazy_clients.py diff --git a/src/provider/codearts/__init__.py b/src/provider/codearts/__init__.py index 6beee50b..443e8a39 100644 --- a/src/provider/codearts/__init__.py +++ b/src/provider/codearts/__init__.py @@ -28,6 +28,7 @@ from typing import Any from ...provider.base import CheckinResult, ErrKind, Event, Model, Quota +from ...tasks.pacer import paced from . import dpop from . import events as codearts_events from .client import CodeArtsClient @@ -93,15 +94,9 @@ async def stream_chat(self, credential_data: dict, payload: dict, """引擎调用入口:dict 凭证 → 上游流 → 中立事件。""" credential = CodeArtsCredential.from_dict(credential_data) key = credential_key(credential) - if self.pacer is not None: - await self.pacer.wait_turn(key) - try: + async with paced(self.pacer, key): async for event in self.client.stream_chat(credential, payload, model): yield event - finally: - # 并发模式必须归还名额,否则该凭证被当成永远在途而失去节流 - if self.pacer is not None: - self.pacer.release(key) async def refresh(self, credential_data: dict) -> dict: """预刷新:回写新 refresh_token 与临时凭证(一次性 token 必须轮转)。""" diff --git a/src/provider/codearts/client.py b/src/provider/codearts/client.py index 1df01f46..afd1e31c 100644 --- a/src/provider/codearts/client.py +++ b/src/provider/codearts/client.py @@ -33,7 +33,7 @@ from ...provider import base from ...provider.base import Event, Model, Quota, UpstreamReloginRequired -from ...provider.proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client +from ..lazy_clients import LazyClients from . import auth as codearts_auth from . import events as codearts_events from . import signer @@ -210,7 +210,7 @@ class UpstreamHTTPError(base.UpstreamHTTPError): classify_status = staticmethod(codearts_events.classify_status) -class CodeArtsClient: +class CodeArtsClient(LazyClients): """上游 HTTP 客户端。主机与登录配置可覆盖,便于测试与灰度。""" def __init__( @@ -234,21 +234,6 @@ def __init__( self._short_client = short_client self._proxy = proxy - def _stream(self) -> httpx.AsyncClient: - if self._stream_client is None: - self._stream_client = build_client(timeout=STREAM_TIMEOUT, proxy=self._proxy) - return self._stream_client - - def _short(self) -> httpx.AsyncClient: - if self._short_client is None: - self._short_client = build_client(timeout=SHORT_TIMEOUT, proxy=self._proxy) - return self._short_client - - async def aclose(self) -> None: - for client in (self._stream_client, self._short_client): - if client is not None: - await client.aclose() - def chat_url(self) -> str: """本次聊天要用的完整地址(legacy 走 `/v1/chat/chat`)。""" path = EP_CHAT if self._legacy else EP_CHAT_V2 @@ -275,7 +260,7 @@ async def stream_chat(self, credential: CodeArtsCredential, payload: dict[str, A headers=chat_headers(credential, trace_id=secrets.token_hex(16)), payload=raw, x_sdk_date=sdk_date()) snapshot = codearts_events.TextSnapshot() - async with self._stream().stream("POST", url, content=raw, headers=headers) as response: + async with self._stream.stream("POST", url, content=raw, headers=headers) as response: if response.status_code >= 400: body_bytes = await base.read_body_bounded(response) raise UpstreamHTTPError(response.status_code, body_bytes, @@ -478,7 +463,7 @@ async def refresh_token(self, credential: CodeArtsCredential) -> CodeArtsCredent """ try: tokens = await codearts_auth.refresh_tokens( - self._short(), self.login, refresh_token=credential.refresh_token, + self._short, self.login, refresh_token=credential.refresh_token, dpop_private_jwk=credential.dpop_private_jwk, code_verifier=credential.code_verifier) except codearts_auth.TokenEndpointError as error: @@ -502,7 +487,7 @@ async def _request_json(self, method: str, url: str, credential: CodeArtsCredent signed = signer.sign_headers( _sign_credential(credential), method=method, path=path, raw_query=query, headers=headers, payload=raw, x_sdk_date=sdk_date()) - response = await self._short().request(method, url, content=raw if body is not None + response = await self._short.request(method, url, content=raw if body is not None else None, headers=signed) if response.status_code >= 400: raise UpstreamHTTPError(response.status_code, diff --git a/src/provider/codebuddy/client.py b/src/provider/codebuddy/client.py index 8020961a..721b2780 100644 --- a/src/provider/codebuddy/client.py +++ b/src/provider/codebuddy/client.py @@ -25,7 +25,8 @@ from ...engine.sse import iter_frames from ...provider import base from ...provider.base import ErrKind, Event, GrowthResult, Model, Quota -from ...provider.proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client +from ...tasks.pacer import paced +from ..lazy_clients import LazyClients from . import events as cb_events from .credential import CodeBuddyCredential, parse_credential from .events import UpstreamProtocolViolation @@ -206,7 +207,7 @@ def build_headers(credential: CodeBuddyCredential, endpoint: str, *, ) -class CodeBuddyClient: +class CodeBuddyClient(LazyClients): def __init__( self, *, @@ -224,23 +225,6 @@ def __init__( self.require_leading_system = require_leading_system self._proxy = proxy - @property - def _stream(self) -> httpx.AsyncClient: - if self._stream_client is None: - self._stream_client = build_client(timeout=STREAM_TIMEOUT, proxy=self._proxy) - return self._stream_client - - @property - def _short(self) -> httpx.AsyncClient: - if self._short_client is None: - self._short_client = build_client(timeout=SHORT_TIMEOUT, proxy=self._proxy) - return self._short_client - - async def aclose(self) -> None: - for client in (self._stream_client, self._short_client): - if client is not None: - await client.aclose() - # ------------------------------------------------------------- 聊天流 async def stream_chat(self, credential: CodeBuddyCredential, payload: dict[str, Any], @@ -295,8 +279,12 @@ async def stream_chat(self, credential: CodeBuddyCredential, payload: dict[str, ) as response: if response.status_code >= 400: raw = await base.read_body_bounded(response) + # body 兜底:国际站 429+6004 只把重置时刻写在正文(`reset at + # … UTC+8`),不给 Retry-After 头;不传 body 就只能回落 600s + # 基数,到期重试再撞一次 429(实测同一号一天被爆 42 次)。 raise UpstreamHTTPError(response.status_code, raw, - **base.retry_hints(response.headers)) + **base.retry_hints(response.headers, + body=raw)) async for frame in iter_frames(response.aiter_bytes()): for event in cb_events.parse_all_events(frame): yield event @@ -795,15 +783,9 @@ async def stream_chat(self, credential_data: dict, payload: dict, model: str) -> AsyncIterator[Event]: credential = CodeBuddyCredential.from_dict(credential_data) key = _pacer_key(self.id, credential) - if self.pacer is not None: - await self.pacer.wait_turn(key) - try: + async with paced(self.pacer, key): async for event in self.client.stream_chat(credential, payload, model): yield event - finally: - # 并发模式必须归还名额,否则该凭证被当成永远在途而失去节流 - if self.pacer is not None: - self.pacer.release(key) def host(self) -> str: return host_of(self.client.endpoint) diff --git a/src/provider/kilo/client.py b/src/provider/kilo/client.py index 3320e7db..c68896a7 100644 --- a/src/provider/kilo/client.py +++ b/src/provider/kilo/client.py @@ -36,7 +36,8 @@ Model, Quota, ) -from ...provider.proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client +from ...tasks.pacer import paced +from ..lazy_clients import LazyClients from . import events as kilo_events # 上游端点(公开事实,非用户可配置输入) @@ -85,7 +86,7 @@ class UpstreamHTTPError(base.UpstreamHTTPError): classify_status = staticmethod(kilo_events.classify_status) -class KiloClient: +class KiloClient(LazyClients): """上游 HTTP 客户端。host 可覆盖,便于测试与灰度。""" def __init__( @@ -101,25 +102,10 @@ def __init__( self._short_client = short_client self._proxy = proxy - def _stream(self) -> httpx.AsyncClient: - if self._stream_client is None: - self._stream_client = build_client(timeout=STREAM_TIMEOUT, proxy=self._proxy) - return self._stream_client - - def _short(self) -> httpx.AsyncClient: - if self._short_client is None: - self._short_client = build_client(timeout=SHORT_TIMEOUT, proxy=self._proxy) - return self._short_client - - async def aclose(self) -> None: - for client in (self._stream_client, self._short_client): - if client is not None: - await client.aclose() - async def stream_chat(self, payload: dict[str, Any], model: str) -> AsyncIterator[Event]: """POST chat/completions 并逐事件产出中立 Event。非 2xx 抛 UpstreamHTTPError。""" body = prepare_body(payload, model) - async with self._stream().stream( + async with self._stream.stream( "POST", f"{self.host}{EP_CHAT}", json=body, headers=request_headers(), ) as response: if response.status_code >= 400: @@ -139,7 +125,7 @@ async def fetch_models(self) -> list[Model]: 无静态白名单:上游增删免费模型、改 `isFree` 自动跟随。候选全灭时 抛出让上层用上次成功的缓存兜底,避免 Kilo 从模型列表里消失。 """ - response = await self._short().get( + response = await self._short.get( f"{self.host}{EP_MODELS}", headers=request_headers()) if response.status_code >= 400: raise UpstreamHTTPError(response.status_code, @@ -231,14 +217,9 @@ async def list_models(self, credential_data: dict) -> list[Model]: async def stream_chat(self, credential_data: dict, payload: dict, model: str) -> AsyncIterator[Event]: # 与 zen 同样单桶(同渠道共用一条匿名额度),并发模式下必须配对 release。 - if self.pacer is not None: - await self.pacer.wait_turn("kilo") - try: + async with paced(self.pacer, "kilo"): async for event in self.client.stream_chat(payload, model): yield event - finally: - if self.pacer is not None: - self.pacer.release("kilo") async def aclose(self) -> None: await self.client.aclose() diff --git a/src/provider/lazy_clients.py b/src/provider/lazy_clients.py new file mode 100644 index 00000000..3cfddc07 --- /dev/null +++ b/src/provider/lazy_clients.py @@ -0,0 +1,48 @@ +"""渠道客户端的懒建 HTTP 连接池。 + +7 个渠道各写一份「两个懒建 client + aclose」,差异只有 `@property` 与方法 +两种写法。统一成 property:调用点一律 `self._stream` / `self._short`。 + +为什么懒建:绝大多数请求只用其中一条池(聊天走 stream、探活走 short), +两个都预建会白占一份连接池;而未建的那条在 `aclose()` 里跳过即可。 +""" + +from __future__ import annotations + +import httpx + +from .proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client + + +class LazyClients: + """`_stream` / `_short` 两条按需建立的连接池 + 统一释放。 + + 子类在 `__init__` 里设置 `_stream_client` / `_short_client` / `_proxy` + (前三项为 None 表示「按需自建」)。 + """ + + _stream_client: httpx.AsyncClient | None + _short_client: httpx.AsyncClient | None + _proxy: str | None + + @property + def _stream(self) -> httpx.AsyncClient: + """流式请求池:读无上限(SSE 长连接),写 60s(大 payload 也要发完)。""" + if self._stream_client is None: + self._stream_client = build_client(timeout=STREAM_TIMEOUT, + proxy=self._proxy) + return self._stream_client + + @property + def _short(self) -> httpx.AsyncClient: + """短请求池(探活 / 额度 / 模型列表):整体 30s。""" + if self._short_client is None: + self._short_client = build_client(timeout=SHORT_TIMEOUT, + proxy=self._proxy) + return self._short_client + + async def aclose(self) -> None: + """释放两条池;从未建立的跳过(懒建的意义就在这)。""" + for client in (self._stream_client, self._short_client): + if client is not None: + await client.aclose() diff --git a/src/provider/officeace/__init__.py b/src/provider/officeace/__init__.py index 870354a4..00937fcf 100644 --- a/src/provider/officeace/__init__.py +++ b/src/provider/officeace/__init__.py @@ -48,6 +48,7 @@ Quota, UpstreamProtocolViolation, ) +from ...tasks.pacer import paced from ..codearts import dpop from . import events as officeace_events from .client import OfficeAceClient @@ -127,15 +128,9 @@ async def stream_chat(self, credential_data: dict, payload: dict, """引擎调用入口:dict 凭证 → 上游流 → 中立事件。""" credential = OfficeAceCredential.from_dict(credential_data) key = credential_key(credential) - if self.pacer is not None: - await self.pacer.wait_turn(key) - try: + async with paced(self.pacer, key): async for event in self.client.stream_chat(credential, payload, model): yield event - finally: - # 并发模式必须归还名额,否则该凭证被当成永远在途而失去节流 - if self.pacer is not None: - self.pacer.release(key) # -------------------------------------------------------- 额度探测 diff --git a/src/provider/officeace/client.py b/src/provider/officeace/client.py index bae507dd..1c036c9e 100644 --- a/src/provider/officeace/client.py +++ b/src/provider/officeace/client.py @@ -39,9 +39,9 @@ UpstreamProtocolViolation, UpstreamReloginRequired, ) -from ...provider.proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client from .. import naming from ..codearts.signer import sdk_date +from ..lazy_clients import LazyClients from . import events as officeace_events from .credential import OfficeAceCredential, merge_refreshed from .events import ( @@ -110,7 +110,7 @@ class UpstreamHTTPError(base.UpstreamHTTPError): classify_status = staticmethod(officeace_events.classify_status) -class OfficeAceClient: +class OfficeAceClient(LazyClients): """上游 HTTP 客户端。主机可覆盖,便于测试与灰度。""" def __init__( @@ -136,22 +136,8 @@ def __init__( # 模型费率负缓存:{小写模型名: cost_coefficient};失败不清空(宁可旧值) self._rates: dict[str, float] = {} - def _stream(self) -> httpx.AsyncClient: - if self._stream_client is None: - self._stream_client = build_client(timeout=STREAM_TIMEOUT, - proxy=self._proxy) - return self._stream_client - def _short(self) -> httpx.AsyncClient: - if self._short_client is None: - self._short_client = build_client(timeout=SHORT_TIMEOUT, - proxy=self._proxy) - return self._short_client - async def aclose(self) -> None: - for client in (self._stream_client, self._short_client): - if client is not None: - await client.aclose() # ---------------------------------------------------------------- 聊天 @@ -173,7 +159,7 @@ async def stream_chat(self, credential: OfficeAceCredential, logger.info("officeAce 调整 max_tokens:客户端 %s → 上游 %s(model=%s)", payload.get("max_tokens", payload.get("max_completion_tokens")), bumped, model) - async with self._stream().stream( + async with self._stream.stream( "POST", url, json=body, headers=request_headers(credential), ) as response: if response.status_code >= 400: @@ -199,7 +185,7 @@ async def fetch_models(self, credential: OfficeAceCredential) -> list[Model]: "credential missing model_base_url", public="该凭证缺少 officeAce 网关地址,请重新登录该账号") url = f"{self._gateway_of(credential)}{EP_MODELS}" - response = await self._short().get( + response = await self._short.get( url, headers={"Authorization": credential.authorization, "Accept": "application/json"}) if response.status_code >= 400: @@ -368,7 +354,7 @@ async def refresh_token(self, credential: OfficeAceCredential, """ from .oauth import OfficeAceOAuth - oauth = OfficeAceOAuth(self.login, client=self._short()) + oauth = OfficeAceOAuth(self.login, client=self._short) try: tokens = await oauth.refresh_tokens( refresh_token=credential.refresh_token, @@ -416,7 +402,7 @@ async def _v11_json(self, method: str, path: str, self._credential_sign(credential), method=method, url=url, headers=headers or {}, payload=body or b"", x_sdk_date=_sdk_date(self._now()), region=self.region) - response = await self._short().request( + response = await self._short.request( method, url, headers=signed, content=body if body is not None else None) if response.status_code >= 400: raise UpstreamHTTPError(response.status_code, diff --git a/src/provider/qoder/__init__.py b/src/provider/qoder/__init__.py index 93be91bc..943a2bb2 100644 --- a/src/provider/qoder/__init__.py +++ b/src/provider/qoder/__init__.py @@ -32,6 +32,7 @@ Model, Quota, ) +from ...tasks.pacer import paced from . import events as qoder_events from .client import QoderClient, realm_for from .credential import QoderCredential, parse_credential @@ -108,15 +109,9 @@ async def stream_chat(self, credential_data: dict, payload: dict, model: str) -> AsyncIterator[Event]: credential = QoderCredential.from_dict(credential_data) key = credential_key(credential) - if self.pacer is not None: - await self.pacer.wait_turn(key) - try: + async with paced(self.pacer, key): async for event in self.client.stream_chat(credential, payload, model): yield event - finally: - # 并发模式必须归还名额,否则该凭证被当成永远在途而失去节流 - if self.pacer is not None: - self.pacer.release(key) # ---------------------------------------------------------------- 签到 diff --git a/src/provider/qoder/client.py b/src/provider/qoder/client.py index 43953d4e..4af5da6f 100644 --- a/src/provider/qoder/client.py +++ b/src/provider/qoder/client.py @@ -46,7 +46,7 @@ Quota, UpstreamTransportError, ) -from ...provider.proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client +from ..lazy_clients import LazyClients from . import events as qoder_events from .cosy import CosySession, CosySessionCache, qoder_encode from .credential import QoderCredential, merge_refreshed @@ -250,7 +250,7 @@ def build_chat_body(payload: dict[str, Any], model: str) -> dict[str, Any]: return body -class QoderClient: +class QoderClient(LazyClients): """上游 HTTP 客户端。`host` = openapi(业务),`gateway` = 推理网关。 主机与登录地址可覆盖(测试 / 灰度);HTTP 客户端可注入 MockTransport。 @@ -279,23 +279,6 @@ def __init__( self._model_cache: dict[str, tuple[float, list[Model]]] = {} self._proxy = proxy - @property - def _stream(self) -> httpx.AsyncClient: - if self._stream_client is None: - self._stream_client = build_client(timeout=STREAM_TIMEOUT, proxy=self._proxy) - return self._stream_client - - @property - def _short(self) -> httpx.AsyncClient: - if self._short_client is None: - self._short_client = build_client(timeout=SHORT_TIMEOUT, proxy=self._proxy) - return self._short_client - - async def aclose(self) -> None: - for client in (self._stream_client, self._short_client): - if client is not None: - await client.aclose() - # --------------------------------------------------------------- 推理流 async def stream_chat(self, credential: QoderCredential, payload: dict[str, Any], diff --git a/src/provider/trae/client.py b/src/provider/trae/client.py index 7a075fa5..c3098ffd 100644 --- a/src/provider/trae/client.py +++ b/src/provider/trae/client.py @@ -27,8 +27,9 @@ Model, Quota, ) -from ...provider.proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client from ...provider.token_expiry import normalize_epoch +from ...tasks.pacer import paced +from ..lazy_clients import LazyClients from . import events as trae_events from . import pricing from .callback import ( @@ -300,7 +301,7 @@ def oauth_headers() -> dict[str, str]: } -class TraeClient: +class TraeClient(LazyClients): """上游 HTTP 客户端。host 可覆盖,便于测试。""" def __init__( @@ -320,21 +321,6 @@ def __init__( self._short_client = short_client self._proxy = proxy - def _stream(self) -> httpx.AsyncClient: - if self._stream_client is None: - self._stream_client = build_client(timeout=STREAM_TIMEOUT, proxy=self._proxy) - return self._stream_client - - def _short(self) -> httpx.AsyncClient: - if self._short_client is None: - self._short_client = build_client(timeout=SHORT_TIMEOUT, proxy=self._proxy) - return self._short_client - - async def aclose(self) -> None: - for client in (self._stream_client, self._short_client): - if client is not None: - await client.aclose() - # ------------------------------------------------------------- 聊天流 async def stream_chat( @@ -343,7 +329,7 @@ async def stream_chat( """POST llm_utils_chat 并逐事件产出中立 Event。非 2xx 抛 UpstreamHTTPError。""" body = prepare_body(payload, model) url = f"{self.agent_host}{EP_CHAT}" - async with self._stream().stream( + async with self._stream.stream( "POST", url, json=body, headers=solo_headers(credential), ) as response: if response.status_code >= 400: @@ -518,7 +504,7 @@ async def get_user_info(self, credential: TraeCredential) -> tuple[str, str]: async def _post_json(self, url: str, body: dict[str, Any], headers: dict[str, str]) -> dict[str, Any]: - response = await self._short().post(url, json=body, headers=headers) + response = await self._short.post(url, json=body, headers=headers) if response.status_code >= 400: raise UpstreamHTTPError(response.status_code, await base.read_body_bounded(response)) @@ -734,15 +720,9 @@ async def stream_chat(self, credential_data: dict, payload: dict, """引擎调用入口:dict 凭证 → 上游流 → 中立事件。""" credential = TraeCredential.from_dict(credential_data) key = _pacer_key(credential) - if self.pacer is not None: - await self.pacer.wait_turn(key) - try: + async with paced(self.pacer, key): async for event in self.client.stream_chat(credential, payload, model): yield event - finally: - # 并发模式必须归还名额,否则该凭证被当成永远在途而失去节流 - if self.pacer is not None: - self.pacer.release(key) async def refresh(self, credential_data: dict) -> dict: refreshed = await self.client.refresh_token(TraeCredential.from_dict(credential_data)) diff --git a/src/provider/zen/client.py b/src/provider/zen/client.py index ba58ae3c..677e1690 100644 --- a/src/provider/zen/client.py +++ b/src/provider/zen/client.py @@ -47,7 +47,8 @@ Model, Quota, ) -from ...provider.proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client +from ...tasks.pacer import paced +from ..lazy_clients import LazyClients from . import events as zen_events logger = logging.getLogger(__name__) @@ -323,7 +324,7 @@ class UpstreamHTTPError(base.UpstreamHTTPError): classify_status = staticmethod(zen_events.classify_status) -class ZenClient: +class ZenClient(LazyClients): """上游 HTTP 客户端。host / version 可覆盖,便于测试与灰度。""" def __init__( @@ -347,21 +348,6 @@ def __init__( self._short_client = short_client self._proxy = proxy - def _stream(self) -> httpx.AsyncClient: - if self._stream_client is None: - self._stream_client = build_client(timeout=STREAM_TIMEOUT, proxy=self._proxy) - return self._stream_client - - def _short(self) -> httpx.AsyncClient: - if self._short_client is None: - self._short_client = build_client(timeout=SHORT_TIMEOUT, proxy=self._proxy) - return self._short_client - - async def aclose(self) -> None: - for client in (self._stream_client, self._short_client): - if client is not None: - await client.aclose() - async def stream_chat(self, payload: dict[str, Any], model: str) -> AsyncIterator[Event]: """POST chat/completions 并逐事件产出中立 Event。非 2xx 抛 UpstreamHTTPError。""" body, injected = prepare_body(payload, model) @@ -372,7 +358,7 @@ async def stream_chat(self, payload: dict[str, Any], model: str) -> AsyncIterato kept_tool_calls = False attempt = 0 while True: - async with self._stream().stream( + async with self._stream.stream( "POST", f"{self.host}{EP_CHAT}", json=body, headers=gate_headers(version=self.version), ) as response: @@ -419,7 +405,7 @@ async def fetch_models(self) -> list[Model]: cached_at, cached_models = self._models_cache if time.monotonic() - cached_at < self.models_cache_ttl: return list(cached_models) - response = await self._short().get( + response = await self._short.get( f"{self.host}{EP_MODELS}", headers=gate_headers(version=self.version)) if response.status_code >= 400: raise UpstreamHTTPError(response.status_code, @@ -485,7 +471,7 @@ async def _probe_one(self, model_id: str) -> bool: {"messages": [{"role": "user", "content": PROBE_PROMPT}]}, model_id) try: async with asyncio.timeout(PROBE_TIMEOUT): - async with self._short().stream( + async with self._short.stream( "POST", f"{self.host}{EP_CHAT}", json=body, headers=gate_headers(version=self.version), ) as response: @@ -528,14 +514,9 @@ async def list_models(self, credential_data: dict) -> list[Model]: async def stream_chat(self, credential_data: dict, payload: dict, model: str) -> AsyncIterator[Event]: - if self.pacer is not None: - await self.pacer.wait_turn("zen") - try: + async with paced(self.pacer, "zen"): async for event in self.client.stream_chat(payload, model): yield event - finally: - if self.pacer is not None: - self.pacer.release("zen") async def aclose(self) -> None: await self.client.aclose() diff --git a/src/tasks/pacer.py b/src/tasks/pacer.py index fae9593d..6437c11d 100644 --- a/src/tasks/pacer.py +++ b/src/tasks/pacer.py @@ -44,7 +44,8 @@ import hashlib import random import time -from collections.abc import Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager from ..config import live @@ -68,6 +69,28 @@ def stable_key(provider: str, identity: str | None) -> str: return f"{provider}:{digest}" +@asynccontextmanager +async def paced(pacer: Pacer | None, key: str | None = None) -> AsyncIterator[None]: + """`wait_turn` + `finally release` 的配对封装(7 个渠道共用)。 + + provider 的 `stream_chat` 都要「取名额 → 消费上游流 → 归还」,手写时最容易 + 漏的是 `finally`:`async for ... break` 不关闭 async generator,`finally` + 推迟到 GC 才跑,名额丢失会让该桶永久满载(见模块 docstring)。 + + `pacer=None` 表示不限速(无凭证渠道 / 测试),直接放行。 + 做成模块级函数而不是 `Pacer` 的方法:测试替身只需实现 `wait_turn` / + `release` 两个既有接口,不必跟着实现新方法。 + """ + if pacer is None: + yield + return + await pacer.wait_turn(key) + try: + yield + finally: + pacer.release(key) + + class Pacer: """上游请求节流器(严格 / 并发两种模式,见模块 docstring)。""" @@ -291,3 +314,5 @@ def release(self, key: str | None = None) -> None: else: return # 多余释放:不虚增名额 self._wake.set() # 唤醒可能正等这个名额的请求 + + diff --git a/tests/provider/codearts/test_client.py b/tests/provider/codearts/test_client.py index d44ef689..8fedbb6e 100644 --- a/tests/provider/codearts/test_client.py +++ b/tests/provider/codearts/test_client.py @@ -236,8 +236,8 @@ def test_client_defaults_and_chat_url(): async def test_client_lazy_pools_and_aclose(): client = CodeArtsClient(endpoint="https://snap.test") - assert client._stream() is client._stream() # noqa: SLF001 - assert client._short() is client._short() # noqa: SLF001 + assert client._stream is client._stream # noqa: SLF001 + assert client._short is client._short # noqa: SLF001 await client.aclose() await CodeArtsClient().aclose() # 两侧都没创建 → 不报错 diff --git a/tests/provider/kilo/test_client.py b/tests/provider/kilo/test_client.py index 656f3cef..2faef575 100644 --- a/tests/provider/kilo/test_client.py +++ b/tests/provider/kilo/test_client.py @@ -194,8 +194,8 @@ def no_free(_request: httpx.Request) -> httpx.Response: async def test_client_lazy_clients_and_aclose(): """未注入 client 时惰性创建;aclose 释放两侧连接池。""" client = KiloClient() - assert client._stream() is client._stream() # noqa: SLF001 - 惰性复用同一实例 - assert client._short() is client._short() # noqa: SLF001 + assert client._stream is client._stream # noqa: SLF001 - 惰性复用同一实例 + assert client._short is client._short # noqa: SLF001 await client.aclose() # 都没创建过:aclose 不报错(两个分支都为 None) diff --git a/tests/provider/officeace/test_client.py b/tests/provider/officeace/test_client.py index 28bd9452..cd725897 100644 --- a/tests/provider/officeace/test_client.py +++ b/tests/provider/officeace/test_client.py @@ -105,14 +105,14 @@ def test_client_normalises_bases_and_defaults(): def test_client_lazy_clients_and_aclose(): client = OfficeAceClient(cloud_base="https://cloud.test", gateway="https://gw.test") - assert client._stream() is client._stream() - assert client._short() is client._short() - assert client._stream() is not client._short() + assert client._stream is client._stream + assert client._short is client._short + assert client._stream is not client._short async def test_client_aclose_closes_both_pools(): client = _client(lambda request: httpx.Response(200, json={})) - stream = client._stream() + stream = client._stream await client.aclose() assert stream.is_closed diff --git a/tests/provider/officeace/test_provider.py b/tests/provider/officeace/test_provider.py index 7dce7182..4f2f9b42 100644 --- a/tests/provider/officeace/test_provider.py +++ b/tests/provider/officeace/test_provider.py @@ -246,6 +246,6 @@ def test_checkin_scope_unknown_identity_returns_empty(): async def test_aclose_closes_client(): client = _client(lambda request: httpx.Response(200)) - stream = client._stream() + stream = client._stream await OfficeAceProvider(client=client).aclose() assert stream.is_closed diff --git a/tests/provider/test_lazy_clients.py b/tests/provider/test_lazy_clients.py new file mode 100644 index 00000000..4e4ec150 --- /dev/null +++ b/tests/provider/test_lazy_clients.py @@ -0,0 +1,86 @@ +"""渠道客户端共享的懒建连接池。 + +7 家原先各写一份「两个懒建 client + aclose」,差异只有 property/方法两种写法。 +这里直接测共享实现:懒建、复用、按需释放。 +""" + +from __future__ import annotations + +import httpx +import pytest + +from src.provider.lazy_clients import LazyClients + + +class _Client(LazyClients): + def __init__(self, *, stream=None, short=None, proxy=None) -> None: + self._stream_client = stream + self._short_client = short + self._proxy = proxy + + +def test_pools_are_lazy_and_reused(): + client = _Client() + assert client._stream_client is None and client._short_client is None + + stream = client._stream + short = client._short + + assert client._stream is stream # 复用同一实例,不重建 + assert client._short is short + assert stream is not short # 两条独立的池 + + +def test_injected_clients_are_used_as_is(): + """注入的 client(测试与复用场景)不能被替换掉。""" + given = httpx.AsyncClient() + client = _Client(stream=given) + assert client._stream is given + + +def test_proxy_is_forwarded_to_both_pools(monkeypatch): + calls: list[dict] = [] + + def fake(**kwargs): + calls.append(kwargs) + return httpx.AsyncClient() + + monkeypatch.setattr("src.provider.lazy_clients.build_client", fake) + client = _Client(proxy="socks5://127.0.0.1:1080") + + assert client._stream is not None + assert client._short is not None + assert calls[0]["proxy"] == "socks5://127.0.0.1:1080" + assert calls[1]["proxy"] == "socks5://127.0.0.1:1080" + # 两条池用不同的超时(流式读无上限,短请求整体 30s) + assert calls[0]["timeout"].read is None + assert calls[1]["timeout"].read == 30.0 + + +async def test_aclose_skips_pools_never_created(): + """只建了 stream 时,aclose 不能因 short 为 None 而崩(懒建的意义)。""" + client = _Client() + assert client._stream is not None + assert client._short_client is None + await client.aclose() + + +async def test_aclose_is_idempotent(): + client = _Client() + stream, short = client._stream, client._short + await client.aclose() + assert stream.is_closed and short.is_closed + # 再关一次不该抛(客户端已 closed 时 aclose 是 no-op) + await client.aclose() + + +async def test_aclose_with_no_pools_is_noop(): + client = _Client() + await client.aclose() # 一条都没建过 + + +@pytest.mark.parametrize("attr", ["_stream", "_short"]) +def test_pools_ignore_environment_proxy(attr): + """`trust_env=False` 是既有约定:不吃 HTTP_PROXY,避免劫持带 Token 的请求。""" + client = _Client() + assert getattr(client, attr).trust_env is False diff --git a/tests/provider/test_proxy.py b/tests/provider/test_proxy.py index fadf2987..ae44affa 100644 --- a/tests/provider/test_proxy.py +++ b/tests/provider/test_proxy.py @@ -138,24 +138,27 @@ def _codearts_client(proxy): return CodeArtsClient(proxy=proxy) -@pytest.mark.parametrize("factory,module_path,is_property", [ - (_trae_client, "src.provider.trae.client", False), - (_zen_client, "src.provider.zen.client", False), - (_kilo_client, "src.provider.kilo.client", False), - (_codebuddy_client, "src.provider.codebuddy.client", True), - (_qoder_client, "src.provider.qoder.client", True), - (_codearts_client, "src.provider.codearts.client", False), +@pytest.mark.parametrize("factory,module_path", [ + (_trae_client, "src.provider.trae.client"), + (_zen_client, "src.provider.zen.client"), + (_kilo_client, "src.provider.kilo.client"), + (_codebuddy_client, "src.provider.codebuddy.client"), + (_qoder_client, "src.provider.qoder.client"), + (_codearts_client, "src.provider.codearts.client"), ]) -async def test_provider_client_forwards_proxy(monkeypatch, factory, module_path, - is_property): +async def test_provider_client_forwards_proxy(monkeypatch, factory, module_path): + """代理按渠道转发:两条池都拿到同一个 proxy。 + + `build_client` 现在只在 `lazy_clients` 里调用(7 家共用一份懒建逻辑), + 所以 patch 点在那里,`module_path` 只用来定位被测客户端类。 + """ import importlib - module = importlib.import_module(module_path) - calls = _capture_build(monkeypatch, module) + calls = _capture_build(monkeypatch, + importlib.import_module("src.provider.lazy_clients")) client = factory("socks5://127.0.0.1:1080") - stream = client._stream if is_property else client._stream() - short = client._short if is_property else client._short() + stream, short = client._stream, client._short assert calls[0]["proxy"] == "socks5://127.0.0.1:1080" assert calls[1]["proxy"] == "socks5://127.0.0.1:1080" assert stream is not None and short is not None diff --git a/tests/provider/test_stream_write_timeout.py b/tests/provider/test_stream_write_timeout.py index 29ef4c91..f56009dc 100644 --- a/tests/provider/test_stream_write_timeout.py +++ b/tests/provider/test_stream_write_timeout.py @@ -9,23 +9,13 @@ import importlib import httpx -import pytest - -# 7 个渠道客户端模块,均导出 STREAM_TIMEOUT / SHORT_TIMEOUT(同一份常量,见 proxy.py) -CLIENT_MODULES = [ - "src.provider.codebuddy.client", - "src.provider.codearts.client", - "src.provider.kilo.client", - "src.provider.officeace.client", - "src.provider.qoder.client", - "src.provider.trae.client", - "src.provider.zen.client", -] - - -@pytest.mark.parametrize("module_name", CLIENT_MODULES) -def test_stream_timeout_allows_slow_body_upload(module_name: str) -> None: - module = importlib.import_module(module_name) + +# 超时常量只有一份(proxy.py),所有渠道经 LazyClients 共用 +MODULE = "src.provider.proxy" + + +def test_stream_timeout_allows_slow_body_upload() -> None: + module = importlib.import_module(MODULE) timeout = module.STREAM_TIMEOUT # 上传数 MB 请求体在代理链路上需要足够余量 @@ -35,9 +25,8 @@ def test_stream_timeout_allows_slow_body_upload(module_name: str) -> None: assert timeout.read is None -@pytest.mark.parametrize("module_name", CLIENT_MODULES) -def test_short_timeout_unchanged(module_name: str) -> None: - module = importlib.import_module(module_name) +def test_short_timeout_unchanged() -> None: + module = importlib.import_module(MODULE) # 非流式短请求保持 30s,防止顺手改坏 expected = httpx.Timeout(30.0) diff --git a/tests/provider/trae/test_client.py b/tests/provider/trae/test_client.py index 221b57e3..be538834 100644 --- a/tests/provider/trae/test_client.py +++ b/tests/provider/trae/test_client.py @@ -343,8 +343,8 @@ def not_object(_request: httpx.Request) -> httpx.Response: async def test_client_lazy_and_close_paths(): client = TraeClient() - assert client._stream() is client._stream() - assert client._short() is client._short() + assert client._stream is client._stream + assert client._short is client._short await client.aclose() async def test_provider_import_classify_and_models(): diff --git a/tests/provider/zen/test_client.py b/tests/provider/zen/test_client.py index e4873f32..eb3b7a3b 100644 --- a/tests/provider/zen/test_client.py +++ b/tests/provider/zen/test_client.py @@ -326,8 +326,8 @@ def all_dead(request: httpx.Request) -> httpx.Response: async def test_client_lazy_clients_and_aclose(): """未注入 client 时惰性创建;aclose 释放两侧连接池。""" client = ZenClient() - assert client._stream() is client._stream() # noqa: SLF001 - 惰性复用同一实例 - assert client._short() is client._short() # noqa: SLF001 + assert client._stream is client._stream # noqa: SLF001 - 惰性复用同一实例 + assert client._short is client._short # noqa: SLF001 await client.aclose() # 都没创建过:aclose 不报错(两个分支都为 None) diff --git a/tests/regression/test_coverage_edge_trae.py b/tests/regression/test_coverage_edge_trae.py index a90a27a5..32d2cb87 100644 --- a/tests/regression/test_coverage_edge_trae.py +++ b/tests/regression/test_coverage_edge_trae.py @@ -62,8 +62,8 @@ def test_ug_headers_carry_region_and_auth(): async def test_client_aclose_is_idempotent(): client = TraeClient() - client._stream() - client._short() + assert client._stream is not None # 两条池都建起来 + assert client._short is not None await client.aclose() await client.aclose() @@ -158,7 +158,7 @@ def test_close_when_only_one_client_created(): from src.provider.trae.client import TraeClient client = TraeClient() - client._stream() # 只实例化 stream 客户端 + assert client._stream is not None # 只实例化 stream 客户端 asyncio.run(client.aclose()) def test_build_login_url_missing_keys_still_builds(): diff --git a/tests/tasks/test_pacer.py b/tests/tasks/test_pacer.py index ad653137..fdcda7d7 100644 --- a/tests/tasks/test_pacer.py +++ b/tests/tasks/test_pacer.py @@ -7,7 +7,7 @@ import pytest from src.tasks import TaskReport -from src.tasks.pacer import JITTER_RATIO, Pacer, stable_key +from src.tasks.pacer import JITTER_RATIO, Pacer, paced, stable_key async def test_pacer_disabled_is_noop(): @@ -400,3 +400,71 @@ def test_pacer_range_interval_is_not_jittered_further(): def test_pacer_disabled_next_interval_is_zero(): assert Pacer(0, 0).next_interval() == 0.0 + + +# ------------------------------------------------------------------ paced() + + +class _RecordingPacer: + """只实现 provider 真正用到的两个接口(paced 不要求更多)。""" + + def __init__(self) -> None: + self.waited: list[str | None] = [] + self.released: list[str | None] = [] + + async def wait_turn(self, key: str | None = None) -> None: + self.waited.append(key) + + def release(self, key: str | None = None) -> None: + self.released.append(key) + + +async def test_paced_takes_and_releases_turn(): + pacer = _RecordingPacer() + async with paced(pacer, "bucket"): + assert pacer.waited == ["bucket"] + assert pacer.released == [] # 还没出块 + + assert pacer.released == ["bucket"] + + +async def test_paced_releases_even_when_body_raises(): + """`finally` 归还名额:漏了会让该桶永久满载(「用了三次就限制」)。""" + pacer = _RecordingPacer() + with pytest.raises(RuntimeError): + async with paced(pacer, "b"): + raise RuntimeError("上游炸了") + + assert pacer.released == ["b"] + + +async def test_paced_releases_on_generator_early_close(): + """`async for ... break` 不耗尽生成器:出块时必须已归还。""" + pacer = _RecordingPacer() + + async def consume() -> None: + async with paced(pacer, "b"): + yield 1 # noqa: B901 - 故意只产一个 + + gen = consume() + assert await gen.__anext__() == 1 + await gen.aclose() # 模拟 break 后的显式关闭 + + assert pacer.released == ["b"] + + +async def test_paced_without_pacer_is_passthrough(): + """无凭证渠道 / 测试:pacer=None 直接放行,不报错。""" + ran = False + async with paced(None, "whatever"): + ran = True + + assert ran is True + + +async def test_paced_passes_key_through(): + pacer = _RecordingPacer() + async with paced(pacer, None): + pass + + assert pacer.waited == [None] and pacer.released == [None] diff --git a/tests/tasks/test_runner.py b/tests/tasks/test_runner.py index bfdb59a6..3d545d90 100644 --- a/tests/tasks/test_runner.py +++ b/tests/tasks/test_runner.py @@ -393,7 +393,7 @@ async def test_providers_release_http_pools_on_shutdown(tmp_path): trae = TraeProvider() # 触发惰性客户端创建 assert codebuddy.client._stream is not None - assert trae.client._stream() is not None + assert trae.client._stream is not None await codebuddy.aclose() await trae.aclose() From 9ba8b806639fcb382e1192664f8cfd4f09ff0711 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 22:21:38 +0800 Subject: [PATCH 016/153] =?UTF-8?q?fix(provider):=20=E5=86=B7=E5=8D=B4?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E4=B8=8A=E6=B8=B8=E6=AD=A3=E6=96=87=E7=82=B9?= =?UTF-8?q?=E5=90=8D=E7=9A=84=E9=87=8D=E7=BD=AE=E6=97=B6=E5=88=BB=EF=BC=88?= =?UTF-8?q?6004=20=E6=97=A0=20Retry-After=20=E5=A4=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeBuddy 国际站 429+6004 只在 msg 文案里给恢复时刻 (`usage will reset at 2026-10-25 22:34:25 UTC+8`),不给 Retry-After 头。 原实现取不到 → 回落 MODEL 的 600s 基数 → 到期重试再撞 429:实测 cred_4d071e6360344ddd 一天被爆 42 次、cred_bf2043bb8972474d 16 次。 - base.parse_reset_at:解析 `reset at YYYY-MM-DD HH:MM:SS UTC±N`(带 冒号偏移也认);过去时刻 / 无偏移 / 脏日期一律 None(回落常量) - retry_hints(headers, now, body=None):头优先、头缺失才看正文;不传 body 时逐字节等价于旧签名,其余 7 个渠道零影响 - codebuddy/client 的 raise 点传 body=raw - 调度器不用改:clamp_retry_at 已把上游时刻钳在 [now+常量, now+24h] --- PROPOSAL.md | 4 +- TECHNICAL.md | 1 + src/provider/base.py | 56 +++++++++++++++++++-- tests/provider/test_retry_after.py | 81 ++++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 6 deletions(-) diff --git a/PROPOSAL.md b/PROPOSAL.md index 2e35d4c5..618fb368 100644 --- a/PROPOSAL.md +++ b/PROPOSAL.md @@ -19,7 +19,7 @@ - **成长中心**(`/v2/activity/growth`):只读 `buddy/travel/status|config`、`tasks`、`streak`、`redeem/summary`、`lottery/chances`、`buddy/quota`、`energy`;写入 `buddy/travel/claim|depart`、`tasks/accept`、`/tasks/{code}/claim`、`makeup-cards/use`、`redeem`、`lottery/draw`、`buddy/open`。契约:`accept_status` 五态、`/tasks/accept` 收复数数组、`/redeem` 的 `tier` 是档位标识(`"7d"`/`"14d"`/`"28d"`)、实发字段为 `*_granted`;进程内 OAuth bearer 可直连。 - **凭证身份可能为空**:OAuth 路径下上游未回填 `account_uid`/`user_id`,同账号隔离必须回落 `credential_id`。**活跃上报**(`POST /v2/report`,默认关):body 为事件数组(`eventCode=chat_request_send`),`userId` 必填——缺失时 HTTP 200 `code:0` 但静默丢弃;身份为空时回落 JWT `sub`。 - **reasoning 客户端给什么透传什么**;`reasoning_effort` 缺失时补 `medium`(不发则上游把思考写进 `delta.content`、`reasoning_content` 恒空、`reasoning_tokens` 恒 0)。**可选机制(默认全关)**:`REASONING_EFFORT_MAP`、`ANTHROPIC_THINKING_EFFORTS`、`FORCE_DISABLE_THINKING`,全关时行为逐字节不变。**输出上限键名不对称**:CB 忽略 `max_completion_tokens`,只认 `max_tokens`;TRAE 两个键都不生效——不做键映射。 -- 错误码:`6004` 模型用量超限、`14003` 模型饱和(均模型级)、`11102` 无此模型(负缓存)、`14018` 积分耗尽、`1005` 权益耗尽、`11101/11115/11128/11135` 请求级(不罚号)。`6004`/`14003` 落账号级会让整条凭证其他模型一起被冷却 60s。 +- 错误码:`6004` 模型用量超限、`14003` 模型饱和(均模型级)、`11102` 无此模型(负缓存)、`14018` 积分耗尽、`1005` 权益耗尽、`11101/11115/11128/11135` 请求级(不罚号)。`6004`/`14003` 落账号级会让整条凭证其他模型一起被冷却 60s。**`6004` 不给 `Retry-After` 头**,恢复时刻只在 `msg` 文案里(`usage will reset at 2026-10-25 22:34:25 UTC+8`),需从正文解析(见 §6 冷却)。 ### 2.2 TRAE SOLO(`trae`) @@ -125,7 +125,7 @@ Provider 承担上游私有部分:发请求、解析事件、分类错误、 4. **到期额度两级字典序**:先比主窗口(`QUOTA_EXPIRY_WINDOW_SECONDS`,默认 36h)内将过期的额度,打平再比次窗口(`QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS`,默认 7 天)。 5. **健康度三态**:`known` > `unknown` > `exhausted`;打平按剩余积分多者优先,再按 `credential_id` 稳定。 -**为什么冷却分两层**:`429 + 6004` 是「这个模型在当前账号用超了」,`400/404 + 11102` 是「当前账号没这个模型」,一律记账号级会让一次模型级限流把整个账号踢出池。账号级写 `credentials.cooling_until`,模型级另建 `credential_model_cooldowns`;账号级冷却出现时清空该凭证的模型级条目,防「切模型」绕过。业务码识别只认 `"code": N` 键值形态。冷却时长:`PLAN` 12h、`SOFT` 60s、`OTHER` 连续 3 次→10m、`MODEL` 600s 起翻倍封顶 2h、`BLOCKED` 6h 起封顶 24h、`CONCURRENCY` 固定 60s、`CREDIT` 到次日 04:00。**传输层失败归 `SOFT`**:`httpx.TransportError` 与 `UpstreamTransportError` 统一在 `executor._classify` 兜住(异常从 `__aenter__`、`aiter_bytes()`、写请求体多处抛出,逐点包装必漏);刻意不泛化到 `OSError` / `ConnectionError`。**上游重置时刻可覆盖常量**:`Retry-After`(整秒 / HTTP-date)或 `Try again in 17h 59m` 经 `parse_retry_after` 解析后按作用域写入,并双端钳制在 `[now + 常量, now + 24h]`。 +**为什么冷却分两层**:`429 + 6004` 是「这个模型在当前账号用超了」,`400/404 + 11102` 是「当前账号没这个模型」,一律记账号级会让一次模型级限流把整个账号踢出池。账号级写 `credentials.cooling_until`,模型级另建 `credential_model_cooldowns`;账号级冷却出现时清空该凭证的模型级条目,防「切模型」绕过。业务码识别只认 `"code": N` 键值形态。冷却时长:`PLAN` 12h、`SOFT` 60s、`OTHER` 连续 3 次→10m、`MODEL` 600s 起翻倍封顶 2h、`BLOCKED` 6h 起封顶 24h、`CONCURRENCY` 固定 60s、`CREDIT` 到次日 04:00。**传输层失败归 `SOFT`**:`httpx.TransportError` 与 `UpstreamTransportError` 统一在 `executor._classify` 兜住(异常从 `__aenter__`、`aiter_bytes()`、写请求体多处抛出,逐点包装必漏);刻意不泛化到 `OSError` / `ConnectionError`。**上游重置时刻可覆盖常量**:`Retry-After`(整秒 / HTTP-date)或 `Try again in 17h 59m` 经 `parse_retry_after` 解析后按作用域写入,并双端钳制在 `[now + 常量, now + 24h]`。**头缺失时看响应体**:CB 国际站 `429+6004` 不给 `Retry-After`,恢复时刻只在正文(`your usage will reset at 2026-10-25 22:34:25 UTC+8`),由 `parse_reset_at` 解析同一种绝对时刻;头优先于正文,正文无偏移(`UTC±N`)则放弃。不解析正文的后果是固定 600s 基数到期重试、再撞一次 429(实测同一号一天被爆 42 次)。 **为什么是「窗口内积分总量」**:实测 CB 额度是几十个各自独立到期的小包(每日 100 积分 × N),只存一个日期没有区分度。四个取舍:① 统计窗口内到期积分总量,账号间才可比;② 落库到期阶梯 `[(到期 epoch, 包剩余积分)]` 而非预计算数字;③ 过滤条件必须 `end > now` 且排除已用完的包;④ 两级窗口,只比 36h 会大量账号同为 0。**展示与调度指标分开存**:`quota_packages`(含包名 / 用量)与 `quota_expiry_ladder`(只有 `[到期, 剩余]`)。 diff --git a/TECHNICAL.md b/TECHNICAL.md index cd36324f..015223b3 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -457,6 +457,7 @@ class Scheduler: - 登记名是**该凭证所属上游的原始模型名**(`_upstream_model`),CB 与 TRAE 大小写变体不同,归一名会漏判。 - 选号路径:`executor._select` 逐凭证过滤 `is_selectable(now, 模型名)` 并注入 `Candidate.credit_rate`,再交 `Scheduler.select`;同时完成收窄、粘性命中与排序(`_sticky(...) or scheduler.select(...)`)。 - 退避:`MODEL` 10m 起翻倍封顶 2h;`BLOCKED` 6h 起翻倍封顶 24h;`CONCURRENCY` 固定 60s 不翻倍。换 reason 重新计数。 +- **上游点名恢复时刻优先**:`Retry-After` 头 → `parse_retry_after`;头缺失时 CB 走 `parse_reset_at` 读正文 `reset at … UTC+8`(`retry_hints(headers, body=raw)`,仅 CodeBuddy 传 body,其余渠道签名不变)。两者都按 `clamp_retry_at` 钳制在 `[now + 常量, now + 24h]`,故 `MODEL` 的 600s 只是下限,上游说 22:34 恢复就冷却到 22:34。 - 清除:`save_success(..., model=)` 只删 `reason='blocked'`;`revive` / 删除凭证 / 账号级冷却出现都清模型条目。留存任务每轮 `purge_expired_model_cooldowns()`;列表只下发未过期。 - 无模型名可归因时退化为账号级 SOFT。 - **进入模型级冷却的来源不止模型限流**:Qoder 节点故障(§5.3)也归 `MODEL`。全部候选只因该模型被排除时(`_all_model_cooled`)503 文案用 `model 'x' temporarily unavailable on upstream`;统计落 `error_type=model_unavailable`,其余耗尽仍 `no_healthy_credential`;**对外错误码不变**。 diff --git a/src/provider/base.py b/src/provider/base.py index f2334ef5..1910f371 100644 --- a/src/provider/base.py +++ b/src/provider/base.py @@ -12,7 +12,7 @@ import time from collections.abc import AsyncIterator, Callable from dataclasses import dataclass, field -from datetime import UTC +from datetime import UTC, datetime, timedelta, timezone from email.utils import parsedate_to_datetime from enum import StrEnum from typing import Any, ClassVar, Protocol, runtime_checkable @@ -176,14 +176,62 @@ def parse_retry_after_seconds(headers: Any, now: int) -> float | None: return delta -def retry_hints(headers: Any, now: int | None = None) -> dict[str, Any]: +# 响应体里点名的重置时刻:CodeBuddy 国际站 `429 + 6004` 不回 `Retry-After` +# 头,恢复时刻只写在正文里——`{"code":6004,"msg":"usage exceeds frequency +# limit, but don't worry, your usage will reset at 2026-10-25 22:34:25 UTC+8, +# alternatively, you can switch to the other models..."}`。 +# 只解析这一种带显式 UTC 偏移的形态:没有偏移的本地时刻无从判断时区, +# 猜错会把冷却写到过去(立刻解冻)或几天后(长期移出池子),一律放弃。 +_RESET_AT_TEXT = re.compile( + r"reset\s+at\s+(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})" + r"\s*UTC\s*([+-])(\d{1,2})(?::?(\d{2}))?", + re.IGNORECASE) + + +def parse_reset_at(body: bytes | str, now: int) -> int | None: + """响应体里的「usage will reset at … UTC+8」→ 恢复时刻 epoch;无则 None。 + + 与 `parse_retry_after` 同一语义(「何时恢复」的绝对时刻),供 + `retry_hints` 在**没有 `Retry-After` 头**时兜底。过去时刻 / 无法解析 + 返回 None:宁可回落本地常量冷却,也不要让一个坏正文把冷却写到过去。 + """ + text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else body + if not text or "reset" not in text.lower(): + return None + match = _RESET_AT_TEXT.search(text) + if match is None: + return None + year, month, day, hour, minute, second, sign, hours, minutes = match.groups() + try: + offset = timedelta(hours=int(hours), minutes=int(minutes or 0)) + moment = datetime(int(year), int(month), int(day), int(hour), int(minute), + int(second), + tzinfo=timezone(-offset if sign == "-" else offset)) + except ValueError: # 2 月 30 日 / 偏移越界这类脏值 + return None + return int(moment.timestamp()) if int(moment.timestamp()) > now else None + + +def retry_hints(headers: Any, now: int | None = None, + body: bytes | str | None = None) -> dict[str, Any]: """`UpstreamHTTPError` 的 retry 关键字参数(无头时两值均为 None)。 让各渠道的 raise 点保持一行:`**retry_hints(response.headers)`。 + + `body` 是**头缺失时的兜底来源**(见 `parse_reset_at`):部分上游(CodeBuddy + 国际站 429+6004)只把恢复时刻写在正文里,不给 `Retry-After` 头,不解析正文 + 就只能回落到固定 600s 基数、到期重试再撞一次 429。头优先于正文——头是协议 + 标准位,正文只是文案。 """ moment = int(time.time()) if now is None else now - return {"retry_at": parse_retry_after(headers, moment), - "retry_after_seconds": parse_retry_after_seconds(headers, moment)} + retry_at = parse_retry_after(headers, moment) + retry_after_seconds = parse_retry_after_seconds(headers, moment) + if retry_at is None and body is not None: + reset_at = parse_reset_at(body, moment) + if reset_at is not None: + retry_at = reset_at + retry_after_seconds = float(reset_at - moment) + return {"retry_at": retry_at, "retry_after_seconds": retry_after_seconds} async def read_body_bounded(response: Any, limit: int = BOUNDED_BODY_LIMIT) -> bytes: diff --git a/tests/provider/test_retry_after.py b/tests/provider/test_retry_after.py index 5fc027d1..581b8807 100644 --- a/tests/provider/test_retry_after.py +++ b/tests/provider/test_retry_after.py @@ -14,7 +14,9 @@ import pytest from src.provider.base import ( + ErrKind, UpstreamHTTPError, + parse_reset_at, parse_retry_after, parse_retry_after_seconds, retry_hints, @@ -196,3 +198,82 @@ def _credential_for(module_path: str): if "trae" in module_path: return {"accessToken": "t"} return {"bearer_token": "t"} + + +# ------------------------------------------------- 响应体里的重置时刻(A2b) +# +# CodeBuddy 国际站 `429 + 6004` 不回 `Retry-After` 头,恢复时刻只在正文: +# `... your usage will reset at 2026-10-25 22:34:25 UTC+8, alternatively ...`。 +# 不解析它就永远回落 600s 基数:实测同一号一天被爆 42 次,每次都「冷却到期 +# → 重试 → 又 429」。 + +# 2026-10-25 22:34:25 UTC+8 = 14:34:25Z +RESET_MOMENT = 1_792_938_865 +RESET_BODY = ( + b'{"code":6004,"msg":"usage exceeds frequency limit, but don\'t worry, your ' + b'usage will reset at 2026-10-25 22:34:25 UTC+8, alternatively, you can ' + b'switch to the other models to continue using it.","requestId":"8967ac37"}' +) + + +def test_parse_reset_at_from_real_body(): + assert parse_reset_at(RESET_BODY, NOW) == RESET_MOMENT + # 距现在多少秒 = 绝对时刻 - now(供诊断,不参与钳制) + assert retry_hints({}, NOW, body=RESET_BODY)["retry_after_seconds"] == ( + RESET_MOMENT - NOW) + + +def test_parse_reset_at_accepts_str_and_other_offsets(): + """str 入参、`UTC+08:00` 写法、负偏移都要能解析。""" + text = "usage will reset at 2026-10-25 22:34:25 UTC+08:00" + assert parse_reset_at(text, NOW) == RESET_MOMENT + # UTC-5:同一墙钟时刻在 UTC-5 是 5 小时之后 + negative = "reset at 2026-10-25 22:34:25 UTC-5" + assert parse_reset_at(negative, NOW) == RESET_MOMENT + 13 * 3600 + + +def test_parse_reset_at_rejects_past_and_garbage(): + """过去时刻 / 无偏移 / 脏日期一律 None:交给调度器回落本地常量。""" + assert parse_reset_at("reset at 2020-01-01 00:00:00 UTC+8", NOW) is None + # 无 UTC 偏移:无法判断时区,不猜 + assert parse_reset_at("reset at 2026-10-25 22:34:25", NOW) is None + # 非法日期(2 月 30 日) + assert parse_reset_at("reset at 2026-02-30 00:00:00 UTC+8", NOW) is None + assert parse_reset_at("", NOW) is None + assert parse_reset_at(b"", NOW) is None + assert parse_reset_at(b"no reset word here 2026-10-25 22:34:25 UTC+8", NOW) is None + + +def test_retry_hints_prefers_header_over_body(): + """头是协议标准位,正文只是文案:两者都有时以头为准。""" + hints = retry_hints({"Retry-After": "120"}, NOW, body=RESET_BODY) + assert hints == {"retry_at": NOW + 120, "retry_after_seconds": 120.0} + + +def test_retry_hints_falls_back_to_body_without_header(): + hints = retry_hints({}, NOW, body=RESET_BODY) + assert hints["retry_at"] == RESET_MOMENT + # body 不含重置时刻时仍是 None(行为与历史版本一致) + assert retry_hints({}, NOW, body=b'{"code":6004}') == { + "retry_at": None, "retry_after_seconds": None} + # 不传 body 与历史签名逐字节等价 + assert retry_hints({}, NOW) == {"retry_at": None, "retry_after_seconds": None} + + +async def test_codebuddy_stream_fills_retry_at_from_body(): + """CodeBuddy 的 raise 点把 body 传给了 retry_hints:429+6004 冷却到重置时刻。""" + from src.provider.codebuddy.client import CodeBuddyClient, CodeBuddyCredential + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(429, content=RESET_BODY) + + transport = httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=None) + client = CodeBuddyClient(stream_client=transport, short_client=transport) + before = int(time.time()) + with pytest.raises(UpstreamHTTPError) as caught: + [event async for event in client.stream_chat( + CodeBuddyCredential(bearer_token="t"), {"messages": []}, + "deepseek-v4.1-flash")] + assert caught.value.retry_at == RESET_MOMENT + assert caught.value.kind() is ErrKind.MODEL + assert caught.value.retry_at > before From 062d4e5e5ecc9995eee5c48fd9c4e7048c0895be Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 22:33:57 +0800 Subject: [PATCH 017/153] =?UTF-8?q?fix(db):=20=E7=95=99=E5=AD=98=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=9B=9E=E6=94=B6=E8=BF=87=E6=9C=9F=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E7=BA=A7=E5=86=B7=E5=8D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit purge_expired_model_cooldowns 只清 (凭证, 模型) 表,账号级 credentials.cooling_until 的过期值永远留在库里。选号按 `cooling_until > now` 判断不受影响,但管理台列表下发的是原始值: 一条几周前的陈旧冷却会让该行在「冷却」维度排序失真,人工排查也会 看到幽灵记录。实测清理前库里有 9 条过期值(最早 10-13)。 - repo.purge_expired_account_cooldowns:过期值置 NULL(不动未过期的, 清掉未过期等于提前解冻一个被限流的号) - RetentionTask 每轮调用,独立计数 expired_account_coolings - 前端 TASK_REPORT_LABEL 补展示名 --- TECHNICAL.md | 4 ++-- src/db/repo.py | 15 +++++++++++++++ src/tasks/retention.py | 11 ++++++++--- tests/db/test_repo_cooldown.py | 34 ++++++++++++++++++++++++++++++++++ web/src/api/display.ts | 1 + 5 files changed, 60 insertions(+), 5 deletions(-) diff --git a/TECHNICAL.md b/TECHNICAL.md index 015223b3..196372bb 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -458,7 +458,7 @@ class Scheduler: - 选号路径:`executor._select` 逐凭证过滤 `is_selectable(now, 模型名)` 并注入 `Candidate.credit_rate`,再交 `Scheduler.select`;同时完成收窄、粘性命中与排序(`_sticky(...) or scheduler.select(...)`)。 - 退避:`MODEL` 10m 起翻倍封顶 2h;`BLOCKED` 6h 起翻倍封顶 24h;`CONCURRENCY` 固定 60s 不翻倍。换 reason 重新计数。 - **上游点名恢复时刻优先**:`Retry-After` 头 → `parse_retry_after`;头缺失时 CB 走 `parse_reset_at` 读正文 `reset at … UTC+8`(`retry_hints(headers, body=raw)`,仅 CodeBuddy 传 body,其余渠道签名不变)。两者都按 `clamp_retry_at` 钳制在 `[now + 常量, now + 24h]`,故 `MODEL` 的 600s 只是下限,上游说 22:34 恢复就冷却到 22:34。 -- 清除:`save_success(..., model=)` 只删 `reason='blocked'`;`revive` / 删除凭证 / 账号级冷却出现都清模型条目。留存任务每轮 `purge_expired_model_cooldowns()`;列表只下发未过期。 +- 清除:`save_success(..., model=)` 只删 `reason='blocked'`;`revive` / 删除凭证 / 账号级冷却出现都清模型条目。留存任务每轮 `purge_expired_model_cooldowns()`(删行)与 `purge_expired_account_cooldowns()`(账号级 `cooling_until` 置 NULL);列表只下发未过期。账号级那条是**卫生清理**不是调度依赖——选号本就按 `cooling_until > now` 判断,但陈旧值会让管理台「冷却」排序失真。 - 无模型名可归因时退化为账号级 SOFT。 - **进入模型级冷却的来源不止模型限流**:Qoder 节点故障(§5.3)也归 `MODEL`。全部候选只因该模型被排除时(`_all_model_cooled`)503 文案用 `model 'x' temporarily unavailable on upstream`;统计落 `error_type=model_unavailable`,其余耗尽仍 `no_healthy_credential`;**对外错误码不变**。 @@ -652,7 +652,7 @@ PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_k | 每日签到(checkin) | 每 10 分钟 | 成功即封账(`日期:scope` → 截止时刻,内存态,跨天清理);渠道可回填 `seal_until`(Qoder 10:00 换窗口);失败重试 | | 成长中心(growth) | 每 `GROWTH_INTERVAL_MINUTES`(默认 60,下限 5) | 仅 CB:7 类领取;落 `growth_events` + 回写 `growth_last_result` | | 活跃上报(activity,默认关) | 每 10 分钟醒,仅 `ACTIVITY_REPORT_HOUR`(默认 10 点)窗口内执行 | CB + 国际版:补发 `chat_request_send`;按「endpoint + userId」隔离、当日封账 | -| 明细清理(retention) | 每 5 分钟 | `usage_events` 全量重算小时汇总(幂等 upsert)+ 90 天清理;同期限回收 `credit_events` / `audit_events` / `alert_events` / `growth_events`。跑在 `asyncio.to_thread` | +| 明细清理(retention) | 每 5 分钟 | `usage_events` 全量重算小时汇总(幂等 upsert)+ 90 天清理;同期限回收 `credit_events` / `audit_events` / `alert_events` / `growth_events`;顺带清过期冷却(模型级删行 + 账号级 `cooling_until` 置 NULL)。跑在 `asyncio.to_thread` | | 数据库备份(backup) | 每 `BACKUP_INTERVAL_MINUTES`(默认 1440,下限 60) | `VACUUM INTO` + `integrity_check` + 0600,保留 `BACKUP_KEEP`(默认 7)。跑在 `asyncio.to_thread` | | 渠道模型列表刷新(注入协程 `api/models.py::list_models`) | 每 `MODEL_CATALOG_MINUTES`(默认 30,下限 5) | 兜底重拉(TTL 门禁 + 逐渠道 publish + 落盘)。周期跟 zen 判活缓存(30 分钟)对齐 | | 模型目录刷新(注入协程) | 每 `OPENROUTER_CATALOG_MINUTES`(默认 1440,下限 60) | 拉 OpenRouter → 三张表 → 落盘 → 就地换入 | diff --git a/src/db/repo.py b/src/db/repo.py index 6faa282d..138f05ab 100644 --- a/src/db/repo.py +++ b/src/db/repo.py @@ -353,6 +353,21 @@ def purge_expired_model_cooldowns(self, now: int | None = None) -> int: (_now(now),)) return cursor.rowcount + def purge_expired_account_cooldowns(self, now: int | None = None) -> int: + """清掉已过期的账号级 `cooling_until`(留存任务每轮调用)。 + + 选号本身用 `cooling_until > now` 判断,过期值不会挡住凭证,但它会 + 长期留在库里:管理台列表下发的是原始值,一条几周前的陈旧冷却会让 + 该行在「冷却」维度上排序失真,也让「谁在冷却」这类人工排查看到 + 幽灵记录。与模型级条目分开计数,便于区分两类回收。 + """ + with self._db.transaction() as conn: + cursor = conn.execute( + "UPDATE credentials SET cooling_until = NULL " + "WHERE cooling_until IS NOT NULL AND cooling_until <= ?", + (_now(now),)) + return cursor.rowcount + def model_cooldowns_for(self, credential_id: str, now: int | None = None) -> dict[str, int]: """该凭证仍在生效的模型级冷却(model → 截止 epoch),供管理台展示。""" diff --git a/src/tasks/retention.py b/src/tasks/retention.py index e46f091c..de9e5417 100644 --- a/src/tasks/retention.py +++ b/src/tasks/retention.py @@ -8,9 +8,10 @@ class RetentionTask: """明细 90 天清理 + 小时汇总(永久保留)。 - `credentials` 非 None 时顺带清理已过期的 (凭证, 模型) 冷却行—— - 那些行只在限流/负缓存期间存在,不回收到期行会无限累积(管理台 - 每次列表都要整表扫一遍)。 + `credentials` 非 None 时顺带清理已过期的冷却行——模型级 + `credential_model_cooldowns` 整行删除、账号级 `credentials.cooling_until` + 置 NULL。那些值只在限流/负缓存期间有意义,不回收到期值会无限累积 + (管理台每次列表都要整表扫一遍,且陈旧值会让「冷却」排序失真)。 `credit_events` 非 None 时同样按保留期清理积分流水:它每轮探测都可能 落一行,不回收会长期增长(读侧有 LIMIT,但表本身会一直变大)。 @@ -43,6 +44,9 @@ def run_once(self) -> dict[str, int]: expired_coolings = ( self._credentials.purge_expired_model_cooldowns() if self._credentials is not None else 0) + expired_account_coolings = ( + self._credentials.purge_expired_account_cooldowns() + if self._credentials is not None else 0) purged_credit_events = ( self._credit_events.prune(keep_days=self.retention_days) if self._credit_events is not None else 0) @@ -57,6 +61,7 @@ def run_once(self) -> dict[str, int]: if self._growth_events is not None else 0) return {"rolled_up": rolled, "purged": purged, "expired_coolings": expired_coolings, + "expired_account_coolings": expired_account_coolings, "purged_credit_events": purged_credit_events, "purged_audit": purged_audit, "purged_alerts": purged_alerts, diff --git a/tests/db/test_repo_cooldown.py b/tests/db/test_repo_cooldown.py index be6bcac0..3e4e888a 100644 --- a/tests/db/test_repo_cooldown.py +++ b/tests/db/test_repo_cooldown.py @@ -153,6 +153,40 @@ def test_retention_task_purges_expired_model_cooldowns(repo): # 不传 credentials(旧调用方)时不报错,计数为 0 assert RetentionTask(collector).run_once()["expired_coolings"] == 0 +def test_retention_task_clears_expired_account_cooldown(repo): + """账号级冷却过期后置 NULL:否则陈旧值会永久留在库里。 + + 选号用 `cooling_until > now` 判断,过期值不挡凭证,但管理台列表下发的是 + 原始值——一条几周前的冷却会让该行在「冷却」维度排序失真。 + """ + credentials, _db = repo + cid = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + now = int(time.time()) + credentials.save_error(cid, ErrorOutcome(cooling_until=now - 10, err_count=0)) + assert credentials.candidates()[0].cooling_until == now - 10 + + collector = StatsCollector(_db) + report = RetentionTask(collector, credentials=credentials).run_once() + assert report["expired_account_coolings"] == 1 + assert credentials.candidates()[0].cooling_until is None + # 再跑一轮:已经没有过期值,计数归零(幂等) + assert RetentionTask(collector, credentials=credentials).run_once()[ + "expired_account_coolings"] == 0 + +def test_retention_task_keeps_active_account_cooldown(repo): + """仍在冷却期内的账号级冷却不能被清掉:清掉等于提前解冻一个被限流的号。""" + credentials, _db = repo + cid = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + future = int(time.time()) + 3600 + credentials.save_error(cid, ErrorOutcome(cooling_until=future, err_count=0)) + + collector = StatsCollector(_db) + report = RetentionTask(collector, credentials=credentials).run_once() + assert report["expired_account_coolings"] == 0 + assert credentials.candidates()[0].cooling_until == future + # 未注入凭证仓储时同样不报错 + assert RetentionTask(collector).run_once()["expired_account_coolings"] == 0 + def test_retention_task_prunes_audit_events(repo): """留存任务按保留期回收审计流水;未注入时计数为 0(README「审计保留 90 天」)。""" from src.db.repo import AuditRepository diff --git a/web/src/api/display.ts b/web/src/api/display.ts index f04131d2..449feb6f 100644 --- a/web/src/api/display.ts +++ b/web/src/api/display.ts @@ -269,6 +269,7 @@ export const TASK_REPORT_LABEL: Record = { rolled_up: "汇总小时", purged: "清理明细", expired_coolings: "回收冷却", + expired_account_coolings: "回收账号冷却", purged_credit_events: "回收流水", purged_alerts: "回收告警", evaluated: "评估", From 780060626a4a86f049195aa8c3240da9175e0bb3 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 22:42:26 +0800 Subject: [PATCH 018/153] =?UTF-8?q?fix(engine):=20=E5=86=B7=E5=8D=B4?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=8C=BA=E5=88=86=E8=B4=A6=E5=8F=B7=E7=BA=A7?= =?UTF-8?q?=E4=B8=8E=E6=A8=A1=E5=9E=8B=E7=BA=A7=E4=BD=9C=E7=94=A8=E5=9F=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_cooling_until` 的日志无条件打「模型 <名>」,而账号级分类(SOFT / PLAN / CREDIT / OTHER)的调用方同样把请求模型名传进来。于是 zen 免费层 429(SOFT,落账号级 `credentials.cooling_until`)的日志读成: 采用上游时刻 2026-10-26 08:00:00(原常量 22:26:57): 凭证 cred_6d62… 模型 glm-5.3-flash-free 分类 soft 冷却 34443s 库里的实际位置是账号级、模型级 0 行,日志却像模型级限流,排查时 极易误判成「只锁了这一个模型」。 `_scope_label(kind, model)`:MODEL_SCOPED_KINDS 且有模型名 → 「模型 <名>」, 其余(含 MODEL 缺模型名的退化路径)→ 「作用域 账号」。 --- TECHNICAL.md | 3 ++- src/engine/scheduler.py | 20 ++++++++++++++-- tests/engine/test_scheduler_retry_at.py | 32 +++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/TECHNICAL.md b/TECHNICAL.md index 196372bb..247fbba0 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -134,6 +134,7 @@ MODEL_SCOPED_KINDS = frozenset({MODEL, BLOCKED, CONCURRENCY}) - **zen / kilo 的 401 → `INVALID`**:两者无凭证,`401 Missing API key.` 只说明该模型要付费 key;归 `DEAD` 会让一次强制付费请求硬禁用整条免费渠道。 - **kilo 429 与 502/503/504 → `MODEL`**:免费池是 OpenRouter 共享池,报错点名具体模型(`limit_source: upstream_provider_shared_pool`),同时刻其他免费模型仍可用。 - **CodeArts 临时凭证过期 → `SOFT`**:`HTTP 400` + `APIG.0602 security token has expired`,识别在 `401/403 → DEAD` 之前(`_STS_EXPIRED_MARKERS`)。判 `DEAD` 会让 `RefreshTask` 跳过该凭证,断掉唯一自愈路径。 +- **TLS 中断(`ssl.SSLError`)→ `SOFT`**:`executor._classify` 与 `httpx.TransportError` 并列兜住。anyio 只把 `SSLEOFError` 转成 `BrokenResourceError`,其余(`record layer failure`、证书链握手失败)原样上抛,既不是 `httpx.TransportError` 也不在「只认具体类」的白名单里——不单列就一路逃到 `stream_guarded`,表现为一帧 `internal_error`:不换号、不冷却、`usage_events` 里零痕迹。仍不放开到 `OSError` 整体(磁盘满、句柄耗尽会误冷却健康凭证)。 **业务码只从 `"code": N` 键值形态提取**(`provider.base.business_codes`):裸搜数字会让 `"code":111020` 命中 `11102`。判定顺序即优先级,`429+6004` 先于 `429`,`402/14018` 先于状态码兜底。 @@ -457,7 +458,7 @@ class Scheduler: - 登记名是**该凭证所属上游的原始模型名**(`_upstream_model`),CB 与 TRAE 大小写变体不同,归一名会漏判。 - 选号路径:`executor._select` 逐凭证过滤 `is_selectable(now, 模型名)` 并注入 `Candidate.credit_rate`,再交 `Scheduler.select`;同时完成收窄、粘性命中与排序(`_sticky(...) or scheduler.select(...)`)。 - 退避:`MODEL` 10m 起翻倍封顶 2h;`BLOCKED` 6h 起翻倍封顶 24h;`CONCURRENCY` 固定 60s 不翻倍。换 reason 重新计数。 -- **上游点名恢复时刻优先**:`Retry-After` 头 → `parse_retry_after`;头缺失时 CB 走 `parse_reset_at` 读正文 `reset at … UTC+8`(`retry_hints(headers, body=raw)`,仅 CodeBuddy 传 body,其余渠道签名不变)。两者都按 `clamp_retry_at` 钳制在 `[now + 常量, now + 24h]`,故 `MODEL` 的 600s 只是下限,上游说 22:34 恢复就冷却到 22:34。 +- **上游点名恢复时刻优先**:`Retry-After` 头 → `parse_retry_after`;头缺失时 CB 走 `parse_reset_at` 读正文 `reset at … UTC+8`(`retry_hints(headers, body=raw)`,仅 CodeBuddy 传 body,其余渠道签名不变)。两者都按 `clamp_retry_at` 钳制在 `[now + 常量, now + 24h]`,故 `MODEL` 的 600s 只是下限,上游说 22:34 恢复就冷却到 22:34。日志按 `_scope_label` 打作用域:模型级写「模型 <名>」,账号级写「作用域 账号」——账号级分类的调用方同样传请求模型名,一律打 `模型` 会让 zen 429 这类账号级冷却读起来像模型级限流。 - 清除:`save_success(..., model=)` 只删 `reason='blocked'`;`revive` / 删除凭证 / 账号级冷却出现都清模型条目。留存任务每轮 `purge_expired_model_cooldowns()`(删行)与 `purge_expired_account_cooldowns()`(账号级 `cooling_until` 置 NULL);列表只下发未过期。账号级那条是**卫生清理**不是调度依赖——选号本就按 `cooling_until > now` 判断,但陈旧值会让管理台「冷却」排序失真。 - 无模型名可归因时退化为账号级 SOFT。 - **进入模型级冷却的来源不止模型限流**:Qoder 节点故障(§5.3)也归 `MODEL`。全部候选只因该模型被排除时(`_all_model_cooled`)503 文案用 `model 'x' temporarily unavailable on upstream`;统计落 `error_type=model_unavailable`,其余耗尽仍 `no_healthy_credential`;**对外错误码不变**。 diff --git a/src/engine/scheduler.py b/src/engine/scheduler.py index 9f614ba2..9d33602c 100644 --- a/src/engine/scheduler.py +++ b/src/engine/scheduler.py @@ -409,12 +409,28 @@ def _cooling_until(candidate: Candidate, kind: ErrKind, now: int, if adopted: # 只在真的采用上游时刻时打一行:普通错误每个都打会把日志刷满 logger.info( - "采用上游时刻 %s(原常量 %s):凭证 %s 模型 %s 分类 %s 冷却 %ss", + "采用上游时刻 %s(原常量 %s):凭证 %s %s 分类 %s 冷却 %ss", _format_moment(until), _format_moment(now + duration), - candidate.credential_id, model or "-", kind.value, until - now) + candidate.credential_id, _scope_label(kind, model), kind.value, + until - now) return until +def _scope_label(kind: ErrKind, model: str | None) -> str: + """日志里的冷却作用域:模型级写模型名,账号级明确写「账号」。 + + 不能一律打 `model`:账号级分类(SOFT / PLAN / CREDIT / OTHER)的调用方 + 同样把请求模型名传进来,日志于是读成「只锁了这个模型」,与 + `credential_model_cooldowns` 的实际写入位置矛盾——实测 zen 免费层 429 + 落的是账号级 `cooling_until`,日志却写「模型 glm-5.3-flash-free」,排查时 + 极易误判成模型级限流。`MODEL` 缺模型名时已退化为账号级软冷却 + (见 `note_error`),故无模型的模型级分类也按账号打。 + """ + if kind in MODEL_SCOPED_KINDS and model: + return f"模型 {model}" + return "作用域 账号" + + def _format_moment(moment: int) -> str: """日志里的时刻:本地时间可读串(epoch 只适合机读,排查时难对表)。""" return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(moment)) diff --git a/tests/engine/test_scheduler_retry_at.py b/tests/engine/test_scheduler_retry_at.py index 8d2fb7d3..d6cae4c9 100644 --- a/tests/engine/test_scheduler_retry_at.py +++ b/tests/engine/test_scheduler_retry_at.py @@ -181,6 +181,38 @@ def test_adopted_moment_is_logged(caplog): assert any("采用上游时刻" in record.getMessage() for record in caplog.records) +def test_adopted_moment_log_says_account_scope_for_soft(caplog): + """账号级分类即使传了模型名,日志也必须写「账号」。 + + 实测 zen 免费层 429(SOFT)落的是账号级 `cooling_until`,旧日志却打 + 「模型 glm-5.3-flash-free」,读起来像模型级限流,排查时误判。 + """ + with caplog.at_level(logging.INFO, logger="src.engine.scheduler"): + Scheduler().note_error(cand(), ErrKind.SOFT, NOW, model="glm-5.3-flash-free", + retry_at=NOW + 7200) + message = next(r.getMessage() for r in caplog.records if "采用上游时刻" in r.getMessage()) + assert "作用域 账号" in message + assert "glm-5.3-flash-free" not in message + + +def test_adopted_moment_log_says_model_scope_for_model_kind(caplog): + """模型级分类写模型名,与 `credential_model_cooldowns` 的写入位置一致。""" + with caplog.at_level(logging.INFO, logger="src.engine.scheduler"): + Scheduler().note_error(cand(), ErrKind.MODEL, NOW, model="deepseek-v4.1-flash", + retry_at=NOW + 7200) + message = next(r.getMessage() for r in caplog.records if "采用上游时刻" in r.getMessage()) + assert "模型 deepseek-v4.1-flash" in message + assert "作用域 账号" not in message + + +def test_adopted_moment_log_says_account_when_model_scoped_kind_lacks_model(caplog): + """MODEL 无模型名时已退化为账号级软冷却,日志不能还写模型级。""" + with caplog.at_level(logging.INFO, logger="src.engine.scheduler"): + Scheduler().note_error(cand(), ErrKind.MODEL, NOW, retry_at=NOW + 7200) + message = next(r.getMessage() for r in caplog.records if "采用上游时刻" in r.getMessage()) + assert "作用域 账号" in message + + def test_fallback_is_not_logged(caplog): with caplog.at_level(logging.INFO, logger="src.engine.scheduler"): Scheduler().note_error(cand(), ErrKind.SOFT, NOW) From 1961be16b81e98620ff19f1979648b89224774e0 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 22:43:55 +0800 Subject: [PATCH 019/153] =?UTF-8?q?fix(test):=20=E7=AB=AF=E5=88=B0?= =?UTF-8?q?=E7=AB=AF=E7=94=A8=E4=BE=8B=E6=94=B9=E7=94=A8=E7=9B=B8=E5=AF=B9?= =?UTF-8?q?=E6=97=B6=E5=88=BB=E7=9A=84=E6=AD=A3=E6=96=87=EF=BC=8C=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=E6=97=B6=E9=97=B4=E7=82=B8=E5=BC=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9ba8b80 里的 `test_codebuddy_stream_fills_retry_at_from_body` 写死了 `2026-10-25 22:34:25 UTC+8`(当时那条真实报文的时刻)。`parse_reset_at` 对过去时刻正确返回 None(见 `test_parse_reset_at_rejects_past_and_garbage`), 于是这个用例在 22:34:25 之后必然失败——实测 22:43 复跑即 `assert None == 1792938865`。 改用 `_reset_body_at(now + 3600)` 按当前时刻生成同形状正文,断言 `retry_at == reset_at`。纯函数用例里的固定 `NOW` / `RESET_MOMENT` 不受 影响(那些注入 `now`,与真实时钟无关)。 --- tests/provider/test_retry_after.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/provider/test_retry_after.py b/tests/provider/test_retry_after.py index 581b8807..523efdff 100644 --- a/tests/provider/test_retry_after.py +++ b/tests/provider/test_retry_after.py @@ -9,6 +9,7 @@ import email.utils import time +from datetime import datetime, timedelta, timezone import httpx import pytest @@ -260,12 +261,33 @@ def test_retry_hints_falls_back_to_body_without_header(): assert retry_hints({}, NOW) == {"retry_at": None, "retry_after_seconds": None} +def _reset_body_at(moment: int) -> bytes: + """按 `moment` 生成一份真实形状的正文(重置时刻写成 UTC+8 墙钟)。 + + 端到端用例必须用**相对当前时刻**的正文。写死某个日期的话,跑到那天之后 + `parse_reset_at` 会把「过去的时刻」判成 None(这是它的正确行为,见 + `test_parse_reset_at_rejects_past_and_garbage`),用例就变成隔天必红—— + 实测 2026-10-25 22:34:25 那条写死正文在 22:35 之后即失败。 + """ + stamp = datetime.fromtimestamp(moment, timezone(timedelta(hours=8))) + return ( + b'{"code":6004,"msg":"usage exceeds frequency limit, but don\'t worry, your ' + b'usage will reset at ' + + stamp.strftime("%Y-%m-%d %H:%M:%S").encode() + + b' UTC+8, alternatively, you can switch to the other models to ' + b'continue using it.","requestId":"8967ac37"}' + ) + + async def test_codebuddy_stream_fills_retry_at_from_body(): """CodeBuddy 的 raise 点把 body 传给了 retry_hints:429+6004 冷却到重置时刻。""" from src.provider.codebuddy.client import CodeBuddyClient, CodeBuddyCredential + reset_at = int(time.time()) + 3600 + body = _reset_body_at(reset_at) + def handler(_request: httpx.Request) -> httpx.Response: - return httpx.Response(429, content=RESET_BODY) + return httpx.Response(429, content=body) transport = httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=None) client = CodeBuddyClient(stream_client=transport, short_client=transport) @@ -274,6 +296,6 @@ def handler(_request: httpx.Request) -> httpx.Response: [event async for event in client.stream_chat( CodeBuddyCredential(bearer_token="t"), {"messages": []}, "deepseek-v4.1-flash")] - assert caught.value.retry_at == RESET_MOMENT + assert caught.value.retry_at == reset_at assert caught.value.kind() is ErrKind.MODEL assert caught.value.retry_at > before From c4eb993c5c44a63a85d9e62902f0e0de08054fbe Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 22:49:58 +0800 Subject: [PATCH 020/153] =?UTF-8?q?fix(engine):=20TLS=20=E4=B8=AD=E6=96=AD?= =?UTF-8?q?=EF=BC=88ssl.SSLError=EF=BC=89=E5=BD=92=20SOFT=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=80=83=E6=88=90=20internal=5Ferror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit anyio 的 `_call_sslobject_method` 只把 `SSLEOFError` 转成 `BrokenResourceError`,其余 `ssl.SSLError` 原样上抛。它是 `OSError` 子类 但**不是** `httpx.TransportError`,落不进 `_classify` 的任何一支,于是 异常一路逃到 `stream_guarded`,表现为回一帧 `internal_error`:不换号、 不冷却、`usage_events` 里零痕迹,失败归因面板完全看不到。 实测 2026-10-25 15:50 经 7897 代理两次 codebuddy_intl: `[SSL] record layer failure (_ssl.c:2580)`。同一波还有 RemoteProtocolError / ReadError / ConnectError(那些是 TransportError,正常归 SOFT),只有 SSL 这两条逃出去了。 - `_classify` 把 `ssl.SSLError` 与 `httpx.TransportError` 并列判 SOFT - 仍然只认**具体类**,不放开到 `OSError` 整体:磁盘满、句柄耗尽也是 OSError 子类,误判成上游故障会去冷却一个完全健康的凭证 - 测试:前提校验(该异常确实不是 TransportError)+ 流式换号冷却 / 全量 TLS 中断落 no_healthy_credential / 非流式换号,三条路径都锁住 --- PROPOSAL.md | 2 +- src/engine/executor.py | 12 ++++- tests/engine/test_executor_transport.py | 68 +++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/PROPOSAL.md b/PROPOSAL.md index 618fb368..d1f25b3a 100644 --- a/PROPOSAL.md +++ b/PROPOSAL.md @@ -125,7 +125,7 @@ Provider 承担上游私有部分:发请求、解析事件、分类错误、 4. **到期额度两级字典序**:先比主窗口(`QUOTA_EXPIRY_WINDOW_SECONDS`,默认 36h)内将过期的额度,打平再比次窗口(`QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS`,默认 7 天)。 5. **健康度三态**:`known` > `unknown` > `exhausted`;打平按剩余积分多者优先,再按 `credential_id` 稳定。 -**为什么冷却分两层**:`429 + 6004` 是「这个模型在当前账号用超了」,`400/404 + 11102` 是「当前账号没这个模型」,一律记账号级会让一次模型级限流把整个账号踢出池。账号级写 `credentials.cooling_until`,模型级另建 `credential_model_cooldowns`;账号级冷却出现时清空该凭证的模型级条目,防「切模型」绕过。业务码识别只认 `"code": N` 键值形态。冷却时长:`PLAN` 12h、`SOFT` 60s、`OTHER` 连续 3 次→10m、`MODEL` 600s 起翻倍封顶 2h、`BLOCKED` 6h 起封顶 24h、`CONCURRENCY` 固定 60s、`CREDIT` 到次日 04:00。**传输层失败归 `SOFT`**:`httpx.TransportError` 与 `UpstreamTransportError` 统一在 `executor._classify` 兜住(异常从 `__aenter__`、`aiter_bytes()`、写请求体多处抛出,逐点包装必漏);刻意不泛化到 `OSError` / `ConnectionError`。**上游重置时刻可覆盖常量**:`Retry-After`(整秒 / HTTP-date)或 `Try again in 17h 59m` 经 `parse_retry_after` 解析后按作用域写入,并双端钳制在 `[now + 常量, now + 24h]`。**头缺失时看响应体**:CB 国际站 `429+6004` 不给 `Retry-After`,恢复时刻只在正文(`your usage will reset at 2026-10-25 22:34:25 UTC+8`),由 `parse_reset_at` 解析同一种绝对时刻;头优先于正文,正文无偏移(`UTC±N`)则放弃。不解析正文的后果是固定 600s 基数到期重试、再撞一次 429(实测同一号一天被爆 42 次)。 +**为什么冷却分两层**:`429 + 6004` 是「这个模型在当前账号用超了」,`400/404 + 11102` 是「当前账号没这个模型」,一律记账号级会让一次模型级限流把整个账号踢出池。账号级写 `credentials.cooling_until`,模型级另建 `credential_model_cooldowns`;账号级冷却出现时清空该凭证的模型级条目,防「切模型」绕过。业务码识别只认 `"code": N` 键值形态。冷却时长:`PLAN` 12h、`SOFT` 60s、`OTHER` 连续 3 次→10m、`MODEL` 600s 起翻倍封顶 2h、`BLOCKED` 6h 起封顶 24h、`CONCURRENCY` 固定 60s、`CREDIT` 到次日 04:00。**传输层失败归 `SOFT`**:`httpx.TransportError`、`ssl.SSLError` 与 `UpstreamTransportError` 统一在 `executor._classify` 兜住(异常从 `__aenter__`、`aiter_bytes()`、写请求体多处抛出,逐点包装必漏);刻意不泛化到 `OSError` / `ConnectionError`。`ssl.SSLError` 必须单列:anyio 只把 `SSLEOFError` 转成 `BrokenResourceError`,其余 TLS 中断(`record layer failure` 等)原样上抛,既不是 `TransportError` 也不是白名单内的具体类,不列就逃到 `stream_guarded` 变成 `internal_error`——不换号、不冷却、统计里零痕迹。**上游重置时刻可覆盖常量**:`Retry-After`(整秒 / HTTP-date)或 `Try again in 17h 59m` 经 `parse_retry_after` 解析后按作用域写入,并双端钳制在 `[now + 常量, now + 24h]`。**头缺失时看响应体**:CB 国际站 `429+6004` 不给 `Retry-After`,恢复时刻只在正文(`your usage will reset at 2026-10-25 22:34:25 UTC+8`),由 `parse_reset_at` 解析同一种绝对时刻;头优先于正文,正文无偏移(`UTC±N`)则放弃。不解析正文的后果是固定 600s 基数到期重试、再撞一次 429(实测同一号一天被爆 42 次)。 **为什么是「窗口内积分总量」**:实测 CB 额度是几十个各自独立到期的小包(每日 100 积分 × N),只存一个日期没有区分度。四个取舍:① 统计窗口内到期积分总量,账号间才可比;② 落库到期阶梯 `[(到期 epoch, 包剩余积分)]` 而非预计算数字;③ 过滤条件必须 `end > now` 且排除已用完的包;④ 两级窗口,只比 36h 会大量账号同为 0。**展示与调度指标分开存**:`quota_packages`(含包名 / 用量)与 `quota_expiry_ladder`(只有 `[到期, 剩余]`)。 diff --git a/src/engine/executor.py b/src/engine/executor.py index 751bde40..751d8a68 100644 --- a/src/engine/executor.py +++ b/src/engine/executor.py @@ -5,6 +5,7 @@ import asyncio import contextlib import logging +import ssl import time from collections.abc import AsyncIterator, Iterable, Iterator from dataclasses import dataclass, replace @@ -1621,11 +1622,20 @@ def _classify(error: Exception) -> ErrKind | None: 刻意**不**泛化到 `OSError` / `ConnectionError`:文件系统类错误(磁盘满、 句柄耗尽)也是它们的子类,误判成上游故障会去冷却一个完全健康的凭证。 `UpstreamTransportError` 自带 `kind()`,已被上面第一段覆盖。 + + **`ssl.SSLError` 单独列出**:TLS 层的 record layer failure / 证书链握手 + 失败在 anyio 的 `_call_sslobject_method` 里只有 `SSLEOFError` 被转成 + `BrokenResourceError`,其余 `SSLError` 原样上抛(见 anyio/streams/tls.py)。 + 它是 `OSError` 子类但**不是** `httpx.TransportError`,落不进上面的判断—— + 实测 2026-10-25 15:50 两次 codebuddy_intl 走 7897 代理时 `[SSL] record + layer failure` 直接逃到 `stream_guarded`,表现为一帧 `internal_error`: + 不换号、不冷却、不记统计,失败归因面板里连痕迹都没有。TLS 中断与读中断 + 同属链路瞬态,归 SOFT。仍然只认**具体类**,不放开到 `OSError` 整体。 """ kind_method = getattr(error, "kind", None) if callable(kind_method): return kind_method() - if isinstance(error, httpx.TransportError): + if isinstance(error, (httpx.TransportError, ssl.SSLError)): return ErrKind.SOFT return None diff --git a/tests/engine/test_executor_transport.py b/tests/engine/test_executor_transport.py index d836d656..6024d6f4 100644 --- a/tests/engine/test_executor_transport.py +++ b/tests/engine/test_executor_transport.py @@ -4,10 +4,15 @@ `httpx.ConnectTimeout`——它们没有 `kind()`,此前 `_classify` 返回 None, 异常直接上抛到 `stream_guarded`,表现为回一帧 `internal_error`、既不换号 也不冷却、还不记账。本文件锁住修复后的口径:传输层失败 = SOFT。 + +`ssl.SSLError` 同属这一类但**不是** `httpx.TransportError`(anyio 只转 +`SSLEOFError`),2026-10-25 15:50 经代理的 `record layer failure` 就是这样 +逃出去的,故单列成组用例。 """ from __future__ import annotations +import ssl import time import httpx @@ -151,3 +156,66 @@ async def test_complete_all_transport_errors_raise_no_healthy_credential(repo): await executor.complete(complete_request()) assert provider.calls == 2 assert len(cooled_ids(credentials)) == 2 + + +def _ssl_record_layer_failure() -> ssl.SSLError: + """复刻实测异常:TLS record layer failure(anyio 原样上抛,不转 BrokenResource)。 + + 2026-10-25 15:50 两次 codebuddy_intl 经 7897 代理时就是这个值;它是 + `OSError` 子类但**不是** `httpx.TransportError`,此前 `_classify` 返回 + None,异常逃到 `stream_guarded` 变成一帧 `internal_error`。 + """ + return ssl.SSLError(1, "[SSL] record layer failure (_ssl.c:2580)") + + +def test_ssl_error_is_not_httpx_transport_error(): + """前提校验:这条异常必须**不是** TransportError,否则下面的用例测不到修复。""" + assert not isinstance(_ssl_record_layer_failure(), httpx.TransportError) + + +async def test_stream_rotates_and_cools_on_ssl_error(repo): + """TLS 中断 → 换号重试(不是 internal_error),失败的那张进 SOFT 冷却。""" + credentials, _db = repo + ids = {token: add_credential(credentials, token) for token in ("first", "second")} + executor, provider = build_executor( + credentials, [[_ssl_record_layer_failure()], TRAE_GOOD]) + + frames = [frame async for frame in executor.stream(stream_request())] + joined = b"".join(frames) + + assert provider.calls == 2 + assert frames[-1] == SSE_DONE + assert b'"hi"' in joined + assert b"internal_error" not in joined + failed_id = ids[provider.tokens[0]] + assert cooled_ids(credentials) == {failed_id} + + +async def test_stream_all_ssl_errors_end_with_no_healthy_credential(repo): + """全部凭证都 TLS 中断 → no_healthy_credential 终帧,而不是 internal_error。""" + credentials, _db = repo + add_credential(credentials, "a") + add_credential(credentials, "b") + executor, provider = build_executor( + credentials, [[_ssl_record_layer_failure()]]) + + frames = [frame async for frame in executor.stream(stream_request())] + joined = b"".join(frames) + + assert provider.calls == 2 + assert b"no_healthy_credential" in joined + assert b"internal_error" not in joined + + +async def test_complete_rotates_on_ssl_error(repo): + """非流式:TLS 中断同样走换号重试。""" + credentials, _db = repo + add_credential(credentials, "first") + add_credential(credentials, "second") + executor, provider = build_executor( + credentials, [[_ssl_record_layer_failure()], TRAE_GOOD]) + + result = await executor.complete(complete_request()) + + assert provider.calls == 2 + assert result["choices"][0]["message"]["content"] == "hi" From 7b27b0fb64320aaca8cfe15785a489aeb8a64b85 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 23:44:43 +0800 Subject: [PATCH 021/153] =?UTF-8?q?fix(web):=20=E6=8E=A2=E6=B4=BB=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=91=98=E8=A6=81=E9=99=90=E5=AE=BD=E5=8D=95=E8=A1=8C?= =?UTF-8?q?=E6=88=AA=E6=96=AD=EF=BC=8Chover=20=E7=9C=8B=E5=85=A8=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 折行会把列撑宽挤掉右侧操作列;改用 LongTextTip + truncate。 --- web/src/pages/ModelCatalogPage.tsx | 16 +++++++++++----- .../pages/__tests__/ModelCatalogPage.test.tsx | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/web/src/pages/ModelCatalogPage.tsx b/web/src/pages/ModelCatalogPage.tsx index 50ef540d..1b86bd16 100644 --- a/web/src/pages/ModelCatalogPage.tsx +++ b/web/src/pages/ModelCatalogPage.tsx @@ -26,7 +26,7 @@ import { PageHeader } from "../components/PageHeader"; import { PageSkeleton } from "../components/PageSkeleton"; import { Pagination } from "../components/Pagination"; import { SortableHead } from "../components/SortableHead"; -import { ColumnHint } from "../components/Tip"; +import { ColumnHint, LongTextTip } from "../components/Tip"; import { ToastViewport, useToasts } from "../components/Toast"; import { useSort } from "../hooks/useSort"; import { DEFAULT_PAGE_SIZE } from "../lib/constants"; @@ -277,10 +277,16 @@ function ProbePanel({ 未探过 )} {row.last_error && ( -
- {row.last_error} -
+ // 错误摘要可能很长(上游原文):限宽单行截断,hover 看全文。 + // 不折行是必要的——折行会把整列撑宽,挤掉右侧操作列。 + +
+ {row.last_error} +
+
)}
diff --git a/web/src/pages/__tests__/ModelCatalogPage.test.tsx b/web/src/pages/__tests__/ModelCatalogPage.test.tsx index d492af85..25dc7d76 100644 --- a/web/src/pages/__tests__/ModelCatalogPage.test.tsx +++ b/web/src/pages/__tests__/ModelCatalogPage.test.tsx @@ -552,6 +552,21 @@ describe("ModelCatalogPage(模型列表)", () => { expect(never).toHaveTextContent("未探过"); }); + it("探活面板:错误摘要限宽单行截断,hover 看全文", async () => { + stubProbePage(); + renderPage(); + await settle(); + + const error = screen.getByTestId("probe-error-trae-glm-5.2"); + // 折行会把整列撑宽挤掉操作列,故必须 truncate + expect(error.className).toContain("truncate"); + expect(error).toHaveTextContent("HTTP 404: model not found"); + + await userEvent.hover(error); + const tip = await screen.findByRole("tooltip"); + expect(tip).toHaveTextContent("HTTP 404: model not found"); + }); + it("探活面板:无记录时给出空态", async () => { stubProbePage({ rows: [] }); renderPage(); From 57066a9043db67d5debb6c2cad63d6ce6c48ced9 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 23:44:50 +0800 Subject: [PATCH 022/153] =?UTF-8?q?docs(readme):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8F=B0=E6=88=AA=E5=9B=BE=E9=A2=84=E8=A7=88?= =?UTF-8?q?=EF=BC=88=E4=B8=AD=E8=8B=B1=E5=8F=8C=E8=AF=AD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assets/screenshots 下 11 张 webp(1600px 宽,共约 640KB),覆盖凭证、统计、 Playground、模型列表、上游状态、API Key、用户、审计、日志、告警、任务与配置。 截图取自本地实例,账号 / IP / Key 已脱敏。 --- README.en.md | 20 ++++++++++++++++++++ README.md | 20 ++++++++++++++++++++ assets/screenshots/alerts.webp | Bin 0 -> 51226 bytes assets/screenshots/apikeys.webp | Bin 0 -> 31120 bytes assets/screenshots/audit.webp | Bin 0 -> 41680 bytes assets/screenshots/credentials.webp | Bin 0 -> 48412 bytes assets/screenshots/logs.webp | Bin 0 -> 128788 bytes assets/screenshots/models.webp | Bin 0 -> 55268 bytes assets/screenshots/playground.webp | Bin 0 -> 75358 bytes assets/screenshots/settings.webp | Bin 0 -> 35890 bytes assets/screenshots/stats.webp | Bin 0 -> 83114 bytes assets/screenshots/upstream.webp | Bin 0 -> 59266 bytes assets/screenshots/users.webp | Bin 0 -> 37168 bytes 13 files changed, 40 insertions(+) create mode 100644 assets/screenshots/alerts.webp create mode 100644 assets/screenshots/apikeys.webp create mode 100644 assets/screenshots/audit.webp create mode 100644 assets/screenshots/credentials.webp create mode 100644 assets/screenshots/logs.webp create mode 100644 assets/screenshots/models.webp create mode 100644 assets/screenshots/playground.webp create mode 100644 assets/screenshots/settings.webp create mode 100644 assets/screenshots/stats.webp create mode 100644 assets/screenshots/upstream.webp create mode 100644 assets/screenshots/users.webp diff --git a/README.en.md b/README.en.md index c0f7888a..07c32820 100644 --- a/README.en.md +++ b/README.en.md @@ -22,6 +22,26 @@ Wraps **CodeBuddy** (CN + international), **TRAE SOLO**, **OpenCode Zen**, **Kil Protocol-level implementation (private envelopes, signing, gate spoofing) is in [`TECHNICAL.md`](TECHNICAL.md) (Chinese). +## Screenshots + +Admin console (captured from a local instance; accounts and IPs masked): + +| Credentials: pool overview, grouped by channel | Usage: requests, tokens, cost | +|---|---| +| ![Credentials](assets/screenshots/credentials.webp) | ![Usage](assets/screenshots/stats.webp) | +| **Playground: models × channels and multipliers** | **Model list: OpenRouter list prices and ability score** | +| ![Playground](assets/screenshots/playground.webp) | ![Model list](assets/screenshots/models.webp) | +| **Upstream status: upstream outage vs. our problem** | **API keys: client setup and quotas** | +| ![Upstream status](assets/screenshots/upstream.webp) | ![API keys](assets/screenshots/apikeys.webp) | +| **Users: three roles, one-time activation** | **Audit log: successful and failed logins** | +| ![Users](assets/screenshots/users.webp) | ![Audit log](assets/screenshots/audit.webp) | +| **Server logs: ring buffer with redaction** | **Ops alerts: four risk rules, periodic** | +| ![Server logs](assets/screenshots/logs.webp) | ![Ops alerts](assets/screenshots/alerts.webp) | + +**Tasks & settings: 60+ hot-reloadable options, DB overrides win over `.env`** + +![Tasks and settings](assets/screenshots/settings.webp) + ## Quick start Requirements: Python 3.12+, [uv](https://docs.astral.sh/uv/), Node.js 24+ and pnpm 10+ (frontend build only). diff --git a/README.md b/README.md index 3a6a2355..e9dc941f 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,26 @@ 协议层实现(私有信封、签名、门禁伪装等)见 [`TECHNICAL.md`](TECHNICAL.md)。 +## 界面预览 + +管理台(截图取自本地实例,账号与 IP 已脱敏): + +| 凭证管理:池概况与渠道分组 | 用量统计:请求 / token / 成本 | +|---|---| +| ![凭证管理](assets/screenshots/credentials.webp) | ![用量统计](assets/screenshots/stats.webp) | +| **Playground:模型 × 渠道与倍率** | **模型列表:OpenRouter 刊例价与能力分** | +| ![Playground](assets/screenshots/playground.webp) | ![模型列表](assets/screenshots/models.webp) | +| **上游状态:区分上游故障与本服务问题** | **API Key:客户端接入说明与配额** | +| ![上游状态](assets/screenshots/upstream.webp) | ![API Key](assets/screenshots/apikeys.webp) | +| **用户管理:三角色与一次性激活** | **审计日志:登录成败都留痕** | +| ![用户管理](assets/screenshots/users.webp) | ![审计日志](assets/screenshots/audit.webp) | +| **服务端日志:环形缓冲 + 脱敏** | **运维告警:四类风险周期评估** | +| ![服务端日志](assets/screenshots/logs.webp) | ![运维告警](assets/screenshots/alerts.webp) | + +**任务与配置:60+ 项可热更,DB 覆盖值优先于 `.env`** + +![任务与配置](assets/screenshots/settings.webp) + ## 快速开始 前置:Python 3.12+、[uv](https://docs.astral.sh/uv/)、Node.js 24+ 与 pnpm 10+(仅构建前端需要)。 diff --git a/assets/screenshots/alerts.webp b/assets/screenshots/alerts.webp new file mode 100644 index 0000000000000000000000000000000000000000..a80077abae77b49ee7f8298fd216cf47ba1c401e GIT binary patch literal 51226 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≤igmcS{5ULil@~0|BMpA=<4sTUthvs&KAD zmF}=snwE;b%nB>4R3MhQMAspRHA|Hgg}ARrtK0Q9pFcj)BhAVtGWEk@R502~E>lEZ zQqF&$_W6zg@{wZMVEboPlTbV|u%9=nf3K)jACf?O9Mbi3lDUGQpjB1+76ttRG3yk` z8`_0XeRfAm&#OEX(9iz{78uYn*xUVMFc`o{E$+mZaRzl<;N)lCy|&E9QsbN`CzpjH z!PdEm?EK!w`}BxHsG9NR8R6Xj${Ljf6@m7l;#?LJYUeY4T{d43oBxnUqP*C9F^>0j zxAeF`F8xa{r`b~bv;wAw!iJdl&sl}qO54`J4?m`3};!f-J%A~<@%eJEn2I*y3KO|eZb4uG-CHlHAyIdwS;R$MX zGh6nBJlU!}XsZv_5B_b9Z5Bb-Y4k0CKKd&6a?jL0e>0QuUMaHgM==O@(lx973{ndC5|xzKTjUP)7dcoEWq^?Y zt1*+t;FDAbxJk;6P)_(#TBCiP>c?(irrcX^dO5tsLB9tW89U#+(}bpGLv#7bfwQZn zSG2M6xT*MI2R{B3TvL~z^EdTrzN=5X^27sn{)2Uk(XQ@R)k!IG?gCKuJoteOJZ&+6Xpkc^EStoMrbB4OQ~1jAPyYt+*LdlWKc8@3Nx za+ln&CR!W2(J@v%OA7h^y`xK^-p_G`F`L++QFf$#9i+(wE@H7MoW1|?cf3*HU3e1Q zp=xdT)|p;Q-mjl1!`L1gzg#walAp#^k0xQW)X2T6j$Lh#7tJ@pjsoI8mCbSYPKNj) zhc2z5GpC26_%@9dFr`p47^kddppGHHl9{v#AFRY!zn?M7zE>Zek^RhfIfZ(`=IjZC zzK8(e-OuI38Fz8vRS`MFlao^~no*jD1kUEC!542xw;kdJ+wjU8zY8Aqq{ zOUHsmten{Qu!2}(I`Pjq;)=H4)Nq;{r@fprRS9eq2ds-u9)8s zZ80eOjwKd`2r%4P`ERUGEryQ~#EW6dkn<+I*;9?o`BBkjk-#4e355e3nA8&;hiuGn z6*WtXP6J9F7!Rwo5tZ|cD=HY7pt4pw2JxsIC^zh+h;z-q`xk^@wM(!}kFeiWV~nb; ztBBSRW9Z6+MIn_7c6E97ZlVP{h{%TUjxhh=V+{YYG$oy=1r;Lmz*{(wi)KJ& z-{r${jq=k|I3`}2!Td~$5$ev1-0;gkM5j3OnJ-9HYY_p95G~djduxjtbh-+&fFD8I zj#3MS{zfm%LJ+o{rWqgak>Z{F7=)pvU#>4h)=6HRJ?UC8gD@OURlY=iI${%dG@#p` zlCQE}ZpFerq&>XpPvbys#XzGG9ZLd*r>KFbIJwwmT7pxMz7Q81N3)ceLw5_lc~CY+ zh&%zZjTDGVO{^-bTTVRp&#(M>72$Xc0)VEfYs^lUW5(;Z`#oEDt(+riY_=J{XZf)x zc75c*sj)|Li6IP9Yp;FwvnB3Jc(tpA$DL3xJ|RLQW8&c2YI`0Dj#}aVk2?0B>axee zO9LSWWC(QR>KBWpmX^*J{BGkE%?s7vEH`urWTi~48`tK7?WQXS65hVF066 z>b@h{cuiKg^_ya>Ihcr!0_L;4sLhdi5ToEG|VDL!{i$Yg4yL4UsD#Vi^tWB;2~&M@RPlT;M?w6Zg8quEq^+3 z8w&V7T6%WTImDDQYue?Sm!^xSt+mSA81>u*>X=DkVnyMU(PFH5n~ut-8TEv8Qi4on)$QdSA+j>uYx&gZ^s>$JaQL@04k$< zIuz`tig6HXiIB(p7{g$mOnCs&gGleErkCdk4m)-*kK~L^#^$>;Ob}V&XQN&+f=g;M znkGIxieceh&8s>GzI1F6hoXWa{KWB+-8SyhohA@D9cpYutUgE9c-I6i4e!1M?VIq@ zaHlL4_AOIYBlD8CMghaq!0bsvdmNk!lTR|XLLhE)qjZ=W!>W&9vVMgoWtZ|VjHM;a zTVwA)V-il}nf|81W79VXdzw<^BPF;^22y>^X}NON+;s4*R3<} zGs7?m<>}adowLLaqfF4(O3BW?RO()pltOQ`G-dC?3F z&zO=gCHx_ibRCW9SN!eUhUv^_RAcMWIdbXTCsmq@)%kh!!i1P3sI>WcSnt{0S8w9+onN@aRV@MzSLgxhOo@H|jy6*ysuDA5l;C_(t?gwpkN9j;b;k6emSSJll zduxu>^Wa4nidWUM&Lfd%Z?01mCV@7Wu&{Ysvl=GS7F}mtv5H+J)0JQ;14^LS0}sT# zUn-9jSjQJcmSv<#t=LVsmoSG!-8& z^XQR^G?V3K@v@vB>Zcw`Po15vW)bZtojY=Y!vbFI7_59{162c!s}K2>G&|TRT14dp zH+#gYa1UylbHz;ut{|@pt%zAR+&a6L&EZxRKQQC<-S|@)nX+NGXp&Br(+}tMWBaQm znf-eJSugp^@i391C`(y3D?5^xGvlmmc|N+(c4LC@mJ&g^wg9nUC(S*W!oEOt20u~=C0l`FPOb%Ha8 zP?^2f+4U##FCubStB=jW0lS{FZ)dDNuDxxfT*UE27D%OlHyF8hL^7|OhCm=;bqan; zAu570=6bJPV*IMJk`J%A4(N}0lFt!Xq5gC6V>jP%Tb;^~{2+wkmJZ)4Sf?RhcD;68 zU=xLt8VXH88jjfu1q~yO^8GL|)eb18ZQRhr2qbLON8;L?nGN5&++1gSKkzE|2^Kjehhh9m8>8svDx3bL0x zrEetr6*~Q;Cb+s7nFV{Y{7Vy{K| zR3L{MC%)tVBzF9$WKu}H4*!aT$%l0HD1RKse~d@S(QAV`%MjqIHs}FR4$>joOWxdNsxxq* zgX2J^ghAE|>L!U8oq0-ry#Yc|@g=|N9hnxCUba4mq+KZCdGwI9cdVdI&{PJHLbZO^ zdvjVmj9>Gdf|bU|M_4hHbiN+qT0b04iM@QLf)C&A{PtatBYBC22S{DdA^YKayRUTc zyxnk}65MrE5EyAijNBcGs*u^jcaW8e!)}&GbJ@kN7QsxRVHpH|0-s8XEG^&S0P zvYE6OrTigxuFw=FamMKMo*;^x07)KwQL3%jR5PsWq8>St{fan2J4+F01WI#LGN>2x zw%KrRf#yS?$J%<=Cx$OFx5{5~r>I;9X#(wZ%DXe}5o=98cIy%dPP-$(H1KAi?6bg~ z6Q&rM%f^-aD)n(MF*Qnb2f;}j_Sqeg;IN5G?8Z~hf_fTHGPKUX+N#*a=J%`d)0@{@ zWVs~9AR}%Mu?^zeGP`(q_LOiwa`4f!f`U5n%?&N@P6w8jS*B_*sTAh8e14H2HWjMz z;$CcJNIh2~Ud;yQwFO)fm~(=Pb;J1b7j+}$WK}^s)G~?>(Q9u!buQI2*f2_u#E3HH z=G41)+p)H-j4wyusSUgW1Yw@uXVPecuwF*>*pEC0bZ5$n+!kj}A#oJ=9z9+nV1Sv5 z*mNA@T?Z#Khps*+=Hv`U?#_!SRRL0w*JkJ33lWpJHz>DZ)cC@LagKq#K>fV;YvAJ2 z7T-AhX6C5*N(tR0jrQ?rd|$gt#~cT=;n`kk{L(6bYM4g62yizH`iON?EvGRZ1kKfO z2pGc&jzyNpj@k1(o{6)f3L|*0WbPcv%b#>_;P6`pN&cwUIf8I)#J|jARBe448$bg@ z>+Pl5{&)c?B@*aW>*lPC`&{kkfdJc%HdFmz(H^sFX?8&*lG0_#=}}Mc;?Cl%-Ai0|leb@Szn!d_F`1D*n1va#f&%LJbAFrlfResv>m^4ajXa z2_%L@Zm;i3q*_2rVV zWrqAf4LpN^Kl}z%^1xg6f=#Kw(I?Il&LioOZC_If&sDazWz14}niB<@F1Fm}X(`K; zhED3g?NCPo7hTn&0{uXmdW+K`Q7SGpEg|kBf4s#0$dw1cJT{Tg*4-V=q*uamO{Ysw zE7h%NeGexd!_*lJ@&17#o`PbC!MMUZV#l>0c{U6o z(1qyd>UI5Hm#l?&`G~(D38O}Fjkj-M=b+*P>t-{#c82;Oq+&zp38%&}t>k#><(z{U zL?>J>9AVFIsZUe+32?pDp~^!l*Wyiy7P7zNN|Vl;ayS-F%?a_&QP(vIUDMW)2lkbfwNtV;n%)UEFoqULLD}qhznMsbUxsw!f;Hclze%* zNp?rCPGL`RoDnuE5MttL&1!e>mFE}?Pl-XJ48P&TUYal+!>?u~n!Ik{SC3a7teAu) zyPh+G1Ma3`&#r|Q!c?j8zdu{c_75{vy%Li!a{ z7$ue$-u>1wEKx@P1bJm%!^g;m=HOo1*{ghAZ%7NZ=90V?0R=@*cAsngXoZ0c@e6>*4tahFjTr8EIt-?e`)^=BjsJt77?^O(I2z7!zfd=Yae4 zDc(-hP9UB4xRQZ(%`ZF{M!mjHKH6`Y1&($nhHtFcQM2t(nxD)NDlSDz&jN799Wm8v zgE)}}=4%U%bsD6qyCv8y1B=U|lA6XaS zHgv!xQ`w>}4m#Z16*7qFKbgHAY>_xAXCh^4-`mS7CxzFfKP{X%|IC*VoQ_VIBe(CP z{5l(QPm!&R-1Rpg%gEcTdt{cIy_@c$8$m^&&c*3|i%l$yrjE=<%RQkBr?K|1f{&(S z!f&joD+K2&Tq|%q29Pl2mtze{>3aDi>wrkM#b!?<+khP|oh^^B#W8g*#jF+*zgZCA zQNp+A`fiKIvv0@Bs?~YdlWEJr_!G!6Mt^8050g$hzid+5^BQ~>R&mwnmO4pUo{?je z;wP#o^Le1aB?3&x52(iA`t+ zZAev1aw!XPL^8VZqtJ{9dew7tbO{n=s|UrS%b^t&1{~9V&;lFpwXY^yK1S0aUW3Gd z0eO~lQ!(pt(|`LUOTOKu8$zW)-=3xg zsVCNe4XAqn@dOb1g}emSsZfeQLL*WV$AG2-^2J3z8SPt}Z#{bD>sCH2t<9B8N4*@| zkI1%!ML{wzMCY3 z_A`SSHUqOt?75a{@_Ssd36I78{=`CI?W!w%<2cA=Qv8NcHd%5qY~)^)*w$(m%Tu#5 zHZB>l-5Fg5!k_$**nAyabGK3%801P;HAI(z=PjD3%$C62OeT$>?&@M#X_vt9_7BIM zH#_N>?bh2-rqtmLYyv0O>`1;DiqZd(VQR#+}5&<4@+T}Vob)LvgFm9(fvcZ zQ1k3PGvYqIU7h)b*Uv3Vl`-&UsBH06pE*7}GuYG}%_{{>2@v`3i6G&e1i4Rm$eb$Z zsd860##%Z&l4qm_Plzq66=o*4aIz=F7&{LkYqhg6w)=yTyK8OqWMbn(A($nv3mpk- zUR)rrla?KF5d05N$)x!Va4!KO1IuehiTWW2Q(45VLxm7q){K)|cvzT9XrLMyQyp|J zkP7^HD7jc{;I=+zq)I#x)WO*kremqBm*9DKEg!KDBG^&g9;kyb#c^)?f4rKxCpHyb z(Joo1sQvMwRa4pIXimbtwWFL*Pj+Mbn7K(y-2bo;tPJ#_|4z@A7nxBav8fm5Y^${{ z8+}E=OMKi~X$*B?Ma#P|eFb>06*h;ZA626puz8SVh)sl_ZdNa2!kF(~2xKu|Ns|}v zrRiFCq@0>B>O0=D(d)Z}8{&MLOj${9JW=?Z;l|UsQkHvJgyS5EA{l zk3ZK>_~ZkCjwHMe>K-JZnpCW2I3>CL$^w(1>wt1rz9&{pia1s8xsIGqZ!p_$nxWXt zg=%2~n^@va#I4T>+awo`1{-9nT3d#+tmE|FU1G9cKaNKteA(6>ctIVTYI;4q|G6D< zn)*@`Ho(B8%_ggE1$VQymoq4QZ)T8>|2<_YQ4}|guRm4!2u-tPC4*tkx8?B zGPTQqDK~F|qGa%|KWkdDdRdMJVM7Fmr!&Ow5iHMd8B(TZdT*BKVVu_&mw7bwp0zdW zO(aNu3JDkYTs7gPYL59c(nJM@FWd7mp&K>Why9ksQf=On=26`>g`3zQ17tS4uZS;_ODFve7$uf|dMu4`5!Z{0~U{F?XvlnWe|>~f=` zdEY9JXXisrpLvC%9*c7ZCM2F5xZltwhf^LN?{eRbz&^p-Eg{Cmk~zqR0?}HJ0q#=H z{e}^a_$CNlv1eXRp8OHuw3c7&tT*@x*pe8R8^pYDBx<2KzV2rYS65{z^!W%_o0^G8 z#tPhoSlAp@0+15K*6mn!F8U*QW5WFa5HQDa3ybrrW4%O9rkvF#x+vXK%o%cz$#^Ee zRs2&gpb(x!=Fv6Ups1i;Yc|0f^sSP?gcW^~mWDp2Agx#yXnA69KoYBlza2$%SVcw# zk`=QC-)+SM1h@(?K*6SxG40EB49%=GR=6>wJjBQ>UK9*>e4-t1> zW73S0j(Wf_YtbjTOUN09!9a+Ax5m46|)~$f4&O42F`t(#voG zb8^j5oWo=txKv|&lw(OG1!T5J2*LMN#CpZX>%huD6I6H)-UB!M##a=5xFS|H)>fa! z;A?k&$+Jpgesrfw_8(JjJa#bireefG$zw!~?%u6)V~|k}A1#fbOD%0P1=Foai2C{D z7~(If`ZcMndN3!p=m$gK9R0(-*B3P#FLc+Cogyp-wK{=l+3I?f_EZ&GIg3T(SPHRD9E1dL$rkvKqElxMbJ zZTEMPDY@pyn_Yb6*QXIw-JbtN%Q-X&!^LQ{ZQHhO+qP}nwr$(C z`L=D_w!7z><@|uTRaxDAQc0cX7&2t5yE{(fkQqgPR{nwfv?*uW!8ElVsDzw1SXeNh z8SBvNpns)U<$Qvk{V1aAEzvrjiZ*TYVNX&;q>fpPy0BVgieE9OxQlDI=T9P5^hPyi zwq_1C1JJ>#WhV)_!9y$gbI?Tx5^B%tePRx96x=?+nR-uwp6lovo9MUNiR6z(z0YQI zA`2Ho(fS8y)p8M^L2>ck3=O&wCERpsr5tR9<`KTp6oU!5rYf-}RoY0{ZW~$g?s@By z^qksLOX5R^tJfP<_kD=>!G>aKx%MZew8V|F#eL`EOfnH?fJ8EG5z!%*n5-(*G zGT0ax*rCDlnvRppI>`J+G9qlC+Yq~2rC<2YRW;pG@)XE84Cl9NUj_3uKAh<(&lYIq zy=u4j&w;6;{?u37&=o1o+qSxY(}#B)20)7|*+nyea?=y+&b}bg^A{^5@qkJ8{fCYb ziz$9=hh#058M_gYBtM4ii#pCXQHXb%VmMx`Z_|Gihg2xCL`_D*$z#WI;(|#5fM}dd=1-#(4=NJnJv% zez75|ym>xg59UIR$>Asu>_oX230B<8UQ9vL)jUe$VA?n~knkPFRFMpVa*N{8ibtzd zqkSH89bjFp!(5JdtAyin6z*V=mW&34O*dS#%E1f*Eq6+$h+ds z3=)1S`udfVAoBqkFDdX_Ze1M3h^lE%sq6>i+x1c*{BMu_KTmZU-mqy)0tOZm-{$|X z07*(t79fLekxsz9g8d?uP5j^RDT8PQmV%&(`#pJ?_fkpZ4qgYSR^2|AIDX!`*8UVk zjG>#Z$whq%2UECX?nsYt38I&6B~-8k~T zI1_2)#Eu_MU-|?iMwG!bFbaW56dczzd7jB#Lf(Yt?f)rrd6;e-%NUjLR>(3D7+wfK zBmL?WvWpu|j_0Z@iM(k*5t+CDrFXMG2}Z?QibKvXiT>(1JlE{HHK|S^fm))ugC=VC zujpl+Vf@8L^v2`PPN9KN;YSu=w!|rrP1hVr{xS1?rSHu_+jIJ(2ldTkx38<}LGwf& zU2H{8=cSx5>M1lHFqn{(cA@!9?RrFB@3lYzN(y}b8ipx_iq=-y5%P^Uz7ck=+7Y~J z1_JBP`WIxNorX3;NIa&Qr;P{2LPgXwkj9dk=4eYuf2~u(ahe{)rkt{t<~@zJsT3H` z9hc;L z2+^PVAs1ewD&|_M>WSr<+IKV1z9v_zyv;r6T4v#t#N!G}y0mkLbB!q_2;HM2ZOUEF zs8m^1U^IJq3k!D3tgMi?X>-)jrd&)YHK-W19(;P2b>jc;+3iMYvVH4yTady$Q{Qj zYDFWk|25(f5F_uXb^xsdM-USigTjGOkI8q4nC)H80GOMBNG=~U{I0MR-moMStK^Cs z=yO)sE{EfP?(k;LLH$8Ni9(R3cw?ici)0*AA(s-*3>u-9P0)2_mO6$-Sv!NI(%@h? zs3U>? zZIIsE2AAk)x`9Q&a6j{`!a#k`xXRvAVj96nF%F~FN2v_5aP%zVxQEPY)Ov-tRdOgoFUR9k_Y*3J| zL`lyde&Yrx4Z^4tUoAV@3M>g`iX3uPZm#$`c1;mvdvs_UT_JnpiByXG8-EA0J*Tcl zB~V?`*oj@PJN9n}SNY5hP8Bh@Uko{$WFWjnH`eQ-O->QyUr^(ulo;$SP{Nl*U~Aph zB%2!R6Z>fU)db-frzee5f-lem_pUg{ukea8>nQoO-;?+M*z?Dws}$cV5lwUd8{rN1)nNe5?-fM@f}-PER*%w zLQJu+L^uP^cIP5}Q=nKjo1Ae<)=qxh!k_BPi3`MO&rxqrc7IbVVf9Olj&g|Qy(6#- zLZSOxKf`|8X0HY}NVH~fE+`c)r<^N*e$7Cfx!-BuAYt{kZ^es2<*&eYi>R{>vNhksW&u(b~f$}1!q zqi6v%C}l4)ucA`xg{2V@8lk*d$snw!ST1->zEs&GA6Pxr?7nIzbq#1gwkYYxyn1gf zWQkwn9TH`{!N#OSuC{<0K(`O#cQ|9b2fWv_euu+Gj~}1&z^x+>#k>` zW#m6|6FvWe6L-hvQbD(y0|y4BM?WQckAQBPzc>I4q-)a?q4lraL||nn8dZb zJ!P(FvBj-yOX^pez)YZEwwNgEVP>x=;p7lh!%j~BCr%hqw;yt~d-H>yJc+4qU{VYf zRmzs#QyLZ8uav4Pe_BIIF{|TKqKgd(F|%wmHHy0DjvzSGgmrAd!5!z z6i?#eYqhN4aF^P9LOz42!LEUz=OxwBoGR+CSI2jHSC}^1ZaW3)QN1bDz6xUo#Q;qCxC}d>Mj$j^R-eQx zp7#k;(M-3pado;Q6lgV*$KKq_#h1jm2XY`E`uV_S744qVV3!nBJvpzpVe4C-Fl|9N zv&{_V2fE$_v7oYQn_UmU6f0{BC>-&7!z(N+f>lSLv0law-&RmnFdQcy9NvgXZqi}M z_y)h|Gu1!=WZ05F3>N>I%7z=JtOdC|Cbtj)uiDnFT`H+?VWDNyg#L6zBHU%ymA;?h zf@=^qq3{vI`dJu$aN7BVPzlN@+niFSgLMh24Os(U`UQDSk?gAi@;Gr{0cZQh6iHrp zCWQop7j0r4MZ<4DcflszqqY5@_E{%4DmW3qj~&NklKPjIz%BXGB;`8?4+9~t@rYAi zI+WWf{AqvsQXX$xvKgC`&t`eBou|gymp_`!BX9DflHtnY+QhkRl%rs)Aam?LC^s~D zN5u^tM3Jf1a167GBYeaj#8cws8?btdn}a_5 z^^@z`Uhs_x1_n0iDBFO4ME3gOpNy4Za_*DrI4I2UM)@X-7kSf#0x8u{s9lf&fLUhp zWOS^8S26hqa8tcl(j%!NhqX)e0YE@4s>;16x>0{LNnkaX^@WImOU_4zRiA7sJ*%2L zc^o2n&3NYcCz(t71`62!j9N`kv`{GEWU{%^u0gSSozl<~r3eSQS8bH*R^9n2`XbUe*4YC9&yhj@v zGH-Tm{Hm;r({=r>ZS}AB-9d!V%yfk2dN}y9REv&N_G6(Y)6QGF37ZTV^Y!atszCu= z`@J)%AteF)kAEax^r`J0vCp0f^sfTn=}9quZb6_<9`#mjsUM5?!<>wNK^Qw%%*({D z^Rax#S>-I@FN<|PGDjp$+0G4M*rC*)E2%$!&pm^ zsU4NskzI=B5)`&RZ%X8!^*^p3bOis{!cf?}t{bG77Wi*Yd%-%>vRWC>q%GPm^5G79 zQSx>zksW3xi|j%V7Zj((!!^CT_ns-0s*JzUUKg%RwWZ^$W$pR{naVvOi-pz%X_{xh zP!Z?5p!1LM2(B{KIBG0XoGJ%Ccez~jkHV_VN73Y6Bcae#e5{a)Q3@{o1of%zt(@|` z2T#Q}IC?2Y;C1!6hOS0jXEIGZ?qIqA7Y< z=-GApjmpvYINz0<><|0cA~iN2X4v^gjf476$`gVD2`YE#KG>8HB#NSMBdR%CN_9WG z-;reyvw6wGcD_t=^E>Sk$U98|Mn>sa8cY~)$?u5w*$qgs=nTO{2gZt<(hNMjJ%(W| z4+#xyJy5#tAPv@sE6UzU zAHlhb`IZX?Hl~o)8vs{Yr~36hE6ke?rWaKEK(9vYSz8Li;XFjuu|3oq7<&&mSfEV6 zh~3?BbYk2@($^Px5r3|Yz<+oFND%SLRBPCL0wUSL;&-9~?ad2h(PB0wC=-09wmRt2 zt^4_q6A=gpzTH^bx14yDj`3^CbuZZKDtRc^e?jkrANPg)T93Ia_$2Ck3DYC=_voO~ ziBK-_L&puk$2v~|5j_#UsZHDt2h}w&cOYf6@ z`QtY(P(x?m-5r14t~FUS`gk@9dUs$CH#DVPOb+JKKfB_OD}#M_Rq^l6^R03}!k>1Y zNsj_R!Trs-fe0TB=%T|g@@3wSj*^CN(|;LEFR=1lGt|`5WB{ZZVqjcD>UP&3x=XAu z*E$6DLA44O$;4%#tDEg&r{C7F3B!sK+pPIrl6kPeeqRCY1H*J7ynar{*Q9Zb7^t_X z6MzF=g&^$}Z^70}0a*EItJGjPX-kQ{h~215N7sp^yBLJRcC_)Zo?>zJ3yWfX4{c7MKlwapn@g>u;wC==1Ku%$%n?s3~- z52#!eCj}xZ{FPYpkRwTs>2S3o_(vv8|H_BFroJz-0KwS(hsuN4YWR%xO2C@ejaHCV zLbiiXv2NG0EQzzXEI;7x*`B_#<3{=xYCWvqgbk%dW`qi3LRGISsYY{&ET3-p zanhB=gUh6~SnJVhLd`!-ydD7CZj(<>*VevV8oRS^t0f_hP4z|)<2uwMyKpvV8C}z& zzysj+_j}sqlND{|F<}W?OK3*C@=gUgjH?D66%UkVYq8;)uu6!E>6;vbnx4NK`E;jl zZ55qd5wwpk79PZz3+*ZY1lUFX2E)_LdR#Z=UsF=yngy673@I%$o`deN8ur$epDRrc zng{J&Vm?%{i-#nLjpAy_gu3qA%X)B=vtG~2)H_(5A!2PMi&R)-IUs~+F$w6DANw6w z#u_o)YyfHK9(M$6f!@Ekq#&+Y%|cay`0KLdOKQ8ce{pO`1UV`OcD5jcE({0|L)FD6@(%ka|Qmpzy@58gH9jo$qV(`OkW5tWZP2kdmZ;4 zozlC#gEvW?c?LIWP-D(0t+c8J=S_bCT2i#%THwa?zn#s}9_xyl-jD!3CU6c_eY87N z*_Zst`;R6q0GhTZxB`G|hVtY)&k zCxqg62~-VC_-R71I)V}JY6jHNJqs6VpkHEdozE|!FhF!flrsC+V?)@@x#@*HEm}J0 z;{Td--~adh-9Qzc+sBn%afbr$*M^oH49FtjASr!LB%uW`~1$9>$ZR#XtF!51zyyW#8nM>Yw3HgtCoOo2D z;<2>8aK1`{KN$#7ov>qxr)WG6)w|R-Rb;R=K_{SU%^p{p zrpm)0=gcKF5VV2aiUtCZBo&O80~b$bPQ7;+0^T5g6jf9NvNem3X7uL|xivmFxA*if z5fzk-;LL`TBs(p9q-YcTCGKM=^tCHbUbu+6+h=Tx1gCM(<2wlUzWc`~+yjzk{?`gh z0_~2=Ij-H7Kobz!ITn%^V$1EoAM;OyI}#sYMS+rWVLojkc_R^E9$h_Lz1Mu0{VLKr z&f4{FQ^aM%$jWY?V}^Inj=i82!6ZlcM$#ukKz?HY=%&VuI!BvMCvcD)X;;x30U!FiZk3zlRb1`e|%uM0WcMV*s7x$j*tcJ3uGL z!G>}X=2JWUQBHqpRlrT-Z=_e0I^y6z9wpN>&1DVCyE~Sx(4A8Xl6dVIBI7hqgMOz`Y&8$RywFI@b0Y# z?q^D`nHvU>TjVbeZ<6AyC?8~j_ zw&ui!MUU%_pMfTotuOqBB6mf&Jc?Jt>_^+9#MkJ_*?%97-ybaJf@nmgGOSeQeFxiZ z@MupsHpf)5boHHZOKOqfOi|D~YE94FpPE%bd=ABLSEr7j#iMdH5PPfc=g{ARnHdX)r+dy0yM{l{(`_v|{E2Txg=glp;TBu6Q zi|#pCvt2%AA#kFLi-gPul}Um6D%HGLv!O4G6kEuiY<&S+(BS{z_xl5k4Q z*<#QcKJ@HyS!4e1q=1k8>i7LX>ZnDPR!7X-`hw|)50`|+E7huFKk~0($#X9F_#1{- zU$OU!__S)F=F7N|ES^1XXKpp`uHWtpTd$hH_tt4ZAgGyKq=s93iu_^SD@>feOQ9-U z3a?(2rAV6fiM(DUeAvdWnkQ^vTJJm%!BUfJxxyC7>>~*GpBMZ96l9Jg? zw-pGw^2$;2?X*h6=C<)C+xx!5RjTrEKDXVWDvRklP&fvBXw%i;+z@peu8=$>Jr^ z7NK?vB=@-2=rGCd2*=9S4{~B^41!Rt&2sb9RRlh&%>HtbsH~9=E}yNzhHGK~z&0Y~ zc0a0U9924%r~pdUe1_+OTUBE)ID6TwFnOQAGr4Z{jM!wd14c8gC{>>@NqN3UU5|}} zf&GU{S#iq%Yy~g`k&cI&+xMTGaerx*1(6#*G&DLicpk~k;C*P@>~vr_KoWKTnOWes ztzvs|mYiQ#G_z$BIuZKQr52g9g4lbu282_Hdd1#MeO|*+K+@?qH@Uj9x5j<7Uc=f^k0tl=N)!#haDEQU)SnSJY&B?+~YOj>=ZW};}VgF$_2 z(+2w8zvG!c`N!t_p6~t5(!Av05H{PFM!t11B!#o%vVx0xV0jMXWIZ?Vyy}VT8wi`8 z)mwsHEk*^eRxa;R+0RL~kv{MKltT-2%SlYVpU^tvYcjj;-bu7dnqOK+OaZ?ata=9T zeipX^>?>Lf$py`UG5=zXt;3xHj}n~j?loXh4Z)=~C8h`@;M0>5EJ;m^&DHE+lJHnZ zzFKRl{1;fMgq?}@>Y46(3+eiUCzh0pIll9iF3eyXCsX3|>$_po#*XF+rd^d-g4mgJ zY)EBjif41G8*(`Q&TlrGXm(QVr^-U-_GY~*oLy@`g}b5~{HF=gQ8;`)NvxHL+#?%I zwej{{PJRe^J|)+8L>or`$h&G1Gs+ktlH-Q=kmj@@2hs)B&&fe4BTuMQJ=} zy z2;-FLc@d_F$5;73bOT7js_{RnVH@}vRlRg=L%kVI;y?~>^Nq9>yLZbs=KZMz>7G3ej-g@3>k8qbyX-Xmtgh+z945H8NJz`jAp zF$>_8biF^5R*2N1Nk&G*q-*q@|!f6jy=fN#OgwrPk?E ztTaW}t&bm>MS`WOCtO(?v;jd0)YUNs@?n6=On2xNH0!ptk=AX2+9S*VY}vr z;Jh$yu;I~4bkOL#ZX_Q;GEU^!1 zJb@=f__By+r94Es%=Uw0SB9&LXugF5>o}TS*kLStv`krsaxY;Km8tT*;6RsJS zAvV5qq~Y#DTxq2cHP8Cw|5@=@>~Mc@=XC6f!B4tX2H6FJMPKWHwjirW1ySHsjeu7i zA#a^3=tdS8QN8gx&0N)Gsi?IpAGi2_WZQWz>MTF8>fb8X5T9057VU4?rO@^#3Sj0_ z;-HXNQ?wCOO*@GI&VJ-xi&f$4KKG)&1bD{1Bd;+Bnok3LtWB$DP@3<5^^#=)S5r;* z($wi?esgvR;Gd~4gsW&;VrUk4SVYh{~AK==54JkX6?l7c2|~G6_C4) ztC(K^ucX)mXxI5&9^g?w2C71H0?cYZe^3}tj;t!;C%#cJOoB3&uy9WR%s)sn_14-! z5wRBXO=ilO$i58oa$8*LNSq-81>KK>u5`<=@hF7MNR(x^rd7_V1z5hL3bj~F;)v`p z%pC~-vECiFPIq}_#^!P2ZQLxi$Sm$b6D{hHuCjw9Qvw&vfJ@t+Id@>pv?MW7B?gs! zQ5O~Ew0|hZ4E&0K4x&9=+D*O^U5Ns^T5tm>!LH;VGPVZk6mRUF(x085toT6Q4|J-I zU-{7l9Q~Yohuqr#zDY^cies(0&bUAuy#|c9akYu-u!A^JBE4+?d@08ii=#39^u4`` z#Y}^yz&O6rfS9qQl10oo znVA#Cg1m!unNp5=cs$=dF&7MewHbQJfrP1Ta$AO9n1L=#LopVDeTUV(vAcetaqGZU zEfa9(10hi&r^-{QqKAIi?uBhp^*|NMK6f>Hn8J074hHm=+b9EWV0JUHdjfm$1(g8i zo1hyRWtocBV}Ugip*t~nG?o#t&w+*EQTT`G2drSK{SXb(X`i*&`S>V<;PpFSLjil< zbGYci{UhBT-{f$c1ZZ(5LkGUe8}TInLF@Z*KU8;h!+FRJ;G*Kq7nRcNN!;x=LDiy7 zc-Qyn#i3%tfj`OjF#DXiS1mRv@yWV7N4}@bgXGo0c$Pz%gNo@$o?a}sX8zEOfu%0P zhxZt~gl^D;lYMzz_?+`ob*M{=NoY$a9W+gR`&9_$WN(RfM=y>j36WMXW2Paf12E2; zK*%hRg@62a(M9Zhhi!8WFSj>_Ox|-A@lMWIK{I?Dn^^ksdbki~gBRJKSjL_kL49_b zi^rEi(P|Sa1U1U2LktmS6i)M{v>1|i9D+wF`d0tI<2Am<(j}aRbwKt5CUd+4?}`}i z!dn8hiHrkhs$kO<``!w}m$F!kVV#mlu@_BUocknw#srU>)&;+i0@IV@0t+ov9>r!{ zPzg60Ah&wxKEKGm+|4-*4<`&D%CU!Pl*A_wfyU7(bfkNY74D5~AX>aW!7-VWPT+6J zMFoD)Dq4;%rDJDMV&Du|m|fO$NzUHGJ38;4vdW1oiV3j_lXF2sg{U5zG7s@ZR)r*; zT`lpz&}LtjVU?CAs>MK?5Q$mtSnFS>BcqlQRnIQm{_bxBVd84>=%s`4=eb8TMMu)~ zt{VzL+l0rL%jbPi`O)9+=53foQE+>n05d0QpD4>MV#Dcdn-2Zk;S`*Z@hFDHoy7GY{WGiz{RqW)H;XbxfGVz~$2rI){-3AVH#G$MgvCfYaO98_W zuWk4pCk6ON=k>){5CZ2e1NcAFu6S1=dQt7%J<1P?{vgYAb!0Ab7oqUpihD7XYKFcB zJP`<`12e}OH1UShz>tF}GQ(!7)!wW-+q&u}xMS%_f7`YOwQ>YmhjpHu?LfO&}Y z*!CDRs(Ymfa`t zTX=t?>^{n(V<23N{kl%mF&l&xzw}0KCO7XMty{7rYg@`ad%`AP2MPz0zxnihtLHMNXsVCp zLX=b9LkgWM?e#yoo`<8g6vk=Ic_a z=F00$uX>%It2 z0dxi z!vAgHs3s1G(aVf{MQ5|zhK@u>p{k*(WMIk5_4Y?Cm@Khoc?|$=l!P8eic~)*iLWzf zR8X@|*|R{5AZ&sx%*@m3KOzubtkf*hBlm=XP2?gPF=7W;4{K?ZoodbA6ONOFA0t9$ z0|z+o@ue*)1B&}kmUJj4ss~B|yaTd&N?mz1 zRqF2uu?E;eKzFFz+6*+2mLYtJ3<#MD^_iP&h z-jVag0Z=;Y`zrwWa`Ezqu;T2Lkms8;atQ$qY6#T2;B4#;0~}d{TZz4C%n4xp*K&6h zTvBpH=9d`j1t+w8Cznl4mSp(do%*Iup&|yc4(hE~OH*q|9WNPU^@NZrk3!!i=uhvi z?|`sTsdNs9q7S3RF!!H+{;}bqi`s37%F08#Hd$#WB#!;yjHP1P%J&i4w}?&@1JrVt z_rmU+vSeT_l-cZg7y3z~-`WQ~851QHp7n^$tjiP1g zjs|Kt?HJVU!#4gqPuqkHjl1?wtQ%6DR0RPSd;CrXMdt!_Xy{ zi2HHK9%uX)76*t|jP67%Bq<##lV=L&Xl*mQb0$I~c0bmP){5^U=*eItUdlYR=liec zInV82BKRUhp@*ZJW5fra8M-IUP8$xeua)W=jc1354pi9~jw@nK^lJe$S9n=2dh+PA zq{LK7_?q(PIV~9zr)Fdm!3{`PHZ5U@ww#*teT(p?f-LDL}ri1=C$`_|4NDd-x6$A}wj{=l6Xv)ryv_I-kSxc5nCsLyz-K2)Mgak-Rm z$fH04cBM(4fPwU+eDNEF5<&aqL4_#{mRSPdA6MOKB+`2(pnRcdlOfu2O23O9E4$jp z<0cIk6_hGSVaA+6uDfu)XethQ4ROW{CPA9;u0##qRA_3``xR3Ogu+iUkn_>R218-} z!#Zm&GLu0F5i6=h!aG$`$G+KS$>ZlQ(4hA%SSCLhyY`b@`9g5Yq(~dxzvybgQomBT}7NLJUX90>`Q_(OtaFpiUcBCh9{k=Ts<+n+?&P z!HFa`&|i-cg4;poKc3)NnA2xl*ecmj@)?Wi$}|?|ijSQL?Inv2f=;asfnX2o7jEYj z{-`DM@7a6W{13lKdOnFaCEPK_`&H75K#x8lKlK;)znlNRiUgFTDPdwP@%R& zOFxWG`g~z3o;j1}+J{CqXxsR*aU52Ykrlelis%i>R^!OtkaEz)GZ3S+*!}D(jaSBh z^sOQYg@r<~fE5wQW<`*dT2j7xtDIN^Z%w)QIai=Df;(pNWWUJpb7bE9iSB}Z-AvXW6!cqN8N=~wpy-*(vwr0doC z*eQ$_%pgY?RIw5wz}~wlLF4^_ftJHp0cYqH&>nOHYojTcf*4auuU$nzqc6rZc|mh| zJtPE_N;IoH(o@uY^~-%F5$mc1_i)w1vcSN&eSR!|xEk*m>FZV~N;XG7k+p~*x44In zT>%<|%-RM0% z5^JJV6{mPQV2sdA9J(L>;La^@MA8Eo%6#6T2ym8Y`Yio*ye8*`_k=gm^cux$4MTdz zC3z-_NjrcQ;e{+vn#y1_a==-#AfPzlKRT-oZS;~z>W^BaLdAo@Qlx|nJGi}x6d8@t zA!i2=A8Mn_b>+v#J6ZT`zyJit^$m`E>mU*6LF_V@to#{D>TKtPljUD=D;~U;i?WfA!VlWkRMhUOlc#p~j2Q(`bvG&#|d^0|)ZlC$7+jB2tloBe>`rF|uM)7EW; zgT^HA^h^}LsaJdE66|9{rA7?0Wz7LBA<0GdGwTc*b}Arkud}nm*M}9@kVB8OK&rD^ zTjE+099E^JgrqAG&k+lSLaT;Z!WB-gj77pVnOJp@z8t(*VIq%b(_x+RdvM~S^Xpp( z?MBp|BP!BB4Clr^>zP2l-ukSkGAR=+4g8%v8B9YV(NcxI$3S`LOxMK#V}{YmD`3MA zs|N^|8DB|MK&J%y{2vOkBhn;*UfDDLk^#&AL@f*eN|cQT@)nMR4ied0;KUhZz2~o^>Dyw7Y=TwBOJ9YA;Xsufx5i}DEZ?E&G68D`EVXu0|csT7lnb5T)`MR}75 zVx@zQfYRQB4i5ZkHpA-}zojm3E15c2Qz_i)>>Mo|M@_9pXUFtf4nP=;)f{;e(pt`F z@ZB}yMJIE~*On$C&P}Brttp322|R|5q=~-hD3Es$i?`g6knBj>gI&1bmD(kRY#=DN zxAbSW%^_sCAPg8x{1RZ30d;-{6TBD3m$K-B)3&3p$1b^5&--h zx29>L+XWM>q_PUqs%{mD9p~mBN17X|X0WSeVVhl+6|1hdp`L)Mf_7zo@GtrK#O2J} zYKEGtifTXpiA+(l-gCTXdJwLq5~=pf*sbjSi#JDO9&hJLTL8lFT-9ynrzvBueAv4H zfqauKLJ;i~^-H(EIF1q3uksIKdQys5{sn7}pHHvV(!c3-ybI^dPF#ifz{cLJAr!Z; zJEAcsm(5Ekf2O7XhYSZ6FX8GAY^0sm2dv%9=%ffcBhBU!=+9JCEA$v&L4!izVK6b9 zC4Jgz6}OZ$#A$TiN@U3j3NPA7mo!4OOWsGOtuWfGC~6PNqU=7yhs>99D{Ad=9C$ON zrsihIs<_5G46ZASuj?%T*tTK=8g7nv&w(rSYBp7xED+;_C4$sRNZE%^ux;SxFyQJeaXpSr12C6hPDVa$!1QURVj7pf$L+uf|g?hp04 zdsuPwfUZ9JAmHn88pBJvzj2ant;@BvfbWb>1DZJtLW39Z`hpbjcIdR#zMo)em_2d1 zdSBY>7D8hfSwb$6g;;b+ld=yW`K7+?= z#c=hQPeY{|$1_x{+|!vJh?cMipE+-dXRUD%%&(WSO3IMMLuAjtQ*R zRBI>c*X(01pbueJHoo>|vr~Rg6c+yS<$;ub0cYvCFBDt~&InrOfM{x@-|n5z39M%N z!LEzbu!@BIFe0cby#4{{tJ#^fUCE_ELLC`KCCO*#GsvqYvr#t_0)7nU;0nBlxs;0J zjju69KM@qE^~+=Y2jG-vQz9pKh0t-4EXnIhc0X2$GGILrh4w#n_1a+I+&-A0H^(zo zK`=xbrEiM!?G5QR@ir6oZXTBu+Va^FJeK2WjxGj(dQG{hXI|pd+SJ{r zX8k}~vcB!LT+HP&lKgAsS!iRmEUw0UP~7k~egx}{pfk;wpo#FoUvMS)Y}<_o=k?q! z(jf%jIFeuNWwAG3Y{>EkxWom6?7pPkWev5Z^>Rde#C|Ud*6wI@wCwt57H0!9Dka{n zqeO>RGMGu(@wwkKy#kTJ4<38+{?28veZf`RE{3BEZ$c&_R5kf4okpgxE<+02zr&BQ zxSzW!due_ZCav44ozOhLtnnwC zh3TwHJfvMx%cs8Se`t9*g3K-+*Lx%Ap%!D)*8;P76gp`;Zw&}@nXql44Z#3+@g6ar zNodScyhM0e@0Gx%sqtfAibuhc?K&|TcI6;v{F*)8cgL%0wR+K+p3N&u3r*X`?L0df+IkFKSXYi(f%d>&OB=LRzuCRiKoa zd`yGwDApxd*h5-)Mai9gP8)}i*?XYik!N*Z?_RYliv%tjU%lgZk-;x_I5nOlG0kQ5QkvB z5sGW>3<<>?%uUNes|jaBbNhyAMR%A}w%o1P%V5c`471@SPRZ2Qs! zs!7~cEf2R=0bK>j-zFEI0TS+_w~(3!aGO0#Wl|&}%_hVxnuaC4oCQ?Yw1HN#Y#gY{ zkZ5`7t$*27k)FL_ocb?%kFs*e|utRbb49avSGagfu#9uFLamp-u$z=KIS#??JxsHu|Ie^dD()-wvX)&)!V z8jT&9AaoKN1eZuyGvRr$zoJIRHj$lG^N8M!okjLqKP*OnIx+#(TMSj@J7D;CMT^Z| zLKiJmSS(@LYX+>UnnK}R_RWfxBw?-!mLAmc7)_3dA}dxg{nDst7nli{uGtE(A4+}6 z@rCToxh&99K+jFcTD$VVwa9q1GJuER`$Rn0o#W4078tNh%D^AgbbaH~?UjNinf1{B zD}HqfWzKpG;AuP;*-?n%#%;jdtzQ`_AukEY*e|V0wr%KZ0t*={u{xM;99?)Erw1}0ld=EvEt1AoxKh}xzTo4nzx)cm!)=HkTkd4!^jADOoOPZn&pG?XwEgyg zGdu@k^&pwCh+Sr1oSm^-S z;2h3}yPzzP2nZ|UnFf6QbzB!C^Yw|FG1k40@Pk205NK<A$CP7b|n%1K3FOq}$XO zKwwET%fugEpCFpx_GFV=%evz<{aMX#^I+sSQ{)G)s>yUG%~mTs1Kg!YfudJh9MP8%f- zY-A)4cy3M+irbid;wR0{~`Pao^X67~~8U)J~j+CKdiDGkK>MqiiznxIc(HtZ0TLDZw8Z>k?c zMB{u<^PppBSY7Wqr#-l1v@mf%_dv|Ly}49)(xb<8l|{#`f;_LC6x6m5Ks9q^-iOn` z{l4k$CST2WU}Q49DVhzoE5Yp~^GeOm_hMk5SNI9yn8$CWxZ^BE94v`pId(zz`0T=1 z&?$9#RORBrGV0tgT4tJL=iEhF`zaT*ZIoprkRToE4_7#_E?WvmQZW9?BU@6-?)M?{ zPRQrSPhLMpY{}hY`v2Q25=W0DLAEE3dtfSVu(%-BzzgrJ^>=TqEUllGP*sCpPL z(3lC7by#6?_Qy`(XFbE?XVB}8!eZ}RV;ve`G67rLdv|X#Jv9hcNydTr=)}QnFt{>K z`Q>kd6HaeXT0_lsu|>n4@bb&5zGQtMs7Z1V=|xM^DH8PExu{s%cg#=p~i)+J$dIDqg$KR(3<>^Ej> zUs+OshrSWVD)+pys-RNgDc`WNSjHpEX;A;b8_7)IEq7$X6BF9}xIr8ZaVaggGw$mB z9Uny^05qD>Km4(_`upRS`tS@(shyiMGNl=6F`-+@gY)#tyti#<5ZuT9Z_$#?pqj<0>z&OG38MK5TYWe#qtoxTol<6nGm5(?s!+- zgc?&mGWUf)b4_qq;-)>Pe_Nx^7|4PNeuv+qNNWr4psIO!gxG&8b!8k4|6pM^nm6@T z!PAyAmVNsj;&cebut55pbaV22Zw2KIDqj1cA%-|WM063x$cXHuteN|p*simd(DmV5 z>^`JZhH5p-V@Eramj*8H?UAwUKZW{nBk(Z)L9sLHYO**rj~z}ifqAP!CqhdZr@3cB zi^$8bk1XDUvxtl9rIAPXPW!!Y8G2_b*ScZOF2sib*6!5wZ7JK;=ZzUK$3uS4Z$U7g zRO)MB9JT|-pZ1iPd9{)Kvb4m}AdmQ&PciSK0bR$_8S0PZZqCaeXqib_E^n!<;YUPE zF)}|GjlYpggeAx8IOMT^EQ4@&~7_av^Aneo}O9sz7-^Gm8%p$`X=b!?t zw3lQx$aMR+J`Xh-pPTobI9IYPHzZ=Csdj+)^le9`C)8sD6cT{H9`s;&s~l zf7#2}_h)*;rl3|y-|PFIa94w2Ou_sQRIr<~fQdAmQ7iWK-MLsTnfOcE6p5S3cOLii zLeR~Z%r6sujUkq@+{4HIDwb6F&>_;(HUQKtKsyRI<6vi2G|vV%z{$Nv`T4V65ZH{D zkRX`})7dT14oAe?i2*z|?$kZYb;TD)rjJp^gqb;2YgQaj{8>I~>-o)TlDocdqjl`B zjSnW7s|<|b?4V+B>Z9GePa>@jl7!!?01BT6Osw;;($hjsLbLBPOFLiTpO&61wU>f) z#CMyPB1RwA?~8rJz&zqqX8ZNhLtz>NLliD%lKiUpj4LWP8>ii|NGndYQ7F`bxDQ<# z72-i8`#|vab%TG1)wT7-gr(^V4;R>6AsEHw9v-jzI4$E7QB!1!Mj%q7jSR<0?J_bT zU-@)+(To(-&30{?-@+7|3Ju?u5bvT*PGK}S0rwo_GMfbt$8UGW0XMVPf}_yK%|Stq zpgH*otSd&dy~3zzsB2xH!Y_j3&(-S|6wb#{k3G!drlpMPBGpQDi_7%1JHohDHb@K>QC_80L6`QT(nan1QyrNsnaAfajL5m`I%w@ z;?*CL2Ks%!`vUFz+g>)KF$$5lrzBH#s9*gv4ItVsEH%p|B9wUmo5_@ z8hft`I_NtH&=ks=sfQG5>a`;zlS`M6zZ^C&x{38sqn`m;Hd^XC2ES@#0Sq$#heVDA ztD&^LM5eFNDG;mw@ix#*dhLYbE@c!~D(A#DT?{%V5<}7WMlg5>;w7j^kbBp*v9NKu z1p&>tczGh~hDw96Ka~g?II0+oP?G z7W60~HvAdE7l$H7j(OOKNaAz``Ky=rmd}-w;`EVyGyq>0tMXFNzMt1x%E#OsLJ|#) z*jBrjyI&JUw>(K?Ps5+emwDLj%$_x*SH&hONw+HcjoCiQNPyxIah98aqfFC5$@~QjlF0Loip;Or#@-27BQV(Wx*y+p#2;C8!CzF98gvaI@nyoZg z42&(=`(2HJQs|}3>fYCs0lru$9UEVPy?z4#U-cnIrF(ru0Q)w846uj)YO0-1%;{at zns50A`9jjqa5PGR6Z|->cJMOhz)xrVAox2P!K>W*rj7It2SeWaSwSfTHRKlcn$cM> zwb(-O2f;^qlSFdYbqKw`6GWA?%;jqjB;URa(s3EW)@TBQ@`gG#IKO5+4-k(;`8)T3 zg{^Jji6Hc6nvJ%#DPOcuYF_d@P@=ekiQH8ek?}RL7?skljwJvU8(J5bq13#|yo4ir z5Frs+?eop%vAqXmU!aUq1jck|)rO_C8SEp!Z`S@~yl~284BM;Z1JQ^7dc4!$hN~XMcl!U4)du88xcj=JW^^<>NjD)> zm$OO~N0N!~KwRPh0XQe{bt?_>=RX8U*Tu{*h#B$LK|V-KYf7ldAmo;c%v@F(5i4~1 z2_X8u~g0@fV+dw|7;QHY%`li2Z z0(j+6{j+g3KdnI^Xg6s2{a6xscYVe-{qV9-K%YtC8$Goj&pn+c$-c+tg6GygfDCzO z9yW992*U_BN*~;C5y5A_ryAF%^v0DFUo3R**Ps0wB(nK&hvsDz#qljg4%-;_pYyBt zM#Ke^D=l152mE%!T-G@HG|>mRjeDHV0u#Al(;tf;yCZ=8!>Dr&9QBg3@1QP)h`d!d z?I7@i0Rb$B$i6daVVA<4Lh~ceXhwq8&^fPHD29%1WMLl%VLuGFpU*4E=rPc3{P=qL zu!0NSn}txmFCPl=A|dh7X!2;YAUfnKR5|4uyj7T;-b?v;tjtkm;rb4A1-nc{2i#1m zV7|sL0EM%l)lN3(jpf~;m7U0}NCeRLYqhEt7|-c98piolfXDnzE-#rCHn7)8dL2GX znp<7tM|HvOdmz)6MuEaH(W|mCh?UNao1(-4cLGw1CeXag=D~0yE2FD})Uj=U%#|$P zA_M$jOAz7!dU(|pq?$Oqz!4Rjz(lCwnROd}lqdL?d&`_hTcL0(^lm!9vZXuy#k}CH z8FN`+f7oc}fNTE{o$DtMi8~PSJlR(^b=e1k3tyks+v?Y=$N4DR8#yjC43Z%Hh~$9% zCSY?b$l%Ptl2jp(_i8|=pZGRcX6(etg)cfW#;wRP5YyvgNb_{X1nZ7(ANYbnCLJUn zb5bNO2=|81M?8wRSUy%KLLSzJcKzg^V@RSC9_oJC1Co`%q*vgDpHGr!c?NEO)~vGKLnfT2785BO zfp7UEJ?7FWzp$K(e9TlF61oy_E=U-=kb)HsKh{rIc{|ZW$^~Mb<$TRshf&F7Vkoje z66q{Ld{Q$kksr1k6wuU6&yt1`1iU6BVKTUL<&wE0@zSjb!wBW=Nr9a<8d)Ogv!OR! zZE!&5M!?FD&8G&0k#AvN-ayKp`rG}y$N?PcTM(7usC$ab-*$iUvTu-Cm4D8(FBe9j zyH%f}w63C_581(GHDhr;MvQ2*9Kbt$ zE>0A4sD1CkEc<~C5P36rD1g=6?CIYUN0ze9qf~uV%~uuZdNY`~B|&4=n_{x)fvDQv zAPLOAbDw1&1)L5D&#VwLwUIwD>Do5da#OUY@OfeAR zI^GFE5!Ww*>gSos(B4n0@L52|NMu;49Pp+mGlbK>3!A$S)u&I0#xzf&rfEh$1 z#oC!vqr&(0c9X9v50o+D0E=05L^_kGbC=(}Ij|1-T53$Cu71FR{|e$Y(}FpZn<;)< z>K7o;n`Mna-od#T8)yi*j8(#|p_GkEPNv;A^StjW0zf%;P6Zp2yM0oKH|{EdF3)4u zmo0n%trliW$HDoRl@UZbq~kC)FWW-htErp+l+ykrS*L!^M92vXs|O5v{=N%R{Vi$X zO)Mcl!SH9?9#o#~Uz7 zn}M$U_U^~?WV0EFhe7`&nS*d*InBFwY^P(ht1ibt%QtnX%U%A`g}5Ve7NJ$>-eI$R zZGt|U^RVWPrmq&ih()#D0CGAMs!d#$peK4g4nKY4@e6aQSA(#|u4dAfcdRwRdd`Td zAtMlAmw+J!8k9@6$@S*?o9PgVx*>>HqRH*V+Zcq}X6LTw+oOKTcPT|lQCcerAG;pl zEWisnBWL*&mbK*rpLm-tANp>Y(jS7kl1cUjqD{PuR5cOBwY51T!2tKZsjQ!I-<#A1 zIs&{Z7Pm^m0jqxx70jnS;bZnH{pk(4&1v!Z?oMpb_dU);lgmn86KD!%*j*{FmLvDI_KQs0F_3r2NG_94s)>| zHw5Nfk%Qg{Zp3@z$V4*O5%@`l=^B?459fg|Vt{w};)96Y_Y7iKCGo6n(I8+`$;oOf zd_@-V;xGL;MqOJ!%b+m1xpWP1jwt}n(>zb>Z1AqO0X-EfczAt}l!IO(H=k&$zg_h_ z-yeZV_h(BLS0;UU$7Ew&m3Q)Tql7STa(~saE_`Q4*`SC?4^f2ZDuY82kTPh-kI=6l#n+9vsW0V0O znLV15W2;$@L|VqZg(E@~szn{>V%$;q8{v(8;=>@b_@<=866fgL4P_ddPOd)WVTUv% z1U^s9Kd|k3&1!aXbKCPzil&zgW_kVV$~)~~>>Qo{Lse5{D6b)rahLsb#vm_)E13b= z&@(+5ln{nr!Vg6#YA73iKEzZPe6MdpyYR_?xrOFZztgR$RfR(lS&59kR3g<-f%NrG z!fAu~f#5I*RWW`d($O_pt~YM>YoqsZ!dfVftE`QygF>)3|HfleZ4wB9c96gYzrjj_ z^?o2L6l*3mJmMez%aDNt}5YUz;m#g@5mZ4JxpO5pn zZOV(DM0vEav4N+R)Vw&GX!#*c4gAOiQd)so_ld*V0d!S~1fp;wIFUcz|naJp?C1k}vxrr91`ij(r~C0FSR66F{P=v%n1W5ftEHxa@5usJz-Ln_&+KzR*J|4 z&!r`RaTtH3zblGDzTu_%UE}yREEN{ClNIGd+wQbO2r!jcFTh#QS#WFK_2_u)tpx#)d_ICNf)%QXBrWZWKXY|_!@!0lv6e#6 zy>>$FU5}v$xYD}3ekU-8?It-up2Bf^^RyIg5qhe8sVo=to*VZi4_ytAWD>2;J;@r> ze>OZb6I>Ed4R8CeZoW4mEx=xLUd4GmG~rB)1nTMIBW8uc1`NqACyJ$Dh@A&Eq2qqK zjMf^mX|X0j=bXUB&DkH@G=XBd%`8!a`9G+e3OHC5;S}x121@KR8JRXj(Q2i9&~lg1 z(IeC(hguCMXFI9*0RI_T5S!FFRo};a^-grGxaWxn$viX_AqOJ*awoGC-~a#s0wl7E zNc&-azX1UflXp)?*SK^uqRs6X6 z-xm{PBv9(wlSG&t^Nv@M(f8`iquLWG1yJ3Ssb3L`^E1uaS;ha)$&go_I*FX9b|-tt zbvbJgu3jLwzW4T4N*$SQE1llKYs0C*c7z=^?G5Z?4D8&ZtafXluZX&%-(&EmxMYsdS)l5H+K zxXM?J-?4X{7Jx*&<%4715w_o@Jbt=It-2bK>RvNCb#k`k13B}ur~BYMb1tX0gJbZ-8ha}w z6q=E#`-fk9QS)YMY~k*&!plH_-FNT-N&#E7=Bh}^%1dk9L$;;1PsoO^Fhb1yrW*0W zetf-~WLI#@uDLw+TVIJ>9C*nq+3JrZ_u4I`5*kejYy?-GH3GfbN^qbMX}ebzt^ZQg z462~T*s>VqE(sW0N#%P808aHx*v8xm%y3dglqy*%K}ui?6Kn_|PSGmx zz!CX`1aI99AlOT}lO&bSWx7sNy{U&(v9mXpU`Fr8%+IxFrjVnV@y5K6B>GO~LGzlw zI*&TKP3-C-z^e@hgd?);UJxc$2u-Y@ekx{ybo-{ybhM?QoO9y9LYkF7drv_Ww7U`W z01kxsOfrRHBk>CV^{aPv;g6UI3-T(+mdi|Gw00nMx*OG*x$s|?i7-H z6#VBCh!xAzZ8#=j9~L8aO8A<(xObrB{MyW!Cg?dCk|4m6!8ZZtl^9LmjTcsES6?+x zZb{wmVN*yjh1+?Z*&V}Yh{Trxq^Pp#2x;N%0XtaTX#3e0-!cu7Ex1lA;PhuO80FjT zVa{4M_J5LL*KFk_Pyh^7JI-=}Wf3NGQr}=QW+-m2I5;S&AF2c2OG|k9M7sdx!_sa? z@AQ|cbQgU*^{XjbpFqan9DNP7P{mLI8{mTMYD=TkdKE{csVp!(0)8ZHu^Gz@gH1t>==985mxlt{~(HM zx$5`vA0Y+5S8Cmg+}(q4psgpbQh@*m?*WfZY;8y5Jb^%)MZ zh;jOeaewcWfHDpMG_RziB^JMuV1g}qtKQtR2N1$_aqo@q3}M$SWQMVItrmW0>aq8T z&8<#84p+Sna?Q^LdA^$fnzmIt0>6Fq84vd(@f> zKK+QKU2~jiLiOpvAQ)z9E9Nl$=i)gP1Mo zCP1%x)ygw6JNt3sdP!Sh#I3Jea)vSp8A|(64<~DbtnK-tcl9za!1W$jdk>JndK$VWgS`wFYW{Whv(Kw{XT)WDh$Ep}hZs|51oM&?60` zK_ogXS^LY!rquwW7lta(xp#ZIxWn8-9#%tW7ehN#do<)*Of3Tc(9dy6*Un4Xc#T#{ zcQG#_w94%MT27lOqh9=#p9_LEGYO$h_<)S@YvhavoLr51Szfq~sta z)!SPeJOY-IU9``Q5+Y;QsFBvDepr?~r?+gy?-<0r2(&=LlT~9CGpkGcZKq3R zGYLSe+qQ40Y9c|GkP1`a%b3-jt`UjvFHy?`>`BLDd0xgwE4vCRds{o`1kbCG%RHd( zOItHk-EA!bF6N)`^RX7d_z(Cs{Ab=t`GvDaze1qMj_L(PQCoGw;a=6bFus)E;x4`v z%Yv=2y30zX7xOrymwN(*Y4!7s@3bR1Im?FMi_eb4$jJB_=#PhVTMorGaRx`m6;sB? zD;OXXN=Npa4Mvcf{f1ESRoZVY@`gL?{ zoe4UMZql>MBc8`Y9!jend4CjGx^i%(-G)$aK~uR^@p>&7_M$-{Xs-=ZhwE&-$7&?`PGkM|=!f_Rp#KV??hr{&h2B)2vlUpw_W+JOMKl6=Apc)KD3 zV$s`$RLhAQ&0qOd-ne*}_;@x&fuM5U8$gnkTs=wtspm^Sa>MA9;KE)i*Z*OuWhHo_ zt?O60w^tTlsk$Y67X;qB7GFz)wbGBAsW`bWm|pjsaCygeJ!g{A2OnQ$?K$OV-!GVC z{b@kK6)fB<)%bZ+QI+LwRV58;1O(+GQ`E2^itzsu-t98X0^+)R)UY_C0tIi|*_gd@ z_Gb11=>e|9Yn=+sva6HXXvp=w0quL-Q$FI2+Oy%nTeyUGSe_auXOFhdiQowaj!@)X z8av0EwbSq8s!y`EdY+I6uce zqDrD0gMM5O5k~K8j?9Rh=1e3Ccobfz&D`g^bag7*9+qvHckK9$J8dGL-w{C);IKRZVJYw1<97*}s^N4h%Vt=bNl-5&xlBhZjmaer>xk<6^gGD7h+lf zTV2#Q+we24sp>8O?!x(D?eT5l%NpFOiqZK6ZZ;!=pRLtVH zWEiH4c3J2Oc1|HPcuv%F!`nWVBq!w~HogK%3~SitPI`y|)fRk9=u8jI&Ds_=wknJ{ zj8Y-X;c-f%w#U3`y@KX~9y0cy{SUhz3{|%R6~Io${qzdXL|S7*JO})Q9Ac&=tOc+5 z@+?)bqhX*^DJ@=69dD%fZ;;zTm@8eZ) zgX)iTe)X=M>``AMjj`=cD}(Q4@Zn~l;l8EAiBp>NN{Z1}b&qWBABU`)?zF=Xb?p|m z-N1|m{wNLdfMR4PO-32~HWv#mze~1t)*Z4dg6*yME#u=&u_x{eAT4^prOC5LpHEu1~jYM z43PCwS46(@PVWOuErERRO2|8_!BmLz#R~P$cYPKhMJfH2B56ls31g64X=+Nuo*|Ox zII^D+^cgy&`^xl1<^kW^@JvE4{==MLur|G2M((Dn|21FNU~$e5lZq)2!EVkQHv$LR zW1`Dl^aEr@x*ksD6B=VzZR#(z03FsdVf!5@6%i+kQuFRWi&QAFKt3KoR2E%BykGB) zJpz7^B87Lr%J%--hj0lf!@&IrnEeu}c{$b(fN8>Q6WBKxoS1rASIj@tO@+rk z-#eLm{F#cmF7?%r2Mhkv;0wL4vTI~{?vcJ@9!(9Afj@%6U>RUG;L=!NyZ3}d!IuLV zJV2H%frBoNJDF>1`pa1~ojO3sI44iegtit8%XU5$g+~d}?s z5$#DK-^iQ!U>K_LC+~$(ub(fD%d})CrAL@yF_9VuRB2HQ%%0uQq3)Dt+qoC>B;A@$yVGLs;5hl zCEi2A&H4#Ry{ny1+b%0(XkSB65I;Y5O1@L#4qj}6PBZWlieGl;hxma0W6kCsHYh$^ zlmDNQ1&+253@J^?j;d0dYCuA`KbCBhAWEBZjOd)dB#xeGk$t|1Ej`i&!X+sr{U&}- zV9Nv=K{iDk@Zc>b+}hmpUKL1Qv?KJ)lc{1=$&W1L_#X@*$q#0ilaTsFGwSk`4T@dZ zMJu-* zS?^mhpnKPVJb^Yloi$C>ehiBREs;P{7=zc)Rk5|jlov6#jzJQMIuqhL~2 z-IG1^Ow1e&@?rrrMBY3%!s`onko{5DnvV*L5`V=7#1OPDFKk#H@{R!;E(6aopTGc6 zj~Rmnl*U(au@3>1dz;U;B}N7sMTtyz4D0(n$wyQ*7P{2)?`pABoIO}WU|O_8I1R)! zgpl06kY~%Tg>Xl|GEK<;Y21_{Si}6his2828=2#+90;%+BdC}!I5Cn}&^Be9qNTqo z$h|&a0@J?2v)FVw&$~))4V${3L+`yjIdjIUaB~T(jOG2B|Hr4O{@%sb$sOH<0l9?f zF}=ARSucQs-qh#;a56ltFf$U`cH4*|C8d+>j)yT>>?ab^!nRIFg0cWHfYu;?8H*Tn zSyK9gUsO&3lAv}X-QkJ-36ux1hn2w!h)0a|;enuYz70vdmIrcW6`qMErx|^r^A{lApRMUbCb`^4hUM;srJwl+Vu7~HeEljDWLb&){(|M8rqR8`I zh)fQ`uHQ_|^2IsVISh`b4R z^Z-<}*l@etl_(w|p%Et@C4p@m)?1N>^wve4SUWP6*wfJunw!5D$>AnywoO~FQ6bEy zJ-uC3iEm)Q=%vQm9LwX;dV()5!d0;?3nGzqJsPWFBdx4bjXt6z;fhOq>8F0_p}-Q4 z&;Zn6m_}%}>p+EU$=Va43#UpUKitv<>#bjUKKY6b*3Mcn8qLAyFydX|^s5TJoSM6z z+p>k57xNS&^@T4EiYh2s`Da1)!@sTLa<(k0mXbRTL74U|o~xD4{eRc=f-%xCp3l6n z5I1=B@;Ud+?8}+Kdm6nXkkAFNCDYaEg$T6>UpCp6LrJ|%6ydlfWTjqwo<0U*C^ z-rSng$!zpU>sB@$dckiMQ(04KeZ1^(cC-e)Zgf8q++^LBgeDGb6q(=(Ho&3B;$XIn zHZh5tUuLXD`3-oIpns+}RG`%#SF|uo zoo!2%+?1febCwqp&z?@eXEaNNcP??rQlW|+LnFOYcf+z;3oI#Uf8A2@k&ES0^z|0L zoerIOm9cz4^CdQ0YltxI;TrtCumA#3EfCxn_Y?PnC}1r3P5^QDRyd&+?xE66N8bCf zTb~7@XRjb;*cK}$lGPCE=Y<9(=`lPK!`BcHR|4V&J&=U9z(cI%fJ~Qe^yD(yZifuJ zo)P`e@*z>E^rybUcEtezt|=Z9I)zE6#cxL&iHs9$p0u@(P%$LIe$oF^j$IyN)S@lo zI2Sw6H~_NRZ(*NR8EsNz$+9eqjI8ST41*;0O)I__qK?$)gm)?yJ=_Sh@gN2&swY4c zSnHUeOPPB-dp_~%P7rP?SM%kYE;i24sW%mHlOgt=*udXXrShYrPQEzinyW>=X$GQ(@msD& zSL*T-_0c-(0}vxg3L3F#tLsQKE$A5EPpAu_Lc5=@iD7l+8J@n*J(G67oO^9DHyAe$7UIL1=KS|Ru8Qxl3|IPN}=*^!|~Fo^GM#w zt*Bq_j(m9QN z@bwMf8PW&yD|-4PN}~L!5l3L|Z48%3y9=`Q&ZxtUeQ}*l^xVLVTTA--3{tpZJBFwR zT3B^&imPTn3tX@vLktvl!Vae>7JghdXN#lDQZ@H=MehRvw-_NhsYaElvDn#$0=C_m zlGDrf^D*&?>3ro9CghJm`}y>T-gSOTWZFh`(iW^`jq|2?H_>h5xGAN-3DbL;SRH+{=L>-+cbg`DO(`i2*RYC%T z##r=w^wew%d$l>dWAZ_RoH~zhX!8#WS0~v$mH`EE?~H7NEMy?W!d~ut9Naj%uAMxz zKT1FSh_#|^grHG6KQm|UDcG?u7L#~Z_uca?S>vyMm5=nYH+r?|#W5o4jhQ5M z7U?q1_%;g6OsD7H6IXyLj;@B+(hR@BF~1`KKW66NRAOjSjqA`54FRz0P}mnIRsQt3 z*R9eR8AQ@Ic(H>(+koxjArAj*iSpfZsgL%#$IohLRImDw`cGA%Y&QbFi9%KK%sCSI zYf%P!xUR51_R@e7Ei(#!pR^`jIO-yo;(EEJxsPU%hx;}^f6#hAT>5z?pMA zqHS{VLGjZG8KEPgITwf8jwCaW{z87ouS$PeBg4P32mnj?mVo;(MJ&5_#Yb}Em79*% zc{I+B;g7*^*dVgdZ1_=1r^Y!jPZ{7jW;@Cec0A-$yDmGGtdJ1WBCws6U~cW*UXsFE zM)K_8#5)Ipg}W%;_vD+q!Oj0?%L(H(45ovIHU|rO^SLSh3?#6GKlP5FpWZId2c4cyn8LHEyH*po{OJSeEZ_4M&Mp1y> zvgMs-bIa7fpZO`>ehSephE{GK`ebzcQzbq-RxE~ZG z_-*EQa*Nd*Z5dYpkVgHZ52b@|q1cZ|Sszd|p14dm*ToGY+e7!SEGOl57sTK1$4j8B zdLRA}7-DIfH>o!7lrWY-$Gt~nq#3xN z^c97jCDLtxe``TKkTg5#k-p!_PwH-QTpTuOyzCsN!Ch318hp_Rr1${pz6Hl6pATqc zYlwKXI%&}w3Z)k9=UbZ7{dasx97ANV(ZPASnpf)kpnYQ7p?k0+G9;*f>M6b+4_!Q) z9eXn`zbN27yc2Rqs%2o?u)5~bih&cgTz#*|yp$D^?EgwkG>Era@)~J>zRUX(mu%q= zJs-Es#Z7vVH?u3XaDtRtp{Oq}Ka74+9BWNgmo^tA!U`Z;p9pz(+Ed8CL(FrY1H&oJ zW*(e#aCH*2;^LX{2?Ui*(BaIhC~b!>2sFW@!o4ban@lz=G&#T;0R>03K5QaNF!p?4 z^f*Kk2}~uD*}@+cF%}ovW1rUu*v~uEsW?-2cBy%B$46m=&<9T|b3KEadA@>!E$BB@hvR_s9L zAPcr~4xzjYQimweoxD5-4!GCoJKVAk%Mr)p$+=MGSUw_`IVK0qz@>-HrVlzM%^gYo z>OcSrymKOqV&6@d%4XQ+NF+Nn0!&=5zXd5{B1)aLcEyjunW#D(WNqkvO=63+7EEJD zDHhHL?8V=XSl>o3RZbsoy3T_;ZbC1|=`=U1mb|^m4vS<#{oC(kot)!CyLsjGj(W|0 zZf?BD=q(*N8b1p+2mLb$HVoP~=EP=1Y%+Oqlt|mulMGuC&5Rx&3_`DEE@?Q={MD%- zp#Z+=-vXj~7a@@8CBKTHcvn|B8Zx2*^87F3-~hdTtvRLR#%Bg@_hnZbBkKZ=47+y0z%2LSqK#8T zgb~*+QyqwA#3YDU$iW>(Hkh0ndWP*x8F{eBx@}njQVb0I6WN)PMdTabuZzzFuW5ZNUgJ1& z;&^$S7vjX!M zfxn6XWpr%q3uOqQ_`gdgn4I7l3=Z6~MjhpFvj$0K9?5?_5`eV-LAZHCpIkcI2j(;@ z2&$`nisJrN%oDfDSX`2FQcI@Y+51lM!cS^uqBP(^(!yX!jm1C3(2PN?0cJS?kC^zf zaYQK=u%hDI-AS~^^42DPKC2bBI$`KY&>bHoWkte6yfq>n-US>L$wAJ`TUO0MMH@gb zn@>0jRiUiJd38ta5RxXI1!p8ki1_awILM)rj7zXlrt+*78F@9 zS?Nx$4My}u_=Bx1{;>S}vzkKwBO=ysd9h#)7lgkZVrqJljcimy()!n3-)3b7Bd{k) zPtJ|Z*^d}4XBw^8IsdE87p@_iMO--}96##cRFTZw1Qf+hCxM?9**p&T!ohylf+icD z^)+(eHROAbCuIuD#-z{PKycZy56X<@h06$NluhJ~{71u)U53{2ERQ^=JMrU;%kA0Z z&`AJ7<$3l~dL*S;wKul5@+3L3K^UT3V*!GoZ|?OQ61e^|BZM_qVjt&i&u3qaQzd@4 zz#LrTNw;)Bb~*fw;`8lLsH-s<-n>(4q{O+$Uyzq(KM4&(K=yj2V*Av z$8piUTvV6-kMfYCjr~GO|M-3`7wbqx^zIYc3$y7`AAVw8p2JV(-lc=_g#?pjZ`Ma1 zJ=$Po2fD#kxMxQ+#LXrg6`LrFvQb~NK(bv*`ly4J25n%!wdi=o6y0HvTu{#0mawDyw#!q7J_4yA2$(wB6eo~ zT(~fW$)J0ncRq{&=Pc%Si?4sMB_zsjCe8IClx`{38!0640Tjzi;^KoSOv$iIgLi3d zN28!CU=FjraS+r2T}78(@Ih86{hLHki&cRBHy}cou|+yFA$)CT50y>+6MouH!Dbn+ zh?BC6fXmmYm!E~*XSa?C$5L;lLMz_4u7-hEi=OKL>UeNDI4P$!;yYMst#Tc6* z)@NyP!vngr0*x#GZ zDJQL#a;H6_2Qh^$wVRsOw>BDy0fVx8>j%_QI$Te$#-9-Kq@)tKn~L*V66~x_=>8)+ zsQ^|p)^G(>3=_uyHt-5+%#MPvK7NRW0v>u9S;)eidL{aPb4ZqV0}|M#8_k}Wu1|~N zm22Lg^Ov2{}b^^nDqm=0FQ9aIJ+}!oP)>VIT#<`el3~p#j zkO4Rjj#1%ZT40|bI)puxCxP&jW#YA=IjE`bz7_x@ntm6tQpWj>SQ;7 z32M<^rF!;$JZUW|oIGG81Op1r`k{pFms`G(=6SK0g6rV-4Un!0HrE_UBb{PTy(QRU^ z&F+zM`IrWir+|GPY^18m*RMbomGfk&72*Eslr93`JxZ-JvvW_(y8%&*v52VzKEJVR z<}%`6Jxb~Dk77niz{%!Uq)E{omx!Yy_SHZF<60>_T4R0;YOUt^0W8XaCFq#G{AP{u zt3{+=(Caf_IgSm5LhfF#NO*+v?dA(V9VjZe!AIyI*>NiXKFN%p|EA53s=JX$X`UM= zTjlQHJYXseD$s@T{dyBbIm!UVkv35WSEc(jD+mlv7o`1GFC`+a;w%2e7N<$~IhbB1 zqA^<1YTiI$lS!b<_o0QH49vPKAdfJLHSGBTk$Rt&uGJ3E^#KfsrMO{bfCG>K09XVb zg+>Cf000000kBl7k^R0;J8K?RL?Xt#;uW=R-whi^;qHV)vr>TNCxc`Gd0VDX6@*ry zgJA4m5jup&z?#Rt0ox6Ps%3Fj^)uHD{2h0+Bi;n^spDQUcw#`>|donqa4 zKnY5jX%Emq?^Q7)^R!=>H2IC2xL9zANft?nUshA!PZDshWh9kkg>k606>jge6=x)p z^Jh6wd;k+u!QD?eY3j40*9Pcq&S3HcDwDNT zhb-n~(d@MbJIr$VLEe=NmrB+cr*6}rbK!MweI2hFe60h`RmKKRPK_a+bQw zLpX>-YUz`%U*XGHSG}}^htTa-aNY*S>0Rxx1iSU*?7hmFLd_#~a-fLY*jrP&n{>Y~R?a!YSxzi0vFwQra&m=--9 z&U5UTq%nCFDQ;@v8v(U>_V*-JeRUCF@>PTwt*!R!Hf~p0STbt)Gis z{HL+sp)dA<>CPd}MlWxzN(-rIn*X{}tbeZP`MycL>v$Z**#FnAj7Z`-)bTN1!uG&a zn~Hjmzv-bdl`#!Hd#*OzQ>&=hJoEjH zBlW`Uw;oS4-21zJ?X)e=m+v$+R=Nr*f~n_$5t9cP5K+0lO}*S>`N(SuJnl_bce|{9N)vZuO;(@(Yc2%RaMluRZ^H z)9%eGZxyXqibb>Pw$A2$J3FCnso2dn4~E3oNvzu);yF#&8d4VMZi|1j_luliL6WP# v?cNE}Hyrl_mVcEKdOdT0+T9<^ByMh&(k-U?ZN+x+*f?o{V}VW`2$RrJoUT(+vzj^p8xgw zDgJ`LC3lM#_rv^9zs29_8~*G1J-h+`sdwK;*OmSJxtxF8Z}!Xm-ThTw-v7wo{hRe2 z_C){iyV<|}5%eSLwe=nT@~efP^y~do+{rBVi~1|~mEJ&KmoNUC`a}LuJwN`Df43K( z5A5Uo?fb+2di#jq!LN@$*T*Wvs7 zdlX6-z59x9N-Dncv_ecmk3zG~msPqV7lr0GUuN-=d=#1u zzO0hMjtm5kL)%i|D)b5x<%|%@FPhVJRuAaA?*KG~cx^Mu@(mY}=%K5FT%-VJLylY2 zwYQffA*w)bzyCAFS?Mq|8+=K*tMXoG7WjfnNBOPLui(=P?SspCrmiH>W&qVyR?Scy zz&t+h3v-?}+Fi0S;H_G9p(cF)(Lgh;oHIZec-VNs;Snn$K(!IBRf=MVAJJyfM8>oi zfhm8@^zYuH%gMQd?AXvOA5b`V8%;0*?zPkX$jM%+u{9=_?7aedT|VB;HS#3nlC{-F z?<*&Jy3F2yT(tGr?|tTEFIV54kWKbny>d_a&&mJ?gk(}wl*zaMa{>RODq4;4cngc1 z5vqM%b*Mn#fQy&j{7*_Ri38gaYnw$jBn8d{;ds>N%gWbDp->UW{ak_&%ectK1LJs@ zgp2)Vq-tZ-#6W7+3x~L&Vo9cAwo!;cDQXX2zNL73U>)`MJNs9?V(!r@eB|BhT-RZH z79=Zv!jT<36cP|D{b)rKTifm0K5~L4kE;+V5eUJ9hHZqTWg&uy|2J&>HNt=1*FR;+ z>nqdu`a!g@HfS-hH^PsW2L3b_$G`eqzu~WWUJCsx%O*-|=*RcLlqK(9CLasneA5ou zc^KkB>@l83Dy<#Kw^t*Y{~T|DCFLphfSSrRvLctRBKL7C?J5MU6W;~0k47!t zy>9l=cah7}n(~boO-{dJ=0p&eEw%#MvpO;82jPY}r9r_sZ#L zF2^Xx@76$hj@{iw80?dP-@1YtS|5oe%70Nv$)sl2{ zSuQ6Un2=Le-#)#!@Zg0ioda^o?gOAFlY^~nRhL^X*;5Dfo>H>=YwT6Xd3*ENSjnY7 zq*O_ehr1=<=#ko$&Slupo@dI+Qu+|GT<^nL1)rtcceCGTIg3&rasGL7TyB!#BSqbf z^SBk~buG^8UYOH4H>3Oa-&fNUWu2Pp{~lByYLf zOnb@K8&LlXEB{O9)nPLIA!@>f*IU8`MJh=2Kg+MtiRCFQmp+@(lry?h={-b4(X?MZIQawcx8gW?jT3wSJ6XB& zjB)@zEsp>`7HKQNU%-(%dp;93SDeCXXRYe?p*3G25CUS1$o<>O{+A9w#`)tMU^*wX z^9#lkV_s9g0Mc;O08J1_u%|8&;U1C+OrgV?%T_7osU^x>6TYr;*vt?zQ!0Wz>DQ*% zRiQuWINTeeoba{*HWtZ{^5*}}7bqzQ;LA!6Jp`W=sfNwTO9(io ziR9J*z~$Tpl-ZNMu8c+^9{S{W*SKUnVeHh9d zq;o>}H+~Q18jy10kQ4Y1C{QCknw2>Zvqiy}DR33Q&$fQH?p0Ko_Ep{5H<2Hb4DKoZ zVbj0G)?W`W?7E^oyR^!01p@=48Y^$v=jRd5>y~=^ZYP~!zCbK#9qRl_JpH(8{b{ty zOCZGl&3Xx6{lCnZ-O!oP^^vkFFFhdclPKSJ+4=g52tzG&LFU;b!jkA#3I9kT`laLY!R5ks2 z^CwlGt5-)%i_-&?q(qmF7Micrf)UdZo;EGE`?b3Fv~0-~_o3^jC?ItTF1a^{{G5PZ zZSFq}^S{9VU(D|QI68durL6v>#QRyo1`0r)rDg(4A=(9TSF(wp$a7vT%3R7%A8N*i z>S!$5Ke&X&I9OBieu4yJRTyA95ypa|=q4ea6_Q_q3z8)Kzxb`c93^4forK5%Olw)y znjpIhQum4BAETP%EAN^^xgeUK`O+xSd#6=?2HyAMZ8X&jR8{^FFy|h zSa5Cm5R=(3o{|i}Enw+`B zpGeaXBGQ&QnWD2+~k;nIW8aEfMBSlx$GLrF^)c4DG9672%fU(JHc znlYjmYP3-+9niFjKU1F8lfSb;LJsP&Ax!m;h(fYUsQ2#?rR#;V(PPcgciQ)iFmXVo>^g1@AjbkjG6xhj zYBB%kAj!mE-2h|wv~Nd71w`}Q&b+k2pr$kewoB0{O92#sy+(5PZlDW|s0VoR1y9OA z4`7YQz^dFC__7`-fG4aR3_&C?l8c9NTW#CG8=8Xt%Vux5F{m}rhJTFZO&Dvgl9|X6 z(7>5T5(a`uVU~AjLOAb!qH^JlxJ&@+^Eg38z8A}&9(OEQ;vg9N6isecWuZZkJ!w$O ztz9c*Z)dVR`ZQp~8~oim>=sO%CorN&WbUUJ-A(3xR#RFAC^Sc*{;n>3*4|xkl**vc z?z;?jxI4rx&mJ6*=i-V?Yyh-ZKcU1SVz!>M3?xgQgkzYOvwvWaE5$A(HTfd}V13ic zCVQ)x?F7335?aQ9jCc2%}!yJsvRq$20xMc>QlGbcgi~ zEcPI+eq#SD7TpXgXL#HV^d3}HE(fsK@z-qhSo>%w1ax>FyF*CADt5CW;kf0!)UZa@ z>Fi7kk<&zyW(PWH1^Pnu4vqfqumBcsqpfg6RI(e!OqAGIQ4FffiS~ACWN_YmQ0!#^ z=r1rpZKlZGQs8PKMn_`Zv;s%pI5FIy<7|m<4l8WV+y9Fn{SPekzmxg#7E7>%@g8nS z$_K!|xi~G%@qa3kV;E|i zKRHb~E8`u2T}^;dQBk=Z0crRL6#mXD{CA%JUxelV5Agqs`0q&m%LV*hu=?-(^}hpY zCIH~;``5*|h5;tDy@dvz($+hSChsKP>l^#b2L3!MyX^RH^|gTZQJ_9L5!4U+Vt$rc z3R74S0001OxQf_Wv98crF*Y+^N}+JbfjWb;ro=@1bczuKV$qQN9DQyD%&Iw)y6G3Wzwmh>JN{JIp7 zSCKwZun#pV-;%$7P?v9UTp1@_UbH(k-%LgD7YAAi3@pKtbt#`p53i?*vFB(nb37U& z#bDW_f9-p1-3-EYp)|}?$lz<6`*9e7Mv@TjO$;#CY#DFBbg&MTm?pndkRqOESKDGB zk$K0>xsPc0tO@38$zrK;8igY+-6i6?tp(G+a=A7@S8R9l79PQ5Qm3C*6+`ethzAf1 zoZ{ox1Sg%qec+@iG-|_DMdoW1L@AR!+j`B*_bJlJuOz24VObukbreA7(tgR2gPK^3 zLqx#IbsEwX!_<3-ZT#CLoK@bS!z~{O=C+DufoHun^)WcP_NvFEodhZybkQf!b|kbu z{Z~-MID-aIRsZ8u5_;~$y!P(*ZnO%opV^iiX=Umn8QI@(pfzNPg8D^;Z7r|zt%O6j zme^HWg_kiE@@pc&VW{`O?Bt&il);%9(>eeEfW-_es<~hb<6E)OH&Cu)Ci<3Sd0+}_ zSOLAfs!xYc_k{o;0k{4+mZ~;>cT4K0T+Bq_a`Ajs4%Os4L?X;tsFR5)41?XI|+Nzz}Y`0M6t29e+S9EF)o%Z(Ow~LQGVA4)F9efrk1X7iK};_ z7DJ{}hXrwf2QadRv$xj*R-P74TY{gG?IxHb(^pt#asqt@)8|SA393e z9N%zgX~z^>pZoNhdy6*wYTjN;q9iWBXb-lq#94JA*|^!`AP&r-007X1Lq z_(M|w@#nFoy<$oMqZ#tAw}93B&D$sVA+$Q8WU@(+I|P)u#Of!K$+LbERK=)5HE_j? zKe$-h>|a1>XAoB$7<(?NfP@JpaZ*lbPtlPY`FX_tjEJ=I12p{rU-5U@G4+aS=6l*f zuyGfnQ0dG%-2tt{$hLaQG+7wP&ZRWE8J?WdSHhdMw4wMxAA)D;YXS>vN9|2k**e)5 zY}3x+*qU3I{{omH zfkZx5;qqTAot2}pm4``|X7*i+iNYTyDj8_@P?Lx*v))CaEM2rNe6SehxnWfPj1rl? z(^*TU&D;JmQ0!)5V_&?VEs&e06@kDxb&bM{uSnCA^Ys%^A#f(=W%|0B%qGDfRJEZu zN;pY3%5=vKl97!0f@* zp0bMS*UnZLcWLuD2VeJr_G~C1*dQVo;nlKMl_V@f04zyB7#QcO0E?FhXey%+761=_ z6NTs@B{&lGQqzyb5tG)DI~^u9fgrOy68`aen9L8I^Q)Q9953C%*TVVxH(y$!C@DPl zqn%=1c7M{V%u4+nE${%Q#$rJ`0r1Ume|8bRAhL@NIkSLTR;V2Ok)BRmE~Eplfj-)_ zAtqTlQxjp%?PWb$=l4DHFGbEd;~qFnmLNc)(eD8bf-k}_M~7eLyWR6S^4^z@_SF@g zfwtp$30B7j#TEJ*q&=FL^|BVpU<*HA%|zfTxBy;{7W$zpp;c);pj<`dHaBsx+J-O$ zeqHr{9r5SDuZBJIF~)JyUXP_o8|mACUq=r934B002%ta@1_T zl?`+BzDDFN-_3FRlq6cRrc6xL-pu2cD|GrcSCrSP+g9P-Dm;uL9147QUU#Zdqb1Y6 z$JX)9mFKG6*Dr};!rqf!YdG1dLF?jjfxD*w(&;H&d~I@X2o} zB%=Rx9$-^`RPW&}%9U#m6{yE!f}yKu7co$un9k5snQkN+B(GdQlmQb+g`|QKVvzlQ z=a3aKrqo=bsl4eabvuoW!Sonk#Z0MWUgm>%93fe^i;ln}QPk!GO}#MI_gj%4h-dy{T@|*=u3lO0P8SI4b)wyo0g0>aN|Wy%8Lef-Yu9^)k@P!UGW*uE)YTO_Rg zy#C2ZOcp-JW)7)19*)MBVqB--#H4W%(I$1dtElbH1b5st9nKR}aTLKfSky1&c=U7~ zocAR-lUS_cr-@U|rIiV2XPyygt`{+cx^Xvwiv>zS;|ny4%tGqVUSMMfB|l~mwmoUv znH>;X#x+jBMy2UwkIgB!?67+ND&qH)x*-k*ukCYKiq!iZ1kEjX=nQZ6N2Jd#v`T2L z8c^MpQDejCkUMJh>TT`!en%8&h2zBl%|*NC7jP(7YC9`8aHUR4$|lM|l(PYBW_dxY zj1YX=N<=R`My8DztBa)p3s0#e)9@X3l?!(F&Q7o!vhqft=_eH>4Y$ean2<5xszlL- z<`{5P8|mwM52mfv2MOWet&T7aCiBNIL%}5(4O$&jgv9MyoEp8Ij`dDL7B<^TO>&t% zfb|xWO*`8T2Dgbz9r&KF2*RUF);zbWD#NCoDsN_ILB9IYIJM{D8X(KmU%IuhsjPgs z*c17swWpMZwbC&4u5{Ef7FnRYtOi2bdE-^XAe2yR8kRKS*n{7kHw~pl4@8+@R^j3? zN?%Z}p6Gq)MRrv{Ll+axJQB|d`_EWUlrQl?!y-Sm9IW*yP927tev851bQXJyR4HOJ4;C=03I^X@gr(bL9eaQtdT=r^Wua|DMMqJ;iGwcp#>Zhv zWewLVQAbytglg=OW=x{7@^J4av{?hHq01c&-aX_I>!ze_+_C%F;{!1bxMrZlmCx;> z71SY0p!6K=cMW5Vg8K_B!0b?XxS?_;flK|20<7@)T*`E=??~v^HUT67$huBO-6F!* z#@D@|Ye5Q3p;hpA8FB0x-;bHry>f=@h;q0!fqdF(bpzN@n4ZKH6(G4ujMU z%O`lPJq*OCeQkT6^u$W>Q*kK<+)|NJCd09G(Yd0qK`ffUQ#7b&y92Y@-aY#(?&r!E zQA1fwg6ntb6n2B0ilLF~s7s$05^P+~r(6PFzoZhYuLt&i=jHa9+NN~~u|j7a=yYR! z0b}(VfrV|VH>|=G4Qj0%;a{K$jP?%b*&(mOqrx=M*dID7{ecEEZ+wf)Z@8|Hve2+? z0P$X3uI0ifcxo$T__Xt;6*jef#m87KJhmy`Y7|WbeK?mLIywNtI=!laz0c1qJ-~nnSp6hN zL<-}Ky+yt}Gm(%cUbIG33hg!|NGR7BLiMD$T!G1n^;(g|lVX zBu*rX*nkZ-XbN94adepJpbD>|DSM_Z19h3+*#OJrG_eZtQA;o)DAQ@+$hEyu@m{W_ zon{v{5v#fJvSy@1g7pw`w@XGHdoF;84Bqwg2OMNvrg&k5>&8;a-o&wDkF}<`*$b6(Swst4=3t~@j<-LQmBKq z2c!}ZMnB=D4elqKQiC;5F!a!V5&~Y%Dhd-<%8tn-v{qtaj}(E(N7r-}E%&zdj8Fu5 z1q_ucLa^y;`6!lt+?VuEhJMFcyADXK-afUE<^)TKLcV>IO>Cl}1srfZ#oQQ^3-|YW z1rFw0KTW*^SiRMEy+%n3d2Js!OP51y+Tr5J*FRVE8iv|VNG;ZzeFq)aF^FvFH~QUC z&L1wY5M>)rK!6siNltm&zy`zv$x=7M)>NdXKucZY$TjvDs979oBegL$jz~4!sU1dc zUi8(7WXH~BA{{c!SpD!{i;BZ3{ti07ssMb!f@&7F5d|62w4e<$wp}+gmu`cmfEuwV zbhB8$Wx3pvRP+EjpWSr>T@3iJzNq-Q{&YK7Krsta`-ZffWxtib9!66s)Xkxp9WbE!sfdtmf_5A@@VY?ysFufR+ zmd6-aM1%$nvr}+rWUd0JR~k7#7t+R+zLA7^2!`aDk95S6wb_<-FaL?Y!y|#0G-0-c z_(~#z)e_D{!n< zH!z$Q^<~BquO&%r5kq5izQ?Mi^lrg*^}_6kIi^X@vaLLZ zCU>7M^RnhKxb7*J03iUWvhVl8+s%iNl!;!LLixaKb}PdUG9&20jwTsvyQFors-T9- zK~59!68im-m~AL;jq#NxzR+}^R2-t6aQ@Y}8>Mb4`8!$n8qM75n(eqOO?|{#wS>ge zG?41-EBy21T-Hr~xVLk{d!XqAcI>{F9=O)JF;)w+X84%B^_5j#K(j=h2E@@(p2P*l zs79IpciL5dCNh0BD*ZG2Z^8snbT~;SM%sBrKKO{yYrJ}LPGRciUPdiVh>c`x`OiK9 zk{#zAjH?e2nf?K3r5O3zD)1k_+^QE9tx?3#ky7J)3pw4CrbVx3N48=Vai_>0<h2|JCCzbmgp+6vI&0VoY@OH z*2DoXDIUh&#Foh2soy4z+V#)Y4d{9KwTBM_zmX6-~OsgGoUVC3qEdlW@=_YNH3m0HKfetB=VB!_wZY6L2$@ zJt^JZ6&U}P6*64Gs9Vn{zy~HZ(sh+OP{^f&@&5z<~SthB}vu?Q*G^@=RXW&V?l%YTR`49|^@MH7TtqWCcds51b9YjCvcGd(bt2z}qWJl~=U zU_SD`xx=1e7#fAg%Qt~BJ~VG}Mmesq@>bTzb92vzqyYwk*k}eB5FK_xbrLR!A`v7? zyKBzvg;l^2%1V89`a3eHjdGxtqSklu4MLGMBgp$euIy7oIHt-UEcU0{)eA>XI3LPW z7P#rC`=@=y1C)27NM$aD67Z9UcI&rdZxLx|*F=lF9<->6_{>jM(=iSV<4lruAWE!> zjY#Byi=*M$YNUog7u+%?-!3MBqn9w(N1;S$!jPYx?as>0_$Ig!#IF6yQc>DA?=K_3 zfLS?~ev(}(_F5(0l7-X%E*PkxwxTm~n7wkv>7!z5zTF4)Lh%M3dU;(XEmoqXxpO7% zfdlD^<#3-SH6^EDPlfXQ&O&=~`Y;VFXj$6IlhOys{7Pyf*~Ee>A`iJoKe^CL93uKsm6iRefHkPXDik;ZB61HlFLtAmP+eeG#-;%jV$wrEj@y9%!M z6+CrAz?TTYUqHgvs5$pR&{l;VX3X~s($_#I5i^oTB=>3kb7uuMUn76Rw^Bh&<0?!J zaz1m1%P8zpenQh4W_MPZgd8cv8OdGgZ77L#H5A`OlsDvLi~B?h=aof6u1;^8BP=*9 z&>kp2|D!2ISE)p$l3+IF+!@Vzi#?>drm}RVmwl0bgNE?1=&hsGX3=Rlo@M1okH{NS zdCJ^iz7;`-?t)n6Hm+4n$m9YIQy{};m1W)g%K?!em-zf>!3RxLmLwzkpl{YEDtFWn zW-+!r*VVOj!Z201rv59hwhVDRJzG^DXW%qoq+WQ2V{&QYHC%(CL z7Qf$)j-i!3oO0`BSqtml-99|S;8S=d!8ijpOkF5c_dkc&aMPq&dkGjxqgWEZKnXCb z*kFAbL?-o6yF|`#C56D1P9K}Bx34-4jDy@>U)f)ELEL}SwA#rUIs4#eIFnh#go}`R z&4eHCa>+HMXSdF{IlgOF{@7*uKp-YuH)uJ>PoNrh9M$)^R8{6m^Sda}XO)YhBUaYZ zq8ENBK#!oCPcpT(UCodScc^hygKcpI_gA%C?Yv3KbyUN6b4s*-v=Fnc2PY(Tf5!+T z9+YfN^H2sbG<13?s1n3BRzt%Ss;=125Q z?o*3I{gljj_}*XNXIf9nFZ&x$n=3Bv8kRi4l##};HxyME7>it&NKNG9Il8t^rt`*S zW2p=`QkXTn*;t|pJxH8IT=RM+Zw=z}ME8j40!xgC-yR4M!~s^>GVoAyOIF%_YWsOm zTusO@Nz2`%`a?f3qGIt?w-(^>0-uXy*L5;d?%{JKE@lJIkZ`p43rVlLU#O-mqK@wx zdR`!{aKr6#^3rkCw(wB^nC_fDB`MtY$;iIcKP`iB3Ldti!$*f?U&IyiN$O{&_@i4*^CgxeO*+M3??pD;i(P??|c>;TPJTG2D;gJWSt&7doWJ!bXf27<8C|LfiZLADs_Ck}{4_$Q@#A@zYPbAC zx$)55ghhv+hX)pE`)T`1aSQRvVt^#~N(H>O%1t&} zn(7kN6|YHlEPWHo1Nzh7b1cvDhAt!pxIdsSJLUpCClJmE9&*YTWK}<6 z>o>ghTXI+qm;=1GR}08b2BJV(Eqwou_6YaiwaI>z2DY$|oHU(b9hlIS_`h|F`{tYi z2x<8epkO~vWD`KIwBrj{`euO>&-Z!D)Ee+(jLv?iH8ldyt%91Bts8K^N>M=H_#n=G z-Dg|90|@q^@n;9an3ikpX588X!#g=Tko9C0S!D_Rl*u&i0+@N*@f9VAj?w6Vj-^d0 z`z2!H?BM)>bl8c)6LLOT9WejEHMJ^ffzXnSRf&sn+D&<8y_xGE7A_ewKIEC(3s_iC3Y3eDv8K8~xuMY|JR7N(Zr_-R_Kt)YlM z&7M{X3n-PcpK*XMd{Cwc7)#g0gmwg(zGeW}gc7E)JVv};o2E;^5>Y3Phv3r4MZYS0 z)N%jl;ZAp996j|qK{39QpYB5;8;-^%yE3ue+Q?1A^;PDjPA9u}J!dYY63?XOPuh76 z7~@DR<|5LAO5$O8Pkau3dq-fzkvIRDTpSb6f3oJVQvH>X2TXV;Lus$Q-;<_UAusV_YRM5m03?yWub zQpP!nKRhKJ*Bd~!G9jQinGIbDRcT`?5f^8@115^v={+%S$83?lmtOTf!ulh>lqn7z zG-G^qfwGiPp+`H?{5&)ETt~)MRtS&G=H)a28qkk`@w|pW#ZLpq0SABparNQ&G*fDI zQkl;i?O6r@04ZEF>}{(UrWuA0fw;}A1O_TZrsZXuwva$Ow1+X5%S^44FDkN&EMx%I zfUupCOfcrgLK-D1F_KX0i88D}PHe7LDO7?(iyyjAH4NsaLo5r9H>yt!8-2uw6Pw=? z{KmTg)%=XG=3;1b{3>KpFSfl_6{Yfwv(joA*7;Im;<0zI}+ML{-STO!UnIpXgqRq=<- z*%Icb@}N|@CWUr1y0lifh)sOv&4A`>&=74DUxtm$ex<8)04RyIJ?pY&diiOBHk+Le zUmyb@Cv$4P8PK$zQm0S427(e+dQ}K|>ZEnqy~}>lihCJ5o47Nr%9$U6bv`i&~&?#eQp&u9#7E&6a)1fr};-O z%Sx@qTe!oL2ssdR`dYw2DpLB+RGABnSV2ZNK0sUR?n%GT%Jzt_t8Y{p>0+a!Afv@n z=bGHw#|1kP;0LZPJtm?&0?224mVe;`%AuzysmXK1s~K@79O2#p_PQJ&;BLO1?OX^x zK}pk$TyVoW1A-iL4O+lvA{X~o)!z=bCM^W|kEUl9%qQ5imTa!am zR$U>I8Tu*%=`&4ST}3}fePpV;smz2^+zSA}$5m;w#h>(Psgos)6WrA=MF2UGt;hWG z`^If=2whH-qJ<|Dmbo)BT|jpqnHEMdrdaGZ%joOP##FA0Z~RTkld+~>IqutPk>7}%_f66H5m9wylp?{n` z0xYz+ya3`l5q*K@G|J=+mqsz2loa8{S0{CC-M4%KL~27`H~eZL*+p_I(K*>M*j+w^ z&-N95F6JqwVVRJ**~@c?2$0sj&g=7wIwR>Xh6z`P!%|oR^x@{5%l`Sw_v{>bb!AZ@?I zQP!Ur)S)lmrlSA2nADtA;H<5-p*zIX4=drPtMT&9=M`#$ydtpbRXEDuG?Q5{wTA4w zcHmcxV6?$SJSV`hj3Obto!s>0+lKrgc2J%Z;+8p-4YftxJn`PZW09`I?l-m zAt9daLy+jrwR#>2|L~&mQtYCOaYU0s-yi|&C;$KuyT8@?oo*VkP&wffws!M1(~<4} zbYz(bgJgV;DO=rZ7%zH%T(ec|iB~|Jn6L!Pm)J-yxBF8CYoe47B~F|qMBcU0J_jGx z=tUJaQC3tU(*f)@I0sR8TL4A@!isc1c6+S?z`7e6H zph%s@WxWRT9u#0lygbQtLa(+rImg}n*+~8X^ntyW4a1nOv-0;2zwx1irT)#=rXn--F*rB=`KG6rgiq9Rkn&!Y9v;bX4JsvVWoz{c;K4y_NOf zB5$i*7|-ViPerp}1B+5MCm6a!&_uE89bgE#PyMzzHWld=q$*@~8BvU@OXc{as@23#r;6voh0Rk|N%><6Lh{*gXjvFzZpdH&3$r^&BR z`7{COTRO0RBq-V9VhgGhO}-mg{wBVyF`&N7bYls5sh; zdOIvR>AO1T!X|lZ{ey-sX31;cQ^60CBd5}qV24bn>zVcfj< z9qSvT*m|`RB(m7gw1{;CT=X1WqXV29Uqd4Y_EH&1yLi7`b7d=7gG@%s8sck9$sGPj zU(ImR^p!L_j82;#7N@z$PItF8Jz~Q8{=wAWr$ou*T&mQ5SsW!D5~4t`dcfS2psEL; zGF$9?HMJTD2B>s6;>DUfQ`s0KGL-$8h7}$BJ|h5q4L<)N+%GmQqv}O+oAjpG+J*m6 z<>i>L zQe3KEM+Qjmg4}cAcgotF_UVdO-1&%bPv11gXr(hBpe+77tz)fF!N3GQE~Sm(@yLW({O>cLyt&AuJ{ zjS4bx>y-w;SptkibtkiM`kqXdjV*|RgSSw2S8GD0Csz=o0V8HU;V8u8l z(%Yr>_sfzcSQ$c)O+Tq}2$DM}#jeNdVqj{z-pmom;$Sq5#EAHhyONb)XqMvC-`ctq zPsBe&?U>uJF6BUDBsd!oE%y`U(?$NAO8vxXsh`FW7?-OX*r6d+bax29j_{f8c2!c6c@sPS~wz|A#MM$wqA(|$x zC`3xW0V*lp$>;n*_q+eYUd=ekJWK7FYi4>ru$7f!+HaBS2h~jB-ni26?+)0YwiE?4 zmKxxp=|q|j6JG%Uz*6-Boxs3pWgfE8ft+>0JsKi_8&m59R5j${CMx~l$Y=$lv#5;h zHyqs9>ymUTHc6On&0xR^$ie-Oz>fSPo{InzG5;RebAvCf=?eiuc+ym5N}%?r0HOGh zb#=pHUUrBB8}&4Eu!Ky?A1|i6OTeoAILM#a#{lTmC?oH*%z#eBLuez#fh(Jl8#&K$ z{4VYSc4g8XO(T8yLc_1?=0~C$Rgm}Y(}P+S7{iw&*f@QgdckP0VDgO!iTo_Mya_|9lA?C&Fy|M?2*tc zc0VXL-TFir{XyPChrV^tP zK-tgM?K{q$+5{2{xUQ#utz7dp3^@-bM>8k-@~sFkW6Uk?wi(m||c5I$#Uf3P&}2 zB0C33#)P5U`{7s!JSw&k3X~MFb+Kt7cF7^}?x z(}>(T4pUveYew##gsH7OFeP_Q!qie4nx4HSVQMQ6&B)!AFtL_{X5{WknA*rkh%BBE z_>Nqpc2NKl{nli-Cu@P*O~2G z&+4JU<=OBnsR9820CaU-%V-c{*BM&Iv0~TYMX3afN3|5&{`RuLt@y3m5BvBf7oxj~-RtiIOb4>BuolG7{>@v7iM;UM`|(Ip6C-XT6}t zmC;6CKn^Ad{BvA&w7A9?n;7ZL_k0tG{CaFD-7;HI?P;b3zm+-~Pe({VEj5G< zP}Q4tl-3DFxV=czZRigsIAP&jmF#vHq+!0pDmJZ)$Q6S-@W=&LHmFt zIeSWO9QB8WS!=5j;p#}o!H=y^dD~`_LP6Hd-qB`CN@G9S*3-m^LA{nwOdIm%TCSaA z-E{GiL#G`_Mk!6Wfp!VgZY3eyg6QiS13jhn|6JTv3eQ>ZfFI9mkyP$p|BA!&wcGkp zv;&LW1NPz{o-lO3^+BCw{sIuP?aj0}FiI)SFx`D3f`YrB3@<>JD3t-OL@28Oa0j57 z8SYQuN983mvQNReQz=QHA9LWApC?o~_xq`*Z@e^|1Xo!!rNXQewlMmJ$AX=RN)O(UA?j9bD^awH1R8H_bw6 zonoHu>(sYCBe74FN7|?)0u|}1ZeSCsoY*v=+C#|A9AO!5CFbsJaT4PIofb!V^8^1- zHxMc)f(Lr%^932sho`HGN0Cx#ZFn61%aP;PKUyvUvMs1h+z|@c3p+c)jX^DM|N>10r&z-fYJ3qu6-aUiT1t9Iv%%p8cn(~@GvT!Z=Lv%pYM51@eQIKhn4X-5U))S`OJL~8d zE@{I>!|ieaYZyxtUrVo*5NZX2iTrKUy+KP zOLtx<0fBVHI2AE2)7-6uzV)lc&usNu@azBx(>nS`DL{>aB6^jAi(-E)7Hv+dyBrI$M!YcDWh2KxErR5Rv-dHJi%IRk#Fc2&WXUNH5w3%4= zy6LnG(<8cZ>@rPA2F7@=1fX93{NHo@Nl%W0n?vL?8=2Mc)Tkp&Qa~Ij%K(P4Rn<4h zkd@aC5Ia`E64pG5omw2_=QB~M0hVf|#;0Zd)TB4f$+-7fn#s9q=iD848L}%qqNC)i z6{J&VE*cmf3Ivwb;Ro&@=ai-JItrNm`!ZsY?qoOfx25j%fpu7 z02d+mNpWoq$AvHjc!tiBb~Gu+p9Dv0gM5f2EPmxIhnDOgqTOZWzVb^VcLmQ&;w-%2 z5=T;`WnZRq4rOAB7g)}cIwSlo|#k;&H|GNFiBQXqPbiDGtfgQH5Z_xbbc zojXw5FlUbxMZ)4Y4b*~SMijp{+Nxls1fCAAG`y}%YUrXSF*L5um;R+ID~@tX26-RN zb|G7ZJay4zN`c7za|0n?Uo|ZSpnYZbR%6rg5|Yd5GaxE=O1IaktY2Ksn#!`Bc+{V4 z*!kJjWSS0t+(PxgIytBAOqgg1$F^-}V%xTDPHfw@C$?>yZ*1Gn#60ueo~yG?{e@n= zdiSn+YTdDsvI0OS%Wv29vf2mp(5DOs8X1-BK-k=`F=0?iK*-DGhAWND&Ec*@XO-d>5ExtKZ5!EC;mlUwFL59M-N<)9+so+#aFMFtUai| zb)a&G@OoXRGw$0d7ZmLT-Jy#0%`~iN`4@PwDLa>ikb>c#Hq)Zz;W(6NneGj@D^vez zdOwhs{5o0UcB^H00R-!a*|IBV!3xZ?@Wr}F6M2k<8p*orC>O1q*~k0ffY`2;8(UJNBn}xVI^hW+CV_95K{Dq(8KB zK<&Ip&&|9ndaQEvAUIrnA5rxlyM+RY<^Zn?w_=)wN~YB`S8-?iWenhqP}O42rCSGC zOBRD_b^OaQd}mmbh&WPG`QPgEqXvpDVJ`#%Avk`*4xcEhEI(ry2)_i!DuFQPM%KIp z8`juIP(^jl;hS)Z>iyENMUji+Ad<;|;j_Ii)9$W6Io1@ z8Nl|{zF%>T0CZ>t+(xOcBVUXUM%kdhm zWK#LNee|t=E!eh<(gETc>lC7J0`aW^1^s5A^6k#AJ1nwLg!t7F*o7K|r6%nS3?I!m zC#e>%XgBHJ!M*-4s33;p!lL!&wJv>ZjI3i9210*3_)V5DJx|xq1;t4?fU?sjqS|4>}Qsde-ezuYZv4g0GTO&;a3~h#&(`1HuH7CCGL#Mh5|Uvh{|sl>>7zw zg2CgN^|8I{B+Iyae^Jkd?IM}X(+OkJ-M(V&GY_To!8feTs*;kO=dJEkg#90vb#zdku9d5dXFlc_6$R;D1u`Qa2 zu}VBYj`%!wesmCKCZ10f?7Ea&2AS%u|1@NvawvFG?ijFxzGIxHLjA2fWRGh$(p( zEywXQ!GlA~NYDW}(!b>4z}K`CtEEHzqK=*c6A)?~Bkt+%!yLxT?(b)f%nIqGD+-xc zg@;h_{UrL4ZnJF@(XK(?GNUpy&$JyhLU20m|3Forkky+O4S8z z5A0nW>{6JHPr?<|0U@=!Ty??83wmJe*q*^wh%^!dv*|7V-_AL$gS^*pSb(g$EUu2Ka>E6aZ@xR zDeqXnZo^^ac@^5P-fj0*1ZDOIv6ViT0hCy^wn|%IR||LDU&t}gpz)?GCJ;fi>$}(M zY_HiUiz{KM7^{C3`hi9#(OiRGUG(5G-Vo2Oy*pDSfR}WNIm6~DTr&fn_GN$4L#~#1 zo7;Jfn%su~;dN1T_2hqEn20tldFIq1-5qW!{hVLqIDG0g3MQjy+UC6dutsDhu~3x@ z_9q@v@?c_4e3|@2>E<@nx<9|q(CyV&L#R#t5s!`ofItS9mx+LY)`9SwaRMMn9s2$D zB+3pi8`xeaC(oMfBsx8`qNqw<1Ih#;iGX5cpR|kFCM{(0*~fLEM0W11ep=b0HjF9J zTL_)+OUxO!RBao#6BJ5y(WgC{u)wK&(>RmEhs7w z_N1M&QAuW+Hg%q#{#!}K{8ve5X*K{ieOxU_$>vEahG#?Q_z7rfW1dIHqg%VaWrPfY zq)sXxs-~gz%&i;-VSk)ZA?zzgVS>eMDXew{Q!T#ef?qeaLcx#%qV;z^u6i3s-fUHr zAj<1@^cZ#;$?fWLJ6kG_3Q6X~%K_rrDLCUepd}nqsL>Fpda%BsnB#&qw7s~7E4w>G z1N*9Iiv7C1GQ_G*Y!@MrSnj9`9RUG71$`j^rh+G;i_iXnj^?IwfRJR?Pej{Y0_BBx zA0xz_{U-gYDk7B;Zm3{bL~@whbp{I7HfxyYFkWKO9KM;e9--FjV%^iO-zedxp`NdR zFHp6ZjNa9tu)R0m3)aYpM&rHAL(+P1@R?B3?%CoM2B35DgS^R-GItmI_upY1*Az1Y z32J06AKq_Z1#O4P@REFJ0>7vUbKXqZR;S@YAcrgj!dG5g`g5Plm$U)qfY7RsqVk-9 z%1~>Sk+BG0>j_B?HGZ7S2@u*PvC^vr2nqBF`)|$Re`^<3_II{|uEIV%q4vb4|67x2 z0>Sv)feU1;c}KN@%4T|2&GXDrOOLV1iLAXAT?Q;RLUN0|1vCW|ia4HzIHQ;FJ-?LR zyjB*3nuYvAF3}3kQ%t24MqnQaUNg4{>^9jo?Jw7U{y zZ0Cyb5E^w?#Dn&V!LEYCWh^hAR~)U4PqyOX;s$OJj(|Anl!m1dGxosTdB2&&!BHLq))n#Gm1m8!}x?UKi*`~UITS$yy4>) zC*doi3P+I^u#cGcU>GE(%*cp*+-TZQ)2!G202lQrAX8VzNIc!HcW=pK7kqVQ%&}Hg z2|*^dHc%Xg=2VzKTPNAUT@`|6uM&2j7zhN&fo}D|4-V1f%*w51|8x|t&k`<^F(*PK zMwaJGaJleKTcG`skQIeCmb<0GEA+rOOwDViD z)_g9`)^Vk_l9O=~Yuq}4KFOW<6xynqV36z>de{i?2LHH!sJMPK+Ig8o%2jKm7bjyvG5P0SBP*YBKJ}~VQRs)3A zZJ_Sh7M!x#?YMU+vQTJ_O}5l7mz?x_LNAMhbJHUt;FkVb^6Vbqp5jBy;M06yo9n+G z=teFA7qf?dZv&4h2^4L%9LzwgtG1$YbHUX(#eFuTWU6$}sE?st{N2AhXy$FHM|!}C z?_$uLWq)%Eh|(%YKA?yiI!=4SY9;@A(%UK)fZ{rqVVDZ|`$^c$4n8$%e4;_FfoAW9 zsj;e^gVG3QyGg-aC@8`cg?^kySzZC-?m$l8Ts*^Q7Q~->Tx{|OErzG(v{N+I>Yc44 z{;Z}{G?Eg`SktAoXgTh4(WaKkOZrHN_ZP=mW=T;YQhJu|lQj?2(Hafn^`sY<~ianJIZ?!&I1ll{F z=fw2m{o*UZG5EYb%gaP1e)ntW%M^)rLQ>||FeNy*yT+X~_D~x>7OZSA$F(RBqlH;} zHzeaa{4)+ZEGLU9dHchAl<49j!cMckH~pny9^9falrtXAtjE83)v!m2c4&_|>1b%x z!;BD;8~)az8;~Cl-y4}JlXacQme?C)Dw%q-+Do`O+HxY(#~km7jFn*bO@5 z33?aaESrrxT;!Fb!@(B)&8%!WfSvn8c|d}ooO%3!rGO@Rc@R}Gz}5ncEN3YN0?%+2 ziLbPLDcT0^ANkdc`XClk3RHbw^!|JPmG$W{(`3YkWn!ktpM=y&(m2`Bi5){0)mQ}% zr0z{Y*SnN?40V3X-tkU!;I-Pq#A=_;OhZZ1Vs>3pcu%6X9{KvXI{*wF&IJ9?4Cc0~ z0~=@EU3N^vEEkxx2IwWX7&!sqj$&breOan$y854;K5qEKfe0&ZaFN4&0X=UiK&q z-`4W7I}qW{eTKk-8gk9PS?dTr)@|)Wz|y;W%^UmTz-dz!Li`9ZiRk7oxc^h70lPIh zLHJz^{MyXE_-0A4Ijbi=Bu%)#3IU>kJ5wVZqyo7jp37NK2o`9z&%T(c7G`UF@XmGX zR^FgE;gw0(5LSN(_J$#PydYLX`?b5^T=J2(Rbiy9NT_^J!;HKUiOeBIXs`pZ@r?5a z5~0p|AH~qe(Cev2d$FQMOs59r;a(>2Mvf#&TRx!;ElnV6{K>*{B8DU|nV@74*HJ1c z_8Le#t^mJScH|X+CN>cmikV@fyT%ukYLl*3+u#wwGy2B3Zx0B9SF9w)pn?(+1(vzp z?-W%z1sJK_l*wf4+&ca6%+w;!;!lO5UZG%$=_}GV@sD%J!fagF%XF}k6KN&p`Ke3>!zaiA5lHvW}xLtv!Z)h7HX zod*ud#YTnB?tB}mF%%e6~A;91z=8IPEx5X^J*vu~_oU1zt(g=+hYi4oDm zbykj*oGA0?B$i%R-UVu3Lo%4C)Mw11Lv?-wlZB8AEBU6^KITrD`@0^9##bv4FsH1J zCOv|pH*4O;4lxn3SRp&Kr+YpiF1A9rjox-K6? zNHG>pB)heMi^ojPObLxTk<5S5U;SCRFD*u$R*?xM*s|kM5LWWNUJ#c$u;4>T?!pbU zx6-GTaM>|)AYldGRD4ZjxnMIxov5w5mK)uO7=Nl_@>BC&%)9siI`x9M_x$@1(N>xQ ztS!&HY&k;#3)^pp;Fgg@qqARw3m}h7+1pbK@I}%Crot^sm(lch?$m;5Mtkc)Yr?Z6 zFYJU(L}%fbB>N*qSyxXdPp@7DyIYmns99{_k*l z_qK6W)kq5umSE1sd-#QRVkC>~G`1!uHO z6pSeH30b0?2_|8}U~Iy46d;}N59V74IIn8G3aebln{`Y>HEgMJ-$)#lnu?uB=*EZ37*eOF z=4QZOrO~oYOLIZkO-0P-^!xOPKr$!q05WOmU~ySdj_UH9YVE_$oA!y;eysAAh|;mR zc)&yI!J!`{Vfkkcg+cO_IxK+BznCN3e>S^m2frrF`6-YNPKknV7gS}$o=}-A8-7h{ zwH=@}VT0MhDs&IOx<64OQYpx2izzUa@xDY~;2NWbI$n3;^HM9R4?Q|HsypMiC0EWx zg5X@}Q=A-QE#y%0WOMi!9PNprG@PGU4rYi$)92u1vN%zv_`K%+N4}VEr#_*+I=X9Z z7xW|s9hU1c>1Z#yL9(nkb7wc|`f_$MO?>2e>G1Lj*^5bC34etbd-cn>1;<(_MBtD# ztdkXyIZ3~S90fj}IV74HAa_bqi>o+zPEvFj8fY9g*dM&_4)^pV^=XlQlu)t9=|pBv zA5)Lma9JwkXmuBWKKbLHnSpZN1+RFLJu%UKXsUbdts6SjPS=;i`PqN9!L z9dl%(y}fqc@8XEGAiez;H{@t2AG*`w$iut){W*Cv$KyOz-BAg@2K4T(Ht|(V{XD? zoWNPPj-~|yxXZ&y2L6!`BAFO$Sx5bD6|W9U?nzZV#??QjdLLDO+!tX)(a*<(`!#&J zVschA10w*83>U2sd8UW$wVp!O?Z0mE57e;)2=xwyLfrlC*Y-mYHbVEt?Z@nwE3>t@ z{T@XE6Ox^DDL2vH1_>b~W!( zI9&5RK^rr*!+q^IO%Us6XYOrKT45)PhQ)(>%4a35(X+eO&?4CRv>TXaMNXBSXNDHs zj@?TITro;l)!~R#Z^{`!K)m<)NF*Nri95oB59t%PyXt`7gW9fP=(fV?^l{F#RNtZQAhHT#@i?aR;ImJA%=b-J!4CoU zq85hjGepIgCti@8P91l8f)2zYSTI#ds8FKd<@Grgpk%_~Inw_@(gkb{l_|KGa`fuq z@fqZ)EB;IFO(*w{^f6*v*XsQ=y0J`ICh7Nf`IRid;>7fe-Kl?vli|C>7k%z2KiltE zZs=UNw93RAW-3`^zIO!3i{7zERqKDDa=;U-rEJCWMI_OTkzew2`aB%1u0w9jdUxiB zJZ~GCT6ip1l|1xq1*QWbIf+y3%j{{$Bz@85dOus){Od<3F!BR3N8?zPH(4Mfr~7nP z1`p;UDckDq!T4Ou1(t>6DNM(v*0Jx%$X#@Wit}ow4UR!%%$TU{G6w!t?twtV9@v36 zq?Gq2-b&CA95Xpz=hsBWo1>3)tuuFAUDq1^06)un4Ta-Eo^_*wbN8~T3F$F#!MTi9 zGpq$gfQTs`)7x%@Dl1A@Iy+*XJgx>`s*vTgNc%|QQDTQxSNZlP=z?i>Y1jl7#$#5u zAN?*4iSo`-AhHcND6>yC!MU-l(B^Hh>Dh z^)g}qu85g^lIfops;PF@l_YSsK4f^46f5<<+b9RUmbIU<0497?^}9I;5=v6}Sp4OY z5wl^&AJaTph}9|0yLwr^mLg}C!3CbfA@azXK?5yz8f3FX>5EZ0(+rFm#LFk0)gI@38Y~3&(HqV-1egmmS~GL2SNO`T z{uJAZi#dQoHldU>m_>6+MRKXRM8L)kBPUW`y86b7IT{`4Jb;T6}Mm8t)tq5Z=gSsc7vL zq>@^k1k?rTmk428s}oK*%kgmx;Q3-^h2Ah1-aBK5(_P=}z4~j6E(EE!fHM0Hr{g*u z$yZozDAaa=r-e}L_-f=)x%*u{deK{BtIJT^jK3`n#Yh`F4r$U2{%sg}rHp$V&1XWU zMST~)kfCyjG2pAK;GB^|v>4n}Fq)O-Azdk7BR24t<=BJaHC1DVI{&Ba|ej zOf5AxrrKJc#z;=#nQs;1Z@$!V?$G8u9zb2!%+|LO2vzT4*MxfhOml`Raf&~8Raz>N zu)kyP6Na}v!vzhpek!!BzfX!Cv??KT^e;)LB4ETXvMA) zNYf`v)ouK~g;?v>l9Ux`{&v`qw7!46v6~v);Su}w9BV;tA6*o&-yEgKl8Cn^%Y0N5 zZJ-2Q*|90U?k>S(an<5cuspr@ff|ZCOh)8Lt;T17X~s; zxkYIPMmcdlP^zG=32okT#Hh<=_BhAk1mtu_`RO#I89QHg%Wo;$a1Ct0WNT+Zwt$Iy zZmm@*j=^oTj%TS{_pLDOkCzJ&w!+@BHp1W~h)!F^!d*?Pa!8uTosEREe!AVv1cv4`STU${K-zF3tHypkR`~!rT#ZOpzb_qcyy==k|uFa}AVa(rHuTplR(;N@0n)qYliBrmyq}G<-^q|*t^K>EH+L1> z4vg?}A8MAkC>cyTji_=*&6zk>ASVWLHdd?49-1}Ig0{`u(hChTf&(y(wDtzeUXp}P z^`W3!_b^+Rs`ynY*!H{`5^VT=ydw`@wSLuE*J681unuA^CQqQr{|2 zUA#X;K+v&5Ly0~_Is=}o@k9*40b`lXmgy9u>#+J0_uhA{mHs0;f7(3-brs{!A zh^m)7>OJ}Aet4o}G!IAgRcebK9S3rcrBM~VF)dJe4!x~)H)Wh7ewi_!s|n7xC7JX? z-U*8^^xI(Tt$&j#9$%)MZu+{jb*>(OdFS{dEXCW3mN63RpLA2)#NV^lOsZ_+OSSo> z?9@hd!!X9G*Ez96&Jqtm98IHpPBe2_Fh>{SPw*@ZgDoS-Q>4(wy`UolN43cJaXFRf#nJazIGx;kd!IYN_P8J=ChdA{^R9#tL_~!wwn}E7c#5?xrFo+A zY|&u4b1*ta1nS=2#{@CAcU0?oq4>cz%zU_706PUBk|56z4K&djJs`oqmmG%Oj zXW)xrk(Rf;t$-^Yt`FYA_YW&gnBozWqV>J{Ou;#ylw9yHeN5Q+qldP}IuM(#SAiFL z0AI&ccu|GU9vbf6cF8It{cplh@srH(e`Tze;2dFD2D1o16~SVKaZeb=Zo(bjke;#L z2{x{zBA?x}H%ZKh9f--n?pW1WRn0x;ZFgM-TtS;&b<`R3S}KU8XjO%52X^#PG;T{Oc_*FS@2-iYDDYB zKJJ>UDPvzZCt8L@!~O1(w+{HwyVqsR?HqD8eTiCeLX{4%o4e>D}^NmU*ZgHsq8K?cA31#D~d#BDg2i zM`hNMyzwz{X8O(sef%neDhkz;6Y>M&)Z(o2H!&pnlJg@7m|`Wi+(|i;L1g-lGP;71 zk^Oex;4q{UkzM=UcKGaaqW?g{1vq`@Fr<|(vfHttcq9g_5{16mR(K%(iCeJC7ROB5 z5*28Dsf9-_5BSs_IH%j>0A#co5b4OBO&f`9PpE*kCY-V2Fo~d)*?yeI#VhGN5ygaR z)A{Dq8oq)y_d+@`GE@x1C?leQP1*(f@2n*f_v6G}_0wz&OT_erFBEdYCjz*A3PjDN zD@1`3LKL&zB|u1Kl4n&08q|X~@eYPjkU9av75kGC0ayf$Pt;JWW2}W6@Q6BCPT zDq83m=QK5g+(ZovV%HuC9UqwTam_K6I}YJQ^A5;%P?*N>WX!aA*)x$zjv1MYhM1Si zX7rQ&x|(XD1_L+jRcWwQpvvAGL{aP_l?!!~o!k-XwM!$tW&@t=sgY)&sbyR@P`_r=q74mTdouKog;enAy)o>5W;&ILEgdA6VdE-w`j zf}7&p?@xj1>i~X|f+@Vf$4Y!pIL}OnEVQZB1Y$@=*XDH6^PgRazj*F1lEIED{ehYn zm7e1#cl{xci$gS2yJoGRQl_|E?reb(_>uQ%WxMtCwPt!b&slGJTqItBCNSCRI_e0tMLcU&v5qTr0|`&wb5Kguh=zfEo#g}t%@&6HAScVMpJ7` zM%|yLC1YC`ysNLV^IRP#IY_eFe1517j{-HmPttg_z9VUdpDJet|ILe^ED0u{zOpYK z;2|~dI_j_7F!AfSQvYTl;7d)#&bcw1dhHnT%;9;?|S9mdbh7pwB1vd^Ut4Z^A$c#E3>9>^dtj!eJd%b0p?! zg%+Wpkylq+z-J?QnPJ&1`Q|4jx^^QHOX|YxrY^LDkA z16^rJLj$bQ21!=}XC_>NN(S_F z0Fi5ta$2vSO?V}^GP~YF2<7->;$N%VUs{Sa4Fh;FDs(XvsNM6?NV>$3Wi(wsm@QL& z0GL9(wlI^&0iTNpEw~>SbjT#oO|NoGmD?XeF}2yaV8Q-a37K2X_t7IR?+?1gVNXKo zcipwESInyzLg1Re>b7=_*KHa+^pIHY+1G^|{lZ{ssY~E1H6>)8 zUd!6ZEc7fe4F29t1VMLSxW=%Hyax`^5;b8~-x7#qZknTo0GV7^*h`*J`?G^E!&t}nl1mN;$C zvYA6x<#p19e4irfsi0EEiam}mC)D!^`r@{mKK@MfqfX6uu_N7k=ZQrN3SNGeY z3Jkk18zR$~!Cz}osc_Y1YVJw1)m)a#9lRX$w;CVWjy76j0P5#Rh8UX6m7&HprMWEX zZ`4CtVvUDYV%OyegR{~?k!QF#$~yJ02Zq&~`L4`|-?qa{3E<9Om;9AkRl8^|Amm9g z!J+)WvsUtyg;LnCcKXBzX)Hfml7>Ndu z*LSO;G&NQPZ*sKrUVN04U?&kz!ISW5zw~I}-JHO#d=KG|KP4`>i{}sf%W`Su;h2#; z4$2w|=w`;xpQF8B2AN5{8)>H6K|&_j$;8e%;acnB__f)X(qR~}r{81BzKua07w08^ zhR-BrUE3GppuEcavVD0M> zODbFZ5rNCq>TUGy%);3*S<*t5Sl+?@~L;NyIFLK zTO>7W5pX3nFgWRRA2qHU*(-glB0Ds7(WXTfF~Zjf6!Y|Ww&vBfjvT!8;I>8;K{>ZV19wT# zI`CWfTS1V=D`I=ZaCbk?r8BCOq2~_IW1UA`6ImQc(NeX}9)CqVv}jmQ{tzK$SYX%6A9^Ohlab`ha5Dwqm%uZRp$84&Jg-v;bO@ zpI5(A9hOMPu!zN5+&Xuq>#`&8{H>B%vTf8;(JXm^m*c@2h|sDJsmz#i2nj2zU;#Su{`l{3>$=iiSZKdHQ% z^nE+p?Ihu2)!-iaS&zzWT9NyHka-Tjh~bt77=?N!NvwO%IZ}>5yd>MRNKP+z47{>A zvGFH54g!)@5TmO$Czmx1n(MF5hl-qjUzQ=v6|?M*WUx8?UmM~a_9;g)XM^$c!z{J9 z3-M!}EW{5Ss7Q@MC0F~=6J=sf|2{Xc>DoIC!FDuuSu5|}A5Ti0trUiV&0?~#Mw*Jg z*q@GoX*(75?x!|uG^Xi>?j5m9IZDT`C+)>^!*QB$s^n!ypYcFSjTGpAemW57;9ku{ zC5?G=)nV!G2MACuub?R&E?2;+5Uy%HqO0KbA~Z0TtVjYs)gc|{QQ)DO(l76eNmRwM zmJ?K4U_Cyng z6XWb>QQp@W7wMX=lj-)z*oh(Z*?JaQrJo{@8Tfx)<37in^&CfXLg=z5m?fJsZT&AC z6tno(+>WZnFkWW9>?eF*YSI1#1wWb}m3MnS$#ds33x&dHa|?LwcK(AfvgObhq9^$N zUPhBD4O;mjl~N)IAFG4HmU;%Ax!pbWiE#~tdP?U^*k5>%qx=F0U-lXx_5;CSk65Uw zD5b8|;P-_OXu|G{mZOPL=3}g{#z!Yk?&d=3M?A1$rJ>*evNZFQWj|nzw?_2En%v!l z1Dx&lW+8qHS*2gEya{s|8>G`nWMro+RmfWJ$e?_zmzgm3I~tVDXiiSNJeII-lq>p{%N4lBBe35WgB)bU1vkx z!!bjutz5V8;{da6pj#y?8LXt^E}F+`pGm&L?nm=Z`#JR-hgJ{wZ`r2u}v=vuXExiajni%$jylB=RQ zk;S3mIp?w3^RecyVv(0Qr#YA2k!Pi97_;>D79%Wr1UXxjl8ZAgPsK@EUp&>$hOA5{=DFlJ&dfj8Dbb z91LTS!ZtL@)s|31kpB_%((a2Ef|8CjJbDARkRn@a>BP0M8xL51laOw4`||@K>OOjo zh_bhcdzWmIFbp_93HQ*VhMAWBJ|e@fo$>bRcwMdXqE1sMV(93dhVrTtFw0lOC;6G1 z27+v-h5^>XAcUDK(j~^LKmBS}2*K>Z2?Ra^0>TQ=hpmMPA=#j05cT(<><{>N>mWRP z#f>e@;mMIp3-u}bxpbWKctUnJpYn45QAI%d5?So2O?=suW5DJ44Yb6o%Z= zgXm?@quiIw8i$JBKet{de4Vm)P6F7=FS#Qi_plapshyO2TRS4H_eF}N2?l}D-|)x4 zU4Pusr42gruC(%~xg2RjoLU1Q&puMmLXD->jc4js(S8@JoN?2FN$w>jf8#

n zeUhqNLq`ZJo~Pbk*2hB4Ai+x=3J4g$F$dR0{Dtq<1)PyVN^*Djo&<^G-{ZzFJ|&LV zR*)GztkW>Lnaj;U>jG^{2;~y7U+T6%k=<}T5Jt}$YuS^5Z z2-+iTT!w#ry3G(=jzsJ3fWTd*e#*RFo0g1yz3QhJk?>K2=xLg^_n?r=3{hA}U%w&E z#{>LR-QBP;lP8U?>i=v#a4fYZ@}Z%oytnr#0y(7w8g6WiGy5x^xG&O!0VOnb5j!C$s6f+H~KR zs3A8>%b2{tEvoc)mx0xsE3>I+fKOFUbFJnD!4hAdN2 z-%o8nILAc%MD^Ba^hK_Ver&LRg#WZd#|DW+scfEK9o|rsUcatr>y>xmSX21L)Q_he z?T*ewo(jsnqN0}4nC|NOxc5(R9^e*=Q+jcjRMu&M!4$MaoJq21H ZFY1IM_^)o8MN-LNnm_5^Mf`v3e*o!9L(u>L literal 0 HcmV?d00001 diff --git a/assets/screenshots/audit.webp b/assets/screenshots/audit.webp new file mode 100644 index 0000000000000000000000000000000000000000..ce3204b899abcc6df72d585cf1c8a35c696fdd97 GIT binary patch literal 41680 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 literal 0 HcmV?d00001 diff --git a/assets/screenshots/logs.webp b/assets/screenshots/logs.webp new file mode 100644 index 0000000000000000000000000000000000000000..6b62066f8cfb11ef1de37998f880b70c4905f996 GIT binary patch literal 128788 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 z�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 F007-+_p$&0 literal 0 HcmV?d00001 diff --git a/assets/screenshots/settings.webp b/assets/screenshots/settings.webp new file mode 100644 index 0000000000000000000000000000000000000000..c6be27a29e489a1c23361acd90a0c6e4001005c8 GIT binary patch literal 35890 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%0Ddi5^tw1w6dfv{K2( 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 zNV>30r~p}D00000000000HPFe2NoJI5ljez000000YNBVzyJUM000000000003l6C Apa1{> literal 0 HcmV?d00001 diff --git a/assets/screenshots/upstream.webp b/assets/screenshots/upstream.webp new file mode 100644 index 0000000000000000000000000000000000000000..92ac34a289190baea835cbe496d7b7d7e9fb5441 GIT binary patch literal 59266 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 bPOA4g=icsr-_y5mKlDRCbUju5 zzN+=jHP@VDjxoMcmJ%1IN&o`V_#vXGuE<4%_xC;02S_$BjSxf#sDKAwhICOO2??>X zWLrEUw59E*VHdIwSaWYzv03glcXPOxfWX{%?0D~ex50PHGsMa8vBC4`O3-}mTdg7@ zC*Z?(#9-BL*$dXU(Q_7X{VWfFT>u6E0(GW)_W%^1Fy9wnjn9Lh=7oS*@4;ulb?%|x zzMt!}CV=siF_Iu2Q1~tL*;+3+{=NB~`!V`A$5%=UG zRguuc0)$+Qa(vQAl;D?o>IHbd9W+NMC@gGI54H077`mXB6aBL_a4q}yXqkzrU+2vY z_Q3;7nGC1q+T}L%rYeE<$p5qrT7ADXr~|ZJg%}aNj`{7Wgqi&E9aTJ%b14MwC3fEP zpkO^I;!&Q^QXfV8sB$u{6UC*Q3It>qvM{`Joa`sz0fwUZJ?npMXW-{ayMjU+iu&t) z*VccNvj39L^2{IKP8&{};30K1V-Iir z=^HxG8m-{83&1c_-J5e1p;BslDF%D~C^z8BnTW5>kGpB+eRgkoAcKeSLz?V~V>rgv z?uxF-XK_xMu3g5SSDnTGA#1`){6GQO_u@xOPb8rIx@3ogbLMtd07(>-4AEP3=_y@m zb~!yC@WJa{JG9N+Plb!DJb+k`o2q~0nvr&MTBl|f($y;;lBS0G*b^MidDM3bETBPf z#s_RMEsd2I%hwZzqIb=ryE8|qAe_+8xp*M~o5Fm**{Uv*uUo_F0ZNyD>V5j*#)I$Y z_IgHl4yxEpoI@fD2z~m83VGLYA3r-17RWoBLGvAwGPZjI3z4%3)R1r_NEx z9n4m8rgqqn^y=R$OwE;!i4(WU#xb-uBnt}wr0I+kJ42J)1H@!TouhN~&k8@t7FFpf z=S)<)h<`EB^WnH->AD z%DhuzUJOLD%V#X9u}tkd^xtX{wr_JVcfk00Do*D!n{GxykD4CtjC?%Z^al|cJK z>u3snnq^=EgY;iCwxM79TY^GW(0|d`v2qmWK#mlU@?VQG_1^2ePzvjElJRCTKS#*> zEA1Z@C!EAj9MZONgw~Hoqu^ykW!-Ko84^1m+;Hm?bEyK7Zl!a-CN#`?t!;)^(dWhl z(7s~37gOSll+WayWYrtO#LJrkF|(M2$)zd+!=2H1v9c#hHQQ}M%4c5?ASNXz#7G&P zIgGwj!Sqxd8arIXCixTLs3e6-x$P0aCT^vR1qI^T=bvjIF-j&d)NLyF66~_Pyg1YX z44esrp%3vH#u_%~TV{!I^HV4Bd(Yz*WZppqUE>YAyj6Z!Q7K~uBm(lg>ETsJwGZrq z4(7xD2WC!8Ldhl4-JHcCs)Wdy?#NbWj z5}6X2|2#1#uuLgm2|;^z0!(1_PO}2BZpN8?aU#IQl{4tSxm))AA9)PU+Uvza*JKY z|3bk*xXwit-RN(lf7b77wWdy874!em6!9`dD)N1+XqJVOnxlQN_%E2u2j?>K)o$&{ zT&0rM%t49yifm5(b?e&~e?#Y6mTTOSufrO5ur`ien%zeb!{DG;EmSQhJ&Q8;^$SeQ z$Dt-#$v-+n5uEeqG?XC!4Xrw`wFvni3`knqf@Gph_EW+{T?j>Jwlohf{j6HG=Tb}Z zVBsP}79hR-$@UVxMmv0<-zl<-Z{}qr>5vD#oDgIsIhx4F5KJ-=fET)JtA)OBBq@3+ zDfrC1G*a0A;5?~q9~3aY&3mNZqM#)wT;|J;frJn!Cq%>7|+;%b$r?|fotm{@2 ziwV?K2T6%vv7L__KMn;p>dszn1)A~DMip70mD@7J*=IVPEXtxSRubGU_??K#txc%< zkJY8iisg{Dq5qu@shMk<{wz6f*NN3P5=8#a$_goVs6 zN2iNNXx^<*3H~cla-~kJBm>q<{tAjheBviNC-^6KJZx+`!NU4=$f?qXO4rJRC+JLgdV?r z{AH)IX3bRG$kIi9HgE&0>MxL2S*m7b92l30cuuT%%s&T(#$OuA{xQ3kMZ3xepr1Mx zdQ4`l4O`b=eim4gqW6zpHKIz9?l%M^-Iz&i87ss9(D7rmPIl=#`UIDXzKfH#d~P;M7u5d8vp1TL!#U4idd~?SpD*_() z5JUX@TT}90ofz3hvH)2XT-)cxluaaF?5zb;#S;S z2f|`LnT5K(2I$Yh_D))W#Wh&bDofnhu+~HM4nU+uhSYE{@h^QQB_KWq-rYYn>o0!+ zp2V;Em;}a)!)pKG2G~(#FU!To8tZ6{cAzGnbr|Opmx7fMGLM`{c57`-?VTpNrTROE zxp315!ryW5Yq4XJ^G#kPc$U5eknybk$n)~h<;xv;w3DvJydF&Yp-5Joq;qb+Cv0nv zXpmJuN$o4E!BG6GIP$|kklZAFfxPy{vmq+Vbq*%lpHR>&Gs)*Jw+e4g|gDvG5 zkL0;Q_jTGyQ&(Y?T=-Jwh0F$v7!Xh*jjuZ4Aaem56H>D$SR3JtP|Ht>7dSju)hY%@ zSLT5~`mVu_)_?x|gKE{t3X161>3%)?Sbg`GEOM~Ql=@|?QRElw*+ty344}HeOpx%# z1G2f|G;M2qg3qc&f$s^LasD0|zBlG9AzPSQAr)AZkWcG5==5rG zY)XA>OY+o%qa2nAHE|R13Zh?c7KQYvE=%Gau{C9Fsf?Dg`3LdQe`wEPdKv>h*@`|s z9@DyPxjknW;G-(ls8l;N>)YimRL4KL7j<_vWe0MaS~6w-nFsBE(n2yvVyCN& zm5F(1XT*PmylcLIBvc3P0c8~7z}_LJ^tI={4n5L!mmK^y)Qb zvp0M~wyyh@1;D$fvy(~3Z`F^_s5X@lC3Knc?X{&BPi&*6Xcn*_yN}CC4??)El|n1* zovjoOGei1{rk#}{bpGc*B)qSwmELbe;vEuQi)tDv{PodI{}-`p_}K+o?}ukca{drH zP`;%<53JCq_>-l9s1T~kOJnOHsQ>!)Z%ZN8za~NOSypDa`kwPX#2;}A-+&1iTXOi{ zFf}hEZ#ngM(&q$2d>O%`C~CW66S;Eq1PjpsE+T9OQ<-d$F`S|SL zj187UW2MeppcauOxL(5e4O!1KRaHn#saPOs4LH^U%0O4w&n zVa07ND$aaFP;?LW$=~P`*O<&%1FmVySL3NKvmj=5 zgCDKufVL)B2Y~(o01wW8-IqKV7H503!LnV^D8GEQh9o8#xi<6v3)S@B$SHL1a4Nsv z|9zkA9V({(4UYet%aPSRVER{P{VVpF#C?mJD(#pLXZn0Ka&)#B7mIJ~5jSDF5(CV%WlkqK+!!Z=+W+d^^g2~_q#d*q|HrAt zDE0X*F1LF7E5f+epiTGFA~Pv^-R*adh}2 z#%<0Q$&n+^s~DKo^l!rYUnN3Ywbsx6pZ=Taq^M+6pdZ(_Wcvn@ba`Vt$jShtL%vNP z(kly8CLT)dH7LVVOZ3$5(bdtETg)tIJ&)VJljMIk2Nm(b82fY3tP8FH25?+xU$U#_ znz*P?1ucw$K@-GC#g!i6^CJFJoxe#88IhV@hp;e(68+l#AKPXRv!UP~9sg^J{)?~n zfA%T=|BL^Vll%Y842AgveFNq@sMNxn*pi7DOKxZN_7ibS6+$Pz;5dre6 zPyDtzzj5EMaujr=PMwP4*;t<3?EC8{BKhe%3mc1N-z2AF^K_T?;6C2h{wCSJX#mQY zCSy0aWBlCzBV^^}&4@{p=L%)o76m=fsjd*F<2S4#=We_I<|cwYe83MWaPD1fTr+PT zZzWgW(Lsv!3@p%n_>fZuiucj`;_{ur;9_>sN_Dq!9hCvZ5(>>g;P>cwUh97XYv6u7 z(Cc@?!)t09j}4ba*JDTw;#6J>#b4Ji@pjTLw3IW3s<@AIEW{@W$(!VuLqHG#D$v7R z|J0dj&xj+~mQlKrDV1{9q)$ZL@C}$j`3<2E&0l|PpX1+VLg3==F6xaNUW_IT zl`>W<1bk#Yeh^;DruI7~!?Rh&@aFfn$n5y+{xq+f==clQ%{j933i$xS!(GMHy(EDW zrbNZQQa!yg@SVSiqhU9SS?AXYD3$Z$&>{wP>}_Y9AU?48A)ZWDfsCWlc>%<_c(}Un+L;)fnI6Q;I znrzNCsF-O&9{EP+Xd$^0(+u~nZs|{a8@lS*>sJ3&v=r90FgV|j#;7Z~?$ib&Asbt* zx!?0DJ;Bvn%~LX!5rb^EujW0fXK0K7K5NTi=I^+Es`*?D^bus=1`5v+Q3G{qgE`QM z)zk?RiXiyMr^|Bqd+mqp2+3mTlWoeSrWh5|{IZJrZcv-PR?MB%zeOmJ=BQO`6;EH; zDuoJ6z~IaThMgw4f?#OF5{ja;b#}Z8gto?kV3uEO_afNi+pXdZ5;;99U{t1eRiKps zq$F&Sg5d(dK#11h$(KlD6*bxq|tT zOAD*PN!p)B;mLJPKVaa1fp;Gi;@_MzGo-XbWNJafQ_SI!)p+?dIqUz2Ht@j7J|+hj z9q;^&z+l-tHi?ELeirSM`ceBqJGJSt34{RzmK5B{vso`LKXUo8gx>kh$@#~<$_ zb?>Yylo5=BVA~HB8|iV2V6I$LY1RpeCaCEuyTldi5^_nV4O_>U;KRxqqbbhg=vsw?H}M;h`u$ODJHXuzJGpAcb(!iT3?QE#7?H%ZEoo0 zxGzrkJk7C{EtGzH^MHM=_T#lfrEg$jE@VAbxAwsD3bv$F!DMPY4kB%p!FHz?b^fxA zzVxyvs$B-|CVG~hUZzXRpY@hhA{g878^{ZwXUQznPebk8EKQvu6V9IhTXtRC9|#Dz ziA(Y2N4+J%`QHyfpdfC8y6r?K#Qxx6rdxD9P7*ioiW0un>?WWsgTy?|2MuEKB0OVV!$eqxo0Vi`C0!I&NCqfQNf^sgWO9}MNLDM2b@fZ zU0ctch1~D6vH{IDpqp|@ygbD_96RkIgg+VO4L_$(%6hT+RbAi^r>*G5Fp1LLf z_rjsL^TBKmsm+Zw&q5HDX0#ohQOeh(@V!j%o>R#M+?WdI20}Wwhv>V8f`RQL&en4F zL|nu!LaA~}HCK^R6_(0 zjQqh!vmrV0s0l)zjZ3dR_Y321lypd5sH*jmFtL@Rsz^g;VnhbWdfMPMyQ> zd=@luUEd3wiQ#S`^Pxg_s-TGZYe)N3{$W@&Q79)~-MRWqt$MUh({ryj-Ra60>P?M^ z+46L8o9^+bY66@}x-um>5`H*Tw?FA_^hLtwQeVwVp}!tz?<~KXB2>POBwC-H$^vIV z`VA%>P3-)@12pVg9^@B%Xlahzdi)UWjlF@1@NwGJ*{#9O#mJ|t1b%m{xT7oxR!CoYQ-br;1}-#`9hWGRu>l!?Q7@e&WV@`|b`<$8jp>jBtg9nH zdfi|ZDa4}vn@E5w@WL*YW-IP)ibUg8SEB!=ea)^-U;{qNqFY&o(;Zlut7$i$Iz&8! zKYsb=W?2)bS@fBWiHOj|w;xf$HaI47vX^tdwK>biCh(tZh;vy(`L_LbR|`zuol{EM z5xAP($;PbJLKcf_)A$qZJIqe8JR*Whg&&s3uIMHR`((`0FND%sZTOqSZ22C3Mz0m@ zBIH!NZ{fEB;B?G8rYr57r!)rEMKE1Mr67YiH}Z?*Y~oF(gNnNx*2F+S#7Ex4WsT=P z=#$N`k?{I6ZwlrJdg8=+SWpg;^4WPIO}6eG#t)PCnfBa6+SwmA;t4iZ_FDqu(oB1l zyOZ=NUR?#i^idqcbwXT29u*YntM|~djWX6ok%7_;O@&~DzXlWz(U!kbK^Jfl7Yn~i zpkD{^Yruu}mA;R4fjzeo+jxB&O>3*TJ8UV5G)XI0{8u*Sg5L zuxqEyy3KgxaK18$Uh%cM_;O|wD;pPK@ZqKtkCev?oyyhO2RsqqX%B|hG@op}6{ItP z-S#vzNl(my(;IKpQNat0f}iuzZD+$Mjd|zK`fsB&@pTlcyH9Ms?(!+o%oJfhw8CBr z29=14wnZ(b2|T2-=4YtSf@jkDZ@t~cYckRDHHq=lOrzDJbG7ufnC5FE&dM&aMS{*$ ze-52Plnpl0d)7L;UZiDVznI%|DlG89-8S^G;=b#MR#-+}Qi7|?)P0P0VRP0~=WPM8 z(;e_0T;~Mn#Z(=v`}u(vOSw#hxJXSvS*M4=9V?fg$e2Ny!ex=(Mk`}RIcG%j@fP}* za;gOqoKUPCeah#sjOIyQf6(-@eu8Vp_t;2#tYH-oL!pzmmT4N&v4e4uo4%)b;y|$S zbTclL1Yw}v;v;8y)KVCT4;>3aXjR8$pkn=bmhB;nMrV<1^5tPAip0R~NW)$5aTa>B zLa3H0m)Miz?G=$QFrHHRsp8ump*LQnFO>Kf-xTC=4Cb1YGW>U6CWjm@7aVhUP}@51Nw8lam1xAhy`|B6R`Nthp2^YXK zlpcMzC6;#5uxb(u`lnQm1niDycu5u$BYL#FoBWc)Qb%VOk|fM~E#Xv;DFc_IDQg+e z_fNhUSzN4RXQ5{QeJGF|h+(X!Fz%Lk2s%bHbD3N_^d8tZ& zOjO_yh3C?6cFm2q(}1pe!OP&ZMU-%oe}PVPdThBmbm${IjpBvd5MWG>jF&9y*v9do zIj*W%2NvrK9dje#Vt!{M9ZEQMSiAR!#r^B$2VnC@-CHNClJZm@>r8hh9zf=1;Xh?W9rsuQI-OD{EBDgc&@mAh4|309l915ai00KQp~ynP`$ZuX)y<4;IaSu z@Q)wmm=6ts=zu>CHpm)9br!`Y%ILYpnRfcTBQAp9#-B;Dp`0#&eUY9{Y8P5Z|5a3s zfzcZ_^4cY<4rU z0bjdaUtj-zcng7yi9X8;(qU(VjjYyoH3>W{K^0KMt&;^-iKdE_3Th%7~;t{)H(^excG zg2K8C{9yfKqd8gYq&*xj3c2RAn!Lgu0d_VDwcqug( zhu4hQNGrftH?qYzQ;jo%(`r^87uhT|E?=mlDTl_cgUI4%YKvuJ6JwVO(??yUCM4O= z+X93qI39g~&L`#k89w7pQpwTUIIxW>eVanF2~-B^d$@&Sa3ydv)DR7>_ZL#JxIZ3a zc9Q$Bx0lA2b7k;3ki7s%3*;SYoRnE@#|@X9kNhbwV4CehQ%Z#>2N5HXmHT0|L$O8p z&&d8ao)N$5MTIgoS!h@v)sr)7q%YaQkT9dYDuhA%U&h{6y|AV%{G^pE$NlHXoEI3( zuAnvOtD1U{P4(GCOFA4@h8jR%N{Jhma17MoK_sgADz+dj)6FgQJor)w>C4KHH$-By zx}>0C&Sg7E^NFocY9zhhY6w&W+mJd7{QwpKzMz&Q;h1|y=!%#p@DSBR~#>#8c+u@>)_i4kavy+6d~8c@4B@ z;6NHuvhxR|_rk@@sbykgr1Sxr1z+lrMMYq>IfO?*6O@i_a0LX_Qf3@|RbXKKlm0ta z)gH48QD}1#1$zIU?;-Y9!8NND0}}fN!^?V0yG4ZMCx7B{Zw>T)Days+olqe$bix}> zATCJDng$rc7M;UlHx_Jdwt&e|NvjRA2X2}%yuRvfL46slc+waHJa}(xD68-mhc}zD z(&$n5BUB66ZCi3BGls?(BZg7lAOH_)DjxRCZ`)WI;=qC%bbO?0OtB*=-A7}k7#l-o zWaWmmrS)k>g;bI($Dc&Zk)!(0F+x?S27UiZ*qTF_J&B5_4^)j-<{S$Wu$J>d0Gj<( zpw9Bqcn8D5LPF(L@K#R0y^AG_y`=5l6-JT5E8uO%}OB32oU}tY#h(A~Y)+{AI z7E#VIW9jrN_>60SB715X2D}R#{T+@HtgN5&soi}<5XDu!c;y#4ihTIMykYSf7}zC5 zuu4)STWlY~utNiC4^BzumVuPkB-R`%K5c>K8necXZ0Fh<2+yv5siHBjHZ68t0^xzy z_>8Rbo=CpvfpgO*t!TTKPV>so1afVB%nF_aY9ZCL7d9CWnigDJ(NE$LA3#Ktrjkp9 zArF~=KkhWGyM|`T&upa6SYEu`6ki4zf?!y9N^l-PQ9yvJ=@252`ZCZ;2q{RHmwu?O zYzEiT&`lgmoEfop%Riqr-zmA?ybE`REzRHAY%D1^8(a`oug-w=+8Z@G3n^;7PY1nK zzZu?Ak-wx6u;@>+%92%4e-f8a*g?oW2BJmT({`tzFMhO;?~#8l`xXI`ldc%Sa?`5n*) zJgD{K+!K&p-fcTgL85e-ZnLS6t419}PBsf`tXY9h--M#9{v95JBBx_1NqXFh7_-ly zZm#Z30&G2>_^b|1*(nv6j;4Wm9S>3)itjd#dp#KhMv_96;#}~4dMKG5&kF*eCzcLk zU@E-y5aKZHJ0u-I{c6#SCMogGC~%qBj7M0A{=-pc3R7Ah5Uves{pk8D%{IqP;?b>% z|D-l|M}?%q?5BBD{DULK0*iN~-Mmz9kn>;IVLoBgp zr7viL@a|gv%c%Ek@ak^RZ>s|`CP_lMoM2r&TLLn#wmFz}Trs$Zk7whgKUv)aR~xC*+#sF*QM=7lcCcc= z0QkfJd93O9>aMk01_{*z2XvQ(66J1u_^pS$7yHl=1FP!S{e_bGRJ~Gve#P^pC5zCNL0#TV<5q!5nHpk$UopAMCxgh8=m@D9 zGaZS@>;i##sNT$7>!YHlYL=p3<6gc%k5yr`qc&Lk4`y*lT_7KAZ?uL^@O?2C>GRYQ z@hK?tFau){n^i3tVS%2uL%H^#h!tXS+JHe4g5YH{`)7XU*K=`8HodQ-j0!!N(PV01 zInv<(E;Us)*@+Fz0wXR^P=6YB63Qf))n?PHvM|gMKYWt(@Gs`J@|MJW7O4BLtV||P z&b3oj;hmeNF-H4qsYn}~wq!{Dn4a{PAa;->|G?SPN|P#$PVM}3Z=TNCn2+kb36uN~ zypHNTB`SG2{h18SS|x_phhRBZsSS0K2Qu78i;Sl>cVSVLV+a4kuoW3fZWJx$cFv|6 zZV$|D2+Ldv{0dFmeh!|Tdy_c$735Irc9mHQ~-lg~|!Juabl?mY`o?E2%0R+n;T4U-ZezvHH*`KX!1=xZ^alMUr z|9eg~g3M=FTC%fnK^PSH<$7H=U*79lTKlXLJa0Wkj86t;-|QpVd~}SFe5`w4K2@uI z7mem%PZQf+6t6B12rXTX0{K@#W5^iFVa(@6|L2DDr3J?qzKhRJ6MPzZS@g40+}3NU zMhzjk6{hx}8ujJ=38$OyFK>*LW|W{YwDDO_w^tDJP?OE9`*7$0NWpEQ>;)p3KMLm= z3Vh1B+o?%#b$>0>i) z<+$+-3B;eMPfb{qVVmZLVhPZNCx}fg&>_bpJ-D_wU`A7tON}0*}cgQ;cajgL;^IAsR3V%8iD^vc6)E`TG!_m8W1O>$45WOw=tr ztb-L+#VMwSe{kkX%zEn2m~R$8dAR7=yW&(pt1HiRN3FI zQJ#xKmq^6A=$)XyJXV=N-U)84^!MexQeHr!gT;H0Rzzv-D!WX~M`Oh#xl4~ZwNY%c zfjh)b5m8pDSgrrO+zfv7XE}5+pOdYY6q-%m7U&PX!Uz%i6-XV0fb7W=Qsv`nD^_}a zIpa3Jy~C^A7eW5^F;%{(&pDL7o^Ez6`F$gnB;yTr4qU?eqa;|>Q-Soo=71e!ra$i# z>YGD7C0@uAi@92T``&cEDaWTk7yoh>Vm0$rZ3&$iYSSU+S2pBic1~T{i1$pgu6zm* z5XmQ>F_-FQO*x6h9_vCHRhA@}sU6O~gx$IC+GPKS+|}G+Wy>tE(x^XrRvnN>Q&v@P zs=+EB%5cYcHv(cO=;9_9FW>%`giP=`Me!q<)^37!6LK4Q^s3AfRn|W;Y}*L5#ZZ z@LC|3JsRsS%^K;=67PQYgg&fY(s|j9A;V(t@ko@$hCW)0*P%0vCHk zPcDr~G4aeHoS3N}TgGUiu3C}XS#ZaMh7o>-8&wo2KL-}|K5f&&eK02o0#@0KZI;FtI*Hpe{wyGDZqry{eWek%JyJW5ho|~M#|B%{4@0Oit z%P8pce{tLA0Xs^8eKgQ}LWUupN#zq!?l!=UM%kg!e8YJsHC*#i z<%9MI@6v1IL-+u6`*I^i9U~AZO@uZi*!@z z@rHsQUH5jfH>57_!qmgmtUBJcKMhsow-JsZ{121xo2T8(;cpU`u>0rN4TS$Kn zH%G3`A;^Jzu}aWBxSSa5Lfr`gfxqq0jJS5mJnUBVTT9oWP9o*W7a+XhRqm3Hln2@#6My`zm; z_`cTDi3gazzMam0{(azKm9>r5EbD|EP&afZP&DQPW0%-7EoR#*7Ek{gNT9h=M_IG? z%LyqtGwlqh-7j{Nxbb+4AO>#)qPj zwViZ_ZbDN9oZoEK)ah>y%G4ojr>_=orp_O})xKrN8kl4$k#}NCCe#z(k{U%<+H{l^ z`7AE1I+(|ac)XnCDM%`Q9PhNLK6QaZx?PIo`b>$GY_OeDq@TggR)(ys2e4?B3l4q1 zY@Co7Ojfw!V(|?O20w#;agB-wy^*brNP~!0L<*6&F`Oh zf+dld^|~Z@!WiV3N4UX4&dEFE%<7VBbL?mQzw=57hAs$WA*QI$VH~DtbPqXzSc*W$ z35i-#%h0-cx-Y>zb%)wl@p>=M%I3eYBxUeFAS>bT>k_IYd*zM&Szl-~R_$taw$9s2 z`LOX=YBG0NSwoI4>hl^tyknjEmzseD28VNo(65!i*G#rh``?}z~ zhnrl^+1a!keE~C`D^3zB3CX$9K*w5Wzb)%Mb-e_{Oz% zN;2fpJ5I1Z*BdKMREWPan(oY5)2|JXjv+vaekh%2D9vQtqzQlL54?m_RUrd=dV_s zUg9dh5`VwZruHS{%InL5=EkfuCSEn5c^TQZYbyUHR{sH7+3`@}8HM6;*VtJm^0Rs9 zC?ITyim$&FedyC`B<>8=GjZR;4`aGak7J`QJHUNqwCFi`d093j3g`;)TBgJ_s4r&} zFSA+_1FB5ro*`@zWoEHpgTh3lV_u7qC=|2$&WrHk{OIx_wdYko6Np}cE+!Rl?^!1o z(ka=qzRWNYrwW&7x*L>cR$lUmaJslwl1?onW!zvgryM@SLYlN}uGf{JL_Lvf+rug{Sz6LBt;}MR|B5(8w7z4A19j;!orD_Q{n@UzG5g-1`3D6idHq! z#0>v8G6yCr5%tg(&k%utvr&T{zSqh#AkVfgSYmHGKv4L$i~5%Ahq?OV@BstqfhaJt z)*y&_@LK4~NJ1d7)$o3rB~}v<6t_#;hli?RwtIFDZOvEsJk#?W6g7DW}Z4$fkf*IeS1-4FLF zb9U<<^j1hI>aHT2AoT(Q4lOy@tqRN@iv!ye-2Udz9{jtl z-PYz0iqfCcZGpi~)MaFeBDDE<6IRe`YT$;O;%}yc0WQjN^#8U%c zF2Ujd{H~~o829fb6ftB3V}UUuCmvre_vLGVDUUh=4u0^yDgk5-;D^j)^kfWK8G%?j zRimmON+QGtXeb5p(OJ~46Y9KM?}O%SDluH@Co~SR<=Ppaw(5?oV_ z*4q2FW|#H%bF|zAW&BjY&nX4466gyx(ZyzR7%NCE2sN8a$7t4coRI#Zg}kIoX1JGr zgW1WUfnPIh16-l-vO^^#&fFle*k-Mn8{6QrA;g2D^Qn9pK7xS!-}e(mS)?h0S|G2U zDo5S7o6bKiV_LIP^R8tne_e~7p6dV|xT-HVn5k)P(3C7W3kgaAAxD->r1D=qpCYhP z^;zfhUyquZMrp`pu`+ZceAO`S?*~Qp?(g;$*Jw^qy~Ct$K+S@X6t)&!HZSUp;GG)s zpM%@TNVPXKw+24k8(%{6LZCSWXWppH&Qcn~nO*53m-&vX(8xvTqNon8HRK7td+N{# zx&?;t;RxSz3i1N718&aiQ$DJplpPnL;bShBaD>azvZQk@rE3!kyrkvpjJ9;P;iqEZ zQ4?SCaW@=#=v$V(Bi`l8AF7#ocvROqY1TkDzY`DfHX%iqYL!}xNl@(zq)lhr-!ifU zfV^mK^BCJ;eiwTJ`AJAuq8;rE*gr}hU)DNl*OoZIxql)ce1WZj@RY$$-A5NXfckfz zCCUCUH(_ENy;9v^^&!2IeXPw3(^RM^It(XYcG(KtVntdEh@1L)i5br|Hw`4%VQvZb zYQ&ddW|Wacp)SfZeFMv+K-=to9Y4R>&#$h^vWkDxtCJEIz5!2N9A8rlysiKY=_m2H~Dh5?&@LI=k#Ti<(mqARBl2FED&MB?iibk;$n zOQa9}{R{O@<6txsqetzUMy1gOX!c^4eOZwIcFsCkZV`DQ_p_KY zEsxJ=U#4BaKG!NSWGG=5EI%nV*JAHsccqIvW%^QImw~+iMI+C}PK;g;)fE1lZ9P`BqD%j72KgDNH)CMCKq0XOsFb4 zKk>1f${UjhJJjfE8^ZtoD{V6LMt~A|5Y!3>t)HmC16%6DE>{x zO{lyCF9i7kX}Shb9LT}#jsZzOQYV(^$oSj%^u_^GX71#db;p52pe&euD4Wz}j`(w) zlB`83A{l-Mnp_ZAH+K(f-RKW@&|$X6=I(X-!=wdsk3ZTIYvDo=^f~G(dxls+-R{Xp zP=}fE{9DiRF4(aM09j0caOBS!Bn>d7H}$wU+VNBNc^Z32VasAxt?4B!-QO26k)$CS zdkaXpGQ_oTb=!5yw+sapnOq#|N$EYmuM2-Z)_D%^-M){!IeZ|vcior|15?IEm(^jkGQN@h zRta#~Y~dM|@y^Ao)o@xV#_PBkl_d;7c7S|=O`^zk%aa~0NNnF;iJ|}MKZHAbv?PT9 zjp-Ycxly!)2v~SA02wb>MS|nQZW=| zn@~MoZ>(xCDQ`jLT(xZO=1$#Rcr*(EcktUm3!vB-D6T=k(txhabf2hI*Wi;Ze5XQj z_(RHs4W@2_GJeESIQXuqDp6ODwskoyGP=Ghtj7w1d(@a5`|x#S&qik%Ral=vCM+l_ z&lz|rqgt&u5UIR2@{5vy1v@ey{4H3)g16zj?H;th3xc@f)D@F1maZz**}auVRQDLJGcC|uYRH4Wi1mig~%h{sz+GV{g&c8UAI9} zO>vId#f{rFk1T{MP*iMo1QkxsksL`IA@`s5k9BjAmo!@y^v3TV1DZ%7jx}vMH4zoe zcA&#whP!UL9A-I>Kuf{1hX;>nfEDyv<5r!!1~We12~u-KiXS$jxZ+DJlp3R4DAb&6 z(ekOS@NIH=l)ETJM*C`ZZE1sP6{ctTQo?$XJO0P*sLZ#1=f&Vo(~0Q=-ipe`wVm}Q zq}c-q+?X_FMi~!6hzW#z$Me3)(uvla zAPPZCWU)z&ehJ0pFQj(@uUIJI)*a}#DZ)R(`8(ZQJYc7(&dXOFf{ISVy&-|%Il zG-hIEq}(mo9=(wbO7rO$VWa zhTBEZ8F|^4Y3$HCNPr050sDpF^JMs9CG9fY^gR0q9lEn0rccf*W5MQ1+SzhUS?eg! zW)8COgz%h@oU?{vWt3`0sn&(Ob(_KjvKYIgWX4uTO7v4j_P%-J4G`a}GIT0DIS%k8 zUQI~06B{zK0HbBBv2zdof`@pK<pN_#hA5b%l2)lz4@b~zOYEIfN9N!3->w7@<}8hLgj`&546@wiwD%6 zm`2mtO9OHs6{reN&D=MflnvPhb!A1i@NjW)F)!SZI=sTq z$popOTq;3km9SpH&s%~v;W6Y0zg9?`f7Ba9QYkj@aO!$lsuL76VXsd1i!NgH=o|Rg z6)`;>Xz4Yh|M8J*@Z1CHFG~$V3jcsZBjX}H^nxc+O^p1T3cf@vr128qG%*89g#Qge zK)$~RDq2W~uz%B$R|*FA+0cfAuq)!enb~zB&CO`!0SmIrje#BGMbmwU)RXgbKIi25 zT#{)3^Efqfd;XM%0B=a#$^hERp^$)sfMZ4?>ycAd!9EoYu=Pq#9VS5`pf7B?-ne`d zE*8OHOABN~6!_0Yyyb`aZlBpyyf0?QoE~18cWf=sNlU^Z>-gnB>;VjFnjBS#PKAENQ@z~RQ3 zTzAz@WIhb;sT{9033xP0Q5J%a<$nA3uI8#<5Pse$7Kag_RKmjSGc$zq@ifGkNwrJ4 zK_tehq00+RXxig`6bRuqtP)GEnubRrLf`<>gaAAMJ^w)w1FFk9nZXrvph8Yk|HP?J z;<9xBr^3p)$u;C&CqQ(Dh3Nvdr*2&jDa*sGgLZQvn{VZ2e^)hWt!n}!n-GoTtlgo} z9d)u`bDUwIUL#D)EJG`?`ib)hyVv5FVNz(R(#zQC9w z9=foJmjtjH=4ltYPyTz%B;8)(BN-@Xl)M!~UDg)zBcgUw4*A(kJLM%_&9O>K=wUq@ zJqL-4D$EfOQ7d-?V;wOXrBKCf6?!J89Dl1Gg;117eV@5;yM<9tDdHkwFINYWp0PB9 z&s^J54Op@nMhl@TjK`|mHcQn$p$I^EYV|b2pG#b$s0G&E0_Yda#jM(#4KiDSV~8AG z^(hTgL;f<9ygkSrRpJ3_Q(&M3*)IDO#M@j&xPhKFJYnULn!0_7z`x$~XR-81=CW)! zI0lT)kuL>22%Zm_ast>N*-R)V2x)=oEKeOzTu`=n`aNFX9&1PHdcm69XY6f`uW zRC}V89efEg*6EW|(3%SB8)$!aX(Tt(XU0a?2MAcQyO*IB{_q?QHK`(XsFnMMJXq|wja9K?~+>x0>t7%_<1z9*uS>M&9@=)^B04wO`+h6 zzD3BCZ;ZEgSoU5%CTl_FS|F4T4CD~mhxNRNto5RcP&2er9QutFZd+VwTS>!+2aFp6 z2}7Yq6)$fic3+$3@t0{vOAPNo*@>2imc4VD6Sad=a9#gwlidBpix!SeDl<)FWuUN5 zyWgf%(g?sw(Ep}SA>zy4NX;aJ-6>yH#d3yHM>Srn00AJSr6JNP+}L~B0*6sEyv#xA zP8kVUx&`(oC0!lw0x}@d=w4i3Rq`b2J?V;cYX6lTf(OmS3;~r9 zCuuG4U~P~ZlA8TvmFXRP;pnGQ`dLli908zQpGG~n$I3qQ&X@o^n<#Wm>i=YkW-P9) z1p=u(a}7@%&j5~Gs;}&vX}v3Gk(neK-aoQWR%Un4ByvE{(r|WM=CEFA}wsm|H09mgO(Di?Je*H-0!-D#dnE7J`32=vEvuLi%KM6hWLFEvq ziB0nv6$pGV_krQgPKzS3h(YmXi z8$@#*! z?@J@p-G&UWZ#njMVwP~v$*={ch3H=b13N#dTj#!ctGHz?0TaqXp;n;fMugv|>j^8e zIFf`VvIkRKi|leA|1u%WhX)I{agE2<>B3nl_EjgZ%h}uIuj-xcTDt7v7GClrut;Db zod0+*A-Mw=XD2$qghs~mIDp0MtJiTJh|)CRfz#O~jxkwq0cN@x!310g2Jl@;r8RQ{ z7Bn?WQ1BqrkN6`jofL4EG0T%Q%WNhxu|dohJrf!OSX81FnIJ`K6nB#Bw@zdFLDT$R z!k=~hj{+U1mt3@bdXajqnc?YJTzNrzanLzzK+v-}GEHSBra7nW(8N0ah0ZRfZ{G7K z*>4re4RJVTuEx1~>ZDq{Ksgq1RJ<8tON<`U@~+69bha8;LB=L!wW|GYIw!BGz+^3G z8VlI`&^4{_vYhGiQta)HdEsM4e(idjPfR^q<^}Phs_223{ZLntC;`q2BsZY<4*^LY z-|;Nb8ji|Co7LmA&eS=^TmAP$U>3GRYuUWD=W03MuZ)d!G?O zRxTgPgBW6Fc0&n;qg!U9S}Xhs;~iT?Fybtp(R>+xWlH!lB$EJvjRJ%gf)c$_^{*bv zo?8u*evc2KCU*hF3z|RJP#t$P-^#Qe{xf37w(nuE^ivaZcbi)TDLf}{eDgw=Te3x! zPhN_9szAly()Z22(0rwX*|HyfN8BT?&0fhyx4}v;;~ptI^2+EIW$~&%s9E*(1W$D%h7mUdDZVD3=$n&6zCIez4~f8!9+WFF@NE(6|# zRn$=sig*pKuZ_LC#k2w4YpfL#1_(s3b3SPRJg-=M4cS-mg?d*5nPo)=E4@8^`n&Y) zvI7^#7{gO|f?h1Wex<8u^YIyAY~;HWtWKIcQOQ-xa3Xb3ZV<#s+(%N(+y(3DfwGt} zz!qdJ`H3Jxl}agO_#1F5DEHHBF#IXlzMO+K?CUjh%iWE@gu5ZXtbuF(@rS1IKMGB5 zECDiPo)ri#dGxKvehr}QASy$>?GF7Z4IYyKD7qu9Qd>zCg2~#0#8Zae1w55%nS3`f zu6f|54a6y&!*vItO5O1g*$NGYuNj%RIY z*$2Bu@?O0Do+4SWV1qDZW*3*w>HqnJM08=|4YSKgOjF34K`Dg=$&+y^Z;Oat6Jhfc zZ)obR-jLne@LYDSesvSkl~q&8)?8G|5Hw;zl=fEb%GD{g8T5RxsFm4ofIa}UW!3?6 z-cSr6fZmzinZf-sY|!u4TT;6;ho^%=G>;UB!br(Nl6T?ADN%qtj!(h94|#2OpX|A(zfZx}!d&z=n$U?|0_W+$0$9 zhl_fu$#Jme|1wgt<{eZTIO|)?Yx)G3d3@8b)I7s)qN-NXv4HHN)6*q(=g-m$(>3^- zzcNp>JQ9bl3}R1EsD+u~4lDAo{uFJs=Y-R!zX zfVj|Tf70d80$avSh@$-mh7?Mm{dQjZ(T$9V_uUx1YC#`F9ileGovmke0nQEx9@TBl z%I~D96lRj}u(ZH>cuTB?X`zrHCW&$IDN&lnX#q}--#p9R8UWF7#G!m)-lceYtdt;p7+_D z`o-Sw!)Ye!Kk(Y!67u6j%Ly(@;l$n0Ky zNReUI6Kb@K`n}Kf1j}p)Xo`1SUndK*(s){$j0s91#wvoRI{S&Mt955+u8laWL4seW zsxZ~+SZH-xxco5nRl#MU-8J6!Bw1tq-I$JoIF|#=Id)BVp)vq=Maac;4XOMfp1=0cMrJ87cArHmyhHx3KaKB2jz~zYeev^cp@?!=uUQDs%DACRUzz_9n5P#wjma1negm^ zKAGZ~vo!g%^1dL(f!uIUta9E=BHbQ|4ExG_YGHd#49q0j2h({$?C1c+o4)6=UQzo# zsuZzb*TV+SWFLyV65WJP5N?x&h!vd6T_lAqJd7;{ukje!;v=^s6nk+ot!o6RCYox%B62By8KJM`x_x{P1{W(cQ zQupWFzco~mA?;2)Q5StH2`Et&owEL3Uy_ES#(Yf|6YFig9&(doc1ZlkfPVI7-b8mH z?{l)sX5>&Nn~5u%8zaUtW4^n-2NEjK944l%udwRdDEWzL{%2jR>)94n`Y-^D6&V)Ju zVv?LFV2(4A6LMvi?b`fq_~J6vWXNTC@3 z_kugI&P64?(vz|n;p8F17r~&Ohtm5WsiibA8xKN8K-u_LVO&ww>vAp#r0W-exM;@f zQzc-Y54GQm=f*Z00`iHx9N3%xPpvJP3N5th6@Hl*I(UpjpwFNBiC&fPgpS^SPjNj| zzF@L~T$TFN{tG_|TUdnn(*u}U1X@TpzyYJk zteO)4VBV0#g3xa1e6Q6J%B*45z}^z_`KmTEyRJ2&8P~r*A5VaFV9%`t5*3;OIW0fZ zmyW_x8YXvD(7>+_bT8GO{%2~opMpeZnr_Pq!^^}hyw%#|I~@&>Vr(Bj^k zKI{g~xl8PB52y+AOex}A_7`I#f0M9@?yM$3yQ#wcD!a9hC@+TDXt+>I5o;l0iOwsW zT|OB44iOXhwX0%%y)ehy9la2&V;2x{w88<#dC34*d2;@%WfnZawn5Zp zzS{RFV!%Mdi!WIql-_@H7<_eCR;1|qNml}Q{EGxWI(ebR_Id4+IYaUFv+4lMf?w94 zYpwphi}lej-AXL4(G~hbvdjROBDjndUaEm9PcYDlBXQs$Ar=L~h=n%MpwU?`E&3oh?5B z`B#d?#khvXL*wR@nEWik2=r{F;xmqN79d6dK z?%4_?XrkRXD@g-c5?PWIVZzPV;w76hyI8=qV=rIK^tP~;Ite!=!yKKCt4LqTs5SAAaBqqu{ z=htAP6t}rHwHMe2(W{v;1-S3yT=+UZ%ndP|1LHH9KW7$(ZQ7;QqTSchMTp3+xQ9z(Ku zu6M`eET#IG8*=1`$R^IB=63&wWo7KVK(c!}Y2-Wu@hCC|S)q;WH%W^3HoQbySTA(C zy}cIc&g3=dFDMGt;hA}+<(6N(C)x%w?^vk@q&mrE zSMKoEn1Ou?L78j&vdx-nzcyKNFLiMruYh2L~if#qv@1&TGb1G^c%1@L91}q9^A;d74N;cb_tsDsruAtDl z;+LCa2hQ8L)+C&VZTLG`&Xu7=Og+E&XVDi;ElfpuGQE$uf%i2^3zNCyPsKg=K%1dD ziN!2HX_tunf$Zm;!i}^rF({<;PN2AdSs{pCY7W=|8j|TN{@%7bjRB?fhjjmt74=!h z9;%)YaOt7t(xMy>k+8zq?3A8ZyO1)DsVRy0=E_QSM#Z*Mm$g=0*DZJ zTJF(KvoBj-G87@dZToR3Rp@)u|G$^I$JwQe)g~vrW3?)O2RfPzvdcKg%hYoM9zd`N z$JT!6H=5pDNxmm*OjC;&;>6S*eS=ON1VcX|s4|d4J&O!MG)+%l`}oYqVwl8lA5#C# z>8A51AHypn4-uz^L>_k-tMiTnTXHv*UsuD74wbEsSw5}w;b$$QS3S8A&ax-TF_|HA zq-j12sV2vS3k9Fyo&0qoF$9|s{_`yo>D5k_RU71oc5Gim_T~IgA~2ea*bwu>+7P~Y z_<*(|C>#@@!p%j&1JDp;dMUS6j40=qVCW{H#m7^K_gbOt>PcsC= zHb!Yg&*R_1fSnrP&p#tsuzMo8Lef}JxC&^ zS^U@cwgUj>87gxaSVsIpZopTDf-0>!K8Lm6pvoW>9JG}85i@p9_PFnIZ<|e5uQ&FO z0y}i_d=_MwyyzEp>gq*w*>L#j?1ppcrpsu@ zcc(RNxeP!lr1L&PV)p&CrFr{sA#mQrJAs@KS1Ao<hP7zzfS>kGRhCj-tOThpQfn%R0N)xmEaA z5V|tv!dA2g9mj2xFYTt#6*Q3=1CUf9a@rRGt3mR&&{L&{$@h$}_Fif%?3vUma~f2G zr`1@tPEKEY;b_a&2EY< z^r;GfA6#3w^^3k?a@kz`_EKk11|T+CN1@$5155^R63{b;)ksoFNL16`JwtTj7QCE! zWD*VB_K# zv0_1}?pXOapiH_JOZcGUsXDra&g6#`6So#+CW|2VwzcfVmT;&)A4bef!I8tQdtU_I z+_VPN&AANU)zQ2`h&v%iv^;)E&KAd8nHOgb%zsI4XS+&p?fOrELfgr|V4^wag zR)}PqK@lWLkFLhHuUO>8;*%Uu5HsGYv-6hBqs^YYl-7Ym?I}Rg;3Y$lLPXemKOLM0 z7F<&~P|2b0YH_KQ@ARgsIJs+_PSL6oQ@x%ZsKL4k#v6&8U_K8M(BA5dE1-iI#dI;1 zgPf9`nK^CAtF~TDa-dW++rEHNH7Dp*m~?-1q>owx6F32g{@{Nx<)lCS2SAvQQ*pGa zH1XFWl=xF%h-yEyp^0R<+4H$)+snsRebs89)`rn7tIBp7!jl?=da_h~hpp{ySNlxKeXyo1k+>0mM~1&NFFN1goF zp(X*`U5F|a>wvM^DbebYeCe=~s%t;{7Hn+}`5iDMDW7uG<7I41vOP2y0-wUy3^msX zEYva#0iqg{-Mi4vgDV@{Aa~swxr?cv{^j-d^-*KCEQA&|obU61EIRskIHS?Xq0gpv zP$qrp-sAd5F$#R^1XV$EY|Hzfd%PI*tvTJSgq4m&doU>fEkAq6-K7Dhe_{Xr*`IB_ zJ>l%WK%JTs_mPCY`(3*iHWL`t9b%`w;i#W5xVQw_8{H_ly$ z>d6nOExLHqJ+K}Dd2T=nNqiSnSoW*}$E#L{_66Ul@IoG>u^4;Cp6(jIfJereG~}yf|KV zQaCw7ReJX(5zAjDk@+u&D66ZcY4H23Wk<-;q6#ijy$3^68sCcsDQ=+X~i zqQ=M|?!Zg|no+5X2Hm6C#{)RUFwoPu#WT3Yt)vS@V7S-yAgE?m!7Vy*O833&_X!>f zyLriwWuK&eV$NLv8=VC+8l(UKHwTyo&eZs-woBX2r%fqvE0D`oE$$kHDg%;UTUxXp z=Yj8*%%Ax}u_7*rJTLBFNtyJY`yoy16M^=3X7P1#*hEH2?aiCsG{ua6lsya(7_TBjV;62Pd*a-o^b)2?|aRw^bXI*Du znazdTa&D;5+jM{X3%?TU=2@n#{@Ym7Xoc9!d7))?XYo4aU8-GTTV~~4*3Ruw>pg3s zP6;ycsQ>_>tA$*~PCj{%8MzmgUP>an#+S#bS(9YEt@kBVs}2PECg0wMS{z zD1jy;@OMm}Itt{3?vl&RvTJ7*C4@^}{Mqw;kjO0M;h8=VnUO1FbFtnkV~d)i0L1jl zjCS1VezCe=Msnb&6y&p9A=ewJrVIrY`!Us1FT0Ym08oT- z2|C1unla)ec*?L^SXDrWJ5>I$EwU9*$zjl^;CSkg(T&ByNq`r$8Wu~|;qaj0HL`;9 z4$IlMeEgM_xw?81;k9c2QX;5srPg)4U-=sDU1T*$kU^|1!e4nm5O!0O3`89J-ek zM&B+clFPj&$}j$`kd;GTRAUX=6%1l{)7U+alQnbA14wT_ulu2XtPEh>*;ytGq(dEf zdQrkElsM;{2awIne|-$9J)Bx$*Q22TN8q}=HSV4U*Mq2in&s%lw5eh#p#Oz zCzDS}h$Z<`>E%!jXq%bLWRK$;-uFRp@XcC5!HKEaiKLK{B3|Of()=fm@EXkqy>^7d z<4jlF8ddCOS=@;u4H^ylhO2*`2i5s}H#rba1*Qc=9d1{O>>BClb3Mb~D>lwpduTgm zUbC9-=}MxK;t_)LAM-++D<*XIU-LAF6=%vE*MSnBXtyMq}JkF}cEC_ZPyz zb}#W%9e!CWm}x7;nhc=nB?Q%7o&r9F;zE~<7G>cKm$n0rT6jX56kIA=Lde04V&TgK zP0<-Zq+gSJiZ`@Se!Ku_s*WWJek@VSl028R ziv5~V__{)?xC{GIB7tC*v0!f~25#vkgo9aqv;xCsj*#XIM^gnuAY+L5%f;wu+?pP2 zAk!o*b@81%Z1VNyqx$?1vo;Sd`PDsIyDFdlFU_NqYLsj{r#czO1N~L%R132>kA-k@ zZp>sEmMv<2PZ9(j!aRb=EhdE06MNb(=0i{WSTGp3QLAERiMGS%*ZMma(tNPS+P!Y- z#uyy_@fx3VvU?ETl}0r)zAJ1*L-y78yd%u$?JNoIFcqOAwVRJ7zzn@h$z8&09Sq8l zlGynyGQjR?ED*eg8fCjSw8n+{^hU0vRD8dg{|LR+|3>&v@tEbUzW7P908C%T!X6&j zM!VE{u8Rd`m|Jy*Qn)#LmEm&kHlg6$7_C4gx$RLxUed^Rd*BgYTgwomt=pk^Xe+ei zMAneH+pG@X!&&x(cX=G9b`g&XPk7|6JrQ?4#iZb`dsK}m<(vXOcbdrMqQ3Hihi$#~ zQX|b)H^?lt9bj{Y?QTAY9NRMRI1GypdK6UYHwdl*%seKJ~OHl!9 zh>3+Zw9fc1O$f52uN8pu?p9Zdvx4FZk%3*)Xm-0xK_{+I!Cj1Xv0C? zef_+qud8dDZis~eepXE)zOmiYL>kXdFYo2wa-`Y%@bKO0)d$j9`8(1wDx*B-dX?;09q?Q{HL=Gl|lkkj|f}sc< zbvczo8>h)kJ^ebtxP=bSc zg;dl4(4{sX%*&^2=Zq(#|qFPu<45IjD1(g#)>vY^_+ zq=4n2&jyoVdAqP0+YGBD_VaVL_haDgJfHQfTbu}d64o+Pl~CjbS9Tti{a#kTJe?R~ zXHmBW-1$^``8cKz&d&UQ?!szscx+*9Fy5g^8`t>+x{Vb`6sbt^T-o+d8)@k#8f#Ne zRx$I~lPyfz zRNxKQ3F@5s#^96ME>MEG^WL5@6%c1pCU*!Wh$&5}G{?c+3`)SvX|v||%Wg9Fm&|@_ zx&ME!bRTZ=-FbMhVhd;p#xt}yX;W^3si>YtrGpWjfB|_}olqtE^*>N;$*;+3$j^5n z03T9P^Sh9b_|*0&3DVAEK@V0ivi)e0L(uwq5~2BSp9#J2MlSXoHqQKd$#-e_4CPf! zMukk=!sPh5knOk_8E z>U*t4N1RIl-`i97eJHn|3#ktFfoSJuS4y_^om}W*oMOv}Y+b9zU04_#25NFy|wdUtS=T{lrzj({Imz zHKd7~&?iwL&*xoK$vYAdLY#i=k?mM;?z*16oSKx(+3Py9t3JV3$pge9e|njaqR>TO ztPd;;j96{*O=_d80S6{Wc^(B*%^U7D+MFefx{{HIm`VuPXXin(`q68CN=0VYS7ZOX zkYv)yuK^)Oiu|$RcOE@VY>!1Mvp^mrAnd(P)u1)6Xe%C#*ax}E7a`#G+2Ftc2!t!H zCL`WbbSIxF53`5S=Bk2FwvhU`F$4NN`|9FUF)e|S1_FbC0A-C{_+eKD3Tt{7k#zMN zYt*R5y1LrK4UWwLp1%lfi;1wT;|_`b2!44Nk)cwLLfY!V62o5FQ6y9!-q$~MrG8b@ zv>NOVL<=3RX5kp=fm`_3t3O8@;g@feamNTeuRpm<}Nt!PUtFM_uF0y$9 zo^r&WfZYQ#d49GIZUMhRSb|zx5~y!-^=s9~eoEE(WZd5yO!LVJ?NJDk<>LIq-p{&f zj=)0f*ssF(&6n+JjrfUjvb=VKO>{46GD${6m-7MH=n1qqcjxCPT2K)AI7vdIp%@h7 zMub6o&j+ct$7wMlr1m6%`D&4)-164>O=b_F^ArceuN@9RJm+SzS|i4yE62)x?+iVtl* zJ7S%nt=_3Pmi8H4D#UTee8;+5G@jJnfO4c_767pWN*h9flj#!bsV6R6Ku!=6On?FE z@|9IRB3x;J387-(0%w<`?|g-AR`PgMl{2ij--d?-@1+47-{emea4xumI6%o#-F?jB zl9XVvthCL((;Y9}i)6NTN-ykE`sYYn_}@1{-qHXPR}?ZL%+E)B^P~27`Z#|W-iWp_ zRz(l%@3=+y4o$J0FJsr|{PY%FdAgmr&-Q{D($~^Cd>)MgSw+QWk4gH4BfAa(g`tY3 zx!xdW`dj#4>^gWlvCD7G1~)P{?UhpmyeJYpp3aftTnqEG=@Ep4Nv zgp-#tBnNeA%a^CORh$VCI@c=gp`3e&9SFh3A>arfLt)E~x_1M!Z42p)kNAwfV^&!0h z>NmLxC9PzU=drwhdhh#AM*)>YdnFYsrGQPGI+B87ToZGql{pXnA}4{OqA^EBfc0wH zPO6!?>7$=Kx(M4|oIHkxsLZql?C8TCpTY3DAsbrTCnexl5g52(?fKIpXCH@6YLC3v zE=j9=$`2lkt(me}3{az#y>b?eogR_`Z|FR-qIL8TCK=@LW$|qk@?pZz+p@L_%4#)B zJgv$=K)|2r<2f5TXNq`cMR$OgrPUK-zBFz}08MAXX`53p#L!yc=kC95{gV^3eEN#R3O$Hx<9Us&b%d*p7tqV*sIhp?e; zn}mJUa*cp7#QPb>KM`(3C1aU_z*Jg>lRYYiTJ$i0wh5EBP0;benpc2_sy;^OSe|+) z4C2hWQ6E(!GuJw|6-1Wyx66(66C+>`inb;a$Q8BwwWqgU@_@f$1?8q*bY{MFJJm7-d+^g{CdaRX^vgY^Q=yQ zShx2;CY2U5XXUMwjzvt|XN*E1-0m)~Tmf6_y%U>lCZ9d!JYYK-$&RnK(#fyjv!&^* z_UIc`bGh>GvQZGOY#JI4ZeBk5#^qVNG6ZR9n$E^N+hafv%4d%6&e`lnpbe$;GBBwK zNf{sZ0v2%Z{~K*rmY}^fiDu{*{i_?I?|J~t4K@bIcM67bJrpC2D(&@jmCZ-lhq*V&hyjx9eCaJ9Y>-`*MPZ zX)~TIy{W%XG9_=p(q3teSCI)$Tl8D^{z0>^uf@h{dBs*ao0d|@P%;uH``4i&z6~&i7RAU~ar)Kihq zuX20U-09mbw8MFTJPi*@Vxx0fHfR7zZ@`amU;LHEU_E^fG5c`bcFHK2lHG^E@mGE> z!zqBGg!#DzSTMU%KpBp0{pTOYtmTBBAO~-z+QEN1xg6!X^5`Ca4H2KM`Fyvy^MOoG zbq;C4wZ-NVlZ4-31Q6GVnO*`azF(_E2@d;sK=ONsOt*VI;z9*|HBhxg$%dlgBc&^p zA*n|eyu8uA=@^|qu}-#QnA^*P8$A{$MGmf%rAEQRQp!559p+8O2hmf1fTqpgWqS3E zVkY9F=TS8TPpyZYy*$Kkfu|ZBZe~*D$GmP_KP$(h3(ykkx)ZMVUT$$GM0>0F-=gz@{%dwyyh+?F` z5*-VAbT^kP?^#&*tuoxfDE({oIjUGUhA!n=MgM-?a>SK0VN3m+ZrAYu$tNSRH{7?~ z7W`v>o;y0*De+1Hr!=~mta*cVzm4qdUm1;TK}^Ih>7;wukeDtIV|p8Au?Av}Ge+5- z_Wgyw*}e#LZjSVLMx_)g4cHG#?<1TX?wp9BP83OPZi2QdFg9Nko=HR9#4(99%lSWh zkH(q(bBtM2=T|J%xrE&_CO(}nzWxBw<31-jI9dNJVj7x`2dk0FSIj1IZ*_YPGclzk=OC%!f7@J@X zdnbE+2(k-_io&@PTlQ}M0^u9KITC9xtNZ~*FBytacoH#X1qI>sfB*mh0fjX)2vGWV z0)V1J6ybYA(#zzDUI1d>Zf9jdvd6n>Go^fal3`oqSpfi7bMi`tWfq5;lbOjIKU3ivNQl+SzRaPlw=Zn?PJbsQ5oOy zJF2q6b4ua`I!$i~Dt00;@>W2FuYG@Si<>a{_=PS*)J!4=SFQ#t5}@Q&oD^FIg5mYn z&{#;+^r(iI66W4HY=?cJ+v@W=F@`6a{$66@td}9JC=Cl-rMTlDzqTDS1E}rN7Tl$x zh|?Q-u74$FJ2sp8saPN3KKvQ`Kb8m@+qx#%Aq=#@OZ$(o?{!jf1>=FAMsB|LnWN=0 z&^a>+yQ6%1EFyry3$)~>3tm_Tq7wu53D%SHyWT#>QsaY=FfsVgHtvIYWI(+YpRk;J zni+`jn8Xh1qKm3JLsJZZZ`_iY<|tT>1Q^mA=CIilzYkery3=UP6H&mTM7?Nn=}eLU zklPd|n~RNUQ~~SOFeCD0M&HnyBE{rkFoQIwe26Q0sAkja6H&VFMmm_K4`<+$*8A2p zilp*31R>9C>AWGY<=#auLN=;0pf~^k2mrG_^tHgyN--TL7XAMyr-ck{y;#2Zkm}~u zm*MGC-!*+hsn}y}cer!lz@s}i^_;qYH+h2$s~>B1YRW3P(VkLBd$#In)vH(3jU2vT zCgavtPEE^ya{_`g5mC{xOMr_9oy#x_2Rx-!X%V6wY&VT(13HjEW|>RWvE4##{zs_g z5=eG9oO54HKkzg;HTw_V56SMn6q&)rsjg{txs_YKv#&$&`4-g*Y174W*6ci*OU0b* z5KtfjisZ4%EOV? z(QRZNuN(uQ4rTN1?>uq_Ab;!(US0tJ3s5;6#K)w0FUjt7VxMUh0091}nIf+M00001 z*=QF(qi{YSLi=6`L32vgkwsHSbFs4G;%Z!k<(^ou<4jSfr|K8#g!X9+M!%N0 z<@)CE|1S}AySu1@(G$@scXvR#zHA!?ZY|F08Wdxu+fPY;v@D9#2Ny=>aQHQtMOb{aiR01PxHJMJ%v%8uo&FA?(RUdZ-nVvr(o0$>> z*C?&$S}itmrB~3;7?*K?3zt(-0$}r$UQcYS{n&ctrtcuA0O)D-Bgmwvoj!{)a2Tjm z+-RO@+|seemFP2>#XC8v4rN7H=l9CXQgO>iiCnsuvacB$!hsSiGF=w>oMOBvFA z)CU%Re8iP#405Tagsp!QdGDpV$==Kl_#;!i)re=qu=)b6Y591q=G!xcgtZNJ)p@$Pn>RrM+KX^EEViUrC> zYR)?MUU*>c?SV;`t3$&aB+iXhxtzZs1 zeWG8j#6~~$iPF8ya!fM4>cKqUNeUKQXWX_gcRR!tAR3_tk%8+q_+MYpTWb6EjNIYz z(s%qRfOicYn|(S!{C{`7%CGm)`mSWj-fFvR+hC|<`3p@0eSurBl|dd4?L2st4@pcw z3@yO}B?sm+)&c(we3X+b;ZU9qS&+h$dc6WUle*`~eEIAK z{fL)G77l{%CdeBjK{}(;s6T7)pbB$v9}mC;C?`A;A6?-gnq$AgnTSVa*$; z#n3DMl*9ZVwcVwpPMrv|hR)QHKGjLGqBi>1_6oZno+eq|TU+PJ5jJIrN%Sz4VMwju49u$P%c!tA$%N&^ijwBaZM$%?FW zM+#D)4B;go61E)cl!j;(!lc!)O_*W?v`lwv{yCQH;gjpSWQgXmb<_X3Y#ve* zc}fA(ucxQfdX!p}rEa>7Q3DGS&(YFCbNB!t|gOp(>7B z6x8;k*jMa23u5fD=Ahzjq_6>uNFCv(g`#zGVjKsF8QlqgPeaswR?z=UpeDWoW(u<; z3!*T4{UgTv0*Zpk&+^l&##uZKps1tJ(UhOSx*I&vS9G_hvM>qwB}#U-Vh z>DSs%XvMe{%qRkN$vK-b{6%O88KwfsF4p1-fzred*#1M9ZgrhA zv6;{Rcve03BYL5n{=&ngqNM0_qW@DnVTrUfjU>N%kn>oz;h z@b|(HTvHX&?%h(D%>-Xj{_T-o?HL(6;hgI{lITDXvS?s?J;^k z-}S#cQC4?wM`%F79HbAVnhOSV**woWE$MWqKZ7Z|6E~_oIq`K8S%&x02xCL3Lg{4dz#79f2ULo5X?!+7|mDJUHwsdmQUWtU;Dsq z4!JxzaEA)5UL&|^ZtA<7*OVO4YX=7qP4ID_^lJajECJ9`xoWwCV!HmJ-w zjccl{W`d%|JIIjwZQLPPidZUniOX)jN?j}Q`cBW50r|9EDM<)Ckm@tD+(^~_Zb{kS zz&^SfE(gt~Ss3=9|Aj=zXoYY#0(Hgf*A2MeU+1^!WxlNjCk8^__|=-nrl=!A)yxU6 zv~u@#&%!<8!=Vaa3a)bYLeV#@IH0~owP%(Nj^rn^&ThBwU=vC!KY)BmU`n2l>&%)I z0Ym*_vmUks_2E0>hRc7{So1$eyf6u{yoP@_D8mgq1P}S(bO63>w%57_f3Jwa-661y z%R}l35X$B!`SwiKaIP%iF=hexIxxZh<=Q!zUll-j)(duI6P;90tna~}R|Kb_jb@YU z6-wMqQAm>h)}8JC2~x2FMkpYsoPJvk_2P;q&0LYhrekrbJKt4M2m%2h?K?>i+K-QqB4fYprts3ugSRF;%%`8=G!kJsW0TrQ}18!gCpH? zo*_OD;Z9&_uIB?)Wj@Py*@Pk)w-0&%jlu%y2To|J3w>f6f6k zMN#8Wzp+xN(JR!)BY>N-~CqA9(0aPj3Z7a6 ze=`4dh!y4uG9SSo0l|O~;5eWMv~dEPn1)CNXez$tDR!vrcP~xJ+7OU~=h2j5y@~fg zXgx!BXiMOG6@GF=3uFW2pehugavf6bvg5PG@7RMMK|naRc}5qJL)ks9e8n8 zpNbzuv37%+`n_|IjE63&o99T87!nHijdqPOC)4q9vF_ zBefMJpHSdngsx4Xb6BF~gCbqd0e$n!F~uFGvek;I^@2yJzys2y+%2TaB{Ttk%U)k& zt=>QeQQ7``q}QSc!|Zcr$pWU(iF36{39tc`PjvDI0g#MCN<*;hpp9#7IH*jkL)_T< zpy<7wpFChkaWkxdmy2km@+O~H_(9?Tm`}p~xWHsiIDV*Nv7}Rx3d)7t0Uu0<3@Q}% z{x@Z}Cmm2DdJ$S0fs-I;3{}2bKlHp1K zSt{>I0x!v5(GG+xrcUXZ>8aUPpn;Nz9C9^q#xak5VJ{A$luK=io7umJhRokfhvfUI zn`8biVya)k z;M=RjD$56?%iafiX3@Js(X7OfXadB1+iy%waEMgYlQ0i5cF0VSBJ;0u`=P4|#3myg z)psX*Rs|32od*mu6JR=rg-XXFt_gf&%@V)Ly*=xuyUmyZ3H3FoJ|mO>Ft#AoR_8Oj zzJ#Id3_p7t3y{j4(CjOo(y#yk0000000000000000000000000000000000000000 Q000000000000000077rc6951J literal 0 HcmV?d00001 From c093bb1f406af9c00b90235373b80181c416a88e Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Sun, 25 Oct 2026 23:51:01 +0800 Subject: [PATCH 023/153] =?UTF-8?q?fix(web):=20=E3=80=8C=E5=BD=93=E6=97=A5?= =?UTF-8?q?=E5=B7=B2=E7=94=A8=E3=80=8D=E5=88=97=E6=94=B9=E7=94=A8=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=95=B0=E5=AD=97=E6=A0=BC=E5=BC=8F=E5=8C=96=EF=BC=8C?= =?UTF-8?q?=E7=AA=84=E5=B1=8F=E4=B8=8D=E5=86=8D=E8=A2=AB=E6=92=91=E5=8F=98?= =?UTF-8?q?=E5=BD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `usageTodayLabel` 原样拼原始数字(`365309724 token`)且成本恒四位小数 (`$0.5000`):token 量级到亿时这一列被撑到几百 px,把 API Key 表格挤 变形;四位小数又让 `$0.5` 读成 `$0.5000`,且小额 `$0.0042` 在别处两位 小数下会被抹成 `$0`。 改用 display.ts 已有的三个格式化函数(与统计页同口径): - 请求数 `formatNumber` → `2,974 次` - token `formatCompact` → `3.7亿 token` - 成本 `formatMoney(..., "USD", false)` → 按绝对值自适应 `$7.49` / `$0.0042` 无循环依赖(display.ts 不引 keyQuota.ts)。测试补一条大数用例,并把 原 `$0.5000` 的断言改成 `$0.5`。 --- web/src/api/__tests__/keyQuota.test.tsx | 10 +++++++++- web/src/api/keyQuota.ts | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/web/src/api/__tests__/keyQuota.test.tsx b/web/src/api/__tests__/keyQuota.test.tsx index faeb9d0b..d49fcad1 100644 --- a/web/src/api/__tests__/keyQuota.test.tsx +++ b/web/src/api/__tests__/keyQuota.test.tsx @@ -84,7 +84,15 @@ describe("keyQuota 纯函数", () => { expect(usageTodayLabel({ requests: 0, tokens: 0, cost_usd: 0 })).toBe("今日未用"); expect(usageTodayLabel({ requests: 2, tokens: 30, cost_usd: 0 })).toBe("2 次 · 30 token"); expect(usageTodayLabel({ requests: 2, tokens: 30, cost_usd: 0.5 })) - .toBe("2 次 · 30 token · $0.5000"); + .toBe("2 次 · 30 token · $0.5"); + }); + + it("usageTodayLabel 格式化大数:请求数千分位、token 紧凑、成本按量级取位数", () => { + expect(usageTodayLabel({ requests: 2974, tokens: 365_309_724, cost_usd: 7.4879 })) + .toBe("2,974 次 · 3.7亿 token · $7.49"); + // 小额成本不能被两位小数抹成 $0 + expect(usageTodayLabel({ requests: 1, tokens: 100, cost_usd: 0.0042 })) + .toBe("1 次 · 100 token · $0.0042"); }); }); diff --git a/web/src/api/keyQuota.ts b/web/src/api/keyQuota.ts index 00641cdb..631c5da7 100644 --- a/web/src/api/keyQuota.ts +++ b/web/src/api/keyQuota.ts @@ -10,6 +10,8 @@ * 失败按不限制处理,与服务端 `load_key_quota` 同一方针)。 */ +import { formatCompact, formatMoney, formatNumber } from "./display"; + /** 六项配额;0 或缺省 = 该项关闭。与服务端 `src/key_quota.py` 一一对应。 */ export interface KeyQuota { daily_requests: number; @@ -110,11 +112,18 @@ export function usageTodayOf(row: unknown): KeyUsageToday { }; } -/** 「当日已用」文案:请求数 + token(有成本时附上)。 */ +/** + * 「当日已用」文案:请求数 + token(有成本时附上)。 + * + * 三个量级差得远,一律过格式化:请求数千分位(`2,974 次`)、token 走紧凑 + * 单位(`3.7亿 token`,原样 `365309724` 会把这一列撑到几百 px)、成本按 + * 绝对值自适应小数位(`$7.49` / 小额 `$0.0042`)。原样透传原始数字会让 + * 表格在窄屏下被这一列挤变形。 + */ export function usageTodayLabel(usage: KeyUsageToday): string { if (!usage.requests) return "今日未用"; - const parts = [`${usage.requests} 次`, `${usage.tokens} token`]; - if (usage.cost_usd) parts.push(`$${usage.cost_usd.toFixed(4)}`); + const parts = [`${formatNumber(usage.requests)} 次`, `${formatCompact(usage.tokens)} token`]; + if (usage.cost_usd) parts.push(formatMoney(usage.cost_usd, "USD", false)); return parts.join(" · "); } From 947a9663d545a992da97fff5c02f78119c7b009d Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Mon, 26 Oct 2026 08:37:08 +0800 Subject: [PATCH 024/153] =?UTF-8?q?fix(engine):=20=E6=8E=A2=E6=B4=BB?= =?UTF-8?q?=E5=88=A4=E6=AD=BB=E5=8F=AA=E5=BD=B1=E5=93=8D=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=EF=BC=8C=E5=88=AB=E5=90=8D=E7=99=BB=E8=AE=B0=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E4=BB=A5=E5=85=8D=E6=89=87=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游目录撒谎(officeAce `/v2/models` 挂着 `deepseek-v4.1-flash-1017`, 推理端点却直接 404 `ModelArts.81009`)时,探活判死会把该 (渠道, 模型) 从模型列表里摘掉。旧实现连**别名登记**一起摘,于是执行侧 `_narrow_providers` 因「没有任何渠道登记该模型」回退成全部渠道扇出: 请求被发到 codebuddy / codearts / codebuddy_intl,三家各回「无此模型」, 8 条负缓存写在了从没有该模型的渠道上。 - `api/models._hidden_aliases`:判死的 (渠道, 模型) 补登记回别名表—— 收窄仍指向真正持有它的渠道,负缓存让该渠道不可选 → 快速 503 「该模型暂时不可用」,其余渠道零调用、零负缓存。键推导复用 `model_merge._register_entry_alias`(与真实条目同一套)。 - `engine/executor._error_type_for`:`ErrKind.BLOCKED` 独立成 `model_not_found`,不再并进 `invalid_request`——后者读起来像客户端 参数写错,实际是上游把模型下架了。可用性视图计入口径同步。 - `scripts/cleanup_stale_blocked.py`:清理扇出留下的噪声负缓存 (该渠道目录里根本没有这个模型);目录里有的行一律保留。 --- README.en.md | 4 +- README.md | 4 +- TECHNICAL.md | 19 ++ scripts/cleanup_stale_blocked.py | 169 ++++++++++++++++ src/api/admin_model_availability.py | 11 +- src/api/model_merge.py | 38 ++-- src/api/models.py | 87 ++++++++- src/engine/executor.py | 9 +- src/stats/collector.py | 4 +- src/stats/query.py | 13 +- tests/api/test_chat_narrowing.py | 45 +++++ tests/api/test_models.py | 204 +++++++++++++++++++- tests/api/test_models_availability.py | 18 +- tests/engine/test_executor_stats.py | 69 +++++++ tests/scripts/test_cleanup_stale_blocked.py | 193 ++++++++++++++++++ web/src/api/__tests__/display.test.ts | 3 + web/src/api/display.ts | 2 + 17 files changed, 854 insertions(+), 38 deletions(-) create mode 100644 scripts/cleanup_stale_blocked.py create mode 100644 tests/scripts/test_cleanup_stale_blocked.py diff --git a/README.en.md b/README.en.md index 07c32820..ae3ac3b9 100644 --- a/README.en.md +++ b/README.en.md @@ -415,7 +415,7 @@ Playground and `/v1/models` use stale-while-revalidate: cached lists return imme (`GET /api/model-catalog`, admin / operator) read-only view of the OpenRouter public catalog: list prices, context / output limits, modalities, capabilities, knowledge cutoff, and the three Artificial Analysis indices (not measured here, no sorting). Each row carries an `available` flag; a default-off *only current channels* switch narrows locally. -**Suspected-offline view**: `GET /api/models/availability` (operator) aggregates `upstream_blocked` / `invalid_request` failures over the last N days (default 7, 1–30) by `(channel, model)`. *Add to blocklist* appends to `model_blocklist` through the settings endpoint and writes an audit row. +**Suspected-offline view**: `GET /api/models/availability` (operator) aggregates `upstream_blocked` / `model_not_found` / `invalid_request` failures over last N days (default 7, 1–30) by `(channel, model)`. *Add to blocklist* appends to `model_blocklist` via settings endpoint + audit row. **Floating aliases take the newest snapshot score**: OpenRouter attaches both a floating alias and dated snapshots to one model, declaring them one release via `canonical_slug`. This gateway runs reasoning on by default, matching the Max variant, so it takes the newest snapshot in the family; ids with an explicit date stamp keep their own score. Families derive only from canonicals carrying `-YYYYMMDD`. @@ -476,7 +476,7 @@ The sidebar is on every page. Several to-dos you would only notice by visiting a Default range is **the last 24 hours** (also 7 days / 30 days / all / custom). Custom takes two `datetime-local` inputs (browser-local timezone), one side may be empty; a start not earlier than the end voids the window with a message. Every stats endpoint accepts `until` (epoch seconds, half-open `[since, until)`). -**Failure types** are a controlled enum: exhausted model-level cooldown → `model_unavailable`, genuinely empty pool → `no_healthy_credential` (same outbound error code, different stats meaning). +**Failure types** = controlled enum: exhausted model-level cooldown → `model_unavailable`; genuinely empty pool → `no_healthy_credential`; upstream explicitly says "no such model" → `model_not_found` (kept apart from `invalid_request`: one means the model was withdrawn, the other means the request was malformed) — same outbound code, different stats meaning. **Request ID**: every response carries `X-Request-ID`. A valid inbound value is reused, otherwise one is generated; allowed charset `[A-Za-z0-9._:-]`, length 1–128. It lands in `usage_events.request_id` and shows on hover in the detail time column. **Never forwarded upstream** (outbound header semantics differ per channel). diff --git a/README.md b/README.md index e9dc941f..32dad3e7 100644 --- a/README.md +++ b/README.md @@ -417,7 +417,7 @@ Playground 与 `/v1/models` 走 stale-while-revalidate:有缓存立刻回旧 (`GET /api/model-catalog`,管理员 / 操作员)只读展示 OpenRouter 公开目录:刊例价、上下文 / 输出上限、模态、能力、知识截止、Artificial Analysis 三项指数(非本服务实测,不做排序)。每行带 `available` 布尔,页面提供默认关闭的「只看当前渠道」开关。 -**疑似下线视图**:`GET /api/models/availability`(operator)把最近 N 天(默认 7,可调 1–30)`upstream_blocked` / `invalid_request` 的失败按 `(渠道, 模型)` 聚合。行内「加入黑名单」把该模型追加到 `model_blocklist`,走既有设置接口并落审计。 +**疑似下线视图**:`GET /api/models/availability`(operator)把最近 N 天(默认 7,可调 1–30)`upstream_blocked` / `model_not_found` / `invalid_request` 失败按 `(渠道, 模型)` 聚合。行内「加入黑名单」→ 追加 `model_blocklist`,走既有设置接口 + 审计。 **浮动别名取同族最新快照的分**:OpenRouter 对同一模型同时挂浮动别名与带日期快照,并用 `canonical_slug` 声明同属一次发布。本项目转发时推理默认开着,与 Max 变体同口径,故按 `canonical_slug` 的日期取同族最新快照的分;显式带日期戳的 id 仍用自己的分。家族只从带 `-YYYYMMDD` 的 canonical 派生。 @@ -478,7 +478,7 @@ Playground 与 `/v1/models` 走 stale-while-revalidate:有缓存立刻回旧 时间范围默认**最近 24 小时**(另有 7 天 / 30 天 / 全部 / 自定义)。自定义给两个 `datetime-local`(按浏览器本地时区),可只填一边;起点不早于终点时整窗作废并提示。所有统计端点接受 `until`(epoch 秒,半开上界 `[since, until)`)。 -**明细失败类型**是受控枚举(后端 `CONTROLLED_ERROR_TYPES`):模型级冷却耗尽记 `model_unavailable`,凭证池真耗尽记 `no_healthy_credential`(对外错误码相同,仅统计口径区分)。 +**失败类型** = 受控枚举(`CONTROLLED_ERROR_TYPES`):模型级冷却耗尽 → `model_unavailable`;池真耗尽 → `no_healthy_credential`;上游明确说「没这个模型」→ `model_not_found`(与「请求无效」分开:前者是模型下线,后者是请求写错)。对外码相同,仅统计口径不同。 **请求 ID**:每个响应都带 `X-Request-ID`。入站合法则沿用,否则自生成;合法字符集 `[A-Za-z0-9._:-]`、长度 1–128。该 ID 落进 `usage_events.request_id`,明细时间列悬停可见。**不透传到上游**(各渠道出站头语义不同)。 diff --git a/TECHNICAL.md b/TECHNICAL.md index 247fbba0..385ddab6 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -452,6 +452,8 @@ class Scheduler: ### 6.2 候选收窄 `Executor._narrow_providers`:**候选只信模型目录缓存**。目录能证明归属时,候选渠道只有它们——全部不可用直接 503,不再逐渠道试(各上游只会回 11102 / 4001 / 401,还会写下数小时负缓存)。目录未就绪或模型不在目录里按全部候选路由;`模型@渠道` 与 Key 绑定不受影响。新鲜度上限为 TTL 300s(`MODEL_LIST_TTL_SECONDS`)+ 后台兜底刷新。 +**「目录登记」的口径含探活判死的渠道**(§9.1.1):判死只把它从**展示**里摘掉,别名登记保留——否则「没登记」回退成全部渠道扇出,正是收窄要防的那件事。 + ### 6.3 模型级冷却 `credential_model_cooldowns(credential_id, model, cooling_until, hits, reason)` 按 **(凭证, 模型)** 建表——账号级 `cooling_until` 放不下「同账号其他模型仍可用」。`model_probes(...)` 是**观测记录**:冷却表是调度权威(真实失败驱动),探活表只供展示(探测失败未必是模型问题)。 @@ -581,6 +583,23 @@ PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_k - **HTTP 出口 SWR**:TTL 到期后 `/v1/models` 与 `/api/playground/models` 走 `serve_models`:有缓存直接回旧列表,过期渠道丢后台(`_schedule_refresh`)。两条纪律:① 某渠道一条缓存都没有时才同步等(冷启动 / 新渠道);② 后台刷新收敛在 `services.model_refresh_tasks`,`model_refreshing` 去重,`lifespan` 关闭时 `cancel()` + `await`。代价:列表最多滞后一个 TTL。 - **逐渠道增量 publish**:每拉完一条渠道 `publish_aliases()` 一次(就地更新,executor 闭包引用同一 dict),不等最慢那条(zen 逐个探活可占 12–15s,期间别名表为空会让 `_narrow_providers` 按全部渠道放行)。合并逻辑收敛到 `merged_entries()`。 +### 9.1.1 探活判死只影响展示,不影响归属 +`model_probes.failed_and_blocked()`(判死 **且** 负缓存仍生效)命中的 (渠道, 模型) 从**展示**里摘掉(`merged_entries` 逐渠道抽掉),但**别名登记保留**(`api/models._hidden_aliases` → `_register_entry_alias`)。 + +- **为什么必须保留**:别名表是执行侧 `_narrow_providers` 收窄候选的唯一依据。摘掉后「没有任何渠道登记该模型」→ 回退成**全部渠道扇出** → 请求被发到一堆根本没有该模型的上游,各回「无此模型」(CodeBuddy 11102 / officeAce 81004·81009 / CodeArts InferHub.002002009),并被写上 (凭证, 模型) 负缓存——污染的是**从没有该模型**的渠道。 +- **实测**(2026-10-26 `deepseek-v4.1-flash-1017`):officeAce 的 `/v2/models` 挂着它(26 个之一)但推理端点直接 404 `ModelArts.81009`(上游目录撒谎);人工探活判死后别名表丢了归属 → 扇出到 codebuddy / codearts / codebuddy_intl → 8 条负缓存写在了从没有该模型的渠道上。 +- **修好后**:收窄仍指向真正持有它的渠道 → 负缓存(判死的前提条件)让该渠道不可选 → 快速 503「该模型暂时不可用」,其余渠道零调用、零负缓存。 +- **补登记的键推导与真实条目同源**(`_register_entry_alias`):两处各写一份迟早漂移,漂移表现是「用户按展示名请求时收窄失效」。**不覆盖**已有映射(真实条目优先);**只登记有可用凭证的渠道**(与 `merged_entries` 同口径)。 +- **兜底清理**:`scripts/cleanup_stale_blocked.py` 删「该渠道目录里根本没有这个模型」的负缓存行(扇出留下的噪声)。判据保守:目录里**有**这个模型的行一律保留(那是上游真把它下架了,负缓存正是应有的避让);目录快照读不出来 → 一律不删。 + +### 9.1.2 「上游没这个模型」独立成 error_type +`ErrKind.BLOCKED`(上游明确说没有这个模型)统计落 `error_type=model_not_found`,**不再并进 `invalid_request`**。后者是「请求无效」(`ErrKind.INVALID` / `ErrKind.REQUEST`),归因面板显示「请求无效」,读起来像客户端参数写错了——实际是上游把该模型下架了(实测 2026-10-26 officeAce 404 `ModelArts.81009` 被标成「请求无效」)。 + +- 受控枚举 `CONTROLLED_ERROR_TYPES` 增 `model_not_found`(前端 `USAGE_ERROR_LABEL` 同步:「上游无此模型」)。 +- 可用性视图(`GET /api/models/availability`)的计入口径增 `model_not_found`:它**仍属**模型级失败,只是分类更准。 +- **收尾行不变**:轮换耗尽时那一行仍按 `_exhaust_error_type` 落 `model_unavailable` / `no_healthy_credential`——「上游没这个模型」(尝试级归因)与「这个模型现在全都不可用」(收尾行)是两件事。 + + **单模型查询** `GET /v1/models/{model_id:path}`:与列表**共用** `_revalidated_providers` + `_served_entries`。命中三种写法(对外 id / 展示名 / 任一渠道 `raw_ids`),`strip().lower()` 比较。`:path` 容纳带命名空间的原代号(`dots-studio/dots-3-note-preview:free`)。**白名单外与不存在同形 404**(`model_not_found`),客户端无法探测池子。能力分与列表同源。 ### 9.3 落盘快照与启动回灌 diff --git a/scripts/cleanup_stale_blocked.py b/scripts/cleanup_stale_blocked.py new file mode 100644 index 00000000..686bebfd --- /dev/null +++ b/scripts/cleanup_stale_blocked.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""清理「该渠道从来没有这个模型」的负缓存行(`reason='blocked'`)。 + +背景:负缓存是 (凭证, 模型) 粒度的「该后端无此模型」,由上游明确拒绝触发 +(CodeBuddy 11102 / officeAce 81004·81009 / CodeArts InferHub.002002009)。 +它本该只落在**真正提供过该模型**的渠道上。 + +但 2026-10-26 实测到一批被污染的负缓存:`deepseek-v4.1-flash-1017` 只有 +officeAce 的 `/v2/models` 挂着(26 个模型之一),探活判死后别名表丢了归属, +请求扇出到 codebuddy / codearts / codebuddy_intl,三家各回「无此模型」, +于是 8 条负缓存写在了**从没有该模型**的渠道上——它们锁的是「上游没有这个 +模型」,可这些上游本来就没有,无从谈起,纯粹是噪声。 + +判定(保守):一条 blocked 负缓存行,若该**渠道的模型目录里根本没有这个 +模型**(按小写原名比对),则是扇出留下的噪声 → 可删。 + +为什么这条判据是安全的: + * 负缓存的语义是「该后端无此模型」,前提是**该后端曾被问过这个模型**。 + 目录里没有 → 从没被问过(或问的是别的名字)→ 这条缓存拦不到任何东西。 + * 反过来,目录里**有**这个模型的行一律保留:那是上游真的把它下架了 + (officeAce 的 81009),负缓存正是应有的避让。 + * 目录是**缓存快照**(`data/model_catalog.json`),可能过期。只用它做 + 「该渠道确实不认识这个名字」的判据,不做「上游已恢复」的判据。 + +用法: + python3 scripts/cleanup_stale_blocked.py # 预览,不写库 + python3 scripts/cleanup_stale_blocked.py --apply # 备份后删除 + python3 scripts/cleanup_stale_blocked.py --db PATH # 指定数据库 + python3 scripts/cleanup_stale_blocked.py --all # 连「目录里有」的 + # 也删(不推荐) +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from scripts._common import backup # noqa: E402 + +# 落盘目录快照里的字段名(见 `src/api/model_catalog.py` 的格式说明)。 +_CATALOG_PROVIDERS = "providers" +_CATALOG_MODELS = "models" + + +def _catalog_models(data_dir: Path) -> dict[str, set[str]]: + """落盘目录快照 → {渠道: {小写原名}}。 + + 快照损坏 / 不存在 → 空 dict(本脚本据此**什么也不删**:没有目录就没有 + 「该渠道不认识这个名字」的判据)。读取失败绝不静默成「目录为空」—— + 那会把所有负缓存都当成噪声删掉。 + """ + path = data_dir / "model_catalog.json" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + providers = payload.get(_CATALOG_PROVIDERS) + if not isinstance(providers, dict): + return {} + tables: dict[str, set[str]] = {} + for provider_id, record in providers.items(): + models = record.get(_CATALOG_MODELS) if isinstance(record, dict) else None + if not isinstance(models, list): + continue + tables[provider_id] = { + str(item.get("id", "")).lower() + for item in models if isinstance(item, dict) and item.get("id") + } + return tables + + +def stale_rows(conn, tables: dict[str, set[str]], + *, include_known: bool = False) -> list[tuple[str, str, str, int]]: + """待删的负缓存行:(渠道, 模型, 凭证 id, 到期时刻)。 + + `include_known=False`(默认)只返回「该渠道的目录里没有这个模型」的行。 + `include_known=True` 时连「目录里有」的也返回——那是管理员确认上游已恢复 + 后的强制清理。 + """ + rows = conn.execute( + "SELECT cred.provider AS provider, c.model AS model, " + " c.credential_id AS credential_id, c.cooling_until AS cooling_until " + "FROM credential_model_cooldowns c " + "JOIN credentials cred ON cred.id = c.credential_id " + "WHERE c.reason = 'blocked' AND c.cooling_until > ? " + "ORDER BY cred.provider, c.model, c.credential_id", + (int(time.time()),), + ).fetchall() + stale: list[tuple[str, str, str, int]] = [] + for row in rows: + provider, model, credential_id, cooling_until = row + known = tables.get(provider) + if known is None: + # 该渠道的目录快照没有它 → 无从判断,保守保留 + continue + if model.lower() in known and not include_known: + continue + stale.append((provider, model, credential_id, int(cooling_until))) + return stale + + +def delete_rows(conn, stale: list[tuple[str, str, str, int]]) -> int: + """删除待清理的负缓存行,返回删除条数。""" + total = 0 + for _provider, model, credential_id, _until in stale: + cursor = conn.execute( + "DELETE FROM credential_model_cooldowns " + "WHERE credential_id = ? AND model = ? AND reason = 'blocked'", + (credential_id, model)) + total += cursor.rowcount + return total + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="清理「该渠道从来没有这个模型」的负缓存行") + parser.add_argument("--db", help="SQLite 路径(默认 DATA_DIR/coding2api.sqlite3)") + parser.add_argument("--data-dir", help="数据目录(默认取数据库所在目录)") + parser.add_argument("--apply", action="store_true", + help="实际删除(默认只预览)") + parser.add_argument("--all", action="store_true", + help="连「目录里登记过」的负缓存也删(上游已恢复时用)") + args = parser.parse_args(argv) + + from src.config import load_settings + from src.db.conn import Database + + db_path = Path(args.db) if args.db else Path(load_settings().db_path) + if not db_path.is_file(): + parser.error(f"数据库不存在: {db_path}") + data_dir = Path(args.data_dir) if args.data_dir else db_path.parent + + tables = _catalog_models(data_dir) + if not tables: + print("没有可用的模型目录快照(缺 model_catalog.json 或读取失败):" + "无判据,不删任何行") + return 0 + + database = Database(db_path) + conn = database.connect() + stale = stale_rows(conn, tables, include_known=args.all) + if not stale: + print("没有需要清理的负缓存") + return 0 + for provider, model, credential_id, until in stale: + print(f" {provider:<14} {model:<28} {credential_id} " + f"到期 {until}") + total = len(stale) + scope = "全部" if args.all else "该渠道目录里没有的" + print(f"共 {total} 条负缓存({scope})") + + if not args.apply: + print("预览模式,未修改数据库;确认无误后加 --apply 执行") + return 0 + + target = backup(db_path) + deleted = delete_rows(conn, stale) + conn.commit() + print(f"已删除 {deleted} 条负缓存(备份: {target})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/api/admin_model_availability.py b/src/api/admin_model_availability.py index dfd00ef3..2c1bad27 100644 --- a/src/api/admin_model_availability.py +++ b/src/api/admin_model_availability.py @@ -10,10 +10,11 @@ `since` 绝对时刻限定,走 `idx_usage_ts`;`days` 默认 7、上限 30,`limit` 上限 200——聚合必须限窗口与行数,否则一张跑了一年的库会被这一个只读端点 拖垮。 -* 只认 `error_type in ('upstream_blocked', 'invalid_request')`:前者是协议 - 预留值(provider 直接上报时自动纳入),后者是当前 executor 对 - `ErrKind.BLOCKED` / `ErrKind.INVALID` 的统一归类——「上游不认这个模型」 - 目前就落在 `invalid_request` 里。 +* 只认 `error_type in ('upstream_blocked', 'model_not_found', 'invalid_request')`: + `model_not_found` 是 executor 对「上游明确说没有这个模型」(`ErrKind.BLOCKED`, + 2026-10-26 从 `invalid_request` 里独立出来)的归类,`upstream_blocked` 是协议 + 预留值(provider 直接上报时自动纳入),`invalid_request` 是请求无效 + (`ErrKind.INVALID` / `ErrKind.REQUEST`,上游 400 拒绝也落这里)。 * `blocked` 是**现算**的:拿 `MODEL_BLOCKLIST`(热更)与记录里的模型名 做 glob 匹配,与 `/v1/models` 的过滤同口径(归一键 / 展示名任一命中即算)。 不在服务端缓存,改完黑名单下一次请求立即反映。 @@ -64,7 +65,7 @@ MAX_LIMIT = 200 # 计入「模型级失败」的 error_type(见模块 docstring)。 -FAILURE_ERROR_TYPES = ("upstream_blocked", "invalid_request") +FAILURE_ERROR_TYPES = ("upstream_blocked", "model_not_found", "invalid_request") def _blocked(model: str, patterns: tuple[str, ...]) -> bool: diff --git a/src/api/model_merge.py b/src/api/model_merge.py index b8510c6f..392c041b 100644 --- a/src/api/model_merge.py +++ b/src/api/model_merge.py @@ -458,20 +458,30 @@ def _register_aliases(entries: list[dict[str, Any]], """ for entry in entries: for provider_id in sorted(entry["providers"]): - provider_aliases = aliases.setdefault(provider_id, {}) - raw_id = entry["raw_ids"][provider_id] - provider_aliases[entry["id"].lower()] = raw_id - provider_aliases[raw_id.lower()] = raw_id - # 「按展示名入键」的渠道额外登记展示名键:用户按 `Kimi K3` 或 - # `kimi-k3` 直连都能落到该渠道。不登记同渠道重名项(CB 的 - # `hy4-preview` / `hy4-preview-x` 都叫「Hy4 Preview」,登记任一个 - # 都会指向错误模型)。 - display = entry["meta"].get("name") - if provider_id in entry["name_keyed"] and display: - provider_aliases[str(display).lower()] = raw_id - label_key = normalize_model_key(str(display)) - if label_key != str(display).lower(): - provider_aliases[label_key] = raw_id + _register_entry_alias(entry, provider_id, aliases) + + +def _register_entry_alias(entry: dict[str, Any], provider_id: str, + aliases: dict[str, dict[str, str]]) -> None: + """单个 (条目, 渠道) 的别名登记(`_register_aliases` 的单元)。 + + 单独成函数是为了让「探活判死但仍要保留归属」的补登记走**同一套**键推导 + (见 `api.models._hidden_aliases`):两处各写一份键推导,迟早漂移。 + """ + raw_id = entry["raw_ids"][provider_id] + provider_aliases = aliases.setdefault(provider_id, {}) + provider_aliases[entry["id"].lower()] = raw_id + provider_aliases[raw_id.lower()] = raw_id + # 「按展示名入键」的渠道额外登记展示名键:用户按 `Kimi K3` 或 + # `kimi-k3` 直连都能落到该渠道。不登记同渠道重名项(CB 的 + # `hy4-preview` / `hy4-preview-x` 都叫「Hy4 Preview」,登记任一个 + # 都会指向错误模型)。 + display = entry["meta"].get("name") + if provider_id in entry["name_keyed"] and display: + provider_aliases[str(display).lower()] = raw_id + label_key = normalize_model_key(str(display)) + if label_key != str(display).lower(): + provider_aliases[label_key] = raw_id def _build_aliases(entries: list[dict[str, Any]], diff --git a/src/api/models.py b/src/api/models.py index 11225b2f..95daa658 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -43,12 +43,16 @@ from ..engine.reasoning import parse_effort_map from ..model_alias import parse_model_aliases from ..pricing import PriceTable +from ..provider.base import Model +from ..provider.naming import normalize_model_key from .deps import ApiKeyPrincipal, Services, api_key_user from .model_catalog import load_catalog, save_catalog from .model_merge import ( _build_aliases, _entry_response, _merge_provider, + _model_label, + _register_entry_alias, _sort_key, _visible, ) @@ -96,8 +100,10 @@ def merged_entries(services: Services, connected: set[str]) -> list[dict[str, An **再叠一层「探活判死」过滤**(`probed_out`):(渠道, 模型) 被探活判死且 仍被 blocked 负缓存锁着时,把这条模型**从该渠道的表里抽掉**——只抽该渠道, 别的渠道正常时同一模型照常展示(合并键跨渠道,多渠道条目退化成单渠道条目)。 - 隐藏条目时同时跳过它,别名表里也就没有「渠道 → 原名」,执行侧 - `_narrow_providers` 自然不再选它。与黑名单同口径:点名直连指定模型不受影响。 + 与黑名单同口径:点名直连指定模型不受影响。 + + **但别名登记不能跟着摘**(见 `_hidden_aliases`):摘掉后执行侧 + `_narrow_providers` 会因「没有任何渠道登记该模型」回退成全部渠道扇出。 """ patterns = services.settings.blocklist_patterns probed_out = services.model_probes.failed_and_blocked() @@ -116,19 +122,90 @@ def merged_entries(services: Services, connected: set[str]) -> list[dict[str, An return sorted(grouped.values(), key=_sort_key) -def _publish(services: Services, entries: list[dict[str, Any]]) -> None: +def _hidden_aliases(services: Services, + probed_out: set[tuple[str, str]], + connected: set[str]) -> dict[str, dict[str, str]]: + """探活判死的 (渠道, 模型) → 别名登记(**只影响执行侧收窄,不进展示**)。 + + 「探活判死」把该 (渠道, 模型) 从模型列表里摘掉(`merged_entries`),若别名 + 登记也跟着摘,执行侧 `_narrow_providers` 就找不到任何登记该模型的渠道 → + **回退成全部渠道扇出**:请求被发到一堆根本没有该模型的上游,各回「无此 + 模型」,并被写上 (凭证, 模型) 负缓存——污染的是从未提供该模型的渠道。 + + 实测 2026-10-26 `deepseek-v4.1-flash-1017`:officeAce 的 `/v2/models` 挂着 + 它(26 个模型之一)但推理端点直接 404 `ModelArts.81009`;人工探活判死后 + 别名表丢了 officeAce 的归属,请求扇出到 codebuddy / codearts / + codebuddy_intl,三家各回「无此模型」,8 条负缓存写在从没有该模型的渠道上。 + + 故这里把判死的 (渠道, 模型) 补登记回别名表:请求仍**收窄**到真正持有它 + 的渠道,由负缓存(判死的前提条件)让该渠道不可选 → 快速 503「该模型暂 + 时不可用」,而不是打一轮无关上游。 + + 键推导走 `_register_entry_alias`(与真实条目**同一套**):两处各写一份 + 键推导,迟早漂移——漂移的表现是「用户按展示名请求时收窄失效」。 + **不覆盖已有映射**:真实条目的登记优先。 + **只登记有可用凭证的渠道**(`connected`):与 `merged_entries` 的口径一致 + (没凭证的渠道既不拉取也不展示),否则会给一个根本打不通的渠道登记归属。 + """ + aliases: dict[str, dict[str, str]] = {} + for provider_id, lower in sorted(probed_out): + if provider_id not in connected: + continue + table = services.model_list_cache.get(provider_id) or {} + entry = _hidden_entry(table.get(lower), lower, provider_id) + _register_entry_alias(entry, provider_id, aliases) + return aliases + + +def _hidden_entry(model: Model | None, lower: str, + provider_id: str) -> dict[str, Any]: + """判死模型 → 一个只含它自己的最小条目(供别名登记用)。 + + 键的推导与真实条目同源(`_model_label` / `normalize_model_key`):对外 id + 取展示名的归一键,展示名取清洗后的文本;`name_keyed` 含该渠道(它按 + 展示名入的键,故展示名键也要登记)。 + + **目录里已经没有这个模型**(上游把它撤了,`model is None`):无从得知它 + 的对外 id 与展示名,退回按原 id 登记(`lower`)。此时用户按原 id 请求 + 仍能收窄到该渠道;按展示名请求则收窄不到——但那条路径本来就没有「这个 + 模型属于哪」的信息可言。 + """ + if model is None: + return {"id": lower, "meta": {"name": ""}, + "raw_ids": {provider_id: lower}, "name_keyed": set()} + label = _model_label(model) + return { + "id": normalize_model_key(label) or normalize_model_key(model.id), + "meta": {"name": label}, + "raw_ids": {provider_id: model.id}, + "name_keyed": {provider_id}, + } + + +def _publish(services: Services, entries: list[dict[str, Any]], + connected: set[str]) -> None: """定稿好的条目 → 就地更新别名表(executor 的闭包引用同一个 dict 对象)。 先算好再 clear+update:`_build_aliases` 若抛错,不能让别名表停在「已清空、 未写入」的中间态(那会让所有扁平名请求失去候选收窄)。对象身份必须保留, 故不能整体替换 dict。 + **判死的 (渠道, 模型) 补登记回别名表**(`_hidden_aliases`):它们已从 + 展示里摘掉,但执行侧的候选收窄必须仍指向真正持有它们的渠道,否则请求 + 扇出到一堆不认该模型的上游(见 `_hidden_aliases` 的 docstring)。 + 自定义别名(`MODEL_ALIASES`)**每次现读**运行态覆盖层再解析:这是热更 项,改完下一次列表 / 单查就生效,不需要重启或等 TTL。解析结果同时进 「条目表」(列表侧)与「别名表」(执行侧),两者天然是同一份。 """ custom = parse_model_aliases(str(getattr(services.settings, "model_aliases", ""))) aliases = _build_aliases(entries, custom) + for provider_id, provider_aliases in _hidden_aliases( + services, services.model_probes.failed_and_blocked(), + connected).items(): + target = aliases.setdefault(provider_id, {}) + for key, raw_id in provider_aliases.items(): + target.setdefault(key, raw_id) services.model_aliases.clear() services.model_aliases.update(aliases) @@ -140,7 +217,7 @@ def publish_aliases(services: Services, connected: set[str]) -> None: zen 探活(十几秒)——那段时间里已拉好的渠道本该已经能收窄候选,否则扁平名 请求会按「全部渠道」扇出,真实打一轮不认这个模型的上游。 """ - _publish(services, merged_entries(services, connected)) + _publish(services, merged_entries(services, connected), connected) async def _refresh_provider(services: Services, provider_id: str, provider: Any) -> None: @@ -304,7 +381,7 @@ def _served_entries(services: Services, connected: set[str], 同一份「定稿后」条目(`_publish` 会就地 finalize,id 撞车消歧只发生一次)。 """ entries = merged_entries(services, connected) - _publish(services, entries) + _publish(services, entries, connected) if allowed_models: from ..auth.access import model_allowed entries = [entry for entry in entries diff --git a/src/engine/executor.py b/src/engine/executor.py index 751d8a68..3f997e91 100644 --- a/src/engine/executor.py +++ b/src/engine/executor.py @@ -1752,7 +1752,14 @@ def _error_type_for(kind: ErrKind) -> str: return "rate_limit" if kind is ErrKind.DEAD: return "credential_unavailable" - if kind in (ErrKind.INVALID, ErrKind.BLOCKED, ErrKind.REQUEST): + if kind is ErrKind.BLOCKED: + # 「该后端无此模型」(11102 / 81004 / 81009 / InferHub 002002009): + # **不是**请求无效。归到 `invalid_request` 会让归因面板把「上游没这个 + # 模型」读成「客户端参数写错了」(实测 2026-10-26 officeAce + # `deepseek-v4.1-flash-1017` 404 `ModelArts.81009` 就是被标成 + # 「请求无效」)。独立成类才能让运维一眼看出是模型下线而非请求错。 + return "model_not_found" + if kind in (ErrKind.INVALID, ErrKind.REQUEST): return "invalid_request" return "upstream_error" diff --git a/src/stats/collector.py b/src/stats/collector.py index e2160deb..6e4d5fe0 100644 --- a/src/stats/collector.py +++ b/src/stats/collector.py @@ -27,6 +27,7 @@ # client_disconnect — 客户端中途断开(executor.stream) # credential_unavailable — session 失效(_error_type_for(DEAD)) # invalid_request — 请求无效(_record_invalid) + # model_not_found — 上游明确说「没有这个模型」(_error_type_for(BLOCKED)) # no_healthy_credential — 无可用凭证(executor 耗尽轮换) # model_unavailable — 耗尽轮换但候选全部只因**该模型**的模型级 # 冷却不可用(_exhaust_error_type;换个模型即可用) @@ -35,7 +36,8 @@ # upstream_first_event_timeout — 出帧前上游不吐首个数据事件(A1) # upstream_idle_timeout — 已出帧后上游停止吐数据(A1) "client_disconnect", "credential_unavailable", - "invalid_request", "no_healthy_credential", "model_unavailable", + "invalid_request", "model_not_found", "no_healthy_credential", + "model_unavailable", "rate_limit", "upstream_error", "upstream_protocol", "upstream_first_event_timeout", "upstream_idle_timeout", }) diff --git a/src/stats/query.py b/src/stats/query.py index a66d27d7..31b8ff2a 100644 --- a/src/stats/query.py +++ b/src/stats/query.py @@ -589,10 +589,12 @@ def metrics_provider_tokens(self) -> list[tuple[str, int, int, int, int]]: def model_failures(self, *, since: int, limit: int = 200) -> list[dict[str, Any]]: """按 (provider, model) 聚合窗口内的模型级失败(可用性视图的数据源)。 - 只认两类错误:`invalid_request`(当前 executor 把上游「无此模型」的 - `ErrKind.BLOCKED` 也归到这一类)与 `upstream_blocked`(协议预留值, - 未来 provider 直接上报时自动纳入)。窗口由调用方给绝对时刻 `since`, - SQL 走 `idx_usage_ts`;`limit` 由调用方收敛,多取一条供判断截断。 + 只认三类错误:`model_not_found`(executor 对「上游明确说没有这个模型」 + `ErrKind.BLOCKED` 的归类,2026-10-26 从 `invalid_request` 里独立出来)、 + `invalid_request`(请求无效:`ErrKind.INVALID` / `ErrKind.REQUEST`,上游 + 400 拒绝也落这里)与 `upstream_blocked`(协议预留值,未来 provider + 直接上报时自动纳入)。窗口由调用方给绝对时刻 `since`,SQL 走 + `idx_usage_ts`;`limit` 由调用方收敛,多取一条供判断截断。 `last_failed_at` 取该组内最大 `ts`;排序按失败次数降序,同次数按 (provider, model) 升序,保证分页 / 快照稳定。 @@ -601,7 +603,8 @@ def model_failures(self, *, since: int, limit: int = 200) -> list[dict[str, Any] """ SELECT provider, model, COUNT(*) AS failures, MAX(ts) AS last_failed_at FROM usage_events - WHERE ts >= ? AND error_type IN ('upstream_blocked', 'invalid_request') + WHERE ts >= ? AND error_type IN + ('upstream_blocked', 'model_not_found', 'invalid_request') GROUP BY provider, model ORDER BY failures DESC, provider ASC, model ASC LIMIT ? diff --git a/tests/api/test_chat_narrowing.py b/tests/api/test_chat_narrowing.py index edbd35bb..fb4bb39f 100644 --- a/tests/api/test_chat_narrowing.py +++ b/tests/api/test_chat_narrowing.py @@ -341,3 +341,48 @@ async def test_forced_provider_without_usable_credential_keeps_narrowing(dual_re with pytest.raises(NoHealthyCredential): await executor.complete(_request("deepseek-v4.1-flash@codebuddy"), username="u") assert (cb.calls, trae.calls) == (0, 0) + + +async def test_probe_failed_model_still_narrows_to_its_owner(dual_repo): + """探活判死的模型仍收窄到持有它的渠道 → 不扇出到无此模型的渠道。 + + 实测故障(2026-10-26 `deepseek-v4.1-flash-1017`):officeAce 的 + `/v2/models` 挂着它但推理端点直接 404 `ModelArts.81009`(上游目录撒谎)。 + 人工探活判死后,若别名登记跟着被摘掉,执行侧 `_narrow_providers` 因 + 「没有任何渠道登记该模型」回退成**全部渠道** → 请求扇出到 codebuddy / + codearts / codebuddy_intl,三家各回「无此模型」(11102 / not registered), + 并被写上 (凭证, 模型) 负缓存——污染的是从没有该模型的渠道。 + + 修好后:别名登记保留 → 收窄到 officeAce → 负缓存让该渠道的凭证不可选 + → 快速 503「该模型暂时不可用」,其余渠道零调用、零负缓存。 + """ + repo, db = dual_repo + officeace_id = repo.add(provider="officeace", credential_data={"t": "x"}) + repo.add(provider="codebuddy", credential_data={"bearer_token": "cb"}) + repo.add(provider="codearts", credential_data={"t": "x"}) + # 负缓存(判死的前提):officeAce 的这个模型被负缓存锁住 + repo.save_error(officeace_id, ErrorOutcome(model_cooldowns={ + "deepseek-v4.1-flash-1017": ModelCooldown( + cooling_until=int(time.time()) + 3600, hits=1, reason="blocked")})) + officeace = _RejectProvider("officeace", [GOOD]) + cb = _RejectProvider("codebuddy", [GOOD]) + codearts = _RejectProvider("codearts", [GOOD]) + executor = Executor(ExecutorDeps( + providers={"officeace": officeace, "codebuddy": cb, "codearts": codearts}, + credentials=repo, scheduler=Scheduler(), + default_model="deepseek-v4.1-flash-1017", + # 别名表保留判死渠道的归属(`api.models._hidden_aliases` 的产出) + model_aliases={"officeace": {"deepseek-v4.1-flash-1017": + "deepseek-v4.1-flash-1017"}}, + upstream_model_name=lambda pid, name: { + "officeace": {"deepseek-v4.1-flash-1017": "deepseek-v4.1-flash-1017"} + }.get(pid, {}).get(name.lower(), name))) + + with pytest.raises(NoHealthyCredential) as exc_info: + await executor.complete(_request("deepseek-v4.1-flash-1017"), username="u") + assert "temporarily unavailable on upstream" in str(exc_info.value) + # 只有持有该模型的渠道被考虑过;无此模型的渠道零调用 + assert (officeace.calls, cb.calls, codearts.calls) == (0, 0, 0) + # 负缓存零新增:没给任何渠道写「该后端无此模型」 + assert db.connect().execute( + "SELECT COUNT(*) FROM credential_model_cooldowns").fetchone()[0] == 1 diff --git a/tests/api/test_models.py b/tests/api/test_models.py index 45817bd0..526b28d0 100644 --- a/tests/api/test_models.py +++ b/tests/api/test_models.py @@ -413,6 +413,10 @@ def test_failed_probe_hides_model_for_that_channel_only(tmp_path): 其它渠道正常时同一模型照常展示(多渠道条目退化成单渠道条目);负缓存 到期后自动回到列表(那之后没人再探,只看探活结论会永久隐藏)。 + + **但别名登记必须保留**(2026-10-26 实测故障):摘掉别名后执行侧 + `_narrow_providers` 找不到任何登记该模型的渠道 → 回退成全部渠道扇出, + 请求被发到一堆不认该模型的上游,各回「无此模型」并被写上负缓存。 """ import time @@ -455,8 +459,9 @@ def import_credential(self, raw): # pragma: no cover - 未使用 # 只剩 zen 提供:条目仍在,但 providers 里没有 trae entry = client.get("/v1/models", headers=auth).json()["data"][0] assert (entry["id"], entry["providers"]) == ("glm-5.3", ["zen"]) - # 别名表同步收窄:执行侧 `_narrow_providers` 不再选 trae - assert "trae" not in services.model_aliases + # 别名表**保留** trae 的归属:执行侧 `_narrow_providers` 仍收窄到 + # 真正持有该模型的渠道,不会因「没人登记」回退成全部渠道扇出 + assert services.model_aliases["trae"]["glm-5.3"] == "glm-5.3" # 负缓存到期:自动回到列表(不依赖再次探活) with services.credentials._db.transaction() as conn: @@ -465,6 +470,201 @@ def import_credential(self, raw): # pragma: no cover - 未使用 assert entry["providers"] == ["trae", "zen"] +def test_failed_probe_keeps_alias_so_narrowing_does_not_fan_out(tmp_path): + """判死模型的别名登记必须保留:否则执行侧收窄失效 → 全部渠道扇出。 + + 实测故障(2026-10-26 `deepseek-v4.1-flash-1017`):officeAce 的 + `/v2/models` 挂着它(26 个之一)但推理端点直接 404 `ModelArts.81009`; + 人工探活判死后别名表丢了 officeAce 的归属 → `_narrow_providers` 因 + 「没有任何渠道登记该模型」回退成**全部渠道** → 请求扇出到 codebuddy / + codearts / codebuddy_intl,三家各回「无此模型」(11102 / not registered), + 并被写上 (凭证, 模型) 负缓存——8 条负缓存落在从没有该模型的渠道上。 + + 本用例断言:判死后别名表仍有 officeAce 的归属(收窄依据在),而展示 + 里该模型已消失。 + """ + import time + + from src.provider.base import Model + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + MODEL_BLOCKLIST="") + + class Stub: + def __init__(self, pid: str): + self.id = pid + + async def list_models(self, _data): + return [Model(id="deepseek-v4.1-flash-1017")] + + def import_credential(self, raw): # pragma: no cover - 未使用 + return raw + + app = build_app(settings, providers={"officeace": Stub("officeace"), + "codebuddy": Stub("codebuddy")}) + for provider_id in ("officeace", "codebuddy"): + app.state.credentials.add(provider=provider_id, credential_data={"t": "x"}) + key = app.state.api_keys.create("root")["api_key"] + auth = {"Authorization": f"Bearer {key}"} + with TestClient(app) as client: + services = app.state.services + # officeAce 独有该模型(codebuddy 的模型表里没有它) + services.model_list_cache["officeace"] = { + "deepseek-v4.1-flash-1017": Model(id="deepseek-v4.1-flash-1017")} + services.model_list_cache["codebuddy"] = {"deepseek-v4.1-flash": Model( + id="deepseek-v4.1-flash")} + + services.model_probes.record(provider="officeace", + model="deepseek-v4.1-flash-1017", state="failed") + credential_id = services.credentials.candidates(["officeace"])[0].credential_id + with services.credentials._db.transaction() as conn: + conn.execute( + "INSERT INTO credential_model_cooldowns " + "(credential_id, model, cooling_until, hits, reason) " + "VALUES (?,?,?,?,'blocked')", + (credential_id, "deepseek-v4.1-flash-1017", int(time.time()) + 3600, 1)) + + client.get("/v1/models", headers=auth) # 触发一次 publish + + # 展示里该模型消失(判死 + 负缓存生效) + ids = {item["id"] for item in + client.get("/v1/models", headers=auth).json()["data"]} + assert "deepseek-v4.1-flash-1017" not in ids + # 但别名登记保留 → 执行侧仍收窄到 officeAce,不扇出到 codebuddy + assert services.model_aliases["officeace"]["deepseek-v4.1-flash-1017"] \ + == "deepseek-v4.1-flash-1017" + + +def test_hidden_alias_not_registered_for_channel_without_credentials(tmp_path): + """没有可用凭证的渠道不登记判死别名(与 `merged_entries` 同口径)。 + + 给一个根本打不通的渠道登记归属,等于把请求指向一个死渠道。 + """ + import time + + from src.provider.base import Model + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + MODEL_BLOCKLIST="") + + class Stub: + id = "officeace" + + async def list_models(self, _data): + return [Model(id="m-only")] + + def import_credential(self, raw): # pragma: no cover - 未使用 + return raw + + app = build_app(settings, providers={"officeace": Stub()}) + key = app.state.api_keys.create("root")["api_key"] + auth = {"Authorization": f"Bearer {key}"} + with TestClient(app) as client: + services = app.state.services + # 渠道在缓存里,但没有凭证 → 不在 `connected` 里 + services.model_list_cache["officeace"] = {"m-only": Model(id="m-only")} + services.model_probes.record(provider="officeace", model="m-only", + state="failed") + credential_id = app.state.credentials.add( + provider="officeace", credential_data={"t": "x"}) + with services.credentials._db.transaction() as conn: + conn.execute( + "INSERT INTO credential_model_cooldowns " + "(credential_id, model, cooling_until, hits, reason) " + "VALUES (?,?,?,?,'blocked')", + (credential_id, "m-only", int(time.time()) + 3600, 1)) + # 凭证被硬禁用 → 渠道不在 `connected`(无**可用**凭证) + with services.credentials._db.transaction() as conn: + conn.execute("UPDATE credentials SET disabled = 1 WHERE id = ?", + (credential_id,)) + + client.get("/v1/models", headers=auth) + assert "officeace" not in services.model_aliases + + +def test_hidden_alias_does_not_override_real_entry(tmp_path): + """判死补登记**不覆盖**真实条目的映射(真实条目优先)。""" + import time + + from src.provider.base import Model + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + MODEL_BLOCKLIST="") + + class Stub: + id = "trae" + + async def list_models(self, _data): + return [Model(id="glm-5.3")] + + def import_credential(self, raw): # pragma: no cover - 未使用 + return raw + + app = build_app(settings, providers={"trae": Stub()}) + app.state.credentials.add(provider="trae", credential_data={"t": "x"}) + key = app.state.api_keys.create("root")["api_key"] + auth = {"Authorization": f"Bearer {key}"} + with TestClient(app) as client: + services = app.state.services + services.model_probes.record(provider="trae", model="glm-5.3", state="failed") + credential_id = services.credentials.candidates(["trae"])[0].credential_id + with services.credentials._db.transaction() as conn: + conn.execute( + "INSERT INTO credential_model_cooldowns " + "(credential_id, model, cooling_until, hits, reason) " + "VALUES (?,?,?,?,'blocked')", + (credential_id, "glm-5.3", int(time.time()) + 3600, 1)) + + client.get("/v1/models", headers=auth) + # 真实条目的映射原样保留(值仍是该渠道的原 id) + assert services.model_aliases["trae"]["glm-5.3"] == "glm-5.3" + + +def test_hidden_alias_falls_back_to_raw_id_when_catalog_dropped_it(tmp_path): + """上游把模型从目录里撤了 → 判死别名按**原 id**登记(仍能收窄)。 + + 目录里查不到该模型时无从得知它的对外 id 与展示名,退回按原 id 登记: + 用户按原 id 请求仍收窄到该渠道;按展示名请求则收窄不到(那条路径本来 + 就没有「这个模型属于哪」的信息)。 + """ + import time + + from src.provider.base import Model + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + MODEL_BLOCKLIST="") + + class Stub: + id = "officeace" + + async def list_models(self, _data): + return [Model(id="m-gone")] + + def import_credential(self, raw): # pragma: no cover - 未使用 + return raw + + app = build_app(settings, providers={"officeace": Stub()}) + app.state.credentials.add(provider="officeace", credential_data={"t": "x"}) + key = app.state.api_keys.create("root")["api_key"] + auth = {"Authorization": f"Bearer {key}"} + with TestClient(app) as client: + services = app.state.services + services.model_probes.record(provider="officeace", model="m-gone", + state="failed") + credential_id = services.credentials.candidates(["officeace"])[0].credential_id + with services.credentials._db.transaction() as conn: + conn.execute( + "INSERT INTO credential_model_cooldowns " + "(credential_id, model, cooling_until, hits, reason) " + "VALUES (?,?,?,?,'blocked')", + (credential_id, "m-gone", int(time.time()) + 3600, 1)) + # 上游已把该模型从目录里撤掉:缓存里查不到 + services.model_list_cache["officeace"] = {} + + client.get("/v1/models", headers=auth) + assert services.model_aliases["officeace"]["m-gone"] == "m-gone" + + def test_probe_ok_does_not_hide_model(tmp_path): """探活成功(清了负缓存)不影响列表;判死但负缓存已清同样不隐藏。""" import time diff --git a/tests/api/test_models_availability.py b/tests/api/test_models_availability.py index ac4c8d07..c769ba6f 100644 --- a/tests/api/test_models_availability.py +++ b/tests/api/test_models_availability.py @@ -114,6 +114,21 @@ def test_ignores_other_error_types_and_successes(admin_client): assert _rows(client)["rows"] == [] +def test_model_not_found_counts_as_model_level_failure(admin_client): + """「上游无此模型」(`model_not_found`)计入模型级失败。 + + 2026-10-26 起该类从 `invalid_request` 里独立出来(此前被读成「客户端 + 参数写错了」),但**仍属**模型级失败,必须继续进可用性视图。 + """ + app, client = admin_client + _insert(app, provider="officeace", model="deepseek-v4.1-flash-1017", + error_type="model_not_found") + rows = _rows(client)["rows"] + assert len(rows) == 1 + assert rows[0]["provider"] == "officeace" + assert rows[0]["failures"] == 1 + + def test_window_excludes_old_failures(admin_client): app, client = admin_client now = int(time.time()) @@ -199,7 +214,8 @@ def test_blocked_matches_normalized_key_and_display_name(admin_client): def test_error_types_reported(admin_client): """响应里带口径说明,前端不必硬编码「哪几类算下线」。""" _app, client = admin_client - assert _rows(client)["error_types"] == ["upstream_blocked", "invalid_request"] + assert _rows(client)["error_types"] == [ + "upstream_blocked", "model_not_found", "invalid_request"] # ------------------------------------------------------- 探活结论与人工接管 diff --git a/tests/engine/test_executor_stats.py b/tests/engine/test_executor_stats.py index a13e5dd9..3ca76778 100644 --- a/tests/engine/test_executor_stats.py +++ b/tests/engine/test_executor_stats.py @@ -299,6 +299,17 @@ def test_error_type_mapping_covers_all_kinds(): assert _error_type_for(ErrKind.DEAD) == "credential_unavailable" assert _error_type_for(ErrKind.SOFT) == "upstream_error" assert _error_type_for(ErrKind.OTHER) == "upstream_error" + # 「上游没这个模型」独立成类:与「请求无效」分开(2026-10-26) + assert _error_type_for(ErrKind.BLOCKED) == "model_not_found" + assert _error_type_for(ErrKind.INVALID) == "invalid_request" + assert _error_type_for(ErrKind.REQUEST) == "invalid_request" + + +def test_blocked_error_type_is_controlled(): + """新枚举必须在受控白名单里,否则 `record` 会把它置 None(统计里看不到)。""" + from src.stats.collector import CONTROLLED_ERROR_TYPES + + assert "model_not_found" in CONTROLLED_ERROR_TYPES async def test_dead_credential_also_records_attribution_row(repo): @@ -423,6 +434,64 @@ def kind(self): assert collector.events[-1]["error_type"] == "no_healthy_credential" assert credentials.candidates()[0].cooling_until is not None + +async def test_blocked_error_records_model_not_found(repo): + """上游说「没这个模型」→ 统计记 `model_not_found`(不是「请求无效」)。 + + 实测 2026-10-26 officeAce `deepseek-v4.1-flash-1017` 404 + `ModelArts.81009`:归到 `invalid_request` 时归因面板显示「请求无效」, + 读起来像客户端参数写错了,实际是上游把该模型下架了。 + + 走流式路径且轮换预算用尽(`max_rotate=1`):那一行是**尝试级**归因, + 类型取自最后一次上游错误的分类(`_stream_error_type`)。 + """ + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + + class Blocked(Exception): + def __init__(self) -> None: + super().__init__("model not registered") + + def kind(self): + return ErrKind.BLOCKED + + executor = _exec_with_stats(repo, [[Blocked()]], collector, max_rotate=1) + from src.compat.openai.request import parse_chat_request + + async for _ in executor.stream(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}], "stream": True})): + pass + kinds = [event["error_type"] for event in collector.events] + assert "model_not_found" in kinds + assert "invalid_request" not in kinds + # 模型级负缓存照写(只锁这一个模型,不牵连整条凭证) + assert _db.connect().execute( + "SELECT COUNT(*) FROM credential_model_cooldowns").fetchone()[0] == 1 + + +async def test_blocked_exhaustion_still_records_model_unavailable(repo): + """轮换耗尽时的收尾行仍是 `model_unavailable`(口径不变)。 + + 「上游没这个模型」与「这个模型现在全都不可用」是两件事:前者是尝试级 + 归因(`model_not_found`),后者是收尾行(`model_unavailable`)。 + """ + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + + class Blocked(Exception): + def kind(self): + return ErrKind.BLOCKED + + executor = _exec_with_stats(repo, [[Blocked()]], collector) + from src.compat.openai.request import parse_chat_request + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + assert collector.events[-1]["error_type"] == "model_unavailable" + async def test_stream_plan_error_records_failure(repo): """流式路径的权益耗尽同样要落统计。""" credentials, _db = repo diff --git a/tests/scripts/test_cleanup_stale_blocked.py b/tests/scripts/test_cleanup_stale_blocked.py new file mode 100644 index 00000000..4ec9cb61 --- /dev/null +++ b/tests/scripts/test_cleanup_stale_blocked.py @@ -0,0 +1,193 @@ +"""scripts/cleanup_stale_blocked.py 的判定与删除测试。 + +背景(2026-10-26 实测):`deepseek-v4.1-flash-1017` 只有 officeAce 的 +`/v2/models` 挂着,探活判死后请求扇出到 codebuddy / codearts / +codebuddy_intl,三家各回「无此模型」→ 8 条负缓存写在了**从没有该模型**的 +渠道上。负缓存的语义是「该后端无此模型」,前提是该后端曾被问过这个模型; +目录里没有 → 这条缓存拦不到任何东西 → 是噪声。 +""" + +from __future__ import annotations + +import json +import sqlite3 +import time +from pathlib import Path + +import pytest + +from src.db.conn import Database +from src.db.migrate import apply_schema +from tests.helpers.paths import load_script + +CLEAN = load_script("cleanup_stale_blocked") + + +@pytest.fixture() +def db(tmp_path): + database = Database(tmp_path / "t.sqlite3") + apply_schema(database.connect()) + yield database + database.close() + + +def _credential(conn, provider: str) -> str: + conn.execute( + "INSERT INTO credentials (id, provider, data_enc, enabled, disabled, created_at) " + "VALUES (?, ?, ?, 1, 0, ?)", (f"cred_{provider}", provider, b"{}", int(time.time()))) + return f"cred_{provider}" + + +def _blocked(conn, credential_id: str, model: str, *, + until: int | None = None, reason: str = "blocked") -> None: + conn.execute( + "INSERT INTO credential_model_cooldowns " + "(credential_id, model, cooling_until, hits, reason) VALUES (?,?,?,?,?)", + (credential_id, model, until if until is not None else int(time.time()) + 3600, + 1, reason)) + + +def _catalog(data_dir: Path, tables: dict[str, list[str]]) -> None: + payload = {"version": 1, "providers": { + provider_id: {"saved_at": time.time(), + "models": [{"id": model_id} for model_id in models]} + for provider_id, models in tables.items()}} + (data_dir / "model_catalog.json").write_text( + json.dumps(payload), encoding="utf-8") + + +def test_stale_rows_only_when_channel_never_had_the_model(db, tmp_path): + """目录里没有该模型的渠道 → 负缓存是扇出噪声;目录里有的 → 保留。""" + conn = db.connect() + _credential(conn, "officeace") + _credential(conn, "codebuddy") + _blocked(conn, "cred_officeace", "deepseek-v4.1-flash-1017") + _blocked(conn, "cred_codebuddy", "deepseek-v4.1-flash-1017") + conn.commit() + _catalog(tmp_path, {"officeace": ["deepseek-v4.1-flash-1017"], + "codebuddy": ["deepseek-v4.1-flash"]}) + + tables = CLEAN._catalog_models(tmp_path) + stale = CLEAN.stale_rows(conn, tables) + assert [(row[0], row[1]) for row in stale] == [ + ("codebuddy", "deepseek-v4.1-flash-1017")] + + +def test_expired_and_non_blocked_rows_are_ignored(db, tmp_path): + """已过期的负缓存与其它 reason(模型限流)都不是本脚本的目标。""" + conn = db.connect() + _credential(conn, "codebuddy") + _blocked(conn, "cred_codebuddy", "gone", until=int(time.time()) - 1) + _blocked(conn, "cred_codebuddy", "limited", reason="model") + conn.commit() + _catalog(tmp_path, {"codebuddy": ["other"]}) + + stale = CLEAN.stale_rows(conn, CLEAN._catalog_models(tmp_path)) + assert stale == [] + + +def test_channels_absent_from_catalog_are_kept(db, tmp_path): + """目录快照里没有该渠道 → 无从判断,保守保留(不删)。""" + conn = db.connect() + _credential(conn, "kilo") + _blocked(conn, "cred_kilo", "m") + conn.commit() + _catalog(tmp_path, {"officeace": ["m"]}) + + stale = CLEAN.stale_rows(conn, CLEAN._catalog_models(tmp_path)) + assert stale == [] + + +def test_include_known_also_returns_catalogued_models(db, tmp_path): + """`--all` 连「目录里登记过」的也返回(上游已恢复时强制清理)。""" + conn = db.connect() + _credential(conn, "officeace") + _blocked(conn, "cred_officeace", "m") + conn.commit() + _catalog(tmp_path, {"officeace": ["m"]}) + + tables = CLEAN._catalog_models(tmp_path) + assert CLEAN.stale_rows(conn, tables) == [] + known = CLEAN.stale_rows(conn, tables, include_known=True) + assert [(row[0], row[1]) for row in known] == [("officeace", "m")] + + +def test_delete_rows_removes_only_targeted_entries(db, tmp_path): + """删除只命中待清理的行,其余负缓存原样保留。""" + conn = db.connect() + _credential(conn, "officeace") + _credential(conn, "codebuddy") + _blocked(conn, "cred_officeace", "m-ok") + _blocked(conn, "cred_codebuddy", "m-stale") + conn.commit() + _catalog(tmp_path, {"officeace": ["m-ok"], "codebuddy": ["other"]}) + + stale = CLEAN.stale_rows(conn, CLEAN._catalog_models(tmp_path)) + assert CLEAN.delete_rows(conn, stale) == 1 + conn.commit() + remaining = [row[0] for row in conn.execute( + "SELECT model FROM credential_model_cooldowns")] + assert remaining == ["m-ok"] + + +def test_missing_or_broken_catalog_deletes_nothing(db, tmp_path, capsys): + """没有可读目录快照 → 无判据,一律不删(绝不能把负缓存清空)。""" + conn = db.connect() + _credential(conn, "codebuddy") + _blocked(conn, "cred_codebuddy", "m") + conn.commit() + db_path = Path(db.path) + db.close() + + assert CLEAN.main(["--db", str(db_path), "--data-dir", str(tmp_path)]) == 0 + assert "无判据,不删任何行" in capsys.readouterr().out + assert sqlite3.connect(str(db_path)).execute( + "SELECT COUNT(*) FROM credential_model_cooldowns").fetchone()[0] == 1 + + # 快照损坏:同样不删 + (tmp_path / "model_catalog.json").write_text("{broken", encoding="utf-8") + assert CLEAN.main(["--db", str(db_path), "--data-dir", str(tmp_path)]) == 0 + assert sqlite3.connect(str(db_path)).execute( + "SELECT COUNT(*) FROM credential_model_cooldowns").fetchone()[0] == 1 + + +def test_main_preview_and_apply(db, tmp_path, capsys): + """预览不动库;`--apply` 删除并留备份。""" + conn = db.connect() + _credential(conn, "codebuddy") + _blocked(conn, "cred_codebuddy", "m-stale") + conn.commit() + _catalog(tmp_path, {"codebuddy": ["other"]}) + db_path = Path(db.path) + db.close() + + assert CLEAN.main(["--db", str(db_path), "--data-dir", str(tmp_path)]) == 0 + assert "共 1 条负缓存" in capsys.readouterr().out + assert sqlite3.connect(str(db_path)).execute( + "SELECT COUNT(*) FROM credential_model_cooldowns").fetchone()[0] == 1 + + assert CLEAN.main(["--db", str(db_path), "--data-dir", str(tmp_path), + "--apply"]) == 0 + assert "已删除 1 条负缓存" in capsys.readouterr().out + assert sqlite3.connect(str(db_path)).execute( + "SELECT COUNT(*) FROM credential_model_cooldowns").fetchone()[0] == 0 + assert list(tmp_path.glob("*.bak-*")), "apply 前必须留下备份" + + +def test_main_reports_nothing_to_clean(db, tmp_path, capsys): + conn = db.connect() + _credential(conn, "codebuddy") + _blocked(conn, "cred_codebuddy", "m") + conn.commit() + _catalog(tmp_path, {"codebuddy": ["m"]}) + db_path = Path(db.path) + db.close() + + assert CLEAN.main(["--db", str(db_path), "--data-dir", str(tmp_path)]) == 0 + assert "没有需要清理的负缓存" in capsys.readouterr().out + + +def test_main_rejects_missing_db(db, tmp_path): + db.close() + with pytest.raises(SystemExit): + CLEAN.main(["--db", str(tmp_path / "nope.sqlite3")]) diff --git a/web/src/api/__tests__/display.test.ts b/web/src/api/__tests__/display.test.ts index 6651d107..e55cf442 100644 --- a/web/src/api/__tests__/display.test.ts +++ b/web/src/api/__tests__/display.test.ts @@ -425,6 +425,9 @@ describe("display helpers", () => { expect(usageErrorLabel("client_disconnect")).toBe("客户端中断"); expect(usageErrorLabel("model_unavailable")).toBe("该模型暂时不可用"); expect(usageErrorLabel("no_healthy_credential")).toBe("无可用凭证"); + // 「上游没这个模型」与「请求无效」是两回事(2026-10-26 独立成类) + expect(usageErrorLabel("model_not_found")).toBe("上游无此模型"); + expect(usageErrorLabel("invalid_request")).toBe("请求无效"); // A1 流式超时:两个新枚举都要有中文文案(不能落到兜底「失败(xxx)」) expect(usageErrorLabel("upstream_first_event_timeout")).toBe("上游首字超时"); expect(usageErrorLabel("upstream_idle_timeout")).toBe("上游流中断"); diff --git a/web/src/api/display.ts b/web/src/api/display.ts index 449feb6f..702085d4 100644 --- a/web/src/api/display.ts +++ b/web/src/api/display.ts @@ -476,6 +476,7 @@ export type UsageErrorType = | "client_disconnect" | "credential_unavailable" | "invalid_request" + | "model_not_found" | "model_unavailable" | "no_healthy_credential" | "rate_limit" @@ -489,6 +490,7 @@ export const USAGE_ERROR_LABEL: Record = { client_disconnect: "客户端中断", credential_unavailable: "凭证失效", invalid_request: "请求无效", + model_not_found: "上游无此模型", model_unavailable: "该模型暂时不可用", no_healthy_credential: "无可用凭证", rate_limit: "额度耗尽", From e61a22f35297be9077cc570b8642cb55d50d647b Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Mon, 26 Oct 2026 09:01:17 +0800 Subject: [PATCH 025/153] =?UTF-8?q?fix(trae):=20=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E6=89=93=E4=B8=8D=E5=BC=80=E6=97=B6=E6=94=B9=E7=94=A8=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E9=93=BE=E6=8E=A5=E5=AE=8C=E6=88=90=E7=99=BB=E5=BD=95?= =?UTF-8?q?=EF=BC=88issue=20#21=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NAS / 远程部署里 TRAE 跳 auth_callback_url 常直接「网络错误」, refreshToken 只留在浏览器地址栏,服务端永远收不到回调。复用 CodeArts 已跑通的粘贴轨道,把它接到 callback 轨道上: - `POST /api/auth/upstream/complete` 新增回调轨道分支:已鉴权请求按会话 用户名取 pending,校验 TTL / state / loginTraceID 后调 `provider.complete_callback`,消费 pending 再落库。 - `POST /api/auth/upstream/cancel` 支持回调轨道:按 state(可省略)清掉 待完成登录,贴错 state 不清。 - 「添加凭证」里 TRAE 发起登录即给出粘贴框,自动回调仍是主路径;凭证 列表轮询检测到新条目时收起粘贴框。 - 超时文案补上「也可以粘贴地址栏链接」这条出口。 --- src/api/admin_auth.py | 92 +++++++++-- tests/api/test_authorize.py | 149 ++++++++++++++++++ web/src/components/AddCredentialDialog.tsx | 21 ++- .../__tests__/CredentialsPage.login.test.tsx | 40 +++++ 4 files changed, 285 insertions(+), 17 deletions(-) diff --git a/src/api/admin_auth.py b/src/api/admin_auth.py index c3a2b583..72637ed2 100644 --- a/src/api/admin_auth.py +++ b/src/api/admin_auth.py @@ -9,6 +9,7 @@ import inspect import ipaddress import time +from urllib.parse import parse_qs, urlparse from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse @@ -23,7 +24,10 @@ from ..auth.users import create_password_hash, verify_password from ..compat.openai.request import InvalidRequest from ..config import Settings +from ..provider.base import AuthResult +from ..provider.trae.callback import login_trace_id from ..webapp.security import session_cookie_secure +from .authorize import PENDING_CALLBACK_TTL_SECONDS from .deps import ( SESSION_COOKIE, Services, @@ -114,6 +118,48 @@ def _record_login(services: Services, *, username: str, ok: bool, ip: str) -> No detail="" if ok else "密码错误或用户不存在") +async def _complete_callback_paste(services: Services, pending: dict[str, tuple[float, str]], + username: str, provider_id: str, + raw_url: str, state: str) -> AuthResult: + """callback 轨道(TRAE)的粘贴兑换:按发起人的 pending 登录完成回调。 + + 与 `/authorize` 的差别只在**入口**:那边是浏览器 302(无鉴权,靠 + `loginTraceID` 绑定),这边是已鉴权的管理台写请求,因此用会话里的 + 用户名直接定位 pending。仍复用同一条 TTL,且 state 必须与 pending 一致 + (`/authorize` 是从 pending 里取 state,这边是用户可能贴错链接)。 + + 粘回来的地址若带 `loginTraceID`,必须与这次登录的 trace 相符——防止 + 用户把另一次登录(另一个浏览器标签)的回调贴进来,把两边的 + machine/device id 搞混。 + """ + provider = services.registry.get(provider_id) + completer = getattr(provider, "complete_callback", None) + if provider is None or not callable(completer): + raise InvalidRequest( + f"provider {provider_id!r} does not support callback completion") + entry = pending.get(username) + if entry is None: + raise InvalidRequest( + "没有进行中的登录。请回到管理台重新点击「登录」,再把回调地址粘回来。") + started_at, pending_state = entry + if time.monotonic() - started_at > PENDING_CALLBACK_TTL_SECONDS: + pending.pop(username, None) + raise InvalidRequest( + f"这次登录已超过 {PENDING_CALLBACK_TTL_SECONDS // 60} 分钟有效期," + "请回到管理台重新发起登录。") + if state and state != pending_state: + raise InvalidRequest( + "粘贴的回调地址不属于当前这次登录,请重新发起登录后再粘贴。") + pasted_trace = (parse_qs(urlparse(raw_url).query).get("loginTraceID") or [""])[0] + machine_id, _, device_id = pending_state.partition(":") + if pasted_trace and pasted_trace != login_trace_id(machine_id, device_id): + raise InvalidRequest( + "粘贴的回调地址不属于当前这次登录,请重新发起登录后再粘贴。") + pending.pop(username, None) + data = await completer(raw_url, pending_state) + return AuthResult(credential_data=data, nickname=str(data.get("nickname") or "")) + + def create_router(services: Services) -> APIRouter: router = APIRouter() @@ -251,35 +297,57 @@ async def upstream_auth_poll(payload: dict, return {"status": "success", "credential_id": credential_id} @router.post("/api/auth/upstream/cancel") - async def upstream_auth_cancel(payload: dict, + async def upstream_auth_cancel(request: Request, payload: dict, _csrf: None = Depends(csrf_protected), principal=Depends(principal_from_request)): require_admin(principal) - oauth = services.upstream_auth.get(str(payload.get("provider") or "")) + provider_id = str(payload.get("provider") or "") + state = str(payload.get("state") or "") + oauth = services.upstream_auth.get(provider_id) if oauth is None: + # callback 轨道(TRAE)没有上游轮询会话可取消,但要清掉待完成 + # 登录,否则取消后它还会挂到 TTL 到期,期间用户重新点「登录」 + # 会被这条僵尸 pending 抢走(按用户名分键,后写覆盖前写, + # 本身不会错记,但残留条目会让「已取消」的假象持续)。 + pending = request.app.state.pending_callbacks + entry = pending.get(principal.username) + if (provider_id in services.registry and entry is not None + and (not state or entry[1] == state)): + pending.pop(principal.username, None) + return {"cancelled": True} raise InvalidRequest("provider does not support polling login") - cancelled = oauth.store.cancel(str(payload.get("state") or ""), principal.username) + cancelled = oauth.store.cancel(state, principal.username) return {"cancelled": cancelled} @router.post("/api/auth/upstream/complete") - async def upstream_auth_complete(payload: dict, + async def upstream_auth_complete(request: Request, payload: dict, _csrf: None = Depends(csrf_protected), principal=Depends(principal_from_request)): - """粘贴回调链接完成登录(CodeArts 等回调落不到本服务的渠道)。 + """粘贴回调链接完成登录(回调落不到本服务、或浏览器打不开回调地址的渠道)。 + + 两条路: + + - **paste 轨道**(CodeArts 等):服务端从 start 起就登记了 PKCE/ticket + 会话,`oauth.complete_callback(url, state, username)` 自己按 state 找。 + - **callback 轨道**(TRAE):回调本应 302 回本服务 `/authorize`,但 + 远程 / NAS 部署里 TRAE 侧跳过去常直接「网络错误」,refreshToken 只 + 存在浏览器地址栏里,服务端永远收不到(issue #21 后半段)。用户把 + 那条地址整条粘回来,这里用与 `/authorize` 相同的 pending 绑定换 token。 - 用户把门户回跳地址(`http://127.0.0.1:{port}/oauth/callback?code=…`) - 粘回来,服务端用其中的 code + start 时登记的 PKCE 私钥换 token, 与 `poll` 一样直接落库、不回传 token。 """ require_admin(principal) provider_id = str(payload.get("provider") or "") + raw_url = str(payload.get("url") or "") + state = str(payload.get("state") or "") oauth = services.upstream_auth.get(provider_id) completer = getattr(oauth, "complete_callback", None) - if oauth is None or not callable(completer): - raise InvalidRequest( - f"provider {provider_id!r} does not support callback completion") - result = await completer(str(payload.get("url") or ""), - str(payload.get("state") or ""), principal.username) + if oauth is not None and callable(completer): + result = await completer(raw_url, state, principal.username) + else: + result = await _complete_callback_paste( + services, request.app.state.pending_callbacks, + principal.username, provider_id, raw_url, state) credential_id = services.credentials.add( provider=provider_id, credential_data=result.credential_data, nickname=result.nickname, added_by=principal.username) diff --git a/tests/api/test_authorize.py b/tests/api/test_authorize.py index dfbd7996..a55b3e36 100644 --- a/tests/api/test_authorize.py +++ b/tests/api/test_authorize.py @@ -534,3 +534,152 @@ def test_authorize_browser_failure_page_states_the_reason(tmp_path): # 结构化失败:JSON 契约不变 json_response = client.get("/authorize?refreshToken=RT") assert json_response.json()["error"]["message"] == "no pending TRAE login in progress" + + +# ---------------------------------------------------------------- 粘贴回调链接 +# issue #21 后半段:NAS / 远程部署里 TRAE 跳回调地址常直接「网络错误」, +# refreshToken 只在浏览器地址栏里,服务端永远收不到。把那条地址粘回管理台, +# 由 /upstream/complete 用同一条 pending 完成兑换。 + + +def _start_trae_login(app, client) -> dict: + client.post("/api/auth/login", json={"username": "root", "password": "rootpw"}) + return client.post("/api/auth/upstream/start", json={"provider": "trae"}).json() + + +def test_paste_callback_completes_trae_login(tmp_path): + """粘贴整条回调地址 → 换 token → 落库 → pending 消费。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + + with TestClient(app) as client: + started = _start_trae_login(app, client) + trace = login_trace_id(*started["state"].split(":")) + response = client.post("/api/auth/upstream/complete", json={ + "provider": "trae", + "url": f"/authorize?refreshToken=RT1&loginTraceID={trace}"}) + + assert response.status_code == 200, response.text + assert response.json()["status"] == "success" + assert "root" not in app.state.pending_callbacks + + listed = app.state.services.credentials.list_all() + assert len(listed) == 1 and listed[0]["provider"] == "trae" + assert listed[0]["added_by"] == "root" + # 兑换出的 token 绝不出现在响应里(上面只断言了 status) + + +def test_paste_callback_accepts_explicit_state(tmp_path): + """带上发起时的 state 也能完成(前端会一并传回)。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + started = _start_trae_login(app, client) + response = client.post("/api/auth/upstream/complete", json={ + "provider": "trae", "state": started["state"], + "url": "/authorize?refreshToken=RT1"}) + assert response.status_code == 200, response.text + + +def test_paste_callback_requires_pending_login(tmp_path): + """没有进行中的登录:直接 400,不给「凭一条链接往池子里塞凭证」的口子。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + client.post("/api/auth/login", json={"username": "root", "password": "rootpw"}) + response = client.post("/api/auth/upstream/complete", json={ + "provider": "trae", "url": "/authorize?refreshToken=RT1"}) + assert response.status_code == 400 + assert "没有进行中的登录" in response.json()["error"]["message"] + + +def test_paste_callback_rejects_expired_pending(tmp_path): + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + started = _start_trae_login(app, client) + app.state.pending_callbacks["root"] = (time.monotonic() - 601, started["state"]) + response = client.post("/api/auth/upstream/complete", json={ + "provider": "trae", "url": "/authorize?refreshToken=RT1"}) + assert response.status_code == 400 + assert "10 分钟有效期" in response.json()["error"]["message"] + assert "root" not in app.state.pending_callbacks + + +def test_paste_callback_rejects_foreign_state_and_trace(tmp_path): + """贴错链接(另一次登录的 state / 别人那次登录的 trace)一律拒绝。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + started = _start_trae_login(app, client) + + foreign_state = client.post("/api/auth/upstream/complete", json={ + "provider": "trae", "state": "m:d", "url": "/authorize?refreshToken=RT1"}) + assert foreign_state.status_code == 400 + assert "不属于当前这次登录" in foreign_state.json()["error"]["message"] + + foreign_trace = client.post("/api/auth/upstream/complete", json={ + "provider": "trae", + "url": "/authorize?refreshToken=RT1&loginTraceID=0000000000000000"}) + assert foreign_trace.status_code == 400 + # 被拒的粘贴不消费 pending:发起人仍能用正确链接走完 + assert "root" in app.state.pending_callbacks + assert started["state"] in app.state.pending_callbacks["root"][1] + + +def test_paste_callback_requires_provider_support(tmp_path): + """registry 里没有该渠道(或没有 complete_callback)→ 400。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={}) + with TestClient(app) as client: + client.post("/api/auth/login", json={"username": "root", "password": "rootpw"}) + response = client.post("/api/auth/upstream/complete", json={ + "provider": "trae", "url": "/authorize?refreshToken=RT1"}) + assert response.status_code == 400 + assert "does not support callback completion" in response.json()["error"]["message"] + + +def test_cancel_clears_pending_trae_login(tmp_path): + """取消 TRAE 登录会清掉待完成回调,不留僵尸 pending。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + started = _start_trae_login(app, client) + assert "root" in app.state.pending_callbacks + + # 贴错 state(另一次登录的)不能清掉当前 pending + foreign = client.post("/api/auth/upstream/cancel", + json={"provider": "trae", "state": "m:d"}) + assert foreign.status_code == 400 + assert "root" in app.state.pending_callbacks + + response = client.post("/api/auth/upstream/cancel", + json={"provider": "trae", "state": started["state"]}) + assert response.status_code == 200 + assert response.json() == {"cancelled": True} + assert "root" not in app.state.pending_callbacks + + # 再取消一次:没有 pending 可清,回到「不支持」的 400 + again = client.post("/api/auth/upstream/cancel", + json={"provider": "trae", "state": started["state"]}) + assert again.status_code == 400 + + +def test_cancel_clears_pending_trae_login_without_state(tmp_path): + """不带 state 的取消(前端拿不到 state 时)也清当前发起人的 pending。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + _start_trae_login(app, client) + response = client.post("/api/auth/upstream/cancel", json={"provider": "trae"}) + assert response.status_code == 200 + assert "root" not in app.state.pending_callbacks + diff --git a/web/src/components/AddCredentialDialog.tsx b/web/src/components/AddCredentialDialog.tsx index 2ca8e3c1..4bfe1401 100644 --- a/web/src/components/AddCredentialDialog.tsx +++ b/web/src/components/AddCredentialDialog.tsx @@ -138,20 +138,28 @@ export function AddCredentialDialog({ // 前端无法轮询渠道,改为轮询凭证列表,出现新凭证即视为完成。 // 回调地址一并透出:它由服务端 PUBLIC_BASE_URL 决定,指错主机时 // 浏览器会静默跳不回来(用户只看到「授权完成但没反应」)。 + // 同时给出粘贴入口:远程 / NAS 部署里 TRAE 跳回调常直接「网络错误」, + // 那条地址只在浏览器地址栏里,服务端永远收不到(issue #21)。 + setPasteProvider(provider); + setPasteUrl(""); onNotice(started.callback_url ? `已在新标签页打开授权页,完成后本页自动刷新出凭证。` - + `若一直没有反应,请确认浏览器能打开回调地址 ${started.callback_url}。` - : "已在新标签页打开授权页,完成后本页自动刷新出凭证。"); + + `若浏览器打不开回调地址 ${started.callback_url}(显示网络错误),` + + `把地址栏里的整条链接粘贴到下方即可。` + : "已在新标签页打开授权页,完成后本页自动刷新出凭证。" + + "若浏览器打不开回调地址,把地址栏里的整条链接粘贴到下方即可。"); const deadline = Date.now() + 5 * 60 * 1000; const baseline = credentialCountRef.current; const timer = window.setInterval(async () => { const after = (await api.credentials()).credentials.length; if (after > baseline || Date.now() > deadline) { stopPolling(provider); + if (after > baseline) setPasteProvider(null); onNotice(after > baseline ? "登录成功,凭证已保存。" : "授权超时:浏览器没有回到本服务。请检查授权后地址栏是否跳转到回调地址," - + "以及服务端 PUBLIC_BASE_URL 是否为浏览器可访问的地址。"); + + "以及服务端 PUBLIC_BASE_URL 是否为浏览器可访问的地址;" + + "也可以把地址栏里的整条链接粘贴到下方完成登录。"); } }, 3000); loginTimersRef.current[provider] = timer; @@ -358,7 +366,8 @@ export function AddCredentialDialog({

CodeBuddy 走设备码轮询(自动检测完成);TRAE 走浏览器回调 (授权后由 /authorize 直接落库,本对话框轮询凭证列表, - 期间勿关闭对话框);Qoder 走设备码登录;CodeArts 走 OAuth2 授权码登录 + 期间勿关闭对话框;浏览器打不开回调地址时把地址栏整条链接粘回来); + Qoder 走设备码登录;CodeArts 走 OAuth2 授权码登录 (授权后把地址栏打不开的 127.0.0.1 回调链接粘回本对话框); OfficeAce 由云端承接回调(本对话框轮询,多账号请用「换账号」)。

@@ -370,7 +379,9 @@ export function AddCredentialDialog({ data-testid="paste-callback-input" value={pasteUrl} onChange={(event) => setPasteUrl(event.target.value)} - placeholder="http://127.0.0.1:12800/oauth/callback?code=…&state=…" + placeholder={pasteProvider === "trae" + ? "/authorize?refreshToken=…&userInfo=…&loginTraceID=…" + : "http://127.0.0.1:12800/oauth/callback?code=…&state=…"} />
diff --git a/web/src/pages/__tests__/CredentialsPage.login.test.tsx b/web/src/pages/__tests__/CredentialsPage.login.test.tsx index 8dc9b489..dfe42e56 100644 --- a/web/src/pages/__tests__/CredentialsPage.login.test.tsx +++ b/web/src/pages/__tests__/CredentialsPage.login.test.tsx @@ -231,6 +231,46 @@ describe("渠道登录入口", () => { vi.useRealTimers(); }); + it("TRAE 回调打不开时:粘贴整条回调链接完成登录", async () => { + // issue #21 后半段:NAS 部署里 TRAE 跳回调直接「网络错误」, + // refreshToken 只在浏览器地址栏,粘回管理台由 /upstream/complete 兑换。 + vi.stubGlobal("open", () => null); + let completed = false; + const fetchSpy = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/upstream/start")) { + return jsonResponse({ + flow: "callback", + state: "machine:device", + auth_url: "https://trae.example/login", + interval: null, + callback_url: "http://127.0.0.1:8000/authorize", + }); + } + if (url.includes("/upstream/complete")) { + completed = String(init?.body).includes("/authorize?refreshToken=RT1"); + return jsonResponse({ status: "success", credential_id: "cred_new" }); + } + return jsonResponse(listBody([makeCredential({ id: "c0" })])); + }); + vi.stubGlobal("fetch", fetchSpy); + + renderPage(, ADMIN); + await settle(); + await expandCredentialGroups(); + await openAddDialog(); + await userEvent.click(screen.getByTestId("start-login-trae")); + + // 回调轨道同时给出粘贴入口(自动回调仍是主路径) + const input = await screen.findByTestId("paste-callback-input"); + await userEvent.type(input, "/authorize?refreshToken=RT1&loginTraceID=abc"); + await userEvent.click(screen.getByTestId("paste-callback-submit")); + + expect(await screen.findByTestId("credentials-notice")).toHaveTextContent("登录成功"); + expect(completed).toBe(true); + expect(screen.queryByTestId("paste-callback")).not.toBeInTheDocument(); + }); + it("CodeArts 走 paste:展示粘贴框,提交回调链接完成登录", async () => { const opened: string[] = []; vi.stubGlobal("open", () => { From 9558be54fee5c0ce92e6e1351140f10546fa15d8 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Mon, 26 Oct 2026 09:34:02 +0800 Subject: [PATCH 026/153] =?UTF-8?q?feat(stats):=20=E5=9B=BE=E8=A1=A8?= =?UTF-8?q?=E6=8C=87=E6=A0=87=E6=94=B9=E8=AF=AD=E4=B9=89=E5=8C=96=E8=81=9A?= =?UTF-8?q?=E5=90=88=EF=BC=8C=E6=97=B6=E9=97=B4=E8=BD=B4=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=B0=8F=E6=97=B6=E6=A1=B6=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=88=86?= =?UTF-8?q?=E4=BD=8D=E6=97=B6=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图表指标从「拼一个 SUM 表达式」改为 `METRIC_KINDS` 语义表:查询侧固定取齐 10 列聚合(`_METRIC_AGGREGATES`),由 `_metric_value(metric, row)` 归一。 - 新增派生指标:成功率(ok_count/requests)、缓存命中率(cached/input)、 单请求成本(cost/requests)。比率一律**聚合后相除**(池级口径),不是逐 小时比率的平均——后者会被低量小时等权带偏。 - 无样本一律给 `null` 而非 0:均值给 0 会画成「0 ms」的假低谷(与「该小时 没有成功请求」是两件事),比率给 0 会被读成「0%」。 - 时间轴补齐:`usage_hourly` 只在有请求时才有行,稀疏下发会让前端把「空档 5 小时」和「相邻 1 小时」画成一样宽。`_dense_hours()` 按 since/until 补齐 小时桶,跨度超 `MAX_DENSE_HOURS`(92 天)时退回观测小时。 - `timeline` 新增 `__total`:全渠道合计单独聚合,均值与比率必须用同一小时 的 sum/count 重新相除,前端相加没有意义。 - 新增 `GET /api/stats/percentile-timeline`:按小时 P50/P95,读明细表(仅 90 天),供延迟图「中位数线 + P95 上界带」——均值会把长尾摊平。 测试:行/分支覆盖率 100%。 --- src/api/admin_stats.py | 10 ++ src/stats/query.py | 251 +++++++++++++++++++++++++++------- tests/api/test_admin_stats.py | 6 +- tests/stats/test_pricing.py | 23 ++++ tests/stats/test_query.py | 179 +++++++++++++++++++++++- 5 files changed, 409 insertions(+), 60 deletions(-) diff --git a/src/api/admin_stats.py b/src/api/admin_stats.py index 3c8b59cc..0a01deb0 100644 --- a/src/api/admin_stats.py +++ b/src/api/admin_stats.py @@ -157,6 +157,16 @@ async def stats_model_timeline(principal=Depends(principal_from_request), return stats_query.model_timeline( username=_scope(principal, username), since=since, until=until, metric=metric) + @router.get("/api/stats/percentile-timeline") + async def stats_percentile_timeline(principal=Depends(principal_from_request), + username: str | None = None, + since: int | None = None, + until: int | None = None, + metric: str = "latency"): + """延迟分位时序(P50 中位数 + P95 上界):延迟图的「典型值 + 长尾」两层。""" + return stats_query.percentile_timeline( + username=_scope(principal, username), since=since, until=until, metric=metric) + @router.get("/api/stats/by-user") async def stats_by_user(principal=Depends(principal_from_request), username: str | None = None, since: int | None = None, diff --git a/src/stats/query.py b/src/stats/query.py index 31b8ff2a..4e504569 100644 --- a/src/stats/query.py +++ b/src/stats/query.py @@ -7,21 +7,47 @@ from ..sorting import parse_sort_order, sql_order -METRIC_COLUMNS: dict[str, str] = { - # metric 参数 → usage_hourly 上的取值表达式 - "requests": "requests", - "tokens": "input_tokens + output_tokens", - "latency": "latency_sum", # 均值由调用方除以 ok_count - "ttfb": "ttfb_sum", # 同上 +# 图表指标 → 取值语义。小时汇总只存 sum / count,因此**派生比率必须在聚合后 +# 相除**:先按小时求比率再平均是另一种口径(会被低量小时等权带偏),不能混用。 +METRIC_KINDS: dict[str, str] = { + "requests": "sum_requests", + "tokens": "sum_tokens", # 成本默认口径是人民币(与本模块展示一致);仅含可定价明细的小时才有值。 - "cost": "cost_cny_sum", + "cost": "sum_cost", + "latency": "mean_latency", # 均值由 ok_count 归一 + "ttfb": "mean_ttfb", # 同上 + # 派生指标(P1):成功率 / 缓存命中率 / 单请求成本。 + "success_rate": "ratio_ok", + "cache_rate": "ratio_cache", + "cost_per_request": "per_request_cost", } -# 分组统计下发的分位档:P50 / P90 / P95 / P99(latency 与 ttfb 各一组列)。 -# **不加进 METRIC_COLUMNS**:图表指标读小时汇总,只存 sum/count,数学上无法 -# 还原分布;分位只能从明细表算(见 `_percentile_join`)。 +# 三个图表查询(timeline / model_timeline)共用的聚合列。 +# +# 为什么一次取全:派生指标要的是**多个 sum 相除**,只选 `SUM(expr)` 一个值就 +# 得为每个 metric 拼不同的 SQL,三处(timeline、model_timeline、将来新增的) +# 迟早会分叉。这里固定取齐 10 列,取值语义留给 `_metric_value` 决定。 +_METRIC_AGGREGATES = """ + SUM(requests) AS requests, + SUM(ok_count) AS ok_count, + SUM(input_tokens) AS input_tokens, + SUM(output_tokens) AS output_tokens, + SUM(cached_tokens) AS cached_tokens, + SUM(cached_known) AS cached_known, + SUM(cost_cny_sum) AS cost_cny_sum, + SUM(cost_known) AS cost_known, + SUM(latency_sum) AS latency_sum, + SUM(ttfb_sum) AS ttfb_sum""" + +# 分位档:图表时序用的分位(延迟图要「中位数线 + P95 带」)。 +# **不加进 METRIC_KINDS**:分位读明细表(小时汇总只存 sum/count,数学上无法 +# 还原分布),见 `percentile_timeline`。 PERCENTILES: tuple[int, ...] = (50, 90, 95, 99) +# 稠密时间轴上限:`timeline` 按小时补桶,超过这个跨度就不再补(「全部」范围 +# 可能横跨几个月,几万个空点只会拖垮图表)。约 92 天,覆盖最长的 90 天窗口。 +MAX_DENSE_HOURS = 92 * 24 + # 明细聚合子查询(分位 + 输出速度)在主查询里的 JOIN 别名:`_percentile_join` # 与两个分组路径共用,别名固定。 _DETAIL_AGG_ALIAS = "agg" @@ -406,23 +432,46 @@ class StatsQuery: def __init__(self, db) -> None: self._db = db - def _metric_sql(self, metric: str) -> tuple[str, bool]: - """返回 (取值表达式, 是否均值语义)。非法 metric 回退 requests。""" - expr = METRIC_COLUMNS.get(metric, "requests") - return expr, metric in ("latency", "ttfb") + def _metric_value(self, metric: str, row: Any) -> Any: + """把一行聚合结果按 metric 归一成图表值;无有效样本给 `None`。 - def _metric_value(self, metric: str, expr_value, ok_count, cost_known: int = 0) -> Any: - """把 SQL 原始值按 metric 归一:均值类除以 ok_count,无成功则 0。 + 入参是**整行**(`_METRIC_AGGREGATES` 的 10 列),不再只给单个 + `SUM(expr)`:成功率、缓存命中率、单请求成本都是两个 sum 相除,先聚合 + 后相除才是池级口径(先逐小时相除再平均会被低量小时等权带偏)。 - 成本是**部分可定价**的:某小时/模型若一条明细都没匹配到价表 - (`cost_known=0`),聚合值只是 0,直接展示会被读成「免费」;这里回 None - 让前端显示 —。只要有一条可定价,就按可定价部分求和(宁可少算不虚报)。 + 「无样本」一律给 `None` 而不是 0——前端据此断线(不画成 0 值), + 否则一个没上报缓存的渠道会显示成「命中率 0%」。 + + - `requests` / `tokens`:sum,恒有值 + - `latency` / `ttfb`:成功请求均值,无成功 → None(原实现给 0,会画成 + 「0 ms」的假低谷,与「该小时没有成功请求」是两件事) + - `cost`:仅含可定价明细的小时才有值(部分可定价时按已定价部分求和) + - `success_rate`:ok_count / requests,requests 为 0 → None + - `cache_rate`:cached / input,无上报(cached_known=0)或输入为 0 → None + - `cost_per_request`:cost / requests,无定价或请求为 0 → None """ - if metric in ("latency", "ttfb"): - return round(expr_value / ok_count) if ok_count else 0 - if metric == "cost" and not cost_known: - return None - return expr_value + kind = METRIC_KINDS.get(metric, "sum_requests") + if kind == "sum_requests": + return row["requests"] + if kind == "sum_tokens": + return row["input_tokens"] + row["output_tokens"] + if kind == "sum_cost": + return row["cost_cny_sum"] if row["cost_known"] else None + if kind == "mean_latency": + return round(row["latency_sum"] / row["ok_count"]) if row["ok_count"] else None + if kind == "mean_ttfb": + return round(row["ttfb_sum"] / row["ok_count"]) if row["ok_count"] else None + if kind == "ratio_ok": + return round(row["ok_count"] / row["requests"], 4) if row["requests"] else None + if kind == "ratio_cache": + if not row["cached_known"] or not row["input_tokens"]: + return None + return round(row["cached_tokens"] / row["input_tokens"], 4) + if kind == "per_request_cost": + if not row["cost_known"] or not row["requests"]: + return None + return round(row["cost_cny_sum"] / row["requests"], 6) + raise ValueError(f"未知指标语义: {kind!r}") # pragma: no cover - 词表封闭 @staticmethod def _where(*, username: str | None = None, provider: str | None = None, @@ -801,39 +850,89 @@ def timeline(self, *, username: str | None = None, metric: str = "requests") -> list[dict[str, Any]]: """按小时的指标时间序列(usage_hourly 聚合,跨 model 汇总)。 - 每个点含当日出现过的各渠道指标值(按 metric 变化:请求数 / 总 token / - 平均耗时 ms / 平均首字延迟 ms),供前端绘制曲线。渠道集合动态取自 - 数据(新增渠道无需改 SQL),返回结构形如 {hour, codebuddy, trae, qoder}; - 某渠道在该小时没有数据时补 0,保证同一批点的键集合一致,前端画线不 - 会因缺键断线。 + 每个点含该小时各渠道的指标值(按 metric 变化,语义见 `METRIC_KINDS`), + 供前端绘制曲线。渠道集合动态取自数据(新增渠道无需改 SQL),返回结构 + 形如 `{hour, codebuddy, trae, qoder}`。 + + **时间轴是稠密的**:`usage_hourly` 只在有请求时才有行,稀疏下发会让前端 + 把「空档 5 小时」和「相邻 1 小时」画成一样宽(分类轴压缩时间)。这里按 + 小时补齐缺失的桶,让每个点等距: + + - 给了 `since` / `until` 就按窗口补齐(含两端); + - 都没给(「全部」)就补在首个到末个观测小时之间; + - 跨度超过 `MAX_DENSE_HOURS` 时不补(几万个空点只会拖垮图表), + 前端仍按时间戳定位,只是空档不插点。 + + 缺失桶的取值按指标语义区分(`_empty_value`):请求数 / token 补 0(没流量 + 就是 0),均值与比率补 `None`(「没有样本」不是 0,前端据此断线)。 + + 每个点还带一个 `__total` 键 = **全渠道合计**(单独按小时聚合一次)。为什么 + 不在前端把各渠道值相加:均值(耗时 / 首字)与比率(成功率 / 命中率)相加 + 没有意义,必须用同一小时的 sum/count 重新相除。前端「总量」视图读这个键。 """ - expr, _as_mean = self._metric_sql(metric) where, params = self._where(username=username, since=since, since_col="hour_utc", until=until, until_col="hour_utc") rows = self._db.connect().execute( f""" - SELECT hour_utc, provider, - SUM({expr}) AS value, SUM(ok_count) AS ok_count, - SUM(cost_known) AS cost_known + SELECT hour_utc, provider,{_METRIC_AGGREGATES} FROM usage_hourly {where} GROUP BY hour_utc, provider ORDER BY hour_utc """, params).fetchall() + # 全渠道合计:均值/比率必须重新相除,不能把各渠道的值相加 + total_rows = self._db.connect().execute( + f""" + SELECT hour_utc,{_METRIC_AGGREGATES} + FROM usage_hourly {where} + GROUP BY hour_utc + ORDER BY hour_utc + """, params).fetchall() raw: dict[int, dict[str, Any]] = {} + totals: dict[int, Any] = {} providers: set[str] = set() for row in rows: hour = row["hour_utc"] provider = row["provider"] providers.add(provider) - raw.setdefault(hour, {})[provider] = self._metric_value( - metric, row["value"], row["ok_count"], row["cost_known"]) + raw.setdefault(hour, {})[provider] = self._metric_value(metric, row) + for row in total_rows: + totals[row["hour_utc"]] = self._metric_value(metric, row) # 渠道按名排序:同一批点的键顺序稳定(与渠道注册顺序无关) ordered = sorted(providers) + empty = self._empty_value(metric) return [ - {"hour": hour, **{p: raw[hour].get(p, 0) for p in ordered}} - for hour in sorted(raw) + {"hour": hour, + "__total": totals.get(hour, empty), + **{p: raw.get(hour, {}).get(p, empty) for p in ordered}} + for hour in self._dense_hours(raw, since=since, until=until) ] + @staticmethod + def _dense_hours(observed: dict[int, Any], *, since: int | None, + until: int | None) -> list[int]: + """补齐小时桶(见 `timeline` 文档)。跨度超限时退回已观测的小时。""" + if not observed: + return [] + hours = sorted(observed) + start = (since // 3600) * 3600 if since is not None else hours[0] + if until is not None: + # 半开区间:until 整点本身不算(与 `_where` 的 `< until` 一致) + end = ((until - 1) // 3600) * 3600 if until > start else start + else: + end = hours[-1] + # 走到这里必有 end ≥ start:观测到的小时都落在 [since, until) 内, + # 首末观测小时天然被两端夹住,无需再判空 + count = (end - start) // 3600 + 1 + if count > MAX_DENSE_HOURS: + return hours + return [start + index * 3600 for index in range(count)] + + @staticmethod + def _empty_value(metric: str) -> Any: + """缺失小时桶的取值:可加量补 0,均值 / 比率 / 成本补 None(断线)。""" + return 0 if METRIC_KINDS.get(metric) in ("sum_requests", "sum_tokens") else None + + def events(self, *, username: str | None = None, since: int | None = None, until: int | None = None, before: int | None = None, limit: int = 50, @@ -1016,35 +1115,85 @@ def model_timeline(self, *, username: str | None = None, """按小时的各模型指标趋势(usage_hourly 聚合)。 Top N 模型按「请求数」排序(排序口径固定,与图表指标解耦); - 曲线值按 metric 变化(请求数 / 总 token / 平均耗时 / 平均首字延迟)。 - 返回结构 {models, points} 不变。 + 曲线值按 metric 变化(语义见 `METRIC_KINDS`)。返回结构 `{models, points}` + 不变。时间轴与 `timeline` 同样按小时补齐(见其文档)。 """ - expr, as_mean = self._metric_sql(metric) where, params = self._where(username=username, since=since, since_col="hour_utc", until=until, until_col="hour_utc") rows = self._db.connect().execute( f""" - SELECT hour_utc, model, SUM(requests) AS requests, - SUM({expr}) AS value, SUM(ok_count) AS ok_count, - SUM(cost_known) AS cost_known + SELECT hour_utc, model,{_METRIC_AGGREGATES} FROM usage_hourly {where} GROUP BY hour_utc, model ORDER BY hour_utc """, params).fetchall() totals: dict[str, int] = {} - series: dict[str, dict[int, int]] = {} - hours: list[int] = [] + series: dict[str, dict[int, Any]] = {} + observed: dict[int, dict[str, Any]] = {} for row in rows: hour, model = row["hour_utc"], row["model"] - if not hours or hours[-1] != hour: - hours.append(hour) totals[model] = totals.get(model, 0) + row["requests"] - series.setdefault(model, {})[hour] = ( - self._metric_value(metric, row["value"], row["ok_count"], row["cost_known"])) + series.setdefault(model, {})[hour] = self._metric_value(metric, row) + observed.setdefault(hour, {})[model] = True # Top N 模型:按总量降序;总量相同按名字稳定排序 top_models = sorted(totals, key=lambda m: (-totals[m], m))[:top] + empty = self._empty_value(metric) points = [ - {"hour": hour, **{m: series.get(m, {}).get(hour, 0) for m in top_models}} - for hour in hours + {"hour": hour, **{m: series.get(m, {}).get(hour, empty) for m in top_models}} + for hour in self._dense_hours(observed, since=since, until=until) ] return {"models": top_models, "points": points} + + def percentile_timeline(self, *, username: str | None = None, + since: int | None = None, until: int | None = None, + metric: str = "latency") -> dict[str, Any]: + """按小时的分位时序(P50 中位数 + P95 上界),读明细表。 + + 延迟图只画均值会盖住长尾:池级均值 10 s 时 P95 可能 40 s,而「少数请求 + 卡住」正是最该看见的故障形态。故一张图给「中位数线 + P95 上界带」。 + + **只能读明细表**(`usage_events`,仅 90 天):小时汇总只存 sum/count, + 数学上无法还原分布。`metric` 只接受 `latency` / `ttfb`(其余回退 + `latency`);口径与分组表分位一致——只统计成功请求、nearest-rank + (`⌈pct·n/100⌉`)、NULL 不参与排名。 + + 返回 `{"points": [{hour, p50, p95, samples}], "metric": ..., "window_days": 90}`; + 缺失小时补 None(前端断线,不画成 0)。`samples` 是该小时的样本数, + 前端可据此说明「该点样本很少」。 + """ + column = "ttfb_ms" if metric == "ttfb" else "latency_ms" + where, params = self._where(username=username, ok=1, since=since, until=until) + clause = f"AND {where[6:]}" if where else "" + rows = self._db.connect().execute( + f""" + SELECT hour_utc, + MAX(CASE WHEN rn = (50 * n + 99) / 100 THEN val END) AS p50, + MAX(CASE WHEN rn = (95 * n + 99) / 100 THEN val END) AS p95, + MAX(n) AS samples + FROM ( + SELECT (ts / 3600) * 3600 AS hour_utc, {column} AS val, + ROW_NUMBER() OVER ( + PARTITION BY (ts / 3600) * 3600 ORDER BY {column} + ) AS rn, + COUNT({column}) OVER ( + PARTITION BY (ts / 3600) * 3600 + ) AS n + FROM usage_events + WHERE {column} IS NOT NULL {clause} + ) AS ranked + GROUP BY hour_utc + ORDER BY hour_utc + """, params).fetchall() + observed = {row["hour_utc"]: True for row in rows} + by_hour = {row["hour_utc"]: row for row in rows} + points = [] + for hour in self._dense_hours(observed, since=since, until=until): + row = by_hour.get(hour) + points.append({ + "hour": hour, + "p50": row["p50"] if row else None, + "p95": row["p95"] if row else None, + "samples": row["samples"] if row else 0, + }) + return {"points": points, "metric": column.removesuffix("_ms"), + "window_days": 90} diff --git a/tests/api/test_admin_stats.py b/tests/api/test_admin_stats.py index 8b73d04a..ae24420b 100644 --- a/tests/api/test_admin_stats.py +++ b/tests/api/test_admin_stats.py @@ -45,8 +45,10 @@ def test_stats_endpoints_scope_by_principal(admin_client): assert client.get("/api/stats/by-credential").json() == {"credentials": []} timeline = client.get("/api/stats/timeline").json() assert timeline["points"] - # 渠道列动态取自数据(本用例只写了 trae),不再固定为两渠道 - assert list(timeline["points"][0].keys()) == ["hour", "trae"] + # 渠道列动态取自数据(本用例只写了 trae),不再固定为两渠道; + # `__total` 是全渠道合计(均值/比率必须按同一小时的 sum/count 重新相除, + # 前端不能把各渠道的值相加) + assert list(timeline["points"][0].keys()) == ["hour", "__total", "trae"] def test_stats_endpoints_restrict_non_admin(tmp_path): settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path)) diff --git a/tests/stats/test_pricing.py b/tests/stats/test_pricing.py index cea8fd31..1ddc911e 100644 --- a/tests/stats/test_pricing.py +++ b/tests/stats/test_pricing.py @@ -235,6 +235,29 @@ def test_query_model_timeline_cost_unpriced_is_none(collector_query): assert result["points"][0]["unpriced"] is None +def test_query_timeline_cost_per_request(collector_query): + """单请求成本 = 已定价成本 ÷ 请求数(先聚合后相除);无可定价明细给 None。""" + collector, query, _db = collector_query + hour = 1_699_999_200 # 整点,与小时桶对齐 + # 两条可定价(各 1M 输入 = 1 USD = 6.7 CNY)+ 一条不可定价 + for offset in (0, 1): + collector.record(username="u", provider="trae", model="glm-5.2", ok=True, + input_tokens=1_000_000, output_tokens=0, now=hour + offset) + collector.record(username="u", provider="trae", model="unpriced", ok=True, + input_tokens=1_000_000, output_tokens=0, now=hour + 2) + points = query.timeline(username="u", metric="cost_per_request") + # 成本只算可定价部分(13.4 CNY),请求数 3 → 4.466667 + assert points[0]["trae"] == pytest.approx(13.4 / 3, rel=1e-4) + + # 全部不可定价 → None(不是 0 元) + other_hour = hour + 3600 + collector.record(username="u", provider="trae", model="unpriced", ok=True, + input_tokens=100, output_tokens=0, now=other_hour) + points = query.timeline(username="u", metric="cost_per_request", + since=other_hour) + assert points[0]["trae"] is None + + def test_collector_rollup_preserves_cost(collector_query): collector, query, db = collector_query now = int(time.time()) diff --git a/tests/stats/test_query.py b/tests/stats/test_query.py index 932edd5d..034a00b5 100644 --- a/tests/stats/test_query.py +++ b/tests/stats/test_query.py @@ -4,13 +4,14 @@ import time +import pytest from fastapi.testclient import TestClient from src.auth.session import create_session_token from src.config import Settings from src.main import build_app from src.stats.collector import StatsCollector -from src.stats.query import StatsQuery +from src.stats.query import MAX_DENSE_HOURS, StatsQuery from src.tasks.retention import RetentionTask from tests.helpers.constants import SECRET @@ -414,7 +415,10 @@ def test_stats_until_filters_groups_and_timeline(stats): collector.rollup_hourly() # 小时汇总路径:桶 0 与桶 7200,until=7200 只留桶 0 assert query.by_provider(username="u", until=7200)[0]["requests"] == 1 - assert len(query.timeline(username="u", until=7200)) == 1 + # 时间轴按小时补齐:桶 0 有数据、桶 3600 补 0,桶 7200 被 until 排除 + points = query.timeline(username="u", until=7200) + assert [point["hour"] for point in points] == [0, 3600] + assert points[0]["trae"] == 1 and points[1]["trae"] == 0 # 明细路径(ts 列):凭证维度读 usage_events,until 也按 ts 过滤 by_cred = query.by_credential(username="u", until=7200) assert sum(row["requests"] for row in by_cred) == 0 # 明细无 credential_id 不分组 @@ -438,13 +442,15 @@ def test_stats_hourly_rolls_via_retention(stats): collector.record(username="u", provider="codebuddy", model="m", ok=True, now=1_700_003_602, input_tokens=7, output_tokens=3, credit=0.5, latency_ms=100) - # 增量累加:最新小时立即可见(不需等 5 分钟一轮的 rollup) + # 增量累加:最新小时立即可见(不需等 5 分钟一轮的 rollup)。 + # 时间轴按 since 补齐:since=1_700_000_000 落在桶 1699999200,故首桶是空的 points = query.timeline(username="u", since=1_700_000_000) - assert len(points) == 1 and points[0]["trae"] == 2 and points[0]["codebuddy"] == 1 + assert [point["hour"] for point in points] == [1699999200, 1700002800] + assert points[1]["trae"] == 2 and points[1]["codebuddy"] == 1 # 全量重算与该小时的增量值一致(幂等,不双计) RetentionTask(collector, retention_days=90).run_once() points = query.timeline(username="u", since=1_700_000_000) - assert len(points) == 1 and points[0]["trae"] == 2 and points[0]["codebuddy"] == 1 + assert points[1]["trae"] == 2 and points[1]["codebuddy"] == 1 bucket = collector._db.connect().execute( "SELECT SUM(requests), SUM(ok_count), SUM(input_tokens), SUM(output_tokens), " "SUM(credit_known), SUM(latency_sum) FROM usage_hourly").fetchone() @@ -496,10 +502,169 @@ def test_stats_timeline_metric_dimensions(stats): # 非法 metric 回退请求次数 assert query.timeline(username="u", metric="bogus") == req + +def test_stats_timeline_derived_metrics(stats): + """派生指标:成功率 / 缓存命中率 / 单请求成本,均为「先聚合后相除」。""" + collector, query = stats + base = 1_699_999_200 # 整点:与 usage_hourly 的 hour 桶对齐 + # codebuddy:3 次请求 2 成功,输入 100 命中 40,单次成本由 pricing 决定 + collector.record(username="u", provider="codebuddy", model="glm-5.2", ok=True, + input_tokens=40, cached_tokens=40, now=base) + collector.record(username="u", provider="codebuddy", model="glm-5.2", ok=True, + input_tokens=40, cached_tokens=0, now=base) + collector.record(username="u", provider="codebuddy", model="glm-5.2", ok=False, + error_type="rate_limit", input_tokens=20, now=base) + collector.rollup_hourly() + + rate = query.timeline(username="u", metric="success_rate")[0] + # 3 请求 2 成功 → 0.6667(不是逐小时平均,而是 sum/sum) + assert rate["codebuddy"] == pytest.approx(0.6667) + cache = query.timeline(username="u", metric="cache_rate")[0] + # 命中 40 / 输入 100 → 0.4 + assert cache["codebuddy"] == pytest.approx(0.4) + + +def test_stats_timeline_derived_metrics_no_sample_is_none(stats): + """无样本的派生指标给 None(断线),不画成 0%。""" + collector, query = stats + # 未上报缓存 → cache_rate 为 None;无请求的小时在稠密轴上同样是 None + collector.record(username="u", provider="trae", model="unpriced", ok=True, + input_tokens=100, now=1_700_000_000) + collector.rollup_hourly() + assert query.timeline(username="u", metric="cache_rate")[0]["trae"] is None + # 无可定价明细 → 单请求成本 None + assert query.timeline(username="u", metric="cost_per_request")[0]["trae"] is None + # 全失败的小时:均值类为 None(不是 0 ms) + collector.record(username="u", provider="trae", model="m", ok=False, + error_type="upstream_error", now=1_700_003_600) + collector.rollup_hourly() + lat = query.timeline(username="u", metric="latency", since=1_700_000_000) + assert lat[1]["trae"] is None + + +def test_stats_timeline_dense_hours(stats): + """时间轴稠密:缺失小时补桶,可加量补 0、均值补 None。""" + collector, query = stats + base = 1_699_999_200 # 整点:与 usage_hourly 的 hour 桶对齐 + collector.record(username="u", provider="trae", model="m", ok=True, now=base) + collector.record(username="u", provider="trae", model="m", ok=True, + now=base + 5 * 3600) # 中间空 4 小时 + collector.rollup_hourly() + + points = query.timeline(username="u") + assert len(points) == 6 # 首末之间每小时一个点 + assert [point["hour"] for point in points] == [base + i * 3600 for i in range(6)] + # 请求数:空档补 0 + assert [point["trae"] for point in points] == [1, 0, 0, 0, 0, 1] + # 均值:空档补 None(不是 0 ms) + lat = query.timeline(username="u", metric="latency") + assert lat[1]["trae"] is None + + +def test_stats_timeline_total_recomputes_ratio(stats): + """`__total` 按同一小时的 sum/count 重新相除,不是各渠道值相加。""" + collector, query = stats + base = 1_699_999_200 + # codebuddy:2 请求全成功、各 100ms;trae:2 请求 1 成功、各 900ms + for _ in range(2): + collector.record(username="u", provider="codebuddy", model="m", ok=True, + latency_ms=100, now=base) + collector.record(username="u", provider="trae", model="m", ok=True, + latency_ms=900, now=base) + collector.record(username="u", provider="trae", model="m", ok=False, + error_type="rate_limit", latency_ms=900, now=base) + collector.rollup_hourly() + + # 请求数:可加 → 4 + req = query.timeline(username="u")[0] + assert req["codebuddy"] == 2 and req["trae"] == 2 and req["__total"] == 4 + # 耗时:全池加权均值 (100+100+900)/3 = 366.67 → 367;相加会得到 1000 + lat = query.timeline(username="u", metric="latency")[0] + assert lat["__total"] == 367 + # 成功率:3 成功 / 4 请求 = 0.75;相加会得到 1.5 + rate = query.timeline(username="u", metric="success_rate")[0] + assert rate["__total"] == pytest.approx(0.75) + + +def test_stats_timeline_dense_hours_respects_window(stats): + """稠密轴按 since/until 补齐窗口两端;跨度超上限时退回已观测小时。""" + collector, query = stats + base = 1_699_999_200 # 整点:与 usage_hourly 的 hour 桶对齐 + collector.record(username="u", provider="trae", model="m", ok=True, now=base) + collector.rollup_hourly() + + # 窗口从更早开始 → 前面补空桶 + points = query.timeline(username="u", since=base - 2 * 3600) + assert [point["hour"] for point in points] == [base - 7200, base - 3600, base] + assert points[0]["trae"] == 0 + # 跨度超 MAX_DENSE_HOURS(92 天)→ 不补,只回已观测小时 + wide = query.timeline(username="u", since=base - MAX_DENSE_HOURS * 3600) + assert [point["hour"] for point in wide] == [base] + + +def test_stats_model_timeline_dense_and_derived(stats): + """model_timeline 同样稠密补桶,派生指标语义与 timeline 一致。""" + collector, query = stats + base = 1_699_999_200 # 整点:与 usage_hourly 的 hour 桶对齐 + collector.record(username="u", provider="trae", model="a", ok=True, + input_tokens=50, cached_tokens=25, now=base) + collector.record(username="u", provider="trae", model="a", ok=False, + error_type="rate_limit", now=base + 2 * 3600) + collector.rollup_hourly() + + result = query.model_timeline(username="u", metric="success_rate") + assert [point["hour"] for point in result["points"]] == [ + base, base + 3600, base + 7200] + assert result["points"][0]["a"] == 1.0 + assert result["points"][1]["a"] is None # 空档:比率断线 + assert result["points"][2]["a"] == 0.0 + cache = query.model_timeline(username="u", metric="cache_rate") + assert cache["points"][0]["a"] == pytest.approx(0.5) + + +def test_stats_percentile_timeline(stats): + """分位时序:P50/P95 按小时算,只统计成功请求,缺失小时补 None。""" + collector, query = stats + base = 1_699_999_200 # 整点:与 usage_hourly 的 hour 桶对齐 + for latency in (100, 200, 300, 400, 1000): + collector.record(username="u", provider="trae", model="m", ok=True, + latency_ms=latency, now=base) + # 失败请求的延迟不计入(ok=0) + collector.record(username="u", provider="trae", model="m", ok=False, + error_type="rate_limit", latency_ms=99999, now=base) + collector.record(username="u", provider="trae", model="m", ok=True, + latency_ms=700, now=base + 2 * 3600) + + result = query.percentile_timeline(username="u") + assert result["metric"] == "latency" and result["window_days"] == 90 + points = result["points"] + assert [point["hour"] for point in points] == [base, base + 3600, base + 7200] + # 5 个样本:P50 = ⌈0.5·5⌉ = 第 3 小 = 300;P95 = ⌈0.95·5⌉ = 第 5 小 = 1000 + assert points[0]["p50"] == 300 and points[0]["p95"] == 1000 + assert points[0]["samples"] == 5 + assert points[1]["p50"] is None and points[1]["samples"] == 0 + assert points[2]["p50"] == 700 + # ttfb 口径:无 ttfb 数据 → 全 None + assert all(point["p50"] is None + for point in query.percentile_timeline(username="u", + metric="ttfb")["points"]) + + +def test_stats_percentile_timeline_endpoint(admin_client): + """percentile-timeline 端点:作用域与 metric 透传。""" + _app, client = admin_client + collector = client.app.state.stats_collector + collector.record(username="root", provider="trae", model="m", ok=True, + latency_ms=500, ttfb_ms=120) + body = client.get("/api/stats/percentile-timeline").json() + assert body["metric"] == "latency" and body["points"][0]["p50"] == 500 + ttfb = client.get("/api/stats/percentile-timeline?metric=ttfb").json() + assert ttfb["metric"] == "ttfb" and ttfb["points"][0]["p95"] == 120 + def test_stats_model_timeline_metric_and_ttfb_rollup(stats): """model_timeline 指标切换 + ttfb 聚合进小时表(老库补列后可用)。""" collector, query = stats - base = 1_700_000_000 + base = 1_699_999_200 # 整点:与 usage_hourly 的 hour 桶对齐 collector.record(username="u", provider="codebuddy", model="glm-5.2", ok=True, input_tokens=10, output_tokens=5, latency_ms=100, ttfb_ms=40, now=base) collector.record(username="u", provider="codebuddy", model="glm-5.2", ok=True, @@ -520,7 +685,7 @@ def test_stats_model_timeline_metric_and_ttfb_rollup(stats): def test_stats_model_timeline_top_models_and_points(stats): """按模型趋势:Top N 宽表点列,按总量降序;其余模型不出线。""" collector, query = stats - base = 1_700_000_000 + base = 1_699_999_200 # 整点:与 usage_hourly 的 hour 桶对齐 # glm-5.2 共 4 次(最热)、kimi-k3 共 2 次、legacy-old 共 1 次(超出 Top 2) for _ in range(4): collector.record(username="u", provider="codebuddy", model="glm-5.2", ok=True, now=base) From ad302b544da5e00c3c8f7a4c455b63ff6da7d4b0 Mon Sep 17 00:00:00 2001 From: Robbs Luo Date: Mon, 26 Oct 2026 09:34:25 +0800 Subject: [PATCH 027/153] =?UTF-8?q?feat(web):=20=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E9=A1=B5=E9=87=8D=E6=8E=92=EF=BC=9A=E4=B8=80=E5=BC=A0=E5=9B=BE?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E7=B3=BB=E5=88=97=EF=BC=8C=E5=BB=B6=E8=BF=9F?= =?UTF-8?q?=E5=8A=A0=E5=88=86=E4=BD=8D=E5=9B=BE=EF=BC=8C=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E6=8A=98=E5=8F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图表与布局 - 主图默认只画「全部渠道」合计(读后端 `__total`),渠道拆分改为顶部切换 控件;多系列叠加在 7 天窗口下互相遮挡,看不出「今天总量多少」。 - X 轴改数值轴(`type="number"` + `scale="time"` + 毫秒时间戳):用格式化 字符串当分类轴会把缺失的小时压成等宽,等于伪造时间。 - 比率指标轴在数据全部 ≥90% 时截断下界并标注「Y 轴自 90% 起」:固定 0–100% 时 98%–100% 是一条直线,看不出波动。截断轴必须标注,否则读者会 以为底部就是 0。 - 单请求成本用规整上界(1/2/2.5/5×10ⁿ),recharts 的 `auto` 会给出 `0.0085 / 0.017 / 0.0255` 这种读数时要做除法的刻度。 - Y 轴显式给 `dataKey`,否则 recharts 把毫秒时间戳纳入定义域(曾算出 400% 的上界)。曲线一律 `type="linear"`(`monotone` 会凭空造值)。 - 延迟 / 首字指标另配一张分位图(P50 中位数 + P95 上界带),均值会把长尾 摊平,「少数请求卡住」才是最该看见的故障形态。 - 模型趋势最多 3 条线,其余交给分组表。 信息密度 - 主卡收敛到 4 张(请求数 / Token / 成本 / 耗时),输出速度、缓存命中率、 Credit 降为一条状态条:六张卡的 hint 太长,把主指标挤成 180px。 - 图表指标筛选并入筛选卡(都是「决定下面看什么」的全局开关)。 - 口径说明收进可折叠的「口径说明」:三段加起来近 300px,把数据推出首屏。 - 失败归因在零失败时不渲染整张卡(空卡仍占 239px,「没有失败」本身就是 好消息),错误态仍显示,否则加载失败会被误读成「零失败」。 - 明细表默认 10 条:行是两行文本,20 条就是 1140px、近 4 屏;需要更多可 切 50/100。分页选项新增 10。 - 明细表列宽收窄(`px-1.5`)+ 渠道/凭证、模型列定宽截断:长邮箱把整列 撑到 192px,逼得最后两列横向滚动。 - 速度单位提到列头(`tok/s`),行内只留数字:10 列的表每行重复单位多占 45px。 --- web/src/api/__tests__/display.test.ts | 34 ++ web/src/api/client.ts | 6 + web/src/api/display.ts | 51 ++- web/src/api/hooks.ts | 15 + web/src/api/types.ts | 44 ++- web/src/components/LatencyChart.tsx | 168 ++++++++ web/src/components/ModelTrendChart.tsx | 90 ++++- web/src/components/SortMenuHead.tsx | 9 + web/src/components/UsageChart.tsx | 304 ++++++++++++--- .../__tests__/LatencyChart.test.tsx | 79 ++++ .../__tests__/ModelTrendChart.test.tsx | 55 +++ .../components/__tests__/UsageChart.test.tsx | 214 ++++++++++- web/src/lib/constants.ts | 2 +- web/src/pages/StatsPage.tsx | 361 +++++++++++++----- .../pages/__tests__/StatsPage.errors.test.tsx | 6 +- .../pages/__tests__/StatsPage.live.test.tsx | 2 +- web/src/pages/__tests__/StatsPage.test.tsx | 149 +++++++- web/src/test-utils/stats.ts | 18 + web/src/ui/index.tsx | 57 ++- 19 files changed, 1461 insertions(+), 203 deletions(-) create mode 100644 web/src/components/LatencyChart.tsx create mode 100644 web/src/components/__tests__/LatencyChart.test.tsx create mode 100644 web/src/components/__tests__/ModelTrendChart.test.tsx diff --git a/web/src/api/__tests__/display.test.ts b/web/src/api/__tests__/display.test.ts index e55cf442..2603476f 100644 --- a/web/src/api/__tests__/display.test.ts +++ b/web/src/api/__tests__/display.test.ts @@ -18,6 +18,7 @@ import { credentialState, formatDuration, formatNumber, + formatRate, formatTime, formatTimeParts, upstreamLevelLabel, @@ -110,11 +111,33 @@ describe("formatChartValue", () => { expect(formatChartValue(12.34, "cost")).toBe("¥12.34 (元)"); }); + it("比率指标:0..1 转百分比(一位小数);空值给 —", () => { + expect(formatChartValue(0.9567, "success_rate")).toBe("95.7%"); + expect(formatChartValue(0.4, "cache_rate")).toBe("40.0%"); + }); + + it("单请求成本:金额 + 「/ 次」", () => { + expect(formatChartValue(0.0312, "cost_per_request")).toBe("¥0.0312 / 次"); + }); + it("未知指标回退请求次数", () => { expect(formatChartValue(12, "bogus")).toBe("12 (次)"); }); }); +describe("formatRate", () => { + it("比率乘 100 保留一位小数", () => { + expect(formatRate(0.9)).toBe("90.0%"); + expect(formatRate(1)).toBe("100.0%"); + expect(formatRate(0)).toBe("0.0%"); + }); + + it("无样本给 —(不渲染成 0%)", () => { + expect(formatRate(null)).toBe("—"); + expect(formatRate(undefined)).toBe("—"); + }); +}); + describe("formatSpeed", () => { it("输出速度带 tok/s;估算值前置 ≈;无样本显示 —", () => { expect(formatSpeed(94.1)).toBe("94.1 tok/s"); @@ -143,6 +166,17 @@ describe("formatAxisValue", () => { expect(formatAxisValue(1500.5, "cost")).toBe("1,500.5"); }); + it("单请求成本:按量级给 3–4 位小数(两位会让相邻刻度都显示 0.03)", () => { + expect(formatAxisValue(0.028, "cost_per_request")).toBe("0.028"); + expect(formatAxisValue(0.0312, "cost_per_request")).toBe("0.0312"); + expect(formatAxisValue(0.5, "cost_per_request")).toBe("0.5"); + }); + + it("比率:整数百分比(轴宽有限)", () => { + expect(formatAxisValue(0.9, "success_rate")).toBe("90%"); + expect(formatAxisValue(1, "cache_rate")).toBe("100%"); + }); + it("轴顶单位映射:耗时/首字不重复标单位", () => { expect(METRIC_AXIS_UNIT.requests).toBe("次"); expect(METRIC_AXIS_UNIT.tokens).toBe("tokens"); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 45c0bc86..db0d30f4 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -28,6 +28,7 @@ import type { StatsOverview, TimelinePoint, ModelTimelineResponse, + PercentileTimelineResponse, NavBadges, StatsEventsResponse, StatsEventFilters, @@ -284,6 +285,11 @@ export const api = { metric?: StatsMetric) => request( `/api/stats/model-timeline${query({ username, since, until, metric })}`), + /** 延迟分位时序(P50 中位数 + P95 上界):读明细表,仅覆盖 90 天。 */ + statsPercentileTimeline: (username?: string, since?: number, until?: number, + metric?: "latency" | "ttfb") => + request( + `/api/stats/percentile-timeline${query({ username, since, until, metric })}`), statsEvents: (username?: string, since?: number, until?: number, before?: number, limit?: number, sort?: string, order?: string, offset?: number, filters?: StatsEventFilters) => diff --git a/web/src/api/display.ts b/web/src/api/display.ts index 702085d4..f5300f0d 100644 --- a/web/src/api/display.ts +++ b/web/src/api/display.ts @@ -415,11 +415,14 @@ export function formatSpeedPair( visible: number | null | undefined, total: number | null | undefined, estimated = false, + withUnit = true, ): string { if (visible == null && total == null) return "—"; const one = (value: number | null | undefined) => value == null ? "—" : value.toFixed(1); - return `${estimated ? "≈" : ""}${one(visible)} / ${one(total)} tok/s`; + const body = `${estimated ? "≈" : ""}${one(visible)} / ${one(total)}`; + // withUnit=false:列头已经写了 tok/s,每行重复一遍要多占约 45px + return withUnit ? `${body} tok/s` : body; } /** 图表 hover 数值:按指标格式化,单位用 () 包裹(耗时/首字数已含 ms/s)。 */ @@ -432,11 +435,28 @@ export function formatChartValue(value: number, metric: string): string { case "ttfb": case "latency": return formatLatency(value); + case "success_rate": + case "cache_rate": + return formatRate(value); + case "cost_per_request": + return `${formatMoney(value, "CNY", false)} / 次`; default: return `${formatCompact(value)} (次)`; } } +/** + * 比率指标的展示:入参是 0..1 的小数,输出百分比。 + * + * 与 `formatCacheRate` 的区别:那个收「命中 / 输入」两个原始计数并自己相除, + * 用于表格;这里收**后端已算好的比率**(图表值),两者不能混用——把 0.4 再 + * 除一次输入会得到 0.4%。空值(无样本)给 —,不渲染 0%。 + */ +export function formatRate(value: number | null | undefined): string { + if (value === null || value === undefined) return "—"; + return `${(value * 100).toFixed(1)}%`; +} + /** * Y 轴刻度文案。 * @@ -448,14 +468,28 @@ export function formatChartValue(value: number, metric: string): string { * - 成本:保留两位小数(单请求常是零点几元,`formatCompact` 的「万/亿」不适用) * - 耗时 / 首字:复用 `formatLatency`,与 hover 完全同口径(`850 ms` / `8.2 s`), * 刻度自带单位所以不再另标 + * - 比率:整百分比(轴刻度不需要小数,`40%` 比 `40.0%` 更省宽度) + * - 单请求成本:四位有效小数的金额(量级常在 0.01–0.3 元,两位会全变成 0.00) */ export function formatAxisValue(value: number, metric: string): string { if (metric === "ttfb" || metric === "latency") { return formatLatency(value); } + if (metric === "cost_per_request") { + // 单请求成本量级常在 0.01–0.3 元:两位小数会让 0.028 与 0.031 都显示 + // 「0.03」,刻度看起来重复且无法分辨。按量级给 3–4 位小数。 + const digits = Math.abs(value) >= 0.1 ? 3 : 4; + return value.toLocaleString("zh-CN", { + minimumFractionDigits: 0, + maximumFractionDigits: digits, + }); + } if (metric === "cost") { return value.toLocaleString("zh-CN", { maximumFractionDigits: 2 }); } + if (metric === "success_rate" || metric === "cache_rate") { + return `${Math.round(value * 100)}%`; + } return formatCompact(value); } @@ -463,13 +497,28 @@ export function formatAxisValue(value: number, metric: string): string { * 图表 Y 轴单位文案,写在轴顶(刻度本身不带单位,避免每格重复)。 * * 耗时 / 首字不出现在这里:它们的刻度自带 `ms` / `s`,再标一次是啰嗦。 + * 比率同理(刻度自带 `%`)。 */ export const METRIC_AXIS_UNIT: Record = { requests: "次", tokens: "tokens", cost: "元", + cost_per_request: "元/次", }; +/** 比率类指标(0..1):Y 轴与 hover 都要乘 100,且空值要断线而非归零。 */ +export const RATE_METRICS = new Set(["success_rate", "cache_rate"]); + +/** 均值 / 比率类指标:空档(null)代表「无样本」,不是 0,图线必须断开。 */ +export const SPARSE_METRICS = new Set([ + "latency", + "ttfb", + "success_rate", + "cache_rate", + "cost_per_request", + "cost", +]); + /** 统计明细的失败类型(线值,来自后端 CONTROLLED_ERROR_TYPES)。 * 注意与 ProbeFailureReason 不是同一套:那是探测失败,这是请求失败。 */ export type UsageErrorType = diff --git a/web/src/api/hooks.ts b/web/src/api/hooks.ts index 789c6456..a6cbc8b1 100644 --- a/web/src/api/hooks.ts +++ b/web/src/api/hooks.ts @@ -263,6 +263,21 @@ export function useStatsModelTimeline(username?: string, target?: string, since? }); } +/** 延迟分位时序(P50 中位数 + P95 上界):读明细表,仅覆盖 90 天。 + * + * `enabled=false` 时不发请求:该图只在延迟类指标下展示,其余指标用不上, + * 没必要为每次切指标都扫一遍明细表。 */ +export function useStatsPercentileTimeline(username?: string, target?: string, + since?: number, until?: number, + metric?: "latency" | "ttfb", + enabled = true) { + return useQuery({ + queryKey: adminKey(username, "stats-percentile", target, since, until, metric), + queryFn: () => api.statsPercentileTimeline(target, since, until, metric), + enabled, + }); +} + /** 逐请求明细:排序切换 rowid 游标 / offset 两套分页(见后端 events 文档)。 * * `username` 是**作用域收窄**(钻取到某个用户时才有值):为空则按会话作用域 diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 75e28227..3db8f27b 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -218,21 +218,33 @@ export interface CredentialStats extends GroupStats { credential_name: string | null; } -/** 图表指标:请求次数 / token 消耗 / 成本 / 平均耗时 / 平均首字延迟。 */ -export type StatsMetric = "requests" | "tokens" | "cost" | "latency" | "ttfb"; - -/** 小时粒度时间序列点(usage_hourly 聚合,跨 model 汇合)。值语义随 metric 变化。 */ +/** 图表指标:请求次数 / token 消耗 / 成本 / 平均耗时 / 平均首字延迟 / 成功率 / 缓存命中率 / 单请求成本。 */ +export type StatsMetric = + | "requests" + | "tokens" + | "cost" + | "latency" + | "ttfb" + | "success_rate" + | "cache_rate" + | "cost_per_request"; + +/** 小时粒度时间序列点(usage_hourly 聚合,跨 model 汇合)。值语义随 metric 变化。 + * + * 时间轴是**稠密**的:后端按小时补齐缺失桶(见 `StatsQuery.timeline`), + * 因此相邻点的 `hour` 差值恒为 3600。缺失桶的取值按指标语义区分——可加量 + * (请求数 / token)补 0,均值与比率补 `null`(前端据此断线,不画成 0)。 */ export interface TimelinePoint { /** UTC 秒,对齐到小时起点 */ hour: number; - /** 各渠道指标值(渠道名为动态键:codebuddy / trae / zen…) */ - [provider: string]: number; + /** 各渠道指标值(渠道名为动态键:codebuddy / trae / zen…);无样本为 null */ + [provider: string]: number | null; } /** 按模型趋势:每小时各 Top N 模型的指标值(模型名为动态键)。 */ export interface ModelTimelinePoint { hour: number; - [model: string]: number; + [model: string]: number | null; } export interface ModelTimelineResponse { @@ -240,6 +252,24 @@ export interface ModelTimelineResponse { points: ModelTimelinePoint[]; } +/** 分位时序点:某小时的 P50(中位数)与 P95(上界)延迟,读明细表(仅 90 天)。 */ +export interface PercentilePoint { + hour: number; + /** 该小时 P50;无成功样本为 null(断线) */ + p50: number | null; + /** 该小时 P95;无成功样本为 null(断线) */ + p95: number | null; + /** 该小时的样本条数(0 = 空档),用于标注「样本很少」 */ + samples: number; +} + +export interface PercentileTimelineResponse { + points: PercentilePoint[]; + metric: "latency" | "ttfb"; + /** 明细保留期(天):分位只能覆盖这段窗口 */ + window_days: number; +} + /** 逐请求明细(usage_events,仅保留 90 天)。 */ export interface UsageEventRow { rowid: number; diff --git a/web/src/components/LatencyChart.tsx b/web/src/components/LatencyChart.tsx new file mode 100644 index 00000000..b330e447 --- /dev/null +++ b/web/src/components/LatencyChart.tsx @@ -0,0 +1,168 @@ +import { + Area, + CartesianGrid, + ComposedChart, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { PercentilePoint } from "../api/types"; +import { formatAxisValue, formatLatency } from "../api/display"; +import { formatTimeTick } from "./UsageChart"; + +/** Y 轴宽度:容下「120 s」这类刻度。 */ +const Y_AXIS_WIDTH = 56; + +/** + * 分位图的数据整形:给每个点补 `time`(毫秒)与 `band`(P50–P95 区间)。 + * + * 抽成导出函数是为了可测:jsdom 不产出 SVG,图表内部的 `tickFormatter` / + * `formatter` 回调根本不会执行,把逻辑留在 JSX 里就永远是覆盖盲区。 + * + * `band` 只在该小时样本 ≥ 5 时才给值:一两条样本算出的 P95 没有统计意义, + * 画成带会让人误以为「长尾很重」。样本不足时给 null,带断开,折线照旧。 + */ +export function buildLatencyRows(points: PercentilePoint[]) { + return points.map((point) => ({ + ...point, + time: point.hour * 1000, + band: point.samples >= 5 && point.p50 !== null && point.p95 !== null + ? [point.p50, point.p95] + : null, + })); +} + +/** Tooltip 值格式化:区间给「a – b」,单值给原值。 */ +export function formatLatencyTooltip(value: number | [number, number]): string { + if (Array.isArray(value)) { + return `${formatLatency(value[0])} – ${formatLatency(value[1])}`; + } + return formatLatency(value); +} + +/** + * 延迟分位图:P50 中位数线 + P95 上界带。 + * + * 为什么不用「均值折线」:池级均值会把长尾摊平——实测某天均值 10 s 时 P95 是 + * 21 s,而「少数请求卡住」正是最该被看见的故障形态。中位数说明「典型体验」, + * P95 与它的间距说明「有多少请求在受苦」,两条线一起才回答得了「慢在哪」。 + * + * 带不是「置信区间」:它只是 P50 到 P95 之间的区域,用来把长尾的量级视觉化, + * 不要写成统计置信度。 + */ +export function LatencyChart({ + points, + metric = "latency", + windowDays = 90, +}: { + points: PercentilePoint[]; + metric?: "latency" | "ttfb"; + windowDays?: number; +}) { + const data = buildLatencyRows(points); + const unit = metric === "ttfb" ? "首字" : "耗时"; + + return ( +
+
+ + + + formatTimeTick(Number(value), points)} + minTickGap={40} + /> + formatAxisValue(Number(value), metric)} + /> + formatTimeTick(Number(value), points, true)} + formatter={(value, name) => [ + formatLatencyTooltip(value as number | [number, number]), name, + ]} + /> + {/* P95 上界带:P50 到 P95 之间的区域,把长尾量级视觉化 */} + + + + + +
+
+ + + P50 中位数 + + + + P95 上界 + + + 只统计成功请求 · 取自逐请求明细(仅 {windowDays} 天)· {unit}口径 + +
+
+ ); +} diff --git a/web/src/components/ModelTrendChart.tsx b/web/src/components/ModelTrendChart.tsx index 81e3a212..0c0d1abf 100644 --- a/web/src/components/ModelTrendChart.tsx +++ b/web/src/components/ModelTrendChart.tsx @@ -1,6 +1,5 @@ import { CartesianGrid, - Legend, Line, LineChart, ResponsiveContainer, @@ -9,7 +8,13 @@ import { YAxis, } from "recharts"; import type { ModelTimelinePoint } from "../api/types"; -import { formatAxisValue, formatChartValue, METRIC_AXIS_UNIT } from "../api/display"; +import { + formatAxisValue, + formatChartValue, + METRIC_AXIS_UNIT, + SPARSE_METRICS, +} from "../api/display"; +import { formatTimeTick } from "./UsageChart"; const COLORS = [ "var(--chart-1)", @@ -23,7 +28,24 @@ const COLORS = [ /** Y 轴宽度:容下紧凑刻度(如「1500万」「850 ms」)。 */ const Y_AXIS_WIDTH = 56; -/** 按模型指标趋势:每小时各 Top N 模型一条曲线(recharts)。 */ +/** + * 一张图最多画几条模型线。 + * + * 超过 3 条就分不清(曲线互相交叉、图例要折行),而「哪个模型在涨」用 Top 3 + * 已经能回答;更多模型交给下方的「按模型」分组表。后端 `top` 默认 6,这里再 + * 收敛一次,保证图本身可读。 + */ +const MAX_LINES = 3; + +/** + * 按模型指标趋势(recharts)。 + * + * **时间轴是数值轴**:`hour` 是 UTC 秒,`scale="time"` 让点按真实时间定位, + * 缺测小时表现为空位而不是被压缩成等宽(与 `UsageChart` 同口径)。 + * + * **最多 3 条线**(`MAX_LINES`):模型数超过 3 时只画请求量 Top 3,其余在 + * 「按模型」分组表里看。单系列时不画图例——标题与 Top 文案已经说明是哪条。 + */ export function ModelTrendChart({ points, models, @@ -33,38 +55,39 @@ export function ModelTrendChart({ models: string[]; metric?: string; }) { - const data = points.map((point) => ({ - ...point, - time: new Date(point.hour * 1000).toLocaleString("zh-CN", { - hour12: false, - month: "2-digit", - day: "2-digit", - hour: "2-digit", - }), - })); + const currentMetric = metric ?? "requests"; + const sparse = SPARSE_METRICS.has(currentMetric); + const data = points.map((point) => ({ ...point, time: point.hour * 1000 })); // 单位标签只对不自带单位的指标(计数 / token)显示;耗时/首字的刻度已含 ms/s - const axisUnit = METRIC_AXIS_UNIT[metric ?? "requests"]; + const axisUnit = METRIC_AXIS_UNIT[currentMetric]; + const shown = models.slice(0, MAX_LINES); return ( -
+
- + formatTimeTick(Number(value), points)} minTickGap={40} /> formatAxisValue(Number(value), metric ?? "requests")} + tickFormatter={(value) => formatAxisValue(Number(value), currentMetric)} label={ axisUnit ? { @@ -86,18 +109,20 @@ export function ModelTrendChart({ color: "var(--popover-foreground)", }} labelStyle={{ fontWeight: 600 }} - formatter={(value, name) => [formatChartValue(Number(value), metric ?? "requests"), name]} + labelFormatter={(value) => formatTimeTick(Number(value), points, true)} + formatter={(value, name) => [formatChartValue(Number(value), currentMetric), name]} /> - - {models.map((model, index) => ( + {shown.map((model, index) => ( ))} @@ -106,3 +131,26 @@ export function ModelTrendChart({
); } + +/** 图例:每个模型一个色点(单系列时由标题说明,不渲染)。 */ +export function ModelLegend({ models }: { models: string[] }) { + const shown = models.slice(0, MAX_LINES); + if (shown.length <= 1) return null; + return ( +
+ {shown.map((model, index) => ( + + + {model} + + ))} +
+ ); +} diff --git a/web/src/components/SortMenuHead.tsx b/web/src/components/SortMenuHead.tsx index 97e3e03a..ab925931 100644 --- a/web/src/components/SortMenuHead.tsx +++ b/web/src/components/SortMenuHead.tsx @@ -19,6 +19,7 @@ import { */ export function SortMenuHead({ label, + unit, options, activeKey, direction, @@ -27,6 +28,11 @@ export function SortMenuHead({ testId, }: { label: string; + /** + * 单位(如 `tok/s`)。以更小字号缀在列名后,而不是拼进 `label`: + * 「速度 tok/s」会把列头撑到 100px 以上,10 列的表里正好挤掉最后一列。 + */ + unit?: string; /** 子列:[{key: 后端排序键, label: 展示名}],数组顺序即菜单顺序。 */ options: { key: string; label: string }[]; /** 当前排序键;不在 options 里时本列不显示激活态(别的列正在排序)。 */ @@ -57,6 +63,9 @@ export function SortMenuHead({ )} > {label} + {unit && ( + {unit} + )} {activeOption && ( · {activeOption.label} )} diff --git a/web/src/components/UsageChart.tsx b/web/src/components/UsageChart.tsx index ba29191b..1c08d558 100644 --- a/web/src/components/UsageChart.tsx +++ b/web/src/components/UsageChart.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { Area, AreaChart, @@ -7,7 +8,13 @@ import { XAxis, YAxis, } from "recharts"; -import { formatAxisValue, formatChartValue, METRIC_AXIS_UNIT } from "../api/display"; +import { + formatAxisValue, + formatChartValue, + formatNumber, + METRIC_AXIS_UNIT, + RATE_METRICS, +} from "../api/display"; import { providerChartColor, providerLabel } from "../api/providers"; import type { TimelinePoint } from "../api/types"; import { ProviderIcon } from "./ProviderIcon"; @@ -16,7 +23,7 @@ import { ProviderIcon } from "./ProviderIcon"; const Y_AXIS_WIDTH = 56; /** 时间序列点的保留键(不是渠道名),其余键都当作渠道序列。 */ -const NON_PROVIDER_KEYS = new Set(["hour", "time"]); +const NON_PROVIDER_KEYS = new Set(["hour", "time", "__total"]); /** * 从数据点里收集出现过的渠道名(稳定排序)。 @@ -34,55 +41,154 @@ export function chartProviders(points: TimelinePoint[]): string[] { return [...seen].sort(); } -/** 请求量时间序列曲线(recharts)。点少时仍画满可用宽度,不加插值。 +/** 渠道在该窗口内是否真有数据(全 null / 全 0 的渠道不进图例)。 */ +export function hasData(points: TimelinePoint[], provider: string): boolean { + return points.some((point) => { + const value = point[provider]; + return value !== null && value !== undefined && value !== 0; + }); +} + +/** 图表的视图口径:总量(全渠道合计)或单渠道。 */ +export type ChartScope = "total" | string; + +/** + * 比率类指标的 Y 轴定义域。 + * + * 固定 `[0, 1]` 诚实但没信息量:成功率常年在 98%–100% 之间,整条线会贴在 + * 顶部、波动完全看不见(实测 30 天 min=98.55%,固定 0–100% 时曲线是一条直线)。 + * 但也不能让 recharts 的 `auto` 自己决定——那会把 98.5% 画成「接近 0」, + * 而且行内的毫秒时间戳会污染定义域(曾算出 400% 的上界)。 * - * 渠道图例放在图表**下方**(与「按模型趋势」同口径):渠道数会随上游增减 - * (国际站接入后一条模型最多挂 5 个渠道),图例放面板右上角会把标题挤掉, - * 放图下方则按需折行、不占用标题行。 + * 取舍:**数据全部高于 `RATE_FLOOR` 时,轴从 `RATE_FLOOR` 起**,让窄幅波动 + * 可见;否则从 0 起,保持「0 就是 0」的直觉。截断轴必须标注(见调用处的 + * `truncated`),否则读者会以为底部就是 0——那是另一种撒谎。 */ -export function UsageChart({ points, metric }: { points: TimelinePoint[]; metric?: string }) { - const data = points.map((point) => ({ - ...point, - time: new Date(point.hour * 1000).toLocaleString("zh-CN", { - hour12: false, - month: "2-digit", - day: "2-digit", - hour: "2-digit", - }), - })); +const RATE_FLOOR = 0.9; + +export function rateAxisDomain(values: (number | null | undefined)[]): { + domain: [number, number]; + ticks: number[] | undefined; + truncated: boolean; +} { + const present = values.filter((value): value is number => + typeof value === "number" && Number.isFinite(value)); + const low = present.length > 0 ? Math.min(...present) : 0; + if (low >= RATE_FLOOR) { + return { + domain: [RATE_FLOOR, 1], + ticks: [RATE_FLOOR, 0.95, 1].filter((tick) => tick >= RATE_FLOOR && tick <= 1), + truncated: true, + }; + } + return { domain: [0, 1], ticks: [0, 0.25, 0.5, 0.75, 1], truncated: false }; +} + +/** + * 单请求成本的 Y 轴上界:取「数据最大值 ×1.1」向上取整到 1/2/5×10ⁿ 的整数档。 + * + * 为什么要自己算:recharts 的 `auto` 会直接拿最小值当刻度(实测出现 + * `0.0085 / 0.017 / 0.0255`),读数时得做除法。规整成 `0.02 / 0.04` 这类 + * 整档后,刻度看一眼就知道量级。 + */ +export function niceCostCeiling(max: number): number { + if (!(max > 0)) return 1; + const target = max * 1.1; + const magnitude = 10 ** Math.floor(Math.log10(target)); + for (const step of [1, 2, 2.5, 5]) { + const candidate = step * magnitude; + if (candidate >= target) return candidate; + } + // 5×10ⁿ 不够时进下一档(如 7×10ⁿ → 10×10ⁿ);10×10ⁿ 一定够,故不再循环 + return 10 * magnitude; +} + +/** + * 指标时间序列曲线(recharts)。 + * + * **时间轴是数值轴**(`dataKey="hour"` + 秒级时间戳 + `type="number"`):用格式化 + * 字符串当分类轴会把缺失的小时压成等宽——7 天里空 3 天的那一段会被画得和相邻 + * 一天一样宽,等于伪造时间。后端已按小时补齐稠密桶,这里再让 recharts 按真实 + * 时间定位,缺测小时自然表现为空位。 + * + * **默认只画一条线**(`scope="total"`,全渠道合计):多系列叠加在 7 天窗口下会 + * 互相遮挡,且「今天总量多少」才是这个面板要回答的问题。要按渠道拆看,由调用方 + * 传 `scope` 切到单个渠道(`scale` 一次只画一个渠道),不是把所有渠道叠在一起。 + * + * 均值 / 比率类指标的缺失桶是 `null`:`connectNulls={false}` 让线在空档断开, + * 不把「没有样本」连成一条假的斜线。 + */ +export function UsageChart({ + points, + metric, + scope = "total", +}: { + points: TimelinePoint[]; + metric?: string; + scope?: ChartScope; +}) { + const currentMetric = metric ?? "requests"; + const isRate = RATE_METRICS.has(currentMetric); + const data = useMemo( + () => points.map((point) => ({ ...point, time: point.hour * 1000 })), + [points], + ); // 单位标签只对不自带单位的指标(计数 / token)显示;耗时/首字的刻度已含 ms/s - const axisUnit = METRIC_AXIS_UNIT[metric ?? "requests"]; - const providers = chartProviders(points); + const axisUnit = METRIC_AXIS_UNIT[currentMetric]; + // 单渠道视图:只画该渠道;总量视图:画后端算好的 `__total` + const series = scope === "total" ? "__total" : scope; + const color = scope === "total" ? "var(--chart-1)" : providerChartColor(scope); + const label = scope === "total" ? "全部渠道" : providerLabel(scope); + // 比率指标:轴下界按数据决定(见 rateAxisDomain),截断时必须在轴顶写明 + const rateAxis = isRate + ? rateAxisDomain(points.map((point) => point[series])) + : null; + // 单请求成本:量级小且取值分散,用规整上界(见 niceCostCeiling) + const costAxis = currentMetric === "cost_per_request" + ? [0, niceCostCeiling(Math.max(0, ...points.map((point) => point[series] ?? 0)))] + : null; + const empty = points.length === 0 || points.every( + (point) => point[series] === null || point[series] === undefined); return (
-
+ {/* 绘图区高度:桌面 208px(h-52),窄屏 224px(h-56)。 + 原来固定 256px(h-64)——在 1280px 宽、近千像素的卡片里,一条折线 + 上下留白过多,纵轴利用率只有一半;图表本身读的是「趋势与拐点」, + 不需要那么高。窄屏反而要略高:横向刻度少,太扁会看不出起伏。 */} +
- + - {providers.map((provider) => ( - - - - - ))} + + + + formatTimeTick(Number(value), points)} minTickGap={40} /> formatAxisValue(Number(value), metric ?? "requests")} + tickFormatter={(value) => formatAxisValue(Number(value), currentMetric)} label={ axisUnit ? { @@ -104,36 +210,136 @@ export function UsageChart({ points, metric }: { points: TimelinePoint[]; metric color: "var(--popover-foreground)", }} labelStyle={{ fontWeight: 600 }} - formatter={(value, name) => [formatChartValue(Number(value), metric ?? "requests"), name]} + labelFormatter={(value) => formatTimeTick(Number(value), points, true)} + formatter={(value) => [formatChartValue(Number(value), currentMetric), label]} + /> + - {providers.map((provider) => ( - - ))}
- {/* 图例自绘而非 recharts :渠道图标与名称要成组、按需折行, - 且复用与列表行一致的 ProviderIcon。 */}
- {providers.map((provider) => ( - - - {providerLabel(provider)} + + {scope === "total" ? ( + + ) : ( + + )} + {label} + + {rateAxis?.truncated && ( + /* 轴从 90% 起:不说明就是撒谎(读者会以为底部是 0) */ + + Y 轴自 90% 起(便于看清窄幅波动) + )} + {empty && 该范围内没有可绘制的数据} +
+
+ ); +} + +/** X 轴刻度共用:只依赖 `hour`,故接受任何带 hour 的点(TimelinePoint / PercentilePoint)。 */ +type HourPoint = { hour: number }; + +/** + * X 轴刻度文案:短窗口只显示时刻(`14时`),跨天窗口显示「月-日 时」。 + * + * 窗口是否跨天由数据首末时间决定,不按点数猜:24 小时窗口即使只有 3 个点也 + * 应该只写时刻,而 7 天窗口的第一个点必须带日期。 + */ +export function formatTimeTick(timestamp: number, points: HourPoint[], + withDate = false): string { + const date = new Date(timestamp); + const clock = `${String(date.getHours()).padStart(2, "0")}时`; + if (!withDate && spansSingleDay(points)) return clock; + const day = `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + return `${day} ${clock}`; +} + +/** 首末点是否落在同一天(本地时区)。 */ +function spansSingleDay(points: HourPoint[]): boolean { + if (points.length === 0) return true; + const first = new Date(points[0].hour * 1000); + const last = new Date(points[points.length - 1].hour * 1000); + return first.getFullYear() === last.getFullYear() + && first.getMonth() === last.getMonth() + && first.getDate() === last.getDate(); +} + +/** + * 渠道构成:一条 100% 宽度的堆叠条 + 图例。 + * + * 不用「每渠道一条横条」:渠道通常只有 2–4 个,横条在 1100px 宽的卡片里会被 + * 拉到极长,读起来散且浪费高度。堆叠条本身就表达「构成」(份额合计 100%), + * 宽度被自然填满,高度只占一行。 + * + * 也不做饼 / 环:渠道数常到 8,环形图在 5 个以上就分不清扇区,且无法按大小排序。 + */ +export function ProviderShare({ + rows, + total, +}: { + rows: { provider: string; value: number }[]; + total: number; +}) { + const sorted = [...rows].sort((a, b) => b.value - a.value); + const sum = total > 0 ? total : 1; + const items = sorted.map((row) => ({ + ...row, + percent: (row.value / sum) * 100, + })); + return ( +
+
`${providerLabel(row.provider)} ${row.percent.toFixed(1)}%`) + .join(",")}`} + > + {items.map((row) => ( + ))}
+
    + {items.map((row) => ( +
  • + + {providerLabel(row.provider)} + + {row.percent.toFixed(1)}% + + + {formatNumber(row.value)} + +
  • + ))} +
); } diff --git a/web/src/components/__tests__/LatencyChart.test.tsx b/web/src/components/__tests__/LatencyChart.test.tsx new file mode 100644 index 00000000..44cfd244 --- /dev/null +++ b/web/src/components/__tests__/LatencyChart.test.tsx @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { LatencyChart, buildLatencyRows, formatLatencyTooltip } from "../LatencyChart"; + +const POINTS = [ + { hour: 1_700_000_000, p50: 1200, p95: 5200, samples: 40 }, + { hour: 1_700_003_600, p50: null, p95: null, samples: 0 }, + { hour: 1_700_007_200, p50: 2400, p95: 9800, samples: 3 }, +]; + +describe("buildLatencyRows", () => { + it("样本 ≥ 5 才给 P50–P95 区间", () => { + const rows = buildLatencyRows(POINTS); + expect(rows[0].band).toEqual([1200, 5200]); + // 空档与样本不足的小时都不画带 + expect(rows[1].band).toBeNull(); + expect(rows[2].band).toBeNull(); + }); + + it("补齐毫秒时间戳(recharts 数值轴用)", () => { + expect(buildLatencyRows(POINTS)[0].time).toBe(1_700_000_000_000); + }); + + it("P50/P95 任一为 null 时不给带(半条数据不成区间)", () => { + const rows = buildLatencyRows([ + { hour: 1, p50: 100, p95: null, samples: 40 }, + { hour: 2, p50: null, p95: 900, samples: 40 }, + ]); + expect(rows[0].band).toBeNull(); + expect(rows[1].band).toBeNull(); + }); +}); + +describe("formatLatencyTooltip", () => { + it("区间给「下界 – 上界」", () => { + expect(formatLatencyTooltip([1200, 5200])).toBe("1.2 s – 5.2 s"); + }); + + it("单值按延迟格式(≥1s 用 s)", () => { + expect(formatLatencyTooltip(850)).toBe("850 ms"); + }); +}); + +describe("LatencyChart", () => { + it("渲染容器与图例(jsdom 无布局,ResponsiveContainer 不产出 SVG)", () => { + render(); + expect(screen.getByTestId("latency-chart")).toBeInTheDocument(); + const legend = screen.getByTestId("latency-chart-legend"); + expect(legend).toHaveTextContent("P50 中位数"); + expect(legend).toHaveTextContent("P95 上界"); + }); + + it("图例写明口径与窗口天数(分位只覆盖明细保留期)", () => { + render(); + expect(screen.getByTestId("latency-chart-legend")).toHaveTextContent("仅 90 天"); + expect(screen.getByTestId("latency-chart-legend")).toHaveTextContent("只统计成功请求"); + }); + + it("首字口径在图例里写明", () => { + render(); + expect(screen.getByTestId("latency-chart-legend")).toHaveTextContent("首字口径"); + }); + + it("耗时口径在图例里写明", () => { + render(); + expect(screen.getByTestId("latency-chart-legend")).toHaveTextContent("耗时口径"); + }); + + it("样本不足 5 条的小时不画带(避免一两条样本画出尖峰)", () => { + // 行为在 SVG 层,jsdom 只能确认不抛错;分档逻辑由 band 字段决定 + render(); + expect(screen.getByTestId("latency-chart")).toBeInTheDocument(); + }); + + it("空点集也能渲染(不抛错)", () => { + render(); + expect(screen.getByTestId("latency-chart")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/__tests__/ModelTrendChart.test.tsx b/web/src/components/__tests__/ModelTrendChart.test.tsx new file mode 100644 index 00000000..96123882 --- /dev/null +++ b/web/src/components/__tests__/ModelTrendChart.test.tsx @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ModelLegend, ModelTrendChart } from "../ModelTrendChart"; + +const POINTS = [ + { hour: 1_700_000_000, "glm-5.2": 4, "kimi-k3": 1 }, + { hour: 1_700_003_600, "glm-5.2": 0, "kimi-k3": 2 }, +]; + +describe("ModelTrendChart", () => { + it("渲染容器(jsdom 无布局,ResponsiveContainer 不产出 SVG)", () => { + render(); + expect(screen.getByTestId("model-trend-chart")).toBeInTheDocument(); + }); + + it("模型数超过 3 时只画前 3 条(更多交给分组表)", () => { + const models = ["a", "b", "c", "d", "e"]; + render(); + // 图例只列前 3 个:第 4、5 个不上图 + render(); + const legends = screen.getAllByTestId("model-trend-legend"); + expect(legends[0]).toHaveTextContent("a"); + expect(legends[0]).not.toHaveTextContent("d"); + }); + + it("均值类指标(null 空档)断线,可加量指标连线", () => { + // 仅验证不抛错:connectNulls 由 metric 决定,行为在 SVG 层,jsdom 无法断言 + const sparsePoints = [ + { hour: 1_700_000_000, "glm-5.2": 100 }, + { hour: 1_700_003_600, "glm-5.2": null }, + ]; + render(); + render(); + expect(screen.getAllByTestId("model-trend-chart")).toHaveLength(2); + }); +}); + +describe("ModelLegend", () => { + it("单系列不渲染图例(标题已说明是哪条)", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("多系列渲染色点 + 模型名", () => { + render(); + const legend = screen.getByTestId("model-trend-legend"); + expect(legend).toHaveTextContent("glm-5.2"); + expect(legend).toHaveTextContent("kimi-k3"); + }); + + it("空模型列表不渲染", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/web/src/components/__tests__/UsageChart.test.tsx b/web/src/components/__tests__/UsageChart.test.tsx index 668ac04e..9f28bffe 100644 --- a/web/src/components/__tests__/UsageChart.test.tsx +++ b/web/src/components/__tests__/UsageChart.test.tsx @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { render } from "@testing-library/react"; -import { UsageChart, chartProviders } from "../UsageChart"; +import { + ProviderShare, UsageChart, chartProviders, formatTimeTick, hasData, niceCostCeiling, + rateAxisDomain, +} from "../UsageChart"; describe("chartProviders", () => { it("收集出现过的渠道名,按字母序稳定排序", () => { @@ -13,33 +16,226 @@ describe("chartProviders", () => { it("只有 hour 时返回空数组(不把保留键当渠道)", () => { expect(chartProviders([{ hour: 1 }])).toEqual([]); }); + + it("不把 __total 当渠道(它是全渠道合计键)", () => { + expect(chartProviders([{ hour: 1, __total: 9, trae: 4 }])).toEqual(["trae"]); + }); +}); + +describe("hasData", () => { + it("全 null / 全 0 视为无数据(不进图例与切换项)", () => { + const points = [{ hour: 1, trae: 0 }, { hour: 2, trae: null }]; + expect(hasData(points, "trae")).toBe(false); + }); + + it("有任一非零值即有数据", () => { + expect(hasData([{ hour: 1, trae: 0 }, { hour: 2, trae: 3 }], "trae")).toBe(true); + }); +}); + +describe("formatTimeTick", () => { + it("同一天窗口只写时刻", () => { + // 本地时区的同一天:用当天 10:00 与 14:00 + const day = new Date(2026, 9, 25, 10, 0, 0); + const points = [ + { hour: Math.floor(day.getTime() / 1000) }, + { hour: Math.floor(day.getTime() / 1000) + 4 * 3600 }, + ]; + expect(formatTimeTick(points[0].hour * 1000, points)).toBe("10时"); + }); + + it("跨天窗口带日期", () => { + const first = new Date(2026, 9, 24, 23, 0, 0); + const last = new Date(2026, 9, 25, 3, 0, 0); + const points = [ + { hour: Math.floor(first.getTime() / 1000) }, + { hour: Math.floor(last.getTime() / 1000) }, + ]; + expect(formatTimeTick(first.getTime(), points)).toBe("10-24 23时"); + }); + + it("withDate 强制带日期(hover 提示用)", () => { + const same = new Date(2026, 9, 25, 10, 0, 0); + expect(formatTimeTick(same.getTime(), [{ hour: Math.floor(same.getTime() / 1000) }], true)) + .toBe("10-25 10时"); + }); + + it("空点集按同一天处理", () => { + expect(formatTimeTick(new Date(2026, 9, 25, 10, 0, 0).getTime(), [])).toBe("10时"); + }); +}); + +describe("rateAxisDomain", () => { + it("数据全部高于 90% 时截断轴下界,并给三档刻度", () => { + const axis = rateAxisDomain([0.9855, 0.999, 1]); + expect(axis.domain).toEqual([0.9, 1]); + expect(axis.ticks).toEqual([0.9, 0.95, 1]); + expect(axis.truncated).toBe(true); + }); + + it("有低于 90% 的值时从 0 起(不做截断)", () => { + const axis = rateAxisDomain([0.4, 1]); + expect(axis.domain).toEqual([0, 1]); + expect(axis.ticks).toEqual([0, 0.25, 0.5, 0.75, 1]); + expect(axis.truncated).toBe(false); + }); + + it("忽略 null / 非数值(空档不参与定义域)", () => { + expect(rateAxisDomain([null, undefined, 0.99]).domain).toEqual([0.9, 1]); + }); + + it("全空时退回 0 起的完整区间", () => { + const axis = rateAxisDomain([null, null]); + expect(axis.domain).toEqual([0, 1]); + expect(axis.truncated).toBe(false); + }); +}); + +describe("niceCostCeiling", () => { + it("向上取整到 1/2/2.5/5 × 10ⁿ 的整档", () => { + // 0.0312 × 1.1 = 0.0343 → 0.05(5 × 10⁻²) + expect(niceCostCeiling(0.0312)).toBeCloseTo(0.05); + // 0.019 × 1.1 = 0.0209 → 0.025(2.5 × 10⁻²) + expect(niceCostCeiling(0.019)).toBeCloseTo(0.025); + expect(niceCostCeiling(1.5)).toBe(2); + expect(niceCostCeiling(0.4)).toBeCloseTo(0.5); + }); + + it("0 或负值退回 1(不让定义域退化)", () => { + expect(niceCostCeiling(0)).toBe(1); + expect(niceCostCeiling(-1)).toBe(1); + }); + + it("5×10ⁿ 档不够时进下一档", () => { + // 0.7 × 1.1 = 0.77:1/2/2.5/5 × 10⁻¹ 都 < 0.77 → 10 × 10⁻¹ = 1 + expect(niceCostCeiling(0.7)).toBeCloseTo(1); + }); +}); + +describe("ProviderShare", () => { + it("堆叠条按份额排宽度,图例降序展示百分比与请求数", () => { + const { getByTestId } = render( + , + ); + const root = getByTestId("provider-share"); + // 堆叠条:份额合计 100%,最宽的段是 codebuddy(70/125 = 56%) + const bar = root.querySelector("[role='img']")!; + const segments = [...bar.querySelectorAll("span")]; + expect(segments).toHaveLength(3); + // React 归一化内联样式,`56.00%` 落成 `56%`——按数值断言,不锁字符串 + const widthOf = (el: Element) => + Number(/width:\s*([\d.]+)%/.exec(el.getAttribute("style") ?? "")?.[1]); + expect(widthOf(segments[0])).toBeCloseTo(56, 1); + expect(widthOf(segments[1])).toBeCloseTo(40, 1); + expect(widthOf(segments[2])).toBeCloseTo(4, 1); + expect(widthOf(segments[0]) + widthOf(segments[1]) + widthOf(segments[2])) + .toBeCloseTo(100, 1); + // 图例降序:CodeBuddy 在 TRAE 之前 + const items = [...root.querySelectorAll("li")]; + expect(items[0]).toHaveTextContent("CodeBuddy"); + expect(items[0]).toHaveTextContent("56.0%"); + expect(items[0]).toHaveTextContent("70"); + expect(items[1]).toHaveTextContent("TRAE"); + }); + + it("total 为 0 时不除以 0(份额 0%,条形宽 0)", () => { + const { getByTestId } = render( + , + ); + const root = getByTestId("provider-share"); + const segment = root.querySelector("[role='img'] span")!; + expect(segment.getAttribute("style")).toContain("0%"); + expect(root.querySelector("li")).toHaveTextContent("0.0%"); + }); }); describe("UsageChart", () => { it("渲染容器(jsdom 无布局,ResponsiveContainer 不产出 SVG)", () => { const { getByTestId } = render( , ); expect(getByTestId("usage-chart")).toBeInTheDocument(); }); - it("渠道图例渲染在图表下方而非标题行", () => { + it("默认画全部渠道合计,图例在图表下方", () => { const { getByTestId } = render( , ); const chart = getByTestId("usage-chart"); const legend = getByTestId("usage-chart-legend"); // 图例是图表容器(固定高度绘图区)之后的兄弟节点——位于下方,不占标题行 const plot = chart.firstElementChild; - expect(plot?.className).toContain("h-64"); + // 绘图区高度:窄屏 h-56 / 桌面 sm:h-52(原来固定 h-64,桌面留白过多) + expect(plot?.className).toContain("h-56"); + expect(plot?.className).toContain("sm:h-52"); expect(legend.previousElementSibling).toBe(plot); - expect(legend).toHaveTextContent("CodeBuddy"); - expect(legend).toHaveTextContent("TRAE"); + // 默认口径是「全部渠道」:单系列不列各渠道 + expect(legend).toHaveTextContent("全部渠道"); + expect(legend).not.toHaveTextContent("CodeBuddy"); + }); + + it("切到单渠道时图例显示该渠道", () => { + const { getByTestId } = render( + , + ); + expect(getByTestId("usage-chart-legend")).toHaveTextContent("TRAE"); + }); + + it("整窗无数据时给出提示而不是留白", () => { + const { getByTestId } = render( + , + ); + expect(getByTestId("usage-chart-empty")).toBeInTheDocument(); + }); + + it("比率轴截断时在图例标注,避免读者误以为底部是 0", () => { + const { getByTestId } = render( + , + ); + expect(getByTestId("usage-chart-axis-note")).toHaveTextContent("Y 轴自 90% 起"); + }); + + it("非比率指标不出现截断标注", () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId("usage-chart-axis-note")).toBeNull(); + }); + + it("有数据时不显示空态提示", () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId("usage-chart-empty")).toBeNull(); + }); + + it("无点时也算空态", () => { + const { getByTestId } = render(); + expect(getByTestId("usage-chart-empty")).toBeInTheDocument(); }); }); diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index a65bc770..eba01160 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -11,7 +11,7 @@ export const MIN_PASSWORD_LENGTH = 8; * 所有列表页共用同一组:用户不该在「用量统计」看到 10/20/50/100、在「模型列表」 * 又看到 20/50/100。默认值取 `DEFAULT_PAGE_SIZE`(见下),各页可显式覆盖。 */ -export const PAGE_SIZE_OPTIONS = [20, 50, 100] as const; +export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const; /** 默认每页条数(`PAGE_SIZE_OPTIONS` 中的一项)。 */ export const DEFAULT_PAGE_SIZE = 50; diff --git a/web/src/pages/StatsPage.tsx b/web/src/pages/StatsPage.tsx index 6736c1bd..442e97d7 100644 --- a/web/src/pages/StatsPage.tsx +++ b/web/src/pages/StatsPage.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; -import { Activity, BarChart3, Coins, CreditCard, Download, Gauge, Timer, Wallet, X, Zap } from "lucide-react"; +import { Activity, BarChart3, ChevronDown, Coins, Download, Gauge, Timer, Wallet, X } from "lucide-react"; +import { cn } from "cn"; import { useSessionContext } from "../Layout"; import { useSort } from "../hooks/useSort"; import { @@ -11,11 +12,13 @@ import { useStatsEvents, useStatsModelTimeline, useStatsOverview, + useStatsPercentileTimeline, useStatsTimeline, } from "../api/hooks"; import { formatCacheRate, formatCompact, formatCredit, formatLatency, formatMoney, formatNumber, formatSpeed, formatSpeedPair, formatTime, formatTimeParts, usageErrorLabel } from "../api/display"; import { useStatsErrors, type StatsErrorRow } from "../api/statsErrors"; -import { ModelTrendChart } from "../components/ModelTrendChart"; +import { ModelLegend, ModelTrendChart } from "../components/ModelTrendChart"; +import { LatencyChart } from "../components/LatencyChart"; import { PageHeader } from "../components/PageHeader"; import { PageSkeleton } from "../components/PageSkeleton"; import { Pagination } from "../components/Pagination"; @@ -23,7 +26,7 @@ import { ProviderIcon } from "../components/ProviderIcon"; import { SortableHead } from "../components/SortableHead"; import { SortMenuHead } from "../components/SortMenuHead"; import { Tip } from "../components/Tip"; -import { UsageChart } from "../components/UsageChart"; +import { ProviderShare, UsageChart, chartProviders, hasData } from "../components/UsageChart"; import { providerLabel } from "../api/providers"; import { api } from "../api/client"; import { buildCustomRange, toLocalInput } from "../lib/customRange"; @@ -31,8 +34,12 @@ import type { GroupStats, StatsDrill, StatsEventFilters, UsageEventRow } from ". import { Button, Checkbox, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, Empty, Field, + Footnote, Input, Label, Metric, @@ -59,14 +66,26 @@ const RANGES = [ { value: "custom", label: "自定义", seconds: -1 }, ]; -/** 图表指标筛选:默认请求次数;均值类(耗时/首字)后端按成功请求归一。 */ +/** + * 图表指标:按「要回答什么问题」分组。 + * + * 请求次数 / Token 回答「用量」;成本 / 单请求成本回答「花得值不值」; + * 耗时 / 首字回答「快不快」;成功率 / 缓存命中率回答「稳不稳、省不省」。 + * 八个指标共用一张主图(切换而非叠加),渠道拆分由主图下方的渠道切换控制。 + */ const METRICS = [ { value: "requests", label: "请求次数", icon: }, { value: "tokens", label: "Token", icon: }, { value: "cost", label: "成本", icon: }, + { value: "cost_per_request", label: "单请求成本", icon: }, { value: "latency", label: "耗时", icon: }, { value: "ttfb", label: "首字延迟", icon: }, -]; + { value: "success_rate", label: "成功率", icon: }, + { value: "cache_rate", label: "缓存命中率", icon: }, +] as const; + +/** 延迟类指标:主图之外再画一张分位图(P50 中位数 + P95 上界)。 */ +const LATENCY_METRICS = new Set(["latency", "ttfb"]); /** * 请求明细的合并列:把同类列并成一列,默认值一眼可读,悬停展开全部子项。 @@ -203,9 +222,12 @@ function timeCell(row: UsageEventRow) { */ function sourceCell(row: UsageEventRow) { return ( - - + {/* 固定宽度 + truncate 必须落在内层块元素上:`table-cell` 上的 max-width / + truncate 不生效,长邮箱(如 hongsheng900805@gmail.com)会把整列撑到 + 192px,逼得最后两列横向滚动。 */} + {credentialCellLabel(row.credential_name, row.credential_id)} @@ -274,7 +296,8 @@ function eventSpeedCell(row: UsageEventRow) { - {formatSpeedPair(row.tps_visible, row.tps)} + {/* 单位交给列头(tok/s),行内只留数字:10 列的表里每行重复单位要多占 45px */} + {formatSpeedPair(row.tps_visible, row.tps, false, false)} ); } @@ -288,15 +311,20 @@ export function StatsPage() { const [customFrom, setCustomFrom] = useState(""); const [customTo, setCustomTo] = useState(""); const [metric, setMetric] = useState("requests"); + // 主图口径:默认「全部渠道」合计;切到某渠道则只看那一条曲线。 + // 不做多系列叠加(7 天窗口下会互相遮挡),一次只画一条。 + const [chartScope, setChartScope] = useState("total"); const [groupDimension, setGroupDimension] = useState("provider"); // 分组统计默认按请求数降序(「用得最多」才是分组表的用途) const groupSort = useSort("requests", "desc", { group: "asc" }); // 明细默认按时间(rowid)降序 → 走游标分页;换列则切 offset 分页 const eventSort = useSort("time", "desc"); // 明细分页:cursors[i] = 第 i 页的 before 游标(第 0 页为 undefined); - // rowid 单调递增,游标栈支持双向翻页且不漏不重 + // rowid 单调递增,游标栈支持双向翻页且不漏不重。 + // 默认 10 条:明细行是两行文本(时间+日期、凭证+渠道),20 条就是 1140px, + // 把页面拉到近 4 屏。看明细通常是「扫最近几条」,需要更多可切 50/100。 const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(20); + const [pageSize, setPageSize] = useState(10); const [cursors, setCursors] = useState<(number | undefined)[]>([undefined]); // 分组表分位档:默认 P95(既有观感);四档后端一次算好,切换只换展示列 const [percentile, setPercentile] = useState("p95"); @@ -457,10 +485,17 @@ export function StatsPage() { const byCredential = useStatsByCredential( session.username, undefined, effectiveSince, effectiveUntil, groupSort.sort, groupSort.order); + // 渠道构成卡片:只在请求量指标下出现(份额需要「请求数」这个分母) + const showShare = metric === "requests" + && (byProvider.data?.providers.length ?? 0) > 0; const timeline = useStatsTimeline(session.username, undefined, effectiveSince, effectiveUntil, metric); const modelTimeline = useStatsModelTimeline(session.username, undefined, effectiveSince, effectiveUntil, metric); + // 延迟分位只在延迟类指标下取数(其余指标用不上,省一次明细表扫描) + const latencyPercentile = useStatsPercentileTimeline( + session.username, undefined, effectiveSince, effectiveUntil, + metric === "ttfb" ? "ttfb" : "latency", LATENCY_METRICS.has(metric)); // 失败归因:端点仅 operator+(viewer 打开会 403),故按角色禁用查询 const errors = useStatsErrors(session.username, effectiveSince, effectiveUntil, undefined, session.is_operator); @@ -487,6 +522,17 @@ export function StatsPage() { ? (byUser.data?.users ?? []) : (byCredential.data?.credentials ?? []); + // 主图的渠道切换候选:只列窗口内真有数据的渠道(全 0 / 全空的渠道不进选项, + // 否则切过去是一张空图) + const timelineProviders = useMemo(() => { + const points = timeline.data?.points ?? []; + return chartProviders(points).filter((provider) => hasData(points, provider)); + }, [timeline.data]); + // 切走的渠道可能在新窗口里没有数据:此时回落到「全部渠道」,不留空白图 + const effectiveScope = chartScope !== "total" && !timelineProviders.includes(chartScope) + ? "total" + : chartScope; + if (overview.isLoading) { return (
@@ -522,9 +568,10 @@ export function StatsPage() { icon={} /> - - {/* 「时间范围」label 后紧接 tabs(左右相邻,不拉开两端) */} -
+ {/* 筛选条:时间范围与图表指标合并在同一张卡里——两者都是「决定下面看什么」 + 的全局开关,分成两张卡会白占一整块高度和一条分隔线。 */} + +
时间范围 + + 图表指标 + ({ value, label }))} + onChange={setMetric} + testId="metric-tabs" + />
{/* 自定义范围:两个 datetime-local,精确到分钟、按浏览器本地时区解析。 起点晚于终点时提示并暂停取数(见 buildCustomRange)。 */} @@ -576,10 +631,15 @@ export function StatsPage() {
+ {/* 四张主卡:请求数 / Token / 成本 / 耗时——正好对应「用了多少、花了多少、 + 快不快」三个问题。其余指标(输出速度、Credit、成功率)降为下方一条 + 状态条:它们的 hint 太长,占满六张卡会把每张压到 180px、hint 排成六行, + 反而让主指标看不见。 */} } /> + hint={`成功 ${formatNumber(stats?.ok_count)} · 成功率 ${rateText}`} + icon={} /> } /> } + label="成本(估算)" + value={formatMoney(stats?.cost_cny, "CNY")} + hint={`美元 ${formatMoney(stats?.cost_usd, "USD")} · 按 OpenRouter 刊例价折算`} + icon={} /> } /> - } - /> - } - />
-
-

图表指标

- ({ value, label }))} - onChange={setMetric} - testId="metric-tabs" - /> + {/* 次要指标条:输出速度 / Credit / 缓存命中率。用一条分隔条而不是三张卡, + 避免与上面四张主卡抢视觉权重。 */} +
+
+
输出速度
+
+ {formatSpeed(stats?.tps_visible, stats?.tps_estimated)} +
+
+ {stats?.tps === null + ? "该范围内没有可用的生成时间样本" + : `总产出(含思考)${formatSpeed(stats?.tps, stats?.tps_estimated)}`} +
+
+
+
缓存命中率
+
+ {formatCacheRate(cached, stats?.input_tokens)} +
+
+ 命中 {hitText} · 未命中 {missText} +
+
+
+
Credit 消耗
+
+ {formatCredit(stats?.credit, stats?.credit_estimated)} +
+
+ 渠道可选字段;≈ 为推算值 +
+
-
- - {(timeline.data?.points.length ?? 0) === 0 ? ( - 该范围内没有小时汇总数据 - ) : ( - - )} - + 1 ? ( + ({ + value: provider, label: providerLabel(provider), + })), + ]} + onChange={setChartScope} + testId="chart-scope-tabs" + /> + ) : undefined + } + > + {(timeline.data?.points.length ?? 0) === 0 ? ( + 该范围内没有小时汇总数据 + ) : ( + <> + + {/* 渠道构成只在请求量指标下出现(份额需要「请求数」这个分母)。 + 放在主图卡片底部而不是单独一张卡:它只有一行高度,独立成卡会多出 + 标题栏 + 上下内边距 + 外框,把一行内容撑成 123px。 */} + {showShare && ( +
+
+ 渠道构成 + 按请求数占比 +
+ ({ + provider: row.provider, value: row.requests, + }))} + total={(byProvider.data?.providers ?? []).reduce( + (sum, row) => sum + row.requests, 0)} + /> +
+ )} + + )} +
+ + {/* 主图之外的两张辅助图:延迟类指标多一张分位图。 + 单张时必须占满整行——否则 `lg:grid-cols-2` 只放一个子元素, + 右半边整块留白(900–1200px 下尤其明显)。 */} + {/* 辅助图区:延迟类指标并排「分位图 + 模型趋势」;其余指标只有模型趋势, + 独占整行(否则 `lg:grid-cols-2` 只放一个子元素,右半边整块留白)。 */} +
+ {/* 延迟类指标:主图之外再给一张分位图(P50 中位数 + P95 上界)。 + 均值会摊平长尾,分位才看得出「有多少请求在受苦」。 */} + {LATENCY_METRICS.has(metric) && ( + + P50 中位数 · P95 上界 · 成功请求 + + }> + {(latencyPercentile.data?.points.length ?? 0) === 0 ? ( + 该范围内没有可用的延迟样本 + ) : ( + + )} + + )} - 请求量 Top {modelTimeline.data.models.length} 模型 + 请求量 Top {Math.min(3, modelTimeline.data.models.length)} 模型 ) : undefined }> {(modelTimeline.data?.points.length ?? 0) === 0 ? ( 该范围内没有小时汇总数据 ) : ( - + <> + + + )}
@@ -847,29 +992,34 @@ export function StatsPage() { )} -

- {groupKey === "credential_id" - ? "凭证分组取自逐请求明细(仅保留 90 天),无凭证的预热失败等请求不计入。" - : "各维度数据源为小时汇总(永久保留),最近 5 分钟内的请求可能尚未计入。"} - {" "}点任意一行可钻取到下方「请求明细」。 -

- {/* 延迟列的两套口径必须写明:均值永久、分位只覆盖 90 天, - 混在同一行里不说明会被读成「同一个范围」。 */} -

- 延迟列只统计成功请求。平均耗时 / 平均首字取自小时汇总(与总览卡片同口径); - 分位为第 P50/P90/P95/P99 百分位(向上取整名次,默认 P95),取自逐请求明细, - 只覆盖最近 90 天。速度列两个分子分母相同(生成时间总和,不是逐条平均),正文 - token(output − reasoning)贴近屏幕上看到的刷新速度,总产出 token(含思考) - 含思考成批吐出的部分;成功请求只要有产出就计入(不设输出量下限),并剔除生成 - 窗口 <200ms 或单条 >1000 tok/s 的样本——那是上游把内容攒成一批吐出、生成 - 时长根本不可测,不是「很快」。无首字数据时按端到端耗时估算(同样不设输出门槛, - 分母含首字等待,前置 ≈),同样取自逐请求明细(仅 90 天)。 -

+ +

+ {groupKey === "credential_id" + ? "凭证分组取自逐请求明细(仅保留 90 天),无凭证的预热失败等请求不计入。" + : "各维度数据源为小时汇总(永久保留),最近 5 分钟内的请求可能尚未计入。"} + {" "}点任意一行可钻取到下方「请求明细」。 +

+ {/* 延迟列的两套口径必须写明:均值永久、分位只覆盖 90 天, + 混在同一行里不说明会被读成「同一个范围」。 */} +

+ 延迟列只统计成功请求。平均耗时 / 平均首字取自小时汇总(与总览卡片同口径); + 分位为第 P50/P90/P95/P99 百分位(向上取整名次,默认 P95),取自逐请求明细, + 只覆盖最近 90 天。速度列两个分子分母相同(生成时间总和,不是逐条平均),正文 + token(output − reasoning)贴近屏幕上看到的刷新速度,总产出 token(含思考) + 含思考成批吐出的部分;成功请求只要有产出就计入(不设输出量下限),并剔除生成 + 窗口 <200ms 或单条 >1000 tok/s 的样本——那是上游把内容攒成一批吐出、生成 + 时长根本不可测,不是「很快」。无首字数据时按端到端耗时估算(同样不设输出门槛, + 分母含首字等待,前置 ≈),同样取自逐请求明细(仅 90 天)。 +

+
{/* 失败归因(operator+):跨用户看「哪里在坏」。点行 → 钻取到该渠道+模型 - 的失败明细(ok=0),与下方「请求明细」联动。 */} - {session.is_operator && ( + 的失败明细(ok=0),与下方「请求明细」联动。 + 无失败时整张卡不渲染:一张只有「该范围内没有失败请求」的空卡仍占 239px + (标题栏 + 空态 + 口径说明),而「没有失败」本身就是好消息,不必占版面。 + 错误态仍要显示,否则加载失败会被误读成「零失败」。 */} + {session.is_operator && (errors.isError || (errors.data?.errors.length ?? 0) > 0) && ( 按渠道 / 模型 / 错误类型分组 · 取自逐请求明细(仅 90 天) @@ -881,8 +1031,6 @@ export function StatsPage() { 失败归因加载失败,请刷新重试;若刚升级服务,请确认后端已重启加载新端点。
- ) : (errors.data?.errors.length ?? 0) === 0 ? ( - 该范围内没有失败请求 ) : ( @@ -950,12 +1098,12 @@ export function StatsPage() {
)} -

+ 窗口缺省为最近 24 小时(与上方时间范围一致时按所选范围统计); 未上报错误类型的历史行显示为「失败」。失败数是尝试口径: 换号重试每次上游拒绝都记一行,「未救回」是未被救回的失败尝试数 (同一请求多次失败会算多次;同请求另有成功行即算救回)。点任意一行可钻取到下方「请求明细」(只看失败)。 -

+
)} @@ -1041,8 +1189,8 @@ export function StatsPage() { {row.username} )} {sourceCell(row)} - - {row.model} + + {row.model} {statusCell(row)} {tokenCell(row)} @@ -1073,7 +1221,11 @@ export function StatsPage() { 该范围内没有请求明细 ) : ( <> - + {/* 明细表列多(时间/用户/渠道凭证/模型/状态/Token/Credit/成本/延迟/速度), + 默认单元格内边距在 1280px 下会让表比容器宽十几像素、逼出横向滚动。 + 这里收窄到 px-1.5:省下的正是最后两列被裁掉的宽度。 */} +
{/* 速度合并列:正文 / 总产出(逐行按生成窗口算,无首字数据的 - 请求不回落估算,直接 —)。列头下拉可选子列排序。 */} - @@ -1128,8 +1282,8 @@ export function StatsPage() { {row.username} )} {sourceCell(row)} - - {row.model} + + {row.model} {statusCell(row)} @@ -1173,25 +1327,36 @@ export function StatsPage() { setPageIndex(next); }} /> -

+ 速度 = 输出 token ÷ 生成时间(耗时 − 首字):左侧正文(不含思考)、右侧总产出 (含思考),与分组表同规则(有输出 token、生成窗口 ≥200ms、单条 ≤1000 tok/s)。 逐行不按端到端耗时估算——单条样本摊不平首字等待,故无生成窗口的请求显示 —。 -

+ )} - -

- 隐私:不保存提示词、回答、请求头、Token、工具参数与原始错误体;逐请求明细保留 90 天,小时汇总永久保留;按 API Key 归属用户统计。 -

-

- credit 为渠道可选字段,经常不返回;TRAE 上游不给单请求积分,带 ≈ 的数值是按官方单价折算(CodeBuddy 为上游返回的真值)。健康度只依赖额度探测接口,主指标是 token 数。缓存命中率 = 命中 token ÷ 输入 token,仅在上报过缓存时展示。 -

-

- 成本为估算:按 OpenRouter 的模型刊例价(USD / 百万 token)与本服务写入时生效的美元汇率折算,展示人民币、括号内为美元;不是上游真实扣费,未匹配到定价的模型不计入(显示 —)。汇率可在「任务与配置」页热更,仅影响之后写入的请求,历史成本不重算。 -

+ + + + + 隐私与统计口径 + + 不保存提示词与回答 · 明细保留 90 天 · 成本为刊例价估算 + + + +

+ 隐私:不保存提示词、回答、请求头、Token、工具参数与原始错误体;逐请求明细保留 90 天,小时汇总永久保留;按 API Key 归属用户统计。 +

+

+ credit 为渠道可选字段,经常不返回;TRAE 上游不给单请求积分,带 ≈ 的数值是按官方单价折算(CodeBuddy 为上游返回的真值)。健康度只依赖额度探测接口,主指标是 token 数。缓存命中率 = 命中 token ÷ 输入 token,仅在上报过缓存时展示。 +

+

+ 成本为估算:按 OpenRouter 的模型刊例价(USD / 百万 token)与本服务写入时生效的美元汇率折算,展示人民币、括号内为美元;不是上游真实扣费,未匹配到定价的模型不计入(显示 —)。汇率可在「任务与配置」页热更,仅影响之后写入的请求,历史成本不重算。 +

+
+
); diff --git a/web/src/pages/__tests__/StatsPage.errors.test.tsx b/web/src/pages/__tests__/StatsPage.errors.test.tsx index 46edbbb3..bb011532 100644 --- a/web/src/pages/__tests__/StatsPage.errors.test.tsx +++ b/web/src/pages/__tests__/StatsPage.errors.test.tsx @@ -103,12 +103,14 @@ describe("StatsPage 失败归因", () => { }); }); - it("没有失败时给空态,不渲染表格", async () => { + it("没有失败时整张卡不渲染(不给空态占版面)", async () => { mockFetch(statsRoutes({ errors: [] })); renderPage(, ADMIN); await settle(); - expect(screen.getByTestId("no-errors")).toHaveTextContent("没有失败请求"); + // 「零失败」不必占一张卡:空态 + 标题栏 + 口径说明约 239px, + // 把真正的数据推到更下面。无失败时整块隐藏。 + expect(screen.queryByTestId("error-panel")).not.toBeInTheDocument(); expect(screen.queryByTestId("error-table")).not.toBeInTheDocument(); }); diff --git a/web/src/pages/__tests__/StatsPage.live.test.tsx b/web/src/pages/__tests__/StatsPage.live.test.tsx index 44dab639..fffefc84 100644 --- a/web/src/pages/__tests__/StatsPage.live.test.tsx +++ b/web/src/pages/__tests__/StatsPage.live.test.tsx @@ -51,7 +51,7 @@ describe("StatsPage 实时明细", () => { // 实时行与分页明细表同列:速度按广播行自带的 tps / tps_visible 渲染 const liveTable = screen.getByTestId("events-live-table"); expect(liveTable).toHaveTextContent("速度"); - expect(liveTable).toHaveTextContent("51.2 / 82.8 tok/s"); + expect(liveTable).toHaveTextContent("51.2 / 82.8"); // 时间两行(时刻优先、日期在下)、渠道/凭证合并列:实时表与分页表同一套渲染 const liveCell = screen.getAllByTestId("live-row")[0] .querySelector("[data-label='时间']")!; diff --git a/web/src/pages/__tests__/StatsPage.test.tsx b/web/src/pages/__tests__/StatsPage.test.tsx index a4d6a4d1..347f131a 100644 --- a/web/src/pages/__tests__/StatsPage.test.tsx +++ b/web/src/pages/__tests__/StatsPage.test.tsx @@ -11,8 +11,10 @@ import { MODEL_TIMELINE, MODELS, OVERVIEW, + PERCENTILE_TIMELINE, PROVIDERS, READER, + TIMELINE, USERS, } from "@/test-utils/stats"; @@ -37,14 +39,16 @@ describe("StatsPage", () => { expect(screen.getByTestId("stats-page")).toHaveTextContent("120"); // 成功率并入请求数卡片 hint;Token 三项并入 Token 消耗卡片 - expect(screen.getByText("成功率 95.0%")).toBeInTheDocument(); + expect(screen.getByText("成功 114 · 成功率 95.0%")).toBeInTheDocument(); expect(screen.getByText(/输入 1000(命中 400 · 未命中 600) · 输出 2000 · 推理 300 · 缓存命中率 40.0%/)) .toBeInTheDocument(); expect(screen.getByText(/首字延迟 220 ms/)).toBeInTheDocument(); - // 输出速度卡片:正文 token ÷ 生成时间总和(非逐条平均),总产出在 hint 里 - const speedCard = screen.getByText("输出速度").closest("[data-slot=card]")!; + // 输出速度:正文 token ÷ 生成时间总和(非逐条平均),总产出在副行里 + const speedCard = screen.getByTestId("stat-speed"); expect(speedCard).toHaveTextContent("52.0 tok/s"); expect(speedCard).toHaveTextContent("总产出(含思考)94.1 tok/s"); + // 缓存命中率单列一格(P1 新增指标) + expect(screen.getByTestId("stat-cache")).toHaveTextContent("40.0%"); expect(screen.getByTestId("group-table")).toHaveTextContent("CodeBuddy"); expect(screen.getByTestId("group-table")).toHaveTextContent("TRAE"); }); @@ -108,7 +112,7 @@ describe("StatsPage", () => { const row = within(table).getAllByText("TRAE")[0].closest("tr")!; expect(row).toHaveTextContent("—"); // 总览未探测到 credit - expect(screen.getByText("Credit 消耗").closest("[data-slot=card]")).toHaveTextContent("—"); + expect(screen.getByTestId("stat-credit")).toHaveTextContent("—"); }); it("推算 credit 显示 ≈ 前缀(TRAE 无上游积分)", async () => { @@ -125,7 +129,7 @@ describe("StatsPage", () => { await settle(); // 总览卡片与按渠道表对推算值都标 ≈ - expect(screen.getByText("Credit 消耗").closest("[data-slot=card]")).toHaveTextContent("≈5.25"); + expect(screen.getByTestId("stat-credit")).toHaveTextContent("≈5.25"); const table = screen.getByTestId("group-table"); const traeRow = within(table).getAllByText("TRAE")[0].closest("tr")!; expect(traeRow).toHaveTextContent("≈5.25"); @@ -204,7 +208,7 @@ describe("StatsPage", () => { expect(screen.getByText("请求数").closest("[data-slot=card]")).toHaveTextContent("成功率 —"); expect(screen.getByText("平均耗时").closest("[data-slot=card]")).toHaveTextContent("首字延迟 —"); // 速度无样本:显示 —,并在 hint 里说明「为什么没有」而不是留白 - const speedCard = screen.getByText("输出速度").closest("[data-slot=card]")!; + const speedCard = screen.getByTestId("stat-speed"); expect(speedCard).toHaveTextContent("—"); expect(speedCard).toHaveTextContent("没有可用的生成时间样本"); expect(screen.getByTestId("no-group-stats")).toBeInTheDocument(); @@ -289,6 +293,132 @@ describe("StatsPage", () => { expect(screen.getByTestId("metric-tabs-cost")).toHaveAttribute("aria-selected", "true"); }); + it("新增派生指标可选:成功率 / 缓存命中率 / 单请求成本", async () => { + const fetchSpy = mockFetch({ + "/api/stats/overview": OVERVIEW, + "/api/stats/by-provider": PROVIDERS, + "/api/stats/by-model": MODELS, + "/api/stats/by-user": USERS, + "/api/stats/by-credential": CREDENTIALS, + "/api/stats/model-timeline": MODEL_TIMELINE, + "/api/stats/timeline": TIMELINE, + "/api/stats/events": EVENTS_EMPTY, + }); + renderPage(, READER); + await settle(); + fetchSpy.mockClear(); + + for (const metric of ["success_rate", "cache_rate", "cost_per_request"]) { + await userEvent.click(screen.getByTestId(`metric-tabs-${metric}`)); + await waitFor(() => + expect(fetchSpy.mock.calls.some(([url]) => + String(url).includes(`metric=${metric}`))).toBe(true), + ); + } + }); + + it("主图默认画全部渠道合计,可切到单个渠道", async () => { + mockFetch({ + "/api/stats/overview": OVERVIEW, + "/api/stats/by-provider": PROVIDERS, + "/api/stats/by-model": MODELS, + "/api/stats/by-user": USERS, + "/api/stats/by-credential": CREDENTIALS, + "/api/stats/model-timeline": MODEL_TIMELINE, + "/api/stats/timeline": TIMELINE, + "/api/stats/events": EVENTS_EMPTY, + }); + renderPage(, READER); + await settle(); + + // 渠道数 > 1 时出现切换条,默认选中「全部渠道」 + expect(screen.getByTestId("chart-scope-tabs-total")) + .toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("usage-chart-legend")).toHaveTextContent("全部渠道"); + + await userEvent.click(screen.getByTestId("chart-scope-tabs-trae")); + await waitFor(() => + expect(screen.getByTestId("usage-chart-legend")).toHaveTextContent("TRAE"), + ); + }); + + it("延迟类指标额外展示分位图(P50 中位数 + P95 上界)", async () => { + mockFetch({ + "/api/stats/overview": OVERVIEW, + "/api/stats/by-provider": PROVIDERS, + "/api/stats/by-model": MODELS, + "/api/stats/by-user": USERS, + "/api/stats/by-credential": CREDENTIALS, + "/api/stats/model-timeline": MODEL_TIMELINE, + "/api/stats/timeline": TIMELINE, + "/api/stats/percentile-timeline": PERCENTILE_TIMELINE, + "/api/stats/events": EVENTS_EMPTY, + }); + renderPage(, READER); + await settle(); + + // 默认请求次数指标下不展示分位图 + expect(screen.queryByTestId("latency-chart")).toBeNull(); + + await userEvent.click(screen.getByTestId("metric-tabs-latency")); + await waitFor(() => expect(screen.getByTestId("latency-chart")).toBeInTheDocument()); + expect(screen.getByTestId("latency-chart-legend")).toHaveTextContent("P50 中位数"); + expect(screen.getByTestId("latency-chart-legend")).toHaveTextContent("P95 上界"); + }); + + it("请求量指标下展示渠道构成(按请求数占比)", async () => { + mockFetch({ + "/api/stats/overview": OVERVIEW, + "/api/stats/by-provider": PROVIDERS, + "/api/stats/by-model": MODELS, + "/api/stats/by-user": USERS, + "/api/stats/by-credential": CREDENTIALS, + "/api/stats/model-timeline": MODEL_TIMELINE, + "/api/stats/timeline": TIMELINE, + "/api/stats/events": EVENTS_EMPTY, + }); + renderPage(, READER); + await settle(); + + // 70 / (70+50) = 58.3% + const share = screen.getByTestId("provider-share"); + expect(share).toHaveTextContent("TRAE"); + expect(share).toHaveTextContent("58.3%"); + + // 换到非请求量指标后不展示(份额没有同一个分母) + await userEvent.click(screen.getByTestId("metric-tabs-latency")); + await waitFor(() => expect(screen.queryByTestId("provider-share")).toBeNull()); + }); + + it("分组表点行下钻到该渠道的明细(键盘同样可触发)", async () => { + const fetchSpy = mockFetch({ + "/api/stats/overview": OVERVIEW, + "/api/stats/by-provider": PROVIDERS, + "/api/stats/by-model": MODELS, + "/api/stats/by-user": USERS, + "/api/stats/by-credential": CREDENTIALS, + "/api/stats/model-timeline": MODEL_TIMELINE, + "/api/stats/timeline": TIMELINE, + "/api/stats/events": EVENTS_EMPTY, + }); + renderPage(, READER); + await settle(); + fetchSpy.mockClear(); + + // 点击:明细请求带 provider 过滤 + await userEvent.click(screen.getByLabelText("查看 trae 的请求明细")); + await waitFor(() => expect(fetchSpy.mock.calls.some(([url]) => + String(url).includes("provider=trae"))).toBe(true)); + + // 键盘:Enter 触发同一下钻(不只是鼠标可点) + fetchSpy.mockClear(); + const cell = screen.getByLabelText("查看 codebuddy 的请求明细"); + cell.focus(); + await userEvent.keyboard("{Enter}"); + await waitFor(() => expect(fetchSpy.mock.calls.some(([url]) => + String(url).includes("provider=codebuddy"))).toBe(true)); + }); + it("请求明细:成本列显示人民币,缺失显示 —", async () => { mockFetch({ "/api/stats/overview": OVERVIEW, @@ -344,11 +474,14 @@ describe("StatsPage", () => { await settle(); const table = screen.getByTestId("events-table"); + // 单位以小字缀在列头(tok/s),行内只留数字 expect(within(table).getByText("速度")).toBeInTheDocument(); + expect(within(table).getByText("tok/s")).toBeInTheDocument(); // 成功行:后端算好的 正文 51.2 / 总产出 82.8 tok/s;悬停展开口径 const okRow = within(table).getByText("glm-5.2").closest("tr")!; - expect(okRow).toHaveTextContent("51.2 / 82.8 tok/s"); - await userEvent.hover(within(okRow).getByText("51.2 / 82.8 tok/s")); + // 单位在列头(tok/s),行内只留数字——10 列的表每行重复单位要多占 45px + expect(okRow).toHaveTextContent("51.2 / 82.8"); + await userEvent.hover(within(okRow).getByText("51.2 / 82.8")); expect(await screen.findByRole("tooltip")).toHaveTextContent("总产出(含思考)82.8 tok/s"); // 失败行没有输出 → 生成窗口不可测,整格 — const failedRow = within(table).getByText("额度耗尽").closest("tr")!; diff --git a/web/src/test-utils/stats.ts b/web/src/test-utils/stats.ts index 6c72cb12..8ea0fb71 100644 --- a/web/src/test-utils/stats.ts +++ b/web/src/test-utils/stats.ts @@ -60,6 +60,24 @@ export const MODEL_TIMELINE = { ], }; +/** 主图时间序列:渠道为动态键 + 后端算好的 `__total` 全渠道合计。 */ +export const TIMELINE = { + points: [ + { hour: 1700000000, __total: 3, trae: 2, codebuddy: 1 }, + { hour: 1700003600, __total: 7, trae: 4, codebuddy: 3 }, + ], +}; + +/** 延迟分位时序:P50 中位数 + P95 上界(读明细表,仅 90 天)。 */ +export const PERCENTILE_TIMELINE = { + metric: "latency", + window_days: 90, + points: [ + { hour: 1700000000, p50: 1200, p95: 5200, samples: 40 }, + { hour: 1700003600, p50: 2400, p95: 9800, samples: 12 }, + ], +}; + export const EVENTS_EMPTY = { events: [], next_before: null }; export const BASE_TS = 1_700_000_000; diff --git a/web/src/ui/index.tsx b/web/src/ui/index.tsx index 043365aa..ceb72535 100644 --- a/web/src/ui/index.tsx +++ b/web/src/ui/index.tsx @@ -10,6 +10,11 @@ import { CardHeader, } from "@/components/ui/card"; import { Alert as ShadAlert } from "@/components/ui/alert"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { Label, @@ -102,13 +107,15 @@ export function Panel({ return ( {(title || action) && ( - + // flex-wrap + basis:窄屏下标题与操作区各占一行,而不是把标题挤成 + // 「指标趋 / 势」两行(CardHeader 的 grid 在极窄容器里会压标题列)。 + {title && ( -

+

{title}

)} - {action} + {action && {action}}
)} {children} @@ -262,7 +269,11 @@ export function Tabs({
{options.map((option) => (