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) <noreply@anthropic.com>

* fix: send explicit thinking disable for Claude and cap Opus 4.1 output

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve mistral-medium-latest window and OpenRouter Claude 5 off

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: cover au. bedrock geo and Fable 5 caching, revert unverified mistral ladder

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: scope the Anthropic explicit disable to models that think by default

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: translate the reasoning off sentinel on the backend Anthropic path

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: translate the reasoning off sentinel on the Bedrock Converse path

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: share the reasoning off sentinel and make its translation testable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-14 18:41:40 +02:00
committed by GitHub
parent 850b028778
commit 68fc7825bb
11 changed files with 416 additions and 75 deletions
+42 -1
View File
@@ -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<SystemContentBlock>) {
@@ -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"
));
}
}
+91 -14
View File
@@ -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<AnthropicRequestContent>,
}
/// 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<f32>,
) -> (
Option<AnthropicThinking>,
Option<AnthropicOutputConfig>,
Option<f32>,
) {
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 {
+35 -11
View File
@@ -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"));
+6
View File
@@ -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::{
@@ -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,
@@ -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')
@@ -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)
+40 -19
View File
@@ -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<AIProvider, AIProviderDetails> = {
@@ -75,17 +80,18 @@ export const AI_PROVIDERS: Record<AIProvider, AIProviderDetails> = {
},
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<AIProvider, AIProviderDetails> = {
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<AIProvider, AIProviderDetails> = {
},
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')) {
@@ -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',
@@ -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(
@@ -78,7 +78,7 @@ const PROVIDER_REASONING_LEVELS: Partial<Record<AIProvider, ReasoningEffort[]>>
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<T extends Record<string, any>>(
}
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<string, any>
return {