#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
use bcode_model::{ModelPricingBucket, ProviderRequestProjection, TokenUsage};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
pub const PROMPT_CACHE_VERIFICATION_REPORT_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PromptCacheMechanism {
AutomaticPrefix,
ExplicitPoints,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PromptCacheThresholds {
pub min_hit_round_ratio: f64,
pub min_warm_read_ratio: f64,
pub max_late_uncached_ratio: f64,
pub max_write_amplification: f64,
}
impl Default for PromptCacheThresholds {
fn default() -> Self {
Self {
min_hit_round_ratio: 0.9,
min_warm_read_ratio: 0.5,
max_late_uncached_ratio: 0.15,
max_write_amplification: 3.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PromptCacheExpectations {
pub mechanism: PromptCacheMechanism,
#[serde(default)]
pub reports_cache_writes: Option<bool>,
pub supports_cache_key: bool,
#[serde(default)]
pub ttl_seconds: BTreeSet<u64>,
pub min_prefix_tokens: u64,
pub min_prefix_declared: bool,
#[serde(default)]
pub max_cache_points: Option<usize>,
#[serde(default)]
pub thresholds: PromptCacheThresholds,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheWriteTtlTokens {
#[serde(default)]
pub ttl_seconds: Option<u64>,
pub tokens: u32,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheRoundObservation {
pub round: usize,
#[serde(default)]
pub input_tokens: Option<u32>,
#[serde(default)]
pub cached_input_tokens: Option<u32>,
#[serde(default)]
pub cache_write_input_tokens: Option<u32>,
#[serde(default)]
pub uncached_input_tokens: Option<u32>,
pub valid_input_breakdown: bool,
#[serde(default)]
pub cache_ttl_seconds: Option<u64>,
#[serde(default)]
pub cache_write_tokens_by_ttl: Vec<CacheWriteTtlTokens>,
#[serde(default)]
pub detailed_cache_read_tokens: Option<u32>,
#[serde(default)]
pub requested_cache_points: Option<usize>,
#[serde(default)]
pub emitted_cache_points: Option<usize>,
#[serde(default)]
pub dropped_cache_points: Option<usize>,
pub tool_round: bool,
}
impl CacheRoundObservation {
#[must_use]
pub fn from_provider_usage(
round: usize,
usage: &TokenUsage,
projection: Option<&ProviderRequestProjection>,
) -> Self {
let mut cache_write_tokens_by_ttl: BTreeMap<Option<u64>, u32> = BTreeMap::new();
let mut detailed_cache_read_tokens = None;
for detail in &usage.details {
match detail.bucket {
ModelPricingBucket::CacheWriteInput => {
let total = cache_write_tokens_by_ttl
.entry(detail.cache_ttl_seconds)
.or_insert(0_u32);
*total = total.saturating_add(detail.tokens);
}
ModelPricingBucket::CacheReadInput => {
detailed_cache_read_tokens = Some(
detailed_cache_read_tokens
.unwrap_or(0_u32)
.saturating_add(detail.tokens),
);
}
ModelPricingBucket::Input | ModelPricingBucket::Output => {}
}
}
Self {
round,
input_tokens: usage.input_tokens,
cached_input_tokens: usage.cached_input_tokens,
cache_write_input_tokens: usage.cache_write_input_tokens,
uncached_input_tokens: usage.uncached_input_tokens(),
valid_input_breakdown: usage.has_valid_input_breakdown(),
cache_ttl_seconds: usage.pricing_context.cache_ttl_seconds,
cache_write_tokens_by_ttl: cache_write_tokens_by_ttl
.into_iter()
.map(|(ttl_seconds, tokens)| CacheWriteTtlTokens {
ttl_seconds,
tokens,
})
.collect(),
detailed_cache_read_tokens,
requested_cache_points: projection.and_then(|projection| projection.cache_point_count),
emitted_cache_points: projection
.and_then(|projection| projection.emitted_cache_point_count),
dropped_cache_points: projection
.and_then(|projection| projection.dropped_cache_point_count),
tool_round: false,
}
}
#[must_use]
pub const fn with_tool_round(mut self, tool_round: bool) -> Self {
self.tool_round = tool_round;
self
}
#[must_use]
pub fn from_session_usage(
round: usize,
usage: &bcode_session_models::SessionTokenUsage,
) -> Self {
let details = usage
.pricing_usage_details
.iter()
.filter_map(|detail| {
Some(bcode_model::ModelTokenUsageDetail {
bucket: match detail.bucket.as_str() {
"input" => ModelPricingBucket::Input,
"cache_read_input" => ModelPricingBucket::CacheReadInput,
"cache_write_input" => ModelPricingBucket::CacheWriteInput,
"output" => ModelPricingBucket::Output,
_ => return None,
},
modality: match detail.modality.as_str() {
"image" => bcode_model::ModelTokenModality::Image,
"audio" => bcode_model::ModelTokenModality::Audio,
"video" => bcode_model::ModelTokenModality::Video,
_ => bcode_model::ModelTokenModality::Text,
},
tokens: detail.tokens,
cache_ttl_seconds: detail.cache_ttl_seconds,
})
})
.collect::<Vec<_>>();
let normalized = TokenUsage {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
total_tokens: usage.total_tokens,
cached_input_tokens: usage.cached_input_tokens,
cache_write_input_tokens: usage.cache_write_input_tokens,
details: details.into_boxed_slice(),
pricing_context: Box::new(bcode_model::ModelPricingContext {
request_input_tokens: usage.pricing_context.request_input_tokens,
cache_ttl_seconds: usage.pricing_context.cache_ttl_seconds,
..bcode_model::ModelPricingContext::default()
}),
reasoning_tokens: usage.reasoning_tokens,
};
Self::from_provider_usage(round, &normalized, None)
}
#[must_use]
pub fn has_cache_read(&self) -> bool {
self.cached_input_tokens.is_some_and(|tokens| tokens > 0)
}
#[must_use]
pub fn has_cache_write(&self) -> bool {
self.cache_write_input_tokens
.is_some_and(|tokens| tokens > 0)
}
}
pub mod measurement {
pub const ROUND_COUNT: &str = "prompt_cache.round_count";
pub const ELIGIBLE_ROUND_COUNT: &str = "prompt_cache.eligible_round_count";
pub const HIT_ROUND_COUNT: &str = "prompt_cache.hit_round_count";
pub const HIT_ROUND_RATIO: &str = "prompt_cache.hit_round_ratio";
pub const CACHED_INPUT_INCREASE_COUNT: &str = "prompt_cache.cached_input_increase_count";
pub const UNCACHED_INPUT_TOKENS: &str = "prompt_cache.uncached_input_tokens";
pub const CACHED_INPUT_TOKENS: &str = "prompt_cache.cached_input_tokens";
pub const CACHE_WRITE_INPUT_TOKENS: &str = "prompt_cache.cache_write_input_tokens";
pub const INPUT_TOKENS: &str = "prompt_cache.input_tokens";
pub const LATE_UNCACHED_RATIO: &str = "prompt_cache.late_uncached_ratio";
pub const WRITE_AMPLIFICATION: &str = "prompt_cache.write_amplification";
pub const FIRST_REQUEST_READ_RATIO: &str = "prompt_cache.first_request_read_ratio";
pub const WARM_READ_RATIO: &str = "prompt_cache.warm_read_ratio";
pub const DROPPED_CACHE_POINTS: &str = "prompt_cache.dropped_cache_points";
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "status")]
pub enum PromptCacheScenarioOutcome {
Passed,
Skipped {
reason: String,
},
Failed {
reason: String,
},
}
impl PromptCacheScenarioOutcome {
#[must_use]
pub const fn is_passed(&self) -> bool {
matches!(self, Self::Passed)
}
#[must_use]
pub const fn is_failed(&self) -> bool {
matches!(self, Self::Failed { .. })
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PromptCacheScenarioResult {
pub scenario: String,
pub outcome: PromptCacheScenarioOutcome,
#[serde(default)]
pub measurements: BTreeMap<String, f64>,
#[serde(default)]
pub rounds: Vec<CacheRoundObservation>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PromptCacheVerificationReport {
pub schema_version: u32,
pub provider_id: String,
pub model_id: String,
#[serde(default)]
pub expectations: Option<PromptCacheExpectations>,
pub scenarios: Vec<PromptCacheScenarioResult>,
}
impl PromptCacheVerificationReport {
#[must_use]
pub fn is_success(&self) -> bool {
self.scenarios
.iter()
.all(|scenario| !scenario.outcome.is_failed())
}
#[must_use]
pub fn verified_any_behavior(&self) -> bool {
self.scenarios
.iter()
.any(|scenario| scenario.outcome.is_passed())
}
}
#[cfg(test)]
mod tests {
use super::*;
use bcode_model::{ModelPricingContext, ModelTokenModality, ModelTokenUsageDetail};
#[test]
fn observation_normalizes_usage_and_projection() {
let usage = TokenUsage {
input_tokens: Some(1_000),
output_tokens: Some(10),
total_tokens: Some(1_010),
cached_input_tokens: Some(600),
cache_write_input_tokens: Some(300),
details: Box::new([
ModelTokenUsageDetail {
bucket: ModelPricingBucket::CacheWriteInput,
modality: ModelTokenModality::Text,
tokens: 300,
cache_ttl_seconds: Some(3_600),
},
ModelTokenUsageDetail {
bucket: ModelPricingBucket::CacheReadInput,
modality: ModelTokenModality::Text,
tokens: 600,
cache_ttl_seconds: None,
},
]),
pricing_context: Box::new(ModelPricingContext {
cache_ttl_seconds: Some(3_600),
..ModelPricingContext::default()
}),
reasoning_tokens: None,
};
let projection = ProviderRequestProjection {
context_representation: None,
cache_point_count: Some(3),
emitted_cache_point_count: Some(3),
dropped_cache_point_count: Some(0),
..ProviderRequestProjection::default()
};
let observation = CacheRoundObservation::from_provider_usage(2, &usage, Some(&projection))
.with_tool_round(true);
assert_eq!(observation.round, 2);
assert_eq!(observation.uncached_input_tokens, Some(100));
assert!(observation.valid_input_breakdown);
assert_eq!(observation.cache_ttl_seconds, Some(3_600));
assert_eq!(
observation.cache_write_tokens_by_ttl,
vec![CacheWriteTtlTokens {
ttl_seconds: Some(3_600),
tokens: 300,
}]
);
assert_eq!(observation.detailed_cache_read_tokens, Some(600));
assert_eq!(observation.requested_cache_points, Some(3));
assert_eq!(observation.dropped_cache_points, Some(0));
assert!(observation.tool_round);
assert!(observation.has_cache_read());
assert!(observation.has_cache_write());
}
#[test]
fn report_success_ignores_skips_but_not_failures() {
let mut report = PromptCacheVerificationReport {
schema_version: PROMPT_CACHE_VERIFICATION_REPORT_SCHEMA_VERSION,
provider_id: "p".into(),
model_id: "m".into(),
expectations: None,
scenarios: vec![PromptCacheScenarioResult {
scenario: "a".into(),
outcome: PromptCacheScenarioOutcome::Skipped {
reason: "not advertised".into(),
},
measurements: BTreeMap::new(),
rounds: Vec::new(),
}],
};
assert!(report.is_success());
assert!(!report.verified_any_behavior());
report.scenarios.push(PromptCacheScenarioResult {
scenario: "b".into(),
outcome: PromptCacheScenarioOutcome::Failed {
reason: "no reuse".into(),
},
measurements: BTreeMap::new(),
rounds: Vec::new(),
});
assert!(!report.is_success());
}
#[test]
fn report_round_trips_through_json() {
let report = PromptCacheVerificationReport {
schema_version: PROMPT_CACHE_VERIFICATION_REPORT_SCHEMA_VERSION,
provider_id: "p".into(),
model_id: "m".into(),
expectations: Some(PromptCacheExpectations {
mechanism: PromptCacheMechanism::ExplicitPoints,
reports_cache_writes: Some(true),
supports_cache_key: true,
ttl_seconds: BTreeSet::from([300, 3_600]),
min_prefix_tokens: 1_024,
min_prefix_declared: true,
max_cache_points: Some(4),
thresholds: PromptCacheThresholds::default(),
}),
scenarios: Vec::new(),
};
let json = serde_json::to_string(&report).expect("serialize");
let decoded: PromptCacheVerificationReport =
serde_json::from_str(&json).expect("deserialize");
assert_eq!(decoded, report);
}
}