Skip to main content

codex_rollout_trace/
inference.rs

1//! Hot-path helpers for recording upstream inference attempts.
2//!
3//! The model client should not need to know whether rollout tracing is enabled.
4//! A disabled context records nothing, which keeps one-shot HTTP calls,
5//! WebSocket reuse, and retry/fallback attempts on the same code path.
6
7use std::fmt::Display;
8use std::sync::Arc;
9use std::sync::atomic::AtomicBool;
10use std::sync::atomic::Ordering;
11
12use codex_protocol::models::ResponseItem;
13use codex_protocol::protocol::TokenUsage;
14use http::HeaderMap;
15use http::HeaderValue;
16use serde::Serialize;
17use serde_json::Value as JsonValue;
18use uuid::Uuid;
19
20use crate::model::AgentThreadId;
21use crate::model::CodexTurnId;
22use crate::model::InferenceCallId;
23use crate::payload::RawPayloadKind;
24use crate::raw_event::RawTraceEventContext;
25use crate::raw_event::RawTraceEventPayload;
26use crate::writer::TraceWriter;
27
28const INFERENCE_CALL_ID_HEADER: &str = "x-codex-inference-call-id";
29
30/// Turn-local inference tracing context.
31///
32/// This is intentionally a no-op capable handle instead of an `Option` at each
33/// transport callsite. Whether tracing is enabled is a session concern; retry,
34/// fallback, and stream mapping code should always be able to say what happened
35/// without first branching on trace availability.
36#[derive(Clone, Debug)]
37pub struct InferenceTraceContext {
38    state: InferenceTraceContextState,
39}
40
41#[derive(Clone, Debug)]
42enum InferenceTraceContextState {
43    Disabled,
44    Enabled(EnabledInferenceTraceContext),
45}
46
47#[derive(Clone, Debug)]
48struct EnabledInferenceTraceContext {
49    writer: Arc<TraceWriter>,
50    thread_id: AgentThreadId,
51    codex_turn_id: CodexTurnId,
52    model: String,
53    provider_name: String,
54}
55
56/// One concrete upstream request attempt.
57///
58/// A Codex turn can create multiple attempts when auth recovery retries the
59/// HTTP request or WebSocket setup falls back to HTTP. Completion is often
60/// observed after the client returns the response stream, so the attempt owns
61/// the terminal guard that prevents duplicate lifecycle events.
62#[derive(Debug)]
63pub struct InferenceTraceAttempt {
64    state: InferenceTraceAttemptState,
65}
66
67#[derive(Debug)]
68enum InferenceTraceAttemptState {
69    Disabled,
70    Enabled(EnabledInferenceTraceAttempt),
71}
72
73#[derive(Debug)]
74struct EnabledInferenceTraceAttempt {
75    context: EnabledInferenceTraceContext,
76    inference_call_id: InferenceCallId,
77    terminal_recorded: AtomicBool,
78}
79
80/// Non-delta response payload saved for completed or interrupted inference streams.
81///
82/// We intentionally record completed output items instead of every stream delta
83/// here. The raw stream can be added later as a separate payload class; this
84/// response summary gives the reducer stable response identity when available
85/// plus model-visible output without duplicating high-volume text deltas.
86#[derive(Serialize)]
87struct TracedResponseStreamOutput<'a> {
88    response_id: Option<&'a str>,
89    upstream_request_id: Option<&'a str>,
90    token_usage: Option<&'a TokenUsage>,
91    output_items: Vec<JsonValue>,
92}
93
94impl InferenceTraceContext {
95    /// Builds a context that accepts trace calls and records nothing.
96    pub fn disabled() -> Self {
97        Self {
98            state: InferenceTraceContextState::Disabled,
99        }
100    }
101
102    /// Builds an enabled context for all upstream attempts made by one Codex turn.
103    pub fn enabled(
104        writer: Arc<TraceWriter>,
105        thread_id: AgentThreadId,
106        codex_turn_id: CodexTurnId,
107        model: String,
108        provider_name: String,
109    ) -> Self {
110        Self {
111            state: InferenceTraceContextState::Enabled(EnabledInferenceTraceContext {
112                writer,
113                thread_id,
114                codex_turn_id,
115                model,
116                provider_name,
117            }),
118        }
119    }
120
121    /// Starts a new attempt after the concrete provider request has been built.
122    pub fn start_attempt(&self) -> InferenceTraceAttempt {
123        let InferenceTraceContextState::Enabled(context) = &self.state else {
124            return InferenceTraceAttempt::disabled();
125        };
126
127        InferenceTraceAttempt {
128            state: InferenceTraceAttemptState::Enabled(EnabledInferenceTraceAttempt {
129                context: context.clone(),
130                inference_call_id: next_inference_call_id(),
131                terminal_recorded: AtomicBool::new(false),
132            }),
133        }
134    }
135}
136
137impl InferenceTraceAttempt {
138    /// Builds an attempt that records nothing.
139    pub fn disabled() -> Self {
140        Self {
141            state: InferenceTraceAttemptState::Disabled,
142        }
143    }
144
145    fn inference_call_id(&self) -> Option<&str> {
146        match &self.state {
147            InferenceTraceAttemptState::Disabled => None,
148            InferenceTraceAttemptState::Enabled(attempt) => {
149                Some(attempt.inference_call_id.as_str())
150            }
151        }
152    }
153
154    /// Adds rollout-trace propagation headers for this attempt when tracing is enabled.
155    pub fn add_request_headers(&self, headers: &mut HeaderMap) {
156        let Some(inference_call_id) = self.inference_call_id() else {
157            return;
158        };
159        let Ok(inference_call_id) = HeaderValue::from_str(inference_call_id) else {
160            // These IDs are generated internally as UUID strings, so rejection
161            // should be impossible in practice. Tracing remains best-effort,
162            // though, and must never make provider requests fail.
163            return;
164        };
165
166        headers.insert(INFERENCE_CALL_ID_HEADER, inference_call_id);
167    }
168
169    /// Records the request payload replay should treat as the model-visible inference input.
170    ///
171    /// This is usually the exact provider request. Callers may instead pass a
172    /// logical request when the transport omits already-sent input, such as
173    /// websocket reuse after an untraced warmup response.
174    pub fn record_started(&self, request: &impl Serialize) {
175        let InferenceTraceAttemptState::Enabled(attempt) = &self.state else {
176            return;
177        };
178        let Some(request_payload) = write_json_payload_best_effort(
179            &attempt.context.writer,
180            RawPayloadKind::InferenceRequest,
181            request,
182        ) else {
183            return;
184        };
185
186        append_with_context_best_effort(
187            &attempt.context,
188            RawTraceEventPayload::InferenceStarted {
189                inference_call_id: attempt.inference_call_id.clone(),
190                thread_id: attempt.context.thread_id.clone(),
191                codex_turn_id: attempt.context.codex_turn_id.clone(),
192                model: attempt.context.model.clone(),
193                provider_name: attempt.context.provider_name.clone(),
194                request_payload,
195            },
196        );
197    }
198
199    /// Records successful provider completion and serializes the observed output items.
200    ///
201    /// Callers pass protocol-native response items so this crate owns the
202    /// trace-specific serialization rules. That keeps codex-core focused on
203    /// transport behavior while preserving trace evidence that normal request
204    /// serialization intentionally omits.
205    pub fn record_completed(
206        &self,
207        response_id: &str,
208        upstream_request_id: Option<&str>,
209        token_usage: &Option<TokenUsage>,
210        output_items: &[ResponseItem],
211    ) {
212        let Some(attempt) = self.take_terminal_attempt() else {
213            return;
214        };
215        let Some(response_payload) = write_response_payload_best_effort(
216            attempt,
217            Some(response_id),
218            upstream_request_id,
219            token_usage.as_ref(),
220            output_items,
221        ) else {
222            return;
223        };
224
225        append_with_context_best_effort(
226            &attempt.context,
227            RawTraceEventPayload::InferenceCompleted {
228                inference_call_id: attempt.inference_call_id.clone(),
229                response_id: Some(response_id.to_string()),
230                upstream_request_id: upstream_request_id.map(str::to_string),
231                response_payload,
232            },
233        );
234    }
235
236    /// Records pre-response and mid-stream failures.
237    pub fn record_failed(
238        &self,
239        error: impl Display,
240        upstream_request_id: Option<&str>,
241        output_items: &[ResponseItem],
242    ) {
243        let Some(attempt) = self.take_terminal_attempt() else {
244            return;
245        };
246        let partial_response_payload = if output_items.is_empty() {
247            None
248        } else {
249            write_response_payload_best_effort(
250                attempt,
251                /*response_id*/ None,
252                upstream_request_id,
253                /*token_usage*/ None,
254                output_items,
255            )
256        };
257        append_with_context_best_effort(
258            &attempt.context,
259            RawTraceEventPayload::InferenceFailed {
260                inference_call_id: attempt.inference_call_id.clone(),
261                upstream_request_id: upstream_request_id.map(str::to_string),
262                error: error.to_string(),
263                partial_response_payload,
264            },
265        );
266    }
267
268    /// Records a provider stream that Codex intentionally stopped consuming.
269    ///
270    /// This happens when the turn is interrupted or when mailbox delivery
271    /// preempts the current sampling request. Complete output items observed
272    /// before that point are retained as partial response evidence.
273    pub fn record_cancelled(
274        &self,
275        reason: impl Display,
276        upstream_request_id: Option<&str>,
277        output_items: &[ResponseItem],
278    ) {
279        let Some(attempt) = self.take_terminal_attempt() else {
280            return;
281        };
282        let partial_response_payload = if output_items.is_empty() {
283            None
284        } else {
285            write_response_payload_best_effort(
286                attempt,
287                /*response_id*/ None,
288                upstream_request_id,
289                /*token_usage*/ None,
290                output_items,
291            )
292        };
293        append_with_context_best_effort(
294            &attempt.context,
295            RawTraceEventPayload::InferenceCancelled {
296                inference_call_id: attempt.inference_call_id.clone(),
297                upstream_request_id: upstream_request_id.map(str::to_string),
298                reason: reason.to_string(),
299                partial_response_payload,
300            },
301        );
302    }
303
304    fn take_terminal_attempt(&self) -> Option<&EnabledInferenceTraceAttempt> {
305        let attempt = match &self.state {
306            InferenceTraceAttemptState::Disabled => return None,
307            InferenceTraceAttemptState::Enabled(attempt) => attempt,
308        };
309        if attempt.terminal_recorded.swap(true, Ordering::AcqRel) {
310            return None;
311        }
312        Some(attempt)
313    }
314}
315
316/// Serializes a response item for trace evidence rather than future request construction.
317///
318/// The protocol serializer intentionally omits some readable reasoning content
319/// when shaping items for later model requests. Rollout traces need the item as
320/// Codex received it, so this helper restores that content in the raw payload.
321pub(crate) fn trace_response_item_json(item: &ResponseItem) -> JsonValue {
322    let mut value = serde_json::to_value(item).unwrap_or_else(|err| {
323        serde_json::json!({
324            "serialization_error": err.to_string(),
325        })
326    });
327
328    if let ResponseItem::Reasoning {
329        content: Some(content),
330        ..
331    } = item
332        && let JsonValue::Object(object) = &mut value
333    {
334        object.insert(
335            "content".to_string(),
336            serde_json::to_value(content).unwrap_or_else(|err| {
337                serde_json::json!({
338                    "serialization_error": err.to_string(),
339                })
340            }),
341        );
342    }
343
344    value
345}
346
347fn next_inference_call_id() -> InferenceCallId {
348    Uuid::new_v4().to_string()
349}
350
351fn write_json_payload_best_effort(
352    writer: &TraceWriter,
353    kind: RawPayloadKind,
354    payload: &impl Serialize,
355) -> Option<crate::RawPayloadRef> {
356    writer.write_json_payload(kind, payload).ok()
357}
358
359fn write_response_payload_best_effort(
360    attempt: &EnabledInferenceTraceAttempt,
361    response_id: Option<&str>,
362    upstream_request_id: Option<&str>,
363    token_usage: Option<&TokenUsage>,
364    output_items: &[ResponseItem],
365) -> Option<crate::RawPayloadRef> {
366    let response_payload = TracedResponseStreamOutput {
367        response_id,
368        upstream_request_id,
369        token_usage,
370        output_items: output_items.iter().map(trace_response_item_json).collect(),
371    };
372    write_json_payload_best_effort(
373        &attempt.context.writer,
374        RawPayloadKind::InferenceResponse,
375        &response_payload,
376    )
377}
378
379fn append_with_context_best_effort(
380    context: &EnabledInferenceTraceContext,
381    payload: RawTraceEventPayload,
382) {
383    let event_context = RawTraceEventContext {
384        thread_id: Some(context.thread_id.clone()),
385        codex_turn_id: Some(context.codex_turn_id.clone()),
386    };
387    let _ = context.writer.append_with_context(event_context, payload);
388}
389
390#[cfg(test)]
391mod tests {
392    use std::sync::Arc;
393
394    use codex_protocol::ResponseItemId;
395    use codex_protocol::models::ReasoningItemContent;
396    use codex_protocol::models::ReasoningItemReasoningSummary;
397    use pretty_assertions::assert_eq;
398    use serde_json::json;
399    use tempfile::TempDir;
400
401    use super::*;
402    use crate::model::ExecutionStatus;
403    use crate::replay_bundle;
404
405    #[test]
406    fn disabled_attempt_adds_no_request_headers() {
407        let mut headers = HeaderMap::new();
408
409        InferenceTraceAttempt::disabled().add_request_headers(&mut headers);
410
411        assert!(headers.is_empty());
412    }
413
414    #[test]
415    fn enabled_attempt_adds_inference_request_header() -> anyhow::Result<()> {
416        let temp = TempDir::new()?;
417        let writer = Arc::new(TraceWriter::create(
418            temp.path(),
419            "trace-1".to_string(),
420            "rollout-1".to_string(),
421            "thread-root".to_string(),
422        )?);
423        let context = InferenceTraceContext::enabled(
424            writer,
425            "thread-root".to_string(),
426            "turn-1".to_string(),
427            "gpt-test".to_string(),
428            "test-provider".to_string(),
429        );
430        let attempt = context.start_attempt();
431        let mut headers = HeaderMap::new();
432
433        attempt.add_request_headers(&mut headers);
434
435        let header = headers
436            .get(INFERENCE_CALL_ID_HEADER)
437            .expect("inference header present");
438        assert_eq!(Some(header.to_str()?), attempt.inference_call_id());
439        assert!(Uuid::parse_str(header.to_str()?).is_ok());
440        Ok(())
441    }
442
443    #[test]
444    fn enabled_context_records_replayable_inference_attempt() -> anyhow::Result<()> {
445        let temp = TempDir::new()?;
446        let writer = Arc::new(TraceWriter::create(
447            temp.path(),
448            "trace-1".to_string(),
449            "rollout-1".to_string(),
450            "thread-root".to_string(),
451        )?);
452        writer.append(RawTraceEventPayload::ThreadStarted {
453            thread_id: "thread-root".to_string(),
454            agent_path: "/root".to_string(),
455            metadata_payload: None,
456        })?;
457        writer.append(RawTraceEventPayload::CodexTurnStarted {
458            codex_turn_id: "turn-1".to_string(),
459            thread_id: "thread-root".to_string(),
460        })?;
461        let context = InferenceTraceContext::enabled(
462            writer,
463            "thread-root".to_string(),
464            "turn-1".to_string(),
465            "gpt-test".to_string(),
466            "test-provider".to_string(),
467        );
468
469        let attempt = context.start_attempt();
470        attempt.record_started(&json!({
471            "model": "gpt-test",
472            "input": [{
473                "type": "message",
474                "role": "user",
475                "content": [{"type": "input_text", "text": "hello"}]
476            }],
477        }));
478        attempt.record_completed("resp-1", Some("req-1"), &None, &[]);
479
480        let rollout = replay_bundle(temp.path())?;
481        let inference = rollout
482            .inference_calls
483            .values()
484            .next()
485            .expect("recorded inference call");
486
487        assert_eq!(rollout.inference_calls.len(), 1);
488        assert_eq!(inference.thread_id, "thread-root");
489        assert_eq!(inference.codex_turn_id, "turn-1");
490        assert_eq!(inference.execution.status, ExecutionStatus::Completed);
491        assert_eq!(inference.upstream_request_id, Some("req-1".to_string()));
492        assert_eq!(rollout.raw_payloads.len(), 2);
493
494        Ok(())
495    }
496
497    #[test]
498    fn traced_response_item_preserves_reasoning_content_omitted_by_normal_serializer() {
499        let item = ResponseItem::Reasoning {
500            id: Some(ResponseItemId::with_suffix("rs", "1")),
501            summary: vec![ReasoningItemReasoningSummary::SummaryText {
502                text: "summary".to_string(),
503            }],
504            content: Some(vec![ReasoningItemContent::Text {
505                text: "raw reasoning".to_string(),
506            }]),
507            encrypted_content: Some("encoded".to_string()),
508            internal_chat_message_metadata_passthrough: None,
509        };
510
511        let normal = serde_json::to_value(&item).expect("response item serializes");
512        let traced = trace_response_item_json(&item);
513
514        assert_eq!(normal.get("content"), None);
515        assert_eq!(
516            traced,
517            json!({
518                "type": "reasoning",
519                "id": "rs_1",
520                "summary": [{"type": "summary_text", "text": "summary"}],
521                "content": [{"type": "text", "text": "raw reasoning"}],
522                "encrypted_content": "encoded",
523            }),
524        );
525    }
526}