Skip to main content

agent_framework_azure/
lib.rs

1//! # agent-framework-azure
2//!
3//! An Azure OpenAI [`ChatClient`] for `agent-framework-rs`, supporting both
4//! static API-key and Microsoft Entra ID (OAuth bearer token) authentication.
5//!
6//! Azure OpenAI's Chat Completions wire format is identical to OpenAI's, so
7//! request/response conversion is reused from
8//! [`agent_framework_openai::convert`] rather than duplicated — only the URL
9//! shape (`{endpoint}/openai/deployments/{deployment}/chat/completions`) and
10//! authentication differ.
11//!
12//! ```no_run
13//! use agent_framework_azure::AzureOpenAIClient;
14//! use agent_framework_core::prelude::*;
15//!
16//! # async fn demo() -> Result<()> {
17//! let client = AzureOpenAIClient::new(
18//!     "https://my-resource.openai.azure.com",
19//!     "my-gpt4o-deployment",
20//!     "my-api-key",
21//! );
22//! let agent = Agent::builder(client)
23//!     .instructions("You are concise.")
24//!     .build();
25//! let reply = agent.run_once("Say hi").await?;
26//! println!("{}", reply.text());
27//! # Ok(())
28//! # }
29//! ```
30//!
31//! Entra ID (bearer token) authentication instead of a static key:
32//!
33//! ```no_run
34//! use std::sync::Arc;
35//! use agent_framework_azure::{AzureOpenAIClient, StaticTokenCredential};
36//!
37//! let credential = Arc::new(StaticTokenCredential::new("eyJ0eXAi..."));
38//! let client = AzureOpenAIClient::with_token_credential(
39//!     "https://my-resource.openai.azure.com",
40//!     "my-gpt4o-deployment",
41//!     credential,
42//! );
43//! ```
44//!
45//! A real Microsoft Entra ID credential chain — try a managed identity, then a
46//! client secret, then the Azure CLI, whichever succeeds first (each caches and
47//! refreshes tokens for the configured scope):
48//!
49//! ```no_run
50//! use std::sync::Arc;
51//! use agent_framework_azure::{
52//!     AzureCliCredential, ChainedTokenCredential, ClientSecretCredential,
53//!     ManagedIdentityCredential, TokenCredential,
54//! };
55//!
56//! # async fn demo() -> agent_framework_core::error::Result<()> {
57//! let scope = "https://cognitiveservices.azure.com/.default";
58//! let chain = ChainedTokenCredential::new(vec![
59//!     Arc::new(ManagedIdentityCredential::new(scope)),
60//!     Arc::new(ClientSecretCredential::new("tenant", "client", "secret", scope)),
61//!     Arc::new(AzureCliCredential::new(scope)),
62//! ]);
63//! let token = chain.get_token().await?;
64//! # let _ = token;
65//! # Ok(())
66//! # }
67//! ```
68
69mod 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
93/// Parse a `Retry-After` header into a delay in seconds.
94///
95/// Mirrors the OpenAI/Anthropic clients: Azure OpenAI returns the
96/// integer/decimal-seconds form on `429`/`503`, which is what we honor so a
97/// [`RetryingChatClient`](agent_framework_core::client::RetryingChatClient) can
98/// wait exactly as long as the server asks. A date-form or unparseable value is
99/// treated as absent.
100pub(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/// How a request authenticates against Azure OpenAI.
109#[derive(Clone)]
110enum Auth {
111    /// `api-key: <key>` header.
112    ApiKey(String),
113    /// `Authorization: Bearer <token>`, fetched fresh per request from a
114    /// [`TokenCredential`].
115    Credential(Arc<dyn TokenCredential>),
116}
117
118/// An Azure OpenAI chat client
119/// (`POST {endpoint}/openai/deployments/{deployment}/chat/completions`).
120pub 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    /// Create a client authenticating with a static API key
160    /// (`api-key` header).
161    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    /// Create a client authenticating via a [`TokenCredential`]
178    /// (`Authorization: Bearer <token>`, e.g. Microsoft Entra ID).
179    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    /// Build an API-key-authenticated client from `AZURE_OPENAI_ENDPOINT`,
196    /// `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, and
197    /// optional `AZURE_OPENAI_API_VERSION`.
198    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    /// Override the API version (default `"2024-10-21"`).
214    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    /// The deployment name this client targets.
220    pub fn deployment(&self) -> &str {
221        &self.inner.deployment
222    }
223
224    /// The API version this client sends.
225    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    /// Build the Chat Completions request body, reusing conversion from
239    /// `agent-framework-openai` verbatim.
240    fn build_body(&self, messages: &[Message], options: &ChatOptions, stream: bool) -> Value {
241        let mut body = Map::new();
242        // The deployment in the URL already selects the model; only send
243        // `model` if the caller explicitly asked for a specific one.
244        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    /// The header name/value pair to authenticate a request, per the
269    /// client's configured [`Auth`] mode.
270    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            // Azure OpenAI is wire-compatible with OpenAI's Chat Completions,
296            // so status/body classification (401/403 -> auth, 400/404/422 ->
297            // invalid request or content filter) is shared verbatim with
298            // `agent-framework-openai` rather than duplicated.
299            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    // region: URL building
353
354    #[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    // endregion
383
384    // region: auth header selection
385
386    #[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    // endregion
408
409    // region: request body building (reuses agent-framework-openai::convert)
410
411    #[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    // endregion
462
463    // region: response parsing (reuses agent-framework-openai::convert)
464
465    #[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    // endregion
483
484    // Streaming: `get_streaming_response` calls
485    // `agent_framework_openai::parse_sse_stream(resp).boxed()` directly, with
486    // no azure-specific logic of its own — the wiring is verified at compile
487    // time (this crate wouldn't type-check against `ChatStream` otherwise),
488    // and SSE parsing itself (text-only and tool-call fixtures, `[DONE]`
489    // handling, error surfacing) is already covered by
490    // `agent-framework-openai`'s own test suite. `reqwest::Response` can't be
491    // constructed from raw bytes outside an actual HTTP exchange, so
492    // reproducing those fixtures here would require standing up a mock
493    // server rather than a plain unit test.
494
495    // region: env-var constructor
496
497    /// Guards Azure env var mutation across the tests below: `cargo test`
498    /// runs tests in the same process on multiple threads, and env vars are
499    /// process-global.
500    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        // SAFETY: serialized by ENV_MUTEX against the other env-var tests in
506        // this module; no other test in this crate touches these variables.
507        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        // SAFETY: serialized by ENV_MUTEX; see above.
530        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        // SAFETY: serialized by ENV_MUTEX; see above.
549        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    // endregion
564}