Skip to main content

vtcode_config/core/
prompt_cache.rs

1use crate::constants::prompt_cache;
2use crate::env_helpers::default_true;
3use anyhow::Context;
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7/// Global prompt caching configuration loaded from vtcode.toml
8#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
9#[derive(Debug, Clone, Deserialize, Serialize)]
10pub struct PromptCachingConfig {
11    /// Enable prompt caching features globally
12    #[serde(default = "default_enabled")]
13    pub enabled: bool,
14
15    /// Base directory for local prompt cache storage (supports `~` expansion)
16    #[serde(default = "default_cache_dir")]
17    pub cache_dir: String,
18
19    /// Maximum number of cached prompt entries to retain on disk
20    #[serde(default = "default_max_entries")]
21    pub max_entries: usize,
22
23    /// Maximum age (in days) before cached entries are purged
24    #[serde(default = "default_max_age_days")]
25    pub max_age_days: u64,
26
27    /// Automatically evict stale entries on startup/shutdown
28    #[serde(default = "default_auto_cleanup")]
29    pub enable_auto_cleanup: bool,
30
31    /// Minimum quality score required before persisting an entry
32    #[serde(default = "default_min_quality_threshold")]
33    pub min_quality_threshold: f64,
34
35    /// Enable prompt-shaping optimizations that improve provider-side cache locality.
36    /// When enabled, VT Code keeps volatile runtime context at the end of prompt text.
37    #[serde(default = "default_cache_friendly_prompt_shaping")]
38    pub cache_friendly_prompt_shaping: bool,
39
40    /// Provider specific overrides
41    #[serde(default)]
42    pub providers: ProviderPromptCachingConfig,
43}
44
45impl Default for PromptCachingConfig {
46    fn default() -> Self {
47        Self {
48            enabled: default_enabled(),
49            cache_dir: default_cache_dir(),
50            max_entries: default_max_entries(),
51            max_age_days: default_max_age_days(),
52            enable_auto_cleanup: default_auto_cleanup(),
53            min_quality_threshold: default_min_quality_threshold(),
54            cache_friendly_prompt_shaping: default_cache_friendly_prompt_shaping(),
55            providers: ProviderPromptCachingConfig::default(),
56        }
57    }
58}
59
60impl PromptCachingConfig {
61    /// Resolve the configured cache directory to an absolute path
62    ///
63    /// - `~` is expanded to the user's home directory when available
64    /// - Relative paths are resolved against the provided workspace root when supplied
65    /// - Falls back to the configured string when neither applies
66    pub fn resolve_cache_dir(&self, workspace_root: Option<&Path>) -> PathBuf {
67        resolve_path(&self.cache_dir, workspace_root)
68    }
69
70    /// Returns true when prompt caching is active for the given provider runtime name.
71    pub fn is_provider_enabled(&self, provider_name: &str) -> bool {
72        if !self.enabled {
73            return false;
74        }
75
76        match provider_name.to_ascii_lowercase().as_str() {
77            "openai" => self.providers.openai.enabled,
78            "anthropic" | "minimax" => self.providers.anthropic.enabled,
79            "gemini" => {
80                self.providers.gemini.enabled
81                    && !matches!(self.providers.gemini.mode, GeminiPromptCacheMode::Off)
82            }
83            "openrouter" => self.providers.openrouter.enabled,
84            "moonshot" => self.providers.moonshot.enabled,
85            "deepseek" => self.providers.deepseek.enabled,
86            "zai" => self.providers.zai.enabled,
87            _ => false,
88        }
89    }
90}
91
92/// Per-provider configuration overrides
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
94#[derive(Debug, Clone, Deserialize, Serialize, Default)]
95pub struct ProviderPromptCachingConfig {
96    #[serde(default = "OpenAIPromptCacheSettings::default")]
97    pub openai: OpenAIPromptCacheSettings,
98
99    #[serde(default = "AnthropicPromptCacheSettings::default")]
100    pub anthropic: AnthropicPromptCacheSettings,
101
102    #[serde(default = "GeminiPromptCacheSettings::default")]
103    pub gemini: GeminiPromptCacheSettings,
104
105    #[serde(default = "OpenRouterPromptCacheSettings::default")]
106    pub openrouter: OpenRouterPromptCacheSettings,
107
108    #[serde(default = "MoonshotPromptCacheSettings::default")]
109    pub moonshot: MoonshotPromptCacheSettings,
110
111    #[serde(default = "DeepSeekPromptCacheSettings::default")]
112    pub deepseek: DeepSeekPromptCacheSettings,
113
114    #[serde(default = "ZaiPromptCacheSettings::default")]
115    pub zai: ZaiPromptCacheSettings,
116}
117
118/// OpenAI prompt caching controls (automatic with metrics)
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120#[derive(Debug, Clone, Deserialize, Serialize)]
121pub struct OpenAIPromptCacheSettings {
122    #[serde(default = "default_true")]
123    pub enabled: bool,
124
125    #[serde(default = "default_openai_min_prefix_tokens")]
126    pub min_prefix_tokens: u32,
127
128    #[serde(default = "default_openai_idle_expiration")]
129    pub idle_expiration_seconds: u64,
130
131    #[serde(default = "default_true")]
132    pub surface_metrics: bool,
133
134    /// Strategy for generating OpenAI `prompt_cache_key`.
135    /// Session mode derives one stable key per VT Code conversation.
136    #[serde(default = "default_openai_prompt_cache_key_mode")]
137    pub prompt_cache_key_mode: OpenAIPromptCacheKeyMode,
138
139    /// Optional prompt cache retention string to pass directly into OpenAI Responses API.
140    /// Supported values are "in_memory" and "24h". If set, VT Code will include
141    /// `prompt_cache_retention` in the request body to extend the model-side prompt
142    /// caching window or request the explicit in-memory policy.
143    #[serde(default)]
144    pub prompt_cache_retention: Option<String>,
145}
146
147impl Default for OpenAIPromptCacheSettings {
148    fn default() -> Self {
149        Self {
150            enabled: default_true(),
151            min_prefix_tokens: default_openai_min_prefix_tokens(),
152            idle_expiration_seconds: default_openai_idle_expiration(),
153            surface_metrics: default_true(),
154            prompt_cache_key_mode: default_openai_prompt_cache_key_mode(),
155            prompt_cache_retention: None,
156        }
157    }
158}
159
160impl OpenAIPromptCacheSettings {
161    /// Validate OpenAI provider prompt cache settings. Returns Err if the retention value is invalid.
162    pub fn validate(&self) -> anyhow::Result<()> {
163        if let Some(ref retention) = self.prompt_cache_retention {
164            validate_openai_retention_policy(retention)
165                .with_context(|| format!("Invalid prompt_cache_retention: {}", retention))?;
166        }
167        Ok(())
168    }
169}
170
171/// Build a stable OpenAI `prompt_cache_key` for requests that should share
172/// provider-side cache routing.
173#[must_use]
174pub fn build_openai_prompt_cache_key(
175    prompt_cache_enabled: bool,
176    prompt_cache_key_mode: &OpenAIPromptCacheKeyMode,
177    lineage_id: Option<&str>,
178) -> Option<String> {
179    if !prompt_cache_enabled {
180        return None;
181    }
182
183    let lineage_id = lineage_id.map(str::trim).filter(|value| !value.is_empty());
184    match prompt_cache_key_mode {
185        OpenAIPromptCacheKeyMode::Session => {
186            lineage_id.map(|lineage_id| format!("vtcode:openai:{lineage_id}"))
187        }
188        OpenAIPromptCacheKeyMode::Off => None,
189    }
190}
191
192/// OpenAI prompt cache key derivation mode.
193#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
194#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
195#[serde(rename_all = "snake_case")]
196pub enum OpenAIPromptCacheKeyMode {
197    /// Do not send `prompt_cache_key` in OpenAI requests.
198    Off,
199    /// Send one stable `prompt_cache_key` per VT Code session.
200    #[default]
201    Session,
202}
203
204/// Anthropic Claude cache control settings
205#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
206#[derive(Debug, Clone, Deserialize, Serialize)]
207pub struct AnthropicPromptCacheSettings {
208    #[serde(default = "default_true")]
209    pub enabled: bool,
210
211    /// Default TTL in seconds for the first cache breakpoint (tools/system).
212    /// Anthropic only supports "5m" (300s) or "1h" (3600s) TTL formats.
213    /// Set to >= 3600 for 1-hour cache on tools and system prompts.
214    /// Default: 3600 (1 hour) - recommended for stable tool definitions
215    #[serde(default = "default_anthropic_tools_ttl")]
216    pub tools_ttl_seconds: u64,
217
218    /// TTL for subsequent cache breakpoints (messages).
219    /// Set to >= 3600 for 1-hour cache on messages.
220    /// Default: 300 (5 minutes) - recommended for frequently changing messages
221    #[serde(default = "default_anthropic_messages_ttl")]
222    pub messages_ttl_seconds: u64,
223
224    /// Maximum number of cache breakpoints to use (max 4 per Anthropic spec).
225    /// Default: 4
226    #[serde(default = "default_anthropic_max_breakpoints")]
227    pub max_breakpoints: u8,
228
229    /// Apply cache control to system prompts by default
230    #[serde(default = "default_true")]
231    pub cache_system_messages: bool,
232
233    /// Apply cache control to user messages exceeding threshold
234    #[serde(default = "default_true")]
235    pub cache_user_messages: bool,
236
237    /// Apply cache control to tool definitions by default
238    /// Default: true (tools are typically stable and benefit from longer caching)
239    #[serde(default = "default_true")]
240    pub cache_tool_definitions: bool,
241
242    /// Minimum message length (in characters) before applying cache control
243    /// to avoid caching very short messages that don't benefit from caching.
244    /// Default: 256 characters (~64 tokens)
245    #[serde(default = "default_min_message_length")]
246    pub min_message_length_for_cache: usize,
247
248    /// Extended TTL for Anthropic prompt caching (in seconds)
249    /// Set to >= 3600 for 1-hour cache on messages
250    #[serde(default = "default_anthropic_extended_ttl")]
251    pub extended_ttl_seconds: Option<u64>,
252}
253
254impl Default for AnthropicPromptCacheSettings {
255    fn default() -> Self {
256        Self {
257            enabled: default_true(),
258            tools_ttl_seconds: default_anthropic_tools_ttl(),
259            messages_ttl_seconds: default_anthropic_messages_ttl(),
260            max_breakpoints: default_anthropic_max_breakpoints(),
261            cache_system_messages: default_true(),
262            cache_user_messages: default_true(),
263            cache_tool_definitions: default_true(),
264            min_message_length_for_cache: default_min_message_length(),
265            extended_ttl_seconds: default_anthropic_extended_ttl(),
266        }
267    }
268}
269
270/// Gemini API caching preferences
271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
272#[derive(Debug, Clone, Deserialize, Serialize)]
273pub struct GeminiPromptCacheSettings {
274    #[serde(default = "default_true")]
275    pub enabled: bool,
276
277    #[serde(default = "default_gemini_mode")]
278    pub mode: GeminiPromptCacheMode,
279
280    #[serde(default = "default_gemini_min_prefix_tokens")]
281    pub min_prefix_tokens: u32,
282
283    /// TTL for explicit caches (ignored in implicit mode)
284    #[serde(default = "default_gemini_explicit_ttl")]
285    pub explicit_ttl_seconds: Option<u64>,
286}
287
288impl Default for GeminiPromptCacheSettings {
289    fn default() -> Self {
290        Self {
291            enabled: default_true(),
292            mode: GeminiPromptCacheMode::default(),
293            min_prefix_tokens: default_gemini_min_prefix_tokens(),
294            explicit_ttl_seconds: default_gemini_explicit_ttl(),
295        }
296    }
297}
298
299/// Gemini prompt caching mode selection
300#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
301#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
302#[serde(rename_all = "snake_case")]
303#[derive(Default)]
304pub enum GeminiPromptCacheMode {
305    #[default]
306    Implicit,
307    Explicit,
308    Off,
309}
310
311/// OpenRouter passthrough caching controls
312#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
313#[derive(Debug, Clone, Deserialize, Serialize)]
314pub struct OpenRouterPromptCacheSettings {
315    #[serde(default = "default_true")]
316    pub enabled: bool,
317
318    /// Propagate provider cache instructions automatically
319    #[serde(default = "default_true")]
320    pub propagate_provider_capabilities: bool,
321
322    /// Surface cache savings reported by OpenRouter
323    #[serde(default = "default_true")]
324    pub report_savings: bool,
325}
326
327impl Default for OpenRouterPromptCacheSettings {
328    fn default() -> Self {
329        Self {
330            enabled: default_true(),
331            propagate_provider_capabilities: default_true(),
332            report_savings: default_true(),
333        }
334    }
335}
336
337/// Moonshot prompt caching configuration (leverages server-side reuse)
338#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
339#[derive(Debug, Clone, Deserialize, Serialize)]
340pub struct MoonshotPromptCacheSettings {
341    #[serde(default = "default_moonshot_enabled")]
342    pub enabled: bool,
343}
344
345impl Default for MoonshotPromptCacheSettings {
346    fn default() -> Self {
347        Self {
348            enabled: default_moonshot_enabled(),
349        }
350    }
351}
352
353/// DeepSeek prompt caching configuration (automatic KV cache reuse)
354#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
355#[derive(Debug, Clone, Deserialize, Serialize)]
356pub struct DeepSeekPromptCacheSettings {
357    #[serde(default = "default_true")]
358    pub enabled: bool,
359
360    /// Emit cache hit/miss metrics from responses when available
361    #[serde(default = "default_true")]
362    pub surface_metrics: bool,
363}
364
365impl Default for DeepSeekPromptCacheSettings {
366    fn default() -> Self {
367        Self {
368            enabled: default_true(),
369            surface_metrics: default_true(),
370        }
371    }
372}
373
374/// Z.AI prompt caching configuration (disabled until platform exposes metrics)
375#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
376#[derive(Debug, Clone, Deserialize, Serialize)]
377pub struct ZaiPromptCacheSettings {
378    #[serde(default = "default_zai_enabled")]
379    pub enabled: bool,
380}
381
382impl Default for ZaiPromptCacheSettings {
383    fn default() -> Self {
384        Self {
385            enabled: default_zai_enabled(),
386        }
387    }
388}
389
390fn default_enabled() -> bool {
391    prompt_cache::DEFAULT_ENABLED
392}
393
394fn default_cache_dir() -> String {
395    format!("~/{path}", path = prompt_cache::DEFAULT_CACHE_DIR)
396}
397
398fn default_max_entries() -> usize {
399    prompt_cache::DEFAULT_MAX_ENTRIES
400}
401
402fn default_max_age_days() -> u64 {
403    prompt_cache::DEFAULT_MAX_AGE_DAYS
404}
405
406fn default_auto_cleanup() -> bool {
407    prompt_cache::DEFAULT_AUTO_CLEANUP
408}
409
410fn default_min_quality_threshold() -> f64 {
411    prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD
412}
413
414fn default_cache_friendly_prompt_shaping() -> bool {
415    prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING
416}
417
418fn default_openai_min_prefix_tokens() -> u32 {
419    prompt_cache::OPENAI_MIN_PREFIX_TOKENS
420}
421
422fn default_openai_idle_expiration() -> u64 {
423    prompt_cache::OPENAI_IDLE_EXPIRATION_SECONDS
424}
425
426fn default_openai_prompt_cache_key_mode() -> OpenAIPromptCacheKeyMode {
427    OpenAIPromptCacheKeyMode::Session
428}
429
430#[allow(dead_code)]
431fn default_anthropic_default_ttl() -> u64 {
432    prompt_cache::ANTHROPIC_DEFAULT_TTL_SECONDS
433}
434
435#[allow(dead_code)]
436fn default_anthropic_extended_ttl() -> Option<u64> {
437    Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
438}
439
440fn default_anthropic_tools_ttl() -> u64 {
441    prompt_cache::ANTHROPIC_TOOLS_TTL_SECONDS
442}
443
444fn default_anthropic_messages_ttl() -> u64 {
445    prompt_cache::ANTHROPIC_MESSAGES_TTL_SECONDS
446}
447
448fn default_anthropic_max_breakpoints() -> u8 {
449    prompt_cache::ANTHROPIC_MAX_BREAKPOINTS
450}
451
452#[allow(dead_code)]
453fn default_min_message_length() -> usize {
454    prompt_cache::ANTHROPIC_MIN_MESSAGE_LENGTH_FOR_CACHE
455}
456
457fn default_gemini_min_prefix_tokens() -> u32 {
458    prompt_cache::GEMINI_MIN_PREFIX_TOKENS
459}
460
461fn default_gemini_explicit_ttl() -> Option<u64> {
462    Some(prompt_cache::GEMINI_EXPLICIT_DEFAULT_TTL_SECONDS)
463}
464
465fn default_gemini_mode() -> GeminiPromptCacheMode {
466    GeminiPromptCacheMode::Implicit
467}
468
469fn default_zai_enabled() -> bool {
470    prompt_cache::ZAI_CACHE_ENABLED
471}
472
473fn default_moonshot_enabled() -> bool {
474    prompt_cache::MOONSHOT_CACHE_ENABLED
475}
476
477fn resolve_path(input: &str, workspace_root: Option<&Path>) -> PathBuf {
478    let trimmed = input.trim();
479    if trimmed.is_empty() {
480        return resolve_default_cache_dir();
481    }
482
483    if let Some(stripped) = trimmed
484        .strip_prefix("~/")
485        .or_else(|| trimmed.strip_prefix("~\\"))
486    {
487        if let Some(home) = dirs::home_dir() {
488            return home.join(stripped);
489        }
490        return PathBuf::from(stripped);
491    }
492
493    let candidate = Path::new(trimmed);
494    if candidate.is_absolute() {
495        return candidate.to_path_buf();
496    }
497
498    if let Some(root) = workspace_root {
499        return root.join(candidate);
500    }
501
502    candidate.to_path_buf()
503}
504
505fn resolve_default_cache_dir() -> PathBuf {
506    if let Some(home) = dirs::home_dir() {
507        return home.join(prompt_cache::DEFAULT_CACHE_DIR);
508    }
509    PathBuf::from(prompt_cache::DEFAULT_CACHE_DIR)
510}
511
512/// Validate the OpenAI Responses API prompt cache retention policy.
513/// The public API currently accepts only `in_memory` and `24h`.
514fn validate_openai_retention_policy(input: &str) -> anyhow::Result<()> {
515    let input = input.trim();
516    if input.is_empty() {
517        anyhow::bail!("Empty retention string");
518    }
519
520    if matches!(input, "in_memory" | "24h") {
521        return Ok(());
522    }
523
524    anyhow::bail!("prompt_cache_retention must be one of: in_memory, 24h");
525}
526
527impl PromptCachingConfig {
528    /// Validate prompt cache config and provider overrides
529    pub fn validate(&self) -> anyhow::Result<()> {
530        // Validate OpenAI provider settings
531        self.providers.openai.validate()?;
532        Ok(())
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use assert_fs::TempDir;
540    use std::fs;
541
542    #[test]
543    fn prompt_caching_defaults_align_with_constants() {
544        let cfg = PromptCachingConfig::default();
545        assert!(cfg.enabled);
546        assert_eq!(cfg.max_entries, prompt_cache::DEFAULT_MAX_ENTRIES);
547        assert_eq!(cfg.max_age_days, prompt_cache::DEFAULT_MAX_AGE_DAYS);
548        assert!(
549            (cfg.min_quality_threshold - prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD).abs()
550                < f64::EPSILON
551        );
552        assert_eq!(
553            cfg.cache_friendly_prompt_shaping,
554            prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING
555        );
556        assert!(cfg.providers.openai.enabled);
557        assert_eq!(
558            cfg.providers.openai.min_prefix_tokens,
559            prompt_cache::OPENAI_MIN_PREFIX_TOKENS
560        );
561        assert_eq!(
562            cfg.providers.openai.prompt_cache_key_mode,
563            OpenAIPromptCacheKeyMode::Session
564        );
565        assert_eq!(
566            cfg.providers.anthropic.extended_ttl_seconds,
567            Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
568        );
569        assert_eq!(cfg.providers.gemini.mode, GeminiPromptCacheMode::Implicit);
570        assert!(cfg.providers.moonshot.enabled);
571        assert_eq!(cfg.providers.openai.prompt_cache_retention, None);
572    }
573
574    #[test]
575    fn resolve_cache_dir_expands_home() {
576        let cfg = PromptCachingConfig {
577            cache_dir: "~/.custom/cache".to_string(),
578            ..PromptCachingConfig::default()
579        };
580        let resolved = cfg.resolve_cache_dir(None);
581        if let Some(home) = dirs::home_dir() {
582            assert!(resolved.starts_with(home));
583        } else {
584            assert_eq!(resolved, PathBuf::from(".custom/cache"));
585        }
586    }
587
588    #[test]
589    fn resolve_cache_dir_uses_workspace_when_relative() {
590        let temp = TempDir::new().unwrap();
591        let workspace = temp.path();
592        let cfg = PromptCachingConfig {
593            cache_dir: "relative/cache".to_string(),
594            ..PromptCachingConfig::default()
595        };
596        let resolved = cfg.resolve_cache_dir(Some(workspace));
597        assert_eq!(resolved, workspace.join("relative/cache"));
598    }
599
600    #[test]
601    fn validate_openai_retention_policy_valid_and_invalid() {
602        assert!(validate_openai_retention_policy("24h").is_ok());
603        assert!(validate_openai_retention_policy("in_memory").is_ok());
604        assert!(validate_openai_retention_policy("5m").is_err());
605        assert!(validate_openai_retention_policy("1d").is_err());
606        assert!(validate_openai_retention_policy("abc").is_err());
607        assert!(validate_openai_retention_policy("").is_err());
608    }
609
610    #[test]
611    fn validate_prompt_cache_rejects_invalid_retention() {
612        let mut cfg = PromptCachingConfig::default();
613        cfg.providers.openai.prompt_cache_retention = Some("invalid".to_string());
614        assert!(cfg.validate().is_err());
615    }
616
617    #[test]
618    fn prompt_cache_key_mode_parses_from_toml() {
619        let parsed: PromptCachingConfig = toml::from_str(
620            r#"
621[providers.openai]
622prompt_cache_key_mode = "off"
623"#,
624        )
625        .expect("prompt cache config should parse");
626
627        assert_eq!(
628            parsed.providers.openai.prompt_cache_key_mode,
629            OpenAIPromptCacheKeyMode::Off
630        );
631    }
632
633    #[test]
634    fn build_openai_prompt_cache_key_uses_trimmed_lineage_id() {
635        let key = build_openai_prompt_cache_key(
636            true,
637            &OpenAIPromptCacheKeyMode::Session,
638            Some(" lineage-abc "),
639        );
640
641        assert_eq!(key.as_deref(), Some("vtcode:openai:lineage-abc"));
642    }
643
644    #[test]
645    fn build_openai_prompt_cache_key_honors_disabled_or_off_mode() {
646        assert_eq!(
647            build_openai_prompt_cache_key(false, &OpenAIPromptCacheKeyMode::Session, Some("id")),
648            None
649        );
650        assert_eq!(
651            build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Off, Some("id")),
652            None
653        );
654        assert_eq!(
655            build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Session, Some("  ")),
656            None
657        );
658    }
659
660    #[test]
661    fn provider_enablement_respects_global_and_provider_flags() {
662        let mut cfg = PromptCachingConfig {
663            enabled: true,
664            ..PromptCachingConfig::default()
665        };
666        cfg.providers.openai.enabled = true;
667        assert!(cfg.is_provider_enabled("openai"));
668
669        cfg.enabled = false;
670        assert!(!cfg.is_provider_enabled("openai"));
671    }
672
673    #[test]
674    fn provider_enablement_handles_aliases_and_modes() {
675        let mut cfg = PromptCachingConfig {
676            enabled: true,
677            ..PromptCachingConfig::default()
678        };
679
680        cfg.providers.anthropic.enabled = true;
681        assert!(cfg.is_provider_enabled("minimax"));
682
683        cfg.providers.gemini.enabled = true;
684        cfg.providers.gemini.mode = GeminiPromptCacheMode::Off;
685        assert!(!cfg.is_provider_enabled("gemini"));
686    }
687
688    #[test]
689    fn bundled_config_templates_match_prompt_cache_defaults() {
690        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
691        let loader_source = fs::read_to_string(manifest_dir.join("src/loader/config.rs"))
692            .expect("loader config source");
693        assert!(loader_source.contains("[prompt_cache]"));
694        assert!(loader_source.contains("enabled = true"));
695        assert!(loader_source.contains("cache_friendly_prompt_shaping = true"));
696        assert!(loader_source.contains("# prompt_cache_retention = \"24h\""));
697
698        let workspace_root = manifest_dir.parent().expect("workspace root").to_path_buf();
699        let example_config = fs::read_to_string(workspace_root.join("vtcode.toml.example"))
700            .expect("vtcode.toml.example");
701        if example_config.contains("[prompt_cache]") {
702            assert!(example_config.contains("enabled = true"));
703            assert!(example_config.contains("cache_friendly_prompt_shaping = true"));
704            assert!(example_config.contains("# prompt_cache_retention = \"24h\""));
705        }
706
707        let prompt_cache_guide =
708            fs::read_to_string(workspace_root.join("docs/tools/PROMPT_CACHING_GUIDE.md"))
709                .expect("prompt caching guide");
710        assert!(prompt_cache_guide.contains(
711            "VT Code enables `prompt_cache.cache_friendly_prompt_shaping = true` by default."
712        ));
713        assert!(prompt_cache_guide.contains(
714            "Default: `None` (opt-in) - VT Code does not set prompt_cache_retention by default;"
715        ));
716
717        let field_reference =
718            fs::read_to_string(workspace_root.join("docs/config/CONFIG_FIELD_REFERENCE.md"))
719                .expect("config field reference");
720        assert!(field_reference.contains("`prompt_cache.cache_friendly_prompt_shaping`"));
721        assert!(field_reference.contains("`prompt_cache.providers.openai.prompt_cache_retention`"));
722    }
723}