use super::model::ApiProtocol;
use super::responses_request::{build_request_body, stream_response};
use super::traits::*;
use crate::types::*;
use tokio::sync::mpsc;
use tracing::{debug, info};
pub struct AzureOpenAiProvider;
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
impl StreamProvider for AzureOpenAiProvider {
fn protocol(&self) -> Option<ApiProtocol> {
Some(ApiProtocol::AzureOpenAiResponses)
}
async fn stream(
&self,
config: StreamConfig,
tx: mpsc::UnboundedSender<StreamEvent>,
cancel: tokio_util::sync::CancellationToken,
) -> Result<Message, ProviderError> {
if config.output_schema.is_some() {
tracing::warn!(
"structured outputs are not yet wired for the Azure OpenAI provider; output_schema will be ignored"
);
}
let model_config = config
.model_config
.as_ref()
.ok_or_else(|| ProviderError::Other("ModelConfig required".into()))?;
let endpoint = responses_endpoint(&model_config.base_url);
let mut body = build_request_body(&config, ApiProtocol::AzureOpenAiResponses);
if let Some(deployment) = &endpoint.deployment {
if *deployment != config.model {
info!(
"Azure OpenAI: legacy deployment base_url; sending model={deployment:?} \
in place of the configured model {:?}",
config.model
);
}
body["model"] = serde_json::json!(deployment);
}
debug!(
"Azure OpenAI request: model={} url={}",
body["model"], endpoint.url
);
let client = reqwest::Client::new();
let mut request = client
.post(&endpoint.url)
.header("content-type", "application/json");
if !config.api_key.is_empty() {
request = request.header("api-key", &config.api_key);
}
for (k, v) in &model_config.headers {
request = request.header(k, v);
}
stream_response(
request.json(&body),
"Azure OpenAI",
ApiProtocol::AzureOpenAiResponses,
&config,
&model_config.provider,
tx,
cancel,
)
.await
}
}
#[derive(Debug, PartialEq, Eq)]
struct Endpoint {
url: String,
deployment: Option<String>,
}
fn responses_endpoint(base_url: &str) -> Endpoint {
const DEPLOYMENTS: &str = "/openai/deployments";
let path_end = base_url.find(['?', '#']).unwrap_or(base_url.len());
if path_end < base_url.len() {
info!(
"Azure OpenAI: ignoring the query/fragment on base_url ({}); the v1 Responses \
endpoint takes no api-version",
&base_url[path_end..]
);
}
let base = base_url[..path_end].trim_end_matches('/');
let legacy = base
.find(DEPLOYMENTS)
.map(|i| (i, &base[i + DEPLOYMENTS.len()..]))
.filter(|(_, rest)| rest.is_empty() || rest.starts_with('/'));
if let Some((i, rest)) = legacy {
let deployment = rest
.trim_start_matches('/')
.split('/')
.next()
.filter(|d| !d.is_empty())
.map(str::to_string);
return Endpoint {
url: format!("{}/openai/v1/responses", &base[..i]),
deployment,
};
}
let url = if base.ends_with("/openai/v1") {
format!("{base}/responses")
} else if base.ends_with("/openai") {
format!("{base}/v1/responses")
} else {
format!("{base}/openai/v1/responses")
};
Endpoint {
url,
deployment: None,
}
}
#[cfg(test)]
fn build_azure_request_body(config: &StreamConfig) -> serde_json::Value {
build_request_body(config, ApiProtocol::AzureOpenAiResponses)
}
#[cfg(test)]
mod tests {
use super::*;
fn config(level: ThinkingLevel) -> StreamConfig {
StreamConfig {
model: "gpt-5.5".into(),
system_prompt: "".into(),
messages: vec![Message::user("hi")],
tools: vec![],
thinking_level: level,
api_key: "key".into(),
max_tokens: None,
temperature: None,
model_config: None,
cache_config: CacheConfig::default(),
output_schema: None,
}
}
#[test]
fn thinking_level_sets_reasoning_effort() {
let body = build_azure_request_body(&config(ThinkingLevel::Medium));
assert_eq!(body["reasoning"]["effort"], "medium");
}
#[test]
fn xhigh_and_max_clamp_to_high() {
for level in [
ThinkingLevel::XHigh,
ThinkingLevel::Max,
ThinkingLevel::High,
] {
let body = build_azure_request_body(&config(level));
assert_eq!(body["reasoning"]["effort"], "high");
}
let body = build_azure_request_body(&config(ThinkingLevel::Low));
assert_eq!(body["reasoning"]["effort"], "low");
}
#[test]
fn thinking_off_omits_reasoning() {
let body = build_azure_request_body(&config(ThinkingLevel::Off));
assert!(body["reasoning"].is_null());
}
fn deployment(level: ThinkingLevel, compat_from: ModelConfig) -> StreamConfig {
let mut mc = crate::provider::ModelConfig::custom(
crate::provider::ApiProtocol::AzureOpenAiResponses,
"azure",
"https://r.openai.azure.com/openai/deployments/d",
"gpt-6-sol",
"GPT-6 Sol",
);
mc.compat = compat_from.compat;
let mut c = config(level);
c.model_config = Some(mc);
c
}
use crate::provider::ModelConfig;
#[test]
fn every_base_url_shape_resolves_to_the_v1_responses_path() {
let v1 = "https://r.openai.azure.com/openai/v1/responses";
for base in [
"https://r.openai.azure.com",
"https://r.openai.azure.com/",
"https://r.openai.azure.com/openai",
"https://r.openai.azure.com/openai/",
"https://r.openai.azure.com/openai/v1",
"https://r.openai.azure.com/openai/v1/",
] {
assert_eq!(
responses_endpoint(base),
Endpoint {
url: v1.into(),
deployment: None
},
"{base}"
);
}
let foundry = responses_endpoint("https://r.services.ai.azure.com/openai/v1/");
assert_eq!(
foundry.url,
"https://r.services.ai.azure.com/openai/v1/responses"
);
}
#[test]
fn legacy_deployment_base_url_moves_the_deployment_into_model() {
for base in [
"https://r.openai.azure.com/openai/deployments/my-gpt",
"https://r.openai.azure.com/openai/deployments/my-gpt/",
] {
assert_eq!(
responses_endpoint(base),
Endpoint {
url: "https://r.openai.azure.com/openai/v1/responses".into(),
deployment: Some("my-gpt".into()),
},
"{base}"
);
}
for base in [
"https://r.openai.azure.com/openai/deployments/my-gpt?api-version=2024-10-21",
"https://r.openai.azure.com/openai/deployments/my-gpt/?api-version=2024-10-21",
"https://r.openai.azure.com/openai/deployments/my-gpt#frag",
"https://r.openai.azure.com/openai/deployments/my-gpt?a=b#frag",
] {
assert_eq!(
responses_endpoint(base),
Endpoint {
url: "https://r.openai.azure.com/openai/v1/responses".into(),
deployment: Some("my-gpt".into()),
},
"{base}"
);
}
assert_eq!(
responses_endpoint("https://r.openai.azure.com/openai/v1?api-version=preview"),
Endpoint {
url: "https://r.openai.azure.com/openai/v1/responses".into(),
deployment: None,
}
);
let e = responses_endpoint("https://r.openai.azure.com/openai/deployments/");
assert_eq!(e.url, "https://r.openai.azure.com/openai/v1/responses");
assert_eq!(e.deployment, None);
}
#[test]
fn compat_ceiling_is_honoured_and_off_omits_effort() {
let sol = || ModelConfig::gpt_6_sol();
let body = build_azure_request_body(&deployment(ThinkingLevel::Off, sol()));
assert!(body["reasoning"].is_null());
for (level, want) in [
(ThinkingLevel::XHigh, "xhigh"),
(ThinkingLevel::Max, "max"),
(ThinkingLevel::High, "high"),
] {
let body = build_azure_request_body(&deployment(level, sol()));
assert_eq!(body["reasoning"]["effort"], want, "{level:?}");
}
let body =
build_azure_request_body(&deployment(ThinkingLevel::Off, ModelConfig::gpt_6_astra()));
assert!(body["reasoning"].is_null());
}
#[test]
fn a_model_config_without_compat_keeps_the_clamp() {
for level in [
ThinkingLevel::Off,
ThinkingLevel::Low,
ThinkingLevel::High,
ThinkingLevel::XHigh,
ThinkingLevel::Max,
] {
let mut with = deployment(level, ModelConfig::mock());
assert!(with.model_config.as_ref().unwrap().compat.is_none());
with.temperature = Some(0.2);
let mut without = config(level);
without.temperature = Some(0.2);
assert_eq!(
serde_json::to_string(&build_azure_request_body(&with)).unwrap(),
serde_json::to_string(&build_azure_request_body(&without)).unwrap(),
"{level:?}"
);
}
}
#[test]
fn tool_result_sends_every_text_block() {
let mut c = config(ThinkingLevel::Off);
c.messages = vec![Message::ToolResult {
tool_call_id: "call-1".into(),
tool_name: "lint".into(),
content: vec![
Content::Text {
text: "Here are the results:".into(),
},
Content::Text {
text: "[{\"line\":3}]".into(),
},
],
is_error: false,
timestamp: 0,
}];
let body = build_azure_request_body(&c);
let output = &body["input"][0];
assert_eq!(output["type"], "function_call_output");
assert_eq!(output["output"], "Here are the results:\n[{\"line\":3}]");
}
}