From 68fc7825bb5cd04347debb1a30af227614b9d9f5 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 14 Aug 2026 18:41:40 +0200 Subject: [PATCH] fix: refresh AI provider model defaults and capability metadata (#10690) * fix: refresh AI provider model defaults and capability metadata Co-Authored-By: Claude Opus 5 (1M context) * fix: send explicit thinking disable for Claude and cap Opus 4.1 output Co-Authored-By: Claude Opus 5 (1M context) * fix: resolve mistral-medium-latest window and OpenRouter Claude 5 off Co-Authored-By: Claude Opus 5 (1M context) * fix: cover au. bedrock geo and Fable 5 caching, revert unverified mistral ladder Co-Authored-By: Claude Opus 5 (1M context) * fix: scope the Anthropic explicit disable to models that think by default Co-Authored-By: Claude Opus 5 (1M context) * fix: translate the reasoning off sentinel on the backend Anthropic path Co-Authored-By: Claude Opus 5 (1M context) * fix: translate the reasoning off sentinel on the Bedrock Converse path Co-Authored-By: Claude Opus 5 (1M context) * refactor: share the reasoning off sentinel and make its translation testable Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/windmill-ai/src/ai_bedrock.rs | 43 ++++++- .../windmill-ai/src/providers/anthropic.rs | 105 +++++++++++++++--- backend/windmill-ai/src/providers/bedrock.rs | 46 ++++++-- backend/windmill-ai/src/providers/mod.rs | 6 + .../lib/components/copilot/chat/anthropic.ts | 6 +- .../copilot/lib.anthropicRouting.test.ts | 14 +++ .../src/lib/components/copilot/lib.test.ts | 16 +++ frontend/src/lib/components/copilot/lib.ts | 59 ++++++---- .../src/lib/components/copilot/modelConfig.ts | 17 ++- .../copilot/reasoningRegistry.test.ts | 96 +++++++++++++++- .../components/copilot/reasoningRegistry.ts | 83 +++++++++++--- 11 files changed, 416 insertions(+), 75 deletions(-) diff --git a/backend/windmill-ai/src/ai_bedrock.rs b/backend/windmill-ai/src/ai_bedrock.rs index 02ab702d31..b223725af2 100644 --- a/backend/windmill-ai/src/ai_bedrock.rs +++ b/backend/windmill-ai/src/ai_bedrock.rs @@ -109,6 +109,19 @@ const BEDROCK_PROMPT_CACHING_SUPPORTED_MODEL_IDS: &[&str] = &[ "anthropic.claude-3-5-sonnet-20241022-v2:0", ]; +/// Claude 4.6 and later are published under several id spellings for the same +/// model (`anthropic.claude-sonnet-4-6`, `...-4-6-v1`, `...-4-6-v1:0`), so they +/// are matched by family prefix rather than by exact id. +const BEDROCK_PROMPT_CACHING_SUPPORTED_MODEL_PREFIXES: &[&str] = &[ + "anthropic.claude-fable-5", + "anthropic.claude-opus-4-6", + "anthropic.claude-opus-4-7", + "anthropic.claude-opus-4-8", + "anthropic.claude-opus-5", + "anthropic.claude-sonnet-4-6", + "anthropic.claude-sonnet-5", +]; + fn build_default_cache_point() -> aws_sdk_bedrockruntime::types::CachePointBlock { aws_sdk_bedrockruntime::types::CachePointBlock::builder() .r#type(aws_sdk_bedrockruntime::types::CachePointType::Default) @@ -123,7 +136,7 @@ fn normalize_bedrock_model_id(model: &str) -> String { .unwrap_or(model) .to_ascii_lowercase(); - for prefix in ["global.", "us.", "eu.", "apac."] { + for prefix in ["global.", "us.", "eu.", "apac.", "au."] { if let Some(normalized_model) = model.strip_prefix(prefix) { return normalized_model.to_string(); } @@ -135,6 +148,9 @@ fn normalize_bedrock_model_id(model: &str) -> String { pub fn bedrock_model_supports_prompt_caching(model: &str) -> bool { let normalized_model = normalize_bedrock_model_id(model); BEDROCK_PROMPT_CACHING_SUPPORTED_MODEL_IDS.contains(&normalized_model.as_str()) + || BEDROCK_PROMPT_CACHING_SUPPORTED_MODEL_PREFIXES + .iter() + .any(|prefix| normalized_model.starts_with(prefix)) } fn append_cache_point_to_system_prompts(system_prompts: &mut Vec) { @@ -1241,6 +1257,27 @@ mod tests { )); } + /// Claude 4.6+ ships under bare, `-v1` and `-v1:0` spellings of the same id, + /// so every one of them has to reach the prefix match. + #[test] + fn bedrock_prompt_caching_supports_claude_4_6_and_later_id_spellings() { + for model in [ + "anthropic.claude-sonnet-4-6", + "anthropic.claude-sonnet-4-6-v1:0", + "us.anthropic.claude-opus-4-6-v1", + "global.anthropic.claude-opus-4-8", + "anthropic.claude-opus-5", + "eu.anthropic.claude-sonnet-5-v1:0", + "au.anthropic.claude-sonnet-5", + "anthropic.claude-fable-5", + ] { + assert!( + bedrock_model_supports_prompt_caching(model), + "{model} must support prompt caching" + ); + } + } + #[test] fn bedrock_prompt_caching_rejects_unsupported_or_opaque_model_ids() { assert!(!bedrock_model_supports_prompt_caching( @@ -1249,5 +1286,9 @@ mod tests { assert!(!bedrock_model_supports_prompt_caching( "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-profile" )); + // Opus 4.5 is dated-id only — the 4.6+ prefixes must not swallow it. + assert!(!bedrock_model_supports_prompt_caching( + "anthropic.claude-opus-4-5-20251101-v2:0" + )); } } diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs index 6ebc555da6..195df62d44 100644 --- a/backend/windmill-ai/src/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -12,6 +12,7 @@ use crate::{ }; use async_trait::async_trait; use http::Method; +use super::REASONING_OFF_SENTINEL; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use windmill_common::{client::AuthedClient, error::Error}; @@ -137,17 +138,23 @@ pub struct AnthropicMessage { pub content: Vec, } -/// Adaptive thinking config for Anthropic native API. `summarized` display -/// matches the chat proxy path (renders a summarized thinking stream). +/// Thinking config for the Anthropic native API. `summarized` display matches +/// the chat proxy path (renders a summarized thinking stream); the disable +/// carries no display. #[derive(Serialize, Debug)] pub struct AnthropicThinking { pub r#type: &'static str, - pub display: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub display: Option<&'static str>, } impl AnthropicThinking { fn adaptive() -> Self { - Self { r#type: "adaptive", display: "summarized" } + Self { r#type: "adaptive", display: Some("summarized") } + } + + fn disabled() -> Self { + Self { r#type: "disabled", display: None } } } @@ -157,6 +164,34 @@ pub struct AnthropicOutputConfig { pub effort: String, } +/// Resolve the thinking config, effort and sampling params for a reasoning +/// selection. Adaptive thinking rejects sampling params, so temperature only +/// survives when thinking is off or explicitly disabled (Anthropic returns a +/// hard 400 otherwise). +fn anthropic_thinking_config( + reasoning_effort: Option<&str>, + temperature: Option, +) -> ( + Option, + Option, + Option, +) { + match reasoning_effort { + // The disable sentinel is not an effort token — Anthropic's vocabulary + // is low..max and rejects it. The disable carries no effort either: + // pairing it with xhigh or max is itself a 400 on Opus 5. + Some(effort) if effort == REASONING_OFF_SENTINEL => { + (Some(AnthropicThinking::disabled()), None, temperature) + } + Some(effort) => ( + Some(AnthropicThinking::adaptive()), + Some(AnthropicOutputConfig { effort: effort.to_string() }), + None, + ), + None => (None, None, temperature), + } +} + /// Anthropic-specific request structure for standard API #[derive(Serialize)] pub struct AnthropicRequest<'a> { @@ -652,16 +687,8 @@ impl AnthropicQueryBuilder { } } - // Adaptive thinking rejects sampling params, so drop temperature when - // reasoning is on (Anthropic returns a hard 400 otherwise). - let (thinking, output_config, temperature) = match args.reasoning_effort { - Some(effort) => ( - Some(AnthropicThinking::adaptive()), - Some(AnthropicOutputConfig { effort: effort.to_string() }), - None, - ), - None => (None, None, args.temperature), - }; + let (thinking, output_config, temperature) = + anthropic_thinking_config(args.reasoning_effort, args.temperature); // Build request based on platform if self.is_vertex() { @@ -1096,6 +1123,56 @@ mod tests { assert!(body.get("temperature").is_none()); } + /// An agent step stores the chat's off sentinel verbatim as its + /// `reasoning_effort`, so the disable has to be translated here rather than + /// forwarded as an effort token Anthropic would reject. + #[test] + fn anthropic_thinking_config_translates_the_off_sentinel() { + let (thinking, output_config, temperature) = + anthropic_thinking_config(Some("none"), Some(0.5)); + assert_eq!(thinking.as_ref().map(|t| t.r#type), Some("disabled")); + assert!(output_config.is_none()); + // Sampling params are only rejected alongside adaptive thinking. + assert_eq!(temperature, Some(0.5)); + + let (thinking, output_config, temperature) = + anthropic_thinking_config(Some("xhigh"), Some(0.5)); + assert_eq!(thinking.as_ref().map(|t| t.r#type), Some("adaptive")); + assert_eq!(output_config.map(|c| c.effort), Some("xhigh".to_string())); + assert!(temperature.is_none()); + + let (thinking, output_config, temperature) = anthropic_thinking_config(None, Some(0.5)); + assert!(thinking.is_none()); + assert!(output_config.is_none()); + assert_eq!(temperature, Some(0.5)); + } + + #[test] + fn anthropic_request_serializes_the_off_sentinel_as_a_thinking_disable() { + let request = AnthropicRequest { + model: "claude-opus-5", + system: None, + messages: vec![], + tools: None, + tool_choice: None, + temperature: Some(0.5), + thinking: Some(AnthropicThinking::disabled()), + output_config: None, + max_tokens: Some(64000), + stream: true, + }; + + let body: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&request).unwrap()).unwrap(); + assert_eq!(body["thinking"]["type"], "disabled"); + // A disable paired with an effort is a 400 on Opus 5, and `display` + // only applies to a thinking mode that actually runs. + assert!(body["thinking"].get("display").is_none()); + assert!(body.get("output_config").is_none()); + // Sampling params are only rejected alongside adaptive thinking. + assert_eq!(body["temperature"], 0.5); + } + #[test] fn anthropic_request_omits_thinking_when_reasoning_off() { let request = AnthropicRequest { diff --git a/backend/windmill-ai/src/providers/bedrock.rs b/backend/windmill-ai/src/providers/bedrock.rs index 4405a50dcb..03d460a95f 100644 --- a/backend/windmill-ai/src/providers/bedrock.rs +++ b/backend/windmill-ai/src/providers/bedrock.rs @@ -25,6 +25,7 @@ use crate::{ query_builder::{ParsedResponse, StreamEventSink}, types::{OpenAIMessage, StreamingEvent, TokenUsage, ToolDef}, }; +use super::REASONING_OFF_SENTINEL; use bytes::Bytes; use futures::{stream::BoxStream, StreamExt}; use http::{HeaderMap, Method, StatusCode}; @@ -358,9 +359,7 @@ async fn handle_bedrock_sdk_streaming( let (bedrock_messages, system_prompts) = openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?; // Adaptive thinking rejects sampling params; drop temperature when reasoning is on. - let temperature = openai_req - .reasoning_effort - .is_none() + let temperature = (!effort_enables_thinking(openai_req.reasoning_effort.as_deref())) .then_some(openai_req.temperature) .flatten(); let inference_config = create_inference_config(temperature, openai_req.max_tokens); @@ -410,11 +409,25 @@ async fn handle_bedrock_sdk_streaming( }) } -/// Build the Converse `additionalModelRequestFields` enabling Claude adaptive -/// thinking at the given effort. `display: summarized` is billing-neutral on -/// Anthropic models and matches the direct-Anthropic chat path, which renders -/// summarized thinking in the UI. +/// Whether an effort token turns adaptive thinking on. `"none"` is the disable +/// sentinel rather than a level, and sampling params stay usable alongside it. +fn effort_enables_thinking(effort: Option<&str>) -> bool { + matches!(effort, Some(effort) if effort != REASONING_OFF_SENTINEL) +} + +/// Build the Converse `additionalModelRequestFields` carrying Claude's thinking +/// config. `display: summarized` is billing-neutral on Anthropic models and +/// matches the direct-Anthropic chat path, which renders summarized thinking in +/// the UI. fn bedrock_thinking_fields(effort: &str) -> aws_smithy_types::Document { + if effort == REASONING_OFF_SENTINEL { + // The disable carries no effort: pairing it with xhigh or max is a 400 + // on Opus 5, and omitting it leaves the model at the effort where the + // disable is accepted. + return json_to_document(serde_json::json!({ + "thinking": { "type": "disabled" } + })); + } json_to_document(serde_json::json!({ "thinking": { "type": "adaptive", "display": "summarized" }, "output_config": { "effort": effort } @@ -664,9 +677,7 @@ async fn handle_bedrock_sdk_non_streaming( let (bedrock_messages, system_prompts) = openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?; // Adaptive thinking rejects sampling params; drop temperature when reasoning is on. - let temperature = openai_req - .reasoning_effort - .is_none() + let temperature = (!effort_enables_thinking(openai_req.reasoning_effort.as_deref())) .then_some(openai_req.temperature) .flatten(); let inference_config = create_inference_config(temperature, openai_req.max_tokens); @@ -935,7 +946,8 @@ impl BedrockQueryBuilder { openai_messages_to_bedrock(&prepared_messages, enable_prompt_caching)?; // Adaptive thinking rejects sampling params; drop temperature when reasoning is on. - let temperature = reasoning_effort.is_none().then_some(temperature).flatten(); + let temperature = + (!effort_enables_thinking(reasoning_effort)).then_some(temperature).flatten(); // Build inference configuration using shared helper let inference_config = create_inference_config(temperature, max_tokens.map(|t| t as i32)); @@ -1285,6 +1297,18 @@ mod tests { ); } + #[test] + fn bedrock_thinking_fields_translate_the_off_sentinel_to_a_disable() { + let fields = document_to_json(&bedrock_thinking_fields("none")); + assert_eq!(fields["thinking"]["type"], "disabled"); + // An effort alongside the disable is a 400 on Opus 5. + assert!(fields.get("output_config").is_none()); + // Sampling params survive a disable, unlike adaptive thinking. + assert!(effort_enables_thinking(Some("xhigh"))); + assert!(!effort_enables_thinking(Some("none"))); + assert!(!effort_enables_thinking(None)); + } + #[test] fn bedrock_thinking_fields_carry_adaptive_thinking_and_effort() { let fields = document_to_json(&bedrock_thinking_fields("xhigh")); diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index 094cc71490..63e4d0de6d 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -8,6 +8,12 @@ pub mod other; use std::time::{Duration, Instant}; +/// The effort token the chat and agent surfaces send to turn reasoning off. +/// It is not a provider-native level — each provider translates it to its own +/// disable (Anthropic and Bedrock to `thinking: {type: "disabled"}`, DeepSeek to +/// its `thinking` param, Gemini to a zero budget or the model's floor). +pub(crate) const REASONING_OFF_SENTINEL: &str = "none"; + use windmill_common::cache::Cache; use crate::{ diff --git a/frontend/src/lib/components/copilot/chat/anthropic.ts b/frontend/src/lib/components/copilot/chat/anthropic.ts index 7273dc8cd5..52074a84c9 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.ts @@ -157,8 +157,10 @@ export async function getAnthropicCompletion( const client = options?.anthropicClient ?? workspaceAIClients.getAnthropicClient() - // Adds output_config.effort + adaptive thinking when an effort is set; - // no-op otherwise. Returns the base shape unchanged when off. + // An effort adds output_config.effort + adaptive thinking; the off sentinel + // adds an explicit thinking disable instead. An unset effort leaves the base + // shape untouched, which is itself off on the models that only think when + // asked. const anthropicParams = applyReasoningToConfig( { model: config.model, diff --git a/frontend/src/lib/components/copilot/lib.anthropicRouting.test.ts b/frontend/src/lib/components/copilot/lib.anthropicRouting.test.ts index ae2451d49d..4bffcd80ac 100644 --- a/frontend/src/lib/components/copilot/lib.anthropicRouting.test.ts +++ b/frontend/src/lib/components/copilot/lib.anthropicRouting.test.ts @@ -197,6 +197,20 @@ describe('Anthropic Messages API routing', () => { expect(headers['X-Resource-Path']).toBe('u/admin/foundry') }) + it('raises the Claude output budget only from Opus 4.5 on', async () => { + const { getModelMaxTokens } = await import('./lib') + // 4.5+ matches Sonnet's budget... + for (const model of ['claude-opus-4-5', 'claude-opus-4-8', 'claude-opus-5']) { + expect(getModelMaxTokens('anthropic', model)).toBe(64000) + } + expect(getModelMaxTokens('openrouter', 'anthropic/claude-opus-4.5')).toBe(64000) + expect(getModelMaxTokens('aws_bedrock', 'anthropic.claude-opus-4-5-20251101-v1:0')).toBe(64000) + // ...while Opus 4.1 and older cap at 32K and must not be raised. + expect(getModelMaxTokens('anthropic', 'claude-opus-4-1')).toBe(32000) + expect(getModelMaxTokens('aws_bedrock', 'anthropic.claude-opus-4-1-20250805-v1:0')).toBe(32000) + expect(getModelMaxTokens('aws_bedrock', 'anthropic.claude-opus-4-20250514-v1:0')).toBe(32000) + }) + it('caps max_tokens for metadata completions so the Anthropic SDK stays non-streaming', async () => { const { getNonStreamingCompletion, getNonStreamingMetadataCompletion, METADATA_MAX_TOKENS } = await import('./lib') diff --git a/frontend/src/lib/components/copilot/lib.test.ts b/frontend/src/lib/components/copilot/lib.test.ts index 55e0e92c60..964bf6e703 100644 --- a/frontend/src/lib/components/copilot/lib.test.ts +++ b/frontend/src/lib/components/copilot/lib.test.ts @@ -267,6 +267,22 @@ describe('model context windows', () => { expect(getKnownModelContextWindow('deepseek-reasoner')).toBe(1000000) }) + it('gives the gpt-5.6 family its own window instead of the 400K gpt-5 one', () => { + expect(getKnownModelContextWindow('gpt-5.6-sol')).toBe(1050000) + expect(getKnownModelContextWindow('gpt-5.6-terra')).toBe(1050000) + expect(getKnownModelContextWindow('openai/gpt-5.6-luna')).toBe(1050000) + // the older families keep theirs + expect(getKnownModelContextWindow('gpt-5.5')).toBe(1000000) + expect(getKnownModelContextWindow('gpt-5-mini')).toBe(400000) + }) + + it('maps both Mistral Medium 3.5 spellings to 256K, not the 128K fallback', () => { + expect(getKnownModelContextWindow('mistral-medium-3.5')).toBe(256000) + expect(getKnownModelContextWindow('mistral-medium-latest')).toBe(256000) + // a pinned older snapshot must not inherit the 256K window + expect(getKnownModelContextWindow('mistral-medium-2505')).toBeUndefined() + }) + it('maps Qwen3-Max to 256K and leaves other Qwen ids to the assumed window', () => { expect(getKnownModelContextWindow('qwen3-max')).toBe(256000) expect(getKnownModelContextWindow('qwen3-max-2025-09-23')).toBe(256000) diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index fc2cb0abd2..d5722f2f6f 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -57,15 +57,20 @@ interface AIProviderDetails { defaultModels: string[] } +// The first entry is what a new workspace is created with (see +// CreateWorkspaceInner), so each list leads with the balanced tier rather than +// the frontier model. The gpt-5 family is deprecated (retires 2026-12-11) but +// still served, so it stays in the list below the 5.6 models. const OPENAI_MODELS = [ + 'gpt-5.6-terra', + 'gpt-5.6-sol', + 'gpt-5.6-luna', 'gpt-5', 'gpt-5-mini', - 'gpt-5-nano', 'gpt-4o', 'gpt-4o-mini', 'o4-mini', - 'o3', - 'o3-mini' + 'o3' ] export const AI_PROVIDERS: Record = { @@ -75,17 +80,18 @@ export const AI_PROVIDERS: Record = { }, anthropic: { label: 'Anthropic', - defaultModels: ['claude-sonnet-4-6', 'claude-3-5-haiku-latest'] + defaultModels: ['claude-sonnet-5', 'claude-opus-5', 'claude-opus-4-8', 'claude-haiku-4-5'] }, googleai: { label: 'Google AI', defaultModels: [ - 'gemini-2.5-flash', - 'gemini-2.5-pro', - 'gemini-2.5-flash-lite', - 'gemini-3-flash', + 'gemini-3.6-flash', + 'gemini-3.5-flash', 'gemini-3.1-pro', - 'gemini-3.1-flash-lite' + 'gemini-3.5-flash-lite', + 'gemini-3.1-flash-lite', + 'gemini-2.5-pro', + 'gemini-2.5-flash' ] }, azure_openai: { @@ -95,25 +101,26 @@ export const AI_PROVIDERS: Record = { azure_foundry: { label: 'Azure AI Foundry', defaultModels: [ - 'gpt-4o', - 'gpt-4o-mini', - 'DeepSeek-R1', + 'gpt-5.6-terra', + 'gpt-5.6-sol', + 'claude-sonnet-5', + 'claude-opus-5', + 'DeepSeek-V4-Pro', 'Llama-3.3-70B-Instruct', - 'Phi-4', - 'Mistral-Large-2411' + 'Phi-4' ] }, mistral: { label: 'Mistral', - defaultModels: ['codestral-latest'] + defaultModels: ['mistral-medium-latest', 'codestral-latest'] }, deepseek: { label: 'DeepSeek', - defaultModels: ['deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] + defaultModels: ['deepseek-v4-pro', 'deepseek-v4-flash'] }, groq: { label: 'Groq', - defaultModels: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant'] + defaultModels: ['openai/gpt-oss-120b', 'openai/gpt-oss-20b'] }, openrouter: { label: 'OpenRouter', @@ -125,7 +132,11 @@ export const AI_PROVIDERS: Record = { }, aws_bedrock: { label: 'AWS Bedrock', - defaultModels: ['global.anthropic.claude-haiku-4-5-20251001-v1:0'] + defaultModels: [ + 'global.anthropic.claude-sonnet-5', + 'global.anthropic.claude-opus-5', + 'global.anthropic.claude-haiku-4-5-20251001-v1:0' + ] }, customai: { label: 'Custom AI', @@ -295,9 +306,19 @@ export function getModelMaxTokens(provider: AIProvider, model: string) { ) { return 100000 } else if ( + // Raising this further would also raise the worst case of the + // non-streaming completion path, which the Anthropic SDK refuses once + // the request could run past ~10 minutes. model.includes('claude-sonnet') || + model.includes('claude-haiku') || + model.includes('claude-fable') || + model.includes('claude-mythos') || + // Opus only from 4.5 on. Opus 4.1 and older cap at 32K and fall through + // to the row below. Dots are normalized because OpenRouter writes + // `anthropic/claude-opus-4.5` where Anthropic writes `claude-opus-4-5`. + /claude-opus-(4-(5|6|7|8)|5)(?!\d)/.test(model.replace(/\./g, '-')) || model.includes('gemini-2.5') || - model.includes('claude-haiku') + model.includes('gemini-3') ) { return 64000 } else if (model.includes('gpt-4.1')) { diff --git a/frontend/src/lib/components/copilot/modelConfig.ts b/frontend/src/lib/components/copilot/modelConfig.ts index ee034f0500..a11fd5c034 100644 --- a/frontend/src/lib/components/copilot/modelConfig.ts +++ b/frontend/src/lib/components/copilot/modelConfig.ts @@ -74,6 +74,7 @@ const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [ // Haiku, older Claude models (3.x, 4.0, 4.1, 4.5) and date-suffixed Claude 4 // base ids (claude-sonnet-4-20250514) fall through to 200K ['claude-fable-5', 1_000_000], + ['claude-mythos-5', 1_000_000], ['claude-opus-5', 1_000_000], ['claude-sonnet-5', 1_000_000], ['claude-opus-4-8', 1_000_000], @@ -82,7 +83,8 @@ const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [ ['claude-sonnet-4-6', 1_000_000], ['claude', 200_000], // OpenAI — gpt-5 covers the base family (-mini / -nano) and the 5.1/5.2 - // revisions, all 400K; only 5.4+ moved to 1M + // revisions, all 400K; 5.4/5.5 moved to 1M and 5.6 to 1.05M + ['gpt-5.6', 1_050_000], ['gpt-5.5', 1_000_000], ['gpt-5.4', 1_000_000], ['gpt-5', 400_000], @@ -94,8 +96,9 @@ const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [ ['gemini-3.1', 1_000_000], ['gemini-3', 1_000_000], ['gemini-2.5', 1_000_000], - // DeepSeek — the V4 family is 1M; deepseek-chat / deepseek-reasoner are - // aliases of V4-Flash since April 2026 + // DeepSeek — the V4 family (pro / flash) is 1M. The deepseek-chat / + // deepseek-reasoner aliases were retired 2026-07-24 but can still sit in a + // saved selection, so they keep resolving to the window they had. ['deepseek-v4', 1_000_000], ['deepseek-chat', 1_000_000], ['deepseek-reasoner', 1_000_000], @@ -103,7 +106,11 @@ const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [ // Alibaba — Qwen3-Max is 256K. No qwen family fallback: variant windows range // from 8K (character models) to 1M, too wide for even a conservative guess ['qwen3-max', 256_000], - // Others + // Others — Mistral Medium 3.5 is 256K, reachable under both its version and + // the `-latest` alias. There is deliberately no `mistral-medium` family row: + // pinned older snapshots are 128K, and over-claiming a window overflows it. + ['mistral-medium-3.5', 256_000], + ['mistral-medium-latest', 256_000], ['llama', 128_000], ['codestral', 32_000] ] @@ -187,7 +194,7 @@ const TEXT_ONLY_MODELS = new Set([ 'groq:llama-3.3-70b-versatile', 'groq:llama-3.1-8b-instant', // gpt-oss (text-only everywhere it is hosted) — on groq it succeeds the two - // llama defaults above, which retire 2026-08-16 + // llama entries above, which retire 2026-08-16 'groq:openai/gpt-oss-120b', 'groq:openai/gpt-oss-20b', 'openrouter:openai/gpt-oss-120b', diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.test.ts b/frontend/src/lib/components/copilot/reasoningRegistry.test.ts index 2b27ac9d7b..85fd21ee61 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.test.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.test.ts @@ -51,6 +51,42 @@ describe('supportsReasoning (static registry)', () => { 'max' ]) }) + it('flags the Claude 5 family, which the 4.x-only patterns used to miss', () => { + expect(supportsReasoning('anthropic', 'claude-opus-5')).toBe(true) + expect(supportsReasoning('anthropic', 'claude-sonnet-5')).toBe(true) + expect(supportsReasoning('anthropic', 'claude-mythos-5')).toBe(true) + expect(supportsReasoning('aws_bedrock', 'global.anthropic.claude-opus-5')).toBe(true) + // The 5 family carries the full ladder including xhigh. + for (const model of ['claude-opus-5', 'claude-sonnet-5', 'claude-mythos-5']) { + expect(getReasoningCapability('anthropic', model).levels).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + 'max' + ]) + } + // Opus 5 / Sonnet 5 think when the field is omitted, so off is the + // explicit disable rather than dropping the field. (Mythos, like Fable, + // rejects the disable — covered with Fable below.) + for (const model of ['claude-opus-5', 'claude-sonnet-5']) { + expect(getReasoningCapability('anthropic', model).canDisable).toBe(true) + expect( + resolveRequestReasoning({ provider: 'anthropic', model, reasoning: REASONING_OFF }) + ).toBe('none') + } + // Bedrock translates the same sentinel on its Converse path. + expect( + getReasoningCapability('aws_bedrock', 'global.anthropic.claude-opus-5').canDisable + ).toBe(true) + expect( + resolveRequestReasoning({ + provider: 'aws_bedrock', + model: 'global.anthropic.claude-opus-5', + reasoning: REASONING_OFF + }) + ).toBe('none') + }) it('flags Claude models served through Bedrock, with the Anthropic ladder', () => { expect(supportsReasoning('aws_bedrock', 'us.anthropic.claude-opus-4-6-v1')).toBe(true) expect(supportsReasoning('aws_bedrock', 'anthropic.claude-sonnet-4-6-v1:0')).toBe(true) @@ -88,12 +124,29 @@ describe('supportsReasoning (static registry)', () => { it('flags DeepSeek models with the two effective levels, excluding the chat alias', () => { expect(supportsReasoning('deepseek', 'deepseek-v4-flash')).toBe(true) expect(supportsReasoning('deepseek', 'deepseek-v4-pro')).toBe(true) - expect(supportsReasoning('deepseek', 'deepseek-reasoner')).toBe(true) // `deepseek-chat` is the documented non-thinking mode — no knob. expect(supportsReasoning('deepseek', 'deepseek-chat')).toBe(false) // Only high/max are real; low/medium/xhigh are server-side aliases. expect(getReasoningCapability('deepseek', 'deepseek-v4-flash').levels).toEqual(['high', 'max']) }) + it('exposes the gpt-5.6 ladder, which reopened xhigh and max', () => { + expect(supportsReasoning('openai', 'gpt-5.6-sol')).toBe(true) + expect(getReasoningCapability('openai', 'gpt-5.6-sol').levels).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + 'max' + ]) + expect(getReasoningCapability('openai', 'gpt-5.6-terra').canDisable).toBe(true) + expect(getReasoningCapability('openrouter', 'openai/gpt-5.6-luna').levels).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + 'max' + ]) + }) it('flags OpenAI reasoning families, not gpt-4o', () => { expect(supportsReasoning('openai', 'gpt-5')).toBe(true) expect(supportsReasoning('openai', 'o3')).toBe(true) @@ -126,6 +179,8 @@ describe('supportsReasoning (static registry)', () => { expect(supportsReasoning('mistral', 'ministral-8b-latest')).toBe(false) // 'high' is the only accepted effort token; off = omit the field. expect(getReasoningCapability('mistral', 'mistral-medium-3-5').levels).toEqual(['high']) + // both spellings of the version resolve, so neither is left unsupported + expect(supportsReasoning('mistral', 'mistral-medium-3.5')).toBe(true) expect(getReasoningCapability('mistral', 'mistral-medium-3-5').canDisable).toBe(true) }) it('returns no levels for providers without a registry entry', () => { @@ -178,6 +233,8 @@ describe('supportsReasoning (static registry)', () => { false ) expect(getReasoningCapability('openrouter', 'openai/o3').canDisable).toBe(false) + // Claude 5 routed through OpenRouter keeps the off that the 4 family has. + expect(getReasoningCapability('openrouter', 'anthropic/claude-opus-5').canDisable).toBe(true) expect(getReasoningCapability('openrouter', 'openai/gpt-5-mini').canDisable).toBe(false) expect(getReasoningCapability('openrouter', 'x-ai/grok-4').canDisable).toBe(false) expect(getReasoningCapability('openrouter', 'deepseek/deepseek-r1').canDisable).toBe(false) @@ -255,15 +312,16 @@ describe('Azure AI Foundry reasoning follows the model family', () => { 'xhigh', 'max' ]) - // Off is achieved by omission (Foundry rejects effort 'none'), like Anthropic. + expect(getReasoningCapability('azure_foundry', 'claude-opus-4-8').canDisable).toBe(true) expect(getReasoningCapability('azure_foundry', 'claude-sonnet-5').canDisable).toBe(true) + // Off is the explicit thinking disable, which Foundry serves like Anthropic. expect( resolveRequestReasoning({ provider: 'azure_foundry', model: 'claude-sonnet-5', reasoning: REASONING_OFF }) - ).toBeUndefined() + ).toBe('none') }) it('treats Foundry OpenAI deployments like the OpenAI provider', () => { @@ -337,14 +395,40 @@ describe('resolveRequestReasoning', () => { resolveRequestReasoning({ provider: 'openai', model: 'o3', reasoning: REASONING_OFF }) ).toBeUndefined() }) - it('keeps off as undefined for providers without default-on reasoning', () => { + it('keeps off as undefined only where no disable token exists', () => { + // Fable and Mythos reject an explicit disable, so there is nothing to send. + for (const model of ['claude-fable-5', 'claude-mythos-5']) { + expect( + resolveRequestReasoning({ provider: 'anthropic', model, reasoning: REASONING_OFF }) + ).toBeUndefined() + } expect( resolveRequestReasoning({ - provider: 'anthropic', - model: 'claude-sonnet-4-6', + provider: 'aws_bedrock', + model: 'us.anthropic.claude-opus-4-8-v1', reasoning: REASONING_OFF }) ).toBeUndefined() + // Claude 4.6-4.8 keep omission as their off: it already works there, so + // the explicit disable is scoped to the models that need it. + for (const model of ['claude-opus-4-8', 'claude-opus-4-6', 'claude-sonnet-4-6']) { + expect( + resolveRequestReasoning({ provider: 'anthropic', model, reasoning: REASONING_OFF }) + ).toBeUndefined() + } + }) + + it('turns the Anthropic off sentinel into an explicit thinking disable', () => { + expect( + applyReasoningToConfig({ model: 'claude-opus-5', max_tokens: 1 }, 'anthropic', 'none') + ).toEqual({ model: 'claude-opus-5', max_tokens: 1, thinking: { type: 'disabled' } }) + // An effort still produces adaptive thinking, not the disable. + expect( + applyReasoningToConfig({ model: 'claude-opus-5', max_tokens: 1 }, 'anthropic', 'xhigh') + ).toMatchObject({ + output_config: { effort: 'xhigh' }, + thinking: { type: 'adaptive', display: 'summarized' } + }) }) it('never sends a disable token for non-capable models', () => { expect( diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.ts b/frontend/src/lib/components/copilot/reasoningRegistry.ts index 581715882d..ec4aab933d 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.ts @@ -78,7 +78,7 @@ const PROVIDER_REASONING_LEVELS: Partial> function openrouterReasoningLevels(model: string): ReasoningEffort[] { const m = model.toLowerCase() const base = baseModelId(model) - if (/claude-(opus|sonnet)-4/.test(m)) { + if (/claude-(opus|sonnet)-(4|5)/.test(m)) { return ['minimal', 'low', 'medium', 'high', 'xhigh'] } if (m.includes('gemini-')) { @@ -95,11 +95,15 @@ function openrouterReasoningLevels(model: string): ReasoningEffort[] { /** * OpenAI's effort vocabulary is model-dependent: `minimal` exists on gpt-5 but - * not on gpt-5.1+, `xhigh` only on gpt-5.5; o-series take low/medium/high. - * An unsupported level is rejected, so scope the list to the model. + * not on gpt-5.1+, `xhigh` arrived on gpt-5.5 and `max` on gpt-5.6; o-series + * take low/medium/high. An unsupported level is rejected, so scope the list to + * the model. (`none` is the disable token, handled by `explicitOffToken`.) */ function openaiReasoningLevels(model: string): ReasoningEffort[] { const base = baseModelId(model) + if (/^gpt-5\.6/.test(base)) { + return ['low', 'medium', 'high', 'xhigh', 'max'] + } if (/^gpt-5\.5/.test(base)) { return ['low', 'medium', 'high', 'xhigh'] } @@ -138,18 +142,28 @@ function geminiCanDisable(model: string): boolean { } /** - * Anthropic's effort ladder is model-dependent: `xhigh` exists only on Opus 4.7/4.8 - * and Fable; `max` on Opus 4.6+ and Sonnet 4.6. Offering an unsupported level would - * 400, so scope the list to the model. + * Anthropic's effort ladder is model-dependent: `xhigh` exists on Opus 4.7/4.8, + * the 5 family and Fable/Mythos; `max` also on Opus 4.6 and Sonnet 4.6. + * Offering an unsupported level would 400, so scope the list to the model. */ function anthropicReasoningLevels(model: string): ReasoningEffort[] { const m = model.toLowerCase() - if (/claude-opus-4-(7|8)/.test(m) || m.includes('fable')) { + if ( + /claude-(opus|sonnet)-5/.test(m) || + /claude-opus-4-(7|8)/.test(m) || + m.includes('fable') || + m.includes('mythos') + ) { return ['low', 'medium', 'high', 'xhigh', 'max'] } return ['low', 'medium', 'high', 'max'] } +/** Mistral writes both `mistral-medium-3.5` and `mistral-medium-3-5`. */ +function normalizeMistralId(model: string): string { + return baseModelId(model).replace(/\./g, '-') +} + /** * Conservative static predicate for whether a model accepts an effort knob. * Kept tight to avoid 400s on models that reject reasoning params. @@ -165,7 +179,10 @@ function supportsReasoningStatic(provider: AIProvider, model: string): boolean { // 4.6+ only: Opus 4.5 rejects adaptive thinking (and, on Bedrock, // the whole output_config surface) — live-verified hard 400. return ( - /claude-opus-4-(6|7|8)/.test(m) || /claude-sonnet-(4-6|5)/.test(m) || m.includes('fable') + /claude-opus-(4-(6|7|8)|5)/.test(m) || + /claude-sonnet-(4-6|5)/.test(m) || + m.includes('fable') || + m.includes('mythos') ) case 'openai': case 'azure_openai': @@ -177,7 +194,7 @@ function supportsReasoningStatic(provider: AIProvider, model: string): boolean { return ( base.startsWith('gpt-5') || /^o\d/.test(base) || - /claude-(opus|sonnet)-4/.test(m) || + /claude-(opus|sonnet)-(4|5)/.test(m) || /gemini-(2\.5|3)/.test(m) || m.includes('deepseek-r') || m.includes('deepseek-v4') || @@ -188,14 +205,17 @@ function supportsReasoningStatic(provider: AIProvider, model: string): boolean { return /gemini-(2\.5|3)/.test(m) case 'deepseek': // All current API models take reasoning_effort (live-verified). The - // deprecated `deepseek-chat` alias is excluded: its documented meaning - // is "non-thinking mode", and sending an effort would silently flip it - // into thinking mode — picking that alias is itself an off choice. + // retired `deepseek-chat` alias stays excluded: its documented meaning + // is "non-thinking mode", so a saved selection on it must not silently + // become a thinking request. return base.startsWith('deepseek') && base !== 'deepseek-chat' case 'mistral': // Only the ids verified to accept reasoning_effort; other models // (large, magistral, ministral, pinned versions) reject the param. - return /^mistral-(small|medium)-latest$/.test(base) || base.startsWith('mistral-medium-3-5') + return ( + /^mistral-(small|medium)-latest$/.test(base) || + normalizeMistralId(model).startsWith('mistral-medium-3-5') + ) default: return false } @@ -246,9 +266,10 @@ function canDisableReasoning(provider: AIProvider, model: string): boolean { switch (reasoningProviderFamily(provider, model)) { case 'anthropic': case 'aws_bedrock': - // Claude 4.6+ only think when asked, so omission is a real off — - // except Fable, where thinking is always on (explicit disable 400s). - return !m.includes('fable') + // Every Claude but Fable and Mythos can stop thinking: 4.6-4.8 by + // omission, and the 5 family through the explicit disable that + // `explicitOffToken` sends (both routes translate it). + return !ANTHROPIC_ALWAYS_THINKING.test(m) case 'googleai': return geminiCanDisable(model) case 'openai': @@ -260,7 +281,9 @@ function canDisableReasoning(provider: AIProvider, model: string): boolean { // 'none' is in OpenRouter's vocabulary, but the gateway can't // disable a model whose upstream can't — scope off per underlying // family, like the levels. - if (/claude-(opus|sonnet)-4/.test(m)) { + // The 5 family thinks by default, but its upstream takes an explicit + // disable, so the gateway's 'none' has something to translate to. + if (/claude-(opus|sonnet)-(4|5)/.test(m)) { return true } if (m.includes('gemini-')) { @@ -313,6 +336,17 @@ export function resolveEffectiveReasoning( */ export const DEEPSEEK_OFF_SENTINEL: ReasoningEffort = 'none' +/** + * Sentinel for the Anthropic off case. Like the DeepSeek one it never reaches + * the wire as an effort: the 'anthropic' branch of `applyReasoningToConfig` + * translates it to `thinking: {type: "disabled"}`, which is the only off the + * always-on 5 family respects. + */ +export const ANTHROPIC_OFF_SENTINEL: ReasoningEffort = 'none' + +/** Claude models whose thinking cannot be turned off — an explicit disable 400s. */ +const ANTHROPIC_ALWAYS_THINKING = /fable|mythos/ + /** * Disable token to forward when the user explicitly turns reasoning off on a * model that reasons *by default* — omitting the field would silently keep @@ -320,6 +354,15 @@ export const DEEPSEEK_OFF_SENTINEL: ReasoningEffort = 'none' */ export function explicitOffToken(provider: AIProvider, model: string): ReasoningEffort | undefined { switch (reasoningProviderFamily(provider, model)) { + case 'anthropic': + case 'aws_bedrock': + // Claude 4.6-4.8 only think when asked, so omission is already a + // real off there and stays the wire form. Only the 5 family, which + // thinks when the field is absent, needs the explicit disable — + // Fable and Mythos reject it outright and get no off token at all. + return /claude-(opus|sonnet)-5/.test(model.toLowerCase()) + ? ANTHROPIC_OFF_SENTINEL + : undefined case 'googleai': // Gemini 2.5/3 think by default (dynamic budget / level). The backend // proxy maps 'none' to off on Flash, or the floor on Pro (only @@ -390,6 +433,12 @@ export function applyReasoningToConfig>( } switch (apiKind) { case 'anthropic': { + // The disable must not carry an effort: Opus 5 rejects it at xhigh + // and max, and dropping the field leaves the model at its default + // effort, where the disable is accepted. + if (effort === ANTHROPIC_OFF_SENTINEL) { + return { ...config, thinking: { type: 'disabled' } } as unknown as T + } // Adaptive thinking rejects sampling params; strip them when reasoning is on. const { temperature: _t, top_p: _p, top_k: _k, ...rest } = config as Record return {