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#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
9#[derive(Debug, Clone, Deserialize, Serialize)]
10pub struct PromptCachingConfig {
11 #[serde(default = "default_enabled")]
13 pub enabled: bool,
14
15 #[serde(default = "default_cache_dir")]
17 pub cache_dir: String,
18
19 #[serde(default = "default_max_entries")]
21 pub max_entries: usize,
22
23 #[serde(default = "default_max_age_days")]
25 pub max_age_days: u64,
26
27 #[serde(default = "default_auto_cleanup")]
29 pub enable_auto_cleanup: bool,
30
31 #[serde(default = "default_min_quality_threshold")]
33 pub min_quality_threshold: f64,
34
35 #[serde(default = "default_cache_friendly_prompt_shaping")]
38 pub cache_friendly_prompt_shaping: bool,
39
40 #[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 pub fn resolve_cache_dir(&self, workspace_root: Option<&Path>) -> PathBuf {
67 resolve_path(&self.cache_dir, workspace_root)
68 }
69
70 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#[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#[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 #[serde(default = "default_openai_prompt_cache_key_mode")]
137 pub prompt_cache_key_mode: OpenAIPromptCacheKeyMode,
138
139 #[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 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#[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#[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 Off,
199 #[default]
201 Session,
202}
203
204#[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 #[serde(default = "default_anthropic_tools_ttl")]
216 pub tools_ttl_seconds: u64,
217
218 #[serde(default = "default_anthropic_messages_ttl")]
222 pub messages_ttl_seconds: u64,
223
224 #[serde(default = "default_anthropic_max_breakpoints")]
227 pub max_breakpoints: u8,
228
229 #[serde(default = "default_true")]
231 pub cache_system_messages: bool,
232
233 #[serde(default = "default_true")]
235 pub cache_user_messages: bool,
236
237 #[serde(default = "default_true")]
240 pub cache_tool_definitions: bool,
241
242 #[serde(default = "default_min_message_length")]
246 pub min_message_length_for_cache: usize,
247
248 #[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#[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 #[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#[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#[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 #[serde(default = "default_true")]
320 pub propagate_provider_capabilities: bool,
321
322 #[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#[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#[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 #[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#[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
512fn 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 pub fn validate(&self) -> anyhow::Result<()> {
530 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}