mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
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:
@@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user