1use crate::constants::prompt_cache;
2use anyhow::Context;
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
8#[derive(Debug, Clone, Deserialize, Serialize)]
9pub struct PromptCachingConfig {
10 #[serde(default = "default_enabled")]
12 pub enabled: bool,
13
14 #[serde(default = "default_cache_dir")]
16 pub cache_dir: String,
17
18 #[serde(default = "default_max_entries")]
20 pub max_entries: usize,
21
22 #[serde(default = "default_max_age_days")]
24 pub max_age_days: u64,
25
26 #[serde(default = "default_auto_cleanup")]
28 pub enable_auto_cleanup: bool,
29
30 #[serde(default = "default_min_quality_threshold")]
32 pub min_quality_threshold: f64,
33
34 #[serde(default = "default_cache_friendly_prompt_shaping")]
37 pub cache_friendly_prompt_shaping: bool,
38
39 #[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 pub fn resolve_cache_dir(&self, workspace_root: Option<&Path>) -> PathBuf {
66 resolve_path(&self.cache_dir, workspace_root)
67 }
68
69 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#[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#[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 #[serde(default = "default_openai_prompt_cache_key_mode")]
136 pub prompt_cache_key_mode: OpenAIPromptCacheKeyMode,
137
138 #[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 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#[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#[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 Off,
198 #[default]
200 Session,
201}
202
203#[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 #[serde(default = "default_anthropic_tools_ttl")]
215 pub tools_ttl_seconds: u64,
216
217 #[serde(default = "default_anthropic_messages_ttl")]
221 pub messages_ttl_seconds: u64,
222
223 #[serde(default = "default_anthropic_max_breakpoints")]
226 pub max_breakpoints: u8,
227
228 #[serde(default = "default_true")]
230 pub cache_system_messages: bool,
231
232 #[serde(default = "default_true")]
234 pub cache_user_messages: bool,
235
236 #[serde(default = "default_true")]
239 pub cache_tool_definitions: bool,
240
241 #[serde(default = "default_min_message_length")]
245 pub min_message_length_for_cache: usize,
246
247 #[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#[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 #[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#[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#[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 #[serde(default = "default_true")]
319 pub propagate_provider_capabilities: bool,
320
321 #[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#[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#[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 #[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#[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
515fn 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 pub fn validate(&self) -> anyhow::Result<()> {
533 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}