Skip to main content

claude_codex/providers/codex/
mod.rs

1pub mod auth;
2pub mod client;
3pub mod continuation;
4pub mod count_tokens;
5pub(crate) mod events;
6pub mod request_summary;
7pub mod translate;
8pub mod websocket;
9
10use async_trait::async_trait;
11use axum::Json;
12use axum::body::Body;
13use axum::response::{IntoResponse, Response};
14use bytes::Bytes;
15use http::StatusCode;
16use std::sync::Arc;
17
18use crate::anthropic::error::json_error;
19use crate::anthropic::schema::{CountTokensResponse, MessagesRequest};
20use crate::config;
21use crate::monitor::usage_from_anthropic_sse;
22use crate::provider::{CliHandlers, Provider, RequestContext};
23use crate::registry;
24use crate::retry::{compute_backoff_delay, sleep};
25
26use self::auth::token_store::file_store;
27use self::client::CodexHttpClient;
28use self::continuation::{
29    ContinuationCandidate, clear_continuation, continuation_candidate, record_continuation,
30};
31use self::count_tokens::count_translated_tokens;
32use self::translate::accumulate::accumulate_response_with_traffic;
33use self::translate::live_stream::LiveStreamTranslator;
34use self::translate::model_allowlist::{
35    assert_allowed_model, full_lane_web_search_model, resolve_model_request, uses_responses_lite,
36};
37use self::translate::reducer::finish_metadata_from_upstream;
38use self::translate::request::{TranslateOptions, has_hosted_web_search, translate_request};
39
40const MAX_RETRYABLE_LIVE_STREAM_RETRIES: u32 = 10;
41use self::translate::stream::translate_stream_bytes_with_traffic;
42
43// ---------------------------------------------------------------------------
44// Provider
45// ---------------------------------------------------------------------------
46
47pub struct CodexProvider {
48    client: Arc<CodexHttpClient>,
49}
50
51impl Default for CodexProvider {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl CodexProvider {
58    pub fn new() -> Self {
59        Self {
60            client: Arc::new(CodexHttpClient::new()),
61        }
62    }
63}
64
65#[async_trait]
66impl Provider for CodexProvider {
67    fn name(&self) -> &'static str {
68        "codex"
69    }
70
71    fn supported_models(&self) -> Vec<String> {
72        let mut models: Vec<String> = registry::CODEX_MODELS
73            .iter()
74            .map(|m| m.to_string())
75            .collect();
76        for m in registry::CODEX_MODELS {
77            models.push(format!("{m}-fast"));
78        }
79        models.sort_unstable();
80        models.dedup();
81        models
82    }
83
84    fn cli(&self) -> &'static dyn CliHandlers {
85        &CODEX_CLI
86    }
87
88    async fn handle_messages(&self, body: MessagesRequest, ctx: RequestContext) -> Response {
89        let message_id = format!("msg_{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
90        let want_stream = body.stream;
91        let model = body.model.as_deref().unwrap_or("gpt-5.6-sol");
92
93        let mut resolved = resolve_model_request(model);
94        if let Err(e) = assert_allowed_model(&resolved.model) {
95            return json_error(
96                StatusCode::BAD_REQUEST,
97                "invalid_request_error",
98                format!(
99                    "Model \"{model}\" resolves to unsupported model \"{}\"",
100                    e.model
101                ),
102            );
103        }
104        let use_responses_lite = apply_model_lane_for_request(&mut resolved.model, &body);
105        if let Some(monitor) = ctx.monitor.as_ref() {
106            monitor.model_resolved(&ctx.req_id, &resolved.model);
107        }
108
109        let translated = match translate_request(
110            &body,
111            TranslateOptions {
112                session_id: ctx.session_id.clone(),
113                service_tier: resolved.service_tier.clone(),
114                model: resolved.model.clone(),
115                use_responses_lite,
116            },
117        ) {
118            Ok(t) => t,
119            Err(e) => {
120                return json_error(
121                    StatusCode::BAD_REQUEST,
122                    "invalid_request_error",
123                    e.to_string(),
124                );
125            }
126        };
127
128        // Check continuation
129        let previous_response_id_enabled = config::codex_previous_response_id();
130        let continuation = continuation_candidate(
131            ctx.session_id.as_deref(),
132            &translated,
133            previous_response_id_enabled,
134        );
135
136        // Post to upstream with continuation
137        let client = self.client.clone();
138        if let Some(monitor) = ctx.monitor.as_ref() {
139            monitor.upstream_started(&ctx.req_id);
140        }
141        if want_stream && matches!(config::codex_transport(), config::CodexTransport::WebSocket) {
142            let stream_request = translated.clone();
143            return live_stream_response(
144                client,
145                message_id,
146                model,
147                ctx,
148                stream_request,
149                continuation,
150            )
151            .await;
152        }
153
154        let upstream = match client
155            .post_codex(&translated, &ctx, Some(&continuation))
156            .await
157        {
158            Ok(r) => r,
159            Err(e) => {
160                clear_continuation(ctx.session_id.as_deref());
161                return map_codex_error_to_response(&e);
162            }
163        };
164
165        if want_stream {
166            let sse_bytes = match translate_stream_bytes_with_traffic(
167                &upstream.body,
168                &message_id,
169                model,
170                ctx.traffic.as_deref(),
171            ) {
172                Ok(b) => b,
173                Err(e) => {
174                    clear_continuation(ctx.session_id.as_deref());
175                    return map_codex_failure_to_response(&format!(
176                        "Stream translation error: {e}"
177                    ));
178                }
179            };
180            if let Some(monitor) = ctx.monitor.as_ref() {
181                let (input_tokens, output_tokens) = usage_from_anthropic_sse(&sse_bytes);
182                monitor.stream_progress(
183                    &ctx.req_id,
184                    sse_bytes.len() as u64,
185                    count_sse_events(&sse_bytes),
186                    input_tokens,
187                    output_tokens,
188                );
189            }
190            update_continuation_from_upstream(
191                ctx.session_id.as_deref(),
192                &translated,
193                &upstream.body,
194            );
195
196            let headers = [
197                (http::header::CONTENT_TYPE, "text/event-stream"),
198                (http::header::CACHE_CONTROL, "no-cache"),
199                (http::header::CONNECTION, "keep-alive"),
200            ];
201            (headers, sse_bytes).into_response()
202        } else {
203            match accumulate_response_with_traffic(
204                &upstream.body,
205                &message_id,
206                model,
207                ctx.traffic.as_deref(),
208            ) {
209                Ok(json) => {
210                    if let Some(monitor) = ctx.monitor.as_ref() {
211                        monitor.usage_updated(
212                            &ctx.req_id,
213                            json.pointer("/usage/input_tokens").and_then(|v| v.as_u64()),
214                            json.pointer("/usage/output_tokens")
215                                .and_then(|v| v.as_u64()),
216                        );
217                    }
218                    update_continuation_from_upstream(
219                        ctx.session_id.as_deref(),
220                        &translated,
221                        &upstream.body,
222                    );
223                    (StatusCode::OK, Json(json)).into_response()
224                }
225                Err(e) => {
226                    clear_continuation(ctx.session_id.as_deref());
227                    map_codex_failure_to_response(&format!("Accumulation error: {e}"))
228                }
229            }
230        }
231    }
232
233    async fn handle_count_tokens(&self, body: MessagesRequest, ctx: RequestContext) -> Response {
234        let model = body.model.as_deref().unwrap_or("gpt-5.6-sol");
235        let mut resolved = resolve_model_request(model);
236        if let Err(e) = assert_allowed_model(&resolved.model) {
237            return json_error(
238                StatusCode::BAD_REQUEST,
239                "invalid_request_error",
240                format!(
241                    "Model \"{model}\" resolves to unsupported model \"{}\"",
242                    e.model
243                ),
244            );
245        }
246        let use_responses_lite = apply_model_lane_for_request(&mut resolved.model, &body);
247        if let Some(monitor) = ctx.monitor.as_ref() {
248            monitor.model_resolved(&ctx.req_id, &resolved.model);
249        }
250
251        let translated = match translate_request(
252            &body,
253            TranslateOptions {
254                session_id: None,
255                service_tier: resolved.service_tier.clone(),
256                model: resolved.model.clone(),
257                use_responses_lite,
258            },
259        ) {
260            Ok(t) => t,
261            Err(e) => {
262                return json_error(
263                    StatusCode::BAD_REQUEST,
264                    "invalid_request_error",
265                    e.to_string(),
266                );
267            }
268        };
269
270        let tokens = count_translated_tokens(&translated);
271        if let Some(monitor) = ctx.monitor.as_ref() {
272            monitor.usage_updated(&ctx.req_id, Some(tokens), None);
273        }
274        (
275            StatusCode::OK,
276            Json(CountTokensResponse {
277                input_tokens: tokens,
278            }),
279        )
280            .into_response()
281    }
282}
283
284/// Picks the upstream model and lane for a request. Hosted web_search must
285/// run on the full Responses API (the lite lane rejects hosted tools), and
286/// lite-only models like gpt-5.6-luna don't exist there, so such requests
287/// are upgraded to a full-lane model. Returns whether to use the lite lane.
288fn apply_model_lane_for_request(model: &mut String, body: &MessagesRequest) -> bool {
289    if has_hosted_web_search(body) {
290        *model = full_lane_web_search_model(model).to_string();
291        return false;
292    }
293    uses_responses_lite(model)
294}
295
296fn count_sse_events(bytes: &[u8]) -> u64 {
297    String::from_utf8_lossy(bytes).matches("event:").count() as u64
298}
299
300enum LiveStreamStart {
301    Response(Response),
302    Retry {
303        error: client::CodexError,
304        full_context: bool,
305    },
306}
307
308async fn live_stream_response(
309    client: Arc<CodexHttpClient>,
310    message_id: String,
311    model: &str,
312    ctx: RequestContext,
313    request_body: translate::request::ResponsesRequest,
314    continuation: ContinuationCandidate,
315) -> Response {
316    let model = model.to_string();
317    let mut attempt = 0_u32;
318    let mut continuation = Some(continuation);
319
320    loop {
321        let upstream_events = match client
322            .stream_codex_websocket_events(&request_body, &ctx, continuation.as_ref())
323            .await
324        {
325            Ok(events) => events,
326            Err(err) if retryable_live_start_codex_error(&err) => {
327                if retry_with_full_context_for_live_error(&err)
328                    && drop_live_continuation_for_retry(&mut continuation, &ctx)
329                {
330                    attempt += 1;
331                    continue;
332                }
333                if attempt >= MAX_RETRYABLE_LIVE_STREAM_RETRIES {
334                    clear_continuation(ctx.session_id.as_deref());
335                    return map_codex_error_to_response(&err);
336                }
337                let delay = compute_backoff_delay(attempt, err.retry_after.as_deref());
338                if delay.exceeds_budget {
339                    clear_continuation(ctx.session_id.as_deref());
340                    return map_codex_error_to_response(&err);
341                }
342                attempt += 1;
343                sleep(delay.wait_ms).await;
344                continue;
345            }
346            Err(err) => {
347                clear_continuation(ctx.session_id.as_deref());
348                return map_codex_error_to_response(&err);
349            }
350        };
351
352        match live_stream_response_once(
353            upstream_events,
354            message_id.clone(),
355            &model,
356            ctx.clone(),
357            request_body.clone(),
358        )
359        .await
360        {
361            LiveStreamStart::Response(response) => return response,
362            LiveStreamStart::Retry {
363                error,
364                full_context,
365            } => {
366                if full_context && drop_live_continuation_for_retry(&mut continuation, &ctx) {
367                    attempt += 1;
368                    continue;
369                }
370                if attempt >= MAX_RETRYABLE_LIVE_STREAM_RETRIES {
371                    clear_continuation(ctx.session_id.as_deref());
372                    return map_codex_error_to_response(&error);
373                }
374                let delay = compute_backoff_delay(attempt, error.retry_after.as_deref());
375                if delay.exceeds_budget {
376                    clear_continuation(ctx.session_id.as_deref());
377                    return map_codex_error_to_response(&error);
378                }
379                attempt += 1;
380                sleep(delay.wait_ms).await;
381            }
382        }
383    }
384}
385
386async fn live_stream_response_once(
387    mut upstream_events: websocket::CodexWebSocketEventReceiver,
388    message_id: String,
389    model: &str,
390    ctx: RequestContext,
391    request_body: translate::request::ResponsesRequest,
392) -> LiveStreamStart {
393    let mut translator = LiveStreamTranslator::new(message_id, model.to_string());
394    let mut upstream_sse_body = Vec::new();
395
396    while let Some(item) = upstream_events.recv().await {
397        let payload = match item {
398            Ok(payload) => payload,
399            Err(err) => {
400                if retryable_live_start_codex_error(&err) {
401                    let full_context = retry_with_full_context_for_live_error(&err);
402                    return LiveStreamStart::Retry {
403                        error: err,
404                        full_context,
405                    };
406                }
407                clear_continuation(ctx.session_id.as_deref());
408                return LiveStreamStart::Response(map_codex_error_to_response(&err));
409            }
410        };
411        append_upstream_sse_payload(&mut upstream_sse_body, &payload);
412        let (chunk, terminal) = match translate_live_stream_payload(&mut translator, &payload, &ctx)
413        {
414            Ok(result) => result,
415            Err(message) => {
416                if retryable_live_start_payload(&payload, &message) {
417                    let lower_message = message.to_ascii_lowercase();
418                    let status = websocket::event_error_status(&payload).unwrap_or_else(|| {
419                        let error = payload.get("error").or_else(|| {
420                            payload.get("response").and_then(|value| value.get("error"))
421                        });
422                        let overloaded = error.is_some_and(|error| {
423                            error.get("code").and_then(|value| value.as_str())
424                                == Some("overloaded_error")
425                                || error.get("type").and_then(|value| value.as_str())
426                                    == Some("overloaded_error")
427                        });
428                        if payload.get("type").and_then(|value| value.as_str())
429                            == Some("codex.rate_limits")
430                            || lower_message.contains("rate limit")
431                        {
432                            429
433                        } else if overloaded || lower_message.contains("overloaded") {
434                            529
435                        } else {
436                            503
437                        }
438                    });
439                    return LiveStreamStart::Retry {
440                        error: client::CodexError {
441                            status,
442                            message: message.clone(),
443                            detail: Some(message),
444                            retry_after: retry_after_from_live_payload(&payload),
445                            origin: client::CodexErrorOrigin::WebSocket,
446                        },
447                        full_context: false,
448                    };
449                }
450                clear_continuation(ctx.session_id.as_deref());
451                return LiveStreamStart::Response(map_codex_failure_to_response(&message));
452            }
453        };
454        if !chunk.is_empty() {
455            record_live_stream_progress(&ctx, &chunk);
456            if terminal {
457                update_continuation_from_upstream(
458                    ctx.session_id.as_deref(),
459                    &request_body,
460                    &upstream_sse_body,
461                );
462                return LiveStreamStart::Response(single_live_stream_response(chunk));
463            }
464            return LiveStreamStart::Response(remaining_live_stream_response(
465                upstream_events,
466                translator,
467                chunk,
468                ctx,
469                request_body,
470                upstream_sse_body,
471            ));
472        }
473        if terminal {
474            update_continuation_from_upstream(
475                ctx.session_id.as_deref(),
476                &request_body,
477                &upstream_sse_body,
478            );
479            return LiveStreamStart::Response(empty_live_stream_response());
480        }
481    }
482
483    LiveStreamStart::Retry {
484        error: client::CodexError {
485            status: 0,
486            message: "WebSocket connection closed before terminal Codex response event".to_string(),
487            detail: Some(websocket::WEBSOCKET_MISSING_TERMINAL_DETAIL.to_string()),
488            retry_after: None,
489            origin: client::CodexErrorOrigin::WebSocket,
490        },
491        full_context: true,
492    }
493}
494
495fn translate_live_stream_payload(
496    translator: &mut LiveStreamTranslator,
497    payload: &serde_json::Value,
498    ctx: &RequestContext,
499) -> Result<(Vec<u8>, bool), String> {
500    let chunk = translator.accept(payload, ctx.traffic.as_deref())?;
501    let terminal = is_codex_terminal_event(payload) || translator.is_finished();
502    Ok((chunk, terminal))
503}
504
505fn record_live_stream_progress(ctx: &RequestContext, chunk: &[u8]) {
506    if let Some(monitor) = ctx.monitor.as_ref() {
507        let (input_tokens, output_tokens) = usage_from_anthropic_sse(chunk);
508        monitor.stream_progress(
509            &ctx.req_id,
510            chunk.len() as u64,
511            count_sse_events(chunk),
512            input_tokens,
513            output_tokens,
514        );
515    }
516}
517
518fn single_live_stream_response(chunk: Vec<u8>) -> Response {
519    event_stream_response(futures_util::stream::once(async move {
520        Ok::<Bytes, std::io::Error>(Bytes::from(chunk))
521    }))
522}
523
524fn empty_live_stream_response() -> Response {
525    event_stream_response(futures_util::stream::empty::<Result<Bytes, std::io::Error>>())
526}
527
528fn remaining_live_stream_response(
529    mut upstream_events: websocket::CodexWebSocketEventReceiver,
530    mut translator: LiveStreamTranslator,
531    first_chunk: Vec<u8>,
532    ctx: RequestContext,
533    request_body: translate::request::ResponsesRequest,
534    mut upstream_sse_body: Vec<u8>,
535) -> Response {
536    let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, std::io::Error>>(64);
537    tokio::spawn(async move {
538        if tx.send(Ok(Bytes::from(first_chunk))).await.is_err() {
539            clear_continuation(ctx.session_id.as_deref());
540            return;
541        }
542        while let Some(item) = upstream_events.recv().await {
543            match item {
544                Ok(payload) => {
545                    append_upstream_sse_payload(&mut upstream_sse_body, &payload);
546                    let (chunk, terminal) =
547                        match translate_live_stream_payload(&mut translator, &payload, &ctx) {
548                            Ok(result) => result,
549                            Err(message) => {
550                                clear_continuation(ctx.session_id.as_deref());
551                                let chunk = translator.error_chunk(
552                                    &message,
553                                    "api_error",
554                                    ctx.traffic.as_deref(),
555                                );
556                                if !chunk.is_empty() {
557                                    record_live_stream_progress(&ctx, &chunk);
558                                    let _ = tx.send(Ok(Bytes::from(chunk))).await;
559                                }
560                                return;
561                            }
562                        };
563                    if !chunk.is_empty() {
564                        record_live_stream_progress(&ctx, &chunk);
565                        if tx.send(Ok(Bytes::from(chunk))).await.is_err() {
566                            clear_continuation(ctx.session_id.as_deref());
567                            return;
568                        }
569                    }
570                    if terminal {
571                        update_continuation_from_upstream(
572                            ctx.session_id.as_deref(),
573                            &request_body,
574                            &upstream_sse_body,
575                        );
576                        return;
577                    }
578                }
579                Err(err) => {
580                    clear_continuation(ctx.session_id.as_deref());
581                    let chunk =
582                        translator.finish_after_closed_completed_tool_call(ctx.traffic.as_deref());
583                    if !chunk.is_empty() {
584                        record_live_stream_progress(&ctx, &chunk);
585                        let _ = tx.send(Ok(Bytes::from(chunk))).await;
586                        return;
587                    }
588                    let error_type = codex_stream_error_type(&err);
589                    let chunk = translator.error_chunk(
590                        codex_error_message(&err),
591                        error_type,
592                        ctx.traffic.as_deref(),
593                    );
594                    if !chunk.is_empty() {
595                        record_live_stream_progress(&ctx, &chunk);
596                        let _ = tx.send(Ok(Bytes::from(chunk))).await;
597                    }
598                    return;
599                }
600            }
601        }
602
603        clear_continuation(ctx.session_id.as_deref());
604        let chunk = translator.finish_after_closed_completed_tool_call(ctx.traffic.as_deref());
605        if !chunk.is_empty() {
606            record_live_stream_progress(&ctx, &chunk);
607            let _ = tx.send(Ok(Bytes::from(chunk))).await;
608            return;
609        }
610        let chunk = translator.error_chunk(
611            "WebSocket connection closed before terminal Codex response event",
612            "api_error",
613            ctx.traffic.as_deref(),
614        );
615        if !chunk.is_empty() {
616            record_live_stream_progress(&ctx, &chunk);
617            let _ = tx.send(Ok(Bytes::from(chunk))).await;
618        }
619    });
620
621    let stream = futures_util::stream::unfold(rx, |mut rx| async {
622        rx.recv().await.map(|item| (item, rx))
623    });
624    event_stream_response(stream)
625}
626
627fn append_upstream_sse_payload(buffer: &mut Vec<u8>, payload: &serde_json::Value) {
628    let text = payload.to_string();
629    for line in text.lines() {
630        buffer.extend_from_slice(b"data: ");
631        buffer.extend_from_slice(line.as_bytes());
632        buffer.push(b'\n');
633    }
634    buffer.push(b'\n');
635}
636
637fn event_stream_response<S>(stream: S) -> Response
638where
639    S: futures_util::Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
640{
641    let headers = [
642        (http::header::CONTENT_TYPE, "text/event-stream"),
643        (http::header::CACHE_CONTROL, "no-cache"),
644        (http::header::CONNECTION, "keep-alive"),
645    ];
646    (headers, Body::from_stream(stream)).into_response()
647}
648
649fn is_codex_terminal_event(payload: &serde_json::Value) -> bool {
650    matches!(
651        payload.get("type").and_then(|v| v.as_str()),
652        Some("response.completed")
653            | Some("response.incomplete")
654            | Some("response.done")
655            | Some("response.failed")
656            | Some("response.error")
657            | Some("error")
658    )
659}
660
661fn retryable_live_start_codex_error(err: &client::CodexError) -> bool {
662    if err.origin == client::CodexErrorOrigin::WebSocketHandshake {
663        return err.status == 0 || matches!(err.status, 429 | 500 | 502 | 503 | 504 | 529);
664    }
665    matches!(err.status, 429 | 500 | 502 | 503 | 504 | 529)
666        || (err.status == 0 && retryable_live_message(codex_error_message(err)))
667}
668
669fn retry_with_full_context_for_live_error(err: &client::CodexError) -> bool {
670    matches!(
671        err.detail.as_deref(),
672        Some("previous_response_not_found")
673            | Some(websocket::WEBSOCKET_RESPONSE_START_TIMEOUT_DETAIL)
674            | Some(websocket::WEBSOCKET_MISSING_TERMINAL_DETAIL)
675    )
676}
677
678fn drop_live_continuation_for_retry(
679    continuation: &mut Option<ContinuationCandidate>,
680    ctx: &RequestContext,
681) -> bool {
682    if continuation
683        .as_ref()
684        .and_then(|candidate| candidate.previous_response_id.as_deref())
685        .is_none()
686    {
687        return false;
688    }
689
690    clear_continuation(ctx.session_id.as_deref());
691    *continuation = None;
692    true
693}
694
695fn retryable_live_message(message: &str) -> bool {
696    let lower = message.to_ascii_lowercase();
697    [
698        "overloaded",
699        "rate limit",
700        "you can retry your request",
701        "temporarily unavailable",
702        "timed out",
703        "connection closed",
704        "connection reset",
705    ]
706    .iter()
707    .any(|needle| lower.contains(needle))
708}
709
710fn retryable_live_start_payload(payload: &serde_json::Value, _message: &str) -> bool {
711    events::classify_event_failure(payload).is_some_and(|failure| failure.retryable())
712}
713
714fn retry_after_from_live_payload(payload: &serde_json::Value) -> Option<String> {
715    events::classify_event_failure(payload).and_then(|failure| failure.retry_after)
716}
717
718fn codex_stream_error_type(err: &client::CodexError) -> &'static str {
719    match err.status {
720        429 => "rate_limit_error",
721        529 => "overloaded_error",
722        _ if codex_error_message(err)
723            .to_lowercase()
724            .contains("overloaded") =>
725        {
726            "overloaded_error"
727        }
728        _ => "api_error",
729    }
730}
731
732fn update_continuation_from_upstream(
733    session_id: Option<&str>,
734    request_body: &translate::request::ResponsesRequest,
735    upstream_body: &[u8],
736) {
737    match finish_metadata_from_upstream(upstream_body) {
738        Ok(Some(finish)) if finish.continuation_eligible => {
739            record_continuation(
740                session_id,
741                request_body,
742                finish.response_id.as_deref(),
743                &finish.output_items,
744            );
745        }
746        _ => clear_continuation(session_id),
747    }
748}
749
750// ---------------------------------------------------------------------------
751// Error mapping
752// ---------------------------------------------------------------------------
753
754fn map_codex_error_to_response(err: &client::CodexError) -> Response {
755    let message = codex_error_message(err);
756    if is_context_window_overflow(message) {
757        return map_codex_failure_to_response(message);
758    }
759
760    match err.status {
761        401 | 403 => json_error(
762            StatusCode::UNAUTHORIZED,
763            "authentication_error",
764            err.detail.as_deref().unwrap_or("Authentication failed"),
765        ),
766        429 => {
767            let retry_after = err.retry_after.as_deref().unwrap_or("5");
768            let resp = json_error(
769                StatusCode::TOO_MANY_REQUESTS,
770                "rate_limit_error",
771                &err.message,
772            );
773            let headers = [(http::header::RETRY_AFTER, retry_after)];
774            (headers, resp).into_response()
775        }
776        status @ (500 | 502 | 503 | 504 | 529)
777            if matches!(
778                err.origin,
779                client::CodexErrorOrigin::BufferedHttp
780                    | client::CodexErrorOrigin::BufferedWebSocket
781            ) =>
782        {
783            let response = json_error(
784                StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY),
785                if status == 529 {
786                    "overloaded_error"
787                } else {
788                    "api_error"
789                },
790                codex_error_message(err),
791            );
792            if let Some(retry_after) = err.retry_after.as_deref() {
793                ([(http::header::RETRY_AFTER, retry_after)], response).into_response()
794            } else {
795                response
796            }
797        }
798        _ => json_error(
799            StatusCode::BAD_GATEWAY,
800            "api_error",
801            codex_error_message(err),
802        ),
803    }
804}
805
806fn map_codex_failure_to_response(message: &str) -> Response {
807    if is_context_window_overflow(message) {
808        json_error(StatusCode::PAYLOAD_TOO_LARGE, "request_too_large", message)
809    } else {
810        json_error(StatusCode::BAD_GATEWAY, "api_error", message)
811    }
812}
813
814fn is_context_window_overflow(message: &str) -> bool {
815    message.to_ascii_lowercase().contains("context window")
816}
817
818fn codex_error_message(err: &client::CodexError) -> &str {
819    err.detail.as_deref().unwrap_or({
820        if err.status == 0 {
821            err.message.as_str()
822        } else {
823            "Upstream error"
824        }
825    })
826}
827
828// ---------------------------------------------------------------------------
829// CLI
830// ---------------------------------------------------------------------------
831
832pub(crate) struct CodexCli;
833
834impl CliHandlers for CodexCli {
835    fn login(&self) -> Result<(), anyhow::Error> {
836        anyhow::bail!(
837            "Codex auth is provided by the Codex CLI. Run `codex login` to create ~/.codex/auth.json; this proxy reads it directly."
838        )
839    }
840
841    fn device(&self) -> Result<(), anyhow::Error> {
842        anyhow::bail!(
843            "Codex auth is provided by the Codex CLI. Run `codex login` to create ~/.codex/auth.json; this proxy reads it directly."
844        )
845    }
846
847    fn status(&self) -> Result<(), anyhow::Error> {
848        let store = file_store();
849        let stored = store.load_auth()?;
850        match stored {
851            Some(auth) => {
852                println!(
853                    "Account: {}",
854                    auth.account_id.as_deref().unwrap_or("(none)")
855                );
856                println!("{}", format_expiry(auth.expires, now_ms()));
857                println!("Storage: {} (Codex CLI)", store.auth_path());
858                Ok(())
859            }
860            None => anyhow::bail!(
861                "No Codex credentials. Run `codex login` to create {}",
862                store.auth_path()
863            ),
864        }
865    }
866
867    fn logout(&self) -> Result<(), anyhow::Error> {
868        println!(
869            "Codex credentials are managed by the Codex CLI; run `codex logout` to remove them"
870        );
871        Ok(())
872    }
873}
874
875pub(crate) static CODEX_CLI: CodexCli = CodexCli;
876
877// ---------------------------------------------------------------------------
878// CLI helpers
879// ---------------------------------------------------------------------------
880
881fn now_ms() -> u64 {
882    std::time::SystemTime::now()
883        .duration_since(std::time::UNIX_EPOCH)
884        .unwrap_or_default()
885        .as_millis() as u64
886}
887
888fn format_expiry(expires: u64, now: u64) -> String {
889    let remaining = (i128::from(expires) - i128::from(now)).div_euclid(1000);
890    let iso = time::OffsetDateTime::from_unix_timestamp_nanos(i128::from(expires) * 1_000_000)
891        .ok()
892        .and_then(|dt| {
893            let fmt = time::format_description::parse_borrowed::<2>(
894                "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z",
895            )
896            .ok()?;
897            dt.format(&fmt).ok()
898        })
899        .unwrap_or_else(|| "invalid".to_string());
900    format!("Expires: {iso} (in {remaining}s)")
901}
902
903// ---------------------------------------------------------------------------
904// Tests
905// ---------------------------------------------------------------------------
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910
911    fn request_with_tools(tools: serde_json::Value) -> MessagesRequest {
912        serde_json::from_value(serde_json::json!({
913            "model": "gpt-5.6-luna",
914            "messages": [{"role":"user", "content":"find it"}],
915            "tools": tools
916        }))
917        .unwrap()
918    }
919
920    #[test]
921    fn web_search_requests_leave_lite_lane_and_upgrade_luna() {
922        let body = request_with_tools(serde_json::json!([
923            {"type":"web_search_20250305", "name":"web_search"}
924        ]));
925        for (resolved, expected) in [
926            ("gpt-5.6-luna", "gpt-5.6-sol"),
927            ("gpt-5.6-sol", "gpt-5.6-sol"),
928            ("gpt-5.6-terra", "gpt-5.6-terra"),
929            ("gpt-5.4", "gpt-5.4"),
930        ] {
931            let mut model = resolved.to_string();
932            let lite = apply_model_lane_for_request(&mut model, &body);
933            assert!(!lite, "{resolved} with web_search must use the full lane");
934            assert_eq!(model, expected);
935        }
936    }
937
938    #[test]
939    fn requests_without_web_search_keep_model_and_lite_lane() {
940        let body = request_with_tools(serde_json::json!([
941            {"name":"Bash", "input_schema":{}}
942        ]));
943        for (resolved, lite_expected) in [
944            ("gpt-5.6-luna", true),
945            ("gpt-5.6-sol", true),
946            ("gpt-5.4", false),
947        ] {
948            let mut model = resolved.to_string();
949            let lite = apply_model_lane_for_request(&mut model, &body);
950            assert_eq!(model, resolved, "model must not change without web_search");
951            assert_eq!(lite, lite_expected);
952        }
953    }
954
955    #[test]
956    fn live_stream_progress_records_terminal_usage() {
957        let monitor = crate::monitor::MonitorHandle::new(10);
958        monitor.request_started(
959            "request",
960            None,
961            None,
962            crate::monitor::EndpointKind::Messages,
963        );
964        let ctx = RequestContext {
965            req_id: "request".to_string(),
966            session_id: None,
967            session_seq: None,
968            provider: "codex".to_string(),
969            traffic: None,
970            monitor: Some(monitor.clone()),
971            passthrough: None,
972        };
973        let chunk = b"event: message_delta\ndata: {\"type\":\"message_delta\",\"usage\":{\"input_tokens\":12,\"output_tokens\":48}}\n\n";
974
975        record_live_stream_progress(&ctx, chunk);
976
977        let state = monitor.snapshot();
978        assert_eq!(state.active[0].input_tokens, Some(12));
979        assert_eq!(state.active[0].output_tokens, Some(48));
980    }
981
982    #[test]
983    fn supported_models_includes_fast_variants() {
984        let provider = CodexProvider::new();
985        let models = provider.supported_models();
986        assert!(models.contains(&"gpt-5.6-sol".to_string()));
987        assert!(models.contains(&"gpt-5.6-sol-fast".to_string()));
988        assert!(models.contains(&"gpt-5.6-terra".to_string()));
989        assert!(models.contains(&"gpt-5.6-luna".to_string()));
990        assert!(models.contains(&"gpt-5.4".to_string()));
991        assert!(models.contains(&"gpt-5.4-mini".to_string()));
992    }
993
994    #[test]
995    fn format_expiry_with_future_expiry() {
996        // 2100-01-01T00:00:00Z in ms
997        let expires = 4102444800000;
998        let now = 4102444790000; // 10s before
999        let output = format_expiry(expires, now);
1000        assert!(output.starts_with("Expires: 2100-01-01T00:00:00.000Z (in "));
1001        assert!(output.ends_with("s)"));
1002    }
1003
1004    #[test]
1005    fn format_expiry_with_past_expiry() {
1006        // 2000-01-01T00:00:00Z in ms
1007        let expires = 946684800000;
1008        let now = 946684810000; // 10s after
1009        let output = format_expiry(expires, now);
1010        assert!(output.starts_with("Expires: 2000-01-01T00:00:00.000Z (in -"));
1011    }
1012
1013    #[tokio::test]
1014    async fn statusless_codex_error_returns_source_message() {
1015        let err = client::CodexError {
1016            status: 0,
1017            message: "WebSocket connect error: HTTP error: 502 Bad Gateway".to_string(),
1018            detail: None,
1019            retry_after: None,
1020            origin: client::CodexErrorOrigin::WebSocket,
1021        };
1022
1023        let response = map_codex_error_to_response(&err);
1024        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
1025
1026        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1027            .await
1028            .unwrap();
1029        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
1030        assert_eq!(
1031            body.pointer("/error/message").and_then(|v| v.as_str()),
1032            Some("WebSocket connect error: HTTP error: 502 Bad Gateway")
1033        );
1034    }
1035
1036    #[test]
1037    fn live_start_statusless_websocket_handshake_error_is_retryable() {
1038        let err = client::CodexError {
1039            status: 0,
1040            message: "WebSocket connect timeout after 15000ms".to_string(),
1041            detail: None,
1042            retry_after: None,
1043            origin: client::CodexErrorOrigin::WebSocketHandshake,
1044        };
1045
1046        assert!(retryable_live_start_codex_error(&err));
1047    }
1048
1049    #[test]
1050    fn live_start_payload_retry_detection_covers_rate_limit_and_overload() {
1051        assert!(retryable_live_start_payload(
1052            &serde_json::json!({
1053                "type": "codex.rate_limits",
1054                "rate_limits": {"limit_reached": true}
1055            }),
1056            "rate limit reached",
1057        ));
1058        assert!(retryable_live_start_payload(
1059            &serde_json::json!({
1060                "type": "response.failed",
1061                "response": {"error": {"type": "overloaded_error", "message": "overloaded"}}
1062            }),
1063            "overloaded",
1064        ));
1065        assert!(!retryable_live_start_payload(
1066            &serde_json::json!({
1067                "type": "response.failed",
1068                "response": {"error": {"message": "bad request"}}
1069            }),
1070            "bad request",
1071        ));
1072    }
1073}