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