1pub mod auth;
2pub mod chat_completions;
3pub mod client;
4pub mod compaction;
5pub mod continuation;
6pub mod count_tokens;
7pub(crate) mod events;
8pub mod images;
9pub mod native;
10pub mod request_summary;
11pub mod search;
12pub mod transcription;
13pub mod translate;
14pub mod websocket;
15
16use async_trait::async_trait;
17use axum::Json;
18use axum::body::Body;
19use axum::response::{IntoResponse, Response};
20use bytes::Bytes;
21use http::StatusCode;
22use std::sync::Arc;
23use std::time::{Duration, Instant};
24
25use crate::anthropic::error::json_error;
26use crate::anthropic::schema::{CountTokensResponse, MessagesRequest};
27use crate::anthropic::sse::parse_sse_events;
28use crate::config;
29use crate::logging::create_logger;
30use crate::monitor::usage_from_anthropic_sse;
31use crate::provider::{CliHandlers, Provider, RequestContext};
32use crate::registry;
33use crate::request_identity::ConversationIdentity;
34use crate::retry::{compute_backoff_delay, sleep};
35
36use self::auth::token_store::file_store;
37use self::client::CodexHttpClient;
38use self::compaction::{
39 CompactionAttempt, abort_compaction_attempt, activate_compaction, apply_compaction_replay,
40 begin_compaction, request_compaction, store_compaction,
41};
42use self::continuation::{
43 ContinuationReservation, abort_continuation_for_owner, continuation_candidate_for_owner,
44 record_continuation_for_owner,
45};
46use self::count_tokens::count_translated_tokens;
47use self::translate::accumulate::accumulate_response_with_traffic;
48use self::translate::live_stream::LiveStreamTranslator;
49use self::translate::model_allowlist::{
50 assert_allowed_model, full_lane_web_search_model, resolve_model_request_with_config_override,
51 uses_responses_lite,
52};
53use self::translate::reducer::finish_metadata_from_upstream;
54use self::translate::request::{
55 TranslateOptions, has_hosted_web_search, is_compact_messages_request, translate_request,
56};
57
58const MAX_RETRYABLE_LIVE_STREAM_RETRIES: u32 = 10;
59const MAX_EMPTY_COMPLETION_RETRIES: u32 = 10;
60const EMPTY_CODEX_COMPLETION_DETAIL: &str = "empty_codex_completion";
61const LIVE_STREAM_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
62use self::translate::stream::translate_stream_bytes_with_traffic;
63
64pub(crate) fn clear_session_compaction(session_id: &str) {
69 compaction::clear_compaction(session_id);
70}
71
72pub struct CodexProvider {
73 client: Arc<CodexHttpClient>,
74}
75
76impl Default for CodexProvider {
77 fn default() -> Self {
78 Self::new()
79 }
80}
81
82impl CodexProvider {
83 pub fn new() -> Self {
84 Self {
85 client: Arc::new(CodexHttpClient::new()),
86 }
87 }
88}
89
90impl CodexProvider {
91 async fn handle_messages_inner(
92 &self,
93 body: MessagesRequest,
94 ctx: RequestContext,
95 conversation_identity: Option<ConversationIdentity>,
96 ) -> Response {
97 let message_id = format!("msg_{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
98 let want_stream = body.stream;
99 let model = body.model.as_deref().unwrap_or("gpt-5.6-sol");
100
101 let mut resolved =
102 resolve_model_request_with_config_override(model, !body.bypass_provider_model_override);
103 if let Err(e) = assert_allowed_model(&resolved.model) {
104 return json_error(
105 StatusCode::BAD_REQUEST,
106 "invalid_request_error",
107 format!(
108 "Model \"{model}\" resolves to unsupported model \"{}\"",
109 e.model
110 ),
111 );
112 }
113 if search::is_standalone_search_request(&body) {
114 if let Some(monitor) = ctx.monitor.as_ref() {
115 monitor.model_resolved(&ctx.req_id, &resolved.model);
116 }
117 let (search_request, query) = match search::build_search_request(
118 &body,
119 &resolved.model,
120 ctx.session_id.as_deref(),
121 ) {
122 Ok(request) => request,
123 Err(error) => {
124 return json_error(
125 StatusCode::BAD_REQUEST,
126 "invalid_request_error",
127 error.to_string(),
128 );
129 }
130 };
131 let log = create_logger("codex");
132 let started_at = Instant::now();
133 log.info(
134 "codex_standalone_search_started",
135 Some(serde_json::Map::from_iter([
136 ("reqId".to_string(), serde_json::json!(&ctx.req_id)),
137 ("model".to_string(), serde_json::json!(&resolved.model)),
138 ("stream".to_string(), serde_json::json!(want_stream)),
139 ])),
140 );
141 if let Some(monitor) = ctx.monitor.as_ref() {
142 monitor.upstream_started(&ctx.req_id);
143 }
144 let search_response = match self.client.post_search(&search_request, &ctx).await {
145 Ok(response) => response,
146 Err(error) => {
147 log.warn(
148 "codex_standalone_search_failed",
149 Some(serde_json::Map::from_iter([
150 ("reqId".to_string(), serde_json::json!(&ctx.req_id)),
151 ("model".to_string(), serde_json::json!(&resolved.model)),
152 ("status".to_string(), serde_json::json!(error.status)),
153 (
154 "ms".to_string(),
155 serde_json::json!(started_at.elapsed().as_millis()),
156 ),
157 ])),
158 );
159 return map_codex_error_to_response(&error);
160 }
161 };
162 log.info(
163 "codex_standalone_search_completed",
164 Some(serde_json::Map::from_iter([
165 ("reqId".to_string(), serde_json::json!(&ctx.req_id)),
166 ("model".to_string(), serde_json::json!(&resolved.model)),
167 (
168 "resultCount".to_string(),
169 serde_json::json!(search_response.results.as_ref().map(Vec::len)),
170 ),
171 (
172 "ms".to_string(),
173 serde_json::json!(started_at.elapsed().as_millis()),
174 ),
175 ])),
176 );
177 let input_tokens = search::search_request_input_tokens(&search_request);
178 let output_tokens = search::search_response_output_tokens(&search_response);
179 if let Some(monitor) = ctx.monitor.as_ref() {
180 monitor.usage_updated(&ctx.req_id, Some(input_tokens), Some(output_tokens));
181 }
182 return search::anthropic_search_response(
183 &search_response,
184 &query,
185 &message_id,
186 model,
187 want_stream,
188 input_tokens,
189 ctx.traffic.as_deref(),
190 );
191 }
192 let use_responses_lite = apply_model_lane_for_request(&mut resolved.model, &body);
193 if let Some(monitor) = ctx.monitor.as_ref() {
194 monitor.model_resolved(&ctx.req_id, &resolved.model);
195 }
196
197 let mut translated = match translate_request(
198 &body,
199 TranslateOptions {
200 session_id: ctx.session_id.clone(),
201 service_tier: resolved.service_tier.clone(),
202 model: resolved.model.clone(),
203 use_responses_lite,
204 },
205 ) {
206 Ok(t) => t,
207 Err(e) => {
208 return json_error(
209 StatusCode::BAD_REQUEST,
210 "invalid_request_error",
211 e.to_string(),
212 );
213 }
214 };
215
216 let compact_boundary = is_compact_messages_request(&body);
217 let server_compaction_enabled = config::codex_server_compaction();
218 let mut compaction_attempt = None;
219 if !server_compaction_enabled && let Some(session_id) = ctx.session_id.as_deref() {
220 compaction::clear_compaction(session_id);
221 }
222 if server_compaction_enabled
223 && compact_boundary
224 && let Some(session_id) = ctx.session_id.as_deref()
225 {
226 let attempt = begin_compaction(session_id, &translated.model);
227 compaction_attempt = Some(attempt);
228 log_compaction_event(
229 "server_compaction_triggered",
230 &ctx,
231 translated.input.len(),
232 None,
233 );
234 if let Some(monitor) = ctx.monitor.as_ref() {
235 monitor.compaction_started(&ctx.req_id);
236 }
237 let mut compaction_ctx = ctx.clone();
238 compaction_ctx.monitor = None;
239 match request_compaction(self.client.as_ref(), &translated, &compaction_ctx).await {
240 Ok(native_history) => {
241 if store_compaction(session_id, attempt, native_history) {
242 log_compaction_event(
243 "server_compaction_completed",
244 &ctx,
245 translated.input.len(),
246 None,
247 );
248 } else {
249 log_compaction_event(
250 "server_compaction_failed",
251 &ctx,
252 translated.input.len(),
253 Some("compaction state was superseded or exceeded the in-memory limit"),
254 );
255 }
256 }
257 Err(error) => {
258 abort_compaction_attempt(Some(session_id), Some(attempt));
259 log_compaction_event(
260 "server_compaction_failed",
261 &ctx,
262 translated.input.len(),
263 Some(&error.to_string()),
264 );
265 }
266 }
267 } else if server_compaction_enabled
268 && !compact_boundary
269 && let Some(replay) = apply_compaction_replay(ctx.session_id.as_deref(), &translated)
270 {
271 translated = replay.request;
272 compaction_attempt = Some(replay.attempt);
273 }
274
275 let previous_response_id_enabled = config::codex_previous_response_id();
277 let continuation = continuation_candidate_for_owner(
278 conversation_identity.as_ref(),
279 &translated,
280 previous_response_id_enabled,
281 );
282 let turn_id = continuation.turn_id();
283 let configured_transport = config::codex_transport();
284 let transport = configured_transport.as_str();
285 let upstream_started_at = Instant::now();
286 let log = create_logger("codex");
287 let req_id = ctx.req_id.clone();
288 log.info(
289 "codex_upstream_request_started",
290 Some(serde_json::Map::from_iter([
291 ("reqId".to_string(), serde_json::json!(&req_id)),
292 ("transport".to_string(), serde_json::json!(transport)),
293 ("model".to_string(), serde_json::json!(&resolved.model)),
294 ("stream".to_string(), serde_json::json!(want_stream)),
295 (
296 "responsesLite".to_string(),
297 serde_json::json!(use_responses_lite),
298 ),
299 (
300 "previousResponseIdEnabled".to_string(),
301 serde_json::json!(previous_response_id_enabled),
302 ),
303 (
304 "hasPreviousResponseId".to_string(),
305 serde_json::json!(continuation.candidate().previous_response_id.is_some()),
306 ),
307 (
308 "inputDeltaCount".to_string(),
309 serde_json::json!(continuation.candidate().input_delta.as_ref().map(Vec::len)),
310 ),
311 ("turnId".to_string(), serde_json::json!(turn_id)),
312 ])),
313 );
314
315 let client = self.client.clone();
317 if let Some(monitor) = ctx.monitor.as_ref() {
318 monitor.upstream_started(&ctx.req_id);
319 }
320 if want_stream {
321 let stream_request = translated.clone();
322 let response = live_stream_response(
323 client,
324 message_id,
325 model,
326 ctx,
327 stream_request,
328 continuation,
329 LiveStreamCompaction {
330 compact_boundary,
331 attempt: compaction_attempt,
332 },
333 configured_transport,
334 )
335 .await;
336 log.info(
337 "codex_upstream_response_ready",
338 Some(serde_json::Map::from_iter([
339 ("reqId".to_string(), serde_json::json!(&req_id)),
340 ("transport".to_string(), serde_json::json!(transport)),
341 (
342 "status".to_string(),
343 serde_json::json!(response.status().as_u16()),
344 ),
345 (
346 "ms".to_string(),
347 serde_json::json!(upstream_started_at.elapsed().as_millis()),
348 ),
349 ])),
350 );
351 return response;
352 }
353
354 let request_continuation = continuation.clone();
355 let mut continuation = Some(continuation);
356 let mut attempt = 0_u32;
357 let upstream = loop {
358 let response = match client
359 .post_codex_for_owner(&translated, &ctx, continuation.as_ref())
360 .await
361 {
362 Ok(r) => r,
363 Err(e) => {
364 log.warn(
365 "codex_upstream_request_failed",
366 Some(serde_json::Map::from_iter([
367 ("reqId".to_string(), serde_json::json!(&req_id)),
368 ("transport".to_string(), serde_json::json!(transport)),
369 ("status".to_string(), serde_json::json!(e.status)),
370 (
371 "origin".to_string(),
372 serde_json::json!(format!("{:?}", e.origin)),
373 ),
374 ("error".to_string(), serde_json::json!(&e.message)),
375 (
376 "ms".to_string(),
377 serde_json::json!(upstream_started_at.elapsed().as_millis()),
378 ),
379 ])),
380 );
381 abort_compaction_attempt(ctx.session_id.as_deref(), compaction_attempt);
382 abort_continuation_for_owner(&request_continuation);
383 return map_codex_error_to_response(&e);
384 }
385 };
386 if !is_empty_codex_success_completion(&response.body) {
387 break response;
388 }
389 let error = empty_buffered_completion_error();
392 drop_live_continuation_for_retry(&mut continuation);
393 if attempt >= MAX_EMPTY_COMPLETION_RETRIES {
394 abort_compaction_attempt(ctx.session_id.as_deref(), compaction_attempt);
395 abort_continuation_for_owner(&request_continuation);
396 return map_codex_error_to_response(&error);
397 }
398 let delay = compute_backoff_delay(attempt, None);
399 if delay.exceeds_budget {
400 abort_compaction_attempt(ctx.session_id.as_deref(), compaction_attempt);
401 abort_continuation_for_owner(&request_continuation);
402 return map_codex_error_to_response(&error);
403 }
404 attempt += 1;
405 sleep(delay.wait_ms).await;
406 };
407 log.info(
408 "codex_upstream_response_received",
409 Some(serde_json::Map::from_iter([
410 ("reqId".to_string(), serde_json::json!(&req_id)),
411 ("transport".to_string(), serde_json::json!(transport)),
412 ("status".to_string(), serde_json::json!(upstream.status)),
413 (
414 "bodyBytes".to_string(),
415 serde_json::json!(upstream.body.len()),
416 ),
417 (
418 "ms".to_string(),
419 serde_json::json!(upstream_started_at.elapsed().as_millis()),
420 ),
421 ])),
422 );
423
424 if want_stream {
425 let estimated_input_tokens = count_translated_tokens(&translated);
426 let sse_bytes = match translate_stream_bytes_with_traffic(
427 &upstream.body,
428 &message_id,
429 model,
430 estimated_input_tokens,
431 ctx.traffic.as_deref(),
432 ) {
433 Ok(b) => b,
434 Err(e) => {
435 abort_compaction_attempt(ctx.session_id.as_deref(), compaction_attempt);
436 abort_continuation_for_owner(&request_continuation);
437 return map_codex_failure_to_response(&format!(
438 "Stream translation error: {e}"
439 ));
440 }
441 };
442 if let Some(monitor) = ctx.monitor.as_ref() {
443 let (input_tokens, output_tokens) = usage_from_anthropic_sse(&sse_bytes);
444 monitor.stream_progress(
445 &ctx.req_id,
446 sse_bytes.len() as u64,
447 count_sse_events(&sse_bytes),
448 input_tokens,
449 output_tokens,
450 );
451 }
452 update_continuation_from_upstream(
453 ctx.session_id.as_deref(),
454 &request_continuation,
455 compaction_attempt,
456 &translated,
457 &upstream.body,
458 upstream.socket_id,
459 compact_boundary,
460 );
461
462 let headers = [
463 (http::header::CONTENT_TYPE, "text/event-stream"),
464 (http::header::CACHE_CONTROL, "no-cache"),
465 (http::header::CONNECTION, "keep-alive"),
466 ];
467 (headers, sse_bytes).into_response()
468 } else {
469 match accumulate_response_with_traffic(
470 &upstream.body,
471 &message_id,
472 model,
473 ctx.traffic.as_deref(),
474 ) {
475 Ok(json) => {
476 if let Some(monitor) = ctx.monitor.as_ref() {
477 monitor.usage_updated(
478 &ctx.req_id,
479 json.pointer("/usage/input_tokens").and_then(|v| v.as_u64()),
480 json.pointer("/usage/output_tokens")
481 .and_then(|v| v.as_u64()),
482 );
483 }
484 update_continuation_from_upstream(
485 ctx.session_id.as_deref(),
486 &request_continuation,
487 compaction_attempt,
488 &translated,
489 &upstream.body,
490 upstream.socket_id,
491 compact_boundary,
492 );
493 (StatusCode::OK, Json(json)).into_response()
494 }
495 Err(e) => {
496 abort_compaction_attempt(ctx.session_id.as_deref(), compaction_attempt);
497 abort_continuation_for_owner(&request_continuation);
498 map_codex_failure_to_response(&format!("Accumulation error: {e}"))
499 }
500 }
501 }
502 }
503}
504
505#[async_trait]
506impl Provider for CodexProvider {
507 fn name(&self) -> &'static str {
508 "codex"
509 }
510
511 fn supported_models(&self) -> Vec<String> {
512 let mut models: Vec<String> = registry::CODEX_MODELS
513 .iter()
514 .map(|m| m.to_string())
515 .collect();
516 for m in registry::CODEX_MODELS {
517 models.push(format!("{m}-fast"));
518 }
519 models.sort_unstable();
520 models.dedup();
521 models
522 }
523
524 fn cli(&self) -> &'static dyn CliHandlers {
525 &CODEX_CLI
526 }
527
528 async fn handle_messages(&self, body: MessagesRequest, ctx: RequestContext) -> Response {
529 self.handle_messages_inner(body, ctx, None).await
530 }
531
532 async fn handle_messages_with_conversation_identity(
533 &self,
534 body: MessagesRequest,
535 ctx: RequestContext,
536 conversation_identity: Option<ConversationIdentity>,
537 ) -> Response {
538 self.handle_messages_inner(body, ctx, conversation_identity)
539 .await
540 }
541
542 async fn handle_count_tokens(&self, body: MessagesRequest, ctx: RequestContext) -> Response {
543 let model = body.model.as_deref().unwrap_or("gpt-5.6-sol");
544 let mut resolved =
545 resolve_model_request_with_config_override(model, !body.bypass_provider_model_override);
546 if let Err(e) = assert_allowed_model(&resolved.model) {
547 return json_error(
548 StatusCode::BAD_REQUEST,
549 "invalid_request_error",
550 format!(
551 "Model \"{model}\" resolves to unsupported model \"{}\"",
552 e.model
553 ),
554 );
555 }
556 let use_responses_lite = apply_model_lane_for_request(&mut resolved.model, &body);
557 if let Some(monitor) = ctx.monitor.as_ref() {
558 monitor.model_resolved(&ctx.req_id, &resolved.model);
559 }
560
561 let translated = match translate_request(
562 &body,
563 TranslateOptions {
564 session_id: None,
565 service_tier: resolved.service_tier.clone(),
566 model: resolved.model.clone(),
567 use_responses_lite,
568 },
569 ) {
570 Ok(t) => t,
571 Err(e) => {
572 return json_error(
573 StatusCode::BAD_REQUEST,
574 "invalid_request_error",
575 e.to_string(),
576 );
577 }
578 };
579
580 let tokens = count_translated_tokens(&translated);
581 if let Some(monitor) = ctx.monitor.as_ref() {
582 monitor.usage_updated(&ctx.req_id, Some(tokens), None);
583 }
584 (
585 StatusCode::OK,
586 Json(CountTokensResponse {
587 input_tokens: tokens,
588 }),
589 )
590 .into_response()
591 }
592}
593
594fn apply_model_lane_for_request(model: &mut String, body: &MessagesRequest) -> bool {
599 if has_hosted_web_search(body) {
600 *model = full_lane_web_search_model(model).to_string();
601 return false;
602 }
603 uses_responses_lite(model)
604}
605
606fn count_sse_events(bytes: &[u8]) -> u64 {
607 String::from_utf8_lossy(bytes).matches("event:").count() as u64
608}
609
610fn log_compaction_event(
611 event: &str,
612 ctx: &RequestContext,
613 input_items: usize,
614 error: Option<&str>,
615) {
616 let mut fields = serde_json::Map::new();
617 fields.insert("reqId".into(), serde_json::json!(ctx.req_id));
618 fields.insert("inputItems".into(), serde_json::json!(input_items));
619 if let Some(error) = error {
620 fields.insert("error".into(), serde_json::json!(error));
621 create_logger("codex").warn(event, Some(fields));
622 } else {
623 create_logger("codex").info(event, Some(fields));
624 }
625}
626
627fn abort_request_state(
628 session_id: Option<&str>,
629 continuation: &ContinuationReservation,
630 compaction_attempt: Option<CompactionAttempt>,
631) {
632 abort_compaction_attempt(session_id, compaction_attempt);
633 abort_continuation_for_owner(continuation);
634}
635
636struct LiveRequestStateCleanup {
637 continuation: ContinuationReservation,
638 session_id: Option<String>,
639 compaction_attempt: Option<CompactionAttempt>,
640 armed: bool,
641}
642
643impl LiveRequestStateCleanup {
644 fn new(
645 continuation: ContinuationReservation,
646 session_id: Option<String>,
647 compaction_attempt: Option<CompactionAttempt>,
648 ) -> Self {
649 Self {
650 continuation,
651 session_id,
652 compaction_attempt,
653 armed: true,
654 }
655 }
656
657 fn abort(&mut self) {
658 if self.armed {
659 abort_request_state(
660 self.session_id.as_deref(),
661 &self.continuation,
662 self.compaction_attempt,
663 );
664 self.armed = false;
665 }
666 }
667
668 fn disarm(&mut self) {
669 self.armed = false;
670 }
671}
672
673impl Drop for LiveRequestStateCleanup {
674 fn drop(&mut self) {
675 if self.armed {
676 abort_request_state(
677 self.session_id.as_deref(),
678 &self.continuation,
679 self.compaction_attempt,
680 );
681 }
682 }
683}
684
685enum LiveStreamStart {
686 Response(Response),
687 Retry {
688 error: client::CodexError,
689 full_context_retry_attempted: bool,
690 },
691}
692
693#[derive(Clone, Copy)]
694struct LiveStreamCompaction {
695 compact_boundary: bool,
696 attempt: Option<CompactionAttempt>,
697}
698
699#[allow(clippy::too_many_arguments)]
700async fn live_stream_response(
701 client: Arc<CodexHttpClient>,
702 message_id: String,
703 model: &str,
704 ctx: RequestContext,
705 request_body: translate::request::ResponsesRequest,
706 continuation: ContinuationReservation,
707 compaction: LiveStreamCompaction,
708 transport: config::CodexTransport,
709) -> Response {
710 let model = model.to_string();
711 let request_continuation = continuation.clone();
712 let mut cleanup = LiveRequestStateCleanup::new(
713 request_continuation.clone(),
714 ctx.session_id.clone(),
715 compaction.attempt,
716 );
717 let mut attempt = 0_u32;
718 let mut continuation = Some(continuation);
719
720 loop {
721 let upstream_events = match transport {
722 config::CodexTransport::Http => {
723 client
724 .stream_codex_http_events_for_owner(&request_body, &ctx)
725 .await
726 }
727 config::CodexTransport::WebSocket => {
728 client
729 .stream_codex_websocket_events_for_owner(
730 &request_body,
731 &ctx,
732 continuation.as_ref(),
733 )
734 .await
735 }
736 config::CodexTransport::Auto => {
737 client
738 .stream_codex_auto_events_for_owner(&request_body, &ctx, continuation.as_ref())
739 .await
740 }
741 };
742 let upstream_events = match upstream_events {
743 Ok(events) => events,
744 Err(err) if err.origin == client::CodexErrorOrigin::Http => {
745 cleanup.abort();
746 return map_codex_error_to_response(&err);
747 }
748 Err(err) if retryable_live_start_codex_error(&err) => {
749 let dropped = drop_live_continuation_for_retry(&mut continuation);
750 if dropped && is_missing_previous_response_error(&err) {
751 attempt += 1;
752 continue;
753 }
754 if attempt >= MAX_RETRYABLE_LIVE_STREAM_RETRIES {
755 cleanup.abort();
756 return map_codex_error_to_response(&err);
757 }
758 let delay = compute_backoff_delay(attempt, err.retry_after.as_deref());
759 if delay.exceeds_budget {
760 cleanup.abort();
761 return map_codex_error_to_response(&err);
762 }
763 attempt += 1;
764 sleep(delay.wait_ms).await;
765 continue;
766 }
767 Err(err) => {
768 cleanup.abort();
769 return map_codex_error_to_response(&err);
770 }
771 };
772
773 match live_stream_response_once(
774 upstream_events,
775 message_id.clone(),
776 &model,
777 ctx.clone(),
778 request_continuation.clone(),
779 request_body.clone(),
780 compaction,
781 )
782 .await
783 {
784 LiveStreamStart::Response(response) => {
785 cleanup.disarm();
786 return response;
787 }
788 LiveStreamStart::Retry {
789 error,
790 full_context_retry_attempted,
791 } => {
792 if error.origin == client::CodexErrorOrigin::Http {
797 cleanup.abort();
798 return map_codex_error_to_response(&error);
799 }
800 let dropped = drop_live_continuation_for_retry(&mut continuation);
801 if full_context_retry_attempted && client::is_continuation_retry_error(&error) {
802 cleanup.abort();
803 return map_codex_error_to_response(&error);
804 }
805 if dropped && is_missing_previous_response_error(&error) {
806 attempt += 1;
807 continue;
808 }
809 if attempt >= MAX_RETRYABLE_LIVE_STREAM_RETRIES {
810 cleanup.abort();
811 return map_codex_error_to_response(&error);
812 }
813 let delay = compute_backoff_delay(attempt, error.retry_after.as_deref());
814 if delay.exceeds_budget {
815 cleanup.abort();
816 return map_codex_error_to_response(&error);
817 }
818 attempt += 1;
819 sleep(delay.wait_ms).await;
820 }
821 }
822 }
823}
824
825fn provider_retry(
826 upstream_events: &websocket::CodexWebSocketEventStream,
827 error: client::CodexError,
828) -> LiveStreamStart {
829 let full_context_retry_attempted = upstream_events.used_full_context_retry();
830 upstream_events.mark_provider_retry_handoff();
831 LiveStreamStart::Retry {
832 error,
833 full_context_retry_attempted,
834 }
835}
836
837#[allow(clippy::too_many_arguments)]
838async fn live_stream_response_once(
839 mut upstream_events: websocket::CodexWebSocketEventStream,
840 message_id: String,
841 model: &str,
842 ctx: RequestContext,
843 request_continuation: ContinuationReservation,
844 request_body: translate::request::ResponsesRequest,
845 compaction: LiveStreamCompaction,
846) -> LiveStreamStart {
847 let estimated_input_tokens = count_translated_tokens(&request_body);
848 let mut translator = LiveStreamTranslator::with_estimated_input_tokens(
849 message_id,
850 model.to_string(),
851 estimated_input_tokens,
852 );
853 let mut upstream_sse_body = Vec::new();
854 let mut pending_chunk = Vec::new();
857 let mut generation_started = false;
858
859 while let Some(item) = upstream_events.recv().await {
860 let payload = match item {
861 Ok(payload) => payload,
862 Err(err) => {
863 if retryable_live_start_codex_error(&err) {
864 return provider_retry(&upstream_events, err);
865 }
866 abort_request_state(
867 ctx.session_id.as_deref(),
868 &request_continuation,
869 compaction.attempt,
870 );
871 return LiveStreamStart::Response(map_codex_error_to_response(&err));
872 }
873 };
874 if !generation_started && codex_generation_event(&payload) {
875 if let Some(monitor) = ctx.monitor.as_ref() {
876 monitor.generation_started(&ctx.req_id);
877 }
878 generation_started = true;
879 }
880 append_upstream_sse_payload(&mut upstream_sse_body, &payload);
881 let (chunk, terminal) = match translate_live_stream_payload(&mut translator, &payload, None)
882 {
883 Ok(result) => result,
884 Err(message) => {
885 if retryable_live_start_payload(&payload, &message) {
886 let lower_message = message.to_ascii_lowercase();
887 let status = websocket::event_error_status(&payload).unwrap_or_else(|| {
888 let error = payload.get("error").or_else(|| {
889 payload.get("response").and_then(|value| value.get("error"))
890 });
891 let overloaded = error.is_some_and(|error| {
892 error.get("code").and_then(|value| value.as_str())
893 == Some("overloaded_error")
894 || error.get("type").and_then(|value| value.as_str())
895 == Some("overloaded_error")
896 });
897 if payload.get("type").and_then(|value| value.as_str())
898 == Some("codex.rate_limits")
899 || lower_message.contains("rate limit")
900 {
901 429
902 } else if overloaded || lower_message.contains("overloaded") {
903 529
904 } else {
905 503
906 }
907 });
908 return provider_retry(
909 &upstream_events,
910 client::CodexError {
911 status,
912 message: message.clone(),
913 detail: Some(message),
914 retry_after: retry_after_from_live_payload(&payload),
915 origin: client::CodexErrorOrigin::WebSocket,
916 },
917 );
918 }
919 abort_request_state(
920 ctx.session_id.as_deref(),
921 &request_continuation,
922 compaction.attempt,
923 );
924 return LiveStreamStart::Response(map_codex_failure_to_response(&message));
925 }
926 };
927 pending_chunk.extend_from_slice(&chunk);
928 if terminal
929 && is_codex_success_terminal_event(&payload)
930 && !translator.has_semantic_output()
931 {
932 return provider_retry(&upstream_events, empty_live_completion_error());
933 }
934 if translator.has_semantic_output() && !pending_chunk.is_empty() {
935 record_live_stream_downstream_capture(&ctx, &pending_chunk);
936 record_live_stream_progress(&ctx, &pending_chunk);
937 if terminal {
938 update_continuation_from_upstream(
939 ctx.session_id.as_deref(),
940 &request_continuation,
941 compaction.attempt,
942 &request_body,
943 &upstream_sse_body,
944 upstream_events.socket_id(),
945 compaction.compact_boundary,
946 );
947 return LiveStreamStart::Response(single_live_stream_response(pending_chunk));
948 }
949 return LiveStreamStart::Response(remaining_live_stream_response(
950 upstream_events,
951 translator,
952 pending_chunk,
953 ctx,
954 request_continuation,
955 request_body,
956 upstream_sse_body,
957 compaction,
958 ));
959 }
960 if terminal {
961 update_continuation_from_upstream(
962 ctx.session_id.as_deref(),
963 &request_continuation,
964 compaction.attempt,
965 &request_body,
966 &upstream_sse_body,
967 upstream_events.socket_id(),
968 compaction.compact_boundary,
969 );
970 if pending_chunk.is_empty() {
971 return LiveStreamStart::Response(empty_live_stream_response());
972 }
973 record_live_stream_downstream_capture(&ctx, &pending_chunk);
974 record_live_stream_progress(&ctx, &pending_chunk);
975 return LiveStreamStart::Response(single_live_stream_response(pending_chunk));
976 }
977 }
978
979 provider_retry(
980 &upstream_events,
981 client::CodexError {
982 status: 0,
983 message: "WebSocket connection closed before terminal Codex response event".to_string(),
984 detail: Some(websocket::WEBSOCKET_MISSING_TERMINAL_DETAIL.to_string()),
985 retry_after: None,
986 origin: client::CodexErrorOrigin::WebSocket,
987 },
988 )
989}
990
991fn empty_live_completion_error() -> client::CodexError {
992 client::CodexError {
993 status: 503,
994 message: "Codex completed without producing output".to_string(),
995 detail: Some(EMPTY_CODEX_COMPLETION_DETAIL.to_string()),
996 retry_after: None,
997 origin: client::CodexErrorOrigin::WebSocket,
998 }
999}
1000
1001fn codex_generation_event(payload: &serde_json::Value) -> bool {
1002 !matches!(
1003 payload.get("type").and_then(|value| value.as_str()),
1004 Some("codex.rate_limits" | "keepalive") | None
1005 )
1006}
1007
1008fn translate_live_stream_payload(
1009 translator: &mut LiveStreamTranslator,
1010 payload: &serde_json::Value,
1011 traffic: Option<&crate::traffic::TrafficCapture>,
1012) -> Result<(Vec<u8>, bool), String> {
1013 let chunk = translator.accept(payload, traffic)?;
1014 let terminal = is_codex_terminal_event(payload) || translator.is_finished();
1015 Ok((chunk, terminal))
1016}
1017
1018fn record_live_stream_downstream_capture(ctx: &RequestContext, chunk: &[u8]) {
1019 let Some(traffic) = ctx.traffic.as_ref() else {
1020 return;
1021 };
1022 for event in parse_sse_events(chunk) {
1023 let Ok(data) = serde_json::from_str::<serde_json::Value>(&event.data) else {
1024 continue;
1025 };
1026 traffic.write_json_event(
1027 "050-downstream-event",
1028 &serde_json::json!({
1029 "event": event.event.as_deref().unwrap_or("message"),
1030 "data": data,
1031 }),
1032 );
1033 }
1034}
1035
1036fn record_live_stream_progress(ctx: &RequestContext, chunk: &[u8]) {
1037 if let Some(monitor) = ctx.monitor.as_ref() {
1038 let (input_tokens, output_tokens) = usage_from_anthropic_sse(chunk);
1039 monitor.stream_progress(
1040 &ctx.req_id,
1041 chunk.len() as u64,
1042 count_sse_events(chunk),
1043 input_tokens,
1044 output_tokens,
1045 );
1046 }
1047}
1048
1049fn single_live_stream_response(chunk: Vec<u8>) -> Response {
1050 event_stream_response(futures_util::stream::once(async move {
1051 Ok::<Bytes, std::io::Error>(Bytes::from(chunk))
1052 }))
1053}
1054
1055fn empty_live_stream_response() -> Response {
1056 event_stream_response(futures_util::stream::empty::<Result<Bytes, std::io::Error>>())
1057}
1058
1059#[allow(clippy::too_many_arguments)]
1060fn remaining_live_stream_response(
1061 mut upstream_events: websocket::CodexWebSocketEventStream,
1062 mut translator: LiveStreamTranslator,
1063 first_chunk: Vec<u8>,
1064 ctx: RequestContext,
1065 request_continuation: ContinuationReservation,
1066 request_body: translate::request::ResponsesRequest,
1067 mut upstream_sse_body: Vec<u8>,
1068 compaction: LiveStreamCompaction,
1069) -> Response {
1070 let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, std::io::Error>>(64);
1071 tokio::spawn(async move {
1072 if tx.send(Ok(Bytes::from(first_chunk))).await.is_err() {
1073 abort_request_state(
1074 ctx.session_id.as_deref(),
1075 &request_continuation,
1076 compaction.attempt,
1077 );
1078 return;
1079 }
1080 let mut heartbeat = tokio::time::interval(LIVE_STREAM_HEARTBEAT_INTERVAL);
1081 heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1082 heartbeat.tick().await;
1083 loop {
1084 let item = tokio::select! {
1085 biased;
1086 _ = tx.closed() => {
1087 abort_request_state(
1088 ctx.session_id.as_deref(),
1089 &request_continuation,
1090 compaction.attempt,
1091 );
1092 return;
1093 }
1094 item = upstream_events.recv() => item,
1095 _ = heartbeat.tick() => {
1096 let chunk = translator.ping_chunk(ctx.traffic.as_deref());
1097 if !chunk.is_empty() {
1098 record_live_stream_progress(&ctx, &chunk);
1099 if tx.send(Ok(Bytes::from(chunk))).await.is_err() {
1100 abort_request_state(
1101 ctx.session_id.as_deref(),
1102 &request_continuation,
1103 compaction.attempt,
1104 );
1105 return;
1106 }
1107 }
1108 continue;
1109 }
1110 };
1111 let Some(item) = item else {
1112 break;
1113 };
1114 match item {
1115 Ok(payload) => {
1116 append_upstream_sse_payload(&mut upstream_sse_body, &payload);
1117 let (chunk, terminal) = match translate_live_stream_payload(
1118 &mut translator,
1119 &payload,
1120 ctx.traffic.as_deref(),
1121 ) {
1122 Ok(result) => result,
1123 Err(message) => {
1124 abort_request_state(
1125 ctx.session_id.as_deref(),
1126 &request_continuation,
1127 compaction.attempt,
1128 );
1129 let chunk = translator.error_chunk(
1130 &message,
1131 "api_error",
1132 ctx.traffic.as_deref(),
1133 );
1134 if !chunk.is_empty() {
1135 record_live_stream_progress(&ctx, &chunk);
1136 let _ = tx.send(Ok(Bytes::from(chunk))).await;
1137 }
1138 return;
1139 }
1140 };
1141 if !chunk.is_empty() {
1142 record_live_stream_progress(&ctx, &chunk);
1143 if tx.send(Ok(Bytes::from(chunk))).await.is_err() {
1144 abort_request_state(
1145 ctx.session_id.as_deref(),
1146 &request_continuation,
1147 compaction.attempt,
1148 );
1149 return;
1150 }
1151 }
1152 if terminal {
1153 update_continuation_from_upstream(
1154 ctx.session_id.as_deref(),
1155 &request_continuation,
1156 compaction.attempt,
1157 &request_body,
1158 &upstream_sse_body,
1159 upstream_events.socket_id(),
1160 compaction.compact_boundary,
1161 );
1162 return;
1163 }
1164 }
1165 Err(err) => {
1166 abort_request_state(
1167 ctx.session_id.as_deref(),
1168 &request_continuation,
1169 compaction.attempt,
1170 );
1171 let chunk =
1172 translator.finish_after_closed_completed_tool_call(ctx.traffic.as_deref());
1173 if !chunk.is_empty() {
1174 record_live_stream_progress(&ctx, &chunk);
1175 let _ = tx.send(Ok(Bytes::from(chunk))).await;
1176 return;
1177 }
1178 let error_type = codex_stream_error_type(&err);
1179 let chunk = translator.error_chunk(
1180 codex_error_message(&err),
1181 error_type,
1182 ctx.traffic.as_deref(),
1183 );
1184 if !chunk.is_empty() {
1185 record_live_stream_progress(&ctx, &chunk);
1186 let _ = tx.send(Ok(Bytes::from(chunk))).await;
1187 }
1188 return;
1189 }
1190 }
1191 }
1192
1193 abort_request_state(
1194 ctx.session_id.as_deref(),
1195 &request_continuation,
1196 compaction.attempt,
1197 );
1198 let chunk = translator.finish_after_closed_completed_tool_call(ctx.traffic.as_deref());
1199 if !chunk.is_empty() {
1200 record_live_stream_progress(&ctx, &chunk);
1201 let _ = tx.send(Ok(Bytes::from(chunk))).await;
1202 return;
1203 }
1204 let chunk = translator.error_chunk(
1205 "Upstream event stream closed before terminal Codex response event",
1206 "api_error",
1207 ctx.traffic.as_deref(),
1208 );
1209 if !chunk.is_empty() {
1210 record_live_stream_progress(&ctx, &chunk);
1211 let _ = tx.send(Ok(Bytes::from(chunk))).await;
1212 }
1213 });
1214
1215 let stream = futures_util::stream::unfold(rx, |mut rx| async {
1216 rx.recv().await.map(|item| (item, rx))
1217 });
1218 event_stream_response(stream)
1219}
1220
1221fn append_upstream_sse_payload(buffer: &mut Vec<u8>, payload: &serde_json::Value) {
1222 let text = payload.to_string();
1223 for line in text.lines() {
1224 buffer.extend_from_slice(b"data: ");
1225 buffer.extend_from_slice(line.as_bytes());
1226 buffer.push(b'\n');
1227 }
1228 buffer.push(b'\n');
1229}
1230
1231fn event_stream_response<S>(stream: S) -> Response
1232where
1233 S: futures_util::Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
1234{
1235 let headers = [
1236 (http::header::CONTENT_TYPE, "text/event-stream"),
1237 (http::header::CACHE_CONTROL, "no-cache"),
1238 (http::header::CONNECTION, "keep-alive"),
1239 ];
1240 (headers, Body::from_stream(stream)).into_response()
1241}
1242
1243fn empty_buffered_completion_error() -> client::CodexError {
1244 client::CodexError {
1245 status: 503,
1246 message: "Codex completed without producing output".to_string(),
1247 detail: Some(EMPTY_CODEX_COMPLETION_DETAIL.to_string()),
1248 retry_after: None,
1249 origin: match config::codex_transport() {
1250 config::CodexTransport::Http => client::CodexErrorOrigin::BufferedHttp,
1251 _ => client::CodexErrorOrigin::BufferedWebSocket,
1252 },
1253 }
1254}
1255
1256fn is_empty_codex_success_completion(upstream_sse: &[u8]) -> bool {
1259 use self::translate::reducer::{ReducerEvent, TERM_COMPLETED, TERM_DONE};
1260
1261 let Ok(events) = self::translate::reducer::reduce_upstream_bytes(upstream_sse) else {
1262 return false;
1263 };
1264 let mut saw_success_terminal = false;
1265 for event in &events {
1266 match event {
1267 ReducerEvent::TextDelta { text, .. } if !text.is_empty() => return false,
1268 ReducerEvent::ThinkingStart { .. }
1269 | ReducerEvent::ToolStart { .. }
1270 | ReducerEvent::WebSearch { .. } => return false,
1271 ReducerEvent::Finish { terminal_type, .. }
1272 if terminal_type == TERM_COMPLETED || terminal_type == TERM_DONE =>
1273 {
1274 saw_success_terminal = true;
1275 }
1276 _ => {}
1277 }
1278 }
1279 saw_success_terminal
1280}
1281
1282fn is_codex_terminal_event(payload: &serde_json::Value) -> bool {
1283 matches!(
1284 payload.get("type").and_then(|v| v.as_str()),
1285 Some("response.completed")
1286 | Some("response.incomplete")
1287 | Some("response.done")
1288 | Some("response.failed")
1289 | Some("response.error")
1290 | Some("error")
1291 )
1292}
1293
1294fn is_codex_success_terminal_event(payload: &serde_json::Value) -> bool {
1295 matches!(
1296 payload.get("type").and_then(|v| v.as_str()),
1297 Some("response.completed") | Some("response.done")
1298 )
1299}
1300
1301fn retryable_live_start_codex_error(err: &client::CodexError) -> bool {
1302 if err.origin == client::CodexErrorOrigin::WebSocketHandshake {
1303 if err.detail.as_deref() == Some(websocket::WEBSOCKET_PROXY_TUNNEL_REJECTED_DETAIL) {
1304 return false;
1305 }
1306 return err.status == 0 || matches!(err.status, 429 | 500 | 502 | 503 | 504 | 529);
1307 }
1308 if err.detail.as_deref() == Some(websocket::WEBSOCKET_KEEPALIVE_FAILURE_DETAIL) {
1309 return true;
1310 }
1311 matches!(err.status, 429 | 500 | 502 | 503 | 504 | 529)
1312 || (err.status == 0 && retryable_live_message(codex_error_message(err)))
1313}
1314
1315fn is_missing_previous_response_error(err: &client::CodexError) -> bool {
1316 matches!(
1317 err.detail.as_deref(),
1318 Some("previous_response_not_found")
1319 | Some(websocket::WEBSOCKET_CONTINUATION_SOCKET_MISSING_DETAIL)
1320 )
1321}
1322
1323fn drop_live_continuation_for_retry(continuation: &mut Option<ContinuationReservation>) -> bool {
1324 if continuation
1325 .as_ref()
1326 .and_then(|reservation| reservation.candidate().previous_response_id.as_deref())
1327 .is_none()
1328 {
1329 return false;
1330 }
1331
1332 if let Some(reservation) = continuation.as_ref() {
1333 *continuation = Some(reservation.full_context_retry());
1334 }
1335 true
1336}
1337
1338fn retryable_live_message(message: &str) -> bool {
1339 let lower = message.to_ascii_lowercase();
1340 [
1341 "overloaded",
1342 "rate limit",
1343 "you can retry your request",
1344 "temporarily unavailable",
1345 "timed out",
1346 "connection closed",
1347 "connection reset",
1348 "broken pipe",
1349 "epipe",
1350 ]
1351 .iter()
1352 .any(|needle| lower.contains(needle))
1353}
1354
1355fn retryable_live_start_payload(payload: &serde_json::Value, _message: &str) -> bool {
1356 events::classify_event_failure(payload).is_some_and(|failure| failure.retryable())
1357}
1358
1359fn retry_after_from_live_payload(payload: &serde_json::Value) -> Option<String> {
1360 events::classify_event_failure(payload).and_then(|failure| failure.retry_after)
1361}
1362
1363fn codex_stream_error_type(err: &client::CodexError) -> &'static str {
1364 match err.status {
1365 429 => "rate_limit_error",
1366 529 => "overloaded_error",
1367 _ if codex_error_message(err)
1368 .to_lowercase()
1369 .contains("overloaded") =>
1370 {
1371 "overloaded_error"
1372 }
1373 _ => "api_error",
1374 }
1375}
1376
1377#[allow(clippy::too_many_arguments)]
1378fn update_continuation_from_upstream(
1379 session_id: Option<&str>,
1380 continuation: &ContinuationReservation,
1381 compaction_attempt: Option<CompactionAttempt>,
1382 request_body: &translate::request::ResponsesRequest,
1383 upstream_body: &[u8],
1384 socket_id: Option<u64>,
1385 compact_boundary: bool,
1386) {
1387 match finish_metadata_from_upstream(upstream_body) {
1388 Ok(Some(finish)) if finish.continuation_eligible => {
1389 if compact_boundary {
1390 activate_compaction(
1391 session_id,
1392 compaction_attempt,
1393 &request_body.model,
1394 &finish.output_items,
1395 );
1396 }
1397 record_continuation_for_owner(
1398 continuation,
1399 request_body,
1400 finish.response_id.as_deref(),
1401 socket_id,
1402 &finish.output_items,
1403 );
1404 }
1405 _ => {
1406 abort_compaction_attempt(session_id, compaction_attempt);
1407 abort_continuation_for_owner(continuation);
1408 }
1409 }
1410}
1411
1412fn map_codex_error_to_response(err: &client::CodexError) -> Response {
1417 let message = codex_error_message(err);
1418 if is_context_window_overflow(message) {
1419 return map_codex_failure_to_response(message);
1420 }
1421 if err.detail.as_deref() == Some(EMPTY_CODEX_COMPLETION_DETAIL) {
1422 return json_error(StatusCode::SERVICE_UNAVAILABLE, "api_error", &err.message);
1423 }
1424
1425 match err.status {
1426 401 => json_error(
1427 StatusCode::UNAUTHORIZED,
1428 "authentication_error",
1429 err.detail.as_deref().unwrap_or("Authentication failed"),
1430 ),
1431 403 => json_error(
1432 StatusCode::FORBIDDEN,
1433 "permission_error",
1434 err.detail.as_deref().unwrap_or("Permission denied"),
1435 ),
1436 429 => {
1437 let response = json_error(
1438 StatusCode::TOO_MANY_REQUESTS,
1439 "rate_limit_error",
1440 &err.message,
1441 );
1442 if let Some(retry_after) = err.retry_after.as_deref() {
1443 ([(http::header::RETRY_AFTER, retry_after)], response).into_response()
1444 } else {
1445 response
1446 }
1447 }
1448 status @ (400..=599) => {
1449 let response = json_error(
1450 StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY),
1451 if status == 529 {
1452 "overloaded_error"
1453 } else {
1454 "api_error"
1455 },
1456 codex_error_message(err),
1457 );
1458 if let Some(retry_after) = err.retry_after.as_deref() {
1459 ([(http::header::RETRY_AFTER, retry_after)], response).into_response()
1460 } else {
1461 response
1462 }
1463 }
1464 _ => json_error(
1465 StatusCode::BAD_GATEWAY,
1466 "api_error",
1467 codex_error_message(err),
1468 ),
1469 }
1470}
1471
1472fn map_codex_failure_to_response(message: &str) -> Response {
1473 if is_context_window_overflow(message) {
1474 json_error(StatusCode::PAYLOAD_TOO_LARGE, "request_too_large", message)
1475 } else {
1476 json_error(StatusCode::BAD_GATEWAY, "api_error", message)
1477 }
1478}
1479
1480fn is_context_window_overflow(message: &str) -> bool {
1481 message.to_ascii_lowercase().contains("context window")
1482}
1483
1484fn codex_error_message(err: &client::CodexError) -> &str {
1485 if err.status == 0 {
1486 err.message.as_str()
1487 } else {
1488 err.detail.as_deref().unwrap_or("Upstream error")
1489 }
1490}
1491
1492pub(crate) struct CodexCli;
1497
1498impl CliHandlers for CodexCli {
1499 fn login(&self) -> Result<(), anyhow::Error> {
1500 anyhow::bail!(
1501 "Codex auth is provided by the Codex CLI. Run `codex login` to create ~/.codex/auth.json; this proxy reads it directly."
1502 )
1503 }
1504
1505 fn device(&self) -> Result<(), anyhow::Error> {
1506 anyhow::bail!(
1507 "Codex auth is provided by the Codex CLI. Run `codex login` to create ~/.codex/auth.json; this proxy reads it directly."
1508 )
1509 }
1510
1511 fn status(&self) -> Result<(), anyhow::Error> {
1512 let store = file_store();
1513 let stored = store.load_auth()?;
1514 match stored {
1515 Some(auth) => {
1516 println!(
1517 "Account: {}",
1518 auth.account_id.as_deref().unwrap_or("(none)")
1519 );
1520 println!("{}", format_expiry(auth.expires, now_ms()));
1521 println!("Storage: {} (Codex CLI)", store.auth_path());
1522 Ok(())
1523 }
1524 None => anyhow::bail!(
1525 "No Codex credentials. Run `codex login` to create {}",
1526 store.auth_path()
1527 ),
1528 }
1529 }
1530
1531 fn logout(&self) -> Result<(), anyhow::Error> {
1532 println!(
1533 "Codex credentials are managed by the Codex CLI; run `codex logout` to remove them"
1534 );
1535 Ok(())
1536 }
1537}
1538
1539pub(crate) static CODEX_CLI: CodexCli = CodexCli;
1540
1541fn now_ms() -> u64 {
1546 std::time::SystemTime::now()
1547 .duration_since(std::time::UNIX_EPOCH)
1548 .unwrap_or_default()
1549 .as_millis() as u64
1550}
1551
1552fn format_expiry(expires: u64, now: u64) -> String {
1553 let remaining = (i128::from(expires) - i128::from(now)).div_euclid(1000);
1554 let iso = time::OffsetDateTime::from_unix_timestamp_nanos(i128::from(expires) * 1_000_000)
1555 .ok()
1556 .and_then(|dt| {
1557 let fmt = time::format_description::parse_borrowed::<2>(
1558 "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z",
1559 )
1560 .ok()?;
1561 dt.format(&fmt).ok()
1562 })
1563 .unwrap_or_else(|| "invalid".to_string());
1564 format!("Expires: {iso} (in {remaining}s)")
1565}
1566
1567#[cfg(test)]
1572mod tests {
1573 use futures_util::{SinkExt, StreamExt};
1574 use http_body_util::BodyExt;
1575 use tokio::net::{TcpListener, TcpStream};
1576 use tokio_tungstenite::tungstenite::Message;
1577
1578 use super::*;
1579
1580 fn live_test_request(text: &str) -> translate::request::ResponsesRequest {
1581 translate::request::ResponsesRequest {
1582 model: "gpt-5.6-sol".to_string(),
1583 instructions: None,
1584 input: vec![translate::request::ResponsesInputItem::Message {
1585 role: "user".to_string(),
1586 content: vec![translate::request::ResponsesContentPart::InputText {
1587 text: text.to_string(),
1588 }],
1589 }],
1590 tools: None,
1591 tool_choice: None,
1592 store: false,
1593 stream: true,
1594 parallel_tool_calls: true,
1595 include: None,
1596 client_metadata: None,
1597 service_tier: None,
1598 prompt_cache_key: None,
1599 text: translate::request::ResponsesText {
1600 verbosity: None,
1601 format: None,
1602 },
1603 reasoning: None,
1604 }
1605 }
1606
1607 fn live_test_context(session_id: &str) -> RequestContext {
1608 RequestContext {
1609 req_id: format!("request-{session_id}"),
1610 session_id: Some(session_id.to_string()),
1611 session_seq: None,
1612 provider: "codex".to_string(),
1613 traffic: None,
1614 monitor: None,
1615 passthrough: None,
1616 }
1617 }
1618
1619 fn authenticated_live_test_client(base_url: String) -> Arc<CodexHttpClient> {
1620 let client = CodexHttpClient::new_for_test(
1621 reqwest::Client::builder().no_proxy().build().unwrap(),
1622 base_url,
1623 1_000,
1624 1_000,
1625 0,
1626 );
1627 client
1628 .auth_manager()
1629 .set_test_auth(auth::token_store::StoredAuth {
1630 access: "test".to_string(),
1631 refresh: String::new(),
1632 expires: u64::MAX,
1633 account_id: Some("acct".to_string()),
1634 });
1635 Arc::new(client)
1636 }
1637
1638 async fn next_live_websocket_request(
1639 websocket: &mut tokio_tungstenite::WebSocketStream<TcpStream>,
1640 ) -> serde_json::Value {
1641 loop {
1642 match websocket.next().await {
1643 Some(Ok(Message::Ping(payload))) => {
1644 websocket.send(Message::Pong(payload)).await.unwrap();
1645 }
1646 Some(Ok(Message::Text(text))) => return serde_json::from_str(&text).unwrap(),
1647 other => panic!("unexpected WebSocket request frame: {other:?}"),
1648 }
1649 }
1650 }
1651
1652 async fn emit_live_event(
1653 websocket: &mut tokio_tungstenite::WebSocketStream<TcpStream>,
1654 event: &serde_json::Value,
1655 ) {
1656 websocket
1657 .send(Message::Text(event.to_string()))
1658 .await
1659 .unwrap();
1660 }
1661
1662 fn upstream_sse(events: &[serde_json::Value]) -> Vec<u8> {
1663 let mut bytes = Vec::new();
1664 for event in events {
1665 bytes.extend_from_slice(format!("data: {event}\n\n").as_bytes());
1666 }
1667 bytes
1668 }
1669
1670 #[test]
1671 fn terminal_only_completed_upstream_is_empty_completion() {
1672 let body = upstream_sse(&[serde_json::json!({
1673 "type": "response.completed",
1674 "response": {"id": "resp_1", "status": "completed", "incomplete_details": null, "usage": {"input_tokens": 5, "output_tokens": 0}}
1675 })]);
1676 assert!(is_empty_codex_success_completion(&body));
1677 }
1678
1679 #[test]
1680 fn terminal_only_done_upstream_is_empty_completion() {
1681 let body = upstream_sse(&[serde_json::json!({
1682 "type": "response.done",
1683 "response": {"id": "resp_1", "usage": {}}
1684 })]);
1685 assert!(is_empty_codex_success_completion(&body));
1686 }
1687
1688 #[test]
1689 fn empty_message_item_is_empty_completion() {
1690 let body = upstream_sse(&[
1691 serde_json::json!({
1692 "type": "response.output_item.added",
1693 "output_index": 0,
1694 "item": {"type": "message", "id": "msg_1"}
1695 }),
1696 serde_json::json!({
1697 "type": "response.output_item.done",
1698 "output_index": 0,
1699 "item": {"type": "message"}
1700 }),
1701 serde_json::json!({
1702 "type": "response.completed",
1703 "response": {"id": "resp_1", "usage": {}}
1704 }),
1705 ]);
1706 assert!(is_empty_codex_success_completion(&body));
1707 }
1708
1709 #[test]
1710 fn upstream_with_text_is_not_empty_completion() {
1711 let body = upstream_sse(&[
1712 serde_json::json!({
1713 "type": "response.output_item.added",
1714 "output_index": 0,
1715 "item": {"type": "message", "id": "msg_1"}
1716 }),
1717 serde_json::json!({
1718 "type": "response.output_text.delta",
1719 "output_index": 0,
1720 "delta": "hello"
1721 }),
1722 serde_json::json!({
1723 "type": "response.output_item.done",
1724 "output_index": 0,
1725 "item": {"type": "message"}
1726 }),
1727 serde_json::json!({
1728 "type": "response.completed",
1729 "response": {"id": "resp_1", "usage": {}}
1730 }),
1731 ]);
1732 assert!(!is_empty_codex_success_completion(&body));
1733 }
1734
1735 #[test]
1736 fn upstream_with_tool_call_is_not_empty_completion() {
1737 let body = upstream_sse(&[
1738 serde_json::json!({
1739 "type": "response.output_item.added",
1740 "output_index": 0,
1741 "item": {"type": "function_call", "call_id": "call_1", "name": "Read", "arguments": ""}
1742 }),
1743 serde_json::json!({
1744 "type": "response.output_item.done",
1745 "output_index": 0,
1746 "item": {"type": "function_call", "call_id": "call_1", "name": "Read", "arguments": "{}"}
1747 }),
1748 serde_json::json!({
1749 "type": "response.completed",
1750 "response": {"id": "resp_1", "usage": {}}
1751 }),
1752 ]);
1753 assert!(!is_empty_codex_success_completion(&body));
1754 }
1755
1756 #[test]
1757 fn terminal_only_incomplete_upstream_is_not_empty_completion() {
1758 let body = upstream_sse(&[serde_json::json!({
1759 "type": "response.incomplete",
1760 "response": {"id": "resp_1", "incomplete_details": {"reason": "max_output_tokens"}, "usage": {}}
1761 })]);
1762 assert!(!is_empty_codex_success_completion(&body));
1763 }
1764
1765 #[test]
1766 fn upstream_without_terminal_event_is_not_empty_completion() {
1767 assert!(!is_empty_codex_success_completion(&upstream_sse(&[])));
1768 }
1769
1770 fn request_with_tools(tools: serde_json::Value) -> MessagesRequest {
1771 serde_json::from_value(serde_json::json!({
1772 "model": "gpt-5.6-luna",
1773 "messages": [{"role":"user", "content":"find it"}],
1774 "tools": tools
1775 }))
1776 .unwrap()
1777 }
1778
1779 #[test]
1780 fn web_search_requests_leave_lite_lane_and_upgrade_luna() {
1781 let body = request_with_tools(serde_json::json!([
1782 {"type":"web_search_20250305", "name":"web_search"}
1783 ]));
1784 for (resolved, expected) in [
1785 ("gpt-5.6-luna", "gpt-5.6-sol"),
1786 ("gpt-5.6-sol", "gpt-5.6-sol"),
1787 ("gpt-5.6-terra", "gpt-5.6-terra"),
1788 ("gpt-5.4", "gpt-5.4"),
1789 ] {
1790 let mut model = resolved.to_string();
1791 let lite = apply_model_lane_for_request(&mut model, &body);
1792 assert!(!lite, "{resolved} with web_search must use the full lane");
1793 assert_eq!(model, expected);
1794 }
1795 }
1796
1797 #[test]
1798 fn requests_without_web_search_keep_model_and_lite_lane() {
1799 let body = request_with_tools(serde_json::json!([
1800 {"name":"Bash", "input_schema":{}}
1801 ]));
1802 for (resolved, lite_expected) in [
1803 ("gpt-5.6-luna", true),
1804 ("gpt-5.6-sol", true),
1805 ("gpt-5.4", false),
1806 ] {
1807 let mut model = resolved.to_string();
1808 let lite = apply_model_lane_for_request(&mut model, &body);
1809 assert_eq!(model, resolved, "model must not change without web_search");
1810 assert_eq!(lite, lite_expected);
1811 }
1812 }
1813
1814 #[test]
1815 fn generation_timing_ignores_control_events() {
1816 assert!(!codex_generation_event(&serde_json::json!({
1817 "type": "codex.rate_limits"
1818 })));
1819 assert!(!codex_generation_event(&serde_json::json!({
1820 "type": "keepalive"
1821 })));
1822 assert!(codex_generation_event(&serde_json::json!({
1823 "type": "response.created"
1824 })));
1825 }
1826
1827 #[test]
1828 fn live_stream_progress_records_terminal_usage() {
1829 let monitor = crate::monitor::MonitorHandle::new(10);
1830 monitor.request_started(
1831 "request",
1832 None,
1833 None,
1834 crate::monitor::EndpointKind::Messages,
1835 );
1836 let ctx = RequestContext {
1837 req_id: "request".to_string(),
1838 session_id: None,
1839 session_seq: None,
1840 provider: "codex".to_string(),
1841 traffic: None,
1842 monitor: Some(monitor.clone()),
1843 passthrough: None,
1844 };
1845 let chunk = b"event: message_delta\ndata: {\"type\":\"message_delta\",\"usage\":{\"input_tokens\":12,\"output_tokens\":48}}\n\n";
1846
1847 record_live_stream_progress(&ctx, chunk);
1848
1849 let state = monitor.snapshot();
1850 assert_eq!(state.active[0].input_tokens, Some(12));
1851 assert_eq!(state.active[0].output_tokens, Some(48));
1852 }
1853
1854 #[tokio::test]
1855 async fn live_stream_response_emits_downstream_frames_before_terminal_event() {
1856 use http_body_util::BodyExt as _;
1857
1858 let body = request_with_tools(serde_json::json!([]));
1859 let request_body = translate_request(
1860 &body,
1861 TranslateOptions {
1862 session_id: None,
1863 service_tier: None,
1864 model: "gpt-5.6-sol".to_string(),
1865 use_responses_lite: true,
1866 },
1867 )
1868 .unwrap();
1869 let ctx = RequestContext {
1870 req_id: "incremental-http".to_string(),
1871 session_id: None,
1872 session_seq: None,
1873 provider: "codex".to_string(),
1874 traffic: None,
1875 monitor: None,
1876 passthrough: None,
1877 };
1878 let (tx, rx) = tokio::sync::mpsc::channel(8);
1879 tx.send(Ok(serde_json::json!({"type": "keepalive"})))
1880 .await
1881 .unwrap();
1882 tx.send(Ok(serde_json::json!({
1883 "type": "response.output_item.added",
1884 "output_index": 0,
1885 "item": {"type": "message", "id": "msg_up"}
1886 })))
1887 .await
1888 .unwrap();
1889 tx.send(Ok(serde_json::json!({
1890 "type": "response.output_text.delta",
1891 "output_index": 0,
1892 "delta": "first"
1893 })))
1894 .await
1895 .unwrap();
1896
1897 let (rx, _) = websocket::CodexWebSocketEventStream::pending(rx);
1898 let continuation = ContinuationReservation::for_owner_turn(None, None);
1899 let response = match live_stream_response_once(
1900 rx,
1901 "msg_test".to_string(),
1902 "claude-opus-4-8",
1903 ctx,
1904 continuation,
1905 request_body,
1906 LiveStreamCompaction {
1907 compact_boundary: false,
1908 attempt: None,
1909 },
1910 )
1911 .await
1912 {
1913 LiveStreamStart::Response(response) => response,
1914 LiveStreamStart::Retry { error, .. } => panic!("unexpected retry: {error}"),
1915 };
1916 let mut body = response.into_body();
1917 let first = tokio::time::timeout(Duration::from_millis(200), body.frame())
1918 .await
1919 .expect("initial downstream frame must be available immediately")
1920 .unwrap()
1921 .unwrap()
1922 .into_data()
1923 .unwrap();
1924 let first = String::from_utf8(first.to_vec()).unwrap();
1925 assert!(first.contains("event: message_start"));
1926 assert!(first.contains("event: ping"));
1927 assert!(first.contains("event: content_block_start"));
1928 assert!(first.contains("event: content_block_delta"));
1929
1930 tx.send(Ok(serde_json::json!({
1931 "type": "response.output_text.delta",
1932 "output_index": 0,
1933 "delta": "second"
1934 })))
1935 .await
1936 .unwrap();
1937 let second = tokio::time::timeout(Duration::from_millis(200), body.frame())
1938 .await
1939 .expect("text delta must arrive before the terminal event")
1940 .unwrap()
1941 .unwrap()
1942 .into_data()
1943 .unwrap();
1944 assert!(
1945 String::from_utf8(second.to_vec())
1946 .unwrap()
1947 .contains("event: content_block_delta")
1948 );
1949
1950 for payload in [
1951 serde_json::json!({
1952 "type": "response.output_item.done",
1953 "output_index": 0,
1954 "item": {"type": "message"}
1955 }),
1956 serde_json::json!({
1957 "type": "response.completed",
1958 "response": {
1959 "id": "resp_1",
1960 "status": "completed",
1961 "incomplete_details": null,
1962 "usage": {"input_tokens": 1, "output_tokens": 1}
1963 }
1964 }),
1965 ] {
1966 tx.send(Ok(payload)).await.unwrap();
1967 }
1968 drop(tx);
1969 while let Some(frame) = body.frame().await {
1970 frame.unwrap();
1971 }
1972 }
1973
1974 #[test]
1975 fn supported_models_includes_fast_variants() {
1976 let provider = CodexProvider::new();
1977 let models = provider.supported_models();
1978 assert!(models.contains(&"gpt-5.6-sol".to_string()));
1979 assert!(models.contains(&"gpt-5.6-sol-fast".to_string()));
1980 assert!(models.contains(&"gpt-5.6-terra".to_string()));
1981 assert!(models.contains(&"gpt-5.6-luna".to_string()));
1982 assert!(models.contains(&"gpt-5.4".to_string()));
1983 assert!(models.contains(&"gpt-5.4-mini".to_string()));
1984 }
1985
1986 #[test]
1987 fn format_expiry_with_future_expiry() {
1988 let expires = 4102444800000;
1990 let now = 4102444790000; let output = format_expiry(expires, now);
1992 assert!(output.starts_with("Expires: 2100-01-01T00:00:00.000Z (in "));
1993 assert!(output.ends_with("s)"));
1994 }
1995
1996 #[test]
1997 fn format_expiry_with_past_expiry() {
1998 let expires = 946684800000;
2000 let now = 946684810000; let output = format_expiry(expires, now);
2002 assert!(output.starts_with("Expires: 2000-01-01T00:00:00.000Z (in -"));
2003 }
2004
2005 #[tokio::test]
2006 async fn live_upstream_status_and_retry_after_are_preserved() {
2007 let err = client::CodexError {
2008 status: 422,
2009 message: "invalid request".to_string(),
2010 detail: Some("invalid request".to_string()),
2011 retry_after: Some("7".to_string()),
2012 origin: client::CodexErrorOrigin::WebSocketHandshake,
2013 };
2014 let response = map_codex_error_to_response(&err);
2015 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
2016 assert_eq!(
2017 response.headers().get(http::header::RETRY_AFTER).unwrap(),
2018 "7"
2019 );
2020 }
2021
2022 #[tokio::test]
2023 async fn statusless_codex_error_returns_source_message() {
2024 let err = client::CodexError {
2025 status: 0,
2026 message: "WebSocket connect error: HTTP error: 502 Bad Gateway".to_string(),
2027 detail: None,
2028 retry_after: None,
2029 origin: client::CodexErrorOrigin::WebSocket,
2030 };
2031
2032 let response = map_codex_error_to_response(&err);
2033 assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
2034
2035 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2036 .await
2037 .unwrap();
2038 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2039 assert_eq!(
2040 body.pointer("/error/message").and_then(|v| v.as_str()),
2041 Some("WebSocket connect error: HTTP error: 502 Bad Gateway")
2042 );
2043 }
2044
2045 #[tokio::test]
2046 async fn empty_live_completion_maps_to_explicit_service_unavailable() {
2047 let err = empty_live_completion_error();
2048
2049 assert_eq!(err.status, 503);
2050 assert_eq!(err.detail.as_deref(), Some(EMPTY_CODEX_COMPLETION_DETAIL));
2051 assert_eq!(
2052 map_codex_error_to_response(&err).status(),
2053 StatusCode::SERVICE_UNAVAILABLE
2054 );
2055 }
2056
2057 #[test]
2058 fn live_start_statusless_websocket_handshake_error_is_retryable() {
2059 let err = client::CodexError {
2060 status: 0,
2061 message: "WebSocket connect timeout after 15000ms".to_string(),
2062 detail: None,
2063 retry_after: None,
2064 origin: client::CodexErrorOrigin::WebSocketHandshake,
2065 };
2066
2067 assert!(retryable_live_start_codex_error(&err));
2068 }
2069
2070 #[test]
2071 fn live_start_proxy_tunnel_rejection_is_not_retryable() {
2072 let err = client::CodexError {
2073 status: 0,
2074 message: "WebSocket proxy tunnel was rejected".to_string(),
2075 detail: Some(websocket::WEBSOCKET_PROXY_TUNNEL_REJECTED_DETAIL.to_string()),
2076 retry_after: None,
2077 origin: client::CodexErrorOrigin::WebSocketHandshake,
2078 };
2079
2080 assert!(!retryable_live_start_codex_error(&err));
2081 }
2082
2083 #[test]
2084 fn live_start_keepalive_failure_is_retryable() {
2085 let err = client::CodexError {
2086 status: 0,
2087 message: "WebSocket keepalive error: test write failed".to_string(),
2088 detail: Some(websocket::WEBSOCKET_KEEPALIVE_FAILURE_DETAIL.to_string()),
2089 retry_after: None,
2090 origin: client::CodexErrorOrigin::WebSocket,
2091 };
2092
2093 assert!(retryable_live_start_codex_error(&err));
2094 }
2095
2096 #[test]
2097 fn live_start_payload_retry_detection_covers_rate_limit_and_overload() {
2098 assert!(retryable_live_start_payload(
2099 &serde_json::json!({
2100 "type": "codex.rate_limits",
2101 "rate_limits": {"limit_reached": true}
2102 }),
2103 "rate limit reached",
2104 ));
2105 assert!(retryable_live_start_payload(
2106 &serde_json::json!({
2107 "type": "response.failed",
2108 "response": {"error": {"type": "overloaded_error", "message": "overloaded"}}
2109 }),
2110 "overloaded",
2111 ));
2112 assert!(!retryable_live_start_payload(
2113 &serde_json::json!({
2114 "type": "response.failed",
2115 "response": {"error": {"message": "bad request"}}
2116 }),
2117 "bad request",
2118 ));
2119 }
2120
2121 async fn run_live_failure_case(
2122 session_id: &str,
2123 event: serde_json::Value,
2124 expected_attempts: usize,
2125 ) -> StatusCode {
2126 let owner = ConversationIdentity::Main(session_id.to_string());
2127 continuation::clear_continuation_for_owner(Some(&owner));
2128 websocket::invalidate_codex_websocket_pool_owner(&owner);
2129 let request = live_test_request("one");
2130 let continuation = continuation_candidate_for_owner(Some(&owner), &request, true);
2131 let compaction_attempt = begin_compaction(session_id, &request.model);
2132
2133 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2134 let addr = listener.local_addr().unwrap();
2135 let server = tokio::spawn(async move {
2136 for _ in 0..expected_attempts {
2137 let (socket, _) = listener.accept().await.unwrap();
2138 let mut websocket = tokio_tungstenite::accept_async(socket).await.unwrap();
2139 let _ = next_live_websocket_request(&mut websocket).await;
2140 emit_live_event(&mut websocket, &event).await;
2141 drop(websocket);
2142 }
2143 });
2144 let client = authenticated_live_test_client(format!("http://{addr}/responses"));
2145 let response = tokio::time::timeout(
2146 std::time::Duration::from_secs(15),
2147 live_stream_response(
2148 client,
2149 "message".to_string(),
2150 &request.model,
2151 live_test_context(session_id),
2152 request.clone(),
2153 continuation.clone(),
2154 LiveStreamCompaction {
2155 compact_boundary: false,
2156 attempt: Some(compaction_attempt),
2157 },
2158 config::CodexTransport::WebSocket,
2159 ),
2160 )
2161 .await
2162 .expect("live failure case timed out");
2163 tokio::time::timeout(std::time::Duration::from_secs(5), server)
2164 .await
2165 .expect("live failure server timed out")
2166 .expect("live failure server failed");
2167
2168 assert!(!continuation::is_current_turn_for_owner(&continuation));
2169 assert!(!store_compaction(
2170 session_id,
2171 compaction_attempt,
2172 Vec::new()
2173 ));
2174 websocket::invalidate_codex_websocket_pool_owner(&owner);
2175 response.status()
2176 }
2177
2178 #[tokio::test]
2179 async fn dropping_live_stream_during_retry_backoff_aborts_request_state() {
2180 let _registry_guard = continuation::lock_continuation_registry_for_async_tests().await;
2181 let _pool_guard = websocket::lock_codex_websocket_pool_for_tests().await;
2182 let session_id = "live-retry-backoff-cleanup";
2183 let owner = ConversationIdentity::Main(session_id.to_string());
2184 continuation::clear_continuation_for_owner(Some(&owner));
2185 websocket::invalidate_codex_websocket_pool_owner(&owner);
2186 let request = live_test_request("one");
2187 let continuation = continuation_candidate_for_owner(Some(&owner), &request, true);
2188 let compaction_attempt = begin_compaction(session_id, &request.model);
2189
2190 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2191 let addr = listener.local_addr().unwrap();
2192 let (event_sent_tx, event_sent_rx) = tokio::sync::oneshot::channel();
2193 let (socket_closed_tx, socket_closed_rx) = tokio::sync::oneshot::channel();
2194 let server = tokio::spawn(async move {
2195 let (socket, _) = listener.accept().await.unwrap();
2196 let mut websocket = tokio_tungstenite::accept_async(socket).await.unwrap();
2197 let _ = next_live_websocket_request(&mut websocket).await;
2198 emit_live_event(
2199 &mut websocket,
2200 &serde_json::json!({
2201 "type": "codex.rate_limits",
2202 "rate_limits": {"allowed": false, "limit_reached": true}
2203 }),
2204 )
2205 .await;
2206 event_sent_tx.send(()).unwrap();
2207 drop(websocket);
2208 socket_closed_tx.send(()).unwrap();
2209 });
2210 let client = authenticated_live_test_client(format!("http://{addr}/responses"));
2211 let task_request = request.clone();
2212 let task_continuation = continuation.clone();
2213 let response_task = tokio::spawn(async move {
2214 let model = task_request.model.clone();
2215 live_stream_response(
2216 client,
2217 "message".to_string(),
2218 &model,
2219 live_test_context(session_id),
2220 task_request,
2221 task_continuation,
2222 LiveStreamCompaction {
2223 compact_boundary: false,
2224 attempt: Some(compaction_attempt),
2225 },
2226 config::CodexTransport::WebSocket,
2227 )
2228 .await
2229 });
2230
2231 event_sent_rx.await.unwrap();
2232 tokio::time::timeout(std::time::Duration::from_secs(1), socket_closed_rx)
2233 .await
2234 .expect("retry handoff did not close the abandoned attempt socket")
2235 .expect("retry handoff socket-close sender dropped");
2236 for _ in 0..16 {
2237 tokio::task::yield_now().await;
2238 }
2239 assert!(
2240 !response_task.is_finished(),
2241 "logical request must still be waiting in retry backoff"
2242 );
2243 response_task.abort();
2244 assert!(response_task.await.unwrap_err().is_cancelled());
2245
2246 assert!(!continuation::is_current_turn_for_owner(&continuation));
2247 assert!(!store_compaction(
2248 session_id,
2249 compaction_attempt,
2250 Vec::new()
2251 ));
2252 server.await.unwrap();
2253 websocket::invalidate_codex_websocket_pool_owner(&owner);
2254 }
2255
2256 #[tokio::test]
2257 async fn dropping_live_response_body_after_first_chunk_aborts_request_state() {
2258 let _registry_guard = continuation::lock_continuation_registry_for_async_tests().await;
2259 let _pool_guard = websocket::lock_codex_websocket_pool_for_tests().await;
2260 let session_id = "live-response-body-drop-cleanup";
2261 let owner = ConversationIdentity::Main(session_id.to_string());
2262 continuation::clear_continuation_for_owner(Some(&owner));
2263 websocket::invalidate_codex_websocket_pool_owner(&owner);
2264 let request = live_test_request("one");
2265 let continuation = continuation_candidate_for_owner(Some(&owner), &request, true);
2266 let compaction_attempt = begin_compaction(session_id, &request.model);
2267
2268 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2269 let addr = listener.local_addr().unwrap();
2270 let (socket_closed_tx, socket_closed_rx) = tokio::sync::oneshot::channel();
2271 let server = tokio::spawn(async move {
2272 let (socket, _) = listener.accept().await.unwrap();
2273 let mut websocket = tokio_tungstenite::accept_async(socket).await.unwrap();
2274 let _ = next_live_websocket_request(&mut websocket).await;
2275 emit_live_event(
2276 &mut websocket,
2277 &serde_json::json!({
2278 "type": "response.output_item.added",
2279 "output_index": 0,
2280 "item": {"type": "message", "id": "msg-partial"}
2281 }),
2282 )
2283 .await;
2284 emit_live_event(
2285 &mut websocket,
2286 &serde_json::json!({
2287 "type": "response.output_text.delta",
2288 "output_index": 0,
2289 "delta": "partial"
2290 }),
2291 )
2292 .await;
2293 while websocket.next().await.is_some() {}
2294 socket_closed_tx.send(()).unwrap();
2295 });
2296 let client = authenticated_live_test_client(format!("http://{addr}/responses"));
2297
2298 let response = tokio::time::timeout(
2299 std::time::Duration::from_secs(2),
2300 live_stream_response(
2301 client,
2302 "message".to_string(),
2303 &request.model,
2304 live_test_context(session_id),
2305 request.clone(),
2306 continuation.clone(),
2307 LiveStreamCompaction {
2308 compact_boundary: false,
2309 attempt: Some(compaction_attempt),
2310 },
2311 config::CodexTransport::WebSocket,
2312 ),
2313 )
2314 .await
2315 .expect("live response did not publish the first chunk");
2316 let mut body = response.into_body();
2317 tokio::time::timeout(std::time::Duration::from_secs(1), body.frame())
2318 .await
2319 .expect("first downstream chunk timed out")
2320 .expect("live response body ended before the first chunk")
2321 .expect("first downstream chunk failed");
2322 drop(body);
2323
2324 tokio::time::timeout(std::time::Duration::from_secs(1), socket_closed_rx)
2325 .await
2326 .expect("dropping the downstream body did not close the upstream socket")
2327 .expect("socket-close acknowledgement sender dropped");
2328 assert!(!continuation::is_current_turn_for_owner(&continuation));
2329 assert!(!store_compaction(
2330 session_id,
2331 compaction_attempt,
2332 Vec::new()
2333 ));
2334 server.await.unwrap();
2335 websocket::invalidate_codex_websocket_pool_owner(&owner);
2336 }
2337
2338 #[tokio::test]
2339 async fn stale_request_cleanup_preserves_newer_turn_and_compaction_attempt() {
2340 let _registry_guard = continuation::lock_continuation_registry_for_async_tests().await;
2341 let session_id = "stale-live-request-cleanup";
2342 let owner = ConversationIdentity::Main(session_id.to_string());
2343 continuation::clear_continuation_for_owner(Some(&owner));
2344 let request = live_test_request("one");
2345 let stale_continuation = continuation_candidate_for_owner(Some(&owner), &request, true);
2346 let stale_compaction = begin_compaction(session_id, &request.model);
2347 let stale_cleanup = LiveRequestStateCleanup::new(
2348 stale_continuation,
2349 Some(session_id.to_string()),
2350 Some(stale_compaction),
2351 );
2352
2353 let newer_continuation = continuation_candidate_for_owner(Some(&owner), &request, true);
2354 let newer_compaction = begin_compaction(session_id, &request.model);
2355 drop(stale_cleanup);
2356
2357 assert!(continuation::is_current_turn_for_owner(&newer_continuation));
2358 assert!(store_compaction(session_id, newer_compaction, Vec::new()));
2359 abort_request_state(
2360 Some(session_id),
2361 &newer_continuation,
2362 Some(newer_compaction),
2363 );
2364 }
2365
2366 #[tokio::test]
2367 async fn retry_exhaustion_aborts_live_request_state_after_eleven_attempts() {
2368 let _registry_guard = continuation::lock_continuation_registry_for_async_tests().await;
2369 let _pool_guard = websocket::lock_codex_websocket_pool_for_tests().await;
2370 let status = run_live_failure_case(
2371 "live-retry-exhaustion-cleanup",
2372 serde_json::json!({
2373 "type": "codex.rate_limits",
2374 "rate_limits": {
2375 "allowed": false,
2376 "limit_reached": true,
2377 "primary": {"reset_after_seconds": 0}
2378 }
2379 }),
2380 11,
2381 )
2382 .await;
2383 assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
2384 }
2385
2386 #[tokio::test]
2387 async fn excessive_retry_after_aborts_live_request_state() {
2388 let _registry_guard = continuation::lock_continuation_registry_for_async_tests().await;
2389 let _pool_guard = websocket::lock_codex_websocket_pool_for_tests().await;
2390 let status = run_live_failure_case(
2391 "live-excessive-retry-after-cleanup",
2392 serde_json::json!({
2393 "type": "codex.rate_limits",
2394 "rate_limits": {
2395 "allowed": false,
2396 "limit_reached": true,
2397 "primary": {"reset_after_seconds": 31}
2398 }
2399 }),
2400 1,
2401 )
2402 .await;
2403 assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
2404 }
2405
2406 #[tokio::test]
2407 async fn nonretryable_live_error_aborts_request_state() {
2408 let _registry_guard = continuation::lock_continuation_registry_for_async_tests().await;
2409 let _pool_guard = websocket::lock_codex_websocket_pool_for_tests().await;
2410 let status = run_live_failure_case(
2411 "live-nonretryable-cleanup",
2412 serde_json::json!({
2413 "type": "response.failed",
2414 "response": {
2415 "status": "failed",
2416 "error": {"message": "invalid request"}
2417 }
2418 }),
2419 1,
2420 )
2421 .await;
2422 assert_eq!(status, StatusCode::BAD_GATEWAY);
2423 }
2424
2425 #[tokio::test]
2426 async fn cancellation_while_replacement_startup_is_blocked_aborts_request_state() {
2427 let _registry_guard = continuation::lock_continuation_registry_for_async_tests().await;
2428 let _pool_guard = websocket::lock_codex_websocket_pool_for_tests().await;
2429 let session_id = "live-blocked-replacement-cleanup";
2430 let owner = ConversationIdentity::Main(session_id.to_string());
2431 continuation::clear_continuation_for_owner(Some(&owner));
2432 websocket::invalidate_codex_websocket_pool_owner(&owner);
2433 let request = live_test_request("one");
2434 let continuation = continuation_candidate_for_owner(Some(&owner), &request, true);
2435 let compaction_attempt = begin_compaction(session_id, &request.model);
2436
2437 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2438 let addr = listener.local_addr().unwrap();
2439 let (replacement_accepted_tx, replacement_accepted_rx) = tokio::sync::oneshot::channel();
2440 let (release_replacement_tx, release_replacement_rx) = tokio::sync::oneshot::channel();
2441 let server = tokio::spawn(async move {
2442 let (first_socket, _) = listener.accept().await.unwrap();
2443 let mut first_websocket = tokio_tungstenite::accept_async(first_socket).await.unwrap();
2444 let _ = next_live_websocket_request(&mut first_websocket).await;
2445 emit_live_event(
2446 &mut first_websocket,
2447 &serde_json::json!({
2448 "type": "codex.rate_limits",
2449 "rate_limits": {
2450 "allowed": false,
2451 "limit_reached": true,
2452 "primary": {"reset_after_seconds": 0}
2453 }
2454 }),
2455 )
2456 .await;
2457 drop(first_websocket);
2458
2459 let (_replacement_socket, _) = listener.accept().await.unwrap();
2460 replacement_accepted_tx.send(()).unwrap();
2461 let _ = release_replacement_rx.await;
2462 });
2463 let client = authenticated_live_test_client(format!("http://{addr}/responses"));
2464 let task_request = request.clone();
2465 let task_continuation = continuation.clone();
2466 let response_task = tokio::spawn(async move {
2467 let model = task_request.model.clone();
2468 live_stream_response(
2469 client,
2470 "message".to_string(),
2471 &model,
2472 live_test_context(session_id),
2473 task_request,
2474 task_continuation,
2475 LiveStreamCompaction {
2476 compact_boundary: false,
2477 attempt: Some(compaction_attempt),
2478 },
2479 config::CodexTransport::WebSocket,
2480 )
2481 .await
2482 });
2483
2484 tokio::time::timeout(std::time::Duration::from_secs(2), replacement_accepted_rx)
2485 .await
2486 .expect("replacement startup did not reach the blocked handshake")
2487 .expect("replacement startup acknowledgement sender dropped");
2488 response_task.abort();
2489 assert!(response_task.await.unwrap_err().is_cancelled());
2490 let _ = release_replacement_tx.send(());
2491 server.await.unwrap();
2492
2493 assert!(!continuation::is_current_turn_for_owner(&continuation));
2494 assert!(!store_compaction(
2495 session_id,
2496 compaction_attempt,
2497 Vec::new()
2498 ));
2499 websocket::invalidate_codex_websocket_pool_owner(&owner);
2500 }
2501}