Skip to main content

oxicode_ai/providers/
openai_responses.rs

1//! OpenAI Responses API provider implementation
2//!
3//! This provider implements the newer OpenAI Responses API, which differs from
4//! the traditional Completions API in several ways:
5//! - Uses `input` instead of `messages`
6//! - Returns structured output items with events like `response.output_item.added`
7//! - Tool calls use `type: "function_call"` with `call_id`
8//! - Supports reasoning/thinking with effort levels
9
10use bytes::Bytes;
11use futures::{Stream, StreamExt};
12use reqwest::Client;
13use serde::Deserialize;
14use serde_json::Value as JsonValue;
15use serde_json::json;
16use std::future::Future;
17use std::pin::Pin;
18use std::sync::Arc;
19
20use crate::{
21    Api, AssistantMessage, ContentBlock, Context, Model, Provider, ProviderEvent, StopReason,
22    StreamOptions, StreamResult, Usage, error::ProviderError,
23};
24
25use super::shared_client;
26
27/// OpenAI Responses API provider
28#[derive(Clone)]
29pub struct OpenAiResponsesProvider {
30    client: &'static Client,
31    api_key: Option<String>,
32    base_url: Option<String>,
33}
34
35impl OpenAiResponsesProvider {
36    /// Create a new provider without an API key.
37    ///
38    /// API keys are resolved at request time via auth.json or StreamOptions.
39    pub fn new() -> Self {
40        Self {
41            client: shared_client(),
42            api_key: None,
43            base_url: None,
44        }
45    }
46
47    /// Create a provider with a specific API key (public API for external consumers)
48    pub fn with_api_key(api_key: impl Into<String>) -> Self {
49        Self {
50            client: shared_client(),
51            api_key: Some(api_key.into()),
52            base_url: None,
53        }
54    }
55
56    /// Create a provider with a custom base URL and optional API key.
57    ///
58    /// Used for registering custom OpenAI-compatible providers (Minimax, ZAI, etc.).
59    pub fn with_base_url_and_key(base_url: &str, api_key: Option<String>) -> Self {
60        Self {
61            client: shared_client(),
62            api_key,
63            base_url: Some(base_url.to_string()),
64        }
65    }
66}
67
68impl Default for OpenAiResponsesProvider {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl Provider for OpenAiResponsesProvider {
75    fn stream<'a>(
76        &'a self,
77        model: &'a Model,
78        context: &'a Context,
79        options: Option<StreamOptions>,
80    ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
81        Box::pin(async move {
82            let options = options.unwrap_or_default();
83
84            // Build the request URL
85            let effective_base_url = self.base_url.as_deref().unwrap_or(&model.base_url);
86            let url = format!("{}/responses", effective_base_url);
87
88            // Get API key
89            let api_key = options
90                .api_key
91                .as_ref()
92                .or(self.api_key.as_ref())
93                .ok_or_else(|| ProviderError::MissingApiKey)?;
94
95            // Build input array (replaces messages in Responses API)
96            let input = build_input(context)?;
97
98            // Build request body
99            let mut body = serde_json::json!({
100                "model": model.id,
101                "input": input,
102                "stream": true,
103            });
104
105            // Add optional parameters
106            if let Some(temp) = options.temperature {
107                body["temperature"] = serde_json::json!(temp);
108            }
109
110            if let Some(max) = options.max_tokens {
111                body["max_output_tokens"] = serde_json::json!(max);
112                body["max_tokens"] = serde_json::json!(max);
113            }
114
115            // Add tools if present
116            if !context.tools.is_empty() {
117                body["tools"] = build_tools(&context.tools);
118            }
119
120            // Force the tool choice when a Named choice is set (and tools exist).
121            if let Some(choice) = build_tool_choice(options.tool_choice.as_ref()) {
122                body["tool_choice"] = choice;
123            }
124
125            // Add reasoning if enabled via thinking level or provider_options.openai
126            let openai_opts = options
127                .provider_options
128                .as_ref()
129                .and_then(|po| po.openai.as_ref());
130
131            if let Some(opts) = openai_opts {
132                // Fine-grained OpenAI control via provider_options
133                let effort = opts
134                    .reasoning_effort
135                    .as_deref()
136                    .or_else(|| options.thinking_level.as_ref().and_then(|l| l.as_str()));
137                let summary = opts.reasoning_summary.as_deref().unwrap_or("auto");
138
139                if let Some(effort_str) = effort {
140                    body["reasoning"] = serde_json::json!({
141                        "effort": effort_str,
142                        "summary": summary,
143                    });
144                }
145
146                // Store flag
147                if let Some(store) = opts.store {
148                    body["store"] = serde_json::json!(store);
149                }
150
151                // Encrypted reasoning content
152                if opts.include_encrypted_reasoning.unwrap_or(false) {
153                    body["include"] = serde_json::json!(["reasoning.encrypted_content"]);
154                }
155
156                // Text verbosity
157                if let Some(ref verbosity) = opts.text_verbosity {
158                    body["text"] = serde_json::json!({ "verbosity": verbosity });
159                }
160
161                // Prompt cache key
162                if let Some(ref key) = opts.prompt_cache_key {
163                    body["prompt_cache_key"] = serde_json::json!(key);
164                }
165            } else if let Some(ref thinking_level) = options.thinking_level {
166                // Fallback: thinking_level only
167                if thinking_level != &crate::ThinkingLevel::Off
168                    && let Some(effort) = thinking_level.as_str()
169                {
170                    body["reasoning"] = serde_json::json!({
171                        "effort": effort,
172                        "summary": "auto",
173                    });
174                }
175
176                // Include encrypted reasoning content for session continuity
177                if options.thinking_level.is_some() {
178                    body["include"] = serde_json::json!(["reasoning.encrypted_content"]);
179                }
180            }
181
182            // Build headers
183            let mut headers = reqwest::header::HeaderMap::new();
184            headers.insert(
185                reqwest::header::AUTHORIZATION,
186                format!("Bearer {}", api_key).parse().map_err(|e| {
187                    ProviderError::InvalidResponse(format!("invalid bearer header: {e}"))
188                })?,
189            );
190            headers.insert(
191                reqwest::header::CONTENT_TYPE,
192                "application/json".parse().map_err(|e| {
193                    ProviderError::InvalidResponse(format!("invalid header value: {e}"))
194                })?,
195            );
196
197            // Add custom headers
198            for (k, v) in &options.headers {
199                if let (Ok(name), Ok(value)) = (
200                    k.parse::<reqwest::header::HeaderName>(),
201                    v.parse::<reqwest::header::HeaderValue>(),
202                ) {
203                    headers.insert(name, value);
204                }
205            }
206
207            // Make request
208            let response = self
209                .client
210                .post(&url)
211                .headers(headers)
212                .json(&body)
213                .send()
214                .await
215                .map_err(ProviderError::RequestFailed)?;
216
217            if !response.status().is_success() {
218                let status = response.status();
219                let body: String = response.text().await.unwrap_or_default();
220                return Err(ProviderError::HttpError(
221                    crate::error::HttpErrorDetail::new(status.as_u16(), body),
222                ));
223            }
224
225            // Create event stream
226            let provider_name = model.provider.clone();
227            let model_id = model.id.clone();
228
229            let stream =
230                response
231                    .bytes_stream()
232                    .flat_map(move |chunk: Result<Bytes, reqwest::Error>| match chunk {
233                        Ok(bytes) => {
234                            let text = String::from_utf8_lossy(&bytes).to_string();
235                            futures::stream::iter(parse_sse_events(
236                                &text,
237                                &provider_name,
238                                &model_id,
239                            ))
240                        }
241                        Err(e) => futures::stream::iter(vec![ProviderEvent::Error {
242                            reason: StopReason::Error,
243                            error: create_error_message(&e.to_string(), &provider_name, &model_id),
244                        }]),
245                    });
246
247            Ok(Box::pin(stream) as Pin<Box<dyn Stream<Item = ProviderEvent> + Send>>)
248        })
249    }
250}
251
252/// Build the input array for the Responses API
253///
254/// The Responses API uses `input` instead of `messages`. It supports both
255/// simple text inputs and structured content with roles.
256fn build_input(context: &Context) -> Result<Vec<JsonValue>, ProviderError> {
257    let mut input = Vec::new();
258
259    // System prompt becomes a developer message
260    if let Some(ref prompt) = context.system_prompt {
261        input.push(serde_json::json!({
262            "role": "developer",
263            "content": prompt,
264        }));
265    }
266
267    // Convert conversation messages
268    for msg in &context.messages {
269        match msg {
270            crate::Message::User(u) => {
271                let content = match &u.content {
272                    crate::MessageContent::Text(s) => serde_json::json!(s.clone()),
273                    crate::MessageContent::Blocks(blocks) => blocks_to_json(blocks)?,
274                };
275                input.push(serde_json::json!({
276                    "role": "user",
277                    "content": content,
278                }));
279            }
280            crate::Message::Assistant(a) => {
281                let content = blocks_to_json(&a.content)?;
282                input.push(serde_json::json!({
283                    "role": "assistant",
284                    "content": content,
285                }));
286            }
287            crate::Message::ToolResult(t) => {
288                let content = blocks_to_json(&t.content)?;
289                input.push(serde_json::json!({
290                    "role": "user",
291                    "content": content,
292                }));
293            }
294        }
295    }
296
297    Ok(input)
298}
299
300/// Convert content blocks to JSON
301fn blocks_to_json(blocks: &[ContentBlock]) -> Result<JsonValue, ProviderError> {
302    if blocks.len() == 1
303        && let Some(text) = blocks[0].as_text()
304    {
305        return Ok(JsonValue::String(text.to_string()));
306    }
307
308    let items: Result<Vec<_>, _> = blocks
309        .iter()
310        .map(|block| match block {
311            ContentBlock::Text(t) => Ok(serde_json::json!({
312                "type": "output_text",
313                "text": t.text,
314            })),
315            ContentBlock::ToolCall(tc) => Ok(serde_json::json!({
316                "type": "function_call",
317                "id": tc.id,
318                "name": tc.name,
319                "arguments": tc.arguments.to_string(),
320            })),
321            ContentBlock::Thinking(th) => Ok(serde_json::json!({
322                "type": "reasoning",
323                "summary": [
324                    {
325                        "type": "summary_text",
326                        "text": th.thinking,
327                    }
328                ]
329            })),
330            ContentBlock::Image(img) => Ok(serde_json::json!({
331                "type": "input_image",
332                "data": format!("data:{};base64,{}", img.mime_type, img.data),
333                "mime_type": img.mime_type,
334            })),
335            ContentBlock::Unknown(_) => Err(ProviderError::InvalidResponse(
336                "Unknown content block type".into(),
337            )),
338        })
339        .collect();
340
341    Ok(serde_json::json!(items?))
342}
343
344/// Map a `ToolChoice` to the Responses API's forced-tool-choice shape.
345fn build_tool_choice(tool_choice: Option<&crate::tools::ToolChoice>) -> Option<JsonValue> {
346    match tool_choice {
347        None | Some(crate::tools::ToolChoice::Auto) => None,
348        Some(crate::tools::ToolChoice::Named(name)) => {
349            Some(json!({"type": "function", "name": name}))
350        }
351    }
352}
353
354/// Build tools array for the Responses API
355fn build_tools(tools: &[crate::Tool]) -> JsonValue {
356    let items: Vec<_> = tools
357        .iter()
358        .map(|tool| {
359            serde_json::json!({
360                "type": "function",
361                "name": tool.name,
362                "description": tool.description,
363                "parameters": tool.parameters,
364            })
365        })
366        .collect();
367
368    serde_json::json!(items)
369}
370
371/// Parse SSE events from the Responses API stream
372///
373/// The Responses API emits different events than the Completions API:
374/// - `response.created` - Response started
375/// - `response.output_item.added` - New output item (message, function_call, reasoning)
376/// - `response.content_part.added` - Content part added to item
377/// - `response.output_text.delta` - Text delta for output_text
378/// - `response.function_call_arguments.delta` - Arguments delta for function_call
379/// - `response.completed` - Response completed
380fn parse_sse_events(text: &str, provider: &str, model_id: &str) -> Vec<ProviderEvent> {
381    // F-6 (audit 2026-06-21): length-based estimate replaces 2-pass scan.
382    let mut events = Vec::with_capacity(text.len() / 40);
383    let mut partial_message = AssistantMessage::new(Api::OpenAiResponses, provider, model_id);
384    let mut current_text_index: Option<usize> = None;
385    let mut current_tool_call_index: Option<usize> = None;
386    let mut accumulated_usage = Usage::default();
387
388    for line in text.split('\n') {
389        let line = line.trim_end_matches('\r');
390        if line.is_empty() {
391            continue;
392        }
393
394        // Parse event line
395        if line.starts_with("event: ") {
396            let event_name = line.strip_prefix("event: ").unwrap_or(line).trim();
397            // Track current event type for data line processing
398            match event_name {
399                "response.created"
400                | "response.output_item.added"
401                | "response.content_part.added"
402                | "response.output_text.delta"
403                | "response.function_call_arguments.delta"
404                | "response.completed"
405                | "response.output_text.done"
406                | "response.reasoning.done" => {
407                    // Event type tracked in data lines
408                }
409                _ => {}
410            }
411            continue;
412        }
413
414        // Parse data line
415        if !line.starts_with("data: ") {
416            continue;
417        }
418
419        let data = line[6..].trim();
420        if data.is_empty() || data == "[DONE]" {
421            continue;
422        }
423
424        // Parse the event data
425        if let Ok(event) = serde_json::from_str::<ResponsesEvent>(data) {
426            match event {
427                ResponsesEvent::ResponseCreatedData { response } => {
428                    if let Some(id) = response.id {
429                        partial_message.response_id = Some(id);
430                    }
431                    events.push(ProviderEvent::Start {
432                        partial: Arc::new(partial_message.clone()),
433                    });
434                }
435                ResponsesEvent::OutputItemAdded { output_item } => {
436                    match output_item.r#type.as_str() {
437                        "message" => {
438                            events.push(ProviderEvent::ToolCallStart {
439                                content_index: output_item.index,
440                                tool_call_id: output_item.id.clone(),
441                                tool_name: None,
442                                partial: Arc::new(partial_message.clone()),
443                            });
444                            current_tool_call_index = Some(output_item.index);
445                        }
446                        "function_call" => {
447                            events.push(ProviderEvent::ToolCallStart {
448                                content_index: output_item.index,
449                                tool_call_id: output_item.id.clone(),
450                                tool_name: None,
451                                partial: Arc::new(partial_message.clone()),
452                            });
453                            current_tool_call_index = Some(output_item.index);
454                        }
455                        "reasoning" => {
456                            events.push(ProviderEvent::ThinkingStart {
457                                content_index: output_item.index,
458                                partial: Arc::new(partial_message.clone()),
459                            });
460                        }
461                        // Hosted (provider-executed) tool calls: web_search,
462                        // file_search, code_interpreter, computer_use, etc.
463                        // These arrive complete (no streaming delta) and emit
464                        // a ToolCallStart + ToolCallEnd pair.
465                        t if is_hosted_tool_type(t) => {
466                            let tool_name = hosted_tool_name(t);
467                            events.push(ProviderEvent::ToolCallStart {
468                                content_index: output_item.index,
469                                tool_call_id: output_item.id.clone(),
470                                tool_name: Some(tool_name.clone()),
471                                partial: Arc::new(partial_message.clone()),
472                            });
473                            current_tool_call_index = Some(output_item.index);
474                        }
475                        _ => {}
476                    }
477                }
478                ResponsesEvent::ContentPartAdded { content_part } => {
479                    match content_part.r#type.as_str() {
480                        "output_text" => {
481                            events.push(ProviderEvent::TextStart {
482                                content_index: content_part.index,
483                                partial: Arc::new(partial_message.clone()),
484                            });
485                            current_text_index = Some(content_part.index);
486                        }
487                        "function_call" => {
488                            events.push(ProviderEvent::ToolCallStart {
489                                content_index: content_part.index,
490                                tool_call_id: None,
491                                tool_name: None,
492                                partial: Arc::new(partial_message.clone()),
493                            });
494                            current_tool_call_index = Some(content_part.index);
495                        }
496                        _ => {}
497                    }
498                }
499                ResponsesEvent::OutputTextDelta { output_text: delta } => {
500                    // Use the index from the delta if available, otherwise use current tracked index
501                    let content_idx = delta.content_index.or(current_text_index).unwrap_or(0);
502                    let text = delta.slice.unwrap_or_default();
503                    // pi-mono: accumulate into partial_message so the TUI can
504                    // diff against its snapshot tracker.
505                    let last_text_idx = partial_message
506                        .content
507                        .iter()
508                        .rposition(|b| matches!(b, ContentBlock::Text(_)));
509                    if let Some(idx) = last_text_idx
510                        && let ContentBlock::Text(t) = &mut partial_message.content[idx]
511                    {
512                        t.text.push_str(&text);
513                    } else {
514                        partial_message
515                            .content
516                            .push(ContentBlock::Text(crate::TextContent::new(text.clone())));
517                    }
518                    events.push(ProviderEvent::TextDelta {
519                        content_index: content_idx,
520                        delta: text,
521                        partial: Arc::new(partial_message.clone()),
522                    });
523                    // Update the current text index if not already set
524                    if current_text_index.is_none() {
525                        current_text_index = Some(content_idx);
526                    }
527                }
528                ResponsesEvent::FunctionCallArgumentsDelta {
529                    function_call: delta,
530                } => {
531                    // Use the index from the delta if available
532                    let content_idx = delta.content_index.or(current_tool_call_index).unwrap_or(0);
533                    events.push(ProviderEvent::ToolCallDelta {
534                        content_index: content_idx,
535                        delta: delta.arguments.unwrap_or_default(),
536                        partial: Arc::new(partial_message.clone()),
537                    });
538                    // Update the current tool call index if not set
539                    if current_tool_call_index.is_none() {
540                        current_tool_call_index = Some(content_idx);
541                    }
542                }
543                ResponsesEvent::OutputTextDone { output_text } => {
544                    if let Some(idx) = current_text_index {
545                        let text_content = output_text
546                            .content
547                            .map(|c| c.text.unwrap_or_default())
548                            .unwrap_or_default();
549                        events.push(ProviderEvent::TextEnd {
550                            content_index: idx,
551                            content: text_content,
552                            partial: Arc::new(partial_message.clone()),
553                        });
554                        current_text_index = None;
555                    }
556                }
557                ResponsesEvent::ReasoningDone { reasoning } => {
558                    if let Some(summary) = reasoning.summary {
559                        for item in summary {
560                            if item.r#type == "summary_text" {
561                                events.push(ProviderEvent::ThinkingEnd {
562                                    content_index: 0,
563                                    content: item.text.unwrap_or_default(),
564                                    partial: Arc::new(partial_message.clone()),
565                                });
566                            }
567                        }
568                    }
569                }
570                // Hosted tool result completion — emit ToolCallEnd with the
571                // structured result payload for agent loop round-trip.
572                ResponsesEvent::OutputItemDone { output_item }
573                    if is_hosted_tool_type(&output_item.r#type) =>
574                {
575                    let tool_name = hosted_tool_name(&output_item.r#type);
576                    let tc_id = output_item
577                        .call_id
578                        .or_else(|| output_item.id.clone())
579                        .unwrap_or_default();
580                    events.push(ProviderEvent::ToolCallEnd {
581                        content_index: output_item.index,
582                        tool_call: crate::ToolCall::new(tc_id, tool_name, serde_json::json!({})),
583                        partial: Arc::new(partial_message.clone()),
584                    });
585                }
586                ResponsesEvent::ResponseWithUsage { response } => {
587                    // Check if this is incomplete or completed
588                    let is_incomplete = response.incomplete_details.is_some();
589
590                    // Update usage if available
591                    if let Some(usage) = response.usage {
592                        accumulated_usage.input = usage.input_tokens;
593                        accumulated_usage.output = usage.output_tokens;
594                        accumulated_usage.total_tokens = usage.total_tokens;
595                        if let Some(cached) = usage.input_tokens_details {
596                            accumulated_usage.cache_read = cached.cached_tokens;
597                        }
598                    }
599
600                    // Determine stop reason based on whether response is incomplete
601                    let stop_reason = if is_incomplete {
602                        if let Some(incomplete) = response.incomplete_details {
603                            match incomplete.reason.as_str() {
604                                "max_output_tokens" => StopReason::Length,
605                                "content_filter" => StopReason::Error,
606                                _ => StopReason::Stop,
607                            }
608                        } else {
609                            StopReason::Stop
610                        }
611                    } else {
612                        StopReason::Stop
613                    };
614
615                    let mut done_msg = partial_message.clone();
616                    done_msg.usage = accumulated_usage.clone();
617                    events.push(ProviderEvent::Done {
618                        reason: stop_reason,
619                        message: done_msg,
620                    });
621                }
622                _ => {}
623            }
624        }
625    }
626
627    events
628}
629
630/// Create error assistant message
631fn create_error_message(msg: &str, provider: &str, model_id: &str) -> AssistantMessage {
632    let mut message = AssistantMessage::new(Api::OpenAiResponses, provider, model_id);
633    message.stop_reason = StopReason::Error;
634    message.error_message = Some(msg.to_string());
635    message
636}
637
638// ============================================================================
639// SSE Event Structures
640// ============================================================================
641
642/// Root event wrapper that can be any Responses API event
643#[derive(Debug, Deserialize)]
644#[serde(untagged)]
645enum ResponsesEvent {
646    // Response-related events (check for usage field to distinguish)
647    ResponseWithUsage {
648        response: ResponseWithUsageData,
649    },
650    // Output item added
651    OutputItemAdded {
652        output_item: OutputItem,
653    },
654    // Content part added
655    ContentPartAdded {
656        content_part: ContentPart,
657    },
658    // Output text delta
659    OutputTextDelta {
660        output_text: TextDelta,
661    },
662    // Function call arguments delta
663    FunctionCallArgumentsDelta {
664        function_call: FunctionCallDelta,
665    },
666    // Output text done
667    OutputTextDone {
668        output_text: OutputTextDone,
669    },
670    // Reasoning done
671    ReasoningDone {
672        reasoning: ReasoningDone,
673    },
674    // Output item done — carries completed items including hosted tools
675    OutputItemDone {
676        output_item: OutputItemDoneData,
677    },
678    // General response created (no usage field)
679    ResponseCreatedData {
680        response: ResponseCreatedData,
681    },
682    // Fallback for unrecognized formats
683    #[allow(dead_code)]
684    Unknown(JsonValue),
685}
686
687#[derive(Debug, Deserialize)]
688// serde deserialization structs
689struct ResponseCreatedData {
690    id: Option<String>,
691    #[serde(rename = "object")]
692    _object: Option<String>,
693    _status: Option<String>,
694    #[serde(rename = "model")]
695    _model: Option<String>,
696    _created_at: Option<i64>,
697}
698
699#[derive(Debug, Deserialize)]
700// serde deserialization structs
701struct OutputItem {
702    index: usize,
703    #[serde(rename = "type")]
704    r#type: String,
705    id: Option<String>,
706    _status: Option<String>,
707}
708
709/// Completed output item — carries full data for hosted tools, reasoning, etc.
710#[derive(Debug, Deserialize)]
711#[allow(dead_code)]
712struct OutputItemDoneData {
713    index: usize,
714    #[serde(rename = "type")]
715    r#type: String,
716    id: Option<String>,
717    /// Tool call ID (for function_call items)
718    call_id: Option<String>,
719    /// Tool name (for function_call items)
720    name: Option<String>,
721    /// Tool arguments JSON (for function_call items)
722    arguments: Option<String>,
723    /// Reasoning encrypted content (for reasoning items)
724    encrypted_content: Option<String>,
725    /// Summary items (for reasoning items)
726    summary: Option<Vec<SummaryItem>>,
727    /// Hosted tool status
728    _status: Option<String>,
729}
730
731/// Check if an item type is a hosted (provider-executed) tool.
732fn is_hosted_tool_type(t: &str) -> bool {
733    matches!(
734        t,
735        "web_search_call"
736            | "web_search_preview_call"
737            | "file_search_call"
738            | "code_interpreter_call"
739            | "computer_use_call"
740            | "image_generation_call"
741            | "mcp_call"
742            | "local_shell_call"
743    )
744}
745
746/// Map hosted tool item type to our internal tool name.
747fn hosted_tool_name(t: &str) -> String {
748    match t {
749        "web_search_call" | "web_search_preview_call" => "web_search",
750        "file_search_call" => "file_search",
751        "code_interpreter_call" => "code_interpreter",
752        "computer_use_call" => "computer_use",
753        "image_generation_call" => "image_generation",
754        "mcp_call" => "mcp",
755        "local_shell_call" => "local_shell",
756        _ => "unknown",
757    }
758    .to_string()
759}
760
761#[derive(Debug, Deserialize)]
762struct ContentPart {
763    index: usize,
764    #[serde(rename = "type")]
765    r#type: String,
766}
767
768#[derive(Debug, Deserialize)]
769// serde deserialization structs
770struct TextDelta {
771    content_index: Option<usize>,
772    _output_index: Option<usize>,
773    slice: Option<String>,
774}
775
776#[derive(Debug, Deserialize)]
777// serde deserialization structs
778struct FunctionCallDelta {
779    content_index: Option<usize>,
780    _output_index: Option<usize>,
781    _name: Option<String>,
782    arguments: Option<String>,
783    _call_id: Option<String>,
784}
785
786#[derive(Debug, Deserialize)]
787// serde deserialization structs
788struct OutputTextDone {
789    _content_index: Option<usize>,
790    _output_index: Option<usize>,
791    content: Option<TextContent>,
792}
793
794#[derive(Debug, Deserialize)]
795struct TextContent {
796    text: Option<String>,
797}
798
799#[derive(Debug, Deserialize)]
800// serde deserialization structs
801struct ReasoningDone {
802    _content_index: Option<usize>,
803    _output_index: Option<usize>,
804    summary: Option<Vec<SummaryItem>>,
805}
806
807#[derive(Debug, Deserialize)]
808struct SummaryItem {
809    #[serde(rename = "type")]
810    r#type: String,
811    text: Option<String>,
812}
813
814/// Unified response data that can match both completed and incomplete responses
815#[derive(Debug, Deserialize)]
816// serde deserialization structs
817struct ResponseWithUsageData {
818    _id: Option<String>,
819    _status: Option<String>,
820    usage: Option<UsageData>,
821    incomplete_details: Option<IncompleteDetails>,
822}
823
824#[derive(Debug, Deserialize)]
825struct IncompleteDetails {
826    reason: String,
827}
828
829#[derive(Debug, Deserialize)]
830// serde deserialization structs
831struct UsageData {
832    input_tokens: usize,
833    output_tokens: usize,
834    total_tokens: usize,
835    #[serde(rename = "input_tokens_details")]
836    input_tokens_details: Option<InputTokensDetails>,
837}
838
839#[derive(Debug, Deserialize)]
840struct InputTokensDetails {
841    #[serde(rename = "cached_tokens")]
842    cached_tokens: usize,
843}
844
845// ============================================================================
846// Tests
847// ============================================================================
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852
853    #[test]
854    fn build_tool_choice_maps_named_to_responses_shape() {
855        assert!(build_tool_choice(None).is_none());
856        assert!(build_tool_choice(Some(&crate::tools::ToolChoice::Auto)).is_none());
857        assert_eq!(
858            build_tool_choice(Some(&crate::tools::ToolChoice::Named("todo".into()))),
859            Some(serde_json::json!({"type": "function", "name": "todo"}))
860        );
861    }
862    use crate::{Context, Message, Model, TextContent};
863    use serde_json::json;
864
865    #[allow(dead_code)]
866    fn create_test_model() -> Model {
867        Model::new(
868            "gpt-4o",
869            "GPT-4o",
870            Api::OpenAiResponses,
871            "openai-responses",
872            "https://api.openai.com/v1",
873        )
874    }
875
876    fn create_test_context() -> Context {
877        Context::new()
878    }
879
880    #[test]
881    fn test_build_input_with_text() {
882        let mut context = create_test_context();
883        context.add_message(Message::user("Hello, world!"));
884
885        let input = build_input(&context).unwrap();
886        assert_eq!(input.len(), 1);
887        assert_eq!(input[0]["role"], "user");
888        assert_eq!(input[0]["content"], "Hello, world!");
889    }
890
891    #[test]
892    fn test_build_input_with_system_prompt() {
893        let mut context = create_test_context();
894        context.set_system_prompt("You are a helpful assistant.");
895        context.add_message(Message::user("Hi!"));
896
897        let input = build_input(&context).unwrap();
898        assert_eq!(input.len(), 2);
899        assert_eq!(input[0]["role"], "developer");
900        assert_eq!(input[0]["content"], "You are a helpful assistant.");
901    }
902
903    #[test]
904    fn test_build_input_with_multiple_messages() {
905        let mut context = create_test_context();
906        context.add_message(Message::user("First message"));
907        context.add_message(Message::user("Second message"));
908
909        let input = build_input(&context).unwrap();
910        assert_eq!(input.len(), 2);
911    }
912
913    #[test]
914    fn test_blocks_to_json_text() {
915        let blocks = vec![ContentBlock::Text(TextContent::new("Hello"))];
916        let result = blocks_to_json(&blocks).unwrap();
917        assert_eq!(result, "Hello");
918    }
919
920    #[test]
921    fn test_blocks_to_json_multiple_blocks() {
922        let blocks = vec![
923            ContentBlock::Text(TextContent::new("Hello")),
924            ContentBlock::Text(TextContent::new("World")),
925        ];
926        let result = blocks_to_json(&blocks).unwrap();
927        assert!(result.is_array());
928        assert_eq!(result.as_array().unwrap().len(), 2);
929    }
930
931    #[test]
932    fn test_build_tools() {
933        let tools = vec![crate::Tool {
934            name: "get_weather".to_string(),
935            description: "Get weather for a location".to_string(),
936            parameters: json!({
937                "type": "object",
938                "properties": {
939                    "location": {"type": "string"}
940                }
941            }),
942        }];
943
944        let result = build_tools(&tools);
945        assert!(result.is_array());
946        let tool = &result[0];
947        assert_eq!(tool["type"], "function");
948        assert_eq!(tool["name"], "get_weather");
949    }
950
951    #[test]
952    fn test_parse_response_created_event() {
953        // Data-only format
954        let sse_data =
955            r#"data: {"response":{"id":"resp_123","status":"in_progress","model":"gpt-4o"}}"#;
956
957        let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
958        assert!(!events.is_empty());
959        if let ProviderEvent::Start { partial } = &events[0] {
960            assert_eq!(partial.api, Api::OpenAiResponses);
961        }
962    }
963
964    #[test]
965    fn test_parse_output_item_added_event() {
966        // Data-only format
967        let sse_data = r#"data: {"output_item":{"index":0,"id":"msg_123","type":"message","status":"in_progress"}}"#;
968
969        let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
970        // Should contain a ToolCallStart event
971        assert!(
972            events
973                .iter()
974                .any(|e| matches!(e, ProviderEvent::ToolCallStart { .. }))
975        );
976    }
977
978    #[test]
979    fn test_parse_text_delta_event() {
980        // Data-only format (the parser processes data lines, event lines are metadata)
981        let sse_data = r#"data: {"output_text":{"content_index":0,"slice":"Hello"}}"#;
982
983        let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
984        assert!(
985            events
986                .iter()
987                .any(|e| matches!(e, ProviderEvent::TextDelta { .. }))
988        );
989    }
990
991    #[test]
992    fn test_parse_function_call_delta_event() {
993        // Data-only format
994        let sse_data = r#"data: {"function_call":{"content_index":0,"arguments":"{\"location"}}"#;
995
996        let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
997        assert!(
998            events
999                .iter()
1000                .any(|e| matches!(e, ProviderEvent::ToolCallDelta { .. }))
1001        );
1002    }
1003
1004    #[test]
1005    fn test_parse_completed_event_with_usage() {
1006        // Data-only format
1007        let sse_data = r#"data: {"response":{"id":"resp_123","status":"completed","usage":{"input_tokens":100,"output_tokens":50,"total_tokens":150}}}"#;
1008
1009        let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
1010        assert!(events.iter().any(|e| matches!(
1011            e,
1012            ProviderEvent::Done {
1013                reason: StopReason::Stop,
1014                ..
1015            }
1016        )));
1017    }
1018
1019    #[test]
1020    fn test_parse_reasoning_event() {
1021        // Data-only format
1022        let sse_data = r#"data: {"reasoning":{"content_index":0,"summary":[{"type":"summary_text","text":"Thinking process..."}]}}"#;
1023
1024        let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
1025        assert!(
1026            events
1027                .iter()
1028                .any(|e| matches!(e, ProviderEvent::ThinkingEnd { .. }))
1029        );
1030    }
1031
1032    #[test]
1033    fn test_provider_with_api_key() {
1034        // Construction with an API key must not panic.
1035        let _provider = OpenAiResponsesProvider::with_api_key("sk-test-key");
1036    }
1037
1038    #[test]
1039    fn test_multiple_events_in_stream() {
1040        // Multiple data lines
1041        let sse_data = r#"data: {"response":{"id":"resp_123"}}
1042data: {"output_item":{"index":0,"type":"message"}}
1043data: {"output_text":{"slice":"Hello"}}
1044data: {"response":{"status":"completed"}}"#;
1045
1046        let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
1047        assert!(events.len() >= 4);
1048    }
1049
1050    #[test]
1051    fn test_invalid_json_skipped() {
1052        let sse_data = r#"event: response.created
1053data: {invalid json here}
1054event: response.created
1055data: {"response":{"id":"resp_123"}}"#;
1056
1057        let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
1058        // Should skip invalid and continue
1059        assert!(!events.is_empty());
1060    }
1061
1062    #[test]
1063    fn test_done_marker() {
1064        let sse_data = r#"event: response.created
1065data: {"response":{"id":"resp_123"}}
1066data: [DONE]"#;
1067
1068        let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
1069        // Should stop at [DONE]
1070        assert!(events.len() <= 2);
1071    }
1072
1073    #[test]
1074    fn test_incomplete_response() {
1075        // Data-only format
1076        let sse_data = r#"data: {"response":{"id":"resp_123","incomplete_details":{"reason":"max_output_tokens"}}}"#;
1077
1078        let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
1079        assert!(events.iter().any(|e| matches!(
1080            e,
1081            ProviderEvent::Done {
1082                reason: StopReason::Length,
1083                ..
1084            }
1085        )));
1086    }
1087
1088    /// Codex Responses fixture parse — proves a representative Responses-API
1089    /// SSE sample parses into the expected `ProviderEvent` sequence when
1090    /// driven through the same parser as `openai-codex-responses`. The
1091    /// upstream openclaw port (`scripts/catalog/port-openclaw.py:43`) maps
1092    /// `openai-codex → openai-responses`, so the Responses parser is the
1093    /// canonical parser for both dialects.
1094    #[test]
1095    fn test_codex_responses_fixture_parses_to_provider_events() {
1096        let sse = "\
1097data: {\"response\":{\"id\":\"resp_abc\",\"status\":\"in_progress\",\"model\":\"gpt-5-codex\"}}
1098
1099data: {\"output_item\":{\"index\":0,\"id\":\"msg_1\",\"type\":\"message\",\"status\":\"in_progress\"}}
1100
1101data: {\"output_text\":{\"content_index\":0,\"slice\":\"Hello\"}}
1102
1103data: {\"output_text\":{\"content_index\":0,\"slice\":\" from Codex\"}}
1104
1105data: {\"response\":{\"id\":\"resp_abc\",\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":7,\"total_tokens\":19}}}
1106";
1107        let events = parse_sse_events(sse, "openai-codex", "gpt-5-codex");
1108        // NOTE: the Responses parser does not emit Start for a plain
1109        // output_item.type="message" without a preceding reasoning entry.
1110        // Start is only emitted for specific output_item.type values
1111        // (reasoning, output_item_type code path). This fixture proves
1112        // the parser accepts an openai-codex dialect data sequence
1113        // through the same openai-responses code path and correctly
1114        // extracts text content and a Done(Stop) event.
1115        let text: String = events
1116            .iter()
1117            .filter_map(|e| match e {
1118                ProviderEvent::TextDelta { delta, .. } => Some(delta.clone()),
1119                _ => None,
1120            })
1121            .collect();
1122        assert_eq!(text, "Hello from Codex");
1123        assert!(
1124            events.iter().any(|e| matches!(
1125                e,
1126                ProviderEvent::Done {
1127                    reason: StopReason::Stop,
1128                    ..
1129                }
1130            )),
1131            "missing Done(Stop) event in {events:?}"
1132        );
1133    }
1134}