Skip to main content

zeroclaw_providers/
openai_codex.rs

1use crate::ModelProviderRuntimeOptions;
2use crate::auth::AuthService;
3use crate::auth::openai_oauth::extract_account_id_from_jwt;
4use crate::multimodal;
5use crate::traits::{
6    ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
7    ModelProvider, ProviderCapabilities, StreamChunk, StreamError, StreamEvent, StreamOptions,
8    StreamResult, ToolCall as ProviderToolCall,
9};
10use async_trait::async_trait;
11use futures_util::StreamExt;
12use futures_util::stream;
13use reqwest::Client;
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use std::collections::{HashMap, HashSet};
17use std::path::PathBuf;
18use zeroclaw_api::tool::ToolSpec;
19
20const DEFAULT_CODEX_RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses";
21const DEFAULT_CODEX_INSTRUCTIONS: &str =
22    "You are ZeroClaw, a concise and helpful coding assistant.";
23/// OpenAI Codex speaks the "responses" wire protocol, not chat_completions.
24const WIRE_API: &str = "responses";
25const RESPONSES_HISTORY_PROVIDER: &str = "openai_codex";
26const RESPONSES_HISTORY_KIND: &str = "responses_output_items";
27
28#[derive(Clone)]
29pub struct OpenAiCodexModelProvider {
30    /// `[model_providers.<family>.<alias>]` config-key alias.
31    alias: String,
32    auth: AuthService,
33    auth_profile_override: Option<String>,
34    responses_url: String,
35    custom_endpoint: bool,
36    gateway_api_key: Option<String>,
37    reasoning_effort: Option<String>,
38    client: Client,
39}
40
41#[derive(Debug, Serialize)]
42struct ResponsesRequest {
43    model: String,
44    input: Vec<Value>,
45    instructions: String,
46    store: bool,
47    stream: bool,
48    text: ResponsesTextOptions,
49    reasoning: ResponsesReasoningOptions,
50    include: Vec<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    tools: Option<Vec<ResponsesToolSpec>>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    tool_choice: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    parallel_tool_calls: Option<bool>,
57}
58
59#[derive(Debug, Serialize)]
60struct ResponsesToolSpec {
61    #[serde(rename = "type")]
62    kind: String,
63    name: String,
64    description: String,
65    parameters: Value,
66    strict: bool,
67}
68
69#[derive(Debug, Serialize)]
70struct ResponsesTextOptions {
71    verbosity: String,
72}
73
74#[derive(Debug, Serialize)]
75struct ResponsesReasoningOptions {
76    effort: String,
77    summary: String,
78}
79
80#[derive(Debug, Deserialize)]
81struct ResponsesResponse {
82    #[serde(default)]
83    output: Vec<Value>,
84    #[serde(default)]
85    output_text: Option<String>,
86}
87
88#[derive(Debug, Default)]
89struct ResponsesStreamState {
90    saw_text_delta: bool,
91    text_accumulator: String,
92    fallback_text: Option<String>,
93    tool_calls: HashMap<String, PendingToolCall>,
94    emitted_tool_call_ids: HashSet<String>,
95    collected_tool_calls: Vec<ProviderToolCall>,
96    output_items: Vec<Value>,
97}
98
99#[derive(Debug, Default, Clone)]
100struct PendingToolCall {
101    item_id: Option<String>,
102    call_id: Option<String>,
103    name: Option<String>,
104    arguments: String,
105}
106
107#[derive(Debug, Default)]
108struct ResponsesTurnResult {
109    text: Option<String>,
110    tool_calls: Vec<ProviderToolCall>,
111    reasoning_content: Option<String>,
112}
113
114impl OpenAiCodexModelProvider {
115    pub fn new(
116        alias: &str,
117        options: &ModelProviderRuntimeOptions,
118        gateway_api_key: Option<&str>,
119    ) -> anyhow::Result<Self> {
120        let state_dir = options
121            .zeroclaw_dir
122            .clone()
123            .unwrap_or_else(default_zeroclaw_dir);
124        let auth = AuthService::new(&state_dir, options.secrets_encrypt);
125        let responses_url = resolve_responses_url(options)?;
126
127        Ok(Self {
128            alias: alias.to_string(),
129            auth,
130            auth_profile_override: options.auth_profile_override.clone(),
131            custom_endpoint: !is_default_responses_url(&responses_url),
132            responses_url,
133            gateway_api_key: gateway_api_key.map(ToString::to_string),
134            reasoning_effort: options.reasoning_effort.clone(),
135            client: Client::builder()
136                .connect_timeout(std::time::Duration::from_secs(10))
137                .read_timeout(std::time::Duration::from_secs(300))
138                .build()
139                .unwrap_or_else(|_| Client::new()),
140        })
141    }
142}
143
144fn default_zeroclaw_dir() -> PathBuf {
145    directories::UserDirs::new().map_or_else(
146        || PathBuf::from(".zeroclaw"),
147        |dirs| dirs.home_dir().join(".zeroclaw"),
148    )
149}
150
151fn build_responses_url(base_or_endpoint: &str) -> anyhow::Result<String> {
152    let candidate = base_or_endpoint.trim();
153    if candidate.is_empty() {
154        anyhow::bail!("OpenAI Codex endpoint override cannot be empty");
155    }
156
157    let mut parsed = reqwest::Url::parse(candidate).map_err(|_| {
158        ::zeroclaw_log::record!(
159            WARN,
160            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
161                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
162                .with_attrs(::serde_json::json!({"candidate": candidate})),
163            "openai_codex: endpoint override is not a valid URL"
164        );
165        anyhow::Error::msg("OpenAI Codex endpoint override must be a valid URL")
166    })?;
167
168    match parsed.scheme() {
169        "http" | "https" => {}
170        _ => anyhow::bail!("OpenAI Codex endpoint override must use http:// or https://"),
171    }
172
173    let path = parsed.path().trim_end_matches('/');
174    if !path.ends_with("/responses") {
175        let with_suffix = if path.is_empty() || path == "/" {
176            "/responses".to_string()
177        } else {
178            format!("{path}/responses")
179        };
180        parsed.set_path(&with_suffix);
181    }
182
183    parsed.set_query(None);
184    parsed.set_fragment(None);
185
186    Ok(parsed.to_string())
187}
188
189fn resolve_responses_url(options: &ModelProviderRuntimeOptions) -> anyhow::Result<String> {
190    if let Some(api_url) = options
191        .provider_api_url
192        .as_deref()
193        .and_then(|value| first_nonempty(Some(value)))
194    {
195        return build_responses_url(&api_url);
196    }
197
198    Ok(DEFAULT_CODEX_RESPONSES_URL.to_string())
199}
200
201fn canonical_endpoint(url: &str) -> Option<(String, String, u16, String)> {
202    let parsed = reqwest::Url::parse(url).ok()?;
203    let host = parsed.host_str()?.to_ascii_lowercase();
204    let port = parsed.port_or_known_default()?;
205    let path = parsed.path().trim_end_matches('/').to_string();
206    Some((parsed.scheme().to_ascii_lowercase(), host, port, path))
207}
208
209fn is_default_responses_url(url: &str) -> bool {
210    canonical_endpoint(url) == canonical_endpoint(DEFAULT_CODEX_RESPONSES_URL)
211}
212
213fn first_nonempty(text: Option<&str>) -> Option<String> {
214    text.and_then(|value| {
215        let trimmed = value.trim();
216        if trimmed.is_empty() {
217            None
218        } else {
219            Some(trimmed.to_string())
220        }
221    })
222}
223
224fn normalize_model_id(model: &str) -> &str {
225    model.rsplit('/').next().unwrap_or(model)
226}
227
228fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<ResponsesToolSpec>> {
229    let items = tools?;
230    if items.is_empty() {
231        return None;
232    }
233
234    Some(
235        items
236            .iter()
237            .map(|tool| ResponsesToolSpec {
238                kind: "function".to_string(),
239                name: tool.name.clone(),
240                description: tool.description.clone(),
241                parameters: tool.parameters.clone(),
242                strict: false,
243            })
244            .collect(),
245    )
246}
247
248fn response_message_item(role: &str, content: Vec<Value>) -> Value {
249    serde_json::json!({
250        "type": "message",
251        "role": role,
252        "content": content,
253    })
254}
255
256fn legacy_tool_output_message(content: &str) -> Value {
257    response_message_item(
258        "user",
259        vec![serde_json::json!({
260            "type": "input_text",
261            "text": format!("Legacy tool output without call_id:\n{content}"),
262        })],
263    )
264}
265
266fn response_item_type(item: &Value) -> Option<&str> {
267    item.get("type").and_then(Value::as_str)
268}
269
270fn is_replayable_responses_output_item(item: &Value) -> bool {
271    matches!(
272        response_item_type(item),
273        Some("message" | "reasoning" | "function_call")
274    )
275}
276
277fn encode_responses_history_items(output_items: &[Value], has_tool_calls: bool) -> Option<String> {
278    if !has_tool_calls {
279        return None;
280    }
281
282    let replay_items = output_items
283        .iter()
284        .filter(|item| is_replayable_responses_output_item(item))
285        .cloned()
286        .collect::<Vec<_>>();
287
288    if !replay_items
289        .iter()
290        .any(|item| response_item_type(item) == Some("function_call"))
291    {
292        return None;
293    }
294
295    serde_json::to_string(&serde_json::json!({
296        "provider": RESPONSES_HISTORY_PROVIDER,
297        "kind": RESPONSES_HISTORY_KIND,
298        "items": replay_items,
299    }))
300    .ok()
301}
302
303fn decode_responses_history_items(reasoning_content: &str) -> Option<Vec<Value>> {
304    let value = serde_json::from_str::<Value>(reasoning_content).ok()?;
305    if value.get("provider").and_then(Value::as_str) != Some(RESPONSES_HISTORY_PROVIDER)
306        || value.get("kind").and_then(Value::as_str) != Some(RESPONSES_HISTORY_KIND)
307    {
308        return None;
309    }
310
311    let items = value
312        .get("items")
313        .and_then(Value::as_array)?
314        .iter()
315        .filter(|item| is_replayable_responses_output_item(item))
316        .cloned()
317        .collect::<Vec<_>>();
318
319    (!items.is_empty()).then_some(items)
320}
321
322fn build_responses_input(messages: &[ChatMessage]) -> (String, Vec<Value>) {
323    let mut system_parts: Vec<&str> = Vec::new();
324    let mut input: Vec<Value> = Vec::new();
325
326    for msg in messages {
327        match msg.role.as_str() {
328            "system" => system_parts.push(&msg.content),
329            "user" => {
330                let (cleaned_text, image_refs) = multimodal::parse_image_markers(&msg.content);
331
332                let mut content_items = Vec::new();
333
334                if !cleaned_text.trim().is_empty() {
335                    content_items.push(serde_json::json!({
336                        "type": "input_text",
337                        "text": cleaned_text,
338                    }));
339                }
340
341                for image_ref in image_refs {
342                    content_items.push(serde_json::json!({
343                        "type": "input_image",
344                        "image_url": image_ref,
345                    }));
346                }
347
348                if content_items.is_empty() {
349                    content_items.push(serde_json::json!({
350                        "type": "input_text",
351                        "text": "",
352                    }));
353                }
354
355                input.push(response_message_item("user", content_items));
356            }
357            "assistant" => {
358                if let Ok(value) = serde_json::from_str::<Value>(&msg.content)
359                    && let Some(tool_calls_value) = value.get("tool_calls")
360                    && let Ok(parsed_calls) =
361                        serde_json::from_value::<Vec<ProviderToolCall>>(tool_calls_value.clone())
362                {
363                    let content = value
364                        .get("content")
365                        .and_then(Value::as_str)
366                        .filter(|content| !content.trim().is_empty());
367                    let responses_history_items = value
368                        .get("reasoning_content")
369                        .and_then(Value::as_str)
370                        .and_then(decode_responses_history_items);
371
372                    if let Some(items) = responses_history_items {
373                        if let Some(content) = content
374                            && !items
375                                .iter()
376                                .any(|item| response_item_type(item) == Some("message"))
377                        {
378                            input.push(response_message_item(
379                                "assistant",
380                                vec![serde_json::json!({
381                                    "type": "output_text",
382                                    "text": content,
383                                })],
384                            ));
385                        }
386
387                        input.extend(items);
388                        continue;
389                    }
390
391                    if let Some(content) = value
392                        .get("content")
393                        .and_then(Value::as_str)
394                        .filter(|content| !content.trim().is_empty())
395                    {
396                        input.push(response_message_item(
397                            "assistant",
398                            vec![serde_json::json!({
399                                "type": "output_text",
400                                "text": content,
401                            })],
402                        ));
403                    }
404
405                    for call in parsed_calls {
406                        input.push(serde_json::json!({
407                            "type": "function_call",
408                            "call_id": call.id,
409                            "name": call.name,
410                            "arguments": call.arguments,
411                        }));
412                    }
413                } else if !msg.content.trim().is_empty() {
414                    input.push(response_message_item(
415                        "assistant",
416                        vec![serde_json::json!({
417                            "type": "output_text",
418                            "text": msg.content,
419                        })],
420                    ));
421                }
422            }
423            "tool" => {
424                if let Ok(value) = serde_json::from_str::<Value>(&msg.content) {
425                    if let Some(call_id) = value
426                        .get("tool_call_id")
427                        .and_then(Value::as_str)
428                        .and_then(|id| first_nonempty(Some(id)))
429                    {
430                        let output = value
431                            .get("content")
432                            .and_then(Value::as_str)
433                            .unwrap_or_default();
434                        input.push(serde_json::json!({
435                            "type": "function_call_output",
436                            "call_id": call_id,
437                            "output": output,
438                        }));
439                    } else if !msg.content.trim().is_empty() {
440                        input.push(legacy_tool_output_message(&msg.content));
441                    }
442                } else if !msg.content.trim().is_empty() {
443                    input.push(legacy_tool_output_message(&msg.content));
444                }
445            }
446            _ => {}
447        }
448    }
449
450    let instructions = if system_parts.is_empty() {
451        DEFAULT_CODEX_INSTRUCTIONS.to_string()
452    } else {
453        system_parts.join("\n\n")
454    };
455
456    (instructions, input)
457}
458
459fn clamp_reasoning_effort(model: &str, effort: &str) -> String {
460    let id = normalize_model_id(model);
461    // gpt-5-codex currently supports only low|medium|high.
462    if id == "gpt-5-codex" {
463        return match effort {
464            "low" | "medium" | "high" => effort.to_string(),
465            "minimal" => "low".to_string(),
466            _ => "high".to_string(),
467        };
468    }
469    if (id.starts_with("gpt-5.2") || id.starts_with("gpt-5.3")) && effort == "minimal" {
470        return "low".to_string();
471    }
472    if id.starts_with("gpt-5-codex") && effort == "xhigh" {
473        return "high".to_string();
474    }
475    if id == "gpt-5.1" && effort == "xhigh" {
476        return "high".to_string();
477    }
478    if id == "gpt-5.1-codex-mini" {
479        return if effort == "high" || effort == "xhigh" {
480            "high".to_string()
481        } else {
482            "medium".to_string()
483        };
484    }
485    effort.to_string()
486}
487
488fn resolve_reasoning_effort(model_id: &str, configured: Option<&str>) -> String {
489    let raw = configured
490        .and_then(|value| first_nonempty(Some(value)))
491        .map(|s| s.to_ascii_lowercase())
492        .unwrap_or_else(|| "xhigh".to_string());
493    clamp_reasoning_effort(model_id, &raw)
494}
495
496fn nonempty_preserve(text: Option<&str>) -> Option<String> {
497    text.and_then(|value| {
498        if value.is_empty() {
499            None
500        } else {
501            Some(value.to_string())
502        }
503    })
504}
505
506fn extract_responses_text(response: &ResponsesResponse) -> Option<String> {
507    if let Some(text) = first_nonempty(response.output_text.as_deref()) {
508        return Some(text);
509    }
510
511    for item in &response.output {
512        if response_item_type(item) != Some("message") {
513            continue;
514        }
515
516        if let Some(parts) = item.get("content").and_then(Value::as_array) {
517            for content in parts {
518                if response_item_type(content) == Some("output_text")
519                    && let Some(text) = first_nonempty(content.get("text").and_then(Value::as_str))
520                {
521                    return Some(text);
522                }
523            }
524        }
525    }
526
527    for item in &response.output {
528        if let Some(parts) = item.get("content").and_then(Value::as_array) {
529            for content in parts {
530                if let Some(text) = first_nonempty(content.get("text").and_then(Value::as_str)) {
531                    return Some(text);
532                }
533            }
534        }
535    }
536
537    None
538}
539
540fn extract_responses_tool_calls(response: &ResponsesResponse) -> Vec<ProviderToolCall> {
541    response
542        .output
543        .iter()
544        .filter(|item| response_item_type(item) == Some("function_call"))
545        .filter_map(|item| {
546            let name = item.get("name").and_then(Value::as_str)?.to_string();
547            let arguments = item
548                .get("arguments")
549                .and_then(Value::as_str)
550                .unwrap_or_default()
551                .to_string();
552            Some(ProviderToolCall {
553                id: item
554                    .get("call_id")
555                    .and_then(Value::as_str)
556                    .or_else(|| item.get("id").and_then(Value::as_str))
557                    .map(ToString::to_string)
558                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
559                name,
560                arguments,
561                extra_content: None,
562            })
563        })
564        .collect()
565}
566
567fn responses_turn_from_response(response: &ResponsesResponse) -> ResponsesTurnResult {
568    let tool_calls = extract_responses_tool_calls(response);
569    let reasoning_content =
570        encode_responses_history_items(&response.output, !tool_calls.is_empty());
571
572    ResponsesTurnResult {
573        text: extract_responses_text(response),
574        tool_calls,
575        reasoning_content,
576    }
577}
578
579fn record_responses_output_item(state: &mut ResponsesStreamState, item: Value) {
580    if !is_replayable_responses_output_item(&item) {
581        return;
582    }
583
584    let item_id = item
585        .get("id")
586        .and_then(Value::as_str)
587        .or_else(|| item.get("call_id").and_then(Value::as_str));
588    if let Some(item_id) = item_id
589        && state.output_items.iter().any(|existing| {
590            existing
591                .get("id")
592                .and_then(Value::as_str)
593                .or_else(|| existing.get("call_id").and_then(Value::as_str))
594                == Some(item_id)
595        })
596    {
597        return;
598    }
599
600    state.output_items.push(item);
601}
602
603fn replace_responses_output_items(state: &mut ResponsesStreamState, items: &[Value]) {
604    let replay_items = items
605        .iter()
606        .filter(|item| is_replayable_responses_output_item(item))
607        .cloned()
608        .collect::<Vec<_>>();
609
610    if !replay_items.is_empty() {
611        state.output_items = replay_items;
612    }
613}
614
615fn response_output_text_from_event_item(item: &Value) -> Option<String> {
616    if item.get("type").and_then(Value::as_str) != Some("message") {
617        return None;
618    }
619
620    item.get("content")
621        .and_then(Value::as_array)
622        .and_then(|parts| {
623            parts.iter().find_map(|part| {
624                if part.get("type").and_then(Value::as_str) == Some("output_text") {
625                    first_nonempty(part.get("text").and_then(Value::as_str))
626                } else {
627                    None
628                }
629            })
630        })
631}
632
633fn pending_tool_call_key(item_id: Option<&str>, output_index: Option<u64>) -> Option<String> {
634    item_id
635        .map(ToString::to_string)
636        .or_else(|| output_index.map(|index| format!("output:{index}")))
637}
638
639fn emit_tool_call(
640    state: &mut ResponsesStreamState,
641    tool_call: ProviderToolCall,
642) -> Option<ProviderToolCall> {
643    if state.emitted_tool_call_ids.insert(tool_call.id.clone()) {
644        state.collected_tool_calls.push(tool_call.clone());
645        Some(tool_call)
646    } else {
647        None
648    }
649}
650
651#[derive(Debug)]
652struct ResponsesStreamApiError(String);
653
654impl std::fmt::Display for ResponsesStreamApiError {
655    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
656        write!(f, "OpenAI Codex stream error: {}", self.0)
657    }
658}
659
660impl std::error::Error for ResponsesStreamApiError {}
661
662fn process_responses_stream_event(
663    event: Value,
664    state: &mut ResponsesStreamState,
665) -> anyhow::Result<Vec<StreamEvent>> {
666    if let Some(message) = extract_stream_error_message(&event) {
667        return Err(ResponsesStreamApiError(message).into());
668    }
669
670    let mut emitted = Vec::new();
671    match event.get("type").and_then(Value::as_str) {
672        Some("response.output_text.delta") => {
673            if let Some(text) = nonempty_preserve(event.get("delta").and_then(Value::as_str)) {
674                state.saw_text_delta = true;
675                state.text_accumulator.push_str(&text);
676                emitted.push(StreamEvent::TextDelta(StreamChunk::delta(text)));
677            }
678        }
679        Some("response.output_text.done") if !state.saw_text_delta => {
680            state.fallback_text = nonempty_preserve(event.get("text").and_then(Value::as_str));
681        }
682        Some("response.output_item.added") => {
683            let item = event.get("item");
684            let item_type = item
685                .and_then(|value| value.get("type"))
686                .and_then(Value::as_str);
687            if item_type == Some("function_call") {
688                let key = pending_tool_call_key(
689                    item.and_then(|value| value.get("id"))
690                        .and_then(Value::as_str),
691                    event.get("output_index").and_then(Value::as_u64),
692                );
693                if let Some(key) = key {
694                    let entry = state.tool_calls.entry(key).or_default();
695                    entry.item_id = item
696                        .and_then(|value| value.get("id"))
697                        .and_then(Value::as_str)
698                        .map(ToString::to_string);
699                    entry.call_id = item
700                        .and_then(|value| value.get("call_id"))
701                        .and_then(Value::as_str)
702                        .map(ToString::to_string);
703                    entry.name = item
704                        .and_then(|value| value.get("name"))
705                        .and_then(Value::as_str)
706                        .map(ToString::to_string);
707                    if let Some(arguments) = item
708                        .and_then(|value| value.get("arguments"))
709                        .and_then(Value::as_str)
710                    {
711                        entry.arguments = arguments.to_string();
712                    }
713                }
714            }
715        }
716        Some("response.function_call_arguments.delta") => {
717            if let Some(key) = pending_tool_call_key(
718                event.get("item_id").and_then(Value::as_str),
719                event.get("output_index").and_then(Value::as_u64),
720            ) {
721                let entry = state.tool_calls.entry(key).or_default();
722                entry.item_id = event
723                    .get("item_id")
724                    .and_then(Value::as_str)
725                    .map(ToString::to_string);
726                entry.arguments.push_str(
727                    event
728                        .get("delta")
729                        .and_then(Value::as_str)
730                        .unwrap_or_default(),
731                );
732            }
733        }
734        Some("response.function_call_arguments.done") => {
735            let key = pending_tool_call_key(
736                event.get("item_id").and_then(Value::as_str),
737                event.get("output_index").and_then(Value::as_u64),
738            );
739            let mut pending = key
740                .as_ref()
741                .and_then(|key| state.tool_calls.remove(key))
742                .unwrap_or_default();
743            pending.item_id = pending.item_id.or_else(|| {
744                event
745                    .get("item_id")
746                    .and_then(Value::as_str)
747                    .map(ToString::to_string)
748            });
749            pending.call_id = pending.call_id.or_else(|| {
750                event
751                    .get("call_id")
752                    .and_then(Value::as_str)
753                    .map(ToString::to_string)
754            });
755            pending.name = pending.name.or_else(|| {
756                event
757                    .get("name")
758                    .and_then(Value::as_str)
759                    .map(ToString::to_string)
760            });
761            if let Some(arguments) = event.get("arguments").and_then(Value::as_str) {
762                pending.arguments = arguments.to_string();
763            }
764
765            if let Some(name) = pending.name {
766                let tool_call = ProviderToolCall {
767                    id: pending
768                        .call_id
769                        .or(pending.item_id)
770                        .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
771                    name,
772                    arguments: pending.arguments,
773                    extra_content: None,
774                };
775                if let Some(tool_call) = emit_tool_call(state, tool_call) {
776                    emitted.push(StreamEvent::ToolCall(tool_call));
777                }
778            }
779        }
780        Some("response.output_item.done") => {
781            if let Some(item) = event.get("item") {
782                record_responses_output_item(state, item.clone());
783                match item.get("type").and_then(Value::as_str) {
784                    Some("message") if !state.saw_text_delta && state.fallback_text.is_none() => {
785                        state.fallback_text = response_output_text_from_event_item(item);
786                    }
787                    Some("function_call") => {
788                        if let Some(name) = item.get("name").and_then(Value::as_str) {
789                            let tool_call = ProviderToolCall {
790                                id: item
791                                    .get("call_id")
792                                    .and_then(Value::as_str)
793                                    .or_else(|| item.get("id").and_then(Value::as_str))
794                                    .unwrap_or_default()
795                                    .to_string(),
796                                name: name.to_string(),
797                                arguments: item
798                                    .get("arguments")
799                                    .and_then(Value::as_str)
800                                    .unwrap_or_default()
801                                    .to_string(),
802                                extra_content: None,
803                            };
804                            if let Some(tool_call) = emit_tool_call(state, tool_call) {
805                                emitted.push(StreamEvent::ToolCall(tool_call));
806                            }
807                        }
808                    }
809                    _ => {}
810                }
811            }
812        }
813        Some("response.completed" | "response.done") => {
814            if let Some(response) = event
815                .get("response")
816                .and_then(|value| serde_json::from_value::<ResponsesResponse>(value.clone()).ok())
817            {
818                if !state.saw_text_delta && state.fallback_text.is_none() {
819                    state.fallback_text = extract_responses_text(&response);
820                }
821                replace_responses_output_items(state, &response.output);
822                for tool_call in extract_responses_tool_calls(&response) {
823                    if let Some(tool_call) = emit_tool_call(state, tool_call) {
824                        emitted.push(StreamEvent::ToolCall(tool_call));
825                    }
826                }
827            }
828        }
829        _ => {}
830    }
831
832    Ok(emitted)
833}
834
835fn process_sse_chunk(
836    chunk: &str,
837    state: &mut ResponsesStreamState,
838) -> anyhow::Result<Vec<StreamEvent>> {
839    let data_lines: Vec<String> = chunk
840        .lines()
841        .filter_map(|line| line.strip_prefix("data:"))
842        .map(|line| line.trim().to_string())
843        .collect();
844    if data_lines.is_empty() {
845        return Ok(Vec::new());
846    }
847
848    let joined = data_lines.join("\n");
849    let trimmed = joined.trim();
850    if trimmed.is_empty() || trimmed == "[DONE]" {
851        return Ok(Vec::new());
852    }
853
854    if let Ok(event) = serde_json::from_str::<Value>(trimmed) {
855        return process_responses_stream_event(event, state);
856    }
857
858    let mut emitted = Vec::new();
859    for line in data_lines {
860        let line = line.trim();
861        if line.is_empty() || line == "[DONE]" {
862            continue;
863        }
864        let event = serde_json::from_str::<Value>(line).map_err(|err| {
865            let sanitized = super::sanitize_api_error(line);
866            ::zeroclaw_log::record!(
867                ERROR,
868                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
869                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
870                    .with_attrs(::serde_json::json!({
871                        "phase": "sse_parse",
872                        "payload": &sanitized,
873                        "error": format!("{}", err),
874                    })),
875                "openai_codex: SSE data parse failed"
876            );
877            anyhow::Error::msg(format!(
878                "OpenAI Codex SSE data parse failed: {err}. Payload: {sanitized}"
879            ))
880        })?;
881        emitted.extend(process_responses_stream_event(event, state)?);
882    }
883
884    Ok(emitted)
885}
886
887fn parse_sse_turn(body: &str) -> anyhow::Result<ResponsesTurnResult> {
888    let mut state = ResponsesStreamState::default();
889    let mut buffer = body.to_string();
890
891    while let Some(idx) = buffer.find("\n\n") {
892        let chunk = buffer[..idx].to_string();
893        buffer = buffer[idx + 2..].to_string();
894        process_sse_chunk(&chunk, &mut state)?;
895    }
896
897    if !buffer.trim().is_empty() {
898        process_sse_chunk(&buffer, &mut state)?;
899    }
900
901    Ok(ResponsesTurnResult {
902        text: if state.saw_text_delta {
903            nonempty_preserve(Some(&state.text_accumulator))
904        } else {
905            state.fallback_text
906        },
907        reasoning_content: encode_responses_history_items(
908            &state.output_items,
909            !state.collected_tool_calls.is_empty(),
910        ),
911        tool_calls: state.collected_tool_calls,
912    })
913}
914
915fn ensure_nonempty_responses_turn(
916    result: ResponsesTurnResult,
917    empty_error: impl FnOnce() -> anyhow::Error,
918) -> anyhow::Result<ResponsesTurnResult> {
919    if result.text.as_deref().is_some_and(|text| !text.is_empty()) || !result.tool_calls.is_empty()
920    {
921        Ok(result)
922    } else {
923        Err(empty_error())
924    }
925}
926
927fn extract_stream_error_message(event: &Value) -> Option<String> {
928    let event_type = event.get("type").and_then(Value::as_str);
929
930    if event_type == Some("error") {
931        return first_nonempty(
932            event
933                .get("message")
934                .and_then(Value::as_str)
935                .or_else(|| event.get("code").and_then(Value::as_str))
936                .or_else(|| {
937                    event
938                        .get("error")
939                        .and_then(|error| error.get("message"))
940                        .and_then(Value::as_str)
941                }),
942        );
943    }
944
945    if event_type == Some("response.failed") {
946        return first_nonempty(
947            event
948                .get("response")
949                .and_then(|response| response.get("error"))
950                .and_then(|error| error.get("message"))
951                .and_then(Value::as_str),
952        );
953    }
954
955    None
956}
957
958fn append_utf8_stream_chunk(
959    body: &mut String,
960    pending: &mut Vec<u8>,
961    chunk: &[u8],
962) -> anyhow::Result<()> {
963    if pending.is_empty()
964        && let Ok(text) = std::str::from_utf8(chunk)
965    {
966        body.push_str(text);
967        return Ok(());
968    }
969
970    if !chunk.is_empty() {
971        pending.extend_from_slice(chunk);
972    }
973    if pending.is_empty() {
974        return Ok(());
975    }
976
977    match std::str::from_utf8(pending) {
978        Ok(text) => {
979            body.push_str(text);
980            pending.clear();
981            Ok(())
982        }
983        Err(err) => {
984            let valid_up_to = err.valid_up_to();
985            if valid_up_to > 0 {
986                // SAFETY: `valid_up_to` always points to the end of a valid UTF-8 prefix.
987                let prefix = std::str::from_utf8(&pending[..valid_up_to])
988                    .expect("valid UTF-8 prefix from Utf8Error::valid_up_to");
989                body.push_str(prefix);
990                pending.drain(..valid_up_to);
991            }
992
993            if err.error_len().is_some() {
994                ::zeroclaw_log::record!(
995                    ERROR,
996                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
997                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
998                        .with_attrs(::serde_json::json!({
999                            "phase": "utf8_decode",
1000                            "error": format!("{}", err),
1001                        })),
1002                    "openai_codex: response contained invalid UTF-8"
1003                );
1004                return Err(anyhow::Error::msg(format!(
1005                    "OpenAI Codex response contained invalid UTF-8: {err}"
1006                )));
1007            }
1008
1009            // `error_len == None` means we have a valid prefix and an incomplete
1010            // multi-byte sequence at the end; keep it buffered until next chunk.
1011            Ok(())
1012        }
1013    }
1014}
1015
1016fn parse_responses_body(body: &str) -> anyhow::Result<ResponsesTurnResult> {
1017    let body_trimmed = body.trim_start();
1018    let looks_like_sse = body_trimmed.starts_with("event:") || body_trimmed.starts_with("data:");
1019    if looks_like_sse {
1020        let result = parse_sse_turn(body)?;
1021        return ensure_nonempty_responses_turn(result, || {
1022            let sanitized = super::sanitize_api_error(body);
1023            ::zeroclaw_log::record!(
1024                ERROR,
1025                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1026                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1027                    .with_attrs(::serde_json::json!({"payload": &sanitized})),
1028                "openai_codex: empty SSE stream payload"
1029            );
1030            anyhow::Error::msg(format!(
1031                "No response from OpenAI Codex stream payload: {sanitized}"
1032            ))
1033        });
1034    }
1035
1036    let parsed: ResponsesResponse = serde_json::from_str(body).map_err(|err| {
1037        let sanitized = super::sanitize_api_error(body);
1038        ::zeroclaw_log::record!(
1039            ERROR,
1040            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1041                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1042                .with_attrs(::serde_json::json!({
1043                    "payload": &sanitized,
1044                    "error": format!("{}", err),
1045                })),
1046            "openai_codex: JSON parse failed"
1047        );
1048        anyhow::Error::msg(format!(
1049            "OpenAI Codex JSON parse failed: {err}. Payload: {sanitized}"
1050        ))
1051    })?;
1052    let result = responses_turn_from_response(&parsed);
1053    ensure_nonempty_responses_turn(result, || {
1054        let sanitized = super::sanitize_api_error(body);
1055        ::zeroclaw_log::record!(
1056            ERROR,
1057            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1058                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1059                .with_attrs(::serde_json::json!({"payload": &sanitized})),
1060            "openai_codex: empty response"
1061        );
1062        anyhow::Error::msg(format!("No response from OpenAI Codex: {sanitized}"))
1063    })
1064}
1065
1066/// Read the response body incrementally via `bytes_stream()` to avoid
1067/// buffering the entire SSE payload in memory.  The previous implementation
1068/// used `response.text().await?` which holds the HTTP connection open until
1069/// every byte has arrived — on high-latency links the long-lived connection
1070/// often drops mid-read, producing the "error decoding response body" failure
1071/// reported in #3544.
1072async fn decode_responses_body(response: reqwest::Response) -> anyhow::Result<ResponsesTurnResult> {
1073    let mut body = String::new();
1074    let mut pending_utf8 = Vec::new();
1075    let mut stream = response.bytes_stream();
1076
1077    while let Some(chunk) = stream.next().await {
1078        let bytes = chunk.map_err(|err| {
1079            ::zeroclaw_log::record!(
1080                ERROR,
1081                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1082                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1083                    .with_attrs(::serde_json::json!({
1084                        "phase": "stream_read",
1085                        "error": format!("{}", err),
1086                    })),
1087                "openai_codex: error reading response stream"
1088            );
1089            anyhow::Error::msg(format!("error reading OpenAI Codex response stream: {err}"))
1090        })?;
1091        append_utf8_stream_chunk(&mut body, &mut pending_utf8, &bytes)?;
1092    }
1093
1094    if !pending_utf8.is_empty() {
1095        let err = match std::str::from_utf8(&pending_utf8) {
1096            Err(e) => e,
1097            Ok(_) => {
1098                // Structurally unreachable: append_utf8_stream_chunk only accumulates
1099                // incomplete multi-byte sequences (error_len == None), so from_utf8
1100                // always returns Err here. Handled as an error rather than a panic so
1101                // the daemon survives if the invariant is somehow violated.
1102                ::zeroclaw_log::record!(
1103                    ERROR,
1104                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1105                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
1106                    "openai_codex: pending bytes were valid UTF-8 (invariant violated)"
1107                );
1108                return Err(anyhow::Error::msg(
1109                    "OpenAI Codex response stream ended with valid UTF-8 in pending bytes (unexpected)",
1110                ));
1111            }
1112        };
1113        ::zeroclaw_log::record!(
1114            ERROR,
1115            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1116                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1117                .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
1118            "openai_codex: response ended with incomplete UTF-8"
1119        );
1120        return Err(anyhow::Error::msg(format!(
1121            "OpenAI Codex response ended with incomplete UTF-8: {err}"
1122        )));
1123    }
1124
1125    parse_responses_body(&body)
1126}
1127
1128impl OpenAiCodexModelProvider {
1129    fn responses_request_builder(
1130        &self,
1131        bearer_token: &str,
1132        account_id: Option<&str>,
1133        access_token: Option<&str>,
1134        use_gateway_api_key_auth: bool,
1135        request: &ResponsesRequest,
1136    ) -> reqwest::RequestBuilder {
1137        let mut request_builder = self
1138            .client
1139            .post(&self.responses_url)
1140            .header("Authorization", format!("Bearer {bearer_token}"))
1141            .header("OpenAI-Beta", "responses=experimental")
1142            .header("originator", "pi")
1143            .header("Content-Type", "application/json");
1144
1145        if request.stream {
1146            request_builder = request_builder.header("accept", "text/event-stream");
1147        }
1148
1149        if let Some(account_id) = account_id {
1150            request_builder = request_builder.header("chatgpt-account-id", account_id);
1151        }
1152
1153        if use_gateway_api_key_auth {
1154            if let Some(access_token) = access_token {
1155                request_builder = request_builder.header("x-openai-access-token", access_token);
1156            }
1157            if let Some(account_id) = account_id {
1158                request_builder = request_builder.header("x-openai-account-id", account_id);
1159            }
1160        }
1161
1162        request_builder
1163    }
1164
1165    async fn send_responses_request(
1166        &self,
1167        input: Vec<Value>,
1168        instructions: String,
1169        tools: Option<Vec<ResponsesToolSpec>>,
1170        model: &str,
1171    ) -> anyhow::Result<ResponsesTurnResult> {
1172        let use_gateway_api_key_auth = self.custom_endpoint && self.gateway_api_key.is_some();
1173        let profile = match self
1174            .auth
1175            .get_profile("openai-codex", self.auth_profile_override.as_deref())
1176            .await
1177        {
1178            Ok(profile) => profile,
1179            Err(err) if use_gateway_api_key_auth => {
1180                ::zeroclaw_log::record!(
1181                    WARN,
1182                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1183                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1184                        .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
1185                    "failed to load OpenAI Codex profile; continuing with custom endpoint API key mode"
1186                );
1187                None
1188            }
1189            Err(err) => return Err(err),
1190        };
1191        let oauth_access_token = match self
1192            .auth
1193            .get_valid_openai_access_token(self.auth_profile_override.as_deref())
1194            .await
1195        {
1196            Ok(token) => token,
1197            Err(err) if use_gateway_api_key_auth => {
1198                ::zeroclaw_log::record!(
1199                    WARN,
1200                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1201                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1202                        .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
1203                    "failed to refresh OpenAI token; continuing with custom endpoint API key mode"
1204                );
1205                None
1206            }
1207            Err(err) => return Err(err),
1208        };
1209
1210        let account_id = profile.and_then(|profile| profile.account_id).or_else(|| {
1211            oauth_access_token
1212                .as_deref()
1213                .and_then(extract_account_id_from_jwt)
1214        });
1215        let access_token = if use_gateway_api_key_auth {
1216            oauth_access_token
1217        } else {
1218            Some(oauth_access_token.ok_or_else(|| {
1219                ::zeroclaw_log::record!(
1220                    ERROR,
1221                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1222                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1223                        .with_attrs(::serde_json::json!({"missing": "oauth_access_token"})),
1224                    "openai_codex: auth profile not found"
1225                );
1226                anyhow::Error::msg(
1227                    "OpenAI Codex auth profile not found. Run `zeroclaw auth login --provider openai-codex`.",
1228                )
1229            })?)
1230        };
1231        let account_id = if use_gateway_api_key_auth {
1232            account_id
1233        } else {
1234            Some(account_id.ok_or_else(|| {
1235                ::zeroclaw_log::record!(
1236                    ERROR,
1237                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1238                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1239                        .with_attrs(::serde_json::json!({"missing": "account_id"})),
1240                    "openai_codex: account_id not found in profile/token"
1241                );
1242                anyhow::Error::msg(
1243                    "OpenAI Codex account id not found in auth profile/token. Run `zeroclaw auth login --provider openai-codex` again.",
1244                )
1245            })?)
1246        };
1247        let normalized_model = normalize_model_id(model);
1248
1249        let has_tools = tools.is_some();
1250        let mut request = ResponsesRequest {
1251            model: normalized_model.to_string(),
1252            input,
1253            instructions,
1254            store: false,
1255            stream: true,
1256            text: ResponsesTextOptions {
1257                verbosity: "medium".to_string(),
1258            },
1259            reasoning: ResponsesReasoningOptions {
1260                effort: resolve_reasoning_effort(
1261                    normalized_model,
1262                    self.reasoning_effort.as_deref(),
1263                ),
1264                summary: "auto".to_string(),
1265            },
1266            include: vec!["reasoning.encrypted_content".to_string()],
1267            tools,
1268            tool_choice: has_tools.then(|| "auto".to_string()),
1269            parallel_tool_calls: has_tools.then_some(true),
1270        };
1271
1272        let bearer_token = if use_gateway_api_key_auth {
1273            self.gateway_api_key.as_deref().unwrap_or_default()
1274        } else {
1275            access_token.as_deref().unwrap_or_default()
1276        };
1277
1278        let request_builder = self.responses_request_builder(
1279            bearer_token,
1280            account_id.as_deref(),
1281            access_token.as_deref(),
1282            use_gateway_api_key_auth,
1283            &request,
1284        );
1285
1286        let response = request_builder.json(&request).send().await?;
1287
1288        if !response.status().is_success() {
1289            return Err(super::api_error("OpenAI Codex", response).await);
1290        }
1291
1292        match decode_responses_body(response).await {
1293            Ok(result) => Ok(result),
1294            Err(stream_err) => {
1295                if stream_err
1296                    .downcast_ref::<ResponsesStreamApiError>()
1297                    .is_some()
1298                {
1299                    return Err(stream_err);
1300                }
1301
1302                ::zeroclaw_log::record!(
1303                    WARN,
1304                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1305                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1306                        .with_attrs(::serde_json::json!({"error": format!("{}", stream_err)})),
1307                    "OpenAI Codex streaming response decode failed, retrying without streaming"
1308                );
1309
1310                request.stream = false;
1311                let non_streaming_response = self
1312                    .responses_request_builder(
1313                        bearer_token,
1314                        account_id.as_deref(),
1315                        access_token.as_deref(),
1316                        use_gateway_api_key_auth,
1317                        &request,
1318                    )
1319                    .json(&request)
1320                    .send()
1321                    .await?;
1322
1323                if !non_streaming_response.status().is_success() {
1324                    return Err(super::api_error("OpenAI Codex", non_streaming_response).await);
1325                }
1326
1327                decode_responses_body(non_streaming_response)
1328                    .await
1329                    .map_err(|fallback_err| {
1330                        ::zeroclaw_log::record!(
1331                            ERROR,
1332                            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1333                                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1334                                .with_attrs(::serde_json::json!({
1335                                    "stream_err": format!("{}", stream_err),
1336                                    "fallback_err": format!("{}", fallback_err),
1337                                })),
1338                            "openai_codex: stream + non-stream fallback both failed"
1339                        );
1340                        anyhow::Error::msg(format!(
1341                            "OpenAI Codex streaming response decode failed ({stream_err}); non-streaming retry failed ({fallback_err})"
1342                        ))
1343                    })
1344            }
1345        }
1346    }
1347}
1348
1349#[async_trait]
1350impl ModelProvider for OpenAiCodexModelProvider {
1351    // ── Provider-family defaults ──
1352    fn default_wire_api(&self) -> &str {
1353        WIRE_API
1354    }
1355
1356    fn default_base_url(&self) -> Option<&str> {
1357        Some(DEFAULT_CODEX_RESPONSES_URL)
1358    }
1359
1360    fn capabilities(&self) -> ProviderCapabilities {
1361        ProviderCapabilities {
1362            native_tool_calling: true,
1363            vision: true,
1364            prompt_caching: false,
1365            extended_thinking: false,
1366        }
1367    }
1368
1369    async fn chat_with_system(
1370        &self,
1371        system_prompt: Option<&str>,
1372        message: &str,
1373        model: &str,
1374        _temperature: Option<f64>,
1375    ) -> anyhow::Result<String> {
1376        // Build temporary messages array
1377        let mut messages = Vec::new();
1378        if let Some(sys) = system_prompt {
1379            messages.push(ChatMessage::system(sys));
1380        }
1381        messages.push(ChatMessage::user(message));
1382
1383        // Normalize images: convert file paths to data URIs
1384        let config = zeroclaw_config::schema::MultimodalConfig::default();
1385        let prepared = crate::multimodal::prepare_messages_for_provider(&messages, &config).await?;
1386
1387        let (instructions, input) = build_responses_input(&prepared.messages);
1388        self.send_responses_request(input, instructions, None, model)
1389            .await
1390            .map(|response| response.text.unwrap_or_default())
1391    }
1392
1393    async fn chat_with_history(
1394        &self,
1395        messages: &[ChatMessage],
1396        model: &str,
1397        _temperature: Option<f64>,
1398    ) -> anyhow::Result<String> {
1399        // Normalize image markers: convert file paths to data URIs
1400        let config = zeroclaw_config::schema::MultimodalConfig::default();
1401        let prepared = crate::multimodal::prepare_messages_for_provider(messages, &config).await?;
1402
1403        let (instructions, input) = build_responses_input(&prepared.messages);
1404        self.send_responses_request(input, instructions, None, model)
1405            .await
1406            .map(|response| response.text.unwrap_or_default())
1407    }
1408
1409    async fn chat(
1410        &self,
1411        request: ProviderChatRequest<'_>,
1412        model: &str,
1413        _temperature: Option<f64>,
1414    ) -> anyhow::Result<ProviderChatResponse> {
1415        let config = zeroclaw_config::schema::MultimodalConfig::default();
1416        let prepared =
1417            crate::multimodal::prepare_messages_for_provider(request.messages, &config).await?;
1418        let (instructions, input) = build_responses_input(&prepared.messages);
1419        let response = self
1420            .send_responses_request(input, instructions, convert_tools(request.tools), model)
1421            .await?;
1422
1423        Ok(ProviderChatResponse {
1424            text: response.text,
1425            tool_calls: response.tool_calls,
1426            usage: None,
1427            reasoning_content: response.reasoning_content,
1428        })
1429    }
1430
1431    fn supports_streaming(&self) -> bool {
1432        false
1433    }
1434
1435    fn supports_streaming_tool_events(&self) -> bool {
1436        false
1437    }
1438
1439    fn stream_chat(
1440        &self,
1441        request: ProviderChatRequest<'_>,
1442        model: &str,
1443        _temperature: Option<f64>,
1444        options: StreamOptions,
1445    ) -> stream::BoxStream<'static, StreamResult<StreamEvent>> {
1446        if !options.enabled {
1447            return stream::once(async { Ok(StreamEvent::Final) }).boxed();
1448        }
1449
1450        let provider = self.clone();
1451        let messages = request.messages.to_vec();
1452        let tools = request.tools.map(|items| items.to_vec());
1453        let model = model.to_string();
1454        let count_tokens = options.count_tokens;
1455        let (tx, rx) = tokio::sync::mpsc::channel::<StreamResult<StreamEvent>>(16);
1456
1457        tokio::spawn(async move {
1458            let config = zeroclaw_config::schema::MultimodalConfig::default();
1459            let prepared =
1460                match crate::multimodal::prepare_messages_for_provider(&messages, &config).await {
1461                    Ok(prepared) => prepared,
1462                    Err(err) => {
1463                        let _ = tx
1464                            .send(Err(StreamError::ModelProvider(err.to_string())))
1465                            .await;
1466                        return;
1467                    }
1468                };
1469
1470            let (instructions, input) = build_responses_input(&prepared.messages);
1471            let result = provider
1472                .send_responses_request(
1473                    input,
1474                    instructions,
1475                    convert_tools(tools.as_deref()),
1476                    &model,
1477                )
1478                .await;
1479
1480            match result {
1481                Ok(response) => {
1482                    for tool_call in response.tool_calls {
1483                        if tx.send(Ok(StreamEvent::ToolCall(tool_call))).await.is_err() {
1484                            return;
1485                        }
1486                    }
1487
1488                    if let Some(text) = response.text.filter(|text| !text.is_empty()) {
1489                        let chunk = if count_tokens {
1490                            StreamChunk::delta(text).with_token_estimate()
1491                        } else {
1492                            StreamChunk::delta(text)
1493                        };
1494                        if tx.send(Ok(StreamEvent::TextDelta(chunk))).await.is_err() {
1495                            return;
1496                        }
1497                    }
1498
1499                    let _ = tx.send(Ok(StreamEvent::Final)).await;
1500                }
1501                Err(err) => {
1502                    let _ = tx
1503                        .send(Err(StreamError::ModelProvider(err.to_string())))
1504                        .await;
1505                }
1506            }
1507        });
1508
1509        stream::unfold(rx, |mut rx| async move {
1510            rx.recv().await.map(|event| (event, rx))
1511        })
1512        .boxed()
1513    }
1514}
1515
1516impl ::zeroclaw_api::attribution::Attributable for OpenAiCodexModelProvider {
1517    fn role(&self) -> ::zeroclaw_api::attribution::Role {
1518        ::zeroclaw_api::attribution::Role::Provider(
1519            ::zeroclaw_api::attribution::ProviderKind::Model(
1520                ::zeroclaw_api::attribution::ModelProviderKind::OpenAiCodex,
1521            ),
1522        )
1523    }
1524    fn alias(&self) -> &str {
1525        &self.alias
1526    }
1527}
1528
1529#[cfg(test)]
1530mod tests {
1531    use super::*;
1532
1533    enum MockCodexReply {
1534        Sse(&'static str),
1535        Json(serde_json::Value),
1536        Status(axum::http::StatusCode, &'static str),
1537    }
1538
1539    async fn mock_codex_provider(
1540        replies: Vec<MockCodexReply>,
1541    ) -> (
1542        OpenAiCodexModelProvider,
1543        std::sync::Arc<std::sync::Mutex<Vec<serde_json::Value>>>,
1544        tokio::task::JoinHandle<()>,
1545        tempfile::TempDir,
1546    ) {
1547        use axum::http::header;
1548        use axum::response::IntoResponse;
1549        use axum::{Json, Router, routing::post};
1550        use std::collections::VecDeque;
1551        use std::sync::{Arc, Mutex};
1552        use tokio::net::TcpListener;
1553
1554        let captured: Arc<Mutex<Vec<serde_json::Value>>> = Arc::new(Mutex::new(Vec::new()));
1555        let captured_clone = Arc::clone(&captured);
1556        let replies = Arc::new(Mutex::new(VecDeque::from(replies)));
1557        let replies_clone = Arc::clone(&replies);
1558
1559        let app = Router::new().route(
1560            "/responses",
1561            post(move |Json(body): Json<serde_json::Value>| {
1562                let captured = Arc::clone(&captured_clone);
1563                let replies = Arc::clone(&replies_clone);
1564                async move {
1565                    captured.lock().unwrap().push(body);
1566                    match replies
1567                        .lock()
1568                        .unwrap()
1569                        .pop_front()
1570                        .unwrap_or(MockCodexReply::Status(
1571                            axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1572                            "",
1573                        )) {
1574                        MockCodexReply::Sse(body) => (
1575                            axum::http::StatusCode::OK,
1576                            [(header::CONTENT_TYPE, "text/event-stream")],
1577                            body.to_string(),
1578                        )
1579                            .into_response(),
1580                        MockCodexReply::Json(body) => Json(body).into_response(),
1581                        MockCodexReply::Status(status, body) => {
1582                            (status, body.to_string()).into_response()
1583                        }
1584                    }
1585                }
1586            }),
1587        );
1588
1589        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1590        let addr = listener.local_addr().unwrap();
1591        let server_handle = tokio::spawn(async move {
1592            axum::serve(listener, app).await.unwrap();
1593        });
1594
1595        let temp_dir = tempfile::tempdir().unwrap();
1596        let options = ModelProviderRuntimeOptions {
1597            provider_api_url: Some(format!("http://{addr}")),
1598            zeroclaw_dir: Some(temp_dir.path().to_path_buf()),
1599            secrets_encrypt: false,
1600            ..ModelProviderRuntimeOptions::default()
1601        };
1602        let provider = OpenAiCodexModelProvider::new("test", &options, Some("test-key")).unwrap();
1603
1604        (provider, captured, server_handle, temp_dir)
1605    }
1606
1607    #[test]
1608    fn extracts_output_text_first() {
1609        let response = ResponsesResponse {
1610            output: vec![],
1611            output_text: Some("hello".into()),
1612        };
1613        assert_eq!(extract_responses_text(&response).as_deref(), Some("hello"));
1614    }
1615
1616    #[test]
1617    fn extracts_nested_output_text() {
1618        let response = ResponsesResponse {
1619            output: vec![serde_json::json!({
1620                "type": "message",
1621                "role": "assistant",
1622                "content": [
1623                    {
1624                        "type": "output_text",
1625                        "text": "nested"
1626                    }
1627                ]
1628            })],
1629            output_text: None,
1630        };
1631        assert_eq!(extract_responses_text(&response).as_deref(), Some("nested"));
1632    }
1633
1634    #[test]
1635    fn default_state_dir_is_non_empty() {
1636        let path = default_zeroclaw_dir();
1637        assert!(!path.as_os_str().is_empty());
1638    }
1639
1640    #[test]
1641    fn build_responses_url_appends_suffix_for_base_url() {
1642        assert_eq!(
1643            build_responses_url("https://api.tonsof.blue/v1").unwrap(),
1644            "https://api.tonsof.blue/v1/responses"
1645        );
1646    }
1647
1648    #[test]
1649    fn build_responses_url_keeps_existing_responses_endpoint() {
1650        assert_eq!(
1651            build_responses_url("https://api.tonsof.blue/v1/responses").unwrap(),
1652            "https://api.tonsof.blue/v1/responses"
1653        );
1654    }
1655
1656    #[test]
1657    fn resolve_responses_url_uses_provider_api_url_override() {
1658        let options = ModelProviderRuntimeOptions {
1659            provider_api_url: Some("https://proxy.example.com/v1".to_string()),
1660            ..ModelProviderRuntimeOptions::default()
1661        };
1662
1663        assert_eq!(
1664            resolve_responses_url(&options).unwrap(),
1665            "https://proxy.example.com/v1/responses"
1666        );
1667    }
1668
1669    #[test]
1670    fn default_responses_url_detector_handles_equivalent_urls() {
1671        assert!(is_default_responses_url(DEFAULT_CODEX_RESPONSES_URL));
1672        assert!(is_default_responses_url(
1673            "https://chatgpt.com/backend-api/codex/responses/"
1674        ));
1675        assert!(!is_default_responses_url(
1676            "https://api.tonsof.blue/v1/responses"
1677        ));
1678    }
1679
1680    #[test]
1681    fn constructor_enables_custom_endpoint_key_mode() {
1682        let options = ModelProviderRuntimeOptions {
1683            provider_api_url: Some("https://api.tonsof.blue/v1".to_string()),
1684            ..ModelProviderRuntimeOptions::default()
1685        };
1686
1687        let provider = OpenAiCodexModelProvider::new("test", &options, Some("test-key")).unwrap();
1688        assert!(provider.custom_endpoint);
1689        assert_eq!(provider.gateway_api_key.as_deref(), Some("test-key"));
1690    }
1691
1692    #[tokio::test]
1693    async fn codex_retries_non_streaming_when_stream_decode_fails() {
1694        let (provider, captured, server_handle, _temp_dir) = mock_codex_provider(vec![
1695            MockCodexReply::Sse("data: not-json\n\ndata: [DONE]\n"),
1696            MockCodexReply::Json(serde_json::json!({
1697                "output_text": "fallback ok",
1698                "output": []
1699            })),
1700        ])
1701        .await;
1702
1703        let messages = vec![ChatMessage::user("hello")];
1704        let response = provider
1705            .chat(
1706                ProviderChatRequest {
1707                    messages: &messages,
1708                    tools: None,
1709                    thinking: None,
1710                },
1711                "gpt-5-codex",
1712                None,
1713            )
1714            .await
1715            .expect("provider should retry with stream=false after streaming decode failure");
1716
1717        assert_eq!(response.text.as_deref(), Some("fallback ok"));
1718
1719        let requests = captured.lock().unwrap();
1720        assert_eq!(requests.len(), 2, "expected one retry request");
1721        assert_eq!(requests[0]["stream"], true);
1722        assert_eq!(requests[1]["stream"], false);
1723
1724        server_handle.abort();
1725    }
1726
1727    #[tokio::test]
1728    async fn codex_retries_non_streaming_when_stream_contains_malformed_frame_after_text() {
1729        let (provider, captured, server_handle, _temp_dir) = mock_codex_provider(vec![
1730            MockCodexReply::Sse(
1731                "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\ndata: not-json\n\ndata: [DONE]\n",
1732            ),
1733            MockCodexReply::Json(serde_json::json!({
1734                "output_text": "fallback after partial",
1735                "output": []
1736            })),
1737        ])
1738        .await;
1739
1740        let messages = vec![ChatMessage::user("hello")];
1741        let response = provider
1742            .chat(
1743                ProviderChatRequest {
1744                    messages: &messages,
1745                    tools: None,
1746                    thinking: None,
1747                },
1748                "gpt-5-codex",
1749                None,
1750            )
1751            .await
1752            .expect("provider should retry after malformed stream frame");
1753
1754        assert_eq!(response.text.as_deref(), Some("fallback after partial"));
1755
1756        let requests = captured.lock().unwrap();
1757        assert_eq!(requests.len(), 2, "expected one retry request");
1758        assert_eq!(requests[0]["stream"], true);
1759        assert_eq!(requests[1]["stream"], false);
1760
1761        server_handle.abort();
1762    }
1763
1764    #[tokio::test]
1765    async fn codex_does_not_retry_stream_api_error_events() {
1766        let (provider, captured, server_handle, _temp_dir) = mock_codex_provider(vec![
1767            MockCodexReply::Sse(
1768                "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"message\":\"quota exceeded\"}}}\n\ndata: [DONE]\n",
1769            ),
1770        ])
1771        .await;
1772
1773        let messages = vec![ChatMessage::user("hello")];
1774        let err = provider
1775            .chat(
1776                ProviderChatRequest {
1777                    messages: &messages,
1778                    tools: None,
1779                    thinking: None,
1780                },
1781                "gpt-5-codex",
1782                None,
1783            )
1784            .await
1785            .expect_err("stream API errors should not be retried");
1786
1787        assert!(
1788            err.to_string()
1789                .contains("OpenAI Codex stream error: quota exceeded"),
1790            "{err}"
1791        );
1792        assert_eq!(captured.lock().unwrap().len(), 1);
1793
1794        server_handle.abort();
1795    }
1796
1797    #[tokio::test]
1798    async fn codex_does_not_retry_failed_http_status() {
1799        let (provider, captured, server_handle, _temp_dir) =
1800            mock_codex_provider(vec![MockCodexReply::Status(
1801                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1802                "server down",
1803            )])
1804            .await;
1805
1806        let messages = vec![ChatMessage::user("hello")];
1807        provider
1808            .chat(
1809                ProviderChatRequest {
1810                    messages: &messages,
1811                    tools: None,
1812                    thinking: None,
1813                },
1814                "gpt-5-codex",
1815                None,
1816            )
1817            .await
1818            .expect_err("HTTP errors should not be retried");
1819
1820        assert_eq!(captured.lock().unwrap().len(), 1);
1821
1822        server_handle.abort();
1823    }
1824
1825    #[test]
1826    fn clamp_reasoning_effort_adjusts_known_models() {
1827        assert_eq!(
1828            clamp_reasoning_effort("gpt-5-codex", "xhigh"),
1829            "high".to_string()
1830        );
1831        assert_eq!(
1832            clamp_reasoning_effort("gpt-5-codex", "minimal"),
1833            "low".to_string()
1834        );
1835        assert_eq!(
1836            clamp_reasoning_effort("gpt-5-codex", "medium"),
1837            "medium".to_string()
1838        );
1839        assert_eq!(
1840            clamp_reasoning_effort("gpt-5.3-codex", "minimal"),
1841            "low".to_string()
1842        );
1843        assert_eq!(
1844            clamp_reasoning_effort("gpt-5.1", "xhigh"),
1845            "high".to_string()
1846        );
1847        assert_eq!(
1848            clamp_reasoning_effort("gpt-5-codex", "xhigh"),
1849            "high".to_string()
1850        );
1851        assert_eq!(
1852            clamp_reasoning_effort("gpt-5.1-codex-mini", "low"),
1853            "medium".to_string()
1854        );
1855        assert_eq!(
1856            clamp_reasoning_effort("gpt-5.1-codex-mini", "xhigh"),
1857            "high".to_string()
1858        );
1859        assert_eq!(
1860            clamp_reasoning_effort("gpt-5.3-codex", "xhigh"),
1861            "xhigh".to_string()
1862        );
1863    }
1864
1865    #[test]
1866    fn resolve_reasoning_effort_prefers_configured_override() {
1867        // V0.8.0 grammar: configured value wins; no env-var fallback.
1868        assert_eq!(
1869            resolve_reasoning_effort("gpt-5-codex", Some("high")),
1870            "high".to_string()
1871        );
1872    }
1873
1874    #[test]
1875    fn resolve_reasoning_effort_defaults_when_unconfigured() {
1876        assert_eq!(
1877            resolve_reasoning_effort("gpt-5-codex", None),
1878            "high".to_string()
1879        );
1880    }
1881
1882    #[test]
1883    fn parse_sse_turn_reads_output_text_delta() {
1884        let payload = r#"data: {"type":"response.created","response":{"id":"resp_123"}}
1885
1886data: {"type":"response.output_text.delta","delta":"Hello"}
1887data: {"type":"response.output_text.delta","delta":" world"}
1888data: {"type":"response.completed","response":{"output_text":"Hello world"}}
1889data: [DONE]
1890"#;
1891
1892        assert_eq!(
1893            parse_sse_turn(payload).unwrap().text.as_deref(),
1894            Some("Hello world")
1895        );
1896    }
1897
1898    #[test]
1899    fn parse_sse_turn_falls_back_to_completed_response() {
1900        let payload = r#"data: {"type":"response.completed","response":{"output_text":"Done"}}
1901data: [DONE]
1902"#;
1903
1904        assert_eq!(
1905            parse_sse_turn(payload).unwrap().text.as_deref(),
1906            Some("Done")
1907        );
1908    }
1909
1910    #[test]
1911    fn parse_responses_body_rejects_unrecognized_sse_without_payload() {
1912        let payload = r#"data: not-json
1913data: [DONE]
1914"#;
1915
1916        let err = parse_responses_body(payload).expect_err("empty SSE should fail closed");
1917        assert!(
1918            err.to_string()
1919                .contains("OpenAI Codex SSE data parse failed"),
1920            "{err}"
1921        );
1922    }
1923
1924    #[test]
1925    fn parse_responses_body_rejects_json_without_text_or_tool_calls() {
1926        let payload = r#"{"output":[]}"#;
1927
1928        let err = parse_responses_body(payload).expect_err("empty JSON should fail closed");
1929        assert!(
1930            err.to_string().contains("No response from OpenAI Codex"),
1931            "{err}"
1932        );
1933    }
1934
1935    #[test]
1936    fn parse_responses_body_allows_sse_markers_inside_json_text() {
1937        let payload = serde_json::json!({
1938            "output_text": "Example SSE frame:\ndata: {\"type\":\"example\"}\nevent: response.done",
1939            "output": []
1940        })
1941        .to_string();
1942
1943        let result = parse_responses_body(&payload).expect("JSON text should not be parsed as SSE");
1944        assert_eq!(
1945            result.text.as_deref(),
1946            Some("Example SSE frame:\ndata: {\"type\":\"example\"}\nevent: response.done")
1947        );
1948        assert!(result.tool_calls.is_empty());
1949    }
1950
1951    #[test]
1952    fn parse_responses_body_preserves_reasoning_items_for_tool_calls() {
1953        let payload = serde_json::json!({
1954            "output": [
1955                {
1956                    "type": "reasoning",
1957                    "id": "rs_1",
1958                    "summary": [],
1959                    "encrypted_content": "enc_reasoning"
1960                },
1961                {
1962                    "type": "function_call",
1963                    "id": "fc_1",
1964                    "call_id": "call_1",
1965                    "name": "shell",
1966                    "arguments": "{\"command\":\"pwd\"}",
1967                    "status": "completed"
1968                }
1969            ]
1970        })
1971        .to_string();
1972
1973        let result = parse_responses_body(&payload).expect("tool call response should parse");
1974        assert_eq!(result.tool_calls.len(), 1);
1975        assert_eq!(result.tool_calls[0].id, "call_1");
1976
1977        let items = decode_responses_history_items(
1978            result
1979                .reasoning_content
1980                .as_deref()
1981                .expect("Responses history items should be captured"),
1982        )
1983        .expect("history envelope should decode");
1984
1985        assert_eq!(items.len(), 2);
1986        assert_eq!(items[0]["type"], "reasoning");
1987        assert_eq!(items[0]["encrypted_content"], "enc_reasoning");
1988        assert_eq!(items[1]["type"], "function_call");
1989        assert_eq!(items[1]["call_id"], "call_1");
1990    }
1991
1992    #[test]
1993    fn build_responses_input_maps_content_types_by_role() {
1994        let messages = vec![
1995            ChatMessage {
1996                role: "system".into(),
1997                content: "You are helpful.".into(),
1998            },
1999            ChatMessage {
2000                role: "user".into(),
2001                content: "Hi".into(),
2002            },
2003            ChatMessage {
2004                role: "assistant".into(),
2005                content: "Hello!".into(),
2006            },
2007            ChatMessage {
2008                role: "user".into(),
2009                content: "Thanks".into(),
2010            },
2011        ];
2012        let (instructions, input) = build_responses_input(&messages);
2013        assert_eq!(instructions, "You are helpful.");
2014        assert_eq!(input.len(), 3);
2015
2016        let json: Vec<Value> = input
2017            .iter()
2018            .map(|item| serde_json::to_value(item).unwrap())
2019            .collect();
2020        assert_eq!(json[0]["role"], "user");
2021        assert_eq!(json[0]["content"][0]["type"], "input_text");
2022        assert_eq!(json[1]["role"], "assistant");
2023        assert_eq!(json[1]["content"][0]["type"], "output_text");
2024        assert_eq!(json[2]["role"], "user");
2025        assert_eq!(json[2]["content"][0]["type"], "input_text");
2026    }
2027
2028    #[test]
2029    fn build_responses_input_uses_default_instructions_without_system() {
2030        let messages = vec![ChatMessage {
2031            role: "user".into(),
2032            content: "Hello".into(),
2033        }];
2034        let (instructions, input) = build_responses_input(&messages);
2035        assert_eq!(instructions, DEFAULT_CODEX_INSTRUCTIONS);
2036        assert_eq!(input.len(), 1);
2037    }
2038
2039    #[test]
2040    fn build_responses_input_maps_tool_outputs() {
2041        let messages = vec![
2042            ChatMessage {
2043                role: "tool".into(),
2044                content: r#"{"tool_call_id":"call_123","content":"result"}"#.into(),
2045            },
2046            ChatMessage {
2047                role: "user".into(),
2048                content: "Go".into(),
2049            },
2050        ];
2051        let (instructions, input) = build_responses_input(&messages);
2052        assert_eq!(instructions, DEFAULT_CODEX_INSTRUCTIONS);
2053        assert_eq!(input.len(), 2);
2054        assert_eq!(input[0]["type"], "function_call_output");
2055        assert_eq!(input[0]["call_id"], "call_123");
2056        assert_eq!(input[0]["output"], "result");
2057        assert_eq!(input[1]["role"], "user");
2058    }
2059
2060    #[test]
2061    fn build_responses_input_replays_plain_tool_text_without_synthetic_call_id() {
2062        let messages = vec![ChatMessage {
2063            role: "tool".into(),
2064            content: "legacy plain text result".into(),
2065        }];
2066
2067        let (_, input) = build_responses_input(&messages);
2068
2069        assert_eq!(input.len(), 1);
2070        assert_eq!(input[0]["type"], "message");
2071        assert_eq!(input[0]["role"], "user");
2072        assert_eq!(input[0]["content"][0]["type"], "input_text");
2073        assert_eq!(
2074            input[0]["content"][0]["text"],
2075            "Legacy tool output without call_id:\nlegacy plain text result"
2076        );
2077        assert!(input[0].get("call_id").is_none());
2078    }
2079
2080    #[test]
2081    fn build_responses_input_replays_tool_json_without_call_id_as_text() {
2082        let messages = vec![ChatMessage {
2083            role: "tool".into(),
2084            content: r#"{"content":"legacy result","status":"ok"}"#.into(),
2085        }];
2086
2087        let (_, input) = build_responses_input(&messages);
2088
2089        assert_eq!(input.len(), 1);
2090        assert_eq!(input[0]["type"], "message");
2091        assert_eq!(input[0]["role"], "user");
2092        assert_eq!(input[0]["content"][0]["type"], "input_text");
2093        assert_eq!(
2094            input[0]["content"][0]["text"],
2095            r#"Legacy tool output without call_id:
2096{"content":"legacy result","status":"ok"}"#
2097        );
2098        assert!(input[0].get("call_id").is_none());
2099    }
2100
2101    #[test]
2102    fn build_responses_input_replays_blank_tool_call_id_as_legacy_text() {
2103        for raw_id in ["", "   "] {
2104            let messages = vec![ChatMessage {
2105                role: "tool".into(),
2106                content: serde_json::json!({
2107                    "tool_call_id": raw_id,
2108                    "content": "legacy result"
2109                })
2110                .to_string(),
2111            }];
2112
2113            let (_, input) = build_responses_input(&messages);
2114
2115            assert_eq!(input.len(), 1);
2116            assert_eq!(input[0]["type"], "message");
2117            assert_eq!(input[0]["role"], "user");
2118            assert_eq!(input[0]["content"][0]["type"], "input_text");
2119            assert!(input[0].get("call_id").is_none());
2120            assert!(
2121                input[0]["content"][0]["text"]
2122                    .as_str()
2123                    .unwrap()
2124                    .contains("\"legacy result\"")
2125            );
2126        }
2127    }
2128
2129    #[test]
2130    fn build_responses_input_maps_native_assistant_tool_calls() {
2131        let messages = vec![ChatMessage::assistant(
2132            r#"{"content":"Using shell","tool_calls":[{"id":"call_abc","name":"shell","arguments":"{\"command\":\"pwd\"}"}]}"#,
2133        )];
2134        let (_, input) = build_responses_input(&messages);
2135
2136        assert_eq!(input.len(), 2);
2137        assert_eq!(input[0]["type"], "message");
2138        assert_eq!(input[0]["role"], "assistant");
2139        assert_eq!(input[0]["content"][0]["type"], "output_text");
2140        assert_eq!(input[1]["type"], "function_call");
2141        assert_eq!(input[1]["call_id"], "call_abc");
2142        assert_eq!(input[1]["name"], "shell");
2143    }
2144
2145    #[test]
2146    fn build_responses_input_replays_reasoning_item_before_tool_result() {
2147        let reasoning_item = serde_json::json!({
2148            "type": "reasoning",
2149            "id": "rs_1",
2150            "summary": [],
2151            "encrypted_content": "enc_reasoning"
2152        });
2153        let function_call_item = serde_json::json!({
2154            "type": "function_call",
2155            "id": "fc_1",
2156            "call_id": "call_1",
2157            "name": "shell",
2158            "arguments": "{\"command\":\"pwd\"}",
2159            "status": "completed"
2160        });
2161        let reasoning_content =
2162            encode_responses_history_items(&[reasoning_item, function_call_item], true)
2163                .expect("history envelope should encode");
2164        let messages = vec![
2165            ChatMessage::assistant(
2166                serde_json::json!({
2167                    "content": null,
2168                    "tool_calls": [
2169                        {
2170                            "id": "call_1",
2171                            "name": "shell",
2172                            "arguments": "{\"command\":\"pwd\"}"
2173                        }
2174                    ],
2175                    "reasoning_content": reasoning_content
2176                })
2177                .to_string(),
2178            ),
2179            ChatMessage::tool(
2180                serde_json::json!({
2181                    "tool_call_id": "call_1",
2182                    "content": "ok"
2183                })
2184                .to_string(),
2185            ),
2186        ];
2187
2188        let (_, input) = build_responses_input(&messages);
2189        assert_eq!(input.len(), 3);
2190        assert_eq!(input[0]["type"], "reasoning");
2191        assert_eq!(input[0]["encrypted_content"], "enc_reasoning");
2192        assert_eq!(input[1]["type"], "function_call");
2193        assert_eq!(input[1]["call_id"], "call_1");
2194        assert_eq!(input[2]["type"], "function_call_output");
2195        assert_eq!(input[2]["call_id"], "call_1");
2196        assert_eq!(input[2]["output"], "ok");
2197    }
2198
2199    #[test]
2200    fn convert_tools_opts_out_of_responses_strict_mode() {
2201        let tools = vec![ToolSpec {
2202            name: "jira".to_string(),
2203            description: "Interact with Jira".to_string(),
2204            parameters: serde_json::json!({
2205                "type": "object",
2206                "properties": {
2207                    "action": { "type": "string" },
2208                    "issue_key": { "type": "string" }
2209                },
2210                "required": ["action"]
2211            }),
2212        }];
2213
2214        let converted = convert_tools(Some(&tools)).expect("tool should convert");
2215        let value = serde_json::to_value(&converted[0]).expect("tool should serialize");
2216        assert_eq!(value["type"], "function");
2217        assert_eq!(value["name"], "jira");
2218        assert_eq!(value["strict"], false);
2219        assert_eq!(value["parameters"]["required"][0], "action");
2220    }
2221
2222    #[test]
2223    fn parse_sse_turn_collects_function_calls() {
2224        let payload = r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"shell","arguments":""}}
2225
2226data: {"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":0,"delta":"{\"command\":\"pw"}
2227data: {"type":"response.function_call_arguments.done","item_id":"fc_1","output_index":0,"name":"shell","arguments":"{\"command\":\"pwd\"}"}
2228data: {"type":"response.completed","response":{"output":[]}}
2229data: [DONE]
2230"#;
2231
2232        let result = parse_sse_turn(payload).unwrap();
2233        assert_eq!(result.tool_calls.len(), 1);
2234        assert_eq!(result.tool_calls[0].id, "call_1");
2235        assert_eq!(result.tool_calls[0].name, "shell");
2236        assert_eq!(result.tool_calls[0].arguments, "{\"command\":\"pwd\"}");
2237    }
2238
2239    #[test]
2240    fn build_responses_input_handles_image_markers() {
2241        let messages = vec![ChatMessage::user(
2242            "Describe this\n\n[IMAGE:data:image/png;base64,abc]",
2243        )];
2244        let (_, input) = build_responses_input(&messages);
2245
2246        assert_eq!(input.len(), 1);
2247        assert_eq!(input[0]["role"], "user");
2248        assert_eq!(input[0]["content"].as_array().unwrap().len(), 2);
2249
2250        let json = input[0]["content"].as_array().unwrap();
2251
2252        // First content = text
2253        assert_eq!(json[0]["type"], "input_text");
2254        assert!(json[0]["text"].as_str().unwrap().contains("Describe this"));
2255
2256        // Second content = image
2257        assert_eq!(json[1]["type"], "input_image");
2258        assert_eq!(json[1]["image_url"], "data:image/png;base64,abc");
2259    }
2260
2261    #[test]
2262    fn build_responses_input_preserves_text_only_messages() {
2263        let messages = vec![ChatMessage::user("Hello without images")];
2264        let (_, input) = build_responses_input(&messages);
2265
2266        assert_eq!(input.len(), 1);
2267        assert_eq!(input[0]["content"].as_array().unwrap().len(), 1);
2268
2269        let json = &input[0]["content"][0];
2270        assert_eq!(json["type"], "input_text");
2271        assert_eq!(json["text"], "Hello without images");
2272    }
2273
2274    #[test]
2275    fn build_responses_input_handles_multiple_images() {
2276        let messages = vec![ChatMessage::user(
2277            "Compare these: [IMAGE:data:image/png;base64,img1] and [IMAGE:data:image/jpeg;base64,img2]",
2278        )];
2279        let (_, input) = build_responses_input(&messages);
2280
2281        assert_eq!(input.len(), 1);
2282        assert_eq!(input[0]["content"].as_array().unwrap().len(), 3); // text + 2 images
2283
2284        let json = input[0]["content"].as_array().unwrap();
2285
2286        assert_eq!(json[0]["type"], "input_text");
2287        assert_eq!(json[1]["type"], "input_image");
2288        assert_eq!(json[2]["type"], "input_image");
2289    }
2290
2291    #[test]
2292    fn capabilities_includes_vision() {
2293        let options = ModelProviderRuntimeOptions {
2294            secrets_encrypt: false,
2295            ..ModelProviderRuntimeOptions::default()
2296        };
2297        let provider = OpenAiCodexModelProvider::new("test", &options, None)
2298            .expect("provider should initialize");
2299        let caps = provider.capabilities();
2300
2301        assert!(caps.native_tool_calling);
2302        assert!(caps.vision);
2303    }
2304
2305    #[test]
2306    fn provider_does_not_advertise_streaming_until_live_sse_is_wired() {
2307        let provider =
2308            OpenAiCodexModelProvider::new("test", &ModelProviderRuntimeOptions::default(), None)
2309                .expect("provider should initialize");
2310
2311        assert!(!provider.supports_streaming());
2312        assert!(!provider.supports_streaming_tool_events());
2313    }
2314}