1use crate::constants::prompt_cache;
2use crate::env_helpers::default_true;
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5use vtcode_commons::VtCodePaths;
6
7#[must_use]
11pub fn supports_openai_explicit_prompt_cache(model: &str) -> bool {
12 use crate::models::ModelId;
13 let Ok(model_id) = model.parse::<ModelId>() else {
14 return false;
15 };
16 (model_id.as_str() == model || model == crate::constants::models::openai::GPT_5_6)
17 && matches!(
18 model_id,
19 ModelId::GPT56Sol
20 | ModelId::GPT56Terra
21 | ModelId::GPT56Luna
22 | ModelId::GPT6Astra
23 | ModelId::GPT6Sol
24 | ModelId::GPT61Sol
25 | ModelId::GPT6Luna
26 )
27}
28
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
31#[derive(Debug, Clone, Deserialize, Serialize)]
32pub struct PromptCachingConfig {
33 #[serde(default = "default_enabled")]
35 pub enabled: bool,
36
37 #[serde(default = "default_cache_dir")]
39 pub cache_dir: String,
40
41 #[serde(default = "default_max_entries")]
43 pub max_entries: usize,
44
45 #[serde(default = "default_max_age_days")]
47 pub max_age_days: u64,
48
49 #[serde(default = "default_auto_cleanup")]
51 pub enable_auto_cleanup: bool,
52
53 #[serde(default = "default_min_quality_threshold")]
55 pub min_quality_threshold: f64,
56
57 #[serde(default = "default_cache_friendly_prompt_shaping")]
60 pub cache_friendly_prompt_shaping: bool,
61
62 #[serde(default = "default_true")]
67 pub stable_tool_catalog_across_modes: bool,
68
69 #[serde(default = "default_true")]
72 pub gap_warning_enabled: bool,
73
74 #[serde(default)]
77 pub gap_warning_threshold_secs: Option<u64>,
78
79 #[serde(default)]
81 pub providers: ProviderPromptCachingConfig,
82}
83
84impl Default for PromptCachingConfig {
85 fn default() -> Self {
86 let cache_dir = default_cache_dir();
87 Self {
88 enabled: default_enabled() && !cache_dir.is_empty(),
89 cache_dir,
90 max_entries: default_max_entries(),
91 max_age_days: default_max_age_days(),
92 enable_auto_cleanup: default_auto_cleanup(),
93 min_quality_threshold: default_min_quality_threshold(),
94 cache_friendly_prompt_shaping: default_cache_friendly_prompt_shaping(),
95 stable_tool_catalog_across_modes: true,
96 gap_warning_enabled: default_true(),
97 gap_warning_threshold_secs: None,
98 providers: ProviderPromptCachingConfig::default(),
99 }
100 }
101}
102
103impl PromptCachingConfig {
104 pub fn resolve_cache_dir(&self, workspace_root: Option<&Path>) -> PathBuf {
110 resolve_path(&self.cache_dir, workspace_root)
111 }
112
113 pub fn is_provider_enabled(&self, provider_name: &str) -> bool {
115 if !self.enabled {
116 return false;
117 }
118
119 match provider_name.to_ascii_lowercase().as_str() {
120 "openai" | "merge-gateway" => self.providers.openai.enabled,
121 "anthropic" | "minimax" => self.providers.anthropic.enabled,
122 "gemini" => {
123 self.providers.gemini.enabled && !matches!(self.providers.gemini.mode, GeminiPromptCacheMode::Off)
124 }
125 "openrouter" => self.providers.openrouter.enabled,
126 "moonshot" => self.providers.moonshot.enabled,
127 "deepseek" => self.providers.deepseek.enabled,
128 "zai" => self.providers.zai.enabled,
129 _ => false,
130 }
131 }
132
133 pub fn gap_threshold_secs(&self, provider_name: &str) -> Option<u64> {
138 self.gap_threshold_secs_for_model(provider_name, "")
139 }
140
141 pub fn gap_threshold_secs_for_model(&self, provider_name: &str, model: &str) -> Option<u64> {
144 if !self.gap_warning_enabled || !self.is_provider_enabled(provider_name) {
145 return None;
146 }
147
148 let provider = provider_name.to_ascii_lowercase();
149 let modern_openai = provider == "openai" && supports_openai_explicit_prompt_cache(model);
150 if let Some(threshold) = self.gap_warning_threshold_secs {
151 return Some(threshold);
152 }
153
154 Some(match provider.as_str() {
155 "anthropic" | "minimax" => prompt_cache::ANTHROPIC_CACHE_GAP_WARNING_SECONDS,
156 "openai"
157 if modern_openai || self.providers.openai.prompt_cache_retention == Some(PromptCacheRetention::H24) =>
158 {
159 30 * 60
160 }
161 "openai" => prompt_cache::OPENAI_CACHE_GAP_WARNING_SECONDS,
162 _ => prompt_cache::DEFAULT_CACHE_GAP_WARNING_SECONDS,
163 })
164 }
165}
166
167#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
169#[derive(Debug, Clone, Deserialize, Serialize, Default)]
170pub struct ProviderPromptCachingConfig {
171 #[serde(default = "OpenAIPromptCacheSettings::default")]
172 pub openai: OpenAIPromptCacheSettings,
173
174 #[serde(default = "AnthropicPromptCacheSettings::default")]
175 pub anthropic: AnthropicPromptCacheSettings,
176
177 #[serde(default = "GeminiPromptCacheSettings::default")]
178 pub gemini: GeminiPromptCacheSettings,
179
180 #[serde(default = "OpenRouterPromptCacheSettings::default")]
181 pub openrouter: OpenRouterPromptCacheSettings,
182
183 #[serde(default = "MoonshotPromptCacheSettings::default")]
184 moonshot: MoonshotPromptCacheSettings,
185
186 #[serde(default = "DeepSeekPromptCacheSettings::default")]
187 pub deepseek: DeepSeekPromptCacheSettings,
188
189 #[serde(default = "ZaiPromptCacheSettings::default")]
190 zai: ZaiPromptCacheSettings,
191}
192
193#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
197pub enum PromptCacheRetention {
198 #[serde(rename = "in_memory")]
200 InMemory,
201 #[default]
203 #[serde(rename = "24h")]
204 H24,
205 #[serde(other, rename = "unknown")]
211 Unknown,
212}
213
214impl PromptCacheRetention {
215 pub fn as_str(&self) -> &str {
217 match self {
218 Self::InMemory => "in_memory",
219 Self::H24 => "24h",
220 Self::Unknown => "unknown",
221 }
222 }
223}
224
225impl std::fmt::Display for PromptCacheRetention {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 f.write_str(self.as_str())
228 }
229}
230
231#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
233#[derive(Debug, Clone, Deserialize, Serialize)]
234pub struct OpenAIPromptCacheSettings {
235 #[serde(default = "default_true")]
236 pub enabled: bool,
237
238 #[serde(default = "default_openai_min_prefix_tokens")]
239 min_prefix_tokens: u32,
240
241 #[serde(default = "default_openai_idle_expiration")]
242 idle_expiration_seconds: u64,
243
244 #[serde(default = "default_true")]
245 pub surface_metrics: bool,
246
247 #[serde(default)]
250 pub auxiliary_explicit_cache: bool,
251
252 #[serde(default = "default_openai_prompt_cache_key_mode")]
255 pub prompt_cache_key_mode: OpenAIPromptCacheKeyMode,
256
257 #[serde(default)]
262 pub prompt_cache_retention: Option<PromptCacheRetention>,
263}
264
265impl Default for OpenAIPromptCacheSettings {
266 fn default() -> Self {
267 Self {
268 enabled: default_true(),
269 min_prefix_tokens: default_openai_min_prefix_tokens(),
270 idle_expiration_seconds: default_openai_idle_expiration(),
271 surface_metrics: default_true(),
272 auxiliary_explicit_cache: false,
273 prompt_cache_key_mode: default_openai_prompt_cache_key_mode(),
274 prompt_cache_retention: None,
275 }
276 }
277}
278
279impl OpenAIPromptCacheSettings {
280 fn validate(&self) -> anyhow::Result<()> {
283 if let Some(PromptCacheRetention::Unknown) = self.prompt_cache_retention {
284 anyhow::bail!("prompt_cache_retention must be one of: in_memory, 24h");
285 }
286 Ok(())
287 }
288}
289
290#[must_use]
293pub fn build_openai_prompt_cache_key(
294 prompt_cache_enabled: bool,
295 prompt_cache_key_mode: &OpenAIPromptCacheKeyMode,
296 lineage_id: Option<&str>,
297) -> Option<String> {
298 if !prompt_cache_enabled {
299 return None;
300 }
301
302 let lineage_id = lineage_id.map(str::trim).filter(|value| !value.is_empty());
303 match prompt_cache_key_mode {
304 OpenAIPromptCacheKeyMode::Session => lineage_id.map(|lineage_id| format!("vtcode:openai:{lineage_id}")),
305 OpenAIPromptCacheKeyMode::Off => None,
306 }
307}
308
309#[must_use]
313pub fn map_prompt_cache_key_for_provider(provider_name: &str, key: String) -> String {
314 let provider = provider_name.trim().to_ascii_lowercase();
315 match provider.as_str() {
316 "merge-gateway" => key.replacen("vtcode:openai:", "vtcode:merge:", 1),
317 "openrouter" => key.replacen("vtcode:openai:", "vtcode:openrouter:", 1),
318 "xai" => key.replacen("vtcode:openai:", "vtcode:xai:", 1),
319 _ => key,
320 }
321}
322
323#[must_use]
325pub fn session_affinity_provider(provider_name: &str) -> bool {
326 matches!(
327 provider_name.trim().to_ascii_lowercase().as_str(),
328 "openai" | "merge-gateway" | "openrouter" | "xai"
329 )
330}
331
332#[must_use]
338pub fn session_affinity_key_enabled(
339 provider_name: &str,
340 global_prompt_cache_enabled: bool,
341 openai_prompt_cache_enabled: bool,
342) -> bool {
343 if !global_prompt_cache_enabled {
344 return false;
345 }
346 match provider_name.trim().to_ascii_lowercase().as_str() {
347 "openai" | "merge-gateway" => openai_prompt_cache_enabled,
348 "openrouter" | "xai" => true,
349 _ => false,
350 }
351}
352
353#[must_use]
359pub fn build_session_affinity_prompt_cache_key(
360 provider_name: &str,
361 session_affinity_enabled: bool,
362 prompt_cache_key_mode: &OpenAIPromptCacheKeyMode,
363 lineage_id: Option<&str>,
364) -> Option<String> {
365 if !session_affinity_enabled {
366 return None;
367 }
368 let provider = provider_name.trim().to_ascii_lowercase();
369 let lineage = lineage_id.map(str::trim).filter(|value| !value.is_empty())?;
370 match provider.as_str() {
371 "openai" | "merge-gateway" => match prompt_cache_key_mode {
372 OpenAIPromptCacheKeyMode::Session => {
373 Some(map_prompt_cache_key_for_provider(&provider, format!("vtcode:openai:{lineage}")))
374 }
375 OpenAIPromptCacheKeyMode::Off => None,
376 },
377 "openrouter" | "xai" => Some(format!("vtcode:{provider}:{lineage}")),
378 _ => None,
379 }
380}
381
382#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
384#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
385#[serde(rename_all = "snake_case")]
386pub enum OpenAIPromptCacheKeyMode {
387 Off,
389 #[default]
391 Session,
392}
393
394#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
396#[derive(Debug, Clone, Deserialize, Serialize)]
397pub struct AnthropicPromptCacheSettings {
398 #[serde(default = "default_true")]
399 pub enabled: bool,
400
401 #[serde(default = "default_anthropic_tools_ttl")]
406 pub tools_ttl_seconds: u64,
407
408 #[serde(default = "default_anthropic_messages_ttl")]
412 pub messages_ttl_seconds: u64,
413
414 #[serde(default = "default_anthropic_max_breakpoints")]
417 pub max_breakpoints: u8,
418
419 #[serde(default = "default_true")]
421 pub cache_system_messages: bool,
422
423 #[serde(default = "default_true")]
425 pub cache_user_messages: bool,
426
427 #[serde(default = "default_true")]
430 pub cache_tool_definitions: bool,
431
432 #[serde(default = "default_min_message_length")]
436 pub min_message_length_for_cache: usize,
437
438 #[serde(default = "default_anthropic_extended_ttl")]
441 pub extended_ttl_seconds: Option<u64>,
442
443 #[serde(default)]
447 pub prefer_extended_ttl: bool,
448}
449
450impl Default for AnthropicPromptCacheSettings {
451 fn default() -> Self {
452 Self {
453 enabled: default_true(),
454 tools_ttl_seconds: default_anthropic_tools_ttl(),
455 messages_ttl_seconds: default_anthropic_messages_ttl(),
456 max_breakpoints: default_anthropic_max_breakpoints(),
457 cache_system_messages: default_true(),
458 cache_user_messages: default_true(),
459 cache_tool_definitions: default_true(),
460 min_message_length_for_cache: default_min_message_length(),
461 extended_ttl_seconds: default_anthropic_extended_ttl(),
462 prefer_extended_ttl: false,
463 }
464 }
465}
466
467#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
469#[derive(Debug, Clone, Deserialize, Serialize)]
470pub struct GeminiPromptCacheSettings {
471 #[serde(default = "default_true")]
472 pub enabled: bool,
473
474 #[serde(default = "default_gemini_mode")]
475 pub mode: GeminiPromptCacheMode,
476
477 #[serde(default = "default_gemini_min_prefix_tokens")]
478 min_prefix_tokens: u32,
479
480 #[serde(default = "default_gemini_explicit_ttl")]
482 pub explicit_ttl_seconds: Option<u64>,
483}
484
485impl Default for GeminiPromptCacheSettings {
486 fn default() -> Self {
487 Self {
488 enabled: default_true(),
489 mode: GeminiPromptCacheMode::default(),
490 min_prefix_tokens: default_gemini_min_prefix_tokens(),
491 explicit_ttl_seconds: default_gemini_explicit_ttl(),
492 }
493 }
494}
495
496#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
498#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
499#[serde(rename_all = "snake_case")]
500#[derive(Default)]
501pub enum GeminiPromptCacheMode {
502 #[default]
503 Implicit,
504 Explicit,
505 Off,
506}
507
508#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
510#[derive(Debug, Clone, Deserialize, Serialize)]
511pub struct OpenRouterPromptCacheSettings {
512 #[serde(default = "default_true")]
513 pub enabled: bool,
514
515 #[serde(default = "default_true")]
517 propagate_provider_capabilities: bool,
518
519 #[serde(default = "default_true")]
521 pub report_savings: bool,
522}
523
524impl Default for OpenRouterPromptCacheSettings {
525 fn default() -> Self {
526 Self {
527 enabled: default_true(),
528 propagate_provider_capabilities: default_true(),
529 report_savings: default_true(),
530 }
531 }
532}
533
534#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
536#[derive(Debug, Clone, Deserialize, Serialize)]
537pub struct MoonshotPromptCacheSettings {
538 #[serde(default = "default_moonshot_enabled")]
539 enabled: bool,
540}
541
542impl Default for MoonshotPromptCacheSettings {
543 fn default() -> Self {
544 Self { enabled: default_moonshot_enabled() }
545 }
546}
547
548#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
550#[derive(Debug, Clone, Deserialize, Serialize)]
551pub struct DeepSeekPromptCacheSettings {
552 #[serde(default = "default_true")]
553 pub enabled: bool,
554
555 #[serde(default = "default_true")]
557 pub surface_metrics: bool,
558}
559
560impl Default for DeepSeekPromptCacheSettings {
561 fn default() -> Self {
562 Self {
563 enabled: default_true(),
564 surface_metrics: default_true(),
565 }
566 }
567}
568
569#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
571#[derive(Debug, Clone, Deserialize, Serialize)]
572pub struct ZaiPromptCacheSettings {
573 #[serde(default = "default_zai_enabled")]
574 enabled: bool,
575}
576
577impl Default for ZaiPromptCacheSettings {
578 fn default() -> Self {
579 Self { enabled: default_zai_enabled() }
580 }
581}
582
583fn default_enabled() -> bool {
584 prompt_cache::DEFAULT_ENABLED
585}
586
587fn default_cache_dir() -> String {
588 VtCodePaths::resolve()
589 .map(|paths| paths.cache_dir().join("prompts").display().to_string())
590 .unwrap_or_default()
591}
592
593fn default_max_entries() -> usize {
594 prompt_cache::DEFAULT_MAX_ENTRIES
595}
596
597fn default_max_age_days() -> u64 {
598 prompt_cache::DEFAULT_MAX_AGE_DAYS
599}
600
601fn default_auto_cleanup() -> bool {
602 prompt_cache::DEFAULT_AUTO_CLEANUP
603}
604
605fn default_min_quality_threshold() -> f64 {
606 prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD
607}
608
609fn default_cache_friendly_prompt_shaping() -> bool {
610 prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING
611}
612
613fn default_openai_min_prefix_tokens() -> u32 {
614 prompt_cache::OPENAI_MIN_PREFIX_TOKENS
615}
616
617fn default_openai_idle_expiration() -> u64 {
618 prompt_cache::OPENAI_IDLE_EXPIRATION_SECONDS
619}
620
621fn default_openai_prompt_cache_key_mode() -> OpenAIPromptCacheKeyMode {
622 OpenAIPromptCacheKeyMode::Session
623}
624
625fn default_anthropic_extended_ttl() -> Option<u64> {
626 Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
627}
628
629fn default_anthropic_tools_ttl() -> u64 {
630 prompt_cache::ANTHROPIC_TOOLS_TTL_SECONDS
631}
632
633fn default_anthropic_messages_ttl() -> u64 {
634 prompt_cache::ANTHROPIC_MESSAGES_TTL_SECONDS
635}
636
637fn default_anthropic_max_breakpoints() -> u8 {
638 prompt_cache::ANTHROPIC_MAX_BREAKPOINTS
639}
640
641fn default_min_message_length() -> usize {
642 prompt_cache::ANTHROPIC_MIN_MESSAGE_LENGTH_FOR_CACHE
643}
644
645fn default_gemini_min_prefix_tokens() -> u32 {
646 prompt_cache::GEMINI_MIN_PREFIX_TOKENS
647}
648
649fn default_gemini_explicit_ttl() -> Option<u64> {
650 Some(prompt_cache::GEMINI_EXPLICIT_DEFAULT_TTL_SECONDS)
651}
652
653fn default_gemini_mode() -> GeminiPromptCacheMode {
654 GeminiPromptCacheMode::Implicit
655}
656
657fn default_zai_enabled() -> bool {
658 prompt_cache::ZAI_CACHE_ENABLED
659}
660
661fn default_moonshot_enabled() -> bool {
662 prompt_cache::MOONSHOT_CACHE_ENABLED
663}
664
665fn resolve_path(input: &str, workspace_root: Option<&Path>) -> PathBuf {
666 let trimmed = input.trim();
667 if trimmed.is_empty() {
668 return resolve_default_cache_dir();
669 }
670
671 if let Some(stripped) = trimmed.strip_prefix("~/").or_else(|| trimmed.strip_prefix("~\\")) {
672 if let Some(home) = dirs::home_dir() {
673 return home.join(stripped);
674 }
675 return PathBuf::from(stripped);
676 }
677
678 let candidate = Path::new(trimmed);
679 if candidate.is_absolute() {
680 return candidate.to_path_buf();
681 }
682
683 if let Some(root) = workspace_root {
684 return root.join(candidate);
685 }
686
687 candidate.to_path_buf()
688}
689
690fn resolve_default_cache_dir() -> PathBuf {
691 VtCodePaths::resolve()
692 .map(|paths| paths.cache_dir().join("prompts"))
693 .unwrap_or_default()
694}
695
696impl PromptCachingConfig {
697 pub(crate) fn validate(&self) -> anyhow::Result<()> {
699 self.providers.openai.validate()?;
701 Ok(())
702 }
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708 use assert_fs::TempDir;
709 use std::fs;
710
711 fn find_workspace_root() -> PathBuf {
715 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
716 let mut current = manifest.as_path();
717 while let Some(parent) = current.parent() {
718 let cargo_toml = parent.join("Cargo.toml");
719 if cargo_toml.is_file() && fs::read_to_string(&cargo_toml).is_ok_and(|c| c.contains("[workspace]")) {
720 return parent.to_path_buf();
721 }
722 current = parent;
723 }
724 panic!("could not find workspace root from CARGO_MANIFEST_DIR");
725 }
726
727 #[test]
728 fn prompt_caching_defaults_align_with_constants() {
729 let cfg = PromptCachingConfig::default();
730 assert!(cfg.enabled);
731 assert_eq!(cfg.max_entries, prompt_cache::DEFAULT_MAX_ENTRIES);
732 assert_eq!(cfg.max_age_days, prompt_cache::DEFAULT_MAX_AGE_DAYS);
733 assert!((cfg.min_quality_threshold - prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD).abs() < f64::EPSILON);
734 assert_eq!(cfg.cache_friendly_prompt_shaping, prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING);
735 assert!(cfg.providers.openai.enabled);
736 assert_eq!(cfg.providers.openai.min_prefix_tokens, prompt_cache::OPENAI_MIN_PREFIX_TOKENS);
737 assert_eq!(cfg.providers.openai.prompt_cache_key_mode, OpenAIPromptCacheKeyMode::Session);
738 assert_eq!(cfg.providers.anthropic.extended_ttl_seconds, Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS));
739 assert_eq!(cfg.providers.gemini.mode, GeminiPromptCacheMode::Implicit);
740 assert!(cfg.providers.moonshot.enabled);
741 assert_eq!(cfg.providers.openai.prompt_cache_retention, None);
742 }
743
744 #[test]
745 fn resolve_cache_dir_expands_home() {
746 let cfg = PromptCachingConfig {
747 cache_dir: "~/.custom/cache".to_string(),
748 ..PromptCachingConfig::default()
749 };
750 let resolved = cfg.resolve_cache_dir(None);
751 if let Some(home) = dirs::home_dir() {
752 assert!(resolved.starts_with(home));
753 } else {
754 assert_eq!(resolved, PathBuf::from(".custom/cache"));
755 }
756 }
757
758 #[test]
759 fn resolve_cache_dir_uses_workspace_when_relative() {
760 let temp = TempDir::new().unwrap();
761 let workspace = temp.path();
762 let cfg = PromptCachingConfig {
763 cache_dir: "relative/cache".to_string(),
764 ..PromptCachingConfig::default()
765 };
766 let resolved = cfg.resolve_cache_dir(Some(workspace));
767 assert_eq!(resolved, workspace.join("relative/cache"));
768 }
769
770 #[test]
771 fn prompt_cache_retention_deserializes_valid_values() {
772 let cfg: OpenAIPromptCacheSettings = toml::from_str(
773 r#"
774 prompt_cache_retention = "in_memory"
775 "#,
776 )
777 .unwrap();
778 assert_eq!(cfg.prompt_cache_retention, Some(PromptCacheRetention::InMemory));
779
780 let cfg2: OpenAIPromptCacheSettings = toml::from_str(
781 r#"
782 prompt_cache_retention = "24h"
783 "#,
784 )
785 .unwrap();
786 assert_eq!(cfg2.prompt_cache_retention, Some(PromptCacheRetention::H24));
787 }
788
789 #[test]
790 fn prompt_cache_retention_unknown_values_deserialize_as_unknown() {
791 let cfg: OpenAIPromptCacheSettings = toml::from_str(
792 r#"
793 prompt_cache_retention = "5m"
794 "#,
795 )
796 .unwrap();
797 assert_eq!(cfg.prompt_cache_retention, Some(PromptCacheRetention::Unknown));
798 }
799
800 #[test]
806 fn prompt_cache_retention_as_str_matches_serde() {
807 for retention in [
808 PromptCacheRetention::InMemory,
809 PromptCacheRetention::H24,
810 PromptCacheRetention::Unknown,
811 ] {
812 assert_eq!(
813 serde_json::to_value(retention).unwrap(),
814 serde_json::json!(retention.as_str()),
815 "serde wire form drifted from as_str() for {retention:?}"
816 );
817 }
818 }
819
820 #[test]
821 fn validate_prompt_cache_rejects_unknown_retention() {
822 let mut cfg = PromptCachingConfig::default();
823 cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::Unknown);
824 assert!(cfg.validate().is_err());
825 }
826
827 #[test]
828 fn prompt_cache_key_mode_parses_from_toml() {
829 let parsed: PromptCachingConfig = toml::from_str(
830 r#"
831[providers.openai]
832prompt_cache_key_mode = "off"
833"#,
834 )
835 .expect("prompt cache config should parse");
836
837 assert_eq!(parsed.providers.openai.prompt_cache_key_mode, OpenAIPromptCacheKeyMode::Off);
838 }
839
840 #[test]
841 fn build_openai_prompt_cache_key_uses_trimmed_lineage_id() {
842 let key = build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Session, Some(" lineage-abc "));
843
844 assert_eq!(key.as_deref(), Some("vtcode:openai:lineage-abc"));
845 }
846
847 #[test]
848 fn map_prompt_cache_key_namespaces_merge_gateway() {
849 assert_eq!(
850 map_prompt_cache_key_for_provider("merge-gateway", "vtcode:openai:abc".to_string()),
851 "vtcode:merge:abc"
852 );
853 assert_eq!(map_prompt_cache_key_for_provider("openai", "vtcode:openai:abc".to_string()), "vtcode:openai:abc");
854 assert_eq!(
855 map_prompt_cache_key_for_provider("openrouter", "vtcode:openai:abc".to_string()),
856 "vtcode:openrouter:abc"
857 );
858 assert_eq!(map_prompt_cache_key_for_provider("xai", "vtcode:openai:abc".to_string()), "vtcode:xai:abc");
859 }
860
861 #[test]
862 fn session_affinity_key_gate_covers_openrouter_and_xai() {
863 assert!(session_affinity_key_enabled("openrouter", true, false));
864 assert!(session_affinity_key_enabled("xai", true, false));
865 assert!(session_affinity_key_enabled("openai", true, true));
866 assert!(!session_affinity_key_enabled("openai", true, false));
867 assert!(!session_affinity_key_enabled("openrouter", false, true));
868 assert!(!session_affinity_key_enabled("anthropic", true, true));
869 assert!(session_affinity_provider("openrouter"));
870 assert!(session_affinity_provider("xai"));
871 assert!(!session_affinity_provider("anthropic"));
872 }
873
874 #[test]
875 fn build_session_affinity_prompt_cache_key_namespaces_providers() {
876 let mode = OpenAIPromptCacheKeyMode::Session;
877 assert_eq!(
878 build_session_affinity_prompt_cache_key("openai", true, &mode, Some("lineage-1")).as_deref(),
879 Some("vtcode:openai:lineage-1")
880 );
881 assert_eq!(
882 build_session_affinity_prompt_cache_key("merge-gateway", true, &mode, Some("lineage-1")).as_deref(),
883 Some("vtcode:merge:lineage-1")
884 );
885 assert_eq!(
886 build_session_affinity_prompt_cache_key("openrouter", true, &mode, Some("lineage-1")).as_deref(),
887 Some("vtcode:openrouter:lineage-1")
888 );
889 assert_eq!(
890 build_session_affinity_prompt_cache_key("xai", true, &mode, Some("lineage-1")).as_deref(),
891 Some("vtcode:xai:lineage-1")
892 );
893 assert_eq!(
895 build_session_affinity_prompt_cache_key("openai", true, &OpenAIPromptCacheKeyMode::Off, Some("lineage-1")),
896 None
897 );
898 assert_eq!(
899 build_session_affinity_prompt_cache_key(
900 "openrouter",
901 true,
902 &OpenAIPromptCacheKeyMode::Off,
903 Some("lineage-1")
904 )
905 .as_deref(),
906 Some("vtcode:openrouter:lineage-1")
907 );
908 assert_eq!(build_session_affinity_prompt_cache_key("openrouter", false, &mode, Some("lineage-1")), None);
910 assert_eq!(build_session_affinity_prompt_cache_key("openrouter", true, &mode, Some(" ")), None);
911 assert_eq!(build_session_affinity_prompt_cache_key("anthropic", true, &mode, Some("lineage-1")), None);
912 }
913
914 #[test]
915 fn build_openai_prompt_cache_key_honors_disabled_or_off_mode() {
916 assert_eq!(build_openai_prompt_cache_key(false, &OpenAIPromptCacheKeyMode::Session, Some("id")), None);
917 assert_eq!(build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Off, Some("id")), None);
918 assert_eq!(build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Session, Some(" ")), None);
919 }
920
921 #[test]
922 fn provider_enablement_respects_global_and_provider_flags() {
923 let mut cfg = PromptCachingConfig { enabled: true, ..PromptCachingConfig::default() };
924 cfg.providers.openai.enabled = true;
925 assert!(cfg.is_provider_enabled("openai"));
926 assert!(cfg.is_provider_enabled("merge-gateway"));
927
928 cfg.enabled = false;
929 assert!(!cfg.is_provider_enabled("openai"));
930 assert!(!cfg.is_provider_enabled("merge-gateway"));
931 }
932
933 #[test]
934 fn provider_enablement_handles_aliases_and_modes() {
935 let mut cfg = PromptCachingConfig { enabled: true, ..PromptCachingConfig::default() };
936
937 cfg.providers.anthropic.enabled = true;
938 assert!(cfg.is_provider_enabled("minimax"));
939
940 cfg.providers.gemini.enabled = true;
941 cfg.providers.gemini.mode = GeminiPromptCacheMode::Off;
942 assert!(!cfg.is_provider_enabled("gemini"));
943 }
944
945 #[test]
946 fn gap_threshold_uses_provider_defaults() {
947 let cfg = PromptCachingConfig::default();
948 assert_eq!(cfg.gap_threshold_secs("anthropic"), Some(prompt_cache::ANTHROPIC_CACHE_GAP_WARNING_SECONDS));
949 assert_eq!(cfg.gap_threshold_secs("minimax"), Some(prompt_cache::ANTHROPIC_CACHE_GAP_WARNING_SECONDS));
950 assert_eq!(cfg.gap_threshold_secs("openai"), Some(prompt_cache::OPENAI_CACHE_GAP_WARNING_SECONDS));
951 assert_eq!(cfg.gap_threshold_secs("gemini"), Some(prompt_cache::DEFAULT_CACHE_GAP_WARNING_SECONDS));
952 }
953
954 #[test]
955 fn gap_threshold_uses_model_ttl_and_ignores_legacy_retention_for_modern_models() {
956 let mut cfg = PromptCachingConfig::default();
957 for retention in [
958 None,
959 Some(PromptCacheRetention::H24),
960 Some(PromptCacheRetention::InMemory),
961 ] {
962 cfg.providers.openai.prompt_cache_retention = retention;
963 for model in ["gpt-5.6-sol", "gpt-6-luna", "gpt-6.1-sol"] {
964 assert_eq!(cfg.gap_threshold_secs_for_model("openai", model), Some(1800), "{model}");
965 }
966 }
967 assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-5.1-mini"), Some(600));
968 assert_eq!(cfg.gap_threshold_secs_for_model("merge-gateway", "gpt-6-luna"), Some(300));
969 cfg.gap_warning_threshold_secs = Some(77);
970 assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-6-luna"), Some(77));
971 cfg.enabled = false;
972 assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-6-luna"), None);
973 }
974
975 #[test]
976 fn explicit_cache_models_reject_migration_aliases_and_auxiliary_mode_is_opt_in() {
977 for model in [
978 "gpt-5.4",
979 "gpt-5-codex",
980 "gpt",
981 "gpt-5.1-mini",
982 "openai/gpt-6-luna",
983 "gpt-6-future",
984 ] {
985 assert!(!supports_openai_explicit_prompt_cache(model), "{model}");
986 }
987 for model in ["gpt-5.6", "gpt-5.6-terra", "gpt-6-astra", "gpt-6-sol"] {
988 assert!(supports_openai_explicit_prompt_cache(model), "{model}");
989 }
990 let defaults: OpenAIPromptCacheSettings = toml::from_str("").unwrap();
991 assert!(!defaults.auxiliary_explicit_cache);
992 let configured: OpenAIPromptCacheSettings = toml::from_str("auxiliary_explicit_cache = true").unwrap();
993 assert!(configured.auxiliary_explicit_cache);
994 }
995
996 #[test]
997 fn gap_threshold_respects_disable_switches() {
998 let cfg = PromptCachingConfig { gap_warning_enabled: false, ..Default::default() };
999 assert_eq!(cfg.gap_threshold_secs("anthropic"), None);
1000
1001 let mut cfg = PromptCachingConfig::default();
1002 cfg.providers.anthropic.enabled = false;
1003 assert_eq!(cfg.gap_threshold_secs("anthropic"), None);
1004 assert_eq!(cfg.gap_threshold_secs("minimax"), None);
1005
1006 let cfg = PromptCachingConfig { enabled: false, ..Default::default() };
1007 assert_eq!(cfg.gap_threshold_secs("openai"), None);
1008 }
1009
1010 #[test]
1011 fn gap_threshold_treats_extended_retention_as_an_advisory_not_a_guarantee() {
1012 let mut cfg = PromptCachingConfig::default();
1013 cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::H24);
1014 assert_eq!(cfg.gap_threshold_secs("openai"), Some(1800));
1015 cfg.gap_warning_threshold_secs = Some(42);
1016 assert_eq!(cfg.gap_threshold_secs("openai"), Some(42));
1017 cfg.gap_warning_threshold_secs = None;
1018
1019 cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::InMemory);
1020 assert_eq!(cfg.gap_threshold_secs("openai"), Some(prompt_cache::OPENAI_CACHE_GAP_WARNING_SECONDS));
1021 }
1022
1023 #[test]
1024 fn gap_threshold_honors_explicit_override() {
1025 let cfg = PromptCachingConfig {
1026 gap_warning_threshold_secs: Some(42),
1027 ..Default::default()
1028 };
1029 assert_eq!(cfg.gap_threshold_secs("anthropic"), Some(42));
1030 assert_eq!(cfg.gap_threshold_secs("openai"), Some(42));
1031 }
1032
1033 #[test]
1034 fn gap_warning_fields_parse_from_toml() {
1035 let parsed: PromptCachingConfig = toml::from_str(
1036 r#"
1037gap_warning_enabled = false
1038gap_warning_threshold_secs = 120
1039"#,
1040 )
1041 .expect("prompt cache config should parse");
1042
1043 assert!(!parsed.gap_warning_enabled);
1044 assert_eq!(parsed.gap_warning_threshold_secs, Some(120));
1045
1046 let defaults: PromptCachingConfig = toml::from_str("").expect("empty config");
1047 assert!(defaults.gap_warning_enabled);
1048 assert_eq!(defaults.gap_warning_threshold_secs, None);
1049 }
1050
1051 #[test]
1052 fn bundled_config_templates_match_prompt_cache_defaults() {
1053 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1054 let bootstrap_template =
1055 fs::read_to_string(manifest_dir.join("data/default_config.toml")).expect("bootstrap config template");
1056 let config: crate::VTCodeConfig = toml::from_str(&bootstrap_template).expect("parse bootstrap template");
1057 assert!(config.prompt_cache.enabled);
1058 assert!(config.prompt_cache.cache_friendly_prompt_shaping);
1059 assert!(bootstrap_template.contains("# prompt_cache_retention = \"24h\""));
1060
1061 let workspace_root = find_workspace_root();
1062 let example_config =
1063 fs::read_to_string(workspace_root.join("vtcode.toml.example")).expect("vtcode.toml.example");
1064 if example_config.contains("[prompt_cache]") {
1065 assert!(example_config.contains("enabled = true"));
1066 assert!(example_config.contains("cache_friendly_prompt_shaping = true"));
1067 assert!(example_config.contains("# prompt_cache_retention = \"24h\""));
1068 }
1069
1070 let prompt_cache_guide = fs::read_to_string(workspace_root.join("docs/tools/PROMPT_CACHING_GUIDE.md"))
1071 .expect("prompt caching guide");
1072 let normalized_guide = vtcode_commons::formatting::collapse_whitespace(&prompt_cache_guide);
1075 assert!(
1076 normalized_guide
1077 .contains("VT Code enables `prompt_cache.cache_friendly_prompt_shaping = true` by default.")
1078 );
1079 assert!(normalized_guide.contains("`prompt_cache_retention` is opt-in for older supported Responses models"));
1082
1083 let field_reference = fs::read_to_string(workspace_root.join("docs/config/CONFIG_FIELD_REFERENCE.md"))
1084 .expect("config field reference");
1085 assert!(field_reference.contains("`prompt_cache.cache_friendly_prompt_shaping`"));
1086 assert!(field_reference.contains("`prompt_cache.providers.openai.prompt_cache_retention`"));
1087 }
1088
1089 #[test]
1090 fn bundled_example_config_parses_and_validates() {
1091 let workspace_root = find_workspace_root();
1094 let example = fs::read_to_string(workspace_root.join("vtcode.toml.example")).expect("vtcode.toml.example");
1095 let config: crate::VTCodeConfig = toml::from_str(&example).expect("example config should parse");
1096 config.validate().expect("example config should validate");
1097 }
1098}