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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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"));
}

Expand Down Expand Up @@ -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",
] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/crates/key-vault/src/commands/crud/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
33 changes: 28 additions & 5 deletions src-tauri/crates/key-vault/src/commands/tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}));
Expand Down Expand Up @@ -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);
Expand All @@ -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]
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/crates/orgtrack-core/src/pricing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
Expand Down
17 changes: 17 additions & 0 deletions src/types/model/info.anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions src/types/model/info.anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading