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/// Rewrite an OpenAI-style cache key for routes that share the wire shape but
374/// need distinct routing stickiness. Keeps the stable session identifier while
375/// namespacing gateway/session-affinity traffic apart from native OpenAI.
376#[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/// Providers that send lineage-stable session/cache identity on the wire.
388#[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/// Whether `LLMRequest.prompt_cache_key` should carry session lineage for this provider.
397///
398/// OpenAI/Merge keep the OpenAI prompt-cache enablement gate.
399/// OpenRouter/xAI need lineage for documented session affinity even when the
400/// OpenAI-specific cache block is off (global prompt-cache master switch still applies).
401#[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/// Build the namespaced session-affinity / prompt-cache key for a request.
418///
419/// OpenAI/Merge honor [`OpenAIPromptCacheKeyMode`]. OpenRouter/xAI always use a
420/// stable session namespace when the affinity gate is on: sticky routing is
421/// independent of the OpenAI prompt-cache key mode.
422#[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/// OpenAI prompt cache key derivation mode.
447#[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    /// Do not send `prompt_cache_key` in OpenAI requests.
452    Off,
453    /// Send one stable `prompt_cache_key` per VT Code session.
454    #[default]
455    Session,
456}
457
458/// Anthropic Claude cache control settings
459#[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    /// Default TTL in seconds for the first cache breakpoint (tools/system).
466    /// Anthropic only supports "5m" (300s) or "1h" (3600s) TTL formats.
467    /// Set to >= 3600 for 1-hour cache on tools and system prompts.
468    /// Default: 3600 (1 hour) - recommended for stable tool definitions
469    #[serde(default = "default_anthropic_tools_ttl")]
470    pub tools_ttl_seconds: u64,
471
472    /// TTL for subsequent cache breakpoints (messages).
473    /// Set to >= 3600 for 1-hour cache on messages.
474    /// Default: 300 (5 minutes) - recommended for frequently changing messages
475    #[serde(default = "default_anthropic_messages_ttl")]
476    pub messages_ttl_seconds: u64,
477
478    /// Maximum number of cache breakpoints to use (max 4 per Anthropic spec).
479    /// Default: 4
480    #[serde(default = "default_anthropic_max_breakpoints")]
481    pub max_breakpoints: u8,
482
483    /// Apply cache control to system prompts by default
484    #[serde(default = "default_true")]
485    pub cache_system_messages: bool,
486
487    /// Apply cache control to user messages exceeding threshold
488    #[serde(default = "default_true")]
489    pub cache_user_messages: bool,
490
491    /// Apply cache control to tool definitions by default
492    /// Default: true (tools are typically stable and benefit from longer caching)
493    #[serde(default = "default_true")]
494    pub cache_tool_definitions: bool,
495
496    /// Minimum message length (in characters) before applying cache control
497    /// to avoid caching very short messages that don't benefit from caching.
498    /// Default: 256 characters (~64 tokens)
499    #[serde(default = "default_min_message_length")]
500    pub min_message_length_for_cache: usize,
501
502    /// Extended TTL for Anthropic prompt caching (in seconds)
503    /// Set to >= 3600 for 1-hour cache on messages
504    #[serde(default = "default_anthropic_extended_ttl")]
505    pub extended_ttl_seconds: Option<u64>,
506
507    /// Prefer the 1h extended TTL for tools/system/messages even when the
508    /// per-breakpoint TTLs are 5m. Use when sessions idle past the 5m cache
509    /// window. Opt-in; 1h writes cost 2x base input.
510    #[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/// Gemini API caching preferences
532#[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    /// TTL for explicit caches (ignored in implicit mode)
545    #[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/// Gemini prompt caching mode selection
561#[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/// OpenRouter passthrough caching controls
573#[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    /// Propagate provider cache instructions automatically
580    #[serde(default = "default_true")]
581    propagate_provider_capabilities: bool,
582
583    /// Surface cache savings reported by OpenRouter
584    #[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/// Moonshot prompt caching configuration (leverages server-side reuse)
599#[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/// DeepSeek prompt caching configuration (automatic KV cache reuse)
613#[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    /// Emit cache hit/miss metrics from responses when available
620    #[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/// Z.AI prompt caching configuration (disabled until platform exposes metrics)
634#[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    /// Validate prompt cache config and provider overrides
762    pub(crate) fn validate(&self) -> anyhow::Result<()> {
763        // Validate OpenAI provider settings
764        self.providers.openai.validate()?;
765        Ok(())
766    }
767}
768
769#[cfg(test)]
770mod tests;
771#[cfg(test)]
772mod ttl_tests;