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