From a6420ea34beddd31fbf024231a5513cc954a2586 Mon Sep 17 00:00:00 2001 From: jonathan MERCIER Date: Thu, 30 Jul 2026 17:14:26 +0200 Subject: [PATCH] Add Azure AI Foundry multi-LLM provider (#10622) Co-authored-by: Jack Amadeo --- crates/goose-provider-types/src/canonical.rs | 21 +- .../src/canonical/name_builder.rs | 8 +- .../src/formats/anthropic.rs | 22 +- .../src/formats/openai.rs | 30 +- .../src/formats/openai_responses.rs | 36 +- crates/goose-providers/src/anthropic.rs | 101 +- crates/goose-providers/src/api_client.rs | 11 + crates/goose-providers/src/azure_foundry.rs | 1175 +++++++++++++++++ crates/goose-providers/src/lib.rs | 1 + crates/goose-providers/src/openai.rs | 150 ++- .../goose-providers/src/openai_compatible.rs | 147 ++- crates/goose/src/model_config.rs | 49 +- .../goose/src/providers/azure_foundry_def.rs | 173 +++ crates/goose/src/providers/azureauth.rs | 25 +- crates/goose/src/providers/init.rs | 5 + .../src/providers/inventory/registrations.rs | 71 + crates/goose/src/providers/mod.rs | 1 + crates/goose/src/session/session_manager.rs | 83 +- .../docs/getting-started/providers.md | 1 + .../docs/guides/azure-foundry-provider.md | 101 ++ goose-self-test.yaml | 9 + .../modal/subcomponents/ProviderLogo.tsx | 2 + .../subcomponents/icons/azure_foundry.png | Bin 0 -> 2512 bytes .../subcomponents/icons/azure_foundry@2x.png | Bin 0 -> 5346 bytes .../subcomponents/icons/azure_foundry@3x.png | Bin 0 -> 8495 bytes 25 files changed, 2048 insertions(+), 174 deletions(-) create mode 100644 crates/goose-providers/src/azure_foundry.rs create mode 100644 crates/goose/src/providers/azure_foundry_def.rs create mode 100644 documentation/docs/guides/azure-foundry-provider.md create mode 100644 ui/desktop/src/components/settings/providers/modal/subcomponents/icons/azure_foundry.png create mode 100644 ui/desktop/src/components/settings/providers/modal/subcomponents/icons/azure_foundry@2x.png create mode 100644 ui/desktop/src/components/settings/providers/modal/subcomponents/icons/azure_foundry@3x.png diff --git a/crates/goose-provider-types/src/canonical.rs b/crates/goose-provider-types/src/canonical.rs index 5ac718dae..1b05b09fe 100644 --- a/crates/goose-provider-types/src/canonical.rs +++ b/crates/goose-provider-types/src/canonical.rs @@ -65,10 +65,10 @@ pub fn recommended_models_from_registry(provider: &str) -> Vec { .collect() } -/// Providers that run models locally — their cost is always zero regardless -/// of what the canonical registry says for the underlying model architecture. -fn is_local_provider(provider: &str) -> bool { - matches!(provider, "ollama" | "local") +/// Catalog pricing is not valid for local inference or Azure Foundry deployments. +/// Azure billing depends on deployment region, SKU, offer, and contract. +fn should_clear_catalog_pricing(provider: &str) -> bool { + matches!(provider, "ollama" | "local" | "azure_foundry") } pub fn maybe_get_canonical_model(provider: &str, model: &str) -> Option { @@ -81,9 +81,7 @@ pub fn maybe_get_canonical_model(provider: &str, model: &str) -> Option String { fn is_meta_provider(provider: &str) -> bool { matches!( provider, - "databricks" | "databricks_v2" | "tetrate" | "bedrock" | "azure" + "databricks" | "databricks_v2" | "tetrate" | "bedrock" | "azure" | "azure_foundry" ) } @@ -45,7 +45,7 @@ pub fn map_provider_name(provider: &str) -> &str { match provider { // Goose provider names that differ from models.dev names "xai" => "x-ai", - "azure_openai" => "azure", + "azure_openai" | "azure_foundry" => "azure", "aws_bedrock" => "amazon-bedrock", "gcp_vertex_ai" => "google-vertex", "gemini_oauth" => "google", @@ -420,6 +420,10 @@ mod tests { map_to_canonical_model("azure", "gpt-4o", r), Some("openai/gpt-4o".to_string()) ); + assert_eq!( + map_to_canonical_model("azure_foundry", "gpt-4o", r), + Some("openai/gpt-4o".to_string()) + ); // === OpenAI O-series === assert_eq!( diff --git a/crates/goose-provider-types/src/formats/anthropic.rs b/crates/goose-provider-types/src/formats/anthropic.rs index 49484dea7..e114319ed 100644 --- a/crates/goose-provider-types/src/formats/anthropic.rs +++ b/crates/goose-provider-types/src/formats/anthropic.rs @@ -776,6 +776,26 @@ pub fn create_request( messages: &[Message], tools: &[Tool], options: AnthropicFormatOptions, +) -> Result { + create_request_for_model( + provider_name, + model_config, + &model_config.model_name, + system, + messages, + tools, + options, + ) +} + +pub fn create_request_for_model( + provider_name: &str, + model_config: &ModelConfig, + wire_model_name: &str, + system: &str, + messages: &[Message], + tools: &[Tool], + options: AnthropicFormatOptions, ) -> Result { let options = options.for_model(model_config); let anthropic_messages = format_messages_with_options(messages, options); @@ -788,7 +808,7 @@ pub fn create_request( let max_tokens = model_config.max_output_tokens(); let mut payload = json!({ - "model": model_config.model_name, + "model": wire_model_name, "messages": anthropic_messages, "max_tokens": max_tokens, }); diff --git a/crates/goose-provider-types/src/formats/openai.rs b/crates/goose-provider-types/src/formats/openai.rs index b3acacfa1..fbe00f039 100644 --- a/crates/goose-provider-types/src/formats/openai.rs +++ b/crates/goose-provider-types/src/formats/openai.rs @@ -1401,6 +1401,32 @@ pub fn create_request_with_options( image_format: &ImageFormat, for_streaming: bool, format_options: OpenAiFormatOptions, +) -> anyhow::Result { + let (wire_model_name, _) = extract_reasoning_effort(&model_config.model_name); + create_request_for_model_with_options( + model_config, + &wire_model_name, + &model_config.model_name, + system, + messages, + tools, + image_format, + for_streaming, + format_options, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn create_request_for_model_with_options( + model_config: &ModelConfig, + wire_model_name: &str, + capability_model_name: &str, + system: &str, + messages: &[Message], + tools: &[Tool], + image_format: &ImageFormat, + for_streaming: bool, + format_options: OpenAiFormatOptions, ) -> anyhow::Result { if model_config.model_name.starts_with("o1-mini") { return Err(anyhow!( @@ -1408,7 +1434,7 @@ pub fn create_request_with_options( )); } - let (model_name, legacy_reasoning_effort) = extract_reasoning_effort(&model_config.model_name); + let (model_name, legacy_reasoning_effort) = extract_reasoning_effort(capability_model_name); let is_reasoning_model = is_openai_responses_model(&model_name); let supports_xai_effort = supports_xai_reasoning_effort(&model_name); let reasoning_effort = if is_reasoning_model { @@ -1439,7 +1465,7 @@ pub fn create_request_with_options( messages_array.extend(messages_spec); let mut payload = json!({ - "model": model_name, + "model": wire_model_name, "messages": messages_array }); diff --git a/crates/goose-provider-types/src/formats/openai_responses.rs b/crates/goose-provider-types/src/formats/openai_responses.rs index 32d655657..a4facaf96 100644 --- a/crates/goose-provider-types/src/formats/openai_responses.rs +++ b/crates/goose-provider-types/src/formats/openai_responses.rs @@ -388,6 +388,7 @@ fn add_message_items(input_items: &mut Vec, messages: &[Message]) { MessageContentBlock::ToolRequest(request) if message.role == Role::Assistant => { if !text_items.is_empty() { input_items.push(json!({ + "type": "message", "role": role, "content": text_items })); @@ -435,6 +436,7 @@ fn add_message_items(input_items: &mut Vec, messages: &[Message]) { MessageContentBlock::ToolResponse(response) => { if !text_items.is_empty() { input_items.push(json!({ + "type": "message", "role": role, "content": text_items })); @@ -520,6 +522,7 @@ fn add_message_items(input_items: &mut Vec, messages: &[Message]) { MessageContentBlock::FrontendToolRequest(request) => { if !text_items.is_empty() { input_items.push(json!({ + "type": "message", "role": role, "content": text_items })); @@ -559,6 +562,7 @@ fn add_message_items(input_items: &mut Vec, messages: &[Message]) { if !text_items.is_empty() { input_items.push(json!({ + "type": "message", "role": role, "content": text_items })); @@ -579,11 +583,31 @@ pub fn create_responses_request( system: &str, messages: &[Message], tools: &[Tool], +) -> anyhow::Result { + let (wire_model_name, _) = extract_reasoning_effort(&model_config.model_name); + create_responses_request_for_model( + model_config, + &wire_model_name, + &model_config.model_name, + system, + messages, + tools, + ) +} + +pub fn create_responses_request_for_model( + model_config: &ModelConfig, + wire_model_name: &str, + capability_model_name: &str, + system: &str, + messages: &[Message], + tools: &[Tool], ) -> anyhow::Result { let mut input_items = Vec::new(); if !system.is_empty() { input_items.push(json!({ + "type": "message", "role": "system", "content": [{ "type": "input_text", @@ -594,7 +618,7 @@ pub fn create_responses_request( add_message_items(&mut input_items, messages); - let (model_name, legacy_reasoning_effort) = extract_reasoning_effort(&model_config.model_name); + let (model_name, legacy_reasoning_effort) = extract_reasoning_effort(capability_model_name); // All models routed here are responses-capable; temperature is rejected // by the API for reasoning models regardless of whether an explicit // effort suffix was provided. @@ -639,7 +663,7 @@ pub fn create_responses_request( )); } let mut payload = json!({ - "model": model_name, + "model": wire_model_name, "input": input_items, "store": store, }); @@ -1358,16 +1382,12 @@ mod tests { let types: Vec<&str> = input .iter() - .map(|item| { - item.get("type") - .and_then(|v| v.as_str()) - .unwrap_or_else(|| item["role"].as_str().unwrap()) - }) + .map(|item| item["type"].as_str().unwrap()) .collect(); assert_eq!( types, - vec!["assistant", "function_call", "assistant", "function_call"] + vec!["message", "function_call", "message", "function_call"] ); } diff --git a/crates/goose-providers/src/anthropic.rs b/crates/goose-providers/src/anthropic.rs index 5c8b0e50b..f5248fa79 100644 --- a/crates/goose-providers/src/anthropic.rs +++ b/crates/goose-providers/src/anthropic.rs @@ -16,7 +16,8 @@ use tokio_util::io::StreamReader; use super::api_client::ApiClient; use super::base::{ConfigKey, MessageStream, ModelInfo, Provider, ProviderMetadata}; use super::formats::anthropic::{ - create_request, response_to_streaming_message, AnthropicFormatOptions, ANTHROPIC_PROVIDER_NAME, + create_request_for_model, response_to_streaming_message, AnthropicFormatOptions, + ANTHROPIC_PROVIDER_NAME, }; use super::openai_compatible::handle_status; use super::openai_compatible::map_http_error_to_provider_error; @@ -155,6 +156,54 @@ impl AnthropicProviderBuilder { } impl AnthropicProvider { + pub async fn stream_for_model( + &self, + model_config: &ModelConfig, + wire_model: &str, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result { + let mut payload = create_request_for_model( + ANTHROPIC_PROVIDER_NAME, + model_config, + wire_model, + system, + messages, + tools, + self.format_options, + )?; + payload["stream"] = Value::Bool(true); + let mut log = start_log(model_config, &payload)?; + let response = self + .with_retry(|| async { + handle_status( + self.api_client + .request("v1/messages") + .model_headers(model_config)? + .response_post(&payload) + .await?, + ) + .await + }) + .await + .inspect_err(|e| { + let _ = log.error(e); + })?; + let stream = response.bytes_stream().map_err(io::Error::other); + Ok(Box::pin(try_stream! { + let reader = StreamReader::new(stream); + let framed = tokio_util::codec::FramedRead::new(reader, tokio_util::codec::LinesCodec::new()).map_err(anyhow::Error::from); + let messages = response_to_streaming_message(framed); + pin!(messages); + while let Some(message) = futures::StreamExt::next(&mut messages).await { + let (message, usage) = message.map_err(ProviderError::from_stream_error)?; + log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; + yield (message, usage); + } + })) + } + async fn fetch_models_from_api(&self) -> Result, ProviderError> { let response = self.api_client.request("v1/models").api_get().await?; @@ -233,6 +282,13 @@ impl Provider for AnthropicProvider { &self.name } + async fn refresh_credentials(&self) -> Result<(), ProviderError> { + self.api_client + .refresh_credentials() + .await + .map_err(|error| ProviderError::Authentication(error.to_string())) + } + fn skip_canonical_filtering(&self) -> bool { self.skip_canonical_filtering } @@ -266,49 +322,14 @@ impl Provider for AnthropicProvider { messages: &[Message], tools: &[Tool], ) -> Result { - let mut payload = create_request( - ANTHROPIC_PROVIDER_NAME, + self.stream_for_model( model_config, + &model_config.model_name, system, messages, tools, - self.format_options, - )?; - payload - .as_object_mut() - .unwrap() - .insert("stream".to_string(), Value::Bool(true)); - - let mut log = start_log(model_config, &payload)?; - - let response = self - .with_retry(|| async { - let request = self - .api_client - .request("v1/messages") - .model_headers(model_config)?; - let resp = request.response_post(&payload).await?; - handle_status(resp).await - }) - .await - .inspect_err(|e| { - let _ = log.error(e); - })?; - - let stream = response.bytes_stream().map_err(io::Error::other); - - Ok(Box::pin(try_stream! { - let stream_reader = StreamReader::new(stream); - let framed = tokio_util::codec::FramedRead::new(stream_reader, tokio_util::codec::LinesCodec::new()).map_err(anyhow::Error::from); - - let message_stream = response_to_streaming_message(framed); - pin!(message_stream); - while let Some(message) = futures::StreamExt::next(&mut message_stream).await { - let (message, usage) = message.map_err(ProviderError::from_stream_error)?; - log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; - yield (message, usage); - } - })) + ) + .await } } diff --git a/crates/goose-providers/src/api_client.rs b/crates/goose-providers/src/api_client.rs index 57a44fc8c..5ba3b2a30 100644 --- a/crates/goose-providers/src/api_client.rs +++ b/crates/goose-providers/src/api_client.rs @@ -195,6 +195,10 @@ fn convert_key_to_pkcs8_pem(key_pem_str: &str) -> Result { #[async_trait] pub trait AuthProvider: Send + Sync { async fn get_auth_header(&self) -> Result<(String, String)>; + + async fn refresh_credentials(&self) -> Result<()> { + anyhow::bail!("credential refresh not supported") + } } pub struct ApiResponse { @@ -356,6 +360,13 @@ impl ApiClient { } } + pub async fn refresh_credentials(&self) -> Result<()> { + match &self.auth { + AuthMethod::Custom(provider) => provider.refresh_credentials().await, + _ => anyhow::bail!("credential refresh not supported"), + } + } + pub async fn api_post(&self, path: &str, payload: &Value) -> Result { self.request(path).api_post(payload).await } diff --git a/crates/goose-providers/src/azure_foundry.rs b/crates/goose-providers/src/azure_foundry.rs new file mode 100644 index 000000000..5eee2808b --- /dev/null +++ b/crates/goose-providers/src/azure_foundry.rs @@ -0,0 +1,1175 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use anyhow::Result; +use async_trait::async_trait; +use rmcp::model::Tool; + +use crate::anthropic::{AnthropicProvider, AnthropicProviderBuilder, ANTHROPIC_API_VERSION}; +use crate::api_client::{ApiClient, AuthMethod, RequestBuilderDecorator, TlsConfig}; +use crate::base::{ + ConfigKey, MessageStream, ModelInfo, Provider, ProviderDescriptor, ProviderMetadata, +}; +use crate::conversation::message::Message; +use crate::errors::ProviderError; +use crate::formats::openai::is_openai_responses_model; +use crate::model::ModelConfig; +use crate::openai::{OpenAiProvider, OpenAiProviderBuilder}; +use crate::openai_compatible::{handle_response_openai_compat, OpenAiCompatibleProvider}; + +pub const AZURE_FOUNDRY_PROVIDER_NAME: &str = "azure_foundry"; +pub const AZURE_FOUNDRY_DEFAULT_MODEL: &str = "Phi-4"; +pub const AZURE_FOUNDRY_DOC_URL: &str = + "https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/inference"; + +pub const AZURE_FOUNDRY_KNOWN_MODELS: &[&str] = &[ + "Phi-4", + "Phi-4-mini", + "Meta-Llama-3.3-70B-Instruct", + "Mistral-large-2411", + "Cohere-command-r-plus-08-2024", + "AI21-Jamba-1.5-Large", + "DeepSeek-R1", + "DeepSeek-V3", + "glm-4.7", + "Kimi-K2-Instruct", + "claude-sonnet-4-6", + "claude-opus-4-6", + "gpt-5", + "o3", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndpointKind { + Maas, + Resource, + Project, +} + +pub fn endpoint_kind(endpoint: &str) -> EndpointKind { + if endpoint.contains("/api/projects/") { + EndpointKind::Project + } else if endpoint + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(endpoint) + .split('/') + .next() + .is_some_and(|host| host.ends_with(".services.ai.azure.com")) + { + EndpointKind::Resource + } else { + EndpointKind::Maas + } +} + +pub fn is_project_endpoint(endpoint: &str) -> bool { + endpoint_kind(endpoint) == EndpointKind::Project +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelPublisher { + OpenAi, + Anthropic, + Partner, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DeploymentMetadata { + publisher: ModelPublisher, + model_name: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InferenceRoute { + MaasChatCompletions, + ProjectChatCompletions, + ProjectResponses, + AnthropicMessages, +} + +fn inference_route( + project_endpoint: bool, + publisher: ModelPublisher, + underlying_model: &str, +) -> InferenceRoute { + if !project_endpoint { + return InferenceRoute::MaasChatCompletions; + } + match publisher { + ModelPublisher::Anthropic => InferenceRoute::AnthropicMessages, + ModelPublisher::OpenAi if is_openai_responses_model(underlying_model) => { + InferenceRoute::ProjectResponses + } + ModelPublisher::OpenAi | ModelPublisher::Partner => InferenceRoute::ProjectChatCompletions, + } +} + +impl ModelPublisher { + fn from_azure(value: &str) -> Self { + match value.to_ascii_lowercase().as_str() { + "openai" => Self::OpenAi, + "anthropic" => Self::Anthropic, + _ => Self::Partner, + } + } + + fn from_model_name(value: &str) -> Self { + let value = value.to_ascii_lowercase(); + if value.starts_with("claude") { + Self::Anthropic + } else if is_openai_responses_model(&value) { + Self::OpenAi + } else { + Self::Partner + } + } +} + +pub struct AzureFoundryProvider { + chat: OpenAiCompatibleProvider, + responses: Option, + anthropic: Option, + deployments_client: ApiClient, + endpoint: String, + api_version: Option, + maas_model: Option, + deployments: Mutex>, +} + +impl ProviderDescriptor for AzureFoundryProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + AZURE_FOUNDRY_PROVIDER_NAME, + "Azure AI Foundry", + "OpenAI, Anthropic, and partner models deployed through Azure AI Foundry", + AZURE_FOUNDRY_DEFAULT_MODEL, + AZURE_FOUNDRY_KNOWN_MODELS.to_vec(), + AZURE_FOUNDRY_DOC_URL, + vec![ + ConfigKey::new("AZURE_FOUNDRY_ENDPOINT", true, false, None, true), + ConfigKey::new("AZURE_FOUNDRY_API_KEY", false, true, Some(""), true), + ConfigKey::new("AZURE_FOUNDRY_AD_TOKEN", false, true, Some(""), false), + ConfigKey::new("AZURE_FOUNDRY_MODEL", false, false, None, true), + ConfigKey::new("AZURE_FOUNDRY_API_VERSION", false, false, None, false), + ], + ) + } +} + +impl AzureFoundryProvider { + #[allow(clippy::too_many_arguments)] + pub fn create( + endpoint: String, + api_version: Option, + maas_model: Option, + chat_auth: AuthMethod, + responses_auth: AuthMethod, + anthropic_auth: AuthMethod, + deployments_auth: AuthMethod, + tls_config: Option, + request_builder: Option, + ) -> Result { + let endpoint = endpoint.trim_end_matches('/').to_string(); + let endpoint_kind = endpoint_kind(&endpoint); + let native_inference = endpoint_kind != EndpointKind::Maas; + let maas_model = if native_inference { + None + } else { + Some( + maas_model + .filter(|model| !model.trim().is_empty()) + .ok_or_else(|| { + anyhow::anyhow!("AZURE_FOUNDRY_MODEL is required for MaaS endpoints") + })? + .trim() + .to_string(), + ) + }; + let chat_prefix = if native_inference { + "openai/v1/" + } else { + "v1/" + }; + + let chat_client = configured_client( + endpoint.clone(), + chat_auth, + tls_config.clone(), + request_builder.clone(), + )?; + let chat = OpenAiCompatibleProvider::new( + AZURE_FOUNDRY_PROVIDER_NAME.to_string(), + chat_client, + chat_prefix.to_string(), + ); + + let (responses, anthropic) = if native_inference { + let responses_client = configured_client( + endpoint.clone(), + responses_auth, + tls_config.clone(), + request_builder.clone(), + )?; + let responses = OpenAiProviderBuilder::new(responses_client) + .name(AZURE_FOUNDRY_PROVIDER_NAME) + .base_path("openai/v1/responses") + .skip_canonical_filtering(true) + .build(); + + let hub = endpoint.split("/api/projects/").next().unwrap_or(&endpoint); + let anthropic_client = configured_client( + format!("{hub}/anthropic"), + anthropic_auth, + tls_config.clone(), + request_builder.clone(), + )? + .with_header("anthropic-version", ANTHROPIC_API_VERSION)?; + let anthropic = AnthropicProviderBuilder::new(anthropic_client) + .name(AZURE_FOUNDRY_PROVIDER_NAME) + .skip_canonical_filtering(true) + .build(); + (Some(responses), Some(anthropic)) + } else { + (None, None) + }; + + let deployments_client = configured_client( + endpoint.clone(), + deployments_auth, + tls_config, + request_builder, + )?; + + Ok(Self { + chat, + responses, + anthropic, + deployments_client, + endpoint, + api_version, + maas_model, + deployments: Mutex::new(HashMap::new()), + }) + } + + async fn fetch_deployments( + &self, + ) -> Result<(Vec, HashMap), ProviderError> { + let version = self + .api_version + .as_deref() + .or_else(|| is_project_endpoint(&self.endpoint).then_some("v1")); + let mut next = Some(match version { + Some(version) => format!("deployments?api-version={version}"), + None => "deployments".to_string(), + }); + let mut models = Vec::new(); + let mut deployments = HashMap::new(); + + while let Some(path) = next { + let response = self + .deployments_client + .response_get(&path) + .await + .map_err(|error| ProviderError::NetworkError(error.to_string()))?; + let json = handle_response_openai_compat(response).await?; + if let Some(items) = json.get("value").and_then(|value| value.as_array()) { + for item in items { + let Some(name) = item.get("name").and_then(|value| value.as_str()) else { + continue; + }; + let model_name = item + .get("modelName") + .and_then(|value| value.as_str()) + .unwrap_or(name); + let publisher = item + .get("modelPublisher") + .and_then(|value| value.as_str()) + .map(ModelPublisher::from_azure) + .unwrap_or_else(|| ModelPublisher::from_model_name(model_name)); + models.push(name.to_string()); + deployments.insert( + name.to_string(), + DeploymentMetadata { + publisher, + model_name: model_name.to_string(), + }, + ); + } + } + next = json + .get("nextLink") + .and_then(|value| value.as_str()) + .map(|link| with_api_version(link, version)); + } + + models.sort(); + models.dedup(); + Ok((models, deployments)) + } + + async fn deployment_for(&self, deployment_name: &str) -> Option { + if let Some(deployment) = self + .deployments + .lock() + .expect("Azure Foundry deployment cache poisoned") + .get(deployment_name) + .cloned() + { + return Some(deployment); + } + + let (_, deployments) = self.fetch_deployments().await.ok()?; + let deployment = deployments.get(deployment_name).cloned(); + *self + .deployments + .lock() + .expect("Azure Foundry deployment cache poisoned") = deployments; + deployment + } +} + +fn with_api_version(link: &str, api_version: Option<&str>) -> String { + let Some(api_version) = api_version else { + return link.to_string(); + }; + if link.contains("api-version=") { + return link.to_string(); + } + let separator = if link.contains('?') { '&' } else { '?' }; + format!("{link}{separator}api-version={api_version}") +} + +fn model_info_for_deployment(deployment_name: &str, model_name: &str) -> ModelInfo { + let canonical = crate::canonical::maybe_get_canonical_model("azure_foundry", model_name) + .or_else(|| { + crate::canonical::maybe_get_canonical_model( + "azure_foundry", + &model_name.to_ascii_lowercase(), + ) + }); + ModelInfo { + name: deployment_name.to_string(), + resolved_model: Some(model_name.to_string()), + context_limit: canonical + .as_ref() + .map(|model| model.limit.context) + .unwrap_or_else(|| ModelConfig::new(model_name).context_limit()), + input_token_cost: None, + output_token_cost: None, + currency: None, + supports_cache_control: None, + reasoning: canonical + .and_then(|model| model.reasoning) + .unwrap_or_else(|| ModelConfig::new(model_name).is_reasoning_model()), + } +} + +fn configured_client( + host: String, + auth: AuthMethod, + tls_config: Option, + request_builder: Option, +) -> Result { + let mut client = ApiClient::new_with_tls(host, auth, tls_config)?; + if let Some(request_builder) = request_builder { + client = client.with_request_builder(request_builder); + } + Ok(client) +} + +#[async_trait] +impl Provider for AzureFoundryProvider { + fn get_name(&self) -> &str { + AZURE_FOUNDRY_PROVIDER_NAME + } + + fn skip_canonical_filtering(&self) -> bool { + true + } + + async fn refresh_credentials(&self) -> Result<(), ProviderError> { + self.deployments_client + .refresh_credentials() + .await + .map_err(|error| ProviderError::Authentication(error.to_string())) + } + + async fn fetch_supported_models(&self) -> Result, ProviderError> { + if let Some(model) = &self.maas_model { + return Ok(vec![model.clone()]); + } + if !is_project_endpoint(&self.endpoint) { + return Ok(AZURE_FOUNDRY_KNOWN_MODELS + .iter() + .map(ToString::to_string) + .collect()); + } + let (models, deployments) = self.fetch_deployments().await?; + *self + .deployments + .lock() + .expect("Azure Foundry deployment cache poisoned") = deployments; + Ok(models) + } + + async fn fetch_supported_model_info(&self) -> Result, ProviderError> { + if let Some(model) = &self.maas_model { + return Ok(vec![model_info_for_deployment(model, model)]); + } + if !is_project_endpoint(&self.endpoint) { + return Ok(AZURE_FOUNDRY_KNOWN_MODELS + .iter() + .map(|model| model_info_for_deployment(model, model)) + .collect()); + } + let (models, deployments) = self.fetch_deployments().await?; + let model_info = models + .iter() + .filter_map(|name| { + deployments + .get(name) + .map(|deployment| model_info_for_deployment(name, &deployment.model_name)) + }) + .collect(); + *self + .deployments + .lock() + .expect("Azure Foundry deployment cache poisoned") = deployments; + Ok(model_info) + } + + async fn fetch_model_info(&self, model_name: &str) -> Result { + let resolved_model = if let Some(model) = &self.maas_model { + model.clone() + } else if is_project_endpoint(&self.endpoint) { + self.deployment_for(model_name) + .await + .map(|deployment| deployment.model_name) + .unwrap_or_else(|| model_name.to_string()) + } else { + model_name.to_string() + }; + Ok(model_info_for_deployment(model_name, &resolved_model)) + } + + async fn get_context_limit(&self, model_config: &ModelConfig) -> Result { + if let Some(context_limit) = model_config.context_limit { + return Ok(context_limit); + } + Ok(self + .fetch_model_info(&model_config.model_name) + .await? + .context_limit) + } + + async fn stream( + &self, + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result { + let maas_config = self.maas_model.as_ref().map(|model| { + let mut config = model_config.clone(); + config.model_name = model.clone(); + config + }); + let model_config = maas_config.as_ref().unwrap_or(model_config); + let wire_model = self + .maas_model + .clone() + .unwrap_or_else(|| model_config.model_name.clone()); + let deployment = if is_project_endpoint(&self.endpoint) { + self.deployment_for(&wire_model).await + } else { + None + }; + let publisher = deployment + .as_ref() + .map(|deployment| deployment.publisher) + .unwrap_or_else(|| ModelPublisher::from_model_name(&model_config.model_name)); + let underlying_model = deployment + .as_ref() + .map(|deployment| deployment.model_name.as_str()) + .unwrap_or(&model_config.model_name); + let route = inference_route(self.responses.is_some(), publisher, underlying_model); + let capability_model = deployment + .as_ref() + .map(|deployment| deployment.model_name.as_str()) + .unwrap_or(&model_config.model_name); + let mut capability_config = model_config.clone(); + capability_config.model_name = capability_model.to_string(); + let capability_config = + capability_config.with_canonical_limits(AZURE_FOUNDRY_PROVIDER_NAME); + match route { + InferenceRoute::ProjectResponses => { + self.responses + .as_ref() + .expect("checked above") + .stream_for_model( + &capability_config, + &wire_model, + capability_model, + system, + messages, + tools, + ) + .await + } + InferenceRoute::AnthropicMessages => { + self.anthropic + .as_ref() + .expect("checked above") + .stream_for_model(&capability_config, &wire_model, system, messages, tools) + .await + } + InferenceRoute::MaasChatCompletions | InferenceRoute::ProjectChatCompletions => { + self.chat + .stream_for_model( + &capability_config, + &wire_model, + capability_model, + system, + messages, + tools, + ) + .await + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{body_partial_json, method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn project_endpoint(server: &MockServer) -> String { + format!("{}/api/projects/test", server.uri()) + } + + fn raw_model_config(model_name: &str) -> ModelConfig { + let mut config = ModelConfig::new(model_name); + config.model_name = model_name.to_string(); + config + } + + fn project_provider(server: &MockServer) -> AzureFoundryProvider { + AzureFoundryProvider::create( + project_endpoint(server), + None, + None, + AuthMethod::NoAuth, + AuthMethod::NoAuth, + AuthMethod::NoAuth, + AuthMethod::NoAuth, + None, + None, + ) + .unwrap() + } + + fn chat_stream() -> String { + [ + json!({"id":"c1","object":"chat.completion.chunk","model":"test","choices":[{"delta":{"role":"assistant","content":"Hello"},"index":0}]}), + json!({"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop","index":0}]}), + json!({"id":"c1","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}), + ] + .into_iter() + .map(|chunk| format!("data: {chunk}\n\n")) + .chain(std::iter::once("data: [DONE]\n\n".to_string())) + .collect() + } + + fn responses_stream() -> String { + let created = r#"data: {"type":"response.created","sequence_number":1,"response":{"id":"resp_1","object":"response","created_at":0,"status":"in_progress","model":"gpt-5","output":[]}}"#; + let delta = r#"data: {"type":"response.output_text.delta","sequence_number":2,"item_id":"m1","output_index":0,"content_index":0,"delta":"Hello"}"#; + let completed = r#"data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_1","object":"response","created_at":0,"status":"completed","model":"gpt-5","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}"#; + format!("{created}\n\n{delta}\n\n{completed}\n\ndata: [DONE]\n\n") + } + + fn anthropic_stream() -> String { + let start = r#"data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-6","stop_reason":null,"usage":{"input_tokens":1,"output_tokens":0}}}"#; + let block_start = r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#; + let delta = r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#; + let block_stop = r#"data: {"type":"content_block_stop","index":0}"#; + let message_delta = r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}"#; + let stop = r#"data: {"type":"message_stop"}"#; + format!( + "{start}\n\n{block_start}\n\n{delta}\n\n{block_stop}\n\n{message_delta}\n\n{stop}\n\n" + ) + } + + #[test] + fn routing_matrix_uses_endpoint_publisher_and_underlying_model() { + use InferenceRoute::*; + assert_eq!( + inference_route(false, ModelPublisher::OpenAi, "gpt-5"), + MaasChatCompletions + ); + assert_eq!( + inference_route(false, ModelPublisher::Anthropic, "claude-sonnet-4-6"), + MaasChatCompletions + ); + assert_eq!( + inference_route(true, ModelPublisher::OpenAi, "gpt-5"), + ProjectResponses + ); + assert_eq!( + inference_route(true, ModelPublisher::OpenAi, "o3-mini"), + ProjectResponses + ); + assert_eq!( + inference_route(true, ModelPublisher::OpenAi, "gpt-4o"), + ProjectChatCompletions + ); + assert_eq!( + inference_route(true, ModelPublisher::Partner, "gpt-5"), + ProjectChatCompletions + ); + assert_eq!( + inference_route(true, ModelPublisher::Anthropic, "gpt-5"), + AnthropicMessages + ); + } + + #[test] + fn resource_endpoint_routes_declared_models_to_native_surfaces() { + let resource = endpoint_kind("https://hub.services.ai.azure.com"); + let native_inference = resource != EndpointKind::Maas; + + assert_eq!( + inference_route( + native_inference, + ModelPublisher::from_model_name("gpt-5.6-sol"), + "gpt-5.6-sol", + ), + InferenceRoute::ProjectResponses + ); + assert_eq!( + inference_route( + native_inference, + ModelPublisher::from_model_name("claude-sonnet-4-6"), + "claude-sonnet-4-6", + ), + InferenceRoute::AnthropicMessages + ); + assert_eq!( + inference_route( + native_inference, + ModelPublisher::from_model_name("Mistral-large"), + "Mistral-large", + ), + InferenceRoute::ProjectChatCompletions + ); + } + + #[test] + fn model_fallback_only_routes_known_native_families() { + assert_eq!( + ModelPublisher::from_model_name("gpt-5"), + ModelPublisher::OpenAi + ); + assert_eq!( + ModelPublisher::from_model_name("o3-mini"), + ModelPublisher::OpenAi + ); + assert_eq!( + ModelPublisher::from_model_name("claude-sonnet-4-6"), + ModelPublisher::Anthropic + ); + assert_eq!( + ModelPublisher::from_model_name("Mistral-large"), + ModelPublisher::Partner + ); + assert_eq!( + ModelPublisher::from_model_name("glm-4.7"), + ModelPublisher::Partner + ); + assert_eq!( + ModelPublisher::from_model_name("Kimi-K2"), + ModelPublisher::Partner + ); + } + + #[test] + fn endpoint_type_is_detected() { + assert_eq!( + endpoint_kind("https://hub.services.ai.azure.com/api/projects/project"), + EndpointKind::Project + ); + assert_eq!( + endpoint_kind("https://hub.services.ai.azure.com"), + EndpointKind::Resource + ); + assert_eq!( + endpoint_kind("https://deployment.eastus.models.ai.azure.com"), + EndpointKind::Maas + ); + assert!(is_project_endpoint( + "https://hub.services.ai.azure.com/api/projects/project" + )); + assert!(!is_project_endpoint("https://hub.services.ai.azure.com")); + } + + #[test] + fn pagination_link_keeps_api_version() { + assert_eq!( + with_api_version("https://example.test/deployments?page=2", Some("v1")), + "https://example.test/deployments?page=2&api-version=v1" + ); + assert_eq!( + with_api_version( + "https://example.test/deployments?page=2&api-version=2025-05-01", + Some("v1") + ), + "https://example.test/deployments?page=2&api-version=2025-05-01" + ); + } + + #[test] + fn deployment_metadata_enriches_context_without_pricing() { + let info = model_info_for_deployment("production-chat", "gpt-5"); + assert_eq!(info.name, "production-chat"); + assert_eq!(info.resolved_model.as_deref(), Some("gpt-5")); + assert_eq!(info.context_limit, 400_000); + assert_eq!(info.input_token_cost, None); + assert_eq!(info.output_token_cost, None); + } + + #[test] + fn gpt_5_6_sol_uses_its_full_context_window() { + let info = model_info_for_deployment("gpt-5.6-sol", "gpt-5.6-sol"); + + assert_eq!(info.context_limit, 1_050_000); + assert!(info.reasoning); + } + + #[tokio::test] + async fn deployment_discovery_is_paginated_and_preserves_underlying_model() { + let server = MockServer::start().await; + let page_two = format!("{}/api/projects/test/deployments?page=2", server.uri()); + Mock::given(method("GET")) + .and(path("/api/projects/test/deployments")) + .and(query_param("api-version", "v1")) + .and(query_param("page", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value": [{ + "type": "ModelDeployment", + "name": "partner-prod", + "modelName": "Mistral-large", + "modelVersion": "1", + "modelPublisher": "MistralAI" + }] + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/projects/test/deployments")) + .and(query_param("api-version", "v1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value": [{ + "type": "ModelDeployment", + "name": "openai-prod", + "modelName": "gpt-5", + "modelVersion": "1", + "modelPublisher": "OpenAI" + }], + "nextLink": page_two + }))) + .expect(1) + .mount(&server) + .await; + + let provider = project_provider(&server); + let (names, deployments) = provider.fetch_deployments().await.unwrap(); + assert_eq!(names, vec!["openai-prod", "partner-prod"]); + assert_eq!(deployments["openai-prod"].model_name, "gpt-5"); + assert_eq!(deployments["openai-prod"].publisher, ModelPublisher::OpenAi); + } + + #[tokio::test] + async fn custom_deployment_context_uses_underlying_model() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/projects/test/deployments")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value": [{ + "type": "ModelDeployment", + "name": "production-chat", + "modelName": "gpt-5", + "modelVersion": "1", + "modelPublisher": "OpenAI" + }] + }))) + .expect(1) + .mount(&server) + .await; + + let provider = project_provider(&server); + assert_eq!( + provider + .get_context_limit(&ModelConfig::new("production-chat")) + .await + .unwrap(), + 400_000 + ); + } + + #[tokio::test] + async fn canonical_like_alias_context_uses_underlying_model() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/projects/test/deployments")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value": [{ + "type": "ModelDeployment", + "name": "gpt-5-high", + "modelName": "custom-128k-model", + "modelPublisher": "OpenAI" + }] + }))) + .expect(1) + .mount(&server) + .await; + + let provider = project_provider(&server); + let config = raw_model_config("gpt-5-high"); + assert_eq!(provider.get_context_limit(&config).await.unwrap(), 128_000); + } + + #[tokio::test] + async fn explicit_context_limit_overrides_deployment_metadata() { + let server = MockServer::start().await; + let provider = project_provider(&server); + let config = raw_model_config("gpt-5-high").with_context_limit(Some(64_000)); + + assert_eq!(provider.get_context_limit(&config).await.unwrap(), 64_000); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn project_inventory_failure_is_propagated() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/projects/test/deployments")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": {"message": "expired"} + }))) + .mount(&server) + .await; + + let provider = project_provider(&server); + assert!(provider.fetch_supported_models().await.is_err()); + assert!(provider.fetch_supported_model_info().await.is_err()); + } + + #[tokio::test] + async fn custom_openai_deployment_uses_alias_and_underlying_capabilities() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/projects/test/deployments")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value": [{ + "type": "ModelDeployment", + "name": "production-chat", + "modelName": "gpt-5", + "modelPublisher": "OpenAI" + }] + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/projects/test/openai/v1/responses")) + .and(body_partial_json(json!({"model": "production-chat"}))) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(responses_stream()) + .append_header("content-type", "text/event-stream"), + ) + .expect(1) + .mount(&server) + .await; + + let provider = project_provider(&server); + let config = ModelConfig::new("production-chat").with_temperature(Some(0.7)); + provider + .complete(&config, "system", &[], &[]) + .await + .unwrap(); + let requests = server.received_requests().await.unwrap(); + let payload: serde_json::Value = requests + .iter() + .find(|request| request.url.path().ends_with("/responses")) + .unwrap() + .body_json() + .unwrap(); + assert_eq!(payload["model"], "production-chat"); + assert!(payload.get("temperature").is_none()); + assert!(payload.get("capability_model").is_none()); + assert!(config.request_params.is_none()); + assert!(serde_json::to_value(&config) + .unwrap() + .get("capability_model") + .is_none()); + } + + #[tokio::test] + async fn suffixed_deployment_without_metadata_is_preserved_on_the_wire() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/projects/test/deployments")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"value": []}))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/projects/test/openai/v1/responses")) + .and(body_partial_json(json!({"model": "gpt-5-high"}))) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(responses_stream()) + .append_header("content-type", "text/event-stream"), + ) + .expect(1) + .mount(&server) + .await; + + project_provider(&server) + .complete(&raw_model_config("gpt-5-high"), "system", &[], &[]) + .await + .unwrap(); + } + + #[tokio::test] + async fn suffixed_deployment_alias_is_preserved_on_the_wire() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/projects/test/deployments")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value": [{ + "type": "ModelDeployment", + "name": "gpt-5-high", + "modelName": "gpt-5", + "modelPublisher": "OpenAI" + }] + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/projects/test/openai/v1/responses")) + .and(body_partial_json(json!({"model": "gpt-5-high"}))) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(responses_stream()) + .append_header("content-type", "text/event-stream"), + ) + .expect(1) + .mount(&server) + .await; + + let provider = project_provider(&server); + let config = raw_model_config("gpt-5-high") + .with_thinking_effort(crate::thinking::ThinkingEffort::Off); + assert_eq!(config.model_name, "gpt-5-high"); + provider + .complete(&config, "system", &[], &[]) + .await + .unwrap(); + } + + #[tokio::test] + async fn anthropic_alias_uses_underlying_output_limit_and_preserves_override() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/projects/test/deployments")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value": [{ + "type": "ModelDeployment", + "name": "claude-prod", + "modelName": "claude-sonnet-4-6", + "modelPublisher": "Anthropic" + }] + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/anthropic/v1/messages")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(anthropic_stream()) + .append_header("content-type", "text/event-stream"), + ) + .expect(2) + .mount(&server) + .await; + + let provider = project_provider(&server); + provider + .complete(&ModelConfig::new("claude-prod"), "system", &[], &[]) + .await + .unwrap(); + provider + .complete( + &ModelConfig::new("claude-prod").with_max_tokens(Some(12_345)), + "system", + &[], + &[], + ) + .await + .unwrap(); + + let requests = server.received_requests().await.unwrap(); + let max_tokens: Vec = requests + .iter() + .filter(|request| request.url.path().ends_with("/messages")) + .map(|request| { + request.body_json::().unwrap()["max_tokens"] + .as_i64() + .unwrap() + }) + .collect(); + assert_eq!(max_tokens, vec![128_000, 12_345]); + } + + #[tokio::test] + async fn canonical_claude_deployment_uses_canonical_output_limit() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/projects/test/deployments")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value": [{ + "type": "ModelDeployment", + "name": "claude-sonnet-4-6", + "modelName": "claude-sonnet-4-6", + "modelPublisher": "Anthropic" + }] + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/anthropic/v1/messages")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(anthropic_stream()) + .append_header("content-type", "text/event-stream"), + ) + .expect(1) + .mount(&server) + .await; + + let provider = project_provider(&server); + provider + .complete(&raw_model_config("claude-sonnet-4-6"), "system", &[], &[]) + .await + .unwrap(); + + let request = server + .received_requests() + .await + .unwrap() + .into_iter() + .find(|request| request.url.path().ends_with("/messages")) + .unwrap(); + let payload = request.body_json::().unwrap(); + assert_eq!(payload["max_tokens"], 128_000); + } + + #[tokio::test] + async fn maas_uses_v1_chat_completions_path_and_bound_model() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(chat_stream()) + .append_header("content-type", "text/event-stream"), + ) + .expect(1) + .mount(&server) + .await; + let provider = AzureFoundryProvider::create( + server.uri(), + None, + Some("bound-model".to_string()), + AuthMethod::NoAuth, + AuthMethod::NoAuth, + AuthMethod::NoAuth, + AuthMethod::NoAuth, + None, + None, + ) + .unwrap(); + let config = raw_model_config("wrong-model"); + provider + .complete(&config, "system", &[], &[]) + .await + .unwrap(); + let request = server.received_requests().await.unwrap().pop().unwrap(); + let payload = request.body_json::().unwrap(); + assert_eq!(payload["model"], "bound-model"); + assert!(payload.get("azure_foundry_deployment").is_none()); + assert_eq!( + provider.fetch_supported_models().await.unwrap(), + vec!["bound-model"] + ); + } + + #[tokio::test] + async fn custom_deployment_names_route_to_all_three_surfaces() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/projects/test/deployments")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value": [ + {"type":"ModelDeployment","name":"openai-prod","modelName":"gpt-5","modelVersion":"1","modelPublisher":"OpenAI"}, + {"type":"ModelDeployment","name":"claude-prod","modelName":"claude-sonnet-4-6","modelVersion":"1","modelPublisher":"Anthropic"}, + {"type":"ModelDeployment","name":"partner-prod","modelName":"Mistral-large","modelVersion":"1","modelPublisher":"MistralAI"} + ] + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/projects/test/openai/v1/responses")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(responses_stream()) + .append_header("content-type", "text/event-stream"), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/anthropic/v1/messages")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(anthropic_stream()) + .append_header("content-type", "text/event-stream"), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/projects/test/openai/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(chat_stream()) + .append_header("content-type", "text/event-stream"), + ) + .expect(1) + .mount(&server) + .await; + + let provider = project_provider(&server); + for deployment in ["openai-prod", "claude-prod", "partner-prod"] { + provider + .complete(&ModelConfig::new(deployment), "system", &[], &[]) + .await + .unwrap(); + } + } +} diff --git a/crates/goose-providers/src/lib.rs b/crates/goose-providers/src/lib.rs index 77e011b17..745426458 100644 --- a/crates/goose-providers/src/lib.rs +++ b/crates/goose-providers/src/lib.rs @@ -1,5 +1,6 @@ pub mod anthropic; pub mod api_client; +pub mod azure_foundry; pub mod databricks; pub mod databricks_auth; pub mod databricks_v2; diff --git a/crates/goose-providers/src/openai.rs b/crates/goose-providers/src/openai.rs index e033edaef..d97c30aee 100644 --- a/crates/goose-providers/src/openai.rs +++ b/crates/goose-providers/src/openai.rs @@ -11,7 +11,8 @@ use crate::formats::openai::{ create_request_with_options, get_cost, get_usage, response_to_message, OpenAiFormatOptions, }; use crate::formats::openai_responses::{ - create_responses_request, get_responses_usage, responses_api_to_message, ResponsesApiResponse, + create_responses_request_for_model, get_responses_usage, responses_api_to_message, + ResponsesApiResponse, }; use crate::images::ImageFormat; use crate::openai_compatible::{ @@ -272,6 +273,80 @@ impl OpenAiProviderBuilder { } impl OpenAiProvider { + pub async fn stream_for_model( + &self, + model_config: &ModelConfig, + wire_model: &str, + capability_model: &str, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result { + let mut payload = create_responses_request_for_model( + model_config, + wire_model, + capability_model, + system, + messages, + tools, + )?; + payload["stream"] = serde_json::Value::Bool(self.supports_streaming); + self.stream_responses_payload(model_config, payload).await + } + + async fn stream_responses_payload( + &self, + model_config: &ModelConfig, + payload: serde_json::Value, + ) -> Result { + let mut log = start_log(model_config, &payload)?; + let response = self + .with_retry(|| async { + handle_status( + self.api_client + .request(&Self::map_base_path( + &self.base_path, + "responses", + OPEN_AI_DEFAULT_RESPONSES_PATH, + )) + .model_headers(model_config)? + .response_post(&payload) + .await?, + ) + .await + }) + .await + .inspect_err(|e| { + let _ = log.error(e); + })?; + if self.supports_streaming { + stream_responses_compat(response, log) + } else { + let json: serde_json::Value = response.json().await.map_err(|e| { + ProviderError::RequestFailed(format!("Failed to parse JSON: {}", e)) + })?; + let parsed: ResponsesApiResponse = + serde_json::from_value(json.clone()).map_err(|e| { + ProviderError::ExecutionError(format!( + "Failed to parse responses API response: {}", + e + )) + })?; + let message = responses_api_to_message(&parsed)?; + let usage_data = get_responses_usage(&parsed); + let usage_json = json.get("usage").unwrap_or(&serde_json::Value::Null); + let mut usage = ProviderUsage::new(model_config.model_name.clone(), usage_data); + if let Some(cost) = get_cost(usage_json) { + usage = usage.with_cost(cost, CostSource::ProviderReported); + } + log.write( + &serde_json::to_value(&message).unwrap_or_default(), + Some(&usage_data), + )?; + Ok(super::base::stream_from_single_message(message, usage)) + } + } + #[doc(hidden)] pub fn new(api_client: ApiClient) -> Self { Self { @@ -584,6 +659,13 @@ impl Provider for OpenAiProvider { &self.name } + async fn refresh_credentials(&self) -> Result<(), ProviderError> { + self.api_client + .refresh_credentials() + .await + .map_err(|error| ProviderError::Authentication(error.to_string())) + } + fn skip_canonical_filtering(&self) -> bool { self.skip_canonical_filtering } @@ -655,61 +737,17 @@ impl Provider for OpenAiProvider { tools: &[Tool], ) -> Result { if self.should_use_responses_api_for_provider(&model_config.model_name) { - let mut payload = create_responses_request(model_config, system, messages, tools)?; - payload["stream"] = serde_json::Value::Bool(self.supports_streaming); - - let mut log = start_log(model_config, &payload)?; - - let response = self - .with_retry(|| async { - let payload_clone = payload.clone(); - let resp = self - .api_client - .request(&Self::map_base_path( - &self.base_path, - "responses", - OPEN_AI_DEFAULT_RESPONSES_PATH, - )) - .model_headers(model_config)? - .response_post(&payload_clone) - .await?; - handle_status(resp).await - }) - .await - .inspect_err(|e| { - let _ = log.error(e); - })?; - - if self.supports_streaming { - stream_responses_compat(response, log) - } else { - let json: serde_json::Value = response.json().await.map_err(|e| { - ProviderError::RequestFailed(format!("Failed to parse JSON: {}", e)) - })?; - - let responses_api_response: ResponsesApiResponse = - serde_json::from_value(json.clone()).map_err(|e| { - ProviderError::ExecutionError(format!( - "Failed to parse responses API response: {}", - e - )) - })?; - - let message = responses_api_to_message(&responses_api_response)?; - let usage_data = get_responses_usage(&responses_api_response); - let usage_json = json.get("usage").unwrap_or(&serde_json::Value::Null); - let mut usage = ProviderUsage::new(model_config.model_name.clone(), usage_data); - if let Some(cost) = get_cost(usage_json) { - usage = usage.with_cost(cost, CostSource::ProviderReported); - } - - log.write( - &serde_json::to_value(&message).unwrap_or_default(), - Some(&usage_data), - )?; - - Ok(super::base::stream_from_single_message(message, usage)) - } + let (wire_model, _) = + crate::formats::openai::extract_reasoning_effort(&model_config.model_name); + self.stream_for_model( + model_config, + &wire_model, + &model_config.model_name, + system, + messages, + tools, + ) + .await } else { let payload = create_request_with_options( model_config, diff --git a/crates/goose-providers/src/openai_compatible.rs b/crates/goose-providers/src/openai_compatible.rs index b9e4648a4..2d0c12772 100644 --- a/crates/goose-providers/src/openai_compatible.rs +++ b/crates/goose-providers/src/openai_compatible.rs @@ -18,7 +18,8 @@ use super::retry::ProviderRetry; use crate::conversation::message::Message; use crate::errors::ProviderError; use crate::formats::openai::{ - create_request, get_cost, get_usage, response_to_message, response_to_streaming_message, + create_request, create_request_for_model_with_options, get_cost, get_usage, + response_to_message, response_to_streaming_message, OpenAiFormatOptions, }; use crate::formats::openai_responses::responses_api_to_streaming_message; use crate::model::ModelConfig; @@ -49,6 +50,99 @@ impl OpenAiCompatibleProvider { self } + #[allow(clippy::too_many_arguments)] + fn build_request_for_model( + &self, + model_config: &ModelConfig, + wire_model: &str, + capability_model: &str, + system: &str, + messages: &[Message], + tools: &[Tool], + for_streaming: bool, + ) -> Result { + create_request_for_model_with_options( + model_config, + wire_model, + capability_model, + system, + messages, + tools, + &ImageFormat::OpenAi, + for_streaming, + OpenAiFormatOptions { + preserve_thinking_context: true, + }, + ) + .map_err(|e| ProviderError::RequestFailed(format!("Failed to create request: {}", e))) + } + + pub async fn stream_for_model( + &self, + model_config: &ModelConfig, + wire_model: &str, + capability_model: &str, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result { + let payload = self.build_request_for_model( + model_config, + wire_model, + capability_model, + system, + messages, + tools, + self.supports_streaming, + )?; + self.stream_payload(model_config, payload).await + } + + async fn stream_payload( + &self, + model_config: &ModelConfig, + payload: Value, + ) -> Result { + let mut log = start_log(model_config, &payload)?; + let path = format!("{}chat/completions", self.completions_prefix); + let response = self + .with_retry(|| async { + handle_status( + self.api_client + .request(&path) + .model_headers(model_config)? + .response_post(&payload) + .await?, + ) + .await + }) + .await + .inspect_err(|e| { + let _ = log.error(e); + })?; + if self.supports_streaming { + stream_openai_compat(response, log) + } else { + let json = response.json().await.map_err(|e| { + ProviderError::RequestFailed(format!("Failed to parse JSON: {}", e)) + })?; + let message = response_to_message(&json).map_err(|e| { + ProviderError::RequestFailed(format!("Failed to parse message: {}", e)) + })?; + let usage_json = json.get("usage").unwrap_or(&Value::Null); + let usage_data = get_usage(usage_json); + let mut usage = ProviderUsage::new(model_config.model_name.clone(), usage_data); + if let Some(cost) = get_cost(usage_json) { + usage = usage.with_cost(cost, CostSource::ProviderReported); + } + log.write( + &serde_json::to_value(&message).unwrap_or_default(), + Some(&usage.usage), + )?; + Ok(stream_from_single_message(message, usage)) + } + } + fn build_request( &self, model_config: &ModelConfig, @@ -75,6 +169,13 @@ impl Provider for OpenAiCompatibleProvider { &self.name } + async fn refresh_credentials(&self) -> Result<(), ProviderError> { + self.api_client + .refresh_credentials() + .await + .map_err(|error| ProviderError::Authentication(error.to_string())) + } + async fn fetch_supported_models(&self) -> Result, ProviderError> { let response = self .api_client @@ -116,49 +217,7 @@ impl Provider for OpenAiCompatibleProvider { tools, self.supports_streaming, )?; - let mut log = start_log(model_config, &payload)?; - - let completions_path = format!("{}chat/completions", self.completions_prefix); - let response = self - .with_retry(|| async { - let resp = self - .api_client - .request(&completions_path) - .model_headers(model_config)? - .response_post(&payload) - .await?; - handle_status(resp).await - }) - .await - .inspect_err(|e| { - let _ = log.error(e); - })?; - - if self.supports_streaming { - stream_openai_compat(response, log) - } else { - let json: serde_json::Value = response.json().await.map_err(|e| { - ProviderError::RequestFailed(format!("Failed to parse JSON: {}", e)) - })?; - - let message = response_to_message(&json).map_err(|e| { - ProviderError::RequestFailed(format!("Failed to parse message: {}", e)) - })?; - - let usage_json = json.get("usage").unwrap_or(&serde_json::Value::Null); - let usage_data = get_usage(usage_json); - let mut usage = ProviderUsage::new(model_config.model_name.clone(), usage_data); - if let Some(cost) = get_cost(usage_json) { - usage = usage.with_cost(cost, CostSource::ProviderReported); - } - - log.write( - &serde_json::to_value(&message).unwrap_or_default(), - Some(&usage.usage), - )?; - - Ok(stream_from_single_message(message, usage)) - } + self.stream_payload(model_config, payload).await } } diff --git a/crates/goose/src/model_config.rs b/crates/goose/src/model_config.rs index abd1584b9..5fe2d3c7c 100644 --- a/crates/goose/src/model_config.rs +++ b/crates/goose/src/model_config.rs @@ -14,7 +14,7 @@ pub fn model_config_from_user_config( provider_name: &str, model_name: impl AsRef, ) -> Result { - let model = base_model_config_from_user_config(model_name.as_ref())?; + let model = base_model_config_from_user_config(provider_name, model_name.as_ref())?; materialize_model_config(provider_name, model) } @@ -26,18 +26,26 @@ pub fn model_config_from_user_config_with_session_settings( context_limit: Option, ) -> Result { let config = Config::global(); - let model = base_model_config_from_user_config(model_name.as_ref())?; + let model = base_model_config_from_user_config(provider_name, model_name.as_ref())?; let model = materialize_model_config_inner(model, provider_name, false)? .with_context_limit(context_limit) .with_inherited_session_settings_from(previous, request_params) .with_default_thinking_effort(config.get_goose_thinking_effort()); - Ok(model.with_canonical_limits(provider_name)) + Ok(apply_canonical_limits(provider_name, model)) } pub fn materialize_model_config(provider_name: &str, model: ModelConfig) -> Result { let model = materialize_model_config_inner(model, provider_name, true)?; - Ok(model.with_canonical_limits(provider_name)) + Ok(apply_canonical_limits(provider_name, model)) +} + +fn apply_canonical_limits(provider_name: &str, model: ModelConfig) -> ModelConfig { + if provider_name == goose_providers::azure_foundry::AZURE_FOUNDRY_PROVIDER_NAME { + model + } else { + model.with_canonical_limits(provider_name) + } } fn materialize_model_config_inner( @@ -169,7 +177,10 @@ fn apply_openai_request_params(mut model: ModelConfig) -> ModelConfig { model } -fn base_model_config_from_user_config(model_name: &str) -> Result { +fn base_model_config_from_user_config( + provider_name: &str, + model_name: &str, +) -> Result { let config = Config::global(); let mut model = ModelConfig { model_name: model_name.to_string(), @@ -182,7 +193,9 @@ fn base_model_config_from_user_config(model_name: &str) -> Result { reasoning: None, request_headers: None, }; - model.normalize_effort_suffix(); + if provider_name != goose_providers::azure_foundry::AZURE_FOUNDRY_PROVIDER_NAME { + model.normalize_effort_suffix(); + } Ok(model) } @@ -247,3 +260,27 @@ fn parse_yaml_bool_config(key: &str, value: serde_yaml::Value) -> Result { } } } + +#[cfg(test)] +mod azure_foundry_tests { + use super::*; + + #[test] + fn deployment_name_survives_thinking_effort_changes() { + let config = base_model_config_from_user_config("azure_foundry", "gpt-5-high") + .unwrap() + .with_thinking_effort(ThinkingEffort::Off); + + assert_eq!(config.model_name, "gpt-5-high"); + assert_eq!(config.context_limit, None); + assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Off)); + } + + #[test] + fn none_suffixed_deployment_name_is_preserved() { + let config = base_model_config_from_user_config("azure_foundry", "gpt-5-none").unwrap(); + + assert_eq!(config.model_name, "gpt-5-none"); + assert_eq!(config.thinking_effort(), None); + } +} diff --git a/crates/goose/src/providers/azure_foundry_def.rs b/crates/goose/src/providers/azure_foundry_def.rs new file mode 100644 index 000000000..ad961059f --- /dev/null +++ b/crates/goose/src/providers/azure_foundry_def.rs @@ -0,0 +1,173 @@ +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use futures::future::BoxFuture; +use goose_providers::api_client::{AuthMethod, AuthProvider, TlsConfig}; +use goose_providers::azure_foundry::{endpoint_kind, AzureFoundryProvider, EndpointKind}; +use goose_providers::base::{ProviderDescriptor, ProviderMetadata}; + +use crate::config::{Config, ExtensionConfig}; +use crate::providers::azureauth::{AzureAuth, AzureCredentials}; +use crate::providers::base::ProviderDef; + +const AZURE_PROJECT_ENTRA_RESOURCE: &str = "https://ai.azure.com"; +const AZURE_MAAS_ENTRA_RESOURCE: &str = "https://ml.azure.com"; + +enum AuthHeader { + ApiKey, + Bearer, +} + +struct AzureFoundryAuthProvider { + auth: Arc, + header: AuthHeader, +} + +#[async_trait] +impl AuthProvider for AzureFoundryAuthProvider { + async fn get_auth_header(&self) -> Result<(String, String)> { + let token = self.auth.get_token().await?; + match &self.header { + AuthHeader::ApiKey => Ok(("api-key".to_string(), token.token_value)), + AuthHeader::Bearer => Ok(( + "Authorization".to_string(), + format!("Bearer {}", token.token_value), + )), + } + } + + async fn refresh_credentials(&self) -> Result<()> { + self.auth.invalidate_token().await; + Ok(()) + } +} + +pub struct AzureFoundryProviderDef; + +impl ProviderDescriptor for AzureFoundryProviderDef { + fn metadata() -> ProviderMetadata { + AzureFoundryProvider::metadata() + } +} + +impl ProviderDef for AzureFoundryProviderDef { + type Provider = AzureFoundryProvider; + + fn from_env( + _extensions: Vec, + tls_config: Option, + ) -> BoxFuture<'static, Result> { + Box::pin(from_env(tls_config)) + } +} + +pub async fn from_env(tls_config: Option) -> Result { + let config = Config::global(); + let endpoint: String = config.get_param("AZURE_FOUNDRY_ENDPOINT")?; + let api_version = config.get_param("AZURE_FOUNDRY_API_VERSION").ok(); + let maas_model = config + .get_param::("AZURE_FOUNDRY_MODEL") + .ok() + .filter(|model| !model.trim().is_empty()); + let api_key = config + .get_secret::("AZURE_FOUNDRY_API_KEY") + .ok() + .filter(|key| !key.is_empty()); + let ad_token = config + .get_secret::("AZURE_FOUNDRY_AD_TOKEN") + .ok() + .filter(|token| !token.is_empty()); + let endpoint_kind = endpoint_kind(&endpoint); + let resource = if endpoint_kind == EndpointKind::Maas { + AZURE_MAAS_ENTRA_RESOURCE + } else { + AZURE_PROJECT_ENTRA_RESOURCE + }; + let auth = Arc::new(AzureAuth::new_with_resource( + api_key, + ad_token, + resource.to_string(), + )?); + let auth_method = |header| { + AuthMethod::Custom(Box::new(AzureFoundryAuthProvider { + auth: Arc::clone(&auth), + header, + })) + }; + let anthropic_auth = match auth.credential_type() { + AzureCredentials::ApiKey(key) => AuthMethod::ApiKey { + header_name: "x-api-key".to_string(), + key: key.clone(), + }, + _ => auth_method(AuthHeader::Bearer), + }; + let api_key_auth_header = || match auth.credential_type() { + AzureCredentials::ApiKey(_) => AuthHeader::ApiKey, + _ => AuthHeader::Bearer, + }; + let chat_auth_header = match auth.credential_type() { + AzureCredentials::ApiKey(_) if endpoint_kind == EndpointKind::Maas => AuthHeader::Bearer, + _ => api_key_auth_header(), + }; + + AzureFoundryProvider::create( + endpoint, + api_version, + maas_model, + auth_method(chat_auth_header), + auth_method(api_key_auth_header()), + anthropic_auth, + auth_method(api_key_auth_header()), + tls_config, + Some(crate::session_context::session_id_request_builder()), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn header( + api_key: Option<&str>, + ad_token: Option<&str>, + header: AuthHeader, + ) -> (String, String) { + let auth = Arc::new( + AzureAuth::new_with_resource( + api_key.map(str::to_string), + ad_token.map(str::to_string), + AZURE_PROJECT_ENTRA_RESOURCE.to_string(), + ) + .unwrap(), + ); + AzureFoundryAuthProvider { auth, header } + .get_auth_header() + .await + .unwrap() + } + + #[tokio::test] + async fn project_api_key_uses_api_key_header() { + assert_eq!( + header(Some("key"), None, AuthHeader::ApiKey).await, + ("api-key".to_string(), "key".to_string()) + ); + } + + #[tokio::test] + async fn maas_api_key_uses_bearer_header() { + assert_eq!( + header(Some("key"), None, AuthHeader::Bearer).await, + ("Authorization".to_string(), "Bearer key".to_string()) + ); + } + + #[tokio::test] + async fn entra_token_uses_bearer_header() { + assert_eq!( + header(None, Some("token"), AuthHeader::Bearer).await, + ("Authorization".to_string(), "Bearer token".to_string()) + ); + } +} diff --git a/crates/goose/src/providers/azureauth.rs b/crates/goose/src/providers/azureauth.rs index cce77b585..231fd6563 100644 --- a/crates/goose/src/providers/azureauth.rs +++ b/crates/goose/src/providers/azureauth.rs @@ -59,6 +59,7 @@ struct TokenResponse { #[derive(Debug)] pub struct AzureAuth { credentials: AzureCredentials, + resource: String, cached_token: Arc>>, } @@ -73,6 +74,18 @@ impl AzureAuth { /// # Returns /// * `Result` - A new AzureAuth instance or an error if initialization fails pub fn new(api_key: Option, ad_token: Option) -> Result { + Self::new_with_resource( + api_key, + ad_token, + "https://cognitiveservices.azure.com".to_string(), + ) + } + + pub fn new_with_resource( + api_key: Option, + ad_token: Option, + resource: String, + ) -> Result { let credentials = match (ad_token, api_key) { (Some(token), _) => AzureCredentials::BearerToken(token), (None, Some(key)) => AzureCredentials::ApiKey(key), @@ -81,6 +94,7 @@ impl AzureAuth { Ok(Self { credentials, + resource, cached_token: Arc::new(RwLock::new(None)), }) } @@ -90,6 +104,10 @@ impl AzureAuth { &self.credentials } + pub async fn invalidate_token(&self) { + *self.cached_token.write().await = None; + } + /// Retrieves a valid authentication token. /// /// This method implements an efficient token management strategy: @@ -137,12 +155,7 @@ impl AzureAuth { let az = if cfg!(windows) { "az.cmd" } else { "az" }; let output = tokio::process::Command::new(az) - .args([ - "account", - "get-access-token", - "--resource", - "https://cognitiveservices.azure.com", - ]) + .args(["account", "get-access-token", "--resource", &self.resource]) .set_no_window() .output() .await diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index df88101b8..319681212 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -37,6 +37,7 @@ use super::{ }; use crate::config::ExtensionConfig; use crate::providers::anthropic_def::AnthropicProviderDef; +use crate::providers::azure_foundry_def::AzureFoundryProviderDef; use crate::providers::base::ProviderType; use crate::providers::databricks_def::{self, DatabricksProviderDef}; use crate::providers::databricks_v2_def::{self, DatabricksV2ProviderDef}; @@ -69,6 +70,10 @@ async fn init_registry() -> RwLock { ); registry.register::(false); registry.register::(false); + registry.register_with_inventory::( + true, + Some(registrations::azure_foundry_inventory()), + ); #[cfg(feature = "aws-providers")] registry.register::(false); #[cfg(feature = "local-inference")] diff --git a/crates/goose/src/providers/inventory/registrations.rs b/crates/goose/src/providers/inventory/registrations.rs index df7f0ed18..b27679fa6 100644 --- a/crates/goose/src/providers/inventory/registrations.rs +++ b/crates/goose/src/providers/inventory/registrations.rs @@ -20,6 +20,7 @@ use crate::providers::ollama::OLLAMA_PROVIDER_NAME; use crate::providers::openai::{OPEN_AI_DEFAULT_BASE_PATH, OPEN_AI_PROVIDER_NAME}; use crate::providers::pi_acp::{PI_ACP_BINARY, PI_ACP_PROVIDER_NAME}; use crate::providers::xai_oauth::TokenCache as XaiOAuthTokenCache; +use goose_providers::azure_foundry::{endpoint_kind, EndpointKind, AZURE_FOUNDRY_PROVIDER_NAME}; pub fn openai_inventory() -> InventoryRegistration { InventoryRegistration::new(true, || { @@ -67,6 +68,52 @@ pub fn openai_inventory() -> InventoryRegistration { }) } +pub fn azure_foundry_inventory() -> InventoryRegistration { + InventoryRegistration::new(true, || { + let config = Config::global(); + let mut identity = + InventoryIdentityInput::new(AZURE_FOUNDRY_PROVIDER_NAME, AZURE_FOUNDRY_PROVIDER_NAME); + if let Ok(endpoint) = config.get_param::("AZURE_FOUNDRY_ENDPOINT") { + identity = identity.with_public("endpoint", endpoint); + } + if let Ok(api_version) = config.get_param::("AZURE_FOUNDRY_API_VERSION") { + identity = identity.with_public("api_version", api_version); + } + if let Ok(model) = config.get_param::("AZURE_FOUNDRY_MODEL") { + identity = identity.with_public("model", model); + } + if let Some(api_key) = config_secret_value(config, "AZURE_FOUNDRY_API_KEY") { + identity = identity.with_secret("api_key", api_key); + } + if let Some(ad_token) = config_secret_value(config, "AZURE_FOUNDRY_AD_TOKEN") { + identity = identity.with_secret("ad_token", ad_token); + } + Ok(identity) + }) + .with_configured(|| azure_foundry_configured(Config::global())) +} + +fn azure_foundry_configured(config: &Config) -> bool { + azure_foundry_configured_values( + config + .get_param::("AZURE_FOUNDRY_ENDPOINT") + .ok() + .as_deref(), + config + .get_param::("AZURE_FOUNDRY_MODEL") + .ok() + .as_deref(), + ) +} + +fn azure_foundry_configured_values(endpoint: Option<&str>, model: Option<&str>) -> bool { + let Some(endpoint) = endpoint.filter(|endpoint| !endpoint.trim().is_empty()) else { + return false; + }; + endpoint_kind(endpoint) != EndpointKind::Maas + || model.is_some_and(|model| !model.trim().is_empty()) +} + pub fn anthropic_inventory() -> InventoryRegistration { InventoryRegistration::new(true, || { let config = Config::global(); @@ -222,6 +269,30 @@ mod tests { use crate::config::paths::Paths; use chrono::Utc; + #[test] + fn azure_foundry_maas_requires_a_model_to_be_configured() { + assert!(!azure_foundry_configured_values( + Some("https://deployment.models.ai.azure.com"), + None, + )); + assert!(!azure_foundry_configured_values( + Some("https://deployment.models.ai.azure.com"), + Some(" "), + )); + assert!(azure_foundry_configured_values( + Some("https://deployment.models.ai.azure.com"), + Some("Phi-4"), + )); + assert!(azure_foundry_configured_values( + Some("https://hub.services.ai.azure.com/api/projects/project"), + None, + )); + assert!(azure_foundry_configured_values( + Some("https://hub.services.ai.azure.com"), + None, + )); + } + #[test] #[serial_test::serial] fn gemini_oauth_inventory_configured_uses_token_cache() { diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 05fea6beb..11243e5e8 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -9,6 +9,7 @@ pub mod api_client { } pub mod avian; pub mod azure; +pub mod azure_foundry_def; pub mod azureauth; pub mod base; #[cfg(feature = "aws-providers")] diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index efbb65a8a..f5160570a 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -715,6 +715,27 @@ impl Session { } } +fn deserialize_session_model_config( + provider_name: Option<&str>, + json: &str, +) -> Option { + let mut model_config: ModelConfig = serde_json::from_str(json).ok()?; + // TODO: Remove this workaround once ModelConfig guarantees deserialize(serialize(config)) == config. + if provider_name == Some(goose_providers::azure_foundry::AZURE_FOUNDRY_PROVIDER_NAME) { + #[derive(Deserialize)] + struct AzurePersistedFields { + model_name: String, + #[serde(default)] + request_params: Option>, + } + + let persisted: AzurePersistedFields = serde_json::from_str(json).ok()?; + model_config.model_name = persisted.model_name; + model_config.request_params = persisted.request_params; + } + Some(model_config) +} + impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result { use sqlx::Row; @@ -726,8 +747,11 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { let user_recipe_values = user_recipe_values_json.and_then(|json| serde_json::from_str(&json).ok()); + let provider_name: Option = row.try_get("provider_name").ok().flatten(); let model_config_json: Option = row.try_get("model_config_json").ok().flatten(); - let model_config = model_config_json.and_then(|json| serde_json::from_str(&json).ok()); + let model_config = model_config_json + .as_deref() + .and_then(|json| deserialize_session_model_config(provider_name.as_deref(), json)); let name: String = { let name_val: String = row.try_get("name").unwrap_or_default(); @@ -788,7 +812,7 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { conversation: None, message_count: row.try_get("message_count").unwrap_or(0) as usize, last_message_at, - provider_name: row.try_get("provider_name").ok().flatten(), + provider_name, model_config, goose_mode: row .try_get::("goose_mode") @@ -2587,6 +2611,61 @@ mod tests { const NUM_CONCURRENT_SESSIONS: i32 = 10; const GENERATED_SESSION_NAME: &str = "Generated session name"; + #[test] + fn azure_session_model_config_preserves_suffixed_deployment_id() { + let json = serde_json::to_string(&ModelConfig { + model_name: "gpt-5-high".to_string(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, + request_params: None, + reasoning: None, + request_headers: None, + }) + .unwrap(); + + let config = deserialize_session_model_config( + Some(goose_providers::azure_foundry::AZURE_FOUNDRY_PROVIDER_NAME), + &json, + ) + .unwrap(); + + assert_eq!(config.model_name, "gpt-5-high"); + assert_eq!(config.thinking_effort(), None); + } + + #[test] + fn azure_session_model_config_preserves_explicit_thinking_effort() { + let config = deserialize_session_model_config( + Some(goose_providers::azure_foundry::AZURE_FOUNDRY_PROVIDER_NAME), + r#"{"model_name":"gpt-5-high","context_limit":null,"temperature":null,"max_tokens":null,"toolshim":false,"toolshim_model":null,"request_params":{"thinking_effort":"low"}}"#, + ) + .unwrap(); + + assert_eq!(config.model_name, "gpt-5-high"); + assert_eq!( + config.thinking_effort(), + Some(goose_providers::thinking::ThinkingEffort::Low) + ); + } + + #[test] + fn non_azure_session_model_config_keeps_suffix_normalization() { + let config = deserialize_session_model_config( + Some(goose_providers::openai::OPEN_AI_PROVIDER_NAME), + r#"{"model_name":"gpt-5-high","context_limit":null,"temperature":null,"max_tokens":null,"toolshim":false,"toolshim_model":null}"#, + ) + .unwrap(); + + assert_eq!(config.model_name, "gpt-5"); + assert_eq!( + config.thinking_effort(), + Some(goose_providers::thinking::ThinkingEffort::High) + ); + } + struct NamingTestProvider; #[async_trait::async_trait] diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index ddf72988b..d7200c278 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -27,6 +27,7 @@ goose is compatible with a wide range of LLM providers, allowing you to choose a | [Anthropic](https://www.anthropic.com/) | Offers Claude, an advanced AI model for natural language tasks. | `ANTHROPIC_API_KEY`, `ANTHROPIC_HOST` (optional) | | [Atomic Chat](https://github.com/AtomicBot-ai/Atomic-Chat) | Run local models with Atomic Chat's OpenAI-compatible server. **Because this provider runs locally, you must first [download a model](#local-llms).** | None required. Connects to local server at `localhost:1337` by default. | | [Avian](https://avian.io/) | Cost-effective inference API with DeepSeek, Kimi, GLM, and MiniMax models. OpenAI-compatible with streaming and function calling support. | `AVIAN_API_KEY`, `AVIAN_HOST` (optional) | +| [Azure AI Foundry](/docs/guides/azure-foundry-provider) | Access OpenAI, Anthropic, Microsoft, Meta, Mistral, DeepSeek, GLM, Kimi, and other models deployed through Azure AI Foundry project or MaaS endpoints. | `AZURE_FOUNDRY_ENDPOINT`, `AZURE_FOUNDRY_API_KEY` (optional), `AZURE_FOUNDRY_AD_TOKEN` (optional), `AZURE_FOUNDRY_API_VERSION` (optional) | | [Azure OpenAI](https://learn.microsoft.com/en-us/azure/ai-services/openai/) | Access Azure-hosted OpenAI models, including GPT-4 and GPT-3.5. Supports API key, Entra ID bearer token, and Azure credential chain authentication. | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_KEY` (optional), `AZURE_OPENAI_AD_TOKEN` (optional) | | [ChatGPT Codex](https://chatgpt.com/codex) | Access GPT-5 Codex models optimized for code generation and understanding. **Requires a ChatGPT Plus/Pro subscription.** | No manual key. Uses browser-based OAuth authentication for both CLI and Desktop. | | [Databricks](https://www.databricks.com/) | Unified data analytics and AI platform for building and deploying models. | `DATABRICKS_HOST`, `DATABRICKS_TOKEN` | diff --git a/documentation/docs/guides/azure-foundry-provider.md b/documentation/docs/guides/azure-foundry-provider.md new file mode 100644 index 000000000..552d49953 --- /dev/null +++ b/documentation/docs/guides/azure-foundry-provider.md @@ -0,0 +1,101 @@ +--- +title: Azure AI Foundry +description: Use OpenAI, Anthropic, and partner model deployments from an Azure AI Foundry project +--- + +# Azure AI Foundry + +The `azure_foundry` provider connects goose to Azure AI Foundry deployments. It supports two endpoint types: + +| Endpoint | Inference surface | +|---|---| +| Foundry project: `https://.services.ai.azure.com/api/projects/` | Deployment discovery plus publisher-aware routing | +| Foundry resource: `https://.services.ai.azure.com` | OpenAI models through Responses, Claude through Anthropic Messages, and partner models through Chat Completions | +| MaaS/serverless: `https://..models.ai.azure.com` | Chat Completions for the model bound to the endpoint | + +For project endpoints, goose discovers deployments with `GET /deployments`. Deployment names can be customized; goose uses the returned `modelPublisher` to select the protocol and `modelName` to resolve model metadata such as the context window. + +Resource endpoints do not expose project deployment discovery. goose routes recognizable model or deployment names by family: `gpt-5*` and supported o-series models use Responses, `claude-*` uses Anthropic Messages, and other names use Chat Completions. Use a project endpoint when aliases do not identify their underlying model family. + +## Configuration + +| Variable | Required | Description | +|---|---:|---| +| `AZURE_FOUNDRY_ENDPOINT` | Yes | Full Foundry project or MaaS endpoint | +| `AZURE_FOUNDRY_API_KEY` | No | API key; omit it to use Azure CLI credentials | +| `AZURE_FOUNDRY_MODEL` | MaaS only | Model bound to the configured MaaS endpoint | +| `AZURE_FOUNDRY_AD_TOKEN` | No | Pre-acquired Microsoft Entra access token; takes precedence over the API key | +| `AZURE_FOUNDRY_API_VERSION` | No | Deployment discovery API version; project endpoints default to `v1` | + +Run `goose configure`, select **Configure Providers**, and choose **Azure AI Foundry**. You can also set the variables before starting goose: + +```sh +export AZURE_FOUNDRY_ENDPOINT="https://my-resource.services.ai.azure.com/api/projects/my-project" +export AZURE_FOUNDRY_API_KEY="" +goose session +``` + +For a MaaS endpoint: + +```sh +export AZURE_FOUNDRY_ENDPOINT="https://my-deployment.eastus.models.ai.azure.com" +export AZURE_FOUNDRY_API_KEY="" +export AZURE_FOUNDRY_MODEL="" +goose session +``` + +MaaS endpoints expose a single deployed model. `AZURE_FOUNDRY_MODEL` is required for these endpoints. + +## Authentication + +Authentication is selected in this order: + +1. `AZURE_FOUNDRY_AD_TOKEN` +2. `AZURE_FOUNDRY_API_KEY` +3. Azure CLI credentials + +When neither token nor key is configured, sign in with Azure CLI before starting goose: + +```sh +az login +``` + +Project and resource endpoints request a token for `https://ai.azure.com`. MaaS endpoints request a token for `https://ml.azure.com`. + +## Protocol routing + +For a project endpoint, goose routes each deployment using metadata returned by Azure: + +- publisher `OpenAI` with a Responses-compatible model (`gpt-5*` and the supported o-series) → `/openai/v1/responses` +- publisher `Anthropic` → `/anthropic/v1/messages` +- older OpenAI models and all other publishers → `/openai/v1/chat/completions` + +If deployment discovery is temporarily unavailable, recognizable Responses-compatible OpenAI and `claude-*` names use their native surfaces. Other names use Chat Completions. + +Resource endpoints use the same inference surfaces without deployment discovery: recognizable model-family names select the native protocol. This allows a custom agent's `model:` declaration to select deployments such as `gpt-5.6-sol` without rewriting the name through canonical model resolution. + +MaaS endpoints always use `/v1/chat/completions` and the model configured by `AZURE_FOUNDRY_MODEL`. + +## Model metadata and pricing + +The deployments API provides the deployment name and underlying `modelName`, `modelVersion`, and `modelPublisher`. goose uses the underlying model name to look up a context window in its bundled model catalog. An explicit `GOOSE_CONTEXT_LIMIT` or session override still takes precedence. + +Azure pricing depends on region, SKU, offer, deployment type, and contract. The deployments API does not provide a reliable per-token price, so this provider does not attach a price to discovered deployments. + +## Troubleshooting + +### 401 or 403 + +- Ensure the key belongs to the configured endpoint. +- For Entra authentication, run `az login` again and verify that your identity has access to the Foundry project. +- Do not use a project endpoint key with a MaaS endpoint, or the reverse. + +### No deployments are listed + +- Confirm that the endpoint includes `/api/projects/`. +- Confirm that the project contains model deployments. +- If your project uses a non-default deployment API version, set `AZURE_FOUNDRY_API_VERSION`. + +### Wrong protocol for a custom deployment name + +Refresh the provider model list so goose can retrieve `modelPublisher`. Without deployment metadata, routing can only use recognizable model-name prefixes. diff --git a/goose-self-test.yaml b/goose-self-test.yaml index cfcb042b8..4e71290d1 100644 --- a/goose-self-test.yaml +++ b/goose-self-test.yaml @@ -8,6 +8,7 @@ activities: - Initialize test workspace and logging infrastructure - Test file operations (create, read, update, delete, undo) - Validate shell command execution and error handling + - Validate Azure AI Foundry provider routing and deployment aliases - Analyze code structure and parsing capabilities - Test extension discovery and management - Test load tool for knowledge injection and discovery @@ -156,6 +157,14 @@ prompt: | 4. Test symbol focus and call graphs 5. Verify LOC, function, and class counting + ### Azure AI Foundry Provider Validation + When running from a goose source checkout: + 1. Run the goose-providers Azure Foundry test module. + 2. Verify project deployments route Responses-compatible OpenAI models to Responses, Anthropic models to Messages, and partner or older OpenAI models to Chat Completions. + 3. Verify a custom deployment alias remains the wire model while the underlying Azure model controls reasoning capabilities. + 4. Verify MaaS requires its bound model and uses /v1/chat/completions. + If the source checkout or Rust toolchain is unavailable, mark this validation as skipped rather than failed. + Log results to: {{ workspace_dir }}/phase1_basic_tools.md {% endif %} diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderLogo.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderLogo.tsx index 12a1e8c8c..b1bdcdc40 100644 --- a/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderLogo.tsx +++ b/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderLogo.tsx @@ -9,6 +9,7 @@ import SnowflakeLogo from './icons/snowflake@3x.png'; import XaiLogo from './icons/xai@3x.png'; import MiniMaxLogo from './icons/minimax@3x.png'; import TanzuLogo from './icons/tanzu@3x.png'; +import AzureFoundryLogo from './icons/azure_foundry@3x.png'; import DefaultLogo from './icons/default@3x.png'; import { defineMessages, useIntl } from '../../../../../i18n'; @@ -32,6 +33,7 @@ const providerLogos: Record = { xai: XaiLogo, minimax: MiniMaxLogo, tanzu_ai: TanzuLogo, + azure_foundry: AzureFoundryLogo, default: DefaultLogo, }; diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/azure_foundry.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/azure_foundry.png new file mode 100644 index 0000000000000000000000000000000000000000..bb60c3d695e55b2953c485959c548fa94d1c6dc9 GIT binary patch literal 2512 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 E8?fa>9|66}#`HB4n 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 literal 0 HcmV?d00001 diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/azure_foundry@3x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/azure_foundry@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..a4216305a441d6a96de55269270d27c139bef1e6 GIT binary patch literal 8495 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 literal 0 HcmV?d00001