1use crate::constants::prompt_cache;
2use anyhow::Context;
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10#[derive(Debug, Clone, Deserialize, Serialize)]
11pub struct PromptCachingConfig {
12 #[serde(default = "default_enabled")]
14 pub enabled: bool,
15
16 #[serde(default = "default_cache_dir")]
18 pub cache_dir: String,
19
20 #[serde(default = "default_max_entries")]
22 pub max_entries: usize,
23
24 #[serde(default = "default_max_age_days")]
26 pub max_age_days: u64,
27
28 #[serde(default = "default_auto_cleanup")]
30 pub enable_auto_cleanup: bool,
31
32 #[serde(default = "default_min_quality_threshold")]
34 pub min_quality_threshold: f64,
35
36 #[serde(default = "default_cache_friendly_prompt_shaping")]
39 pub cache_friendly_prompt_shaping: bool,
40
41 #[serde(default)]
43 pub providers: ProviderPromptCachingConfig,
44}
45
46impl Default for PromptCachingConfig {
47 fn default() -> Self {
48 Self {
49 enabled: default_enabled(),
50 cache_dir: default_cache_dir(),
51 max_entries: default_max_entries(),
52 max_age_days: default_max_age_days(),
53 enable_auto_cleanup: default_auto_cleanup(),
54 min_quality_threshold: default_min_quality_threshold(),
55 cache_friendly_prompt_shaping: default_cache_friendly_prompt_shaping(),
56 providers: ProviderPromptCachingConfig::default(),
57 }
58 }
59}
60
61impl PromptCachingConfig {
62 pub fn resolve_cache_dir(&self, workspace_root: Option<&Path>) -> PathBuf {
68 resolve_path(&self.cache_dir, workspace_root)
69 }
70
71 pub fn is_provider_enabled(&self, provider_name: &str) -> bool {
73 if !self.enabled {
74 return false;
75 }
76
77 match provider_name.to_ascii_lowercase().as_str() {
78 "openai" => self.providers.openai.enabled,
79 "anthropic" | "minimax" => self.providers.anthropic.enabled,
80 "gemini" => {
81 self.providers.gemini.enabled
82 && !matches!(self.providers.gemini.mode, GeminiPromptCacheMode::Off)
83 }
84 "openrouter" => self.providers.openrouter.enabled,
85 "moonshot" => self.providers.moonshot.enabled,
86 "deepseek" => self.providers.deepseek.enabled,
87 "zai" => self.providers.zai.enabled,
88 _ => false,
89 }
90 }
91}
92
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
95#[derive(Debug, Clone, Deserialize, Serialize, Default)]
96pub struct ProviderPromptCachingConfig {
97 #[serde(default = "OpenAIPromptCacheSettings::default")]
98 pub openai: OpenAIPromptCacheSettings,
99
100 #[serde(default = "AnthropicPromptCacheSettings::default")]
101 pub anthropic: AnthropicPromptCacheSettings,
102
103 #[serde(default = "GeminiPromptCacheSettings::default")]
104 pub gemini: GeminiPromptCacheSettings,
105
106 #[serde(default = "OpenRouterPromptCacheSettings::default")]
107 pub openrouter: OpenRouterPromptCacheSettings,
108
109 #[serde(default = "MoonshotPromptCacheSettings::default")]
110 pub moonshot: MoonshotPromptCacheSettings,
111
112 #[serde(default = "DeepSeekPromptCacheSettings::default")]
113 pub deepseek: DeepSeekPromptCacheSettings,
114
115 #[serde(default = "ZaiPromptCacheSettings::default")]
116 pub zai: ZaiPromptCacheSettings,
117}
118
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121#[derive(Debug, Clone, Deserialize, Serialize)]
122pub struct OpenAIPromptCacheSettings {
123 #[serde(default = "default_true")]
124 pub enabled: bool,
125
126 #[serde(default = "default_openai_min_prefix_tokens")]
127 pub min_prefix_tokens: u32,
128
129 #[serde(default = "default_openai_idle_expiration")]
130 pub idle_expiration_seconds: u64,
131
132 #[serde(default = "default_true")]
133 pub surface_metrics: bool,
134
135 #[serde(default = "default_openai_prompt_cache_key_mode")]
138 pub prompt_cache_key_mode: OpenAIPromptCacheKeyMode,
139
140 #[serde(default)]
144 pub prompt_cache_retention: Option<String>,
145}
146
147impl Default for OpenAIPromptCacheSettings {
148 fn default() -> Self {
149 Self {
150 enabled: default_true(),
151 min_prefix_tokens: default_openai_min_prefix_tokens(),
152 idle_expiration_seconds: default_openai_idle_expiration(),
153 surface_metrics: default_true(),
154 prompt_cache_key_mode: default_openai_prompt_cache_key_mode(),
155 prompt_cache_retention: None,
156 }
157 }
158}
159
160impl OpenAIPromptCacheSettings {
161 pub fn validate(&self) -> anyhow::Result<()> {
163 if let Some(ref retention) = self.prompt_cache_retention {
164 parse_retention_duration(retention)
165 .with_context(|| format!("Invalid prompt_cache_retention: {}", retention))?;
166 }
167 Ok(())
168 }
169}
170
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
173#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
174#[serde(rename_all = "snake_case")]
175pub enum OpenAIPromptCacheKeyMode {
176 Off,
178 #[default]
180 Session,
181}
182
183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
185#[derive(Debug, Clone, Deserialize, Serialize)]
186pub struct AnthropicPromptCacheSettings {
187 #[serde(default = "default_true")]
188 pub enabled: bool,
189
190 #[serde(default = "default_anthropic_tools_ttl")]
195 pub tools_ttl_seconds: u64,
196
197 #[serde(default = "default_anthropic_messages_ttl")]
201 pub messages_ttl_seconds: u64,
202
203 #[serde(default = "default_anthropic_max_breakpoints")]
206 pub max_breakpoints: u8,
207
208 #[serde(default = "default_true")]
210 pub cache_system_messages: bool,
211
212 #[serde(default = "default_true")]
214 pub cache_user_messages: bool,
215
216 #[serde(default = "default_true")]
219 pub cache_tool_definitions: bool,
220
221 #[serde(default = "default_min_message_length")]
225 pub min_message_length_for_cache: usize,
226
227 #[serde(default = "default_anthropic_extended_ttl")]
230 pub extended_ttl_seconds: Option<u64>,
231}
232
233impl Default for AnthropicPromptCacheSettings {
234 fn default() -> Self {
235 Self {
236 enabled: default_true(),
237 tools_ttl_seconds: default_anthropic_tools_ttl(),
238 messages_ttl_seconds: default_anthropic_messages_ttl(),
239 max_breakpoints: default_anthropic_max_breakpoints(),
240 cache_system_messages: default_true(),
241 cache_user_messages: default_true(),
242 cache_tool_definitions: default_true(),
243 min_message_length_for_cache: default_min_message_length(),
244 extended_ttl_seconds: default_anthropic_extended_ttl(),
245 }
246 }
247}
248
249#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
251#[derive(Debug, Clone, Deserialize, Serialize)]
252pub struct GeminiPromptCacheSettings {
253 #[serde(default = "default_true")]
254 pub enabled: bool,
255
256 #[serde(default = "default_gemini_mode")]
257 pub mode: GeminiPromptCacheMode,
258
259 #[serde(default = "default_gemini_min_prefix_tokens")]
260 pub min_prefix_tokens: u32,
261
262 #[serde(default = "default_gemini_explicit_ttl")]
264 pub explicit_ttl_seconds: Option<u64>,
265}
266
267impl Default for GeminiPromptCacheSettings {
268 fn default() -> Self {
269 Self {
270 enabled: default_true(),
271 mode: GeminiPromptCacheMode::default(),
272 min_prefix_tokens: default_gemini_min_prefix_tokens(),
273 explicit_ttl_seconds: default_gemini_explicit_ttl(),
274 }
275 }
276}
277
278#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
280#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
281#[serde(rename_all = "snake_case")]
282#[derive(Default)]
283pub enum GeminiPromptCacheMode {
284 #[default]
285 Implicit,
286 Explicit,
287 Off,
288}
289
290#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
292#[derive(Debug, Clone, Deserialize, Serialize)]
293pub struct OpenRouterPromptCacheSettings {
294 #[serde(default = "default_true")]
295 pub enabled: bool,
296
297 #[serde(default = "default_true")]
299 pub propagate_provider_capabilities: bool,
300
301 #[serde(default = "default_true")]
303 pub report_savings: bool,
304}
305
306impl Default for OpenRouterPromptCacheSettings {
307 fn default() -> Self {
308 Self {
309 enabled: default_true(),
310 propagate_provider_capabilities: default_true(),
311 report_savings: default_true(),
312 }
313 }
314}
315
316#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
318#[derive(Debug, Clone, Deserialize, Serialize)]
319pub struct MoonshotPromptCacheSettings {
320 #[serde(default = "default_moonshot_enabled")]
321 pub enabled: bool,
322}
323
324impl Default for MoonshotPromptCacheSettings {
325 fn default() -> Self {
326 Self {
327 enabled: default_moonshot_enabled(),
328 }
329 }
330}
331
332#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
334#[derive(Debug, Clone, Deserialize, Serialize)]
335pub struct DeepSeekPromptCacheSettings {
336 #[serde(default = "default_true")]
337 pub enabled: bool,
338
339 #[serde(default = "default_true")]
341 pub surface_metrics: bool,
342}
343
344impl Default for DeepSeekPromptCacheSettings {
345 fn default() -> Self {
346 Self {
347 enabled: default_true(),
348 surface_metrics: default_true(),
349 }
350 }
351}
352
353#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
355#[derive(Debug, Clone, Deserialize, Serialize)]
356pub struct ZaiPromptCacheSettings {
357 #[serde(default = "default_zai_enabled")]
358 pub enabled: bool,
359}
360
361impl Default for ZaiPromptCacheSettings {
362 fn default() -> Self {
363 Self {
364 enabled: default_zai_enabled(),
365 }
366 }
367}
368
369fn default_enabled() -> bool {
370 prompt_cache::DEFAULT_ENABLED
371}
372
373fn default_cache_dir() -> String {
374 format!("~/{path}", path = prompt_cache::DEFAULT_CACHE_DIR)
375}
376
377fn default_max_entries() -> usize {
378 prompt_cache::DEFAULT_MAX_ENTRIES
379}
380
381fn default_max_age_days() -> u64 {
382 prompt_cache::DEFAULT_MAX_AGE_DAYS
383}
384
385fn default_auto_cleanup() -> bool {
386 prompt_cache::DEFAULT_AUTO_CLEANUP
387}
388
389fn default_min_quality_threshold() -> f64 {
390 prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD
391}
392
393fn default_cache_friendly_prompt_shaping() -> bool {
394 prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING
395}
396
397fn default_true() -> bool {
398 true
399}
400
401fn default_openai_min_prefix_tokens() -> u32 {
402 prompt_cache::OPENAI_MIN_PREFIX_TOKENS
403}
404
405fn default_openai_idle_expiration() -> u64 {
406 prompt_cache::OPENAI_IDLE_EXPIRATION_SECONDS
407}
408
409fn default_openai_prompt_cache_key_mode() -> OpenAIPromptCacheKeyMode {
410 OpenAIPromptCacheKeyMode::Session
411}
412
413#[allow(dead_code)]
414fn default_anthropic_default_ttl() -> u64 {
415 prompt_cache::ANTHROPIC_DEFAULT_TTL_SECONDS
416}
417
418#[allow(dead_code)]
419fn default_anthropic_extended_ttl() -> Option<u64> {
420 Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
421}
422
423fn default_anthropic_tools_ttl() -> u64 {
424 prompt_cache::ANTHROPIC_TOOLS_TTL_SECONDS
425}
426
427fn default_anthropic_messages_ttl() -> u64 {
428 prompt_cache::ANTHROPIC_MESSAGES_TTL_SECONDS
429}
430
431fn default_anthropic_max_breakpoints() -> u8 {
432 prompt_cache::ANTHROPIC_MAX_BREAKPOINTS
433}
434
435#[allow(dead_code)]
436fn default_min_message_length() -> usize {
437 prompt_cache::ANTHROPIC_MIN_MESSAGE_LENGTH_FOR_CACHE
438}
439
440fn default_gemini_min_prefix_tokens() -> u32 {
441 prompt_cache::GEMINI_MIN_PREFIX_TOKENS
442}
443
444fn default_gemini_explicit_ttl() -> Option<u64> {
445 Some(prompt_cache::GEMINI_EXPLICIT_DEFAULT_TTL_SECONDS)
446}
447
448fn default_gemini_mode() -> GeminiPromptCacheMode {
449 GeminiPromptCacheMode::Implicit
450}
451
452fn default_zai_enabled() -> bool {
453 prompt_cache::ZAI_CACHE_ENABLED
454}
455
456fn default_moonshot_enabled() -> bool {
457 prompt_cache::MOONSHOT_CACHE_ENABLED
458}
459
460fn resolve_path(input: &str, workspace_root: Option<&Path>) -> PathBuf {
461 let trimmed = input.trim();
462 if trimmed.is_empty() {
463 return resolve_default_cache_dir();
464 }
465
466 if let Some(stripped) = trimmed
467 .strip_prefix("~/")
468 .or_else(|| trimmed.strip_prefix("~\\"))
469 {
470 if let Some(home) = dirs::home_dir() {
471 return home.join(stripped);
472 }
473 return PathBuf::from(stripped);
474 }
475
476 let candidate = Path::new(trimmed);
477 if candidate.is_absolute() {
478 return candidate.to_path_buf();
479 }
480
481 if let Some(root) = workspace_root {
482 return root.join(candidate);
483 }
484
485 candidate.to_path_buf()
486}
487
488fn resolve_default_cache_dir() -> PathBuf {
489 if let Some(home) = dirs::home_dir() {
490 return home.join(prompt_cache::DEFAULT_CACHE_DIR);
491 }
492 PathBuf::from(prompt_cache::DEFAULT_CACHE_DIR)
493}
494
495fn parse_retention_duration(input: &str) -> anyhow::Result<Duration> {
498 let input = input.trim();
499 if input.is_empty() {
500 anyhow::bail!("Empty retention string");
501 }
502
503 let re =
505 Regex::new(r"^(\d+)([smhdSMHD])$").context("Failed to compile retention format regex")?;
506 let caps = re
507 .captures(input)
508 .ok_or_else(|| anyhow::anyhow!("Invalid retention format; use <number>[s|m|h|d]"))?;
509
510 let value_str = caps
511 .get(1)
512 .map(|m| m.as_str())
513 .ok_or_else(|| anyhow::anyhow!("Retention value capture missing"))?;
514 let unit = caps
515 .get(2)
516 .map(|m| m.as_str())
517 .ok_or_else(|| anyhow::anyhow!("Retention unit capture missing"))?
518 .chars()
519 .next()
520 .ok_or_else(|| anyhow::anyhow!("Retention unit is empty"))?
521 .to_ascii_lowercase();
522 let value: u64 = value_str
523 .parse()
524 .with_context(|| format!("Invalid numeric value in retention: {}", value_str))?;
525
526 let seconds = match unit {
527 's' => value,
528 'm' => value * 60,
529 'h' => value * 60 * 60,
530 'd' => value * 24 * 60 * 60,
531 _ => anyhow::bail!("Invalid retention unit; expected s,m,h,d"),
532 };
533
534 const MIN_SECONDS: u64 = 1;
536 const MAX_SECONDS: u64 = 30 * 24 * 60 * 60; if !((MIN_SECONDS..=MAX_SECONDS).contains(&seconds)) {
538 anyhow::bail!("prompt_cache_retention must be between 1s and 30d");
539 }
540
541 Ok(Duration::from_secs(seconds))
542}
543
544impl PromptCachingConfig {
545 pub fn validate(&self) -> anyhow::Result<()> {
547 self.providers.openai.validate()?;
549 Ok(())
550 }
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use assert_fs::TempDir;
557
558 #[test]
559 fn prompt_caching_defaults_align_with_constants() {
560 let cfg = PromptCachingConfig::default();
561 assert!(cfg.enabled);
562 assert_eq!(cfg.max_entries, prompt_cache::DEFAULT_MAX_ENTRIES);
563 assert_eq!(cfg.max_age_days, prompt_cache::DEFAULT_MAX_AGE_DAYS);
564 assert!(
565 (cfg.min_quality_threshold - prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD).abs()
566 < f64::EPSILON
567 );
568 assert_eq!(
569 cfg.cache_friendly_prompt_shaping,
570 prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING
571 );
572 assert!(cfg.providers.openai.enabled);
573 assert_eq!(
574 cfg.providers.openai.min_prefix_tokens,
575 prompt_cache::OPENAI_MIN_PREFIX_TOKENS
576 );
577 assert_eq!(
578 cfg.providers.openai.prompt_cache_key_mode,
579 OpenAIPromptCacheKeyMode::Session
580 );
581 assert_eq!(
582 cfg.providers.anthropic.extended_ttl_seconds,
583 Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
584 );
585 assert_eq!(cfg.providers.gemini.mode, GeminiPromptCacheMode::Implicit);
586 assert!(cfg.providers.moonshot.enabled);
587 assert_eq!(cfg.providers.openai.prompt_cache_retention, None);
588 }
589
590 #[test]
591 fn resolve_cache_dir_expands_home() {
592 let cfg = PromptCachingConfig {
593 cache_dir: "~/.custom/cache".to_string(),
594 ..PromptCachingConfig::default()
595 };
596 let resolved = cfg.resolve_cache_dir(None);
597 if let Some(home) = dirs::home_dir() {
598 assert!(resolved.starts_with(home));
599 } else {
600 assert_eq!(resolved, PathBuf::from(".custom/cache"));
601 }
602 }
603
604 #[test]
605 fn resolve_cache_dir_uses_workspace_when_relative() {
606 let temp = TempDir::new().unwrap();
607 let workspace = temp.path();
608 let cfg = PromptCachingConfig {
609 cache_dir: "relative/cache".to_string(),
610 ..PromptCachingConfig::default()
611 };
612 let resolved = cfg.resolve_cache_dir(Some(workspace));
613 assert_eq!(resolved, workspace.join("relative/cache"));
614 }
615
616 #[test]
617 fn parse_retention_duration_valid_and_invalid() {
618 assert_eq!(
619 parse_retention_duration("24h").unwrap(),
620 Duration::from_secs(86400)
621 );
622 assert_eq!(
623 parse_retention_duration("5m").unwrap(),
624 Duration::from_secs(300)
625 );
626 assert_eq!(
627 parse_retention_duration("1s").unwrap(),
628 Duration::from_secs(1)
629 );
630 assert!(parse_retention_duration("0s").is_err());
631 assert!(parse_retention_duration("31d").is_err());
632 assert!(parse_retention_duration("abc").is_err());
633 assert!(parse_retention_duration("").is_err());
634 assert!(parse_retention_duration("10x").is_err());
635 }
636
637 #[test]
638 fn validate_prompt_cache_rejects_invalid_retention() {
639 let mut cfg = PromptCachingConfig::default();
640 cfg.providers.openai.prompt_cache_retention = Some("invalid".to_string());
641 assert!(cfg.validate().is_err());
642 }
643
644 #[test]
645 fn prompt_cache_key_mode_parses_from_toml() {
646 let parsed: PromptCachingConfig = toml::from_str(
647 r#"
648[providers.openai]
649prompt_cache_key_mode = "off"
650"#,
651 )
652 .expect("prompt cache config should parse");
653
654 assert_eq!(
655 parsed.providers.openai.prompt_cache_key_mode,
656 OpenAIPromptCacheKeyMode::Off
657 );
658 }
659
660 #[test]
661 fn provider_enablement_respects_global_and_provider_flags() {
662 let mut cfg = PromptCachingConfig {
663 enabled: true,
664 ..PromptCachingConfig::default()
665 };
666 cfg.providers.openai.enabled = true;
667 assert!(cfg.is_provider_enabled("openai"));
668
669 cfg.enabled = false;
670 assert!(!cfg.is_provider_enabled("openai"));
671 }
672
673 #[test]
674 fn provider_enablement_handles_aliases_and_modes() {
675 let mut cfg = PromptCachingConfig {
676 enabled: true,
677 ..PromptCachingConfig::default()
678 };
679
680 cfg.providers.anthropic.enabled = true;
681 assert!(cfg.is_provider_enabled("minimax"));
682
683 cfg.providers.gemini.enabled = true;
684 cfg.providers.gemini.mode = GeminiPromptCacheMode::Off;
685 assert!(!cfg.is_provider_enabled("gemini"));
686 }
687}