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