Skip to main content

agent_sdk_providers/impls/
openai_responses.rs

1//! `OpenAI` Responses API provider implementation.
2//!
3//! This module provides an implementation of `LlmProvider` for the `OpenAI`
4//! Responses API (`/v1/responses`). This provider supports the Codex model family
5//! and other agentic `OpenAI` models that expose the Responses surface.
6
7use crate::attachments::validate_request_attachments;
8use crate::provider::LlmProvider;
9use crate::streaming::{StreamBox, StreamDelta, StreamErrorKind};
10use agent_sdk_foundation::llm::{
11    ChatOutcome, ChatRequest, ChatResponse, Content, ContentBlock, Effort, StopReason,
12    ThinkingConfig, ThinkingMode, Usage,
13};
14use anyhow::Result;
15use async_trait::async_trait;
16use futures::StreamExt;
17use reqwest::StatusCode;
18use serde::{Deserialize, Serialize};
19
20const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
21
22// GPT-5.3-Codex (latest Codex model)
23pub const MODEL_GPT53_CODEX: &str = "gpt-5.3-codex";
24
25// GPT-5.2-Codex (legacy Responses-first codex model)
26pub const MODEL_GPT52_CODEX: &str = "gpt-5.2-codex";
27
28/// Reasoning effort level for the model.
29#[derive(Clone, Copy, Debug, Default, Serialize)]
30#[serde(rename_all = "lowercase")]
31pub enum ReasoningEffort {
32    Low,
33    #[default]
34    Medium,
35    High,
36    /// Extra-high reasoning for complex problems
37    #[serde(rename = "xhigh")]
38    XHigh,
39}
40
41/// `OpenAI` Responses API provider.
42///
43/// This provider uses the `/v1/responses` endpoint for `OpenAI` models that expose
44/// agentic workflows over the Responses API.
45#[derive(Clone)]
46pub struct OpenAIResponsesProvider {
47    client: reqwest::Client,
48    api_key: String,
49    model: String,
50    base_url: String,
51    thinking: Option<ThinkingConfig>,
52}
53
54impl OpenAIResponsesProvider {
55    /// Create a new `OpenAI` Responses API provider.
56    #[must_use]
57    pub fn new(api_key: String, model: String) -> Self {
58        Self {
59            client: reqwest::Client::new(),
60            api_key,
61            model,
62            base_url: DEFAULT_BASE_URL.to_owned(),
63            thinking: None,
64        }
65    }
66
67    /// Create a provider with a custom base URL.
68    #[must_use]
69    pub fn with_base_url(api_key: String, model: String, base_url: String) -> Self {
70        Self {
71            client: reqwest::Client::new(),
72            api_key,
73            model,
74            base_url,
75            thinking: None,
76        }
77    }
78
79    /// Create a provider using GPT-5.3-Codex (latest codex model).
80    #[must_use]
81    pub fn gpt53_codex(api_key: String) -> Self {
82        Self::new(api_key, MODEL_GPT53_CODEX.to_owned())
83    }
84
85    /// Create a provider using the latest Codex model.
86    #[must_use]
87    pub fn codex(api_key: String) -> Self {
88        Self::gpt53_codex(api_key)
89    }
90
91    /// Set the provider-owned thinking configuration for this model.
92    #[must_use]
93    pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
94        self.thinking = Some(thinking);
95        self
96    }
97
98    /// Set the reasoning effort level.
99    #[must_use]
100    pub fn with_reasoning_effort(self, effort: ReasoningEffort) -> Self {
101        self.with_thinking(ThinkingConfig::default().with_effort(map_reasoning_effort(effort)))
102    }
103}
104
105#[async_trait]
106impl LlmProvider for OpenAIResponsesProvider {
107    async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
108        let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
109            Ok(thinking) => thinking,
110            Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
111        };
112        if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
113            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
114        }
115        let reasoning = build_api_reasoning(thinking_config.as_ref());
116        let input = build_api_input(&request);
117        let tools: Option<Vec<ApiTool>> = request
118            .tools
119            .map(|ts| ts.into_iter().map(convert_tool).collect());
120        let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
121
122        let api_request = ApiResponsesRequest {
123            model: &self.model,
124            input: &input,
125            tools: tools.as_deref(),
126            max_output_tokens: Some(request.max_tokens),
127            reasoning,
128            parallel_tool_calls: parallel_tool_calls.then_some(true),
129        };
130
131        log::debug!(
132            "OpenAI Responses API request model={} max_tokens={}",
133            self.model,
134            request.max_tokens
135        );
136
137        let response = self
138            .client
139            .post(format!("{}/responses", self.base_url))
140            .header("Content-Type", "application/json")
141            .header("Authorization", format!("Bearer {}", self.api_key))
142            .json(&api_request)
143            .send()
144            .await
145            .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
146
147        let status = response.status();
148        let bytes = response
149            .bytes()
150            .await
151            .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
152
153        log::debug!(
154            "OpenAI Responses API response status={} body_len={}",
155            status,
156            bytes.len()
157        );
158
159        if status == StatusCode::TOO_MANY_REQUESTS {
160            return Ok(ChatOutcome::RateLimited);
161        }
162
163        if status.is_server_error() {
164            let body = String::from_utf8_lossy(&bytes);
165            log::error!("OpenAI Responses server error status={status} body={body}");
166            return Ok(ChatOutcome::ServerError(body.into_owned()));
167        }
168
169        if status.is_client_error() {
170            let body = String::from_utf8_lossy(&bytes);
171            log::warn!("OpenAI Responses client error status={status} body={body}");
172            return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
173        }
174
175        let api_response: ApiResponse = serde_json::from_slice(&bytes)
176            .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
177
178        let content = build_content_blocks(&api_response.output);
179
180        // Determine stop reason based on output content
181        let has_tool_calls = content
182            .iter()
183            .any(|b| matches!(b, ContentBlock::ToolUse { .. }));
184
185        let stop_reason = if has_tool_calls {
186            Some(StopReason::ToolUse)
187        } else {
188            api_response.status.map(|s| match s {
189                ApiStatus::Completed => StopReason::EndTurn,
190                ApiStatus::Incomplete => StopReason::MaxTokens,
191                ApiStatus::Failed => StopReason::StopSequence,
192            })
193        };
194
195        Ok(ChatOutcome::Success(ChatResponse {
196            id: api_response.id,
197            content,
198            model: api_response.model,
199            stop_reason,
200            usage: api_response.usage.map_or(
201                Usage {
202                    input_tokens: 0,
203                    output_tokens: 0,
204                    cached_input_tokens: 0,
205                    cache_creation_input_tokens: 0,
206                },
207                |u| Usage {
208                    input_tokens: u.input_tokens,
209                    output_tokens: u.output_tokens,
210                    cached_input_tokens: u
211                        .input_tokens_details
212                        .as_ref()
213                        .map_or(0, |details| details.cached_tokens),
214                    cache_creation_input_tokens: 0,
215                },
216            ),
217        }))
218    }
219
220    #[allow(clippy::too_many_lines)]
221    fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
222        Box::pin(async_stream::stream! {
223            let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
224                Ok(thinking) => thinking,
225                Err(error) => {
226                    yield Ok(StreamDelta::Error {
227                        message: error.to_string(),
228                        kind: StreamErrorKind::InvalidRequest,
229                    });
230                    return;
231                }
232            };
233            if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
234                yield Ok(StreamDelta::Error {
235                    message: error.to_string(),
236                    kind: StreamErrorKind::InvalidRequest,
237                });
238                return;
239            }
240            let reasoning = build_api_reasoning(thinking_config.as_ref());
241            let input = build_api_input(&request);
242            let tools: Option<Vec<ApiTool>> = request
243                .tools
244                .map(|ts| ts.into_iter().map(convert_tool).collect());
245            let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
246
247            let api_request = ApiResponsesRequestStreaming {
248                model: &self.model,
249                input: &input,
250                tools: tools.as_deref(),
251                max_output_tokens: Some(request.max_tokens),
252                reasoning,
253                parallel_tool_calls: parallel_tool_calls.then_some(true),
254                stream: true,
255            };
256
257            log::debug!("OpenAI Responses API streaming request model={} max_tokens={}", self.model, request.max_tokens);
258
259            let Ok(response) = self.client
260                .post(format!("{}/responses", self.base_url))
261                .header("Content-Type", "application/json")
262                .header("Authorization", format!("Bearer {}", self.api_key))
263                .json(&api_request)
264                .send()
265                .await
266            else {
267                yield Err(anyhow::anyhow!("request failed"));
268                return;
269            };
270
271            let status = response.status();
272
273            if !status.is_success() {
274                let body = response.text().await.unwrap_or_default();
275                let kind = if status == StatusCode::TOO_MANY_REQUESTS {
276                    StreamErrorKind::RateLimited
277                } else if status.is_server_error() {
278                    StreamErrorKind::ServerError
279                } else {
280                    StreamErrorKind::InvalidRequest
281                };
282                log::warn!("OpenAI Responses error status={status} body={body}");
283                yield Ok(StreamDelta::Error { message: body, kind });
284                return;
285            }
286
287            let mut buffer = String::new();
288            let mut stream = response.bytes_stream();
289            let mut usage: Option<Usage> = None;
290            let mut tool_calls: std::collections::HashMap<String, ToolCallAccumulator> =
291                std::collections::HashMap::new();
292
293            while let Some(chunk_result) = stream.next().await {
294                let Ok(chunk) = chunk_result else {
295                    yield Err(anyhow::anyhow!("stream error"));
296                    return;
297                };
298                buffer.push_str(&String::from_utf8_lossy(&chunk));
299
300                while let Some(pos) = buffer.find('\n') {
301                    let line = buffer[..pos].trim().to_string();
302                    buffer = buffer[pos + 1..].to_string();
303                    if line.is_empty() { continue; }
304
305                    let Some(data) = line.strip_prefix("data: ") else {
306                        log::trace!("Responses SSE non-data line: {line}");
307                        continue;
308                    };
309                    if log::log_enabled!(log::Level::Trace) {
310                        let truncated: String = data.chars().take(200).collect();
311                        log::trace!("Responses SSE data: {truncated}");
312                    }
313
314                    if data == "[DONE]" {
315                        // Emit any accumulated tool calls
316                        for acc in tool_calls.values() {
317                            yield Ok(StreamDelta::ToolUseStart {
318                                id: acc.id.clone(),
319                                name: acc.name.clone(),
320                                block_index: 1,
321                                thought_signature: None,
322                            });
323                            yield Ok(StreamDelta::ToolInputDelta {
324                                id: acc.id.clone(),
325                                delta: acc.arguments.clone(),
326                                block_index: 1,
327                            });
328                        }
329
330                        if let Some(u) = usage.take() {
331                            yield Ok(StreamDelta::Usage(u));
332                        }
333
334                        let stop_reason = if tool_calls.is_empty() {
335                            StopReason::EndTurn
336                        } else {
337                            StopReason::ToolUse
338                        };
339                        yield Ok(StreamDelta::Done { stop_reason: Some(stop_reason) });
340                        return;
341                    }
342
343                    // Parse streaming event
344                    let parse_result = serde_json::from_str::<ApiStreamEvent>(data);
345                    if parse_result.is_err() {
346                        log::debug!("Failed to parse Responses SSE event: {data}");
347                    }
348                    if let Ok(event) = parse_result {
349                        match event.r#type.as_str() {
350                            // ── Content deltas ──────────────────────────
351                            "response.output_text.delta" => {
352                                if let Some(delta) = event.delta {
353                                    yield Ok(StreamDelta::TextDelta { delta, block_index: 0 });
354                                }
355                            }
356                            "response.output_item.added" => {
357                                // Register function_call items so we know
358                                // the call_id and name before deltas arrive.
359                                if let Some(item) = &event.item
360                                    && item.r#type.as_deref() == Some("function_call")
361                                    && let (Some(item_id), Some(call_id), Some(name)) =
362                                        (&item.id, &item.call_id, &item.name)
363                                {
364                                    tool_calls
365                                        .entry(item_id.clone())
366                                        .or_insert_with(|| ToolCallAccumulator {
367                                            id: call_id.clone(),
368                                            name: name.clone(),
369                                            arguments: String::new(),
370                                        });
371                                }
372                            }
373                            "response.function_call_arguments.delta" => {
374                                if let (Some(item_id), Some(delta)) =
375                                    (event.resolve_item_id().map(str::to_owned), event.delta)
376                                {
377                                    let acc =
378                                        tool_calls.entry(item_id.clone()).or_insert_with(|| {
379                                            ToolCallAccumulator {
380                                                id: item_id,
381                                                name: event.name.unwrap_or_default(),
382                                                arguments: String::new(),
383                                            }
384                                        });
385                                    acc.arguments.push_str(&delta);
386                                }
387                            }
388                            // ── Reasoning (thinking) deltas ─────────────
389                            "response.reasoning.delta" => {
390                                if let Some(delta) = event.delta {
391                                    yield Ok(StreamDelta::ThinkingDelta {
392                                        delta,
393                                        block_index: 0,
394                                    });
395                                }
396                            }
397                            // ── Completion / usage ──────────────────────
398                            "response.completed" => {
399                                if let Some(resp) = event.response
400                                    && let Some(u) = resp.usage
401                                {
402                                    usage = Some(Usage {
403                                        input_tokens: u.input_tokens,
404                                        output_tokens: u.output_tokens,
405                                        cached_input_tokens: u
406                                            .input_tokens_details
407                                            .as_ref()
408                                            .map_or(0, |details| details.cached_tokens),
409                                        cache_creation_input_tokens: 0,
410                                    });
411                                }
412                            }
413                            // ── Error ───────────────────────────────────
414                            "error" | "response.failed" => {
415                                let is_server_error = data.contains("server_error");
416                                let kind = if is_server_error {
417                                    log::warn!("Responses API server error (recoverable): {data}");
418                                    StreamErrorKind::ServerError
419                                } else {
420                                    log::error!("Responses API error event: {data}");
421                                    StreamErrorKind::InvalidRequest
422                                };
423                                yield Ok(StreamDelta::Error {
424                                    message: data.to_owned(),
425                                    kind,
426                                });
427                                return;
428                            }
429                            // ── Lifecycle events (no content) ───────────
430                            "response.created"
431                            | "response.in_progress"
432                            | "response.output_item.done"
433                            | "response.content_part.added"
434                            | "response.content_part.done"
435                            | "response.output_text.done"
436                            | "response.function_call_arguments.done"
437                            | "response.reasoning.done"
438                            | "response.reasoning_summary_text.delta"
439                            | "response.reasoning_summary_text.done" => {}
440                            // ── Unknown ─────────────────────────────────
441                            other => {
442                                log::debug!("Unhandled Responses SSE event type: {other}");
443                            }
444                        }
445                    }
446                }
447            }
448
449            // Stream ended without [DONE] — flush accumulated tool calls
450            for acc in tool_calls.values() {
451                yield Ok(StreamDelta::ToolUseStart {
452                    id: acc.id.clone(),
453                    name: acc.name.clone(),
454                    block_index: 1,
455                    thought_signature: None,
456                });
457                yield Ok(StreamDelta::ToolInputDelta {
458                    id: acc.id.clone(),
459                    delta: acc.arguments.clone(),
460                    block_index: 1,
461                });
462            }
463
464            if let Some(u) = usage {
465                yield Ok(StreamDelta::Usage(u));
466            }
467
468            let stop_reason = if tool_calls.is_empty() {
469                StopReason::EndTurn
470            } else {
471                StopReason::ToolUse
472            };
473            yield Ok(StreamDelta::Done { stop_reason: Some(stop_reason) });
474        })
475    }
476
477    fn model(&self) -> &str {
478        &self.model
479    }
480
481    fn provider(&self) -> &'static str {
482        "openai-responses"
483    }
484
485    fn configured_thinking(&self) -> Option<&ThinkingConfig> {
486        self.thinking.as_ref()
487    }
488}
489
490// ============================================================================
491// Input building
492// ============================================================================
493
494fn build_api_input(request: &ChatRequest) -> Vec<ApiInputItem> {
495    let mut items = Vec::new();
496
497    // Add system message if present
498    if !request.system.is_empty() {
499        items.push(ApiInputItem::Message(ApiMessage {
500            role: ApiRole::System,
501            content: ApiMessageContent::Text(request.system.clone()),
502        }));
503    }
504
505    // Convert messages
506    for msg in &request.messages {
507        match &msg.content {
508            Content::Text(text) => {
509                items.push(ApiInputItem::Message(ApiMessage {
510                    role: match msg.role {
511                        agent_sdk_foundation::llm::Role::User => ApiRole::User,
512                        agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
513                    },
514                    content: ApiMessageContent::Text(text.clone()),
515                }));
516            }
517            Content::Blocks(blocks) => {
518                let mut content_parts = Vec::new();
519
520                for block in blocks {
521                    match block {
522                        ContentBlock::Text { text } => {
523                            let part = match msg.role {
524                                agent_sdk_foundation::llm::Role::Assistant => {
525                                    ApiInputContent::OutputText { text: text.clone() }
526                                }
527                                agent_sdk_foundation::llm::Role::User => {
528                                    ApiInputContent::InputText { text: text.clone() }
529                                }
530                            };
531                            content_parts.push(part);
532                        }
533                        ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {}
534                        ContentBlock::Image { source } => {
535                            content_parts.push(ApiInputContent::Image {
536                                image_url: format!(
537                                    "data:{};base64,{}",
538                                    source.media_type, source.data
539                                ),
540                            });
541                        }
542                        ContentBlock::Document { source } => {
543                            content_parts.push(ApiInputContent::File {
544                                filename: suggested_filename(&source.media_type),
545                                file_data: format!(
546                                    "data:{};base64,{}",
547                                    source.media_type, source.data
548                                ),
549                            });
550                        }
551                        ContentBlock::ToolUse {
552                            id, name, input, ..
553                        } => {
554                            items.push(ApiInputItem::FunctionCall(ApiFunctionCall::new(
555                                id.clone(),
556                                name.clone(),
557                                serde_json::to_string(input).unwrap_or_default(),
558                            )));
559                        }
560                        ContentBlock::ToolResult {
561                            tool_use_id,
562                            content,
563                            ..
564                        } => {
565                            items.push(ApiInputItem::FunctionCallOutput(
566                                ApiFunctionCallOutput::new(tool_use_id.clone(), content.clone()),
567                            ));
568                        }
569                        // `ContentBlock` is `#[non_exhaustive]`; a block kind this
570                        // SDK version cannot represent on the wire is skipped.
571                        _ => {
572                            log::warn!("Skipping unrecognized OpenAI Responses content block");
573                        }
574                    }
575                }
576
577                if !content_parts.is_empty() {
578                    items.push(ApiInputItem::Message(ApiMessage {
579                        role: match msg.role {
580                            agent_sdk_foundation::llm::Role::User => ApiRole::User,
581                            agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
582                        },
583                        content: ApiMessageContent::Parts(content_parts),
584                    }));
585                }
586            }
587        }
588    }
589
590    items
591}
592
593/// Recursively fix a JSON schema for `OpenAI` strict mode.
594/// Adds `additionalProperties: false` and ensures all properties are required.
595fn fix_schema_for_strict_mode(schema: &mut serde_json::Value) {
596    let Some(obj) = schema.as_object_mut() else {
597        return;
598    };
599
600    // Check if this is an object type schema
601    let is_object_type = obj
602        .get("type")
603        .is_some_and(|t| t.as_str() == Some("object"));
604
605    if is_object_type {
606        // Add additionalProperties: false
607        obj.insert(
608            "additionalProperties".to_owned(),
609            serde_json::Value::Bool(false),
610        );
611
612        // Ensure properties and required exist (strict mode needs them even if empty)
613        obj.entry("properties".to_owned())
614            .or_insert_with(|| serde_json::json!({}));
615        obj.entry("required".to_owned())
616            .or_insert_with(|| serde_json::json!([]));
617
618        // Collect the set of originally required keys
619        let originally_required: std::collections::HashSet<String> = obj
620            .get("required")
621            .and_then(|v| v.as_array())
622            .map(|arr| {
623                arr.iter()
624                    .filter_map(|v| v.as_str().map(String::from))
625                    .collect()
626            })
627            .unwrap_or_default();
628
629        // Wrap previously-optional properties in anyOf with null
630        if let Some(serde_json::Value::Object(props)) = obj.get_mut("properties") {
631            for (key, prop_schema) in props.iter_mut() {
632                if !originally_required.contains(key) {
633                    make_nullable(prop_schema);
634                }
635            }
636        }
637
638        // Ensure all properties are marked as required
639        if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
640            let all_keys: Vec<serde_json::Value> = props
641                .keys()
642                .map(|k| serde_json::Value::String(k.clone()))
643                .collect();
644            obj.insert("required".to_owned(), serde_json::Value::Array(all_keys));
645        }
646    }
647
648    // Recursively process nested schemas
649    if let Some(props) = obj.get_mut("properties")
650        && let Some(props_obj) = props.as_object_mut()
651    {
652        for prop_schema in props_obj.values_mut() {
653            fix_schema_for_strict_mode(prop_schema);
654        }
655    }
656
657    // Process array items
658    if let Some(items) = obj.get_mut("items") {
659        fix_schema_for_strict_mode(items);
660    }
661
662    // Process anyOf/oneOf/allOf
663    for key in ["anyOf", "oneOf", "allOf"] {
664        if let Some(arr) = obj.get_mut(key)
665            && let Some(arr_items) = arr.as_array_mut()
666        {
667            for item in arr_items {
668                fix_schema_for_strict_mode(item);
669            }
670        }
671    }
672}
673
674fn convert_tool(tool: agent_sdk_foundation::llm::Tool) -> ApiTool {
675    let mut schema = tool.input_schema;
676
677    // Strict mode requires additionalProperties: false on all objects and
678    // every property in required. This is incompatible with free-form object
679    // schemas (objects with no defined properties). Detect and skip strict
680    // for those tools.
681    let use_strict = if has_freeform_object(&schema) {
682        log::debug!(
683            "Tool '{}' has free-form object schema — disabling strict mode",
684            tool.name
685        );
686        None
687    } else {
688        fix_schema_for_strict_mode(&mut schema);
689        Some(true)
690    };
691
692    ApiTool {
693        r#type: "function".to_owned(),
694        name: tool.name,
695        description: Some(tool.description),
696        parameters: Some(schema),
697        strict: use_strict,
698    }
699}
700
701/// Check if a JSON schema contains any object-typed properties without
702/// defined `properties` (free-form objects). These are incompatible with
703/// `OpenAI` strict mode.
704/// Wrap a schema in `anyOf: [{original}, {"type": "null"}]` so that
705/// the property accepts its original type OR null.
706///
707/// If the schema already has an `anyOf`, appends `{"type": "null"}` to it.
708fn make_nullable(schema: &mut serde_json::Value) {
709    // Already nullable via anyOf — append null variant if missing
710    if let Some(any_of) = schema
711        .as_object_mut()
712        .and_then(|o| o.get_mut("anyOf"))
713        .and_then(|v| v.as_array_mut())
714    {
715        let has_null = any_of
716            .iter()
717            .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
718        if !has_null {
719            any_of.push(serde_json::json!({"type": "null"}));
720        }
721        return;
722    }
723
724    // Wrap the original schema in anyOf
725    let original = schema.clone();
726    *schema = serde_json::json!({
727        "anyOf": [original, {"type": "null"}]
728    });
729}
730
731fn has_freeform_object(schema: &serde_json::Value) -> bool {
732    let Some(obj) = schema.as_object() else {
733        return false;
734    };
735
736    let is_object = obj
737        .get("type")
738        .is_some_and(|t| t.as_str() == Some("object"));
739
740    if is_object && !obj.contains_key("properties") {
741        return true;
742    }
743
744    // Recurse into properties
745    if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
746        for prop in props.values() {
747            if has_freeform_object(prop) {
748                return true;
749            }
750        }
751    }
752
753    // Recurse into array items
754    if let Some(items) = obj.get("items")
755        && has_freeform_object(items)
756    {
757        return true;
758    }
759
760    // Recurse into anyOf/oneOf/allOf
761    for key in ["anyOf", "oneOf", "allOf"] {
762        if let Some(arr) = obj.get(key).and_then(|v| v.as_array()) {
763            for item in arr {
764                if has_freeform_object(item) {
765                    return true;
766                }
767            }
768        }
769    }
770
771    false
772}
773
774fn suggested_filename(media_type: &str) -> String {
775    match media_type {
776        "application/pdf" => "attachment.pdf".to_string(),
777        "image/png" => "image.png".to_string(),
778        "image/jpeg" => "image.jpg".to_string(),
779        "image/gif" => "image.gif".to_string(),
780        "image/webp" => "image.webp".to_string(),
781        _ => "attachment.bin".to_string(),
782    }
783}
784
785fn build_content_blocks(output: &[ApiOutputItem]) -> Vec<ContentBlock> {
786    let mut blocks = Vec::new();
787
788    for item in output {
789        match item {
790            ApiOutputItem::Message { content, .. } => {
791                for c in content {
792                    if let ApiOutputContent::Text { text } = c
793                        && !text.is_empty()
794                    {
795                        blocks.push(ContentBlock::Text { text: text.clone() });
796                    }
797                }
798            }
799            ApiOutputItem::FunctionCall {
800                call_id,
801                name,
802                arguments,
803                ..
804            } => {
805                let input =
806                    serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
807                blocks.push(ContentBlock::ToolUse {
808                    id: call_id.clone(),
809                    name: name.clone(),
810                    input,
811                    thought_signature: None,
812                });
813            }
814            ApiOutputItem::Unknown => {
815                // Skip unknown output types
816            }
817        }
818    }
819
820    blocks
821}
822
823fn build_api_reasoning(thinking: Option<&ThinkingConfig>) -> Option<ApiReasoning> {
824    thinking
825        .and_then(resolve_reasoning_effort)
826        .map(|effort| ApiReasoning { effort })
827}
828
829const fn resolve_reasoning_effort(config: &ThinkingConfig) -> Option<ReasoningEffort> {
830    if let Some(effort) = config.effort {
831        return Some(map_effort(effort));
832    }
833
834    match &config.mode {
835        ThinkingMode::Adaptive => None,
836        ThinkingMode::Enabled { budget_tokens } => Some(map_budget_to_reasoning(*budget_tokens)),
837    }
838}
839
840const fn map_effort(effort: Effort) -> ReasoningEffort {
841    match effort {
842        Effort::Low => ReasoningEffort::Low,
843        Effort::Medium => ReasoningEffort::Medium,
844        Effort::High => ReasoningEffort::High,
845        Effort::Max => ReasoningEffort::XHigh,
846    }
847}
848
849const fn map_reasoning_effort(effort: ReasoningEffort) -> Effort {
850    match effort {
851        ReasoningEffort::Low => Effort::Low,
852        ReasoningEffort::Medium => Effort::Medium,
853        ReasoningEffort::High => Effort::High,
854        ReasoningEffort::XHigh => Effort::Max,
855    }
856}
857
858const fn map_budget_to_reasoning(budget_tokens: u32) -> ReasoningEffort {
859    if budget_tokens <= 4_096 {
860        ReasoningEffort::Low
861    } else if budget_tokens <= 16_384 {
862        ReasoningEffort::Medium
863    } else if budget_tokens <= 32_768 {
864        ReasoningEffort::High
865    } else {
866        ReasoningEffort::XHigh
867    }
868}
869
870// ============================================================================
871// Streaming helpers
872// ============================================================================
873
874struct ToolCallAccumulator {
875    id: String,
876    name: String,
877    arguments: String,
878}
879
880// ============================================================================
881// API Request Types
882// ============================================================================
883
884#[derive(Serialize)]
885struct ApiResponsesRequest<'a> {
886    model: &'a str,
887    input: &'a [ApiInputItem],
888    #[serde(skip_serializing_if = "Option::is_none")]
889    tools: Option<&'a [ApiTool]>,
890    #[serde(skip_serializing_if = "Option::is_none")]
891    max_output_tokens: Option<u32>,
892    #[serde(skip_serializing_if = "Option::is_none")]
893    reasoning: Option<ApiReasoning>,
894    #[serde(skip_serializing_if = "Option::is_none")]
895    parallel_tool_calls: Option<bool>,
896}
897
898#[derive(Serialize)]
899struct ApiResponsesRequestStreaming<'a> {
900    model: &'a str,
901    input: &'a [ApiInputItem],
902    #[serde(skip_serializing_if = "Option::is_none")]
903    tools: Option<&'a [ApiTool]>,
904    #[serde(skip_serializing_if = "Option::is_none")]
905    max_output_tokens: Option<u32>,
906    #[serde(skip_serializing_if = "Option::is_none")]
907    reasoning: Option<ApiReasoning>,
908    #[serde(skip_serializing_if = "Option::is_none")]
909    parallel_tool_calls: Option<bool>,
910    stream: bool,
911}
912
913#[derive(Serialize)]
914struct ApiReasoning {
915    effort: ReasoningEffort,
916}
917
918#[derive(Serialize)]
919#[serde(untagged)]
920enum ApiInputItem {
921    Message(ApiMessage),
922    FunctionCall(ApiFunctionCall),
923    FunctionCallOutput(ApiFunctionCallOutput),
924}
925
926#[derive(Serialize)]
927struct ApiMessage {
928    role: ApiRole,
929    content: ApiMessageContent,
930}
931
932#[derive(Serialize)]
933#[serde(rename_all = "lowercase")]
934enum ApiRole {
935    System,
936    User,
937    Assistant,
938}
939
940#[derive(Serialize)]
941#[serde(untagged)]
942enum ApiMessageContent {
943    Text(String),
944    Parts(Vec<ApiInputContent>),
945}
946
947#[derive(Serialize)]
948#[serde(tag = "type")]
949enum ApiInputContent {
950    #[serde(rename = "input_text")]
951    InputText { text: String },
952    #[serde(rename = "output_text")]
953    OutputText { text: String },
954    #[serde(rename = "input_image")]
955    Image { image_url: String },
956    #[serde(rename = "input_file")]
957    File { filename: String, file_data: String },
958}
959
960#[derive(Serialize)]
961struct ApiFunctionCall {
962    r#type: &'static str,
963    call_id: String,
964    name: String,
965    arguments: String,
966}
967
968impl ApiFunctionCall {
969    const fn new(call_id: String, name: String, arguments: String) -> Self {
970        Self {
971            r#type: "function_call",
972            call_id,
973            name,
974            arguments,
975        }
976    }
977}
978
979#[derive(Serialize)]
980struct ApiFunctionCallOutput {
981    r#type: &'static str,
982    call_id: String,
983    output: String,
984}
985
986impl ApiFunctionCallOutput {
987    const fn new(call_id: String, output: String) -> Self {
988        Self {
989            r#type: "function_call_output",
990            call_id,
991            output,
992        }
993    }
994}
995
996#[derive(Serialize)]
997struct ApiTool {
998    r#type: String,
999    name: String,
1000    #[serde(skip_serializing_if = "Option::is_none")]
1001    description: Option<String>,
1002    #[serde(skip_serializing_if = "Option::is_none")]
1003    parameters: Option<serde_json::Value>,
1004    #[serde(skip_serializing_if = "Option::is_none")]
1005    strict: Option<bool>,
1006}
1007
1008// ============================================================================
1009// API Response Types
1010// ============================================================================
1011
1012#[derive(Deserialize)]
1013struct ApiResponse {
1014    id: String,
1015    model: String,
1016    output: Vec<ApiOutputItem>,
1017    #[serde(default)]
1018    status: Option<ApiStatus>,
1019    #[serde(default)]
1020    usage: Option<ApiUsage>,
1021}
1022
1023#[derive(Deserialize)]
1024#[serde(rename_all = "snake_case")]
1025enum ApiStatus {
1026    Completed,
1027    Incomplete,
1028    Failed,
1029}
1030
1031#[derive(Deserialize)]
1032struct ApiUsage {
1033    input_tokens: u32,
1034    output_tokens: u32,
1035    #[serde(default)]
1036    input_tokens_details: Option<ApiInputTokensDetails>,
1037}
1038
1039#[derive(Deserialize)]
1040struct ApiInputTokensDetails {
1041    #[serde(default)]
1042    cached_tokens: u32,
1043}
1044
1045#[derive(Deserialize)]
1046#[serde(tag = "type")]
1047enum ApiOutputItem {
1048    #[serde(rename = "message")]
1049    Message {
1050        #[serde(rename = "role")]
1051        _role: String,
1052        content: Vec<ApiOutputContent>,
1053    },
1054    #[serde(rename = "function_call")]
1055    FunctionCall {
1056        call_id: String,
1057        name: String,
1058        arguments: String,
1059    },
1060    #[serde(other)]
1061    Unknown,
1062}
1063
1064#[derive(Deserialize)]
1065#[serde(tag = "type")]
1066enum ApiOutputContent {
1067    #[serde(rename = "output_text")]
1068    Text { text: String },
1069    #[serde(other)]
1070    Unknown,
1071}
1072
1073// ============================================================================
1074// Streaming Types
1075// ============================================================================
1076
1077#[derive(Deserialize)]
1078struct ApiStreamEvent {
1079    r#type: String,
1080    #[serde(default)]
1081    delta: Option<String>,
1082    /// Present on `output_item.added` / `output_item.done` for `function_call` items.
1083    #[serde(default)]
1084    item: Option<ApiStreamItem>,
1085    /// Present on `function_call_arguments.delta`.
1086    #[serde(default)]
1087    item_id: Option<String>,
1088    /// Legacy field — some older events use `call_id` instead of `item_id`.
1089    #[serde(default)]
1090    call_id: Option<String>,
1091    #[serde(default)]
1092    name: Option<String>,
1093    #[serde(default)]
1094    response: Option<ApiStreamResponse>,
1095}
1096
1097impl ApiStreamEvent {
1098    /// Resolve the item identifier from whichever field is present.
1099    fn resolve_item_id(&self) -> Option<&str> {
1100        self.item_id
1101            .as_deref()
1102            .or(self.call_id.as_deref())
1103            .or_else(|| self.item.as_ref().and_then(|i| i.id.as_deref()))
1104    }
1105}
1106
1107#[derive(Deserialize)]
1108struct ApiStreamItem {
1109    #[serde(default)]
1110    id: Option<String>,
1111    #[serde(default)]
1112    r#type: Option<String>,
1113    #[serde(default)]
1114    call_id: Option<String>,
1115    #[serde(default)]
1116    name: Option<String>,
1117}
1118
1119#[derive(Deserialize)]
1120struct ApiStreamResponse {
1121    #[serde(default)]
1122    usage: Option<ApiUsage>,
1123}
1124
1125// ============================================================================
1126// Tests
1127// ============================================================================
1128
1129#[cfg(test)]
1130mod tests {
1131    use super::*;
1132
1133    #[test]
1134    fn test_model_constant() {
1135        assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
1136        assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
1137    }
1138
1139    #[test]
1140    fn test_codex_factory() {
1141        let provider = OpenAIResponsesProvider::codex("test-key".to_string());
1142        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1143        assert_eq!(provider.provider(), "openai-responses");
1144    }
1145
1146    #[test]
1147    fn test_gpt53_codex_factory() {
1148        let provider = OpenAIResponsesProvider::gpt53_codex("test-key".to_string());
1149        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1150        assert_eq!(provider.provider(), "openai-responses");
1151    }
1152
1153    #[test]
1154    fn test_reasoning_effort_serialization() {
1155        let low = serde_json::to_string(&ReasoningEffort::Low).unwrap();
1156        assert_eq!(low, "\"low\"");
1157
1158        let xhigh = serde_json::to_string(&ReasoningEffort::XHigh).unwrap();
1159        assert_eq!(xhigh, "\"xhigh\"");
1160    }
1161
1162    #[test]
1163    fn test_with_reasoning_effort() {
1164        let provider = OpenAIResponsesProvider::codex("test-key".to_string())
1165            .with_reasoning_effort(ReasoningEffort::High);
1166        let thinking = provider.thinking.as_ref().unwrap();
1167        assert!(matches!(thinking.effort, Some(Effort::High)));
1168    }
1169
1170    #[test]
1171    fn test_build_api_reasoning_uses_explicit_effort() {
1172        let reasoning =
1173            build_api_reasoning(Some(&ThinkingConfig::adaptive_with_effort(Effort::Low))).unwrap();
1174        assert!(matches!(reasoning.effort, ReasoningEffort::Low));
1175    }
1176
1177    #[test]
1178    fn test_build_api_reasoning_omits_adaptive_without_effort() {
1179        assert!(build_api_reasoning(Some(&ThinkingConfig::adaptive())).is_none());
1180    }
1181
1182    #[test]
1183    fn test_openai_responses_rejects_adaptive_thinking() {
1184        let provider = OpenAIResponsesProvider::codex("test-key".to_string());
1185        let error = provider
1186            .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1187            .unwrap_err();
1188        assert!(
1189            error
1190                .to_string()
1191                .contains("adaptive thinking is not supported")
1192        );
1193    }
1194
1195    #[test]
1196    fn test_api_tool_serialization() {
1197        let tool = ApiTool {
1198            r#type: "function".to_owned(),
1199            name: "get_weather".to_owned(),
1200            description: Some("Get weather".to_owned()),
1201            parameters: Some(serde_json::json!({"type": "object"})),
1202            strict: Some(true),
1203        };
1204
1205        let json = serde_json::to_string(&tool).unwrap();
1206        assert!(json.contains("\"type\":\"function\""));
1207        assert!(json.contains("\"name\":\"get_weather\""));
1208        assert!(json.contains("\"strict\":true"));
1209    }
1210
1211    #[test]
1212    fn test_api_response_deserialization() {
1213        let json = r#"{
1214            "id": "resp_123",
1215            "model": "gpt-5.2-codex",
1216            "output": [
1217                {
1218                    "type": "message",
1219                    "role": "assistant",
1220                    "content": [
1221                        {"type": "output_text", "text": "Hello!"}
1222                    ]
1223                }
1224            ],
1225            "status": "completed",
1226            "usage": {
1227                "input_tokens": 100,
1228                "output_tokens": 50
1229            }
1230        }"#;
1231
1232        let response: ApiResponse = serde_json::from_str(json).unwrap();
1233        assert_eq!(response.id, "resp_123");
1234        assert_eq!(response.model, "gpt-5.2-codex");
1235        assert_eq!(response.output.len(), 1);
1236    }
1237
1238    #[test]
1239    fn test_api_response_with_function_call() {
1240        let json = r#"{
1241            "id": "resp_456",
1242            "model": "gpt-5.2-codex",
1243            "output": [
1244                {
1245                    "type": "function_call",
1246                    "call_id": "call_abc",
1247                    "name": "read_file",
1248                    "arguments": "{\"path\": \"test.txt\"}"
1249                }
1250            ],
1251            "status": "completed"
1252        }"#;
1253
1254        let response: ApiResponse = serde_json::from_str(json).unwrap();
1255        assert_eq!(response.output.len(), 1);
1256
1257        match &response.output[0] {
1258            ApiOutputItem::FunctionCall {
1259                call_id,
1260                name,
1261                arguments,
1262            } => {
1263                assert_eq!(call_id, "call_abc");
1264                assert_eq!(name, "read_file");
1265                assert!(arguments.contains("test.txt"));
1266            }
1267            _ => panic!("Expected FunctionCall"),
1268        }
1269    }
1270
1271    #[test]
1272    fn test_build_content_blocks_text() {
1273        let output = vec![ApiOutputItem::Message {
1274            _role: "assistant".to_owned(),
1275            content: vec![ApiOutputContent::Text {
1276                text: "Hello!".to_owned(),
1277            }],
1278        }];
1279
1280        let blocks = build_content_blocks(&output);
1281        assert_eq!(blocks.len(), 1);
1282        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello!"));
1283    }
1284
1285    #[test]
1286    fn test_build_content_blocks_function_call() {
1287        let output = vec![ApiOutputItem::FunctionCall {
1288            call_id: "call_123".to_owned(),
1289            name: "test_tool".to_owned(),
1290            arguments: r#"{"key": "value"}"#.to_owned(),
1291        }];
1292
1293        let blocks = build_content_blocks(&output);
1294        assert_eq!(blocks.len(), 1);
1295        assert!(
1296            matches!(&blocks[0], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "test_tool")
1297        );
1298    }
1299}