Skip to main content

agent_framework_openai/
responses.rs

1//! [`OpenAIChatClient`]: a [`ChatClient`] for the OpenAI Responses API
2//! (`POST /v1/responses`).
3//!
4//! The Responses API uses an item-based `input`/`output` shape rather than
5//! the `messages` array used by Chat Completions, and supports a dedicated
6//! `previous_response_id` for service-side conversation state. Wire framing
7//! (SSE parsing style, error handling) mirrors [`crate::OpenAIChatCompletionClient`].
8//!
9//! ```no_run
10//! use agent_framework_openai::responses::OpenAIChatClient;
11//! use agent_framework_core::prelude::*;
12//!
13//! # async fn demo() -> Result<()> {
14//! let client = OpenAIChatClient::new("sk-...", "gpt-4o-mini");
15//! let agent = Agent::builder(client)
16//!     .instructions("You are concise.")
17//!     .build();
18//! let reply = agent.run_once("Say hi").await?;
19//! println!("{}", reply.text());
20//! # Ok(())
21//! # }
22//! ```
23
24use std::collections::{HashMap, VecDeque};
25use std::sync::Arc;
26
27use agent_framework_core::client::{ChatClient, ChatStream};
28use agent_framework_core::error::{Error, Result};
29use agent_framework_core::streaming::Utf8StreamDecoder;
30use agent_framework_core::tools::ToolDefinition;
31use agent_framework_core::types::{
32    Annotation, ChatOptions, ChatResponse, ChatResponseUpdate, Content, DataContent, FinishReason,
33    FunctionApprovalRequestContent, FunctionArguments, FunctionCallContent, FunctionResultContent,
34    Message, ResponseFormat, Role, TextContent, TextReasoningContent, TextSpanRegion, ToolMode,
35    UriContent, UsageContent, UsageDetails,
36};
37use futures::StreamExt;
38use serde_json::{json, Map, Value};
39
40use crate::convert::{
41    audio_format, data_content_media_type, function_arguments_to_string, result_to_string,
42    top_level_media_type, DEFAULT_FILENAME,
43};
44use crate::{ByteStream, DEFAULT_BASE_URL};
45
46/// An OpenAI Responses API chat client (`POST /v1/responses`).
47#[derive(Clone)]
48pub struct OpenAIChatClient {
49    inner: Arc<Inner>,
50}
51
52#[derive(Clone)]
53struct Inner {
54    http: reqwest::Client,
55    api_key: String,
56    base_url: String,
57    model: String,
58    organization: Option<String>,
59}
60
61impl std::fmt::Debug for OpenAIChatClient {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("OpenAIChatClient")
64            .field("base_url", &self.inner.base_url)
65            .field("model", &self.inner.model)
66            .field("organization", &self.inner.organization)
67            .finish_non_exhaustive()
68    }
69}
70
71impl OpenAIChatClient {
72    /// Create a client for the given API key and default model.
73    pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
74        Self {
75            inner: Arc::new(Inner {
76                http: reqwest::Client::new(),
77                api_key: api_key.into(),
78                base_url: DEFAULT_BASE_URL.to_string(),
79                model: model.into(),
80                organization: None,
81            }),
82        }
83    }
84
85    /// Build a client from the `OPENAI_API_KEY` (and optional
86    /// `OPENAI_BASE_URL`) environment variables.
87    pub fn from_env(model: impl Into<String>) -> Result<Self> {
88        let key = std::env::var("OPENAI_API_KEY")
89            .map_err(|_| Error::Configuration("OPENAI_API_KEY is not set".into()))?;
90        let mut client = Self::new(key, model);
91        if let Ok(base) = std::env::var("OPENAI_BASE_URL") {
92            client = client.with_base_url(base);
93        }
94        Ok(client)
95    }
96
97    /// Override the base URL (for Azure OpenAI or compatible servers).
98    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
99        Arc::make_mut(&mut self.inner).base_url = base_url.into();
100        self
101    }
102
103    /// Set the organization header.
104    pub fn with_organization(mut self, org: impl Into<String>) -> Self {
105        Arc::make_mut(&mut self.inner).organization = Some(org.into());
106        self
107    }
108
109    /// The default model id.
110    pub fn model(&self) -> &str {
111        &self.inner.model
112    }
113
114    fn build_body(&self, messages: &[Message], options: &ChatOptions, stream: bool) -> Value {
115        let mut body = Map::new();
116        let model = options
117            .model
118            .clone()
119            .unwrap_or_else(|| self.inner.model.clone());
120        body.insert("model".into(), json!(model));
121
122        let (instructions, rest) = extract_instructions(messages, options.instructions.as_deref());
123        if let Some(instructions) = instructions {
124            body.insert("instructions".into(), json!(instructions));
125        }
126        body.insert("input".into(), json!(messages_to_input(rest)));
127
128        if let Some(conversation_id) = &options.conversation_id {
129            body.insert("previous_response_id".into(), json!(conversation_id));
130        }
131        if let Some(t) = options.temperature {
132            body.insert("temperature".into(), json!(t));
133        }
134        if let Some(t) = options.top_p {
135            body.insert("top_p".into(), json!(t));
136        }
137        if let Some(mt) = options.max_tokens {
138            body.insert("max_output_tokens".into(), json!(mt));
139        }
140        if let Some(store) = options.store {
141            body.insert("store".into(), json!(store));
142        }
143        if let Some(user) = &options.user {
144            body.insert("user".into(), json!(user));
145        }
146        if let Some(metadata) = &options.metadata {
147            body.insert("metadata".into(), json!(metadata));
148        }
149
150        if !options.tools.is_empty() {
151            let tools: Vec<Value> = options.tools.iter().map(tool_to_responses_spec).collect();
152            body.insert("tools".into(), json!(tools));
153            if let Some(allow_multi) = options.allow_multiple_tool_calls {
154                body.insert("parallel_tool_calls".into(), json!(allow_multi));
155            }
156        }
157        if let Some(tool_choice) = &options.tool_choice {
158            body.insert("tool_choice".into(), tool_choice_to_responses(tool_choice));
159        }
160        if let Some(fmt) = &options.response_format {
161            body.insert(
162                "text".into(),
163                json!({ "format": response_format_to_text(fmt) }),
164            );
165        }
166
167        // Inserted before the `additional_properties` pass below, whose
168        // A caller's own `include` entries are already folded in here.
169        if let Some(include) = responses_include(options, true) {
170            body.insert("include".into(), include);
171        }
172
173        for (k, v) in &options.additional_properties {
174            // `include` belongs to `responses_include` alone. Letting it
175            // through would resurrect a value that function deliberately
176            // dropped — an explicit empty array would land as `include: []`,
177            // the one shape it exists to avoid.
178            if k == "include" {
179                continue;
180            }
181            body.entry(k.clone()).or_insert_with(|| v.clone());
182        }
183
184        if stream {
185            body.insert("stream".into(), json!(true));
186        }
187        Value::Object(body)
188    }
189
190    async fn post(&self, body: &Value) -> Result<reqwest::Response> {
191        let url = format!("{}/responses", self.inner.base_url.trim_end_matches('/'));
192        let mut req = self
193            .inner
194            .http
195            .post(&url)
196            .bearer_auth(&self.inner.api_key)
197            .json(body);
198        if let Some(org) = &self.inner.organization {
199            req = req.header("OpenAI-Organization", org);
200        }
201        let resp = req
202            .send()
203            .await
204            .map_err(|e| Error::service(format!("request failed: {e}")))?;
205        if !resp.status().is_success() {
206            let status = resp.status();
207            let retry_after = crate::parse_retry_after(resp.headers());
208            let text = resp.text().await.unwrap_or_default();
209            return Err(crate::classify_service_error(
210                status.as_u16(),
211                &text,
212                format!("OpenAI API error {status}: {text}"),
213                retry_after,
214            ));
215        }
216        Ok(resp)
217    }
218}
219
220#[async_trait::async_trait]
221impl ChatClient for OpenAIChatClient {
222    async fn get_response(
223        &self,
224        messages: Vec<Message>,
225        options: ChatOptions,
226    ) -> Result<ChatResponse> {
227        let body = self.build_body(&messages, &options, false);
228        let resp = self.post(&body).await?;
229        let value: Value = resp
230            .json()
231            .await
232            .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
233        if let Some(err) = response_failure_error(&value) {
234            return Err(err);
235        }
236        Ok(parse_response(&value, options.store))
237    }
238
239    async fn get_streaming_response(
240        &self,
241        messages: Vec<Message>,
242        options: ChatOptions,
243    ) -> Result<ChatStream> {
244        let body = self.build_body(&messages, &options, true);
245        let resp = self.post(&body).await?;
246        Ok(parse_responses_sse_stream(resp, options.store).boxed())
247    }
248
249    fn model(&self) -> Option<&str> {
250        Some(&self.inner.model)
251    }
252}
253
254// region: request conversion
255
256/// The `include` entry that asks a reasoning model to return its *encrypted*
257/// reasoning item alongside the summary.
258pub const ENCRYPTED_REASONING_INCLUDE: &str = "reasoning.encrypted_content";
259
260/// Build the Responses request's `include` array.
261///
262/// A reasoning item must be replayed verbatim on the follow-up turn of a tool
263/// loop, and the service only accepts it when it carries its `id` *and*
264/// `encrypted_content`. When the conversation is held service-side the model
265/// already has that item, but on a stateless request nothing is stored — so
266/// unless the request asks for `reasoning.encrypted_content`, the item comes
267/// back without it and the replay in [`messages_to_input`] has nothing valid
268/// to re-send. This adds it implicitly in exactly that case, mirroring
269/// upstream's `_prepare_options`.
270///
271/// A caller's own `include` entries (passed through
272/// `ChatOptions::additional_properties`) are always preserved, and an explicit
273/// `reasoning.encrypted_content` is honored even when `implicit` is `false` —
274/// `implicit` gates only whether it is added on the caller's behalf. Foundry
275/// passes `false` here: it does not want encrypted reasoning unless asked for
276/// it by name (upstream #7536).
277///
278/// Returns `None` when the array would be empty, so a request that needs no
279/// `include` does not carry an empty one.
280///
281/// `pub` for the same reason as [`extract_instructions`]: `agent-framework-azure`'s
282/// Responses client builds its own body and reuses this step rather than
283/// reimplementing it.
284pub fn responses_include(options: &ChatOptions, implicit: bool) -> Option<Value> {
285    let mut include: Vec<Value> = options
286        .additional_properties
287        .get("include")
288        .and_then(Value::as_array)
289        .cloned()
290        .unwrap_or_default();
291
292    // Upstream keys this off the service-side-storage indicators, not `store`:
293    // a request continuing a stored conversation needs nothing echoed back.
294    let uses_service_side_storage = options
295        .conversation_id
296        .as_deref()
297        .is_some_and(|id| !id.is_empty());
298
299    let already_present = include
300        .iter()
301        .any(|v| v.as_str() == Some(ENCRYPTED_REASONING_INCLUDE));
302    if implicit && !uses_service_side_storage && !already_present {
303        include.push(json!(ENCRYPTED_REASONING_INCLUDE));
304    }
305
306    (!include.is_empty()).then(|| json!(include))
307}
308
309/// Split a leading system message (and/or `ChatOptions::instructions`) out
310/// into the Responses API's top-level `instructions` field, returning the
311/// remaining messages to convert into `input` items.
312///
313/// `pub` (rather than private) so `agent-framework-azure`'s Responses client
314/// can reuse this exact instructions-extraction step ahead of
315/// [`messages_to_input`] when building the Azure OpenAI Responses request
316/// body, instead of reimplementing it.
317pub fn extract_instructions<'a>(
318    messages: &'a [Message],
319    options_instructions: Option<&str>,
320) -> (Option<String>, &'a [Message]) {
321    let mut parts = Vec::new();
322    if let Some(instr) = options_instructions {
323        if !instr.is_empty() {
324            parts.push(instr.to_string());
325        }
326    }
327    let mut rest = messages;
328    if let Some(first) = messages.first() {
329        if first.role == Role::system() {
330            let text = first.text();
331            if !text.is_empty() {
332                parts.push(text);
333            }
334            rest = &messages[1..];
335        }
336    }
337    if parts.is_empty() {
338        (None, rest)
339    } else {
340        (Some(parts.join("\n\n")), rest)
341    }
342}
343
344/// Convert framework messages into the Responses API's `input` item array.
345///
346/// `pub` so `agent-framework-azure`'s Responses client can reuse this
347/// conversion verbatim rather than reimplementing it (Azure OpenAI's
348/// Responses API shares the exact same `input` item wire shape).
349pub fn messages_to_input(messages: &[Message]) -> Vec<Value> {
350    let mut out = Vec::new();
351    for msg in messages {
352        let role = msg.role.as_str();
353        if role == Role::TOOL {
354            for content in &msg.contents {
355                if let Content::FunctionResult(fr) = content {
356                    out.push(function_result_to_item(fr));
357                }
358            }
359            continue;
360        }
361
362        let mut buffered: Vec<Value> = Vec::new();
363        for content in &msg.contents {
364            match content {
365                Content::Text(t) => {
366                    let text_type = if role == Role::ASSISTANT {
367                        "output_text"
368                    } else {
369                        "input_text"
370                    };
371                    buffered.push(json!({ "type": text_type, "text": t.text }));
372                }
373                Content::Uri(u) => {
374                    if let Some(part) = content_to_input_part(&u.uri, Some(&u.media_type)) {
375                        buffered.push(part);
376                    }
377                }
378                Content::Data(d) => {
379                    if let Some(part) =
380                        content_to_input_part(&d.uri, data_content_media_type(d).as_deref())
381                    {
382                        buffered.push(part);
383                    }
384                }
385                Content::HostedFile(h) => {
386                    buffered.push(json!({ "type": "input_file", "file_id": h.file_id }));
387                }
388                Content::FunctionCall(fc) => {
389                    flush_text(&mut out, &mut buffered, role);
390                    out.push(json!({
391                        "type": "function_call",
392                        "call_id": fc.call_id,
393                        "name": fc.name,
394                        "arguments": function_arguments_to_string(&fc.arguments),
395                    }));
396                }
397                Content::FunctionResult(fr) => {
398                    flush_text(&mut out, &mut buffered, role);
399                    out.push(function_result_to_item(fr));
400                }
401                Content::FunctionApprovalResponse(r) => {
402                    flush_text(&mut out, &mut buffered, role);
403                    out.push(json!({
404                        "type": "mcp_approval_response",
405                        "approval_request_id": r.id,
406                        "approve": r.approved,
407                    }));
408                }
409                Content::FunctionApprovalRequest(r) => {
410                    flush_text(&mut out, &mut buffered, role);
411                    out.push(json!({
412                        "type": "mcp_approval_request",
413                        "id": r.id,
414                        "name": r.function_call.name,
415                        "arguments": function_arguments_to_string(&r.function_call.arguments),
416                    }));
417                }
418                Content::TextReasoning(tr) => {
419                    // Re-emit the original reasoning item verbatim (store:false
420                    // replay). A summary-only reasoning content with no
421                    // preserved item has no valid input form (it lacks the
422                    // required id/encrypted_content), so it is dropped.
423                    if let Some(raw) = &tr.raw_representation {
424                        flush_text(&mut out, &mut buffered, role);
425                        out.push(raw.clone());
426                    }
427                }
428                _ => {}
429            }
430        }
431        flush_text(&mut out, &mut buffered, role);
432    }
433    out
434}
435
436/// Map a URI/data content item to a Responses API input content part, or `None`
437/// when it has no wire mapping (mirrors upstream `_openai_content_parser`).
438/// Handles images (`input_image`), audio (`input_audio`), and `application/*`
439/// data (`input_file`).
440fn content_to_input_part(uri: &str, media_type: Option<&str>) -> Option<Value> {
441    let media_type = media_type?;
442    match top_level_media_type(media_type).as_str() {
443        // `detail` defaults to "auto"; the Rust content types carry no override.
444        "image" => Some(json!({
445            "type": "input_image",
446            "image_url": uri,
447            "detail": "auto",
448        })),
449        "audio" => {
450            let format = audio_format(media_type)?;
451            // `input_audio.data` is the raw base64 payload, not a data URI —
452            // same wire rule as the Chat Completions converter.
453            Some(json!({
454                "type": "input_audio",
455                "input_audio": {
456                    "data": crate::convert::strip_data_uri_prefix(uri),
457                    "format": format,
458                },
459            }))
460        }
461        "application" => Some(json!({
462            "type": "input_file",
463            "file_data": uri,
464            "filename": DEFAULT_FILENAME,
465        })),
466        _ => None,
467    }
468}
469
470fn flush_text(out: &mut Vec<Value>, buffered: &mut Vec<Value>, role: &str) {
471    if !buffered.is_empty() {
472        out.push(json!({ "type": "message", "role": role, "content": std::mem::take(buffered) }));
473    }
474}
475
476fn function_result_to_item(fr: &FunctionResultContent) -> Value {
477    json!({
478        "type": "function_call_output",
479        "call_id": fr.call_id,
480        "output": result_to_string(fr),
481    })
482}
483
484/// The flat Responses-API tool spec: `{"type":"function","name":...}`, unlike
485/// Chat Completions' `{"type":"function","function":{...}}` nesting.
486///
487/// `pub` so `agent-framework-azure`'s Responses client can reuse this
488/// mapping rather than reimplementing it.
489pub fn tool_to_responses_spec(tool: &ToolDefinition) -> Value {
490    use agent_framework_core::tools::ToolKind;
491    match &tool.kind {
492        ToolKind::HostedWebSearch => {
493            let mut spec = Map::new();
494            spec.insert("type".into(), json!("web_search"));
495            if let Some(loc) = tool.parameters.get("user_location") {
496                spec.insert("user_location".into(), user_location_to_responses(loc));
497            }
498            Value::Object(spec)
499        }
500        ToolKind::HostedCodeInterpreter => {
501            let mut spec = Map::new();
502            spec.insert("type".into(), json!("code_interpreter"));
503            // A caller-supplied `container` wins; otherwise default to `auto`
504            // and attach any `file_ids` (`_responses_client.py:264-278`).
505            if let Some(container) = tool.parameters.get("container") {
506                spec.insert("container".into(), container.clone());
507            } else {
508                let mut container = Map::new();
509                container.insert("type".into(), json!("auto"));
510                if let Some(file_ids) = tool.parameters.get("file_ids") {
511                    container.insert("file_ids".into(), file_ids.clone());
512                }
513                spec.insert("container".into(), Value::Object(container));
514            }
515            Value::Object(spec)
516        }
517        ToolKind::HostedFileSearch { max_results } => {
518            let mut spec = Map::new();
519            spec.insert("type".into(), json!("file_search"));
520            // The Responses API requires vector-store ids; the marker itself
521            // carries none, so honor ids supplied via the definition's
522            // parameters object when present.
523            if let Some(ids) = tool.parameters.get("vector_store_ids") {
524                spec.insert("vector_store_ids".into(), ids.clone());
525            }
526            // Prefer the marker's `max_results`; fall back to a parameters key.
527            let max = (*max_results)
528                .map(|n| json!(n))
529                .or_else(|| tool.parameters.get("max_results").cloned());
530            if let Some(n) = max {
531                spec.insert("max_num_results".into(), n);
532            }
533            Value::Object(spec)
534        }
535        ToolKind::HostedMcp { url, allowed_tools } => {
536            let mut spec = Map::new();
537            spec.insert("type".into(), json!("mcp"));
538            spec.insert("server_label".into(), json!(tool.name.replace(' ', "_")));
539            spec.insert("server_url".into(), json!(url));
540            if !tool.description.is_empty() {
541                spec.insert("server_description".into(), json!(tool.description));
542            }
543            if let Some(headers) = tool.parameters.get("headers") {
544                spec.insert("headers".into(), headers.clone());
545            }
546            if let Some(allowed) = allowed_tools {
547                spec.insert("allowed_tools".into(), json!(allowed));
548            }
549            spec.insert("require_approval".into(), mcp_require_approval(tool));
550            Value::Object(spec)
551        }
552        ToolKind::HostedImageGeneration => {
553            let mut spec = Map::new();
554            spec.insert("type".into(), json!("image_generation"));
555            // Pass through any caller-supplied generation options (size,
556            // quality, background, …) carried on the marker's parameters.
557            if let Value::Object(params) = &tool.parameters {
558                for (k, v) in params {
559                    if k != "type" && k != "properties" {
560                        spec.insert(k.clone(), v.clone());
561                    }
562                }
563            }
564            Value::Object(spec)
565        }
566        ToolKind::Function => json!({
567            "type": "function",
568            "name": tool.name,
569            "description": tool.description,
570            "parameters": tool.parameters,
571        }),
572    }
573}
574
575/// Build the Responses `web_search.user_location` object from a hosted-tool
576/// `user_location` parameter (`_responses_client.py:310-329`).
577fn user_location_to_responses(location: &Value) -> Value {
578    let mut loc = Map::new();
579    loc.insert("type".into(), json!("approximate"));
580    for key in ["city", "country", "region", "timezone"] {
581        if let Some(v) = location.get(key) {
582            loc.insert(key.into(), v.clone());
583        }
584    }
585    Value::Object(loc)
586}
587
588/// Build the Responses MCP `require_approval` value. A `parameters.approval_mode`
589/// override — either the string `"always_require"`/`"never_require"` or an
590/// object `{"always": [...], "never": [...]}` — takes precedence over the
591/// definition's [`ApprovalMode`]; mirrors `get_mcp_tool`
592/// (`_responses_client.py:365-386`).
593fn mcp_require_approval(tool: &ToolDefinition) -> Value {
594    use agent_framework_core::tools::ApprovalMode;
595    match tool.parameters.get("approval_mode") {
596        Some(Value::String(s)) => {
597            return json!(if s == "always_require" {
598                "always"
599            } else {
600                "never"
601            });
602        }
603        Some(Value::Object(modes)) => {
604            let mut req = Map::new();
605            if let Some(always) = modes.get("always") {
606                req.insert("always".into(), json!({ "tool_names": always }));
607            }
608            if let Some(never) = modes.get("never") {
609                req.insert("never".into(), json!({ "tool_names": never }));
610            }
611            if !req.is_empty() {
612                return Value::Object(req);
613            }
614        }
615        _ => {}
616    }
617    json!(match tool.approval_mode {
618        ApprovalMode::AlwaysRequire => "always",
619        ApprovalMode::NeverRequire => "never",
620    })
621}
622
623/// `pub` so `agent-framework-azure`'s Responses client can reuse this
624/// mapping rather than reimplementing it.
625pub fn tool_choice_to_responses(mode: &ToolMode) -> Value {
626    match mode {
627        ToolMode::Auto => json!("auto"),
628        ToolMode::None => json!("none"),
629        ToolMode::Required(Some(name)) => json!({ "type": "function", "name": name }),
630        ToolMode::Required(None) => json!("required"),
631    }
632}
633
634/// Convert a `ChatOptions::response_format` into a Responses API
635/// `text.format` object. Unlike Chat Completions (which nests the schema
636/// under `json_schema`), the Responses API uses a flat object.
637///
638/// `pub` so `agent-framework-azure`'s Responses client can reuse this
639/// mapping rather than reimplementing it.
640pub fn response_format_to_text(format: &ResponseFormat) -> Value {
641    match format {
642        ResponseFormat::Text => json!({ "type": "text" }),
643        ResponseFormat::JsonObject => json!({ "type": "json_object" }),
644        ResponseFormat::JsonSchema {
645            name,
646            description,
647            schema,
648            strict,
649        } => {
650            let mut obj = Map::new();
651            obj.insert("type".into(), json!("json_schema"));
652            obj.insert("name".into(), json!(name));
653            if let Some(d) = description {
654                obj.insert("description".into(), json!(d));
655            }
656            obj.insert("schema".into(), schema.clone());
657            if let Some(st) = strict {
658                obj.insert("strict".into(), json!(st));
659            }
660            Value::Object(obj)
661        }
662    }
663}
664
665// endregion
666
667// region: response conversion
668
669/// Parse a full (non-streaming) Responses API response.
670///
671/// Map a Responses body whose `status` is `"failed"` to a classified error,
672/// or `None` for a non-failed response. A failed run reports `status:
673/// "failed"` with a 2xx HTTP status, so the error is pulled from the body:
674/// `error.code == "content_filter"` becomes [`Error::ServiceContentFilter`]
675/// (matching the HTTP-level classification in `classify_service_error`),
676/// anything else a generic service error.
677///
678/// `pub` so `agent-framework-azure`'s Responses client can share the exact
679/// classification.
680pub fn response_failure_error(value: &Value) -> Option<Error> {
681    if value.get("status").and_then(Value::as_str) != Some("failed") {
682        return None;
683    }
684    let error = value.get("error");
685    let msg = error
686        .and_then(|e| e.get("message"))
687        .and_then(Value::as_str)
688        .unwrap_or("response failed")
689        .to_string();
690    let code = error.and_then(|e| e.get("code")).and_then(Value::as_str);
691    Some(match code {
692        Some("content_filter") => Error::service_content_filter(msg),
693        _ => Error::service(msg),
694    })
695}
696
697/// `pub` so `agent-framework-azure`'s Responses client (whose wire format is
698/// otherwise identical) can reuse this parser — including `parse_output_item`,
699/// `parse_annotations`, and usage/finish-reason handling — rather than
700/// reimplementing it.
701pub fn parse_response(value: &Value, store: Option<bool>) -> ChatResponse {
702    let mut response = ChatResponse {
703        response_id: value.get("id").and_then(Value::as_str).map(String::from),
704        model: value.get("model").and_then(Value::as_str).map(String::from),
705        ..Default::default()
706    };
707
708    let mut contents: Vec<Content> = Vec::new();
709    if let Some(items) = value.get("output").and_then(Value::as_array) {
710        for item in items {
711            parse_output_item(item, &mut contents);
712        }
713    }
714
715    let mut message = Message::with_contents(Role::assistant(), contents);
716    message.message_id = response.response_id.clone();
717    response.messages.push(message);
718
719    response.finish_reason = finish_reason_from_response(value);
720
721    if let Some(usage) = value.get("usage") {
722        response.usage_details = Some(parse_responses_usage(usage));
723    }
724    if store != Some(false) {
725        response.conversation_id = response.response_id.clone();
726    }
727    response
728}
729
730fn parse_output_item(item: &Value, contents: &mut Vec<Content>) {
731    match item.get("type").and_then(Value::as_str) {
732        Some("message") => {
733            if let Some(parts) = item.get("content").and_then(Value::as_array) {
734                for part in parts {
735                    match part.get("type").and_then(Value::as_str) {
736                        Some("output_text") => {
737                            if let Some(text) = part.get("text").and_then(Value::as_str) {
738                                let mut tc = TextContent::new(text);
739                                tc.annotations = parse_annotations(part);
740                                contents.push(Content::Text(tc));
741                            }
742                        }
743                        Some("refusal") => {
744                            if let Some(text) = part.get("refusal").and_then(Value::as_str) {
745                                contents.push(Content::Text(TextContent::new(text)));
746                            }
747                        }
748                        _ => {}
749                    }
750                }
751            }
752        }
753        Some("function_call") => {
754            let call_id = item
755                .get("call_id")
756                .and_then(Value::as_str)
757                .unwrap_or_default()
758                .to_string();
759            let name = item
760                .get("name")
761                .and_then(Value::as_str)
762                .unwrap_or_default()
763                .to_string();
764            let args = item
765                .get("arguments")
766                .and_then(Value::as_str)
767                .unwrap_or("{}")
768                .to_string();
769            contents.push(Content::FunctionCall(FunctionCallContent::new(
770                call_id,
771                name,
772                Some(FunctionArguments::Raw(args)),
773            )));
774        }
775        Some("reasoning") => {
776            let summaries: Vec<String> = item
777                .get("summary")
778                .and_then(Value::as_array)
779                .map(|arr| {
780                    arr.iter()
781                        .filter_map(|s| s.get("text").and_then(Value::as_str))
782                        .map(str::to_string)
783                        .collect()
784                })
785                .unwrap_or_default();
786            // Preserve the raw reasoning item so a store:false tool-loop replay
787            // can re-send it verbatim — reasoning models require the original
788            // item (id + encrypted_content), not just the summary, on the
789            // follow-up turn. `messages_to_input` re-emits it from
790            // `raw_representation`; it is attached to exactly one content so a
791            // multi-summary item yields exactly one reasoning input item.
792            let raw = Some(item.clone());
793            if summaries.is_empty() {
794                // No summary (e.g. encrypted reasoning) — still carry the item.
795                contents.push(Content::TextReasoning(TextReasoningContent {
796                    text: String::new(),
797                    annotations: None,
798                    raw_representation: raw,
799                    protected_data: None,
800                }));
801            } else {
802                let n = summaries.len();
803                for (i, text) in summaries.into_iter().enumerate() {
804                    contents.push(Content::TextReasoning(TextReasoningContent {
805                        text,
806                        annotations: None,
807                        raw_representation: (i == n - 1).then(|| raw.clone()).flatten(),
808                        protected_data: None,
809                    }));
810                }
811            }
812        }
813        // Code-interpreter runs surface `logs` as text and `image` outputs as
814        // URIs; a bare `code` (no outputs) is a text fallback
815        // (`_create_response_content:748-764`).
816        Some("code_interpreter_call") => {
817            let outputs = item
818                .get("outputs")
819                .and_then(Value::as_array)
820                .filter(|a| !a.is_empty());
821            if let Some(outputs) = outputs {
822                for output in outputs {
823                    match output.get("type").and_then(Value::as_str) {
824                        Some("logs") => {
825                            if let Some(logs) = output.get("logs").and_then(Value::as_str) {
826                                contents.push(Content::Text(TextContent::new(logs)));
827                            }
828                        }
829                        Some("image") => {
830                            if let Some(url) = output.get("url").and_then(Value::as_str) {
831                                contents.push(Content::Uri(UriContent {
832                                    uri: url.to_string(),
833                                    media_type: "image".to_string(),
834                                }));
835                            }
836                        }
837                        _ => {}
838                    }
839                }
840            } else if let Some(code) = item.get("code").and_then(Value::as_str) {
841                contents.push(Content::Text(TextContent::new(code)));
842            }
843        }
844        // A generated image is returned as base64; default to image/png unless
845        // the result is a data URI that states its own type
846        // (`_create_response_content:788-811`).
847        Some("image_generation_call") => {
848            if let Some(result) = item.get("result").and_then(Value::as_str) {
849                let (uri, media_type) = if result.starts_with("data:") {
850                    let media_type = if result.contains(';') {
851                        result
852                            .strip_prefix("data:")
853                            .and_then(|r| r.split(';').next())
854                            .filter(|s| !s.is_empty())
855                            .map(String::from)
856                    } else {
857                        None
858                    };
859                    (result.to_string(), media_type)
860                } else {
861                    (
862                        format!("data:image/png;base64,{result}"),
863                        Some("image/png".to_string()),
864                    )
865                };
866                contents.push(Content::Data(DataContent { uri, media_type }));
867            }
868        }
869        // An MCP approval request round-trips its `id` as the call id so a
870        // later `FunctionApprovalResponse` refers back to it
871        // (`_create_response_content:775-787`).
872        Some("mcp_approval_request") => {
873            let id = item
874                .get("id")
875                .and_then(Value::as_str)
876                .unwrap_or_default()
877                .to_string();
878            let name = item
879                .get("name")
880                .and_then(Value::as_str)
881                .unwrap_or_default()
882                .to_string();
883            let args = item
884                .get("arguments")
885                .and_then(Value::as_str)
886                .unwrap_or("{}")
887                .to_string();
888            contents.push(Content::FunctionApprovalRequest(
889                FunctionApprovalRequestContent {
890                    id: id.clone(),
891                    function_call: FunctionCallContent::new(
892                        id,
893                        name,
894                        Some(FunctionArguments::Raw(args)),
895                    ),
896                },
897            ));
898        }
899        _ => {}
900    }
901}
902
903/// Parse the `annotations` on an `output_text` part into [`Annotation`]s
904/// (`_create_response_content:667-724`). The core annotation type has no free
905/// `additional_properties`, so upstream's `index`/`container_id` extras are
906/// dropped.
907fn parse_annotations(part: &Value) -> Option<Vec<Annotation>> {
908    let arr = part.get("annotations").and_then(Value::as_array)?;
909    let mut out = Vec::new();
910    for ann in arr {
911        let str_field = |k: &str| ann.get(k).and_then(Value::as_str).map(String::from);
912        let regions = || {
913            Some(vec![TextSpanRegion {
914                start_index: ann.get("start_index").and_then(Value::as_i64),
915                end_index: ann.get("end_index").and_then(Value::as_i64),
916            }])
917        };
918        match ann.get("type").and_then(Value::as_str) {
919            Some("file_path") => out.push(Annotation {
920                file_id: str_field("file_id"),
921                ..Default::default()
922            }),
923            Some("file_citation") => out.push(Annotation {
924                url: str_field("filename"),
925                file_id: str_field("file_id"),
926                ..Default::default()
927            }),
928            Some("url_citation") => out.push(Annotation {
929                title: str_field("title"),
930                url: str_field("url"),
931                annotated_regions: regions(),
932                ..Default::default()
933            }),
934            Some("container_file_citation") => out.push(Annotation {
935                file_id: str_field("file_id"),
936                url: str_field("filename"),
937                annotated_regions: regions(),
938                ..Default::default()
939            }),
940            _ => {}
941        }
942    }
943    if out.is_empty() {
944        None
945    } else {
946        Some(out)
947    }
948}
949
950fn finish_reason_from_response(value: &Value) -> Option<FinishReason> {
951    let has_function_call = value
952        .get("output")
953        .and_then(Value::as_array)
954        .map(|items| {
955            items
956                .iter()
957                .any(|i| i.get("type").and_then(Value::as_str) == Some("function_call"))
958        })
959        .unwrap_or(false);
960    if has_function_call {
961        return Some(FinishReason::tool_calls());
962    }
963    let status = value.get("status").and_then(Value::as_str)?;
964    Some(match status {
965        "completed" => FinishReason::stop(),
966        "incomplete" => match value
967            .get("incomplete_details")
968            .and_then(|d| d.get("reason"))
969            .and_then(Value::as_str)
970        {
971            Some("max_output_tokens") => FinishReason::new(FinishReason::LENGTH),
972            Some("content_filter") => FinishReason::new(FinishReason::CONTENT_FILTER),
973            Some(other) => FinishReason::new(other),
974            None => FinishReason::new("incomplete"),
975        },
976        other => FinishReason::new(other),
977    })
978}
979
980fn parse_responses_usage(usage: &Value) -> UsageDetails {
981    let mut details = UsageDetails {
982        input_token_count: usage.get("input_tokens").and_then(Value::as_u64),
983        output_token_count: usage.get("output_tokens").and_then(Value::as_u64),
984        total_token_count: usage.get("total_tokens").and_then(Value::as_u64),
985        ..Default::default()
986    };
987    if let Some(cached) = usage
988        .get("input_tokens_details")
989        .and_then(|d| d.get("cached_tokens"))
990        .and_then(Value::as_u64)
991    {
992        details
993            .additional_counts
994            .insert("openai.cached_input_tokens".into(), cached);
995        // Mirror upstream: also surface as the typed, cross-language field.
996        details.cache_read_input_token_count = Some(cached);
997    }
998    // Cache *writes* are reported separately from cache reads. Absent when the
999    // provider does not report them (older API versions).
1000    if let Some(cache_write) = usage
1001        .get("input_tokens_details")
1002        .and_then(|d| d.get("cache_write_tokens"))
1003        .and_then(Value::as_u64)
1004    {
1005        details
1006            .additional_counts
1007            .insert("openai.cache_write_tokens".into(), cache_write);
1008        details.cache_creation_input_token_count = Some(cache_write);
1009    }
1010    if let Some(reasoning) = usage
1011        .get("output_tokens_details")
1012        .and_then(|d| d.get("reasoning_tokens"))
1013        .and_then(Value::as_u64)
1014    {
1015        details
1016            .additional_counts
1017            .insert("openai.reasoning_tokens".into(), reasoning);
1018        details.reasoning_output_token_count = Some(reasoning);
1019    }
1020    details
1021}
1022
1023// endregion
1024
1025// region: streaming
1026
1027/// Turn a Responses API SSE HTTP response into a stream of updates.
1028///
1029/// `pub` so `agent-framework-azure`'s Responses client can reuse this exact
1030/// SSE event parser (Azure OpenAI's Responses API streams the same event
1031/// shapes) rather than reimplementing it.
1032pub fn parse_responses_sse_stream(
1033    resp: reqwest::Response,
1034    store: Option<bool>,
1035) -> impl futures::Stream<Item = Result<ChatResponseUpdate>> + Send {
1036    let byte_stream: ByteStream = Box::pin(resp.bytes_stream());
1037    futures::stream::unfold(
1038        ResponsesSseState {
1039            byte_stream,
1040            buffer: String::new(),
1041            utf8: Utf8StreamDecoder::new(),
1042            queued: VecDeque::new(),
1043            call_ids: HashMap::new(),
1044            done: false,
1045            store,
1046        },
1047        |mut state| async move {
1048            loop {
1049                if let Some(update) = state.queued.pop_front() {
1050                    return Some((Ok(update), state));
1051                }
1052                if state.done {
1053                    return None;
1054                }
1055                match state.byte_stream.next().await {
1056                    Some(Ok(bytes)) => {
1057                        let decoded = state.utf8.push(&bytes);
1058                        state.buffer.push_str(&decoded);
1059                        while let Some(pos) = state.buffer.find('\n') {
1060                            let line = state.buffer[..pos].trim().to_string();
1061                            state.buffer.drain(..=pos);
1062                            let Some(data) = line.strip_prefix("data:") else {
1063                                continue;
1064                            };
1065                            let data = data.trim();
1066                            if data.is_empty() {
1067                                continue;
1068                            }
1069                            let Ok(value) = serde_json::from_str::<Value>(data) else {
1070                                continue;
1071                            };
1072                            match parse_responses_event(&value, &mut state.call_ids, state.store) {
1073                                EventOutcome::Update(update) => state.queued.push_back(update),
1074                                EventOutcome::Error(e) => {
1075                                    state.done = true;
1076                                    return Some((Err(e), state));
1077                                }
1078                                EventOutcome::None => {}
1079                            }
1080                        }
1081                    }
1082                    Some(Err(e)) => {
1083                        state.done = true;
1084                        return Some((Err(Error::service(format!("stream error: {e}"))), state));
1085                    }
1086                    None => return None,
1087                }
1088            }
1089        },
1090    )
1091}
1092
1093struct ResponsesSseState {
1094    byte_stream: ByteStream,
1095    buffer: String,
1096    utf8: Utf8StreamDecoder,
1097    queued: VecDeque<ChatResponseUpdate>,
1098    /// `output_index` -> `call_id`, resolved when the call is first announced
1099    /// via `response.output_item.added` and used to route later
1100    /// `response.function_call_arguments.delta` fragments.
1101    call_ids: HashMap<i64, String>,
1102    done: bool,
1103    store: Option<bool>,
1104}
1105
1106// A transient control-flow value: produced per SSE event and immediately
1107// destructured in the stream loop, never stored in bulk. Boxing the `Update`
1108// variant to equalize sizes would add a heap allocation on every streamed
1109// token, so the size skew is accepted here.
1110#[allow(clippy::large_enum_variant)]
1111enum EventOutcome {
1112    Update(ChatResponseUpdate),
1113    Error(Error),
1114    None,
1115}
1116
1117/// Wrap streamed reasoning text as a [`TextReasoningContent`] update, or
1118/// [`EventOutcome::None`] when empty.
1119fn reasoning_update(text: &str) -> EventOutcome {
1120    if text.is_empty() {
1121        return EventOutcome::None;
1122    }
1123    EventOutcome::Update(ChatResponseUpdate {
1124        contents: vec![Content::TextReasoning(TextReasoningContent {
1125            text: text.to_string(),
1126            annotations: None,
1127            ..Default::default()
1128        })],
1129        role: Some(Role::assistant()),
1130        ..Default::default()
1131    })
1132}
1133
1134/// Parse one Responses API SSE event (already-decoded JSON `data:` payload).
1135fn parse_responses_event(
1136    value: &Value,
1137    call_ids: &mut HashMap<i64, String>,
1138    store: Option<bool>,
1139) -> EventOutcome {
1140    let event_type = value.get("type").and_then(Value::as_str).unwrap_or("");
1141    match event_type {
1142        "response.created" => {
1143            let resp = value.get("response");
1144            let response_id = resp
1145                .and_then(|r| r.get("id"))
1146                .and_then(Value::as_str)
1147                .map(String::from);
1148            let model = resp
1149                .and_then(|r| r.get("model"))
1150                .and_then(Value::as_str)
1151                .map(String::from);
1152            if response_id.is_none() && model.is_none() {
1153                return EventOutcome::None;
1154            }
1155            EventOutcome::Update(ChatResponseUpdate {
1156                role: Some(Role::assistant()),
1157                response_id,
1158                model,
1159                ..Default::default()
1160            })
1161        }
1162        "response.output_text.delta" => {
1163            let text = value.get("delta").and_then(Value::as_str).unwrap_or("");
1164            if text.is_empty() {
1165                return EventOutcome::None;
1166            }
1167            EventOutcome::Update(ChatResponseUpdate {
1168                contents: vec![Content::Text(TextContent::new(text))],
1169                role: Some(Role::assistant()),
1170                ..Default::default()
1171            })
1172        }
1173        // Reasoning (chain-of-thought / summary) streams as its own text
1174        // channel. Both the incremental `.delta` and the terminal `.done`
1175        // (full text) map to `TextReasoningContent`, mirroring upstream
1176        // `_create_streaming_response_content` (`_responses_client.py:917-928`).
1177        "response.reasoning_text.delta" | "response.reasoning_summary_text.delta" => {
1178            reasoning_update(value.get("delta").and_then(Value::as_str).unwrap_or(""))
1179        }
1180        // `.done` carries the *full* completed text, not another increment —
1181        // the deltas above already streamed it, so emitting it again would
1182        // duplicate the reasoning in the aggregated response (adjacent
1183        // reasoning contents coalesce by appending). Treated as terminal
1184        // metadata, like `response.output_text.done`.
1185        "response.reasoning_text.done" | "response.reasoning_summary_text.done" => {
1186            EventOutcome::None
1187        }
1188        "response.output_item.added" => {
1189            let item = value.get("item");
1190            if item.and_then(|i| i.get("type")).and_then(Value::as_str) != Some("function_call") {
1191                return EventOutcome::None;
1192            }
1193            let output_index = value
1194                .get("output_index")
1195                .and_then(Value::as_i64)
1196                .unwrap_or(0);
1197            let call_id = item
1198                .and_then(|i| i.get("call_id"))
1199                .and_then(Value::as_str)
1200                .unwrap_or_default()
1201                .to_string();
1202            let name = item
1203                .and_then(|i| i.get("name"))
1204                .and_then(Value::as_str)
1205                .unwrap_or_default()
1206                .to_string();
1207            call_ids.insert(output_index, call_id.clone());
1208            EventOutcome::Update(ChatResponseUpdate {
1209                contents: vec![Content::FunctionCall(FunctionCallContent::new(
1210                    call_id, name, None,
1211                ))],
1212                role: Some(Role::assistant()),
1213                ..Default::default()
1214            })
1215        }
1216        "response.function_call_arguments.delta" => {
1217            let output_index = value
1218                .get("output_index")
1219                .and_then(Value::as_i64)
1220                .unwrap_or(0);
1221            let delta = value.get("delta").and_then(Value::as_str).unwrap_or("");
1222            match call_ids.get(&output_index) {
1223                Some(call_id) => EventOutcome::Update(ChatResponseUpdate {
1224                    contents: vec![Content::FunctionCall(FunctionCallContent::new(
1225                        call_id.clone(),
1226                        "",
1227                        Some(FunctionArguments::Raw(delta.to_string())),
1228                    ))],
1229                    role: Some(Role::assistant()),
1230                    ..Default::default()
1231                }),
1232                None => EventOutcome::None,
1233            }
1234        }
1235        "response.completed" => {
1236            let resp = value.get("response");
1237            let response_id = resp
1238                .and_then(|r| r.get("id"))
1239                .and_then(Value::as_str)
1240                .map(String::from);
1241            let model = resp
1242                .and_then(|r| r.get("model"))
1243                .and_then(Value::as_str)
1244                .map(String::from);
1245            let mut contents = Vec::new();
1246            if let Some(usage) = resp.and_then(|r| r.get("usage")) {
1247                contents.push(Content::Usage(UsageContent {
1248                    details: parse_responses_usage(usage),
1249                }));
1250            }
1251            let finish_reason = resp.and_then(finish_reason_from_response);
1252            let conversation_id = if store != Some(false) {
1253                response_id.clone()
1254            } else {
1255                None
1256            };
1257            EventOutcome::Update(ChatResponseUpdate {
1258                contents,
1259                role: Some(Role::assistant()),
1260                response_id,
1261                model,
1262                conversation_id,
1263                finish_reason,
1264                ..Default::default()
1265            })
1266        }
1267        "response.failed" | "error" => {
1268            let resp = value.get("response");
1269            let err_obj = resp
1270                .and_then(|r| r.get("error"))
1271                .or_else(|| value.get("error"));
1272            let msg = err_obj
1273                .and_then(|e| e.get("message"))
1274                .and_then(Value::as_str)
1275                .unwrap_or("response failed")
1276                .to_string();
1277            // Same classification as the non-streaming `response_failure_error`
1278            // so callers branching on content filters see one behavior for
1279            // streamed and non-streamed Responses.
1280            let code = err_obj.and_then(|e| e.get("code")).and_then(Value::as_str);
1281            EventOutcome::Error(match code {
1282                Some("content_filter") => Error::service_content_filter(msg),
1283                _ => Error::service(msg),
1284            })
1285        }
1286        // Recognized but carry no additional content: the arguments are
1287        // already fully accumulated via `.delta` events, and item/part
1288        // lifecycle markers don't themselves map to a `Content`.
1289        "response.function_call_arguments.done"
1290        | "response.output_item.done"
1291        | "response.content_part.added"
1292        | "response.content_part.done"
1293        | "response.in_progress" => EventOutcome::None,
1294        _ => EventOutcome::None,
1295    }
1296}
1297
1298// endregion
1299
1300#[cfg(test)]
1301mod tests {
1302    use super::*;
1303    use agent_framework_core::tools::{ApprovalMode, ToolDefinition, ToolKind};
1304    use agent_framework_core::types::{
1305        FunctionApprovalResponseContent, FunctionResultContent, HostedFileContent,
1306    };
1307
1308    fn user(text: &str) -> Message {
1309        Message::user(text)
1310    }
1311
1312    fn user_with(contents: Vec<Content>) -> Message {
1313        Message::with_contents(Role::user(), contents)
1314    }
1315
1316    /// Parse a single Responses output item into its content list.
1317    fn parse_item(item: Value) -> Vec<Content> {
1318        let mut contents = Vec::new();
1319        parse_output_item(&item, &mut contents);
1320        contents
1321    }
1322
1323    fn client() -> OpenAIChatClient {
1324        OpenAIChatClient::new("test-key", "gpt-4o-mini")
1325    }
1326
1327    // region: request body building
1328
1329    #[test]
1330    fn build_body_simple_text() {
1331        let c = client();
1332        let body = c.build_body(&[user("Hello there")], &ChatOptions::new(), false);
1333        assert_eq!(
1334            body,
1335            json!({
1336                "model": "gpt-4o-mini",
1337                "input": [
1338                    { "type": "message", "role": "user", "content": [
1339                        { "type": "input_text", "text": "Hello there" }
1340                    ]}
1341                ],
1342                // Nothing is stored service-side on this request, so the
1343                // encrypted reasoning item is requested for the replay path.
1344                "include": ["reasoning.encrypted_content"],
1345            })
1346        );
1347    }
1348
1349    #[test]
1350    fn responses_usage_parses_cache_write_tokens() {
1351        // Cache *writes* are reported separately from cache reads (upstream #7369).
1352        let d = parse_responses_usage(&json!({
1353            "input_tokens": 2000,
1354            "output_tokens": 60,
1355            "total_tokens": 2060,
1356            "input_tokens_details": { "cached_tokens": 0, "cache_write_tokens": 1024 },
1357        }));
1358        assert_eq!(d.cache_creation_input_token_count, Some(1024));
1359        assert_eq!(d.cache_read_input_token_count, Some(0));
1360        assert_eq!(
1361            d.additional_counts.get("openai.cache_write_tokens"),
1362            Some(&1024)
1363        );
1364    }
1365
1366    #[test]
1367    fn responses_usage_omits_cache_write_tokens_when_not_reported() {
1368        let d = parse_responses_usage(&json!({
1369            "input_tokens": 100,
1370            "output_tokens": 20,
1371            "input_tokens_details": { "cached_tokens": 40 },
1372        }));
1373        assert_eq!(d.cache_creation_input_token_count, None);
1374        assert!(!d
1375            .additional_counts
1376            .contains_key("openai.cache_write_tokens"));
1377    }
1378
1379    #[test]
1380    fn build_body_extracts_leading_system_message_as_instructions() {
1381        let c = client();
1382        let messages = vec![Message::system("Be terse."), user("Hi")];
1383        let body = c.build_body(&messages, &ChatOptions::new(), false);
1384        assert_eq!(body["instructions"], json!("Be terse."));
1385        assert_eq!(
1386            body["input"],
1387            json!([
1388                { "type": "message", "role": "user", "content": [
1389                    { "type": "input_text", "text": "Hi" }
1390                ]}
1391            ])
1392        );
1393    }
1394
1395    #[test]
1396    fn build_body_combines_options_instructions_and_system_message() {
1397        let c = client();
1398        let messages = vec![Message::system("Also be nice."), user("Hi")];
1399        let options = ChatOptions::new().with_instructions("Be terse.");
1400        let body = c.build_body(&messages, &options, false);
1401        assert_eq!(body["instructions"], json!("Be terse.\n\nAlso be nice."));
1402    }
1403
1404    #[test]
1405    fn build_body_assistant_text_uses_output_text_type() {
1406        let c = client();
1407        let messages = vec![user("Hi"), Message::assistant("Hello!")];
1408        let body = c.build_body(&messages, &ChatOptions::new(), false);
1409        assert_eq!(
1410            body["input"][1],
1411            json!({ "type": "message", "role": "assistant", "content": [
1412                { "type": "output_text", "text": "Hello!" }
1413            ]})
1414        );
1415    }
1416
1417    #[test]
1418    fn build_body_function_call_round_trip() {
1419        let c = client();
1420        let call = FunctionCallContent::new(
1421            "call_1",
1422            "get_weather",
1423            Some(FunctionArguments::Raw(r#"{"city":"Paris"}"#.to_string())),
1424        );
1425        let assistant_msg =
1426            Message::with_contents(Role::assistant(), vec![Content::FunctionCall(call)]);
1427        let tool_msg = Message::with_contents(
1428            Role::tool(),
1429            vec![Content::FunctionResult(FunctionResultContent::new(
1430                "call_1",
1431                Some(json!("18C and sunny")),
1432            ))],
1433        );
1434        let body = c.build_body(
1435            &[user("weather?"), assistant_msg, tool_msg],
1436            &ChatOptions::new(),
1437            false,
1438        );
1439        assert_eq!(
1440            body["input"],
1441            json!([
1442                { "type": "message", "role": "user", "content": [
1443                    { "type": "input_text", "text": "weather?" }
1444                ]},
1445                { "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" },
1446                { "type": "function_call_output", "call_id": "call_1", "output": "18C and sunny" },
1447            ])
1448        );
1449    }
1450
1451    #[test]
1452    fn build_body_tools_are_flat_not_nested() {
1453        let c = client();
1454        let tool = ToolDefinition {
1455            name: "get_weather".into(),
1456            description: "Get the weather".into(),
1457            parameters: json!({ "type": "object", "properties": {} }),
1458            kind: ToolKind::Function,
1459            approval_mode: ApprovalMode::NeverRequire,
1460            executor: None,
1461        };
1462        let options = ChatOptions::new().with_tool(tool);
1463        let body = c.build_body(&[user("hi")], &options, false);
1464        assert_eq!(
1465            body["tools"],
1466            json!([{
1467                "type": "function",
1468                "name": "get_weather",
1469                "description": "Get the weather",
1470                "parameters": { "type": "object", "properties": {} },
1471            }])
1472        );
1473    }
1474
1475    #[test]
1476    fn build_body_tool_choice_required_named() {
1477        let c = client();
1478        let options =
1479            ChatOptions::new().with_tool_choice(ToolMode::Required(Some("get_weather".into())));
1480        let body = c.build_body(&[user("hi")], &options, false);
1481        assert_eq!(
1482            body["tool_choice"],
1483            json!({ "type": "function", "name": "get_weather" })
1484        );
1485    }
1486
1487    #[test]
1488    fn stateless_request_asks_for_the_encrypted_reasoning_item() {
1489        // A reasoning item is only replayable on the next turn if it carries
1490        // `encrypted_content`, and the service only returns that when asked.
1491        let c = client();
1492        let body = c.build_body(&[user("hi")], &ChatOptions::new(), false);
1493        assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
1494    }
1495
1496    #[test]
1497    fn a_service_side_conversation_needs_no_encrypted_reasoning() {
1498        // The service already holds the reasoning item, so echoing it back is
1499        // pointless — and upstream keys this off the storage indicators, not
1500        // off `store`.
1501        let mut options = ChatOptions::new();
1502        options.conversation_id = Some("resp_abc123".into());
1503        let body = client().build_body(&[user("hi")], &options, false);
1504        assert!(
1505            body.get("include").is_none(),
1506            "no include expected, got: {}",
1507            body
1508        );
1509    }
1510
1511    #[test]
1512    fn an_empty_conversation_id_is_not_service_side_storage() {
1513        // An empty id is no id: it would not continue anything server-side, so
1514        // the request is still stateless and still needs the item echoed back.
1515        let mut options = ChatOptions::new();
1516        options.conversation_id = Some(String::new());
1517        let body = client().build_body(&[user("hi")], &options, false);
1518        assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
1519    }
1520
1521    #[test]
1522    fn a_callers_own_include_entries_survive_and_are_not_duplicated() {
1523        // The caller's entries are preserved alongside the implicit one...
1524        let mut options = ChatOptions::new();
1525        options
1526            .additional_properties
1527            .insert("include".into(), json!(["file_search_call.results"]));
1528        let body = client().build_body(&[user("hi")], &options, false);
1529        assert_eq!(
1530            body["include"],
1531            json!(["file_search_call.results", "reasoning.encrypted_content"])
1532        );
1533
1534        // ...and asking for the encrypted item by name does not get it twice.
1535        let mut options = ChatOptions::new();
1536        options
1537            .additional_properties
1538            .insert("include".into(), json!(["reasoning.encrypted_content"]));
1539        let body = client().build_body(&[user("hi")], &options, false);
1540        assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
1541    }
1542
1543    #[test]
1544    fn an_explicit_empty_include_is_omitted_not_sent_as_an_empty_array() {
1545        // `responses_include` drops an empty array, and the
1546        // `additional_properties` pass must not put it back: `include` is
1547        // that function's alone. Reached here via a stored conversation, which
1548        // suppresses the implicit entry that would otherwise fill the array.
1549        let mut options = ChatOptions::new();
1550        options.conversation_id = Some("resp_abc123".into());
1551        options
1552            .additional_properties
1553            .insert("include".into(), json!([]));
1554        let body = client().build_body(&[user("hi")], &options, false);
1555        assert!(
1556            body.get("include").is_none(),
1557            "an empty include should be omitted entirely, got: {}",
1558            body
1559        );
1560    }
1561
1562    #[test]
1563    fn other_additional_properties_still_pass_through() {
1564        // Only `include` is intercepted; everything else the caller sets still
1565        // reaches the body.
1566        let mut options = ChatOptions::new();
1567        options
1568            .additional_properties
1569            .insert("safety_identifier".into(), json!("user-123"));
1570        let body = client().build_body(&[user("hi")], &options, false);
1571        assert_eq!(body["safety_identifier"], json!("user-123"));
1572    }
1573
1574    #[test]
1575    fn build_body_conversation_id_becomes_previous_response_id() {
1576        let c = client();
1577        let mut options = ChatOptions::new();
1578        options.conversation_id = Some("resp_abc123".into());
1579        let body = c.build_body(&[user("hi")], &options, false);
1580        assert_eq!(body["previous_response_id"], json!("resp_abc123"));
1581    }
1582
1583    #[test]
1584    fn build_body_max_tokens_becomes_max_output_tokens() {
1585        let c = client();
1586        let options = ChatOptions::new().with_max_tokens(256);
1587        let body = c.build_body(&[user("hi")], &options, false);
1588        assert_eq!(body["max_output_tokens"], json!(256));
1589        assert!(body.get("max_tokens").is_none());
1590    }
1591
1592    #[test]
1593    fn build_body_response_format_becomes_text_format() {
1594        let c = client();
1595        let mut options = ChatOptions::new();
1596        options.response_format = Some(ResponseFormat::JsonSchema {
1597            name: "answer".into(),
1598            description: None,
1599            schema: json!({"type": "object"}),
1600            strict: Some(true),
1601        });
1602        let body = c.build_body(&[user("hi")], &options, false);
1603        assert_eq!(
1604            body["text"]["format"],
1605            json!({ "type": "json_schema", "name": "answer", "schema": {"type": "object"}, "strict": true })
1606        );
1607    }
1608
1609    #[test]
1610    fn build_body_stream_flag() {
1611        let c = client();
1612        let body = c.build_body(&[user("hi")], &ChatOptions::new(), true);
1613        assert_eq!(body["stream"], json!(true));
1614    }
1615
1616    // endregion
1617
1618    // region: response parsing
1619
1620    #[test]
1621    fn parse_response_text_and_usage() {
1622        let value = json!({
1623            "id": "resp_123",
1624            "model": "gpt-4o-mini",
1625            "status": "completed",
1626            "output": [
1627                { "type": "message", "role": "assistant", "content": [
1628                    { "type": "output_text", "text": "Hello!" }
1629                ]}
1630            ],
1631            "usage": { "input_tokens": 10, "output_tokens": 5, "total_tokens": 15 },
1632        });
1633        let resp = parse_response(&value, None);
1634        assert_eq!(resp.response_id.as_deref(), Some("resp_123"));
1635        assert_eq!(resp.conversation_id.as_deref(), Some("resp_123"));
1636        assert_eq!(resp.text(), "Hello!");
1637        assert_eq!(resp.finish_reason, Some(FinishReason::stop()));
1638        let usage = resp.usage_details.unwrap();
1639        assert_eq!(usage.input_token_count, Some(10));
1640        assert_eq!(usage.output_token_count, Some(5));
1641        assert_eq!(usage.total_token_count, Some(15));
1642    }
1643
1644    #[test]
1645    fn parse_response_store_false_omits_conversation_id() {
1646        let value = json!({
1647            "id": "resp_123",
1648            "status": "completed",
1649            "output": [],
1650        });
1651        let resp = parse_response(&value, Some(false));
1652        assert_eq!(resp.conversation_id, None);
1653    }
1654
1655    #[test]
1656    fn parse_response_function_call_sets_tool_calls_finish_reason() {
1657        let value = json!({
1658            "id": "resp_123",
1659            "status": "completed",
1660            "output": [
1661                { "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" }
1662            ],
1663        });
1664        let resp = parse_response(&value, None);
1665        assert_eq!(resp.finish_reason, Some(FinishReason::tool_calls()));
1666        let calls = resp.function_calls();
1667        assert_eq!(calls.len(), 1);
1668        assert_eq!(calls[0].call_id, "call_1");
1669        assert_eq!(calls[0].name, "get_weather");
1670    }
1671
1672    #[test]
1673    fn parse_response_incomplete_max_output_tokens_is_length() {
1674        let value = json!({
1675            "id": "resp_123",
1676            "status": "incomplete",
1677            "incomplete_details": { "reason": "max_output_tokens" },
1678            "output": [],
1679        });
1680        let resp = parse_response(&value, None);
1681        assert_eq!(
1682            resp.finish_reason,
1683            Some(FinishReason::new(FinishReason::LENGTH))
1684        );
1685    }
1686
1687    #[test]
1688    fn parse_response_reasoning_becomes_text_reasoning() {
1689        let value = json!({
1690            "id": "resp_123",
1691            "status": "completed",
1692            "output": [
1693                { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "thinking..." }] },
1694                { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "done" }] },
1695            ],
1696        });
1697        let resp = parse_response(&value, None);
1698        let contents = &resp.messages[0].contents;
1699        assert!(matches!(&contents[0], Content::TextReasoning(t) if t.text == "thinking..."));
1700        assert!(matches!(&contents[1], Content::Text(t) if t.text == "done"));
1701    }
1702
1703    #[test]
1704    fn reasoning_item_round_trips_through_input_for_store_false_replay() {
1705        // A store:false tool-loop replay must re-send the original reasoning
1706        // item (id + encrypted_content), not just its summary text.
1707        let reasoning_item = json!({
1708            "type": "reasoning",
1709            "id": "rs_abc",
1710            "encrypted_content": "ENC",
1711            "summary": [{ "type": "summary_text", "text": "thinking..." }],
1712        });
1713        let value = json!({
1714            "id": "resp_1",
1715            "status": "completed",
1716            "output": [
1717                reasoning_item,
1718                { "type": "function_call", "call_id": "c1", "name": "f", "arguments": "{}" },
1719            ],
1720        });
1721        let resp = parse_response(&value, Some(false));
1722        // The parsed reasoning content preserves the raw item.
1723        let reasoning = resp.messages[0]
1724            .contents
1725            .iter()
1726            .find_map(|c| match c {
1727                Content::TextReasoning(t) => Some(t),
1728                _ => None,
1729            })
1730            .expect("reasoning content");
1731        assert_eq!(
1732            reasoning.raw_representation.as_ref().unwrap()["id"],
1733            "rs_abc"
1734        );
1735
1736        // Replaying that assistant message back through the input mapper
1737        // re-emits the reasoning item verbatim, ahead of the function call.
1738        let input = messages_to_input(&resp.messages);
1739        let reasoning_pos = input
1740            .iter()
1741            .position(|i| i.get("type") == Some(&json!("reasoning")))
1742            .expect("reasoning item re-emitted");
1743        assert_eq!(input[reasoning_pos]["id"], "rs_abc");
1744        assert_eq!(input[reasoning_pos]["encrypted_content"], "ENC");
1745        let call_pos = input
1746            .iter()
1747            .position(|i| i.get("type") == Some(&json!("function_call")))
1748            .expect("function call present");
1749        assert!(reasoning_pos < call_pos, "reasoning must precede the call");
1750    }
1751
1752    #[test]
1753    fn summary_only_reasoning_is_not_re_emitted_as_input() {
1754        // A reasoning content with no preserved raw item (e.g. from streaming
1755        // display) has no valid input form and must be dropped, not sent as a
1756        // bogus reasoning item lacking id/encrypted_content.
1757        let msg = Message::with_contents(
1758            Role::assistant(),
1759            vec![Content::TextReasoning(TextReasoningContent {
1760                text: "just display".into(),
1761                annotations: None,
1762                raw_representation: None,
1763                protected_data: None,
1764            })],
1765        );
1766        let input = messages_to_input(&[msg]);
1767        assert!(input
1768            .iter()
1769            .all(|i| i.get("type") != Some(&json!("reasoning"))));
1770    }
1771
1772    // endregion
1773
1774    // region: streaming
1775
1776    fn sse_body(events: &[(&str, Value)]) -> String {
1777        let mut out = String::new();
1778        for (event, data) in events {
1779            out.push_str(&format!("event: {event}\ndata: {}\n\n", data));
1780        }
1781        out
1782    }
1783
1784    async fn collect_updates(text: String) -> Vec<ChatResponseUpdate> {
1785        // Build a fake reqwest::Response backed by the given SSE text using a
1786        // tiny local HTTP server would be overkill; instead we drive the
1787        // event parser directly through the same state machine by feeding
1788        // the byte stream via `futures::stream::once`.
1789        let stream =
1790            futures::stream::once(async move { Ok::<_, reqwest::Error>(bytes::Bytes::from(text)) });
1791        let byte_stream: ByteStream = Box::pin(stream);
1792        let mut state = ResponsesSseState {
1793            byte_stream,
1794            buffer: String::new(),
1795            utf8: Utf8StreamDecoder::new(),
1796            queued: VecDeque::new(),
1797            call_ids: HashMap::new(),
1798            done: false,
1799            store: None,
1800        };
1801        let mut updates = Vec::new();
1802        // Drain the single chunk manually (mirrors the real unfold body).
1803        if let Some(Ok(bytes)) = state.byte_stream.next().await {
1804            let decoded = state.utf8.push(&bytes);
1805            state.buffer.push_str(&decoded);
1806            while let Some(pos) = state.buffer.find('\n') {
1807                let line = state.buffer[..pos].trim().to_string();
1808                state.buffer.drain(..=pos);
1809                let Some(data) = line.strip_prefix("data:") else {
1810                    continue;
1811                };
1812                let data = data.trim();
1813                if data.is_empty() {
1814                    continue;
1815                }
1816                let value: Value = serde_json::from_str(data).unwrap();
1817                if let EventOutcome::Update(u) =
1818                    parse_responses_event(&value, &mut state.call_ids, state.store)
1819                {
1820                    updates.push(u);
1821                }
1822            }
1823        }
1824        updates
1825    }
1826
1827    #[tokio::test]
1828    async fn stream_text_only_accumulates() {
1829        let text = sse_body(&[
1830            (
1831                "response.created",
1832                json!({ "type": "response.created", "response": { "id": "resp_1", "model": "gpt-4o-mini" } }),
1833            ),
1834            (
1835                "response.output_text.delta",
1836                json!({ "type": "response.output_text.delta", "delta": "Hel" }),
1837            ),
1838            (
1839                "response.output_text.delta",
1840                json!({ "type": "response.output_text.delta", "delta": "lo!" }),
1841            ),
1842            (
1843                "response.completed",
1844                json!({ "type": "response.completed", "response": { "id": "resp_1", "model": "gpt-4o-mini", "status": "completed", "output": [], "usage": { "input_tokens": 3, "output_tokens": 2 } } }),
1845            ),
1846        ]);
1847        let updates = collect_updates(text).await;
1848        let resp = ChatResponse::from_updates(updates);
1849        assert_eq!(resp.text(), "Hello!");
1850        assert_eq!(resp.response_id.as_deref(), Some("resp_1"));
1851        assert_eq!(resp.finish_reason, Some(FinishReason::stop()));
1852        let usage = resp.usage_details.unwrap();
1853        assert_eq!(usage.input_token_count, Some(3));
1854        assert_eq!(usage.output_token_count, Some(2));
1855    }
1856
1857    #[tokio::test]
1858    async fn stream_tool_call_accumulates_arguments() {
1859        let text = sse_body(&[
1860            (
1861                "response.output_item.added",
1862                json!({ "type": "response.output_item.added", "output_index": 0, "item": { "type": "function_call", "call_id": "call_1", "name": "get_weather" } }),
1863            ),
1864            (
1865                "response.function_call_arguments.delta",
1866                json!({ "type": "response.function_call_arguments.delta", "output_index": 0, "delta": "{\"city\":" }),
1867            ),
1868            (
1869                "response.function_call_arguments.delta",
1870                json!({ "type": "response.function_call_arguments.delta", "output_index": 0, "delta": "\"Paris\"}" }),
1871            ),
1872            (
1873                "response.completed",
1874                json!({ "type": "response.completed", "response": { "id": "resp_2", "status": "completed", "output": [{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"Paris\"}"}] } }),
1875            ),
1876        ]);
1877        let updates = collect_updates(text).await;
1878        let resp = ChatResponse::from_updates(updates);
1879        let calls = resp.function_calls();
1880        assert_eq!(calls.len(), 1);
1881        assert_eq!(calls[0].call_id, "call_1");
1882        assert_eq!(calls[0].name, "get_weather");
1883        assert_eq!(
1884            calls[0].parse_arguments().unwrap().get("city").unwrap(),
1885            &json!("Paris")
1886        );
1887        assert_eq!(resp.finish_reason, Some(FinishReason::tool_calls()));
1888    }
1889
1890    #[tokio::test]
1891    async fn stream_failed_event_is_error() {
1892        let text = sse_body(&[(
1893            "response.failed",
1894            json!({ "type": "response.failed", "response": { "error": { "message": "boom" } } }),
1895        )]);
1896        let stream =
1897            futures::stream::once(async move { Ok::<_, reqwest::Error>(bytes::Bytes::from(text)) });
1898        let byte_stream: ByteStream = Box::pin(stream);
1899        let mut state = ResponsesSseState {
1900            byte_stream,
1901            buffer: String::new(),
1902            utf8: Utf8StreamDecoder::new(),
1903            queued: VecDeque::new(),
1904            call_ids: HashMap::new(),
1905            done: false,
1906            store: None,
1907        };
1908        let bytes = state.byte_stream.next().await.unwrap().unwrap();
1909        let decoded = state.utf8.push(&bytes);
1910        state.buffer.push_str(&decoded);
1911        let mut saw_error = false;
1912        while let Some(pos) = state.buffer.find('\n') {
1913            let line = state.buffer[..pos].trim().to_string();
1914            state.buffer.drain(..=pos);
1915            let Some(data) = line.strip_prefix("data:") else {
1916                continue;
1917            };
1918            let data = data.trim();
1919            if data.is_empty() {
1920                continue;
1921            }
1922            let value: Value = serde_json::from_str(data).unwrap();
1923            if let EventOutcome::Error(e) =
1924                parse_responses_event(&value, &mut state.call_ids, state.store)
1925            {
1926                assert!(e.to_string().contains("boom"));
1927                saw_error = true;
1928            }
1929        }
1930        assert!(
1931            saw_error,
1932            "expected a response.failed event to surface an error"
1933        );
1934    }
1935
1936    #[test]
1937    fn stream_failed_event_classifies_content_filter() {
1938        // Streamed and non-streamed Responses failures must classify alike
1939        // (mirrors `response_failure_error`).
1940        let mut call_ids = HashMap::new();
1941        let filtered = parse_responses_event(
1942            &json!({
1943                "type": "response.failed",
1944                "response": { "error": { "code": "content_filter", "message": "blocked" } }
1945            }),
1946            &mut call_ids,
1947            None,
1948        );
1949        let EventOutcome::Error(err) = filtered else {
1950            panic!("expected an error outcome");
1951        };
1952        assert!(matches!(err, Error::ServiceContentFilter { .. }));
1953
1954        let generic = parse_responses_event(
1955            &json!({
1956                "type": "response.failed",
1957                "response": { "error": { "code": "server_error", "message": "boom" } }
1958            }),
1959            &mut call_ids,
1960            None,
1961        );
1962        let EventOutcome::Error(err) = generic else {
1963            panic!("expected an error outcome");
1964        };
1965        assert!(matches!(err, Error::Service(_)));
1966    }
1967
1968    // endregion
1969
1970    // region: env-var constructor
1971
1972    /// Guards `OPENAI_API_KEY` / `OPENAI_BASE_URL` mutation: `cargo test` runs
1973    /// tests in the same process on multiple threads, and env vars are
1974    /// process-global, so concurrent set/remove across tests would be racy
1975    /// without serializing access.
1976    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1977
1978    #[test]
1979    fn from_env_reads_api_key_and_base_url() {
1980        let _guard = ENV_MUTEX.lock().unwrap();
1981        // SAFETY: serialized by ENV_MUTEX against the other env-var test in
1982        // this module; no other test in this crate touches these variables.
1983        unsafe {
1984            std::env::set_var("OPENAI_API_KEY", "sk-test-123");
1985            std::env::set_var("OPENAI_BASE_URL", "https://example.test/v1");
1986        }
1987        let client = OpenAIChatClient::from_env("gpt-4o-mini").unwrap();
1988        assert_eq!(client.inner.api_key, "sk-test-123");
1989        assert_eq!(client.inner.base_url, "https://example.test/v1");
1990        unsafe {
1991            std::env::remove_var("OPENAI_API_KEY");
1992            std::env::remove_var("OPENAI_BASE_URL");
1993        }
1994    }
1995
1996    #[test]
1997    fn from_env_errors_when_api_key_missing() {
1998        let _guard = ENV_MUTEX.lock().unwrap();
1999        // SAFETY: serialized by ENV_MUTEX; see above.
2000        unsafe {
2001            std::env::remove_var("OPENAI_API_KEY");
2002            std::env::remove_var("OPENAI_BASE_URL");
2003        }
2004        let result = OpenAIChatClient::from_env("gpt-4o-mini");
2005        assert!(result.is_err());
2006    }
2007    #[test]
2008    fn build_body_maps_hosted_tools_to_responses_types() {
2009        use agent_framework_core::tools::{
2010            hosted_code_interpreter, hosted_file_search, hosted_mcp, hosted_web_search,
2011        };
2012        let c = client();
2013        let mut options = ChatOptions::new();
2014        options.tools = vec![
2015            hosted_web_search(),
2016            hosted_code_interpreter(),
2017            hosted_file_search(Some(7)),
2018            hosted_mcp(
2019                "docs",
2020                "https://mcp.example/sse",
2021                Some(vec!["search".into()]),
2022            ),
2023        ];
2024        let body = c.build_body(&[user("hi")], &options, false);
2025        let tools = body["tools"].as_array().unwrap();
2026        assert_eq!(tools[0], json!({ "type": "web_search" }));
2027        assert_eq!(
2028            tools[1],
2029            json!({ "type": "code_interpreter", "container": { "type": "auto" } })
2030        );
2031        assert_eq!(tools[2]["type"], "file_search");
2032        assert_eq!(tools[2]["max_num_results"], json!(7));
2033        assert_eq!(tools[3]["type"], "mcp");
2034        assert_eq!(tools[3]["server_url"], "https://mcp.example/sse");
2035        assert_eq!(tools[3]["allowed_tools"], json!(["search"]));
2036        assert_eq!(tools[3]["require_approval"], "never");
2037    }
2038
2039    // endregion
2040
2041    // region: multimodal input (Responses)
2042
2043    #[test]
2044    fn input_image_uri_becomes_input_image_part() {
2045        let msg = user_with(vec![Content::Uri(UriContent {
2046            uri: "https://example.com/cat.png".into(),
2047            media_type: "image/png".into(),
2048        })]);
2049        let input = messages_to_input(&[msg]);
2050        assert_eq!(
2051            input[0],
2052            json!({ "type": "message", "role": "user", "content": [
2053                { "type": "input_image", "image_url": "https://example.com/cat.png", "detail": "auto" }
2054            ]})
2055        );
2056    }
2057
2058    #[test]
2059    fn input_audio_file_and_hosted_file_parts() {
2060        let msg = user_with(vec![
2061            Content::Data(DataContent {
2062                uri: "data:audio/wav;base64,QQ".into(),
2063                media_type: Some("audio/wav".into()),
2064            }),
2065            Content::Data(DataContent {
2066                uri: "data:application/pdf;base64,JV".into(),
2067                media_type: Some("application/pdf".into()),
2068            }),
2069            Content::HostedFile(HostedFileContent {
2070                file_id: "file-123".into(),
2071            }),
2072        ]);
2073        let input = messages_to_input(&[msg]);
2074        // `input_audio.data` is the raw base64 payload (data-URI prefix
2075        // stripped), the same wire rule as Chat Completions; file inputs keep
2076        // the full data URI in `file_data`.
2077        assert_eq!(
2078            input[0]["content"],
2079            json!([
2080                { "type": "input_audio", "input_audio": { "data": "QQ", "format": "wav" } },
2081                { "type": "input_file", "file_data": "data:application/pdf;base64,JV", "filename": "file" },
2082                { "type": "input_file", "file_id": "file-123" },
2083            ])
2084        );
2085    }
2086
2087    #[test]
2088    fn approval_response_becomes_mcp_approval_response_item() {
2089        let resp = FunctionApprovalResponseContent {
2090            approved: true,
2091            id: "appr_1".into(),
2092            function_call: FunctionCallContent::new("appr_1", "search", None),
2093        };
2094        let msg = user_with(vec![Content::FunctionApprovalResponse(resp)]);
2095        let input = messages_to_input(&[msg]);
2096        assert_eq!(
2097            input[0],
2098            json!({
2099                "type": "mcp_approval_response",
2100                "approval_request_id": "appr_1",
2101                "approve": true,
2102            })
2103        );
2104    }
2105
2106    #[test]
2107    fn approval_request_becomes_mcp_approval_request_item() {
2108        let req = FunctionApprovalRequestContent {
2109            id: "appr_1".into(),
2110            function_call: FunctionCallContent::new(
2111                "appr_1",
2112                "search",
2113                Some(FunctionArguments::Raw(r#"{"q":"x"}"#.into())),
2114            ),
2115        };
2116        let msg = Message::with_contents(
2117            Role::assistant(),
2118            vec![Content::FunctionApprovalRequest(req)],
2119        );
2120        let input = messages_to_input(&[msg]);
2121        assert_eq!(
2122            input[0],
2123            json!({
2124                "type": "mcp_approval_request",
2125                "id": "appr_1",
2126                "name": "search",
2127                "arguments": r#"{"q":"x"}"#,
2128            })
2129        );
2130    }
2131
2132    // endregion
2133
2134    // region: output parsing (annotations, code interpreter, images, approvals)
2135
2136    #[test]
2137    fn output_text_url_citation_annotation() {
2138        let contents = parse_item(json!({
2139            "type": "message", "role": "assistant", "content": [{
2140                "type": "output_text", "text": "See source.",
2141                "annotations": [{
2142                    "type": "url_citation", "title": "Src", "url": "https://ex.com",
2143                    "start_index": 0, "end_index": 3,
2144                }],
2145            }],
2146        }));
2147        let Content::Text(t) = &contents[0] else {
2148            panic!("expected text content");
2149        };
2150        let ann = t.annotations.as_ref().unwrap();
2151        assert_eq!(ann[0].title.as_deref(), Some("Src"));
2152        assert_eq!(ann[0].url.as_deref(), Some("https://ex.com"));
2153        let region = &ann[0].annotated_regions.as_ref().unwrap()[0];
2154        assert_eq!(region.start_index, Some(0));
2155        assert_eq!(region.end_index, Some(3));
2156    }
2157
2158    #[test]
2159    fn output_text_file_and_container_citations() {
2160        let contents = parse_item(json!({
2161            "type": "message", "role": "assistant", "content": [{
2162                "type": "output_text", "text": "x",
2163                "annotations": [
2164                    { "type": "file_citation", "filename": "doc.pdf", "file_id": "file-1", "index": 2 },
2165                    { "type": "file_path", "file_id": "file-2", "index": 0 },
2166                    { "type": "container_file_citation", "filename": "c.txt", "file_id": "file-3",
2167                      "container_id": "cont-1", "start_index": 1, "end_index": 4 },
2168                ],
2169            }],
2170        }));
2171        let Content::Text(t) = &contents[0] else {
2172            panic!("expected text content");
2173        };
2174        let ann = t.annotations.as_ref().unwrap();
2175        assert_eq!(ann[0].url.as_deref(), Some("doc.pdf"));
2176        assert_eq!(ann[0].file_id.as_deref(), Some("file-1"));
2177        assert_eq!(ann[1].file_id.as_deref(), Some("file-2"));
2178        assert_eq!(ann[2].file_id.as_deref(), Some("file-3"));
2179        assert_eq!(ann[2].url.as_deref(), Some("c.txt"));
2180        assert_eq!(
2181            ann[2].annotated_regions.as_ref().unwrap()[0].end_index,
2182            Some(4)
2183        );
2184    }
2185
2186    #[test]
2187    fn code_interpreter_outputs_become_text_and_uri() {
2188        let contents = parse_item(json!({
2189            "type": "code_interpreter_call",
2190            "outputs": [
2191                { "type": "logs", "logs": "hello stdout" },
2192                { "type": "image", "url": "https://ex.com/plot.png" },
2193            ],
2194        }));
2195        assert!(matches!(&contents[0], Content::Text(t) if t.text == "hello stdout"));
2196        assert!(
2197            matches!(&contents[1], Content::Uri(u) if u.uri == "https://ex.com/plot.png" && u.media_type == "image")
2198        );
2199    }
2200
2201    #[test]
2202    fn code_interpreter_without_outputs_falls_back_to_code() {
2203        let contents = parse_item(json!({
2204            "type": "code_interpreter_call", "code": "print(1)",
2205        }));
2206        assert!(matches!(&contents[0], Content::Text(t) if t.text == "print(1)"));
2207    }
2208
2209    #[test]
2210    fn image_generation_raw_base64_becomes_png_data() {
2211        let contents = parse_item(json!({
2212            "type": "image_generation_call", "result": "AAAABBBB",
2213        }));
2214        let Content::Data(d) = &contents[0] else {
2215            panic!("expected data content");
2216        };
2217        assert_eq!(d.uri, "data:image/png;base64,AAAABBBB");
2218        assert_eq!(d.media_type.as_deref(), Some("image/png"));
2219    }
2220
2221    #[test]
2222    fn image_generation_data_uri_keeps_stated_media_type() {
2223        let contents = parse_item(json!({
2224            "type": "image_generation_call", "result": "data:image/webp;base64,ZZZ",
2225        }));
2226        let Content::Data(d) = &contents[0] else {
2227            panic!("expected data content");
2228        };
2229        assert_eq!(d.uri, "data:image/webp;base64,ZZZ");
2230        assert_eq!(d.media_type.as_deref(), Some("image/webp"));
2231    }
2232
2233    #[test]
2234    fn mcp_approval_request_output_round_trips_into_response() {
2235        let contents = parse_item(json!({
2236            "type": "mcp_approval_request",
2237            "id": "appr_9", "name": "search", "arguments": r#"{"q":"rust"}"#,
2238            "server_label": "docs",
2239        }));
2240        let Content::FunctionApprovalRequest(req) = &contents[0] else {
2241            panic!("expected approval request");
2242        };
2243        assert_eq!(req.id, "appr_9");
2244        assert_eq!(req.function_call.call_id, "appr_9");
2245        assert_eq!(req.function_call.name, "search");
2246
2247        // The id round-trips into the request's response item (item 5).
2248        let msg = user_with(vec![Content::FunctionApprovalResponse(
2249            req.create_response(true),
2250        )]);
2251        let input = messages_to_input(&[msg]);
2252        assert_eq!(input[0]["type"], json!("mcp_approval_response"));
2253        assert_eq!(input[0]["approval_request_id"], json!("appr_9"));
2254        assert_eq!(input[0]["approve"], json!(true));
2255    }
2256
2257    // endregion
2258
2259    // region: streaming reasoning
2260
2261    fn reasoning_event(value: Value) -> EventOutcome {
2262        let mut ids = HashMap::new();
2263        parse_responses_event(&value, &mut ids, None)
2264    }
2265
2266    #[test]
2267    fn reasoning_text_delta_streams_and_done_is_terminal_metadata() {
2268        let EventOutcome::Update(delta) =
2269            reasoning_event(json!({ "type": "response.reasoning_text.delta", "delta": "Th" }))
2270        else {
2271            panic!("expected update");
2272        };
2273        assert!(matches!(&delta.contents[0], Content::TextReasoning(t) if t.text == "Th"));
2274
2275        // `.done` carries the full text the deltas already streamed — emitting
2276        // it again would duplicate the reasoning in the aggregate.
2277        let done =
2278            reasoning_event(json!({ "type": "response.reasoning_text.done", "text": "Think" }));
2279        assert!(matches!(done, EventOutcome::None));
2280    }
2281
2282    #[test]
2283    fn reasoning_summary_text_events_map_to_reasoning_content() {
2284        let EventOutcome::Update(delta) = reasoning_event(
2285            json!({ "type": "response.reasoning_summary_text.delta", "delta": "sum" }),
2286        ) else {
2287            panic!("expected update");
2288        };
2289        assert!(matches!(&delta.contents[0], Content::TextReasoning(t) if t.text == "sum"));
2290
2291        let done = reasoning_event(
2292            json!({ "type": "response.reasoning_summary_text.done", "text": "summary" }),
2293        );
2294        assert!(matches!(done, EventOutcome::None));
2295    }
2296
2297    #[test]
2298    fn response_failure_error_classifies_content_filter() {
2299        let filtered = json!({
2300            "status": "failed",
2301            "error": { "code": "content_filter", "message": "blocked" }
2302        });
2303        assert!(matches!(
2304            response_failure_error(&filtered),
2305            Some(Error::ServiceContentFilter { .. })
2306        ));
2307
2308        let generic = json!({
2309            "status": "failed",
2310            "error": { "code": "server_error", "message": "boom" }
2311        });
2312        assert!(matches!(
2313            response_failure_error(&generic),
2314            Some(Error::Service(_))
2315        ));
2316
2317        assert!(response_failure_error(&json!({ "status": "completed" })).is_none());
2318    }
2319
2320    #[test]
2321    fn input_audio_data_strips_data_uri_prefix() {
2322        let part = content_to_input_part("data:audio/wav;base64,QUJD", Some("audio/wav"))
2323            .expect("audio part");
2324        assert_eq!(part["input_audio"]["data"], "QUJD");
2325        assert_eq!(part["input_audio"]["format"], "wav");
2326    }
2327
2328    // endregion
2329
2330    // region: hosted-tool config passthrough
2331
2332    fn hosted(kind: ToolKind, name: &str, params: Value) -> ToolDefinition {
2333        ToolDefinition {
2334            name: name.into(),
2335            description: String::new(),
2336            parameters: params,
2337            kind,
2338            approval_mode: ApprovalMode::NeverRequire,
2339            executor: None,
2340        }
2341    }
2342
2343    #[test]
2344    fn web_search_passes_through_user_location() {
2345        let tool = hosted(
2346            ToolKind::HostedWebSearch,
2347            "web_search",
2348            json!({ "user_location": { "city": "Paris", "country": "FR" } }),
2349        );
2350        assert_eq!(
2351            tool_to_responses_spec(&tool),
2352            json!({
2353                "type": "web_search",
2354                "user_location": { "type": "approximate", "city": "Paris", "country": "FR" },
2355            })
2356        );
2357    }
2358
2359    #[test]
2360    fn file_search_passes_vector_store_ids_and_max_results_param() {
2361        let tool = hosted(
2362            ToolKind::HostedFileSearch { max_results: None },
2363            "file_search",
2364            json!({ "vector_store_ids": ["vs_1"], "max_results": 12 }),
2365        );
2366        let spec = tool_to_responses_spec(&tool);
2367        assert_eq!(spec["vector_store_ids"], json!(["vs_1"]));
2368        assert_eq!(spec["max_num_results"], json!(12));
2369    }
2370
2371    #[test]
2372    fn image_generation_maps_to_responses_tool_with_passthrough_params() {
2373        let tool = hosted(
2374            ToolKind::HostedImageGeneration,
2375            "image_generation",
2376            json!({ "size": "1024x1024", "quality": "high" }),
2377        );
2378        let spec = tool_to_responses_spec(&tool);
2379        assert_eq!(spec["type"], json!("image_generation"));
2380        assert_eq!(spec["size"], json!("1024x1024"));
2381        assert_eq!(spec["quality"], json!("high"));
2382    }
2383
2384    #[test]
2385    fn code_interpreter_passes_file_ids_and_container_override() {
2386        let with_files = hosted(
2387            ToolKind::HostedCodeInterpreter,
2388            "ci",
2389            json!({ "file_ids": ["file-1", "file-2"] }),
2390        );
2391        assert_eq!(
2392            tool_to_responses_spec(&with_files)["container"],
2393            json!({ "type": "auto", "file_ids": ["file-1", "file-2"] })
2394        );
2395        let with_container = hosted(
2396            ToolKind::HostedCodeInterpreter,
2397            "ci",
2398            json!({ "container": { "type": "secure", "id": "c1" } }),
2399        );
2400        assert_eq!(
2401            tool_to_responses_spec(&with_container)["container"],
2402            json!({ "type": "secure", "id": "c1" })
2403        );
2404    }
2405
2406    #[test]
2407    fn mcp_passes_headers_and_string_approval_mode_override() {
2408        let tool = hosted(
2409            ToolKind::HostedMcp {
2410                url: "https://mcp/sse".into(),
2411                allowed_tools: None,
2412            },
2413            "docs",
2414            json!({ "headers": { "Authorization": "Bearer x" }, "approval_mode": "always_require" }),
2415        );
2416        // The enum default is NeverRequire; the parameter overrides it.
2417        let spec = tool_to_responses_spec(&tool);
2418        assert_eq!(spec["headers"], json!({ "Authorization": "Bearer x" }));
2419        assert_eq!(spec["require_approval"], json!("always"));
2420    }
2421
2422    #[test]
2423    fn mcp_object_approval_mode_maps_to_tool_name_lists() {
2424        let tool = hosted(
2425            ToolKind::HostedMcp {
2426                url: "https://mcp/sse".into(),
2427                allowed_tools: None,
2428            },
2429            "docs",
2430            json!({ "approval_mode": { "always": ["delete"], "never": ["read"] } }),
2431        );
2432        assert_eq!(
2433            tool_to_responses_spec(&tool)["require_approval"],
2434            json!({ "always": { "tool_names": ["delete"] }, "never": { "tool_names": ["read"] } })
2435        );
2436    }
2437
2438    // endregion
2439}