Skip to content

feat(thinking): GPT系列模型effort思考等级透传支持 - #1

Open
jsjm1986 wants to merge 20 commits into
dwgx:masterfrom
jsjm1986:master
Open

feat(thinking): GPT系列模型effort思考等级透传支持#1
jsjm1986 wants to merge 20 commits into
dwgx:masterfrom
jsjm1986:master

Conversation

@jsjm1986

Copy link
Copy Markdown

概述

为 GPT 系列模型(sol/terra/luna)实现完整的思考等级参数透传,使客户端可通过 reasoning_effort 参数控制推理深度。

修改内容

OpenAI 入站层 (src/openai/convert.rs)

  • chat/completions 路径:reasoning_effortoutput_config.effort
  • responses API 路径(Codex):reasoning.effortoutput_config.effort

Anthropic 转换层 (src/anthropic/converter.rs)

  • generate_thinking_prefix():GPT 系列模型生成 <effort>LEVEL</effort> 标签
  • 支持任意 effort 值透传(low/medium/high/xhigh/max 等),无白名单限制
  • none 值触发 disabled 模式并注入 <effort>low</effort> 抑制推理
  • has_thinking_tags() 扩展支持 <effort> 标签识别防重复注入
  • Claude 模型保持原有 <thinking_mode> 机制不变

流式处理 (src/anthropic/stream.rs)

  • 新增 thinking_chars_total 字段追踪思考内容累计字符数
  • thinking 块完成时输出 debug 日志

请求处理 (src/anthropic/handlers.rs)

  • 入站请求增加 debug 日志记录 thinking 参数(override 前)
  • override_thinking_from_model_name 不再覆盖用户已指定的 output_config

测试验证

  • ✅ gpt-5.6-sol/luna/terra 全系列 × 5 档位(low/medium/high/xhigh/max)
  • ✅ Codex responses API 路径
  • ✅ 流式/非流式
  • ✅ Claude thinking 模型不受影响
  • ✅ 大小写别名兼容
  • ✅ 811 个单元测试全部通过

实测数据

effort output_tokens(高难度问题)
max 322
low 273
none 288

max 档位比 low 多 ~18% 输出,推理更详细完整。

dwgx and others added 20 commits July 27, 2026 08:38
对全仓 53k 行 Rust + 23k 行前端逐文件精读,再对每条发现逐一读代码复核(含证伪),
只修确认成立的缺陷,每条都配能抓住旧 bug 的回归测试。测试 778 → 792 全绿。

macOS 完整支持(新平台):
- fix(update): ASSET_BIN 改为按 OS×ARCH 选资产。原先只有 cfg(windows)/cfg(not(windows))
  二选一、不看架构,macOS 与 arm64 Linux 全部落到 linux-x86_64 → 一键升级会把 Mach-O
  换成 Linux ELF 且 sha256 校验通过,重启后服务当场死亡。未覆盖组合改为 compile_error!
- ci: 新增 build-release-macos job(macos-14,矩阵产出 aarch64/x86_64 + sha256)
- feat(admin): restart_service 增加 macOS 分支,spawn detached POSIX shell 助手拉起新二进制
- feat(install): install-binary.sh 按 uname 选资产、sha256 工具自适应、装 launchd LaunchAgent

致命缺陷:
- fix(scheduler): transient_wait_outcome 补齐 custom_api 与 model_blocklist 两道硬门。
  与 is_entry_selectable 不对齐时 select 返 None 而等待判定返 Available,
  Available => continue 分支不 sleep 也不递增 attempt_count → 请求永不返回且烧满一核。
  另加竞态重选上限 64 作纵深防御
- fix(throttle): 令牌桶容量补 .max(ONE_TOKEN_MILLI)。容量隐含要求 rpm*burst>=60,
  默认 burst=2 时 rpm<=29 容量就 <1000 永远攒不满一个令牌,而 AIMD 降两档即到 25
  → 默认配置下被上游 429 打两次就整体塌陷

高危缺陷:
- fix(throttle): last_md_nanos 只在真降档时刷新。已达 rpm_min 时仍刷新会让升档静默期
  永不满足,RPM 永久卡在 floor
- fix(scheduler): 裸 429 的 health 键改用 family_key。原用 cred:{id} 而读侧全用 family_key,
  M365 号的 429 全写进从不被读的影子条目 → 被打爆也永不熔断
- fix(token): 新增 persist_disabled_state,5 条自动禁用路径补落盘。原先只写 kiro_stats.json
  而 StatsEntry 不含 disabled 字段 → 重启后死号以 enabled 回池
- fix(stream): partial_invoke_tag_suffix_len 加 64 字节上限。孤立 < 落到缓冲首位时
  emit_len=0 → 此后整条响应文本都不下发,缓冲无界增长
- fix(converter): 工具名前缀改按字节预算截断。原按字节判超限、按字符截前缀,
  30 个汉字缩短后反而变 99 字节仍超限 → 上游 400
- fix(security): 背景图两端点加图片 MIME 白名单 + 10MiB 流式上限 + nosniff。
  匿名端点原样透传上游 Content-Type 可在 /admin 同源 XSS
- ci: 新增 tag 与 Cargo.toml 版本一致性门禁,防 OTA 无限升级循环

其它:
- test: custom_api 测试改用 RFC6761 .invalid TLD,不再依赖真实 DNS
  (fake-IP 代理机器上 198.18/15 命中 SSRF 禁止段致测试必失败)
- docs: README 修正 Docker 默认端口 8991→8990,补平台资产对照表
- docs: CLAUDE.md 更正构建命令须带 --no-default-features、src/test.rs 不参与编译、
  已知问题 #2 仅修一半、#6 描述方向
- fix(install): systemd unit 补挂 rollback-guard.sh,此前公共安装脚本无 OTA 崩溃回滚
用户实测反馈:「优先级设置了 kiro 的 apikey 更小,还是会优先调度上游的 apikey」。

根因是两处叠加,导致用户设的 priority 在跨池维度上从未被比较过:
- 分派顺序写死在 handlers:请求一进来就先 try_custom_api_passthrough,
  只有它返回 None(代挂池全部冷却/失败)才落 Kiro 主路径;
- select_custom_api 只在 custom_api **子集内**按 priority 排序。
于是 custom_api 隐含享有绝对最高优先级 —— 哪怕 Kiro 号 priority=0、代挂号 priority=99,
也永远先走代挂号,与「priority 越小越优先」的产品直觉直接冲突。

改动:
- feat(config): 新增全局 customApiFirst,**默认 false**(= priority 全局统一比较)。
  设 true 可恢复历史的「代挂号绝对优先」行为。TIER1 热重载即时生效。
- feat(credentials): 新增凭据级 customApiFirst(Option<bool>),可**逐个上游 apikey**
  覆盖全局值;None=跟随全局。Admin 的 AddCredentialRequest 同步支持。
- feat(scheduler): 新增 should_try_custom_api_first() 做一次性跨池仲裁 ——
  任一可用代挂号显式 first=true 则先走透传;否则取两池各自最优 priority 比较,
  代挂不劣于 Kiro(<=,priority 相同时维持代挂在前以兼容既有部署)才先走透传。
  禁用与冷却中的号不参与仲裁(后者此刻本就选不出来,不该影响路径决策)。
- fix(handlers): 分派前先仲裁,Kiro 更优时跳过透传直接走 Kiro。
  即便跳过,Kiro 全失败后 provider 的 failover 仍会落回代挂池,兜底能力不减。

仲裁只做路径选择,不改两池各自的选号逻辑,故「两池隔离铁律」
(Kiro 选号永不返回 custom_api、透传结果永不进 health/family 连坐)完全不变。

测试 792 → 799:新增 7 个用例覆盖 kiro 更优 / 代挂更优 / priority 相等 /
全局开关 / 凭据级双向覆盖 / 空池与单池边界 / 冷却号排除。
另经 A/B 端到端实测:默认+kiro优先时日志零透传迹象,其余三种场景均正确走透传。
用户实测反馈:Claude Code 经 KiroStudio 转发 CC 协议时报
「Stream idle timeout - no chunks received」。

根因:/cc/v1/messages 的流式分支**无条件**走 handle_stream_request_buffered,
完全绕过 ccAutoBuffer 开关。buffered 分发会把整轮回答憋到上游流结束才一次性吐,
期间对客户端只发 25s 间隔的 ping、零内容字节。

项目其实早已坐实这个行为有害并因此把 /v1 的默认改成真流式
(见 default_cc_auto_buffer 的注释):
- contextUsageEvent 结尾才到 → 整轮看不到进度,模型越慢越像卡死;
- CC 的 steering(执行途中插消息引导)依赖观察流式增量,
  buffered 把整轮变成不可打断的黑盒。
但那次修正只落在 /v1,本端点漏了 —— 于是把 CC 指向 /cc/v1 的用户拿到旧的有害行为,
且把 ccAutoBuffer 设成 false 也关不掉(开关对该路径完全无效)。

现两个端点由同一开关统一语义:
  ccAutoBuffer=false(默认)→ 两端都真流式(内容边到边转发)
  ccAutoBuffer=true          → 两端都 buffered(换取 message_start 即精确 input_tokens)

另加分发决策的 debug 日志,便于生产排障确认走的是哪条路径。
实测两种取值下 /cc/v1 分别正确走真流式与 buffered。
按需求把 CC 自动切缓冲协议改为默认开启:识别到 Claude Code 的请求走 buffered 分发,
使 message_start 的 input_tokens 直接用上游 contextUsageEvent 的准确值(CC 会校验该字段),
CC 直接打 /v1 即可正确工作,无需手动改用 /cc/v1。

顺带修掉一处长期存在的默认值不一致:ccAutoBuffer 的默认值散落三处 ——
  ① src/model/config.rs   default_cc_auto_buffer()          原为 false
  ② src/anthropic/handlers.rs  CC_AUTO_BUFFER static 初值    本就是 true
  ③ src/admin/types.rs    ConfigSnapshotResponse::default    本就是 true
①与②③相反已久。运行时②会被 main 启动播种覆盖故不会立刻出错,但会让单元测试、
以及任何绕过 create_router_with_provider 的代码路径读到错的默认值,排障时极易误判。
本次改①为 true 后三处对齐,并新增两个测试把一致性钉死(改任一处不同步即失败)。

文档同步:default_cc_auto_buffer 与字段头注释都补全了 buffered 的**代价**
(此前字段头只写好处):整轮回答憋到上游流结束才一次性吐、期间只发 ping →
模型越慢越像卡死(客户端可能报 Stream idle timeout - no chunks received),
且 CC 的 steering 失效;想要真流式设为 false(热更即时生效)。

注意:本次只改默认值,不影响已显式写了 ccAutoBuffer 的现有部署。
项目已刻意把 AI 助手相关文件排除在公开 repo 外(.claude/ 标注为
「Claude Code agent 配置/记忆(本地私有,不公开)」,PROMPT.md / PROMPT-*.md /
docs/PROMPT-* 同样在排除列表)。项目级 CLAUDE.md 属完全同一类(AI 助手导航文件),
371 个历史提交里从未被跟踪,此处补上 ignore 规则使其显式化,避免后续误提交。
- OpenAI chat/completions路径: reasoning_effort → output_config.effort
- OpenAI responses API路径(Codex): reasoning.effort → output_config.effort
- converter层: GPT系列模型(sol/terra/luna)生成<effort>LEVEL</effort>标签
- 支持任意effort值透传(low/medium/high/xhigh/max等)
- none值触发disabled模式并注入<effort>low</effort>抑制推理
- has_thinking_tags扩展支持<effort>标签识别防重复
- 添加debug级别日志记录effort标签注入
- 流式响应新增thinking_chars_total计数用于监控思考完整度
- Claude模型保持原有<thinking_mode>机制不变

经端到端测试验证:
- gpt-5.6-sol/luna/terra全系列支持
- Codex responses API路径正常
- max档位比low多15-40%输出tokens(问题难度相关)
- 流式/非流式均正常
同一把 ksk_ 密钥在 kiro-go(9090) 可用、本项目(8990) 403,根因是
effective_profile_arn 对 api_key 缺 ARN 时回退默认 BuilderId 占位 ARN
(属账户 638616132270),上游判定 token 与 profile 不匹配,回
`403 The bearer token included in the request is invalid` —— 报文形似
token 失效,实为 ARN 越权,极易误诊为「这把 key 不可用」。

实测(同一把 key,仅 profileArn 一个变量):
  不带 profileArn → 400 INVALID_MODEL_ID(认证已通过,仅模型名不合)
  带占位 profileArn → 403 bearer token invalid
对照:伪造 key 不带 ARN → 403,故 400 证明认证通过。kiro-go 用
`omitempty` 天然不发,这正是差异来源。

改动:
- credentials.rs: api_key 同 external_idp 归入「缺真实 ARN 则不发」;
  真实 ARN 已解析到时仍照发,此处只拦占位值。附回归测试 3 例。
- provider.rs: API Key 无 refresh_token,403 时跳过 force-refresh,
  避免无意义重试 + 误加 auth 冷却(流式与非流式两条路径)。
- endpoint/ide.rs: 上游对话/MCP 端点回退 q.{region}.amazonaws.com
  (runtime.*.kiro.dev 实测不可用,回滚 1c1cfd0);刷新/余额/Web
  Portal 仍走 *.kiro.dev。
- auth/idc.rs: register_client 改用 clientName "Kiro" 并补
  grantTypes / redirectUris。
- .gitignore: 忽略 /config/(trash.json 存有被删凭据的完整 token)。

cargo test: 812 passed
- Dockerfile: `# syntax=docker/dockerfile:1` 在国内镜像源下拉不到
  BuildKit frontend,去掉该指令改用内置 dockerfile 解析。
- docker-compose.yml: 补一段注释掉的 HTTP_PROXY/HTTPS_PROXY/NO_PROXY
  示例,需要走本机代理访问上游时取消注释即可。
## 问题

单凭据时 `compute_max_retries(1, 1) == 1`(小号池降重试分支),重试判断
`attempt + 1 < max_retries` 即 `1 < 1` 恒为 false,**凭据级零重试**。上游任何
一次瞬态 429 都直接透传给客户端,Codex 侧自身重试耗尽后报
`exceeded retry limit, last status: 429 Too Many Requests`。

对比 kiro-go:其账户级重试同样是 1 次(429 后 `excluded[account.ID] = true`,
单号下一轮 `GetNextForModelExcluding` 返回 nil 即 break),差异**不在重试次数**,
而在 `CallKiroAPI` 内部还有一圈端点循环 —— 3 个端点,429 时 `continue` 换端点,
错误在函数内部就被吃掉,账户层根本看不到。本项目此前只注册 `ide` 一个端点,
没有横向退路。

## 实现

- 新增 `endpoint/alt.rs`:`CodeWhispererEndpoint` / `AmazonQEndpoint`。三个端点
  只差 host 与 `x-amz-target`,其余请求头/请求体完全一致,故复用 ide 的
  `inject_profile_arn` 与 1M beta 逻辑(可见性放开为 `pub(super)`)。
- `provider.rs` 新增 `endpoint_chain_for`:以凭据/配置指定端点为链首,其余按
  `ENDPOINT_FALLBACK_ORDER` 补齐;在发送处套一层端点循环,瞬态错误
  (408/429/5xx)先在同一凭据上换端点重试,**不消耗 max_retries、不设凭据冷却、
  不扣健康分**。整链失败才把最后响应交给原有凭据级错误分类。
- 新增 `endpointFallback` 配置(默认 true)。关掉即退化为单端点,行为与改动前
  完全一致。

`endpoint` 与 `response` 原子赋值,保证下游 `endpoint.is_xxx(&body)` 错误分类
始终对应真正响应的那个端点。

## 验证

- 三端点均以同一 idc 凭据实测 200,事件流结构一致(`assistantResponseEvent`),
  工具调用均正常返回 `toolUse`;codewhisperer 已确认返回真实对话内容。
- 压测 18/18 全 200,其中 8 次上游 429/500 被回退吃掉,客户端零感知;
  日志确认凭据「不计失败」。
- `endpointFallback=false` 回归:200 且回退事件 0。
- `cargo test` 815 passed / 0 failed;`cargo build --release` 无 error。

## 已知取舍

- `MODEL_TEMPORARILY_UNAVAILABLE`(503 全局容量)也会走完整条链,多花约 1-2s。
  精确跳过需先读 body,而读取会消耗下游所需的 response,故保持简单。
- `amazonq` 与 `ide` 同 host、仅 target 不同,是否真提供独立容量未在限流态下
  验证;`codewhisperer` 为独立 host,回退价值更明确。
## 问题

上游按**字节**拒绝过大请求(约 2MB → 400 `Input is too long.` /
`CONTENT_LENGTH_EXCEEDS_THRESHOLD`),而客户端(Codex)的自动压缩按 **token**
阈值触发。两个阈值不在同一量纲:字节先撞线时压缩根本没机会启动,长会话遂必然
撞墙且无法自恢复 —— 表现为 Codex 侧反复
`{"error":{"message":"上下文窗口已满(对话历史累积超出模型上下文上限)..."}}`
并 Reconnecting 重试至耗尽。

这也解释了为何单纯调高客户端的 `auto_compact_token_limit` 无效:拒绝发生在
token 阈值之前。

## 对比 kiro-go

kiro-go 在网关侧主动截断(`proxy/translator.go` 的 `truncatePayloadToLimit`),
本项目此前完全没有该机制,直接把超限请求发给上游挨 400。

同一个 2MB 请求实测:
- 改动前 kirostudio:拒绝(上下文窗口已满)
- kiro-go:200,input_tokens=151741

## 实现

新增 `anthropic/truncate.rs`,在 `convert_request` 末尾对 `ConversationState`
做超限检查(900KB,与 kiro-go 同值,保守留出请求头与序列化开销余量):

- 保留能放下的最长历史后缀,且不少于 `MIN_RECENT_HISTORY_TURNS`(4) 条;
- 被丢弃处插入一条占位 user 消息,让模型知道上下文被省略过;
- 截断后若首条为 assistant 则一并丢弃(上游要求 user/assistant 交替且以 user
  起始,否则 400)。

未超限时零改动、零额外序列化开销(提前返回)。

## 验证

- 2.43MB / 12 轮历史:改动前必被拒绝,现返回 200 且回复正确;
  日志「已丢弃最旧 8 条历史(保留最近 4 条)并插入占位说明」。
- `cargo test` 819 passed / 0 failed(新增 4 个用例,覆盖不截断、截断后仍达标、
  首条为 user、空历史 noop)。

## 已知边界

只截断历史,不改写用户**当前**消息。故「单条超大用户输入」(如粘贴巨型文件)
仍会撞上游 400 —— 静默截断用户刚发的内容会造成困惑,不如让上游给出明确错误。
kiro-go 有 `truncateCurrentMessage` 兜底,本次未移植。实际影响有限:Codex 的
问题是多轮历史累积,而非单条过大。
MIT 要求保留原始版权声明,故保留 dwgx 一行并追加本 fork 的版权,
而非替换 —— 本仓库的改动(端点回退、历史截断等)由本 fork 贡献。
公共项目应让使用者知道与上游的差异:端点级回退、请求体超限时的历史
截断、GPT 系列 effort 透传。同时在致谢中补上上游 dwgx。
## 核心改动

### 1. 历史工具扁平化(对齐 kiro-go)
- 历史里除活跃轮次外的工具调用扁平化为文本(上游只接受一个活跃轮次)
- 解决长会话 400 REQUEST_BODY_INVALID(积累多组结构化 toolUses/toolResults)
- 性能:无工具调用时 < 1μs 早退;有工具时 < 20μs(vs 请求延迟 100-500ms)
- 副作用:历史工具元数据丢失(转文本),但活跃轮次保持结构化,模型行为不受影响

### 2. promptCacheEnabled 开关修复 + 85% 上限防御
- 修复设计错位:promptCacheEnabled 开关存在但不起作用,照样算 cache 并扣 token
- 加 85% 上限(对齐 kiro-go):保证 input_tokens >= 15% 总量,防客户端压缩失效
- 两道防线:
  ①默认关(promptCacheEnabled=false)→ 完全不算 cache,性能提升(省热路径遍历)
  ②打开时 85% 上限兜底 → 极长会话也不会扣成 0
- 测试:4 个新增边界测试(前缀等于全量、i32 溢出、负数防御)

### 3. 截断孤立工具清理(超越 kiro-go)
- 截断时清理切口处的孤立 toolResults(kiro-go 缺失,会在工具会话截断时 400)
- 截断后补 ACK 应答保持交替(kiro-go 也有此 bug)
- 性能:仅超限时触发,O(n²) 最坏约 0.5-1ms(vs 原始版本直接 400)

## 修复的问题

| 问题 | 原始版本 | 改动后 |
|------|---------|--------|
| 历史多组工具 → 400 | ❌ 长会话必失败 | ✅ 扁平化解决 |
| 截断切断配对 → 400 | ❌ 工具会话必失败 | ✅ 孤立清理解决 |
| input_tokens=0 → 压缩失效 | ❌ 理论存在(极长会话) | ✅ 85% 上限兜底 |
| 截断后 user+user → 400 | ❌ 存在 | ✅ 补 ACK 解决 |

## 性能影响

- 默认配置(promptCacheEnabled=false):✅ 性能提升(跳过 count_prefix_tokens)
- 开关打开:⚪ < 20μs 新增开销(< 0.01% 请求延迟)
- 超限截断:⚪ + 0.5-1ms(低频场景,vs 原始直接 400)

## 测试覆盖

- 830 个测试全过(原 826 + 新增 4 个)
- 新增 sanitize_history 模块 3 个测试
- 新增 truncate 交替性回归测试
- 新增 token cap 边界测试 4 个

## 与 kiro-go 对比

改动后优于 kiro-go 的地方:
1. 保留 promptCacheEnabled 开关且真正生效(kiro-go 无开关)
2. 补充孤立工具清理(kiro-go 缺失)
3. 补充截断后 ACK 应答(kiro-go 也会 user+user → 400)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
## 问题

同一网关同时服务多客户端(Claude Code 打 claude-*、Codex 打 gpt-*)时,
tool_use 截断告警只有 block_index/defect/subkind,**看不出是哪个模型**:

    WARN tool_use 拼装后 input 非合法 JSON(长度 434),归因=truncated 子型=-

排查时无法回答「是模型侧生成问题,还是某条链路的问题」。model 字段此前
只在 KIRO_TOOL_TRACE 的 trace 分支里有,而那个会打印完整参数全文、太吵,
不适合常开。

另一处盲点:`if !completion.is_ok() { return Vec::new(); }` 静默返回,
日志只说明「参数坏」,不说明「所以坏参数没下发、收尾会补 SSE error」。
排查时容易误以为半截参数已经发给客户端了(那才是危险情形——客户端会把
半截参数当完整调用执行)。

## 改动

三处补 model 字段(与同函数内既有 trace 分支写法一致):
- 归因告警(拼装后非法 JSON)
- 修复成功(repair_tool_json 修好)
- 截断跨轮恢复(缓解⑤触发)

一处新增日志:
- 统一出口(②/③/⑤ 任一置失败态后)显式记录处置结果,与上面的归因告警
  配成一对:一条说「为什么坏」,一条说「所以怎么处置」。这条覆盖所有
  开关组合,不随 toolStreamAlignFailure / toolExposeErrorToClient /
  toolTruncationRecovery 的配置组合而漏记。

纯可观测改动:不进控制流,不改任何处置行为。

## 顺带说明(非代码改动)

effort 注入日志(converter.rs 的「GPT 系列模型注入思考等级标签」)是
debug 级。容器 RUST_LOG 需显式加 kirostudio::anthropic::converter=debug
才可见——此前误以为 effort 未生效,实测一直正常注入 <effort>max</effort>。

## 验证

- cargo test: 830 passed / 0 failed
- 测试容器(8991)实测 effort 全链路日志可见后,才切换生产容器
- 生产端到端: HTTP 200

截断类日志需上游真的截断 tool_use 才触发,无法按需复现,故仅经编译与
测试验证、未实测其输出形态。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
现场(生产实证,KIRO_TOOL_TRACE 抓到坏串全文):
  assembled={"plan":[step":"完成 P0 API 审批元数据接线与回归测试","status":...
                    ^^^^^ 中间少了整段 `{"`

gpt-5.6-sol 逐 token 流式发 tool input,帧序列形如
  `{"plan":[` → `{"` → `step` → `":"` → …
第 2 帧 `{"` 恰好是 buf `{"plan":[` 的前缀,规则5 据此判为"迟到的旧短快照"
直接丢弃 → 拼装结果中间缺一段 → 客户端 parse 失败报 Invalid tool parameters。

三个连带后果(都被这一条解释):
  · 归因层误标 `truncated`——看着像上游截断,实则网关自己丢帧;
  · 修复层补不回:缺的是**中间**的结构字符,不是尾部,补括号无从下手
    (故生产 23 次截断 0 次修复成功,与穷举测试 57.8% 修复率的矛盾由此解开);
  · 长度分布集中在 299-426 字节——参数本身并不长,从来不是"生成超长被截断"。

判据:**已完整的 JSON 对象无法再被增量续写**。故规则5 收窄为「buf 已是完整
合法 JSON」时才生效——这正是它原本要防的场景(test_merge_full_then_shorter_
prefix_kept 仍绿);buf 未完整时,前缀匹配的短帧是真增量碎片,落第7步追加。
另注:单一有序字节流上"更旧的快照后到"本不可能,收窄不会放过真实乱序。

回归:新增 3 例(前缀碎片不丢 + 生产同形全序列重放 + 规则5 原语义仍守),
全量 832 测试通过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
## 问题

生产实证 2026-08-01:
```
14:52:09.746  429 {"reason":"INSUFFICIENT_MODEL_CAPACITY"}  ← 模型容量不足
14:52:09.746  RPM自动降档 270→135
14:52:09.746  凭据进入瞬时冷却 duration_secs=15             ← 误处置
14:52:09.951  所有可用凭据均在冷却,返回 429+Retry-After=14
```

`INSUFFICIENT_MODEL_CAPACITY` 是模型容量不足的信号(类似 503 +
`MODEL_TEMPORARILY_UNAVAILABLE`),但此前只认 503 形式,429 带此信号时
掉进通用限流分支,被当成**凭据被限流**误处置 —— 冷了一个健康的号。

## 后果(三重误处置)

1. **冷却凭据是错的** —— 模型容量不足是全局问题,所有凭据对同一个过载模型
   完全等价,切换无意义。
2. **端点回退也救不了** —— 三个端点(ide/codewhisperer/amazonq)后面是
   同一份模型容量,所以整条链全 429。
3. **被凭据数放大** —— 生产 2 个凭据只启用 1 个,任何冷却都等于全池冷却,
   触发 `allCoolingFastFail` 快速失败 → 客户端硬等 14s(若不冷却,剩下
   2 次慢速退避重试很可能自愈,延迟仅约 2s)。

## 修复

1. **扩展容量信号识别**:`default_is_model_temporarily_unavailable` 加
   `INSUFFICIENT_MODEL_CAPACITY` 信号。
2. **放宽状态码门控**:容量路径从「503 专属」改为「503 或 429」均可进入
   (只要 body 带容量信号)。

进入容量路径后的处置(慢速退避 1s base、不冷却、不扣健康分)与既有 503
路径完全一致。普通 429(无容量信号)仍走冷却路径,行为不变。

## 验证

- 3 例回归测试:429 容量信号识别 + 503 经典形式仍工作 + 普通 429 限流不被
  误认
- **835 passed / 0 failed**
- 端到端实测 claude-opus-5-thinking 工具调用正常

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
## 问题

生产实证 2026-08-01 16:22(日志逐行复现):
```
16:22:00  上游 429(真限流,reason: null)
16:22:00  凭据 #47 冷却 15s                   ← 仅1个启用凭据
16:22:01  全池冷却,返回 429 Retry-After=14    ← 网关自己拒,未打上游
16:22:02  全池冷却,返回 429 Retry-After=12    ← 同上
16:22:06  全池冷却,返回 429 Retry-After=8
16:22:10  全池冷却,返回 429 Retry-After=4
```

Codex 重试预算(几秒内几次)被网关造的 429 吃光,报 `exceeded retry limit`。
kiro-go 没有 cooldown + fast-fail 层,429 之后下一个请求还是会打上游,往往就过。

**根因 A(缺陷 B)**:唯一凭据下冷却 → 全域中断

- **冷却的唯一价值是「让调度引到另一个号」**
- 可用凭据数为 1 时,冷却无备用号可切,等于全域中断
- fast-fail 把一次瞬态 429(上游几秒内可自愈)放大成 15 秒客户端硬等
- 次级放大:`上游 429 → RPM 自动降档 30→20(下限)`,入站整形被拖到地板

**根因 B(缺陷 A)**:死端点每请求白跑一跳延迟

端点回退链在 eu-central-1 的实际情况:
- `ide` → 可达
- `codewhisperer.eu-central-1.amazonaws.com` → **DNS 不存在**(容器内实测,对比
  us-east-1 可达)
- `amazonq` → 同 host 为 q.eu-central-1,等于第二次打 ide,无独立容量

每个请求都要在 codewhisperer 白跑一次 DNS 失败才继续(connect timeout),
等于回退链凭空多一跳延迟且毫无收益。这正是「端点回退在你环境下实际等于没有」
的原因 —— 0eaa141 那次专门修的横向退路,在 eu-central-1 退化成单端点。

## 修复

### 1. 唯一凭据保护(根因 A)

`token_manager.rs` 冷却 fast-fail 决策点加前置判断:
```rust
let available = ...count_enabled();
let sole_credential = available == 1;
if all_cooling_fast_fail && wait > 2s && !sole_credential { ... }
```
单凭据时改走网关内短等(上限 20s),用重试时间窗让上游限流自愈(通常几秒内
恢复),而不是立刻甩 429 给客户端(Codex 几次就打光重试预算)。

### 2. 死端点负缓存(根因 B)

`provider.rs` 新增 `dead_endpoints: Mutex<HashMap<endpoint@region, Instant>>`:

- 连接层失败(DNS/TCP/TLS)时记 (端点, region),TTL 30 分钟
- 下次该 (端点, region) 在负缓存内 → 直接跳过(链尾除外,保证至少发一次)
- 拿到 HTTP 响应(含 429/5xx)→ 清负缓存(说明连接层通了)

判据:
- `e.is_connect() || e.is_timeout() || e.is_request()` 进负缓存
- 状态码错误(429/5xx)是**容量**问题,host 本身好的,绝不进负缓存

TTL 30 分钟:避免瞬时网络抖动永久拉黑,过期自动重试(配置/DNS 可能临时修复)。

## 验证

- 语法检查:rustc --crate-type lib 通过
- 两处改动独立:唯一凭据保护在 token_manager,死端点负缓存在 provider,
  互不依赖、可独立回滚
- DNS 实测确认:codewhisperer.eu-central-1 容器内 curl 返回 exitcode=6(不存在),
  对比 us-east-1 可达、q.eu-central-1(ide)可达

## 影响面

- 唯一凭据:启用 ≥2 个凭据时行为完全不变,仅改单凭据下的 fast-fail 判断
- 死端点负缓存:仅影响连接层失败的端点,HTTP 层错误(429/5xx/4xx)流程不变

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants