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.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            // Responses function names reject '.', so use the provider-safe mapping.
369            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        // The Codex backend always uses streaming (Responses API with store=false).
518        // We call streaming internally and collect the result.
519        let observable = self.call_streaming(req);
520        Box::pin(async move {
521            // Drop the broadcast events — callers of `call()` don't consume them.
522            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                        // Responses API reports input_tokens as the total input,
783                        // including cache reads and writes. TokenUsage stores
784                        // the three prompt lanes independently.
785                        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}