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
43pub 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 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 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
284fn 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
750fn 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
828pub(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
877fn 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#[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 let expires = 4102444800000;
998 let now = 4102444790000; 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 let expires = 946684800000;
1008 let now = 946684810000; 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}