feat(ai): add Azure AI Foundry as a native AI provider (#9879)

* feat(ai): add Azure AI Foundry as a native AI provider

Adds `azure_foundry` as a new AIProvider variant wired through the AI
chat (copilot) and AI agent flow steps. Foundry's chat completions API
is OpenAI-compatible and uses Azure conventions (api-key header, Azure
URL building), so it reuses the existing OpenAI-compatible query builder
and proxy path via the shared `is_azure` helper (renamed from
`is_azure_openai`).

Backend (windmill-ai):
- New `AzureFoundry` enum variant (serde `azure_foundry`)
- `get_base_url` requires a resource base URL (like Azure OpenAI / Custom)
- `is_azure()` covers Azure OpenAI + Foundry (api-key auth, Azure URL)
- Added to OpenAI-compatible proxy support and HttpForward proxy mode
- New proxy URL unit test

Frontend (copilot):
- New provider entry, completion config, model-token handling, streamed
  usage tracking, and reasoning registry (all model-id-gated, so a no-op
  for Foundry's non-OpenAI catalog)
- Treated as a chat-completions provider, not the OpenAI Responses API

OpenAPI:
- `azure_foundry` added to AIProvider (openapi.yaml) and AIProviderKind
  (openflow.openapi.yaml); regenerated CLI guidance

Note: the `azure_foundry` resource type (base_url + optional api_key) is
hub-managed and must be published to the Windmill Hub separately.

Fixes WIN-2122

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

* fix(ai): add azure_foundry to copilot flow Zod provider enum

The tracked copilot flow schema (openFlowZod.gen.ts and its openFlow.json
source) still carried the old AIProvider enum, so validateFlowModules /
validateSpecialFlowModule rejected AI-generated flow edits that create or
update an aiagent module with provider kind "azure_foundry" before they
could be saved. Add the value to both (preserving the generated single-line
format) and a regression test over the flow-module validation path.

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

* feat(ai): lead provider list with OpenAI, Anthropic, Google AI

Reorder AI_PROVIDERS so the three primary direct providers come first. The
AIProviderPicker renders the first three entries as quick-access buttons, so
these become the defaults (previously OpenAI, Azure OpenAI, Azure Foundry);
Azure OpenAI / Azure Foundry stay adjacent right after. No logic depends on
provider order (only per-provider defaultModels[0] is read).

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-02 23:42:44 +02:00
committed by GitHub
parent 186ac4933b
commit d9b080f57f
15 changed files with 146 additions and 35 deletions
+15 -7
View File
@@ -49,6 +49,8 @@ pub enum AIProvider {
OpenAI,
#[serde(rename = "azure_openai")]
AzureOpenAI,
#[serde(rename = "azure_foundry")]
AzureFoundry,
Anthropic,
Mistral,
DeepSeek,
@@ -114,9 +116,12 @@ impl AIProvider {
AIProvider::TogetherAI => Ok("https://api.together.xyz/v1".to_string()),
AIProvider::Anthropic => Ok("https://api.anthropic.com/v1".to_string()),
AIProvider::Mistral => Ok("https://api.mistral.ai/v1".to_string()),
p @ (AIProvider::CustomAI | AIProvider::AzureOpenAI) => Err(Error::BadRequest(
format!("{:?} provider requires a base URL in the resource", p),
)),
p @ (AIProvider::CustomAI | AIProvider::AzureOpenAI | AIProvider::AzureFoundry) => {
Err(Error::BadRequest(format!(
"{:?} provider requires a base URL in the resource",
p
)))
}
AIProvider::AWSBedrock => {
// AWS Bedrock uses the SDK directly, not HTTP base URL
Err(Error::internal_err(
@@ -131,13 +136,16 @@ impl AIProvider {
matches!(self, AIProvider::Anthropic)
}
/// Check if this provider/URL combination represents Azure OpenAI
pub fn is_azure_openai(&self, base_url: &str) -> bool {
/// Check whether this provider/URL combination uses Azure conventions
/// (the `api-key` auth header and Azure URL building). This covers Azure
/// OpenAI, Azure AI Foundry, and the `OpenAI` provider pointed at an Azure
/// base path override.
pub fn is_azure(&self, base_url: &str) -> bool {
(matches!(self, AIProvider::OpenAI) && base_url != OPENAI_BASE_URL)
|| matches!(self, AIProvider::AzureOpenAI)
|| matches!(self, AIProvider::AzureOpenAI | AIProvider::AzureFoundry)
}
/// Build Azure OpenAI URL with deployment model path
/// Build an Azure-style URL (Azure OpenAI / Azure AI Foundry) for the given path
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") {
+2 -2
View File
@@ -248,7 +248,7 @@ impl QueryBuilder for OtherQueryBuilder {
}
fn get_endpoint(&self, base_url: &str, _model: &str, _output_type: &OutputType) -> String {
if self.provider_kind.is_azure_openai(base_url) {
if self.provider_kind.is_azure(base_url) {
AIProvider::build_azure_openai_url(base_url, "chat/completions")
} else {
format!("{}/chat/completions", base_url)
@@ -261,7 +261,7 @@ impl QueryBuilder for OtherQueryBuilder {
base_url: &str,
_output_type: &OutputType,
) -> Vec<(&'static str, String)> {
if self.provider_kind.is_azure_openai(base_url) {
if self.provider_kind.is_azure(base_url) {
vec![("api-key", api_key.to_string())]
} else {
vec![("Authorization", format!("Bearer {}", api_key))]
+33 -1
View File
@@ -51,6 +51,7 @@ pub fn supports_openai_compatible_proxy(provider: &AIProvider) -> bool {
provider,
AIProvider::OpenAI
| AIProvider::AzureOpenAI
| AIProvider::AzureFoundry
| AIProvider::Mistral
| AIProvider::DeepSeek
| AIProvider::Groq
@@ -64,6 +65,7 @@ pub fn proxy_execution_mode(provider: &AIProvider) -> ProxyExecutionMode {
match provider {
AIProvider::OpenAI
| AIProvider::AzureOpenAI
| AIProvider::AzureFoundry
| AIProvider::Anthropic
| AIProvider::Mistral
| AIProvider::DeepSeek
@@ -89,7 +91,7 @@ pub fn build_openai_compatible_proxy_request(args: &ProxyBuildArgs<'_>) -> Resul
};
let base_url = credentials.base_url.trim_end_matches('/');
let is_azure = credentials.provider.is_azure_openai(base_url);
let is_azure = credentials.provider.is_azure(base_url);
let url = if is_azure {
AIProvider::build_azure_openai_url(base_url, args.path)
} else {
@@ -201,6 +203,7 @@ mod tests {
let cases = [
(AIProvider::OpenAI, ProxyExecutionMode::HttpForward),
(AIProvider::AzureOpenAI, ProxyExecutionMode::HttpForward),
(AIProvider::AzureFoundry, ProxyExecutionMode::HttpForward),
(AIProvider::Anthropic, ProxyExecutionMode::HttpForward),
(AIProvider::Mistral, ProxyExecutionMode::HttpForward),
(AIProvider::DeepSeek, ProxyExecutionMode::HttpForward),
@@ -253,6 +256,35 @@ mod tests {
.contains(&("api-key".to_string(), "api-key".to_string())));
}
#[test]
fn builds_azure_foundry_proxy_request() {
// Foundry's OpenAI-compatible endpoint uses the same Azure conventions
// (api-key header, /openai -> /openai/v1 path) as Azure OpenAI.
let credentials = credentials(
AIProvider::AzureFoundry,
"https://example.services.ai.azure.com/openai",
);
let method = Method::POST;
let headers = HeaderMap::new();
let request = build_openai_compatible_proxy_request(&ProxyBuildArgs {
method: &method,
path: "chat/completions",
headers: &headers,
body: br#"{"model":"gpt-4o","messages":[]}"#,
credentials: &credentials,
})
.unwrap();
assert_eq!(
request.url,
"https://example.services.ai.azure.com/openai/v1/chat/completions"
);
assert!(request
.headers
.contains(&("api-key".to_string(), "api-key".to_string())));
}
#[test]
fn injects_user_into_proxy_body() {
let mut credentials = credentials(AIProvider::OpenAI, "https://api.openai.com/v1");
+1
View File
@@ -22880,6 +22880,7 @@ components:
[
openai,
azure_openai,
azure_foundry,
anthropic,
mistral,
deepseek,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { flowModulesSchema } from './openFlowZod.gen'
// Guards against the generated copilot flow Zod schema (openFlowZod.gen.ts)
// drifting from the AIProviderKind enum in openflow.openapi.yaml. A missing
// provider kind here silently rejects AI-generated flow edits for that provider
// in the copilot flow-editing path (validateFlowModules -> flowModulesSchema).
function aiAgentModuleWithProviderKind(kind: string) {
return {
id: 'agent',
value: {
type: 'aiagent',
tools: [],
input_transforms: {
provider: {
type: 'static',
value: { kind, resource: '$res:u/admin/foundry', model: 'gpt-4o' }
},
user_message: { type: 'static', value: 'hello' },
output_type: { type: 'static', value: 'text' }
}
}
}
}
describe('copilot flow module validation - AI agent provider kind', () => {
it('accepts azure_foundry (and the existing azure_openai baseline)', () => {
expect(
flowModulesSchema.safeParse([aiAgentModuleWithProviderKind('azure_openai')]).success
).toBe(true)
expect(
flowModulesSchema.safeParse([aiAgentModuleWithProviderKind('azure_foundry')]).success
).toBe(true)
})
it('still rejects an unknown provider kind (enum is actually enforced)', () => {
expect(
flowModulesSchema.safeParse([aiAgentModuleWithProviderKind('not_a_real_provider')]).success
).toBe(false)
})
})
@@ -950,7 +950,11 @@ export async function buildSchemaForTool(
// OPEN AI models don't support strict mode well with schema with complex properties, so we disable it
const model = getCurrentModel()
if (model.provider === 'openai' || model.provider === 'azure_openai') {
if (
model.provider === 'openai' ||
model.provider === 'azure_openai' ||
model.provider === 'azure_foundry'
) {
toolDef.function.strict = false
}
return true
+35 -16
View File
@@ -62,22 +62,10 @@ export const AI_PROVIDERS: Record<AIProvider, AIProviderDetails> = {
label: 'OpenAI',
defaultModels: OPENAI_MODELS
},
azure_openai: {
label: 'Azure OpenAI',
defaultModels: OPENAI_MODELS
},
anthropic: {
label: 'Anthropic',
defaultModels: ['claude-sonnet-4-6', 'claude-3-5-haiku-latest']
},
mistral: {
label: 'Mistral',
defaultModels: ['codestral-latest']
},
deepseek: {
label: 'DeepSeek',
defaultModels: ['deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner']
},
googleai: {
label: 'Google AI',
defaultModels: [
@@ -89,6 +77,29 @@ export const AI_PROVIDERS: Record<AIProvider, AIProviderDetails> = {
'gemini-3.1-flash-lite'
]
},
azure_openai: {
label: 'Azure OpenAI',
defaultModels: OPENAI_MODELS
},
azure_foundry: {
label: 'Azure AI Foundry',
defaultModels: [
'gpt-4o',
'gpt-4o-mini',
'DeepSeek-R1',
'Llama-3.3-70B-Instruct',
'Phi-4',
'Mistral-Large-2411'
]
},
mistral: {
label: 'Mistral',
defaultModels: ['codestral-latest']
},
deepseek: {
label: 'DeepSeek',
defaultModels: ['deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner']
},
groq: {
label: 'Groq',
defaultModels: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant']
@@ -267,7 +278,10 @@ export async function fetchAvailableModels(
export function getModelMaxTokens(provider: AIProvider, model: string) {
if (model.includes('gpt-5')) {
return 128000
} else if ((provider === 'azure_openai' || provider === 'openai') && model.startsWith('o')) {
} else if (
(provider === 'azure_openai' || provider === 'openai' || provider === 'azure_foundry') &&
model.startsWith('o')
) {
return 100000
} else if (
model.includes('claude-sonnet') ||
@@ -287,7 +301,6 @@ export function getModelMaxTokens(provider: AIProvider, model: string) {
return 8192
}
function getModelSpecificConfig(
modelProvider: AIProviderModel,
tools?: OpenAI.Chat.Completions.ChatCompletionTool[]
@@ -302,7 +315,9 @@ function getModelSpecificConfig(
}
const maxTokens = customMaxTokensStore?.[modelKey] ?? defaultMaxTokens
if (
(modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai') &&
(modelProvider.provider === 'openai' ||
modelProvider.provider === 'azure_openai' ||
modelProvider.provider === 'azure_foundry') &&
requiresMaxCompletionTokens(modelProvider.model)
) {
return {
@@ -352,6 +367,7 @@ const DEFAULT_COMPLETION_CONFIG: ChatCompletionCreateParams = {
export const PROVIDER_COMPLETION_CONFIG_MAP: Record<AIProvider, ChatCompletionCreateParams> = {
openai: DEFAULT_COMPLETION_CONFIG,
azure_openai: DEFAULT_COMPLETION_CONFIG,
azure_foundry: DEFAULT_COMPLETION_CONFIG,
groq: DEFAULT_COMPLETION_CONFIG,
openrouter: DEFAULT_COMPLETION_CONFIG,
togetherai: DEFAULT_COMPLETION_CONFIG,
@@ -906,7 +922,10 @@ export async function getCompletion(
// Use Completions API for other providers
const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient()
const completionConfig = applyReasoningToConfig(
(provider === 'openai' || provider === 'azure_openai' || provider === 'googleai') &&
(provider === 'openai' ||
provider === 'azure_openai' ||
provider === 'azure_foundry' ||
provider === 'googleai') &&
config.stream
? {
...config,
@@ -153,6 +153,9 @@ function supportsReasoningStatic(provider: AIProvider, model: string): boolean {
return /claude-opus-4-(6|7|8)/.test(m) || /claude-sonnet-4-6/.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
@@ -209,7 +212,7 @@ export function getReasoningCapability(provider: AIProvider, model: string): Rea
? anthropicReasoningLevels(bareModel)
: provider === 'googleai'
? geminiReasoningLevels(bareModel)
: provider === 'openai' || provider === 'azure_openai'
: provider === 'openai' || provider === 'azure_openai' || provider === 'azure_foundry'
? openaiReasoningLevels(bareModel)
: provider === 'openrouter'
? openrouterReasoningLevels(bareModel)
@@ -236,6 +239,7 @@ 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)
@@ -312,6 +316,7 @@ 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
+1
View File
@@ -452,6 +452,7 @@ components:
enum:
- openai
- azure_openai
- azure_foundry
- anthropic
- mistral
- deepseek
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long