Skip to content

Commit 606eba1

Browse files
committed
fix(codebuddy_intl): 补首条 system prompt,国际站 11128 修复
国际站网关硬性要求 messages[0] 是 system prompt,否则整单 400 + {"code":11128,"msg":"first message is not system prompt"}。 国内站无此要求,存量凭证不带 system 也正常出站。 2026-10-12 用真实国际站凭证对照实测(同请求体): 无 system -> 400 / 11128 前置中性 system -> 200 现象链(usage_events 与日志时间戳逐秒对齐): 上游 400 -> executor 判 kind=request 换号 -> 国际站池里只有 1 条凭证 -> 轮换立即耗尽 -> 503 no_healthy_credential -> 前端显示「无可用凭证」。 gpt-5.6-luna 三次失败(17:38:40 / 18:06:02 / 18:47:10)与同秒的 11128 日志完全对应;deepseek-v4.1-flash 当时能通是因为那几次请求自带 system。所以「无可用凭证」与「渠道风控」是同一 bug 的两张脸,不是翻墙 问题(国际站出口本身在境外,gpt-5.6-sol / 5.5 / luna 实测全部 200)。 改动: - client: 新增 ensure_leading_system() 与 DEFAULT_LEADING_SYSTEM; 构造参数 require_leading_system(默认关) - main: 仅国际站装配时置 require_leading_system=True;国内站出站 body 一个字节不变(挡住「为让国际站通而全局注入」) 三条约束: 1. 判首条必须在 developer->system 归一之后(PI 等客户端把 system 转成 developer,归一已满足要求,再补就成了重复首条) 2. 客户端自带 system 时不注入、不覆盖、不重排(system 正文与位置是 客户端的意图) 3. 正文取中性最小句且不带厂商指纹,否则撞 11128 内容风控 注入只改 deepcopy 出的出站副本,不穿透 request.raw(会话粘性按原文 算指纹,穿透会让粘性静默失效)。 测试 +15:国际站补/不补各形态(含 developer 归一不重复)、国内站出站 body 不变、注入不穿透、不可用 message 形态一律不动、装配层两渠道开关 取值。行/分支覆盖率仍 100%(2381 passed)。 同步记入 TECHNICAL §3.17b 与 PROPOSAL Q76,并记下国际站签到实测报 「签到活动未开启或已过期」(路径存在,属账号侧活动未开启,不改代码)。
1 parent a7ee8c8 commit 606eba1

6 files changed

Lines changed: 191 additions & 5 deletions

File tree

‎PROPOSAL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,4 +365,4 @@ M0 骨架 → M1a TRAE → M1b CB 基础 → M1.5 CB 完整化 → M2 前端 →
365365
| Q73 | 竞品扫描 Round 1/2 的 P0 两项修复:Anthropic 出口缓存 token + Responses `text.format` | 用户要求落地扫描(`docs/competitor-comparison-2026-10-09.md`)里两个「静默丢字段」P0。① **Anthropic 出口补缓存字段**(`compat/anthropic/response.py`):流式 `message_delta` 与非流式 `completion_to_message` 此前只回 `input_tokens`/`output_tokens`,命中缓存的输入被吞掉,Claude Code 缓存统计恒为 0。修法按 Anthropic 语义**拆两笔**——内部 `Usage.input_tokens` 是 OpenAI 口径的 `prompt_tokens`(含命中),故命中时 `input_tokens = max(0, prompt_tokens − cached_tokens)` 另加 `cache_read_input_tokens = cached_tokens`(两者互斥,不减会重复计费);命中为 0/None 不补占位。非流式新增 `_completion_usage` 从 `prompt_tokens_details.cached_tokens` 取命中。② **Responses `text.format` → chat `response_format`**(`compat/responses/request.py`):此前只取 `verbosity`、`format` 整块丢弃,Codex 类结构化输出被静默吞掉。新增 `_map_response_format`:`text`/`json_object` 直通;`json_schema` 按官方 openai-python 形状把扁平 `{type,name,description?,schema,strict?}` 展开成 chat 的嵌套 `{type, json_schema:{...}}`;只有 verbosity 无 format 时不产生 `response_format`;非 dict / 未知 type / json_schema 缺 name 或 schema **显式 400**。**无 schema / DB / 配置变更**。详见 [TECHNICAL.md §3.18](TECHNICAL.md) |
366366
| Q74 | 模型候选只信模型目录缓存列表:收窄渠道不可用不再扇出到未登记渠道(推翻 Q21「回退全量候选」) | 实测故障(2026-10-10):请求 `longcat-2.5-preview`,模型目录里只有 zen 登记它(原代号 `longcat-2.5-preview-free`),zen 匿名免费层 429(kind=SOFT)让虚拟凭证进 60s 账号级冷却。此后旧逻辑每次请求都在 `executor._select` 触发 broader 回退,扇出到 CB/TRAE/kilo/CodeArts——别名表里没有这个键,外发仍是原始名,必然被拒:CodeBuddy 11102 / TRAE 4001 / kilo 401 / CodeArts 404,客户端拿到误导性的 400 `invalid_request` 而非「该渠道暂时不可用」,还给 CodeBuddy 写下 6 小时起步的 (凭证, 模型) 负缓存(`usage_events` 里同一模型同时出现 kilo/codearts/codebuddy/trae 的失败记录)。**修复:删除 `_select` 的 broader 回退**,候选渠道只取 `_narrow_providers`(模型目录缓存列表)的结果——登记了该模型的渠道全部不可用时直接出 503,不再拿请求逐个渠道试错。Q67 的「纯模型冷却不扇出」判定(`_model_cooled_only`)随之只服务 503 文案分流。**为什么不会漏掉真正持有该模型的渠道**:目录证不了归属时(模型不在任何渠道的登记表)`_narrow_providers` 本就放行全部候选;而目录证得了归属时,新鲜度上限是模型列表 TTL 300s + 后台兜底刷新(`MODEL_CATALOG_MINUTES`),最坏 300s 后新渠道就会进候选。代价:目录陈旧窗口内「某渠道新增了该模型但别名表未更新」的场景由宽变严(出 503 而非打到别的渠道),但那本来也是打不通的。无 schema / 配置变更。详见 [TECHNICAL.md §6.1](TECHNICAL.md) |
367367
| Q75 | 管理台列表排序:所有列表加可点列头排序(后端排序为主) | 用户需求:各列表顺序原由 SQL 写死,无法按「剩余额度最多 / 消耗最多 / 最近使用」等自己关心的维度查看。**契约**(新模块 `src/sorting.py`,db 与 api 共用,避免 db → api 反向依赖):所有 `GET` 列表端点接受统一 `sort`(白名单键)+ `order`(`asc`/`desc`);**未知 `sort` 键 / 非法 `order` 值回落到该端点默认,不报错**;`sort` 是白名单键、SQL 片段只由受控常量拼出,用户输入永不进 SQL。`parse_sort_order` 是唯一解析入口,`sql_order(tiebreak=...)` 生成 `ORDER BY`(`id`/`rowid` 兜底稳定次序,防翻页跳行),`sort_rows` 供组装后排序(凭证 / 模型目录含派生字段,`None` 值无论升降序都排最后)。**改动点**:仓储 / 查询方法新增**可选** `sort`/`order`,缺省值与旧行为一致(不传参完全不变,`balance.py` 等不受影响)。**默认顺序变更**:分组统计由「分组键字母序」改为**「请求数降序」**(并列时按分组键升序,与旧行为一致)。**逐请求明细两套分页**:默认(`time` 降序)仍走 rowid 游标、返回 `next_before`(`total` null);按其它列 / 时间升序 / 显式 `offset` 时改 `LIMIT/OFFSET`、额外返回 `total`,此时忽略 `before`(游标只对 rowid 成立;明细只留 90 天,不做复合 keyset 游标)。**前端**:`hooks/useSort.ts`(排序状态 + 每列首次点击方向)+ `components/SortableHead.tsx`(箭头 + `aria-sort`,`ColumnHint` 置于按钮外防误触),切排序 / 改每页数量 / 切范围回到第一页。**不做的**:服务端持久化排序偏好(仅页面内状态)。**无 schema / DB / 配置变更**。详见 [TECHNICAL.md §3.24](TECHNICAL.md) |
368-
| Q76 | CodeBuddy 国际版(第七渠道 `codebuddy_intl`) | 把 CodeBuddy 国际站 [www.codebuddy.ai](https://www.codebuddy.ai) 接成第七个 provider(`KNOWN_PROVIDERS` 加 `"codebuddy_intl"`,**无 schema 变更**)。**与国内站同协议**(同一 Keycloak realm `copilot`、同一组 `/v2/chat/completions` / `/v3/config` / `/v2/plugin/auth/*` / `/v2/billing/*` / `/billing/meter/*` 路径),2026-10-12 匿名探针在两站得到一致响应形态,故 `src/provider/codebuddy/intl.py` 只是 `CodeBuddyProvider` 的数据类子类(覆盖渠道 id 与三项能力),协议解析 / 凭证规范化 / 事件分类 / 签到 / 刷新全部复用国内站代码,**零复制**。**为什么不是「改 `CODEBUDDY_API_ENDPOINT` 切站点」**:endpoint 是进程级单值,切过去会让所有存量国内站凭证立刻打到国际站(两站各自注册、各自计费,表现为额度错配或 403)。两站凭证可同时在池里,各走各的端点、pacer 与设备码登录流程(`_upstream_auth` 对两渠道各建一套 `CodeBuddyOAuth`)。**新增配置**:`CODEBUDDY_INTL_API_ENDPOINT`(默认 `https://www.codebuddy.ai`)、`CODEBUDDY_INTL_ALLOWED_ENDPOINTS`(**刻意不含**国内站主机——两站 Token 由不同网关签发,跨站发送即凭证泄露;把国内站地址填进端点会在启动时抛错而非悄悄发出去)、`CODEBUDDY_INTL_CHAT_MIN_INTERVAL`(热更、默认 5s、**独立 pacer**,共用会让两站请求排进同一条队列互相空等);`_pacer_key` 改为带渠道 id 构造。**能力边界**:聊天 / 模型列表 / 额度探测 / 签到 / 刷新全部启用;**成长中心与活跃上报关闭**——国际站把整个 `/v2/activity/*` 前缀挂在认证插件后(实测 `/v2/activity/nope` 也回 401 而非 404),无法证明子路由存在,而成长中心含抽奖 / 连登兑换 / 盲盒等**不可逆**动作,未验证端点上盲跑会真实消耗账号权益。处置是 `growth`/`activity`/`activity_scope` 置 `None`——调用方本就 `getattr(..., None)` 探测能力,置 None 即自动跳过,无需在任务层加渠道分支。**未核实**:国际站成长中心是否真的不存在(判据如上,只能证明「无法验证」);国际站额度包与国内站的换算关系。**无 schema / DB 变更**。详见 [TECHNICAL.md §3.17b](TECHNICAL.md) |
368+
| Q76 | CodeBuddy 国际版(第七渠道 `codebuddy_intl`) | 把 CodeBuddy 国际站 [www.codebuddy.ai](https://www.codebuddy.ai) 接成第七个 provider(`KNOWN_PROVIDERS` 加 `"codebuddy_intl"`,**无 schema 变更**)。**与国内站同协议**(同一 Keycloak realm `copilot`、同一组 `/v2/chat/completions` / `/v3/config` / `/v2/plugin/auth/*` / `/v2/billing/*` / `/billing/meter/*` 路径),2026-10-12 匿名探针在两站得到一致响应形态,故 `src/provider/codebuddy/intl.py` 只是 `CodeBuddyProvider` 的数据类子类(覆盖渠道 id 与三项能力),协议解析 / 凭证规范化 / 事件分类 / 签到 / 刷新全部复用国内站代码,**零复制**。**为什么不是「改 `CODEBUDDY_API_ENDPOINT` 切站点」**:endpoint 是进程级单值,切过去会让所有存量国内站凭证立刻打到国际站(两站各自注册、各自计费,表现为额度错配或 403)。两站凭证可同时在池里,各走各的端点、pacer 与设备码登录流程(`_upstream_auth` 对两渠道各建一套 `CodeBuddyOAuth`)。**新增配置**:`CODEBUDDY_INTL_API_ENDPOINT`(默认 `https://www.codebuddy.ai`)、`CODEBUDDY_INTL_ALLOWED_ENDPOINTS`(**刻意不含**国内站主机——两站 Token 由不同网关签发,跨站发送即凭证泄露;把国内站地址填进端点会在启动时抛错而非悄悄发出去)、`CODEBUDDY_INTL_CHAT_MIN_INTERVAL`(热更、默认 5s、**独立 pacer**,共用会让两站请求排进同一条队列互相空等);`_pacer_key` 改为带渠道 id 构造。**能力边界**:聊天 / 模型列表 / 额度探测 / 签到 / 刷新全部启用;**成长中心与活跃上报关闭**——国际站把整个 `/v2/activity/*` 前缀挂在认证插件后(实测 `/v2/activity/nope` 也回 401 而非 404),无法证明子路由存在,而成长中心含抽奖 / 连登兑换 / 盲盒等**不可逆**动作,未验证端点上盲跑会真实消耗账号权益。处置是 `growth`/`activity`/`activity_scope` 置 `None`——调用方本就 `getattr(..., None)` 探测能力,置 None 即自动跳过,无需在任务层加渠道分支。**唯一的行为差异:首条 system 强制注入**——国际站网关要求 `messages[0]` 是 system prompt,否则整单 400 + `{"code":11128,"msg":"first message is not system prompt"}`(真实国际站凭证对照实测:无 system → 400,前置中性 system → 200);国内站无此要求,故 client 的 `require_leading_system` 只在国际站装配时开,判首条在 `developer→system` 归一**之后**,客户端自带 system 时不注入不覆盖。**未核实**:国际站成长中心是否真的不存在(判据如上,只能证明「无法验证」);国际站额度包与国内站的换算关系;国际站签到报「签到活动未开启或已过期」(路径存在,属账号侧活动未开启,非渠道缺陷,不改代码)。**无 schema / DB 变更**。详见 [TECHNICAL.md §3.17b](TECHNICAL.md) |

‎TECHNICAL.md‎

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

582582
**为什么是独立渠道而不是「改 `CODEBUDDY_API_ENDPOINT` 切站点」**:endpoint 在本项目是**进程级单值**(`Settings` 单字段,装配时注入一个 client)。切过去会让所有存量国内站凭证立刻打到国际站——两站是各自注册的账号体系,额度与模型清单都不通用,表现为额度错配或 403。国际站作为并列渠道存在,两站凭证可同时在池里,各走各的端点、pacer 与登录流程。
583583

584-
**协议零复制**:两站是**同一套后端**——同一 Keycloak realm(`copilot`)、同一组路径(`/v2/chat/completions`、`/v3/config`、`/v2/plugin/auth/*`、`/v2/billing/*`、`/billing/meter/*`),2026-10-12 匿名探针在两站得到完全一致的响应形态。故 `CodeBuddyIntlProvider` 是 `CodeBuddyProvider` 的**数据类子类**(`src/provider/codebuddy/intl.py`,9 行),只覆盖渠道 id 与三项能力;协议解析、凭证规范化、事件分类、签到、刷新全部共用国内站代码。
584+
**协议零复制**:两站是**同一套后端**——同一 Keycloak realm(`copilot`)、同一组路径(`/v2/chat/completions`、`/v3/config`、`/v2/plugin/auth/*`、`/v2/billing/*`、`/billing/meter/*`),2026-10-12 匿名探针在两站得到完全一致的响应形态。故 `CodeBuddyIntlProvider` 是 `CodeBuddyProvider` 的**数据类子类**(`src/provider/codebuddy/intl.py`,9 行),只覆盖渠道 id 与三项能力;协议解析、凭证规范化、事件分类、签到、刷新全部共用国内站代码。唯一需要分开的行为是聊天出站的 system 注入(见下)。
585585

586586
**路由存在性的判据(实测方法)**:国际站网关对已注册路径回 `401`(openresty/APISIX 认证插件),对未注册路径回应用层 JSON `{"error_msg":"404 Route Not Found"}`。两者可区分,故能证明 `/billing/meter/daily-checkin`(401)存在而 `/billing/nope`(404)不存在。同法确认 `/v2/config`(回业务码 `12403 check ua`,即已进业务逻辑)、`/v3/config`、`/v2/plugin/auth/state` 均在。
587587

588+
**唯一的行为差异:首条 system 强制注入**。国际站网关要求 `messages[0]` 是 system prompt,否则整单 `400` + `{"code":11128,"msg":"first message is not system prompt"}`(2026-10-12 用真实国际站凭证对照实测:同一请求体,无 system → 400;前置一条中性 system → 200 正常返回)。国内站(`copilot.tencent.com`)无此要求,存量凭证不带 system 也正常出站。处置是 client 构造参数 `require_leading_system`,**只在国际站装配时开**:`ensure_leading_system` 仅在首条非 system 时补一条 `DEFAULT_LEADING_SYSTEM = "You are a helpful assistant."`。三条约束:① 判首条必须在 `developer → system` 归一**之后**(PI 等客户端把 system 转成 developer,归一已满足要求,再补就成了重复首条);② 客户端自带 system 时不注入、不覆盖、不重排(system 正文与位置是客户端的意图);③ 正文取中性最小句且不带任何厂商指纹,否则撞 11128 内容风控。注入只改 deepcopy 出的出站副本,不穿透回 `request.raw`(会话粘性按原文算指纹,见 §3.2)。
589+
588590
**能力边界:成长中心与活跃上报关闭**。国际站把整个 `/v2/activity/*` **前缀**挂在认证插件后——实测 `/v2/activity/nope`(不存在的子路径)也返回 401 而非 404,因此 401 无法证明 `growth/*` 与 `report` 子路由是否存在。而成长中心含抽奖 / 连登兑换 / 盲盒 / 消耗补登卡等**不可逆**动作,在一个未验证的端点上盲跑会真实消耗账号权益。处置:`growth` / `activity` / `activity_scope` 三个属性置 `None`——调用方一律用 `getattr(provider, "growth", None)` 探测能力(`tasks/growth.py`、`tasks/activity.py`),置 `None` 即自动跳过,**无需在任务层加渠道分支**;管理台的「成长中心 / 活跃上报」菜单项本就按 `credential.provider === "codebuddy"` 判定,对本渠道天然不出现。
589591

592+
**签到已知不可用**:国际站登录后 `tasks/checkin` 每 10 分钟失败一次,日志为 `签到活动未开启或已过期`(2026-10-12 持续复现)。路径存在(`/billing/meter/checkin-activity-status` 回业务响应而非 404),但该账号的活动未开启——属账号侧状态,非渠道缺陷,故**不改代码**,失败告警保持现状。
593+
590594
**白名单互不含**:`CODEBUDDY_INTL_ALLOWED_ENDPOINTS` 默认只有 `https://www.codebuddy.ai`,**不含**国内站主机(反之国内站那条白名单虽列了两站,但国际站的解析走自己那条校验、不会误用)。两站 Token 由不同网关签发,跨站发送即凭证泄露,故把国内站地址填进 `CODEBUDDY_INTL_API_ENDPOINT` 会在**启动时**抛 `ValueError`,而不是悄悄发出去。
591595

592596
**pacer 与 OAuth 各自独立**:`CODEBUDDY_INTL_CHAT_MIN_INTERVAL`(热更项,默认 5s)装一个独立 `Pacer`——共用会让两站请求排进同一条队列互相空等最小间隔(与 zen/kilo 不共享同理)。`_upstream_auth` 对两个渠道各建一套 `CodeBuddyOAuth`(登录域名不同)。`_pacer_key` 改为带渠道 id 构造(`_pacer_key(provider_id, credential)`):即便将来共用 Pacer,两站账号也不会互相占位。

‎src/main.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,8 @@ def build_app(settings: Settings | None = None, *, providers: dict | None = None
351351
client=CodeBuddyClient(
352352
endpoint=_codebuddy_intl_endpoint(config),
353353
sanitize_markers=config.codebuddy_sanitize_channel_markers,
354+
# 国际站网关硬性要求首条 system(实测 400/11128),国内站无此要求
355+
require_leading_system=True,
354356
proxy=proxies.get("codebuddy_intl"),
355357
), pacer=codebuddy_intl_pacer),
356358
"zen": ZenProvider(

‎src/provider/codebuddy/client.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,32 @@
5353
)
5454
_CHANNEL_MARKER_STANDIN = "[external-client-identity]"
5555

56+
# 缺省首条 system 的占位正文。国际站网关**硬性要求** messages[0] 是
57+
# system prompt,否则整单 400 / 11128 `first message is not system prompt`
58+
# (2026-10-12 用真实国际站凭证实测:无 system → 400,同请求前置下面这句
59+
# → 200)。国内站(copilot.tencent.com)无此要求,存量凭证不带 system 也能
60+
# 正常出站,所以只在 `require_leading_system=True` 时注入。
61+
# 正文刻意取中性最小句:不带任何厂商指纹(否则撞 11128 内容风控),
62+
# 不覆盖客户端自带的 system(已有则不注入,见 `_ensure_leading_system`)。
63+
DEFAULT_LEADING_SYSTEM = "You are a helpful assistant."
64+
65+
66+
def ensure_leading_system(body: dict[str, Any]) -> bool:
67+
"""messages[0] 非 system 时补一条占位 system,返回是否真的补了。
68+
69+
只在 messages 是非空列表、且首条确实是 dict 时补;首条已是 system
70+
(客户端自带)则原样返回 False——不覆盖、不合并、不重排,原有 system
71+
正文与位置都是客户端的意图。
72+
"""
73+
messages = body.get("messages")
74+
if not isinstance(messages, list) or not messages:
75+
return False
76+
first = messages[0]
77+
if not isinstance(first, dict) or first.get("role") == "system":
78+
return False
79+
messages.insert(0, {"role": "system", "content": DEFAULT_LEADING_SYSTEM})
80+
return True
81+
5682

5783

5884
def _clean_history_tool_calls(body: dict[str, Any]) -> None:
@@ -162,12 +188,14 @@ def __init__(
162188
stream_client: httpx.AsyncClient | None = None,
163189
short_client: httpx.AsyncClient | None = None,
164190
sanitize_markers: bool = True,
191+
require_leading_system: bool = False,
165192
proxy: str | None = None,
166193
) -> None:
167194
self.endpoint = endpoint
168195
self._stream_client = stream_client
169196
self._short_client = short_client
170197
self.sanitize_markers = sanitize_markers
198+
self.require_leading_system = require_leading_system
171199
self._proxy = proxy
172200

173201
@property
@@ -209,6 +237,11 @@ async def stream_chat(self, credential: CodeBuddyCredential, payload: dict[str,
209237
for message in messages:
210238
if isinstance(message, dict) and message.get("role") == "developer":
211239
message["role"] = "system"
240+
# 必须在 developer→system 归一**之后**判首条:客户端带 developer 时
241+
# 归一已把它变成 system,再注入就成了重复首条。国际站网关硬性要求
242+
# 首条为 system(见 DEFAULT_LEADING_SYSTEM 注释),国内站不注入。
243+
if self.require_leading_system:
244+
ensure_leading_system(body)
212245
# 会话正文里的伪装客户端指纹同样触发 11128 内容风控(换号无效),
213246
# 出站前中和;只指出现在 system/assistant 的 content
214247
if self.sanitize_markers:

0 commit comments

Comments
 (0)