1use 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
21pub const BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
23
24pub const RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses";
26
27const MODELS_PATH: &str = "models";
28
29pub const MODEL_PREFIX: &str = "chatgpt-codex/";
31
32pub const DISPLAY_MODEL_PREFIX: &str = "codex/";
34
35pub const DEFAULT_RECOMMENDED_MAX_TOKENS: u32 = 32_768;
37
38#[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 OutputItem(serde_json::Value),
67 Done,
68 Malformed(String),
69 Other,
70}
71
72pub struct ChatGptCodexClient {
74 base_url: String,
75 auth: ChatGptCodexAuth,
76 agent: ureq::Agent,
77}
78
79impl ChatGptCodexClient {
80 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 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 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 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 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 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 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 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
295pub 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
341pub fn is_model_id(model: &str) -> bool {
343 model.strip_prefix(MODEL_PREFIX).is_some_and(|raw| !raw.is_empty())
344}
345
346pub 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
354pub 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
362pub fn supports_reasoning_effort(model: &str) -> bool {
364 raw_model_id(model).is_ok_and(is_gpt_5_6_raw_model)
365}
366
367pub 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
384pub 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
400pub 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
420pub 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
436pub 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
453pub 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
461pub 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}