Skip to content

Commit 30edd03

Browse files
committed
Add token counts, timestamps, and model to rollouts
1 parent f14b5ad commit 30edd03

File tree

4 files changed

+167
-3
lines changed

4 files changed

+167
-3
lines changed

codex-rs/core/src/chat_completions.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ pub(crate) async fn stream_chat_completions(
4343

4444
for item in &prompt.input {
4545
match item {
46-
ResponseItem::Message { role, content } => {
46+
ResponseItem::Message { role, content, .. } => {
4747
let mut text = String::new();
4848
for c in content {
4949
match c {
@@ -255,6 +255,8 @@ where
255255
content: vec![ContentItem::OutputText {
256256
text: content.to_string(),
257257
}],
258+
token_usage: None,
259+
timestamp: Some(crate::models::generate_timestamp()),
258260
};
259261

260262
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
@@ -402,6 +404,8 @@ where
402404
content: vec![crate::models::ContentItem::OutputText {
403405
text: std::mem::take(&mut this.cumulative),
404406
}],
407+
token_usage: token_usage.clone(),
408+
timestamp: Some(crate::models::generate_timestamp()),
405409
};
406410

407411
// Buffer Completed so it is returned *after* the aggregated message.

codex-rs/core/src/codex.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ use crate::protocol::SandboxPolicy;
8282
use crate::protocol::SessionConfiguredEvent;
8383
use crate::protocol::Submission;
8484
use crate::protocol::TaskCompleteEvent;
85+
use crate::protocol::TokenUsage;
8586
use crate::rollout::RolloutRecorder;
8687
use crate::safety::SafetyCheck;
8788
use crate::safety::assess_command_safety;
@@ -1159,6 +1160,9 @@ async fn try_run_turn(
11591160
token_usage,
11601161
} => {
11611162
if let Some(token_usage) = token_usage {
1163+
// Attach token usage to the last assistant message in this turn
1164+
attach_token_usage_to_last_assistant_message(&mut output, token_usage.clone());
1165+
11621166
sess.tx_event
11631167
.send(Event {
11641168
id: sub_id.to_string(),
@@ -2003,7 +2007,7 @@ fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> Strin
20032007

20042008
fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option<String> {
20052009
responses.iter().rev().find_map(|item| {
2006-
if let ResponseItem::Message { role, content } = item {
2010+
if let ResponseItem::Message { role, content, .. } = item {
20072011
if role == "assistant" {
20082012
content.iter().rev().find_map(|ci| {
20092013
if let ContentItem::OutputText { text } = ci {
@@ -2021,6 +2025,23 @@ fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option<St
20212025
})
20222026
}
20232027

2028+
fn attach_token_usage_to_last_assistant_message(
2029+
items: &mut [ProcessedResponseItem],
2030+
usage: TokenUsage,
2031+
) {
2032+
for processed_item in items.iter_mut().rev() {
2033+
if let ResponseItem::Message {
2034+
role, token_usage, ..
2035+
} = &mut processed_item.item
2036+
{
2037+
if role == "assistant" && token_usage.is_none() {
2038+
*token_usage = Some(usage);
2039+
break;
2040+
}
2041+
}
2042+
}
2043+
}
2044+
20242045
/// See [`ConversationHistory`] for details.
20252046
fn record_conversation_history(disable_response_storage: bool, wire_api: WireApi) -> bool {
20262047
if disable_response_storage {

codex-rs/core/src/models.rs

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ use mcp_types::CallToolResult;
55
use serde::Deserialize;
66
use serde::Serialize;
77
use serde::ser::Serializer;
8+
use time::OffsetDateTime;
9+
use time::format_description::FormatItem;
10+
use time::macros::format_description;
811

912
use crate::protocol::InputItem;
13+
use crate::protocol::TokenUsage;
1014

1115
#[derive(Debug, Clone, Serialize, Deserialize)]
1216
#[serde(tag = "type", rename_all = "snake_case")]
@@ -39,6 +43,10 @@ pub enum ResponseItem {
3943
Message {
4044
role: String,
4145
content: Vec<ContentItem>,
46+
#[serde(skip_serializing_if = "Option::is_none")]
47+
token_usage: Option<TokenUsage>,
48+
#[serde(skip_serializing_if = "Option::is_none")]
49+
timestamp: Option<String>,
4250
},
4351
Reasoning {
4452
id: String,
@@ -78,7 +86,12 @@ pub enum ResponseItem {
7886
impl From<ResponseInputItem> for ResponseItem {
7987
fn from(item: ResponseInputItem) -> Self {
8088
match item {
81-
ResponseInputItem::Message { role, content } => Self::Message { role, content },
89+
ResponseInputItem::Message { role, content } => Self::Message {
90+
role,
91+
content,
92+
token_usage: None,
93+
timestamp: Some(generate_timestamp()),
94+
},
8295
ResponseInputItem::FunctionCallOutput { call_id, output } => {
8396
Self::FunctionCallOutput { call_id, output }
8497
}
@@ -222,6 +235,16 @@ impl std::ops::Deref for FunctionCallOutputPayload {
222235
}
223236
}
224237

238+
/// Generate a timestamp string in the same format as session timestamps.
239+
/// Format: "YYYY-MM-DDTHH:MM:SS.sssZ" (ISO 8601 with millisecond precision in UTC)
240+
pub fn generate_timestamp() -> String {
241+
let timestamp_format: &[FormatItem] =
242+
format_description!("[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z");
243+
OffsetDateTime::now_utc()
244+
.format(timestamp_format)
245+
.unwrap_or_else(|_| "1970-01-01T00:00:00.000Z".to_string())
246+
}
247+
225248
#[cfg(test)]
226249
mod tests {
227250
#![allow(clippy::unwrap_used)]
@@ -260,6 +283,120 @@ mod tests {
260283
assert_eq!(v.get("output").unwrap().as_str().unwrap(), "bad");
261284
}
262285

286+
#[test]
287+
fn message_with_token_usage_and_timestamp() {
288+
let usage = TokenUsage {
289+
input_tokens: 100,
290+
output_tokens: 50,
291+
total_tokens: 150,
292+
cached_input_tokens: Some(25),
293+
reasoning_output_tokens: None,
294+
};
295+
296+
let timestamp = "2025-07-15T10:30:45.123Z".to_string();
297+
298+
let message = ResponseItem::Message {
299+
role: "assistant".to_string(),
300+
content: vec![ContentItem::OutputText {
301+
text: "Hello".to_string(),
302+
}],
303+
token_usage: Some(usage.clone()),
304+
timestamp: Some(timestamp.clone()),
305+
};
306+
307+
// Test serialization
308+
let json = serde_json::to_string(&message).unwrap();
309+
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
310+
311+
assert_eq!(parsed["type"], "message");
312+
assert_eq!(parsed["role"], "assistant");
313+
assert_eq!(parsed["content"][0]["text"], "Hello");
314+
assert_eq!(parsed["token_usage"]["input_tokens"], 100);
315+
assert_eq!(parsed["token_usage"]["output_tokens"], 50);
316+
assert_eq!(parsed["token_usage"]["total_tokens"], 150);
317+
assert_eq!(parsed["timestamp"], timestamp);
318+
319+
// Test deserialization
320+
let deserialized: ResponseItem = serde_json::from_str(&json).unwrap();
321+
if let ResponseItem::Message {
322+
role,
323+
content,
324+
token_usage: Some(token_usage),
325+
timestamp: Some(ts),
326+
..
327+
} = deserialized
328+
{
329+
assert_eq!(role, "assistant");
330+
assert_eq!(content.len(), 1);
331+
assert_eq!(token_usage.input_tokens, 100);
332+
assert_eq!(token_usage.output_tokens, 50);
333+
assert_eq!(ts, timestamp);
334+
} else {
335+
panic!("Expected Message with token_usage and timestamp");
336+
}
337+
}
338+
339+
#[test]
340+
fn message_without_optional_fields() {
341+
let message = ResponseItem::Message {
342+
role: "user".to_string(),
343+
content: vec![ContentItem::InputText {
344+
text: "Hi".to_string(),
345+
}],
346+
token_usage: None,
347+
timestamp: None,
348+
};
349+
350+
// Test serialization - optional fields should be omitted
351+
let json = serde_json::to_string(&message).unwrap();
352+
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
353+
354+
assert_eq!(parsed["type"], "message");
355+
assert_eq!(parsed["role"], "user");
356+
assert!(parsed.get("token_usage").is_none());
357+
assert!(parsed.get("timestamp").is_none());
358+
359+
// Test deserialization - should work with missing fields
360+
let deserialized: ResponseItem = serde_json::from_str(&json).unwrap();
361+
if let ResponseItem::Message {
362+
role,
363+
token_usage,
364+
timestamp,
365+
..
366+
} = deserialized
367+
{
368+
assert_eq!(role, "user");
369+
assert!(token_usage.is_none());
370+
assert!(timestamp.is_none());
371+
} else {
372+
panic!("Expected Message without optional fields");
373+
}
374+
}
375+
376+
#[test]
377+
fn generate_timestamp_format() {
378+
let timestamp = generate_timestamp();
379+
380+
// Should be valid ISO 8601 format: YYYY-MM-DDTHH:MM:SS.sssZ
381+
let parts: Vec<&str> = timestamp.split('T').collect();
382+
assert_eq!(parts.len(), 2);
383+
384+
let date_part = parts[0];
385+
let time_part = parts[1];
386+
387+
// Date should be YYYY-MM-DD format
388+
assert_eq!(date_part.len(), 10);
389+
assert!(date_part.contains('-'));
390+
391+
// Time should end with Z and have milliseconds
392+
assert!(time_part.ends_with('Z'));
393+
assert!(time_part.contains('.'));
394+
395+
// Should be able to parse as a valid timestamp
396+
assert!(timestamp.len() >= 20); // Minimum ISO format length
397+
assert!(timestamp.len() <= 30); // Maximum reasonable length
398+
}
399+
263400
#[test]
264401
fn deserialize_shell_tool_call_params() {
265402
let json = r#"{

codex-rs/core/src/rollout.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ struct SessionMeta {
2828
timestamp: String,
2929
#[serde(skip_serializing_if = "Option::is_none")]
3030
instructions: Option<String>,
31+
model: String,
3132
}
3233

3334
/// Records all [`ResponseItem`]s for a session and flushes them to disk after
@@ -71,6 +72,7 @@ impl RolloutRecorder {
7172
timestamp,
7273
id: session_id.to_string(),
7374
instructions,
75+
model: config.model.to_string(),
7476
};
7577

7678
// A reasonably-sized bounded channel. If the buffer fills up the send

0 commit comments

Comments
 (0)