1use crate::constants::prompt_cache;
2use crate::env_helpers::default_true;
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5use vtcode_commons::VtCodePaths;
6
7#[must_use]
11pub fn supports_openai_explicit_prompt_cache(model: &str) -> bool {
12 use crate::models::ModelId;
13 let Ok(model_id) = model.parse::<ModelId>() else {
14 return false;
15 };
16 (model_id.as_str() == model || model == crate::constants::models::openai::GPT_5_6)
17 && matches!(
18 model_id,
19 ModelId::GPT56Sol
20 | ModelId::GPT56Terra
21 | ModelId::GPT56Luna
22 | ModelId::GPT6Astra
23 | ModelId::GPT6Sol
24 | ModelId::GPT61Sol
25 | ModelId::GPT61Terra
26 | ModelId::GPT6Luna
27 )
28}
29
30#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
33pub enum PromptCacheTtl {
34 #[default]
35 #[serde(rename = "30m")]
36 M30,
37 #[serde(rename = "12h")]
38 H12,
39}
40
41impl PromptCacheTtl {
42 pub const fn as_str(self) -> &'static str {
43 match self {
44 Self::M30 => "30m",
45 Self::H12 => "12h",
46 }
47 }
48
49 pub const fn seconds(self) -> u64 {
50 match self {
51 Self::M30 => 1_800,
52 Self::H12 => 43_200,
53 }
54 }
55}
56
57pub fn resolved_openai_prompt_cache_ttl(configured: Option<PromptCacheTtl>, model: &str) -> PromptCacheTtl {
59 configured
60 .or_else(|| {
61 crate::models::model_catalog_entry("openai", model)
62 .and_then(|entry| entry.prompt_cache_ttl)
63 .and_then(|ttl| match ttl {
64 "30m" => Some(PromptCacheTtl::M30),
65 "12h" => Some(PromptCacheTtl::H12),
66 _ => None,
67 })
68 })
69 .unwrap_or_default()
70}
71
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct PromptCachingConfig {
76 #[serde(default = "default_enabled")]
78 pub enabled: bool,
79
80 #[serde(default = "default_cache_dir")]
82 pub cache_dir: String,
83
84 #[serde(default = "default_max_entries")]
86 pub max_entries: usize,
87
88 #[serde(default = "default_max_age_days")]
90 pub max_age_days: u64,
91
92 #[serde(default = "default_auto_cleanup")]
94 pub enable_auto_cleanup: bool,
95
96 #[serde(default = "default_min_quality_threshold")]
98 pub min_quality_threshold: f64,
99
100 #[serde(default = "default_cache_friendly_prompt_shaping")]
103 pub cache_friendly_prompt_shaping: bool,
104
105 #[serde(default = "default_true")]
110 pub stable_tool_catalog_across_modes: bool,
111
112 #[serde(default = "default_true")]
115 pub gap_warning_enabled: bool,
116
117 #[serde(default)]
120 pub gap_warning_threshold_secs: Option<u64>,
121
122 #[serde(default)]
124 pub providers: ProviderPromptCachingConfig,
125}
126
127impl Default for PromptCachingConfig {
128 fn default() -> Self {
129 let cache_dir = default_cache_dir();
130 Self {
131 enabled: default_enabled() && !cache_dir.is_empty(),
132 cache_dir,
133 max_entries: default_max_entries(),
134 max_age_days: default_max_age_days(),
135 enable_auto_cleanup: default_auto_cleanup(),
136 min_quality_threshold: default_min_quality_threshold(),
137 cache_friendly_prompt_shaping: default_cache_friendly_prompt_shaping(),
138 stable_tool_catalog_across_modes: true,
139 gap_warning_enabled: default_true(),
140 gap_warning_threshold_secs: None,
141 providers: ProviderPromptCachingConfig::default(),
142 }
143 }
144}
145
146impl PromptCachingConfig {
147 pub fn resolve_cache_dir(&self, workspace_root: Option<&Path>) -> PathBuf {
153 resolve_path(&self.cache_dir, workspace_root)
154 }
155
156 pub fn is_provider_enabled(&self, provider_name: &str) -> bool {
158 if !self.enabled {
159 return false;
160 }
161
162 match provider_name.to_ascii_lowercase().as_str() {
163 "openai" | "merge-gateway" => self.providers.openai.enabled,
164 "anthropic" | "minimax" => self.providers.anthropic.enabled,
165 "gemini" => {
166 self.providers.gemini.enabled && !matches!(self.providers.gemini.mode, GeminiPromptCacheMode::Off)
167 }
168 "openrouter" => self.providers.openrouter.enabled,
169 "moonshot" => self.providers.moonshot.enabled,
170 "deepseek" => self.providers.deepseek.enabled,
171 "zai" => self.providers.zai.enabled,
172 _ => false,
173 }
174 }
175
176 pub fn gap_threshold_secs(&self, provider_name: &str) -> Option<u64> {
181 self.gap_threshold_secs_for_model(provider_name, "")
182 }
183
184 pub fn gap_threshold_secs_for_route(
186 &self,
187 provider: &str,
188 model: &str,
189 ttl: Option<PromptCacheTtl>,
190 ) -> Option<u64> {
191 if !self.gap_warning_enabled
192 || !self.enabled
193 || !(self.is_provider_enabled(provider) || (ttl.is_some() && self.providers.openai.enabled))
194 {
195 return None;
196 }
197 if let Some(threshold) = self.gap_warning_threshold_secs {
198 return Some(threshold);
199 }
200 if let Some(ttl) = ttl {
201 return Some(ttl.seconds());
202 }
203
204 Some(match provider.to_ascii_lowercase().as_str() {
205 "anthropic" | "minimax" => prompt_cache::ANTHROPIC_CACHE_GAP_WARNING_SECONDS,
206 "openai"
207 if supports_openai_explicit_prompt_cache(model)
208 || self.providers.openai.prompt_cache_retention == Some(PromptCacheRetention::H24) =>
209 {
210 30 * 60
211 }
212 "openai" => prompt_cache::OPENAI_CACHE_GAP_WARNING_SECONDS,
213 _ => prompt_cache::DEFAULT_CACHE_GAP_WARNING_SECONDS,
214 })
215 }
216
217 pub fn gap_threshold_secs_for_model(&self, provider_name: &str, model: &str) -> Option<u64> {
220 let ttl = (provider_name.eq_ignore_ascii_case("openai") && supports_openai_explicit_prompt_cache(model))
221 .then(|| resolved_openai_prompt_cache_ttl(self.providers.openai.prompt_cache_ttl, model));
222 self.gap_threshold_secs_for_route(provider_name, model, ttl)
223 }
224}
225
226#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
228#[derive(Debug, Clone, Deserialize, Serialize, Default)]
229pub struct ProviderPromptCachingConfig {
230 #[serde(default = "OpenAIPromptCacheSettings::default")]
231 pub openai: OpenAIPromptCacheSettings,
232
233 #[serde(default = "AnthropicPromptCacheSettings::default")]
234 pub anthropic: AnthropicPromptCacheSettings,
235
236 #[serde(default = "GeminiPromptCacheSettings::default")]
237 pub gemini: GeminiPromptCacheSettings,
238
239 #[serde(default = "OpenRouterPromptCacheSettings::default")]
240 pub openrouter: OpenRouterPromptCacheSettings,
241
242 #[serde(default = "MoonshotPromptCacheSettings::default")]
243 moonshot: MoonshotPromptCacheSettings,
244
245 #[serde(default = "DeepSeekPromptCacheSettings::default")]
246 pub deepseek: DeepSeekPromptCacheSettings,
247
248 #[serde(default = "ZaiPromptCacheSettings::default")]
249 zai: ZaiPromptCacheSettings,
250}
251
252#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
256pub enum PromptCacheRetention {
257 #[serde(rename = "in_memory")]
259 InMemory,
260 #[default]
262 #[serde(rename = "24h")]
263 H24,
264 #[serde(other, rename = "unknown")]
270 Unknown,
271}
272
273impl PromptCacheRetention {
274 pub fn as_str(&self) -> &str {
276 match self {
277 Self::InMemory => "in_memory",
278 Self::H24 => "24h",
279 Self::Unknown => "unknown",
280 }
281 }
282}
283
284impl std::fmt::Display for PromptCacheRetention {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 f.write_str(self.as_str())
287 }
288}
289
290#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
292#[derive(Debug, Clone, Deserialize, Serialize)]
293pub struct OpenAIPromptCacheSettings {
294 #[serde(default = "default_true")]
295 pub enabled: bool,
296
297 #[serde(default = "default_openai_min_prefix_tokens")]
298 min_prefix_tokens: u32,
299
300 #[serde(default = "default_openai_idle_expiration")]
301 idle_expiration_seconds: u64,
302
303 #[serde(default = "default_true")]
304 pub surface_metrics: bool,
305
306 #[serde(default)]
309 pub auxiliary_explicit_cache: bool,
310
311 #[serde(default = "default_openai_prompt_cache_key_mode")]
314 pub prompt_cache_key_mode: OpenAIPromptCacheKeyMode,
315
316 #[serde(default)]
321 pub prompt_cache_retention: Option<PromptCacheRetention>,
322
323 #[serde(default)]
325 pub prompt_cache_ttl: Option<PromptCacheTtl>,
326}
327
328impl Default for OpenAIPromptCacheSettings {
329 fn default() -> Self {
330 Self {
331 enabled: default_true(),
332 min_prefix_tokens: default_openai_min_prefix_tokens(),
333 idle_expiration_seconds: default_openai_idle_expiration(),
334 surface_metrics: default_true(),
335 auxiliary_explicit_cache: false,
336 prompt_cache_key_mode: default_openai_prompt_cache_key_mode(),
337 prompt_cache_retention: None,
338 prompt_cache_ttl: None,
339 }
340 }
341}
342
343impl OpenAIPromptCacheSettings {
344 fn validate(&self) -> anyhow::Result<()> {
347 if let Some(PromptCacheRetention::Unknown) = self.prompt_cache_retention {
348 anyhow::bail!("prompt_cache_retention must be one of: in_memory, 24h");
349 }
350 Ok(())
351 }
352}
353
354#[must_use]
357pub fn build_openai_prompt_cache_key(
358 prompt_cache_enabled: bool,
359 prompt_cache_key_mode: &OpenAIPromptCacheKeyMode,
360 lineage_id: Option<&str>,
361) -> Option<String> {
362 if !prompt_cache_enabled {
363 return None;
364 }
365
366 let lineage_id = lineage_id.map(str::trim).filter(|value| !value.is_empty());
367 match prompt_cache_key_mode {
368 OpenAIPromptCacheKeyMode::Session => lineage_id.map(|lineage_id| format!("vtcode:openai:{lineage_id}")),
369 OpenAIPromptCacheKeyMode::Off => None,
370 }
371}
372
373fn affinity_namespace(provider_name: &str) -> Option<&'static str> {
378 match provider_name.trim().to_ascii_lowercase().as_str() {
379 "openai" => Some("openai"),
380 "merge-gateway" => Some("merge"),
381 "openrouter" => Some("openrouter"),
382 "xai" => Some("xai"),
383 "opencode-go" => Some("opencode-go"),
384 _ => None,
385 }
386}
387
388#[must_use]
392pub fn map_prompt_cache_key_for_provider(provider_name: &str, key: String) -> String {
393 match affinity_namespace(provider_name) {
394 Some(namespace) => key.replacen("vtcode:openai:", &format!("vtcode:{namespace}:"), 1),
395 None => key,
396 }
397}
398
399#[must_use]
401pub fn session_affinity_provider(provider_name: &str) -> bool {
402 affinity_namespace(provider_name).is_some()
403}
404
405#[must_use]
413pub fn session_affinity_key_enabled(
414 provider_name: &str,
415 global_prompt_cache_enabled: bool,
416 openai_prompt_cache_enabled: bool,
417) -> bool {
418 if !global_prompt_cache_enabled {
419 return false;
420 }
421 let provider = provider_name.trim().to_ascii_lowercase();
422 match provider.as_str() {
423 "openai" | "merge-gateway" => openai_prompt_cache_enabled,
424 _ => affinity_namespace(&provider).is_some(),
425 }
426}
427
428#[must_use]
434pub fn build_session_affinity_prompt_cache_key(
435 provider_name: &str,
436 session_affinity_enabled: bool,
437 prompt_cache_key_mode: &OpenAIPromptCacheKeyMode,
438 lineage_id: Option<&str>,
439) -> Option<String> {
440 if !session_affinity_enabled {
441 return None;
442 }
443 let provider = provider_name.trim().to_ascii_lowercase();
444 let lineage = lineage_id.map(str::trim).filter(|value| !value.is_empty())?;
445 match provider.as_str() {
446 "openai" | "merge-gateway" => match prompt_cache_key_mode {
447 OpenAIPromptCacheKeyMode::Session => {
448 Some(map_prompt_cache_key_for_provider(&provider, format!("vtcode:openai:{lineage}")))
449 }
450 OpenAIPromptCacheKeyMode::Off => None,
451 },
452 _ => affinity_namespace(&provider).map(|namespace| format!("vtcode:{namespace}:{lineage}")),
453 }
454}
455
456#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
458#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
459#[serde(rename_all = "snake_case")]
460pub enum OpenAIPromptCacheKeyMode {
461 Off,
463 #[default]
465 Session,
466}
467
468#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
470#[derive(Debug, Clone, Deserialize, Serialize)]
471pub struct AnthropicPromptCacheSettings {
472 #[serde(default = "default_true")]
473 pub enabled: bool,
474
475 #[serde(default = "default_anthropic_tools_ttl")]
480 pub tools_ttl_seconds: u64,
481
482 #[serde(default = "default_anthropic_messages_ttl")]
486 pub messages_ttl_seconds: u64,
487
488 #[serde(default = "default_anthropic_max_breakpoints")]
491 pub max_breakpoints: u8,
492
493 #[serde(default = "default_true")]
495 pub cache_system_messages: bool,
496
497 #[serde(default = "default_true")]
499 pub cache_user_messages: bool,
500
501 #[serde(default = "default_true")]
504 pub cache_tool_definitions: bool,
505
506 #[serde(default = "default_min_message_length")]
510 pub min_message_length_for_cache: usize,
511
512 #[serde(default = "default_anthropic_extended_ttl")]
515 pub extended_ttl_seconds: Option<u64>,
516
517 #[serde(default)]
521 pub prefer_extended_ttl: bool,
522}
523
524impl Default for AnthropicPromptCacheSettings {
525 fn default() -> Self {
526 Self {
527 enabled: default_true(),
528 tools_ttl_seconds: default_anthropic_tools_ttl(),
529 messages_ttl_seconds: default_anthropic_messages_ttl(),
530 max_breakpoints: default_anthropic_max_breakpoints(),
531 cache_system_messages: default_true(),
532 cache_user_messages: default_true(),
533 cache_tool_definitions: default_true(),
534 min_message_length_for_cache: default_min_message_length(),
535 extended_ttl_seconds: default_anthropic_extended_ttl(),
536 prefer_extended_ttl: false,
537 }
538 }
539}
540
541#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
543#[derive(Debug, Clone, Deserialize, Serialize)]
544pub struct GeminiPromptCacheSettings {
545 #[serde(default = "default_true")]
546 pub enabled: bool,
547
548 #[serde(default = "default_gemini_mode")]
549 pub mode: GeminiPromptCacheMode,
550
551 #[serde(default = "default_gemini_min_prefix_tokens")]
552 min_prefix_tokens: u32,
553
554 #[serde(default = "default_gemini_explicit_ttl")]
556 pub explicit_ttl_seconds: Option<u64>,
557}
558
559impl Default for GeminiPromptCacheSettings {
560 fn default() -> Self {
561 Self {
562 enabled: default_true(),
563 mode: GeminiPromptCacheMode::default(),
564 min_prefix_tokens: default_gemini_min_prefix_tokens(),
565 explicit_ttl_seconds: default_gemini_explicit_ttl(),
566 }
567 }
568}
569
570#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
572#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
573#[serde(rename_all = "snake_case")]
574#[derive(Default)]
575pub enum GeminiPromptCacheMode {
576 #[default]
577 Implicit,
578 Explicit,
579 Off,
580}
581
582#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
584#[derive(Debug, Clone, Deserialize, Serialize)]
585pub struct OpenRouterPromptCacheSettings {
586 #[serde(default = "default_true")]
587 pub enabled: bool,
588
589 #[serde(default = "default_true")]
591 propagate_provider_capabilities: bool,
592
593 #[serde(default = "default_true")]
595 pub report_savings: bool,
596}
597
598impl Default for OpenRouterPromptCacheSettings {
599 fn default() -> Self {
600 Self {
601 enabled: default_true(),
602 propagate_provider_capabilities: default_true(),
603 report_savings: default_true(),
604 }
605 }
606}
607
608#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
610#[derive(Debug, Clone, Deserialize, Serialize)]
611pub struct MoonshotPromptCacheSettings {
612 #[serde(default = "default_moonshot_enabled")]
613 enabled: bool,
614}
615
616impl Default for MoonshotPromptCacheSettings {
617 fn default() -> Self {
618 Self { enabled: default_moonshot_enabled() }
619 }
620}
621
622#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
624#[derive(Debug, Clone, Deserialize, Serialize)]
625pub struct DeepSeekPromptCacheSettings {
626 #[serde(default = "default_true")]
627 pub enabled: bool,
628
629 #[serde(default = "default_true")]
631 pub surface_metrics: bool,
632}
633
634impl Default for DeepSeekPromptCacheSettings {
635 fn default() -> Self {
636 Self {
637 enabled: default_true(),
638 surface_metrics: default_true(),
639 }
640 }
641}
642
643#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
645#[derive(Debug, Clone, Deserialize, Serialize)]
646pub struct ZaiPromptCacheSettings {
647 #[serde(default = "default_zai_enabled")]
648 enabled: bool,
649}
650
651impl Default for ZaiPromptCacheSettings {
652 fn default() -> Self {
653 Self { enabled: default_zai_enabled() }
654 }
655}
656
657fn default_enabled() -> bool {
658 prompt_cache::DEFAULT_ENABLED
659}
660
661fn default_cache_dir() -> String {
662 VtCodePaths::resolve()
663 .map(|paths| paths.cache_dir().join("prompts").display().to_string())
664 .unwrap_or_default()
665}
666
667fn default_max_entries() -> usize {
668 prompt_cache::DEFAULT_MAX_ENTRIES
669}
670
671fn default_max_age_days() -> u64 {
672 prompt_cache::DEFAULT_MAX_AGE_DAYS
673}
674
675fn default_auto_cleanup() -> bool {
676 prompt_cache::DEFAULT_AUTO_CLEANUP
677}
678
679fn default_min_quality_threshold() -> f64 {
680 prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD
681}
682
683fn default_cache_friendly_prompt_shaping() -> bool {
684 prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING
685}
686
687fn default_openai_min_prefix_tokens() -> u32 {
688 prompt_cache::OPENAI_MIN_PREFIX_TOKENS
689}
690
691fn default_openai_idle_expiration() -> u64 {
692 prompt_cache::OPENAI_IDLE_EXPIRATION_SECONDS
693}
694
695fn default_openai_prompt_cache_key_mode() -> OpenAIPromptCacheKeyMode {
696 OpenAIPromptCacheKeyMode::Session
697}
698
699fn default_anthropic_extended_ttl() -> Option<u64> {
700 Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
701}
702
703fn default_anthropic_tools_ttl() -> u64 {
704 prompt_cache::ANTHROPIC_TOOLS_TTL_SECONDS
705}
706
707fn default_anthropic_messages_ttl() -> u64 {
708 prompt_cache::ANTHROPIC_MESSAGES_TTL_SECONDS
709}
710
711fn default_anthropic_max_breakpoints() -> u8 {
712 prompt_cache::ANTHROPIC_MAX_BREAKPOINTS
713}
714
715fn default_min_message_length() -> usize {
716 prompt_cache::ANTHROPIC_MIN_MESSAGE_LENGTH_FOR_CACHE
717}
718
719fn default_gemini_min_prefix_tokens() -> u32 {
720 prompt_cache::GEMINI_MIN_PREFIX_TOKENS
721}
722
723fn default_gemini_explicit_ttl() -> Option<u64> {
724 Some(prompt_cache::GEMINI_EXPLICIT_DEFAULT_TTL_SECONDS)
725}
726
727fn default_gemini_mode() -> GeminiPromptCacheMode {
728 GeminiPromptCacheMode::Implicit
729}
730
731fn default_zai_enabled() -> bool {
732 prompt_cache::ZAI_CACHE_ENABLED
733}
734
735fn default_moonshot_enabled() -> bool {
736 prompt_cache::MOONSHOT_CACHE_ENABLED
737}
738
739fn resolve_path(input: &str, workspace_root: Option<&Path>) -> PathBuf {
740 let trimmed = input.trim();
741 if trimmed.is_empty() {
742 return resolve_default_cache_dir();
743 }
744
745 if let Some(stripped) = trimmed.strip_prefix("~/").or_else(|| trimmed.strip_prefix("~\\")) {
746 if let Some(home) = dirs::home_dir() {
747 return home.join(stripped);
748 }
749 return PathBuf::from(stripped);
750 }
751
752 let candidate = Path::new(trimmed);
753 if candidate.is_absolute() {
754 return candidate.to_path_buf();
755 }
756
757 if let Some(root) = workspace_root {
758 return root.join(candidate);
759 }
760
761 candidate.to_path_buf()
762}
763
764fn resolve_default_cache_dir() -> PathBuf {
765 VtCodePaths::resolve()
766 .map(|paths| paths.cache_dir().join("prompts"))
767 .unwrap_or_default()
768}
769
770impl PromptCachingConfig {
771 pub(crate) fn validate(&self) -> anyhow::Result<()> {
773 self.providers.openai.validate()?;
775 Ok(())
776 }
777}
778
779#[cfg(test)]
780mod tests;
781#[cfg(test)]
782mod ttl_tests;