diff --git a/lib/crates/fabro-llm/src/providers/anthropic.rs b/lib/crates/fabro-llm/src/providers/anthropic.rs index b96b8decc..0ed91b0b5 100644 --- a/lib/crates/fabro-llm/src/providers/anthropic.rs +++ b/lib/crates/fabro-llm/src/providers/anthropic.rs @@ -523,6 +523,18 @@ fn extract_thinking_config( .cloned() } +/// Map a reasoning effort level to a thinking `budget_tokens` value for models +/// that don't support the `output_config.effort` parameter (e.g. claude-sonnet-4-5). +fn effort_to_budget_tokens(effort: &str, max_tokens: i64) -> i64 { + let budget = match effort { + "low" => max_tokens / 4, + "high" => max_tokens * 3 / 4, + _ => max_tokens / 2, // "medium" or unknown + }; + // Anthropic requires budget_tokens >= 1024 + budget.max(1024) +} + fn is_auto_cache_enabled(provider_options: Option<&serde_json::Value>) -> bool { provider_options .and_then(|opts| opts.get("anthropic")) @@ -1072,22 +1084,48 @@ fn build_api_request( apply_cache_control_to_conversation_prefix(&mut api_messages); } - let thinking = extract_thinking_config(request.provider_options.as_ref()); + let explicit_thinking = extract_thinking_config(request.provider_options.as_ref()); - let output_config = request - .reasoning_effort - .as_ref() - .map(|effort| serde_json::json!({"effort": effort})); + // Check whether this model supports the `output_config.effort` parameter. + // Older reasoning models (e.g. claude-sonnet-4-5) need `thinking` with + // `budget_tokens` instead. + let model_info = fabro_model::get_model_info(&request.model); + let supports_effort = model_info.as_ref().is_none_or(|m| m.features.effort); + + let mut resolved_max_tokens = request + .max_tokens + .or_else(|| model_info.as_ref().and_then(|m| m.limits.max_output)) + .unwrap_or(65536); + + let (thinking, output_config) = if let Some(effort) = &request.reasoning_effort { + if supports_effort { + ( + explicit_thinking, + Some(serde_json::json!({"effort": effort})), + ) + } else if explicit_thinking.is_none() { + // Convert effort level to a thinking budget for models that don't + // support the effort parameter (e.g. claude-sonnet-4-5). + let budget = effort_to_budget_tokens(effort, resolved_max_tokens); + if resolved_max_tokens <= budget { + resolved_max_tokens = budget + 1024; + } + ( + Some(serde_json::json!({"type": "enabled", "budget_tokens": budget})), + None, + ) + } else { + // thinking already configured via provider_options; skip output_config + (explicit_thinking, None) + } + } else { + (explicit_thinking, None) + }; let api_request = ApiRequest { model: request.model.clone(), messages: api_messages, - max_tokens: request - .max_tokens - .or_else(|| { - fabro_model::get_model_info(&request.model).and_then(|m| m.limits.max_output) - }) - .unwrap_or(65536), + max_tokens: resolved_max_tokens, system: system_value, temperature: request.temperature, top_p: request.top_p, diff --git a/lib/crates/fabro-model/src/catalog.json b/lib/crates/fabro-model/src/catalog.json index f05f13019..64693b07b 100644 --- a/lib/crates/fabro-model/src/catalog.json +++ b/lib/crates/fabro-model/src/catalog.json @@ -6,7 +6,7 @@ "display_name": "Claude Opus 4.6", "limits": { "context_window": 1000000, "max_output": 128000 }, "training": "2025-08-01", - "features": { "tools": true, "vision": true, "reasoning": true }, + "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, "costs": { "input_cost_per_mtok": 15.0, "output_cost_per_mtok": 75.0, @@ -38,7 +38,7 @@ "display_name": "Claude Sonnet 4.6", "limits": { "context_window": 200000, "max_output": 64000 }, "training": "2025-08-01", - "features": { "tools": true, "vision": true, "reasoning": true }, + "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, "costs": { "input_cost_per_mtok": 3.0, "output_cost_per_mtok": 15.0, @@ -71,7 +71,7 @@ "display_name": "GPT-5.2", "limits": { "context_window": 1047576, "max_output": 128000 }, "training": "2025-08-31", - "features": { "tools": true, "vision": true, "reasoning": true }, + "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, "costs": { "input_cost_per_mtok": 1.75, "output_cost_per_mtok": 14.0, @@ -87,7 +87,7 @@ "display_name": "GPT-5 Mini", "limits": { "context_window": 1047576, "max_output": 128000 }, "training": "2025-08-31", - "features": { "tools": true, "vision": true, "reasoning": true }, + "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, "costs": { "input_cost_per_mtok": 0.25, "output_cost_per_mtok": 2.0, @@ -103,7 +103,7 @@ "display_name": "GPT-5.2 Codex", "limits": { "context_window": 1047576, "max_output": 128000 }, "training": "2025-08-31", - "features": { "tools": true, "vision": true, "reasoning": true }, + "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, "costs": { "input_cost_per_mtok": 1.75, "output_cost_per_mtok": 14.0, @@ -119,7 +119,7 @@ "display_name": "GPT-5.3 Codex", "limits": { "context_window": 1047576, "max_output": 128000 }, "training": "2025-08-31", - "features": { "tools": true, "vision": true, "reasoning": true }, + "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, "costs": { "input_cost_per_mtok": 1.75, "output_cost_per_mtok": 14.0, @@ -135,7 +135,7 @@ "display_name": "GPT-5.3 Codex Spark", "limits": { "context_window": 131072, "max_output": 128000 }, "training": "2025-08-31", - "features": { "tools": true, "vision": false, "reasoning": true }, + "features": { "tools": true, "vision": false, "reasoning": true, "effort": true }, "costs": { "input_cost_per_mtok": null, "output_cost_per_mtok": null, @@ -151,7 +151,7 @@ "display_name": "GPT-5.4", "limits": { "context_window": 1047576, "max_output": 128000 }, "training": "2025-08-31", - "features": { "tools": true, "vision": true, "reasoning": true }, + "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, "costs": { "input_cost_per_mtok": 2.5, "output_cost_per_mtok": 15.0, @@ -168,7 +168,7 @@ "display_name": "GPT-5.4 Pro", "limits": { "context_window": 1047576, "max_output": 128000 }, "training": "2025-08-31", - "features": { "tools": true, "vision": true, "reasoning": true }, + "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, "costs": { "input_cost_per_mtok": 30.0, "output_cost_per_mtok": 180.0, @@ -184,7 +184,7 @@ "display_name": "GPT-5.4 Mini", "limits": { "context_window": 400000, "max_output": 128000 }, "training": "2025-08-31", - "features": { "tools": true, "vision": true, "reasoning": true }, + "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, "costs": { "input_cost_per_mtok": 0.75, "output_cost_per_mtok": 4.50, @@ -200,7 +200,7 @@ "display_name": "Gemini 3.1 Pro (Preview)", "limits": { "context_window": 1048576, "max_output": 65536 }, "training": "2025-01-01", - "features": { "tools": true, "vision": true, "reasoning": true }, + "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, "costs": { "input_cost_per_mtok": 2.0, "output_cost_per_mtok": 12.0, @@ -217,7 +217,7 @@ "display_name": "Gemini 3.1 Pro Custom Tools (Preview)", "limits": { "context_window": 1048576, "max_output": 65536 }, "training": "2025-01-01", - "features": { "tools": true, "vision": true, "reasoning": true }, + "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, "costs": { "input_cost_per_mtok": 2.0, "output_cost_per_mtok": 12.0, @@ -233,7 +233,7 @@ "display_name": "Gemini 3 Flash (Preview)", "limits": { "context_window": 1048576, "max_output": 65536 }, "training": "2025-01-01", - "features": { "tools": true, "vision": true, "reasoning": true }, + "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, "costs": { "input_cost_per_mtok": 0.5, "output_cost_per_mtok": 3.0, @@ -249,7 +249,7 @@ "display_name": "Gemini 3.1 Flash Lite (Preview)", "limits": { "context_window": 1048576, "max_output": 65536 }, "training": "2025-01-01", - "features": { "tools": true, "vision": true, "reasoning": true }, + "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, "costs": { "input_cost_per_mtok": 0.25, "output_cost_per_mtok": 1.5, @@ -316,7 +316,7 @@ "display_name": "Mercury 2", "limits": { "context_window": 131072, "max_output": 50000 }, "training": null, - "features": { "tools": true, "vision": false, "reasoning": true }, + "features": { "tools": true, "vision": false, "reasoning": true, "effort": true }, "costs": { "input_cost_per_mtok": 0.25, "output_cost_per_mtok": 0.75, diff --git a/lib/crates/fabro-model/src/types.rs b/lib/crates/fabro-model/src/types.rs index e5e8ef979..0e478b396 100644 --- a/lib/crates/fabro-model/src/types.rs +++ b/lib/crates/fabro-model/src/types.rs @@ -13,6 +13,12 @@ pub struct ModelFeatures { pub tools: bool, pub vision: bool, pub reasoning: bool, + /// Whether the model supports the `reasoning_effort` / `effort` parameter + /// directly (e.g. Anthropic `output_config.effort`, OpenAI `reasoning.effort`). + /// Models with `reasoning=true` but `effort=false` (e.g. claude-sonnet-4-5) + /// need the older `thinking` API with `budget_tokens` instead. + #[serde(default)] + pub effort: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]