diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index 7dcddc93a3..82dceaa02f 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -30,6 +30,11 @@ pub fn effective_reasoning_effort<'a>(model: &str, effort: Option<&'a str>) -> O } } +/// Whether Chat Completions refuses function tools while this model reasons. +pub(crate) fn completions_rejects_tools_with_reasoning(model: &str) -> bool { + reasoning_rule(model).is_some_and(|rule| rule.completions_tools_need_off) +} + /// Whether a request with function tools must send the off sentinel on Chat Completions: /// the model refuses tools there while it reasons, and does accept being turned off. pub(crate) fn completions_tools_need_reasoning_off(model: &str) -> bool { @@ -208,6 +213,12 @@ pub fn create_query_builder( AIProvider::AzureFoundry if AIProvider::is_anthropic_model(model) => Box::new( AnthropicQueryBuilder::new(AIProvider::AzureFoundry, AIPlatform::Standard), ), + // Foundry serves its OpenAI models on `/openai/v1/responses` too. `/chat/completions` + // takes function tools from these only with reasoning off, which gpt-6.1-sol and + // gpt-6-astra cannot do at all. + AIProvider::AzureFoundry if completions_rejects_tools_with_reasoning(model) => { + Box::new(OpenAIQueryBuilder::new(AIProvider::AzureFoundry)) + } // Only for forwarding through the API proxy, which is bearer auth on `/` as // TypeSafe and Cloudflare expect. Neither serves a chat route for its decision models, and // `run_agent` refuses both before a chat request could be built. @@ -408,6 +419,12 @@ mod parity_tests { "claude-sonnet-5", "v1/messages", ), + case( + AIProvider::AzureFoundry, + "https://example.services.ai.azure.com", + "gpt-6.1-sol", + "responses", + ), case( AIProvider::AzureOpenAI, "https://example.openai.azure.com/openai", diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 28ba52e261..8bbc45721d 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -57,7 +57,7 @@ import { } from './script/core' import { prepareNavigatorUserMessage } from './navigator/core' import { sendUserToast } from '$lib/toast' -import { workspaceAIClients, getStreamedCompletionText } from '../lib' +import { AI_PROVIDERS, workspaceAIClients, getStreamedCompletionText } from '../lib' import { logFeatureUsage } from '$lib/utils/featureUsage' import { modelSupportsVision } from '../modelConfig' import { getEffectiveModelContextWindow } from '../modelConfig' @@ -251,12 +251,12 @@ const WEB_SEARCH_ERROR_HINT = const REASONING_SUMMARY_WARNED_STORAGE_KEY = 'ai-chat-reasoning-summary-unverified-warned' function providerDisplayName(provider: string): string { - return provider === 'azure_openai' ? 'Azure OpenAI' : 'OpenAI' + return AI_PROVIDERS[provider as keyof typeof AI_PROVIDERS]?.label ?? 'OpenAI' } function reasoningSummaryUnavailableMessage(provider: string): string { const verifyHint = - provider === 'azure_openai' + provider === 'azure_openai' || provider === 'azure_foundry' ? 'To display it, verify your organization with your provider, then reload this page.' : 'To display it, verify your organization in the OpenAI platform settings (Settings > General), then reload this page.' return `This model is reasoning, but your ${providerDisplayName(provider)} organization is not verified to generate reasoning summaries, so its thinking stays hidden. ${verifyHint}` diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts index 282e7e1aa6..af4b648b61 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts @@ -29,7 +29,9 @@ vi.mock('../lib', () => ({ providerSupportsWebSearch: mocks.providerSupportsWebSearch })) -vi.mock('../reasoningRegistry', () => ({ +vi.mock('../reasoningRegistry', async (importOriginal) => ({ + usesOpenAIResponsesApi: (await importOriginal()) + .usesOpenAIResponsesApi, resolveRequestReasoning: mocks.resolveRequestReasoning, resolveEffectiveReasoning: mocks.resolveEffectiveReasoning })) diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts index 1e27becba0..343dd268e7 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -10,6 +10,7 @@ import { getCompletion, parseOpenAICompletion, providerSupportsWebSearch } from import { resolveEffectiveReasoning, resolveRequestReasoning, + usesOpenAIResponsesApi, type ReasoningProviderModel } from '../reasoningRegistry' import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic' @@ -427,8 +428,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise { expect(completionsRejectsToolsWithReasoning('azure_openai', model), model).toBe(false) } }) + it('sends Foundry gpt models that refuse tools on Chat Completions to the Responses API', () => { + expect(usesOpenAIResponsesApi('azure_foundry', 'gpt-6.1-sol')).toBe(true) + for (const model of ['gpt-4o', 'DeepSeek-V4-Pro', 'claude-sonnet-5']) { + expect(usesOpenAIResponsesApi('azure_foundry', model), model).toBe(false) + } + }) it('forwards an explicit off as effort none through OpenRouter', () => { expect( resolveRequestReasoning({ diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.ts b/frontend/src/lib/components/copilot/reasoningRegistry.ts index 0cbf935832..1cce6e3b0d 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.ts @@ -263,6 +263,19 @@ export function completionsRejectsToolsWithReasoning(provider: AIProvider, model return findReasoningRule(provider, model).rule?.completionsToolsNeedOff ?? false } +/** + * Whether the chat sends this model to the Responses API, falling back to Chat Completions + * where a deployment does not serve it. On Foundry, only the models Chat Completions refuses + * function tools from while they reason: gpt-6.1-sol and gpt-6-astra cannot turn it off. + */ +export function usesOpenAIResponsesApi(provider: AIProvider, model: string): boolean { + return ( + provider === 'openai' || + provider === 'azure_openai' || + (provider === 'azure_foundry' && completionsRejectsToolsWithReasoning(provider, model)) + ) +} + export type ReasoningCapability = { supported: boolean /** Suggested levels for the UI control. Empty when unsupported. */