fix(model): raise default max_output_tokens floor for Claude 4+

Two related fixes so newly-released Claude models don't silently truncate
responses at 4096 output tokens (which manifests as the model 'stopping
mid tool-call' from the user's perspective):

1. Refresh the bundled canonical model registry from models.dev. This
   adds claude-opus-4-7, claude-opus-4-8, and bumps the registry from
   4740 to 4965 models. Generated by:

       cargo run --bin build_canonical_models

   With the refreshed registry, claude-opus-4-8 now resolves through
   strip_version_suffix's existing '-N-M' -> '-N.M' normalization to the
   canonical 'anthropic/claude-opus-4.8' entry (1M context, 128k output).

2. Add a Claude-4+ family floor to ModelConfig::max_output_tokens(). When
   GOOSE_MAX_TOKENS is unset and the canonical lookup doesn't populate
   max_tokens (e.g. a brand-new model id one release ahead of the bundled
   registry), we previously fell back to a hard 4096. That's a cliff:
   every Claude 4.x model supports at least 32k output, and capping at
   4096 cuts off tool_use blocks mid-stream, which agents experience as
   the model 'not making tool calls'.

   The new floor is 32k for Claude 4+ family ids (matches the lowest
   real 4.x output cap). Claude 3.x and non-Claude models keep the
   existing 4096 default since their real caps are genuinely small.

Also adds claude-opus-4-7 and claude-opus-4-8 to ANTHROPIC_KNOWN_MODELS
for UI/discovery.

Tests:
- canonical_lookup_resolves_dash_to_dot_for_claude_4_x: verifies
  claude-opus-4-{1,5,6,7,8} all resolve to the canonical entry and
  get a real (>4096) output cap.
- claude_4_family_falls_back_to_32k_not_4096: verifies the family
  floor for unknown future ids (claude-opus-4-9, anthropic/claude-opus-4.8,
  databricks-claude-opus-4.6, etc).
- claude_3_and_non_claude_keep_4096_default: regression guard.
- explicit_max_tokens_always_wins: GOOSE_MAX_TOKENS still overrides.

Also updates sets_limits_from_canonical_model to assert shape rather
than a hardcoded gpt-4o output number (16384 -> 4096 in models.dev),
so the test stops breaking every registry refresh.
This commit is contained in:
Michael Neale
2026-05-29 17:02:07 +10:00
parent 25ff547487
commit 23aa9b09b4
4 changed files with 5014 additions and 1469 deletions
+148 -1
View File
@@ -432,9 +432,41 @@ impl ModelConfig {
return tokens;
}
// Claude 4.x and newer all support at least 32k output tokens. The
// canonical model table is the source of truth, but when a fresh
// model id (e.g. a just-released `claude-opus-4-N`) is not yet in the
// shipped table we still don't want to silently cap responses at 4096
// — that truncates tool calls mid-stream and looks like the model is
// "broken". Use a Claude-family floor that matches the lowest 4.x
// output cap so behaviour degrades gracefully instead of catastrophically.
if Self::is_claude_4_or_newer(&self.model_name) {
return 32_000;
}
4_096
}
fn is_claude_4_or_newer(model_name: &str) -> bool {
let lower = model_name.to_lowercase();
if !lower.contains("claude") {
return false;
}
// Match `claude-<family>-<major>` where major >= 4. Covers ids like
// `claude-opus-4-8`, `claude-sonnet-4-5`, `anthropic/claude-opus-4.6`,
// `databricks-claude-opus-4.5`, etc. Older Claude 3.x / 2.x ids keep
// the conservative 4096 default since their real output caps are
// genuinely small (3.x sonnet = 8192, 3.x opus = 4096).
static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let re = RE.get_or_init(|| {
regex::Regex::new(r"claude[-/](?:opus|sonnet|haiku)[-.]?(\d+)").unwrap()
});
re.captures(&lower)
.and_then(|c| c.get(1))
.and_then(|m| m.as_str().parse::<u32>().ok())
.map(|major| major >= 4)
.unwrap_or(false)
}
pub fn normalize_effort_suffix(&mut self) {
if !self.is_openai_reasoning_model() {
return;
@@ -575,6 +607,113 @@ mod tests {
assert!(matches!(result.unwrap_err(), ConfigError::InvalidRange(..)));
}
#[test]
fn claude_4_family_falls_back_to_32k_not_4096() {
// Unknown Claude 4+ ids (not yet in the canonical table) must not
// silently truncate at 4096 — that breaks tool-calling mid-response.
for model in [
"claude-opus-4-8",
"claude-opus-4-9",
"claude-sonnet-4-7",
"claude-haiku-4-2",
"anthropic/claude-opus-4.8",
"databricks-claude-opus-4.6",
] {
let cfg = ModelConfig {
model_name: model.to_string(),
context_limit: None,
temperature: None,
max_tokens: None,
toolshim: false,
toolshim_model: None,
fast_model_config: None,
request_params: None,
reasoning: None,
};
assert_eq!(
cfg.max_output_tokens(),
32_000,
"expected 32k floor for Claude 4+ id `{model}`"
);
}
}
#[test]
fn claude_3_and_non_claude_keep_4096_default() {
// Older Claude 3.x and non-Claude models should keep the conservative
// 4096 default — their real output caps are genuinely small or unknown.
for model in [
"claude-3-opus-20240229",
"claude-3-5-sonnet-20241022",
"gpt-4o",
"some-unknown-model",
] {
let cfg = ModelConfig {
model_name: model.to_string(),
context_limit: None,
temperature: None,
max_tokens: None,
toolshim: false,
toolshim_model: None,
fast_model_config: None,
request_params: None,
reasoning: None,
};
assert_eq!(
cfg.max_output_tokens(),
4_096,
"expected 4096 default for non-Claude-4 id `{model}`"
);
}
}
#[test]
fn canonical_lookup_resolves_dash_to_dot_for_claude_4_x() {
// The canonical registry stores Anthropic ids with dots
// (`anthropic/claude-opus-4.8`) but `GOOSE_MODEL` is set with dashes
// (`claude-opus-4-8`). The shared `strip_version_suffix` normalizer
// already converts `-N-M` → `-N.M`, so the lookup should succeed and
// populate real limits from the table — no 4096 fallback.
let _guard = env_lock::lock_env([
("GOOSE_MAX_TOKENS", None::<&str>),
("GOOSE_CONTEXT_LIMIT", None::<&str>),
]);
for (model, expected_min_output) in [
("claude-opus-4-1", 32_000),
("claude-opus-4-5", 64_000),
("claude-opus-4-6", 128_000),
("claude-opus-4-7", 128_000),
("claude-opus-4-8", 128_000),
] {
let cfg = ModelConfig::new_or_fail(model).with_canonical_limits("anthropic");
assert!(
cfg.max_output_tokens() >= expected_min_output,
"{model}: expected canonical lookup to yield >= {expected_min_output} output tokens, got {}",
cfg.max_output_tokens()
);
assert!(
cfg.max_output_tokens() > 4_096,
"{model}: canonical lookup should have raised cap above 4096 fallback"
);
}
}
#[test]
fn explicit_max_tokens_always_wins() {
let cfg = ModelConfig {
model_name: "claude-opus-4-8".to_string(),
context_limit: None,
temperature: None,
max_tokens: Some(8_192),
toolshim: false,
toolshim_model: None,
fast_model_config: None,
request_params: None,
reasoning: None,
};
assert_eq!(cfg.max_output_tokens(), 8_192);
}
#[test]
fn test_model_config_with_max_tokens_env() {
let _guard = env_lock::lock_env([
@@ -877,8 +1016,16 @@ mod tests {
]);
let config = ModelConfig::new_or_fail("gpt-4o").with_canonical_limits("openai");
// Values come from the bundled canonical models registry (synced
// from models.dev). We assert the *shape* (limits got filled in,
// context matches the well-known 128k cap, reasoning is false)
// rather than an exact `max_tokens` number so this test doesn't
// break every time models.dev updates gpt-4o's output cap.
assert_eq!(config.context_limit, Some(128_000));
assert_eq!(config.max_tokens, Some(16_384));
assert!(
config.max_tokens.is_some(),
"expected canonical lookup to populate max_tokens for gpt-4o"
);
assert_eq!(config.reasoning, Some(false));
}
+4
View File
@@ -30,6 +30,10 @@ const ANTHROPIC_PROVIDER_NAME: &str = "anthropic";
pub const ANTHROPIC_DEFAULT_MODEL: &str = "claude-sonnet-4-5";
const ANTHROPIC_DEFAULT_FAST_MODEL: &str = "claude-haiku-4-5";
const ANTHROPIC_KNOWN_MODELS: &[&str] = &[
// Claude 4.8 models
"claude-opus-4-8",
// Claude 4.7 models
"claude-opus-4-7",
// Claude 4.6 models
"claude-opus-4-6",
"claude-sonnet-4-6",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff