vtcode_config/core/
prompt_cache.rs

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