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