Skip to content
Merged
Show file tree
Hide file tree
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
2 changes: 1 addition & 1 deletion src-rust/crates/api/assets/models-snapshot.json

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions src-rust/crates/api/src/model_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -964,7 +964,9 @@ impl Default for ModelRegistry {
fn flagship_patterns_for(provider_id: &str) -> &'static [&'static str] {
match provider_id {
"anthropic" | "amazon-bedrock" | "azure" | "google-vertex" => &[
"claude-opus-5",
"claude-opus-4",
"claude-sonnet-5",
"claude-sonnet-4",
"claude-3-5-sonnet",
"claude-sonnet-3",
Expand Down Expand Up @@ -1105,6 +1107,71 @@ pub fn effective_model_for_config(
mod tests {
use super::*;

#[test]
fn current_claude_snapshot_contains_models_and_pricing() {
let registry = ModelRegistry::new();
for id in [
"claude-fable-5",
"claude-fable-5-1",
"claude-opus-4-8",
"claude-opus-5",
"claude-opus-5-5",
"claude-sonnet-5",
"claude-sonnet-5-5",
] {
let entry = registry
.get("anthropic", id)
.unwrap_or_else(|| panic!("missing {id}"));
assert_eq!(entry.info.context_window, 1_000_000, "{id}");
assert_eq!(entry.info.max_output_tokens, 128_000, "{id}");
assert!(entry.reasoning, "{id}");
assert!(!entry.temperature, "{id}");
}
let sonnet = registry.get("anthropic", "claude-sonnet-5-5").unwrap();
assert_eq!(sonnet.cost_cache_read, Some(0.1));
assert_eq!(sonnet.cost.cache_read, Some(0.1));
assert_eq!(
registry
.get("anthropic", "claude-sonnet-4-6")
.unwrap()
.info
.max_output_tokens,
128_000
);
}

#[test]
fn current_claude_default_prefers_new_opus_generation() {
let registry = ModelRegistry::new();
assert_eq!(
registry.best_model_for_provider("anthropic").as_deref(),
Some("claude-opus-5-5")
);
}

#[test]
fn current_claude_snapshot_retains_existing_model_ids() {
let registry = ModelRegistry::new();
for id in [
"claude-3-5-haiku-20241022",
"claude-3-5-haiku-latest",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
"claude-3-7-sonnet-20250219",
"claude-3-haiku-20240307",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-opus-4-0",
"claude-opus-4-1",
"claude-opus-4-1-20250805",
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
] {
assert!(registry.get("anthropic", id).is_some(), "missing {id}");
}
}

#[test]
fn bundled_snapshot_loads() {
let reg = ModelRegistry::new();
Expand Down
119 changes: 113 additions & 6 deletions src-rust/crates/api/src/providers/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,40 +31,82 @@ use super::message_normalization::normalize_anthropic_messages;
pub(crate) fn claude_model_catalog() -> Vec<ModelInfo> {
let anthropic_id = ProviderId::new(ProviderId::ANTHROPIC);
vec![
ModelInfo {
id: ModelId::new("claude-fable-5-1"),
provider_id: anthropic_id.clone(),
name: "Claude Fable 5.1".to_string(),
context_window: 1_000_000,
max_output_tokens: 128_000,
},
ModelInfo {
id: ModelId::new("claude-fable-5"),
provider_id: anthropic_id.clone(),
name: "Claude Fable 5".to_string(),
context_window: 1_000_000,
max_output_tokens: 128_000,
},
ModelInfo {
id: ModelId::new("claude-opus-5-5"),
provider_id: anthropic_id.clone(),
name: "Claude Opus 5.5".to_string(),
context_window: 1_000_000,
max_output_tokens: 128_000,
},
ModelInfo {
id: ModelId::new("claude-opus-5"),
provider_id: anthropic_id.clone(),
name: "Claude Opus 5".to_string(),
context_window: 1_000_000,
max_output_tokens: 128_000,
},
ModelInfo {
id: ModelId::new("claude-opus-4-8"),
provider_id: anthropic_id.clone(),
name: "Claude Opus 4.8".to_string(),
context_window: 1_000_000,
max_output_tokens: 128_000,
},
ModelInfo {
id: ModelId::new("claude-opus-4-7"),
provider_id: anthropic_id.clone(),
name: "Claude Opus 4.7".to_string(),
context_window: 1_000_000,
max_output_tokens: 128_000,
},
ModelInfo {
id: ModelId::new("claude-opus-4-6"),
provider_id: anthropic_id.clone(),
name: "Claude Opus 4.6".to_string(),
context_window: 200_000,
max_output_tokens: 32_000,
context_window: 1_000_000,
max_output_tokens: 128_000,
},
ModelInfo {
id: ModelId::new("claude-sonnet-5-5"),
provider_id: anthropic_id.clone(),
name: "Claude Sonnet 5.5".to_string(),
context_window: 1_000_000,
max_output_tokens: 128_000,
},
ModelInfo {
id: ModelId::new("claude-sonnet-5"),
provider_id: anthropic_id.clone(),
name: "Claude Sonnet 5".to_string(),
context_window: 1_000_000,
max_output_tokens: 128_000,
},
ModelInfo {
id: ModelId::new("claude-sonnet-4-6"),
provider_id: anthropic_id.clone(),
name: "Claude Sonnet 4.6".to_string(),
context_window: 200_000,
max_output_tokens: 16_000,
context_window: 1_000_000,
max_output_tokens: 128_000,
},
ModelInfo {
id: ModelId::new("claude-haiku-4-5-20251001"),
provider_id: anthropic_id,
name: "Claude Haiku 4.5".to_string(),
context_window: 200_000,
max_output_tokens: 8_096,
max_output_tokens: 64_000,
},
]
}
Expand Down Expand Up @@ -133,7 +175,7 @@ impl AnthropicProvider {
builder = builder.stop_sequences(request.stop_sequences.clone());
}

// Opus 4.7+, Opus 4.8, and Fable 5 reject sampling params and manual
// Adaptive Claude models reject sampling params and manual
// `budget_tokens` with a 400. Send adaptive thinking instead and drop
// temperature / top_p / top_k.
if claurst_core::effort::model_uses_adaptive_thinking(&request.model) {
Expand Down Expand Up @@ -361,6 +403,71 @@ mod tests {

use super::*;

#[test]
fn current_claude_requests_use_adaptive_thinking_without_sampling() {
for model in [
"claude-fable-5-1",
"claude-opus-5",
"claude-opus-5-5",
"claude-sonnet-5",
"claude-sonnet-5-5",
] {
for thinking in [None, Some(ThinkingConfig::enabled(10_000))] {
let has_thinking = thinking.is_some();
let request = ProviderRequest {
model: model.to_string(),
messages: Vec::new(),
system_prompt: None,
tools: Vec::new(),
max_tokens: 16_000,
temperature: Some(0.5),
top_p: Some(0.9),
top_k: Some(20),
stop_sequences: Vec::new(),
thinking,
provider_options: serde_json::json!({}),
};
let wire = serde_json::to_value(AnthropicProvider::build_request(&request))
.expect("request serializes");
for field in ["temperature", "top_p", "top_k"] {
assert!(wire.get(field).is_none(), "{model} must omit {field}");
}
if has_thinking {
assert_eq!(
wire["thinking"],
serde_json::json!({"type": "adaptive"}),
"{model}"
);
} else {
assert!(wire.get("thinking").is_none(), "{model}");
}
}
}
}

#[test]
fn current_claude_catalog_has_correct_token_limits() {
let catalog = claude_model_catalog();
for (id, context, output) in [
("claude-fable-5-1", 1_000_000, 128_000),
("claude-opus-5-5", 1_000_000, 128_000),
("claude-opus-5", 1_000_000, 128_000),
("claude-opus-4-7", 1_000_000, 128_000),
("claude-opus-4-6", 1_000_000, 128_000),
("claude-sonnet-5-5", 1_000_000, 128_000),
("claude-sonnet-5", 1_000_000, 128_000),
("claude-sonnet-4-6", 1_000_000, 128_000),
("claude-haiku-4-5-20251001", 200_000, 64_000),
] {
let entry = catalog
.iter()
.find(|entry| &*entry.id == id)
.unwrap_or_else(|| panic!("missing {id}"));
assert_eq!(entry.context_window, context, "{id}");
assert_eq!(entry.max_output_tokens, output, "{id}");
}
}

fn response_stream(
events: Vec<StreamEvent>,
) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ProviderError>> + Send>> {
Expand Down
24 changes: 21 additions & 3 deletions src-rust/crates/core/src/effort.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
/// and have removed the manual `budget_tokens` and sampling parameters
/// (`temperature` / `top_p` / `top_k`).
///
/// On these models (Claude Opus 4.7, Opus 4.8, and Fable 5) sending
/// On these models (Claude Opus 4.7, Opus 4.8, the Opus 5 and Sonnet 5
/// families, and the Fable models) sending
/// `thinking: {type: "enabled", budget_tokens: N}` or a `temperature` returns
/// a 400 error. Callers must instead send `thinking: {type: "adaptive"}` and
/// control reasoning depth with the `effort` parameter. See the Anthropic
Expand All @@ -27,6 +28,8 @@ pub fn model_uses_adaptive_thinking(model: &str) -> bool {
|| m.contains("opus-4-7")
|| m.contains("claude-opus-4-8")
|| m.contains("opus-4-8")
|| m.contains("opus-5")
|| m.contains("sonnet-5")
}

// ---------------------------------------------------------------------------
Expand All @@ -45,7 +48,7 @@ pub enum EffortLevel {
Medium,
/// Comprehensive implementation with extensive testing and documentation.
High,
/// Maximum capability with deepest reasoning (Opus-tier and Fable models).
/// Maximum capability with deepest reasoning on supported models.
Max,
}

Expand Down Expand Up @@ -130,7 +133,7 @@ impl EffortLevel {
Self::Low => "Quick, straightforward implementation with minimal overhead",
Self::Medium => "Balanced approach with standard implementation and testing",
Self::High => "Comprehensive implementation with extensive testing and documentation",
Self::Max => "Maximum capability with deepest reasoning (Opus-tier and Fable models)",
Self::Max => "Maximum capability with deepest reasoning on supported models",
}
}
}
Expand Down Expand Up @@ -221,6 +224,21 @@ mod tests {
assert!(!model_uses_adaptive_thinking("claude-haiku-4-5"));
}

#[test]
fn current_claude_models_use_adaptive_thinking() {
for model in [
"claude-fable-5-1",
"claude-opus-5",
"claude-opus-5-5",
"claude-sonnet-5",
"claude-sonnet-5-5",
"anthropic/claude-sonnet-5-5",
"CLAUDE-OPUS-5-5",
] {
assert!(model_uses_adaptive_thinking(model), "{model}");
}
}

#[test]
fn display_matches_as_str() {
for level in [
Expand Down
Loading
Loading