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 authored and copyberry committed 2026-09-09 14:48:08 +00:00
1 parent 73a1148c9c
commit d3ffbbed5a
13 files changed
+472 -9

No files matched your search

+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;