mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 08:02:40 +00:00
The two paths derive the endpoint and the credential header independently, so a resource could authenticate in one and 401 in the other. A parity test pins them together across the provider/platform matrix and fails on each divergence below. - Anthropic base URLs were read differently: the proxy trimmed and re-appended `/v1` while the agent step appended `/messages` to the stored value, so a `.../anthropic` base worked in workspace settings and 404'd in an agent step. `build_anthropic_api_url` accepts both forms for both paths, and the URL no longer depends on the client-supplied `X-Anthropic-SDK` header, which is gone. - A base URL stored with a trailing slash doubled it in an agent step. - An OpenAI resource pointed at Azure got Azure's URL layout and `api-key` header from the proxy but bearer auth and the plain path from the agent step, where `OpenAIQueryBuilder` ignored `is_azure`. - The agent step sent an empty credential when the resource had no api key, where the proxy sends none at all. `retain_effective_credentials` gives both the same rule, so an endpoint that authenticates another way still works. - An OAuth resource cannot resolve to a token in a worker: there is no client credentials exchange there, so it now fails with that reason unless it carries the credential header its provider reads. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
568 lines
21 KiB
Rust
568 lines
21 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use http::{HeaderMap, Method};
|
|
use serde_json::value::RawValue;
|
|
use windmill_common::error::{Error, Result};
|
|
|
|
use crate::ai_providers::AIProvider;
|
|
use crate::credentials::ProviderCredentials;
|
|
use crate::utils::AI_HTTP_HEADERS;
|
|
|
|
pub mod fim;
|
|
|
|
/// Inputs needed to transform an OpenAI-compatible proxy request for a provider.
|
|
pub struct ProxyBuildArgs<'a> {
|
|
pub method: &'a Method,
|
|
pub path: &'a str,
|
|
pub headers: &'a HeaderMap,
|
|
pub body: &'a [u8],
|
|
pub credentials: &'a ProviderCredentials,
|
|
}
|
|
|
|
/// Provider-specific request produced by proxy request builders.
|
|
#[derive(Clone, Debug)]
|
|
pub struct ProxyRequest {
|
|
pub method: Method,
|
|
pub url: String,
|
|
pub headers: Vec<(String, String)>,
|
|
pub body: Vec<u8>,
|
|
}
|
|
|
|
/// How the API proxy should execute a request for a provider.
|
|
///
|
|
/// Most providers can be represented as a transformed HTTP request. Google AI
|
|
/// and Bedrock need native execution because their proxy paths also transform
|
|
/// responses or call an SDK rather than forwarding an HTTP request directly.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum ProxyExecutionMode {
|
|
HttpForward,
|
|
NativeGoogleAi,
|
|
NativeAwsBedrock,
|
|
}
|
|
|
|
impl ProxyExecutionMode {
|
|
pub fn uses_query_builder_proxy(self) -> bool {
|
|
matches!(self, Self::HttpForward)
|
|
}
|
|
}
|
|
|
|
pub fn supports_openai_compatible_proxy(provider: &AIProvider) -> bool {
|
|
matches!(
|
|
provider,
|
|
AIProvider::OpenAI
|
|
| AIProvider::AzureOpenAI
|
|
| AIProvider::AzureFoundry
|
|
| AIProvider::Mistral
|
|
| AIProvider::DeepSeek
|
|
| AIProvider::Groq
|
|
| AIProvider::OpenRouter
|
|
| AIProvider::TogetherAI
|
|
| AIProvider::CustomAI
|
|
)
|
|
}
|
|
|
|
pub fn proxy_execution_mode(provider: &AIProvider) -> ProxyExecutionMode {
|
|
match provider {
|
|
AIProvider::OpenAI
|
|
| AIProvider::AzureOpenAI
|
|
| AIProvider::AzureFoundry
|
|
| AIProvider::Anthropic
|
|
| AIProvider::Mistral
|
|
| AIProvider::DeepSeek
|
|
| AIProvider::Groq
|
|
| AIProvider::OpenRouter
|
|
| AIProvider::TogetherAI
|
|
| AIProvider::CustomAI => ProxyExecutionMode::HttpForward,
|
|
AIProvider::GoogleAI => ProxyExecutionMode::NativeGoogleAi,
|
|
AIProvider::AWSBedrock => ProxyExecutionMode::NativeAwsBedrock,
|
|
}
|
|
}
|
|
|
|
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 <secret>`
|
|
/// 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))
|
|
}
|
|
|
|
/// Whether a resource authenticates by an OAuth exchange that only the API runs, so
|
|
/// a worker has no way to obtain its token.
|
|
///
|
|
/// `auth_headers` is what the provider's query builder produced: a resource can still
|
|
/// authenticate the request by supplying the credential header that provider actually
|
|
/// reads. A credential-shaped header it does not read (an `x-api-key` used for routing
|
|
/// by an OpenAI-compatible gateway, say) leaves the request unauthenticated, so it does
|
|
/// not count.
|
|
pub fn needs_unavailable_oauth_exchange(
|
|
credentials: &ProviderCredentials,
|
|
token_url: Option<&str>,
|
|
auth_headers: &[(&'static str, String)],
|
|
) -> bool {
|
|
token_url.is_some()
|
|
&& credentials.api_key.is_none()
|
|
&& !auth_headers.iter().any(|(header_name, _)| {
|
|
CREDENTIAL_HEADERS
|
|
.iter()
|
|
.any(|name| header_name.eq_ignore_ascii_case(name))
|
|
&& resource_replaces_credential(credentials, header_name)
|
|
})
|
|
}
|
|
|
|
/// Drop the query builder's built-in credential header when the resource carries
|
|
/// its own, or when there is no key at all — an endpoint may authenticate the
|
|
/// request another way (its own header, mTLS, or no auth), and an empty credential
|
|
/// must not go out in its place. This applies `credential_header`'s rule to headers a
|
|
/// query builder already built from `credentials.api_key`, so the agent step and the
|
|
/// proxy send the same credential. (An OAuth `access_token` never reaches a query
|
|
/// builder: only the API resolves one.) Non-credential headers always stay.
|
|
pub fn retain_effective_credentials(
|
|
credentials: &ProviderCredentials,
|
|
auth_headers: Vec<(&'static str, String)>,
|
|
) -> Vec<(&'static str, String)> {
|
|
auth_headers
|
|
.into_iter()
|
|
.filter(|(header_name, _)| {
|
|
let carries_credential = CREDENTIAL_HEADERS
|
|
.iter()
|
|
.any(|name| header_name.eq_ignore_ascii_case(name));
|
|
!carries_credential
|
|
|| (credentials.api_key.is_some()
|
|
&& !resource_replaces_credential(credentials, header_name))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// 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<Item = (String, String)> + '_ {
|
|
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<ProxyRequest> {
|
|
let credentials = args.credentials;
|
|
let body = if let Some(user) = credentials.user.as_ref() {
|
|
add_user_to_body(args.body, user)?
|
|
} else {
|
|
args.body.to_vec()
|
|
};
|
|
|
|
let base_url = credentials.base_url.trim_end_matches('/');
|
|
let is_azure = credentials.provider.is_azure(base_url);
|
|
let url = if is_azure {
|
|
AIProvider::build_azure_openai_url(base_url, args.path)
|
|
} else {
|
|
format!("{}/{}", base_url, args.path)
|
|
};
|
|
|
|
let mut headers = vec![("content-type".to_string(), "application/json".to_string())];
|
|
|
|
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()));
|
|
}
|
|
|
|
headers.extend(common_outbound_headers(credentials));
|
|
|
|
Ok(ProxyRequest { method: args.method.clone(), url, headers, body })
|
|
}
|
|
|
|
pub(crate) fn add_user_to_body(body: &[u8], user: &str) -> Result<Vec<u8>> {
|
|
tracing::debug!("Adding user to request body");
|
|
let mut json_body: HashMap<String, Box<RawValue>> = serde_json::from_slice(body)
|
|
.map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?;
|
|
|
|
let user_json_string = serde_json::Value::String(user.to_string()).to_string();
|
|
|
|
json_body.insert(
|
|
"user".to_string(),
|
|
RawValue::from_string(user_json_string)
|
|
.map_err(|e| Error::internal_err(format!("Failed to parse user: {}", e)))?,
|
|
);
|
|
|
|
serde_json::to_vec(&json_body)
|
|
.map_err(|e| Error::internal_err(format!("Failed to reserialize request body: {}", e)))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::collections::HashMap;
|
|
|
|
use crate::ai_providers::AIPlatform;
|
|
|
|
fn credentials(provider: AIProvider, base_url: &str) -> ProviderCredentials {
|
|
ProviderCredentials {
|
|
provider,
|
|
base_url: base_url.to_string(),
|
|
api_key: Some("api-key".to_string()),
|
|
access_token: None,
|
|
organization_id: Some("org-id".to_string()),
|
|
user: None,
|
|
region: None,
|
|
aws_access_key_id: None,
|
|
aws_secret_access_key: None,
|
|
aws_session_token: None,
|
|
platform: AIPlatform::Standard,
|
|
custom_headers: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn builds_openai_compatible_proxy_request() {
|
|
let credentials = credentials(AIProvider::OpenRouter, "https://openrouter.ai/api/v1/");
|
|
let method = Method::POST;
|
|
let headers = HeaderMap::new();
|
|
let body = br#"{"model":"openrouter/model","messages":[]}"#;
|
|
|
|
let request = build_openai_compatible_proxy_request(&ProxyBuildArgs {
|
|
method: &method,
|
|
path: "chat/completions",
|
|
headers: &headers,
|
|
body,
|
|
credentials: &credentials,
|
|
})
|
|
.unwrap();
|
|
|
|
assert_eq!(request.method, Method::POST);
|
|
assert_eq!(request.url, "https://openrouter.ai/api/v1/chat/completions");
|
|
assert_eq!(request.body, body.to_vec());
|
|
assert!(request
|
|
.headers
|
|
.contains(&("authorization".to_string(), "Bearer api-key".to_string())));
|
|
assert!(request
|
|
.headers
|
|
.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<_>>(),
|
|
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())));
|
|
}
|
|
|
|
/// The OAuth guard must not reject the workaround its error names, and must not be
|
|
/// satisfied by a credential header the provider does not read.
|
|
#[test]
|
|
fn oauth_guard_follows_the_provider_credential_header() {
|
|
let token_url = Some("https://login.example/token");
|
|
let mut credentials = credentials(AIProvider::OpenAI, "https://api.openai.com/v1");
|
|
credentials.api_key = None;
|
|
let bearer = [("Authorization", String::new())];
|
|
|
|
assert!(needs_unavailable_oauth_exchange(
|
|
&credentials,
|
|
token_url,
|
|
&bearer
|
|
));
|
|
|
|
// A routing header this provider never authenticates with leaves the request
|
|
// unauthenticated, so it must not satisfy the guard.
|
|
credentials.custom_headers =
|
|
HashMap::from([("x-api-key".to_string(), "routing".to_string())]);
|
|
assert!(needs_unavailable_oauth_exchange(
|
|
&credentials,
|
|
token_url,
|
|
&bearer
|
|
));
|
|
|
|
// The header the provider does read stands in for the token.
|
|
credentials.custom_headers =
|
|
HashMap::from([("Authorization".to_string(), "Bearer static".to_string())]);
|
|
assert!(!needs_unavailable_oauth_exchange(
|
|
&credentials,
|
|
token_url,
|
|
&bearer
|
|
));
|
|
// An api-key resource never needed the exchange in the first place.
|
|
assert!(!needs_unavailable_oauth_exchange(
|
|
&credentials,
|
|
None,
|
|
&bearer
|
|
));
|
|
}
|
|
|
|
/// 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 = [
|
|
(AIProvider::OpenAI, ProxyExecutionMode::HttpForward),
|
|
(AIProvider::AzureOpenAI, ProxyExecutionMode::HttpForward),
|
|
(AIProvider::AzureFoundry, ProxyExecutionMode::HttpForward),
|
|
(AIProvider::Anthropic, ProxyExecutionMode::HttpForward),
|
|
(AIProvider::Mistral, ProxyExecutionMode::HttpForward),
|
|
(AIProvider::DeepSeek, ProxyExecutionMode::HttpForward),
|
|
(AIProvider::Groq, ProxyExecutionMode::HttpForward),
|
|
(AIProvider::OpenRouter, ProxyExecutionMode::HttpForward),
|
|
(AIProvider::TogetherAI, ProxyExecutionMode::HttpForward),
|
|
(AIProvider::CustomAI, ProxyExecutionMode::HttpForward),
|
|
(AIProvider::GoogleAI, ProxyExecutionMode::NativeGoogleAi),
|
|
(AIProvider::AWSBedrock, ProxyExecutionMode::NativeAwsBedrock),
|
|
];
|
|
|
|
for (provider, expected_mode) in cases {
|
|
let mode = proxy_execution_mode(&provider);
|
|
assert_eq!(
|
|
mode, expected_mode,
|
|
"unexpected proxy mode for {provider:?}"
|
|
);
|
|
assert_eq!(
|
|
supports_query_builder_proxy(&provider),
|
|
mode.uses_query_builder_proxy(),
|
|
"query-builder support drifted for {provider:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn builds_azure_openai_proxy_request() {
|
|
let credentials = credentials(
|
|
AIProvider::AzureOpenAI,
|
|
"https://example.openai.azure.com/openai",
|
|
);
|
|
let method = Method::POST;
|
|
let headers = HeaderMap::new();
|
|
|
|
let request = build_openai_compatible_proxy_request(&ProxyBuildArgs {
|
|
method: &method,
|
|
path: "chat/completions",
|
|
headers: &headers,
|
|
body: br#"{"model":"deployment","messages":[]}"#,
|
|
credentials: &credentials,
|
|
})
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
request.url,
|
|
"https://example.openai.azure.com/openai/v1/chat/completions"
|
|
);
|
|
assert!(request
|
|
.headers
|
|
.contains(&("api-key".to_string(), "api-key".to_string())));
|
|
}
|
|
|
|
#[test]
|
|
fn builds_azure_foundry_proxy_request() {
|
|
// Foundry's OpenAI-compatible endpoint uses the same Azure conventions
|
|
// (api-key header, /openai -> /openai/v1 path) as Azure OpenAI.
|
|
let credentials = credentials(
|
|
AIProvider::AzureFoundry,
|
|
"https://example.services.ai.azure.com/openai",
|
|
);
|
|
let method = Method::POST;
|
|
let headers = HeaderMap::new();
|
|
|
|
let request = build_openai_compatible_proxy_request(&ProxyBuildArgs {
|
|
method: &method,
|
|
path: "chat/completions",
|
|
headers: &headers,
|
|
body: br#"{"model":"gpt-4o","messages":[]}"#,
|
|
credentials: &credentials,
|
|
})
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
request.url,
|
|
"https://example.services.ai.azure.com/openai/v1/chat/completions"
|
|
);
|
|
assert!(request
|
|
.headers
|
|
.contains(&("api-key".to_string(), "api-key".to_string())));
|
|
}
|
|
|
|
#[test]
|
|
fn injects_user_into_proxy_body() {
|
|
let mut credentials = credentials(AIProvider::OpenAI, "https://api.openai.com/v1");
|
|
credentials.user = Some("user-1".to_string());
|
|
let method = Method::POST;
|
|
let headers = HeaderMap::new();
|
|
|
|
let request = build_openai_compatible_proxy_request(&ProxyBuildArgs {
|
|
method: &method,
|
|
path: "chat/completions",
|
|
headers: &headers,
|
|
body: br#"{"model":"gpt-4o","messages":[]}"#,
|
|
credentials: &credentials,
|
|
})
|
|
.unwrap();
|
|
let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
|
|
|
|
assert_eq!(body["user"], "user-1");
|
|
assert_eq!(body["model"], "gpt-4o");
|
|
}
|
|
|
|
#[test]
|
|
fn foundry_routes_claude_to_anthropic_messages_api() {
|
|
use crate::providers::create_query_builder;
|
|
use crate::types::OutputType;
|
|
|
|
let creds = credentials(
|
|
AIProvider::AzureFoundry,
|
|
"https://wm-test-ai.services.ai.azure.com/openai/v1",
|
|
);
|
|
|
|
// Claude deployment -> Anthropic Messages API surface + x-api-key auth.
|
|
let claude = create_query_builder(&creds, "claude-sonnet-5");
|
|
assert_eq!(
|
|
claude.get_endpoint(&creds.base_url, "claude-sonnet-5", &OutputType::Text),
|
|
"https://wm-test-ai.services.ai.azure.com/anthropic/v1/messages"
|
|
);
|
|
let auth = claude.get_auth_headers("api-key", &creds.base_url, &OutputType::Text);
|
|
assert!(auth.contains(&("x-api-key", "api-key".to_string())));
|
|
|
|
// OpenAI-compatible deployment -> chat completions surface.
|
|
let gpt = create_query_builder(&creds, "gpt-4o");
|
|
assert_eq!(
|
|
gpt.get_endpoint(&creds.base_url, "gpt-4o", &OutputType::Text),
|
|
"https://wm-test-ai.services.ai.azure.com/openai/v1/chat/completions"
|
|
);
|
|
}
|
|
}
|