Skip to main content

thndrs_lib/core/providers/
codex.rs

1//! ChatGPT Codex provider foundations.
2//!
3//! This provider targets the ChatGPT-backed Codex Responses endpoint. The
4//! endpoint is not a stable OpenAI Platform API, so the public model ids keep a
5//! separate `chatgpt-codex/` prefix and status copy labels it experimental.
6
7use std::borrow::Cow;
8use std::path::Path;
9
10use crate::cli::{ReasoningEffort, ReasoningSummary};
11use crate::{
12    app::AgentEvent,
13    providers::{
14        self, KnownModel, ProviderContentBlock, ProviderContinuation, ProviderError, ProviderMessage,
15        ProviderMessageContent, Result, StreamFormat, StreamingProvider, StreamingRequest, provider_http_agent,
16    },
17    thndrs_core::auth::{self, ChatGptCodexAuth},
18};
19use thndrs_agent::ProviderUsageComponents;
20
21/// ChatGPT Codex backend base URL.
22pub const BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
23
24/// ChatGPT Codex Responses endpoint.
25pub const RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses";
26
27const MODELS_PATH: &str = "models";
28
29/// User/config-facing model id prefix.
30pub const MODEL_PREFIX: &str = "chatgpt-codex/";
31
32/// Display-only prefix for ChatGPT Codex models in the terminal UI.
33pub const DISPLAY_MODEL_PREFIX: &str = "codex/";
34
35/// Recommended request budget for known ChatGPT Codex models.
36pub const DEFAULT_RECOMMENDED_MAX_TOKENS: u32 = 32_768;
37
38/// Parsed ChatGPT Codex Responses stream event.
39#[derive(Clone, Debug, PartialEq)]
40pub enum ResponsesSseEvent {
41    TextDelta(String),
42    ReasoningDelta(String),
43    ToolCallStart {
44        id: String,
45        call_id: String,
46        name: String,
47    },
48    ToolCallArgumentsDelta {
49        id: String,
50        arguments: String,
51    },
52    ToolCallDone {
53        id: String,
54        call_id: Option<String>,
55        name: String,
56        arguments: String,
57    },
58    ResponseStatus(String),
59    Error(String),
60    Usage {
61        input_tokens: u64,
62        output_tokens: u64,
63    },
64    UsageComponents(ProviderUsageComponents),
65    /// Complete output item retained only for in-memory continuation.
66    OutputItem(serde_json::Value),
67    Done,
68    Malformed(String),
69    Other,
70}
71
72/// Concrete ChatGPT Codex API client.
73pub struct ChatGptCodexClient {
74    base_url: String,
75    auth: ChatGptCodexAuth,
76    agent: ureq::Agent,
77}
78
79impl ChatGptCodexClient {
80    /// Create a client from `CHATGPT_CODEX_ACCESS_TOKEN` or `~/.thndrs/auth.json`.
81    pub fn from_env_or_dotenv(_workspace_root: &Path) -> Result<Self> {
82        let auth = auth::resolve_chatgpt_codex_auth().map_err(|error| provider_error_from_auth_error(&error))?;
83        tracing::debug!("loaded ChatGPT Codex auth");
84        Ok(Self::new(BASE_URL, auth))
85    }
86
87    /// Create a client with explicit auth material.
88    pub fn new(base_url: &str, auth: ChatGptCodexAuth) -> Self {
89        Self { base_url: base_url.trim_end_matches('/').to_string(), auth, agent: provider_http_agent() }
90    }
91
92    fn build_auth_headers(&self) -> Vec<(String, String)> {
93        vec![
94            (
95                "Authorization".to_string(),
96                format!("Bearer {}", self.auth.access_token),
97            ),
98            ("chatgpt-account-id".to_string(), self.auth.account_id.clone()),
99            ("originator".to_string(), "thndrs".to_string()),
100        ]
101    }
102
103    /// Build the headers for a streaming Responses request.
104    pub fn build_responses_headers(&self) -> Vec<(String, String)> {
105        let mut headers = self.build_auth_headers();
106        headers.extend([
107            ("OpenAI-Beta".to_string(), "responses=experimental".to_string()),
108            ("accept".to_string(), "text/event-stream".to_string()),
109            ("content-type".to_string(), "application/json".to_string()),
110        ]);
111        headers
112    }
113
114    /// Build a Responses-like streaming request body.
115    pub fn build_responses_request_body(
116        model: &str, messages: &[ProviderMessage], tools: Option<&serde_json::Value>,
117    ) -> Result<serde_json::Value> {
118        Self::build_responses_request_body_with_reasoning(
119            model,
120            messages,
121            tools,
122            ReasoningEffort::default(),
123            ReasoningSummary::default(),
124            &ProviderContinuation::default(),
125        )
126    }
127
128    /// Build a Responses streaming request body for the ChatGPT Codex route.
129    pub fn build_responses_request_body_with_reasoning(
130        model: &str, messages: &[ProviderMessage], tools: Option<&serde_json::Value>, effort: ReasoningEffort,
131        summary: ReasoningSummary, continuation: &ProviderContinuation,
132    ) -> Result<serde_json::Value> {
133        let raw_model = raw_model_id(model)?;
134        let effort = if is_gpt_5_6_raw_model(raw_model) { effort } else { ReasoningEffort::Auto };
135        Ok(build_openai_responses_request_body(
136            raw_model,
137            messages,
138            tools,
139            effort,
140            summary,
141            continuation,
142        ))
143    }
144
145    /// Build a standard Responses request for an already-normalized model id.
146    pub fn build_openai_responses_request_body(
147        raw_model: &str, messages: &[ProviderMessage], tools: Option<&serde_json::Value>, effort: ReasoningEffort,
148        summary: ReasoningSummary, continuation: &ProviderContinuation,
149    ) -> serde_json::Value {
150        build_openai_responses_request_body(raw_model, messages, tools, effort, summary, continuation)
151    }
152
153    /// Verify the configured credential against the lightweight model catalog.
154    pub fn probe_authentication(&self) -> Result<()> {
155        let url = format!("{}/{MODELS_PATH}", self.base_url);
156        let mut request = self.agent.get(&url);
157        for (key, value) in self.build_auth_headers() {
158            request = request.header(&key, &value);
159        }
160        let mut response = request
161            .config()
162            .http_status_as_error(false)
163            .build()
164            .call()
165            .map_err(|error| ProviderError::Http(error.to_string()))?;
166
167        let status = response.status().as_u16();
168        if !(200..=299).contains(&status) {
169            let body = response
170                .body_mut()
171                .read_to_string()
172                .unwrap_or_else(|error| format!("failed to read error body: {error}"));
173            return Err(ProviderError::Status { code: status, body: providers::summarize_error_body(&body) });
174        }
175
176        let body = response
177            .body_mut()
178            .read_to_string()
179            .map_err(|error| ProviderError::Http(error.to_string()))?;
180        serde_json::from_str::<serde_json::Value>(&body)
181            .map(|_| ())
182            .map_err(|error| ProviderError::Json(format!("ChatGPT Codex model catalog JSON parse failed: {error}")))
183    }
184
185    /// Send a streaming request to `POST /responses`.
186    pub fn send_streaming_request(
187        &self, model: &str, messages: &[ProviderMessage], tools: Option<&serde_json::Value>, effort: ReasoningEffort,
188        summary: ReasoningSummary, continuation: &ProviderContinuation,
189    ) -> Result<ureq::http::Response<ureq::Body>> {
190        let body =
191            Self::build_responses_request_body_with_reasoning(model, messages, tools, effort, summary, continuation)?;
192        let url = format!("{}/responses", self.base_url);
193        let mut request = self.agent.post(&url);
194        for (key, value) in self.build_responses_headers() {
195            request = request.header(&key, &value);
196        }
197        let mut response = request
198            .config()
199            .http_status_as_error(false)
200            .build()
201            .send_json(&body)
202            .map_err(|e| ProviderError::Http(e.to_string()))?;
203
204        let status = response.status().as_u16();
205        if !(200..=299).contains(&status) {
206            let body = response
207                .body_mut()
208                .read_to_string()
209                .unwrap_or_else(|e| format!("failed to read error body: {e}"));
210            return Err(ProviderError::Status { code: status, body: providers::summarize_error_body(&body) });
211        }
212        Ok(response)
213    }
214}
215
216impl StreamingProvider for ChatGptCodexClient {
217    type Metadata = ();
218
219    fn name(&self) -> &'static str {
220        "ChatGPT Codex"
221    }
222
223    fn load_status(&self) -> String {
224        String::from("provider: loading ChatGPT Codex auth")
225    }
226
227    fn request_status(&self, model: &str) -> String {
228        format!("provider: POST /backend-api/codex/responses model={model} (ChatGPT-backed, experimental)")
229    }
230
231    fn from_env_or_dotenv(root: &Path) -> Result<Self> {
232        ChatGptCodexClient::from_env_or_dotenv(root)
233    }
234
235    fn load_metadata(&self) -> Result<Self::Metadata> {
236        Ok(())
237    }
238
239    fn metadata_loaded_event(&self, _metadata: &Self::Metadata) -> Option<AgentEvent> {
240        None
241    }
242
243    fn metadata_status(&self, model: &str, _metadata: &Self::Metadata) -> Option<String> {
244        raw_model_id(model)
245            .ok()
246            .map(|raw| format!("model: chatgpt-codex/{raw}  ChatGPT/Codex"))
247    }
248
249    fn token_budget(&self, _model: &str, _metadata: Option<&Self::Metadata>) -> u32 {
250        DEFAULT_RECOMMENDED_MAX_TOKENS
251    }
252
253    fn serialized_request_body(
254        &self, model: &str, messages: &[ProviderMessage], request: &StreamingRequest<'_>,
255    ) -> Result<Vec<u8>> {
256        let body = Self::build_responses_request_body_with_reasoning(
257            model,
258            messages,
259            Some(request.tools),
260            request.reasoning_effort,
261            request.reasoning_summary,
262            request.continuation,
263        )?;
264        providers::serialize_request_body(&body)
265    }
266
267    fn send_streaming_request(
268        &self, model: &str, messages: &[ProviderMessage], request: &StreamingRequest<'_>,
269    ) -> Result<ureq::http::Response<ureq::Body>> {
270        ChatGptCodexClient::send_streaming_request(
271            self,
272            model,
273            messages,
274            Some(request.tools),
275            request.reasoning_effort,
276            request.reasoning_summary,
277            request.continuation,
278        )
279    }
280
281    fn stream_format(&self, model: &str) -> Result<StreamFormat> {
282        raw_model_id(model)?;
283        Ok(StreamFormat::ChatGptCodexResponses)
284    }
285
286    fn request_error_message(error: &ProviderError) -> String {
287        error_message(error)
288    }
289
290    fn is_retryable_request_error(error: &ProviderError) -> bool {
291        is_retryable_error(error)
292    }
293}
294
295/// Verify the configured ChatGPT Codex credential using the model catalog.
296pub fn probe_env_or_dotenv_authentication(workspace_root: &Path) -> Result<()> {
297    ChatGptCodexClient::from_env_or_dotenv(workspace_root)?.probe_authentication()
298}
299
300fn provider_error_from_auth_error(error: &auth::AuthError) -> ProviderError {
301    if error.is_verification_unavailable() {
302        ProviderError::AuthUnavailable(error.to_string())
303    } else {
304        ProviderError::Auth(error.to_string())
305    }
306}
307
308fn build_openai_responses_request_body(
309    raw_model: &str, messages: &[ProviderMessage], tools: Option<&serde_json::Value>, effort: ReasoningEffort,
310    summary: ReasoningSummary, continuation: &ProviderContinuation,
311) -> serde_json::Value {
312    let (instructions, input) = responses_input(messages);
313    let input = continuation_input(continuation, messages).unwrap_or(input);
314    let mut body = serde_json::json!({
315        "model": raw_model,
316        "store": false,
317        "stream": true,
318        "instructions": instructions,
319        "input": input,
320        "tool_choice": "auto",
321        "parallel_tool_calls": true,
322        "text": { "verbosity": "low" },
323        "include": ["reasoning.encrypted_content"],
324    });
325    if effort != ReasoningEffort::Auto {
326        let mut reasoning = serde_json::json!({ "effort": effort.label() });
327        if summary == ReasoningSummary::Auto {
328            reasoning["summary"] = serde_json::Value::String("auto".to_string());
329        }
330        body["reasoning"] = reasoning;
331    }
332    if let Some(tool_schemas) = tools {
333        let converted = responses_tools(tool_schemas);
334        if !converted.as_array().is_some_and(|arr| arr.is_empty()) {
335            body["tools"] = converted;
336        }
337    }
338    body
339}
340
341/// Whether `model` is a ChatGPT Codex model id.
342pub fn is_model_id(model: &str) -> bool {
343    model.strip_prefix(MODEL_PREFIX).is_some_and(|raw| !raw.is_empty())
344}
345
346/// Strip `chatgpt-codex/` from a model id.
347pub fn raw_model_id(model: &str) -> Result<&str> {
348    model
349        .strip_prefix(MODEL_PREFIX)
350        .filter(|raw| !raw.is_empty())
351        .ok_or_else(|| ProviderError::invalid_model_id("ChatGPT Codex", MODEL_PREFIX, model))
352}
353
354/// Render a concise display label without changing the configured model id.
355pub fn display_model_id(model: &str) -> Cow<'_, str> {
356    match model.strip_prefix(MODEL_PREFIX) {
357        Some(raw) if !raw.is_empty() => Cow::Owned(format!("{DISPLAY_MODEL_PREFIX}{raw}")),
358        _ => Cow::Borrowed(model),
359    }
360}
361
362/// Whether this ChatGPT Codex model supports GPT-5.6 reasoning controls.
363pub fn supports_reasoning_effort(model: &str) -> bool {
364    raw_model_id(model).is_ok_and(is_gpt_5_6_raw_model)
365}
366
367/// Return the currently verified ChatGPT Codex reasoning controls.
368pub fn reasoning_options(model: &str) -> Vec<ReasoningEffort> {
369    if supports_reasoning_effort(model) {
370        vec![
371            ReasoningEffort::Auto,
372            ReasoningEffort::None,
373            ReasoningEffort::Low,
374            ReasoningEffort::Medium,
375            ReasoningEffort::High,
376            ReasoningEffort::Xhigh,
377            ReasoningEffort::Max,
378        ]
379    } else {
380        vec![ReasoningEffort::Auto]
381    }
382}
383
384/// Current ChatGPT Codex models from the provider expansion plan.
385pub fn known_models() -> Vec<KnownModel> {
386    vec![
387        KnownModel { id: "chatgpt-codex/gpt-5.6-sol", description: "ChatGPT-backed Codex GPT-5.6 Sol, experimental" },
388        KnownModel {
389            id: "chatgpt-codex/gpt-5.6-terra",
390            description: "ChatGPT-backed Codex GPT-5.6 Terra, experimental",
391        },
392        KnownModel { id: "chatgpt-codex/gpt-5.6-luna", description: "ChatGPT-backed Codex GPT-5.6 Luna, experimental" },
393        KnownModel { id: "chatgpt-codex/gpt-5.5", description: "ChatGPT-backed Codex, experimental" },
394        KnownModel { id: "chatgpt-codex/gpt-5.4", description: "ChatGPT-backed Codex, experimental" },
395        KnownModel { id: "chatgpt-codex/gpt-5.4-mini", description: "ChatGPT-backed Codex mini, experimental" },
396        KnownModel { id: "chatgpt-codex/gpt-5.3-codex-spark", description: "ChatGPT-backed Codex Spark, experimental" },
397    ]
398}
399
400/// Extend an in-memory Responses history after one streamed response.
401pub fn record_response_items(
402    continuation: &mut ProviderContinuation, messages: &[ProviderMessage], response_items: Vec<serde_json::Value>,
403) {
404    if response_items.is_empty() {
405        return;
406    }
407
408    let mut items = match continuation.responses_items() {
409        Some((items, consumed_messages)) => {
410            let mut items = items.to_vec();
411            items.extend(responses_input_items(&messages[consumed_messages..]));
412            items
413        }
414        None => responses_input_items(messages),
415    };
416    items.extend(response_items);
417    continuation.set_responses_items(items, messages.len());
418}
419
420/// Append a tool result to an in-memory Responses history.
421pub fn record_tool_output(
422    continuation: &mut ProviderContinuation, call_id: &str, output: &str, consumed_messages: usize,
423) {
424    let Some((items, _)) = continuation.responses_items() else {
425        return;
426    };
427    let mut items = items.to_vec();
428    items.push(serde_json::json!({
429        "type": "function_call_output",
430        "call_id": call_id,
431        "output": output,
432    }));
433    continuation.set_responses_items(items, consumed_messages);
434}
435
436/// Convert a ChatGPT Codex error into a human-readable failure string.
437pub fn error_message(err: &ProviderError) -> String {
438    match err {
439        ProviderError::AuthUnavailable(message) => format!(
440            "ChatGPT Codex credential verification unavailable: {message}; retry `thndrs setup --provider chatgpt-codex`"
441        ),
442        _ => err.failure_message("ChatGPT Codex usage limit exceeded"),
443    }
444}
445
446pub fn is_retryable_error(err: &ProviderError) -> bool {
447    match err {
448        ProviderError::Status { code, body } if terminal_status_error(*code, body) => false,
449        _ => err.is_retryable(),
450    }
451}
452
453/// Parse raw ChatGPT Codex Responses SSE data lines.
454pub fn parse_responses_sse_chunk(chunk: &str) -> Vec<String> {
455    chunk
456        .lines()
457        .filter_map(|line| line.strip_prefix("data:").map(|data| data.trim_start().to_string()))
458        .collect()
459}
460
461/// Parse one ChatGPT Codex Responses SSE `data:` payload.
462pub fn parse_responses_sse_event(data: &str) -> Vec<ResponsesSseEvent> {
463    if data.trim() == "[DONE]" {
464        return vec![ResponsesSseEvent::Done];
465    }
466
467    let Ok(value) = serde_json::from_str::<serde_json::Value>(data) else {
468        return vec![ResponsesSseEvent::Malformed(data.chars().take(120).collect())];
469    };
470
471    let mut events = Vec::new();
472    let event_type = value.get("type").and_then(|v| v.as_str()).unwrap_or_default();
473    if let Some(error) = extract_responses_error(&value, event_type) {
474        events.push(ResponsesSseEvent::Error(error));
475    }
476    if let Some(status) = extract_responses_status(&value, event_type) {
477        events.push(ResponsesSseEvent::ResponseStatus(status));
478    }
479    if let Some(usage) = extract_responses_usage(&value) {
480        if usage.cache_read_input_tokens.is_some()
481            || usage.cache_creation_input_tokens.is_some()
482            || usage.reasoning_tokens.is_some()
483        {
484            events.push(ResponsesSseEvent::UsageComponents(usage));
485        } else if let (Some(input_tokens), Some(output_tokens)) = (usage.input_tokens, usage.output_tokens) {
486            events.push(ResponsesSseEvent::Usage { input_tokens, output_tokens });
487        } else {
488            events.push(ResponsesSseEvent::UsageComponents(usage));
489        }
490    }
491    if let Some(text) = extract_responses_text_delta(&value, event_type) {
492        events.push(ResponsesSseEvent::TextDelta(text));
493    }
494    if let Some(reasoning) = extract_responses_reasoning_delta(&value, event_type) {
495        events.push(ResponsesSseEvent::ReasoningDelta(reasoning));
496    }
497    if event_type == "response.output_item.done"
498        && let Some(item) = value.get("item")
499    {
500        events.push(ResponsesSseEvent::OutputItem(item.clone()));
501    }
502    if let Some(tool_event) = extract_responses_tool_event(&value, event_type) {
503        events.push(tool_event);
504    }
505
506    if events.is_empty() { vec![ResponsesSseEvent::Other] } else { events }
507}
508
509fn is_gpt_5_6_raw_model(model: &str) -> bool {
510    matches!(model, "gpt-5.6-sol" | "gpt-5.6-terra" | "gpt-5.6-luna")
511}
512
513fn continuation_input(continuation: &ProviderContinuation, messages: &[ProviderMessage]) -> Option<serde_json::Value> {
514    let (items, consumed_messages) = continuation.responses_items()?;
515    let mut input = items.to_vec();
516    input.extend(responses_input_items(&messages[consumed_messages..]));
517    Some(serde_json::Value::Array(input))
518}
519
520fn extract_responses_text_delta(value: &serde_json::Value, event_type: &str) -> Option<String> {
521    if event_type != "response.output_text.delta" {
522        return None;
523    }
524    string_field(value, &["delta"])
525}
526
527fn extract_responses_reasoning_delta(value: &serde_json::Value, event_type: &str) -> Option<String> {
528    if !event_type.contains("reasoning_summary") {
529        return None;
530    }
531    string_field(value, &["delta", "text", "summary_text"])
532}
533
534fn extract_responses_tool_event(value: &serde_json::Value, event_type: &str) -> Option<ResponsesSseEvent> {
535    if event_type == "response.output_item.added" {
536        let item = value.get("item")?;
537        if item.get("type").and_then(|v| v.as_str()) != Some("function_call") {
538            return None;
539        }
540        let id = function_item_id(item)?;
541        let call_id = function_call_id(item).unwrap_or_else(|| id.clone());
542        let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
543        return Some(ResponsesSseEvent::ToolCallStart { id, call_id, name });
544    }
545
546    if event_type.contains("function_call_arguments.delta") {
547        let id = event_item_id(value)?;
548        let arguments = value.get("delta").and_then(|v| v.as_str()).unwrap_or("").to_string();
549        if arguments.is_empty() {
550            return None;
551        }
552        return Some(ResponsesSseEvent::ToolCallArgumentsDelta { id, arguments });
553    }
554
555    if event_type.contains("function_call_arguments.done") || event_type == "response.output_item.done" {
556        let item = value.get("item").unwrap_or(value);
557        if item
558            .get("type")
559            .and_then(|v| v.as_str())
560            .is_some_and(|kind| kind != "function_call")
561        {
562            return None;
563        }
564        let id = function_item_id(item).or_else(|| event_item_id(value))?;
565        let call_id = function_call_id(item);
566        let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
567        let arguments = item
568            .get("arguments")
569            .or_else(|| value.get("arguments"))
570            .and_then(|v| v.as_str())
571            .unwrap_or("")
572            .to_string();
573        return Some(ResponsesSseEvent::ToolCallDone { id, call_id, name, arguments });
574    }
575
576    None
577}
578
579fn extract_responses_error(value: &serde_json::Value, event_type: &str) -> Option<String> {
580    value
581        .get("error")
582        .and_then(|error| match error {
583            serde_json::Value::String(message) => Some(message.clone()),
584            serde_json::Value::Object(_) => error
585                .get("message")
586                .or_else(|| error.get("code"))
587                .and_then(|message| message.as_str())
588                .map(str::to_string),
589            _ => None,
590        })
591        .or_else(|| {
592            if event_type == "error" || event_type.ends_with(".error") {
593                string_field(value, &["message", "code"])
594            } else {
595                None
596            }
597        })
598}
599
600fn extract_responses_status(value: &serde_json::Value, event_type: &str) -> Option<String> {
601    let status = value
602        .get("status")
603        .or_else(|| value.get("response").and_then(|response| response.get("status")))
604        .and_then(|status| status.as_str())
605        .or_else(|| event_type.strip_prefix("response."))
606        .filter(|status| {
607            matches!(
608                *status,
609                "completed" | "failed" | "incomplete" | "cancelled" | "canceled" | "queued" | "in_progress"
610            )
611        })?;
612    Some(status.to_string())
613}
614
615fn extract_responses_usage(value: &serde_json::Value) -> Option<ProviderUsageComponents> {
616    let usage = value
617        .get("usage")
618        .or_else(|| value.get("response").and_then(|response| response.get("usage")))
619        .filter(|usage| !usage.is_null())?;
620    let input_tokens = usage
621        .get("input_tokens")
622        .or_else(|| usage.get("prompt_tokens"))
623        .or_else(|| usage.get("inputTokens"))
624        .and_then(|v| v.as_u64());
625    let output_tokens = usage
626        .get("output_tokens")
627        .or_else(|| usage.get("completion_tokens"))
628        .or_else(|| usage.get("outputTokens"))
629        .and_then(|v| v.as_u64());
630    if input_tokens.is_none() && output_tokens.is_none() {
631        return None;
632    }
633    let details = usage
634        .get("input_tokens_details")
635        .or_else(|| usage.get("prompt_tokens_details"));
636    Some(ProviderUsageComponents {
637        input_tokens,
638        output_tokens,
639        cache_read_input_tokens: details
640            .and_then(|details| details.get("cached_tokens"))
641            .and_then(|value| value.as_u64()),
642        cache_creation_input_tokens: None,
643        reasoning_tokens: usage
644            .get("output_tokens_details")
645            .or_else(|| usage.get("completion_tokens_details"))
646            .and_then(|details| details.get("reasoning_tokens"))
647            .and_then(|value| value.as_u64()),
648    })
649}
650
651fn event_item_id(value: &serde_json::Value) -> Option<String> {
652    string_field(value, &["item_id", "output_item_id", "call_id"])
653}
654
655fn function_call_id(value: &serde_json::Value) -> Option<String> {
656    string_field(value, &["call_id"])
657}
658
659fn function_item_id(value: &serde_json::Value) -> Option<String> {
660    string_field(value, &["id", "item_id", "call_id"])
661}
662
663fn string_field(value: &serde_json::Value, fields: &[&str]) -> Option<String> {
664    fields
665        .iter()
666        .find_map(|field| value.get(*field).and_then(|v| v.as_str()))
667        .filter(|s| !s.is_empty())
668        .map(str::to_string)
669}
670
671fn responses_input(messages: &[ProviderMessage]) -> (String, serde_json::Value) {
672    let mut instructions = String::new();
673    let mut input = Vec::new();
674    for message in messages {
675        if message.role == "system" {
676            if !instructions.is_empty() {
677                instructions.push_str("\n\n");
678            }
679            instructions.push_str(&message.as_text());
680            continue;
681        }
682        input.extend(responses_items_for_message(message));
683    }
684    (instructions, serde_json::Value::Array(input))
685}
686
687fn responses_input_items(messages: &[ProviderMessage]) -> Vec<serde_json::Value> {
688    match responses_input(messages).1 {
689        serde_json::Value::Array(items) => items,
690        _ => Vec::new(),
691    }
692}
693
694fn responses_items_for_message(message: &ProviderMessage) -> Vec<serde_json::Value> {
695    match &message.content {
696        ProviderMessageContent::Text(text) => {
697            vec![serde_json::json!({"role": message.role, "content": text})]
698        }
699        ProviderMessageContent::Blocks(blocks) => blocks
700            .iter()
701            .filter_map(|block| match block {
702                ProviderContentBlock::Text { text } => Some(serde_json::json!({"role": message.role, "content": text})),
703                ProviderContentBlock::ToolResult { tool_use_id, content, .. } => Some(serde_json::json!({
704                    "type": "function_call_output",
705                    "call_id": tool_use_id,
706                    "output": content,
707                })),
708                ProviderContentBlock::ToolUse { id, name, input } => Some(serde_json::json!({
709                    "type": "function_call",
710                    "call_id": id,
711                    "name": name,
712                    "arguments": serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string()),
713                })),
714                ProviderContentBlock::Image { .. } => None,
715            })
716            .collect(),
717    }
718}
719
720fn responses_tools(tools: &serde_json::Value) -> serde_json::Value {
721    serde_json::Value::Array(
722        tools
723            .as_array()
724            .into_iter()
725            .flatten()
726            .map(|tool| {
727                let function = tool.get("function").unwrap_or(tool);
728                serde_json::json!({
729                    "type": "function",
730                    "name": function.get("name").cloned().unwrap_or(serde_json::Value::Null),
731                    "description": function.get("description").cloned().unwrap_or(serde_json::Value::String(String::new())),
732                    "parameters": function
733                        .get("parameters")
734                        .or_else(|| function.get("input_schema"))
735                        .cloned()
736                        .unwrap_or_else(|| serde_json::json!({ "type": "object" })),
737                    "strict": false,
738                })
739            })
740            .collect(),
741    )
742}
743
744fn terminal_status_error(code: u16, body: &str) -> bool {
745    if matches!(code, 400..=404) {
746        return true;
747    }
748    let lower = body.to_ascii_lowercase();
749    lower.contains("subscription")
750        || lower.contains("balance")
751        || lower.contains("quota")
752        || lower.contains("monthly")
753        || lower.contains("usage limit")
754        || lower.contains("insufficient")
755}
756
757#[cfg(test)]
758mod tests {
759    use std::collections::HashMap;
760    use std::env;
761    use std::io::{BufRead, BufReader, Read, Write};
762    use std::net::TcpListener;
763    use std::thread;
764
765    use super::*;
766
767    struct ModelsFixture {
768        base_url: String,
769        task: thread::JoinHandle<()>,
770    }
771
772    impl ModelsFixture {
773        fn respond(status: &str, body: &str) -> Self {
774            let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock models server");
775            let addr = listener.local_addr().expect("mock models server address");
776            let status = status.to_string();
777            let body = body.to_string();
778            let task = thread::spawn(move || {
779                let (mut stream, _) = listener.accept().expect("accept mock models request");
780                let mut request = String::new();
781                {
782                    let mut reader = BufReader::new(&mut stream);
783                    loop {
784                        let mut line = String::new();
785                        let count = reader.read_line(&mut line).expect("read mock models request");
786                        if count == 0 || line == "\r\n" || line == "\n" {
787                            break;
788                        }
789                        request.push_str(&line);
790                    }
791                }
792                let request = request.to_ascii_lowercase();
793                assert!(request.starts_with("get /models "));
794                assert!(request.contains("authorization: bearer test-token"));
795                assert!(request.contains("chatgpt-account-id: acct_123"));
796
797                let response = format!(
798                    "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
799                    body.len()
800                );
801                stream
802                    .write_all(response.as_bytes())
803                    .expect("write mock models response");
804            });
805            Self { base_url: format!("http://{addr}"), task }
806        }
807
808        fn drop_connection() -> Self {
809            let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock models server");
810            let addr = listener.local_addr().expect("mock models server address");
811            let task = thread::spawn(move || {
812                let (mut stream, _) = listener.accept().expect("accept mock models request");
813                let mut request = [0_u8; 4096];
814                let _ = stream.read(&mut request);
815            });
816            Self { base_url: format!("http://{addr}"), task }
817        }
818
819        fn finish(self) {
820            self.task.join().expect("mock models server");
821        }
822    }
823
824    fn test_auth() -> ChatGptCodexAuth {
825        ChatGptCodexAuth { access_token: "test-token".to_string(), account_id: "acct_123".to_string() }
826    }
827
828    #[test]
829    fn raw_model_id_requires_prefix() {
830        assert_eq!(raw_model_id("chatgpt-codex/gpt-5.5").unwrap(), "gpt-5.5");
831        assert!(matches!(
832            raw_model_id("gpt-5.5"),
833            Err(ProviderError::InvalidModelId { .. })
834        ));
835    }
836
837    #[test]
838    fn known_models_include_chatgpt_codex_picker_entries() {
839        let ids: Vec<&str> = known_models().iter().map(|model| model.id).collect();
840        assert!(ids.contains(&"chatgpt-codex/gpt-5.6-sol"));
841        assert!(ids.contains(&"chatgpt-codex/gpt-5.6-terra"));
842        assert!(ids.contains(&"chatgpt-codex/gpt-5.6-luna"));
843        assert!(ids.contains(&"chatgpt-codex/gpt-5.5"));
844        assert!(ids.contains(&"chatgpt-codex/gpt-5.4"));
845        assert!(ids.contains(&"chatgpt-codex/gpt-5.4-mini"));
846        assert!(ids.contains(&"chatgpt-codex/gpt-5.3-codex-spark"));
847    }
848
849    #[test]
850    fn missing_credentials_fail_before_network_access() {
851        let _guard = crate::test_env::lock();
852        unsafe {
853            env::remove_var(auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV);
854        }
855        let dir = tempfile::tempdir().expect("tempdir");
856        let home = dir.path().join("home");
857        std::fs::create_dir_all(&home).expect("home");
858        let old_home = env::var_os("HOME");
859        unsafe {
860            env::set_var("HOME", &home);
861        }
862
863        let result = ChatGptCodexClient::from_env_or_dotenv(dir.path());
864
865        unsafe {
866            if let Some(home) = old_home {
867                env::set_var("HOME", home);
868            } else {
869                env::remove_var("HOME");
870            }
871        }
872        assert!(
873            matches!(result, Err(ProviderError::Auth(message)) if message.contains("run `thndrs login chatgpt-codex`"))
874        );
875    }
876
877    #[test]
878    fn unavailable_chatgpt_auth_is_not_treated_as_a_rejected_credential() {
879        let error = provider_error_from_auth_error(&auth::AuthError::ChatGptCodexUnavailable(
880            "token request failed with status 503".to_string(),
881        ));
882
883        assert!(matches!(error, ProviderError::AuthUnavailable(_)));
884        assert!(!error.is_credential_rejected());
885        assert!(is_retryable_error(&error));
886        assert!(error_message(&error).contains("retry `thndrs setup --provider chatgpt-codex`"));
887    }
888
889    #[test]
890    fn build_headers_include_expected_names_without_snapshotting_token() {
891        let client = ChatGptCodexClient::new(BASE_URL, test_auth());
892        let headers: HashMap<String, String> = client.build_responses_headers().into_iter().collect();
893        assert!(headers.get("Authorization").unwrap().starts_with("Bearer "));
894        assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123");
895        assert_eq!(headers.get("originator").unwrap(), "thndrs");
896        assert_eq!(headers.get("OpenAI-Beta").unwrap(), "responses=experimental");
897        assert_eq!(headers.get("accept").unwrap(), "text/event-stream");
898        assert_eq!(headers.get("content-type").unwrap(), "application/json");
899    }
900
901    #[test]
902    fn client_uses_shared_provider_timeouts() {
903        let client = ChatGptCodexClient::new(BASE_URL, test_auth());
904        let timeouts = client.agent.config().timeouts();
905
906        assert_eq!(
907            timeouts.global, None,
908            "SSE streams must not have a total lifetime deadline"
909        );
910        assert_eq!(timeouts.per_call, None, "SSE streams must not have a per-call deadline");
911        assert_eq!(timeouts.resolve, Some(providers::PROVIDER_CONNECT_TIMEOUT));
912        assert_eq!(timeouts.connect, Some(providers::PROVIDER_CONNECT_TIMEOUT));
913        assert_eq!(timeouts.send_request, Some(providers::PROVIDER_REQUEST_TIMEOUT));
914        assert_eq!(timeouts.send_body, Some(providers::PROVIDER_REQUEST_TIMEOUT));
915        assert_eq!(timeouts.recv_response, Some(providers::PROVIDER_RESPONSE_TIMEOUT));
916        assert_eq!(timeouts.recv_body, Some(providers::PROVIDER_STREAM_IDLE_TIMEOUT));
917    }
918
919    #[test]
920    fn authentication_probe_uses_the_authenticated_models_catalog() {
921        let fixture = ModelsFixture::respond("200 OK", r#"{"object":"list","data":[]}"#);
922        let result = ChatGptCodexClient::new(&fixture.base_url, test_auth()).probe_authentication();
923
924        fixture.finish();
925        assert!(result.is_ok());
926    }
927
928    #[test]
929    fn authentication_probe_preserves_rejected_credential_statuses() {
930        for (status, code) in [("401 Unauthorized", 401), ("403 Forbidden", 403)] {
931            let fixture = ModelsFixture::respond(status, r#"{"error":"rejected"}"#);
932            let error = ChatGptCodexClient::new(&fixture.base_url, test_auth())
933                .probe_authentication()
934                .expect_err("credential should be rejected");
935
936            fixture.finish();
937            assert!(matches!(&error, ProviderError::Status { code: actual, .. } if *actual == code));
938            assert!(error.is_credential_rejected());
939            assert!(!error.is_retryable());
940        }
941    }
942
943    #[test]
944    fn authentication_probe_preserves_transient_statuses() {
945        for (status, code) in [("429 Too Many Requests", 429), ("503 Service Unavailable", 503)] {
946            let fixture = ModelsFixture::respond(status, r#"{"error":"unavailable"}"#);
947            let error = ChatGptCodexClient::new(&fixture.base_url, test_auth())
948                .probe_authentication()
949                .expect_err("provider should be unavailable");
950
951            fixture.finish();
952            assert!(matches!(&error, ProviderError::Status { code: actual, .. } if *actual == code));
953            assert!(!error.is_credential_rejected());
954            assert!(error.is_retryable());
955        }
956    }
957
958    #[test]
959    fn authentication_probe_treats_connection_failures_as_unavailable() {
960        let fixture = ModelsFixture::drop_connection();
961        let error = ChatGptCodexClient::new(&fixture.base_url, test_auth())
962            .probe_authentication()
963            .expect_err("connection should fail");
964
965        fixture.finish();
966        assert!(matches!(error, ProviderError::Http(_)));
967        assert!(!error.is_credential_rejected());
968    }
969
970    #[test]
971    fn authentication_probe_treats_malformed_catalogs_as_unavailable() {
972        let fixture = ModelsFixture::respond("200 OK", "not-json");
973        let error = ChatGptCodexClient::new(&fixture.base_url, test_auth())
974            .probe_authentication()
975            .expect_err("malformed catalog");
976
977        fixture.finish();
978        assert!(matches!(error, ProviderError::Json(_)));
979        assert!(!error.is_credential_rejected());
980    }
981
982    #[test]
983    fn build_responses_body_uses_raw_model_and_response_options() {
984        let messages = vec![
985            ProviderMessage {
986                role: "system".to_string(),
987                content: ProviderMessageContent::Text("be brief".to_string()),
988            },
989            ProviderMessage::user("hello"),
990        ];
991        let defs = crate::tools::tool_definitions();
992        let catalog = crate::tools::tool_catalog_schemas(&defs);
993        let body = ChatGptCodexClient::build_responses_request_body("chatgpt-codex/gpt-5.5", &messages, Some(&catalog))
994            .expect("body");
995
996        assert_eq!(body["model"], "gpt-5.5");
997        assert_eq!(body["instructions"], "be brief");
998        assert_eq!(body["input"][0]["role"], "user");
999        assert_eq!(body["store"], false);
1000        assert_eq!(body["stream"], true);
1001        assert_eq!(body["tool_choice"], "auto");
1002        assert_eq!(body["parallel_tool_calls"], true);
1003        assert_eq!(body["text"]["verbosity"], "low");
1004        assert_eq!(body["tools"][0]["type"], "function");
1005        assert_eq!(body["tools"][0]["name"], defs[0].name.as_ref());
1006    }
1007
1008    #[test]
1009    fn build_responses_body_preserves_run_shell_argv_schema_from_anthropic_catalog() {
1010        let messages = vec![ProviderMessage::user("run a command")];
1011        let defs = crate::tools::tool_definitions();
1012        let catalog = crate::tools::tool_catalog_schemas(&defs);
1013        let body = ChatGptCodexClient::build_responses_request_body("chatgpt-codex/gpt-5.5", &messages, Some(&catalog))
1014            .expect("body");
1015        let run_shell = body["tools"]
1016            .as_array()
1017            .expect("tools array")
1018            .iter()
1019            .find(|tool| tool["name"] == "run_shell")
1020            .expect("run_shell tool");
1021        let definition = defs
1022            .iter()
1023            .find(|definition| definition.name.as_ref() == "run_shell")
1024            .expect("run_shell definition");
1025
1026        assert_eq!(run_shell["parameters"], definition.input_schema);
1027        assert_eq!(run_shell["parameters"]["required"], serde_json::json!(["argv"]));
1028        assert_eq!(run_shell["parameters"]["properties"]["argv"]["type"], "array");
1029    }
1030
1031    #[test]
1032    fn gpt_5_6_request_includes_reasoning_effort_and_optional_summary() {
1033        let messages = vec![ProviderMessage::user("inspect the project")];
1034        let body = ChatGptCodexClient::build_responses_request_body_with_reasoning(
1035            "chatgpt-codex/gpt-5.6-sol",
1036            &messages,
1037            None,
1038            ReasoningEffort::Xhigh,
1039            ReasoningSummary::Auto,
1040            &ProviderContinuation::default(),
1041        )
1042        .expect("body");
1043
1044        assert_eq!(body["model"], "gpt-5.6-sol");
1045        assert_eq!(body["reasoning"]["effort"], "xhigh");
1046        assert_eq!(body["reasoning"]["summary"], "auto");
1047        assert_eq!(body["include"], serde_json::json!(["reasoning.encrypted_content"]));
1048    }
1049
1050    #[test]
1051    fn terra_and_luna_requests_include_reasoning_effort() {
1052        for (model, effort) in [
1053            ("chatgpt-codex/gpt-5.6-terra", ReasoningEffort::None),
1054            ("chatgpt-codex/gpt-5.6-luna", ReasoningEffort::Max),
1055        ] {
1056            let body = ChatGptCodexClient::build_responses_request_body_with_reasoning(
1057                model,
1058                &[ProviderMessage::user("hello")],
1059                None,
1060                effort,
1061                ReasoningSummary::Off,
1062                &ProviderContinuation::default(),
1063            )
1064            .expect("body");
1065
1066            assert_eq!(body["reasoning"]["effort"], effort.label());
1067        }
1068    }
1069
1070    #[test]
1071    fn reasoning_effort_support_is_limited_to_gpt_5_6_models() {
1072        assert!(supports_reasoning_effort("chatgpt-codex/gpt-5.6-sol"));
1073        assert!(supports_reasoning_effort("chatgpt-codex/gpt-5.6-terra"));
1074        assert!(supports_reasoning_effort("chatgpt-codex/gpt-5.6-luna"));
1075        assert!(!supports_reasoning_effort("chatgpt-codex/gpt-5.5"));
1076    }
1077
1078    #[test]
1079    fn display_model_id_uses_short_codex_prefix_only_for_codex_models() {
1080        assert_eq!(display_model_id("chatgpt-codex/gpt-5.6-terra"), "codex/gpt-5.6-terra");
1081        assert_eq!(display_model_id("umans/default"), "umans/default");
1082    }
1083
1084    #[test]
1085    fn older_models_keep_the_existing_responses_payload() {
1086        let body = ChatGptCodexClient::build_responses_request_body_with_reasoning(
1087            "chatgpt-codex/gpt-5.5",
1088            &[ProviderMessage::user("hello")],
1089            None,
1090            ReasoningEffort::High,
1091            ReasoningSummary::Auto,
1092            &ProviderContinuation::default(),
1093        )
1094        .expect("body");
1095
1096        assert!(body.get("reasoning").is_none());
1097    }
1098
1099    #[test]
1100    fn continuation_replays_encrypted_response_items_and_tool_outputs() {
1101        let messages = vec![ProviderMessage::user("inspect")];
1102        let mut continuation = ProviderContinuation::default();
1103        record_response_items(
1104            &mut continuation,
1105            &messages,
1106            vec![serde_json::json!({
1107                "type": "reasoning",
1108                "encrypted_content": "opaque",
1109            })],
1110        );
1111        record_tool_output(&mut continuation, "call_1", "Cargo.toml", messages.len());
1112
1113        let body = ChatGptCodexClient::build_responses_request_body_with_reasoning(
1114            "chatgpt-codex/gpt-5.6-sol",
1115            &messages,
1116            None,
1117            ReasoningEffort::Medium,
1118            ReasoningSummary::Off,
1119            &continuation,
1120        )
1121        .expect("body");
1122        let input = body["input"].as_array().expect("input array");
1123
1124        assert!(input.iter().any(|item| item["encrypted_content"] == "opaque"));
1125        assert!(
1126            input
1127                .iter()
1128                .any(|item| item["type"] == "function_call_output" && item["call_id"] == "call_1")
1129        );
1130        assert!(body["reasoning"].get("summary").is_none());
1131    }
1132
1133    #[test]
1134    fn build_responses_body_uses_raw_model_for_text_only_turns() {
1135        let messages = vec![
1136            ProviderMessage {
1137                role: "system".to_string(),
1138                content: ProviderMessageContent::Text("be brief".to_string()),
1139            },
1140            ProviderMessage::user("hello"),
1141        ];
1142        let body =
1143            ChatGptCodexClient::build_responses_request_body("chatgpt-codex/gpt-5.5", &messages, None).expect("body");
1144
1145        assert_eq!(body["model"], "gpt-5.5");
1146        assert_eq!(body["instructions"], "be brief");
1147        assert_eq!(body["input"][0]["role"], "user");
1148        assert_eq!(body["input"][0]["content"], "hello");
1149        assert_eq!(body["store"], false);
1150        assert_eq!(body["stream"], true);
1151        assert!(body.get("tools").is_none());
1152    }
1153
1154    #[test]
1155    fn build_responses_body_converts_tool_result_history() {
1156        let messages = vec![
1157            ProviderMessage {
1158                role: "assistant".to_string(),
1159                content: ProviderMessageContent::Blocks(vec![ProviderContentBlock::ToolUse {
1160                    id: "call_1".to_string(),
1161                    name: "find_files".to_string(),
1162                    input: serde_json::json!({ "pattern": "Cargo" }),
1163                }]),
1164            },
1165            ProviderMessage {
1166                role: "user".to_string(),
1167                content: ProviderMessageContent::Blocks(vec![ProviderContentBlock::ToolResult {
1168                    tool_use_id: "call_1".to_string(),
1169                    content: "Cargo.toml".to_string(),
1170                    is_error: Some(false),
1171                }]),
1172            },
1173        ];
1174        let body =
1175            ChatGptCodexClient::build_responses_request_body("chatgpt-codex/gpt-5.5", &messages, None).expect("body");
1176
1177        assert_eq!(body["input"][0]["type"], "function_call");
1178        assert_eq!(body["input"][0]["call_id"], "call_1");
1179        assert_eq!(body["input"][0]["name"], "find_files");
1180        assert_eq!(body["input"][0]["arguments"], r#"{"pattern":"Cargo"}"#);
1181        assert_eq!(body["input"][1]["type"], "function_call_output");
1182        assert_eq!(body["input"][1]["call_id"], "call_1");
1183        assert_eq!(body["input"][1]["output"], "Cargo.toml");
1184        assert!(body["input"][1].get("is_error").is_none());
1185    }
1186
1187    #[test]
1188    fn parse_responses_sse_chunk_accepts_optional_space_after_data_colon() {
1189        assert_eq!(
1190            parse_responses_sse_chunk("data:{\"a\":1}\ndata: {\"b\":2}\n"),
1191            vec!["{\"a\":1}".to_string(), "{\"b\":2}".to_string()]
1192        );
1193    }
1194
1195    #[test]
1196    fn parse_responses_sse_text_reasoning_usage_and_statuses() {
1197        assert_eq!(
1198            parse_responses_sse_event(r#"{"type":"response.output_text.delta","delta":"hi"}"#),
1199            vec![ResponsesSseEvent::TextDelta("hi".to_string())]
1200        );
1201        assert_eq!(
1202            parse_responses_sse_event(r#"{"type":"response.reasoning_summary_text.delta","delta":"thinking"}"#),
1203            vec![ResponsesSseEvent::ReasoningDelta("thinking".to_string())]
1204        );
1205        assert_eq!(
1206            parse_responses_sse_event(
1207                r#"{"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":5,"output_tokens":8}}}"#
1208            ),
1209            vec![
1210                ResponsesSseEvent::ResponseStatus("completed".to_string()),
1211                ResponsesSseEvent::Usage { input_tokens: 5, output_tokens: 8 },
1212            ]
1213        );
1214        assert_eq!(
1215            parse_responses_sse_event(r#"{"type":"response.in_progress"}"#),
1216            vec![ResponsesSseEvent::ResponseStatus("in_progress".to_string())]
1217        );
1218        assert_eq!(parse_responses_sse_event("[DONE]"), vec![ResponsesSseEvent::Done]);
1219    }
1220
1221    #[test]
1222    fn parse_responses_sse_event_retains_cache_and_reasoning_components() {
1223        let events = parse_responses_sse_event(
1224            r#"{"type":"response.completed","usage":{"input_tokens":100,"output_tokens":12,"input_tokens_details":{"cached_tokens":40},"output_tokens_details":{"reasoning_tokens":5}}}"#,
1225        );
1226        assert!(
1227            events.contains(&ResponsesSseEvent::UsageComponents(ProviderUsageComponents {
1228                input_tokens: Some(100),
1229                output_tokens: Some(12),
1230                cache_read_input_tokens: Some(40),
1231                cache_creation_input_tokens: None,
1232                reasoning_tokens: Some(5),
1233            }))
1234        );
1235    }
1236
1237    #[test]
1238    fn parse_responses_sse_text_done_does_not_repeat_finalized_text() {
1239        let text: String = [
1240            parse_responses_sse_event(r#"{"type":"response.output_text.delta","delta":"hi"}"#),
1241            parse_responses_sse_event(r#"{"type":"response.output_text.done","text":"hi"}"#),
1242        ]
1243        .into_iter()
1244        .flatten()
1245        .filter_map(|event| match event {
1246            ResponsesSseEvent::TextDelta(text) => Some(text),
1247            _ => None,
1248        })
1249        .collect();
1250
1251        assert_eq!(text, "hi");
1252    }
1253
1254    #[test]
1255    fn parse_responses_sse_tool_call_deltas_and_done() {
1256        assert_eq!(
1257            parse_responses_sse_event(
1258                r#"{"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"find_files"}}"#
1259            ),
1260            vec![ResponsesSseEvent::ToolCallStart {
1261                id: "fc_1".to_string(),
1262                call_id: "call_1".to_string(),
1263                name: "find_files".to_string(),
1264            }]
1265        );
1266        assert_eq!(
1267            parse_responses_sse_event(
1268                r#"{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"pattern\":\"Cargo\""}"#
1269            ),
1270            vec![ResponsesSseEvent::ToolCallArgumentsDelta {
1271                id: "fc_1".to_string(),
1272                arguments: r#"{"pattern":"Cargo""#.to_string(),
1273            }]
1274        );
1275        assert_eq!(
1276            parse_responses_sse_event(
1277                r#"{"type":"response.output_item.done","item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"find_files","arguments":"{\"pattern\":\"Cargo\"}"}}"#
1278            ),
1279            vec![
1280                ResponsesSseEvent::OutputItem(serde_json::json!({
1281                    "id":"fc_1",
1282                    "type":"function_call",
1283                    "call_id":"call_1",
1284                    "name":"find_files",
1285                    "arguments":"{\"pattern\":\"Cargo\"}",
1286                })),
1287                ResponsesSseEvent::ToolCallDone {
1288                    id: "fc_1".to_string(),
1289                    call_id: Some("call_1".to_string()),
1290                    name: "find_files".to_string(),
1291                    arguments: r#"{"pattern":"Cargo"}"#.to_string(),
1292                }
1293            ]
1294        );
1295    }
1296
1297    #[test]
1298    fn parse_responses_sse_backend_errors_and_malformed_payloads() {
1299        assert_eq!(
1300            parse_responses_sse_event(r#"{"type":"error","message":"backend failed"}"#),
1301            vec![ResponsesSseEvent::Error("backend failed".to_string())]
1302        );
1303        assert_eq!(
1304            parse_responses_sse_event(
1305                r#"{"type":"response.failed","response":{"status":"failed"},"error":{"message":"bad auth"}}"#
1306            ),
1307            vec![
1308                ResponsesSseEvent::Error("bad auth".to_string()),
1309                ResponsesSseEvent::ResponseStatus("failed".to_string()),
1310            ]
1311        );
1312        assert_eq!(
1313            parse_responses_sse_event("{not json"),
1314            vec![ResponsesSseEvent::Malformed("{not json".to_string())]
1315        );
1316    }
1317
1318    #[test]
1319    fn retryable_error_classification_matches_policy() {
1320        assert!(is_retryable_error(&ProviderError::Status {
1321            code: 429,
1322            body: "rate limit".into()
1323        }));
1324        for code in [500, 502, 503, 504] {
1325            assert!(is_retryable_error(&ProviderError::Status {
1326                code,
1327                body: "temporary server error".into()
1328            }));
1329        }
1330        assert!(!is_retryable_error(&ProviderError::Status {
1331            code: 402,
1332            body: "monthly usage limit".into()
1333        }));
1334        assert!(!is_retryable_error(&ProviderError::Status {
1335            code: 403,
1336            body: "subscription required".into()
1337        }));
1338        assert!(!is_retryable_error(&ProviderError::Status {
1339            code: 429,
1340            body: "ChatGPT subscription quota exhausted".into()
1341        }));
1342        assert!(!is_retryable_error(&ProviderError::Auth("missing".into())));
1343    }
1344
1345    #[test]
1346    #[ignore = "requires real ChatGPT subscription credentials, CHATGPT_CODEX_ACCESS_TOKEN or ~/.thndrs/auth.json, and network access"]
1347    fn live_device_code_login_requires_chatgpt_subscription() {
1348        let code = auth::request_chatgpt_codex_device_code()
1349            .expect("real ChatGPT subscription prerequisites and network access are required");
1350        assert!(!code.device_auth_id.is_empty());
1351        assert!(!code.user_code.is_empty());
1352    }
1353
1354    #[test]
1355    #[ignore = "requires real ChatGPT subscription credentials, browser login, localhost:1455 availability, and network access"]
1356    fn live_browser_pkce_login_requires_chatgpt_subscription() {
1357        let credentials = auth::login_chatgpt_codex_with_browser_pkce()
1358            .expect("real ChatGPT subscription browser login and network access are required");
1359        assert!(!credentials.access_token.is_empty());
1360        assert!(!credentials.account_id.is_empty());
1361    }
1362
1363    #[test]
1364    #[ignore = "requires real ChatGPT subscription credentials in CHATGPT_CODEX_ACCESS_TOKEN or ~/.thndrs/auth.json and network access"]
1365    fn live_text_stream_requires_chatgpt_subscription() {
1366        let workspace_root = env::current_dir().expect("current dir");
1367        let client = ChatGptCodexClient::from_env_or_dotenv(&workspace_root)
1368            .expect("real ChatGPT subscription credentials and network access are required");
1369        let messages = vec![ProviderMessage::user("Reply with exactly: ok")];
1370        let mut response = client
1371            .send_streaming_request(
1372                "chatgpt-codex/gpt-5.5",
1373                &messages,
1374                None,
1375                ReasoningEffort::default(),
1376                ReasoningSummary::default(),
1377                &ProviderContinuation::default(),
1378            )
1379            .expect("ChatGPT Codex text-only stream requires subscription credentials");
1380        let body = response.body_mut().read_to_string().expect("read body");
1381        assert!(body.contains("data:"));
1382    }
1383
1384    #[test]
1385    #[ignore = "requires real ChatGPT subscription credentials, local tool-call support, and network access"]
1386    fn live_tool_call_requires_chatgpt_subscription() {
1387        let workspace_root = env::current_dir().expect("current dir");
1388        let client = ChatGptCodexClient::from_env_or_dotenv(&workspace_root)
1389            .expect("real ChatGPT subscription credentials and network access are required");
1390        let messages = vec![ProviderMessage::user(
1391            "Use the find_files tool to look for Cargo.toml, then stop.",
1392        )];
1393        let defs = crate::tools::tool_definitions();
1394        let catalog = crate::tools::tool_catalog_schemas(&defs);
1395        let mut response = client
1396            .send_streaming_request(
1397                "chatgpt-codex/gpt-5.5",
1398                &messages,
1399                Some(&catalog),
1400                ReasoningEffort::default(),
1401                ReasoningSummary::default(),
1402                &ProviderContinuation::default(),
1403            )
1404            .expect("ChatGPT Codex tool-call stream requires subscription credentials");
1405        let body = response.body_mut().read_to_string().expect("read body");
1406        assert!(body.contains("function_call") || body.contains("find_files"));
1407    }
1408
1409    #[test]
1410    #[ignore = "requires real ChatGPT subscription credentials and validates private GPT-5.6 ChatGPT Codex backend support"]
1411    fn live_gpt_5_6_models_support_each_reasoning_effort() {
1412        let workspace_root = env::current_dir().expect("current dir");
1413        let client = ChatGptCodexClient::from_env_or_dotenv(&workspace_root)
1414            .expect("real ChatGPT subscription credentials are required");
1415        let messages = vec![ProviderMessage::user("Reply with exactly: ok")];
1416
1417        for model in [
1418            "chatgpt-codex/gpt-5.6-sol",
1419            "chatgpt-codex/gpt-5.6-terra",
1420            "chatgpt-codex/gpt-5.6-luna",
1421        ] {
1422            for effort in ReasoningEffort::ALL {
1423                let mut response = client
1424                    .send_streaming_request(
1425                        model,
1426                        &messages,
1427                        None,
1428                        effort,
1429                        ReasoningSummary::Off,
1430                        &ProviderContinuation::default(),
1431                    )
1432                    .unwrap_or_else(|error| panic!("{model} rejected {}: {error}", effort.label()));
1433                let body = response.body_mut().read_to_string().expect("read body");
1434                assert!(body.contains("data:"), "{model} {} did not stream SSE", effort.label());
1435            }
1436        }
1437    }
1438
1439    #[test]
1440    #[ignore = "requires expired real ChatGPT subscription credentials in ~/.thndrs/auth.json and network access"]
1441    fn live_expired_token_refresh_requires_chatgpt_subscription() {
1442        unsafe {
1443            env::remove_var(auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV);
1444        }
1445        let auth = auth::resolve_chatgpt_codex_auth()
1446            .expect("expired real ChatGPT subscription credentials and network access are required");
1447        assert!(!auth.access_token.is_empty());
1448        assert!(!auth.account_id.is_empty());
1449    }
1450}