Skip to content

Commit 7d0fd4a

Browse files
committed
codex: capture only committed live events
Live stream translation writes traffic artifacts while initial Anthropic framing is still buffered. Empty completion retries discard those bytes, but the capture previously presented them as events delivered to the client. Translate the buffered prefix without a live capture sink, then parse and record its Anthropic SSE events only when the prefix is committed downstream. Later stream events continue to use direct capture. Extend the retry smoke test to prove discarded end-turn framing stays out of downstream artifacts. Client-visible streaming and retry behavior is unchanged.
1 parent 84eb66b commit 7d0fd4a

2 files changed

Lines changed: 67 additions & 24 deletions

File tree

src/providers/codex/mod.rs

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use std::sync::Arc;
2020

2121
use crate::anthropic::error::json_error;
2222
use crate::anthropic::schema::{CountTokensResponse, MessagesRequest};
23+
use crate::anthropic::sse::parse_sse_events;
2324
use crate::config;
2425
use crate::logging::create_logger;
2526
use crate::monitor::usage_from_anthropic_sse;
@@ -563,7 +564,7 @@ async fn live_stream_response_once(
563564
generation_started = true;
564565
}
565566
append_upstream_sse_payload(&mut upstream_sse_body, &payload);
566-
let (chunk, terminal) = match translate_live_stream_payload(&mut translator, &payload, &ctx)
567+
let (chunk, terminal) = match translate_live_stream_payload(&mut translator, &payload, None)
567568
{
568569
Ok(result) => result,
569570
Err(message) => {
@@ -619,6 +620,7 @@ async fn live_stream_response_once(
619620
};
620621
}
621622
if translator.has_semantic_output() && !pending_chunk.is_empty() {
623+
record_live_stream_downstream_capture(&ctx, &pending_chunk);
622624
record_live_stream_progress(&ctx, &pending_chunk);
623625
if terminal {
624626
update_continuation_from_upstream(
@@ -652,6 +654,7 @@ async fn live_stream_response_once(
652654
if pending_chunk.is_empty() {
653655
return LiveStreamStart::Response(empty_live_stream_response());
654656
}
657+
record_live_stream_downstream_capture(&ctx, &pending_chunk);
655658
record_live_stream_progress(&ctx, &pending_chunk);
656659
return LiveStreamStart::Response(single_live_stream_response(pending_chunk));
657660
}
@@ -688,13 +691,31 @@ fn codex_generation_event(payload: &serde_json::Value) -> bool {
688691
fn translate_live_stream_payload(
689692
translator: &mut LiveStreamTranslator,
690693
payload: &serde_json::Value,
691-
ctx: &RequestContext,
694+
traffic: Option<&crate::traffic::TrafficCapture>,
692695
) -> Result<(Vec<u8>, bool), String> {
693-
let chunk = translator.accept(payload, ctx.traffic.as_deref())?;
696+
let chunk = translator.accept(payload, traffic)?;
694697
let terminal = is_codex_terminal_event(payload) || translator.is_finished();
695698
Ok((chunk, terminal))
696699
}
697700

701+
fn record_live_stream_downstream_capture(ctx: &RequestContext, chunk: &[u8]) {
702+
let Some(traffic) = ctx.traffic.as_ref() else {
703+
return;
704+
};
705+
for event in parse_sse_events(chunk) {
706+
let Ok(data) = serde_json::from_str::<serde_json::Value>(&event.data) else {
707+
continue;
708+
};
709+
traffic.write_json_event(
710+
"050-downstream-event",
711+
&serde_json::json!({
712+
"event": event.event.as_deref().unwrap_or("message"),
713+
"data": data,
714+
}),
715+
);
716+
}
717+
}
718+
698719
fn record_live_stream_progress(ctx: &RequestContext, chunk: &[u8]) {
699720
if let Some(monitor) = ctx.monitor.as_ref() {
700721
let (input_tokens, output_tokens) = usage_from_anthropic_sse(chunk);
@@ -744,28 +765,31 @@ fn remaining_live_stream_response(
744765
match item {
745766
Ok(payload) => {
746767
append_upstream_sse_payload(&mut upstream_sse_body, &payload);
747-
let (chunk, terminal) =
748-
match translate_live_stream_payload(&mut translator, &payload, &ctx) {
749-
Ok(result) => result,
750-
Err(message) => {
751-
abort_request_state(
752-
ctx.session_id.as_deref(),
753-
turn_id,
754-
compact_boundary,
755-
&request_body,
756-
);
757-
let chunk = translator.error_chunk(
758-
&message,
759-
"api_error",
760-
ctx.traffic.as_deref(),
761-
);
762-
if !chunk.is_empty() {
763-
record_live_stream_progress(&ctx, &chunk);
764-
let _ = tx.send(Ok(Bytes::from(chunk))).await;
765-
}
766-
return;
768+
let (chunk, terminal) = match translate_live_stream_payload(
769+
&mut translator,
770+
&payload,
771+
ctx.traffic.as_deref(),
772+
) {
773+
Ok(result) => result,
774+
Err(message) => {
775+
abort_request_state(
776+
ctx.session_id.as_deref(),
777+
turn_id,
778+
compact_boundary,
779+
&request_body,
780+
);
781+
let chunk = translator.error_chunk(
782+
&message,
783+
"api_error",
784+
ctx.traffic.as_deref(),
785+
);
786+
if !chunk.is_empty() {
787+
record_live_stream_progress(&ctx, &chunk);
788+
let _ = tx.send(Ok(Bytes::from(chunk))).await;
767789
}
768-
};
790+
return;
791+
}
792+
};
769793
if !chunk.is_empty() {
770794
record_live_stream_progress(&ctx, &chunk);
771795
if tx.send(Ok(Bytes::from(chunk))).await.is_err() {

tests/smoke_cutover.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1580,13 +1580,16 @@ async fn smoke_codex_websocket_stream_retries_empty_close_with_full_context() {
15801580
async fn smoke_codex_websocket_stream_retries_terminal_only_completion_with_full_context() {
15811581
let _guard = env_lock();
15821582
let config = TempDir::new().unwrap();
1583+
let state = TempDir::new().unwrap();
15831584
write_auth(config.path(), "codex");
15841585
clear_codex_websocket_pool_for_tests();
15851586
clear_all_continuations_for_tests();
15861587

15871588
let captured = Arc::new(Mutex::new(Vec::new()));
15881589
let upstream = spawn_websocket_empty_completion_then_retry_upstream(captured.clone()).await;
15891590

1591+
let _traffic_env = EnvGuard::set("CCP_TRAFFIC_LOG", "1");
1592+
let _state_env = EnvGuard::set("XDG_STATE_HOME", state.path());
15901593
let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
15911594
let _base_url_env = EnvGuard::set("CCP_CODEX_BASE_URL", &upstream);
15921595
let _transport_env = EnvGuard::set("CCP_CODEX_TRANSPORT", "websocket");
@@ -1625,6 +1628,22 @@ async fn smoke_codex_websocket_stream_retries_terminal_only_completion_with_full
16251628
String::from_utf8_lossy(&second_body)
16261629
);
16271630

1631+
let downstream_end_turns = traffic_files(state.path())
1632+
.into_iter()
1633+
.filter(|path| {
1634+
path.file_name()
1635+
.and_then(|name| name.to_str())
1636+
.is_some_and(|name| name.ends_with("050-downstream-event.json"))
1637+
})
1638+
.filter_map(|path| std::fs::read(path).ok())
1639+
.filter_map(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
1640+
.filter(|event| event["data"]["delta"]["stop_reason"] == "end_turn")
1641+
.count();
1642+
assert_eq!(
1643+
downstream_end_turns, 2,
1644+
"discarded empty attempts must not be captured as downstream events"
1645+
);
1646+
16281647
let guard = captured.lock().unwrap();
16291648
assert_eq!(guard.len(), 3, "expected full-context retry request");
16301649
assert!(guard[0].get("previous_response_id").is_none());

0 commit comments

Comments
 (0)