1use crate::constants::prompt_cache;
2use anyhow::Context;
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10#[derive(Debug, Clone, Deserialize, Serialize)]
11pub struct PromptCachingConfig {
12 #[serde(default = "default_enabled")]
14 pub enabled: bool,
15
16 #[serde(default = "default_cache_dir")]
18 pub cache_dir: String,
19
20 #[serde(default = "default_max_entries")]
22 pub max_entries: usize,
23
24 #[serde(default = "default_max_age_days")]
26 pub max_age_days: u64,
27
28 #[serde(default = "default_auto_cleanup")]
30 pub enable_auto_cleanup: bool,
31
32 #[serde(default = "default_min_quality_threshold")]
34 pub min_quality_threshold: f64,
35
36 #[serde(default = "default_cache_friendly_prompt_shaping")]
39 pub cache_friendly_prompt_shaping: bool,
40
41 #[serde(default)]
43 pub providers: ProviderPromptCachingConfig,
44}
45
46impl Default for PromptCachingConfig {
47 fn default() -> Self {
48 Self {
49 enabled: default_enabled(),
50 cache_dir: default_cache_dir(),
51 max_entries: default_max_entries(),
52 max_age_days: default_max_age_days(),
53 enable_auto_cleanup: default_auto_cleanup(),
54 min_quality_threshold: default_min_quality_threshold(),
55 cache_friendly_prompt_shaping: default_cache_friendly_prompt_shaping(),
56 providers: ProviderPromptCachingConfig::default(),
57 }
58 }
59}
60
61impl PromptCachingConfig {
62 pub fn resolve_cache_dir(&self, workspace_root: Option<&Path>) -> PathBuf {
68 resolve_path(&self.cache_dir, workspace_root)
69 }
70
71 pub fn is_provider_enabled(&self, provider_name: &str) -> bool {
73 if !self.enabled {
74 return false;
75 }
76
77 match provider_name.to_ascii_lowercase().as_str() {
78 "openai" => self.providers.openai.enabled,
79 "anthropic" | "minimax" => self.providers.anthropic.enabled,
80 "gemini" => {
81 self.providers.gemini.enabled
82 && !matches!(self.providers.gemini.mode, GeminiPromptCacheMode::Off)
83 }
84 "openrouter" => self.providers.openrouter.enabled,
85 "moonshot" => self.providers.moonshot.enabled,
86 "deepseek" => self.providers.deepseek.enabled,
87 "zai" => self.providers.zai.enabled,
88 _ => false,
89 }
90 }
91}
92
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
95#[derive(Debug, Clone, Deserialize, Serialize, Default)]
96pub struct ProviderPromptCachingConfig {
97 #[serde(default = "OpenAIPromptCacheSettings::default")]
98 pub openai: OpenAIPromptCacheSettings,
99
100 #[serde(default = "AnthropicPromptCacheSettings::default")]
101 pub anthropic: AnthropicPromptCacheSettings,
102
103 #[serde(default = "GeminiPromptCacheSettings::default")]
104 pub gemini: GeminiPromptCacheSettings,
105
106 #[serde(default = "OpenRouterPromptCacheSettings::default")]
107 pub openrouter: OpenRouterPromptCacheSettings,
108
109 #[serde(default = "MoonshotPromptCacheSettings::default")]
110 pub moonshot: MoonshotPromptCacheSettings,
111
112 #[serde(default = "DeepSeekPromptCacheSettings::default")]
113 pub deepseek: DeepSeekPromptCacheSettings,
114
115 #[serde(default = "ZaiPromptCacheSettings::default")]
116 pub zai: ZaiPromptCacheSettings,
117}
118
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121#[derive(Debug, Clone, Deserialize, Serialize)]
122pub struct OpenAIPromptCacheSettings {
123 #[serde(default = "default_true")]
124 pub enabled: bool,
125
126 #[serde(default = "default_openai_min_prefix_tokens")]
127 pub min_prefix_tokens: u32,
128
129 #[serde(default = "default_openai_idle_expiration")]
130 pub idle_expiration_seconds: u64,
131
132 #[serde(default = "default_true")]
133 pub surface_metrics: bool,
134
135 #[serde(default = "default_openai_prompt_cache_key_mode")]
138 pub prompt_cache_key_mode: OpenAIPromptCacheKeyMode,
139
140 #[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 parse_retention_duration(retention)
165 .with_context(|| format!("Invalid prompt_cache_retention: {}", retention))?;
166 }
167 Ok(())
168 }
169}
170
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
173#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
174#[serde(rename_all = "snake_case")]
175pub enum OpenAIPromptCacheKeyMode {
176 Off,
178 #[default]
180 Session,
181}
182
183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
185#[derive(Debug, Clone, Deserialize, Serialize)]
186pub struct AnthropicPromptCacheSettings {
187 #[serde(default = "default_true")]
188 pub enabled: bool,
189
190 #[serde(default = "default_anthropic_tools_ttl")]
195 pub tools_ttl_seconds: u64,
196
197 #[serde(default = "default_anthropic_messages_ttl")]
201 pub messages_ttl_seconds: u64,
202
203 #[serde(default = "default_anthropic_max_breakpoints")]
206 pub max_breakpoints: u8,
207
208 #[serde(default = "default_true")]
210 pub cache_system_messages: bool,
211
212 #[serde(default = "default_true")]
214 pub cache_user_messages: bool,
215
216 #[serde(default = "default_true")]
219 pub cache_tool_definitions: bool,
220
221 #[serde(default = "default_min_message_length")]
225 pub min_message_length_for_cache: usize,
226
227 #[serde(default = "default_anthropic_extended_ttl")]
230 pub extended_ttl_seconds: Option<u64>,
231}
232
233impl Default for AnthropicPromptCacheSettings {
234 fn default() -> Self {
235 Self {
236 enabled: default_true(),
237 tools_ttl_seconds: default_anthropic_tools_ttl(),
238 messages_ttl_seconds: default_anthropic_messages_ttl(),
239 max_breakpoints: default_anthropic_max_breakpoints(),
240 cache_system_messages: default_true(),
241 cache_user_messages: default_true(),
242 cache_tool_definitions: default_true(),
243 min_message_length_for_cache: default_min_message_length(),
244 extended_ttl_seconds: default_anthropic_extended_ttl(),
245 }
246 }
247}
248
249#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
251#[derive(Debug, Clone, Deserialize, Serialize)]
252pub struct GeminiPromptCacheSettings {
253 #[serde(default = "default_true")]
254 pub enabled: bool,
255
256 #[serde(default = "default_gemini_mode")]
257 pub mode: GeminiPromptCacheMode,
258
259 #[serde(default = "default_gemini_min_prefix_tokens")]
260 pub min_prefix_tokens: u32,
261
262 #[serde(default = "default_gemini_explicit_ttl")]
264 pub explicit_ttl_seconds: Option<u64>,
265}
266
267impl Default for GeminiPromptCacheSettings {
268 fn default() -> Self {
269 Self {
270 enabled: default_true(),
271 mode: GeminiPromptCacheMode::default(),
272 min_prefix_tokens: default_gemini_min_prefix_tokens(),
273 explicit_ttl_seconds: default_gemini_explicit_ttl(),
274 }
275 }
276}
277
278#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
280#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
281#[serde(rename_all = "snake_case")]
282#[derive(Default)]
283pub enum GeminiPromptCacheMode {
284 #[default]
285 Implicit,
286 Explicit,
287 Off,
288}
289
290#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
292#[derive(Debug, Clone, Deserialize, Serialize)]
293pub struct OpenRouterPromptCacheSettings {
294 #[serde(default = "default_true")]
295 pub enabled: bool,
296
297 #[serde(default = "default_true")]
299 pub propagate_provider_capabilities: bool,
300
301 #[serde(default = "default_true")]
303 pub report_savings: bool,
304}
305
306impl Default for OpenRouterPromptCacheSettings {
307 fn default() -> Self {
308 Self {
309 enabled: default_true(),
310 propagate_provider_capabilities: default_true(),
311 report_savings: default_true(),
312 }
313 }
314}
315
316#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
318#[derive(Debug, Clone, Deserialize, Serialize)]
319pub struct MoonshotPromptCacheSettings {
320 #[serde(default = "default_moonshot_enabled")]
321 pub enabled: bool,
322}
323
324impl Default for MoonshotPromptCacheSettings {
325 fn default() -> Self {
326 Self {
327 enabled: default_moonshot_enabled(),
328 }
329 }
330}
331
332#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
334#[derive(Debug, Clone, Deserialize, Serialize)]
335pub struct DeepSeekPromptCacheSettings {
336 #[serde(default = "default_true")]
337 pub enabled: bool,
338
339 #[serde(default = "default_true")]
341 pub surface_metrics: bool,
342}
343
344impl Default for DeepSeekPromptCacheSettings {
345 fn default() -> Self {
346 Self {
347 enabled: default_true(),
348 surface_metrics: default_true(),
349 }
350 }
351}
352
353#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
355#[derive(Debug, Clone, Deserialize, Serialize)]
356pub struct ZaiPromptCacheSettings {
357 #[serde(default = "default_zai_enabled")]
358 pub enabled: bool,
359}
360
361impl Default for ZaiPromptCacheSettings {
362 fn default() -> Self {
363 Self {
364 enabled: default_zai_enabled(),
365 }
366 }
367}
368
369fn default_enabled() -> bool {
370 prompt_cache::DEFAULT_ENABLED
371}
372
373fn default_cache_dir() -> String {
374 format!("~/{path}", path = prompt_cache::DEFAULT_CACHE_DIR)
375}
376
377fn default_max_entries() -> usize {
378 prompt_cache::DEFAULT_MAX_ENTRIES
379}
380
381fn default_max_age_days() -> u64 {
382 prompt_cache::DEFAULT_MAX_AGE_DAYS
383}
384
385fn default_auto_cleanup() -> bool {
386 prompt_cache::DEFAULT_AUTO_CLEANUP
387}
388
389fn default_min_quality_threshold() -> f64 {
390 prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD
391}
392
393fn default_cache_friendly_prompt_shaping() -> bool {
394 prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING
395}
396
397fn default_true() -> bool {
398 true
399}
400
401fn default_openai_min_prefix_tokens() -> u32 {
402 prompt_cache::OPENAI_MIN_PREFIX_TOKENS
403}
404
405fn default_openai_idle_expiration() -> u64 {
406 prompt_cache::OPENAI_IDLE_EXPIRATION_SECONDS
407}
408
409fn default_openai_prompt_cache_key_mode() -> OpenAIPromptCacheKeyMode {
410 OpenAIPromptCacheKeyMode::Session
411}
412
413#[allow(dead_code)]
414fn default_anthropic_default_ttl() -> u64 {
415 prompt_cache::ANTHROPIC_DEFAULT_TTL_SECONDS
416}
417
418#[allow(dead_code)]
419fn default_anthropic_extended_ttl() -> Option<u64> {
420 Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
421}
422
423fn default_anthropic_tools_ttl() -> u64 {
424 prompt_cache::ANTHROPIC_TOOLS_TTL_SECONDS
425}
426
427fn default_anthropic_messages_ttl() -> u64 {
428 prompt_cache::ANTHROPIC_MESSAGES_TTL_SECONDS
429}
430
431fn default_anthropic_max_breakpoints() -> u8 {
432 prompt_cache::ANTHROPIC_MAX_BREAKPOINTS
433}
434
435#[allow(dead_code)]
436fn default_min_message_length() -> usize {
437 prompt_cache::ANTHROPIC_MIN_MESSAGE_LENGTH_FOR_CACHE
438}
439
440fn default_gemini_min_prefix_tokens() -> u32 {
441 prompt_cache::GEMINI_MIN_PREFIX_TOKENS
442}
443
444fn default_gemini_explicit_ttl() -> Option<u64> {
445 Some(prompt_cache::GEMINI_EXPLICIT_DEFAULT_TTL_SECONDS)
446}
447
448fn default_gemini_mode() -> GeminiPromptCacheMode {
449 GeminiPromptCacheMode::Implicit
450}
451
452fn default_zai_enabled() -> bool {
453 prompt_cache::ZAI_CACHE_ENABLED
454}
455
456fn default_moonshot_enabled() -> bool {
457 prompt_cache::MOONSHOT_CACHE_ENABLED
458}
459
460fn resolve_path(input: &str, workspace_root: Option<&Path>) -> PathBuf {
461 let trimmed = input.trim();
462 if trimmed.is_empty() {
463 return resolve_default_cache_dir();
464 }
465
466 if let Some(stripped) = trimmed
467 .strip_prefix("~/")
468 .or_else(|| trimmed.strip_prefix("~\\"))
469 {
470 if let Some(home) = dirs::home_dir() {
471 return home.join(stripped);
472 }
473 return PathBuf::from(stripped);
474 }
475
476 let candidate = Path::new(trimmed);
477 if candidate.is_absolute() {
478 return candidate.to_path_buf();
479 }
480
481 if let Some(root) = workspace_root {
482 return root.join(candidate);
483 }
484
485 candidate.to_path_buf()
486}
487
488fn resolve_default_cache_dir() -> PathBuf {
489 if let Some(home) = dirs::home_dir() {
490 return home.join(prompt_cache::DEFAULT_CACHE_DIR);
491 }
492 PathBuf::from(prompt_cache::DEFAULT_CACHE_DIR)
493}
494
495fn parse_retention_duration(input: &str) -> anyhow::Result<Duration> {
498 let input = input.trim();
499 if input.is_empty() {
500 anyhow::bail!("Empty retention string");
501 }
502
503 let re =
505 Regex::new(r"^(\d+)([smhdSMHD])$").context("Failed to compile retention format regex")?;
506 let caps = re
507 .captures(input)
508 .ok_or_else(|| anyhow::anyhow!("Invalid retention format; use <number>[s|m|h|d]"))?;
509
510 let value_str = caps
511 .get(1)
512 .map(|m| m.as_str())
513 .ok_or_else(|| anyhow::anyhow!("Retention value capture missing"))?;
514 let unit = caps
515 .get(2)
516 .map(|m| m.as_str())
517 .ok_or_else(|| anyhow::anyhow!("Retention unit capture missing"))?
518 .chars()
519 .next()
520 .ok_or_else(|| anyhow::anyhow!("Retention unit is empty"))?
521 .to_ascii_lowercase();
522 let value: u64 = value_str
523 .parse()
524 .with_context(|| format!("Invalid numeric value in retention: {}", value_str))?;
525
526 let seconds = match unit {
527 's' => value,
528 'm' => value * 60,
529 'h' => value * 60 * 60,
530 'd' => value * 24 * 60 * 60,
531 _ => anyhow::bail!("Invalid retention unit; expected s,m,h,d"),
532 };
533
534 const MIN_SECONDS: u64 = 1;
536 const MAX_SECONDS: u64 = 30 * 24 * 60 * 60; if !((MIN_SECONDS..=MAX_SECONDS).contains(&seconds)) {
538 anyhow::bail!("prompt_cache_retention must be between 1s and 30d");
539 }
540
541 Ok(Duration::from_secs(seconds))
542}
543
544impl PromptCachingConfig {
545 pub fn validate(&self) -> anyhow::Result<()> {
547 self.providers.openai.validate()?;
549 Ok(())
550 }
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use assert_fs::TempDir;
557 use std::fs;
558
559 #[test]
560 fn prompt_caching_defaults_align_with_constants() {
561 let cfg = PromptCachingConfig::default();
562 assert!(cfg.enabled);
563 assert_eq!(cfg.max_entries, prompt_cache::DEFAULT_MAX_ENTRIES);
564 assert_eq!(cfg.max_age_days, prompt_cache::DEFAULT_MAX_AGE_DAYS);
565 assert!(
566 (cfg.min_quality_threshold - prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD).abs()
567 < f64::EPSILON
568 );
569 assert_eq!(
570 cfg.cache_friendly_prompt_shaping,
571 prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING
572 );
573 assert!(cfg.providers.openai.enabled);
574 assert_eq!(
575 cfg.providers.openai.min_prefix_tokens,
576 prompt_cache::OPENAI_MIN_PREFIX_TOKENS
577 );
578 assert_eq!(
579 cfg.providers.openai.prompt_cache_key_mode,
580 OpenAIPromptCacheKeyMode::Session
581 );
582 assert_eq!(
583 cfg.providers.anthropic.extended_ttl_seconds,
584 Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
585 );
586 assert_eq!(cfg.providers.gemini.mode, GeminiPromptCacheMode::Implicit);
587 assert!(cfg.providers.moonshot.enabled);
588 assert_eq!(cfg.providers.openai.prompt_cache_retention, None);
589 }
590
591 #[test]
592 fn resolve_cache_dir_expands_home() {
593 let cfg = PromptCachingConfig {
594 cache_dir: "~/.custom/cache".to_string(),
595 ..PromptCachingConfig::default()
596 };
597 let resolved = cfg.resolve_cache_dir(None);
598 if let Some(home) = dirs::home_dir() {
599 assert!(resolved.starts_with(home));
600 } else {
601 assert_eq!(resolved, PathBuf::from(".custom/cache"));
602 }
603 }
604
605 #[test]
606 fn resolve_cache_dir_uses_workspace_when_relative() {
607 let temp = TempDir::new().unwrap();
608 let workspace = temp.path();
609 let cfg = PromptCachingConfig {
610 cache_dir: "relative/cache".to_string(),
611 ..PromptCachingConfig::default()
612 };
613 let resolved = cfg.resolve_cache_dir(Some(workspace));
614 assert_eq!(resolved, workspace.join("relative/cache"));
615 }
616
617 #[test]
618 fn parse_retention_duration_valid_and_invalid() {
619 assert_eq!(
620 parse_retention_duration("24h").unwrap(),
621 Duration::from_secs(86400)
622 );
623 assert_eq!(
624 parse_retention_duration("5m").unwrap(),
625 Duration::from_secs(300)
626 );
627 assert_eq!(
628 parse_retention_duration("1s").unwrap(),
629 Duration::from_secs(1)
630 );
631 assert!(parse_retention_duration("0s").is_err());
632 assert!(parse_retention_duration("31d").is_err());
633 assert!(parse_retention_duration("abc").is_err());
634 assert!(parse_retention_duration("").is_err());
635 assert!(parse_retention_duration("10x").is_err());
636 }
637
638 #[test]
639 fn validate_prompt_cache_rejects_invalid_retention() {
640 let mut cfg = PromptCachingConfig::default();
641 cfg.providers.openai.prompt_cache_retention = Some("invalid".to_string());
642 assert!(cfg.validate().is_err());
643 }
644
645 #[test]
646 fn prompt_cache_key_mode_parses_from_toml() {
647 let parsed: PromptCachingConfig = toml::from_str(
648 r#"
649[providers.openai]
650prompt_cache_key_mode = "off"
651"#,
652 )
653 .expect("prompt cache config should parse");
654
655 assert_eq!(
656 parsed.providers.openai.prompt_cache_key_mode,
657 OpenAIPromptCacheKeyMode::Off
658 );
659 }
660
661 #[test]
662 fn provider_enablement_respects_global_and_provider_flags() {
663 let mut cfg = PromptCachingConfig {
664 enabled: true,
665 ..PromptCachingConfig::default()
666 };
667 cfg.providers.openai.enabled = true;
668 assert!(cfg.is_provider_enabled("openai"));
669
670 cfg.enabled = false;
671 assert!(!cfg.is_provider_enabled("openai"));
672 }
673
674 #[test]
675 fn provider_enablement_handles_aliases_and_modes() {
676 let mut cfg = PromptCachingConfig {
677 enabled: true,
678 ..PromptCachingConfig::default()
679 };
680
681 cfg.providers.anthropic.enabled = true;
682 assert!(cfg.is_provider_enabled("minimax"));
683
684 cfg.providers.gemini.enabled = true;
685 cfg.providers.gemini.mode = GeminiPromptCacheMode::Off;
686 assert!(!cfg.is_provider_enabled("gemini"));
687 }
688
689 #[test]
690 fn bundled_config_templates_match_prompt_cache_defaults() {
691 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
692 let loader_source = fs::read_to_string(manifest_dir.join("src/loader/config.rs"))
693 .expect("loader config source");
694 assert!(loader_source.contains("[prompt_cache]"));
695 assert!(loader_source.contains("enabled = true"));
696 assert!(loader_source.contains("cache_friendly_prompt_shaping = true"));
697 assert!(loader_source.contains("# prompt_cache_retention = \"24h\""));
698
699 let workspace_root = manifest_dir.parent().expect("workspace root").to_path_buf();
700 let example_config = fs::read_to_string(workspace_root.join("vtcode.toml.example"))
701 .expect("vtcode.toml.example");
702 assert!(example_config.contains("[prompt_cache]"));
703 assert!(example_config.contains("enabled = true"));
704 assert!(example_config.contains("cache_friendly_prompt_shaping = true"));
705 assert!(example_config.contains("# prompt_cache_retention = \"24h\""));
706
707 let embedded_config = fs::read_to_string(workspace_root.join("vtcode-core/vtcode.toml"))
708 .expect("embedded vtcode.toml");
709 assert!(embedded_config.contains("[prompt_cache]"));
710 assert!(embedded_config.contains("enabled = true"));
711 assert!(embedded_config.contains("cache_friendly_prompt_shaping = true"));
712 assert!(embedded_config.contains("# prompt_cache_retention = \"24h\""));
713
714 let prompt_cache_guide =
715 fs::read_to_string(workspace_root.join("docs/tools/PROMPT_CACHING_GUIDE.md"))
716 .expect("prompt caching guide");
717 assert!(prompt_cache_guide.contains(
718 "VT Code enables `prompt_cache.cache_friendly_prompt_shaping = true` by default."
719 ));
720 assert!(prompt_cache_guide.contains(
721 "Default: `None` (opt-in) - VT Code does not set prompt_cache_retention by default;"
722 ));
723
724 let field_reference =
725 fs::read_to_string(workspace_root.join("docs/config/CONFIG_FIELD_REFERENCE.md"))
726 .expect("config field reference");
727 assert!(field_reference.contains(
728 "| `prompt_cache.cache_friendly_prompt_shaping` | `boolean` | no | `true` |"
729 ));
730 assert!(field_reference.contains(
731 "| `prompt_cache.providers.openai.prompt_cache_retention` | `null \\| string` | no | `null` |"
732 ));
733 }
734}