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