Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 52 additions & 12 deletions crates/tui/src/client/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,11 @@ impl CodewhaleClient {
_ => {}
}

// Sampling parameters: Claude 4.7+ rejects temperature/top_p
// entirely; earlier models reject the two together. Send at most one
// (temperature wins), or neither for models that forbid them.
// Sampling parameters: every model from Claude 4.7 on (including the
// Claude 5 generation and Haiku 5.5) rejects non-default
// temperature/top_p; earlier models reject the two together. Send at
// most one (temperature wins), or neither for models that forbid
// them.
if !anthropic_model_rejects_sampling(&request.model) {
if let Some(temperature) = request.temperature {
body["temperature"] = json!(temperature);
Expand Down Expand Up @@ -650,11 +652,25 @@ fn assistant_tool_use_ids(message: &Value) -> Vec<String> {
.unwrap_or_default()
}

/// Models that reject `temperature` / `top_p` outright (Claude 4.7+).
/// Models that reject non-default `temperature` / `top_p` outright (every
/// model released after Opus 4.6). The Messages API reference states those
/// models "do not support" setting `temperature` / `top_p` beyond their
/// backwards-compatible defaults (`temperature: 1.0`, `top_p >= 0.99`), and
/// the migration guides restate it per family: "Setting `temperature`,
/// `top_p`, or `top_k` to any non-default value on Claude Opus 4.7 and
/// later models, including Claude Opus 5.5, returns a 400 error". That
/// covers the Claude 5 generation (`claude-opus-5(-5)`, `claude-sonnet-5(-5)`)
/// and Haiku 5.5 (which admits only its defaults — `temperature: 1`, a lone
/// `top_p: 0.99`; `top_p: 1`, any `top_k`, or both params together 400),
/// besides the Fable/Mythos lines. Dropping the params entirely is always
/// safe; sending a forbidden one bricks the request. Re-checked 2026-10-08.
fn anthropic_model_rejects_sampling(model: &str) -> bool {
let lower = model.to_ascii_lowercase();
lower.contains("opus-4-7")
|| lower.contains("opus-4-8")
|| ["opus-5", "sonnet-5", "haiku-5"]
.iter()
.any(|family| lower.contains(family))
|| lower.contains("fable")
|| lower.contains("mythos")
}
Expand Down Expand Up @@ -1801,15 +1817,39 @@ mod tests {
assert!(body.get("temperature").is_none(), "{body}");
assert!(body.get("top_p").is_none(), "{body}");

// The Claude 5 generation and Haiku 5.5 reject sampling too. The
// datestamped spelling pins the substring match against snapshot
// suffixes.
for model in [
"claude-opus-5",
"claude-opus-5-5",
"claude-opus-5-20260101",
"claude-sonnet-5",
"claude-sonnet-5-5",
"claude-haiku-5-5",
"claude-fable-5",
"claude-mythos-5",
] {
let body =
client.build_anthropic_body(&request_with(model, None, Some(0.7), Some(0.9)), true);
assert!(
body.get("temperature").is_none() && body.get("top_p").is_none(),
"{model} must not receive sampling params: {body}"
);
}

// Older models accept ONE of temperature / top_p (temperature wins).
let body = client.build_anthropic_body(
&request_with("claude-sonnet-4-6", None, Some(0.7), Some(0.9)),
true,
);
assert_eq!(
body.get("temperature").and_then(Value::as_f64),
Some(f64::from(0.7f32))
);
// The pre-4.7 generations stay ungated, so a future over-broad arm
// like `contains("opus-4-")` cannot slip through.
for model in ["claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5"] {
let body =
client.build_anthropic_body(&request_with(model, None, Some(0.7), Some(0.9)), true);
assert_eq!(
body.get("temperature").and_then(Value::as_f64),
Some(f64::from(0.7f32)),
"{model} keeps temperature"
);
}
assert!(body.get("top_p").is_none(), "never send both: {body}");
}

Expand Down
Loading