Skip to main content

vtcode_config/core/prompt_cache/
mod.rs

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/// Known native models supporting explicit breakpoints and configurable cache lifetimes.
8/// Reject migration-only aliases: parsing an older model as a newer replacement
9/// does not establish support for the older model's wire request.
10#[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/// Requested minimum lifetime for Responses prompt-cache writes.
31#[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
57/// Resolve explicit configuration, then catalog policy, then the default lifetime.
58pub 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/// Global prompt caching configuration loaded from vtcode.toml
73#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct PromptCachingConfig {
76    /// Enable prompt caching features globally
77    #[serde(default = "default_enabled")]
78    pub enabled: bool,
79
80    /// Base directory for local prompt cache storage (supports `~` expansion)
81    #[serde(default = "default_cache_dir")]
82    pub cache_dir: String,
83
84    /// Maximum number of cached prompt entries to retain on disk
85    #[serde(default = "default_max_entries")]
86    pub max_entries: usize,
87
88    /// Maximum age (in days) before cached entries are purged
89    #[serde(default = "default_max_age_days")]
90    pub max_age_days: u64,
91
92    /// Automatically evict stale entries on startup/shutdown
93    #[serde(default = "default_auto_cleanup")]
94    pub enable_auto_cleanup: bool,
95
96    /// Minimum quality score required before persisting an entry
97    #[serde(default = "default_min_quality_threshold")]
98    pub min_quality_threshold: f64,
99
100    /// Enable prompt-shaping optimizations that improve provider-side cache locality.
101    /// When enabled, VT Code keeps volatile runtime context at the end of prompt text.
102    #[serde(default = "default_cache_friendly_prompt_shaping")]
103    pub cache_friendly_prompt_shaping: bool,
104
105    /// Keep the wire tool catalog identical across planning and execution
106    /// turns (union of both modes' tools). Planning toggles then do not
107    /// rewrite the tool prefix. The fail-closed execution gate still blocks
108    /// mutations during planning.
109    #[serde(default = "default_true")]
110    pub stable_tool_catalog_across_modes: bool,
111
112    /// Warn before a request when the pause since the previous request likely
113    /// exceeded the provider prompt cache lifetime (advisory only).
114    #[serde(default = "default_true")]
115    pub gap_warning_enabled: bool,
116
117    /// Override for the cache-gap warning threshold in seconds. When unset,
118    /// a provider-specific default is used (Anthropic 300s, OpenAI 600s).
119    #[serde(default)]
120    pub gap_warning_threshold_secs: Option<u64>,
121
122    /// Provider specific overrides
123    #[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    /// Resolve the configured cache directory to an absolute path
148    ///
149    /// - `~` is expanded to the user's home directory when available
150    /// - Relative paths are resolved against the provided workspace root when supplied
151    /// - Falls back to the configured string when neither applies
152    pub fn resolve_cache_dir(&self, workspace_root: Option<&Path>) -> PathBuf {
153        resolve_path(&self.cache_dir, workspace_root)
154    }
155
156    /// Returns true when prompt caching is active for the given provider runtime name.
157    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    /// Resolve the cache-gap warning threshold in seconds for a provider.
177    ///
178    /// Returns `None` when gap warnings or provider caching are disabled.
179    /// Call the model-aware resolver when the active model is known.
180    pub fn gap_threshold_secs(&self, provider_name: &str) -> Option<u64> {
181        self.gap_threshold_secs_for_model(provider_name, "")
182    }
183
184    /// Prefer the lifetime emitted by the route; otherwise preserve model-aware baseline advisories.
185    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    /// Model-aware advisory threshold for callers without resolved route capabilities.
218    /// Runtime callers should pass the effective lifetime to the route-aware resolver.
219    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/// Per-provider configuration overrides
227#[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/// OpenAI prompt cache retention policy.
253/// Controls the model-side prompt caching window.
254#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
256pub enum PromptCacheRetention {
257    /// In-memory caching (short-lived, free)
258    #[serde(rename = "in_memory")]
259    InMemory,
260    /// 24-hour persistent caching
261    #[default]
262    #[serde(rename = "24h")]
263    H24,
264    /// Forward-compatible catch-all for unknown retention values.
265    ///
266    /// The explicit `rename` keeps the serialized form (`"unknown"`) aligned
267    /// with [`PromptCacheRetention::as_str`]; bare `#[serde(other)]` would
268    /// serialize the variant name as `"Unknown"`.
269    #[serde(other, rename = "unknown")]
270    Unknown,
271}
272
273impl PromptCacheRetention {
274    /// Returns the string representation for the OpenAI API wire format.
275    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/// OpenAI prompt caching controls (automatic with metrics)
291#[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    /// Opt in to stable-instruction-only cache writes for independent native
307    /// GPT-5.6+ auxiliary requests. Interactive requests keep implicit caching.
308    #[serde(default)]
309    pub auxiliary_explicit_cache: bool,
310
311    /// Strategy for generating OpenAI `prompt_cache_key`.
312    /// Session-scoped keys derive one stable key per VT Code conversation.
313    #[serde(default = "default_openai_prompt_cache_key_mode")]
314    pub prompt_cache_key_mode: OpenAIPromptCacheKeyMode,
315
316    /// Optional prompt cache retention policy to pass directly into OpenAI Responses API.
317    /// Supported values are "in_memory" and "24h". If set, VT Code will include
318    /// `prompt_cache_retention` in the request body to extend the model-side prompt
319    /// caching window or request the explicit in-memory policy.
320    #[serde(default)]
321    pub prompt_cache_retention: Option<PromptCacheRetention>,
322
323    /// Optional Responses minimum write lifetime; defaults to catalog policy or 30m.
324    #[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    /// Validate OpenAI provider prompt cache settings.
345    /// With the typed enum, invalid values are caught at deserialization time.
346    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/// Build a stable OpenAI `prompt_cache_key` for requests that should share
355/// provider-side cache routing.
356#[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/// Wire namespace for a provider's session-affinity key (`vtcode:{namespace}:{lineage}`).
374/// `None` means the provider carries no session lineage. Single source of truth
375/// for the affinity provider set: the gate and key builders below derive
376/// membership from this instead of repeating provider lists.
377fn 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/// Rewrite an OpenAI-style cache key for routes that share the wire shape but
389/// need distinct routing stickiness. Keeps the stable session identifier while
390/// namespacing gateway/session-affinity traffic apart from native OpenAI.
391#[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/// Providers that send lineage-stable session/cache identity on the wire.
400#[must_use]
401pub fn session_affinity_provider(provider_name: &str) -> bool {
402    affinity_namespace(provider_name).is_some()
403}
404
405/// Whether `LLMRequest.prompt_cache_key` should carry session lineage for this provider.
406///
407/// OpenAI/Merge keep the OpenAI prompt-cache enablement gate.
408/// OpenRouter/xAI/OpenCode Go need lineage for documented session affinity even
409/// when the OpenAI-specific cache block is off (global prompt-cache master
410/// switch still applies). OpenCode Go rejects requests without its session
411/// header, so its lineage must not depend on the OpenAI cache flag.
412#[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/// Build the namespaced session-affinity / prompt-cache key for a request.
429///
430/// OpenAI/Merge honor [`OpenAIPromptCacheKeyMode`]. OpenRouter/xAI/OpenCode Go
431/// always use a stable session namespace when the affinity gate is on: sticky
432/// routing is independent of the OpenAI prompt-cache key mode.
433#[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/// OpenAI prompt cache key derivation mode.
457#[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    /// Do not send `prompt_cache_key` in OpenAI requests.
462    Off,
463    /// Send one stable `prompt_cache_key` per VT Code session.
464    #[default]
465    Session,
466}
467
468/// Anthropic Claude cache control settings
469#[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    /// Default TTL in seconds for the first cache breakpoint (tools/system).
476    /// Anthropic only supports "5m" (300s) or "1h" (3600s) TTL formats.
477    /// Set to >= 3600 for 1-hour cache on tools and system prompts.
478    /// Default: 3600 (1 hour) - recommended for stable tool definitions
479    #[serde(default = "default_anthropic_tools_ttl")]
480    pub tools_ttl_seconds: u64,
481
482    /// TTL for subsequent cache breakpoints (messages).
483    /// Set to >= 3600 for 1-hour cache on messages.
484    /// Default: 300 (5 minutes) - recommended for frequently changing messages
485    #[serde(default = "default_anthropic_messages_ttl")]
486    pub messages_ttl_seconds: u64,
487
488    /// Maximum number of cache breakpoints to use (max 4 per Anthropic spec).
489    /// Default: 4
490    #[serde(default = "default_anthropic_max_breakpoints")]
491    pub max_breakpoints: u8,
492
493    /// Apply cache control to system prompts by default
494    #[serde(default = "default_true")]
495    pub cache_system_messages: bool,
496
497    /// Apply cache control to user messages exceeding threshold
498    #[serde(default = "default_true")]
499    pub cache_user_messages: bool,
500
501    /// Apply cache control to tool definitions by default
502    /// Default: true (tools are typically stable and benefit from longer caching)
503    #[serde(default = "default_true")]
504    pub cache_tool_definitions: bool,
505
506    /// Minimum message length (in characters) before applying cache control
507    /// to avoid caching very short messages that don't benefit from caching.
508    /// Default: 256 characters (~64 tokens)
509    #[serde(default = "default_min_message_length")]
510    pub min_message_length_for_cache: usize,
511
512    /// Extended TTL for Anthropic prompt caching (in seconds)
513    /// Set to >= 3600 for 1-hour cache on messages
514    #[serde(default = "default_anthropic_extended_ttl")]
515    pub extended_ttl_seconds: Option<u64>,
516
517    /// Prefer the 1h extended TTL for tools/system/messages even when the
518    /// per-breakpoint TTLs are 5m. Use when sessions idle past the 5m cache
519    /// window. Opt-in; 1h writes cost 2x base input.
520    #[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/// Gemini API caching preferences
542#[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    /// TTL for explicit caches (ignored in implicit mode)
555    #[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/// Gemini prompt caching mode selection
571#[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/// OpenRouter passthrough caching controls
583#[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    /// Propagate provider cache instructions automatically
590    #[serde(default = "default_true")]
591    propagate_provider_capabilities: bool,
592
593    /// Surface cache savings reported by OpenRouter
594    #[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/// Moonshot prompt caching configuration (leverages server-side reuse)
609#[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/// DeepSeek prompt caching configuration (automatic KV cache reuse)
623#[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    /// Emit cache hit/miss metrics from responses when available
630    #[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/// Z.AI prompt caching configuration (disabled until platform exposes metrics)
644#[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    /// Validate prompt cache config and provider overrides
772    pub(crate) fn validate(&self) -> anyhow::Result<()> {
773        // Validate OpenAI provider settings
774        self.providers.openai.validate()?;
775        Ok(())
776    }
777}
778
779#[cfg(test)]
780mod tests;
781#[cfg(test)]
782mod ttl_tests;