vtcode_core/config/core/
prompt_cache.rs

1use crate::config::constants::prompt_cache;
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5/// Global prompt caching configuration loaded from vtcode.toml
6#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct PromptCachingConfig {
8    /// Enable prompt caching features globally
9    #[serde(default = "default_enabled")]
10    pub enabled: bool,
11
12    /// Base directory for local prompt cache storage (supports `~` expansion)
13    #[serde(default = "default_cache_dir")]
14    pub cache_dir: String,
15
16    /// Maximum number of cached prompt entries to retain on disk
17    #[serde(default = "default_max_entries")]
18    pub max_entries: usize,
19
20    /// Maximum age (in days) before cached entries are purged
21    #[serde(default = "default_max_age_days")]
22    pub max_age_days: u64,
23
24    /// Automatically evict stale entries on startup/shutdown
25    #[serde(default = "default_auto_cleanup")]
26    pub enable_auto_cleanup: bool,
27
28    /// Minimum quality score required before persisting an entry
29    #[serde(default = "default_min_quality_threshold")]
30    pub min_quality_threshold: f64,
31
32    /// Provider specific overrides
33    #[serde(default)]
34    pub providers: ProviderPromptCachingConfig,
35}
36
37impl Default for PromptCachingConfig {
38    fn default() -> Self {
39        Self {
40            enabled: default_enabled(),
41            cache_dir: default_cache_dir(),
42            max_entries: default_max_entries(),
43            max_age_days: default_max_age_days(),
44            enable_auto_cleanup: default_auto_cleanup(),
45            min_quality_threshold: default_min_quality_threshold(),
46            providers: ProviderPromptCachingConfig::default(),
47        }
48    }
49}
50
51impl PromptCachingConfig {
52    /// Resolve the configured cache directory to an absolute path
53    ///
54    /// - `~` is expanded to the user's home directory when available
55    /// - Relative paths are resolved against the provided workspace root when supplied
56    /// - Falls back to the configured string when neither applies
57    pub fn resolve_cache_dir(&self, workspace_root: Option<&Path>) -> PathBuf {
58        resolve_path(&self.cache_dir, workspace_root)
59    }
60}
61
62/// Per-provider configuration overrides
63#[derive(Debug, Clone, Deserialize, Serialize)]
64pub struct ProviderPromptCachingConfig {
65    #[serde(default = "OpenAIPromptCacheSettings::default")]
66    pub openai: OpenAIPromptCacheSettings,
67
68    #[serde(default = "AnthropicPromptCacheSettings::default")]
69    pub anthropic: AnthropicPromptCacheSettings,
70
71    #[serde(default = "GeminiPromptCacheSettings::default")]
72    pub gemini: GeminiPromptCacheSettings,
73
74    #[serde(default = "OpenRouterPromptCacheSettings::default")]
75    pub openrouter: OpenRouterPromptCacheSettings,
76
77    #[serde(default = "MoonshotPromptCacheSettings::default")]
78    pub moonshot: MoonshotPromptCacheSettings,
79
80    #[serde(default = "XAIPromptCacheSettings::default")]
81    pub xai: XAIPromptCacheSettings,
82
83    #[serde(default = "DeepSeekPromptCacheSettings::default")]
84    pub deepseek: DeepSeekPromptCacheSettings,
85
86    #[serde(default = "ZaiPromptCacheSettings::default")]
87    pub zai: ZaiPromptCacheSettings,
88}
89
90impl Default for ProviderPromptCachingConfig {
91    fn default() -> Self {
92        Self {
93            openai: OpenAIPromptCacheSettings::default(),
94            anthropic: AnthropicPromptCacheSettings::default(),
95            gemini: GeminiPromptCacheSettings::default(),
96            openrouter: OpenRouterPromptCacheSettings::default(),
97            moonshot: MoonshotPromptCacheSettings::default(),
98            xai: XAIPromptCacheSettings::default(),
99            deepseek: DeepSeekPromptCacheSettings::default(),
100            zai: ZaiPromptCacheSettings::default(),
101        }
102    }
103}
104
105/// OpenAI prompt caching controls (automatic with metrics)
106#[derive(Debug, Clone, Deserialize, Serialize)]
107pub struct OpenAIPromptCacheSettings {
108    #[serde(default = "default_true")]
109    pub enabled: bool,
110
111    #[serde(default = "default_openai_min_prefix_tokens")]
112    pub min_prefix_tokens: u32,
113
114    #[serde(default = "default_openai_idle_expiration")]
115    pub idle_expiration_seconds: u64,
116
117    #[serde(default = "default_true")]
118    pub surface_metrics: bool,
119}
120
121impl Default for OpenAIPromptCacheSettings {
122    fn default() -> Self {
123        Self {
124            enabled: default_true(),
125            min_prefix_tokens: default_openai_min_prefix_tokens(),
126            idle_expiration_seconds: default_openai_idle_expiration(),
127            surface_metrics: default_true(),
128        }
129    }
130}
131
132/// Anthropic Claude cache control settings
133#[derive(Debug, Clone, Deserialize, Serialize)]
134pub struct AnthropicPromptCacheSettings {
135    #[serde(default = "default_true")]
136    pub enabled: bool,
137
138    #[serde(default = "default_anthropic_default_ttl")]
139    pub default_ttl_seconds: u64,
140
141    /// Optional extended TTL (1 hour) for long-lived caches
142    #[serde(default = "default_anthropic_extended_ttl")]
143    pub extended_ttl_seconds: Option<u64>,
144
145    #[serde(default = "default_anthropic_max_breakpoints")]
146    pub max_breakpoints: u8,
147
148    /// Apply cache control to system prompts by default
149    #[serde(default = "default_true")]
150    pub cache_system_messages: bool,
151
152    /// Apply cache control to user messages exceeding threshold
153    #[serde(default = "default_true")]
154    pub cache_user_messages: bool,
155}
156
157impl Default for AnthropicPromptCacheSettings {
158    fn default() -> Self {
159        Self {
160            enabled: default_true(),
161            default_ttl_seconds: default_anthropic_default_ttl(),
162            extended_ttl_seconds: default_anthropic_extended_ttl(),
163            max_breakpoints: default_anthropic_max_breakpoints(),
164            cache_system_messages: default_true(),
165            cache_user_messages: default_true(),
166        }
167    }
168}
169
170/// Gemini API caching preferences
171#[derive(Debug, Clone, Deserialize, Serialize)]
172pub struct GeminiPromptCacheSettings {
173    #[serde(default = "default_true")]
174    pub enabled: bool,
175
176    #[serde(default = "default_gemini_mode")]
177    pub mode: GeminiPromptCacheMode,
178
179    #[serde(default = "default_gemini_min_prefix_tokens")]
180    pub min_prefix_tokens: u32,
181
182    /// TTL for explicit caches (ignored in implicit mode)
183    #[serde(default = "default_gemini_explicit_ttl")]
184    pub explicit_ttl_seconds: Option<u64>,
185}
186
187impl Default for GeminiPromptCacheSettings {
188    fn default() -> Self {
189        Self {
190            enabled: default_true(),
191            mode: GeminiPromptCacheMode::default(),
192            min_prefix_tokens: default_gemini_min_prefix_tokens(),
193            explicit_ttl_seconds: default_gemini_explicit_ttl(),
194        }
195    }
196}
197
198/// Gemini prompt caching mode selection
199#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
200#[serde(rename_all = "snake_case")]
201pub enum GeminiPromptCacheMode {
202    Implicit,
203    Explicit,
204    Off,
205}
206
207impl Default for GeminiPromptCacheMode {
208    fn default() -> Self {
209        GeminiPromptCacheMode::Implicit
210    }
211}
212
213/// OpenRouter passthrough caching controls
214#[derive(Debug, Clone, Deserialize, Serialize)]
215pub struct OpenRouterPromptCacheSettings {
216    #[serde(default = "default_true")]
217    pub enabled: bool,
218
219    /// Propagate provider cache instructions automatically
220    #[serde(default = "default_true")]
221    pub propagate_provider_capabilities: bool,
222
223    /// Surface cache savings reported by OpenRouter
224    #[serde(default = "default_true")]
225    pub report_savings: bool,
226}
227
228impl Default for OpenRouterPromptCacheSettings {
229    fn default() -> Self {
230        Self {
231            enabled: default_true(),
232            propagate_provider_capabilities: default_true(),
233            report_savings: default_true(),
234        }
235    }
236}
237
238/// Moonshot prompt caching configuration (leverages server-side reuse)
239#[derive(Debug, Clone, Deserialize, Serialize)]
240pub struct MoonshotPromptCacheSettings {
241    #[serde(default = "default_moonshot_enabled")]
242    pub enabled: bool,
243}
244
245impl Default for MoonshotPromptCacheSettings {
246    fn default() -> Self {
247        Self {
248            enabled: default_moonshot_enabled(),
249        }
250    }
251}
252
253/// xAI prompt caching configuration (automatic platform-level cache)
254#[derive(Debug, Clone, Deserialize, Serialize)]
255pub struct XAIPromptCacheSettings {
256    #[serde(default = "default_true")]
257    pub enabled: bool,
258}
259
260impl Default for XAIPromptCacheSettings {
261    fn default() -> Self {
262        Self {
263            enabled: default_true(),
264        }
265    }
266}
267
268/// DeepSeek prompt caching configuration (automatic KV cache reuse)
269#[derive(Debug, Clone, Deserialize, Serialize)]
270pub struct DeepSeekPromptCacheSettings {
271    #[serde(default = "default_true")]
272    pub enabled: bool,
273
274    /// Emit cache hit/miss metrics from responses when available
275    #[serde(default = "default_true")]
276    pub surface_metrics: bool,
277}
278
279impl Default for DeepSeekPromptCacheSettings {
280    fn default() -> Self {
281        Self {
282            enabled: default_true(),
283            surface_metrics: default_true(),
284        }
285    }
286}
287
288/// Z.AI prompt caching configuration (disabled until platform exposes metrics)
289#[derive(Debug, Clone, Deserialize, Serialize)]
290pub struct ZaiPromptCacheSettings {
291    #[serde(default = "default_zai_enabled")]
292    pub enabled: bool,
293}
294
295impl Default for ZaiPromptCacheSettings {
296    fn default() -> Self {
297        Self {
298            enabled: default_zai_enabled(),
299        }
300    }
301}
302
303fn default_enabled() -> bool {
304    prompt_cache::DEFAULT_ENABLED
305}
306
307fn default_cache_dir() -> String {
308    format!("~/{path}", path = prompt_cache::DEFAULT_CACHE_DIR)
309}
310
311fn default_max_entries() -> usize {
312    prompt_cache::DEFAULT_MAX_ENTRIES
313}
314
315fn default_max_age_days() -> u64 {
316    prompt_cache::DEFAULT_MAX_AGE_DAYS
317}
318
319fn default_auto_cleanup() -> bool {
320    prompt_cache::DEFAULT_AUTO_CLEANUP
321}
322
323fn default_min_quality_threshold() -> f64 {
324    prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD
325}
326
327fn default_true() -> bool {
328    true
329}
330
331fn default_openai_min_prefix_tokens() -> u32 {
332    prompt_cache::OPENAI_MIN_PREFIX_TOKENS
333}
334
335fn default_openai_idle_expiration() -> u64 {
336    prompt_cache::OPENAI_IDLE_EXPIRATION_SECONDS
337}
338
339fn default_anthropic_default_ttl() -> u64 {
340    prompt_cache::ANTHROPIC_DEFAULT_TTL_SECONDS
341}
342
343fn default_anthropic_extended_ttl() -> Option<u64> {
344    Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
345}
346
347fn default_anthropic_max_breakpoints() -> u8 {
348    prompt_cache::ANTHROPIC_MAX_BREAKPOINTS
349}
350
351fn default_gemini_min_prefix_tokens() -> u32 {
352    prompt_cache::GEMINI_MIN_PREFIX_TOKENS
353}
354
355fn default_gemini_explicit_ttl() -> Option<u64> {
356    Some(prompt_cache::GEMINI_EXPLICIT_DEFAULT_TTL_SECONDS)
357}
358
359fn default_gemini_mode() -> GeminiPromptCacheMode {
360    GeminiPromptCacheMode::Implicit
361}
362
363fn default_zai_enabled() -> bool {
364    prompt_cache::ZAI_CACHE_ENABLED
365}
366
367fn default_moonshot_enabled() -> bool {
368    prompt_cache::MOONSHOT_CACHE_ENABLED
369}
370
371fn resolve_path(input: &str, workspace_root: Option<&Path>) -> PathBuf {
372    let trimmed = input.trim();
373    if trimmed.is_empty() {
374        return resolve_default_cache_dir();
375    }
376
377    if let Some(stripped) = trimmed
378        .strip_prefix("~/")
379        .or_else(|| trimmed.strip_prefix("~\\"))
380    {
381        if let Some(home) = dirs::home_dir() {
382            return home.join(stripped);
383        }
384        return PathBuf::from(stripped);
385    }
386
387    let candidate = Path::new(trimmed);
388    if candidate.is_absolute() {
389        return candidate.to_path_buf();
390    }
391
392    if let Some(root) = workspace_root {
393        return root.join(candidate);
394    }
395
396    candidate.to_path_buf()
397}
398
399fn resolve_default_cache_dir() -> PathBuf {
400    if let Some(home) = dirs::home_dir() {
401        return home.join(prompt_cache::DEFAULT_CACHE_DIR);
402    }
403    PathBuf::from(prompt_cache::DEFAULT_CACHE_DIR)
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use tempfile::tempdir;
410
411    #[test]
412    fn prompt_caching_defaults_align_with_constants() {
413        let cfg = PromptCachingConfig::default();
414        assert!(cfg.enabled);
415        assert_eq!(cfg.max_entries, prompt_cache::DEFAULT_MAX_ENTRIES);
416        assert_eq!(cfg.max_age_days, prompt_cache::DEFAULT_MAX_AGE_DAYS);
417        assert!(
418            (cfg.min_quality_threshold - prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD).abs()
419                < f64::EPSILON
420        );
421        assert!(cfg.providers.openai.enabled);
422        assert_eq!(
423            cfg.providers.openai.min_prefix_tokens,
424            prompt_cache::OPENAI_MIN_PREFIX_TOKENS
425        );
426        assert_eq!(
427            cfg.providers.anthropic.extended_ttl_seconds,
428            Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
429        );
430        assert_eq!(cfg.providers.gemini.mode, GeminiPromptCacheMode::Implicit);
431        assert!(cfg.providers.moonshot.enabled);
432    }
433
434    #[test]
435    fn resolve_cache_dir_expands_home() {
436        let cfg = PromptCachingConfig {
437            cache_dir: "~/.custom/cache".to_string(),
438            ..PromptCachingConfig::default()
439        };
440        let resolved = cfg.resolve_cache_dir(None);
441        if let Some(home) = dirs::home_dir() {
442            assert!(resolved.starts_with(home));
443        } else {
444            assert_eq!(resolved, PathBuf::from(".custom/cache"));
445        }
446    }
447
448    #[test]
449    fn resolve_cache_dir_uses_workspace_when_relative() {
450        let temp = tempdir().unwrap();
451        let workspace = temp.path();
452        let cfg = PromptCachingConfig {
453            cache_dir: "relative/cache".to_string(),
454            ..PromptCachingConfig::default()
455        };
456        let resolved = cfg.resolve_cache_dir(Some(workspace));
457        assert_eq!(resolved, workspace.join("relative/cache"));
458    }
459}