mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 16:03:21 +00:00
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:
co-authored by
Claude Opus 4.8
parent
ae85d27437
commit
d600c7ecfe
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user