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 = "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#[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#[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 #[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 #[serde(default = "default_true")]
150 pub cache_system_messages: bool,
151
152 #[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#[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 #[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#[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#[derive(Debug, Clone, Deserialize, Serialize)]
215pub struct OpenRouterPromptCacheSettings {
216 #[serde(default = "default_true")]
217 pub enabled: bool,
218
219 #[serde(default = "default_true")]
221 pub propagate_provider_capabilities: bool,
222
223 #[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#[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#[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#[derive(Debug, Clone, Deserialize, Serialize)]
270pub struct DeepSeekPromptCacheSettings {
271 #[serde(default = "default_true")]
272 pub enabled: bool,
273
274 #[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#[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}