Goose 升级后 Azure OpenAI 通道下的 Claude 突然不可用:一次 max_tokens / max_completion_tokens 兼容性排查
本文最后更新于 2026年4月14日 早上
这篇文章记录一个可复现的问题:在 goose 1.27.0 下,通过 Azure OpenAI provider 访问企业网关暴露出来的 Claude 模型一切正常;升级到 1.28.0 后,同样的配置流程会在初始化带 tool 的 chat completion 时失败。最后顺着源码一路翻下来,发现根因不是 Azure 通道本身坏了,也不是 thinking 配置写坏了,而是 goose 从 1.28.0 开始调整了 OpenAI-format 请求里
max_tokens/max_completion_tokens的选择策略,而企业网关里的 Claude 部署只接受max_completion_tokens。
环境:Windows、goose CLI、企业模型网关以 Azure OpenAI Endpoint 形态暴露,模型列表中除了 GPT,也包含
claude-sonnet-4-6这类 Claude 部署。
1. 背景
公司内部统一提供模型网关,对外暴露的是 Azure OpenAI Endpoint。也就是说,客户端这一侧看起来像在接 Azure OpenAI,但网关后面并不一定只有 GPT,也可能挂着 Claude 一类模型。
在我的环境里,这套配置在 goose 1.27.0 下是可以工作的:
- provider 选 Azure OpenAI
- 模型选企业网关暴露出来的
claude-sonnet-4-6 - 正常使用 Claude 的 thinking 配置
但升级到 goose 1.28.0 之后,问题就出现了:Claude 不再能被正常选用,配置流程会直接失败,甚至继续之前可以用 Session 继续对话也会报错。
这个问题最开始其实很像那种“看起来像 provider 坏了、实际上是请求字段细节翻车”的典型企业环境事故:同一个入口,模型列表也能列出来,但真正一发请求就露馅。
2. 现象与复现
在 PowerShell 中执行:
1 | |
然后选择:
Configure ProvidersAzure OpenAI- 模型:
claude-sonnet-4-6 - Claude extended thinking:例如
Adaptive或High
随后 goose 直接报错退出,关键信息如下:
1 | |
这里最容易误导人的,是报错前缀里的 Context length exceeded。真正导致失败的并不是上下文超长,而是请求参数名不兼容:
- 该模型 / 网关实现不接受
max_tokens - 它只接受
max_completion_tokens
换句话说,这不是“token 不够了”,而是“字段名写错了”。前者是配额/长度问题,后者是协议兼容问题;看起来只差一点,排查方向能差十万八千里;纯粹是污染上下文,如果没有准确的版本 diff ,还不知道排查到什么时候。
3. 排查过程
3.1 先确认不是 Azure OpenAI provider 整体不可用
第一步我先看了运行时配置:
1 | |
当时输出里的关键信息包括:
- goose Version:
1.28.0 GOOSE_PROVIDER: azure_openaiAZURE_OPENAI_ENDPOINT: https://...openai.azure.comAZURE_OPENAI_DEPLOYMENT_NAME: gpt-**GOOSE_MODEL: **- 配置 Claude thinking 后会出现
CLAUDE_THINKING_*相关变量
这一步至少能说明两件事:
- Azure OpenAI provider 本身不是坏的,因为 GPT 路径仍然是通的。
- 问题集中发生在:同一 provider 下切到 Claude 模型时,goose 发出的初始化请求被模型端拒绝。
也就是说,排查方向不应该放在「Azure 认证」「endpoint 地址」「provider 是否可用」这些通用问题上,而应该看Claude 这条请求链路的请求体差异。
3.2 源码定位:问题出在 OpenAI-format 请求体里的 max 字段
顺着 goose 源码往下看,Azure OpenAI 这条链路最终会走到 OpenAI-compatible 的请求构造逻辑。核心位置在:
crates/goose/src/providers/formats/openai.rs
更具体一点,这次问题真正相关的函数是:
create_request(...)
它负责组装 OpenAI-format 的 chat completions 请求体,也就是最终会决定到底发:
max_tokens- 还是
max_completion_tokens
这时候问题就收敛了:既然报错已经明确说模型不接受 max_tokens,那就只要搞清楚 goose 在什么版本、什么条件下开始发 max_tokens,基本就能把锅定位出来。
4. 关键版本差异:变更发生在 1.28.0
4.1 1.27.0:始终发送 max_completion_tokens
在 v1.27.0 中,create_request(...) 的逻辑是固定写入 max_completion_tokens:
1 | |
对应源码:
- v1.27.0 文件:
https://github.com/block/goose/blob/v1.27.0/crates/goose/src/providers/formats/openai.rs#L758-L835 - Azure provider(v1.28.0):
https://github.com/block/goose/blob/v1.28.0/crates/goose/src/providers/azure.rs - OpenAI-compatible provider(v1.28.0):
https://github.com/block/goose/blob/v1.28.0/crates/goose/src/providers/openai_compatible.rs
这意味着在 1.27.0 里,无论模型最终是不是被视为 reasoning 模型,这条 OpenAI-format chat completions 路径发出去的都是 max_completion_tokens。
这也解释了为什么在我的企业网关环境里,Claude 能正常工作:它恰好接受的就是这个字段。
4.2 1.28.0:开始在两个字段之间分支选择
到了 v1.28.0,逻辑变成了按模型类型二选一:
1 | |
对应源码:
- v1.28.0 文件:
https://github.com/block/goose/blob/v1.28.0/crates/goose/src/providers/formats/openai.rs#L749-L805
也就是:
- reasoning 模型 →
max_completion_tokens - 非 reasoning 模型 →
max_tokens
这就是这次 breakage 的分界线。
说得更直白一点:1.27.0 的行为是“统一发 max_completion_tokens”,1.28.0 的行为是“先判断你像不像 reasoning 模型,再决定发哪个字段”。而问题恰恰出在,企业网关里的 Claude 实现并没有这个逻辑。
4.3 具体是哪一个提交引入的
继续往前追 git blame / git log,可以把这次行为变化定位到这个提交:
- 提交:
0fba6353e4b00a8c6dabfbca22e51b4229d8a793 - 标题:
feat(openai): capture reasoning summaries from responses API (#7375)
GitHub 链接:
- 提交页:
https://github.com/block/goose/commit/0fba6353e4b00a8c6dabfbca22e51b4229d8a793 v1.27.0...v1.28.0对比:
https://github.com/block/goose/compare/v1.27.0...v1.28.0
从这个提交里,能看到两个关键变化:
- reasoning 模型判断从原来的方法演进为:
1 | |
- 原先固定写死的:
1 | |
被改成了:
1 | |
换句话说,这次问题并不是“1.28.0 突然不能调 Claude 了”这么简单,而是:1.28.0 开始改变了 OpenAI-format 请求体里 max 字段的生成规则,而企业网关里的 Claude 部署刚好不兼容这个变化。
5. 为什么是 Claude 会踩中这个问题
到这里,链路其实就比较清楚了。
企业网关里的 claude-sonnet-4-6 在这个 Azure OpenAI 兼容实现下,表现为:
- 不接受
max_tokens - 只接受
max_completion_tokens
而 goose 从 1.28.0 开始,如果把当前模型判成“非 reasoning 模型”,就会构造出这样的请求:
1 | |
于是请求在初始化阶段就会被拒绝。
把整件事串起来,就是下面这四步:
- 使用的是 Azure OpenAI provider,但背后挂的是企业网关暴露出来的 Claude;
- goose 走的是 OpenAI-format chat completions 请求构造逻辑;
- 1.28.0 开始,goose 不再总是发送
max_completion_tokens; - 企业网关里的 Claude 部署又只接受
max_completion_tokens。
这四个条件叠在一起,就得到了这次升级后的 breakage。
说到底,这不是 Claude “不能配 thinking”,也不是 Azure provider “不支持 Claude”,而是一个更无聊、但也更常见的事实:大家都说自己兼容 OpenAI,结果兼容的边界并不一样。
6. 一个额外观察:UI 里的 Claude thinking,不等于 OpenAI-format 请求里的 reasoning 判定
排查过程中,还有一个很容易想混的点。
在配置界面里,给 Claude 选择 extended thinking(例如 adaptive / high)之后,配置中会出现:
CLAUDE_THINKING_TYPECLAUDE_THINKING_EFFORTCLAUDE_THINKING_BUDGET
这些字段描述的是Claude 语义上的 thinking。但 goose 在 OpenAI-compatible 请求里到底发 max_tokens 还是 max_completion_tokens,依赖的是OpenAI-format 这套请求构造逻辑里的 reasoning 模型判定。
这两套概念在产品层面看起来很像,但在实现层面并不是一回事。
所以即使 UI 里已经选了 Claude thinking,也不代表这条 OpenAI-format 请求一定会走到 max_completion_tokens 分支。只要最终仍被判成“非 reasoning 模型”,它就会发出 max_tokens,并继续撞上企业网关的兼容性限制。
这一点其实很像很多“前台有开关、后台不是同一套语义”的问题:界面上看起来你已经打开了功能,但底层协议分支压根不是按这个开关在跑。
7. 结论
这不是一个单纯的“Claude 模型不可用”问题,本质上是一个OpenAI-compatible 网关与客户端实现之间的字段兼容性问题。
结论可以压缩成一句话:
goose 1.28.0 改变了 OpenAI-format 请求里 max 字段的选择策略;而企业网关里的 Claude 部署不接受
max_tokens,只接受max_completion_tokens,于是升级后兼容性被打破。
从结果上看:
- 1.27.0 能用,是因为它始终发送
max_completion_tokens - 1.28.0 出问题,是因为它开始在
max_tokens/max_completion_tokens之间分支选择
如果你只看表象,会觉得像是“升级后 Claude 坏了”;但如果看协议层,其实更准确的说法是:升级后请求字段策略变了,而你的企业网关正好不兼容这个变化。
8. 可行解法
8.1 方案 A:让企业网关兼容 max_tokens
如果企业网关对外宣称自己是 OpenAI-compatible,最稳妥的做法其实是让它兼容两种写法,例如:
- 把
max_tokens当成max_completion_tokens的别名 - 同时接受两个字段,并以
max_completion_tokens为优先
这是从协议兼容性的角度最合理的修法,也最能减少不同 SDK / 工具链之间的摩擦。
但是这基本不现实,跨部门合作自有国情在此。
8.2 方案 B:短期回退到 goose 1.27.0
如果业务上需要尽快恢复可用性,而网关短期又改不了,那么回退到最后一个已知可用版本是成本最低的方案。
真不愧是 AI 大人给出的方案呢,真详细全面。
8.3 方案 C:本地修改 goose
如果网关不能改,又希望继续使用较新的 goose 版本,那就只能在客户端侧做兼容处理。例如:
- 对
claude-*强制使用max_completion_tokens - 在发送 OpenAI-format 请求前,把
max_tokens重写为max_completion_tokens
这条路的代价是:
- 需要自己构建 goose
- 后续升级时要自己处理合并
- 需要承担自维护成本
8.4 方案 D:在本地加一层反向代理改写请求体
还有一种工程上比较实用的方案,是在 goose 与企业网关之间加一层代理:
- 拦截 JSON 请求体
- 把
max_tokens改写成max_completion_tokens - 再转发给网关
优点是不需要改 goose 源码;缺点是多了一层组件,也多了一层运维负担。
这招属于很“软件工程精髓”的方案 - 包一层:上游不改、下游要跑、你又不能一直回退版本,那就只好在中间垫一层。
8.5 方案 E:改用原生 Claude provider
如果公司环境允许直接走 Anthropic,而不是必须经过 Azure OpenAI 形态的企业网关,那么改用原生 Claude provider 往往会更自然。
但在很多企业环境里,这条路恰恰是走不通的:问题并不是不会接 Anthropic,而是组织层面只提供 Azure OpenAI 形式的统一出口。
如果公司能直接放行 Anthropic,这篇文章大概率一开始就不会存在了。
9. 经验教训
这次问题给我的教训主要有两点:
- “OpenAI-compatible” 不等于所有字段都完全兼容。 只要网关后面挂的不是原生 OpenAI 模型,字段细节就可能出现差异。
- UI 上的 thinking / reasoning 选项,不一定对应到底层请求里的同一套语义。 产品层的概念和协议层的分支判断,最好分开看。
说到底,企业环境里最烦的不是“完全不兼容”,而是“乍一看兼容,真跑起来才发现只兼容 80%”。这 20% 往往最花时间。
10. 附录:如果要在本地修 goose,可以怎么改
如果你的目标不是继续分析,而是尽快在本地把 goose 修到可用,那最直接的改法就是在 crates/goose/src/providers/formats/openai.rs 里,收窄 max_tokens / max_completion_tokens 的选择条件。
10.1 修改位置
目标文件:
crates/goose/src/providers/formats/openai.rs
目标函数:
create_request(...)
在 v1.28.0 中,相关逻辑大致位于这段附近:
create_request(...)起点:
https://github.com/block/goose/blob/v1.28.0/crates/goose/src/providers/formats/openai.rs#L749extract_reasoning_effort(...)和is_reasoning_model:
https://github.com/block/goose/blob/v1.28.0/crates/goose/src/providers/formats/openai.rs#L763-L764let key = if is_reasoning_model { ... } else { ... }:
https://github.com/block/goose/blob/v1.28.0/crates/goose/src/providers/formats/openai.rs#L798-L802
也就是说,最关键的就是这几行:
1 | |
10.2 最小改法:对 claude-* 强制走 max_completion_tokens
如果你的诉求非常明确——就是要让企业网关里的 Claude 先恢复可用——那最小改法可以很直接:
- 保留现有 reasoning 判断
- 额外把
claude-*视为必须使用max_completion_tokens
伪 patch 可以写成这样:
1 | |
如果写成更接近实际提交的 diff,大概是这样:
1 | |
这个改法的优点是:
- 改动非常小
- 风险面有限
- 对你这个案例足够直接
缺点也很明显:
- 它是一个针对模型名前缀的兼容补丁
- 本质上是为你的网关环境做定制
- 如果以后还有别的非 OpenAI 模型走同一条兼容链路,可能还得继续补规则
所以这是个很典型的“先救火”的 patch:好用,但不优雅。
10.3 稍微稳一点的改法:把逻辑抽成单独判断
如果不想把 starts_with("claude-") 直接糊在 create_request(...) 里,也可以先抽一个小函数,例如:
1 | |
然后在请求构造里调用:
1 | |
这样做的好处是后续如果你还想继续补:
- 某些 Azure 代理模型
- 某些企业网关里的非原生 OpenAI 模型
- 其他也只接受
max_completion_tokens的兼容实现
至少改动点会更集中一些,不至于把判断全散在请求构造逻辑里。
10.4 建议顺手补一条测试
如果准备长期自维护,建议顺手在同文件测试里补一条覆盖 claude-* 的 case。
因为 v1.28.0 这份文件本身已经有类似的 create_request(...) 测试:
- 普通模型断言生成
max_tokens - reasoning 模型断言生成
max_completion_tokens
你完全可以再补一个:
- 模型名:
claude-sonnet-4-6 - 断言请求体包含
max_completion_tokens - 同时断言不包含
max_tokens
伪代码大致像这样:
1 | |
这条测试的意义很实际:你这次修的是一个兼容性回归点,没有测试,后面升级或 rebase 时非常容易又被改回去。
10.5 这类 patch 的边界
最后还是要说一句,这种 patch 的定位应该很明确:
- 它适合本地自维护
- 适合企业网关约束很强、短期无法推动上游修复的环境
- 适合“我要先把工作跑起来”这种目标
但它未必是最适合直接 upstream 的方案。
原因也简单:
claude-*不一定永远都意味着“应该发max_completion_tokens”- 这个判断更像是某类兼容网关的现实约束,而不是一个放之四海而皆准的 OpenAI 规则
所以如果你只是要在自己机器上恢复可用,这个 patch 很合适;但如果想提 PR 到上游,通常还需要把判定条件设计得更抽象一些,比如:
- 基于 provider 能力声明
- 基于兼容模式开关
- 或基于更明确的模型元数据,而不是模型名前缀
附:本次排查用到的命令
1 | |