Skip to main content

agent_framework_azure/
responses.rs

1//! [`AzureOpenAIResponsesClient`]: a [`ChatClient`] for the Responses API on
2//! Azure OpenAI (`POST {endpoint}/openai/v1/responses`).
3//!
4//! ## URL shape and api-version
5//!
6//! Unlike [`AzureOpenAIClient`](crate::AzureOpenAIClient) (Chat Completions,
7//! which selects the model via a deployment-scoped URL —
8//! `.../openai/deployments/{deployment}/chat/completions`), the Responses API
9//! on Azure OpenAI is documented upstream as supported only through the
10//! newer, OpenAI-compatible "v1 preview" surface: there is no deployment
11//! segment in the URL at all, and the deployment instead flows into the
12//! request body's `model` field, exactly like the plain
13//! [`OpenAIChatClient`](agent_framework_openai::responses::OpenAIChatClient).
14//!
15//! This mirrors upstream `AzureOpenAIResponsesClient.__init__`
16//! (`azure/_responses_client.py:99-146`), which:
17//! * forces `default_api_version="preview"` when building its settings
18//!   (`_responses_client.py:112`) — distinct from every other Azure OpenAI
19//!   client's `"2024-10-21"` default (`azure/_shared.py:28`,
20//!   [`crate::AzureOpenAIClient`]'s own default);
21//! * auto-derives `base_url = urljoin(endpoint, "/openai/v1/")` for standard
22//!   `*.openai.azure.com` endpoints when no explicit `base_url` is given
23//!   (`_responses_client.py:117-123`), and documents that "currently, the
24//!   base_url must end with `/openai/v1/`" and "the api_version must be
25//!   `preview`" (`_responses_client.py:60-65`);
26//! * requires a deployment name, raising if one isn't configured
27//!   (`_responses_client.py:127-131`).
28//!
29//! This client always derives the `/openai/v1/` route from `endpoint`
30//! (skipping upstream's `.openai.azure.com`-hostname sniff, which its own
31//! comment flags as "a temporary hack" for a case the Rust port doesn't need
32//! to special-case); [`with_base_url`](AzureOpenAIResponsesClient::with_base_url)
33//! is the escape hatch upstream's `base_url` parameter provides for full
34//! control.
35//!
36//! ## Conversion and streaming
37//!
38//! Request/response conversion (messages → `input` items, tool specs, output
39//! parsing, SSE event parsing) is reused verbatim from
40//! [`agent_framework_openai::responses`] rather than duplicated — only the
41//! URL shape, api-version default, and authentication differ, exactly as
42//! [`AzureOpenAIClient`](crate::AzureOpenAIClient) reuses
43//! [`agent_framework_openai::convert`] for Chat Completions. `conversation_id`
44//! ↔ `previous_response_id` and `store` ↔ auto-populated `conversation_id`
45//! behave identically to [`OpenAIChatClient`](agent_framework_openai::responses::OpenAIChatClient)
46//! because the same conversion functions are called.
47//!
48//! ```no_run
49//! use agent_framework_azure::responses::AzureOpenAIResponsesClient;
50//! use agent_framework_core::prelude::*;
51//!
52//! # async fn demo() -> Result<()> {
53//! let client = AzureOpenAIResponsesClient::new(
54//!     "https://my-resource.openai.azure.com",
55//!     "my-gpt4o-deployment",
56//!     "my-api-key",
57//! );
58//! let agent = Agent::builder(client)
59//!     .instructions("You are concise.")
60//!     .build();
61//! let reply = agent.run_once("Say hi").await?;
62//! println!("{}", reply.text());
63//! # Ok(())
64//! # }
65//! ```
66//!
67//! Entra ID (bearer token) authentication instead of a static key — the same
68//! [`TokenCredential`] plumbing
69//! [`AzureOpenAIClient`](crate::AzureOpenAIClient) uses (e.g. the
70//! `"https://cognitiveservices.azure.com/.default"` scope):
71//!
72//! ```no_run
73//! use std::sync::Arc;
74//! use agent_framework_azure::StaticTokenCredential;
75//! use agent_framework_azure::responses::AzureOpenAIResponsesClient;
76//!
77//! let credential = Arc::new(StaticTokenCredential::new("eyJ0eXAi..."));
78//! let client = AzureOpenAIResponsesClient::with_token_credential(
79//!     "https://my-resource.openai.azure.com",
80//!     "my-gpt4o-deployment",
81//!     credential,
82//! );
83//! ```
84
85use std::sync::Arc;
86
87use agent_framework_core::client::{ChatClient, ChatStream};
88use agent_framework_core::error::{Error, Result};
89use agent_framework_core::types::{ChatOptions, ChatResponse, Message};
90use futures::StreamExt;
91use serde_json::{json, Map, Value};
92
93use crate::{Auth, TokenCredential};
94
95/// Default api-version for the Responses API on Azure OpenAI: the "preview"
96/// v1-surface identifier upstream forces today
97/// (`azure/_responses_client.py:112`, `default_api_version="preview"`),
98/// distinct from [`AzureOpenAIClient`](crate::AzureOpenAIClient)'s Chat
99/// Completions default (`"2024-10-21"`, `azure/_shared.py:28`). Overridable
100/// via [`with_api_version`](AzureOpenAIResponsesClient::with_api_version) or
101/// `AZURE_OPENAI_API_VERSION`.
102const DEFAULT_API_VERSION: &str = "preview";
103
104/// An Azure OpenAI Responses API chat client
105/// (`POST {endpoint}/openai/v1/responses`).
106///
107/// See the [module docs](self) for the URL/api-version rationale.
108pub struct AzureOpenAIResponsesClient {
109    inner: Arc<Inner>,
110}
111
112#[derive(Clone)]
113struct Inner {
114    http: reqwest::Client,
115    /// The resource endpoint, e.g. `https://my-resource.openai.azure.com`.
116    /// Used to derive the `/openai/v1/` base URL when `base_url` is `None`.
117    endpoint: String,
118    /// An explicit override of the full base URL (mirrors upstream's
119    /// `base_url` parameter), taking precedence over `endpoint` when set.
120    base_url: Option<String>,
121    deployment: String,
122    api_version: Option<String>,
123    auth: Auth,
124    /// Whether to add `reasoning.encrypted_content` to a stateless request's
125    /// `include` on the caller's behalf. True here, as on OpenAI; Foundry
126    /// turns it off (see [`Self::without_implicit_encrypted_reasoning`]).
127    implicit_encrypted_reasoning: bool,
128}
129
130impl Clone for AzureOpenAIResponsesClient {
131    fn clone(&self) -> Self {
132        Self {
133            inner: self.inner.clone(),
134        }
135    }
136}
137
138impl std::fmt::Debug for AzureOpenAIResponsesClient {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.debug_struct("AzureOpenAIResponsesClient")
141            .field("endpoint", &self.inner.endpoint)
142            .field("base_url", &self.inner.base_url)
143            .field("deployment", &self.inner.deployment)
144            .field("api_version", &self.inner.api_version)
145            .field(
146                "auth",
147                &match &self.inner.auth {
148                    Auth::ApiKey(_) => "api-key",
149                    Auth::Credential(_) => "token-credential",
150                },
151            )
152            .finish_non_exhaustive()
153    }
154}
155
156impl AzureOpenAIResponsesClient {
157    /// Create a client authenticating with a static API key
158    /// (`api-key` header).
159    pub fn new(
160        endpoint: impl Into<String>,
161        deployment: impl Into<String>,
162        api_key: impl Into<String>,
163    ) -> Self {
164        Self {
165            inner: Arc::new(Inner {
166                http: reqwest::Client::new(),
167                endpoint: endpoint.into(),
168                base_url: None,
169                deployment: deployment.into(),
170                api_version: Some(DEFAULT_API_VERSION.to_string()),
171                auth: Auth::ApiKey(api_key.into()),
172                implicit_encrypted_reasoning: true,
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                base_url: None,
189                deployment: deployment.into(),
190                api_version: Some(DEFAULT_API_VERSION.to_string()),
191                auth: Auth::Credential(credential),
192                implicit_encrypted_reasoning: true,
193            }),
194        }
195    }
196
197    /// Build an API-key-authenticated client from `AZURE_OPENAI_ENDPOINT`,
198    /// `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`, and
199    /// optional `AZURE_OPENAI_API_VERSION`/`AZURE_OPENAI_BASE_URL` — the same
200    /// generic `AZURE_OPENAI_*` variables
201    /// [`AzureOpenAIClient::from_env`](crate::AzureOpenAIClient::from_env)
202    /// reads, except for the Responses-specific deployment variable (mirrors
203    /// upstream's `responses_deployment_name` settings field, distinct from
204    /// Chat Completions' `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, so a resource
205    /// with differently named deployments per API surface works;
206    /// `azure/_shared.py:102-103`, docstring at
207    /// `azure/_responses_client.py:53-56`).
208    pub fn from_env() -> Result<Self> {
209        Self::from_env_vars(|key| std::env::var(key).ok())
210    }
211
212    /// Implementation of [`from_env`](Self::from_env), parameterized over an
213    /// environment lookup function.
214    ///
215    /// Kept separate so unit tests can exercise the parsing/validation logic
216    /// against an in-memory map instead of mutating real process environment
217    /// variables: those are process-global, and `AZURE_OPENAI_ENDPOINT`/
218    /// `AZURE_OPENAI_API_KEY`/`AZURE_OPENAI_API_VERSION` are also read by
219    /// [`AzureOpenAIClient::from_env`](crate::AzureOpenAIClient::from_env)'s
220    /// own tests in `lib.rs`, which run concurrently under `cargo test` and
221    /// guard only against races among themselves.
222    fn from_env_vars(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
223        let endpoint = get("AZURE_OPENAI_ENDPOINT")
224            .ok_or_else(|| Error::Configuration("AZURE_OPENAI_ENDPOINT is not set".into()))?;
225        let api_key = get("AZURE_OPENAI_API_KEY")
226            .ok_or_else(|| Error::Configuration("AZURE_OPENAI_API_KEY is not set".into()))?;
227        let deployment = get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME").ok_or_else(|| {
228            Error::Configuration("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME is not set".into())
229        })?;
230        let mut client = Self::new(endpoint, deployment, api_key);
231        match get("AZURE_OPENAI_API_VERSION") {
232            // Empty string opts out of the query parameter entirely (GA v1 /
233            // gateway targets); see `without_api_version`.
234            Some(v) if v.is_empty() => client = client.without_api_version(),
235            Some(v) => client = client.with_api_version(v),
236            None => {}
237        }
238        if let Some(b) = get("AZURE_OPENAI_BASE_URL") {
239            client = client.with_base_url(b);
240        }
241        Ok(client)
242    }
243
244    /// Override the API version (default `"preview"`).
245    pub fn with_api_version(mut self, api_version: impl Into<String>) -> Self {
246        Arc::make_mut(&mut self.inner).api_version = Some(api_version.into());
247        self
248    }
249
250    /// Send no `api-version` query parameter at all.
251    ///
252    /// Microsoft's v1 Responses examples call the bare
253    /// `{endpoint}/openai/v1/responses` URL, and the v1 lifecycle removes
254    /// dated api-version parameters — GA v1 resources and some
255    /// OpenAI-compatible gateways reject or misroute requests carrying one.
256    /// The `"preview"` default mirrors upstream Python
257    /// (`_responses_client.py:112`); use this to produce the documented
258    /// query-less URL instead. (Also reachable by setting the
259    /// `AZURE_OPENAI_API_VERSION` env var to an empty string with
260    /// [`from_env`](Self::from_env).)
261    pub fn without_api_version(mut self) -> Self {
262        Arc::make_mut(&mut self.inner).api_version = None;
263        self
264    }
265
266    /// Stop adding `reasoning.encrypted_content` to a stateless request's
267    /// `include` on the caller's behalf.
268    ///
269    /// The implicit add is right for OpenAI and Azure OpenAI, where a
270    /// `store: false` tool loop has to replay the reasoning item itself. Azure
271    /// AI Foundry does not want it unless it is asked for by name, so
272    /// `FoundryChatClient` builds its transport
273    /// with this set (upstream #7536).
274    ///
275    /// A caller that puts `reasoning.encrypted_content` in its own `include`
276    /// (via `ChatOptions::additional_properties`) still gets it — this governs
277    /// only what the client adds unprompted.
278    pub fn without_implicit_encrypted_reasoning(mut self) -> Self {
279        Arc::make_mut(&mut self.inner).implicit_encrypted_reasoning = false;
280        self
281    }
282
283    /// Override the full base URL used to build requests, taking precedence
284    /// over the endpoint-derived `/openai/v1/` route. Mirrors upstream's
285    /// `base_url` parameter, which "must end with `/openai/v1/`"
286    /// (`azure/_responses_client.py:60-62`) — e.g. for a differently-shaped
287    /// gateway or proxy in front of Azure OpenAI.
288    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
289        Arc::make_mut(&mut self.inner).base_url = Some(base_url.into());
290        self
291    }
292
293    /// The deployment name this client targets (sent as the request body's
294    /// `model` field; see the [module docs](self)).
295    pub fn deployment(&self) -> &str {
296        &self.inner.deployment
297    }
298
299    /// The API version this client sends, or `None` when the query
300    /// parameter is omitted (see [`without_api_version`](Self::without_api_version)).
301    pub fn api_version(&self) -> Option<&str> {
302        self.inner.api_version.as_deref()
303    }
304
305    /// The effective base URL requests are built against: the explicit
306    /// [`with_base_url`](Self::with_base_url) override when set, otherwise
307    /// `None` (the endpoint-derived `/openai/v1/` route is computed lazily by
308    /// `url`).
309    pub fn base_url(&self) -> Option<&str> {
310        self.inner.base_url.as_deref()
311    }
312
313    fn url(&self) -> String {
314        let base = match &self.inner.base_url {
315            Some(explicit) => explicit.trim_end_matches('/').to_string(),
316            None => format!("{}/openai/v1", self.inner.endpoint.trim_end_matches('/')),
317        };
318        match &self.inner.api_version {
319            Some(v) => format!("{base}/responses?api-version={v}"),
320            None => format!("{base}/responses"),
321        }
322    }
323
324    /// Build the Responses API request body, reusing conversion from
325    /// [`agent_framework_openai::responses`] verbatim — see the
326    /// [module docs](self).
327    fn build_body(&self, messages: &[Message], options: &ChatOptions, stream: bool) -> Value {
328        let mut body = Map::new();
329        // Unlike Chat Completions (deployment selects the model via the URL
330        // path), the `/openai/v1/responses` route carries no deployment
331        // segment, so `model` is the *only* way to select it and is always
332        // sent — mirroring `OpenAIChatClient::build_body` and upstream's
333        // `run_options["model"] = self.model` fallback
334        // (`openai/_responses_client.py:432-435`).
335        let model = options
336            .model
337            .clone()
338            .unwrap_or_else(|| self.inner.deployment.clone());
339        body.insert("model".into(), json!(model));
340
341        let (instructions, rest) = agent_framework_openai::responses::extract_instructions(
342            messages,
343            options.instructions.as_deref(),
344        );
345        if let Some(instructions) = instructions {
346            body.insert("instructions".into(), json!(instructions));
347        }
348        body.insert(
349            "input".into(),
350            json!(agent_framework_openai::responses::messages_to_input(rest)),
351        );
352
353        if let Some(conversation_id) = &options.conversation_id {
354            body.insert("previous_response_id".into(), json!(conversation_id));
355        }
356        if let Some(t) = options.temperature {
357            body.insert("temperature".into(), json!(t));
358        }
359        if let Some(t) = options.top_p {
360            body.insert("top_p".into(), json!(t));
361        }
362        if let Some(mt) = options.max_tokens {
363            body.insert("max_output_tokens".into(), json!(mt));
364        }
365        if let Some(store) = options.store {
366            body.insert("store".into(), json!(store));
367        }
368        if let Some(user) = &options.user {
369            body.insert("user".into(), json!(user));
370        }
371        if let Some(metadata) = &options.metadata {
372            body.insert("metadata".into(), json!(metadata));
373        }
374
375        if !options.tools.is_empty() {
376            let tools: Vec<Value> = options
377                .tools
378                .iter()
379                .map(agent_framework_openai::responses::tool_to_responses_spec)
380                .collect();
381            body.insert("tools".into(), json!(tools));
382            if let Some(allow_multi) = options.allow_multiple_tool_calls {
383                body.insert("parallel_tool_calls".into(), json!(allow_multi));
384            }
385        }
386        if let Some(tool_choice) = &options.tool_choice {
387            body.insert(
388                "tool_choice".into(),
389                agent_framework_openai::responses::tool_choice_to_responses(tool_choice),
390            );
391        }
392        if let Some(fmt) = &options.response_format {
393            body.insert(
394                "text".into(),
395                json!({ "format": agent_framework_openai::responses::response_format_to_text(fmt) }),
396            );
397        }
398
399        // A caller's own `include` entries are already folded in here.
400        if let Some(include) = agent_framework_openai::responses::responses_include(
401            options,
402            self.inner.implicit_encrypted_reasoning,
403        ) {
404            body.insert("include".into(), include);
405        }
406
407        for (k, v) in &options.additional_properties {
408            // `include` belongs to `responses_include` alone — see the
409            // matching note in the OpenAI client's `build_body`.
410            if k == "include" {
411                continue;
412            }
413            body.entry(k.clone()).or_insert_with(|| v.clone());
414        }
415
416        if stream {
417            body.insert("stream".into(), json!(true));
418        }
419        Value::Object(body)
420    }
421
422    /// The header name/value pair to authenticate a request, per the
423    /// client's configured [`Auth`] mode — identical logic to
424    /// [`AzureOpenAIClient`](crate::AzureOpenAIClient)'s own `auth_header`.
425    async fn auth_header(&self) -> Result<(&'static str, String)> {
426        match &self.inner.auth {
427            Auth::ApiKey(key) => Ok(("api-key", key.clone())),
428            Auth::Credential(credential) => {
429                let token = credential.get_token().await?;
430                Ok(("Authorization", format!("Bearer {token}")))
431            }
432        }
433    }
434
435    async fn post(&self, body: &Value) -> Result<reqwest::Response> {
436        let (header_name, header_value) = self.auth_header().await?;
437        let resp = self
438            .inner
439            .http
440            .post(self.url())
441            .header(header_name, header_value)
442            .json(body)
443            .send()
444            .await
445            .map_err(|e| Error::service(format!("request failed: {e}")))?;
446        if !resp.status().is_success() {
447            let status = resp.status();
448            let retry_after = crate::parse_retry_after(resp.headers());
449            let text = resp.text().await.unwrap_or_default();
450            // Shared with `agent-framework-openai`'s Responses client — see
451            // `AzureOpenAIClient::post`.
452            return Err(agent_framework_openai::classify_service_error(
453                status.as_u16(),
454                &text,
455                format!("Azure OpenAI API error {status}: {text}"),
456                retry_after,
457            ));
458        }
459        Ok(resp)
460    }
461}
462
463#[async_trait::async_trait]
464impl ChatClient for AzureOpenAIResponsesClient {
465    async fn get_response(
466        &self,
467        messages: Vec<Message>,
468        options: ChatOptions,
469    ) -> Result<ChatResponse> {
470        let body = self.build_body(&messages, &options, false);
471        let resp = self.post(&body).await?;
472        let value: Value = resp
473            .json()
474            .await
475            .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
476        // Mirrors `OpenAIChatClient::get_response`: a failed run reports
477        // `status: "failed"` with a 2xx HTTP status, so the error has to be
478        // pulled out of the body rather than the transport layer —
479        // content-filter failures get the granular variant.
480        if let Some(err) = agent_framework_openai::responses::response_failure_error(&value) {
481            return Err(err);
482        }
483        Ok(agent_framework_openai::responses::parse_response(
484            &value,
485            options.store,
486        ))
487    }
488
489    async fn get_streaming_response(
490        &self,
491        messages: Vec<Message>,
492        options: ChatOptions,
493    ) -> Result<ChatStream> {
494        let body = self.build_body(&messages, &options, true);
495        let resp = self.post(&body).await?;
496        Ok(
497            agent_framework_openai::responses::parse_responses_sse_stream(resp, options.store)
498                .boxed(),
499        )
500    }
501
502    fn model(&self) -> Option<&str> {
503        Some(&self.inner.deployment)
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use agent_framework_core::tools::{ApprovalMode, ToolDefinition, ToolKind};
511    use agent_framework_core::types::{Content, FunctionArguments, FunctionCallContent, ToolMode};
512
513    fn client() -> AzureOpenAIResponsesClient {
514        AzureOpenAIResponsesClient::new(
515            "https://my-resource.openai.azure.com",
516            "my-gpt4o-deployment",
517            "test-key",
518        )
519    }
520
521    fn user(text: &str) -> Message {
522        Message::user(text)
523    }
524
525    // region: URL building
526
527    #[test]
528    fn url_uses_v1_responses_route_with_default_preview_api_version() {
529        let c = client();
530        assert_eq!(
531            c.url(),
532            "https://my-resource.openai.azure.com/openai/v1/responses?api-version=preview"
533        );
534    }
535
536    #[test]
537    fn url_trims_trailing_slash_on_endpoint() {
538        let c = AzureOpenAIResponsesClient::new(
539            "https://my-resource.openai.azure.com/",
540            "my-gpt4o-deployment",
541            "test-key",
542        );
543        assert_eq!(
544            c.url(),
545            "https://my-resource.openai.azure.com/openai/v1/responses?api-version=preview"
546        );
547    }
548
549    #[test]
550    fn url_has_no_deployment_segment_unlike_chat_completions() {
551        // Contrast with `AzureOpenAIClient::url()`, which *does* embed the
552        // deployment in the path; the Responses client never does.
553        let c = client();
554        assert!(!c.url().contains("deployments"));
555        assert!(!c.url().contains("my-gpt4o-deployment"));
556    }
557
558    #[test]
559    fn with_api_version_overrides_default() {
560        let c = client().with_api_version("2025-04-01-preview");
561        assert!(c.url().ends_with("api-version=2025-04-01-preview"));
562    }
563
564    #[test]
565    fn implicit_encrypted_reasoning_is_on_by_default_and_can_be_turned_off() {
566        // Azure OpenAI behaves like OpenAI: a stateless request asks for the
567        // encrypted reasoning item so the tool-loop replay has something valid
568        // to re-send.
569        let body = client().build_body(&[user("hi")], &ChatOptions::new(), false);
570        assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
571
572        // Foundry builds its transport with the implicit add off, and then no
573        // `include` is sent at all rather than an empty one.
574        let body = client().without_implicit_encrypted_reasoning().build_body(
575            &[user("hi")],
576            &ChatOptions::new(),
577            false,
578        );
579        assert!(
580            body.get("include").is_none(),
581            "no include expected, got: {}",
582            body
583        );
584    }
585
586    #[test]
587    fn an_explicit_empty_include_is_omitted_not_sent_as_an_empty_array() {
588        // With the implicit add off (the Foundry shape), an empty `include`
589        // from the caller must not survive the `additional_properties` pass.
590        let mut options = ChatOptions::new();
591        options
592            .additional_properties
593            .insert("include".into(), json!([]));
594        let body = client().without_implicit_encrypted_reasoning().build_body(
595            &[user("hi")],
596            &options,
597            false,
598        );
599        assert!(
600            body.get("include").is_none(),
601            "an empty include should be omitted entirely, got: {}",
602            body
603        );
604    }
605
606    #[test]
607    fn turning_off_the_implicit_add_still_honors_an_explicit_request() {
608        // The switch governs only what the client adds unprompted — a caller
609        // that names the entry still gets it.
610        let mut options = ChatOptions::new();
611        options
612            .additional_properties
613            .insert("include".into(), json!(["reasoning.encrypted_content"]));
614        let body = client().without_implicit_encrypted_reasoning().build_body(
615            &[user("hi")],
616            &options,
617            false,
618        );
619        assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
620    }
621
622    #[test]
623    fn without_api_version_omits_the_query_parameter() {
624        // GA v1 resources / OpenAI-compatible gateways use the documented
625        // bare URL with no api-version query.
626        let c = client().without_api_version();
627        assert_eq!(
628            c.url(),
629            "https://my-resource.openai.azure.com/openai/v1/responses"
630        );
631        assert_eq!(c.api_version(), None);
632
633        let gateway = client()
634            .with_base_url("https://gateway.example.com/openai/v1/")
635            .without_api_version();
636        assert_eq!(
637            gateway.url(),
638            "https://gateway.example.com/openai/v1/responses"
639        );
640    }
641
642    #[test]
643    fn from_env_empty_api_version_opts_out_of_the_query() {
644        let c = AzureOpenAIResponsesClient::from_env_vars(|k| match k {
645            "AZURE_OPENAI_ENDPOINT" => Some("https://my-resource.openai.azure.com".into()),
646            "AZURE_OPENAI_API_KEY" => Some("key".into()),
647            "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" => Some("dep".into()),
648            "AZURE_OPENAI_API_VERSION" => Some(String::new()),
649            _ => None,
650        })
651        .unwrap();
652        assert!(!c.url().contains("api-version"));
653    }
654
655    #[test]
656    fn with_base_url_overrides_derived_route() {
657        let c = client().with_base_url("https://gateway.example.com/openai/v1/");
658        assert_eq!(
659            c.url(),
660            "https://gateway.example.com/openai/v1/responses?api-version=preview"
661        );
662        assert_eq!(c.base_url(), Some("https://gateway.example.com/openai/v1/"));
663    }
664
665    #[test]
666    fn accessors_report_configured_deployment_and_api_version() {
667        let c = client();
668        assert_eq!(c.deployment(), "my-gpt4o-deployment");
669        assert_eq!(c.api_version(), Some("preview"));
670        assert_eq!(c.model(), Some("my-gpt4o-deployment"));
671        assert_eq!(c.base_url(), None);
672    }
673
674    // endregion
675
676    // region: auth header selection
677
678    #[tokio::test]
679    async fn api_key_auth_uses_api_key_header() {
680        let c = client();
681        let (name, value) = c.auth_header().await.unwrap();
682        assert_eq!(name, "api-key");
683        assert_eq!(value, "test-key");
684    }
685
686    #[tokio::test]
687    async fn token_credential_auth_uses_bearer_header() {
688        let credential = Arc::new(crate::StaticTokenCredential::new("my-jwt-token"));
689        let c = AzureOpenAIResponsesClient::with_token_credential(
690            "https://my-resource.openai.azure.com",
691            "my-gpt4o-deployment",
692            credential,
693        );
694        let (name, value) = c.auth_header().await.unwrap();
695        assert_eq!(name, "Authorization");
696        assert_eq!(value, "Bearer my-jwt-token");
697    }
698
699    // endregion
700
701    // region: request-body parity with `agent_framework_openai::responses::OpenAIChatClient`
702    //
703    // These mirror the equivalent `build_body_*` tests in
704    // `agent-framework-openai/src/responses.rs` field-for-field (substituting
705    // the deployment name for `model`), proving this client *reuses* — rather
706    // than reimplements — `extract_instructions`, `messages_to_input`,
707    // `tool_to_responses_spec`, `tool_choice_to_responses`, and
708    // `response_format_to_text`: any divergence in those shared functions
709    // would show up here exactly as it would in the OpenAI crate's own tests.
710
711    #[test]
712    fn build_body_simple_text_matches_openai_shape_with_deployment_as_model() {
713        let c = client();
714        let body = c.build_body(&[user("Hello there")], &ChatOptions::new(), false);
715        assert_eq!(
716            body,
717            json!({
718                "model": "my-gpt4o-deployment",
719                "input": [
720                    { "type": "message", "role": "user", "content": [
721                        { "type": "input_text", "text": "Hello there" }
722                    ]}
723                ],
724                // Matches the OpenAI client's stateless-request `include`.
725                "include": ["reasoning.encrypted_content"],
726            })
727        );
728    }
729
730    #[test]
731    fn build_body_model_always_present_unlike_chat_completions() {
732        // Contrast with `AzureOpenAIClient::build_body`, which *omits*
733        // `model` unless explicitly overridden (the deployment in its URL
734        // already selects it); the Responses route has no such URL segment,
735        // so `model` must always be sent.
736        let c = client();
737        let body = c.build_body(&[user("hi")], &ChatOptions::new(), false);
738        assert_eq!(body["model"], json!("my-gpt4o-deployment"));
739    }
740
741    #[test]
742    fn build_body_model_override_wins_over_deployment() {
743        let c = client();
744        let options = ChatOptions::new().with_model("gpt-4o-override");
745        let body = c.build_body(&[user("hi")], &options, false);
746        assert_eq!(body["model"], json!("gpt-4o-override"));
747    }
748
749    #[test]
750    fn build_body_extracts_leading_system_message_as_instructions() {
751        let c = client();
752        let messages = vec![Message::system("Be terse."), user("Hi")];
753        let body = c.build_body(&messages, &ChatOptions::new(), false);
754        assert_eq!(body["instructions"], json!("Be terse."));
755        assert_eq!(
756            body["input"],
757            json!([
758                { "type": "message", "role": "user", "content": [
759                    { "type": "input_text", "text": "Hi" }
760                ]}
761            ])
762        );
763    }
764
765    #[test]
766    fn build_body_function_call_round_trip() {
767        let c = client();
768        let call = FunctionCallContent::new(
769            "call_1",
770            "get_weather",
771            Some(FunctionArguments::Raw(r#"{"city":"Paris"}"#.to_string())),
772        );
773        let assistant_msg = Message::with_contents(
774            agent_framework_core::types::Role::assistant(),
775            vec![Content::FunctionCall(call)],
776        );
777        let tool_msg = Message::with_contents(
778            agent_framework_core::types::Role::tool(),
779            vec![Content::FunctionResult(
780                agent_framework_core::types::FunctionResultContent::new(
781                    "call_1",
782                    Some(json!("18C and sunny")),
783                ),
784            )],
785        );
786        let body = c.build_body(
787            &[user("weather?"), assistant_msg, tool_msg],
788            &ChatOptions::new(),
789            false,
790        );
791        assert_eq!(
792            body["input"],
793            json!([
794                { "type": "message", "role": "user", "content": [
795                    { "type": "input_text", "text": "weather?" }
796                ]},
797                { "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" },
798                { "type": "function_call_output", "call_id": "call_1", "output": "18C and sunny" },
799            ])
800        );
801    }
802
803    #[test]
804    fn build_body_tools_are_flat_not_nested() {
805        let c = client();
806        let tool = ToolDefinition {
807            name: "get_weather".into(),
808            description: "Get the weather".into(),
809            parameters: json!({ "type": "object", "properties": {} }),
810            kind: ToolKind::Function,
811            approval_mode: ApprovalMode::NeverRequire,
812            executor: None,
813        };
814        let options = ChatOptions::new().with_tool(tool);
815        let body = c.build_body(&[user("hi")], &options, false);
816        assert_eq!(
817            body["tools"],
818            json!([{
819                "type": "function",
820                "name": "get_weather",
821                "description": "Get the weather",
822                "parameters": { "type": "object", "properties": {} },
823            }])
824        );
825    }
826
827    #[test]
828    fn build_body_tool_choice_required_named() {
829        let c = client();
830        let options =
831            ChatOptions::new().with_tool_choice(ToolMode::Required(Some("get_weather".into())));
832        let body = c.build_body(&[user("hi")], &options, false);
833        assert_eq!(
834            body["tool_choice"],
835            json!({ "type": "function", "name": "get_weather" })
836        );
837    }
838
839    #[test]
840    fn build_body_conversation_id_becomes_previous_response_id() {
841        let c = client();
842        let mut options = ChatOptions::new();
843        options.conversation_id = Some("resp_abc123".into());
844        let body = c.build_body(&[user("hi")], &options, false);
845        assert_eq!(body["previous_response_id"], json!("resp_abc123"));
846    }
847
848    #[test]
849    fn build_body_max_tokens_becomes_max_output_tokens() {
850        let c = client();
851        let options = ChatOptions::new().with_max_tokens(256);
852        let body = c.build_body(&[user("hi")], &options, false);
853        assert_eq!(body["max_output_tokens"], json!(256));
854        assert!(body.get("max_tokens").is_none());
855    }
856
857    #[test]
858    fn build_body_stream_sets_stream_flag_without_stream_options() {
859        // Unlike Chat Completions, the Responses API needs no
860        // `stream_options.include_usage` toggle: usage arrives on the
861        // `response.completed` event unconditionally.
862        let c = client();
863        let body = c.build_body(&[user("hi")], &ChatOptions::new(), true);
864        assert_eq!(body["stream"], json!(true));
865        assert!(body.get("stream_options").is_none());
866    }
867
868    // endregion
869
870    // region: response parsing (reuses agent_framework_openai::responses::parse_response)
871
872    #[test]
873    fn parse_response_reuses_openai_responses_convert() {
874        let value = json!({
875            "id": "resp_abc123",
876            "model": "my-gpt4o-deployment",
877            "status": "completed",
878            "output": [{
879                "type": "message",
880                "role": "assistant",
881                "content": [{ "type": "output_text", "text": "Hello!" }],
882            }],
883            "usage": { "input_tokens": 10, "output_tokens": 5, "total_tokens": 15 },
884        });
885        let resp = agent_framework_openai::responses::parse_response(&value, None);
886        assert_eq!(resp.text(), "Hello!");
887        assert_eq!(resp.response_id.as_deref(), Some("resp_abc123"));
888        // `store != Some(false)` defaults `conversation_id` to the response
889        // id, identical to `OpenAIChatClient`.
890        assert_eq!(resp.conversation_id.as_deref(), Some("resp_abc123"));
891        assert_eq!(resp.usage_details.unwrap().total_token_count, Some(15));
892    }
893
894    // endregion
895
896    // Streaming: `get_streaming_response` calls
897    // `agent_framework_openai::responses::parse_responses_sse_stream(resp,
898    // options.store).boxed()` directly, with no azure-specific logic of its
899    // own — the wiring is verified at compile time (this crate wouldn't
900    // type-check against `ChatStream` otherwise). A real SSE round trip is
901    // covered by the loopback test in `tests/credentials_loopback.rs`
902    // (`azure_openai_responses_client_streams_sse_events`); the event-parsing
903    // logic itself is exercised by `agent-framework-openai`'s own test suite.
904
905    // region: env-var constructor
906
907    #[test]
908    fn from_env_reads_all_vars() {
909        let vars = [
910            ("AZURE_OPENAI_ENDPOINT", "https://res.openai.azure.com"),
911            ("AZURE_OPENAI_API_KEY", "test-key-123"),
912            (
913                "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME",
914                "gpt-4o-responses-deployment",
915            ),
916            ("AZURE_OPENAI_API_VERSION", "2025-05-01-preview"),
917            (
918                "AZURE_OPENAI_BASE_URL",
919                "https://gateway.example.com/openai/v1/",
920            ),
921        ]
922        .into_iter()
923        .collect::<std::collections::HashMap<_, _>>();
924
925        let client =
926            AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
927                .unwrap();
928        assert_eq!(client.inner.endpoint, "https://res.openai.azure.com");
929        assert_eq!(client.inner.deployment, "gpt-4o-responses-deployment");
930        assert_eq!(
931            client.inner.api_version.as_deref(),
932            Some("2025-05-01-preview")
933        );
934        assert_eq!(
935            client.inner.base_url.as_deref(),
936            Some("https://gateway.example.com/openai/v1/")
937        );
938        assert!(matches!(client.inner.auth, Auth::ApiKey(ref k) if k == "test-key-123"));
939    }
940
941    #[test]
942    fn from_env_defaults_api_version_to_preview_when_unset() {
943        let vars = [
944            ("AZURE_OPENAI_ENDPOINT", "https://res.openai.azure.com"),
945            ("AZURE_OPENAI_API_KEY", "test-key-123"),
946            (
947                "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME",
948                "gpt-4o-responses-deployment",
949            ),
950        ]
951        .into_iter()
952        .collect::<std::collections::HashMap<_, _>>();
953
954        let client =
955            AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
956                .unwrap();
957        assert_eq!(
958            client.inner.api_version.as_deref(),
959            Some(DEFAULT_API_VERSION)
960        );
961        assert_eq!(client.inner.base_url, None);
962    }
963
964    #[test]
965    fn from_env_errors_when_responses_deployment_missing() {
966        let vars = [
967            ("AZURE_OPENAI_ENDPOINT", "https://res.openai.azure.com"),
968            ("AZURE_OPENAI_API_KEY", "test-key-123"),
969        ]
970        .into_iter()
971        .collect::<std::collections::HashMap<_, _>>();
972
973        let err = AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
974            .unwrap_err();
975        assert!(err
976            .to_string()
977            .contains("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"));
978    }
979
980    #[test]
981    fn from_env_errors_when_endpoint_missing() {
982        let vars = [
983            ("AZURE_OPENAI_API_KEY", "test-key-123"),
984            (
985                "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME",
986                "gpt-4o-responses-deployment",
987            ),
988        ]
989        .into_iter()
990        .collect::<std::collections::HashMap<_, _>>();
991
992        let err = AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
993            .unwrap_err();
994        assert!(err.to_string().contains("AZURE_OPENAI_ENDPOINT"));
995    }
996
997    // `from_env()` itself is intentionally not exercised here against real
998    // process env vars: it is a one-line call to `from_env_vars` (verified
999    // exhaustively above), and env vars are process-global — this crate's
1000    // `AzureOpenAIClient::from_env` tests (`lib.rs`) already mutate the exact
1001    // same `AZURE_OPENAI_ENDPOINT`/`AZURE_OPENAI_API_KEY`/
1002    // `AZURE_OPENAI_API_VERSION` names under their own mutex, and `cargo
1003    // test` runs both modules' tests concurrently in one binary. Testing
1004    // through the injectable `from_env_vars` seam instead gives the same
1005    // coverage with no risk of racing those tests.
1006
1007    // endregion
1008}