diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index 551ba3129b..216d10bab9 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -30,6 +30,52 @@ pub const OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; pub const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1"; pub const GOOGLE_AI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta"; +/// Hosts that serve the OpenAI API with Azure conventions: Azure OpenAI +/// (`*.openai.azure.com`), AI Foundry (`*.services.ai.azure.com`, +/// `*.cognitiveservices.azure.com`), their sovereign-cloud counterparts, and API +/// Management fronting either. +const AZURE_HOST_SUFFIXES: &[&str] = &[ + ".azure.com", + ".azure.us", + ".azure.cn", + ".azure-api.net", + ".azure-api.us", + ".azure-api.cn", +]; + +/// The deployment path Azure OpenAI serves under. It identifies an Azure endpoint +/// reached through a custom domain, which `AZURE_HOST_SUFFIXES` cannot catch — it +/// is the format `openai_azure_base_path` documents. +const AZURE_DEPLOYMENTS_PATH: &str = "/openai/deployments"; + +/// Whether an OpenAI-API base URL is served by Azure, which authenticates with the +/// `api-key` header and lays its routes out under `/openai/...`. +/// +/// An OpenAI-compatible endpoint on an Azure-owned domain (e.g. a self-hosted +/// server behind API Management) is misread as Azure; such a resource has to use +/// the `customai` provider. +fn is_azure_endpoint(base_url: &str) -> bool { + let authority = base_url + .split_once("://") + .map_or(base_url, |(_, rest)| rest) + .split(['/', '?', '#']) + .next() + .unwrap_or_default(); + let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + let host = host_port + .rsplit_once(':') + .map_or(host_port, |(host, _)| host) + .trim_end_matches('.') + .to_ascii_lowercase(); + + AZURE_HOST_SUFFIXES + .iter() + .any(|suffix| host.ends_with(suffix)) + || base_url + .to_ascii_lowercase() + .contains(AZURE_DEPLOYMENTS_PATH) +} + /// Empty string signals BedrockClient::from_env() to use the region from AWS environment/config /// (e.g., AWS_REGION or AWS_DEFAULT_REGION env vars, or ~/.aws/config) pub const USE_ENV_REGION: &str = ""; @@ -107,7 +153,7 @@ impl AIProvider { OPENAI_AZURE_BASE_PATH.clone() }; - Ok(azure_base_path.unwrap_or("https://api.openai.com/v1".to_string())) + Ok(azure_base_path.unwrap_or_else(|| OPENAI_BASE_URL.to_string())) } AIProvider::DeepSeek => Ok(DEEPSEEK_BASE_URL.to_string()), AIProvider::GoogleAI => Ok(GOOGLE_AI_BASE_URL.to_string()), @@ -139,10 +185,17 @@ impl AIProvider { /// 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. + /// endpoint through `openai_azure_base_path` or a resource base URL. + /// + /// A custom `OpenAI` base URL that is not an Azure endpoint is an + /// OpenAI-compatible one (gateway, proxy, self-hosted server) and keeps bearer + /// auth and the plain `/` layout. pub fn is_azure(&self, base_url: &str) -> bool { - (matches!(self, AIProvider::OpenAI) && base_url != OPENAI_BASE_URL) - || matches!(self, AIProvider::AzureOpenAI | AIProvider::AzureFoundry) + match self { + AIProvider::AzureOpenAI | AIProvider::AzureFoundry => true, + AIProvider::OpenAI => is_azure_endpoint(base_url), + _ => false, + } } /// Build an Azure-style OpenAI-compatible URL (Azure OpenAI / Azure AI Foundry) @@ -300,6 +353,39 @@ mod tests { ); } + /// An OpenAI resource with a custom base URL is an OpenAI-compatible endpoint + /// unless it is an Azure one. Azure treatment swaps bearer auth for the + /// `api-key` header and rewrites the path, so both directions must hold. + #[test] + fn openai_is_azure_only_for_azure_endpoints() { + for base_url in [ + // A gateway path ending in /openai must not read as Azure. + "https://gateway-eu.pydantic.dev/proxy/openai", + "https://openrouter.ai/api/v1", + "http://localhost:4000/v1", + OPENAI_BASE_URL, + ] { + assert!( + !AIProvider::OpenAI.is_azure(base_url), + "{base_url} must not be treated as Azure" + ); + } + + for base_url in [ + "https://example.openai.azure.com/openai/deployments/my-deployment", + "https://wm-test-ai.services.ai.azure.com", + "https://contoso.azure-api.cn/openai", + // Azure OpenAI behind a custom domain, the format + // `openai_azure_base_path` documents. + "https://openai.contoso.com/openai/deployments/gpt-4o", + ] { + assert!( + AIProvider::OpenAI.is_azure(base_url), + "{base_url} must be treated as Azure" + ); + } + } + #[test] fn azure_foundry_anthropic_url_from_root_and_legacy() { // Root URL. diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs index a255f47a6d..52ca0085f9 100644 --- a/backend/windmill-ai/src/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -2,14 +2,13 @@ use crate::{ ai_google::parse_data_url, ai_providers::{AIPlatform, AIProvider}, image_handler::prepare_messages_for_api, - proxy::{add_user_to_body, ProxyBuildArgs, ProxyRequest}, + proxy::{ + add_user_to_body, common_outbound_headers, credential_header, ProxyBuildArgs, ProxyRequest, + }, query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink}, sse::{AnthropicSSEParser, SSEParser}, types::*, - utils::{ - collect_system_prompt, extract_text_content, should_use_structured_output_tool, - AI_HTTP_HEADERS, - }, + utils::{collect_system_prompt, extract_text_content, should_use_structured_output_tool}, }; use async_trait::async_trait; use http::Method; @@ -544,31 +543,23 @@ impl AnthropicQueryBuilder { } } - if let Some(api_key) = credentials.api_key.as_ref() { - headers.push(("authorization".to_string(), format!("Bearer {}", api_key))); - if !is_vertex { - headers.push(("X-API-Key".to_string(), api_key.clone())); - } - } - - if let Some(access_token) = credentials.access_token.as_ref() { - headers.push(( - "authorization".to_string(), - format!("Bearer {}", access_token), - )); - } + // One credential header, matching `get_auth_headers`: Vertex takes an OAuth + // bearer token, every other Messages endpoint takes x-api-key. Endpoints in + // front of Anthropic reject requests carrying both. + headers.extend(credential_header( + credentials, + if is_vertex { + "authorization" + } else { + "x-api-key" + }, + )); if let Some(org_id) = credentials.organization_id.as_ref() { headers.push(("OpenAI-Organization".to_string(), org_id.clone())); } - for (header_name, header_value) in AI_HTTP_HEADERS.iter() { - headers.push((header_name.clone(), header_value.clone())); - } - - for (header_name, header_value) in &credentials.custom_headers { - headers.push((header_name.clone(), header_value.clone())); - } + headers.extend(common_outbound_headers(credentials)); Ok(ProxyRequest { method: args.method.clone(), url, headers, body }) } @@ -953,12 +944,11 @@ mod tests { assert_eq!(request.method, Method::POST); assert_eq!(request.url, "https://api.anthropic.com/v1/messages"); assert_eq!(request.body, body.to_vec()); - assert!(has_header( - &request.headers, - "authorization", - "Bearer api-key" - )); - assert!(has_header(&request.headers, "X-API-Key", "api-key")); + assert!(has_header(&request.headers, "x-api-key", "api-key")); + assert!(!request + .headers + .iter() + .any(|(header_name, _)| header_name.eq_ignore_ascii_case("authorization"))); assert!(has_header( &request.headers, "anthropic-version", @@ -1148,6 +1138,40 @@ mod tests { assert!(matches!(err, Error::BadRequest(message) if message.contains("Missing 'model'"))); } + /// Endpoints that authenticate with a bearer token configure it as a resource + /// header; the built-in x-api-key must then step aside, since outgoing headers + /// are appended and both credentials would travel. + #[test] + fn resource_header_replaces_the_built_in_credential() { + let mut credentials = credentials(AIPlatform::Standard); + credentials.custom_headers = HashMap::from([( + "Authorization".to_string(), + "Bearer gateway-token".to_string(), + )]); + let builder = AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::Standard); + let method = Method::POST; + + let request = builder + .build_proxy_request(&ProxyBuildArgs { + method: &method, + path: "messages", + headers: &HeaderMap::new(), + body: br#"{"model":"claude-sonnet-4","messages":[]}"#, + credentials: &credentials, + }) + .unwrap(); + + assert!(has_header( + &request.headers, + "Authorization", + "Bearer gateway-token" + )); + assert!(!request + .headers + .iter() + .any(|(header_name, _)| header_name.eq_ignore_ascii_case("x-api-key"))); + } + #[test] fn builds_azure_foundry_anthropic_proxy_request() { // Foundry resource stored with a legacy /openai/v1 suffix; the Anthropic SDK @@ -1176,6 +1200,6 @@ mod tests { request.url, "https://wm-test-ai.services.ai.azure.com/anthropic/v1/messages" ); - assert!(has_header(&request.headers, "X-API-Key", "api-key")); + assert!(has_header(&request.headers, "x-api-key", "api-key")); } } diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index fe4a0229d4..99c6188d3c 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -82,6 +82,72 @@ pub fn supports_query_builder_proxy(provider: &AIProvider) -> bool { proxy_execution_mode(provider).uses_query_builder_proxy() } +/// The headers a provider can carry its credential in. Any other resource header +/// is passed through untouched, so a header one provider authenticates with stays +/// an ordinary header for the providers that do not. +pub const CREDENTIAL_HEADERS: [&str; 4] = + ["authorization", "x-api-key", "api-key", "x-goog-api-key"]; + +/// Whether a resource header takes over `built_in`, the credential header Windmill +/// would otherwise send. Outgoing headers are appended rather than replaced, so +/// keeping the built-in one alongside a resource-supplied credential would put two +/// on the wire, which endpoints reject. +/// +/// An `authorization` header always takes over, whatever `built_in` is: every +/// endpoint reads it as the credential, so it can never coexist with another one. +pub fn resource_replaces_credential(credentials: &ProviderCredentials, built_in: &str) -> bool { + credentials.custom_headers.keys().any(|header_name| { + header_name.eq_ignore_ascii_case(built_in) + || header_name.eq_ignore_ascii_case("authorization") + }) +} + +/// The credential header for an outbound request, or `None` when the resource +/// supplies its own. +/// +/// An api key goes in `built_in`, the header the provider authenticates keys +/// with. An OAuth token is always a bearer token instead, whatever header that +/// provider's keys use — Azure OpenAI reads keys from `api-key` but Entra ID +/// tokens only from `authorization`. `authorization` carries `Bearer ` +/// and every other credential header carries the raw secret, for every provider. +pub fn credential_header( + credentials: &ProviderCredentials, + built_in: &str, +) -> Option<(String, String)> { + // `resource_replaces_credential` also matches a resource `authorization` + // header, so this covers the bearer case whatever `built_in` is. + if resource_replaces_credential(credentials, built_in) { + return None; + } + let (name, secret) = match (&credentials.api_key, &credentials.access_token) { + (_, Some(access_token)) => ("authorization", access_token), + (Some(api_key), None) => (built_in, api_key), + (None, None) => return None, + }; + let value = if name.eq_ignore_ascii_case("authorization") { + format!("Bearer {}", secret) + } else { + secret.clone() + }; + Some((name.to_string(), value)) +} + +/// The headers every outbound AI request ends with: Windmill's own, then the +/// resource's, which come last so a resource can add to what the provider set. +pub fn common_outbound_headers( + credentials: &ProviderCredentials, +) -> impl Iterator + '_ { + AI_HTTP_HEADERS + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .chain( + credentials + .custom_headers + .iter() + .map(|(name, value)| (name.clone(), value.clone())), + ) +} + pub fn build_openai_compatible_proxy_request(args: &ProxyBuildArgs<'_>) -> Result { let credentials = args.credentials; let body = if let Some(user) = credentials.user.as_ref() { @@ -100,32 +166,16 @@ pub fn build_openai_compatible_proxy_request(args: &ProxyBuildArgs<'_>) -> Resul let mut headers = vec![("content-type".to_string(), "application/json".to_string())]; - if let Some(api_key) = credentials.api_key.as_ref() { - if is_azure { - headers.push(("api-key".to_string(), api_key.clone())); - } else { - headers.push(("authorization".to_string(), format!("Bearer {}", api_key))); - } - } - - if let Some(access_token) = credentials.access_token.as_ref() { - headers.push(( - "authorization".to_string(), - format!("Bearer {}", access_token), - )); - } + headers.extend(credential_header( + credentials, + if is_azure { "api-key" } else { "authorization" }, + )); if let Some(org_id) = credentials.organization_id.as_ref() { headers.push(("OpenAI-Organization".to_string(), org_id.clone())); } - for (header_name, header_value) in AI_HTTP_HEADERS.iter() { - headers.push((header_name.clone(), header_value.clone())); - } - - for (header_name, header_value) in &credentials.custom_headers { - headers.push((header_name.clone(), header_value.clone())); - } + headers.extend(common_outbound_headers(credentials)); Ok(ProxyRequest { method: args.method.clone(), url, headers, body }) } @@ -198,6 +248,100 @@ mod tests { .contains(&("OpenAI-Organization".to_string(), "org-id".to_string()))); } + /// A resource that carries its own credential owns authentication; outgoing + /// headers are appended, not replaced, so the built-in one must be dropped or + /// two credentials reach the endpoint. + #[test] + fn resource_header_replaces_the_built_in_credential() { + let mut credentials = credentials(AIProvider::CustomAI, "https://gateway.example/openai"); + credentials.custom_headers = HashMap::from([( + "Authorization".to_string(), + "Bearer gateway-token".to_string(), + )]); + let method = Method::POST; + + let request = build_openai_compatible_proxy_request(&ProxyBuildArgs { + method: &method, + path: "chat/completions", + headers: &HeaderMap::new(), + body: br#"{"model":"model","messages":[]}"#, + credentials: &credentials, + }) + .unwrap(); + + assert_eq!( + request + .headers + .iter() + .filter(|(header_name, _)| header_name.eq_ignore_ascii_case("authorization")) + .collect::>(), + vec![&( + "Authorization".to_string(), + "Bearer gateway-token".to_string() + )] + ); + } + + /// Only the header the provider authenticates keys with hands over. A + /// credential-shaped header a provider does not read is an ordinary header, + /// and suppressing the built-in credential on account of it strands the + /// request with no credential at all. + #[test] + fn unrelated_credential_header_keeps_the_built_in_one() { + let mut credentials = credentials(AIProvider::CustomAI, "https://gateway.example/openai"); + credentials.custom_headers = + HashMap::from([("x-api-key".to_string(), "routing-key".to_string())]); + let method = Method::POST; + + let request = build_openai_compatible_proxy_request(&ProxyBuildArgs { + method: &method, + path: "chat/completions", + headers: &HeaderMap::new(), + body: br#"{"model":"model","messages":[]}"#, + credentials: &credentials, + }) + .unwrap(); + + assert!(request + .headers + .contains(&("authorization".to_string(), "Bearer api-key".to_string()))); + assert!(request + .headers + .contains(&("x-api-key".to_string(), "routing-key".to_string()))); + } + + /// Azure reads api keys from `api-key` but Entra ID tokens only from + /// `authorization`, so an OAuth resource must stay on the bearer header + /// whatever the provider's key header is. + #[test] + fn oauth_token_is_sent_as_a_bearer_on_azure() { + let mut credentials = credentials( + AIProvider::AzureOpenAI, + "https://example.openai.azure.com/openai", + ); + credentials.api_key = None; + credentials.access_token = Some("oauth-token".to_string()); + let method = Method::POST; + + let request = build_openai_compatible_proxy_request(&ProxyBuildArgs { + method: &method, + path: "chat/completions", + headers: &HeaderMap::new(), + body: br#"{"model":"deployment","messages":[]}"#, + credentials: &credentials, + }) + .unwrap(); + + assert!(request.headers.contains(&( + "authorization".to_string(), + "Bearer oauth-token".to_string() + ))); + assert!(!request + .headers + .iter() + .any(|(header_name, _)| header_name.eq_ignore_ascii_case("api-key"))); + } + #[test] fn query_builder_proxy_support_includes_anthropic() { let cases = [ diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 8605bca374..507cb0420b 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -24,9 +24,10 @@ use windmill_ai::{ ai_providers::AIProvider, image_handler::upload_image_to_s3, providers::create_query_builder, + proxy::{common_outbound_headers, resource_replaces_credential, CREDENTIAL_HEADERS}, query_builder::{BuildRequestArgs, ParsedResponse}, types::*, - utils::{pinned_ai_client_for, should_use_structured_output_tool, AI_HTTP_HEADERS}, + utils::{pinned_ai_client_for, should_use_structured_output_tool}, }; use windmill_common::{ cache, @@ -977,12 +978,24 @@ pub async fn run_agent( let endpoint = query_builder.get_endpoint(base_url, args.provider.get_model(), output_type); let auth_headers = query_builder.get_auth_headers(api_key, base_url, output_type); + // A resource carrying its own credential owns authentication; the + // built-in one is dropped so the two do not both reach the endpoint. + // Non-credential headers (`anthropic-version`) stay either way. + let auth_headers: Vec<_> = auth_headers + .into_iter() + .filter(|(header_name, _)| { + let carries_credential = CREDENTIAL_HEADERS + .iter() + .any(|name| header_name.eq_ignore_ascii_case(name)); + !carries_credential || !resource_replaces_credential(&credentials, header_name) + }) + .collect(); let timeout = resolve_job_timeout(conn, &job.workspace_id, job.id, job.timeout) .await .0; - let resource_headers = &credentials.custom_headers; + let trailing_headers = common_outbound_headers(&credentials).collect::>(); // `endpoint` derives from the user-controlled provider base_url, so pin // DNS to the SSRF-validated address: the connect must not rebind to an @@ -1000,11 +1013,7 @@ pub async fn run_agent( req = req.header(*header_name, header_value.clone()); } - for (header_name, header_value) in AI_HTTP_HEADERS.iter() { - req = req.header(header_name.as_str(), header_value.as_str()); - } - - for (header_name, header_value) in resource_headers { + for (header_name, header_value) in &trailing_headers { req = req.header(header_name.as_str(), header_value.as_str()); }