From d600c7ecfe305533798e82e8d05e5f2f297f9b54 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 3 Jul 2026 18:26:12 +0200 Subject: [PATCH] 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) * 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/) is preserved. Adds a regression test and a unit test for usesAnthropicMessagesApi. Co-Authored-By: Claude Opus 4.8 (1M context) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/windmill-ai/src/ai_providers.rs | 139 +++++++++++++++++- .../windmill-ai/src/providers/anthropic.rs | 58 +++++++- backend/windmill-ai/src/providers/mod.rs | 17 ++- backend/windmill-ai/src/proxy.rs | 27 ++++ backend/windmill-api/src/ai.rs | 10 +- backend/windmill-worker/src/ai_executor.rs | 2 +- .../lib/components/copilot/chat/chatLoop.ts | 3 +- .../components/copilot/modelConfig.test.ts | 23 +++ .../src/lib/components/copilot/modelConfig.ts | 14 ++ .../copilot/reasoningRegistry.test.ts | 30 ++++ .../components/copilot/reasoningRegistry.ts | 41 ++++-- 11 files changed, 336 insertions(+), 28 deletions(-) create mode 100644 frontend/src/lib/components/copilot/modelConfig.test.ts diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index 776228cea7..551ba3129b 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -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://.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 `/openai/v1/`. Any other explicit path + /// (e.g. an Azure OpenAI `.../openai/deployments/` base) is preserved as-is + /// with only `/` 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/`) 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 `/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 { #[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")); + } +} diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs index 05398ce212..c2064f2029 100644 --- a/backend/windmill-ai/src/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -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), Error> { let mut json_body: std::collections::HashMap = 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")); + } } diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index 80fbc8fcca..ab4f670b17 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -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 { +/// +/// `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 { 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 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())), } } diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index 5de4d231c4..fe4a0229d4 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -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" + ); + } } diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 6195fe5644..89dff4cfd6 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -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, diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 2591cc6495..cc346f98d4 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -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 = diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts index 0e5e4923d7..914f95de8f 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -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 { + 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) + }) +}) diff --git a/frontend/src/lib/components/copilot/modelConfig.ts b/frontend/src/lib/components/copilot/modelConfig.ts index 4ab08ce02c..af0dd942b2 100644 --- a/frontend/src/lib/components/copilot/modelConfig.ts +++ b/frontend/src/lib/components/copilot/modelConfig.ts @@ -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") diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.test.ts b/frontend/src/lib/components/copilot/reasoningRegistry.test.ts index 42f0006a1d..2b27ac9d7b 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.test.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.test.ts @@ -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( diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.ts b/frontend/src/lib/components/copilot/reasoningRegistry.ts index ca4525298f..4776e64c79 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.ts @@ -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