fix(ai): route Azure Foundry Claude models via Anthropic Messages API (#9908)

* fix(ai): route Azure Foundry Claude models via Anthropic Messages API

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

* fix(ai): keep explicit Azure OpenAI deployment base URLs intact

build_azure_openai_url only appends /openai/v1 for a bare resource root; any base with an explicit path (e.g. .../openai/deployments/<id>) is preserved. Adds a regression test and a unit test for usesAnthropicMessagesApi.

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

* feat(ai): enable Claude extended thinking on Azure Foundry

Route azure_foundry+Claude through the Anthropic reasoning branch (adaptive thinking + output_config.effort) instead of the gpt/o gate, and recognize claude-sonnet-5. Live-verified: sonnet-5 and opus-4-8 on Foundry accept the low/medium/high/xhigh/max ladder and render summarized thinking.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-07-03 18:26:12 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ae85d27437
commit d600c7ecfe
11 changed files with 336 additions and 28 deletions
+137 -2
View File
@@ -145,18 +145,75 @@ impl AIProvider {
|| matches!(self, AIProvider::AzureOpenAI | AIProvider::AzureFoundry)
}
/// Build an Azure-style URL (Azure OpenAI / Azure AI Foundry) for the given path
/// Build an Azure-style OpenAI-compatible URL (Azure OpenAI / Azure AI Foundry)
/// for the given path. The resource base URL may be stored as the bare resource
/// root (e.g. `https://<res>.services.ai.azure.com`) or with a legacy `/openai`
/// or `/openai/v1` suffix (older Foundry resources shipped that way); those forms
/// resolve to the canonical `<root>/openai/v1/<path>`. Any other explicit path
/// (e.g. an Azure OpenAI `.../openai/deployments/<id>` base) is preserved as-is
/// with only `/<path>` appended.
pub fn build_azure_openai_url(base_url: &str, path: &str) -> String {
let base_url = base_url.trim_end_matches('/');
if base_url.ends_with("/openai") {
if base_url.ends_with("/openai/v1") {
format!("{}/{}", base_url, path)
} else if base_url.ends_with("/openai") {
format!("{}/v1/{}", base_url, path)
} else if base_url.ends_with("/deployments") {
format!("{}/v1/{}", base_url.trim_end_matches("/deployments"), path)
} else if Self::is_bare_host(base_url) {
// A resource root with no path (Foundry convention, or an Azure OpenAI
// resource root) targets the OpenAI-compatible v1 surface.
format!("{}/openai/v1/{}", base_url, path)
} else {
// Any other explicit base path (e.g. an Azure OpenAI deployment URL
// `.../openai/deployments/<id>`) is kept intact.
format!("{}/{}", base_url, path)
}
}
/// Whether the URL is a bare scheme+host with no path component, e.g.
/// `https://x.services.ai.azure.com` (vs `https://x.openai.azure.com/openai/deployments/y`).
fn is_bare_host(url: &str) -> bool {
let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
!after_scheme.contains('/')
}
/// Strip any known Foundry API sub-path from the resource base URL to recover the
/// resource root, so the correct per-model-family path can be appended. Handles
/// both the current root-URL convention and legacy `/openai/v1`-style values.
fn azure_foundry_root(base_url: &str) -> &str {
let base_url = base_url.trim_end_matches('/');
for suffix in [
"/openai/v1",
"/anthropic/v1",
"/openai",
"/anthropic",
"/models",
] {
if let Some(root) = base_url.strip_suffix(suffix) {
return root.trim_end_matches('/');
}
}
base_url
}
/// Build an Azure AI Foundry Anthropic Messages API URL. Claude deployments on
/// Foundry are served only through `<root>/anthropic/v1/...`, not the
/// OpenAI-compatible `/openai/v1` surface.
pub fn build_azure_foundry_anthropic_url(base_url: &str, path: &str) -> String {
format!(
"{}/anthropic/v1/{}",
Self::azure_foundry_root(base_url),
path
)
}
/// Whether a Foundry deployment name refers to an Anthropic (Claude) model,
/// which requires the Anthropic Messages API rather than OpenAI chat completions.
pub fn is_anthropic_model(model: &str) -> bool {
model.to_lowercase().starts_with("claude")
}
/// Extract model from request body (needed for Azure deployments)
pub fn extract_model_from_body(body: &[u8]) -> Result<String> {
#[derive(serde::Deserialize)]
@@ -194,3 +251,81 @@ pub struct ProviderModel {
pub model: String,
pub provider: AIProvider,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn azure_openai_url_handles_root_and_legacy_suffixes() {
// Current convention: resource stores the bare root.
assert_eq!(
AIProvider::build_azure_openai_url(
"https://wm-test-ai.services.ai.azure.com",
"chat/completions"
),
"https://wm-test-ai.services.ai.azure.com/openai/v1/chat/completions"
);
// Legacy resources that already baked in /openai/v1 must still resolve.
assert_eq!(
AIProvider::build_azure_openai_url(
"https://wm-test-ai.services.ai.azure.com/openai/v1/",
"chat/completions"
),
"https://wm-test-ai.services.ai.azure.com/openai/v1/chat/completions"
);
// Azure OpenAI resources typically end in /openai.
assert_eq!(
AIProvider::build_azure_openai_url(
"https://example.openai.azure.com/openai",
"chat/completions"
),
"https://example.openai.azure.com/openai/v1/chat/completions"
);
assert_eq!(
AIProvider::build_azure_openai_url(
"https://example.openai.azure.com/openai/deployments",
"chat/completions"
),
"https://example.openai.azure.com/openai/v1/chat/completions"
);
// An Azure OpenAI base that pins a specific deployment must be preserved
// as-is (not have /openai/v1 appended after the deployment id).
assert_eq!(
AIProvider::build_azure_openai_url(
"https://example.openai.azure.com/openai/deployments/my-deployment",
"chat/completions"
),
"https://example.openai.azure.com/openai/deployments/my-deployment/chat/completions"
);
}
#[test]
fn azure_foundry_anthropic_url_from_root_and_legacy() {
// Root URL.
assert_eq!(
AIProvider::build_azure_foundry_anthropic_url(
"https://wm-test-ai.services.ai.azure.com",
"messages"
),
"https://wm-test-ai.services.ai.azure.com/anthropic/v1/messages"
);
// Legacy /openai/v1 base is normalized back to the root, then routed to
// the Anthropic Messages API.
assert_eq!(
AIProvider::build_azure_foundry_anthropic_url(
"https://wm-test-ai.services.ai.azure.com/openai/v1",
"messages"
),
"https://wm-test-ai.services.ai.azure.com/anthropic/v1/messages"
);
}
#[test]
fn detects_anthropic_models() {
assert!(AIProvider::is_anthropic_model("claude-sonnet-5"));
assert!(AIProvider::is_anthropic_model("Claude-Opus-4-8"));
assert!(!AIProvider::is_anthropic_model("gpt-4o"));
assert!(!AIProvider::is_anthropic_model("DeepSeek-R1"));
}
}
+52 -6
View File
@@ -380,6 +380,13 @@ impl AnthropicQueryBuilder {
self.platform == AIPlatform::GoogleVertexAi
}
/// Claude models hosted on Azure AI Foundry: the Anthropic Messages API is
/// served under the resource's `/anthropic/v1` path rather than at the
/// Anthropic public base URL.
fn is_azure_foundry(&self) -> bool {
matches!(self.provider_kind, AIProvider::AzureFoundry)
}
fn transform_proxy_body_for_vertex(body: &[u8]) -> Result<(String, Vec<u8>), Error> {
let mut json_body: std::collections::HashMap<String, serde_json::Value> =
serde_json::from_slice(body).map_err(|e| {
@@ -426,6 +433,15 @@ impl AnthropicQueryBuilder {
format!("{}/{}:streamRawPredict", base_url, model),
transformed_body,
)
} else if self.is_azure_foundry() {
// Claude on Foundry is served under the resource's /anthropic/v1 path,
// not the resource's /openai/v1 base. The Anthropic SDK sends the path
// as "v1/messages", so drop its leading "v1/" before re-appending.
let path = args.path.trim_start_matches("v1/");
(
AIProvider::build_azure_foundry_anthropic_url(base_url, path),
body,
)
} else if is_anthropic_sdk {
let truncated_base_url = base_url.trim_end_matches("/v1");
(format!("{}/{}", truncated_base_url, args.path), body)
@@ -688,6 +704,8 @@ impl QueryBuilder for AnthropicQueryBuilder {
base_url.trim_end_matches('/'),
model
)
} else if self.is_azure_foundry() {
AIProvider::build_azure_foundry_anthropic_url(base_url, "messages")
} else {
format!("{}/messages", base_url)
}
@@ -748,8 +766,7 @@ mod tests {
#[test]
fn builds_standard_anthropic_proxy_request() {
let credentials = credentials(AIPlatform::Standard);
let builder =
AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::Standard);
let builder = AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::Standard);
let method = Method::POST;
let mut headers = HeaderMap::new();
headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01"));
@@ -786,8 +803,7 @@ mod tests {
let mut credentials = credentials(AIPlatform::GoogleVertexAi);
credentials.base_url = "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/anthropic/models".to_string();
credentials.user = Some("user-1".to_string());
let builder =
AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::GoogleVertexAi);
let builder = AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::GoogleVertexAi);
let method = Method::POST;
let mut headers = HeaderMap::new();
headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01"));
@@ -830,8 +846,7 @@ mod tests {
#[test]
fn rejects_vertex_proxy_request_without_model() {
let credentials = credentials(AIPlatform::GoogleVertexAi);
let builder =
AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::GoogleVertexAi);
let builder = AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::GoogleVertexAi);
let method = Method::POST;
let headers = HeaderMap::new();
@@ -847,4 +862,35 @@ mod tests {
assert!(matches!(err, Error::BadRequest(message) if message.contains("Missing 'model'")));
}
#[test]
fn builds_azure_foundry_anthropic_proxy_request() {
// Foundry resource stored with a legacy /openai/v1 suffix; the Anthropic SDK
// sends the path as "v1/messages". Both must resolve to the resource's
// /anthropic/v1/messages surface.
let mut credentials = credentials(AIPlatform::Standard);
credentials.provider = AIProvider::AzureFoundry;
credentials.base_url = "https://wm-test-ai.services.ai.azure.com/openai/v1".to_string();
let builder = AnthropicQueryBuilder::new(AIProvider::AzureFoundry, AIPlatform::Standard);
let method = Method::POST;
let mut headers = HeaderMap::new();
headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01"));
headers.insert("X-Anthropic-SDK", HeaderValue::from_static("true"));
let request = builder
.build_proxy_request(&ProxyBuildArgs {
method: &method,
path: "v1/messages",
headers: &headers,
body: br#"{"model":"claude-sonnet-5","messages":[]}"#,
credentials: &credentials,
})
.unwrap();
assert_eq!(
request.url,
"https://wm-test-ai.services.ai.azure.com/anthropic/v1/messages"
);
assert!(has_header(&request.headers, "X-API-Key", "api-key"));
}
}
+15 -2
View File
@@ -7,7 +7,9 @@ pub mod openrouter;
pub mod other;
use crate::{
ai_providers::AIProvider, credentials::ProviderCredentials, query_builder::QueryBuilder,
ai_providers::{AIPlatform, AIProvider},
credentials::ProviderCredentials,
query_builder::QueryBuilder,
};
use self::{
@@ -16,7 +18,15 @@ use self::{
};
/// Factory function to create the appropriate query builder from resolved credentials.
pub fn create_query_builder(credentials: &ProviderCredentials) -> Box<dyn QueryBuilder> {
///
/// `model` is the deployment/model name of the request. It matters only for Azure AI
/// Foundry, which fronts multiple model families under one resource: Claude
/// deployments speak the Anthropic Messages API while everything else is
/// OpenAI-compatible, so the builder is chosen per model rather than per provider.
pub fn create_query_builder(
credentials: &ProviderCredentials,
model: &str,
) -> Box<dyn QueryBuilder> {
match credentials.provider {
AIProvider::GoogleAI => Box::new(GoogleAIQueryBuilder::new(credentials.platform.clone())),
AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(credentials.provider.clone())),
@@ -25,6 +35,9 @@ pub fn create_query_builder(credentials: &ProviderCredentials) -> Box<dyn QueryB
credentials.platform.clone(),
)),
AIProvider::OpenRouter => Box::new(OpenRouterQueryBuilder::new()),
AIProvider::AzureFoundry if AIProvider::is_anthropic_model(model) => Box::new(
AnthropicQueryBuilder::new(AIProvider::AzureFoundry, AIPlatform::Standard),
),
_ => Box::new(OtherQueryBuilder::new(credentials.provider.clone())),
}
}
+27
View File
@@ -305,4 +305,31 @@ mod tests {
assert_eq!(body["user"], "user-1");
assert_eq!(body["model"], "gpt-4o");
}
#[test]
fn foundry_routes_claude_to_anthropic_messages_api() {
use crate::providers::create_query_builder;
use crate::types::OutputType;
let creds = credentials(
AIProvider::AzureFoundry,
"https://wm-test-ai.services.ai.azure.com/openai/v1",
);
// Claude deployment -> Anthropic Messages API surface + x-api-key auth.
let claude = create_query_builder(&creds, "claude-sonnet-5");
assert_eq!(
claude.get_endpoint(&creds.base_url, "claude-sonnet-5", &OutputType::Text),
"https://wm-test-ai.services.ai.azure.com/anthropic/v1/messages"
);
let auth = claude.get_auth_headers("api-key", &creds.base_url, &OutputType::Text);
assert!(auth.contains(&("x-api-key", "api-key".to_string())));
// OpenAI-compatible deployment -> chat completions surface.
let gpt = create_query_builder(&creds, "gpt-4o");
assert_eq!(
gpt.get_endpoint(&creds.base_url, "gpt-4o", &OutputType::Text),
"https://wm-test-ai.services.ai.azure.com/openai/v1/chat/completions"
);
}
}
+8 -2
View File
@@ -571,7 +571,10 @@ async fn global_proxy(
let request = match proxy_mode {
ProxyExecutionMode::HttpForward => {
let query_builder = create_query_builder(&credentials);
// Azure AI Foundry routes Claude deployments through the Anthropic
// Messages API, so the builder is chosen from the request's model.
let model = AIProvider::extract_model_from_body(&body).unwrap_or_default();
let query_builder = create_query_builder(&credentials, &model);
let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs {
method: &method,
path: &ai_path,
@@ -873,7 +876,10 @@ async fn proxy(
let request = match proxy_mode {
ProxyExecutionMode::HttpForward => {
let query_builder = create_query_builder(&credentials);
// Azure AI Foundry routes Claude deployments through the Anthropic
// Messages API, so the builder is chosen from the request's model.
let model = AIProvider::extract_model_from_body(&body).unwrap_or_default();
let query_builder = create_query_builder(&credentials, &model);
let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs {
method: &method,
path: &ai_path,
+1 -1
View File
@@ -591,7 +591,7 @@ pub async fn run_agent(
let api_key = credentials.api_key.as_deref().unwrap_or("");
// Create the query builder for the provider
let query_builder = create_query_builder(&credentials);
let query_builder = create_query_builder(&credentials, args.provider.get_model());
// Initialize messages
let mut messages =
@@ -8,6 +8,7 @@ import type {
import { getCompletion, parseOpenAICompletion, providerSupportsWebSearch } from '../lib'
import { resolveRequestReasoning, type ReasoningProviderModel } from '../reasoningRegistry'
import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic'
import { usesAnthropicMessagesApi } from '../modelConfig'
import { getOpenAIResponsesCompletion, parseOpenAIResponsesCompletion } from './openai-responses'
import type { Tool, ToolCallbacks } from './shared'
import { addChatTokenUsage, emptyChatTokenUsage, type ChatTokenUsage } from './tokenUsage'
@@ -257,7 +258,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
const isOpenAI =
modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai'
const isAnthropic = modelProvider.provider === 'anthropic'
const isAnthropic = usesAnthropicMessagesApi(modelProvider.provider, modelProvider.model)
// Resolve effort once in chat context (applies the default-on level for
// capable models, and the provider-native disable token for an explicit
// off on reasoning-by-default providers); passed explicitly to each seam
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { usesAnthropicMessagesApi } from './modelConfig'
describe('usesAnthropicMessagesApi', () => {
it('routes the native Anthropic provider through the Messages API', () => {
expect(usesAnthropicMessagesApi('anthropic', 'claude-sonnet-5')).toBe(true)
})
it('routes Azure Foundry Claude deployments through the Messages API', () => {
expect(usesAnthropicMessagesApi('azure_foundry', 'claude-sonnet-5')).toBe(true)
expect(usesAnthropicMessagesApi('azure_foundry', 'Claude-Opus-4-8')).toBe(true)
})
it('keeps other Azure Foundry models on the OpenAI-compatible path', () => {
expect(usesAnthropicMessagesApi('azure_foundry', 'gpt-4o')).toBe(false)
expect(usesAnthropicMessagesApi('azure_foundry', 'DeepSeek-R1')).toBe(false)
})
it('does not affect other providers', () => {
expect(usesAnthropicMessagesApi('openai', 'gpt-4o')).toBe(false)
expect(usesAnthropicMessagesApi('azure_openai', 'gpt-4o')).toBe(false)
})
})
@@ -1,3 +1,17 @@
import type { AIProvider } from '$lib/gen'
// Azure AI Foundry fronts multiple model families under one resource. Claude
// deployments are served only through the Anthropic Messages API, so the chat must
// route them like the native Anthropic provider (Anthropic SDK, message format)
// rather than the OpenAI-compatible surface used for the rest of Foundry's catalog.
// Mirrors the backend `AIProvider::is_anthropic_model`.
export function usesAnthropicMessagesApi(provider: AIProvider, model: string): boolean {
return (
provider === 'anthropic' ||
(provider === 'azure_foundry' && model.toLowerCase().startsWith('claude'))
)
}
// gpt-5+ and o-series reasoning models reject the legacy `max_tokens` field on
// the OpenAI/Azure Chat Completions API and require `max_completion_tokens`
// instead. The check strips any provider prefix (e.g. OpenRouter's "openai/o3")
@@ -243,6 +243,36 @@ describe('supportsReasoning (static registry)', () => {
})
})
describe('Azure AI Foundry reasoning follows the model family', () => {
it('treats Foundry Claude deployments like the Anthropic provider', () => {
// Live-verified: Foundry Claude accepts the adaptive-thinking effort ladder.
expect(supportsReasoning('azure_foundry', 'claude-sonnet-5')).toBe(true)
expect(supportsReasoning('azure_foundry', 'claude-opus-4-8')).toBe(true)
expect(getReasoningCapability('azure_foundry', 'claude-opus-4-8').levels).toEqual([
'low',
'medium',
'high',
'xhigh',
'max'
])
// Off is achieved by omission (Foundry rejects effort 'none'), like Anthropic.
expect(getReasoningCapability('azure_foundry', 'claude-sonnet-5').canDisable).toBe(true)
expect(
resolveRequestReasoning({
provider: 'azure_foundry',
model: 'claude-sonnet-5',
reasoning: REASONING_OFF
})
).toBeUndefined()
})
it('treats Foundry OpenAI deployments like the OpenAI provider', () => {
expect(supportsReasoning('azure_foundry', 'gpt-5.1')).toBe(true)
expect(supportsReasoning('azure_foundry', 'gpt-4o')).toBe(false)
expect(supportsReasoning('azure_foundry', 'DeepSeek-R1')).toBe(false)
})
})
describe('resolveEffectiveReasoning', () => {
it('defaults capable models to high when unset', () => {
expect(resolveEffectiveReasoning({ provider: 'anthropic', model: 'claude-sonnet-4-6' })).toBe(
@@ -1,4 +1,5 @@
import type { AIProvider, AIProviderModel } from '$lib/gen'
import { usesAnthropicMessagesApi } from './modelConfig'
/**
* Reasoning effort is provider/model-specific. We never normalize a single
@@ -38,6 +39,20 @@ function baseModelId(model: string): string {
return normalized.split('/').pop() ?? normalized
}
/**
* Azure AI Foundry hosts multiple model families under one provider, so reasoning
* support follows the underlying model rather than the provider: Claude deployments
* reason like the native Anthropic provider (adaptive thinking + `output_config.effort`),
* everything else (gpt-5 / o-series) like OpenAI. Resolving to the owning family here
* lets the rest of the registry keep its per-family logic unchanged.
*/
function reasoningProviderFamily(provider: AIProvider, model: string): AIProvider {
if (provider === 'azure_foundry') {
return usesAnthropicMessagesApi(provider, model) ? 'anthropic' : 'openai'
}
return provider
}
/**
* Suggested effort levels per provider, sourced from each provider SDK's own
* vocabulary.
@@ -143,19 +158,18 @@ function anthropicReasoningLevels(model: string): ReasoningEffort[] {
function supportsReasoningStatic(provider: AIProvider, model: string): boolean {
const m = model.toLowerCase()
const base = baseModelId(model)
switch (provider) {
switch (reasoningProviderFamily(provider, model)) {
case 'anthropic':
// Bedrock serves the same Claude models under prefixed ids
// (e.g. `us.anthropic.claude-opus-4-6-v1`), so match on the full string.
case 'aws_bedrock':
// 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/.test(m) || m.includes('fable')
return (
/claude-opus-4-(6|7|8)/.test(m) || /claude-sonnet-(4-6|5)/.test(m) || m.includes('fable')
)
case 'openai':
case 'azure_openai':
// Azure AI Foundry hosts OpenAI models too; the model-id gate keeps
// this a no-op for its non-OpenAI catalog (Llama/DeepSeek/etc.).
case 'azure_foundry':
return base.startsWith('gpt-5') || /^o\d/.test(base)
case 'openrouter':
// Best-effort markers for models whose `supported_parameters` include
@@ -207,16 +221,17 @@ export function getReasoningCapability(provider: AIProvider, model: string): Rea
if (!supported) {
return { supported: false, levels: [], canDisable: false }
}
const family = reasoningProviderFamily(provider, bareModel)
const levels =
provider === 'anthropic' || provider === 'aws_bedrock'
family === 'anthropic' || family === 'aws_bedrock'
? anthropicReasoningLevels(bareModel)
: provider === 'googleai'
: family === 'googleai'
? geminiReasoningLevels(bareModel)
: provider === 'openai' || provider === 'azure_openai' || provider === 'azure_foundry'
: family === 'openai' || family === 'azure_openai'
? openaiReasoningLevels(bareModel)
: provider === 'openrouter'
: family === 'openrouter'
? openrouterReasoningLevels(bareModel)
: (PROVIDER_REASONING_LEVELS[provider] ?? ['low', 'medium', 'high'])
: (PROVIDER_REASONING_LEVELS[family] ?? ['low', 'medium', 'high'])
return { supported, levels, canDisable: canDisableReasoning(provider, bareModel) }
}
@@ -229,7 +244,7 @@ export function getReasoningCapability(provider: AIProvider, model: string): Rea
function canDisableReasoning(provider: AIProvider, model: string): boolean {
const m = model.toLowerCase()
const base = baseModelId(model)
switch (provider) {
switch (reasoningProviderFamily(provider, model)) {
case 'anthropic':
case 'aws_bedrock':
// Claude 4.6+ only think when asked, so omission is a real off —
@@ -239,7 +254,6 @@ function canDisableReasoning(provider: AIProvider, model: string): boolean {
return geminiCanDisable(model)
case 'openai':
case 'azure_openai':
case 'azure_foundry':
// gpt-5.1+ accept effort 'none'; gpt-5 and o-series reject it and
// reason at `medium` by default, so omission isn't off either.
return /^gpt-5\./.test(base)
@@ -306,7 +320,7 @@ export const DEEPSEEK_OFF_SENTINEL: ReasoningEffort = 'none'
* the default-on behavior. Undefined means omission is the correct off.
*/
function explicitOffToken(provider: AIProvider, model: string): ReasoningEffort | undefined {
switch (provider) {
switch (reasoningProviderFamily(provider, model)) {
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
@@ -316,7 +330,6 @@ function explicitOffToken(provider: AIProvider, model: string): ReasoningEffort
return DEEPSEEK_OFF_SENTINEL
case 'openai':
case 'azure_openai':
case 'azure_foundry':
// gpt-5.1+ reasoning is off only via the explicit 'none' effort
// (gpt-5.5 defaults to medium when the field is omitted).
return /^gpt-5\./.test(baseModelId(model)) ? 'none' : undefined