mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
@@ -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") {
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user