Skip to main content

construct/providers/
openai_codex.rs

1use crate::auth::AuthService;
2use crate::auth::openai_oauth::extract_account_id_from_jwt;
3use crate::multimodal;
4use crate::providers::ProviderRuntimeOptions;
5use crate::providers::traits::{
6    ChatMessage, ChatRequest, ChatResponse, Provider, ProviderCapabilities,
7};
8use async_trait::async_trait;
9use futures_util::StreamExt;
10use reqwest::Client;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use std::path::PathBuf;
14
15const DEFAULT_CODEX_RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses";
16const CODEX_RESPONSES_URL_ENV: &str = "CONSTRUCT_CODEX_RESPONSES_URL";
17const CODEX_BASE_URL_ENV: &str = "CONSTRUCT_CODEX_BASE_URL";
18const DEFAULT_CODEX_INSTRUCTIONS: &str =
19    "You are Construct, a concise and helpful coding assistant.";
20
21pub struct OpenAiCodexProvider {
22    auth: AuthService,
23    auth_profile_override: Option<String>,
24    responses_url: String,
25    custom_endpoint: bool,
26    gateway_api_key: Option<String>,
27    reasoning_effort: Option<String>,
28    client: Client,
29}
30
31#[derive(Debug, Serialize)]
32struct ResponsesRequest {
33    model: String,
34    input: Vec<serde_json::Value>,
35    instructions: String,
36    store: bool,
37    stream: bool,
38    text: ResponsesTextOptions,
39    reasoning: ResponsesReasoningOptions,
40    include: Vec<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    tool_choice: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    parallel_tool_calls: Option<bool>,
45    #[serde(skip_serializing_if = "Vec::is_empty")]
46    tools: Vec<ResponsesTool>,
47}
48
49#[derive(Debug, Serialize)]
50struct ResponsesTool {
51    #[serde(rename = "type")]
52    kind: String,
53    name: String,
54    description: String,
55    parameters: serde_json::Value,
56    strict: bool,
57}
58
59#[derive(Debug, Serialize)]
60struct ResponsesTextOptions {
61    verbosity: String,
62}
63
64#[derive(Debug, Serialize)]
65struct ResponsesReasoningOptions {
66    effort: String,
67    summary: String,
68}
69
70#[derive(Debug, Deserialize)]
71struct ResponsesResponse {
72    #[serde(default)]
73    output: Vec<ResponsesOutput>,
74    #[serde(default)]
75    output_text: Option<String>,
76    #[serde(default)]
77    usage: Option<ResponsesUsage>,
78}
79
80#[derive(Debug, Deserialize, Default)]
81struct ResponsesUsage {
82    #[serde(default)]
83    input_tokens: Option<u64>,
84    #[serde(default)]
85    output_tokens: Option<u64>,
86    #[serde(default)]
87    input_tokens_details: Option<ResponsesUsageInputDetails>,
88}
89
90#[derive(Debug, Deserialize, Default)]
91struct ResponsesUsageInputDetails {
92    #[serde(default)]
93    cached_tokens: Option<u64>,
94}
95
96#[derive(Debug, Deserialize)]
97struct ResponsesOutput {
98    #[serde(rename = "type", default)]
99    kind: Option<String>,
100    #[serde(default)]
101    content: Vec<ResponsesContent>,
102    // function_call output fields
103    #[serde(default)]
104    name: Option<String>,
105    #[serde(default)]
106    arguments: Option<String>,
107    #[serde(default)]
108    call_id: Option<String>,
109}
110
111#[derive(Debug, Deserialize)]
112struct ResponsesContent {
113    #[serde(rename = "type")]
114    kind: Option<String>,
115    text: Option<String>,
116}
117
118impl OpenAiCodexProvider {
119    pub fn new(
120        options: &ProviderRuntimeOptions,
121        gateway_api_key: Option<&str>,
122    ) -> anyhow::Result<Self> {
123        let state_dir = options
124            .construct_dir
125            .clone()
126            .unwrap_or_else(default_construct_dir);
127        let auth = AuthService::new(&state_dir, options.secrets_encrypt);
128        let responses_url = resolve_responses_url(options)?;
129
130        Ok(Self {
131            auth,
132            auth_profile_override: options.auth_profile_override.clone(),
133            custom_endpoint: !is_default_responses_url(&responses_url),
134            responses_url,
135            gateway_api_key: gateway_api_key.map(ToString::to_string),
136            reasoning_effort: options.reasoning_effort.clone(),
137            client: Client::builder()
138                .connect_timeout(std::time::Duration::from_secs(10))
139                .read_timeout(std::time::Duration::from_secs(300))
140                .build()
141                .unwrap_or_else(|_| Client::new()),
142        })
143    }
144}
145
146fn default_construct_dir() -> PathBuf {
147    directories::UserDirs::new().map_or_else(
148        || PathBuf::from(".construct"),
149        |dirs| dirs.home_dir().join(".construct"),
150    )
151}
152
153fn build_responses_url(base_or_endpoint: &str) -> anyhow::Result<String> {
154    let candidate = base_or_endpoint.trim();
155    if candidate.is_empty() {
156        anyhow::bail!("OpenAI Codex endpoint override cannot be empty");
157    }
158
159    let mut parsed = reqwest::Url::parse(candidate)
160        .map_err(|_| anyhow::anyhow!("OpenAI Codex endpoint override must be a valid URL"))?;
161
162    match parsed.scheme() {
163        "http" | "https" => {}
164        _ => anyhow::bail!("OpenAI Codex endpoint override must use http:// or https://"),
165    }
166
167    let path = parsed.path().trim_end_matches('/');
168    if !path.ends_with("/responses") {
169        let with_suffix = if path.is_empty() || path == "/" {
170            "/responses".to_string()
171        } else {
172            format!("{path}/responses")
173        };
174        parsed.set_path(&with_suffix);
175    }
176
177    parsed.set_query(None);
178    parsed.set_fragment(None);
179
180    Ok(parsed.to_string())
181}
182
183fn resolve_responses_url(options: &ProviderRuntimeOptions) -> anyhow::Result<String> {
184    if let Some(endpoint) = std::env::var(CODEX_RESPONSES_URL_ENV)
185        .ok()
186        .and_then(|value| first_nonempty(Some(&value)))
187    {
188        return build_responses_url(&endpoint);
189    }
190
191    if let Some(base_url) = std::env::var(CODEX_BASE_URL_ENV)
192        .ok()
193        .and_then(|value| first_nonempty(Some(&value)))
194    {
195        return build_responses_url(&base_url);
196    }
197
198    if let Some(api_url) = options
199        .provider_api_url
200        .as_deref()
201        .and_then(|value| first_nonempty(Some(value)))
202    {
203        return build_responses_url(&api_url);
204    }
205
206    Ok(DEFAULT_CODEX_RESPONSES_URL.to_string())
207}
208
209fn canonical_endpoint(url: &str) -> Option<(String, String, u16, String)> {
210    let parsed = reqwest::Url::parse(url).ok()?;
211    let host = parsed.host_str()?.to_ascii_lowercase();
212    let port = parsed.port_or_known_default()?;
213    let path = parsed.path().trim_end_matches('/').to_string();
214    Some((parsed.scheme().to_ascii_lowercase(), host, port, path))
215}
216
217fn is_default_responses_url(url: &str) -> bool {
218    canonical_endpoint(url) == canonical_endpoint(DEFAULT_CODEX_RESPONSES_URL)
219}
220
221fn first_nonempty(text: Option<&str>) -> Option<String> {
222    text.and_then(|value| {
223        let trimmed = value.trim();
224        if trimmed.is_empty() {
225            None
226        } else {
227            Some(trimmed.to_string())
228        }
229    })
230}
231
232fn resolve_instructions(system_prompt: Option<&str>) -> String {
233    first_nonempty(system_prompt).unwrap_or_else(|| DEFAULT_CODEX_INSTRUCTIONS.to_string())
234}
235
236fn normalize_model_id(model: &str) -> &str {
237    model.rsplit('/').next().unwrap_or(model)
238}
239
240fn build_responses_input(messages: &[ChatMessage]) -> (String, Vec<serde_json::Value>) {
241    let mut system_parts: Vec<&str> = Vec::new();
242    let mut input: Vec<serde_json::Value> = Vec::new();
243
244    for msg in messages {
245        match msg.role.as_str() {
246            "system" => system_parts.push(&msg.content),
247            "user" => {
248                let (cleaned_text, image_refs) = multimodal::parse_image_markers(&msg.content);
249
250                let mut content_items = Vec::new();
251
252                if !cleaned_text.trim().is_empty() {
253                    content_items.push(serde_json::json!({
254                        "type": "input_text",
255                        "text": cleaned_text,
256                    }));
257                }
258
259                for image_ref in image_refs {
260                    content_items.push(serde_json::json!({
261                        "type": "input_image",
262                        "image_url": image_ref,
263                    }));
264                }
265
266                if content_items.is_empty() {
267                    content_items.push(serde_json::json!({
268                        "type": "input_text",
269                        "text": "",
270                    }));
271                }
272
273                input.push(serde_json::json!({
274                    "role": "user",
275                    "content": content_items,
276                }));
277            }
278            "assistant" => {
279                input.push(serde_json::json!({
280                    "role": "assistant",
281                    "content": [{
282                        "type": "output_text",
283                        "text": msg.content,
284                    }],
285                }));
286            }
287            _ => {}
288        }
289    }
290
291    let instructions = if system_parts.is_empty() {
292        DEFAULT_CODEX_INSTRUCTIONS.to_string()
293    } else {
294        system_parts.join("\n\n")
295    };
296
297    (instructions, input)
298}
299
300fn clamp_reasoning_effort(model: &str, effort: &str) -> String {
301    let id = normalize_model_id(model);
302    // gpt-5-codex currently supports only low|medium|high.
303    if id == "gpt-5-codex" {
304        return match effort {
305            "low" | "medium" | "high" => effort.to_string(),
306            "minimal" => "low".to_string(),
307            _ => "high".to_string(),
308        };
309    }
310    if (id.starts_with("gpt-5.2") || id.starts_with("gpt-5.3")) && effort == "minimal" {
311        return "low".to_string();
312    }
313    if id.starts_with("gpt-5-codex") && effort == "xhigh" {
314        return "high".to_string();
315    }
316    if id == "gpt-5.1" && effort == "xhigh" {
317        return "high".to_string();
318    }
319    if id == "gpt-5.1-codex-mini" {
320        return if effort == "high" || effort == "xhigh" {
321            "high".to_string()
322        } else {
323            "medium".to_string()
324        };
325    }
326    effort.to_string()
327}
328
329fn resolve_reasoning_effort(model_id: &str, configured: Option<&str>) -> String {
330    let raw = configured
331        .map(ToString::to_string)
332        .or_else(|| std::env::var("CONSTRUCT_CODEX_REASONING_EFFORT").ok())
333        .and_then(|value| first_nonempty(Some(&value)))
334        .unwrap_or_else(|| "xhigh".to_string())
335        .to_ascii_lowercase();
336    clamp_reasoning_effort(model_id, &raw)
337}
338
339fn nonempty_preserve(text: Option<&str>) -> Option<String> {
340    text.and_then(|value| {
341        if value.is_empty() {
342            None
343        } else {
344            Some(value.to_string())
345        }
346    })
347}
348
349/// Extract both text and tool calls from a Responses API response.
350fn extract_responses_text_and_tools(
351    response: &ResponsesResponse,
352) -> (Option<String>, Vec<crate::providers::ToolCall>) {
353    let text = extract_responses_text(response);
354    let mut tool_calls = Vec::new();
355
356    for item in &response.output {
357        if item.kind.as_deref() == Some("function_call") {
358            if let (Some(name), Some(arguments)) = (&item.name, &item.arguments) {
359                tool_calls.push(crate::providers::ToolCall {
360                    id: item
361                        .call_id
362                        .clone()
363                        .unwrap_or_else(|| format!("call_{}", uuid::Uuid::new_v4())),
364                    name: name.clone(),
365                    arguments: arguments.clone(),
366                });
367            }
368        }
369    }
370
371    (text, tool_calls)
372}
373
374fn extract_responses_text(response: &ResponsesResponse) -> Option<String> {
375    if let Some(text) = first_nonempty(response.output_text.as_deref()) {
376        return Some(text);
377    }
378
379    for item in &response.output {
380        for content in &item.content {
381            if content.kind.as_deref() == Some("output_text") {
382                if let Some(text) = first_nonempty(content.text.as_deref()) {
383                    return Some(text);
384                }
385            }
386        }
387    }
388
389    for item in &response.output {
390        for content in &item.content {
391            if let Some(text) = first_nonempty(content.text.as_deref()) {
392                return Some(text);
393            }
394        }
395    }
396
397    None
398}
399
400fn extract_stream_event_text(event: &Value, saw_delta: bool) -> Option<String> {
401    let event_type = event.get("type").and_then(Value::as_str);
402    match event_type {
403        Some("response.output_text.delta") => {
404            nonempty_preserve(event.get("delta").and_then(Value::as_str))
405        }
406        Some("response.output_text.done") if !saw_delta => {
407            nonempty_preserve(event.get("text").and_then(Value::as_str))
408        }
409        Some("response.completed" | "response.done") => event
410            .get("response")
411            .and_then(|value| serde_json::from_value::<ResponsesResponse>(value.clone()).ok())
412            .and_then(|response| extract_responses_text(&response)),
413        _ => None,
414    }
415}
416
417fn parse_sse_text(body: &str) -> anyhow::Result<Option<String>> {
418    let mut saw_delta = false;
419    let mut delta_accumulator = String::new();
420    let mut fallback_text = None;
421    let mut buffer = body.to_string();
422
423    let mut process_event = |event: Value| -> anyhow::Result<()> {
424        if let Some(message) = extract_stream_error_message(&event) {
425            return Err(anyhow::anyhow!("OpenAI Codex stream error: {message}"));
426        }
427        if let Some(text) = extract_stream_event_text(&event, saw_delta) {
428            let event_type = event.get("type").and_then(Value::as_str);
429            if event_type == Some("response.output_text.delta") {
430                saw_delta = true;
431                delta_accumulator.push_str(&text);
432            } else if fallback_text.is_none() {
433                fallback_text = Some(text);
434            }
435        }
436        Ok(())
437    };
438
439    let mut process_chunk = |chunk: &str| -> anyhow::Result<()> {
440        let data_lines: Vec<String> = chunk
441            .lines()
442            .filter_map(|line| line.strip_prefix("data:"))
443            .map(|line| line.trim().to_string())
444            .collect();
445        if data_lines.is_empty() {
446            return Ok(());
447        }
448
449        let joined = data_lines.join("\n");
450        let trimmed = joined.trim();
451        if trimmed.is_empty() || trimmed == "[DONE]" {
452            return Ok(());
453        }
454
455        if let Ok(event) = serde_json::from_str::<Value>(trimmed) {
456            return process_event(event);
457        }
458
459        for line in data_lines {
460            let line = line.trim();
461            if line.is_empty() || line == "[DONE]" {
462                continue;
463            }
464            if let Ok(event) = serde_json::from_str::<Value>(line) {
465                process_event(event)?;
466            }
467        }
468
469        Ok(())
470    };
471
472    loop {
473        let Some(idx) = buffer.find("\n\n") else {
474            break;
475        };
476
477        let chunk = buffer[..idx].to_string();
478        buffer = buffer[idx + 2..].to_string();
479        process_chunk(&chunk)?;
480    }
481
482    if !buffer.trim().is_empty() {
483        process_chunk(&buffer)?;
484    }
485
486    if saw_delta {
487        return Ok(nonempty_preserve(Some(&delta_accumulator)));
488    }
489
490    Ok(fallback_text)
491}
492
493fn extract_stream_error_message(event: &Value) -> Option<String> {
494    let event_type = event.get("type").and_then(Value::as_str);
495
496    if event_type == Some("error") {
497        return first_nonempty(
498            event
499                .get("message")
500                .and_then(Value::as_str)
501                .or_else(|| event.get("code").and_then(Value::as_str))
502                .or_else(|| {
503                    event
504                        .get("error")
505                        .and_then(|error| error.get("message"))
506                        .and_then(Value::as_str)
507                }),
508        );
509    }
510
511    if event_type == Some("response.failed") {
512        return first_nonempty(
513            event
514                .get("response")
515                .and_then(|response| response.get("error"))
516                .and_then(|error| error.get("message"))
517                .and_then(Value::as_str),
518        );
519    }
520
521    None
522}
523
524fn append_utf8_stream_chunk(
525    body: &mut String,
526    pending: &mut Vec<u8>,
527    chunk: &[u8],
528) -> anyhow::Result<()> {
529    if pending.is_empty() {
530        if let Ok(text) = std::str::from_utf8(chunk) {
531            body.push_str(text);
532            return Ok(());
533        }
534    }
535
536    if !chunk.is_empty() {
537        pending.extend_from_slice(chunk);
538    }
539    if pending.is_empty() {
540        return Ok(());
541    }
542
543    match std::str::from_utf8(pending) {
544        Ok(text) => {
545            body.push_str(text);
546            pending.clear();
547            Ok(())
548        }
549        Err(err) => {
550            let valid_up_to = err.valid_up_to();
551            if valid_up_to > 0 {
552                // SAFETY: `valid_up_to` always points to the end of a valid UTF-8 prefix.
553                let prefix = std::str::from_utf8(&pending[..valid_up_to])
554                    .expect("valid UTF-8 prefix from Utf8Error::valid_up_to");
555                body.push_str(prefix);
556                pending.drain(..valid_up_to);
557            }
558
559            if err.error_len().is_some() {
560                return Err(anyhow::anyhow!(
561                    "OpenAI Codex response contained invalid UTF-8: {err}"
562                ));
563            }
564
565            // `error_len == None` means we have a valid prefix and an incomplete
566            // multi-byte sequence at the end; keep it buffered until next chunk.
567            Ok(())
568        }
569    }
570}
571
572fn decode_utf8_stream_chunks<'a, I>(chunks: I) -> anyhow::Result<String>
573where
574    I: IntoIterator<Item = &'a [u8]>,
575{
576    let mut body = String::new();
577    let mut pending = Vec::new();
578
579    for chunk in chunks {
580        append_utf8_stream_chunk(&mut body, &mut pending, chunk)?;
581    }
582
583    if !pending.is_empty() {
584        let err = std::str::from_utf8(&pending).expect_err("pending bytes should be invalid UTF-8");
585        return Err(anyhow::anyhow!(
586            "OpenAI Codex response ended with incomplete UTF-8: {err}"
587        ));
588    }
589
590    Ok(body)
591}
592
593/// Read the response body incrementally via `bytes_stream()` to avoid
594/// buffering the entire SSE payload in memory.  The previous implementation
595/// used `response.text().await?` which holds the HTTP connection open until
596/// every byte has arrived — on high-latency links the long-lived connection
597/// often drops mid-read, producing the "error decoding response body" failure
598/// reported in #3544.
599async fn decode_responses_body(response: reqwest::Response) -> anyhow::Result<String> {
600    let mut body = String::new();
601    let mut pending_utf8 = Vec::new();
602    let mut stream = response.bytes_stream();
603
604    while let Some(chunk) = stream.next().await {
605        let bytes = chunk
606            .map_err(|err| anyhow::anyhow!("error reading OpenAI Codex response stream: {err}"))?;
607        append_utf8_stream_chunk(&mut body, &mut pending_utf8, &bytes)?;
608    }
609
610    if !pending_utf8.is_empty() {
611        let err = std::str::from_utf8(&pending_utf8)
612            .expect_err("pending bytes should be invalid UTF-8 at end of stream");
613        return Err(anyhow::anyhow!(
614            "OpenAI Codex response ended with incomplete UTF-8: {err}"
615        ));
616    }
617
618    if let Some(text) = parse_sse_text(&body)? {
619        return Ok(text);
620    }
621
622    let body_trimmed = body.trim_start();
623    let looks_like_sse = body_trimmed.starts_with("event:") || body_trimmed.starts_with("data:");
624    if looks_like_sse {
625        return Err(anyhow::anyhow!(
626            "No response from OpenAI Codex stream payload: {}",
627            super::sanitize_api_error(&body)
628        ));
629    }
630
631    let parsed: ResponsesResponse = serde_json::from_str(&body).map_err(|err| {
632        anyhow::anyhow!(
633            "OpenAI Codex JSON parse failed: {err}. Payload: {}",
634            super::sanitize_api_error(&body)
635        )
636    })?;
637    extract_responses_text(&parsed).ok_or_else(|| anyhow::anyhow!("No response from OpenAI Codex"))
638}
639
640/// Like `decode_responses_body` but also extracts function_call tool calls.
641async fn decode_responses_body_with_tools(
642    response: reqwest::Response,
643) -> anyhow::Result<(
644    String,
645    Vec<crate::providers::ToolCall>,
646    Option<crate::providers::traits::TokenUsage>,
647)> {
648    let mut body = String::new();
649    let mut pending_utf8 = Vec::new();
650    let mut stream = response.bytes_stream();
651
652    while let Some(chunk) = stream.next().await {
653        let bytes = chunk
654            .map_err(|err| anyhow::anyhow!("error reading OpenAI Codex response stream: {err}"))?;
655        append_utf8_stream_chunk(&mut body, &mut pending_utf8, &bytes)?;
656    }
657
658    if !pending_utf8.is_empty() {
659        let err = std::str::from_utf8(&pending_utf8)
660            .expect_err("pending bytes should be invalid UTF-8 at end of stream");
661        return Err(anyhow::anyhow!(
662            "OpenAI Codex response ended with incomplete UTF-8: {err}"
663        ));
664    }
665
666    // Try SSE streaming parse first — collect function_call events
667    let mut tool_calls: Vec<crate::providers::ToolCall> = Vec::new();
668    let mut text_result: Option<String> = None;
669    let mut usage_result: Option<crate::providers::traits::TokenUsage> = None;
670
671    // Parse the full SSE body looking for both text and function_call events
672    let body_trimmed = body.trim_start();
673    let looks_like_sse = body_trimmed.starts_with("event:") || body_trimmed.starts_with("data:");
674
675    if looks_like_sse {
676        // Parse SSE events to extract text deltas and function_call events.
677        // The Responses API streams output items incrementally:
678        //   response.output_item.added   → declares a new output item (text, function_call, reasoning)
679        //   response.output_text.delta   → text content delta
680        //   response.function_call_arguments.delta → function call arguments delta
681        //   response.function_call_arguments.done  → function call complete
682        //   response.output_item.done    → output item finalized
683        //   response.completed           → response done (may have empty output array)
684        let mut saw_delta = false;
685        let mut delta_accumulator = String::new();
686
687        // Track in-flight function calls by output_index
688        struct PendingFunctionCall {
689            name: String,
690            call_id: String,
691            arguments: String,
692        }
693        let mut pending_calls: std::collections::HashMap<u64, PendingFunctionCall> =
694            std::collections::HashMap::new();
695
696        for chunk in body.split("\n\n") {
697            for line in chunk.lines() {
698                if let Some(data) = line.strip_prefix("data:") {
699                    let data = data.trim();
700                    if data.is_empty() || data == "[DONE]" {
701                        continue;
702                    }
703                    if let Ok(event) = serde_json::from_str::<Value>(data) {
704                        let event_type = event.get("type").and_then(Value::as_str);
705                        match event_type {
706                            Some("response.output_text.delta") => {
707                                if let Some(delta) = event.get("delta").and_then(Value::as_str) {
708                                    saw_delta = true;
709                                    delta_accumulator.push_str(delta);
710                                }
711                            }
712                            // A new output item is being added — track function_call items
713                            Some("response.output_item.added") => {
714                                if let Some(item) = event.get("item") {
715                                    if item.get("type").and_then(Value::as_str)
716                                        == Some("function_call")
717                                    {
718                                        let output_index = event
719                                            .get("output_index")
720                                            .and_then(Value::as_u64)
721                                            .unwrap_or(0);
722                                        let name = item
723                                            .get("name")
724                                            .and_then(Value::as_str)
725                                            .unwrap_or("")
726                                            .to_string();
727                                        let call_id = item
728                                            .get("call_id")
729                                            .and_then(Value::as_str)
730                                            .unwrap_or("")
731                                            .to_string();
732                                        pending_calls.insert(
733                                            output_index,
734                                            PendingFunctionCall {
735                                                name,
736                                                call_id,
737                                                arguments: String::new(),
738                                            },
739                                        );
740                                    }
741                                }
742                            }
743                            // Accumulate function call arguments
744                            Some("response.function_call_arguments.delta") => {
745                                let output_index = event
746                                    .get("output_index")
747                                    .and_then(Value::as_u64)
748                                    .unwrap_or(0);
749                                if let Some(delta) = event.get("delta").and_then(Value::as_str) {
750                                    if let Some(pending) = pending_calls.get_mut(&output_index) {
751                                        pending.arguments.push_str(delta);
752                                    }
753                                }
754                            }
755                            // Function call arguments complete — finalize the tool call
756                            Some("response.function_call_arguments.done") => {
757                                let output_index = event
758                                    .get("output_index")
759                                    .and_then(Value::as_u64)
760                                    .unwrap_or(0);
761                                // Use the full arguments from the done event if available
762                                if let Some(args) = event.get("arguments").and_then(Value::as_str) {
763                                    if let Some(pending) = pending_calls.get_mut(&output_index) {
764                                        pending.arguments = args.to_string();
765                                    }
766                                }
767                            }
768                            Some("response.completed" | "response.done") => {
769                                if let Some(resp_value) = event.get("response") {
770                                    if let Ok(resp) = serde_json::from_value::<ResponsesResponse>(
771                                        resp_value.clone(),
772                                    ) {
773                                        let (t, tc) = extract_responses_text_and_tools(&resp);
774                                        if !tc.is_empty() {
775                                            tool_calls = tc;
776                                        }
777                                        if text_result.is_none() {
778                                            text_result = t;
779                                        }
780                                        if let Some(u) = token_usage_from_responses(&resp) {
781                                            usage_result = Some(u);
782                                        }
783                                    }
784                                }
785                            }
786                            _ => {}
787                        }
788                    }
789                }
790            }
791        }
792
793        // Collect pending function calls into tool_calls (if response.completed didn't provide them)
794        if tool_calls.is_empty() && !pending_calls.is_empty() {
795            let mut sorted: Vec<_> = pending_calls.into_iter().collect();
796            sorted.sort_by_key(|(idx, _)| *idx);
797            for (_, pending) in sorted {
798                if !pending.name.is_empty() {
799                    tool_calls.push(crate::providers::ToolCall {
800                        id: if pending.call_id.is_empty() {
801                            format!("call_{}", uuid::Uuid::new_v4())
802                        } else {
803                            pending.call_id
804                        },
805                        name: pending.name,
806                        arguments: pending.arguments,
807                    });
808                }
809            }
810        }
811
812        if saw_delta {
813            text_result = Some(delta_accumulator);
814        }
815
816        if usage_result.is_none() {
817            tracing::warn!(
818                "OpenAI Codex SSE stream completed without a usage payload; cost tracking will record a zero-token request"
819            );
820        }
821
822        let text = text_result.unwrap_or_default();
823        return Ok((text, tool_calls, usage_result));
824    }
825
826    // Non-SSE JSON response
827    let parsed: ResponsesResponse = serde_json::from_str(&body).map_err(|err| {
828        anyhow::anyhow!(
829            "OpenAI Codex JSON parse failed: {err}. Payload: {}",
830            super::sanitize_api_error(&body)
831        )
832    })?;
833    let (text, tc) = extract_responses_text_and_tools(&parsed);
834    let usage = token_usage_from_responses(&parsed);
835    Ok((text.unwrap_or_default(), tc, usage))
836}
837
838fn token_usage_from_responses(
839    resp: &ResponsesResponse,
840) -> Option<crate::providers::traits::TokenUsage> {
841    let u = resp.usage.as_ref()?;
842    if u.input_tokens.is_none() && u.output_tokens.is_none() {
843        return None;
844    }
845    Some(crate::providers::traits::TokenUsage {
846        input_tokens: u.input_tokens,
847        output_tokens: u.output_tokens,
848        cached_input_tokens: u
849            .input_tokens_details
850            .as_ref()
851            .and_then(|d| d.cached_tokens),
852    })
853}
854
855impl OpenAiCodexProvider {
856    async fn send_responses_request(
857        &self,
858        input: Vec<serde_json::Value>,
859        instructions: String,
860        model: &str,
861    ) -> anyhow::Result<String> {
862        let (text, _, _) = self
863            .send_responses_request_inner(input, instructions, model, Vec::new())
864            .await?;
865        Ok(text)
866    }
867
868    async fn send_responses_request_inner(
869        &self,
870        input: Vec<serde_json::Value>,
871        instructions: String,
872        model: &str,
873        tools: Vec<ResponsesTool>,
874    ) -> anyhow::Result<(
875        String,
876        Vec<crate::providers::ToolCall>,
877        Option<crate::providers::traits::TokenUsage>,
878    )> {
879        let use_gateway_api_key_auth = self.custom_endpoint && self.gateway_api_key.is_some();
880        let profile = match self
881            .auth
882            .get_profile("openai-codex", self.auth_profile_override.as_deref())
883            .await
884        {
885            Ok(profile) => profile,
886            Err(err) if use_gateway_api_key_auth => {
887                tracing::warn!(
888                    error = %err,
889                    "failed to load OpenAI Codex profile; continuing with custom endpoint API key mode"
890                );
891                None
892            }
893            Err(err) => return Err(err),
894        };
895        let oauth_access_token = match self
896            .auth
897            .get_valid_openai_access_token(self.auth_profile_override.as_deref())
898            .await
899        {
900            Ok(token) => token,
901            Err(err) if use_gateway_api_key_auth => {
902                tracing::warn!(
903                    error = %err,
904                    "failed to refresh OpenAI token; continuing with custom endpoint API key mode"
905                );
906                None
907            }
908            Err(err) => return Err(err),
909        };
910
911        let account_id = profile.and_then(|profile| profile.account_id).or_else(|| {
912            oauth_access_token
913                .as_deref()
914                .and_then(extract_account_id_from_jwt)
915        });
916        let access_token = if use_gateway_api_key_auth {
917            oauth_access_token
918        } else {
919            Some(oauth_access_token.ok_or_else(|| {
920                anyhow::anyhow!(
921                    "OpenAI Codex auth profile not found. Run `construct auth login --provider openai-codex`."
922                )
923            })?)
924        };
925        let account_id = if use_gateway_api_key_auth {
926            account_id
927        } else {
928            Some(account_id.ok_or_else(|| {
929                anyhow::anyhow!(
930                    "OpenAI Codex account id not found in auth profile/token. Run `construct auth login --provider openai-codex` again."
931                )
932            })?)
933        };
934        let normalized_model = normalize_model_id(model);
935
936        let request = ResponsesRequest {
937            model: normalized_model.to_string(),
938            input,
939            instructions,
940            store: false,
941            stream: true,
942            text: ResponsesTextOptions {
943                verbosity: "medium".to_string(),
944            },
945            reasoning: ResponsesReasoningOptions {
946                effort: resolve_reasoning_effort(
947                    normalized_model,
948                    self.reasoning_effort.as_deref(),
949                ),
950                summary: "auto".to_string(),
951            },
952            include: vec!["reasoning.encrypted_content".to_string()],
953            tool_choice: if tools.is_empty() {
954                None
955            } else {
956                Some("auto".to_string())
957            },
958            parallel_tool_calls: if tools.is_empty() { None } else { Some(true) },
959            tools,
960        };
961
962        let bearer_token = if use_gateway_api_key_auth {
963            self.gateway_api_key.as_deref().unwrap_or_default()
964        } else {
965            access_token.as_deref().unwrap_or_default()
966        };
967
968        let mut request_builder = self
969            .client
970            .post(&self.responses_url)
971            .header("Authorization", format!("Bearer {bearer_token}"))
972            .header("OpenAI-Beta", "responses=experimental")
973            .header("originator", "pi")
974            .header("accept", "text/event-stream")
975            .header("Content-Type", "application/json");
976
977        if let Some(account_id) = account_id.as_deref() {
978            request_builder = request_builder.header("chatgpt-account-id", account_id);
979        }
980
981        if use_gateway_api_key_auth {
982            if let Some(access_token) = access_token.as_deref() {
983                request_builder = request_builder.header("x-openai-access-token", access_token);
984            }
985            if let Some(account_id) = account_id.as_deref() {
986                request_builder = request_builder.header("x-openai-account-id", account_id);
987            }
988        }
989
990        tracing::info!(
991            input_count = request.input.len(),
992            tools_count = request.tools.len(),
993            "Codex Responses API request"
994        );
995
996        let response = request_builder.json(&request).send().await?;
997
998        if !response.status().is_success() {
999            // Log the first few input items for debugging on error
1000            tracing::warn!(
1001                input_count = request.input.len(),
1002                tools_count = request.tools.len(),
1003                input_preview = %serde_json::to_string(&request.input.iter().take(3).collect::<Vec<_>>()).unwrap_or_default(),
1004                "Codex API request failed"
1005            );
1006            return Err(super::api_error("OpenAI Codex", response).await);
1007        }
1008
1009        let result = decode_responses_body_with_tools(response).await;
1010        if let Ok((ref text, ref tool_calls, ref usage)) = result {
1011            tracing::info!(
1012                text_len = text.len(),
1013                tool_calls_count = tool_calls.len(),
1014                tool_names = %tool_calls.iter().map(|tc| tc.name.as_str()).collect::<Vec<_>>().join(", "),
1015                input_tokens = usage.as_ref().and_then(|u| u.input_tokens).unwrap_or(0),
1016                output_tokens = usage.as_ref().and_then(|u| u.output_tokens).unwrap_or(0),
1017                "Codex Responses API response"
1018            );
1019            if text.is_empty() && tool_calls.is_empty() {
1020                tracing::warn!(
1021                    "Codex Responses API returned empty text AND no tool calls — model produced no output"
1022                );
1023            }
1024        }
1025        if let Err(ref e) = result {
1026            tracing::error!(error = %e, "Codex Responses API decode failed");
1027        }
1028        result
1029    }
1030}
1031
1032/// Convert a `ToolSpec` into the Codex Responses API `function` tool format.
1033fn tool_spec_to_responses_tool(spec: &crate::tools::ToolSpec) -> Option<ResponsesTool> {
1034    if spec.name.is_empty() {
1035        tracing::warn!("Skipping tool with empty name");
1036        return None;
1037    }
1038    Some(ResponsesTool {
1039        kind: "function".to_string(),
1040        name: spec.name.clone(),
1041        description: spec.description.clone(),
1042        parameters: spec.parameters.clone(),
1043        strict: false,
1044    })
1045}
1046
1047/// Build Responses API input items from conversation history, including tool
1048/// call results (`role=tool` messages → `function_call_output` top-level items).
1049fn build_responses_input_with_tools(messages: &[ChatMessage]) -> (String, Vec<serde_json::Value>) {
1050    let mut system_parts: Vec<&str> = Vec::new();
1051    let mut input: Vec<serde_json::Value> = Vec::new();
1052    // Track emitted function_call call_ids so we can validate function_call_outputs.
1053    // The Responses API rejects any function_call_output whose call_id does not
1054    // match a preceding function_call — this can happen when context compression,
1055    // history trimming, or deferred tool activation drops an assistant message
1056    // that contained the original tool call.
1057    let mut emitted_call_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1058
1059    // Pre-scan tool messages to collect call_ids that have matching outputs. The
1060    // Responses API also rejects any function_call whose call_id lacks a following
1061    // function_call_output (error: "No tool output found for function call X"). This
1062    // happens when tool results are missing from history — e.g. a channel agent
1063    // that crashed mid-turn, or persistence dropped the tool message. We skip
1064    // emitting function_call items that have no matching output to keep the
1065    // payload self-consistent.
1066    let mut outputs_present: std::collections::HashSet<String> = std::collections::HashSet::new();
1067    for msg in messages {
1068        if msg.role.as_str() == "tool" {
1069            if let Ok(parsed) = serde_json::from_str::<Value>(&msg.content) {
1070                if let Some(cid) = parsed.get("tool_call_id").and_then(Value::as_str) {
1071                    if !cid.is_empty() {
1072                        outputs_present.insert(cid.to_string());
1073                    }
1074                }
1075            }
1076        }
1077    }
1078
1079    for msg in messages {
1080        match msg.role.as_str() {
1081            "system" => system_parts.push(&msg.content),
1082            "user" => {
1083                let (cleaned_text, image_refs) = multimodal::parse_image_markers(&msg.content);
1084
1085                let mut content_items: Vec<serde_json::Value> = Vec::new();
1086                if !cleaned_text.trim().is_empty() {
1087                    content_items.push(serde_json::json!({
1088                        "type": "input_text",
1089                        "text": cleaned_text,
1090                    }));
1091                }
1092                for image_ref in image_refs {
1093                    content_items.push(serde_json::json!({
1094                        "type": "input_image",
1095                        "image_url": image_ref,
1096                    }));
1097                }
1098                if content_items.is_empty() {
1099                    content_items.push(serde_json::json!({
1100                        "type": "input_text",
1101                        "text": "",
1102                    }));
1103                }
1104
1105                input.push(serde_json::json!({
1106                    "role": "user",
1107                    "content": content_items,
1108                }));
1109            }
1110            "assistant" => {
1111                // Check if the assistant message contains native tool calls
1112                // (stored as JSON with tool_calls array by build_native_assistant_history).
1113                if let Ok(parsed) = serde_json::from_str::<Value>(&msg.content) {
1114                    if let Some(tool_calls) = parsed.get("tool_calls").and_then(Value::as_array) {
1115                        // Emit the text part first (if any)
1116                        if let Some(text) = parsed.get("content").and_then(Value::as_str) {
1117                            if !text.is_empty() {
1118                                input.push(serde_json::json!({
1119                                    "role": "assistant",
1120                                    "content": [{
1121                                        "type": "output_text",
1122                                        "text": text,
1123                                    }],
1124                                }));
1125                            }
1126                        }
1127                        // Emit each tool call as a top-level function_call input item.
1128                        // Tool calls may be stored in OpenAI format:
1129                        //   {"id": "...", "function": {"name": "...", "arguments": "..."}}
1130                        // or flat format:
1131                        //   {"id": "...", "name": "...", "arguments": "..."}
1132                        for tc in tool_calls {
1133                            let call_id = tc
1134                                .get("id")
1135                                .or_else(|| tc.get("call_id"))
1136                                .and_then(Value::as_str)
1137                                .unwrap_or("");
1138                            let name = tc
1139                                .get("function")
1140                                .and_then(|f| f.get("name"))
1141                                .and_then(Value::as_str)
1142                                .or_else(|| tc.get("name").and_then(Value::as_str))
1143                                .unwrap_or("");
1144                            let arguments = tc
1145                                .get("function")
1146                                .and_then(|f| f.get("arguments"))
1147                                .and_then(Value::as_str)
1148                                .or_else(|| tc.get("arguments").and_then(Value::as_str))
1149                                .unwrap_or("{}");
1150                            // Skip tool calls with empty names — invalid for the API
1151                            if name.is_empty() {
1152                                tracing::warn!(
1153                                    call_id,
1154                                    "Skipping tool call with empty name in history"
1155                                );
1156                                continue;
1157                            }
1158                            // Skip function_calls whose output is missing from history.
1159                            // Responses API rejects orphan function_calls with
1160                            // "No tool output found for function call X".
1161                            if call_id.is_empty() || !outputs_present.contains(call_id) {
1162                                tracing::debug!(
1163                                    call_id,
1164                                    name,
1165                                    "Dropping orphaned function_call — no matching function_call_output in history"
1166                                );
1167                                continue;
1168                            }
1169                            emitted_call_ids.insert(call_id.to_string());
1170                            input.push(serde_json::json!({
1171                                "type": "function_call",
1172                                "call_id": call_id,
1173                                "name": name,
1174                                "arguments": arguments,
1175                            }));
1176                        }
1177                        continue;
1178                    }
1179                }
1180
1181                input.push(serde_json::json!({
1182                    "role": "assistant",
1183                    "content": [{
1184                        "type": "output_text",
1185                        "text": msg.content,
1186                    }],
1187                }));
1188            }
1189            "tool" => {
1190                // Tool result messages: content is JSON like {"tool_call_id": "...", "content": "..."}
1191                // Emit as top-level function_call_output items in the Responses API.
1192                if let Ok(parsed) = serde_json::from_str::<Value>(&msg.content) {
1193                    let call_id = parsed
1194                        .get("tool_call_id")
1195                        .and_then(Value::as_str)
1196                        .unwrap_or("");
1197                    // Skip orphaned tool results with no call_id
1198                    if call_id.is_empty() {
1199                        continue;
1200                    }
1201                    // Skip tool results whose function_call was dropped (by context
1202                    // compression, history trimming, or deferred-loading activation).
1203                    if !emitted_call_ids.contains(call_id) {
1204                        tracing::debug!(
1205                            call_id,
1206                            "Dropping orphaned function_call_output — no matching function_call in history"
1207                        );
1208                        continue;
1209                    }
1210                    let output = parsed
1211                        .get("content")
1212                        .and_then(Value::as_str)
1213                        .unwrap_or(&msg.content);
1214                    input.push(serde_json::json!({
1215                        "type": "function_call_output",
1216                        "call_id": call_id,
1217                        "output": output,
1218                    }));
1219                }
1220            }
1221            _ => {}
1222        }
1223    }
1224
1225    let instructions = if system_parts.is_empty() {
1226        DEFAULT_CODEX_INSTRUCTIONS.to_string()
1227    } else {
1228        system_parts.join("\n\n")
1229    };
1230
1231    (instructions, input)
1232}
1233
1234#[async_trait]
1235impl Provider for OpenAiCodexProvider {
1236    fn capabilities(&self) -> ProviderCapabilities {
1237        ProviderCapabilities {
1238            native_tool_calling: true,
1239            vision: true,
1240            prompt_caching: false,
1241        }
1242    }
1243
1244    async fn chat(
1245        &self,
1246        request: ChatRequest<'_>,
1247        model: &str,
1248        _temperature: f64,
1249    ) -> anyhow::Result<ChatResponse> {
1250        let config = crate::config::MultimodalConfig::default();
1251        let prepared =
1252            crate::multimodal::prepare_messages_for_provider(request.messages, &config).await?;
1253
1254        let (instructions, input) = build_responses_input_with_tools(&prepared.messages);
1255
1256        let tools: Vec<ResponsesTool> = request
1257            .tools
1258            .map(|specs| {
1259                specs
1260                    .iter()
1261                    .filter_map(tool_spec_to_responses_tool)
1262                    .collect()
1263            })
1264            .unwrap_or_default();
1265
1266        let (text, tool_calls, usage) = self
1267            .send_responses_request_inner(input, instructions, model, tools)
1268            .await?;
1269
1270        Ok(ChatResponse {
1271            text: if text.is_empty() { None } else { Some(text) },
1272            tool_calls,
1273            usage,
1274            reasoning_content: None,
1275        })
1276    }
1277
1278    async fn chat_with_system(
1279        &self,
1280        system_prompt: Option<&str>,
1281        message: &str,
1282        model: &str,
1283        _temperature: f64,
1284    ) -> anyhow::Result<String> {
1285        let mut messages = Vec::new();
1286        if let Some(sys) = system_prompt {
1287            messages.push(ChatMessage::system(sys));
1288        }
1289        messages.push(ChatMessage::user(message));
1290
1291        let config = crate::config::MultimodalConfig::default();
1292        let prepared = crate::multimodal::prepare_messages_for_provider(&messages, &config).await?;
1293
1294        let (instructions, input) = build_responses_input(&prepared.messages);
1295        self.send_responses_request(input, instructions, model)
1296            .await
1297    }
1298
1299    async fn chat_with_history(
1300        &self,
1301        messages: &[ChatMessage],
1302        model: &str,
1303        _temperature: f64,
1304    ) -> anyhow::Result<String> {
1305        let config = crate::config::MultimodalConfig::default();
1306        let prepared = crate::multimodal::prepare_messages_for_provider(messages, &config).await?;
1307
1308        let (instructions, input) = build_responses_input(&prepared.messages);
1309        self.send_responses_request(input, instructions, model)
1310            .await
1311    }
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316    use super::*;
1317    use std::sync::{Mutex, MutexGuard, OnceLock};
1318
1319    fn env_lock() -> MutexGuard<'static, ()> {
1320        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1321        LOCK.get_or_init(|| Mutex::new(()))
1322            .lock()
1323            .expect("env lock poisoned")
1324    }
1325
1326    struct EnvGuard {
1327        key: &'static str,
1328        original: Option<String>,
1329    }
1330
1331    impl EnvGuard {
1332        fn set(key: &'static str, value: Option<&str>) -> Self {
1333            let original = std::env::var(key).ok();
1334            match value {
1335                // SAFETY: test-only, single-threaded test runner.
1336                Some(next) => unsafe { std::env::set_var(key, next) },
1337                // SAFETY: test-only, single-threaded test runner.
1338                None => unsafe { std::env::remove_var(key) },
1339            }
1340            Self { key, original }
1341        }
1342    }
1343
1344    impl Drop for EnvGuard {
1345        fn drop(&mut self) {
1346            if let Some(original) = self.original.as_deref() {
1347                // SAFETY: test-only, single-threaded test runner.
1348                unsafe { std::env::set_var(self.key, original) };
1349            } else {
1350                // SAFETY: test-only, single-threaded test runner.
1351                unsafe { std::env::remove_var(self.key) };
1352            }
1353        }
1354    }
1355
1356    #[test]
1357    fn extracts_output_text_first() {
1358        let response = ResponsesResponse {
1359            output: vec![],
1360            output_text: Some("hello".into()),
1361            usage: None,
1362        };
1363        assert_eq!(extract_responses_text(&response).as_deref(), Some("hello"));
1364    }
1365
1366    #[test]
1367    fn extracts_nested_output_text() {
1368        let response = ResponsesResponse {
1369            output: vec![ResponsesOutput {
1370                kind: None,
1371                content: vec![ResponsesContent {
1372                    kind: Some("output_text".into()),
1373                    text: Some("nested".into()),
1374                }],
1375                name: None,
1376                arguments: None,
1377                call_id: None,
1378            }],
1379            output_text: None,
1380            usage: None,
1381        };
1382        assert_eq!(extract_responses_text(&response).as_deref(), Some("nested"));
1383    }
1384
1385    #[test]
1386    fn default_state_dir_is_non_empty() {
1387        let path = default_construct_dir();
1388        assert!(!path.as_os_str().is_empty());
1389    }
1390
1391    #[test]
1392    fn build_responses_url_appends_suffix_for_base_url() {
1393        assert_eq!(
1394            build_responses_url("https://api.tonsof.blue/v1").unwrap(),
1395            "https://api.tonsof.blue/v1/responses"
1396        );
1397    }
1398
1399    #[test]
1400    fn build_responses_url_keeps_existing_responses_endpoint() {
1401        assert_eq!(
1402            build_responses_url("https://api.tonsof.blue/v1/responses").unwrap(),
1403            "https://api.tonsof.blue/v1/responses"
1404        );
1405    }
1406
1407    #[test]
1408    fn resolve_responses_url_prefers_explicit_endpoint_env() {
1409        let _lock = env_lock();
1410        let _endpoint_guard = EnvGuard::set(
1411            CODEX_RESPONSES_URL_ENV,
1412            Some("https://env.example.com/v1/responses"),
1413        );
1414        let _base_guard = EnvGuard::set(CODEX_BASE_URL_ENV, Some("https://base.example.com/v1"));
1415
1416        let options = ProviderRuntimeOptions::default();
1417        assert_eq!(
1418            resolve_responses_url(&options).unwrap(),
1419            "https://env.example.com/v1/responses"
1420        );
1421    }
1422
1423    #[test]
1424    fn resolve_responses_url_uses_provider_api_url_override() {
1425        let _lock = env_lock();
1426        let _endpoint_guard = EnvGuard::set(CODEX_RESPONSES_URL_ENV, None);
1427        let _base_guard = EnvGuard::set(CODEX_BASE_URL_ENV, None);
1428
1429        let options = ProviderRuntimeOptions {
1430            provider_api_url: Some("https://proxy.example.com/v1".to_string()),
1431            ..ProviderRuntimeOptions::default()
1432        };
1433
1434        assert_eq!(
1435            resolve_responses_url(&options).unwrap(),
1436            "https://proxy.example.com/v1/responses"
1437        );
1438    }
1439
1440    #[test]
1441    fn default_responses_url_detector_handles_equivalent_urls() {
1442        assert!(is_default_responses_url(DEFAULT_CODEX_RESPONSES_URL));
1443        assert!(is_default_responses_url(
1444            "https://chatgpt.com/backend-api/codex/responses/"
1445        ));
1446        assert!(!is_default_responses_url(
1447            "https://api.tonsof.blue/v1/responses"
1448        ));
1449    }
1450
1451    #[test]
1452    fn constructor_enables_custom_endpoint_key_mode() {
1453        let options = ProviderRuntimeOptions {
1454            provider_api_url: Some("https://api.tonsof.blue/v1".to_string()),
1455            ..ProviderRuntimeOptions::default()
1456        };
1457
1458        let provider = OpenAiCodexProvider::new(&options, Some("test-key")).unwrap();
1459        assert!(provider.custom_endpoint);
1460        assert_eq!(provider.gateway_api_key.as_deref(), Some("test-key"));
1461    }
1462
1463    #[test]
1464    fn resolve_instructions_uses_default_when_missing() {
1465        assert_eq!(
1466            resolve_instructions(None),
1467            DEFAULT_CODEX_INSTRUCTIONS.to_string()
1468        );
1469    }
1470
1471    #[test]
1472    fn resolve_instructions_uses_default_when_blank() {
1473        assert_eq!(
1474            resolve_instructions(Some("   ")),
1475            DEFAULT_CODEX_INSTRUCTIONS.to_string()
1476        );
1477    }
1478
1479    #[test]
1480    fn resolve_instructions_uses_system_prompt_when_present() {
1481        assert_eq!(
1482            resolve_instructions(Some("Be strict")),
1483            "Be strict".to_string()
1484        );
1485    }
1486
1487    #[test]
1488    fn clamp_reasoning_effort_adjusts_known_models() {
1489        assert_eq!(
1490            clamp_reasoning_effort("gpt-5-codex", "xhigh"),
1491            "high".to_string()
1492        );
1493        assert_eq!(
1494            clamp_reasoning_effort("gpt-5-codex", "minimal"),
1495            "low".to_string()
1496        );
1497        assert_eq!(
1498            clamp_reasoning_effort("gpt-5-codex", "medium"),
1499            "medium".to_string()
1500        );
1501        assert_eq!(
1502            clamp_reasoning_effort("gpt-5.3-codex", "minimal"),
1503            "low".to_string()
1504        );
1505        assert_eq!(
1506            clamp_reasoning_effort("gpt-5.1", "xhigh"),
1507            "high".to_string()
1508        );
1509        assert_eq!(
1510            clamp_reasoning_effort("gpt-5-codex", "xhigh"),
1511            "high".to_string()
1512        );
1513        assert_eq!(
1514            clamp_reasoning_effort("gpt-5.1-codex-mini", "low"),
1515            "medium".to_string()
1516        );
1517        assert_eq!(
1518            clamp_reasoning_effort("gpt-5.1-codex-mini", "xhigh"),
1519            "high".to_string()
1520        );
1521        assert_eq!(
1522            clamp_reasoning_effort("gpt-5.3-codex", "xhigh"),
1523            "xhigh".to_string()
1524        );
1525    }
1526
1527    #[test]
1528    fn resolve_reasoning_effort_prefers_configured_override() {
1529        let _lock = env_lock();
1530        let _guard = EnvGuard::set("CONSTRUCT_CODEX_REASONING_EFFORT", Some("low"));
1531        assert_eq!(
1532            resolve_reasoning_effort("gpt-5-codex", Some("high")),
1533            "high".to_string()
1534        );
1535    }
1536
1537    #[test]
1538    fn resolve_reasoning_effort_uses_legacy_env_when_unconfigured() {
1539        let _lock = env_lock();
1540        let _guard = EnvGuard::set("CONSTRUCT_CODEX_REASONING_EFFORT", Some("minimal"));
1541        assert_eq!(
1542            resolve_reasoning_effort("gpt-5-codex", None),
1543            "low".to_string()
1544        );
1545    }
1546
1547    #[test]
1548    fn parse_sse_text_reads_output_text_delta() {
1549        let payload = r#"data: {"type":"response.created","response":{"id":"resp_123"}}
1550
1551data: {"type":"response.output_text.delta","delta":"Hello"}
1552data: {"type":"response.output_text.delta","delta":" world"}
1553data: {"type":"response.completed","response":{"output_text":"Hello world"}}
1554data: [DONE]
1555"#;
1556
1557        assert_eq!(
1558            parse_sse_text(payload).unwrap().as_deref(),
1559            Some("Hello world")
1560        );
1561    }
1562
1563    #[test]
1564    fn parse_sse_text_falls_back_to_completed_response() {
1565        let payload = r#"data: {"type":"response.completed","response":{"output_text":"Done"}}
1566data: [DONE]
1567"#;
1568
1569        assert_eq!(parse_sse_text(payload).unwrap().as_deref(), Some("Done"));
1570    }
1571
1572    #[test]
1573    fn decode_utf8_stream_chunks_handles_multibyte_split_across_chunks() {
1574        let payload = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"Hello 世\"}\n\ndata: [DONE]\n";
1575        let bytes = payload.as_bytes();
1576        let split_at = payload.find('世').unwrap() + 1;
1577
1578        let decoded = decode_utf8_stream_chunks([&bytes[..split_at], &bytes[split_at..]]).unwrap();
1579        assert_eq!(decoded, payload);
1580        assert_eq!(
1581            parse_sse_text(&decoded).unwrap().as_deref(),
1582            Some("Hello 世")
1583        );
1584    }
1585
1586    #[test]
1587    fn build_responses_input_maps_content_types_by_role() {
1588        let messages = vec![
1589            ChatMessage {
1590                role: "system".into(),
1591                content: "You are helpful.".into(),
1592            },
1593            ChatMessage {
1594                role: "user".into(),
1595                content: "Hi".into(),
1596            },
1597            ChatMessage {
1598                role: "assistant".into(),
1599                content: "Hello!".into(),
1600            },
1601            ChatMessage {
1602                role: "user".into(),
1603                content: "Thanks".into(),
1604            },
1605        ];
1606        let (instructions, input) = build_responses_input(&messages);
1607        assert_eq!(instructions, "You are helpful.");
1608        assert_eq!(input.len(), 3);
1609
1610        assert_eq!(input[0]["role"], "user");
1611        assert_eq!(input[0]["content"][0]["type"], "input_text");
1612        assert_eq!(input[1]["role"], "assistant");
1613        assert_eq!(input[1]["content"][0]["type"], "output_text");
1614        assert_eq!(input[2]["role"], "user");
1615        assert_eq!(input[2]["content"][0]["type"], "input_text");
1616    }
1617
1618    #[test]
1619    fn build_responses_input_uses_default_instructions_without_system() {
1620        let messages = vec![ChatMessage {
1621            role: "user".into(),
1622            content: "Hello".into(),
1623        }];
1624        let (instructions, input) = build_responses_input(&messages);
1625        assert_eq!(instructions, DEFAULT_CODEX_INSTRUCTIONS);
1626        assert_eq!(input.len(), 1);
1627    }
1628
1629    #[test]
1630    fn build_responses_input_ignores_unknown_roles() {
1631        let messages = vec![
1632            ChatMessage {
1633                role: "tool".into(),
1634                content: "result".into(),
1635            },
1636            ChatMessage {
1637                role: "user".into(),
1638                content: "Go".into(),
1639            },
1640        ];
1641        let (instructions, input) = build_responses_input(&messages);
1642        assert_eq!(instructions, DEFAULT_CODEX_INSTRUCTIONS);
1643        assert_eq!(input.len(), 1);
1644        assert_eq!(input[0]["role"], "user");
1645    }
1646
1647    #[test]
1648    fn build_responses_input_handles_image_markers() {
1649        let messages = vec![ChatMessage::user(
1650            "Describe this\n\n[IMAGE:data:image/png;base64,abc]",
1651        )];
1652        let (_, input) = build_responses_input(&messages);
1653
1654        assert_eq!(input.len(), 1);
1655        assert_eq!(input[0]["role"], "user");
1656        let content = input[0]["content"].as_array().unwrap();
1657        assert_eq!(content.len(), 2);
1658
1659        // First content = text
1660        assert_eq!(content[0]["type"], "input_text");
1661        assert!(
1662            content[0]["text"]
1663                .as_str()
1664                .unwrap()
1665                .contains("Describe this")
1666        );
1667
1668        // Second content = image
1669        assert_eq!(content[1]["type"], "input_image");
1670        assert_eq!(content[1]["image_url"], "data:image/png;base64,abc");
1671    }
1672
1673    #[test]
1674    fn build_responses_input_preserves_text_only_messages() {
1675        let messages = vec![ChatMessage::user("Hello without images")];
1676        let (_, input) = build_responses_input(&messages);
1677
1678        assert_eq!(input.len(), 1);
1679        let content = input[0]["content"].as_array().unwrap();
1680        assert_eq!(content.len(), 1);
1681
1682        assert_eq!(content[0]["type"], "input_text");
1683        assert_eq!(content[0]["text"], "Hello without images");
1684    }
1685
1686    #[test]
1687    fn build_responses_input_handles_multiple_images() {
1688        let messages = vec![ChatMessage::user(
1689            "Compare these: [IMAGE:data:image/png;base64,img1] and [IMAGE:data:image/jpeg;base64,img2]",
1690        )];
1691        let (_, input) = build_responses_input(&messages);
1692
1693        assert_eq!(input.len(), 1);
1694        let content = input[0]["content"].as_array().unwrap();
1695        assert_eq!(content.len(), 3); // text + 2 images
1696
1697        assert_eq!(content[0]["type"], "input_text");
1698        assert_eq!(content[1]["type"], "input_image");
1699        assert_eq!(content[2]["type"], "input_image");
1700    }
1701
1702    #[test]
1703    fn capabilities_includes_vision() {
1704        let options = ProviderRuntimeOptions {
1705            provider_api_url: None,
1706            construct_dir: None,
1707            secrets_encrypt: false,
1708            auth_profile_override: None,
1709            reasoning_enabled: None,
1710            reasoning_effort: None,
1711            provider_timeout_secs: None,
1712            extra_headers: std::collections::HashMap::new(),
1713            api_path: None,
1714            provider_max_tokens: None,
1715        };
1716        let provider =
1717            OpenAiCodexProvider::new(&options, None).expect("provider should initialize");
1718        let caps = provider.capabilities();
1719
1720        assert!(caps.native_tool_calling);
1721        assert!(caps.vision);
1722    }
1723
1724    #[test]
1725    fn build_responses_input_drops_orphaned_function_call_output() {
1726        // Simulate history where context compression dropped the assistant message
1727        // containing the function_call, but kept the tool result.
1728        let messages = vec![
1729            ChatMessage {
1730                role: "system".into(),
1731                content: "You are helpful.".into(),
1732            },
1733            ChatMessage {
1734                role: "user".into(),
1735                content: "Do something.".into(),
1736            },
1737            // No assistant message with tool_calls — it was compressed away.
1738            ChatMessage {
1739                role: "tool".into(),
1740                content: r#"{"tool_call_id": "call_orphaned", "content": "some result"}"#.into(),
1741            },
1742        ];
1743        let (_, input) = build_responses_input_with_tools(&messages);
1744        // The orphaned function_call_output should be dropped.
1745        assert!(
1746            !input.iter().any(
1747                |item| item.get("type").and_then(Value::as_str) == Some("function_call_output")
1748            ),
1749            "orphaned function_call_output should be filtered out"
1750        );
1751    }
1752
1753    #[test]
1754    fn build_responses_input_keeps_matched_function_call_output() {
1755        let messages = vec![
1756            ChatMessage { role: "system".into(), content: "You are helpful.".into() },
1757            ChatMessage { role: "user".into(), content: "Do something.".into() },
1758            ChatMessage {
1759                role: "assistant".into(),
1760                content: r#"{"content": null, "tool_calls": [{"id": "call_good", "name": "tool_search", "arguments": "{}"}]}"#.into(),
1761            },
1762            ChatMessage {
1763                role: "tool".into(),
1764                content: r#"{"tool_call_id": "call_good", "content": "search results"}"#.into(),
1765            },
1766        ];
1767        let (_, input) = build_responses_input_with_tools(&messages);
1768        let has_call = input.iter().any(|item| {
1769            item.get("type").and_then(Value::as_str) == Some("function_call")
1770                && item.get("call_id").and_then(Value::as_str) == Some("call_good")
1771        });
1772        let has_output = input.iter().any(|item| {
1773            item.get("type").and_then(Value::as_str) == Some("function_call_output")
1774                && item.get("call_id").and_then(Value::as_str) == Some("call_good")
1775        });
1776        assert!(has_call, "function_call should be present");
1777        assert!(has_output, "matched function_call_output should be kept");
1778    }
1779}