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}
125
126impl Clone for AzureOpenAIResponsesClient {
127    fn clone(&self) -> Self {
128        Self {
129            inner: self.inner.clone(),
130        }
131    }
132}
133
134impl std::fmt::Debug for AzureOpenAIResponsesClient {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        f.debug_struct("AzureOpenAIResponsesClient")
137            .field("endpoint", &self.inner.endpoint)
138            .field("base_url", &self.inner.base_url)
139            .field("deployment", &self.inner.deployment)
140            .field("api_version", &self.inner.api_version)
141            .field(
142                "auth",
143                &match &self.inner.auth {
144                    Auth::ApiKey(_) => "api-key",
145                    Auth::Credential(_) => "token-credential",
146                },
147            )
148            .finish_non_exhaustive()
149    }
150}
151
152impl AzureOpenAIResponsesClient {
153    /// Create a client authenticating with a static API key
154    /// (`api-key` header).
155    pub fn new(
156        endpoint: impl Into<String>,
157        deployment: impl Into<String>,
158        api_key: impl Into<String>,
159    ) -> Self {
160        Self {
161            inner: Arc::new(Inner {
162                http: reqwest::Client::new(),
163                endpoint: endpoint.into(),
164                base_url: None,
165                deployment: deployment.into(),
166                api_version: Some(DEFAULT_API_VERSION.to_string()),
167                auth: Auth::ApiKey(api_key.into()),
168            }),
169        }
170    }
171
172    /// Create a client authenticating via a [`TokenCredential`]
173    /// (`Authorization: Bearer <token>`, e.g. Microsoft Entra ID).
174    pub fn with_token_credential(
175        endpoint: impl Into<String>,
176        deployment: impl Into<String>,
177        credential: Arc<dyn TokenCredential>,
178    ) -> Self {
179        Self {
180            inner: Arc::new(Inner {
181                http: reqwest::Client::new(),
182                endpoint: endpoint.into(),
183                base_url: None,
184                deployment: deployment.into(),
185                api_version: Some(DEFAULT_API_VERSION.to_string()),
186                auth: Auth::Credential(credential),
187            }),
188        }
189    }
190
191    /// Build an API-key-authenticated client from `AZURE_OPENAI_ENDPOINT`,
192    /// `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`, and
193    /// optional `AZURE_OPENAI_API_VERSION`/`AZURE_OPENAI_BASE_URL` — the same
194    /// generic `AZURE_OPENAI_*` variables
195    /// [`AzureOpenAIClient::from_env`](crate::AzureOpenAIClient::from_env)
196    /// reads, except for the Responses-specific deployment variable (mirrors
197    /// upstream's `responses_deployment_name` settings field, distinct from
198    /// Chat Completions' `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, so a resource
199    /// with differently named deployments per API surface works;
200    /// `azure/_shared.py:102-103`, docstring at
201    /// `azure/_responses_client.py:53-56`).
202    pub fn from_env() -> Result<Self> {
203        Self::from_env_vars(|key| std::env::var(key).ok())
204    }
205
206    /// Implementation of [`from_env`](Self::from_env), parameterized over an
207    /// environment lookup function.
208    ///
209    /// Kept separate so unit tests can exercise the parsing/validation logic
210    /// against an in-memory map instead of mutating real process environment
211    /// variables: those are process-global, and `AZURE_OPENAI_ENDPOINT`/
212    /// `AZURE_OPENAI_API_KEY`/`AZURE_OPENAI_API_VERSION` are also read by
213    /// [`AzureOpenAIClient::from_env`](crate::AzureOpenAIClient::from_env)'s
214    /// own tests in `lib.rs`, which run concurrently under `cargo test` and
215    /// guard only against races among themselves.
216    fn from_env_vars(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
217        let endpoint = get("AZURE_OPENAI_ENDPOINT")
218            .ok_or_else(|| Error::Configuration("AZURE_OPENAI_ENDPOINT is not set".into()))?;
219        let api_key = get("AZURE_OPENAI_API_KEY")
220            .ok_or_else(|| Error::Configuration("AZURE_OPENAI_API_KEY is not set".into()))?;
221        let deployment = get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME").ok_or_else(|| {
222            Error::Configuration("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME is not set".into())
223        })?;
224        let mut client = Self::new(endpoint, deployment, api_key);
225        match get("AZURE_OPENAI_API_VERSION") {
226            // Empty string opts out of the query parameter entirely (GA v1 /
227            // gateway targets); see `without_api_version`.
228            Some(v) if v.is_empty() => client = client.without_api_version(),
229            Some(v) => client = client.with_api_version(v),
230            None => {}
231        }
232        if let Some(b) = get("AZURE_OPENAI_BASE_URL") {
233            client = client.with_base_url(b);
234        }
235        Ok(client)
236    }
237
238    /// Override the API version (default `"preview"`).
239    pub fn with_api_version(mut self, api_version: impl Into<String>) -> Self {
240        Arc::make_mut(&mut self.inner).api_version = Some(api_version.into());
241        self
242    }
243
244    /// Send no `api-version` query parameter at all.
245    ///
246    /// Microsoft's v1 Responses examples call the bare
247    /// `{endpoint}/openai/v1/responses` URL, and the v1 lifecycle removes
248    /// dated api-version parameters — GA v1 resources and some
249    /// OpenAI-compatible gateways reject or misroute requests carrying one.
250    /// The `"preview"` default mirrors upstream Python
251    /// (`_responses_client.py:112`); use this to produce the documented
252    /// query-less URL instead. (Also reachable by setting the
253    /// `AZURE_OPENAI_API_VERSION` env var to an empty string with
254    /// [`from_env`](Self::from_env).)
255    pub fn without_api_version(mut self) -> Self {
256        Arc::make_mut(&mut self.inner).api_version = None;
257        self
258    }
259
260    /// Override the full base URL used to build requests, taking precedence
261    /// over the endpoint-derived `/openai/v1/` route. Mirrors upstream's
262    /// `base_url` parameter, which "must end with `/openai/v1/`"
263    /// (`azure/_responses_client.py:60-62`) — e.g. for a differently-shaped
264    /// gateway or proxy in front of Azure OpenAI.
265    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
266        Arc::make_mut(&mut self.inner).base_url = Some(base_url.into());
267        self
268    }
269
270    /// The deployment name this client targets (sent as the request body's
271    /// `model` field; see the [module docs](self)).
272    pub fn deployment(&self) -> &str {
273        &self.inner.deployment
274    }
275
276    /// The API version this client sends, or `None` when the query
277    /// parameter is omitted (see [`without_api_version`](Self::without_api_version)).
278    pub fn api_version(&self) -> Option<&str> {
279        self.inner.api_version.as_deref()
280    }
281
282    /// The effective base URL requests are built against: the explicit
283    /// [`with_base_url`](Self::with_base_url) override when set, otherwise
284    /// `None` (the endpoint-derived `/openai/v1/` route is computed lazily by
285    /// `url`).
286    pub fn base_url(&self) -> Option<&str> {
287        self.inner.base_url.as_deref()
288    }
289
290    fn url(&self) -> String {
291        let base = match &self.inner.base_url {
292            Some(explicit) => explicit.trim_end_matches('/').to_string(),
293            None => format!("{}/openai/v1", self.inner.endpoint.trim_end_matches('/')),
294        };
295        match &self.inner.api_version {
296            Some(v) => format!("{base}/responses?api-version={v}"),
297            None => format!("{base}/responses"),
298        }
299    }
300
301    /// Build the Responses API request body, reusing conversion from
302    /// [`agent_framework_openai::responses`] verbatim — see the
303    /// [module docs](self).
304    fn build_body(&self, messages: &[Message], options: &ChatOptions, stream: bool) -> Value {
305        let mut body = Map::new();
306        // Unlike Chat Completions (deployment selects the model via the URL
307        // path), the `/openai/v1/responses` route carries no deployment
308        // segment, so `model` is the *only* way to select it and is always
309        // sent — mirroring `OpenAIChatClient::build_body` and upstream's
310        // `run_options["model"] = self.model` fallback
311        // (`openai/_responses_client.py:432-435`).
312        let model = options
313            .model
314            .clone()
315            .unwrap_or_else(|| self.inner.deployment.clone());
316        body.insert("model".into(), json!(model));
317
318        let (instructions, rest) = agent_framework_openai::responses::extract_instructions(
319            messages,
320            options.instructions.as_deref(),
321        );
322        if let Some(instructions) = instructions {
323            body.insert("instructions".into(), json!(instructions));
324        }
325        body.insert(
326            "input".into(),
327            json!(agent_framework_openai::responses::messages_to_input(rest)),
328        );
329
330        if let Some(conversation_id) = &options.conversation_id {
331            body.insert("previous_response_id".into(), json!(conversation_id));
332        }
333        if let Some(t) = options.temperature {
334            body.insert("temperature".into(), json!(t));
335        }
336        if let Some(t) = options.top_p {
337            body.insert("top_p".into(), json!(t));
338        }
339        if let Some(mt) = options.max_tokens {
340            body.insert("max_output_tokens".into(), json!(mt));
341        }
342        if let Some(store) = options.store {
343            body.insert("store".into(), json!(store));
344        }
345        if let Some(user) = &options.user {
346            body.insert("user".into(), json!(user));
347        }
348        if let Some(metadata) = &options.metadata {
349            body.insert("metadata".into(), json!(metadata));
350        }
351
352        if !options.tools.is_empty() {
353            let tools: Vec<Value> = options
354                .tools
355                .iter()
356                .map(agent_framework_openai::responses::tool_to_responses_spec)
357                .collect();
358            body.insert("tools".into(), json!(tools));
359            if let Some(allow_multi) = options.allow_multiple_tool_calls {
360                body.insert("parallel_tool_calls".into(), json!(allow_multi));
361            }
362        }
363        if let Some(tool_choice) = &options.tool_choice {
364            body.insert(
365                "tool_choice".into(),
366                agent_framework_openai::responses::tool_choice_to_responses(tool_choice),
367            );
368        }
369        if let Some(fmt) = &options.response_format {
370            body.insert(
371                "text".into(),
372                json!({ "format": agent_framework_openai::responses::response_format_to_text(fmt) }),
373            );
374        }
375
376        for (k, v) in &options.additional_properties {
377            body.entry(k.clone()).or_insert_with(|| v.clone());
378        }
379
380        if stream {
381            body.insert("stream".into(), json!(true));
382        }
383        Value::Object(body)
384    }
385
386    /// The header name/value pair to authenticate a request, per the
387    /// client's configured [`Auth`] mode — identical logic to
388    /// [`AzureOpenAIClient`](crate::AzureOpenAIClient)'s own `auth_header`.
389    async fn auth_header(&self) -> Result<(&'static str, String)> {
390        match &self.inner.auth {
391            Auth::ApiKey(key) => Ok(("api-key", key.clone())),
392            Auth::Credential(credential) => {
393                let token = credential.get_token().await?;
394                Ok(("Authorization", format!("Bearer {token}")))
395            }
396        }
397    }
398
399    async fn post(&self, body: &Value) -> Result<reqwest::Response> {
400        let (header_name, header_value) = self.auth_header().await?;
401        let resp = self
402            .inner
403            .http
404            .post(self.url())
405            .header(header_name, header_value)
406            .json(body)
407            .send()
408            .await
409            .map_err(|e| Error::service(format!("request failed: {e}")))?;
410        if !resp.status().is_success() {
411            let status = resp.status();
412            let retry_after = crate::parse_retry_after(resp.headers());
413            let text = resp.text().await.unwrap_or_default();
414            // Shared with `agent-framework-openai`'s Responses client — see
415            // `AzureOpenAIClient::post`.
416            return Err(agent_framework_openai::classify_service_error(
417                status.as_u16(),
418                &text,
419                format!("Azure OpenAI API error {status}: {text}"),
420                retry_after,
421            ));
422        }
423        Ok(resp)
424    }
425}
426
427#[async_trait::async_trait]
428impl ChatClient for AzureOpenAIResponsesClient {
429    async fn get_response(
430        &self,
431        messages: Vec<Message>,
432        options: ChatOptions,
433    ) -> Result<ChatResponse> {
434        let body = self.build_body(&messages, &options, false);
435        let resp = self.post(&body).await?;
436        let value: Value = resp
437            .json()
438            .await
439            .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
440        // Mirrors `OpenAIChatClient::get_response`: a failed run reports
441        // `status: "failed"` with a 2xx HTTP status, so the error has to be
442        // pulled out of the body rather than the transport layer —
443        // content-filter failures get the granular variant.
444        if let Some(err) = agent_framework_openai::responses::response_failure_error(&value) {
445            return Err(err);
446        }
447        Ok(agent_framework_openai::responses::parse_response(
448            &value,
449            options.store,
450        ))
451    }
452
453    async fn get_streaming_response(
454        &self,
455        messages: Vec<Message>,
456        options: ChatOptions,
457    ) -> Result<ChatStream> {
458        let body = self.build_body(&messages, &options, true);
459        let resp = self.post(&body).await?;
460        Ok(
461            agent_framework_openai::responses::parse_responses_sse_stream(resp, options.store)
462                .boxed(),
463        )
464    }
465
466    fn model(&self) -> Option<&str> {
467        Some(&self.inner.deployment)
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use agent_framework_core::tools::{ApprovalMode, ToolDefinition, ToolKind};
475    use agent_framework_core::types::{Content, FunctionArguments, FunctionCallContent, ToolMode};
476
477    fn client() -> AzureOpenAIResponsesClient {
478        AzureOpenAIResponsesClient::new(
479            "https://my-resource.openai.azure.com",
480            "my-gpt4o-deployment",
481            "test-key",
482        )
483    }
484
485    fn user(text: &str) -> Message {
486        Message::user(text)
487    }
488
489    // region: URL building
490
491    #[test]
492    fn url_uses_v1_responses_route_with_default_preview_api_version() {
493        let c = client();
494        assert_eq!(
495            c.url(),
496            "https://my-resource.openai.azure.com/openai/v1/responses?api-version=preview"
497        );
498    }
499
500    #[test]
501    fn url_trims_trailing_slash_on_endpoint() {
502        let c = AzureOpenAIResponsesClient::new(
503            "https://my-resource.openai.azure.com/",
504            "my-gpt4o-deployment",
505            "test-key",
506        );
507        assert_eq!(
508            c.url(),
509            "https://my-resource.openai.azure.com/openai/v1/responses?api-version=preview"
510        );
511    }
512
513    #[test]
514    fn url_has_no_deployment_segment_unlike_chat_completions() {
515        // Contrast with `AzureOpenAIClient::url()`, which *does* embed the
516        // deployment in the path; the Responses client never does.
517        let c = client();
518        assert!(!c.url().contains("deployments"));
519        assert!(!c.url().contains("my-gpt4o-deployment"));
520    }
521
522    #[test]
523    fn with_api_version_overrides_default() {
524        let c = client().with_api_version("2025-04-01-preview");
525        assert!(c.url().ends_with("api-version=2025-04-01-preview"));
526    }
527
528    #[test]
529    fn without_api_version_omits_the_query_parameter() {
530        // GA v1 resources / OpenAI-compatible gateways use the documented
531        // bare URL with no api-version query.
532        let c = client().without_api_version();
533        assert_eq!(
534            c.url(),
535            "https://my-resource.openai.azure.com/openai/v1/responses"
536        );
537        assert_eq!(c.api_version(), None);
538
539        let gateway = client()
540            .with_base_url("https://gateway.example.com/openai/v1/")
541            .without_api_version();
542        assert_eq!(
543            gateway.url(),
544            "https://gateway.example.com/openai/v1/responses"
545        );
546    }
547
548    #[test]
549    fn from_env_empty_api_version_opts_out_of_the_query() {
550        let c = AzureOpenAIResponsesClient::from_env_vars(|k| match k {
551            "AZURE_OPENAI_ENDPOINT" => Some("https://my-resource.openai.azure.com".into()),
552            "AZURE_OPENAI_API_KEY" => Some("key".into()),
553            "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" => Some("dep".into()),
554            "AZURE_OPENAI_API_VERSION" => Some(String::new()),
555            _ => None,
556        })
557        .unwrap();
558        assert!(!c.url().contains("api-version"));
559    }
560
561    #[test]
562    fn with_base_url_overrides_derived_route() {
563        let c = client().with_base_url("https://gateway.example.com/openai/v1/");
564        assert_eq!(
565            c.url(),
566            "https://gateway.example.com/openai/v1/responses?api-version=preview"
567        );
568        assert_eq!(c.base_url(), Some("https://gateway.example.com/openai/v1/"));
569    }
570
571    #[test]
572    fn accessors_report_configured_deployment_and_api_version() {
573        let c = client();
574        assert_eq!(c.deployment(), "my-gpt4o-deployment");
575        assert_eq!(c.api_version(), Some("preview"));
576        assert_eq!(c.model(), Some("my-gpt4o-deployment"));
577        assert_eq!(c.base_url(), None);
578    }
579
580    // endregion
581
582    // region: auth header selection
583
584    #[tokio::test]
585    async fn api_key_auth_uses_api_key_header() {
586        let c = client();
587        let (name, value) = c.auth_header().await.unwrap();
588        assert_eq!(name, "api-key");
589        assert_eq!(value, "test-key");
590    }
591
592    #[tokio::test]
593    async fn token_credential_auth_uses_bearer_header() {
594        let credential = Arc::new(crate::StaticTokenCredential::new("my-jwt-token"));
595        let c = AzureOpenAIResponsesClient::with_token_credential(
596            "https://my-resource.openai.azure.com",
597            "my-gpt4o-deployment",
598            credential,
599        );
600        let (name, value) = c.auth_header().await.unwrap();
601        assert_eq!(name, "Authorization");
602        assert_eq!(value, "Bearer my-jwt-token");
603    }
604
605    // endregion
606
607    // region: request-body parity with `agent_framework_openai::responses::OpenAIChatClient`
608    //
609    // These mirror the equivalent `build_body_*` tests in
610    // `agent-framework-openai/src/responses.rs` field-for-field (substituting
611    // the deployment name for `model`), proving this client *reuses* — rather
612    // than reimplements — `extract_instructions`, `messages_to_input`,
613    // `tool_to_responses_spec`, `tool_choice_to_responses`, and
614    // `response_format_to_text`: any divergence in those shared functions
615    // would show up here exactly as it would in the OpenAI crate's own tests.
616
617    #[test]
618    fn build_body_simple_text_matches_openai_shape_with_deployment_as_model() {
619        let c = client();
620        let body = c.build_body(&[user("Hello there")], &ChatOptions::new(), false);
621        assert_eq!(
622            body,
623            json!({
624                "model": "my-gpt4o-deployment",
625                "input": [
626                    { "type": "message", "role": "user", "content": [
627                        { "type": "input_text", "text": "Hello there" }
628                    ]}
629                ],
630            })
631        );
632    }
633
634    #[test]
635    fn build_body_model_always_present_unlike_chat_completions() {
636        // Contrast with `AzureOpenAIClient::build_body`, which *omits*
637        // `model` unless explicitly overridden (the deployment in its URL
638        // already selects it); the Responses route has no such URL segment,
639        // so `model` must always be sent.
640        let c = client();
641        let body = c.build_body(&[user("hi")], &ChatOptions::new(), false);
642        assert_eq!(body["model"], json!("my-gpt4o-deployment"));
643    }
644
645    #[test]
646    fn build_body_model_override_wins_over_deployment() {
647        let c = client();
648        let options = ChatOptions::new().with_model("gpt-4o-override");
649        let body = c.build_body(&[user("hi")], &options, false);
650        assert_eq!(body["model"], json!("gpt-4o-override"));
651    }
652
653    #[test]
654    fn build_body_extracts_leading_system_message_as_instructions() {
655        let c = client();
656        let messages = vec![Message::system("Be terse."), user("Hi")];
657        let body = c.build_body(&messages, &ChatOptions::new(), false);
658        assert_eq!(body["instructions"], json!("Be terse."));
659        assert_eq!(
660            body["input"],
661            json!([
662                { "type": "message", "role": "user", "content": [
663                    { "type": "input_text", "text": "Hi" }
664                ]}
665            ])
666        );
667    }
668
669    #[test]
670    fn build_body_function_call_round_trip() {
671        let c = client();
672        let call = FunctionCallContent::new(
673            "call_1",
674            "get_weather",
675            Some(FunctionArguments::Raw(r#"{"city":"Paris"}"#.to_string())),
676        );
677        let assistant_msg = Message::with_contents(
678            agent_framework_core::types::Role::assistant(),
679            vec![Content::FunctionCall(call)],
680        );
681        let tool_msg = Message::with_contents(
682            agent_framework_core::types::Role::tool(),
683            vec![Content::FunctionResult(
684                agent_framework_core::types::FunctionResultContent::new(
685                    "call_1",
686                    Some(json!("18C and sunny")),
687                ),
688            )],
689        );
690        let body = c.build_body(
691            &[user("weather?"), assistant_msg, tool_msg],
692            &ChatOptions::new(),
693            false,
694        );
695        assert_eq!(
696            body["input"],
697            json!([
698                { "type": "message", "role": "user", "content": [
699                    { "type": "input_text", "text": "weather?" }
700                ]},
701                { "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" },
702                { "type": "function_call_output", "call_id": "call_1", "output": "18C and sunny" },
703            ])
704        );
705    }
706
707    #[test]
708    fn build_body_tools_are_flat_not_nested() {
709        let c = client();
710        let tool = ToolDefinition {
711            name: "get_weather".into(),
712            description: "Get the weather".into(),
713            parameters: json!({ "type": "object", "properties": {} }),
714            kind: ToolKind::Function,
715            approval_mode: ApprovalMode::NeverRequire,
716            executor: None,
717        };
718        let options = ChatOptions::new().with_tool(tool);
719        let body = c.build_body(&[user("hi")], &options, false);
720        assert_eq!(
721            body["tools"],
722            json!([{
723                "type": "function",
724                "name": "get_weather",
725                "description": "Get the weather",
726                "parameters": { "type": "object", "properties": {} },
727            }])
728        );
729    }
730
731    #[test]
732    fn build_body_tool_choice_required_named() {
733        let c = client();
734        let options =
735            ChatOptions::new().with_tool_choice(ToolMode::Required(Some("get_weather".into())));
736        let body = c.build_body(&[user("hi")], &options, false);
737        assert_eq!(
738            body["tool_choice"],
739            json!({ "type": "function", "name": "get_weather" })
740        );
741    }
742
743    #[test]
744    fn build_body_conversation_id_becomes_previous_response_id() {
745        let c = client();
746        let mut options = ChatOptions::new();
747        options.conversation_id = Some("resp_abc123".into());
748        let body = c.build_body(&[user("hi")], &options, false);
749        assert_eq!(body["previous_response_id"], json!("resp_abc123"));
750    }
751
752    #[test]
753    fn build_body_max_tokens_becomes_max_output_tokens() {
754        let c = client();
755        let options = ChatOptions::new().with_max_tokens(256);
756        let body = c.build_body(&[user("hi")], &options, false);
757        assert_eq!(body["max_output_tokens"], json!(256));
758        assert!(body.get("max_tokens").is_none());
759    }
760
761    #[test]
762    fn build_body_stream_sets_stream_flag_without_stream_options() {
763        // Unlike Chat Completions, the Responses API needs no
764        // `stream_options.include_usage` toggle: usage arrives on the
765        // `response.completed` event unconditionally.
766        let c = client();
767        let body = c.build_body(&[user("hi")], &ChatOptions::new(), true);
768        assert_eq!(body["stream"], json!(true));
769        assert!(body.get("stream_options").is_none());
770    }
771
772    // endregion
773
774    // region: response parsing (reuses agent_framework_openai::responses::parse_response)
775
776    #[test]
777    fn parse_response_reuses_openai_responses_convert() {
778        let value = json!({
779            "id": "resp_abc123",
780            "model": "my-gpt4o-deployment",
781            "status": "completed",
782            "output": [{
783                "type": "message",
784                "role": "assistant",
785                "content": [{ "type": "output_text", "text": "Hello!" }],
786            }],
787            "usage": { "input_tokens": 10, "output_tokens": 5, "total_tokens": 15 },
788        });
789        let resp = agent_framework_openai::responses::parse_response(&value, None);
790        assert_eq!(resp.text(), "Hello!");
791        assert_eq!(resp.response_id.as_deref(), Some("resp_abc123"));
792        // `store != Some(false)` defaults `conversation_id` to the response
793        // id, identical to `OpenAIChatClient`.
794        assert_eq!(resp.conversation_id.as_deref(), Some("resp_abc123"));
795        assert_eq!(resp.usage_details.unwrap().total_token_count, Some(15));
796    }
797
798    // endregion
799
800    // Streaming: `get_streaming_response` calls
801    // `agent_framework_openai::responses::parse_responses_sse_stream(resp,
802    // options.store).boxed()` directly, with no azure-specific logic of its
803    // own — the wiring is verified at compile time (this crate wouldn't
804    // type-check against `ChatStream` otherwise). A real SSE round trip is
805    // covered by the loopback test in `tests/credentials_loopback.rs`
806    // (`azure_openai_responses_client_streams_sse_events`); the event-parsing
807    // logic itself is exercised by `agent-framework-openai`'s own test suite.
808
809    // region: env-var constructor
810
811    #[test]
812    fn from_env_reads_all_vars() {
813        let vars = [
814            ("AZURE_OPENAI_ENDPOINT", "https://res.openai.azure.com"),
815            ("AZURE_OPENAI_API_KEY", "test-key-123"),
816            (
817                "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME",
818                "gpt-4o-responses-deployment",
819            ),
820            ("AZURE_OPENAI_API_VERSION", "2025-05-01-preview"),
821            (
822                "AZURE_OPENAI_BASE_URL",
823                "https://gateway.example.com/openai/v1/",
824            ),
825        ]
826        .into_iter()
827        .collect::<std::collections::HashMap<_, _>>();
828
829        let client =
830            AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
831                .unwrap();
832        assert_eq!(client.inner.endpoint, "https://res.openai.azure.com");
833        assert_eq!(client.inner.deployment, "gpt-4o-responses-deployment");
834        assert_eq!(
835            client.inner.api_version.as_deref(),
836            Some("2025-05-01-preview")
837        );
838        assert_eq!(
839            client.inner.base_url.as_deref(),
840            Some("https://gateway.example.com/openai/v1/")
841        );
842        assert!(matches!(client.inner.auth, Auth::ApiKey(ref k) if k == "test-key-123"));
843    }
844
845    #[test]
846    fn from_env_defaults_api_version_to_preview_when_unset() {
847        let vars = [
848            ("AZURE_OPENAI_ENDPOINT", "https://res.openai.azure.com"),
849            ("AZURE_OPENAI_API_KEY", "test-key-123"),
850            (
851                "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME",
852                "gpt-4o-responses-deployment",
853            ),
854        ]
855        .into_iter()
856        .collect::<std::collections::HashMap<_, _>>();
857
858        let client =
859            AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
860                .unwrap();
861        assert_eq!(
862            client.inner.api_version.as_deref(),
863            Some(DEFAULT_API_VERSION)
864        );
865        assert_eq!(client.inner.base_url, None);
866    }
867
868    #[test]
869    fn from_env_errors_when_responses_deployment_missing() {
870        let vars = [
871            ("AZURE_OPENAI_ENDPOINT", "https://res.openai.azure.com"),
872            ("AZURE_OPENAI_API_KEY", "test-key-123"),
873        ]
874        .into_iter()
875        .collect::<std::collections::HashMap<_, _>>();
876
877        let err = AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
878            .unwrap_err();
879        assert!(err
880            .to_string()
881            .contains("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"));
882    }
883
884    #[test]
885    fn from_env_errors_when_endpoint_missing() {
886        let vars = [
887            ("AZURE_OPENAI_API_KEY", "test-key-123"),
888            (
889                "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME",
890                "gpt-4o-responses-deployment",
891            ),
892        ]
893        .into_iter()
894        .collect::<std::collections::HashMap<_, _>>();
895
896        let err = AzureOpenAIResponsesClient::from_env_vars(|k| vars.get(k).map(|v| v.to_string()))
897            .unwrap_err();
898        assert!(err.to_string().contains("AZURE_OPENAI_ENDPOINT"));
899    }
900
901    // `from_env()` itself is intentionally not exercised here against real
902    // process env vars: it is a one-line call to `from_env_vars` (verified
903    // exhaustively above), and env vars are process-global — this crate's
904    // `AzureOpenAIClient::from_env` tests (`lib.rs`) already mutate the exact
905    // same `AZURE_OPENAI_ENDPOINT`/`AZURE_OPENAI_API_KEY`/
906    // `AZURE_OPENAI_API_VERSION` names under their own mutex, and `cargo
907    // test` runs both modules' tests concurrently in one binary. Testing
908    // through the injectable `from_env_vars` seam instead gives the same
909    // coverage with no risk of racing those tests.
910
911    // endregion
912}