1use 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#[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#[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#[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 pub fn disabled() -> Self {
97 Self {
98 state: InferenceTraceContextState::Disabled,
99 }
100 }
101
102 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 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 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 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 return;
164 };
165
166 headers.insert(INFERENCE_CALL_ID_HEADER, inference_call_id);
167 }
168
169 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 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 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 None,
252 upstream_request_id,
253 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 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 None,
288 upstream_request_id,
289 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
316pub(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}