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
373#[must_use]
377pub fn map_prompt_cache_key_for_provider(provider_name: &str, key: String) -> String {
378 let provider = provider_name.trim().to_ascii_lowercase();
379 match provider.as_str() {
380 "merge-gateway" => key.replacen("vtcode:openai:", "vtcode:merge:", 1),
381 "openrouter" => key.replacen("vtcode:openai:", "vtcode:openrouter:", 1),
382 "xai" => key.replacen("vtcode:openai:", "vtcode:xai:", 1),
383 _ => key,
384 }
385}
386
387#[must_use]
389pub fn session_affinity_provider(provider_name: &str) -> bool {
390 matches!(
391 provider_name.trim().to_ascii_lowercase().as_str(),
392 "openai" | "merge-gateway" | "openrouter" | "xai"
393 )
394}
395
396#[must_use]
402pub fn session_affinity_key_enabled(
403 provider_name: &str,
404 global_prompt_cache_enabled: bool,
405 openai_prompt_cache_enabled: bool,
406) -> bool {
407 if !global_prompt_cache_enabled {
408 return false;
409 }
410 match provider_name.trim().to_ascii_lowercase().as_str() {
411 "openai" | "merge-gateway" => openai_prompt_cache_enabled,
412 "openrouter" | "xai" => true,
413 _ => false,
414 }
415}
416
417#[must_use]
423pub fn build_session_affinity_prompt_cache_key(
424 provider_name: &str,
425 session_affinity_enabled: bool,
426 prompt_cache_key_mode: &OpenAIPromptCacheKeyMode,
427 lineage_id: Option<&str>,
428) -> Option<String> {
429 if !session_affinity_enabled {
430 return None;
431 }
432 let provider = provider_name.trim().to_ascii_lowercase();
433 let lineage = lineage_id.map(str::trim).filter(|value| !value.is_empty())?;
434 match provider.as_str() {
435 "openai" | "merge-gateway" => match prompt_cache_key_mode {
436 OpenAIPromptCacheKeyMode::Session => {
437 Some(map_prompt_cache_key_for_provider(&provider, format!("vtcode:openai:{lineage}")))
438 }
439 OpenAIPromptCacheKeyMode::Off => None,
440 },
441 "openrouter" | "xai" => Some(format!("vtcode:{provider}:{lineage}")),
442 _ => None,
443 }
444}
445
446#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
448#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
449#[serde(rename_all = "snake_case")]
450pub enum OpenAIPromptCacheKeyMode {
451 Off,
453 #[default]
455 Session,
456}
457
458#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
460#[derive(Debug, Clone, Deserialize, Serialize)]
461pub struct AnthropicPromptCacheSettings {
462 #[serde(default = "default_true")]
463 pub enabled: bool,
464
465 #[serde(default = "default_anthropic_tools_ttl")]
470 pub tools_ttl_seconds: u64,
471
472 #[serde(default = "default_anthropic_messages_ttl")]
476 pub messages_ttl_seconds: u64,
477
478 #[serde(default = "default_anthropic_max_breakpoints")]
481 pub max_breakpoints: u8,
482
483 #[serde(default = "default_true")]
485 pub cache_system_messages: bool,
486
487 #[serde(default = "default_true")]
489 pub cache_user_messages: bool,
490
491 #[serde(default = "default_true")]
494 pub cache_tool_definitions: bool,
495
496 #[serde(default = "default_min_message_length")]
500 pub min_message_length_for_cache: usize,
501
502 #[serde(default = "default_anthropic_extended_ttl")]
505 pub extended_ttl_seconds: Option<u64>,
506
507 #[serde(default)]
511 pub prefer_extended_ttl: bool,
512}
513
514impl Default for AnthropicPromptCacheSettings {
515 fn default() -> Self {
516 Self {
517 enabled: default_true(),
518 tools_ttl_seconds: default_anthropic_tools_ttl(),
519 messages_ttl_seconds: default_anthropic_messages_ttl(),
520 max_breakpoints: default_anthropic_max_breakpoints(),
521 cache_system_messages: default_true(),
522 cache_user_messages: default_true(),
523 cache_tool_definitions: default_true(),
524 min_message_length_for_cache: default_min_message_length(),
525 extended_ttl_seconds: default_anthropic_extended_ttl(),
526 prefer_extended_ttl: false,
527 }
528 }
529}
530
531#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
533#[derive(Debug, Clone, Deserialize, Serialize)]
534pub struct GeminiPromptCacheSettings {
535 #[serde(default = "default_true")]
536 pub enabled: bool,
537
538 #[serde(default = "default_gemini_mode")]
539 pub mode: GeminiPromptCacheMode,
540
541 #[serde(default = "default_gemini_min_prefix_tokens")]
542 min_prefix_tokens: u32,
543
544 #[serde(default = "default_gemini_explicit_ttl")]
546 pub explicit_ttl_seconds: Option<u64>,
547}
548
549impl Default for GeminiPromptCacheSettings {
550 fn default() -> Self {
551 Self {
552 enabled: default_true(),
553 mode: GeminiPromptCacheMode::default(),
554 min_prefix_tokens: default_gemini_min_prefix_tokens(),
555 explicit_ttl_seconds: default_gemini_explicit_ttl(),
556 }
557 }
558}
559
560#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
562#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
563#[serde(rename_all = "snake_case")]
564#[derive(Default)]
565pub enum GeminiPromptCacheMode {
566 #[default]
567 Implicit,
568 Explicit,
569 Off,
570}
571
572#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
574#[derive(Debug, Clone, Deserialize, Serialize)]
575pub struct OpenRouterPromptCacheSettings {
576 #[serde(default = "default_true")]
577 pub enabled: bool,
578
579 #[serde(default = "default_true")]
581 propagate_provider_capabilities: bool,
582
583 #[serde(default = "default_true")]
585 pub report_savings: bool,
586}
587
588impl Default for OpenRouterPromptCacheSettings {
589 fn default() -> Self {
590 Self {
591 enabled: default_true(),
592 propagate_provider_capabilities: default_true(),
593 report_savings: default_true(),
594 }
595 }
596}
597
598#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
600#[derive(Debug, Clone, Deserialize, Serialize)]
601pub struct MoonshotPromptCacheSettings {
602 #[serde(default = "default_moonshot_enabled")]
603 enabled: bool,
604}
605
606impl Default for MoonshotPromptCacheSettings {
607 fn default() -> Self {
608 Self { enabled: default_moonshot_enabled() }
609 }
610}
611
612#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
614#[derive(Debug, Clone, Deserialize, Serialize)]
615pub struct DeepSeekPromptCacheSettings {
616 #[serde(default = "default_true")]
617 pub enabled: bool,
618
619 #[serde(default = "default_true")]
621 pub surface_metrics: bool,
622}
623
624impl Default for DeepSeekPromptCacheSettings {
625 fn default() -> Self {
626 Self {
627 enabled: default_true(),
628 surface_metrics: default_true(),
629 }
630 }
631}
632
633#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
635#[derive(Debug, Clone, Deserialize, Serialize)]
636pub struct ZaiPromptCacheSettings {
637 #[serde(default = "default_zai_enabled")]
638 enabled: bool,
639}
640
641impl Default for ZaiPromptCacheSettings {
642 fn default() -> Self {
643 Self { enabled: default_zai_enabled() }
644 }
645}
646
647fn default_enabled() -> bool {
648 prompt_cache::DEFAULT_ENABLED
649}
650
651fn default_cache_dir() -> String {
652 VtCodePaths::resolve()
653 .map(|paths| paths.cache_dir().join("prompts").display().to_string())
654 .unwrap_or_default()
655}
656
657fn default_max_entries() -> usize {
658 prompt_cache::DEFAULT_MAX_ENTRIES
659}
660
661fn default_max_age_days() -> u64 {
662 prompt_cache::DEFAULT_MAX_AGE_DAYS
663}
664
665fn default_auto_cleanup() -> bool {
666 prompt_cache::DEFAULT_AUTO_CLEANUP
667}
668
669fn default_min_quality_threshold() -> f64 {
670 prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD
671}
672
673fn default_cache_friendly_prompt_shaping() -> bool {
674 prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING
675}
676
677fn default_openai_min_prefix_tokens() -> u32 {
678 prompt_cache::OPENAI_MIN_PREFIX_TOKENS
679}
680
681fn default_openai_idle_expiration() -> u64 {
682 prompt_cache::OPENAI_IDLE_EXPIRATION_SECONDS
683}
684
685fn default_openai_prompt_cache_key_mode() -> OpenAIPromptCacheKeyMode {
686 OpenAIPromptCacheKeyMode::Session
687}
688
689fn default_anthropic_extended_ttl() -> Option<u64> {
690 Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
691}
692
693fn default_anthropic_tools_ttl() -> u64 {
694 prompt_cache::ANTHROPIC_TOOLS_TTL_SECONDS
695}
696
697fn default_anthropic_messages_ttl() -> u64 {
698 prompt_cache::ANTHROPIC_MESSAGES_TTL_SECONDS
699}
700
701fn default_anthropic_max_breakpoints() -> u8 {
702 prompt_cache::ANTHROPIC_MAX_BREAKPOINTS
703}
704
705fn default_min_message_length() -> usize {
706 prompt_cache::ANTHROPIC_MIN_MESSAGE_LENGTH_FOR_CACHE
707}
708
709fn default_gemini_min_prefix_tokens() -> u32 {
710 prompt_cache::GEMINI_MIN_PREFIX_TOKENS
711}
712
713fn default_gemini_explicit_ttl() -> Option<u64> {
714 Some(prompt_cache::GEMINI_EXPLICIT_DEFAULT_TTL_SECONDS)
715}
716
717fn default_gemini_mode() -> GeminiPromptCacheMode {
718 GeminiPromptCacheMode::Implicit
719}
720
721fn default_zai_enabled() -> bool {
722 prompt_cache::ZAI_CACHE_ENABLED
723}
724
725fn default_moonshot_enabled() -> bool {
726 prompt_cache::MOONSHOT_CACHE_ENABLED
727}
728
729fn resolve_path(input: &str, workspace_root: Option<&Path>) -> PathBuf {
730 let trimmed = input.trim();
731 if trimmed.is_empty() {
732 return resolve_default_cache_dir();
733 }
734
735 if let Some(stripped) = trimmed.strip_prefix("~/").or_else(|| trimmed.strip_prefix("~\\")) {
736 if let Some(home) = dirs::home_dir() {
737 return home.join(stripped);
738 }
739 return PathBuf::from(stripped);
740 }
741
742 let candidate = Path::new(trimmed);
743 if candidate.is_absolute() {
744 return candidate.to_path_buf();
745 }
746
747 if let Some(root) = workspace_root {
748 return root.join(candidate);
749 }
750
751 candidate.to_path_buf()
752}
753
754fn resolve_default_cache_dir() -> PathBuf {
755 VtCodePaths::resolve()
756 .map(|paths| paths.cache_dir().join("prompts"))
757 .unwrap_or_default()
758}
759
760impl PromptCachingConfig {
761 pub(crate) fn validate(&self) -> anyhow::Result<()> {
763 self.providers.openai.validate()?;
765 Ok(())
766 }
767}
768
769#[cfg(test)]
770mod tests;
771#[cfg(test)]
772mod ttl_tests;