1use serde::{Deserialize, Serialize};
2use tokio::sync::broadcast;
3use tokio_util::sync::CancellationToken;
4
5use crate::error::RuntimeError;
6use crate::event::{NodeEvent, Observable, TurnId};
7use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
8use crate::provider::{
9 AssistantMessage, CallTiming, DEFAULT_STREAM_BUFFER, LlmRequest, ModelDiscoveryError, Provider,
10 ReasoningEffort, ReasoningSelection, ReasoningWireProfile, StopReason, TokenUsage,
11 estimate_tokens,
12};
13use crate::tool::BoxFut;
14use anyhow::Context;
15
16const CODEX_BASE: &str = "https://chatgpt.com/backend-api/codex";
17const CODEX_MODELS_URL: &str = "https://chatgpt.com/backend-api/wham/models";
18const X_CODEX_TURN_STATE: &str = "x-codex-turn-state";
19const MAX_RETAINED_TURN_STATES: usize = 256;
20
21#[derive(Clone)]
22enum CodexCredentialSource {
23 Static {
24 access_token: String,
25 account_id: String,
26 },
27 Managed(crate::oauth::OAuthCredentialLease),
28}
29
30struct CodexRequestCredentials {
31 access_token: String,
32 account_id: String,
33}
34
35impl CodexCredentialSource {
36 async fn acquire(&self) -> Result<CodexRequestCredentials, crate::oauth::OAuthCredentialError> {
37 match self {
38 Self::Static {
39 access_token,
40 account_id,
41 } => Ok(CodexRequestCredentials {
42 access_token: access_token.clone(),
43 account_id: account_id.clone(),
44 }),
45 Self::Managed(lease) => {
46 let credential = lease.acquire().await?;
47 let account_id =
48 oauth_account_id(&credential.access_token, credential.display_account);
49 Ok(CodexRequestCredentials {
50 access_token: credential.access_token,
51 account_id,
52 })
53 }
54 }
55 }
56}
57
58fn oauth_account_id(access_token: &str, legacy_account: Option<String>) -> String {
59 crate::oauth::extract_chatgpt_account_id(access_token)
60 .or_else(|| {
61 legacy_account.filter(|account| {
62 let account = account.trim();
63 !account.is_empty() && !account.contains('@')
64 })
65 })
66 .unwrap_or_default()
67}
68
69pub struct CodexProvider {
71 name: String,
72 credentials: CodexCredentialSource,
73 client: reqwest::Client,
74 responses_url: String,
75 models_url: String,
76 turn_states: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, String>>>,
77}
78
79impl CodexProvider {
80 pub fn new(
81 name: impl Into<String>,
82 access_token: impl Into<String>,
83 account_id: impl Into<String>,
84 ) -> Self {
85 Self {
86 name: name.into(),
87 credentials: CodexCredentialSource::Static {
88 access_token: access_token.into(),
89 account_id: account_id.into(),
90 },
91 client: reqwest::Client::new(),
92 responses_url: format!("{CODEX_BASE}/responses"),
93 models_url: CODEX_MODELS_URL.into(),
94 turn_states: Default::default(),
95 }
96 }
97
98 fn from_oauth_store(
99 stored: &crate::auth_store::StoredProvider,
100 hub: crate::config_hub::ConfigHub,
101 ) -> Self {
102 Self {
103 name: stored.id.clone(),
104 credentials: CodexCredentialSource::Managed(crate::oauth::OAuthCredentialLease::new::<
105 Self,
106 >(&stored.id, hub)),
107 client: reqwest::Client::new(),
108 responses_url: format!("{CODEX_BASE}/responses"),
109 models_url: CODEX_MODELS_URL.into(),
110 turn_states: Default::default(),
111 }
112 }
113
114 #[cfg(test)]
115 fn with_endpoints(
116 mut self,
117 responses_url: impl Into<String>,
118 models_url: impl Into<String>,
119 ) -> Self {
120 self.responses_url = responses_url.into();
121 self.models_url = models_url.into();
122 self
123 }
124
125 fn build_body(&self, req: &LlmRequest) -> Result<ResponsesRequest, RuntimeError> {
126 let model = req
127 .model
128 .split_once('/')
129 .map(|(_, slug)| slug)
130 .or_else(|| req.model.split_once(':').map(|(_, slug)| slug))
131 .unwrap_or(&req.model)
132 .to_string();
133
134 let input = build_input_items(req)?;
135 let tools = build_tools(&req.tools);
136
137 Ok(ResponsesRequest {
138 model,
139 input,
140 instructions: req
141 .system
142 .clone()
143 .filter(|instructions| !instructions.is_empty()),
144 tools,
145 stream: true,
146 store: false,
147 reasoning: build_reasoning_config(&req.reasoning),
148 text: Some(TextConfig {
149 verbosity: "medium".into(),
150 }),
151 include: Some(vec!["reasoning.encrypted_content".into()]),
152 prompt_cache_key: req.prompt_cache_key.clone(),
153 })
154 }
155
156 fn validate_reasoning(selection: &ReasoningSelection) -> Result<(), RuntimeError> {
157 ReasoningWireProfile::CodexResponses
158 .validate(selection, None)
159 .map_err(|error| RuntimeError::ToolFailed(format!("invalid request: {error}")))
160 }
161}
162
163fn build_reasoning_config(selection: &ReasoningSelection) -> Option<ReasoningConfig> {
164 match selection {
165 ReasoningSelection::ProviderDefault => None,
166 ReasoningSelection::Disabled => Some(ReasoningConfig {
167 effort: Some(ReasoningEffort::None.to_string()),
168 mode: None,
169 summary: None,
170 }),
171 ReasoningSelection::Auto { execution_mode } => Some(ReasoningConfig {
172 effort: None,
173 mode: execution_mode.as_ref().map(ToString::to_string),
174 summary: Some("auto".into()),
175 }),
176 ReasoningSelection::Effort {
177 effort,
178 execution_mode,
179 } => Some(ReasoningConfig {
180 effort: Some(effort.to_string()),
181 mode: execution_mode.as_ref().map(ToString::to_string),
182 summary: (!matches!(effort, ReasoningEffort::None)).then(|| "auto".into()),
183 }),
184 ReasoningSelection::BudgetTokens { .. } => None,
185 }
186}
187
188fn build_input_items(req: &LlmRequest) -> Result<Vec<InputItem>, RuntimeError> {
189 let mut tool_names: std::collections::HashMap<String, String> =
190 std::collections::HashMap::new();
191 for m in &req.messages {
192 if m.role == MessageRole::Assistant {
193 for p in &m.parts {
194 if let MessagePart::ToolUse { id, name, .. } = p {
195 tool_names.insert(id.clone(), crate::tool_naming::to_wire(name));
196 }
197 }
198 }
199 }
200
201 let mut items: Vec<InputItem> = Vec::new();
202
203 for m in &req.messages {
204 match m.role {
205 MessageRole::User => {
206 let Some(content) = build_user_content(&m.parts)? else {
207 continue;
208 };
209 items.push(InputItem {
210 role: Some("user".into()),
211 content: Some(content),
212 item_type: Some("message".into()),
213 call_id: None,
214 name: None,
215 arguments: None,
216 output: None,
217 });
218 }
219 MessageRole::Assistant => {
220 let (text, tool_calls) = split_assistant_parts(&m.parts, &req.tools);
221 if let Some(t) = text {
222 items.push(InputItem {
223 role: Some("assistant".into()),
224 content: Some(InputContent::Text(t)),
225 item_type: Some("message".into()),
226 call_id: None,
227 name: None,
228 arguments: None,
229 output: None,
230 });
231 }
232 for tc in tool_calls {
233 items.push(InputItem {
234 role: None,
235 content: None,
236 item_type: Some("function_call".into()),
237 call_id: Some(tc.id),
238 name: Some(tc.name),
239 arguments: Some(tc.arguments),
240 output: None,
241 });
242 }
243 }
244 MessageRole::Tool => {
245 for p in &m.parts {
246 if let MessagePart::ToolResult {
247 tool_use_id,
248 content,
249 ..
250 } = p
251 {
252 let name = tool_names.get(tool_use_id).cloned();
253 items.push(InputItem {
254 role: None,
255 content: None,
256 item_type: Some("function_call_output".into()),
257 call_id: Some(tool_use_id.clone()),
258 name,
259 arguments: None,
260 output: Some(content.clone()),
261 });
262 }
263 }
264 }
265 MessageRole::System => {
266 let Some(content) = build_user_content(&m.parts)? else {
267 continue;
268 };
269 items.push(InputItem {
270 role: Some(
271 if m.origin == MessageOrigin::Internal
272 && m.parts
273 .iter()
274 .any(|part| matches!(part, MessagePart::ContextRecord(_)))
275 {
276 "developer"
277 } else {
278 "user"
279 }
280 .into(),
281 ),
282 content: Some(content),
283 item_type: Some("message".into()),
284 call_id: None,
285 name: None,
286 arguments: None,
287 output: None,
288 });
289 }
290 }
291 }
292
293 Ok(items)
294}
295
296fn build_user_content(parts: &[MessagePart]) -> Result<Option<InputContent>, RuntimeError> {
297 let mut parts_out: Vec<ResponseInputContent> = Vec::new();
298 for p in parts {
299 match p {
300 MessagePart::ContextRecord(record) => {
301 let text = record.render_for_model();
302 if !text.is_empty() {
303 parts_out.push(ResponseInputContent::InputText { text });
304 }
305 }
306 MessagePart::Text { text } => {
307 if !text.is_empty() {
308 parts_out.push(ResponseInputContent::InputText { text: text.clone() });
309 }
310 }
311 MessagePart::Image { source } => {
312 let data = crate::attachment_store::image_base64(source)?;
313 parts_out.push(ResponseInputContent::InputImage {
314 image_url: format!("data:{};base64,{}", source.media_type, data),
315 detail: (!matches!(source.detail, crate::provider::ImageDetail::Auto))
316 .then(|| source.detail.as_str()),
317 });
318 }
319 MessagePart::CompactSummary { summary, .. } => {
320 if !summary.is_empty() {
321 parts_out.push(ResponseInputContent::InputText {
322 text: summary.clone(),
323 });
324 }
325 }
326 _ => {}
327 }
328 }
329 if let [ResponseInputContent::InputText { text }] = parts_out.as_slice() {
330 Ok(Some(InputContent::Text(text.clone())))
331 } else if parts_out.is_empty() {
332 Ok(None)
333 } else {
334 Ok(Some(InputContent::Parts(parts_out)))
335 }
336}
337
338struct AssistantSplit {
339 id: String,
340 name: String,
341 arguments: String,
342}
343
344fn split_assistant_parts(
345 parts: &[MessagePart],
346 tool_specs: &[crate::tool::ToolSpec],
347) -> (Option<String>, Vec<AssistantSplit>) {
348 let mut text = String::new();
349 let mut tools: Vec<AssistantSplit> = Vec::new();
350 for p in parts {
351 match p {
352 MessagePart::ContextRecord(record) => text.push_str(&record.render_for_model()),
353 MessagePart::Text { text: t } => text.push_str(t),
354 MessagePart::ToolUse {
355 id,
356 name,
357 input,
358 intent,
359 } => tools.push(AssistantSplit {
360 id: id.clone(),
361 name: crate::tool_naming::to_wire(name),
362 arguments: serde_json::to_string(&crate::message::encode_tool_call_input(
363 input,
364 intent.as_ref(),
365 name,
366 tool_specs,
367 ))
368 .unwrap_or_default(),
369 }),
370 _ => {}
371 }
372 }
373 let text_out = if text.is_empty() { None } else { Some(text) };
374 (text_out, tools)
375}
376
377fn build_tools(tools: &[crate::tool::ToolSpec]) -> Vec<ResponsesTool> {
378 tools
379 .iter()
380 .map(|t| ResponsesTool {
381 r#type: "function".into(),
382 name: crate::tool_naming::to_wire(&t.name),
384 description: t.description.clone(),
385 parameters: t.input_schema.clone(),
386 })
387 .collect()
388}
389
390#[derive(Deserialize)]
391struct CodexModelsResponse {
392 models: Vec<CodexModelResponse>,
393}
394
395#[derive(Deserialize)]
396struct CodexModelResponse {
397 slug: String,
398 #[serde(default)]
399 context_window: Option<u64>,
400 #[serde(default)]
401 supported_reasoning_levels: Vec<CodexReasoningLevel>,
402 #[serde(default)]
403 default_reasoning_level: Option<ReasoningEffort>,
404 #[serde(default)]
405 input_modalities: Vec<String>,
406}
407
408#[derive(Deserialize)]
409#[serde(untagged)]
410enum CodexReasoningLevel {
411 Name(String),
412 Detail {
413 effort: String,
414 #[serde(default, rename = "description")]
415 _description: Option<String>,
416 },
417}
418
419impl CodexReasoningLevel {
420 fn effort(self) -> String {
421 match self {
422 Self::Name(effort) | Self::Detail { effort, .. } => effort,
423 }
424 }
425}
426
427fn parse_codex_models(
428 bytes: &[u8],
429) -> Result<Vec<crate::provider::DiscoveredModelDetails>, ModelDiscoveryError> {
430 let response: CodexModelsResponse = serde_json::from_slice(bytes)
431 .map_err(|error| ModelDiscoveryError::InvalidResponse(error.to_string()))?;
432 response
433 .models
434 .into_iter()
435 .enumerate()
436 .map(|(index, model)| {
437 let raw_slug = model.slug.trim();
438 if raw_slug.is_empty() {
439 return Err(ModelDiscoveryError::InvalidResponse(format!(
440 "models[{index}].slug must not be empty"
441 )));
442 }
443 let slug = if raw_slug.starts_with("codex/") {
444 raw_slug.to_string()
445 } else {
446 format!("codex/{raw_slug}")
447 };
448 let reasoning_efforts = model
449 .supported_reasoning_levels
450 .into_iter()
451 .map(|level| {
452 let raw = level.effort();
453 raw.parse().map_err(|error| {
454 ModelDiscoveryError::InvalidResponse(format!(
455 "models[{index}] has invalid reasoning effort `{raw}`: {error}"
456 ))
457 })
458 })
459 .collect::<Result<Vec<_>, _>>()?;
460 let input_modalities = model
461 .input_modalities
462 .into_iter()
463 .filter_map(|value| match value.as_str() {
464 "text" => Some(crate::provider::InputModality::Text),
465 "image" => Some(crate::provider::InputModality::Image),
466 "audio" => Some(crate::provider::InputModality::Audio),
467 _ => None,
468 })
469 .collect();
470 Ok(crate::provider::DiscoveredModelDetails {
471 slug,
472 context_budget: model.context_window,
473 capability_knowledge: crate::provider::CapabilityKnowledge::Advertised(
474 crate::provider::ModelCapabilities {
475 reasoning_efforts,
476 default_reasoning_effort: model.default_reasoning_level,
477 input_modalities,
478 ..Default::default()
479 },
480 ),
481 })
482 })
483 .collect()
484}
485
486fn discovery_error_body(body: &str) -> String {
487 body.chars().take(512).collect()
488}
489
490impl Provider for CodexProvider {
491 fn name(&self) -> &str {
492 &self.name
493 }
494
495 fn capabilities(&self) -> crate::provider::ProviderCapabilities {
496 crate::provider::ProviderCapabilities {
497 prompt_cache_key: true,
498 context_prefix_profile: crate::context_plan::ContextPrefixProfile::CodexResponses,
499 }
500 }
501
502 fn context_prefix(
503 &self,
504 req: &LlmRequest,
505 ) -> Result<crate::context_plan::ContextPrefixSnapshot, RuntimeError> {
506 let body = self.build_body(req)?;
507 let mut builder = crate::context_plan::ContextPrefixSnapshot::builder(
508 crate::context_plan::ContextPrefixProfile::CodexResponses,
509 req,
510 );
511 if let Some(instructions) = &body.instructions {
512 builder.push(crate::context_plan::ContextPrefixLane::Stable, instructions)?;
513 }
514 for tool in &body.tools {
515 builder.push(crate::context_plan::ContextPrefixLane::Tools, tool)?;
516 }
517 for item in &body.input {
518 builder.push(
519 if item.role.as_deref() == Some("developer") {
520 crate::context_plan::ContextPrefixLane::Records
521 } else {
522 crate::context_plan::ContextPrefixLane::Messages
523 },
524 item,
525 )?;
526 }
527 Ok(builder.finish())
528 }
529
530 fn call<'a>(&'a self, req: LlmRequest) -> BoxFut<'a, Result<AssistantMessage, RuntimeError>> {
531 let observable = self.call_streaming(req);
534 Box::pin(async move {
535 let _events = observable.events;
537 observable.output.await
538 })
539 }
540
541 fn call_streaming(&self, req: LlmRequest) -> Observable<AssistantMessage> {
542 let preflight =
543 Self::validate_reasoning(&req.reasoning).and_then(|()| self.build_body(&req));
544 let turn_id = turn_id_from_req(&req);
545 let streaming_tools = req.tools.clone();
546 let credentials = self.credentials.clone();
547 let client = self.client.clone();
548 let responses_url = self.responses_url.clone();
549 let turn_states = self.turn_states.clone();
550 let routing_turn_id = req
551 .messages
552 .last()
553 .map(|message| message.turn_id.to_string())
554 .unwrap_or_else(|| turn_id.to_string());
555 let turn_state_key = req
556 .prompt_cache_key
557 .as_ref()
558 .map(|routing_key| format!("{routing_key}:{routing_turn_id}"));
559 let (tx, events) = broadcast::channel(DEFAULT_STREAM_BUFFER);
560 let cancel = CancellationToken::new();
561 let cancel_for_task = cancel.clone();
562
563 let output: BoxFut<'static, Result<AssistantMessage, RuntimeError>> = Box::pin(
564 async move {
565 let body = preflight?;
566 let routing_key = body.prompt_cache_key.clone();
567 let credentials = tokio::select! {
568 biased;
569 _ = cancel_for_task.cancelled() => {
570 return Err(RuntimeError::Cancelled("codex cancelled before authentication".into()));
571 }
572 result = credentials.acquire() => result.map_err(credential_err)?,
573 };
574 let mut request = client
575 .post(responses_url)
576 .bearer_auth(credentials.access_token)
577 .header("originator", "codex_cli_rs")
578 .header("OpenAI-Beta", "responses=experimental")
579 .header("accept", "text/event-stream")
580 .json(&body);
581 if !credentials.account_id.is_empty() {
582 request = request.header("chatgpt-account-id", credentials.account_id);
583 }
584 if let Some(routing_key) = routing_key.as_deref() {
585 request = request
586 .header("session-id", routing_key)
587 .header("thread-id", routing_key)
588 .header("x-client-request-id", routing_key);
589 }
590 if let Some(turn_state_key) = turn_state_key.as_deref()
591 && let Some(turn_state) = turn_states
592 .lock()
593 .unwrap_or_else(std::sync::PoisonError::into_inner)
594 .get(turn_state_key)
595 .cloned()
596 {
597 request = request.header(X_CODEX_TURN_STATE, turn_state);
598 }
599 use eventsource_stream::Eventsource;
600 use futures::StreamExt;
601
602 let resp = tokio::select! {
603 biased;
604 _ = cancel_for_task.cancelled() => {
605 return Err(RuntimeError::Cancelled("codex cancelled before send".into()));
606 }
607 r = request.send() => r.map_err(net_err)?,
608 };
609 let status = resp.status();
610 let response_turn_state = resp
611 .headers()
612 .get(X_CODEX_TURN_STATE)
613 .and_then(|value| value.to_str().ok())
614 .map(str::to_owned);
615 if !status.is_success() {
616 let body_text = resp.text().await.unwrap_or_default();
617 if let Some(reason) =
618 super::classify_attachment_error(status.as_u16(), &body_text)
619 {
620 return Err(RuntimeError::AttachmentError { reason });
621 }
622 return Err(RuntimeError::ToolFailed(format!(
623 "codex http {status}: {body_text}"
624 )));
625 }
626
627 if let (Some(turn_state_key), Some(turn_state)) =
628 (turn_state_key, response_turn_state)
629 {
630 let mut states = turn_states
631 .lock()
632 .unwrap_or_else(std::sync::PoisonError::into_inner);
633 if !states.contains_key(&turn_state_key)
634 && states.len() >= MAX_RETAINED_TURN_STATES
635 {
636 states.clear();
637 }
638 states.entry(turn_state_key).or_insert(turn_state);
639 }
640
641 let mut stream = resp.bytes_stream().eventsource();
642
643 let mut acc_text = String::new();
644 let mut acc_thinking = String::new();
645 let mut cumulative = 0u64;
646 let mut final_usage: Option<ResponsesUsage> = None;
647 let mut resp_model: Option<String> = None;
648 let mut resp_id: Option<String> = None;
649 let mut stop_reason = StopReason::End;
650
651 let mut partial_tool_calls: Vec<PartialToolCall> = Vec::new();
652
653 while let Some(event) = tokio::select! {
654 biased;
655 _ = cancel_for_task.cancelled() => None,
656 next = stream.next() => next,
657 } {
658 let event =
659 event.map_err(|e| RuntimeError::ToolFailed(format!("codex sse: {e}")))?;
660 if event.data.is_empty() || event.data == "[DONE]" {
661 continue;
662 }
663 let parsed: serde_json::Value = match serde_json::from_str(&event.data) {
664 Ok(v) => v,
665 Err(_) => continue,
666 };
667
668 let ev_type = parsed["type"].as_str().unwrap_or("");
669
670 match ev_type {
671 "response.output_text.delta" => {
672 if let Some(delta) = parsed["delta"].as_str() {
673 acc_text.push_str(delta);
674 cumulative += estimate_tokens(delta);
675 let _ = tx.send(NodeEvent::LlmChunk {
676 text: delta.to_string(),
677 cumulative_tokens: cumulative,
678 });
679 }
680 }
681
682 "response.reasoning_text.delta" => {
683 if let Some(delta) = parsed["delta"].as_str() {
684 acc_thinking.push_str(delta);
685 let _ = tx.send(NodeEvent::ThinkingChunk {
686 text: delta.to_string(),
687 });
688 }
689 }
690
691 "response.output_item.added" => {
692 if let Some(item) = parsed.get("item")
693 && item["type"].as_str() == Some("function_call")
694 {
695 let idx = parsed["output_index"].as_u64().unwrap_or(0) as usize;
696 while partial_tool_calls.len() <= idx {
697 partial_tool_calls.push(PartialToolCall::default());
698 }
699 let slot = &mut partial_tool_calls[idx];
700 slot.id = item["call_id"].as_str().unwrap_or("").to_string();
701 slot.name = item["name"].as_str().unwrap_or("").to_string();
702 }
703 }
704
705 "response.function_call_arguments.delta" => {
706 let idx = parsed["output_index"].as_u64().unwrap_or(0) as usize;
707 while partial_tool_calls.len() <= idx {
708 partial_tool_calls.push(PartialToolCall::default());
709 }
710 if let Some(delta) = parsed["delta"].as_str() {
711 partial_tool_calls[idx].arguments.push_str(delta);
712 let slot = &partial_tool_calls[idx];
713 let _ = tx.send(NodeEvent::ToolCallDraft {
714 index: idx,
715 call_id: slot.id.clone(),
716 name: crate::tool_naming::from_wire(
717 &slot.name,
718 &streaming_tools,
719 ),
720 arguments_delta: delta.to_string(),
721 });
722 }
723 }
724
725 "response.completed" => {
726 if let Some(r) = parsed.get("response") {
727 resp_model = r["model"].as_str().map(|s| s.to_string());
728 resp_id = r["id"].as_str().map(|s| s.to_string());
729 if let Some(u) = r.get("usage") {
730 final_usage =
731 serde_json::from_value::<ResponsesUsage>(u.clone()).ok();
732 }
733 if r["status"].as_str() == Some("cancelled") {
734 stop_reason = StopReason::Cancelled;
735 }
736 }
737 }
738
739 "error" => {
740 let msg = parsed["message"].as_str().unwrap_or("unknown codex error");
741 return Err(RuntimeError::ToolFailed(msg.to_string()));
742 }
743
744 _ => {}
745 }
746 }
747
748 if cancel_for_task.is_cancelled() {
749 let _ = tx.send(NodeEvent::LlmDone {
750 total_tokens: cumulative,
751 });
752 return Err(RuntimeError::Cancelled("codex cancelled mid-stream".into()));
753 }
754
755 let total_output = final_usage
756 .as_ref()
757 .and_then(|u| u.output_tokens)
758 .unwrap_or(cumulative);
759 let _ = tx.send(NodeEvent::LlmDone {
760 total_tokens: total_output,
761 });
762
763 let mut parts: Vec<MessagePart> = Vec::new();
764 if !acc_thinking.is_empty() {
765 parts.push(MessagePart::Thinking {
766 thinking: acc_thinking,
767 signature: None,
768 });
769 }
770 if !acc_text.is_empty() {
771 parts.push(MessagePart::Text { text: acc_text });
772 }
773 for tc in partial_tool_calls {
774 if tc.name.is_empty() {
775 continue;
776 }
777 let input: serde_json::Value = if tc.arguments.is_empty() {
778 serde_json::Value::Object(Default::default())
779 } else {
780 serde_json::from_str(&tc.arguments).unwrap_or(serde_json::Value::Null)
781 };
782 let name = crate::tool_naming::from_wire(&tc.name, &streaming_tools);
783 let (input, intent) =
784 crate::message::decode_tool_call_input(input, &name, &streaming_tools);
785 parts.push(MessagePart::ToolUse {
786 id: tc.id,
787 name,
788 input,
789 intent,
790 });
791 }
792
793 let token_usage = final_usage.map(|u| {
794 let input_tokens = u.input_tokens.unwrap_or(0);
795 let cached_input = u
796 .input_tokens_details
797 .as_ref()
798 .and_then(|d| d.cached_tokens)
799 .unwrap_or(0);
800 let cache_write = u
801 .input_tokens_details
802 .as_ref()
803 .and_then(|d| d.cache_write_tokens)
804 .unwrap_or(0);
805 TokenUsage {
806 input: crate::provider::regular_input_tokens(
810 input_tokens,
811 cached_input,
812 cache_write,
813 ),
814 cached_input,
815 output: u.output_tokens.unwrap_or(0),
816 cache_write,
817 reasoning_tokens: u
818 .output_tokens_details
819 .as_ref()
820 .and_then(|d| d.reasoning_tokens)
821 .unwrap_or(0),
822 }
823 });
824
825 Ok(AssistantMessage {
826 message: Message {
827 role: MessageRole::Assistant,
828 parts,
829 turn_id,
830 origin: MessageOrigin::User,
831 },
832 stop_reason,
833 token_usage: token_usage.unwrap_or_default(),
834 timing: CallTiming::default(),
835 model: resp_model.unwrap_or_default(),
836 response_id: resp_id,
837 })
838 },
839 );
840 Observable {
841 output,
842 events,
843 cancel,
844 }
845 }
846
847 fn discover_models(
848 &self,
849 ) -> crate::tool::BoxFut<'static, Vec<crate::provider::DiscoveredModel>> {
850 let discovery = self.try_discover_models();
851 Box::pin(async move {
852 discovery
853 .await
854 .unwrap_or_default()
855 .into_iter()
856 .map(crate::provider::DiscoveredModel::from)
857 .collect()
858 })
859 }
860
861 fn try_discover_models(
862 &self,
863 ) -> crate::tool::BoxFut<
864 'static,
865 Result<Vec<crate::provider::DiscoveredModelDetails>, ModelDiscoveryError>,
866 > {
867 let credentials = self.credentials.clone();
868 let client = self.client.clone();
869 let models_url = self.models_url.clone();
870 Box::pin(async move {
871 let credentials = credentials.acquire().await.map_err(|error| {
872 ModelDiscoveryError::Transport(format!("codex credentials: {error}"))
873 })?;
874 let mut request = client
875 .get(models_url)
876 .query(&[("client_version", "0.0.0")])
877 .bearer_auth(credentials.access_token);
878 if !credentials.account_id.is_empty() {
879 request = request.header("ChatGPT-Account-Id", credentials.account_id);
880 }
881 let resp = request
882 .send()
883 .await
884 .map_err(|error| ModelDiscoveryError::Transport(error.to_string()))?;
885 let status = resp.status();
886 let bytes = resp
887 .bytes()
888 .await
889 .map_err(|error| ModelDiscoveryError::Transport(error.to_string()))?;
890 if !status.is_success() {
891 return Err(ModelDiscoveryError::Http {
892 status: status.as_u16(),
893 body: discovery_error_body(&String::from_utf8_lossy(&bytes)),
894 });
895 }
896 parse_codex_models(&bytes)
897 })
898 }
899
900 fn test_connection(&self) -> BoxFut<'_, Result<String, String>> {
901 let credentials = self.credentials.clone();
902 let models_url = self.models_url.clone();
903 let name = self.name.clone();
904 Box::pin(async move {
905 let credentials = credentials
906 .acquire()
907 .await
908 .map_err(|error| format!("credentials unavailable — {error}"))?;
909 let client = reqwest::Client::builder()
910 .timeout(std::time::Duration::from_secs(15))
911 .build()
912 .map_err(|e| e.to_string())?;
913 let mut request = client
914 .get(models_url)
915 .query(&[("client_version", "0.0.0")])
916 .bearer_auth(credentials.access_token);
917 if !credentials.account_id.is_empty() {
918 request = request.header("ChatGPT-Account-Id", credentials.account_id);
919 }
920 let resp = request
921 .send()
922 .await
923 .map_err(|e| format!("connection failed — {e}"))?;
924 let status = resp.status();
925 if status.is_success() {
926 Ok(format!("\"{name}\" responded OK"))
927 } else {
928 let body = resp.text().await.unwrap_or_default();
929 Err(format!(
930 "returned {status} — {}",
931 crate::provider::bounded_utf8_prefix(&body, 200)
932 ))
933 }
934 })
935 }
936}
937
938const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
939const CODEX_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
940const CODEX_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
941const CODEX_REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
942
943impl crate::oauth::OAuthProvider for CodexProvider {
944 const KIND: crate::auth_store::ProviderKind = crate::auth_store::ProviderKind::Codex;
945
946 fn authorize_url() -> (String, crate::oauth::Pkce, String) {
947 let pkce = crate::oauth::Pkce::generate();
948 let state = crate::oauth::generate_state();
949 let url = format!(
950 "{}?response_type=code&client_id={}&redirect_uri={}&code_challenge={}&code_challenge_method=S256&state={}&scope=openid+profile+email+offline_access",
951 CODEX_AUTHORIZE_URL, CODEX_CLIENT_ID, CODEX_REDIRECT_URI, pkce.challenge, state
952 );
953 (url, pkce, state)
954 }
955
956 fn exchange_code(
957 code: &str,
958 verifier: &str,
959 ) -> std::pin::Pin<
960 Box<dyn std::future::Future<Output = anyhow::Result<crate::oauth::TokenResult>> + Send>,
961 > {
962 let code = code.to_string();
963 let verifier = verifier.to_string();
964 Box::pin(async move {
965 let client = reqwest::Client::new();
966 let resp = client
967 .post(CODEX_TOKEN_URL)
968 .form(&[
969 ("grant_type", "authorization_code"),
970 ("code", &code),
971 ("redirect_uri", CODEX_REDIRECT_URI),
972 ("client_id", CODEX_CLIENT_ID),
973 ("code_verifier", &verifier),
974 ])
975 .send()
976 .await
977 .context("token exchange request")?;
978
979 let status = resp.status();
980 let body_text = resp.text().await.unwrap_or_default();
981 if !status.is_success() {
982 anyhow::bail!("token exchange failed (HTTP {status}): {body_text}");
983 }
984
985 #[derive(serde::Deserialize)]
986 struct R {
987 access_token: String,
988 refresh_token: Option<String>,
989 id_token: Option<String>,
990 }
991 let data: R = serde_json::from_str(&body_text).context("parse token response")?;
992
993 let expires_at = crate::oauth::parse_jwt_exp(&data.access_token)
994 .unwrap_or_else(|| chrono::Utc::now().timestamp() + 3600);
995 let account = data
996 .id_token
997 .as_deref()
998 .and_then(crate::oauth::extract_account_from_id_token);
999
1000 Ok(crate::oauth::TokenResult {
1001 access_token: data.access_token,
1002 refresh_token: data.refresh_token,
1003 expires_at,
1004 account,
1005 })
1006 })
1007 }
1008
1009 fn refresh_token(
1010 token: &str,
1011 ) -> std::pin::Pin<
1012 Box<dyn std::future::Future<Output = anyhow::Result<crate::oauth::TokenResult>> + Send>,
1013 > {
1014 let token = token.to_string();
1015 Box::pin(async move {
1016 let client = reqwest::Client::new();
1017 let resp = client
1018 .post(CODEX_TOKEN_URL)
1019 .form(&[
1020 ("grant_type", "refresh_token"),
1021 ("refresh_token", &token),
1022 ("client_id", CODEX_CLIENT_ID),
1023 ])
1024 .send()
1025 .await
1026 .context("token refresh request")?;
1027
1028 let status = resp.status();
1029 let body_text = resp.text().await.unwrap_or_default();
1030 if !status.is_success() {
1031 anyhow::bail!("token refresh failed (HTTP {status}): {body_text}");
1032 }
1033
1034 #[derive(serde::Deserialize)]
1035 struct R {
1036 access_token: String,
1037 refresh_token: Option<String>,
1038 id_token: Option<String>,
1039 }
1040 let data: R = serde_json::from_str(&body_text).context("parse refresh response")?;
1041
1042 let expires_at = crate::oauth::parse_jwt_exp(&data.access_token)
1043 .unwrap_or_else(|| chrono::Utc::now().timestamp() + 3600);
1044 let account = data
1045 .id_token
1046 .as_deref()
1047 .and_then(crate::oauth::extract_account_from_id_token);
1048
1049 Ok(crate::oauth::TokenResult {
1050 access_token: data.access_token,
1051 refresh_token: data.refresh_token,
1052 expires_at,
1053 account,
1054 })
1055 })
1056 }
1057
1058 fn from_stored(stored: &crate::auth_store::StoredProvider) -> Self {
1059 let account_id = oauth_account_id(&stored.access_token, stored.account.clone());
1060 CodexProvider::new(&stored.id, &stored.access_token, account_id)
1061 }
1062
1063 fn from_managed_stored(
1064 stored: &crate::auth_store::StoredProvider,
1065 hub: crate::config_hub::ConfigHub,
1066 ) -> Option<Self> {
1067 Some(CodexProvider::from_oauth_store(stored, hub))
1068 }
1069}
1070
1071#[derive(Default)]
1072struct PartialToolCall {
1073 id: String,
1074 name: String,
1075 arguments: String,
1076}
1077
1078fn turn_id_from_req(req: &LlmRequest) -> TurnId {
1079 req.messages
1080 .first()
1081 .map(|m| m.turn_id.clone())
1082 .unwrap_or_else(TurnId::now)
1083}
1084
1085fn net_err(e: reqwest::Error) -> RuntimeError {
1086 RuntimeError::ToolFailed(format!("codex net: {e}"))
1087}
1088
1089fn credential_err(error: crate::oauth::OAuthCredentialError) -> RuntimeError {
1090 RuntimeError::ToolFailed(format!("codex credentials: {error}"))
1091}
1092
1093#[derive(Serialize)]
1094struct ResponsesRequest {
1095 model: String,
1096 input: Vec<InputItem>,
1097 #[serde(skip_serializing_if = "Option::is_none")]
1098 instructions: Option<String>,
1099 #[serde(skip_serializing_if = "Vec::is_empty")]
1100 tools: Vec<ResponsesTool>,
1101 stream: bool,
1102 store: bool,
1103 #[serde(skip_serializing_if = "Option::is_none")]
1104 reasoning: Option<ReasoningConfig>,
1105 #[serde(skip_serializing_if = "Option::is_none")]
1106 text: Option<TextConfig>,
1107 #[serde(skip_serializing_if = "Option::is_none")]
1108 include: Option<Vec<String>>,
1109 #[serde(skip_serializing_if = "Option::is_none")]
1110 prompt_cache_key: Option<String>,
1111}
1112
1113#[derive(Serialize)]
1114struct InputItem {
1115 #[serde(skip_serializing_if = "Option::is_none")]
1116 role: Option<String>,
1117 #[serde(skip_serializing_if = "Option::is_none")]
1118 content: Option<InputContent>,
1119 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
1120 item_type: Option<String>,
1121 #[serde(skip_serializing_if = "Option::is_none")]
1122 call_id: Option<String>,
1123 #[serde(skip_serializing_if = "Option::is_none")]
1124 name: Option<String>,
1125 #[serde(skip_serializing_if = "Option::is_none")]
1126 arguments: Option<String>,
1127 #[serde(skip_serializing_if = "Option::is_none")]
1128 output: Option<String>,
1129}
1130
1131#[derive(Serialize)]
1132#[serde(untagged)]
1133enum InputContent {
1134 Text(String),
1135 Parts(Vec<ResponseInputContent>),
1136}
1137
1138#[derive(Serialize)]
1139#[serde(tag = "type", rename_all = "snake_case")]
1140enum ResponseInputContent {
1141 InputText {
1142 text: String,
1143 },
1144 InputImage {
1145 image_url: String,
1146 #[serde(skip_serializing_if = "Option::is_none")]
1147 detail: Option<&'static str>,
1148 },
1149}
1150
1151#[derive(Serialize)]
1152struct ResponsesTool {
1153 #[serde(rename = "type")]
1154 r#type: String,
1155 name: String,
1156 #[serde(skip_serializing_if = "Option::is_none")]
1157 description: Option<String>,
1158 parameters: serde_json::Value,
1159}
1160
1161#[derive(Serialize)]
1162struct ReasoningConfig {
1163 #[serde(skip_serializing_if = "Option::is_none")]
1164 effort: Option<String>,
1165 #[serde(skip_serializing_if = "Option::is_none")]
1166 mode: Option<String>,
1167 #[serde(skip_serializing_if = "Option::is_none")]
1168 summary: Option<String>,
1169}
1170
1171#[derive(Serialize)]
1172struct TextConfig {
1173 verbosity: String,
1174}
1175
1176#[derive(Deserialize, Default)]
1177struct ResponsesUsage {
1178 #[serde(default)]
1179 input_tokens: Option<u64>,
1180 #[serde(default)]
1181 output_tokens: Option<u64>,
1182 #[serde(default)]
1183 input_tokens_details: Option<InputTokensDetails>,
1184 #[serde(default)]
1185 output_tokens_details: Option<OutputTokensDetails>,
1186}
1187
1188#[derive(Deserialize, Default)]
1189struct InputTokensDetails {
1190 #[serde(default)]
1191 cached_tokens: Option<u64>,
1192 #[serde(default)]
1193 cache_write_tokens: Option<u64>,
1194}
1195
1196#[derive(Deserialize, Default)]
1197struct OutputTokensDetails {
1198 #[serde(default)]
1199 reasoning_tokens: Option<u64>,
1200}
1201
1202#[cfg(test)]
1203mod tests {
1204 use super::{
1205 CodexCredentialSource, CodexProvider, X_CODEX_TURN_STATE, oauth_account_id,
1206 parse_codex_models, split_assistant_parts,
1207 };
1208 use crate::message::MessagePart;
1209 use crate::provider::Provider;
1210 use base64::Engine;
1211 use wiremock::matchers::{header, method, path};
1212 use wiremock::{Mock, MockServer, ResponseTemplate};
1213
1214 struct IntentTool;
1215
1216 impl crate::tool::Tool for IntentTool {
1217 fn name(&self) -> &str {
1218 "probe"
1219 }
1220
1221 fn tier(&self) -> crate::tool::Tier {
1222 crate::tool::Tier::Zero
1223 }
1224
1225 fn call<'a>(
1226 &'a self,
1227 _args: crate::tool::ToolArgs,
1228 _ctx: &'a crate::tool::ToolCtx,
1229 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1230 Box::pin(async { Ok(crate::Value::Unit) })
1231 }
1232 }
1233
1234 #[test]
1235 fn tool_call_intent_is_serialized_into_function_arguments() {
1236 let tools = vec![crate::tool::tool_spec(&IntentTool)];
1237 let (_, calls) = split_assistant_parts(
1238 &[MessagePart::ToolUse {
1239 id: "call-1".into(),
1240 name: "probe".into(),
1241 input: serde_json::json!({"value": 1}),
1242 intent: crate::message::ToolCallIntent::new("Inspect provider state"),
1243 }],
1244 &tools,
1245 );
1246 let arguments: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1247 assert_eq!(arguments["value"], 1);
1248 assert_eq!(arguments["_atman_intent"], "Inspect provider state");
1249 }
1250
1251 #[test]
1252 fn context_prefix_uses_responses_projection_and_preserves_appended_messages() {
1253 let (_dir, _hub, provider, _) = managed_provider(
1254 "http://localhost/responses".into(),
1255 "http://localhost/models".into(),
1256 );
1257 let mut request = request();
1258 request.cache_prompt = true;
1259 request.system = Some("stable".into());
1260 request.messages.push(crate::message::Message::user_text(
1261 crate::event::TurnId::now(),
1262 "first",
1263 ));
1264 let first = provider.context_prefix(&request).unwrap();
1265 let first_bytes = first.initial_observation().wire_prefix_bytes;
1266 request
1267 .messages
1268 .push(crate::message::Message::assistant_text(
1269 crate::event::TurnId::now(),
1270 "second",
1271 ));
1272 let second = provider.context_prefix(&request).unwrap();
1273 let observation = second.compare("codex", "codex", "model", "model", &first);
1274
1275 assert_eq!(
1276 observation.profile,
1277 crate::context_plan::ContextPrefixProfile::CodexResponses
1278 );
1279 assert_eq!(observation.reset_reason, None);
1280 assert_eq!(observation.common_prefix_bytes, first_bytes);
1281 }
1282
1283 #[test]
1284 fn responses_request_serializes_prompt_cache_key() {
1285 let provider = CodexProvider::new("codex", "token", "account");
1286 let mut request = request();
1287 request.cache_prompt = true;
1288 request.prompt_cache_key = Some("atman-route".into());
1289
1290 let body = serde_json::to_value(provider.build_body(&request).unwrap()).unwrap();
1291 assert_eq!(body["prompt_cache_key"], "atman-route");
1292 assert!(provider.capabilities().prompt_cache_key);
1293 }
1294
1295 #[test]
1296 fn responses_request_omits_unrepresentable_empty_messages() {
1297 let provider = CodexProvider::new("codex", "token", "account");
1298 let mut request = request();
1299 for role in [
1300 crate::message::MessageRole::User,
1301 crate::message::MessageRole::Assistant,
1302 crate::message::MessageRole::System,
1303 ] {
1304 request.messages.push(crate::message::Message {
1305 role,
1306 parts: vec![crate::message::MessagePart::Thinking {
1307 thinking: "provider-specific reasoning".into(),
1308 signature: None,
1309 }],
1310 turn_id: crate::event::TurnId::now(),
1311 origin: crate::message::MessageOrigin::User,
1312 });
1313 }
1314 request.messages.push(crate::message::Message::user_text(
1315 crate::event::TurnId::now(),
1316 "",
1317 ));
1318
1319 let body = serde_json::to_value(provider.build_body(&request).unwrap()).unwrap();
1320 assert_eq!(body["input"], serde_json::json!([]));
1321 }
1322
1323 fn request() -> crate::provider::LlmRequest {
1324 crate::provider::LlmRequest {
1325 model: "codex/gpt-test".into(),
1326 messages: Vec::new(),
1327 system: None,
1328 input: crate::Value::Unit,
1329 schema: None,
1330 cache_prompt: false,
1331 prompt_cache_key: None,
1332 tools: Vec::new(),
1333 reasoning: crate::provider::ReasoningSelection::ProviderDefault,
1334 stall_timeout_secs: 0,
1335 }
1336 }
1337
1338 fn managed_provider(
1339 responses_url: String,
1340 models_url: String,
1341 ) -> (
1342 tempfile::TempDir,
1343 crate::config_hub::ConfigHub,
1344 CodexProvider,
1345 String,
1346 ) {
1347 let dir = tempfile::tempdir().unwrap();
1348 let hub = crate::config_hub::ConfigHub::from_config_dir(dir.path());
1349 hub.add_auth_provider(crate::auth_store::StoredProvider {
1350 id: "oauth-account".into(),
1351 name: "OAuth account".into(),
1352 kind: crate::auth_store::ProviderKind::Codex,
1353 access_token: "access-v1".into(),
1354 refresh_token: Some("refresh-v1".into()),
1355 expires_at: chrono::Utc::now().timestamp() - 1,
1356 account: Some("display@example.test".into()),
1357 enabled: true,
1358 model_cache: None,
1359 })
1360 .unwrap();
1361 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
1362 .encode(r#"{"https://api.openai.com/auth":{"chatgpt_account_id":"account-v2"}}"#);
1363 let access_token = format!("header.{payload}.signature");
1364 let refreshed_access_token = access_token.clone();
1365 let lease = crate::oauth::OAuthCredentialLease::with_refresher(
1366 "oauth-account",
1367 crate::auth_store::ProviderKind::Codex,
1368 hub.clone(),
1369 move |refresh_token| {
1370 assert_eq!(refresh_token, "refresh-v1");
1371 let access_token = refreshed_access_token.clone();
1372 Box::pin(async move {
1373 Ok(crate::oauth::TokenResult {
1374 access_token,
1375 refresh_token: Some("refresh-v2".into()),
1376 expires_at: chrono::Utc::now().timestamp() + 3_600,
1377 account: Some("display-v2@example.test".into()),
1378 })
1379 })
1380 },
1381 );
1382 let provider = CodexProvider {
1383 name: "oauth-account".into(),
1384 credentials: CodexCredentialSource::Managed(lease),
1385 client: reqwest::Client::new(),
1386 responses_url: String::new(),
1387 models_url: String::new(),
1388 turn_states: Default::default(),
1389 }
1390 .with_endpoints(responses_url, models_url);
1391 (dir, hub, provider, access_token)
1392 }
1393
1394 async fn mount_models_endpoint(server: &MockServer, access_token: &str) {
1395 Mock::given(method("GET"))
1396 .and(path("/models"))
1397 .and(header("authorization", format!("Bearer {access_token}")))
1398 .and(header("chatgpt-account-id", "account-v2"))
1399 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1400 "models": [{
1401 "slug": "gpt-test",
1402 "supported_reasoning_levels": ["low", "high"]
1403 }]
1404 })))
1405 .expect(1)
1406 .mount(server)
1407 .await;
1408 }
1409
1410 #[test]
1411 fn input_tokens_exclude_cached_tokens_for_window_accounting() {
1412 assert_eq!(
1413 crate::provider::regular_input_tokens(100_000, 60_000, 10_000),
1414 30_000
1415 );
1416 }
1417
1418 #[test]
1419 fn display_email_is_not_used_as_chatgpt_account_id() {
1420 assert_eq!(
1421 oauth_account_id("not-a-jwt", Some("display@example.test".into())),
1422 ""
1423 );
1424 assert_eq!(
1425 oauth_account_id("not-a-jwt", Some("legacy-account-id".into())),
1426 "legacy-account-id"
1427 );
1428 }
1429
1430 #[test]
1431 fn cached_tokens_cannot_underflow_input_tokens() {
1432 assert_eq!(crate::provider::regular_input_tokens(10, 20, 5), 0);
1433 }
1434
1435 #[test]
1436 fn model_catalog_parses_object_reasoning_levels() {
1437 let models = parse_codex_models(
1438 br#"{
1439 "models": [{
1440 "slug": "gpt-test",
1441 "context_window": 272000,
1442 "supported_reasoning_levels": [
1443 {"effort":"low","description":"Fast"},
1444 {"effort":"medium","description":"Balanced"},
1445 {"effort":"high","description":"Deep"},
1446 {"effort":"xhigh","description":"Deeper"},
1447 {"effort":"max","description":"Maximum"},
1448 {"effort":"ultra","description":"Extended"}
1449 ],
1450 "default_reasoning_level": "medium",
1451 "input_modalities": ["text", "image"]
1452 }]
1453 }"#,
1454 )
1455 .unwrap();
1456
1457 assert_eq!(models.len(), 1);
1458 assert_eq!(models[0].slug, "codex/gpt-test");
1459 assert_eq!(models[0].context_budget, Some(272_000));
1460 assert!(models[0].capability_knowledge.thinking());
1461 let capabilities = models[0].capability_knowledge.advertised().unwrap();
1462 assert_eq!(
1463 capabilities.reasoning_efforts,
1464 vec![
1465 crate::provider::ReasoningEffort::Low,
1466 crate::provider::ReasoningEffort::Medium,
1467 crate::provider::ReasoningEffort::High,
1468 crate::provider::ReasoningEffort::XHigh,
1469 crate::provider::ReasoningEffort::Max,
1470 crate::provider::ReasoningEffort::Ultra,
1471 ]
1472 );
1473 assert_eq!(
1474 capabilities.default_reasoning_effort,
1475 Some(crate::provider::ReasoningEffort::Medium)
1476 );
1477 assert_eq!(
1478 capabilities.input_modalities,
1479 vec![
1480 crate::provider::InputModality::Text,
1481 crate::provider::InputModality::Image,
1482 ]
1483 );
1484 }
1485
1486 #[test]
1487 fn model_catalog_accepts_legacy_string_reasoning_levels() {
1488 let models = parse_codex_models(
1489 br#"{
1490 "models": [{
1491 "slug": "codex/legacy-test",
1492 "supported_reasoning_levels": ["low", "high"]
1493 }]
1494 }"#,
1495 )
1496 .unwrap();
1497
1498 assert_eq!(models[0].slug, "codex/legacy-test");
1499 assert_eq!(
1500 models[0]
1501 .capability_knowledge
1502 .advertised()
1503 .unwrap()
1504 .reasoning_efforts,
1505 vec![
1506 crate::provider::ReasoningEffort::Low,
1507 crate::provider::ReasoningEffort::High,
1508 ]
1509 );
1510 }
1511
1512 #[test]
1513 fn malformed_model_catalog_is_not_treated_as_an_empty_catalog() {
1514 let error = parse_codex_models(br#"{"unexpected":[]}"#).unwrap_err();
1515
1516 assert!(matches!(
1517 error,
1518 crate::provider::ModelDiscoveryError::InvalidResponse(_)
1519 ));
1520 }
1521
1522 #[test]
1523 fn reasoning_effort_and_mode_are_not_hardcoded() {
1524 let provider = CodexProvider::new("codex", "token", "account");
1525 let request = crate::provider::LlmRequest {
1526 model: "codex/gpt-test".into(),
1527 messages: Vec::new(),
1528 system: None,
1529 input: crate::Value::Unit,
1530 schema: None,
1531 cache_prompt: false,
1532 prompt_cache_key: None,
1533 tools: Vec::new(),
1534 reasoning: crate::provider::ReasoningSelection::Effort {
1535 effort: crate::provider::ReasoningEffort::XHigh,
1536 execution_mode: Some(crate::provider::ReasoningExecutionMode::Pro),
1537 },
1538 stall_timeout_secs: 0,
1539 };
1540 let body = serde_json::to_value(provider.build_body(&request).unwrap()).unwrap();
1541 assert_eq!(body["reasoning"]["effort"], "xhigh");
1542 assert_eq!(body["reasoning"]["mode"], "pro");
1543 assert_eq!(body["reasoning"]["summary"], "auto");
1544 }
1545
1546 #[test]
1547 fn provider_default_omits_reasoning_instead_of_forcing_medium() {
1548 let provider = CodexProvider::new("codex", "token", "account");
1549 let request = request();
1550 let body = serde_json::to_value(provider.build_body(&request).unwrap()).unwrap();
1551 assert!(body.get("reasoning").is_none());
1552 }
1553
1554 #[test]
1555 fn mixed_user_content_is_a_typed_array_not_a_json_string() {
1556 use base64::Engine;
1557
1558 let provider = CodexProvider::new("codex", "token", "account");
1559 let image = base64::engine::general_purpose::STANDARD.encode(b"\x89PNG\r\n\x1a\n");
1560 let request = crate::provider::LlmRequest {
1561 model: "codex/gpt-test".into(),
1562 messages: vec![crate::message::Message {
1563 role: crate::message::MessageRole::User,
1564 parts: vec![
1565 crate::message::MessagePart::Image {
1566 source: crate::message::ImageSource {
1567 media_type: "image/png".into(),
1568 data: crate::message::ImageData::Base64 { data: image },
1569 detail: crate::provider::ImageDetail::High,
1570 },
1571 },
1572 crate::message::MessagePart::Text {
1573 text: "describe".into(),
1574 },
1575 ],
1576 turn_id: crate::event::TurnId::now(),
1577 origin: crate::message::MessageOrigin::User,
1578 }],
1579 system: None,
1580 input: crate::Value::Unit,
1581 schema: None,
1582 cache_prompt: false,
1583 prompt_cache_key: None,
1584 tools: Vec::new(),
1585 reasoning: crate::provider::ReasoningSelection::ProviderDefault,
1586 stall_timeout_secs: 0,
1587 };
1588
1589 let body = serde_json::to_value(provider.build_body(&request).unwrap()).unwrap();
1590 assert!(body["input"][0]["content"].is_array());
1591 assert_eq!(body["input"][0]["content"][0]["type"], "input_image");
1592 assert_eq!(body["input"][0]["content"][0]["detail"], "high");
1593 assert_eq!(body["input"][0]["content"][1]["type"], "input_text");
1594 }
1595
1596 #[test]
1597 fn compact_summary_is_preserved_as_input_context() {
1598 let provider = CodexProvider::new("codex", "token", "account");
1599 let request = crate::provider::LlmRequest {
1600 model: "codex/gpt-test".into(),
1601 messages: vec![crate::message::Message::system_compact_summary(
1602 crate::event::TurnId::now(),
1603 "retained summary",
1604 1,
1605 9,
1606 9,
1607 )],
1608 system: Some("stable instructions".into()),
1609 input: crate::Value::Unit,
1610 schema: None,
1611 cache_prompt: true,
1612 prompt_cache_key: None,
1613 tools: Vec::new(),
1614 reasoning: crate::provider::ReasoningSelection::ProviderDefault,
1615 stall_timeout_secs: 0,
1616 };
1617
1618 let body = serde_json::to_value(provider.build_body(&request).unwrap()).unwrap();
1619 assert_eq!(body["instructions"], "stable instructions");
1620 assert_eq!(body["input"][0]["role"], "user");
1621 assert_eq!(body["input"][0]["content"], "retained summary");
1622 }
1623
1624 #[test]
1625 fn internal_context_record_projects_as_developer_input() {
1626 let provider = CodexProvider::new("codex", "token", "account");
1627 let mut request = crate::provider::LlmRequest {
1628 model: "codex/gpt-test".into(),
1629 messages: vec![crate::message::Message::user_text(
1630 crate::event::TurnId::now(),
1631 "before",
1632 )],
1633 system: Some("stable instructions".into()),
1634 input: crate::Value::Unit,
1635 schema: None,
1636 cache_prompt: true,
1637 prompt_cache_key: None,
1638 tools: Vec::new(),
1639 reasoning: crate::provider::ReasoningSelection::ProviderDefault,
1640 stall_timeout_secs: 0,
1641 };
1642 let before = provider.context_prefix(&request).unwrap();
1643 let before_bytes = before.initial_observation().wire_prefix_bytes;
1644 request
1645 .messages
1646 .push(crate::message::Message::context_record(
1647 crate::event::TurnId::now(),
1648 crate::context_plan::ContextRecord::new(
1649 "session.goal",
1650 1,
1651 crate::context_plan::ContextRecordAuthority::User,
1652 crate::context_plan::ContextRecordRetention::Latest,
1653 crate::context_plan::ContextRecordBody::text("finish the task"),
1654 ),
1655 ));
1656
1657 let body = serde_json::to_value(provider.build_body(&request).unwrap()).unwrap();
1658 assert_eq!(body["input"][1]["role"], "developer");
1659 assert!(
1660 body["input"][1]["content"]
1661 .as_str()
1662 .is_some_and(|content| content.contains("finish the task"))
1663 );
1664 let after = provider.context_prefix(&request).unwrap();
1665 let observation = after.compare("codex", "codex", "model", "model", &before);
1666 assert_eq!(observation.reset_reason, None);
1667 assert_eq!(observation.common_prefix_bytes, before_bytes);
1668 }
1669
1670 #[test]
1671 fn compact_resume_keeps_summary_tail_tool_pair_and_definitions() {
1672 let provider = CodexProvider::new("codex", "token", "account");
1673 let turn = crate::event::TurnId::now();
1674 let request = crate::provider::LlmRequest {
1675 model: "codex/gpt-test".into(),
1676 messages: vec![
1677 crate::message::Message::system_compact_summary(
1678 turn.clone(),
1679 "retained summary",
1680 1,
1681 9,
1682 9,
1683 ),
1684 crate::message::Message::user_text(turn.clone(), "current request"),
1685 crate::message::Message {
1686 role: crate::message::MessageRole::Assistant,
1687 parts: vec![crate::message::MessagePart::ToolUse {
1688 id: "call_resume".into(),
1689 name: "fs.read".into(),
1690 input: serde_json::json!({"path": "README.md"}),
1691 intent: None,
1692 }],
1693 turn_id: turn.clone(),
1694 origin: crate::message::MessageOrigin::User,
1695 },
1696 crate::message::Message {
1697 role: crate::message::MessageRole::Tool,
1698 parts: vec![crate::message::MessagePart::ToolResult {
1699 tool_use_id: "call_resume".into(),
1700 content: "contents".into(),
1701 is_error: false,
1702 }],
1703 turn_id: turn,
1704 origin: crate::message::MessageOrigin::User,
1705 },
1706 ],
1707 system: Some("stable instructions".into()),
1708 input: crate::Value::Unit,
1709 schema: None,
1710 cache_prompt: true,
1711 prompt_cache_key: None,
1712 tools: vec![crate::tool::ToolSpec {
1713 name: "fs.read".into(),
1714 description: Some("read a file".into()),
1715 input_schema: serde_json::json!({"type": "object"}),
1716 }],
1717 reasoning: crate::provider::ReasoningSelection::ProviderDefault,
1718 stall_timeout_secs: 0,
1719 };
1720
1721 let body = serde_json::to_value(provider.build_body(&request).unwrap()).unwrap();
1722 assert_eq!(body["instructions"], "stable instructions");
1723 assert_eq!(body["input"][0]["content"], "retained summary");
1724 assert_eq!(body["input"][1]["content"], "current request");
1725 assert_eq!(body["input"][2]["type"], "function_call");
1726 assert_eq!(body["input"][2]["call_id"], "call_resume");
1727 assert_eq!(body["input"][3]["type"], "function_call_output");
1728 assert_eq!(body["input"][3]["call_id"], "call_resume");
1729 assert_eq!(body["input"][3]["output"], "contents");
1730 assert_eq!(body["tools"][0]["name"], "fs_read");
1731 }
1732
1733 #[tokio::test]
1734 async fn streaming_call_acquires_credentials_before_sending_request() {
1735 let server = MockServer::start().await;
1736 let responses_url = format!("{}/responses", server.uri());
1737 let models_url = format!("{}/models", server.uri());
1738 let (_dir, _hub, provider, access_token) = managed_provider(responses_url, models_url);
1739 Mock::given(method("POST"))
1740 .and(path("/responses"))
1741 .and(header(
1742 "authorization",
1743 format!("Bearer {access_token}"),
1744 ))
1745 .and(header("chatgpt-account-id", "account-v2"))
1746 .respond_with(
1747 ResponseTemplate::new(200)
1748 .insert_header("content-type", "text/event-stream")
1749 .set_body_string(
1750 "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"response-1\",\"model\":\"gpt-test\",\"status\":\"completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n",
1751 ),
1752 )
1753 .expect(1)
1754 .mount(&server)
1755 .await;
1756
1757 let observable = provider.call_streaming(request());
1758 let message = observable.output.await.unwrap();
1759 assert_eq!(message.response_id.as_deref(), Some("response-1"));
1760 }
1761
1762 #[tokio::test]
1763 async fn streaming_call_reuses_codex_routing_state_within_a_turn() {
1764 let server = MockServer::start().await;
1765 let provider = CodexProvider::new("codex", "token", "account").with_endpoints(
1766 format!("{}/responses", server.uri()),
1767 format!("{}/models", server.uri()),
1768 );
1769 let mut request = request();
1770 request.cache_prompt = true;
1771 request.prompt_cache_key = Some("stable-route".into());
1772 request.messages.push(crate::message::Message::user_text(
1773 crate::event::TurnId::now(),
1774 "hello",
1775 ));
1776 let response = || {
1777 ResponseTemplate::new(200)
1778 .insert_header("content-type", "text/event-stream")
1779 .insert_header(X_CODEX_TURN_STATE, "sticky-turn")
1780 .set_body_string(
1781 "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"response-1\",\"model\":\"gpt-test\",\"status\":\"completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n",
1782 )
1783 };
1784 Mock::given(method("POST"))
1785 .and(path("/responses"))
1786 .and(header("session-id", "stable-route"))
1787 .and(header("thread-id", "stable-route"))
1788 .and(header("x-client-request-id", "stable-route"))
1789 .respond_with(response())
1790 .expect(1)
1791 .mount(&server)
1792 .await;
1793
1794 provider
1795 .call_streaming(request.clone())
1796 .output
1797 .await
1798 .unwrap();
1799 server.reset().await;
1800
1801 Mock::given(method("POST"))
1802 .and(path("/responses"))
1803 .and(header("session-id", "stable-route"))
1804 .and(header("thread-id", "stable-route"))
1805 .and(header("x-client-request-id", "stable-route"))
1806 .and(header(X_CODEX_TURN_STATE, "sticky-turn"))
1807 .respond_with(response())
1808 .expect(1)
1809 .mount(&server)
1810 .await;
1811
1812 provider
1813 .call_streaming(request.clone())
1814 .output
1815 .await
1816 .unwrap();
1817 server.reset().await;
1818
1819 request.messages.push(crate::message::Message::user_text(
1820 crate::event::TurnId::now(),
1821 "next turn",
1822 ));
1823 Mock::given(method("POST"))
1824 .and(path("/responses"))
1825 .respond_with(response())
1826 .expect(1)
1827 .mount(&server)
1828 .await;
1829
1830 provider.call_streaming(request).output.await.unwrap();
1831 let requests = server.received_requests().await.unwrap();
1832 assert_eq!(requests.len(), 1);
1833 assert!(requests[0].headers.get(X_CODEX_TURN_STATE).is_none());
1834 }
1835
1836 #[tokio::test]
1837 async fn model_discovery_acquires_credentials_at_poll_time() {
1838 let server = MockServer::start().await;
1839 let responses_url = format!("{}/responses", server.uri());
1840 let models_url = format!("{}/models", server.uri());
1841 let (_dir, _hub, provider, access_token) = managed_provider(responses_url, models_url);
1842 mount_models_endpoint(&server, &access_token).await;
1843
1844 let discovery = provider.try_discover_models();
1845 let models = discovery.await.unwrap();
1846 assert_eq!(models[0].slug, "codex/gpt-test");
1847 }
1848
1849 #[tokio::test]
1850 async fn connection_test_acquires_credentials_at_request_time() {
1851 let server = MockServer::start().await;
1852 let responses_url = format!("{}/responses", server.uri());
1853 let models_url = format!("{}/models", server.uri());
1854 let (_dir, _hub, provider, access_token) = managed_provider(responses_url, models_url);
1855 mount_models_endpoint(&server, &access_token).await;
1856
1857 assert_eq!(
1858 provider.test_connection().await.unwrap(),
1859 "\"oauth-account\" responded OK"
1860 );
1861 }
1862
1863 #[tokio::test]
1864 async fn connection_test_handles_multibyte_error_body() {
1865 let server = MockServer::start().await;
1866 let responses_url = format!("{}/responses", server.uri());
1867 let models_url = format!("{}/models", server.uri());
1868 let (_dir, _hub, provider, access_token) = managed_provider(responses_url, models_url);
1869 Mock::given(method("GET"))
1870 .and(path("/models"))
1871 .and(header("authorization", format!("Bearer {access_token}")))
1872 .and(header("chatgpt-account-id", "account-v2"))
1873 .respond_with(ResponseTemplate::new(400).set_body_string("界".repeat(100)))
1874 .expect(1)
1875 .mount(&server)
1876 .await;
1877
1878 let error = provider.test_connection().await.unwrap_err();
1879 assert!(error.contains("400"));
1880 assert!(error.ends_with(&"界".repeat(66)));
1881 }
1882
1883 #[tokio::test]
1884 async fn observable_reads_authoritative_credentials_when_polled() {
1885 let server = MockServer::start().await;
1886 let responses_url = format!("{}/responses", server.uri());
1887 let models_url = format!("{}/models", server.uri());
1888 let (_dir, hub, provider, _refreshed_token) = managed_provider(responses_url, models_url);
1889 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
1890 .encode(r#"{"https://api.openai.com/auth":{"chatgpt_account_id":"account-current"}}"#);
1891 let current_token = format!("header.{payload}.signature");
1892 Mock::given(method("POST"))
1893 .and(path("/responses"))
1894 .and(header(
1895 "authorization",
1896 format!("Bearer {current_token}"),
1897 ))
1898 .and(header("chatgpt-account-id", "account-current"))
1899 .respond_with(
1900 ResponseTemplate::new(200)
1901 .insert_header("content-type", "text/event-stream")
1902 .set_body_string(
1903 "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"response-current\",\"model\":\"gpt-test\",\"status\":\"completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n",
1904 ),
1905 )
1906 .expect(1)
1907 .mount(&server)
1908 .await;
1909
1910 let observable = provider.call_streaming(request());
1911 assert!(
1912 hub.update_auth_tokens(
1913 "oauth-account",
1914 crate::config_hub::AuthTokenUpdate {
1915 access_token: current_token,
1916 refresh_token: Some("refresh-current".into()),
1917 expires_at: chrono::Utc::now().timestamp() + 3_600,
1918 account: Some("display-current@example.test".into()),
1919 },
1920 )
1921 .unwrap()
1922 );
1923
1924 let message = observable.output.await.unwrap();
1925 assert_eq!(message.response_id.as_deref(), Some("response-current"));
1926 }
1927}