Skip to content

fix(chat): 客户端已带 max_completion_tokens 时不再注入 max_tokens(修复 issue #35) - #36

Merged
6Kmfi6HP merged 1 commit into
mainfrom
fix/issue-35-token-fields
Oct 2, 2026
Merged

6Kmfi6HP merged 1 commit into
mainfrom
fix/issue-35-token-fields

Conversation

@6Kmfi6HP

@6Kmfi6HP 6Kmfi6HP commented Oct 2, 2026

Copy link
Copy Markdown
Owner

问题(issue #35)

配置 max_tokens_cap / max_tokens_cap_per_model 时,chat 直通路径 convertRequest(internal/app/chat.go)在客户端未显式传 max_tokens 时无条件注入 cap。客户端只带 max_completion_tokens 的请求因此同时携带两个字段上行:

{"max_tokens": 200000, "max_completion_tokens": 32000, ...}

OpenAI 已废弃 max_tokens 并要求与 max_completion_tokens 互斥;opencode zen 的部分后端实例严格校验并直接拒绝:

400 invalid_request_error: `max_tokens` and `max_completion_tokens` cannot both be set; use `max_completion_tokens`.

由于只有部分实例校验,同一请求重放经常成功 → 表现为偶发失败;对 400 不重试的客户端(如 ZCode)硬失败。

根因审计(全库写点梳理)

  • 全库唯一可能在同一上行 chat body 共发 max_tokens + max_completion_tokens 的站点就是 convertRequest:
    • 触发 (b)(本 issue):缺省注入 + 转发客户端 mct;
    • 触发 (c):extra_body 携带 mct 时 typed 字段为 nil,注入分支漏判,随后 fill-merge 把 mct 放回 body,照常凑成并存对。
  • Chat→Anthropic / Chat→Responses / Claude→Responses 翻译路径经 resolveMaxTokens 折叠为单值(fresh map),天然免疫;Claude→Chat / Responses→Chat 只写单键。
  • 所有翻译路径重试复用已构建的请求体字节,不重建、不二次注入;responses EOF continuation 只在 responses 键空间重注入 max_output_tokens。
  • 客户端显式双带(OpenAI 本身即 400)维持原样转发,语义不变;显式 max_tokens 的 [128, cap] clamp 不变。

修复

convertRequest 的 cap 注入分支增加守卫:客户端已使用 max_completion_tokens(顶层 typed 字段 或 extra_body 携带,复用 extraBodyValue)时跳过 max_tokens 注入。双字段均未提供时注入 cap 的行为不变。

-} else if cap := config.MaxTokensCapFor(req.Model); cap > 0 {
+} else if cap := config.MaxTokensCapFor(req.Model); cap > 0 && !clientHasMaxCompletionTokens {

测试

  • 新增回归 TestConvertRequest_NoMaxTokensInjectionWhenMaxCompletionTokensSet(internal/app/chat_bridge_test.go),按 issue 原始线上 JSON 经 json.Unmarshal 走真实解析路径,覆盖四组:仅 max_completion_tokens(不注入、原样转发)/ 双字段缺省(cap 注入照旧)/ 仅显式 max_tokens / extra_body 携带 mct。
  • 全量审计确认零既有测试受影响(max_tokens_cap_test.go 四条注入/clamp 套件、protocol_regression_test.go、claude_bridge_test.go 显式双带 pin 等均保持绿色)。

验证

  • make test 全绿、go vet ./... 与 gofmt 干净。
  • 对照表复现口径:issue 中 {"big-pickle": 200000} + 仅 mct=32000 的客户端请求,修复后上行 body 不再含 max_tokens。

文档

  • docs/CONFIGURATION.md:cap 注入语义补充例外条款;docs/API.md Chat 支持字段列表补 max_completion_tokens;CHANGELOG.md v0.14.17 (unreleased) 新增条目。

备注(不在本 PR 范围)

  • Responses 直通消毒注入 max_output_tokens 时不剥离客户端私带的 chat 风格键(不同键空间,zen chat 型后端的 400 签名不可达),如后续上游收紧校验可另行处理。
  • 临时规避(cap 全设 0)对本修复无冲突;修复后 cap 配置可恢复正常使用。

Closes #35

…et (issue #35)

配置 max_tokens_cap / max_tokens_cap_per_model 时,convertRequest 在客户端
未显式传 max_tokens 时无条件注入 cap。客户端只带 max_completion_tokens 的
请求因此同时携带 max_tokens(=cap) 与 max_completion_tokens 上行;OpenAI 已
废弃 max_tokens 并要求二者互斥,opencode zen 部分后端实例严格校验返回 400
(cannot both be set),其余实例放行,表现为偶发失败(重放常成功)且对 400
不重试的客户端(如 ZCode)硬失败。

改动:
- convertRequest 在客户端已使用 max_completion_tokens 时跳过 cap 注入;
  判定同时覆盖顶层 typed 字段与 extra_body 携带(SDK extra_body 顶层合并
  惯例,复用 extraBodyValue),避免该入口漏判后由 fill-merge 重新凑成
  并存对。
- 双字段均未提供时注入 cap 的行为不变;显式 max_tokens 的 clamp 不变;
  客户端显式双带(OpenAI 本身即非法)原样转发,语义不变。
- 回归测试 TestConvertRequest_NoMaxTokensInjectionWhenMaxCompletionTokensSet
  按 issue 原始线上 JSON 经 json.Unmarshal 走真实解析路径(仅
  max_completion_tokens / 双字段缺省注入照旧 / 仅显式 max_tokens /
  extra_body 携带)。

验证:make test / go vet / gofmt 全绿;全库审计确认 convertRequest 为唯一
可能共发 max_tokens+max_completion_tokens 的站点,翻译路径经
resolveMaxTokens 收敛单值天然免疫,重试复用已构建字节不重建注入。

Ref: /docs CONFIGURATION.md 与 API.md 同步互斥说明。
@6Kmfi6HP
6Kmfi6HP merged commit 8a3ed1a into main Oct 2, 2026
1 check passed
@6Kmfi6HP
6Kmfi6HP deleted the fix/issue-35-token-fields branch October 2, 2026 21:57
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.

max_tokens 被 max_tokens_cap_per_model 填充后与客户端 max_completion_tokens 并存, 触发上游 400 (cannot both be set)

1 participant