Add Guardian context cost and request token telemetry (#44164)

## What changed

- Record per-section text bytes, estimated text tokens, image bytes, and image counts for synchronous reviews and asynchronous scoring without logging evidence payloads.
- Emit estimated request tokens through `codex.guardian.context.request_tokens`. Synchronous estimates include assembled history, instructions, tool definitions, and output format, and measure the full logical request before WebSocket delta generation. Asynchronous estimates cover the assembled input.
- Add shared context budgeting helpers, including conservative image token reservations independent of encoded payload size and model-aware input limit calculation.

## Testing

Add coverage for separate text and image accounting, image estimates independent of encoded size, and section estimates that bound delivered messages. Extend asynchronous scorer and app-server tests to verify cost metric emission.

GitOrigin-RevId: aed45ecd9c23706f88caa51f2a4f2c77872d3bdb
This commit is contained in:
felixxia-oai
2026-09-09 14:48:08 +00:00
committed by copyberry
parent 73a1148c9c
commit d3ffbbed5a
13 changed files with 472 additions and 9 deletions
@@ -179,6 +179,7 @@ struct MockResponsesState {
allow_guardian_review: Notify,
classification_completed: Notify,
truncation_recorded: Notify,
sync_cost_recorded: Notify,
luna_score: f64,
invalid_classification: bool,
review_outcome: ReviewOutcome,
@@ -717,6 +718,21 @@ async fn guardian_v2_routes_scoped_tool_approvals(
"/metrics",
post(
|State(state): State<Arc<MockResponsesState>>, body: String| async move {
if matches!(state.transcript_content, TranscriptContent::MixedEvidence) {
let payload: Value = serde_json::from_str(&body).expect("OTLP JSON");
for metric in payload["resourceMetrics"].as_array().into_iter().flatten()
.flat_map(|resource| resource["scopeMetrics"].as_array().into_iter().flatten())
.flat_map(|scope| scope["metrics"].as_array().into_iter().flatten())
.filter(|metric| metric["name"] == "codex.guardian.context.request_tokens")
{
for point in metric["histogram"]["dataPoints"].as_array().into_iter().flatten() {
let attributes = point["attributes"].as_array().expect("metric attributes");
if attributes.iter().any(|attr| attr["key"] == "target" && attr["value"]["stringValue"] == "sync") {
state.sync_cost_recorded.notify_one();
}
}
}
}
if body.contains("codex.guardian_v2.classification") {
state.classification_completed.notify_one();
}
@@ -1325,6 +1341,11 @@ async fn guardian_v2_routes_scoped_tool_approvals(
assert!(text.contains("Planned action JSON:"));
}
}
if mixed_evidence {
timeout(TIMEOUT, responses_state.sync_cost_recorded.notified())
.await
.expect("sync request cost should be exported");
}
if lifecycle.has_user_answer() {
let reviews = responses_state
.guardian_requests
+9
View File
@@ -1640,6 +1640,9 @@ impl ModelClientSession {
);
self.client
.prepare_response_items_for_request(&mut request.input);
if crate::guardian::is_basic_session_source(&self.client.state.session_source) {
crate::guardian::observe_guardian_request(session_telemetry, &request);
}
let request_session_telemetry =
session_telemetry_for_request(session_telemetry, &request);
let inference_trace_attempt = inference_trace.start_attempt();
@@ -1830,6 +1833,12 @@ impl ModelClientSession {
Err(err) => return Err(provider.map_api_error(err)),
}
// Measure the complete logical request, not only the websocket delta.
if !warmup
&& crate::guardian::is_basic_session_source(&self.client.state.session_source)
{
crate::guardian::observe_guardian_request(session_telemetry, &request);
}
let (incremental_request, previous_response_id_from_untraced_warmup) =
self.prepare_websocket_request(&request);
let inference_trace_attempt = if warmup {
+2
View File
@@ -9,6 +9,8 @@ mod decision;
mod feedback;
mod metrics;
mod prompt;
mod request_budget;
pub(crate) use request_budget::observe as observe_guardian_request;
mod review;
mod review_session;
mod reviewer_config;
+15 -3
View File
@@ -210,9 +210,21 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn(
if transcript_entries.is_empty() {
transcript.items.push(placeholder.to_owned());
}
let items = sections
.compose(presentation, transcript)?
.into_user_inputs()?;
let context = sections.compose(presentation, transcript)?;
for (section, cost) in context.section_costs() {
for (measurement, value) in cost.measurements() {
session.services.session_telemetry.histogram(
codex_guardian_context::SECTION_COST_METRIC,
i64::try_from(value).unwrap_or(i64::MAX),
&[
("target", "sync"),
("section", section),
("measurement", measurement),
],
);
}
}
let items = context.into_user_inputs()?;
Ok(GuardianPromptItems {
items,
transcript_cursor,
@@ -0,0 +1,33 @@
//! Measures the complete synchronous request after image admission and wire
//! prefix assembly. Includes reused history, tool definitions and output format.
use codex_guardian_context::REQUEST_TOKENS_METRIC;
use codex_protocol::protocol::TruncationPolicy;
use crate::context_manager::estimate_item_token_count;
use codex_api::ResponsesApiRequest;
use codex_otel::SessionTelemetry;
pub(crate) fn observe(telemetry: &SessionTelemetry, request: &ResponsesApiRequest) -> usize {
let input = request
.input
.iter()
.map(estimate_item_token_count)
.fold(0i64, i64::saturating_add);
let instructions = TruncationPolicy::Bytes(request.instructions.len()).token_budget();
let metadata = serde_json::to_vec(&(&request.tools, &request.text))
.map(|bytes| TruncationPolicy::Bytes(bytes.len()).token_budget())
.unwrap_or(usize::MAX);
let total = usize::try_from(input)
.unwrap_or(usize::MAX)
.saturating_add(instructions)
.saturating_add(metadata);
// The assembled input already includes inherited history and the current
// review. Do not report a guessed old/new split after context injection.
telemetry.histogram(
REQUEST_TOKENS_METRIC,
i64::try_from(total).unwrap_or(i64::MAX),
&[("target", "sync"), ("component", "total")],
);
total
}
@@ -600,6 +600,21 @@ impl GuardianV2Extension {
drop(history);
drop(node_repl_images);
truncations.extend(std::mem::take(&mut transcript.truncations));
if let Some(metrics) = metrics.as_deref() {
for (section, cost) in transcript.section_costs() {
for (measurement, value) in cost.measurements() {
metrics.histogram(
codex_guardian_context::SECTION_COST_METRIC,
i64::try_from(value).unwrap_or(i64::MAX),
&[
("target", "async"),
("section", section),
("measurement", measurement),
],
);
}
}
}
let classification_input = transcript.into_messages();
let mut failure_reason = "invalid_output";
let mut classification_risk = None;
@@ -349,6 +349,14 @@ fn fast_decision_metric(decision: &str, reason: &str) -> RecordedMetric {
#[derive(Default)]
struct RecordingMetrics(Mutex<Vec<RecordedMetric>>);
impl RecordingMetrics {
fn classification_samples(&self) -> Vec<RecordedMetric> {
self.0.lock().unwrap().iter().filter(|sample| {
!matches!(sample, RecordedMetric::Histogram(name, _, _) if name == codex_guardian_context::SECTION_COST_METRIC || name == codex_guardian_context::REQUEST_TOKENS_METRIC)
}).cloned().collect()
}
}
impl ExtensionMetrics for RecordingMetrics {
fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]) {
self.0.lock().unwrap().push(RecordedMetric::Counter(
@@ -1436,12 +1444,22 @@ max_recent_non_user_entries = 8
.latest_scored_tool_call
.load(Ordering::Acquire)
== 0
|| metrics.0.lock().unwrap().len() < 14
|| metrics.classification_samples().len() < 14
{
tokio::task::yield_now().await;
}
})
.await?;
assert!(metrics.0.lock().unwrap().iter().any(|sample| {
matches!(sample, RecordedMetric::Histogram(name, value, tags)
if name == codex_guardian_context::SECTION_COST_METRIC
&& *value > 0
&& tags == &[
("target".to_owned(), "async".to_owned()),
("section".to_owned(), "conversation_transcript".to_owned()),
("measurement".to_owned(), "text_bytes".to_owned()),
])
}));
thread_store.insert(SecurityRiskScore {
scores: BTreeMap::from([("action_risk".to_owned(), 0.65)]),
call_id: None,
@@ -1535,7 +1553,7 @@ max_recent_non_user_entries = 8
Some(ReviewDecision::Approved)
);
let samples = initial_metrics.0.lock().unwrap();
let samples = initial_metrics.classification_samples();
let classification_duration_ms = match &samples[9] {
RecordedMetric::Histogram(name, duration_ms, _)
if name == CLASSIFICATION_DURATION_METRIC =>
@@ -1545,7 +1563,7 @@ max_recent_non_user_entries = 8
sample => panic!("expected classification duration metric, got {sample:?}"),
};
assert_eq!(
*samples,
samples,
[
("total", 150),
("input", 120),
@@ -292,6 +292,23 @@ impl LunaSampler {
item.set_id(Some(ResponseItemId::new(prefix)));
}
}
let total_tokens = input
.iter()
.map(codex_guardian_context::estimate_input_tokens)
.fold(0usize, usize::saturating_add);
if let Some(metrics) = self.config.metrics.as_deref() {
for (component, tokens) in [
("existing_context", 0),
("new_input", total_tokens),
("total", total_tokens),
] {
metrics.histogram(
codex_guardian_context::REQUEST_TOKENS_METRIC,
i64::try_from(tokens).unwrap_or(i64::MAX),
&[("target", "async"), ("component", component)],
);
}
}
let mut request = ResponsesApiRequest {
model: MODEL.to_owned(),
instructions: String::new(),
@@ -276,14 +276,29 @@ async fn sampler_records_token_usage_after_returning_an_early_classification() -
assert_eq!(sampler.sample(sample_request("turn-1")).await?, "low");
tokio::time::timeout(Duration::from_secs(2), async {
while metrics.0.lock().unwrap().len() < 7 {
while metrics
.0
.lock()
.unwrap()
.iter()
.filter(|metric| metric.0 == CLASSIFICATION_TOKEN_USAGE_METRIC)
.count()
< 7
{
tokio::task::yield_now().await;
}
})
.await?;
assert_eq!(
*metrics.0.lock().unwrap(),
metrics
.0
.lock()
.unwrap()
.iter()
.filter(|metric| metric.0 == CLASSIFICATION_TOKEN_USAGE_METRIC)
.cloned()
.collect::<Vec<_>>(),
[
("total", 37),
("input", 37),
@@ -300,6 +315,27 @@ async fn sampler_records_token_usage_after_returning_an_early_classification() -
))
);
let request = server
.wait_for_request(
/*connection_index*/ INITIAL_WEBSOCKET_CONNECTIONS - 1,
/*request_index*/ 0,
)
.await
.body_json();
let input: Vec<ResponseItem> = serde_json::from_value(request["input"].clone())?;
let estimated = input
.iter()
.map(codex_guardian_context::estimate_input_tokens)
.sum::<usize>();
assert!(metrics.0.lock().unwrap().contains(&(
codex_guardian_context::REQUEST_TOKENS_METRIC.to_owned(),
i64::try_from(estimated)?,
vec![
("target".to_owned(), "async".to_owned()),
("component".to_owned(), "total".to_owned()),
],
)));
Ok(())
}
+200
View File
@@ -0,0 +1,200 @@
//! Payload-free accounting for composed sections and complete Guardian requests.
//! Text and image bytes stay separate. Hosts provide existing-context estimates;
//! the shared wire estimate reserves image tokens without counting base64 as text.
use std::io::Write;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::TruncationPolicy;
use crate::ComposedContext;
use crate::composition::SectionDelivery;
use crate::composition::SectionOutput;
pub const SECTION_COST_METRIC: &str = "codex.guardian.context.section_cost";
pub const REQUEST_TOKENS_METRIC: &str = "codex.guardian.context.request_tokens";
// A conservative reservation matching the existing original-image patch ceiling.
const IMAGE_TOKEN_RESERVATION: usize = 10_000;
/// Conservative input ceiling when the model catalog has no authoritative window.
pub const DEFAULT_MAX_INPUT_TOKENS: usize = 128_000;
/// Applies the same configured-window cap and effective percentage as sync review.
/// Async callers supply no parent-model override.
pub fn effective_input_token_limit(
model: &codex_protocol::openai_models::ModelInfo,
configured_window: Option<i64>,
) -> usize {
let supported = model
.resolved_context_window()
.unwrap_or(DEFAULT_MAX_INPUT_TOKENS as i64);
let supported = if model.used_fallback_model_metadata {
supported.min(DEFAULT_MAX_INPUT_TOKENS as i64)
} else {
supported
};
let limit = configured_window
.unwrap_or(supported)
.min(supported)
.saturating_mul(model.effective_context_window_percent.clamp(0, 100))
/ 100;
usize::try_from(limit.max(0)).unwrap_or(usize::MAX)
}
/// Complete input allowance after the host resolves its model and configuration.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RequestBudget {
pub max_input_tokens: usize,
pub existing_context_tokens: usize,
}
/// Size of section evidence, excluding enclosing message serialization overhead.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SectionCost {
pub text_bytes: usize,
pub image_bytes: usize,
pub image_count: usize,
}
impl SectionCost {
pub fn measurements(self) -> [(&'static str, usize); 4] {
[
("text_bytes", self.text_bytes),
(
"estimated_text_tokens",
TruncationPolicy::Bytes(self.text_bytes).token_budget(),
),
("image_bytes", self.image_bytes),
("image_count", self.image_count),
]
}
}
impl ComposedContext {
/// Conservative cost of composed evidence, including message wrappers.
/// Shared user sections each reserve a wrapper, bounding their coalesced cost.
pub fn estimated_tokens(&self) -> usize {
self.sections
.iter()
.map(section_tokens)
.fold(0usize, usize::saturating_add)
}
/// Stable section names and numeric costs; never exposes evidence in diagnostics.
pub fn section_costs(&self) -> impl Iterator<Item = (&'static str, SectionCost)> + '_ {
self.sections.iter().map(|section| {
let content = match &section.delivery {
SectionDelivery::UserContent(content) => content,
SectionDelivery::Message(message) => match message.as_ref() {
ResponseItem::Message { content, .. } => content,
item => {
return (
section.id,
SectionCost {
text_bytes: ByteCount::item(item),
..SectionCost::default()
},
);
}
},
};
let mut cost = SectionCost::default();
for item in content {
match item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
cost.text_bytes = cost.text_bytes.saturating_add(text.len());
}
ContentItem::InputImage { image_url, .. } => {
cost.image_bytes = cost.image_bytes.saturating_add(image_url.len());
cost.image_count = cost.image_count.saturating_add(1);
}
ContentItem::InputAudio { audio_url } => {
// Guardian currently has no audio contributor. Count a future opaque
// payload conservatively until its consumer supplies modality costs.
cost.text_bytes = cost.text_bytes.saturating_add(audio_url.len());
}
}
}
(section.id, cost)
})
}
}
/// Conservative estimate including roles, framing, metadata and opaque checkpoints.
/// Images reserve tokens separately from their wire bytes. This is a budgeting
/// heuristic, not the model's tokenizer or a measurement of server-side usage.
pub fn estimate_input_tokens(item: &ResponseItem) -> usize {
let content = match item {
ResponseItem::Message { content, .. } => content.as_slice(),
_ => &[],
};
adjusted_tokens(ByteCount::item(item), content)
}
pub(super) fn content_tokens(item: &ContentItem) -> usize {
let bytes = ByteCount::measure(|counter| serde_json::to_writer(counter, item));
adjusted_tokens(bytes, std::slice::from_ref(item))
}
pub(super) fn section_tokens(section: &SectionOutput) -> usize {
match &section.delivery {
SectionDelivery::UserContent(content) if content.is_empty() => 0,
SectionDelivery::UserContent(content) => {
let separators =
TruncationPolicy::Bytes(content.len().saturating_sub(1)).token_budget();
content.iter().map(content_tokens).fold(
estimate_input_tokens(&crate::composition::user_message(Vec::new()))
.saturating_add(separators),
usize::saturating_add,
)
}
SectionDelivery::Message(message) => estimate_input_tokens(message),
}
}
fn adjusted_tokens(mut bytes: usize, content: &[ContentItem]) -> usize {
for item in content {
if let ContentItem::InputImage { image_url, .. } = item {
let payload = ByteCount::measure(|counter| serde_json::to_writer(counter, image_url));
if payload == usize::MAX {
return usize::MAX;
}
bytes = bytes
.saturating_sub(payload.saturating_sub(2))
.saturating_add(TruncationPolicy::Tokens(IMAGE_TOKEN_RESERVATION).byte_budget());
}
}
TruncationPolicy::Bytes(bytes).token_budget()
}
#[derive(Default)]
struct ByteCount(usize);
impl ByteCount {
fn item(item: &ResponseItem) -> usize {
Self::measure(|counter| serde_json::to_writer(counter, item))
}
fn measure(serialize: impl FnOnce(&mut Self) -> serde_json::Result<()>) -> usize {
let mut count = Self::default();
if serialize(&mut count).is_err() {
return usize::MAX;
}
count.0
}
}
impl Write for ByteCount {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0 = self.0.saturating_add(bytes.len());
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[cfg(test)]
#[path = "budget_tests.rs"]
mod tests;
@@ -0,0 +1,92 @@
//! Costs distinguish evidence payloads from complete model request estimates.
use super::*;
use crate::composition::SectionOutput;
use crate::composition::user_message as message;
use pretty_assertions::assert_eq;
#[test]
fn section_costs_keep_multimodal_payloads_separate() {
let context = ComposedContext {
sections: vec![
SectionOutput {
id: "transcript",
delivery: SectionDelivery::UserContent(vec![
ContentItem::InputText {
text: "évidence".to_owned(),
},
ContentItem::InputImage {
image_url: "data:image/png;base64,AAAA".to_owned(),
detail: None,
},
]),
},
SectionOutput {
id: "trusted",
delivery: SectionDelivery::Message(Box::new(message(vec![
ContentItem::InputText {
text: "verified".to_owned(),
},
]))),
},
],
truncations: Vec::new(),
};
assert_eq!(
context.section_costs().collect::<Vec<_>>(),
vec![
(
"transcript",
SectionCost {
text_bytes: "évidence".len(),
image_bytes: "data:image/png;base64,AAAA".len(),
image_count: 1
}
),
(
"trusted",
SectionCost {
text_bytes: "verified".len(),
..SectionCost::default()
}
),
]
);
}
#[test]
fn request_estimate_reserves_images_independently_of_encoded_size() {
let image = |payload: &str| {
message(vec![ContentItem::InputImage {
image_url: format!("data:image/png;base64,{payload}"),
detail: None,
}])
};
let short = estimate_input_tokens(&image("AAAA"));
assert_eq!(short, estimate_input_tokens(&image(&"A".repeat(200_000))));
assert!(short >= IMAGE_TOKEN_RESERVATION);
}
#[test]
fn section_estimate_bounds_the_delivered_message() {
// Individually rounded costs do not always leave room for JSON separators.
for text in ["x", "quoted \"text\"", "évidence"] {
for count in 1..=40 {
let context = ComposedContext {
sections: vec![SectionOutput {
id: "transcript",
delivery: SectionDelivery::UserContent(vec![
ContentItem::InputText {
text: text.to_owned(),
};
count
]),
}],
truncations: Vec::new(),
};
let estimate = context.estimated_tokens();
let delivered = context.into_messages();
assert!(estimate >= estimate_input_tokens(&delivered[0]));
}
}
}
+1 -1
View File
@@ -270,7 +270,7 @@ impl ComposedContext {
}
}
fn user_message(content: Vec<ContentItem>) -> ResponseItem {
pub(super) fn user_message(content: Vec<ContentItem>) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_owned(),
+8
View File
@@ -48,7 +48,15 @@ pub use verified_answers::render_verified_answers;
mod retained_instructions;
mod action;
mod budget;
mod composition;
pub use budget::DEFAULT_MAX_INPUT_TOKENS;
pub use budget::REQUEST_TOKENS_METRIC;
pub use budget::RequestBudget;
pub use budget::SECTION_COST_METRIC;
pub use budget::SectionCost;
pub use budget::effective_input_token_limit;
pub use budget::estimate_input_tokens;
mod profile;
pub use composition::CollectedContext;
pub use composition::ComposedContext;