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;
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
97/// Parse a `Retry-After` header into a delay in seconds.
98///
99/// Mirrors the OpenAI/Anthropic clients: Azure OpenAI returns the
100/// integer/decimal-seconds form on `429`/`503`, which is what we honor so a
101/// [`RetryingChatClient`](agent_framework_core::client::RetryingChatClient) can
102/// wait exactly as long as the server asks. A date-form or unparseable value is
103/// treated as absent.
104pub(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/// How a request authenticates against Azure OpenAI.
113#[derive(Clone)]
114enum Auth {
115    /// `api-key: <key>` header.
116    ApiKey(String),
117    /// `Authorization: Bearer <token>`, fetched fresh per request from a
118    /// [`TokenCredential`].
119    Credential(Arc<dyn TokenCredential>),
120}
121
122/// An Azure OpenAI chat client
123/// (`POST {endpoint}/openai/deployments/{deployment}/chat/completions`).
124pub 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    /// Create a client authenticating with a static API key
164    /// (`api-key` header).
165    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    /// Create a client authenticating via a [`TokenCredential`]
182    /// (`Authorization: Bearer <token>`, e.g. Microsoft Entra ID).
183    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    /// Build an API-key-authenticated client from `AZURE_OPENAI_ENDPOINT`,
200    /// `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, and
201    /// optional `AZURE_OPENAI_API_VERSION`.
202    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    /// Override the API version (default `"2024-10-21"`).
218    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    /// The deployment name this client targets.
224    pub fn deployment(&self) -> &str {
225        &self.inner.deployment
226    }
227
228    /// The API version this client sends.
229    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    /// Build the Chat Completions request body, reusing conversion from
243    /// `agent-framework-openai` verbatim.
244    fn build_body(&self, messages: &[Message], options: &ChatOptions, stream: bool) -> Value {
245        let mut body = Map::new();
246        // The deployment in the URL already selects the model; only send
247        // `model` if the caller explicitly asked for a specific one.
248        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    /// The header name/value pair to authenticate a request, per the
273    /// client's configured [`Auth`] mode.
274    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            // Azure OpenAI is wire-compatible with OpenAI's Chat Completions,
300            // so status/body classification (401/403 -> auth, 400/404/422 ->
301            // invalid request or content filter) is shared verbatim with
302            // `agent-framework-openai` rather than duplicated.
303            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    // region: URL building
357
358    #[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    // endregion
387
388    // region: auth header selection
389
390    #[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    // endregion
412
413    // region: request body building (reuses agent-framework-openai::convert)
414
415    #[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    // endregion
466
467    // region: response parsing (reuses agent-framework-openai::convert)
468
469    #[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    // endregion
487
488    // Streaming: `get_streaming_response` calls
489    // `agent_framework_openai::parse_sse_stream(resp).boxed()` directly, with
490    // no azure-specific logic of its own — the wiring is verified at compile
491    // time (this crate wouldn't type-check against `ChatStream` otherwise),
492    // and SSE parsing itself (text-only and tool-call fixtures, `[DONE]`
493    // handling, error surfacing) is already covered by
494    // `agent-framework-openai`'s own test suite. `reqwest::Response` can't be
495    // constructed from raw bytes outside an actual HTTP exchange, so
496    // reproducing those fixtures here would require standing up a mock
497    // server rather than a plain unit test.
498
499    // region: env-var constructor
500
501    /// Guards Azure env var mutation across the tests below: `cargo test`
502    /// runs tests in the same process on multiple threads, and env vars are
503    /// process-global.
504    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        // SAFETY: serialized by ENV_MUTEX against the other env-var tests in
510        // this module; no other test in this crate touches these variables.
511        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        // SAFETY: serialized by ENV_MUTEX; see above.
534        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        // SAFETY: serialized by ENV_MUTEX; see above.
553        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    // endregion
568}