mirror of
https://github.com/openai/codex.git
synced 2026-11-09 00:39:32 +00:00
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:
1 parent
73a1148c9c
commit
d3ffbbed5a
13 files changed
+472
-9
No files matched your search
@@ -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 §ion.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 §ion.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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user