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
goose configure

然后选择:

  • Configure Providers
  • Azure OpenAI
  • 模型:claude-sonnet-4-6
  • Claude extended thinking:例如 AdaptiveHigh

随后 goose 直接报错退出,关键信息如下:

1
2
Context length exceeded: Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.
Failed to configure provider: init chat completion request with tool did not succeed.

这里最容易误导人的,是报错前缀里的 Context length exceeded。真正导致失败的并不是上下文超长,而是请求参数名不兼容

  • 该模型 / 网关实现不接受 max_tokens
  • 只接受 max_completion_tokens

换句话说,这不是“token 不够了”,而是“字段名写错了”。前者是配额/长度问题,后者是协议兼容问题;看起来只差一点,排查方向能差十万八千里;纯粹是污染上下文,如果没有准确的版本 diff ,还不知道排查到什么时候。

3. 排查过程

3.1 先确认不是 Azure OpenAI provider 整体不可用

第一步我先看了运行时配置:

1
goose info -v

当时输出里的关键信息包括:

  • goose Version:1.28.0
  • GOOSE_PROVIDER: azure_openai
  • AZURE_OPENAI_ENDPOINT: https://...openai.azure.com
  • AZURE_OPENAI_DEPLOYMENT_NAME: gpt-**
  • GOOSE_MODEL: **
  • 配置 Claude thinking 后会出现 CLAUDE_THINKING_* 相关变量

这一步至少能说明两件事:

  1. Azure OpenAI provider 本身不是坏的,因为 GPT 路径仍然是通的。
  2. 问题集中发生在:同一 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
2
3
4
payload.as_object_mut().unwrap().insert(
"max_completion_tokens".to_string(),
json!(model_config.max_output_tokens()),
);

对应源码:

这意味着在 1.27.0 里,无论模型最终是不是被视为 reasoning 模型,这条 OpenAI-format chat completions 路径发出去的都是 max_completion_tokens

这也解释了为什么在我的企业网关环境里,Claude 能正常工作:它恰好接受的就是这个字段

4.2 1.28.0:开始在两个字段之间分支选择

到了 v1.28.0,逻辑变成了按模型类型二选一:

1
2
3
4
5
6
7
8
9
10
11
12
let (model_name, reasoning_effort) = extract_reasoning_effort(&model_config.model_name);
let is_reasoning_model = reasoning_effort.is_some();

let key = if is_reasoning_model {
"max_completion_tokens"
} else {
"max_tokens"
};
payload
.as_object_mut()
.unwrap()
.insert(key.to_string(), json!(model_config.max_output_tokens()));

对应源码:

也就是:

  • 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 链接:

从这个提交里,能看到两个关键变化:

  1. reasoning 模型判断从原来的方法演进为:
1
2
let (model_name, reasoning_effort) = extract_reasoning_effort(&model_config.model_name);
let is_reasoning_model = reasoning_effort.is_some();
  1. 原先固定写死的:
1
2
3
4
payload.as_object_mut().unwrap().insert(
"max_completion_tokens".to_string(),
json!(model_config.max_output_tokens()),
);

被改成了:

1
2
3
4
5
6
7
8
9
let key = if is_reasoning_model {
"max_completion_tokens"
} else {
"max_tokens"
};
payload
.as_object_mut()
.unwrap()
.insert(key.to_string(), json!(model_config.max_output_tokens()));

换句话说,这次问题并不是“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
2
3
{
"max_tokens": ...
}

于是请求在初始化阶段就会被拒绝。

把整件事串起来,就是下面这四步:

  1. 使用的是 Azure OpenAI provider,但背后挂的是企业网关暴露出来的 Claude;
  2. goose 走的是 OpenAI-format chat completions 请求构造逻辑;
  3. 1.28.0 开始,goose 不再总是发送 max_completion_tokens
  4. 企业网关里的 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_TYPE
  • CLAUDE_THINKING_EFFORT
  • CLAUDE_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. 经验教训

这次问题给我的教训主要有两点:

  1. “OpenAI-compatible” 不等于所有字段都完全兼容。 只要网关后面挂的不是原生 OpenAI 模型,字段细节就可能出现差异。
  2. 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 中,相关逻辑大致位于这段附近:

也就是说,最关键的就是这几行:

1
2
3
4
5
6
7
8
let (model_name, reasoning_effort) = extract_reasoning_effort(&model_config.model_name);
let is_reasoning_model = reasoning_effort.is_some();

let key = if is_reasoning_model {
"max_completion_tokens"
} else {
"max_tokens"
};

10.2 最小改法:对 claude-* 强制走 max_completion_tokens

如果你的诉求非常明确——就是要让企业网关里的 Claude 先恢复可用——那最小改法可以很直接:

  • 保留现有 reasoning 判断
  • 额外把 claude-* 视为必须使用 max_completion_tokens

伪 patch 可以写成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let (model_name, reasoning_effort) = extract_reasoning_effort(&model_config.model_name);
let is_reasoning_model = reasoning_effort.is_some();
let force_max_completion_tokens = model_name.starts_with("claude-");

let key = if is_reasoning_model || force_max_completion_tokens {
"max_completion_tokens"
} else {
"max_tokens"
};

payload
.as_object_mut()
.unwrap()
.insert(key.to_string(), json!(model_config.max_output_tokens()));

如果写成更接近实际提交的 diff,大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
@@
- let (model_name, reasoning_effort) = extract_reasoning_effort(&model_config.model_name);
- let is_reasoning_model = reasoning_effort.is_some();
+ let (model_name, reasoning_effort) = extract_reasoning_effort(&model_config.model_name);
+ let is_reasoning_model = reasoning_effort.is_some();
+ let force_max_completion_tokens = model_name.starts_with("claude-");
@@
- let key = if is_reasoning_model {
+ let key = if is_reasoning_model || force_max_completion_tokens {
"max_completion_tokens"
} else {
"max_tokens"
};

这个改法的优点是:

  • 改动非常小
  • 风险面有限
  • 对你这个案例足够直接

缺点也很明显:

  • 它是一个针对模型名前缀的兼容补丁
  • 本质上是为你的网关环境做定制
  • 如果以后还有别的非 OpenAI 模型走同一条兼容链路,可能还得继续补规则

所以这是个很典型的“先救火”的 patch:好用,但不优雅。

10.3 稍微稳一点的改法:把逻辑抽成单独判断

如果不想把 starts_with("claude-") 直接糊在 create_request(...) 里,也可以先抽一个小函数,例如:

1
2
3
fn should_use_max_completion_tokens(model_name: &str, is_reasoning_model: bool) -> bool {
is_reasoning_model || model_name.starts_with("claude-")
}

然后在请求构造里调用:

1
2
3
4
5
let key = if should_use_max_completion_tokens(&model_name, is_reasoning_model) {
"max_completion_tokens"
} else {
"max_tokens"
};

这样做的好处是后续如果你还想继续补:

  • 某些 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#[test]
fn test_create_request_claude_uses_max_completion_tokens() -> anyhow::Result<()> {
let model_config = ModelConfig {
model_name: "claude-sonnet-4-6".to_string(),
max_tokens: Some(1024),
..Default::default()
};

let request = create_request(/* ... */)?;
let value: serde_json::Value = serde_json::to_value(request)?;

assert_eq!(value["max_completion_tokens"], json!(1024));
assert!(value.get("max_tokens").is_none());

Ok(())
}

这条测试的意义很实际:你这次修的是一个兼容性回归点,没有测试,后面升级或 rebase 时非常容易又被改回去。

10.5 这类 patch 的边界

最后还是要说一句,这种 patch 的定位应该很明确:

  • 它适合本地自维护
  • 适合企业网关约束很强、短期无法推动上游修复的环境
  • 适合“我要先把工作跑起来”这种目标

但它未必是最适合直接 upstream 的方案。

原因也简单:

  • claude-* 不一定永远都意味着“应该发 max_completion_tokens
  • 这个判断更像是某类兼容网关的现实约束,而不是一个放之四海而皆准的 OpenAI 规则

所以如果你只是要在自己机器上恢复可用,这个 patch 很合适;但如果想提 PR 到上游,通常还需要把判定条件设计得更抽象一些,比如:

  • 基于 provider 能力声明
  • 基于兼容模式开关
  • 或基于更明确的模型元数据,而不是模型名前缀

附:本次排查用到的命令

1
2
3
4
5
# 查看运行时配置 / 路径 / 日志目录
goose info -v

# 重新进入 provider 配置流程
goose configure

Goose 升级后 Azure OpenAI 通道下的 Claude 突然不可用:一次 max_tokens / max_completion_tokens 兼容性排查
https://gou7ma7.github.io/2026/04/13/devops/@2026_goose_call_azure_openai_claude_breakage_max_tokens/
作者
Roy Lee
发布于
2026年4月13日
许可协议