1use serde::{Deserialize, Serialize};
28use serde_json::Value;
29
30use crate::llm::capabilities::{self, Capabilities, WireDialect};
31
32pub const CACHE_CONFORMANCE_SCHEMA_VERSION: u32 = 1;
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct CacheControlProfile {
43 pub prompt_caching: bool,
46 pub cache_breakpoint_style: String,
49 pub min_useful_prefix_tokens: Option<u32>,
53 pub ttl_notes: Option<String>,
56 pub cache_read_usage_field: String,
59 pub cache_write_usage_field: String,
63}
64
65impl CacheControlProfile {
66 pub fn from_capabilities(caps: &Capabilities) -> Self {
71 if !caps.prompt_caching {
72 return Self {
73 prompt_caching: false,
74 cache_breakpoint_style: caps.cache_breakpoint_style.clone(),
75 min_useful_prefix_tokens: None,
76 ttl_notes: None,
77 cache_read_usage_field: String::new(),
78 cache_write_usage_field: String::new(),
79 };
80 }
81 let (min_prefix, ttl, read_field, write_field) = match caps.message_wire_format {
82 WireDialect::Anthropic => (
83 1024,
84 "5m default breakpoint TTL; 1h with the extended-cache-ttl beta",
85 "usage.cache_read_input_tokens",
86 "usage.cache_creation_input_tokens",
87 ),
88 WireDialect::Gemini => (
89 1024,
90 "Implicit caching with provider-managed eviction; explicit cachedContent honors a caller TTL",
91 "usageMetadata.cachedContentTokenCount",
92 "",
93 ),
94 WireDialect::OpenAiCompat => (
97 1024,
98 "Automatic prefix caching; entries idle-evict after ~5-10 minutes",
99 "usage.prompt_tokens_details.cached_tokens",
100 "",
101 ),
102 WireDialect::Ollama => (0, "No provider-reported cache accounting", "", ""),
106 };
107 Self {
108 prompt_caching: true,
109 cache_breakpoint_style: caps.cache_breakpoint_style.clone(),
110 min_useful_prefix_tokens: if min_prefix > 0 {
111 Some(min_prefix)
112 } else {
113 None
114 },
115 ttl_notes: if ttl.is_empty() {
116 None
117 } else {
118 Some(ttl.to_string())
119 },
120 cache_read_usage_field: read_field.to_string(),
121 cache_write_usage_field: write_field.to_string(),
122 }
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum PromptCacheSupportStatus {
132 CacheSupported,
133 CacheUnsupported,
134 CacheSupportUnknown,
135}
136
137impl PromptCacheSupportStatus {
138 pub fn as_str(self) -> &'static str {
139 match self {
140 Self::CacheSupported => "cache_supported",
141 Self::CacheUnsupported => "cache_unsupported",
142 Self::CacheSupportUnknown => "cache_support_unknown",
143 }
144 }
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct PromptCacheSupport {
151 pub status: PromptCacheSupportStatus,
152 pub supported: Option<bool>,
155 #[serde(skip_serializing_if = "Option::is_none")]
158 pub cache_tier: Option<String>,
159 pub resolved_provider: String,
160 pub resolved_model: String,
161 pub source: String,
162 pub profile: CacheControlProfile,
163}
164
165pub fn prompt_cache_support(provider: &str, model: &str) -> PromptCacheSupport {
169 let provider_key = provider.trim();
170 let model_key = model.trim();
171 let unresolved = provider_key.is_empty()
172 || provider_key.eq_ignore_ascii_case("auto")
173 || model_key.is_empty();
174 if unresolved {
175 return PromptCacheSupport {
176 status: PromptCacheSupportStatus::CacheSupportUnknown,
177 supported: None,
178 cache_tier: None,
179 resolved_provider: provider_key.to_string(),
180 resolved_model: model_key.to_string(),
181 source: "unresolved".to_string(),
182 profile: CacheControlProfile {
183 prompt_caching: false,
184 cache_breakpoint_style: "none".to_string(),
185 min_useful_prefix_tokens: None,
186 ttl_notes: None,
187 cache_read_usage_field: String::new(),
188 cache_write_usage_field: String::new(),
189 },
190 };
191 }
192 let caps = capabilities::lookup(provider_key, model_key);
193 let profile = CacheControlProfile::from_capabilities(&caps);
194 let (status, cache_tier) = if caps.prompt_caching {
195 (
196 PromptCacheSupportStatus::CacheSupported,
197 Some("provider-prompt-cache".to_string()),
198 )
199 } else {
200 (
201 PromptCacheSupportStatus::CacheUnsupported,
202 Some("none".to_string()),
203 )
204 };
205 PromptCacheSupport {
206 status,
207 supported: Some(caps.prompt_caching),
208 cache_tier,
209 resolved_provider: provider_key.to_string(),
210 resolved_model: model_key.to_string(),
211 source: "provider-capabilities".to_string(),
212 profile,
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct NormalizedCacheUsage {
222 pub input_tokens: i64,
225 pub fresh_input_tokens: i64,
228 pub cache_read_tokens: i64,
230 pub cache_write_tokens: i64,
232 pub output_tokens: i64,
233 pub cache_supported: bool,
236 #[serde(default, skip_serializing_if = "Vec::is_empty")]
239 pub missing_fields: Vec<String>,
240}
241
242fn usage_i64(usage: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<i64> {
243 for key in keys {
244 if let Some(found) = usage.get(*key).and_then(Value::as_i64) {
245 return Some(found);
246 }
247 }
248 None
249}
250
251impl NormalizedCacheUsage {
252 pub fn from_usage_value(usage: &Value) -> Self {
259 let Some(object) = usage.as_object() else {
260 return Self {
261 input_tokens: 0,
262 fresh_input_tokens: 0,
263 cache_read_tokens: 0,
264 cache_write_tokens: 0,
265 output_tokens: 0,
266 cache_supported: false,
267 missing_fields: vec!["usage".to_string()],
268 };
269 };
270 let mut missing_fields = Vec::new();
271
272 let input_tokens =
273 usage_i64(object, &["input_tokens", "prompt_tokens"]).unwrap_or_else(|| {
274 missing_fields.push("input_tokens".to_string());
275 0
276 });
277 let output_tokens = usage_i64(object, &["output_tokens", "completion_tokens"])
278 .unwrap_or_else(|| {
279 missing_fields.push("output_tokens".to_string());
280 0
281 });
282
283 let explicit_supported = object.get("cache_supported").and_then(Value::as_bool);
287 let cache_read = usage_i64(
288 object,
289 &[
290 "cache_read_tokens",
291 "cache_read_input_tokens",
292 "cached_tokens",
293 ],
294 )
295 .or_else(|| nested_cached_tokens(object));
296 let cache_write = usage_i64(
297 object,
298 &["cache_write_tokens", "cache_creation_input_tokens"],
299 );
300 if cache_read.is_none() {
301 missing_fields.push("cache_read_tokens".to_string());
302 }
303 if cache_write.is_none() {
304 missing_fields.push("cache_write_tokens".to_string());
305 }
306 let cache_read_tokens = cache_read.unwrap_or(0);
307 let cache_write_tokens = cache_write.unwrap_or(0);
308 let cache_supported = match explicit_supported {
309 Some(flag) => flag,
310 None => cache_read.is_some() || cache_write.is_some(),
311 };
312 let fresh_input_tokens = (input_tokens - cache_read_tokens - cache_write_tokens).max(0);
313 Self {
314 input_tokens,
315 fresh_input_tokens,
316 cache_read_tokens,
317 cache_write_tokens,
318 output_tokens,
319 cache_supported,
320 missing_fields,
321 }
322 }
323}
324
325fn nested_cached_tokens(object: &serde_json::Map<String, Value>) -> Option<i64> {
326 object
327 .get("prompt_tokens_details")
328 .and_then(Value::as_object)
329 .and_then(|details| details.get("cached_tokens"))
330 .and_then(Value::as_i64)
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum CacheConformanceClassification {
339 CacheEffective,
341 CacheSupportedMiss,
343 UnsupportedZero,
345 SupportUnknownZero,
347 NoPromptTokens,
349 ProviderFieldInconsistent,
352}
353
354impl CacheConformanceClassification {
355 pub fn as_str(self) -> &'static str {
356 match self {
357 Self::CacheEffective => "cache_effective",
358 Self::CacheSupportedMiss => "cache_supported_miss",
359 Self::UnsupportedZero => "unsupported_zero",
360 Self::SupportUnknownZero => "support_unknown_zero",
361 Self::NoPromptTokens => "no_prompt_tokens",
362 Self::ProviderFieldInconsistent => "provider_field_inconsistent",
363 }
364 }
365}
366
367fn field_inconsistency(usage: &NormalizedCacheUsage) -> Option<String> {
370 if usage.input_tokens < 0
371 || usage.output_tokens < 0
372 || usage.cache_read_tokens < 0
373 || usage.cache_write_tokens < 0
374 {
375 return Some("negative token count".to_string());
376 }
377 if usage.input_tokens <= 0 && (usage.cache_read_tokens > 0 || usage.cache_write_tokens > 0) {
379 return Some("cache tokens reported with zero prompt tokens".to_string());
380 }
381 if usage.input_tokens > 0
382 && usage.cache_read_tokens + usage.cache_write_tokens > usage.input_tokens
383 {
384 return Some("cache-read + cache-write exceed prompt tokens".to_string());
385 }
386 if !usage.cache_supported && (usage.cache_read_tokens > 0 || usage.cache_write_tokens > 0) {
388 return Some("cache tokens reported while cache_supported=false".to_string());
389 }
390 None
391}
392
393pub fn classify_cache_run(
398 usage: &NormalizedCacheUsage,
399 support: &PromptCacheSupport,
400) -> CacheConformanceClassification {
401 if field_inconsistency(usage).is_some() {
402 return CacheConformanceClassification::ProviderFieldInconsistent;
403 }
404 if usage.input_tokens <= 0 {
405 return CacheConformanceClassification::NoPromptTokens;
406 }
407 if usage.cache_read_tokens > 0 {
408 return CacheConformanceClassification::CacheEffective;
409 }
410 match support.status {
411 PromptCacheSupportStatus::CacheSupported => {
412 CacheConformanceClassification::CacheSupportedMiss
413 }
414 PromptCacheSupportStatus::CacheUnsupported => {
415 CacheConformanceClassification::UnsupportedZero
416 }
417 PromptCacheSupportStatus::CacheSupportUnknown => {
418 CacheConformanceClassification::SupportUnknownZero
419 }
420 }
421}
422
423#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
428pub struct CacheRequestIdentity {
429 #[serde(skip_serializing_if = "Option::is_none")]
430 pub task: Option<String>,
431 #[serde(skip_serializing_if = "Option::is_none")]
432 pub prefix_sha256: Option<String>,
433 #[serde(skip_serializing_if = "Option::is_none")]
434 pub prefix_tokens_estimate: Option<u32>,
435 #[serde(skip_serializing_if = "Option::is_none")]
436 pub tool_schema_sha256: Option<String>,
437 #[serde(skip_serializing_if = "Option::is_none")]
438 pub settings_sha256: Option<String>,
439}
440
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443pub struct CacheConformanceRun {
444 pub run_index: usize,
445 #[serde(skip_serializing_if = "Option::is_none")]
446 pub request: Option<CacheRequestIdentity>,
447 pub usage: NormalizedCacheUsage,
448 pub classification: CacheConformanceClassification,
449 #[serde(skip_serializing_if = "Option::is_none")]
450 pub inconsistency_reason: Option<String>,
451 #[serde(skip_serializing_if = "Option::is_none")]
452 pub elapsed_ms: Option<u64>,
453 #[serde(skip_serializing_if = "Option::is_none")]
456 pub raw_usage: Option<Value>,
457}
458
459#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
462#[serde(rename_all = "snake_case")]
463pub enum CacheVerdict {
464 CacheEffective,
466 CacheSupportedMiss,
468 UnsupportedZero,
470 SupportUnknownZero,
472 ProviderFieldInconsistent,
474 NoPromptTokens,
476 InsufficientRuns,
478}
479
480impl CacheVerdict {
481 pub fn as_str(self) -> &'static str {
482 match self {
483 Self::CacheEffective => "cache_effective",
484 Self::CacheSupportedMiss => "cache_supported_miss",
485 Self::UnsupportedZero => "unsupported_zero",
486 Self::SupportUnknownZero => "support_unknown_zero",
487 Self::ProviderFieldInconsistent => "provider_field_inconsistent",
488 Self::NoPromptTokens => "no_prompt_tokens",
489 Self::InsufficientRuns => "insufficient_runs",
490 }
491 }
492
493 pub fn is_dogfood_failure(self) -> bool {
498 matches!(
499 self,
500 Self::CacheSupportedMiss | Self::ProviderFieldInconsistent
501 )
502 }
503}
504
505#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
508pub struct CacheConformanceBucketCounts {
509 pub cache_effective: usize,
510 pub cache_supported_miss: usize,
511 pub unsupported_zero: usize,
512 pub support_unknown_zero: usize,
513 pub no_prompt_tokens: usize,
514 pub provider_field_inconsistent: usize,
515}
516
517impl CacheConformanceBucketCounts {
518 fn tally(runs: &[CacheConformanceRun]) -> Self {
519 let mut counts = Self::default();
520 for run in runs {
521 match run.classification {
522 CacheConformanceClassification::CacheEffective => counts.cache_effective += 1,
523 CacheConformanceClassification::CacheSupportedMiss => {
524 counts.cache_supported_miss += 1;
525 }
526 CacheConformanceClassification::UnsupportedZero => counts.unsupported_zero += 1,
527 CacheConformanceClassification::SupportUnknownZero => {
528 counts.support_unknown_zero += 1;
529 }
530 CacheConformanceClassification::NoPromptTokens => counts.no_prompt_tokens += 1,
531 CacheConformanceClassification::ProviderFieldInconsistent => {
532 counts.provider_field_inconsistent += 1;
533 }
534 }
535 }
536 counts
537 }
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
544pub struct CacheConformanceReport {
545 pub schema_version: u32,
546 pub provider: String,
547 pub model: String,
548 pub support: PromptCacheSupport,
549 pub runs: Vec<CacheConformanceRun>,
550 pub bucket_counts: CacheConformanceBucketCounts,
551 pub verdict: CacheVerdict,
552 pub dogfood_failure: bool,
556}
557
558fn aggregate_verdict(runs: &[CacheConformanceRun], support: &PromptCacheSupport) -> CacheVerdict {
559 if runs
560 .iter()
561 .any(|run| run.classification == CacheConformanceClassification::ProviderFieldInconsistent)
562 {
563 return CacheVerdict::ProviderFieldInconsistent;
564 }
565 let repeat_cache_read = runs.iter().any(|run| {
568 run.run_index > 0 && run.classification == CacheConformanceClassification::CacheEffective
569 });
570 if repeat_cache_read {
571 return CacheVerdict::CacheEffective;
572 }
573 let any_cache_read = runs
576 .iter()
577 .any(|run| run.classification == CacheConformanceClassification::CacheEffective);
578 let all_no_prompt = !runs.is_empty()
579 && runs
580 .iter()
581 .all(|run| run.classification == CacheConformanceClassification::NoPromptTokens);
582 if all_no_prompt {
583 return CacheVerdict::NoPromptTokens;
584 }
585 match support.status {
586 PromptCacheSupportStatus::CacheUnsupported => CacheVerdict::UnsupportedZero,
587 PromptCacheSupportStatus::CacheSupportUnknown => CacheVerdict::SupportUnknownZero,
588 PromptCacheSupportStatus::CacheSupported => {
589 if any_cache_read {
590 if runs.len() < 2 {
592 CacheVerdict::InsufficientRuns
593 } else {
594 CacheVerdict::CacheSupportedMiss
595 }
596 } else if runs.len() < 2 {
597 CacheVerdict::InsufficientRuns
598 } else {
599 CacheVerdict::CacheSupportedMiss
600 }
601 }
602 }
603}
604
605pub fn report_from_runs(
607 provider: String,
608 model: String,
609 support: PromptCacheSupport,
610 runs: Vec<CacheConformanceRun>,
611) -> CacheConformanceReport {
612 let bucket_counts = CacheConformanceBucketCounts::tally(&runs);
613 let verdict = aggregate_verdict(&runs, &support);
614 CacheConformanceReport {
615 schema_version: CACHE_CONFORMANCE_SCHEMA_VERSION,
616 provider,
617 model,
618 support,
619 runs,
620 bucket_counts,
621 verdict,
622 dogfood_failure: verdict.is_dogfood_failure(),
623 }
624}
625
626fn run_from_fixture_entry(
630 index: usize,
631 entry: &Value,
632 support: &PromptCacheSupport,
633) -> CacheConformanceRun {
634 let (usage_value, request, elapsed_ms) = match entry.as_object() {
635 Some(object) if object.contains_key("usage") => {
636 let usage_value = object.get("usage").cloned().unwrap_or(Value::Null);
637 let request = object.get("request").and_then(|value| {
638 serde_json::from_value::<CacheRequestIdentity>(value.clone()).ok()
639 });
640 let elapsed_ms = object.get("elapsed_ms").and_then(Value::as_u64);
641 (usage_value, request, elapsed_ms)
642 }
643 _ => (entry.clone(), None, None),
645 };
646 let usage = NormalizedCacheUsage::from_usage_value(&usage_value);
647 let classification = classify_cache_run(&usage, support);
648 let inconsistency_reason = field_inconsistency(&usage);
649 CacheConformanceRun {
650 run_index: index,
651 request,
652 usage,
653 classification,
654 inconsistency_reason,
655 elapsed_ms,
656 raw_usage: Some(usage_value),
657 }
658}
659
660pub fn classify_cache_conformance_fixture(
665 provider: impl Into<String>,
666 model: impl Into<String>,
667 raw: &str,
668) -> Result<CacheConformanceReport, String> {
669 let document: Value = serde_json::from_str(raw)
670 .map_err(|error| format!("failed to parse cache conformance fixture: {error}"))?;
671 let mut provider = provider.into();
672 let mut model = model.into();
673 let runs_value = match &document {
674 Value::Array(items) => items.clone(),
675 Value::Object(object) => {
676 if let Some(fixture_provider) = object.get("provider").and_then(Value::as_str) {
677 if provider.trim().is_empty() {
678 provider = fixture_provider.to_string();
679 }
680 }
681 if let Some(fixture_model) = object.get("model").and_then(Value::as_str) {
682 if model.trim().is_empty() {
683 model = fixture_model.to_string();
684 }
685 }
686 match object.get("runs") {
687 Some(Value::Array(items)) => items.clone(),
688 _ => {
689 return Err(
690 "cache conformance fixture object must carry a `runs` array".to_string()
691 )
692 }
693 }
694 }
695 _ => {
696 return Err(
697 "cache conformance fixture must be a runs array or an object with `runs`"
698 .to_string(),
699 )
700 }
701 };
702 let support = prompt_cache_support(&provider, &model);
703 let runs = runs_value
704 .iter()
705 .enumerate()
706 .map(|(index, entry)| run_from_fixture_entry(index, entry, &support))
707 .collect::<Vec<_>>();
708 Ok(report_from_runs(provider, model, support, runs))
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714 use serde_json::json;
715
716 fn supported() -> PromptCacheSupport {
717 PromptCacheSupport {
718 status: PromptCacheSupportStatus::CacheSupported,
719 supported: Some(true),
720 cache_tier: Some("provider-prompt-cache".to_string()),
721 resolved_provider: "anthropic".to_string(),
722 resolved_model: "claude-sonnet-4-6".to_string(),
723 source: "provider-capabilities".to_string(),
724 profile: CacheControlProfile {
725 prompt_caching: true,
726 cache_breakpoint_style: "last_block".to_string(),
727 min_useful_prefix_tokens: Some(1024),
728 ttl_notes: Some("5m".to_string()),
729 cache_read_usage_field: "usage.cache_read_input_tokens".to_string(),
730 cache_write_usage_field: "usage.cache_creation_input_tokens".to_string(),
731 },
732 }
733 }
734
735 fn unsupported() -> PromptCacheSupport {
736 PromptCacheSupport {
737 status: PromptCacheSupportStatus::CacheUnsupported,
738 supported: Some(false),
739 cache_tier: Some("none".to_string()),
740 resolved_provider: "ollama".to_string(),
741 resolved_model: "qwen3".to_string(),
742 source: "provider-capabilities".to_string(),
743 profile: CacheControlProfile {
744 prompt_caching: false,
745 cache_breakpoint_style: "none".to_string(),
746 min_useful_prefix_tokens: None,
747 ttl_notes: None,
748 cache_read_usage_field: String::new(),
749 cache_write_usage_field: String::new(),
750 },
751 }
752 }
753
754 fn unknown() -> PromptCacheSupport {
755 prompt_cache_support("auto", "")
756 }
757
758 fn usage(input: i64, read: i64, write: i64, output: i64) -> NormalizedCacheUsage {
759 NormalizedCacheUsage {
760 input_tokens: input,
761 fresh_input_tokens: (input - read - write).max(0),
762 cache_read_tokens: read,
763 cache_write_tokens: write,
764 output_tokens: output,
765 cache_supported: true,
766 missing_fields: Vec::new(),
767 }
768 }
769
770 #[test]
771 fn cache_read_is_effective_regardless_of_support() {
772 let run = usage(2000, 1800, 0, 50);
773 assert_eq!(
774 classify_cache_run(&run, &supported()),
775 CacheConformanceClassification::CacheEffective
776 );
777 }
778
779 #[test]
780 fn supported_zero_read_is_a_miss_not_unsupported() {
781 let run = usage(2000, 0, 2000, 50);
782 assert_eq!(
783 classify_cache_run(&run, &supported()),
784 CacheConformanceClassification::CacheSupportedMiss
785 );
786 }
787
788 #[test]
789 fn unsupported_zero_read_classifies_unsupported() {
790 let run = usage(2000, 0, 0, 50);
791 assert_eq!(
792 classify_cache_run(&run, &unsupported()),
793 CacheConformanceClassification::UnsupportedZero
794 );
795 }
796
797 #[test]
798 fn missing_field_with_unknown_support_stays_unknown_not_unsupported() {
799 let raw = json!({ "input_tokens": 2000, "output_tokens": 40 });
802 let normalized = NormalizedCacheUsage::from_usage_value(&raw);
803 assert!(!normalized.cache_supported);
804 assert!(normalized
805 .missing_fields
806 .contains(&"cache_read_tokens".to_string()));
807 assert_eq!(
808 classify_cache_run(&normalized, &unknown()),
809 CacheConformanceClassification::SupportUnknownZero
810 );
811 }
812
813 #[test]
814 fn no_prompt_tokens_bucket() {
815 let run = usage(0, 0, 0, 10);
816 assert_eq!(
817 classify_cache_run(&run, &supported()),
818 CacheConformanceClassification::NoPromptTokens
819 );
820 }
821
822 #[test]
823 fn cache_exceeding_prompt_is_inconsistent() {
824 let run = usage(1000, 900, 500, 10);
825 assert_eq!(
826 classify_cache_run(&run, &supported()),
827 CacheConformanceClassification::ProviderFieldInconsistent
828 );
829 }
830
831 #[test]
832 fn read_with_support_false_is_inconsistent() {
833 let mut run = usage(2000, 500, 0, 10);
834 run.cache_supported = false;
835 assert_eq!(
836 classify_cache_run(&run, &supported()),
837 CacheConformanceClassification::ProviderFieldInconsistent
838 );
839 }
840
841 #[test]
842 fn normalize_reads_anthropic_aliases() {
843 let raw = json!({
844 "input_tokens": 4000,
845 "output_tokens": 120,
846 "cache_read_input_tokens": 3500,
847 "cache_creation_input_tokens": 500,
848 });
849 let normalized = NormalizedCacheUsage::from_usage_value(&raw);
850 assert_eq!(normalized.cache_read_tokens, 3500);
851 assert_eq!(normalized.cache_write_tokens, 500);
852 assert_eq!(normalized.fresh_input_tokens, 0);
853 assert!(normalized.cache_supported);
854 assert!(normalized.missing_fields.is_empty());
855 }
856
857 #[test]
858 fn normalize_reads_openai_nested_cached_tokens() {
859 let raw = json!({
860 "prompt_tokens": 3000,
861 "completion_tokens": 90,
862 "prompt_tokens_details": { "cached_tokens": 2048 },
863 });
864 let normalized = NormalizedCacheUsage::from_usage_value(&raw);
865 assert_eq!(normalized.input_tokens, 3000);
866 assert_eq!(normalized.cache_read_tokens, 2048);
867 assert_eq!(normalized.fresh_input_tokens, 952);
868 }
869
870 #[test]
871 fn repeat_run_cache_read_yields_cache_effective_verdict() {
872 let raw = json!({
873 "provider": "anthropic",
874 "model": "claude-sonnet-4-6",
875 "runs": [
876 { "usage": { "input_tokens": 4000, "output_tokens": 80, "cache_read_tokens": 0, "cache_creation_input_tokens": 3800 } },
877 { "usage": { "input_tokens": 4000, "output_tokens": 80, "cache_read_tokens": 3800, "cache_creation_input_tokens": 0 } }
878 ]
879 });
880 let report =
881 classify_cache_conformance_fixture("", "", &raw.to_string()).expect("classify");
882 assert_eq!(report.verdict, CacheVerdict::CacheEffective);
883 assert!(!report.dogfood_failure);
884 assert_eq!(report.bucket_counts.cache_effective, 1);
885 assert_eq!(report.bucket_counts.cache_supported_miss, 1);
886 }
887
888 #[test]
889 fn non_cache_provider_does_not_fail_dogfood() {
890 let raw = json!({
891 "provider": "ollama",
892 "model": "qwen3",
893 "runs": [
894 { "usage": { "input_tokens": 4000, "output_tokens": 80 } },
895 { "usage": { "input_tokens": 4000, "output_tokens": 80 } }
896 ]
897 });
898 let report =
899 classify_cache_conformance_fixture("", "", &raw.to_string()).expect("classify");
900 assert_eq!(report.verdict, CacheVerdict::UnsupportedZero);
901 assert!(!report.dogfood_failure);
902 }
903
904 #[test]
905 fn supported_route_that_never_caches_fails_dogfood() {
906 let raw = json!({
907 "provider": "anthropic",
908 "model": "claude-sonnet-4-6",
909 "runs": [
910 { "usage": { "input_tokens": 4000, "output_tokens": 80, "cache_creation_input_tokens": 3800 } },
911 { "usage": { "input_tokens": 4000, "output_tokens": 80, "cache_creation_input_tokens": 3800 } }
912 ]
913 });
914 let report =
915 classify_cache_conformance_fixture("", "", &raw.to_string()).expect("classify");
916 assert_eq!(report.verdict, CacheVerdict::CacheSupportedMiss);
917 assert!(report.dogfood_failure);
918 }
919}