use serde::{Deserialize, Serialize};
use crate::context::{ContextItem, ContextVisibility};
pub const TOKEN_ESTIMATOR_VERSION: &str = "utf8-bytes-divisor-3-overhead-16-v1";
pub const USAGE_NORMALIZATION_VERSION: &str = "provider-inclusive-input-v1";
pub const MODEL_PROJECTION_MAX_BYTES: usize = 128 * 1024;
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MeasurementProvenance {
ExactSerialized {
boundary: String,
},
Estimated {
estimator: String,
version: String,
},
ProviderReported {
provider: String,
component: String,
},
Derived {
rule: String,
version: String,
},
Unknown,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ByteMeasurement {
pub value: u64,
pub provenance: MeasurementProvenance,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct TokenMeasurement {
pub value: Option<u64>,
pub provenance: MeasurementProvenance,
}
impl TokenMeasurement {
pub const fn unknown() -> Self {
Self { value: None, provenance: MeasurementProvenance::Unknown }
}
pub fn provider(provider: &str, component: &str, value: Option<u64>) -> Self {
Self {
value,
provenance: if value.is_some() {
MeasurementProvenance::ProviderReported {
provider: provider.to_string(),
component: component.to_string(),
}
} else {
MeasurementProvenance::Unknown
},
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct ProviderUsageComponents {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cache_read_input_tokens: Option<u64>,
pub cache_creation_input_tokens: Option<u64>,
pub reasoning_tokens: Option<u64>,
}
impl ProviderUsageComponents {
pub const fn new(input_tokens: u64, output_tokens: u64) -> Self {
Self {
input_tokens: Some(input_tokens),
output_tokens: Some(output_tokens),
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
reasoning_tokens: None,
}
}
pub fn merge_snapshot(&mut self, next: &Self) {
merge_max(&mut self.input_tokens, next.input_tokens);
merge_max(&mut self.output_tokens, next.output_tokens);
merge_max(&mut self.cache_read_input_tokens, next.cache_read_input_tokens);
merge_max(&mut self.cache_creation_input_tokens, next.cache_creation_input_tokens);
merge_max(&mut self.reasoning_tokens, next.reasoning_tokens);
}
pub fn normalize(&self, provider: &str, rule: ProviderUsageRule) -> ProviderUsage {
let inclusive_input_tokens = match rule {
ProviderUsageRule::AnthropicMessages => self.input_tokens.map(|input| {
input
.saturating_add(self.cache_read_input_tokens.unwrap_or(0))
.saturating_add(self.cache_creation_input_tokens.unwrap_or(0))
}),
ProviderUsageRule::OpenAiChat | ProviderUsageRule::OpenAiResponses => self.input_tokens,
};
ProviderUsage {
provider: provider.to_string(),
rule,
components: self.clone(),
inclusive_input_tokens: TokenMeasurement {
value: inclusive_input_tokens,
provenance: if inclusive_input_tokens.is_some() {
MeasurementProvenance::Derived {
rule: rule.label().to_string(),
version: USAGE_NORMALIZATION_VERSION.to_string(),
}
} else {
MeasurementProvenance::Unknown
},
},
}
}
}
fn merge_max(current: &mut Option<u64>, next: Option<u64>) {
if let Some(next) = next {
*current = Some(current.map_or(next, |current| current.max(next)));
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderUsageRule {
AnthropicMessages,
OpenAiChat,
OpenAiResponses,
}
impl ProviderUsageRule {
pub const fn label(self) -> &'static str {
match self {
Self::AnthropicMessages => "anthropic_input_plus_cache_components",
Self::OpenAiChat => "openai_prompt_tokens_inclusive",
Self::OpenAiResponses => "openai_responses_input_tokens_inclusive",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ProviderUsage {
pub provider: String,
pub rule: ProviderUsageRule,
pub components: ProviderUsageComponents,
pub inclusive_input_tokens: TokenMeasurement,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ModelProjectionMessage {
pub role: String,
pub content: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ContextReductionReceipt {
pub item_id: String,
pub method: String,
pub version: String,
pub before_bytes: u64,
pub after_bytes: u64,
pub lossy: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ContextItemSnapshot {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_handle: Option<String>,
pub state: ContextVisibility,
pub reason_code: String,
pub reason: String,
}
impl From<&ContextItem> for ContextItemSnapshot {
fn from(item: &ContextItem) -> Self {
Self {
id: item.id.clone(),
artifact_handle: item.artifact_handle.clone(),
state: item.visibility.clone(),
reason_code: item.reason_code.clone(),
reason: item.reason.clone(),
}
}
}
pub fn snapshot_context(items: &[ContextItem]) -> Vec<ContextItemSnapshot> {
items.iter().map(ContextItemSnapshot::from).collect()
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ProviderRequestAccounting {
pub turn_id: String,
pub request_id: String,
pub attempt: u32,
pub provider: String,
pub model: String,
pub serialized_bytes: ByteMeasurement,
pub estimated_input_tokens: TokenMeasurement,
pub provider_usage: Option<ProviderUsage>,
pub context: Vec<ContextItemSnapshot>,
#[serde(default)]
pub shadow_receipts: Vec<ContextReductionReceipt>,
#[serde(skip)]
pub model_projection: Vec<ModelProjectionMessage>,
}
impl ProviderRequestAccounting {
pub fn from_serialized_request(
turn_id: impl Into<String>, request_id: impl Into<String>, attempt: u32, provider: &str, model: &str,
bytes: &[u8], context: Vec<ContextItemSnapshot>,
) -> Self {
let byte_count = bytes.len() as u64;
Self {
turn_id: turn_id.into(),
request_id: request_id.into(),
attempt,
provider: provider.to_string(),
model: model.to_string(),
serialized_bytes: ByteMeasurement {
value: byte_count,
provenance: MeasurementProvenance::ExactSerialized { boundary: "provider_request_body".to_string() },
},
estimated_input_tokens: TokenMeasurement {
value: Some(estimate_serialized_tokens(byte_count)),
provenance: MeasurementProvenance::Estimated {
estimator: "utf8_bytes_divisor_3_plus_item_overhead".to_string(),
version: TOKEN_ESTIMATOR_VERSION.to_string(),
},
},
provider_usage: None,
context,
shadow_receipts: Vec::new(),
model_projection: Vec::new(),
}
}
pub fn with_model_projection(mut self, projection: Vec<ModelProjectionMessage>) -> Self {
let mut bytes = 0usize;
self.model_projection = projection
.into_iter()
.filter_map(|mut message| {
let remaining = MODEL_PROJECTION_MAX_BYTES.saturating_sub(bytes);
if remaining == 0 {
return None;
}
message.content = truncate_utf8(&message.content, remaining);
bytes = bytes
.saturating_add(message.role.len())
.saturating_add(message.content.len());
Some(message)
})
.collect();
self
}
}
fn truncate_utf8(value: &str, max_bytes: usize) -> String {
if value.len() <= max_bytes {
return value.to_string();
}
let mut end = max_bytes;
while end > 0 && !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_string()
}
pub const fn estimate_serialized_tokens(bytes: u64) -> u64 {
bytes.div_ceil(3).saturating_add(16)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_components_preserve_unknown_and_measured_zero() {
let components = ProviderUsageComponents {
input_tokens: Some(0),
output_tokens: None,
cache_read_input_tokens: None,
cache_creation_input_tokens: Some(0),
reasoning_tokens: None,
};
assert_eq!(components.input_tokens, Some(0));
assert_eq!(components.output_tokens, None);
assert_eq!(components.cache_creation_input_tokens, Some(0));
}
#[test]
fn repeated_stream_snapshots_are_not_added_twice() {
let mut total = ProviderUsageComponents::new(12, 3);
total.merge_snapshot(&ProviderUsageComponents::new(12, 3));
total.merge_snapshot(&ProviderUsageComponents::new(15, 4));
assert_eq!(total.input_tokens, Some(15));
assert_eq!(total.output_tokens, Some(4));
}
#[test]
fn provider_rules_normalize_anthropic_and_openai_inputs_differently() {
let components = ProviderUsageComponents {
input_tokens: Some(100),
output_tokens: Some(10),
cache_read_input_tokens: Some(20),
cache_creation_input_tokens: Some(5),
reasoning_tokens: Some(2),
};
assert_eq!(
components
.normalize("anthropic", ProviderUsageRule::AnthropicMessages)
.inclusive_input_tokens
.value,
Some(125)
);
assert_eq!(
components
.normalize("openai", ProviderUsageRule::OpenAiChat)
.inclusive_input_tokens
.value,
Some(100)
);
}
#[test]
fn request_accounting_records_exact_bytes_and_context_once() {
let item = ContextItem {
id: "item-1".to_string(),
kind: crate::context::ContextItemKind::Transcript,
label: "turn".to_string(),
source_path: None,
scope: ".".to_string(),
content_hash: None,
artifact_handle: None,
byte_count: 4,
content: None,
token_estimate: 18,
visibility: ContextVisibility::Visible,
reason_code: "recent_transcript".to_string(),
reason: "recent transcript entry".to_string(),
};
let context = snapshot_context(std::slice::from_ref(&item));
let accounting = ProviderRequestAccounting::from_serialized_request(
"turn_1",
"turn_1:request:0",
1,
"provider",
"model",
b"{}",
context,
);
assert_eq!(accounting.serialized_bytes.value, 2);
assert_eq!(accounting.estimated_input_tokens.value, Some(17));
assert_eq!(accounting.context.len(), 1);
assert_eq!(accounting.context[0].reason_code, "recent_transcript");
}
}