1use crate::constants::prompt_cache;
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
7#[derive(Debug, Clone, Deserialize, Serialize)]
8pub struct PromptCachingConfig {
9 #[serde(default = "default_enabled")]
11 pub enabled: bool,
12
13 #[serde(default = "default_cache_dir")]
15 pub cache_dir: String,
16
17 #[serde(default = "default_max_entries")]
19 pub max_entries: usize,
20
21 #[serde(default = "default_max_age_days")]
23 pub max_age_days: u64,
24
25 #[serde(default = "default_auto_cleanup")]
27 pub enable_auto_cleanup: bool,
28
29 #[serde(default = "default_min_quality_threshold")]
31 pub min_quality_threshold: f64,
32
33 #[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 pub fn resolve_cache_dir(&self, workspace_root: Option<&Path>) -> PathBuf {
59 resolve_path(&self.cache_dir, workspace_root)
60 }
61}
62
63#[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#[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#[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 #[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 #[serde(default = "default_true")]
139 pub cache_system_messages: bool,
140
141 #[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#[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 #[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#[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#[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 #[serde(default = "default_true")]
209 pub propagate_provider_capabilities: bool,
210
211 #[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#[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#[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#[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 #[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#[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}