Compare commits

...

1 Commits

Author SHA1 Message Date
rhan-oai
f42ab32397 analytics: add responses api call schema 2026-04-17 18:31:25 -07:00
7 changed files with 712 additions and 0 deletions

View File

@@ -49,6 +49,8 @@ use crate::facts::TurnTokenUsageFact;
use crate::reducer::AnalyticsReducer;
use crate::reducer::normalize_path_for_skill_id;
use crate::reducer::skill_id_for_local_skill;
use crate::responses_api::CodexResponsesApiCallFact;
use crate::responses_api::CodexResponsesApiCallStatus;
use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer;
use codex_app_server_protocol::AskForApproval as AppServerAskForApproval;
use codex_app_server_protocol::ClientInfo;
@@ -1050,6 +1052,71 @@ async fn compaction_event_ingests_custom_fact() {
assert_eq!(payload[0]["event_params"]["status"], "failed");
}
#[tokio::test]
async fn responses_api_call_event_ingests_custom_fact() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
ingest_turn_prerequisites(
&mut reducer,
&mut events,
/*include_initialize*/ true,
/*include_resolved_config*/ true,
/*include_started*/ false,
/*include_token_usage*/ false,
)
.await;
assert!(events.is_empty());
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::ResponsesApiCall(Box::new(
CodexResponsesApiCallFact {
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
responses_id: Some("resp-1".to_string()),
turn_responses_call_index: 2,
status: CodexResponsesApiCallStatus::Completed,
error: None,
started_at: 100,
completed_at: Some(101),
duration_ms: Some(1200),
input_item_count: 3,
output_item_count: 4,
input_tokens: Some(10),
cached_input_tokens: Some(2),
output_tokens: Some(6),
reasoning_output_tokens: Some(1),
total_tokens: Some(16),
items: Vec::new(),
},
))),
&mut events,
)
.await;
let payload = serde_json::to_value(&events).expect("serialize events");
assert_eq!(payload.as_array().expect("events array").len(), 1);
assert_eq!(payload[0]["event_type"], "codex_responses_api_call_event");
assert_eq!(payload[0]["event_params"]["thread_id"], "thread-2");
assert_eq!(payload[0]["event_params"]["turn_id"], "turn-2");
assert_eq!(payload[0]["event_params"]["responses_id"], "resp-1");
assert_eq!(payload[0]["event_params"]["turn_responses_call_index"], 2);
assert_eq!(payload[0]["event_params"]["model"], "gpt-5");
assert_eq!(payload[0]["event_params"]["model_provider"], "openai");
assert_eq!(payload[0]["event_params"]["status"], "completed");
assert_eq!(payload[0]["event_params"]["input_tokens"], 10);
assert_eq!(payload[0]["event_params"]["cached_input_tokens"], 2);
assert_eq!(payload[0]["event_params"]["output_tokens"], 6);
assert_eq!(payload[0]["event_params"]["reasoning_output_tokens"], 1);
assert_eq!(payload[0]["event_params"]["total_tokens"], 16);
assert_eq!(payload[0]["event_params"]["ephemeral"], false);
assert_eq!(payload[0]["event_params"]["thread_source"], "user");
assert_eq!(
payload[0]["event_params"]["app_server_client"]["product_client_id"],
"codex-tui"
);
}
#[test]
fn subagent_thread_started_review_serializes_expected_shape() {
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(

View File

@@ -20,6 +20,7 @@ use crate::facts::TrackEventsContext;
use crate::facts::TurnResolvedConfigFact;
use crate::facts::TurnTokenUsageFact;
use crate::reducer::AnalyticsReducer;
use crate::responses_api::CodexResponsesApiCallFact;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ClientResponse;
use codex_app_server_protocol::InitializeParams;
@@ -214,6 +215,12 @@ impl AnalyticsEventsClient {
)));
}
pub fn track_responses_api_call(&self, event: CodexResponsesApiCallFact) {
self.record_fact(AnalyticsFact::Custom(
CustomAnalyticsFact::ResponsesApiCall(Box::new(event)),
));
}
pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) {
self.record_fact(AnalyticsFact::Custom(
CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)),

View File

@@ -10,6 +10,8 @@ use crate::facts::TurnStatus;
use crate::facts::TurnSteerRejectionReason;
use crate::facts::TurnSteerResult;
use crate::facts::TurnSubmissionType;
use crate::responses_api::CodexResponsesApiCallStatus;
use crate::responses_api::CodexResponsesApiItemMetadata;
use codex_app_server_protocol::CodexErrorInfo;
use codex_login::default_client::originator;
use codex_plugin::PluginTelemetryMetadata;
@@ -45,6 +47,7 @@ pub(crate) enum TrackEventRequest {
AppUsed(CodexAppUsedEventRequest),
HookRun(CodexHookRunEventRequest),
Compaction(Box<CodexCompactionEventRequest>),
ResponsesApiCall(Box<CodexResponsesApiCallEventRequest>),
TurnEvent(Box<CodexTurnEventRequest>),
TurnSteer(CodexTurnSteerEventRequest),
PluginUsed(CodexPluginUsedEventRequest),
@@ -350,6 +353,43 @@ pub(crate) struct CodexCompactionEventRequest {
pub(crate) event_params: CodexCompactionEventParams,
}
#[derive(Serialize)]
pub(crate) struct CodexResponsesApiCallEventParams {
pub(crate) thread_id: String,
pub(crate) turn_id: String,
pub(crate) ephemeral: bool,
pub(crate) thread_source: Option<String>,
pub(crate) initialization_mode: ThreadInitializationMode,
pub(crate) subagent_source: Option<String>,
pub(crate) parent_thread_id: Option<String>,
pub(crate) app_server_client: CodexAppServerClientMetadata,
pub(crate) runtime: CodexRuntimeMetadata,
pub(crate) responses_id: Option<String>,
pub(crate) turn_responses_call_index: u64,
pub(crate) model: Option<String>,
pub(crate) model_provider: Option<String>,
pub(crate) reasoning_effort: Option<String>,
pub(crate) status: CodexResponsesApiCallStatus,
pub(crate) error: Option<String>,
pub(crate) started_at: u64,
pub(crate) completed_at: Option<u64>,
pub(crate) duration_ms: Option<u64>,
pub(crate) input_item_count: usize,
pub(crate) output_item_count: usize,
pub(crate) input_tokens: Option<i64>,
pub(crate) cached_input_tokens: Option<i64>,
pub(crate) output_tokens: Option<i64>,
pub(crate) reasoning_output_tokens: Option<i64>,
pub(crate) total_tokens: Option<i64>,
pub(crate) items: Vec<CodexResponsesApiItemMetadata>,
}
#[derive(Serialize)]
pub(crate) struct CodexResponsesApiCallEventRequest {
pub(crate) event_type: &'static str,
pub(crate) event_params: CodexResponsesApiCallEventParams,
}
#[derive(Serialize)]
pub(crate) struct CodexTurnEventParams {
pub(crate) thread_id: String,

View File

@@ -1,6 +1,7 @@
use crate::events::AppServerRpcTransport;
use crate::events::CodexRuntimeMetadata;
use crate::events::GuardianReviewEventParams;
use crate::responses_api::CodexResponsesApiCallFact;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ClientResponse;
use codex_app_server_protocol::InitializeParams;
@@ -295,6 +296,7 @@ pub(crate) enum AnalyticsFact {
pub(crate) enum CustomAnalyticsFact {
SubAgentThreadStarted(SubAgentThreadStartedInput),
Compaction(Box<CodexCompactionEvent>),
ResponsesApiCall(Box<CodexResponsesApiCallFact>),
GuardianReview(Box<GuardianReviewEventParams>),
TurnResolvedConfig(Box<TurnResolvedConfigFact>),
TurnTokenUsage(Box<TurnTokenUsageFact>),

View File

@@ -2,6 +2,7 @@ mod client;
mod events;
mod facts;
mod reducer;
mod responses_api;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
@@ -43,6 +44,14 @@ pub use facts::TurnSteerRequestError;
pub use facts::TurnSteerResult;
pub use facts::TurnTokenUsageFact;
pub use facts::build_track_events_context;
pub use responses_api::CodexResponseItemRole;
pub use responses_api::CodexResponseItemType;
pub use responses_api::CodexResponseMessagePhase;
pub use responses_api::CodexResponsesApiCallFact;
pub use responses_api::CodexResponsesApiCallStatus;
pub use responses_api::CodexResponsesApiItemMetadata;
pub use responses_api::CodexResponsesApiItemPhase;
pub use responses_api::response_items_metadata;
#[cfg(test)]
mod analytics_client_tests;

View File

@@ -6,6 +6,8 @@ use crate::events::CodexCompactionEventRequest;
use crate::events::CodexHookRunEventRequest;
use crate::events::CodexPluginEventRequest;
use crate::events::CodexPluginUsedEventRequest;
use crate::events::CodexResponsesApiCallEventParams;
use crate::events::CodexResponsesApiCallEventRequest;
use crate::events::CodexRuntimeMetadata;
use crate::events::CodexTurnEventParams;
use crate::events::CodexTurnEventRequest;
@@ -47,6 +49,7 @@ use crate::facts::TurnSteerRejectionReason;
use crate::facts::TurnSteerResult;
use crate::facts::TurnTokenUsageFact;
use crate::now_unix_seconds;
use crate::responses_api::CodexResponsesApiCallFact;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ClientResponse;
use codex_app_server_protocol::CodexErrorInfo;
@@ -202,6 +205,9 @@ impl AnalyticsReducer {
CustomAnalyticsFact::Compaction(input) => {
self.ingest_compaction(*input, out);
}
CustomAnalyticsFact::ResponsesApiCall(input) => {
self.ingest_responses_api_call(*input, out);
}
CustomAnalyticsFact::GuardianReview(input) => {
self.ingest_guardian_review(*input, out);
}
@@ -739,6 +745,66 @@ impl AnalyticsReducer {
)));
}
fn ingest_responses_api_call(
&mut self,
input: CodexResponsesApiCallFact,
out: &mut Vec<TrackEventRequest>,
) {
let Some(connection_id) = self.thread_connections.get(&input.thread_id) else {
tracing::warn!(
thread_id = %input.thread_id,
turn_id = %input.turn_id,
"dropping responses API call analytics event: missing thread connection metadata"
);
return;
};
let Some(connection_state) = self.connections.get(connection_id) else {
tracing::warn!(
thread_id = %input.thread_id,
turn_id = %input.turn_id,
connection_id,
"dropping responses API call analytics event: missing connection metadata"
);
return;
};
let Some(thread_metadata) = self.thread_metadata.get(&input.thread_id) else {
tracing::warn!(
thread_id = %input.thread_id,
turn_id = %input.turn_id,
"dropping responses API call analytics event: missing thread lifecycle metadata"
);
return;
};
let Some(turn_state) = self.turns.get(&input.turn_id) else {
tracing::warn!(
thread_id = %input.thread_id,
turn_id = %input.turn_id,
"dropping responses API call analytics event: missing turn metadata"
);
return;
};
let Some(resolved_config) = turn_state.resolved_config.as_ref() else {
tracing::warn!(
thread_id = %input.thread_id,
turn_id = %input.turn_id,
"dropping responses API call analytics event: missing resolved turn config"
);
return;
};
out.push(TrackEventRequest::ResponsesApiCall(Box::new(
CodexResponsesApiCallEventRequest {
event_type: "codex_responses_api_call_event",
event_params: codex_responses_api_call_event_params(
input,
connection_state.app_server_client.clone(),
connection_state.runtime.clone(),
thread_metadata,
resolved_config,
),
},
)));
}
fn ingest_turn_steer_response(
&mut self,
connection_id: u64,
@@ -859,6 +925,46 @@ impl AnalyticsReducer {
}
}
fn codex_responses_api_call_event_params(
input: CodexResponsesApiCallFact,
app_server_client: CodexAppServerClientMetadata,
runtime: CodexRuntimeMetadata,
thread_metadata: &ThreadMetadataState,
resolved_config: &TurnResolvedConfigFact,
) -> CodexResponsesApiCallEventParams {
CodexResponsesApiCallEventParams {
thread_id: input.thread_id,
turn_id: input.turn_id,
ephemeral: resolved_config.ephemeral,
thread_source: thread_metadata.thread_source.map(str::to_string),
initialization_mode: thread_metadata.initialization_mode,
subagent_source: thread_metadata.subagent_source.clone(),
parent_thread_id: thread_metadata.parent_thread_id.clone(),
app_server_client,
runtime,
responses_id: input.responses_id,
turn_responses_call_index: input.turn_responses_call_index,
model: Some(resolved_config.model.clone()),
model_provider: Some(resolved_config.model_provider.clone()),
reasoning_effort: resolved_config
.reasoning_effort
.map(|effort| effort.to_string()),
status: input.status,
error: input.error,
started_at: input.started_at,
completed_at: input.completed_at,
duration_ms: input.duration_ms,
input_item_count: input.input_item_count,
output_item_count: input.output_item_count,
input_tokens: input.input_tokens,
cached_input_tokens: input.cached_input_tokens,
output_tokens: input.output_tokens,
reasoning_output_tokens: input.reasoning_output_tokens,
total_tokens: input.total_tokens,
items: input.items,
}
}
fn codex_turn_event_params(
app_server_client: CodexAppServerClientMetadata,
runtime: CodexRuntimeMetadata,

View File

@@ -0,0 +1,481 @@
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::models::ResponseItem;
use serde::Serialize;
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CodexResponsesApiCallStatus {
Completed,
Failed,
Interrupted,
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CodexResponsesApiItemPhase {
Input,
Output,
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CodexResponseItemType {
Message,
Reasoning,
LocalShellCall,
FunctionCall,
FunctionCallOutput,
CustomToolCall,
CustomToolCallOutput,
ToolSearchCall,
ToolSearchOutput,
WebSearchCall,
ImageGenerationCall,
GhostSnapshot,
Compaction,
Other,
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CodexResponseItemRole {
User,
Assistant,
System,
Developer,
Tool,
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CodexResponseMessagePhase {
Commentary,
FinalAnswer,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct CodexResponsesApiItemMetadata {
pub item_phase: CodexResponsesApiItemPhase,
pub item_index: usize,
pub response_item_type: CodexResponseItemType,
pub role: Option<CodexResponseItemRole>,
pub status: Option<String>,
pub message_phase: Option<CodexResponseMessagePhase>,
pub call_id: Option<String>,
pub tool_name: Option<String>,
pub content_bytes: Option<i64>,
pub arguments_bytes: Option<i64>,
pub output_bytes: Option<i64>,
pub encrypted_content_bytes: Option<i64>,
pub image_generation_revised_prompt_bytes: Option<i64>,
pub image_generation_result_bytes: Option<i64>,
pub text_part_count: Option<usize>,
pub image_part_count: Option<usize>,
}
#[derive(Clone, Debug)]
pub struct CodexResponsesApiCallFact {
pub thread_id: String,
pub turn_id: String,
pub responses_id: Option<String>,
pub turn_responses_call_index: u64,
pub status: CodexResponsesApiCallStatus,
pub error: Option<String>,
pub started_at: u64,
pub completed_at: Option<u64>,
pub duration_ms: Option<u64>,
pub input_item_count: usize,
pub output_item_count: usize,
pub input_tokens: Option<i64>,
pub cached_input_tokens: Option<i64>,
pub output_tokens: Option<i64>,
pub reasoning_output_tokens: Option<i64>,
pub total_tokens: Option<i64>,
pub items: Vec<CodexResponsesApiItemMetadata>,
}
pub fn response_items_metadata(
phase: CodexResponsesApiItemPhase,
items: &[ResponseItem],
) -> Vec<CodexResponsesApiItemMetadata> {
items
.iter()
.enumerate()
.map(|(item_index, item)| response_item_metadata(phase, item_index, item))
.collect()
}
fn response_item_metadata(
item_phase: CodexResponsesApiItemPhase,
item_index: usize,
item: &ResponseItem,
) -> CodexResponsesApiItemMetadata {
let mut metadata =
CodexResponsesApiItemMetadata::new(item_phase, item_index, response_item_type(item));
match item {
ResponseItem::Message {
role,
content,
phase,
..
} => {
metadata.role = response_item_role(role);
metadata.message_phase = phase.as_ref().and_then(response_message_phase);
metadata.content_bytes = nonzero_i64(message_content_text_bytes(content));
let (text_part_count, image_part_count) = message_content_part_counts(content);
metadata.text_part_count = Some(text_part_count);
metadata.image_part_count = Some(image_part_count);
}
ResponseItem::Reasoning {
summary,
content,
encrypted_content,
..
} => {
metadata.content_bytes = nonzero_i64(reasoning_content_bytes(summary, content));
metadata.encrypted_content_bytes =
encrypted_content.as_ref().map(|value| byte_len(value));
metadata.text_part_count =
Some(summary.len() + content.as_ref().map(std::vec::Vec::len).unwrap_or_default());
metadata.image_part_count = Some(0);
}
ResponseItem::LocalShellCall {
call_id,
status,
action,
..
} => {
metadata.call_id = call_id.clone();
metadata.tool_name = Some("local_shell".to_string());
metadata.status = serialized_string(status);
metadata.arguments_bytes = serialized_bytes(action);
}
ResponseItem::FunctionCall {
name,
arguments,
call_id,
..
} => {
metadata.call_id = Some(call_id.clone());
metadata.tool_name = Some(name.clone());
metadata.arguments_bytes = Some(byte_len(arguments));
}
ResponseItem::ToolSearchCall {
call_id,
status,
arguments,
..
} => {
metadata.call_id = call_id.clone();
metadata.tool_name = Some("tool_search".to_string());
metadata.status = status.clone();
metadata.arguments_bytes = serialized_bytes(arguments);
}
ResponseItem::FunctionCallOutput { call_id, output } => {
metadata.call_id = Some(call_id.clone());
metadata.output_bytes = function_call_output_bytes(output);
let (text_part_count, image_part_count) = function_call_output_part_counts(output);
metadata.text_part_count = text_part_count;
metadata.image_part_count = image_part_count;
}
ResponseItem::CustomToolCall {
status,
call_id,
name,
input,
..
} => {
metadata.call_id = Some(call_id.clone());
metadata.tool_name = Some(name.clone());
metadata.status = status.clone();
metadata.arguments_bytes = Some(byte_len(input));
}
ResponseItem::CustomToolCallOutput {
call_id,
name,
output,
} => {
metadata.call_id = Some(call_id.clone());
metadata.tool_name = name.clone();
metadata.output_bytes = function_call_output_bytes(output);
let (text_part_count, image_part_count) = function_call_output_part_counts(output);
metadata.text_part_count = text_part_count;
metadata.image_part_count = image_part_count;
}
ResponseItem::ToolSearchOutput {
call_id,
status,
tools,
..
} => {
metadata.call_id = call_id.clone();
metadata.tool_name = Some("tool_search".to_string());
metadata.status = Some(status.clone());
metadata.output_bytes = serialized_bytes(tools);
}
ResponseItem::WebSearchCall { status, action, .. } => {
metadata.tool_name = Some("web_search".to_string());
metadata.status = status.clone();
metadata.arguments_bytes = action.as_ref().and_then(serialized_bytes);
}
ResponseItem::ImageGenerationCall {
id,
status,
revised_prompt,
result,
} => {
metadata.call_id = Some(id.clone());
metadata.tool_name = Some("image_generation".to_string());
metadata.status = Some(status.clone());
metadata.image_generation_revised_prompt_bytes =
revised_prompt.as_ref().map(|value| byte_len(value));
metadata.image_generation_result_bytes = Some(byte_len(result));
}
ResponseItem::Compaction { encrypted_content } => {
metadata.encrypted_content_bytes = Some(byte_len(encrypted_content));
}
ResponseItem::GhostSnapshot { .. } | ResponseItem::Other => {}
}
metadata
}
impl CodexResponsesApiItemMetadata {
fn new(
item_phase: CodexResponsesApiItemPhase,
item_index: usize,
response_item_type: CodexResponseItemType,
) -> Self {
Self {
item_phase,
item_index,
response_item_type,
role: None,
status: None,
message_phase: None,
call_id: None,
tool_name: None,
content_bytes: None,
arguments_bytes: None,
output_bytes: None,
encrypted_content_bytes: None,
image_generation_revised_prompt_bytes: None,
image_generation_result_bytes: None,
text_part_count: None,
image_part_count: None,
}
}
}
fn response_item_type(item: &ResponseItem) -> CodexResponseItemType {
match item {
ResponseItem::Message { .. } => CodexResponseItemType::Message,
ResponseItem::Reasoning { .. } => CodexResponseItemType::Reasoning,
ResponseItem::LocalShellCall { .. } => CodexResponseItemType::LocalShellCall,
ResponseItem::FunctionCall { .. } => CodexResponseItemType::FunctionCall,
ResponseItem::ToolSearchCall { .. } => CodexResponseItemType::ToolSearchCall,
ResponseItem::FunctionCallOutput { .. } => CodexResponseItemType::FunctionCallOutput,
ResponseItem::CustomToolCall { .. } => CodexResponseItemType::CustomToolCall,
ResponseItem::CustomToolCallOutput { .. } => CodexResponseItemType::CustomToolCallOutput,
ResponseItem::ToolSearchOutput { .. } => CodexResponseItemType::ToolSearchOutput,
ResponseItem::WebSearchCall { .. } => CodexResponseItemType::WebSearchCall,
ResponseItem::ImageGenerationCall { .. } => CodexResponseItemType::ImageGenerationCall,
ResponseItem::GhostSnapshot { .. } => CodexResponseItemType::GhostSnapshot,
ResponseItem::Compaction { .. } => CodexResponseItemType::Compaction,
ResponseItem::Other => CodexResponseItemType::Other,
}
}
fn response_item_role(role: &str) -> Option<CodexResponseItemRole> {
match role {
"user" => Some(CodexResponseItemRole::User),
"assistant" => Some(CodexResponseItemRole::Assistant),
"system" => Some(CodexResponseItemRole::System),
"developer" => Some(CodexResponseItemRole::Developer),
"tool" => Some(CodexResponseItemRole::Tool),
_ => None,
}
}
fn response_message_phase(phase: &MessagePhase) -> Option<CodexResponseMessagePhase> {
match phase {
MessagePhase::Commentary => Some(CodexResponseMessagePhase::Commentary),
MessagePhase::FinalAnswer => Some(CodexResponseMessagePhase::FinalAnswer),
}
}
fn message_content_text_bytes(content: &[ContentItem]) -> i64 {
content
.iter()
.map(|item| match item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => byte_len(text),
ContentItem::InputImage { .. } => 0,
})
.sum()
}
fn message_content_part_counts(content: &[ContentItem]) -> (usize, usize) {
let mut text_part_count = 0;
let mut image_part_count = 0;
for item in content {
match item {
ContentItem::InputText { .. } | ContentItem::OutputText { .. } => {
text_part_count += 1;
}
ContentItem::InputImage { .. } => {
image_part_count += 1;
}
}
}
(text_part_count, image_part_count)
}
fn reasoning_content_bytes(
summary: &[ReasoningItemReasoningSummary],
content: &Option<Vec<ReasoningItemContent>>,
) -> i64 {
let summary_bytes = summary
.iter()
.map(|summary| match summary {
ReasoningItemReasoningSummary::SummaryText { text } => byte_len(text),
})
.sum::<i64>();
let content_bytes = content
.as_ref()
.map(|content| {
content
.iter()
.map(|content| match content {
ReasoningItemContent::ReasoningText { text }
| ReasoningItemContent::Text { text } => byte_len(text),
})
.sum::<i64>()
})
.unwrap_or_default();
summary_bytes + content_bytes
}
fn function_call_output_bytes(output: &FunctionCallOutputPayload) -> Option<i64> {
match &output.body {
FunctionCallOutputBody::Text(text) => Some(byte_len(text)),
FunctionCallOutputBody::ContentItems(items) => serialized_bytes(items),
}
}
fn function_call_output_part_counts(
output: &FunctionCallOutputPayload,
) -> (Option<usize>, Option<usize>) {
let Some(content_items) = output.content_items() else {
return (None, None);
};
let mut text_part_count = 0;
let mut image_part_count = 0;
for item in content_items {
match item {
FunctionCallOutputContentItem::InputText { .. } => {
text_part_count += 1;
}
FunctionCallOutputContentItem::InputImage { .. } => {
image_part_count += 1;
}
}
}
(Some(text_part_count), Some(image_part_count))
}
fn serialized_string<T: Serialize>(value: &T) -> Option<String> {
match serde_json::to_value(value).ok()? {
serde_json::Value::String(value) => Some(value),
value => Some(value.to_string()),
}
}
fn serialized_bytes<T: Serialize>(value: &T) -> Option<i64> {
serde_json::to_string(value)
.ok()
.map(|value| byte_len(&value))
}
fn nonzero_i64(value: i64) -> Option<i64> {
(value > 0).then_some(value)
}
fn byte_len(value: &str) -> i64 {
i64::try_from(value.len()).unwrap_or(i64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::models::ImageDetail;
#[test]
fn maps_message_metadata() {
let items = vec![ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![
ContentItem::OutputText {
text: "hello".to_string(),
},
ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
},
],
end_turn: None,
phase: Some(MessagePhase::FinalAnswer),
}];
let metadata = response_items_metadata(CodexResponsesApiItemPhase::Output, &items);
assert_eq!(metadata[0].item_phase, CodexResponsesApiItemPhase::Output);
assert_eq!(
metadata[0].response_item_type,
CodexResponseItemType::Message
);
assert_eq!(metadata[0].role, Some(CodexResponseItemRole::Assistant));
assert_eq!(
metadata[0].message_phase,
Some(CodexResponseMessagePhase::FinalAnswer)
);
assert_eq!(metadata[0].content_bytes, Some(5));
assert_eq!(metadata[0].text_part_count, Some(1));
assert_eq!(metadata[0].image_part_count, Some(1));
}
#[test]
fn maps_tool_call_output_metadata() {
let items = vec![ResponseItem::CustomToolCallOutput {
call_id: "call_1".to_string(),
name: Some("custom_tool".to_string()),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputText {
text: "result".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "https://example.test/image.png".to_string(),
detail: Some(ImageDetail::High),
},
]),
}];
let metadata = response_items_metadata(CodexResponsesApiItemPhase::Output, &items);
assert_eq!(
metadata[0].response_item_type,
CodexResponseItemType::CustomToolCallOutput
);
assert_eq!(metadata[0].call_id.as_deref(), Some("call_1"));
assert_eq!(metadata[0].tool_name.as_deref(), Some("custom_tool"));
assert!(metadata[0].output_bytes.unwrap_or_default() > 0);
assert_eq!(metadata[0].text_part_count, Some(1));
assert_eq!(metadata[0].image_part_count, Some(1));
}
}