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
625#[allow(dead_code, reason = "Intentional compatibility, platform, or test-only suppression.")]
626fn default_anthropic_extended_ttl() -> Option<u64> {
627 Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
628}
629
630fn default_anthropic_tools_ttl() -> u64 {
631 prompt_cache::ANTHROPIC_TOOLS_TTL_SECONDS
632}
633
634fn default_anthropic_messages_ttl() -> u64 {
635 prompt_cache::ANTHROPIC_MESSAGES_TTL_SECONDS
636}
637
638fn default_anthropic_max_breakpoints() -> u8 {
639 prompt_cache::ANTHROPIC_MAX_BREAKPOINTS
640}
641
642#[allow(dead_code, reason = "Intentional compatibility, platform, or test-only suppression.")]
643fn default_min_message_length() -> usize {
644 prompt_cache::ANTHROPIC_MIN_MESSAGE_LENGTH_FOR_CACHE
645}
646
647fn default_gemini_min_prefix_tokens() -> u32 {
648 prompt_cache::GEMINI_MIN_PREFIX_TOKENS
649}
650
651fn default_gemini_explicit_ttl() -> Option<u64> {
652 Some(prompt_cache::GEMINI_EXPLICIT_DEFAULT_TTL_SECONDS)
653}
654
655fn default_gemini_mode() -> GeminiPromptCacheMode {
656 GeminiPromptCacheMode::Implicit
657}
658
659fn default_zai_enabled() -> bool {
660 prompt_cache::ZAI_CACHE_ENABLED
661}
662
663fn default_moonshot_enabled() -> bool {
664 prompt_cache::MOONSHOT_CACHE_ENABLED
665}
666
667fn resolve_path(input: &str, workspace_root: Option<&Path>) -> PathBuf {
668 let trimmed = input.trim();
669 if trimmed.is_empty() {
670 return resolve_default_cache_dir();
671 }
672
673 if let Some(stripped) = trimmed.strip_prefix("~/").or_else(|| trimmed.strip_prefix("~\\")) {
674 if let Some(home) = dirs::home_dir() {
675 return home.join(stripped);
676 }
677 return PathBuf::from(stripped);
678 }
679
680 let candidate = Path::new(trimmed);
681 if candidate.is_absolute() {
682 return candidate.to_path_buf();
683 }
684
685 if let Some(root) = workspace_root {
686 return root.join(candidate);
687 }
688
689 candidate.to_path_buf()
690}
691
692fn resolve_default_cache_dir() -> PathBuf {
693 VtCodePaths::resolve()
694 .map(|paths| paths.cache_dir().join("prompts"))
695 .unwrap_or_default()
696}
697
698impl PromptCachingConfig {
699 pub(crate) fn validate(&self) -> anyhow::Result<()> {
701 self.providers.openai.validate()?;
703 Ok(())
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710 use assert_fs::TempDir;
711 use std::fs;
712
713 fn find_workspace_root() -> PathBuf {
717 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
718 let mut current = manifest.as_path();
719 while let Some(parent) = current.parent() {
720 let cargo_toml = parent.join("Cargo.toml");
721 if cargo_toml.is_file() && fs::read_to_string(&cargo_toml).is_ok_and(|c| c.contains("[workspace]")) {
722 return parent.to_path_buf();
723 }
724 current = parent;
725 }
726 panic!("could not find workspace root from CARGO_MANIFEST_DIR");
727 }
728
729 #[test]
730 fn prompt_caching_defaults_align_with_constants() {
731 let cfg = PromptCachingConfig::default();
732 assert!(cfg.enabled);
733 assert_eq!(cfg.max_entries, prompt_cache::DEFAULT_MAX_ENTRIES);
734 assert_eq!(cfg.max_age_days, prompt_cache::DEFAULT_MAX_AGE_DAYS);
735 assert!((cfg.min_quality_threshold - prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD).abs() < f64::EPSILON);
736 assert_eq!(cfg.cache_friendly_prompt_shaping, prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING);
737 assert!(cfg.providers.openai.enabled);
738 assert_eq!(cfg.providers.openai.min_prefix_tokens, prompt_cache::OPENAI_MIN_PREFIX_TOKENS);
739 assert_eq!(cfg.providers.openai.prompt_cache_key_mode, OpenAIPromptCacheKeyMode::Session);
740 assert_eq!(cfg.providers.anthropic.extended_ttl_seconds, Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS));
741 assert_eq!(cfg.providers.gemini.mode, GeminiPromptCacheMode::Implicit);
742 assert!(cfg.providers.moonshot.enabled);
743 assert_eq!(cfg.providers.openai.prompt_cache_retention, None);
744 }
745
746 #[test]
747 fn resolve_cache_dir_expands_home() {
748 let cfg = PromptCachingConfig {
749 cache_dir: "~/.custom/cache".to_string(),
750 ..PromptCachingConfig::default()
751 };
752 let resolved = cfg.resolve_cache_dir(None);
753 if let Some(home) = dirs::home_dir() {
754 assert!(resolved.starts_with(home));
755 } else {
756 assert_eq!(resolved, PathBuf::from(".custom/cache"));
757 }
758 }
759
760 #[test]
761 fn resolve_cache_dir_uses_workspace_when_relative() {
762 let temp = TempDir::new().unwrap();
763 let workspace = temp.path();
764 let cfg = PromptCachingConfig {
765 cache_dir: "relative/cache".to_string(),
766 ..PromptCachingConfig::default()
767 };
768 let resolved = cfg.resolve_cache_dir(Some(workspace));
769 assert_eq!(resolved, workspace.join("relative/cache"));
770 }
771
772 #[test]
773 fn prompt_cache_retention_deserializes_valid_values() {
774 let cfg: OpenAIPromptCacheSettings = toml::from_str(
775 r#"
776 prompt_cache_retention = "in_memory"
777 "#,
778 )
779 .unwrap();
780 assert_eq!(cfg.prompt_cache_retention, Some(PromptCacheRetention::InMemory));
781
782 let cfg2: OpenAIPromptCacheSettings = toml::from_str(
783 r#"
784 prompt_cache_retention = "24h"
785 "#,
786 )
787 .unwrap();
788 assert_eq!(cfg2.prompt_cache_retention, Some(PromptCacheRetention::H24));
789 }
790
791 #[test]
792 fn prompt_cache_retention_unknown_values_deserialize_as_unknown() {
793 let cfg: OpenAIPromptCacheSettings = toml::from_str(
794 r#"
795 prompt_cache_retention = "5m"
796 "#,
797 )
798 .unwrap();
799 assert_eq!(cfg.prompt_cache_retention, Some(PromptCacheRetention::Unknown));
800 }
801
802 #[test]
808 fn prompt_cache_retention_as_str_matches_serde() {
809 for retention in [
810 PromptCacheRetention::InMemory,
811 PromptCacheRetention::H24,
812 PromptCacheRetention::Unknown,
813 ] {
814 assert_eq!(
815 serde_json::to_value(retention).unwrap(),
816 serde_json::json!(retention.as_str()),
817 "serde wire form drifted from as_str() for {retention:?}"
818 );
819 }
820 }
821
822 #[test]
823 fn validate_prompt_cache_rejects_unknown_retention() {
824 let mut cfg = PromptCachingConfig::default();
825 cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::Unknown);
826 assert!(cfg.validate().is_err());
827 }
828
829 #[test]
830 fn prompt_cache_key_mode_parses_from_toml() {
831 let parsed: PromptCachingConfig = toml::from_str(
832 r#"
833[providers.openai]
834prompt_cache_key_mode = "off"
835"#,
836 )
837 .expect("prompt cache config should parse");
838
839 assert_eq!(parsed.providers.openai.prompt_cache_key_mode, OpenAIPromptCacheKeyMode::Off);
840 }
841
842 #[test]
843 fn build_openai_prompt_cache_key_uses_trimmed_lineage_id() {
844 let key = build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Session, Some(" lineage-abc "));
845
846 assert_eq!(key.as_deref(), Some("vtcode:openai:lineage-abc"));
847 }
848
849 #[test]
850 fn map_prompt_cache_key_namespaces_merge_gateway() {
851 assert_eq!(
852 map_prompt_cache_key_for_provider("merge-gateway", "vtcode:openai:abc".to_string()),
853 "vtcode:merge:abc"
854 );
855 assert_eq!(map_prompt_cache_key_for_provider("openai", "vtcode:openai:abc".to_string()), "vtcode:openai:abc");
856 assert_eq!(
857 map_prompt_cache_key_for_provider("openrouter", "vtcode:openai:abc".to_string()),
858 "vtcode:openrouter:abc"
859 );
860 assert_eq!(map_prompt_cache_key_for_provider("xai", "vtcode:openai:abc".to_string()), "vtcode:xai:abc");
861 }
862
863 #[test]
864 fn session_affinity_key_gate_covers_openrouter_and_xai() {
865 assert!(session_affinity_key_enabled("openrouter", true, false));
866 assert!(session_affinity_key_enabled("xai", true, false));
867 assert!(session_affinity_key_enabled("openai", true, true));
868 assert!(!session_affinity_key_enabled("openai", true, false));
869 assert!(!session_affinity_key_enabled("openrouter", false, true));
870 assert!(!session_affinity_key_enabled("anthropic", true, true));
871 assert!(session_affinity_provider("openrouter"));
872 assert!(session_affinity_provider("xai"));
873 assert!(!session_affinity_provider("anthropic"));
874 }
875
876 #[test]
877 fn build_session_affinity_prompt_cache_key_namespaces_providers() {
878 let mode = OpenAIPromptCacheKeyMode::Session;
879 assert_eq!(
880 build_session_affinity_prompt_cache_key("openai", true, &mode, Some("lineage-1")).as_deref(),
881 Some("vtcode:openai:lineage-1")
882 );
883 assert_eq!(
884 build_session_affinity_prompt_cache_key("merge-gateway", true, &mode, Some("lineage-1")).as_deref(),
885 Some("vtcode:merge:lineage-1")
886 );
887 assert_eq!(
888 build_session_affinity_prompt_cache_key("openrouter", true, &mode, Some("lineage-1")).as_deref(),
889 Some("vtcode:openrouter:lineage-1")
890 );
891 assert_eq!(
892 build_session_affinity_prompt_cache_key("xai", true, &mode, Some("lineage-1")).as_deref(),
893 Some("vtcode:xai:lineage-1")
894 );
895 assert_eq!(
897 build_session_affinity_prompt_cache_key("openai", true, &OpenAIPromptCacheKeyMode::Off, Some("lineage-1")),
898 None
899 );
900 assert_eq!(
901 build_session_affinity_prompt_cache_key(
902 "openrouter",
903 true,
904 &OpenAIPromptCacheKeyMode::Off,
905 Some("lineage-1")
906 )
907 .as_deref(),
908 Some("vtcode:openrouter:lineage-1")
909 );
910 assert_eq!(build_session_affinity_prompt_cache_key("openrouter", false, &mode, Some("lineage-1")), None);
912 assert_eq!(build_session_affinity_prompt_cache_key("openrouter", true, &mode, Some(" ")), None);
913 assert_eq!(build_session_affinity_prompt_cache_key("anthropic", true, &mode, Some("lineage-1")), None);
914 }
915
916 #[test]
917 fn build_openai_prompt_cache_key_honors_disabled_or_off_mode() {
918 assert_eq!(build_openai_prompt_cache_key(false, &OpenAIPromptCacheKeyMode::Session, Some("id")), None);
919 assert_eq!(build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Off, Some("id")), None);
920 assert_eq!(build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Session, Some(" ")), None);
921 }
922
923 #[test]
924 fn provider_enablement_respects_global_and_provider_flags() {
925 let mut cfg = PromptCachingConfig { enabled: true, ..PromptCachingConfig::default() };
926 cfg.providers.openai.enabled = true;
927 assert!(cfg.is_provider_enabled("openai"));
928 assert!(cfg.is_provider_enabled("merge-gateway"));
929
930 cfg.enabled = false;
931 assert!(!cfg.is_provider_enabled("openai"));
932 assert!(!cfg.is_provider_enabled("merge-gateway"));
933 }
934
935 #[test]
936 fn provider_enablement_handles_aliases_and_modes() {
937 let mut cfg = PromptCachingConfig { enabled: true, ..PromptCachingConfig::default() };
938
939 cfg.providers.anthropic.enabled = true;
940 assert!(cfg.is_provider_enabled("minimax"));
941
942 cfg.providers.gemini.enabled = true;
943 cfg.providers.gemini.mode = GeminiPromptCacheMode::Off;
944 assert!(!cfg.is_provider_enabled("gemini"));
945 }
946
947 #[test]
948 fn gap_threshold_uses_provider_defaults() {
949 let cfg = PromptCachingConfig::default();
950 assert_eq!(cfg.gap_threshold_secs("anthropic"), Some(prompt_cache::ANTHROPIC_CACHE_GAP_WARNING_SECONDS));
951 assert_eq!(cfg.gap_threshold_secs("minimax"), Some(prompt_cache::ANTHROPIC_CACHE_GAP_WARNING_SECONDS));
952 assert_eq!(cfg.gap_threshold_secs("openai"), Some(prompt_cache::OPENAI_CACHE_GAP_WARNING_SECONDS));
953 assert_eq!(cfg.gap_threshold_secs("gemini"), Some(prompt_cache::DEFAULT_CACHE_GAP_WARNING_SECONDS));
954 }
955
956 #[test]
957 fn gap_threshold_uses_model_ttl_and_ignores_legacy_retention_for_modern_models() {
958 let mut cfg = PromptCachingConfig::default();
959 for retention in [
960 None,
961 Some(PromptCacheRetention::H24),
962 Some(PromptCacheRetention::InMemory),
963 ] {
964 cfg.providers.openai.prompt_cache_retention = retention;
965 for model in ["gpt-5.6-sol", "gpt-6-luna", "gpt-6.1-sol"] {
966 assert_eq!(cfg.gap_threshold_secs_for_model("openai", model), Some(1800), "{model}");
967 }
968 }
969 assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-5.1-mini"), Some(600));
970 assert_eq!(cfg.gap_threshold_secs_for_model("merge-gateway", "gpt-6-luna"), Some(300));
971 cfg.gap_warning_threshold_secs = Some(77);
972 assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-6-luna"), Some(77));
973 cfg.enabled = false;
974 assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-6-luna"), None);
975 }
976
977 #[test]
978 fn explicit_cache_models_reject_migration_aliases_and_auxiliary_mode_is_opt_in() {
979 for model in [
980 "gpt-5.4",
981 "gpt-5-codex",
982 "gpt",
983 "gpt-5.1-mini",
984 "openai/gpt-6-luna",
985 "gpt-6-future",
986 ] {
987 assert!(!supports_openai_explicit_prompt_cache(model), "{model}");
988 }
989 for model in ["gpt-5.6", "gpt-5.6-terra", "gpt-6-astra", "gpt-6-sol"] {
990 assert!(supports_openai_explicit_prompt_cache(model), "{model}");
991 }
992 let defaults: OpenAIPromptCacheSettings = toml::from_str("").unwrap();
993 assert!(!defaults.auxiliary_explicit_cache);
994 let configured: OpenAIPromptCacheSettings = toml::from_str("auxiliary_explicit_cache = true").unwrap();
995 assert!(configured.auxiliary_explicit_cache);
996 }
997
998 #[test]
999 fn gap_threshold_respects_disable_switches() {
1000 let cfg = PromptCachingConfig { gap_warning_enabled: false, ..Default::default() };
1001 assert_eq!(cfg.gap_threshold_secs("anthropic"), None);
1002
1003 let mut cfg = PromptCachingConfig::default();
1004 cfg.providers.anthropic.enabled = false;
1005 assert_eq!(cfg.gap_threshold_secs("anthropic"), None);
1006 assert_eq!(cfg.gap_threshold_secs("minimax"), None);
1007
1008 let cfg = PromptCachingConfig { enabled: false, ..Default::default() };
1009 assert_eq!(cfg.gap_threshold_secs("openai"), None);
1010 }
1011
1012 #[test]
1013 fn gap_threshold_treats_extended_retention_as_an_advisory_not_a_guarantee() {
1014 let mut cfg = PromptCachingConfig::default();
1015 cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::H24);
1016 assert_eq!(cfg.gap_threshold_secs("openai"), Some(1800));
1017 cfg.gap_warning_threshold_secs = Some(42);
1018 assert_eq!(cfg.gap_threshold_secs("openai"), Some(42));
1019 cfg.gap_warning_threshold_secs = None;
1020
1021 cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::InMemory);
1022 assert_eq!(cfg.gap_threshold_secs("openai"), Some(prompt_cache::OPENAI_CACHE_GAP_WARNING_SECONDS));
1023 }
1024
1025 #[test]
1026 fn gap_threshold_honors_explicit_override() {
1027 let cfg = PromptCachingConfig {
1028 gap_warning_threshold_secs: Some(42),
1029 ..Default::default()
1030 };
1031 assert_eq!(cfg.gap_threshold_secs("anthropic"), Some(42));
1032 assert_eq!(cfg.gap_threshold_secs("openai"), Some(42));
1033 }
1034
1035 #[test]
1036 fn gap_warning_fields_parse_from_toml() {
1037 let parsed: PromptCachingConfig = toml::from_str(
1038 r#"
1039gap_warning_enabled = false
1040gap_warning_threshold_secs = 120
1041"#,
1042 )
1043 .expect("prompt cache config should parse");
1044
1045 assert!(!parsed.gap_warning_enabled);
1046 assert_eq!(parsed.gap_warning_threshold_secs, Some(120));
1047
1048 let defaults: PromptCachingConfig = toml::from_str("").expect("empty config");
1049 assert!(defaults.gap_warning_enabled);
1050 assert_eq!(defaults.gap_warning_threshold_secs, None);
1051 }
1052
1053 #[test]
1054 fn bundled_config_templates_match_prompt_cache_defaults() {
1055 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1056 let bootstrap_template =
1057 fs::read_to_string(manifest_dir.join("data/default_config.toml")).expect("bootstrap config template");
1058 let config: crate::VTCodeConfig = toml::from_str(&bootstrap_template).expect("parse bootstrap template");
1059 assert!(config.prompt_cache.enabled);
1060 assert!(config.prompt_cache.cache_friendly_prompt_shaping);
1061 assert!(bootstrap_template.contains("# prompt_cache_retention = \"24h\""));
1062
1063 let workspace_root = find_workspace_root();
1064 let example_config =
1065 fs::read_to_string(workspace_root.join("vtcode.toml.example")).expect("vtcode.toml.example");
1066 if example_config.contains("[prompt_cache]") {
1067 assert!(example_config.contains("enabled = true"));
1068 assert!(example_config.contains("cache_friendly_prompt_shaping = true"));
1069 assert!(example_config.contains("# prompt_cache_retention = \"24h\""));
1070 }
1071
1072 let prompt_cache_guide = fs::read_to_string(workspace_root.join("docs/tools/PROMPT_CACHING_GUIDE.md"))
1073 .expect("prompt caching guide");
1074 let normalized_guide = vtcode_commons::formatting::collapse_whitespace(&prompt_cache_guide);
1077 assert!(
1078 normalized_guide
1079 .contains("VT Code enables `prompt_cache.cache_friendly_prompt_shaping = true` by default.")
1080 );
1081 assert!(normalized_guide.contains("`prompt_cache_retention` is opt-in for older supported Responses models"));
1084
1085 let field_reference = fs::read_to_string(workspace_root.join("docs/config/CONFIG_FIELD_REFERENCE.md"))
1086 .expect("config field reference");
1087 assert!(field_reference.contains("`prompt_cache.cache_friendly_prompt_shaping`"));
1088 assert!(field_reference.contains("`prompt_cache.providers.openai.prompt_cache_retention`"));
1089 }
1090
1091 #[test]
1092 fn bundled_example_config_parses_and_validates() {
1093 let workspace_root = find_workspace_root();
1096 let example = fs::read_to_string(workspace_root.join("vtcode.toml.example")).expect("vtcode.toml.example");
1097 let config: crate::VTCodeConfig = toml::from_str(&example).expect("example config should parse");
1098 config.validate().expect("example config should validate");
1099 }
1100}