Skip to main content

atman_runtime/providers/
codex.rs

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
69/// ChatGPT backend provider. Requires `originator: codex_cli_rs` header for Cloudflare.
70pub 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            // Responses function names reject '.', so use the provider-safe mapping.
383            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        // The Codex backend always uses streaming (Responses API with store=false).
532        // We call streaming internally and collect the result.
533        let observable = self.call_streaming(req);
534        Box::pin(async move {
535            // Drop the broadcast events — callers of `call()` don't consume them.
536            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                        // Responses API reports input_tokens as the total input,
807                        // including cache reads and writes. TokenUsage stores
808                        // the three prompt lanes independently.
809                        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}