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