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 = "XAIPromptCacheSettings::default")]
78    pub xai: XAIPromptCacheSettings,
79
80    #[serde(default = "DeepSeekPromptCacheSettings::default")]
81    pub deepseek: DeepSeekPromptCacheSettings,
82}
83
84impl Default for ProviderPromptCachingConfig {
85    fn default() -> Self {
86        Self {
87            openai: OpenAIPromptCacheSettings::default(),
88            anthropic: AnthropicPromptCacheSettings::default(),
89            gemini: GeminiPromptCacheSettings::default(),
90            openrouter: OpenRouterPromptCacheSettings::default(),
91            xai: XAIPromptCacheSettings::default(),
92            deepseek: DeepSeekPromptCacheSettings::default(),
93        }
94    }
95}
96
97/// OpenAI prompt caching controls (automatic with metrics)
98#[derive(Debug, Clone, Deserialize, Serialize)]
99pub struct OpenAIPromptCacheSettings {
100    #[serde(default = "default_true")]
101    pub enabled: bool,
102
103    #[serde(default = "default_openai_min_prefix_tokens")]
104    pub min_prefix_tokens: u32,
105
106    #[serde(default = "default_openai_idle_expiration")]
107    pub idle_expiration_seconds: u64,
108
109    #[serde(default = "default_true")]
110    pub surface_metrics: bool,
111}
112
113impl Default for OpenAIPromptCacheSettings {
114    fn default() -> Self {
115        Self {
116            enabled: default_true(),
117            min_prefix_tokens: default_openai_min_prefix_tokens(),
118            idle_expiration_seconds: default_openai_idle_expiration(),
119            surface_metrics: default_true(),
120        }
121    }
122}
123
124/// Anthropic Claude cache control settings
125#[derive(Debug, Clone, Deserialize, Serialize)]
126pub struct AnthropicPromptCacheSettings {
127    #[serde(default = "default_true")]
128    pub enabled: bool,
129
130    #[serde(default = "default_anthropic_default_ttl")]
131    pub default_ttl_seconds: u64,
132
133    /// Optional extended TTL (1 hour) for long-lived caches
134    #[serde(default = "default_anthropic_extended_ttl")]
135    pub extended_ttl_seconds: Option<u64>,
136
137    #[serde(default = "default_anthropic_max_breakpoints")]
138    pub max_breakpoints: u8,
139
140    /// Apply cache control to system prompts by default
141    #[serde(default = "default_true")]
142    pub cache_system_messages: bool,
143
144    /// Apply cache control to user messages exceeding threshold
145    #[serde(default = "default_true")]
146    pub cache_user_messages: bool,
147}
148
149impl Default for AnthropicPromptCacheSettings {
150    fn default() -> Self {
151        Self {
152            enabled: default_true(),
153            default_ttl_seconds: default_anthropic_default_ttl(),
154            extended_ttl_seconds: default_anthropic_extended_ttl(),
155            max_breakpoints: default_anthropic_max_breakpoints(),
156            cache_system_messages: default_true(),
157            cache_user_messages: default_true(),
158        }
159    }
160}
161
162/// Gemini API caching preferences
163#[derive(Debug, Clone, Deserialize, Serialize)]
164pub struct GeminiPromptCacheSettings {
165    #[serde(default = "default_true")]
166    pub enabled: bool,
167
168    #[serde(default = "default_gemini_mode")]
169    pub mode: GeminiPromptCacheMode,
170
171    #[serde(default = "default_gemini_min_prefix_tokens")]
172    pub min_prefix_tokens: u32,
173
174    /// TTL for explicit caches (ignored in implicit mode)
175    #[serde(default = "default_gemini_explicit_ttl")]
176    pub explicit_ttl_seconds: Option<u64>,
177}
178
179impl Default for GeminiPromptCacheSettings {
180    fn default() -> Self {
181        Self {
182            enabled: default_true(),
183            mode: GeminiPromptCacheMode::default(),
184            min_prefix_tokens: default_gemini_min_prefix_tokens(),
185            explicit_ttl_seconds: default_gemini_explicit_ttl(),
186        }
187    }
188}
189
190/// Gemini prompt caching mode selection
191#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
192#[serde(rename_all = "snake_case")]
193pub enum GeminiPromptCacheMode {
194    Implicit,
195    Explicit,
196    Off,
197}
198
199impl Default for GeminiPromptCacheMode {
200    fn default() -> Self {
201        GeminiPromptCacheMode::Implicit
202    }
203}
204
205/// OpenRouter passthrough caching controls
206#[derive(Debug, Clone, Deserialize, Serialize)]
207pub struct OpenRouterPromptCacheSettings {
208    #[serde(default = "default_true")]
209    pub enabled: bool,
210
211    /// Propagate provider cache instructions automatically
212    #[serde(default = "default_true")]
213    pub propagate_provider_capabilities: bool,
214
215    /// Surface cache savings reported by OpenRouter
216    #[serde(default = "default_true")]
217    pub report_savings: bool,
218}
219
220impl Default for OpenRouterPromptCacheSettings {
221    fn default() -> Self {
222        Self {
223            enabled: default_true(),
224            propagate_provider_capabilities: default_true(),
225            report_savings: default_true(),
226        }
227    }
228}
229
230/// xAI prompt caching configuration (automatic platform-level cache)
231#[derive(Debug, Clone, Deserialize, Serialize)]
232pub struct XAIPromptCacheSettings {
233    #[serde(default = "default_true")]
234    pub enabled: bool,
235}
236
237impl Default for XAIPromptCacheSettings {
238    fn default() -> Self {
239        Self {
240            enabled: default_true(),
241        }
242    }
243}
244
245/// DeepSeek prompt caching configuration (automatic KV cache reuse)
246#[derive(Debug, Clone, Deserialize, Serialize)]
247pub struct DeepSeekPromptCacheSettings {
248    #[serde(default = "default_true")]
249    pub enabled: bool,
250
251    /// Emit cache hit/miss metrics from responses when available
252    #[serde(default = "default_true")]
253    pub surface_metrics: bool,
254}
255
256impl Default for DeepSeekPromptCacheSettings {
257    fn default() -> Self {
258        Self {
259            enabled: default_true(),
260            surface_metrics: default_true(),
261        }
262    }
263}
264
265fn default_enabled() -> bool {
266    prompt_cache::DEFAULT_ENABLED
267}
268
269fn default_cache_dir() -> String {
270    format!("~/{path}", path = prompt_cache::DEFAULT_CACHE_DIR)
271}
272
273fn default_max_entries() -> usize {
274    prompt_cache::DEFAULT_MAX_ENTRIES
275}
276
277fn default_max_age_days() -> u64 {
278    prompt_cache::DEFAULT_MAX_AGE_DAYS
279}
280
281fn default_auto_cleanup() -> bool {
282    prompt_cache::DEFAULT_AUTO_CLEANUP
283}
284
285fn default_min_quality_threshold() -> f64 {
286    prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD
287}
288
289fn default_true() -> bool {
290    true
291}
292
293fn default_openai_min_prefix_tokens() -> u32 {
294    prompt_cache::OPENAI_MIN_PREFIX_TOKENS
295}
296
297fn default_openai_idle_expiration() -> u64 {
298    prompt_cache::OPENAI_IDLE_EXPIRATION_SECONDS
299}
300
301fn default_anthropic_default_ttl() -> u64 {
302    prompt_cache::ANTHROPIC_DEFAULT_TTL_SECONDS
303}
304
305fn default_anthropic_extended_ttl() -> Option<u64> {
306    Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
307}
308
309fn default_anthropic_max_breakpoints() -> u8 {
310    prompt_cache::ANTHROPIC_MAX_BREAKPOINTS
311}
312
313fn default_gemini_min_prefix_tokens() -> u32 {
314    prompt_cache::GEMINI_MIN_PREFIX_TOKENS
315}
316
317fn default_gemini_explicit_ttl() -> Option<u64> {
318    Some(prompt_cache::GEMINI_EXPLICIT_DEFAULT_TTL_SECONDS)
319}
320
321fn default_gemini_mode() -> GeminiPromptCacheMode {
322    GeminiPromptCacheMode::Implicit
323}
324
325fn resolve_path(input: &str, workspace_root: Option<&Path>) -> PathBuf {
326    let trimmed = input.trim();
327    if trimmed.is_empty() {
328        return resolve_default_cache_dir();
329    }
330
331    if let Some(stripped) = trimmed
332        .strip_prefix("~/")
333        .or_else(|| trimmed.strip_prefix("~\\"))
334    {
335        if let Some(home) = dirs::home_dir() {
336            return home.join(stripped);
337        }
338        return PathBuf::from(stripped);
339    }
340
341    let candidate = Path::new(trimmed);
342    if candidate.is_absolute() {
343        return candidate.to_path_buf();
344    }
345
346    if let Some(root) = workspace_root {
347        return root.join(candidate);
348    }
349
350    candidate.to_path_buf()
351}
352
353fn resolve_default_cache_dir() -> PathBuf {
354    if let Some(home) = dirs::home_dir() {
355        return home.join(prompt_cache::DEFAULT_CACHE_DIR);
356    }
357    PathBuf::from(prompt_cache::DEFAULT_CACHE_DIR)
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use tempfile::tempdir;
364
365    #[test]
366    fn prompt_caching_defaults_align_with_constants() {
367        let cfg = PromptCachingConfig::default();
368        assert!(cfg.enabled);
369        assert_eq!(cfg.max_entries, prompt_cache::DEFAULT_MAX_ENTRIES);
370        assert_eq!(cfg.max_age_days, prompt_cache::DEFAULT_MAX_AGE_DAYS);
371        assert!(
372            (cfg.min_quality_threshold - prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD).abs()
373                < f64::EPSILON
374        );
375        assert!(cfg.providers.openai.enabled);
376        assert_eq!(
377            cfg.providers.openai.min_prefix_tokens,
378            prompt_cache::OPENAI_MIN_PREFIX_TOKENS
379        );
380        assert_eq!(
381            cfg.providers.anthropic.extended_ttl_seconds,
382            Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
383        );
384        assert_eq!(cfg.providers.gemini.mode, GeminiPromptCacheMode::Implicit);
385    }
386
387    #[test]
388    fn resolve_cache_dir_expands_home() {
389        let cfg = PromptCachingConfig {
390            cache_dir: "~/.custom/cache".to_string(),
391            ..PromptCachingConfig::default()
392        };
393        let resolved = cfg.resolve_cache_dir(None);
394        if let Some(home) = dirs::home_dir() {
395            assert!(resolved.starts_with(home));
396        } else {
397            assert_eq!(resolved, PathBuf::from(".custom/cache"));
398        }
399    }
400
401    #[test]
402    fn resolve_cache_dir_uses_workspace_when_relative() {
403        let temp = tempdir().unwrap();
404        let workspace = temp.path();
405        let cfg = PromptCachingConfig {
406            cache_dir: "relative/cache".to_string(),
407            ..PromptCachingConfig::default()
408        };
409        let resolved = cfg.resolve_cache_dir(Some(workspace));
410        assert_eq!(resolved, workspace.join("relative/cache"));
411    }
412}