1use crate::config::constants::prompt_cache;
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct PromptCachingConfig {
8 #[serde(default = "default_enabled")]
10 pub enabled: bool,
11
12 #[serde(default = "default_cache_dir")]
14 pub cache_dir: String,
15
16 #[serde(default = "default_max_entries")]
18 pub max_entries: usize,
19
20 #[serde(default = "default_max_age_days")]
22 pub max_age_days: u64,
23
24 #[serde(default = "default_auto_cleanup")]
26 pub enable_auto_cleanup: bool,
27
28 #[serde(default = "default_min_quality_threshold")]
30 pub min_quality_threshold: f64,
31
32 #[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 pub fn resolve_cache_dir(&self, workspace_root: Option<&Path>) -> PathBuf {
58 resolve_path(&self.cache_dir, workspace_root)
59 }
60}
61
62#[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#[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#[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 #[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 #[serde(default = "default_true")]
142 pub cache_system_messages: bool,
143
144 #[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#[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 #[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#[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#[derive(Debug, Clone, Deserialize, Serialize)]
207pub struct OpenRouterPromptCacheSettings {
208 #[serde(default = "default_true")]
209 pub enabled: bool,
210
211 #[serde(default = "default_true")]
213 pub propagate_provider_capabilities: bool,
214
215 #[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#[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#[derive(Debug, Clone, Deserialize, Serialize)]
247pub struct DeepSeekPromptCacheSettings {
248 #[serde(default = "default_true")]
249 pub enabled: bool,
250
251 #[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}