Skip to main content

vtcode_config/core/
prompt_cache.rs

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