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, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
32pub enum PromptCacheTtl {
33 #[default]
34 #[serde(rename = "30m")]
35 M30,
36 #[serde(rename = "12h")]
37 H12,
38}
39
40impl PromptCacheTtl {
41 pub const fn as_str(self) -> &'static str {
42 match self {
43 Self::M30 => "30m",
44 Self::H12 => "12h",
45 }
46 }
47
48 pub const fn seconds(self) -> u64 {
49 match self {
50 Self::M30 => 1_800,
51 Self::H12 => 43_200,
52 }
53 }
54}
55
56pub fn resolved_openai_prompt_cache_ttl(configured: Option<PromptCacheTtl>, model: &str) -> PromptCacheTtl {
58 configured
59 .or_else(|| {
60 crate::models::model_catalog_entry("openai", model)
61 .and_then(|entry| entry.prompt_cache_ttl)
62 .and_then(|ttl| match ttl {
63 "30m" => Some(PromptCacheTtl::M30),
64 "12h" => Some(PromptCacheTtl::H12),
65 _ => None,
66 })
67 })
68 .unwrap_or_default()
69}
70
71#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73#[derive(Debug, Clone, Deserialize, Serialize)]
74pub struct PromptCachingConfig {
75 #[serde(default = "default_enabled")]
77 pub enabled: bool,
78
79 #[serde(default = "default_cache_dir")]
81 pub cache_dir: String,
82
83 #[serde(default = "default_max_entries")]
85 pub max_entries: usize,
86
87 #[serde(default = "default_max_age_days")]
89 pub max_age_days: u64,
90
91 #[serde(default = "default_auto_cleanup")]
93 pub enable_auto_cleanup: bool,
94
95 #[serde(default = "default_min_quality_threshold")]
97 pub min_quality_threshold: f64,
98
99 #[serde(default = "default_cache_friendly_prompt_shaping")]
102 pub cache_friendly_prompt_shaping: bool,
103
104 #[serde(default = "default_true")]
109 pub stable_tool_catalog_across_modes: bool,
110
111 #[serde(default = "default_true")]
114 pub gap_warning_enabled: bool,
115
116 #[serde(default)]
119 pub gap_warning_threshold_secs: Option<u64>,
120
121 #[serde(default)]
123 pub providers: ProviderPromptCachingConfig,
124}
125
126impl Default for PromptCachingConfig {
127 fn default() -> Self {
128 let cache_dir = default_cache_dir();
129 Self {
130 enabled: default_enabled() && !cache_dir.is_empty(),
131 cache_dir,
132 max_entries: default_max_entries(),
133 max_age_days: default_max_age_days(),
134 enable_auto_cleanup: default_auto_cleanup(),
135 min_quality_threshold: default_min_quality_threshold(),
136 cache_friendly_prompt_shaping: default_cache_friendly_prompt_shaping(),
137 stable_tool_catalog_across_modes: true,
138 gap_warning_enabled: default_true(),
139 gap_warning_threshold_secs: None,
140 providers: ProviderPromptCachingConfig::default(),
141 }
142 }
143}
144
145impl PromptCachingConfig {
146 pub fn resolve_cache_dir(&self, workspace_root: Option<&Path>) -> PathBuf {
152 resolve_path(&self.cache_dir, workspace_root)
153 }
154
155 pub fn is_provider_enabled(&self, provider_name: &str) -> bool {
157 if !self.enabled {
158 return false;
159 }
160
161 match provider_name.to_ascii_lowercase().as_str() {
162 "openai" | "merge-gateway" => self.providers.openai.enabled,
163 "anthropic" | "minimax" => self.providers.anthropic.enabled,
164 "gemini" => {
165 self.providers.gemini.enabled && !matches!(self.providers.gemini.mode, GeminiPromptCacheMode::Off)
166 }
167 "openrouter" => self.providers.openrouter.enabled,
168 "moonshot" => self.providers.moonshot.enabled,
169 "deepseek" => self.providers.deepseek.enabled,
170 "zai" => self.providers.zai.enabled,
171 _ => false,
172 }
173 }
174
175 pub fn gap_threshold_secs(&self, provider_name: &str) -> Option<u64> {
180 self.gap_threshold_secs_for_model(provider_name, "")
181 }
182
183 pub fn gap_threshold_secs_for_route(
185 &self,
186 provider: &str,
187 model: &str,
188 ttl: Option<PromptCacheTtl>,
189 ) -> Option<u64> {
190 if !self.gap_warning_enabled
191 || !self.enabled
192 || !(self.is_provider_enabled(provider) || (ttl.is_some() && self.providers.openai.enabled))
193 {
194 return None;
195 }
196 if let Some(threshold) = self.gap_warning_threshold_secs {
197 return Some(threshold);
198 }
199 if let Some(ttl) = ttl {
200 return Some(ttl.seconds());
201 }
202
203 Some(match provider.to_ascii_lowercase().as_str() {
204 "anthropic" | "minimax" => prompt_cache::ANTHROPIC_CACHE_GAP_WARNING_SECONDS,
205 "openai"
206 if supports_openai_explicit_prompt_cache(model)
207 || self.providers.openai.prompt_cache_retention == Some(PromptCacheRetention::H24) =>
208 {
209 30 * 60
210 }
211 "openai" => prompt_cache::OPENAI_CACHE_GAP_WARNING_SECONDS,
212 _ => prompt_cache::DEFAULT_CACHE_GAP_WARNING_SECONDS,
213 })
214 }
215
216 pub fn gap_threshold_secs_for_model(&self, provider_name: &str, model: &str) -> Option<u64> {
219 let ttl = (provider_name.eq_ignore_ascii_case("openai") && supports_openai_explicit_prompt_cache(model))
220 .then(|| resolved_openai_prompt_cache_ttl(self.providers.openai.prompt_cache_ttl, model));
221 self.gap_threshold_secs_for_route(provider_name, model, ttl)
222 }
223}
224
225#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
227#[derive(Debug, Clone, Deserialize, Serialize, Default)]
228pub struct ProviderPromptCachingConfig {
229 #[serde(default = "OpenAIPromptCacheSettings::default")]
230 pub openai: OpenAIPromptCacheSettings,
231
232 #[serde(default = "AnthropicPromptCacheSettings::default")]
233 pub anthropic: AnthropicPromptCacheSettings,
234
235 #[serde(default = "GeminiPromptCacheSettings::default")]
236 pub gemini: GeminiPromptCacheSettings,
237
238 #[serde(default = "OpenRouterPromptCacheSettings::default")]
239 pub openrouter: OpenRouterPromptCacheSettings,
240
241 #[serde(default = "MoonshotPromptCacheSettings::default")]
242 moonshot: MoonshotPromptCacheSettings,
243
244 #[serde(default = "DeepSeekPromptCacheSettings::default")]
245 pub deepseek: DeepSeekPromptCacheSettings,
246
247 #[serde(default = "ZaiPromptCacheSettings::default")]
248 zai: ZaiPromptCacheSettings,
249}
250
251#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
255pub enum PromptCacheRetention {
256 #[serde(rename = "in_memory")]
258 InMemory,
259 #[default]
261 #[serde(rename = "24h")]
262 H24,
263 #[serde(other, rename = "unknown")]
269 Unknown,
270}
271
272impl PromptCacheRetention {
273 pub fn as_str(&self) -> &str {
275 match self {
276 Self::InMemory => "in_memory",
277 Self::H24 => "24h",
278 Self::Unknown => "unknown",
279 }
280 }
281}
282
283impl std::fmt::Display for PromptCacheRetention {
284 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285 f.write_str(self.as_str())
286 }
287}
288
289#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
291#[derive(Debug, Clone, Deserialize, Serialize)]
292pub struct OpenAIPromptCacheSettings {
293 #[serde(default = "default_true")]
294 pub enabled: bool,
295
296 #[serde(default = "default_openai_min_prefix_tokens")]
297 min_prefix_tokens: u32,
298
299 #[serde(default = "default_openai_idle_expiration")]
300 idle_expiration_seconds: u64,
301
302 #[serde(default = "default_true")]
303 pub surface_metrics: bool,
304
305 #[serde(default)]
308 pub auxiliary_explicit_cache: bool,
309
310 #[serde(default = "default_openai_prompt_cache_key_mode")]
313 pub prompt_cache_key_mode: OpenAIPromptCacheKeyMode,
314
315 #[serde(default)]
320 pub prompt_cache_retention: Option<PromptCacheRetention>,
321
322 #[serde(default)]
324 pub prompt_cache_ttl: Option<PromptCacheTtl>,
325}
326
327impl Default for OpenAIPromptCacheSettings {
328 fn default() -> Self {
329 Self {
330 enabled: default_true(),
331 min_prefix_tokens: default_openai_min_prefix_tokens(),
332 idle_expiration_seconds: default_openai_idle_expiration(),
333 surface_metrics: default_true(),
334 auxiliary_explicit_cache: false,
335 prompt_cache_key_mode: default_openai_prompt_cache_key_mode(),
336 prompt_cache_retention: None,
337 prompt_cache_ttl: None,
338 }
339 }
340}
341
342impl OpenAIPromptCacheSettings {
343 fn validate(&self) -> anyhow::Result<()> {
346 if let Some(PromptCacheRetention::Unknown) = self.prompt_cache_retention {
347 anyhow::bail!("prompt_cache_retention must be one of: in_memory, 24h");
348 }
349 Ok(())
350 }
351}
352
353#[must_use]
356pub fn build_openai_prompt_cache_key(
357 prompt_cache_enabled: bool,
358 prompt_cache_key_mode: &OpenAIPromptCacheKeyMode,
359 lineage_id: Option<&str>,
360) -> Option<String> {
361 if !prompt_cache_enabled {
362 return None;
363 }
364
365 let lineage_id = lineage_id.map(str::trim).filter(|value| !value.is_empty());
366 match prompt_cache_key_mode {
367 OpenAIPromptCacheKeyMode::Session => lineage_id.map(|lineage_id| format!("vtcode:openai:{lineage_id}")),
368 OpenAIPromptCacheKeyMode::Off => None,
369 }
370}
371
372#[must_use]
376pub fn map_prompt_cache_key_for_provider(provider_name: &str, key: String) -> String {
377 let provider = provider_name.trim().to_ascii_lowercase();
378 match provider.as_str() {
379 "merge-gateway" => key.replacen("vtcode:openai:", "vtcode:merge:", 1),
380 "openrouter" => key.replacen("vtcode:openai:", "vtcode:openrouter:", 1),
381 "xai" => key.replacen("vtcode:openai:", "vtcode:xai:", 1),
382 _ => key,
383 }
384}
385
386#[must_use]
388pub fn session_affinity_provider(provider_name: &str) -> bool {
389 matches!(
390 provider_name.trim().to_ascii_lowercase().as_str(),
391 "openai" | "merge-gateway" | "openrouter" | "xai"
392 )
393}
394
395#[must_use]
401pub fn session_affinity_key_enabled(
402 provider_name: &str,
403 global_prompt_cache_enabled: bool,
404 openai_prompt_cache_enabled: bool,
405) -> bool {
406 if !global_prompt_cache_enabled {
407 return false;
408 }
409 match provider_name.trim().to_ascii_lowercase().as_str() {
410 "openai" | "merge-gateway" => openai_prompt_cache_enabled,
411 "openrouter" | "xai" => true,
412 _ => false,
413 }
414}
415
416#[must_use]
422pub fn build_session_affinity_prompt_cache_key(
423 provider_name: &str,
424 session_affinity_enabled: bool,
425 prompt_cache_key_mode: &OpenAIPromptCacheKeyMode,
426 lineage_id: Option<&str>,
427) -> Option<String> {
428 if !session_affinity_enabled {
429 return None;
430 }
431 let provider = provider_name.trim().to_ascii_lowercase();
432 let lineage = lineage_id.map(str::trim).filter(|value| !value.is_empty())?;
433 match provider.as_str() {
434 "openai" | "merge-gateway" => match prompt_cache_key_mode {
435 OpenAIPromptCacheKeyMode::Session => {
436 Some(map_prompt_cache_key_for_provider(&provider, format!("vtcode:openai:{lineage}")))
437 }
438 OpenAIPromptCacheKeyMode::Off => None,
439 },
440 "openrouter" | "xai" => Some(format!("vtcode:{provider}:{lineage}")),
441 _ => None,
442 }
443}
444
445#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
447#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
448#[serde(rename_all = "snake_case")]
449pub enum OpenAIPromptCacheKeyMode {
450 Off,
452 #[default]
454 Session,
455}
456
457#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
459#[derive(Debug, Clone, Deserialize, Serialize)]
460pub struct AnthropicPromptCacheSettings {
461 #[serde(default = "default_true")]
462 pub enabled: bool,
463
464 #[serde(default = "default_anthropic_tools_ttl")]
469 pub tools_ttl_seconds: u64,
470
471 #[serde(default = "default_anthropic_messages_ttl")]
475 pub messages_ttl_seconds: u64,
476
477 #[serde(default = "default_anthropic_max_breakpoints")]
480 pub max_breakpoints: u8,
481
482 #[serde(default = "default_true")]
484 pub cache_system_messages: bool,
485
486 #[serde(default = "default_true")]
488 pub cache_user_messages: bool,
489
490 #[serde(default = "default_true")]
493 pub cache_tool_definitions: bool,
494
495 #[serde(default = "default_min_message_length")]
499 pub min_message_length_for_cache: usize,
500
501 #[serde(default = "default_anthropic_extended_ttl")]
504 pub extended_ttl_seconds: Option<u64>,
505
506 #[serde(default)]
510 pub prefer_extended_ttl: bool,
511}
512
513impl Default for AnthropicPromptCacheSettings {
514 fn default() -> Self {
515 Self {
516 enabled: default_true(),
517 tools_ttl_seconds: default_anthropic_tools_ttl(),
518 messages_ttl_seconds: default_anthropic_messages_ttl(),
519 max_breakpoints: default_anthropic_max_breakpoints(),
520 cache_system_messages: default_true(),
521 cache_user_messages: default_true(),
522 cache_tool_definitions: default_true(),
523 min_message_length_for_cache: default_min_message_length(),
524 extended_ttl_seconds: default_anthropic_extended_ttl(),
525 prefer_extended_ttl: false,
526 }
527 }
528}
529
530#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
532#[derive(Debug, Clone, Deserialize, Serialize)]
533pub struct GeminiPromptCacheSettings {
534 #[serde(default = "default_true")]
535 pub enabled: bool,
536
537 #[serde(default = "default_gemini_mode")]
538 pub mode: GeminiPromptCacheMode,
539
540 #[serde(default = "default_gemini_min_prefix_tokens")]
541 min_prefix_tokens: u32,
542
543 #[serde(default = "default_gemini_explicit_ttl")]
545 pub explicit_ttl_seconds: Option<u64>,
546}
547
548impl Default for GeminiPromptCacheSettings {
549 fn default() -> Self {
550 Self {
551 enabled: default_true(),
552 mode: GeminiPromptCacheMode::default(),
553 min_prefix_tokens: default_gemini_min_prefix_tokens(),
554 explicit_ttl_seconds: default_gemini_explicit_ttl(),
555 }
556 }
557}
558
559#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
561#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
562#[serde(rename_all = "snake_case")]
563#[derive(Default)]
564pub enum GeminiPromptCacheMode {
565 #[default]
566 Implicit,
567 Explicit,
568 Off,
569}
570
571#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
573#[derive(Debug, Clone, Deserialize, Serialize)]
574pub struct OpenRouterPromptCacheSettings {
575 #[serde(default = "default_true")]
576 pub enabled: bool,
577
578 #[serde(default = "default_true")]
580 propagate_provider_capabilities: bool,
581
582 #[serde(default = "default_true")]
584 pub report_savings: bool,
585}
586
587impl Default for OpenRouterPromptCacheSettings {
588 fn default() -> Self {
589 Self {
590 enabled: default_true(),
591 propagate_provider_capabilities: default_true(),
592 report_savings: default_true(),
593 }
594 }
595}
596
597#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
599#[derive(Debug, Clone, Deserialize, Serialize)]
600pub struct MoonshotPromptCacheSettings {
601 #[serde(default = "default_moonshot_enabled")]
602 enabled: bool,
603}
604
605impl Default for MoonshotPromptCacheSettings {
606 fn default() -> Self {
607 Self { enabled: default_moonshot_enabled() }
608 }
609}
610
611#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
613#[derive(Debug, Clone, Deserialize, Serialize)]
614pub struct DeepSeekPromptCacheSettings {
615 #[serde(default = "default_true")]
616 pub enabled: bool,
617
618 #[serde(default = "default_true")]
620 pub surface_metrics: bool,
621}
622
623impl Default for DeepSeekPromptCacheSettings {
624 fn default() -> Self {
625 Self {
626 enabled: default_true(),
627 surface_metrics: default_true(),
628 }
629 }
630}
631
632#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
634#[derive(Debug, Clone, Deserialize, Serialize)]
635pub struct ZaiPromptCacheSettings {
636 #[serde(default = "default_zai_enabled")]
637 enabled: bool,
638}
639
640impl Default for ZaiPromptCacheSettings {
641 fn default() -> Self {
642 Self { enabled: default_zai_enabled() }
643 }
644}
645
646fn default_enabled() -> bool {
647 prompt_cache::DEFAULT_ENABLED
648}
649
650fn default_cache_dir() -> String {
651 VtCodePaths::resolve()
652 .map(|paths| paths.cache_dir().join("prompts").display().to_string())
653 .unwrap_or_default()
654}
655
656fn default_max_entries() -> usize {
657 prompt_cache::DEFAULT_MAX_ENTRIES
658}
659
660fn default_max_age_days() -> u64 {
661 prompt_cache::DEFAULT_MAX_AGE_DAYS
662}
663
664fn default_auto_cleanup() -> bool {
665 prompt_cache::DEFAULT_AUTO_CLEANUP
666}
667
668fn default_min_quality_threshold() -> f64 {
669 prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD
670}
671
672fn default_cache_friendly_prompt_shaping() -> bool {
673 prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING
674}
675
676fn default_openai_min_prefix_tokens() -> u32 {
677 prompt_cache::OPENAI_MIN_PREFIX_TOKENS
678}
679
680fn default_openai_idle_expiration() -> u64 {
681 prompt_cache::OPENAI_IDLE_EXPIRATION_SECONDS
682}
683
684fn default_openai_prompt_cache_key_mode() -> OpenAIPromptCacheKeyMode {
685 OpenAIPromptCacheKeyMode::Session
686}
687
688fn default_anthropic_extended_ttl() -> Option<u64> {
689 Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
690}
691
692fn default_anthropic_tools_ttl() -> u64 {
693 prompt_cache::ANTHROPIC_TOOLS_TTL_SECONDS
694}
695
696fn default_anthropic_messages_ttl() -> u64 {
697 prompt_cache::ANTHROPIC_MESSAGES_TTL_SECONDS
698}
699
700fn default_anthropic_max_breakpoints() -> u8 {
701 prompt_cache::ANTHROPIC_MAX_BREAKPOINTS
702}
703
704fn default_min_message_length() -> usize {
705 prompt_cache::ANTHROPIC_MIN_MESSAGE_LENGTH_FOR_CACHE
706}
707
708fn default_gemini_min_prefix_tokens() -> u32 {
709 prompt_cache::GEMINI_MIN_PREFIX_TOKENS
710}
711
712fn default_gemini_explicit_ttl() -> Option<u64> {
713 Some(prompt_cache::GEMINI_EXPLICIT_DEFAULT_TTL_SECONDS)
714}
715
716fn default_gemini_mode() -> GeminiPromptCacheMode {
717 GeminiPromptCacheMode::Implicit
718}
719
720fn default_zai_enabled() -> bool {
721 prompt_cache::ZAI_CACHE_ENABLED
722}
723
724fn default_moonshot_enabled() -> bool {
725 prompt_cache::MOONSHOT_CACHE_ENABLED
726}
727
728fn resolve_path(input: &str, workspace_root: Option<&Path>) -> PathBuf {
729 let trimmed = input.trim();
730 if trimmed.is_empty() {
731 return resolve_default_cache_dir();
732 }
733
734 if let Some(stripped) = trimmed.strip_prefix("~/").or_else(|| trimmed.strip_prefix("~\\")) {
735 if let Some(home) = dirs::home_dir() {
736 return home.join(stripped);
737 }
738 return PathBuf::from(stripped);
739 }
740
741 let candidate = Path::new(trimmed);
742 if candidate.is_absolute() {
743 return candidate.to_path_buf();
744 }
745
746 if let Some(root) = workspace_root {
747 return root.join(candidate);
748 }
749
750 candidate.to_path_buf()
751}
752
753fn resolve_default_cache_dir() -> PathBuf {
754 VtCodePaths::resolve()
755 .map(|paths| paths.cache_dir().join("prompts"))
756 .unwrap_or_default()
757}
758
759impl PromptCachingConfig {
760 pub(crate) fn validate(&self) -> anyhow::Result<()> {
762 self.providers.openai.validate()?;
764 Ok(())
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771 use assert_fs::TempDir;
772 use std::fs;
773
774 fn find_workspace_root() -> PathBuf {
778 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
779 let mut current = manifest.as_path();
780 while let Some(parent) = current.parent() {
781 let cargo_toml = parent.join("Cargo.toml");
782 if cargo_toml.is_file() && fs::read_to_string(&cargo_toml).is_ok_and(|c| c.contains("[workspace]")) {
783 return parent.to_path_buf();
784 }
785 current = parent;
786 }
787 panic!("could not find workspace root from CARGO_MANIFEST_DIR");
788 }
789
790 #[test]
791 fn prompt_caching_defaults_align_with_constants() {
792 let cfg = PromptCachingConfig::default();
793 assert!(cfg.enabled);
794 assert_eq!(cfg.max_entries, prompt_cache::DEFAULT_MAX_ENTRIES);
795 assert_eq!(cfg.max_age_days, prompt_cache::DEFAULT_MAX_AGE_DAYS);
796 assert!((cfg.min_quality_threshold - prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD).abs() < f64::EPSILON);
797 assert_eq!(cfg.cache_friendly_prompt_shaping, prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING);
798 assert!(cfg.providers.openai.enabled);
799 assert_eq!(cfg.providers.openai.min_prefix_tokens, prompt_cache::OPENAI_MIN_PREFIX_TOKENS);
800 assert_eq!(cfg.providers.openai.prompt_cache_key_mode, OpenAIPromptCacheKeyMode::Session);
801 assert_eq!(cfg.providers.anthropic.extended_ttl_seconds, Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS));
802 assert_eq!(cfg.providers.gemini.mode, GeminiPromptCacheMode::Implicit);
803 assert!(cfg.providers.moonshot.enabled);
804 assert_eq!(cfg.providers.openai.prompt_cache_retention, None);
805 }
806
807 #[test]
808 fn resolve_cache_dir_expands_home() {
809 let cfg = PromptCachingConfig {
810 cache_dir: "~/.custom/cache".to_string(),
811 ..PromptCachingConfig::default()
812 };
813 let resolved = cfg.resolve_cache_dir(None);
814 if let Some(home) = dirs::home_dir() {
815 assert!(resolved.starts_with(home));
816 } else {
817 assert_eq!(resolved, PathBuf::from(".custom/cache"));
818 }
819 }
820
821 #[test]
822 fn resolve_cache_dir_uses_workspace_when_relative() {
823 let temp = TempDir::new().unwrap();
824 let workspace = temp.path();
825 let cfg = PromptCachingConfig {
826 cache_dir: "relative/cache".to_string(),
827 ..PromptCachingConfig::default()
828 };
829 let resolved = cfg.resolve_cache_dir(Some(workspace));
830 assert_eq!(resolved, workspace.join("relative/cache"));
831 }
832
833 #[test]
834 fn prompt_cache_retention_deserializes_valid_values() {
835 let cfg: OpenAIPromptCacheSettings = toml::from_str(
836 r#"
837 prompt_cache_retention = "in_memory"
838 "#,
839 )
840 .unwrap();
841 assert_eq!(cfg.prompt_cache_retention, Some(PromptCacheRetention::InMemory));
842
843 let cfg2: OpenAIPromptCacheSettings = toml::from_str(
844 r#"
845 prompt_cache_retention = "24h"
846 "#,
847 )
848 .unwrap();
849 assert_eq!(cfg2.prompt_cache_retention, Some(PromptCacheRetention::H24));
850 }
851
852 #[test]
853 fn prompt_cache_retention_unknown_values_deserialize_as_unknown() {
854 let cfg: OpenAIPromptCacheSettings = toml::from_str(
855 r#"
856 prompt_cache_retention = "5m"
857 "#,
858 )
859 .unwrap();
860 assert_eq!(cfg.prompt_cache_retention, Some(PromptCacheRetention::Unknown));
861 }
862
863 #[test]
869 fn prompt_cache_retention_as_str_matches_serde() {
870 for retention in [
871 PromptCacheRetention::InMemory,
872 PromptCacheRetention::H24,
873 PromptCacheRetention::Unknown,
874 ] {
875 assert_eq!(
876 serde_json::to_value(retention).unwrap(),
877 serde_json::json!(retention.as_str()),
878 "serde wire form drifted from as_str() for {retention:?}"
879 );
880 }
881 }
882
883 #[test]
884 fn validate_prompt_cache_rejects_unknown_retention() {
885 let mut cfg = PromptCachingConfig::default();
886 cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::Unknown);
887 assert!(cfg.validate().is_err());
888 }
889
890 #[test]
891 fn prompt_cache_key_mode_parses_from_toml() {
892 let parsed: PromptCachingConfig = toml::from_str(
893 r#"
894[providers.openai]
895prompt_cache_key_mode = "off"
896"#,
897 )
898 .expect("prompt cache config should parse");
899
900 assert_eq!(parsed.providers.openai.prompt_cache_key_mode, OpenAIPromptCacheKeyMode::Off);
901 }
902
903 #[test]
904 fn build_openai_prompt_cache_key_uses_trimmed_lineage_id() {
905 let key = build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Session, Some(" lineage-abc "));
906
907 assert_eq!(key.as_deref(), Some("vtcode:openai:lineage-abc"));
908 }
909
910 #[test]
911 fn map_prompt_cache_key_namespaces_merge_gateway() {
912 assert_eq!(
913 map_prompt_cache_key_for_provider("merge-gateway", "vtcode:openai:abc".to_string()),
914 "vtcode:merge:abc"
915 );
916 assert_eq!(map_prompt_cache_key_for_provider("openai", "vtcode:openai:abc".to_string()), "vtcode:openai:abc");
917 assert_eq!(
918 map_prompt_cache_key_for_provider("openrouter", "vtcode:openai:abc".to_string()),
919 "vtcode:openrouter:abc"
920 );
921 assert_eq!(map_prompt_cache_key_for_provider("xai", "vtcode:openai:abc".to_string()), "vtcode:xai:abc");
922 }
923
924 #[test]
925 fn session_affinity_key_gate_covers_openrouter_and_xai() {
926 assert!(session_affinity_key_enabled("openrouter", true, false));
927 assert!(session_affinity_key_enabled("xai", true, false));
928 assert!(session_affinity_key_enabled("openai", true, true));
929 assert!(!session_affinity_key_enabled("openai", true, false));
930 assert!(!session_affinity_key_enabled("openrouter", false, true));
931 assert!(!session_affinity_key_enabled("anthropic", true, true));
932 assert!(session_affinity_provider("openrouter"));
933 assert!(session_affinity_provider("xai"));
934 assert!(!session_affinity_provider("anthropic"));
935 }
936
937 #[test]
938 fn build_session_affinity_prompt_cache_key_namespaces_providers() {
939 let mode = OpenAIPromptCacheKeyMode::Session;
940 assert_eq!(
941 build_session_affinity_prompt_cache_key("openai", true, &mode, Some("lineage-1")).as_deref(),
942 Some("vtcode:openai:lineage-1")
943 );
944 assert_eq!(
945 build_session_affinity_prompt_cache_key("merge-gateway", true, &mode, Some("lineage-1")).as_deref(),
946 Some("vtcode:merge:lineage-1")
947 );
948 assert_eq!(
949 build_session_affinity_prompt_cache_key("openrouter", true, &mode, Some("lineage-1")).as_deref(),
950 Some("vtcode:openrouter:lineage-1")
951 );
952 assert_eq!(
953 build_session_affinity_prompt_cache_key("xai", true, &mode, Some("lineage-1")).as_deref(),
954 Some("vtcode:xai:lineage-1")
955 );
956 assert_eq!(
958 build_session_affinity_prompt_cache_key("openai", true, &OpenAIPromptCacheKeyMode::Off, Some("lineage-1")),
959 None
960 );
961 assert_eq!(
962 build_session_affinity_prompt_cache_key(
963 "openrouter",
964 true,
965 &OpenAIPromptCacheKeyMode::Off,
966 Some("lineage-1")
967 )
968 .as_deref(),
969 Some("vtcode:openrouter:lineage-1")
970 );
971 assert_eq!(build_session_affinity_prompt_cache_key("openrouter", false, &mode, Some("lineage-1")), None);
973 assert_eq!(build_session_affinity_prompt_cache_key("openrouter", true, &mode, Some(" ")), None);
974 assert_eq!(build_session_affinity_prompt_cache_key("anthropic", true, &mode, Some("lineage-1")), None);
975 }
976
977 #[test]
978 fn build_openai_prompt_cache_key_honors_disabled_or_off_mode() {
979 assert_eq!(build_openai_prompt_cache_key(false, &OpenAIPromptCacheKeyMode::Session, Some("id")), None);
980 assert_eq!(build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Off, Some("id")), None);
981 assert_eq!(build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Session, Some(" ")), None);
982 }
983
984 #[test]
985 fn provider_enablement_respects_global_and_provider_flags() {
986 let mut cfg = PromptCachingConfig { enabled: true, ..PromptCachingConfig::default() };
987 cfg.providers.openai.enabled = true;
988 assert!(cfg.is_provider_enabled("openai"));
989 assert!(cfg.is_provider_enabled("merge-gateway"));
990
991 cfg.enabled = false;
992 assert!(!cfg.is_provider_enabled("openai"));
993 assert!(!cfg.is_provider_enabled("merge-gateway"));
994 }
995
996 #[test]
997 fn provider_enablement_handles_aliases_and_modes() {
998 let mut cfg = PromptCachingConfig { enabled: true, ..PromptCachingConfig::default() };
999
1000 cfg.providers.anthropic.enabled = true;
1001 assert!(cfg.is_provider_enabled("minimax"));
1002
1003 cfg.providers.gemini.enabled = true;
1004 cfg.providers.gemini.mode = GeminiPromptCacheMode::Off;
1005 assert!(!cfg.is_provider_enabled("gemini"));
1006 }
1007
1008 #[test]
1009 fn gap_threshold_uses_provider_defaults() {
1010 let cfg = PromptCachingConfig::default();
1011 assert_eq!(cfg.gap_threshold_secs("anthropic"), Some(prompt_cache::ANTHROPIC_CACHE_GAP_WARNING_SECONDS));
1012 assert_eq!(cfg.gap_threshold_secs("minimax"), Some(prompt_cache::ANTHROPIC_CACHE_GAP_WARNING_SECONDS));
1013 assert_eq!(cfg.gap_threshold_secs("openai"), Some(prompt_cache::OPENAI_CACHE_GAP_WARNING_SECONDS));
1014 assert_eq!(cfg.gap_threshold_secs("gemini"), Some(prompt_cache::DEFAULT_CACHE_GAP_WARNING_SECONDS));
1015 }
1016
1017 #[test]
1018 fn gap_threshold_uses_model_ttl_and_ignores_legacy_retention_for_modern_models() {
1019 let mut cfg = PromptCachingConfig::default();
1020 for retention in [
1021 None,
1022 Some(PromptCacheRetention::H24),
1023 Some(PromptCacheRetention::InMemory),
1024 ] {
1025 cfg.providers.openai.prompt_cache_retention = retention;
1026 for model in ["gpt-5.6-sol", "gpt-6-luna", "gpt-6.1-sol"] {
1027 assert_eq!(cfg.gap_threshold_secs_for_model("openai", model), Some(1800), "{model}");
1028 }
1029 }
1030 assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-5.1-mini"), Some(600));
1031 assert_eq!(cfg.gap_threshold_secs_for_model("merge-gateway", "gpt-6-luna"), Some(300));
1032 cfg.gap_warning_threshold_secs = Some(77);
1033 assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-6-luna"), Some(77));
1034 cfg.enabled = false;
1035 assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-6-luna"), None);
1036 }
1037
1038 #[test]
1039 fn explicit_cache_models_reject_migration_aliases_and_auxiliary_mode_is_opt_in() {
1040 for model in [
1041 "gpt-5.4",
1042 "gpt-5-codex",
1043 "gpt",
1044 "gpt-5.1-mini",
1045 "openai/gpt-6-luna",
1046 "gpt-6-future",
1047 ] {
1048 assert!(!supports_openai_explicit_prompt_cache(model), "{model}");
1049 }
1050 for model in ["gpt-5.6", "gpt-5.6-terra", "gpt-6-astra", "gpt-6-sol"] {
1051 assert!(supports_openai_explicit_prompt_cache(model), "{model}");
1052 }
1053 let defaults: OpenAIPromptCacheSettings = toml::from_str("").unwrap();
1054 assert!(!defaults.auxiliary_explicit_cache);
1055 let configured: OpenAIPromptCacheSettings = toml::from_str("auxiliary_explicit_cache = true").unwrap();
1056 assert!(configured.auxiliary_explicit_cache);
1057 }
1058
1059 #[test]
1060 fn gap_threshold_respects_disable_switches() {
1061 let cfg = PromptCachingConfig { gap_warning_enabled: false, ..Default::default() };
1062 assert_eq!(cfg.gap_threshold_secs("anthropic"), None);
1063
1064 let mut cfg = PromptCachingConfig::default();
1065 cfg.providers.anthropic.enabled = false;
1066 assert_eq!(cfg.gap_threshold_secs("anthropic"), None);
1067 assert_eq!(cfg.gap_threshold_secs("minimax"), None);
1068
1069 let cfg = PromptCachingConfig { enabled: false, ..Default::default() };
1070 assert_eq!(cfg.gap_threshold_secs("openai"), None);
1071 }
1072
1073 #[test]
1074 fn gap_threshold_treats_extended_retention_as_an_advisory_not_a_guarantee() {
1075 let mut cfg = PromptCachingConfig::default();
1076 cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::H24);
1077 assert_eq!(cfg.gap_threshold_secs("openai"), Some(1800));
1078 cfg.gap_warning_threshold_secs = Some(42);
1079 assert_eq!(cfg.gap_threshold_secs("openai"), Some(42));
1080 cfg.gap_warning_threshold_secs = None;
1081
1082 cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::InMemory);
1083 assert_eq!(cfg.gap_threshold_secs("openai"), Some(prompt_cache::OPENAI_CACHE_GAP_WARNING_SECONDS));
1084 }
1085
1086 #[test]
1087 fn gap_threshold_honors_explicit_override() {
1088 let cfg = PromptCachingConfig {
1089 gap_warning_threshold_secs: Some(42),
1090 ..Default::default()
1091 };
1092 assert_eq!(cfg.gap_threshold_secs("anthropic"), Some(42));
1093 assert_eq!(cfg.gap_threshold_secs("openai"), Some(42));
1094 }
1095
1096 #[test]
1097 fn gap_warning_fields_parse_from_toml() {
1098 let parsed: PromptCachingConfig = toml::from_str(
1099 r#"
1100gap_warning_enabled = false
1101gap_warning_threshold_secs = 120
1102"#,
1103 )
1104 .expect("prompt cache config should parse");
1105
1106 assert!(!parsed.gap_warning_enabled);
1107 assert_eq!(parsed.gap_warning_threshold_secs, Some(120));
1108
1109 let defaults: PromptCachingConfig = toml::from_str("").expect("empty config");
1110 assert!(defaults.gap_warning_enabled);
1111 assert_eq!(defaults.gap_warning_threshold_secs, None);
1112 }
1113
1114 #[test]
1115 fn bundled_config_templates_match_prompt_cache_defaults() {
1116 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1117 let bootstrap_template =
1118 fs::read_to_string(manifest_dir.join("data/default_config.toml")).expect("bootstrap config template");
1119 let config: crate::VTCodeConfig = toml::from_str(&bootstrap_template).expect("parse bootstrap template");
1120 assert!(config.prompt_cache.enabled);
1121 assert!(config.prompt_cache.cache_friendly_prompt_shaping);
1122 assert!(bootstrap_template.contains("# prompt_cache_retention = \"24h\""));
1123
1124 let workspace_root = find_workspace_root();
1125 let example_config =
1126 fs::read_to_string(workspace_root.join("vtcode.toml.example")).expect("vtcode.toml.example");
1127 if example_config.contains("[prompt_cache]") {
1128 assert!(example_config.contains("enabled = true"));
1129 assert!(example_config.contains("cache_friendly_prompt_shaping = true"));
1130 assert!(example_config.contains("# prompt_cache_retention = \"24h\""));
1131 }
1132
1133 let prompt_cache_guide = fs::read_to_string(workspace_root.join("docs/tools/PROMPT_CACHING_GUIDE.md"))
1134 .expect("prompt caching guide");
1135 let normalized_guide = vtcode_commons::formatting::collapse_whitespace(&prompt_cache_guide);
1138 assert!(
1139 normalized_guide
1140 .contains("VT Code enables `prompt_cache.cache_friendly_prompt_shaping = true` by default.")
1141 );
1142 assert!(normalized_guide.contains("`prompt_cache_retention` is opt-in for older supported Responses models"));
1145
1146 let field_reference = fs::read_to_string(workspace_root.join("docs/config/CONFIG_FIELD_REFERENCE.md"))
1147 .expect("config field reference");
1148 assert!(field_reference.contains("`prompt_cache.cache_friendly_prompt_shaping`"));
1149 assert!(field_reference.contains("`prompt_cache.providers.openai.prompt_cache_retention`"));
1150 }
1151
1152 #[test]
1153 fn bundled_example_config_parses_and_validates() {
1154 let workspace_root = find_workspace_root();
1157 let example = fs::read_to_string(workspace_root.join("vtcode.toml.example")).expect("vtcode.toml.example");
1158 let config: crate::VTCodeConfig = toml::from_str(&example).expect("example config should parse");
1159 config.validate().expect("example config should validate");
1160 }
1161}
1162
1163#[cfg(test)]
1164mod ttl_tests {
1165 use super::*;
1166
1167 #[test]
1168 fn prompt_cache_ttl_parsing_and_default() {
1169 assert_eq!(OpenAIPromptCacheSettings::default().prompt_cache_ttl, None);
1170 for (wire, expected) in [("30m", PromptCacheTtl::M30), ("12h", PromptCacheTtl::H12)] {
1171 let parsed: OpenAIPromptCacheSettings = toml::from_str(&format!("prompt_cache_ttl = \"{wire}\"")).unwrap();
1172 assert_eq!(parsed.prompt_cache_ttl, Some(expected));
1173 assert_eq!(expected.as_str(), wire);
1174 }
1175 assert!(toml::from_str::<OpenAIPromptCacheSettings>("prompt_cache_ttl = \"24h\"").is_err());
1176 }
1177
1178 #[test]
1179 fn prompt_cache_gap_route_fallback_preserves_model_baselines() {
1180 let mut cfg = PromptCachingConfig::default();
1181 cfg.providers.openai.prompt_cache_ttl = Some(PromptCacheTtl::H12);
1182 for (provider, model, expected) in [
1183 ("openai", "gpt-6-luna", Some(1_800)),
1184 ("OPENAI", "gpt-6-luna", Some(1_800)),
1185 ("openai", "gpt-5.1-mini", Some(600)),
1186 ("merge-gateway", "gpt-6-luna", Some(300)),
1187 ("custom", "gpt-6-luna", None),
1188 ] {
1189 assert_eq!(cfg.gap_threshold_secs_for_route(provider, model, None), expected, "{provider}/{model}");
1190 }
1191 assert_eq!(cfg.gap_threshold_secs_for_route("openai", "gpt-6-luna", Some(PromptCacheTtl::H12)), Some(43_200));
1192 cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::H24);
1193 assert_eq!(cfg.gap_threshold_secs_for_route("openai", "gpt-5.1-mini", None), Some(1_800));
1194 cfg.gap_warning_threshold_secs = Some(77);
1195 assert_eq!(cfg.gap_threshold_secs_for_route("openai", "gpt-6-luna", None), Some(77));
1196 cfg.providers.openai.enabled = false;
1197 assert_eq!(cfg.gap_threshold_secs_for_route("openai", "gpt-6-luna", None), None);
1198 }
1199
1200 #[test]
1201 fn prompt_cache_ttl_gap_thresholds_follow_effective_route() {
1202 let mut cfg = PromptCachingConfig::default();
1203 for (ttl, seconds) in [(PromptCacheTtl::M30, 1_800), (PromptCacheTtl::H12, 43_200)] {
1204 cfg.providers.openai.prompt_cache_ttl = Some(ttl);
1205 assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-6-luna"), Some(seconds));
1206 assert_eq!(cfg.gap_threshold_secs_for_route("custom", "opaque-model", Some(ttl)), Some(seconds));
1207 }
1208 assert_eq!(cfg.gap_threshold_secs_for_route("custom", "opaque-model", None), None);
1209 cfg.gap_warning_threshold_secs = Some(77);
1210 assert_eq!(cfg.gap_threshold_secs_for_route("custom", "opaque-model", Some(PromptCacheTtl::H12)), Some(77));
1211 cfg.providers.openai.enabled = false;
1212 assert_eq!(cfg.gap_threshold_secs_for_route("custom", "opaque-model", Some(PromptCacheTtl::H12)), None);
1213 }
1214}