1mod credential;
70mod credentials;
71pub mod embeddings;
72#[cfg(feature = "entra-sdk")]
73pub mod entra_sdk;
74pub mod responses;
75
76pub use credential::{StaticTokenCredential, TokenCredential};
77pub use credentials::{
78 AzureCliCredential, ChainedTokenCredential, ClientSecretCredential, DefaultAzureCredential,
79 EnvironmentCredential, ManagedIdentityCredential, WorkloadIdentityCredential,
80 DEFAULT_AUTHORITY, DEFAULT_IMDS_ENDPOINT, REFRESH_SKEW,
81};
82pub use embeddings::AzureOpenAIEmbeddingClient;
83#[cfg(feature = "entra-sdk")]
84pub use entra_sdk::{SdkTokenCredential, AZURE_OPENAI_SCOPE, FOUNDRY_SCOPE};
85pub use responses::AzureOpenAIResponsesClient;
86
87use std::sync::Arc;
88
89use agent_framework_core::client::{ChatClient, ChatStream};
90use agent_framework_core::error::{Error, Result};
91use agent_framework_core::types::{ChatOptions, ChatResponse, Message};
92use futures::StreamExt;
93use serde_json::{json, Map, Value};
94
95pub(crate) const DEFAULT_API_VERSION: &str = "2024-10-21";
96
97pub(crate) fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<f64> {
105 headers
106 .get(reqwest::header::RETRY_AFTER)
107 .and_then(|v| v.to_str().ok())
108 .and_then(|s| s.trim().parse::<f64>().ok())
109 .filter(|s| s.is_finite() && *s >= 0.0)
110}
111
112#[derive(Clone)]
114enum Auth {
115 ApiKey(String),
117 Credential(Arc<dyn TokenCredential>),
120}
121
122pub struct AzureOpenAIClient {
125 inner: Arc<Inner>,
126}
127
128#[derive(Clone)]
129struct Inner {
130 http: reqwest::Client,
131 endpoint: String,
132 deployment: String,
133 api_version: String,
134 auth: Auth,
135}
136
137impl Clone for AzureOpenAIClient {
138 fn clone(&self) -> Self {
139 Self {
140 inner: self.inner.clone(),
141 }
142 }
143}
144
145impl std::fmt::Debug for AzureOpenAIClient {
146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 f.debug_struct("AzureOpenAIClient")
148 .field("endpoint", &self.inner.endpoint)
149 .field("deployment", &self.inner.deployment)
150 .field("api_version", &self.inner.api_version)
151 .field(
152 "auth",
153 &match &self.inner.auth {
154 Auth::ApiKey(_) => "api-key",
155 Auth::Credential(_) => "token-credential",
156 },
157 )
158 .finish_non_exhaustive()
159 }
160}
161
162impl AzureOpenAIClient {
163 pub fn new(
166 endpoint: impl Into<String>,
167 deployment: impl Into<String>,
168 api_key: impl Into<String>,
169 ) -> Self {
170 Self {
171 inner: Arc::new(Inner {
172 http: reqwest::Client::new(),
173 endpoint: endpoint.into(),
174 deployment: deployment.into(),
175 api_version: DEFAULT_API_VERSION.to_string(),
176 auth: Auth::ApiKey(api_key.into()),
177 }),
178 }
179 }
180
181 pub fn with_token_credential(
184 endpoint: impl Into<String>,
185 deployment: impl Into<String>,
186 credential: Arc<dyn TokenCredential>,
187 ) -> Self {
188 Self {
189 inner: Arc::new(Inner {
190 http: reqwest::Client::new(),
191 endpoint: endpoint.into(),
192 deployment: deployment.into(),
193 api_version: DEFAULT_API_VERSION.to_string(),
194 auth: Auth::Credential(credential),
195 }),
196 }
197 }
198
199 pub fn from_env() -> Result<Self> {
203 let endpoint = std::env::var("AZURE_OPENAI_ENDPOINT")
204 .map_err(|_| Error::Configuration("AZURE_OPENAI_ENDPOINT is not set".into()))?;
205 let api_key = std::env::var("AZURE_OPENAI_API_KEY")
206 .map_err(|_| Error::Configuration("AZURE_OPENAI_API_KEY is not set".into()))?;
207 let deployment = std::env::var("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME").map_err(|_| {
208 Error::Configuration("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME is not set".into())
209 })?;
210 let mut client = Self::new(endpoint, deployment, api_key);
211 if let Ok(v) = std::env::var("AZURE_OPENAI_API_VERSION") {
212 client = client.with_api_version(v);
213 }
214 Ok(client)
215 }
216
217 pub fn with_api_version(mut self, api_version: impl Into<String>) -> Self {
219 Arc::make_mut(&mut self.inner).api_version = api_version.into();
220 self
221 }
222
223 pub fn deployment(&self) -> &str {
225 &self.inner.deployment
226 }
227
228 pub fn api_version(&self) -> &str {
230 &self.inner.api_version
231 }
232
233 fn url(&self) -> String {
234 format!(
235 "{}/openai/deployments/{}/chat/completions?api-version={}",
236 self.inner.endpoint.trim_end_matches('/'),
237 self.inner.deployment,
238 self.inner.api_version,
239 )
240 }
241
242 fn build_body(&self, messages: &[Message], options: &ChatOptions, stream: bool) -> Value {
245 let mut body = Map::new();
246 if let Some(model) = &options.model {
249 body.insert("model".into(), json!(model));
250 }
251 body.insert(
252 "messages".into(),
253 json!(agent_framework_openai::convert::messages_to_openai(
254 messages
255 )),
256 );
257 agent_framework_openai::convert::apply_options(&mut body, options);
258 let (tools, tool_choice) = agent_framework_openai::convert::tools_to_openai(options);
259 if let Some(tools) = tools {
260 body.insert("tools".into(), tools);
261 }
262 if let Some(choice) = tool_choice {
263 body.insert("tool_choice".into(), choice);
264 }
265 if stream {
266 body.insert("stream".into(), json!(true));
267 body.insert("stream_options".into(), json!({ "include_usage": true }));
268 }
269 Value::Object(body)
270 }
271
272 async fn auth_header(&self) -> Result<(&'static str, String)> {
275 match &self.inner.auth {
276 Auth::ApiKey(key) => Ok(("api-key", key.clone())),
277 Auth::Credential(credential) => {
278 let token = credential.get_token().await?;
279 Ok(("Authorization", format!("Bearer {token}")))
280 }
281 }
282 }
283
284 async fn post(&self, body: &Value) -> Result<reqwest::Response> {
285 let (header_name, header_value) = self.auth_header().await?;
286 let resp = self
287 .inner
288 .http
289 .post(self.url())
290 .header(header_name, header_value)
291 .json(body)
292 .send()
293 .await
294 .map_err(|e| Error::service(format!("request failed: {e}")))?;
295 if !resp.status().is_success() {
296 let status = resp.status();
297 let retry_after = parse_retry_after(resp.headers());
298 let text = resp.text().await.unwrap_or_default();
299 return Err(agent_framework_openai::classify_service_error(
304 status.as_u16(),
305 &text,
306 format!("Azure OpenAI API error {status}: {text}"),
307 retry_after,
308 ));
309 }
310 Ok(resp)
311 }
312}
313
314#[async_trait::async_trait]
315impl ChatClient for AzureOpenAIClient {
316 async fn get_response(
317 &self,
318 messages: Vec<Message>,
319 options: ChatOptions,
320 ) -> Result<ChatResponse> {
321 let body = self.build_body(&messages, &options, false);
322 let resp = self.post(&body).await?;
323 let value: Value = resp
324 .json()
325 .await
326 .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
327 Ok(agent_framework_openai::convert::parse_response(&value))
328 }
329
330 async fn get_streaming_response(
331 &self,
332 messages: Vec<Message>,
333 options: ChatOptions,
334 ) -> Result<ChatStream> {
335 let body = self.build_body(&messages, &options, true);
336 let resp = self.post(&body).await?;
337 Ok(agent_framework_openai::parse_sse_stream(resp).boxed())
338 }
339
340 fn model(&self) -> Option<&str> {
341 Some(&self.inner.deployment)
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use agent_framework_core::types::{
349 Content, FinishReason, FunctionArguments, FunctionCallContent,
350 };
351
352 fn client() -> AzureOpenAIClient {
353 AzureOpenAIClient::new("https://my-resource.openai.azure.com", "gpt-4o", "test-key")
354 }
355
356 #[test]
359 fn url_includes_deployment_and_api_version() {
360 let c = client();
361 assert_eq!(
362 c.url(),
363 "https://my-resource.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-10-21"
364 );
365 }
366
367 #[test]
368 fn url_trims_trailing_slash_on_endpoint() {
369 let c = AzureOpenAIClient::new(
370 "https://my-resource.openai.azure.com/",
371 "gpt-4o",
372 "test-key",
373 );
374 assert!(c
375 .url()
376 .starts_with("https://my-resource.openai.azure.com/openai/"));
377 assert!(!c.url().contains("azure.com//openai"));
378 }
379
380 #[test]
381 fn with_api_version_overrides_default() {
382 let c = client().with_api_version("2025-01-01-preview");
383 assert!(c.url().ends_with("api-version=2025-01-01-preview"));
384 }
385
386 #[tokio::test]
391 async fn api_key_auth_uses_api_key_header() {
392 let c = client();
393 let (name, value) = c.auth_header().await.unwrap();
394 assert_eq!(name, "api-key");
395 assert_eq!(value, "test-key");
396 }
397
398 #[tokio::test]
399 async fn token_credential_auth_uses_bearer_header() {
400 let credential = Arc::new(credential::StaticTokenCredential::new("my-jwt-token"));
401 let c = AzureOpenAIClient::with_token_credential(
402 "https://my-resource.openai.azure.com",
403 "gpt-4o",
404 credential,
405 );
406 let (name, value) = c.auth_header().await.unwrap();
407 assert_eq!(name, "Authorization");
408 assert_eq!(value, "Bearer my-jwt-token");
409 }
410
411 #[test]
416 fn build_body_omits_model_by_default() {
417 let c = client();
418 let body = c.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
419 assert!(body.get("model").is_none());
420 assert_eq!(
421 body["messages"],
422 json!([{ "role": "user", "content": "hi" }])
423 );
424 }
425
426 #[test]
427 fn build_body_includes_model_when_explicitly_set() {
428 let c = client();
429 let options = ChatOptions::new().with_model("gpt-4o-override");
430 let body = c.build_body(&[Message::user("hi")], &options, false);
431 assert_eq!(body["model"], json!("gpt-4o-override"));
432 }
433
434 #[test]
435 fn build_body_stream_includes_usage_option() {
436 let c = client();
437 let body = c.build_body(&[Message::user("hi")], &ChatOptions::new(), true);
438 assert_eq!(body["stream"], json!(true));
439 assert_eq!(body["stream_options"], json!({ "include_usage": true }));
440 }
441
442 #[test]
443 fn build_body_function_call_round_trip() {
444 let c = client();
445 let call = FunctionCallContent::new(
446 "call_1",
447 "get_weather",
448 Some(FunctionArguments::Raw("{}".to_string())),
449 );
450 let assistant_msg = Message::with_contents(
451 agent_framework_core::types::Role::assistant(),
452 vec![Content::FunctionCall(call)],
453 );
454 let body = c.build_body(
455 &[Message::user("weather?"), assistant_msg],
456 &ChatOptions::new(),
457 false,
458 );
459 assert_eq!(
460 body["messages"][1]["tool_calls"][0]["function"]["name"],
461 json!("get_weather")
462 );
463 }
464
465 #[test]
470 fn parse_response_reuses_openai_convert() {
471 let value = json!({
472 "id": "chatcmpl-123",
473 "model": "gpt-4o",
474 "choices": [{
475 "message": { "role": "assistant", "content": "Hello!" },
476 "finish_reason": "stop",
477 }],
478 "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 },
479 });
480 let resp = agent_framework_openai::convert::parse_response(&value);
481 assert_eq!(resp.text(), "Hello!");
482 assert_eq!(resp.finish_reason, Some(FinishReason::stop()));
483 assert_eq!(resp.usage_details.unwrap().total_token_count, Some(15));
484 }
485
486 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
505
506 #[test]
507 fn from_env_reads_all_four_vars() {
508 let _guard = ENV_MUTEX.lock().unwrap();
509 unsafe {
512 std::env::set_var("AZURE_OPENAI_ENDPOINT", "https://res.openai.azure.com");
513 std::env::set_var("AZURE_OPENAI_API_KEY", "test-key-123");
514 std::env::set_var("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", "gpt-4o-deployment");
515 std::env::set_var("AZURE_OPENAI_API_VERSION", "2025-02-01");
516 }
517 let client = AzureOpenAIClient::from_env().unwrap();
518 assert_eq!(client.inner.endpoint, "https://res.openai.azure.com");
519 assert_eq!(client.inner.deployment, "gpt-4o-deployment");
520 assert_eq!(client.inner.api_version, "2025-02-01");
521 assert!(matches!(client.inner.auth, Auth::ApiKey(ref k) if k == "test-key-123"));
522 unsafe {
523 std::env::remove_var("AZURE_OPENAI_ENDPOINT");
524 std::env::remove_var("AZURE_OPENAI_API_KEY");
525 std::env::remove_var("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME");
526 std::env::remove_var("AZURE_OPENAI_API_VERSION");
527 }
528 }
529
530 #[test]
531 fn from_env_defaults_api_version_when_unset() {
532 let _guard = ENV_MUTEX.lock().unwrap();
533 unsafe {
535 std::env::set_var("AZURE_OPENAI_ENDPOINT", "https://res.openai.azure.com");
536 std::env::set_var("AZURE_OPENAI_API_KEY", "test-key-123");
537 std::env::set_var("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", "gpt-4o-deployment");
538 std::env::remove_var("AZURE_OPENAI_API_VERSION");
539 }
540 let client = AzureOpenAIClient::from_env().unwrap();
541 assert_eq!(client.inner.api_version, DEFAULT_API_VERSION);
542 unsafe {
543 std::env::remove_var("AZURE_OPENAI_ENDPOINT");
544 std::env::remove_var("AZURE_OPENAI_API_KEY");
545 std::env::remove_var("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME");
546 }
547 }
548
549 #[test]
550 fn from_env_errors_when_deployment_missing() {
551 let _guard = ENV_MUTEX.lock().unwrap();
552 unsafe {
554 std::env::set_var("AZURE_OPENAI_ENDPOINT", "https://res.openai.azure.com");
555 std::env::set_var("AZURE_OPENAI_API_KEY", "test-key-123");
556 std::env::remove_var("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME");
557 std::env::remove_var("AZURE_OPENAI_API_VERSION");
558 }
559 let result = AzureOpenAIClient::from_env();
560 assert!(result.is_err());
561 unsafe {
562 std::env::remove_var("AZURE_OPENAI_ENDPOINT");
563 std::env::remove_var("AZURE_OPENAI_API_KEY");
564 }
565 }
566
567 }