diff --git a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/request.rs b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/request.rs index c126f8aefa..b181172982 100644 --- a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/request.rs +++ b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/request.rs @@ -9,7 +9,9 @@ use serde_json::{json, Value}; -use crate::providers::model_capabilities::is_claude_fable_5_1; +use crate::providers::model_capabilities::{ + is_claude_fable_5_1, is_claude_forced_tool_choice_unsupported, +}; use super::client::{AnthropicAuthMode, AnthropicClient}; use super::messages::extract_system; @@ -57,7 +59,6 @@ pub(super) fn prepare_request( }; let resolved_model = crate::providers::model_hints::wire_model_name(client.provider_spec, &parsed.base_model); - let fable_51 = is_claude_fable_5_1(&resolved_model); let (system, mut anthropic_messages) = extract_system(messages, skip_cache_write); // Extract tool_choice override (from side_query structured output) @@ -104,8 +105,11 @@ pub(super) fn prepare_request( } let tool_choice = if let Some(ovr) = tool_choice_override { - if fable_51 && matches!(ovr["type"].as_str(), Some("tool" | "any")) { - // Forced choices return 400 on 5.1. Follow its migration guide: + if is_claude_forced_tool_choice_unsupported(&resolved_model) + && matches!(ovr["type"].as_str(), Some("tool" | "any")) + { + // Forced choices return 400 on Fable 5.1 and Claude 5.5. Follow + // the migration guidance: // request auto plus an explicit instruction on the current turn. // The side-query caller still validates the returned tool call. let instruction = if let Some(name) = ovr["name"].as_str() { @@ -428,6 +432,7 @@ mod tests { assert!(model_uses_effort_beta("claude-sonnet-4-6", "anthropic")); assert!(!model_uses_effort_beta("claude-haiku-4-5", "anthropic")); assert!(model_uses_effort_beta("claude-sonnet-5", "anthropic")); + assert!(model_uses_effort_beta("claude-sonnet-5-5", "anthropic")); assert!(!model_uses_effort_beta("gpt-5.4", "openai")); } @@ -458,6 +463,7 @@ mod tests { "claude-opus-4-6", "claude-sonnet-4-6", "claude-sonnet-5", + "claude-sonnet-5-5", "claude-haiku-4-5", "claude-opus-4-5", ] { diff --git a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/tests/fable51_request_tests.rs b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/tests/fable51_request_tests.rs index 2e3d3244e2..53d11e0398 100644 --- a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/tests/fable51_request_tests.rs +++ b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/tests/fable51_request_tests.rs @@ -132,36 +132,36 @@ fn saved_aliases_and_baseline_never_disable_fable_51_thinking() { fn forced_choices_become_auto_with_current_turn_instructions() { let client = client(AnthropicAuthMode::ApiKey, HashMap::new()); let messages = vec![json!({"role": "user", "content": "Extract the answer"})]; - for choice in [ - json!({"type": "tool", "name": "emit_result"}), - json!({"type": "any"}), + for model in [ + "claude-fable-5-1-high", + "claude-opus-5-5-high", + "claude-sonnet-5-5-high", ] { - let tools = tools_with_choice(choice.clone()); - let original_tools = tools.clone(); - for stream in [false, true] { - let (body, _) = serialized_request( - &client, - "claude-fable-5-1-high", - &messages, - Some(&tools), - stream, - ); - assert_eq!(body["tool_choice"], json!({"type": "auto"})); - assert_eq!(body["tools"].as_array().unwrap().len(), 1); - assert_eq!(body["tools"][0]["name"], "emit_result"); - assert_eq!( - body["messages"][0]["content"][0]["text"], - "Extract the answer" - ); - assert_eq!(body["messages"].as_array().unwrap().len(), 2); - assert_eq!(body["messages"][1]["role"], "system"); - let instruction = body["messages"][1]["content"].as_str().unwrap(); - assert!(instruction.contains("must begin with")); - if choice["type"] == "tool" { - assert!(instruction.contains("emit_result")); + for choice in [ + json!({"type": "tool", "name": "emit_result"}), + json!({"type": "any"}), + ] { + let tools = tools_with_choice(choice.clone()); + let original_tools = tools.clone(); + for stream in [false, true] { + let (body, _) = serialized_request(&client, model, &messages, Some(&tools), stream); + assert_eq!(body["tool_choice"], json!({"type": "auto"})); + assert_eq!(body["tools"].as_array().unwrap().len(), 1); + assert_eq!(body["tools"][0]["name"], "emit_result"); + assert_eq!( + body["messages"][0]["content"][0]["text"], + "Extract the answer" + ); + assert_eq!(body["messages"].as_array().unwrap().len(), 2); + assert_eq!(body["messages"][1]["role"], "system"); + let instruction = body["messages"][1]["content"].as_str().unwrap(); + assert!(instruction.contains("must begin with")); + if choice["type"] == "tool" { + assert!(instruction.contains("emit_result")); + } } + assert_eq!(tools, original_tools); } - assert_eq!(tools, original_tools); } assert_eq!( messages, diff --git a/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs b/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs index 06a2e121d3..14c6da44f1 100644 --- a/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs +++ b/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs @@ -177,6 +177,12 @@ const FAMILY_RULES: &[FamilyRule] = &[ context_window: 200_000, thinking: ThinkingSupport::Optional, }, + // https://platform.claude.com/docs/en/models/sonnet-5-5/overview + FamilyRule { + pattern: "claude-sonnet-5-5", + context_window: 1_000_000, + thinking: ThinkingSupport::Optional, + }, FamilyRule { pattern: "claude-sonnet-5", context_window: 1_000_000, @@ -631,6 +637,18 @@ pub(crate) fn is_claude_fable_5_1(model: &str) -> bool { .is_some_and(|(_, rest)| rest.is_empty() || rest.starts_with('-') || rest.starts_with(':')) } +/// These models reject forced `tool_choice` values (`tool` and `any`). +pub(crate) fn is_claude_forced_tool_choice_unsupported(model: &str) -> bool { + let lower = model.to_ascii_lowercase(); + ["claude-fable-5-1", "claude-opus-5-5", "claude-sonnet-5-5"] + .iter() + .any(|id| { + lower.split_once(id).is_some_and(|(_, rest)| { + rest.is_empty() || rest.starts_with('-') || rest.starts_with(':') + }) + }) +} + /// Resolve capabilities for `model`, optionally consulting the KeyVault /// entry for `account_id`. /// diff --git a/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs b/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs index 3dea31fe05..317e515dde 100644 --- a/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs +++ b/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs @@ -106,6 +106,15 @@ fn opus_5_5_requires_thinking_while_opus_5_can_disable_it() { ); } +#[test] +fn sonnet_5_5_supports_optional_thinking_and_a_1m_context() { + for model in ["claude-sonnet-5-5", "anthropic/claude-sonnet-5-5-high"] { + let caps = resolve(model, None); + assert_eq!(caps.thinking, ThinkingSupport::Optional, "{model}"); + assert_eq!(caps.context_window, 1_000_000, "{model}"); + } +} + // ── OpenAI family ── #[test] diff --git a/src-tauri/crates/key-vault/src/commands/crud/models.rs b/src-tauri/crates/key-vault/src/commands/crud/models.rs index 9bdd382bee..a8527d6e9b 100644 --- a/src-tauri/crates/key-vault/src/commands/crud/models.rs +++ b/src-tauri/crates/key-vault/src/commands/crud/models.rs @@ -30,6 +30,7 @@ fn account_uses_anthropic_native_messages(entry: &ModelKey) -> bool { pub const CLAUDE_CODE_OAUTH_MODELS: &[&str] = &[ "claude-opus-5-5", + "claude-sonnet-5-5", "claude-opus-5", "claude-sonnet-5", "claude-fable-5-1", @@ -44,6 +45,7 @@ pub const CLAUDE_CODE_OAUTH_MODELS: &[&str] = &[ pub const CLAUDE_CODE_OAUTH_DEFAULT_ENABLED_MODELS: &[&str] = &[ "claude-opus-5-5", + "claude-sonnet-5-5", "claude-opus-5", "claude-sonnet-5", "claude-fable-5-1", diff --git a/src-tauri/crates/key-vault/src/commands/tests/tests.rs b/src-tauri/crates/key-vault/src/commands/tests/tests.rs index 75c58dfba5..3eb36970b5 100644 --- a/src-tauri/crates/key-vault/src/commands/tests/tests.rs +++ b/src-tauri/crates/key-vault/src/commands/tests/tests.rs @@ -699,26 +699,43 @@ fn live_claude_catalog_remains_account_visible_only() { } #[test] -fn claude_opus_5_fallback_exposes_effort_variants() { - use crate::commands::crud::KeyInfo; +fn current_claude_fallback_exposes_effort_variants() { + use crate::commands::crud::{ + KeyInfo, CLAUDE_CODE_OAUTH_DEFAULT_ENABLED_MODELS, CLAUDE_CODE_OAUTH_MODELS, + }; use crate::key_store::{AuthMethod, ModelKey, ModelType}; + assert!(CLAUDE_CODE_OAUTH_MODELS.contains(&"claude-sonnet-5-5")); + assert!(CLAUDE_CODE_OAUTH_DEFAULT_ENABLED_MODELS.contains(&"claude-sonnet-5-5")); + let mut key = ModelKey::new(ModelType::ClaudeCode); key.auth_method = AuthMethod::Oauth; key.session_token = Some("access-token".to_string()); - key.available_models = vec!["claude-opus-5".to_string(), "claude-opus-5-5".to_string()]; + key.available_models = vec![ + "claude-opus-5".to_string(), + "claude-opus-5-5".to_string(), + "claude-sonnet-5-5".to_string(), + ]; let info = KeyInfo::from(key); - for base in ["claude-opus-5", "claude-opus-5-5"] { + for base in ["claude-opus-5", "claude-opus-5-5", "claude-sonnet-5-5"] { let variants: Vec<_> = info .model_variants .iter() .filter(|variant| variant.base_model == base) .collect(); - assert_eq!(variants.len(), 5); + assert_eq!( + variants.len(), + if base == "claude-sonnet-5-5" { 10 } else { 5 } + ); assert!(variants .iter() .any(|variant| variant.model == format!("{base}-max"))); + if base == "claude-sonnet-5-5" { + assert!(variants + .iter() + .any(|variant| variant.model == format!("{base}-thinking-high"))); + } assert!(info.default_variants.iter().any(|variant| { variant.base_model == base && variant.model == format!("{base}-high") })); @@ -836,6 +853,7 @@ fn sonnet_ladders_follow_reference_effort_limits() { key.available_models = vec![ "claude-sonnet-4-6".to_string(), "claude-sonnet-5".to_string(), + "claude-sonnet-5-5".to_string(), ]; let info = KeyInfo::from(key); @@ -855,6 +873,10 @@ fn sonnet_ladders_follow_reference_effort_limits() { .model_variants .iter() .any(|variant| variant.model == "claude-sonnet-5-thinking-xhigh")); + assert!(info + .model_variants + .iter() + .any(|variant| variant.model == "claude-sonnet-5-5-thinking-xhigh")); } #[test] @@ -876,6 +898,7 @@ fn current_oauth_generations_are_preselected_without_overriding_provider_default "claude-opus-6", "claude-sonnet-4-10", "claude-sonnet-5", + "claude-sonnet-5-5", "claude-haiku-6", "claude-mythos-6", "claude-fable-5", diff --git a/src-tauri/crates/orgtrack-core/src/model_pricing_catalog.json b/src-tauri/crates/orgtrack-core/src/model_pricing_catalog.json index d0d1f4949d..7aa290b89e 100644 --- a/src-tauri/crates/orgtrack-core/src/model_pricing_catalog.json +++ b/src-tauri/crates/orgtrack-core/src/model_pricing_catalog.json @@ -1,5 +1,5 @@ { - "_comment": "Bundled model-price catalog. Approximate reference rates in USD per 1,000,000 tokens (per Mtok), using standard short-context text pricing unless an explicit Fast variant is listed; DeepSeek uses peak rates. cache_write is the 5-minute cache-creation rate where separately billed, otherwise it mirrors input. cache_read is the cache-hit rate, or input when no discount exists. This is not a time-versioned invoice rate card: service tiers, long context, 1-hour cache writes, storage, regional uplifts and tool fees are not modeled. Sources and 2026-09-14 verification scope: docs/model-pricing-2026-09-14.md. GPT-6 Sol/Luna rates: https://developers.openai.com/api/docs/models/gpt-6-sol and https://developers.openai.com/api/docs/models/gpt-6-luna. Claude Opus 5.5 rates: https://platform.claude.com/docs/en/models/opus-5-5/overview. Older entries not covered there retain their previous reference rates. Sonnet 5 pricing is now permanently 2/10; the announced September increase was cancelled. Cursor op-*/opus-*/composer/grok/default entries are imported model labels; default is Cursor Auto. Compiled into the binary via include_str!; no user data.", + "_comment": "Bundled model-price catalog. Approximate reference rates in USD per 1,000,000 tokens (per Mtok), using standard short-context text pricing unless an explicit Fast variant is listed; DeepSeek uses peak rates. cache_write is the 5-minute cache-creation rate where separately billed, otherwise it mirrors input. cache_read is the cache-hit rate, or input when no discount exists. This is not a time-versioned invoice rate card: service tiers, long context, 1-hour cache writes, storage, regional uplifts and tool fees are not modeled. Sources and 2026-09-14 verification scope: docs/model-pricing-2026-09-14.md. GPT-6 Sol/Luna rates: https://developers.openai.com/api/docs/models/gpt-6-sol and https://developers.openai.com/api/docs/models/gpt-6-luna. Claude Opus 5.5 rates: https://platform.claude.com/docs/en/models/opus-5-5/overview. Claude Sonnet 5.5 rates: https://platform.claude.com/docs/en/models/sonnet-5-5/overview. Older entries not covered there retain their previous reference rates. Sonnet 5 pricing is now permanently 2/10; the announced September increase was cancelled. Cursor op-*/opus-*/composer/grok/default entries are imported model labels; default is Cursor Auto. Compiled into the binary via include_str!; no user data.", "_updated": "2026-09-23", "default": { "input": 3.0, @@ -764,6 +764,13 @@ "cache_write": 5.0, "cache_read": 0.2 }, + { + "id": "claude-sonnet-5-5", + "input": 2.0, + "output": 10.0, + "cache_write": 2.5, + "cache_read": 0.2 + }, { "id": "gpt-5-6", "input": 4.0, diff --git a/src-tauri/crates/orgtrack-core/src/pricing.rs b/src-tauri/crates/orgtrack-core/src/pricing.rs index 111de315d2..f7b4de7923 100644 --- a/src-tauri/crates/orgtrack-core/src/pricing.rs +++ b/src-tauri/crates/orgtrack-core/src/pricing.rs @@ -395,6 +395,7 @@ mod tests { ("gpt-5.5-pro", [30.0, 180.0, 30.0, 30.0]), ("claude-fable-5-1", [10.0, 50.0, 12.5, 0.25]), ("claude-opus-5-5", [4.0, 20.0, 5.0, 0.2]), + ("claude-sonnet-5-5", [2.0, 10.0, 2.5, 0.2]), ("claude-mythos-5-1", [10.0, 50.0, 12.5, 0.25]), ("claude-mythos-5", [10.0, 50.0, 12.5, 1.0]), ("claude-sonnet-5", [2.0, 10.0, 2.5, 0.2]), diff --git a/src/types/model/info.anthropic.test.ts b/src/types/model/info.anthropic.test.ts index 576674b6d6..3eb10d991e 100644 --- a/src/types/model/info.anthropic.test.ts +++ b/src/types/model/info.anthropic.test.ts @@ -29,6 +29,23 @@ describe("Anthropic model info", () => { }); }); + it("recognizes Sonnet 5.5 and its effort variants", () => { + for (const model of [ + "claude-sonnet-5-5", + "claude-sonnet-5-5-thinking-high", + "anthropic/claude-sonnet-5-5-max", + ]) { + expect(getModelInfo(model)).toMatchObject({ + providerKey: "anthropic", + contextWindow: 1000, + maxOutput: 128, + vision: true, + reasoning: true, + pricingTier: "moderate", + }); + } + }); + it("uses published limits for current and legacy Claude models", () => { for (const model of [ "claude-opus-5", diff --git a/src/types/model/info.anthropic.ts b/src/types/model/info.anthropic.ts index 950a1d7152..4a614b443a 100644 --- a/src/types/model/info.anthropic.ts +++ b/src/types/model/info.anthropic.ts @@ -185,6 +185,20 @@ export const ANTHROPIC_MODEL_INFO_ENTRIES: ModelInfoEntry[] = [ pricingTier: "budget", }, }, + // https://platform.claude.com/docs/en/models/sonnet-5-5/overview + { + pattern: "claude-sonnet-5-5", + info: { + provider: "Anthropic", + providerKey: "anthropic", + contextWindow: 1000, + maxOutput: 128, + vision: true, + reasoning: true, + strengthKeys: ["coding", "balanced", "agentic", "speed"], + pricingTier: "moderate", + }, + }, // https://platform.claude.com/docs/en/models/sonnet-5/overview { pattern: "claude-sonnet-5",