Attribute turn metrics to the models used during the turn (#44656)

## Why

Turn completion metrics used session-level telemetry, which could label usage with the wrong model after a model switch. A turn can also include compaction and responses from different models, so a single token total cannot attribute usage correctly.

## What changed

- Accumulate token usage by model using each response's resolved step settings, and emit one histogram sample per model and token type per turn.
- Preserve zero-valued samples when no usage is reported, without adding a sample for an unused selected model when only compaction ran.
- Use turn telemetry for tool-call, memory, network-proxy, and running-process metrics.

## Testing

Add regression tests for model switches, step-level model changes, compaction followed by a response or an aborted turn, and turns without responses. Verify repeated requests aggregate into one sample per model and earlier session usage is excluded.

GitOrigin-RevId: 03ab1dcde7da0b16b3a500a9a05e4c86583ee585
This commit is contained in:
Andrei Eternal
2026-09-10 21:58:58 +00:00
committed by copyberry
parent 8570091e14
commit c8a8295e79
8 changed files with 352 additions and 46 deletions
+2
View File
@@ -3,6 +3,7 @@ mod auto_compact_window;
mod service;
mod session;
mod turn;
mod turn_token_usage;
pub(crate) use crate::tools::ExecutedToolCalls;
pub(crate) use additional_context::AdditionalContextStore;
@@ -18,3 +19,4 @@ pub(crate) use turn::PendingRequestPermissions;
pub(crate) use turn::RunningTask;
pub(crate) use turn::TaskKind;
pub(crate) use turn::TurnState;
pub(crate) use turn_token_usage::TurnTokenUsage;
+2
View File
@@ -17,6 +17,7 @@ use codex_sandboxing::policy_transforms::merge_permission_profiles;
use rmcp::model::RequestId;
use tokio::sync::oneshot;
use super::TurnTokenUsage;
use crate::agent::control::AgentExecutionGuard;
use crate::mcp_tool_call::McpToolApprovalMetadata;
use crate::session::TurnInputQueue;
@@ -101,6 +102,7 @@ pub(crate) struct TurnState {
pub(crate) tool_calls: u64,
pub(crate) has_memory_citation: bool,
pub(crate) token_usage_at_turn_start: TokenUsage,
pub(crate) token_usage_by_model: TurnTokenUsage,
/// The last step captured for execution or selected from a speculative fallback.
/// Remains absent until a step is captured; standalone local compaction has no step.
pub(crate) last_known_step_context: Option<Arc<StepContext>>,
@@ -0,0 +1,48 @@
//! Turn token metrics grouped by the model that actually produced each response.
//! Multiple responses from one model contribute a single histogram sample per turn.
use std::collections::BTreeMap;
use codex_otel::SessionTelemetry;
use codex_otel::TURN_TOKEN_USAGE_METRIC;
use codex_protocol::protocol::TokenUsage;
#[derive(Default)]
pub(crate) struct TurnTokenUsage {
by_model: BTreeMap<String, (SessionTelemetry, TokenUsage)>,
}
impl TurnTokenUsage {
pub(crate) fn record(&mut self, model: &str, telemetry: SessionTelemetry, usage: &TokenUsage) {
let (_, total) = self
.by_model
.entry(model.to_owned())
.or_insert_with(|| (telemetry, TokenUsage::default()));
total.add_assign(usage);
}
pub(crate) fn emit(self, fallback: &SessionTelemetry, tmp_mem: (&str, &str)) {
let mut usage = self.by_model.into_values().collect::<Vec<_>>();
// Preserve zero-valued completion samples for turns without reported usage.
// Do not create a sample for the selected model if only compaction ran.
if usage.is_empty() {
usage.push((fallback.clone(), TokenUsage::default()));
}
for (telemetry, usage) in usage {
for (token_type, value) in [
("total", usage.total_tokens),
("input", usage.input_tokens),
("cached_input", usage.cached_input()),
("cache_write_input", usage.cache_write_input_tokens),
("output", usage.output_tokens),
("reasoning_output", usage.reasoning_output_tokens),
] {
telemetry.histogram(
TURN_TOKEN_USAGE_METRIC,
value.max(0),
&[("token_type", token_type), tmp_mem],
);
}
}
}
}