Skip to main content

vtcode_config/core/
prompt_cache.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 the 30-minute TTL.
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::GPT6Luna
26        )
27}
28
29/// Global prompt caching configuration loaded from vtcode.toml
30#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
31#[derive(Debug, Clone, Deserialize, Serialize)]
32pub struct PromptCachingConfig {
33    /// Enable prompt caching features globally
34    #[serde(default = "default_enabled")]
35    pub enabled: bool,
36
37    /// Base directory for local prompt cache storage (supports `~` expansion)
38    #[serde(default = "default_cache_dir")]
39    pub cache_dir: String,
40
41    /// Maximum number of cached prompt entries to retain on disk
42    #[serde(default = "default_max_entries")]
43    pub max_entries: usize,
44
45    /// Maximum age (in days) before cached entries are purged
46    #[serde(default = "default_max_age_days")]
47    pub max_age_days: u64,
48
49    /// Automatically evict stale entries on startup/shutdown
50    #[serde(default = "default_auto_cleanup")]
51    pub enable_auto_cleanup: bool,
52
53    /// Minimum quality score required before persisting an entry
54    #[serde(default = "default_min_quality_threshold")]
55    pub min_quality_threshold: f64,
56
57    /// Enable prompt-shaping optimizations that improve provider-side cache locality.
58    /// When enabled, VT Code keeps volatile runtime context at the end of prompt text.
59    #[serde(default = "default_cache_friendly_prompt_shaping")]
60    pub cache_friendly_prompt_shaping: bool,
61
62    /// Keep the wire tool catalog identical across planning and execution
63    /// turns (union of both modes' tools). Planning toggles then do not
64    /// rewrite the tool prefix. The fail-closed execution gate still blocks
65    /// mutations during planning.
66    #[serde(default = "default_true")]
67    pub stable_tool_catalog_across_modes: bool,
68
69    /// Warn before a request when the pause since the previous request likely
70    /// exceeded the provider prompt cache lifetime (advisory only).
71    #[serde(default = "default_true")]
72    pub gap_warning_enabled: bool,
73
74    /// Override for the cache-gap warning threshold in seconds. When unset,
75    /// a provider-specific default is used (Anthropic 300s, OpenAI 600s).
76    #[serde(default)]
77    pub gap_warning_threshold_secs: Option<u64>,
78
79    /// Provider specific overrides
80    #[serde(default)]
81    pub providers: ProviderPromptCachingConfig,
82}
83
84impl Default for PromptCachingConfig {
85    fn default() -> Self {
86        let cache_dir = default_cache_dir();
87        Self {
88            enabled: default_enabled() && !cache_dir.is_empty(),
89            cache_dir,
90            max_entries: default_max_entries(),
91            max_age_days: default_max_age_days(),
92            enable_auto_cleanup: default_auto_cleanup(),
93            min_quality_threshold: default_min_quality_threshold(),
94            cache_friendly_prompt_shaping: default_cache_friendly_prompt_shaping(),
95            stable_tool_catalog_across_modes: true,
96            gap_warning_enabled: default_true(),
97            gap_warning_threshold_secs: None,
98            providers: ProviderPromptCachingConfig::default(),
99        }
100    }
101}
102
103impl PromptCachingConfig {
104    /// Resolve the configured cache directory to an absolute path
105    ///
106    /// - `~` is expanded to the user's home directory when available
107    /// - Relative paths are resolved against the provided workspace root when supplied
108    /// - Falls back to the configured string when neither applies
109    pub fn resolve_cache_dir(&self, workspace_root: Option<&Path>) -> PathBuf {
110        resolve_path(&self.cache_dir, workspace_root)
111    }
112
113    /// Returns true when prompt caching is active for the given provider runtime name.
114    pub fn is_provider_enabled(&self, provider_name: &str) -> bool {
115        if !self.enabled {
116            return false;
117        }
118
119        match provider_name.to_ascii_lowercase().as_str() {
120            "openai" | "merge-gateway" => self.providers.openai.enabled,
121            "anthropic" | "minimax" => self.providers.anthropic.enabled,
122            "gemini" => {
123                self.providers.gemini.enabled && !matches!(self.providers.gemini.mode, GeminiPromptCacheMode::Off)
124            }
125            "openrouter" => self.providers.openrouter.enabled,
126            "moonshot" => self.providers.moonshot.enabled,
127            "deepseek" => self.providers.deepseek.enabled,
128            "zai" => self.providers.zai.enabled,
129            _ => false,
130        }
131    }
132
133    /// Resolve the cache-gap warning threshold in seconds for a provider.
134    ///
135    /// Returns `None` when gap warnings or provider caching are disabled.
136    /// Call the model-aware resolver when the active model is known.
137    pub fn gap_threshold_secs(&self, provider_name: &str) -> Option<u64> {
138        self.gap_threshold_secs_for_model(provider_name, "")
139    }
140
141    /// Model-aware advisory threshold, not a guarantee of server-side expiry.
142    /// GPT-5.6+ uses a minimum 30-minute TTL even when legacy 24h retention is configured.
143    pub fn gap_threshold_secs_for_model(&self, provider_name: &str, model: &str) -> Option<u64> {
144        if !self.gap_warning_enabled || !self.is_provider_enabled(provider_name) {
145            return None;
146        }
147
148        let provider = provider_name.to_ascii_lowercase();
149        let modern_openai = provider == "openai" && supports_openai_explicit_prompt_cache(model);
150        if let Some(threshold) = self.gap_warning_threshold_secs {
151            return Some(threshold);
152        }
153
154        Some(match provider.as_str() {
155            "anthropic" | "minimax" => prompt_cache::ANTHROPIC_CACHE_GAP_WARNING_SECONDS,
156            "openai"
157                if modern_openai || self.providers.openai.prompt_cache_retention == Some(PromptCacheRetention::H24) =>
158            {
159                30 * 60
160            }
161            "openai" => prompt_cache::OPENAI_CACHE_GAP_WARNING_SECONDS,
162            _ => prompt_cache::DEFAULT_CACHE_GAP_WARNING_SECONDS,
163        })
164    }
165}
166
167/// Per-provider configuration overrides
168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
169#[derive(Debug, Clone, Deserialize, Serialize, Default)]
170pub struct ProviderPromptCachingConfig {
171    #[serde(default = "OpenAIPromptCacheSettings::default")]
172    pub openai: OpenAIPromptCacheSettings,
173
174    #[serde(default = "AnthropicPromptCacheSettings::default")]
175    pub anthropic: AnthropicPromptCacheSettings,
176
177    #[serde(default = "GeminiPromptCacheSettings::default")]
178    pub gemini: GeminiPromptCacheSettings,
179
180    #[serde(default = "OpenRouterPromptCacheSettings::default")]
181    pub openrouter: OpenRouterPromptCacheSettings,
182
183    #[serde(default = "MoonshotPromptCacheSettings::default")]
184    moonshot: MoonshotPromptCacheSettings,
185
186    #[serde(default = "DeepSeekPromptCacheSettings::default")]
187    pub deepseek: DeepSeekPromptCacheSettings,
188
189    #[serde(default = "ZaiPromptCacheSettings::default")]
190    zai: ZaiPromptCacheSettings,
191}
192
193/// OpenAI prompt cache retention policy.
194/// Controls the model-side prompt caching window.
195#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
197pub enum PromptCacheRetention {
198    /// In-memory caching (short-lived, free)
199    #[serde(rename = "in_memory")]
200    InMemory,
201    /// 24-hour persistent caching
202    #[default]
203    #[serde(rename = "24h")]
204    H24,
205    /// Forward-compatible catch-all for unknown retention values.
206    ///
207    /// The explicit `rename` keeps the serialized form (`"unknown"`) aligned
208    /// with [`PromptCacheRetention::as_str`]; bare `#[serde(other)]` would
209    /// serialize the variant name as `"Unknown"`.
210    #[serde(other, rename = "unknown")]
211    Unknown,
212}
213
214impl PromptCacheRetention {
215    /// Returns the string representation for the OpenAI API wire format.
216    pub fn as_str(&self) -> &str {
217        match self {
218            Self::InMemory => "in_memory",
219            Self::H24 => "24h",
220            Self::Unknown => "unknown",
221        }
222    }
223}
224
225impl std::fmt::Display for PromptCacheRetention {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        f.write_str(self.as_str())
228    }
229}
230
231/// OpenAI prompt caching controls (automatic with metrics)
232#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
233#[derive(Debug, Clone, Deserialize, Serialize)]
234pub struct OpenAIPromptCacheSettings {
235    #[serde(default = "default_true")]
236    pub enabled: bool,
237
238    #[serde(default = "default_openai_min_prefix_tokens")]
239    min_prefix_tokens: u32,
240
241    #[serde(default = "default_openai_idle_expiration")]
242    idle_expiration_seconds: u64,
243
244    #[serde(default = "default_true")]
245    pub surface_metrics: bool,
246
247    /// Opt in to stable-instruction-only cache writes for independent native
248    /// GPT-5.6+ auxiliary requests. Interactive requests keep implicit caching.
249    #[serde(default)]
250    pub auxiliary_explicit_cache: bool,
251
252    /// Strategy for generating OpenAI `prompt_cache_key`.
253    /// Session-scoped keys derive one stable key per VT Code conversation.
254    #[serde(default = "default_openai_prompt_cache_key_mode")]
255    pub prompt_cache_key_mode: OpenAIPromptCacheKeyMode,
256
257    /// Optional prompt cache retention policy to pass directly into OpenAI Responses API.
258    /// Supported values are "in_memory" and "24h". If set, VT Code will include
259    /// `prompt_cache_retention` in the request body to extend the model-side prompt
260    /// caching window or request the explicit in-memory policy.
261    #[serde(default)]
262    pub prompt_cache_retention: Option<PromptCacheRetention>,
263}
264
265impl Default for OpenAIPromptCacheSettings {
266    fn default() -> Self {
267        Self {
268            enabled: default_true(),
269            min_prefix_tokens: default_openai_min_prefix_tokens(),
270            idle_expiration_seconds: default_openai_idle_expiration(),
271            surface_metrics: default_true(),
272            auxiliary_explicit_cache: false,
273            prompt_cache_key_mode: default_openai_prompt_cache_key_mode(),
274            prompt_cache_retention: None,
275        }
276    }
277}
278
279impl OpenAIPromptCacheSettings {
280    /// Validate OpenAI provider prompt cache settings.
281    /// With the typed enum, invalid values are caught at deserialization time.
282    fn validate(&self) -> anyhow::Result<()> {
283        if let Some(PromptCacheRetention::Unknown) = self.prompt_cache_retention {
284            anyhow::bail!("prompt_cache_retention must be one of: in_memory, 24h");
285        }
286        Ok(())
287    }
288}
289
290/// Build a stable OpenAI `prompt_cache_key` for requests that should share
291/// provider-side cache routing.
292#[must_use]
293pub fn build_openai_prompt_cache_key(
294    prompt_cache_enabled: bool,
295    prompt_cache_key_mode: &OpenAIPromptCacheKeyMode,
296    lineage_id: Option<&str>,
297) -> Option<String> {
298    if !prompt_cache_enabled {
299        return None;
300    }
301
302    let lineage_id = lineage_id.map(str::trim).filter(|value| !value.is_empty());
303    match prompt_cache_key_mode {
304        OpenAIPromptCacheKeyMode::Session => lineage_id.map(|lineage_id| format!("vtcode:openai:{lineage_id}")),
305        OpenAIPromptCacheKeyMode::Off => None,
306    }
307}
308
309/// Rewrite an OpenAI-style cache key for routes that share the wire shape but
310/// need distinct routing stickiness. Keeps the stable session identifier while
311/// namespacing gateway/session-affinity traffic apart from native OpenAI.
312#[must_use]
313pub fn map_prompt_cache_key_for_provider(provider_name: &str, key: String) -> String {
314    let provider = provider_name.trim().to_ascii_lowercase();
315    match provider.as_str() {
316        "merge-gateway" => key.replacen("vtcode:openai:", "vtcode:merge:", 1),
317        "openrouter" => key.replacen("vtcode:openai:", "vtcode:openrouter:", 1),
318        "xai" => key.replacen("vtcode:openai:", "vtcode:xai:", 1),
319        _ => key,
320    }
321}
322
323/// Providers that send lineage-stable session/cache identity on the wire.
324#[must_use]
325pub fn session_affinity_provider(provider_name: &str) -> bool {
326    matches!(
327        provider_name.trim().to_ascii_lowercase().as_str(),
328        "openai" | "merge-gateway" | "openrouter" | "xai"
329    )
330}
331
332/// Whether `LLMRequest.prompt_cache_key` should carry session lineage for this provider.
333///
334/// OpenAI/Merge keep the OpenAI prompt-cache enablement gate.
335/// OpenRouter/xAI need lineage for documented session affinity even when the
336/// OpenAI-specific cache block is off (global prompt-cache master switch still applies).
337#[must_use]
338pub fn session_affinity_key_enabled(
339    provider_name: &str,
340    global_prompt_cache_enabled: bool,
341    openai_prompt_cache_enabled: bool,
342) -> bool {
343    if !global_prompt_cache_enabled {
344        return false;
345    }
346    match provider_name.trim().to_ascii_lowercase().as_str() {
347        "openai" | "merge-gateway" => openai_prompt_cache_enabled,
348        "openrouter" | "xai" => true,
349        _ => false,
350    }
351}
352
353/// Build the namespaced session-affinity / prompt-cache key for a request.
354///
355/// OpenAI/Merge honor [`OpenAIPromptCacheKeyMode`]. OpenRouter/xAI always use a
356/// stable session namespace when the affinity gate is on: sticky routing is
357/// independent of the OpenAI prompt-cache key mode.
358#[must_use]
359pub fn build_session_affinity_prompt_cache_key(
360    provider_name: &str,
361    session_affinity_enabled: bool,
362    prompt_cache_key_mode: &OpenAIPromptCacheKeyMode,
363    lineage_id: Option<&str>,
364) -> Option<String> {
365    if !session_affinity_enabled {
366        return None;
367    }
368    let provider = provider_name.trim().to_ascii_lowercase();
369    let lineage = lineage_id.map(str::trim).filter(|value| !value.is_empty())?;
370    match provider.as_str() {
371        "openai" | "merge-gateway" => match prompt_cache_key_mode {
372            OpenAIPromptCacheKeyMode::Session => {
373                Some(map_prompt_cache_key_for_provider(&provider, format!("vtcode:openai:{lineage}")))
374            }
375            OpenAIPromptCacheKeyMode::Off => None,
376        },
377        "openrouter" | "xai" => Some(format!("vtcode:{provider}:{lineage}")),
378        _ => None,
379    }
380}
381
382/// OpenAI prompt cache key derivation mode.
383#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
384#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
385#[serde(rename_all = "snake_case")]
386pub enum OpenAIPromptCacheKeyMode {
387    /// Do not send `prompt_cache_key` in OpenAI requests.
388    Off,
389    /// Send one stable `prompt_cache_key` per VT Code session.
390    #[default]
391    Session,
392}
393
394/// Anthropic Claude cache control settings
395#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
396#[derive(Debug, Clone, Deserialize, Serialize)]
397pub struct AnthropicPromptCacheSettings {
398    #[serde(default = "default_true")]
399    pub enabled: bool,
400
401    /// Default TTL in seconds for the first cache breakpoint (tools/system).
402    /// Anthropic only supports "5m" (300s) or "1h" (3600s) TTL formats.
403    /// Set to >= 3600 for 1-hour cache on tools and system prompts.
404    /// Default: 3600 (1 hour) - recommended for stable tool definitions
405    #[serde(default = "default_anthropic_tools_ttl")]
406    pub tools_ttl_seconds: u64,
407
408    /// TTL for subsequent cache breakpoints (messages).
409    /// Set to >= 3600 for 1-hour cache on messages.
410    /// Default: 300 (5 minutes) - recommended for frequently changing messages
411    #[serde(default = "default_anthropic_messages_ttl")]
412    pub messages_ttl_seconds: u64,
413
414    /// Maximum number of cache breakpoints to use (max 4 per Anthropic spec).
415    /// Default: 4
416    #[serde(default = "default_anthropic_max_breakpoints")]
417    pub max_breakpoints: u8,
418
419    /// Apply cache control to system prompts by default
420    #[serde(default = "default_true")]
421    pub cache_system_messages: bool,
422
423    /// Apply cache control to user messages exceeding threshold
424    #[serde(default = "default_true")]
425    pub cache_user_messages: bool,
426
427    /// Apply cache control to tool definitions by default
428    /// Default: true (tools are typically stable and benefit from longer caching)
429    #[serde(default = "default_true")]
430    pub cache_tool_definitions: bool,
431
432    /// Minimum message length (in characters) before applying cache control
433    /// to avoid caching very short messages that don't benefit from caching.
434    /// Default: 256 characters (~64 tokens)
435    #[serde(default = "default_min_message_length")]
436    pub min_message_length_for_cache: usize,
437
438    /// Extended TTL for Anthropic prompt caching (in seconds)
439    /// Set to >= 3600 for 1-hour cache on messages
440    #[serde(default = "default_anthropic_extended_ttl")]
441    pub extended_ttl_seconds: Option<u64>,
442
443    /// Prefer the 1h extended TTL for tools/system/messages even when the
444    /// per-breakpoint TTLs are 5m. Use when sessions idle past the 5m cache
445    /// window. Opt-in; 1h writes cost 2x base input.
446    #[serde(default)]
447    pub prefer_extended_ttl: bool,
448}
449
450impl Default for AnthropicPromptCacheSettings {
451    fn default() -> Self {
452        Self {
453            enabled: default_true(),
454            tools_ttl_seconds: default_anthropic_tools_ttl(),
455            messages_ttl_seconds: default_anthropic_messages_ttl(),
456            max_breakpoints: default_anthropic_max_breakpoints(),
457            cache_system_messages: default_true(),
458            cache_user_messages: default_true(),
459            cache_tool_definitions: default_true(),
460            min_message_length_for_cache: default_min_message_length(),
461            extended_ttl_seconds: default_anthropic_extended_ttl(),
462            prefer_extended_ttl: false,
463        }
464    }
465}
466
467/// Gemini API caching preferences
468#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
469#[derive(Debug, Clone, Deserialize, Serialize)]
470pub struct GeminiPromptCacheSettings {
471    #[serde(default = "default_true")]
472    pub enabled: bool,
473
474    #[serde(default = "default_gemini_mode")]
475    pub mode: GeminiPromptCacheMode,
476
477    #[serde(default = "default_gemini_min_prefix_tokens")]
478    min_prefix_tokens: u32,
479
480    /// TTL for explicit caches (ignored in implicit mode)
481    #[serde(default = "default_gemini_explicit_ttl")]
482    pub explicit_ttl_seconds: Option<u64>,
483}
484
485impl Default for GeminiPromptCacheSettings {
486    fn default() -> Self {
487        Self {
488            enabled: default_true(),
489            mode: GeminiPromptCacheMode::default(),
490            min_prefix_tokens: default_gemini_min_prefix_tokens(),
491            explicit_ttl_seconds: default_gemini_explicit_ttl(),
492        }
493    }
494}
495
496/// Gemini prompt caching mode selection
497#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
498#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
499#[serde(rename_all = "snake_case")]
500#[derive(Default)]
501pub enum GeminiPromptCacheMode {
502    #[default]
503    Implicit,
504    Explicit,
505    Off,
506}
507
508/// OpenRouter passthrough caching controls
509#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
510#[derive(Debug, Clone, Deserialize, Serialize)]
511pub struct OpenRouterPromptCacheSettings {
512    #[serde(default = "default_true")]
513    pub enabled: bool,
514
515    /// Propagate provider cache instructions automatically
516    #[serde(default = "default_true")]
517    propagate_provider_capabilities: bool,
518
519    /// Surface cache savings reported by OpenRouter
520    #[serde(default = "default_true")]
521    pub report_savings: bool,
522}
523
524impl Default for OpenRouterPromptCacheSettings {
525    fn default() -> Self {
526        Self {
527            enabled: default_true(),
528            propagate_provider_capabilities: default_true(),
529            report_savings: default_true(),
530        }
531    }
532}
533
534/// Moonshot prompt caching configuration (leverages server-side reuse)
535#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
536#[derive(Debug, Clone, Deserialize, Serialize)]
537pub struct MoonshotPromptCacheSettings {
538    #[serde(default = "default_moonshot_enabled")]
539    enabled: bool,
540}
541
542impl Default for MoonshotPromptCacheSettings {
543    fn default() -> Self {
544        Self { enabled: default_moonshot_enabled() }
545    }
546}
547
548/// DeepSeek prompt caching configuration (automatic KV cache reuse)
549#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
550#[derive(Debug, Clone, Deserialize, Serialize)]
551pub struct DeepSeekPromptCacheSettings {
552    #[serde(default = "default_true")]
553    pub enabled: bool,
554
555    /// Emit cache hit/miss metrics from responses when available
556    #[serde(default = "default_true")]
557    pub surface_metrics: bool,
558}
559
560impl Default for DeepSeekPromptCacheSettings {
561    fn default() -> Self {
562        Self {
563            enabled: default_true(),
564            surface_metrics: default_true(),
565        }
566    }
567}
568
569/// Z.AI prompt caching configuration (disabled until platform exposes metrics)
570#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
571#[derive(Debug, Clone, Deserialize, Serialize)]
572pub struct ZaiPromptCacheSettings {
573    #[serde(default = "default_zai_enabled")]
574    enabled: bool,
575}
576
577impl Default for ZaiPromptCacheSettings {
578    fn default() -> Self {
579        Self { enabled: default_zai_enabled() }
580    }
581}
582
583fn default_enabled() -> bool {
584    prompt_cache::DEFAULT_ENABLED
585}
586
587fn default_cache_dir() -> String {
588    VtCodePaths::resolve()
589        .map(|paths| paths.cache_dir().join("prompts").display().to_string())
590        .unwrap_or_default()
591}
592
593fn default_max_entries() -> usize {
594    prompt_cache::DEFAULT_MAX_ENTRIES
595}
596
597fn default_max_age_days() -> u64 {
598    prompt_cache::DEFAULT_MAX_AGE_DAYS
599}
600
601fn default_auto_cleanup() -> bool {
602    prompt_cache::DEFAULT_AUTO_CLEANUP
603}
604
605fn default_min_quality_threshold() -> f64 {
606    prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD
607}
608
609fn default_cache_friendly_prompt_shaping() -> bool {
610    prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING
611}
612
613fn default_openai_min_prefix_tokens() -> u32 {
614    prompt_cache::OPENAI_MIN_PREFIX_TOKENS
615}
616
617fn default_openai_idle_expiration() -> u64 {
618    prompt_cache::OPENAI_IDLE_EXPIRATION_SECONDS
619}
620
621fn default_openai_prompt_cache_key_mode() -> OpenAIPromptCacheKeyMode {
622    OpenAIPromptCacheKeyMode::Session
623}
624
625fn default_anthropic_extended_ttl() -> Option<u64> {
626    Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
627}
628
629fn default_anthropic_tools_ttl() -> u64 {
630    prompt_cache::ANTHROPIC_TOOLS_TTL_SECONDS
631}
632
633fn default_anthropic_messages_ttl() -> u64 {
634    prompt_cache::ANTHROPIC_MESSAGES_TTL_SECONDS
635}
636
637fn default_anthropic_max_breakpoints() -> u8 {
638    prompt_cache::ANTHROPIC_MAX_BREAKPOINTS
639}
640
641fn default_min_message_length() -> usize {
642    prompt_cache::ANTHROPIC_MIN_MESSAGE_LENGTH_FOR_CACHE
643}
644
645fn default_gemini_min_prefix_tokens() -> u32 {
646    prompt_cache::GEMINI_MIN_PREFIX_TOKENS
647}
648
649fn default_gemini_explicit_ttl() -> Option<u64> {
650    Some(prompt_cache::GEMINI_EXPLICIT_DEFAULT_TTL_SECONDS)
651}
652
653fn default_gemini_mode() -> GeminiPromptCacheMode {
654    GeminiPromptCacheMode::Implicit
655}
656
657fn default_zai_enabled() -> bool {
658    prompt_cache::ZAI_CACHE_ENABLED
659}
660
661fn default_moonshot_enabled() -> bool {
662    prompt_cache::MOONSHOT_CACHE_ENABLED
663}
664
665fn resolve_path(input: &str, workspace_root: Option<&Path>) -> PathBuf {
666    let trimmed = input.trim();
667    if trimmed.is_empty() {
668        return resolve_default_cache_dir();
669    }
670
671    if let Some(stripped) = trimmed.strip_prefix("~/").or_else(|| trimmed.strip_prefix("~\\")) {
672        if let Some(home) = dirs::home_dir() {
673            return home.join(stripped);
674        }
675        return PathBuf::from(stripped);
676    }
677
678    let candidate = Path::new(trimmed);
679    if candidate.is_absolute() {
680        return candidate.to_path_buf();
681    }
682
683    if let Some(root) = workspace_root {
684        return root.join(candidate);
685    }
686
687    candidate.to_path_buf()
688}
689
690fn resolve_default_cache_dir() -> PathBuf {
691    VtCodePaths::resolve()
692        .map(|paths| paths.cache_dir().join("prompts"))
693        .unwrap_or_default()
694}
695
696impl PromptCachingConfig {
697    /// Validate prompt cache config and provider overrides
698    pub(crate) fn validate(&self) -> anyhow::Result<()> {
699        // Validate OpenAI provider settings
700        self.providers.openai.validate()?;
701        Ok(())
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use assert_fs::TempDir;
709    use std::fs;
710
711    /// Walk up from `CARGO_MANIFEST_DIR` to the directory whose `Cargo.toml`
712    /// contains `[workspace]`. Hardcoding `parent()` breaks when crates are
713    /// nested (e.g. `crates/codegen/<crate>` — three levels deep, not one).
714    fn find_workspace_root() -> PathBuf {
715        let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
716        let mut current = manifest.as_path();
717        while let Some(parent) = current.parent() {
718            let cargo_toml = parent.join("Cargo.toml");
719            if cargo_toml.is_file() && fs::read_to_string(&cargo_toml).is_ok_and(|c| c.contains("[workspace]")) {
720                return parent.to_path_buf();
721            }
722            current = parent;
723        }
724        panic!("could not find workspace root from CARGO_MANIFEST_DIR");
725    }
726
727    #[test]
728    fn prompt_caching_defaults_align_with_constants() {
729        let cfg = PromptCachingConfig::default();
730        assert!(cfg.enabled);
731        assert_eq!(cfg.max_entries, prompt_cache::DEFAULT_MAX_ENTRIES);
732        assert_eq!(cfg.max_age_days, prompt_cache::DEFAULT_MAX_AGE_DAYS);
733        assert!((cfg.min_quality_threshold - prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD).abs() < f64::EPSILON);
734        assert_eq!(cfg.cache_friendly_prompt_shaping, prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING);
735        assert!(cfg.providers.openai.enabled);
736        assert_eq!(cfg.providers.openai.min_prefix_tokens, prompt_cache::OPENAI_MIN_PREFIX_TOKENS);
737        assert_eq!(cfg.providers.openai.prompt_cache_key_mode, OpenAIPromptCacheKeyMode::Session);
738        assert_eq!(cfg.providers.anthropic.extended_ttl_seconds, Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS));
739        assert_eq!(cfg.providers.gemini.mode, GeminiPromptCacheMode::Implicit);
740        assert!(cfg.providers.moonshot.enabled);
741        assert_eq!(cfg.providers.openai.prompt_cache_retention, None);
742    }
743
744    #[test]
745    fn resolve_cache_dir_expands_home() {
746        let cfg = PromptCachingConfig {
747            cache_dir: "~/.custom/cache".to_string(),
748            ..PromptCachingConfig::default()
749        };
750        let resolved = cfg.resolve_cache_dir(None);
751        if let Some(home) = dirs::home_dir() {
752            assert!(resolved.starts_with(home));
753        } else {
754            assert_eq!(resolved, PathBuf::from(".custom/cache"));
755        }
756    }
757
758    #[test]
759    fn resolve_cache_dir_uses_workspace_when_relative() {
760        let temp = TempDir::new().unwrap();
761        let workspace = temp.path();
762        let cfg = PromptCachingConfig {
763            cache_dir: "relative/cache".to_string(),
764            ..PromptCachingConfig::default()
765        };
766        let resolved = cfg.resolve_cache_dir(Some(workspace));
767        assert_eq!(resolved, workspace.join("relative/cache"));
768    }
769
770    #[test]
771    fn prompt_cache_retention_deserializes_valid_values() {
772        let cfg: OpenAIPromptCacheSettings = toml::from_str(
773            r#"
774            prompt_cache_retention = "in_memory"
775            "#,
776        )
777        .unwrap();
778        assert_eq!(cfg.prompt_cache_retention, Some(PromptCacheRetention::InMemory));
779
780        let cfg2: OpenAIPromptCacheSettings = toml::from_str(
781            r#"
782            prompt_cache_retention = "24h"
783            "#,
784        )
785        .unwrap();
786        assert_eq!(cfg2.prompt_cache_retention, Some(PromptCacheRetention::H24));
787    }
788
789    #[test]
790    fn prompt_cache_retention_unknown_values_deserialize_as_unknown() {
791        let cfg: OpenAIPromptCacheSettings = toml::from_str(
792            r#"
793            prompt_cache_retention = "5m"
794            "#,
795        )
796        .unwrap();
797        assert_eq!(cfg.prompt_cache_retention, Some(PromptCacheRetention::Unknown));
798    }
799
800    /// `PromptCacheRetention` exposes the same value through `as_str()`/
801    /// `Display` and the serde wire form. Both the config table and the OpenAI
802    /// API wire format read this enum, so a one-sided change (e.g. a bare
803    /// `#[serde(other)]` that serializes `Unknown` as `"Unknown"`) would desync
804    /// them. Pin every variant.
805    #[test]
806    fn prompt_cache_retention_as_str_matches_serde() {
807        for retention in [
808            PromptCacheRetention::InMemory,
809            PromptCacheRetention::H24,
810            PromptCacheRetention::Unknown,
811        ] {
812            assert_eq!(
813                serde_json::to_value(retention).unwrap(),
814                serde_json::json!(retention.as_str()),
815                "serde wire form drifted from as_str() for {retention:?}"
816            );
817        }
818    }
819
820    #[test]
821    fn validate_prompt_cache_rejects_unknown_retention() {
822        let mut cfg = PromptCachingConfig::default();
823        cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::Unknown);
824        assert!(cfg.validate().is_err());
825    }
826
827    #[test]
828    fn prompt_cache_key_mode_parses_from_toml() {
829        let parsed: PromptCachingConfig = toml::from_str(
830            r#"
831[providers.openai]
832prompt_cache_key_mode = "off"
833"#,
834        )
835        .expect("prompt cache config should parse");
836
837        assert_eq!(parsed.providers.openai.prompt_cache_key_mode, OpenAIPromptCacheKeyMode::Off);
838    }
839
840    #[test]
841    fn build_openai_prompt_cache_key_uses_trimmed_lineage_id() {
842        let key = build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Session, Some(" lineage-abc "));
843
844        assert_eq!(key.as_deref(), Some("vtcode:openai:lineage-abc"));
845    }
846
847    #[test]
848    fn map_prompt_cache_key_namespaces_merge_gateway() {
849        assert_eq!(
850            map_prompt_cache_key_for_provider("merge-gateway", "vtcode:openai:abc".to_string()),
851            "vtcode:merge:abc"
852        );
853        assert_eq!(map_prompt_cache_key_for_provider("openai", "vtcode:openai:abc".to_string()), "vtcode:openai:abc");
854        assert_eq!(
855            map_prompt_cache_key_for_provider("openrouter", "vtcode:openai:abc".to_string()),
856            "vtcode:openrouter:abc"
857        );
858        assert_eq!(map_prompt_cache_key_for_provider("xai", "vtcode:openai:abc".to_string()), "vtcode:xai:abc");
859    }
860
861    #[test]
862    fn session_affinity_key_gate_covers_openrouter_and_xai() {
863        assert!(session_affinity_key_enabled("openrouter", true, false));
864        assert!(session_affinity_key_enabled("xai", true, false));
865        assert!(session_affinity_key_enabled("openai", true, true));
866        assert!(!session_affinity_key_enabled("openai", true, false));
867        assert!(!session_affinity_key_enabled("openrouter", false, true));
868        assert!(!session_affinity_key_enabled("anthropic", true, true));
869        assert!(session_affinity_provider("openrouter"));
870        assert!(session_affinity_provider("xai"));
871        assert!(!session_affinity_provider("anthropic"));
872    }
873
874    #[test]
875    fn build_session_affinity_prompt_cache_key_namespaces_providers() {
876        let mode = OpenAIPromptCacheKeyMode::Session;
877        assert_eq!(
878            build_session_affinity_prompt_cache_key("openai", true, &mode, Some("lineage-1")).as_deref(),
879            Some("vtcode:openai:lineage-1")
880        );
881        assert_eq!(
882            build_session_affinity_prompt_cache_key("merge-gateway", true, &mode, Some("lineage-1")).as_deref(),
883            Some("vtcode:merge:lineage-1")
884        );
885        assert_eq!(
886            build_session_affinity_prompt_cache_key("openrouter", true, &mode, Some("lineage-1")).as_deref(),
887            Some("vtcode:openrouter:lineage-1")
888        );
889        assert_eq!(
890            build_session_affinity_prompt_cache_key("xai", true, &mode, Some("lineage-1")).as_deref(),
891            Some("vtcode:xai:lineage-1")
892        );
893        // OpenAI/Merge honor Off mode; OpenRouter/xAI ignore it for sticky routing.
894        assert_eq!(
895            build_session_affinity_prompt_cache_key("openai", true, &OpenAIPromptCacheKeyMode::Off, Some("lineage-1")),
896            None
897        );
898        assert_eq!(
899            build_session_affinity_prompt_cache_key(
900                "openrouter",
901                true,
902                &OpenAIPromptCacheKeyMode::Off,
903                Some("lineage-1")
904            )
905            .as_deref(),
906            Some("vtcode:openrouter:lineage-1")
907        );
908        // Disabled gate or blank lineage yields None.
909        assert_eq!(build_session_affinity_prompt_cache_key("openrouter", false, &mode, Some("lineage-1")), None);
910        assert_eq!(build_session_affinity_prompt_cache_key("openrouter", true, &mode, Some("  ")), None);
911        assert_eq!(build_session_affinity_prompt_cache_key("anthropic", true, &mode, Some("lineage-1")), None);
912    }
913
914    #[test]
915    fn build_openai_prompt_cache_key_honors_disabled_or_off_mode() {
916        assert_eq!(build_openai_prompt_cache_key(false, &OpenAIPromptCacheKeyMode::Session, Some("id")), None);
917        assert_eq!(build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Off, Some("id")), None);
918        assert_eq!(build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Session, Some("  ")), None);
919    }
920
921    #[test]
922    fn provider_enablement_respects_global_and_provider_flags() {
923        let mut cfg = PromptCachingConfig { enabled: true, ..PromptCachingConfig::default() };
924        cfg.providers.openai.enabled = true;
925        assert!(cfg.is_provider_enabled("openai"));
926        assert!(cfg.is_provider_enabled("merge-gateway"));
927
928        cfg.enabled = false;
929        assert!(!cfg.is_provider_enabled("openai"));
930        assert!(!cfg.is_provider_enabled("merge-gateway"));
931    }
932
933    #[test]
934    fn provider_enablement_handles_aliases_and_modes() {
935        let mut cfg = PromptCachingConfig { enabled: true, ..PromptCachingConfig::default() };
936
937        cfg.providers.anthropic.enabled = true;
938        assert!(cfg.is_provider_enabled("minimax"));
939
940        cfg.providers.gemini.enabled = true;
941        cfg.providers.gemini.mode = GeminiPromptCacheMode::Off;
942        assert!(!cfg.is_provider_enabled("gemini"));
943    }
944
945    #[test]
946    fn gap_threshold_uses_provider_defaults() {
947        let cfg = PromptCachingConfig::default();
948        assert_eq!(cfg.gap_threshold_secs("anthropic"), Some(prompt_cache::ANTHROPIC_CACHE_GAP_WARNING_SECONDS));
949        assert_eq!(cfg.gap_threshold_secs("minimax"), Some(prompt_cache::ANTHROPIC_CACHE_GAP_WARNING_SECONDS));
950        assert_eq!(cfg.gap_threshold_secs("openai"), Some(prompt_cache::OPENAI_CACHE_GAP_WARNING_SECONDS));
951        assert_eq!(cfg.gap_threshold_secs("gemini"), Some(prompt_cache::DEFAULT_CACHE_GAP_WARNING_SECONDS));
952    }
953
954    #[test]
955    fn gap_threshold_uses_model_ttl_and_ignores_legacy_retention_for_modern_models() {
956        let mut cfg = PromptCachingConfig::default();
957        for retention in [
958            None,
959            Some(PromptCacheRetention::H24),
960            Some(PromptCacheRetention::InMemory),
961        ] {
962            cfg.providers.openai.prompt_cache_retention = retention;
963            for model in ["gpt-5.6-sol", "gpt-6-luna", "gpt-6.1-sol"] {
964                assert_eq!(cfg.gap_threshold_secs_for_model("openai", model), Some(1800), "{model}");
965            }
966        }
967        assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-5.1-mini"), Some(600));
968        assert_eq!(cfg.gap_threshold_secs_for_model("merge-gateway", "gpt-6-luna"), Some(300));
969        cfg.gap_warning_threshold_secs = Some(77);
970        assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-6-luna"), Some(77));
971        cfg.enabled = false;
972        assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-6-luna"), None);
973    }
974
975    #[test]
976    fn explicit_cache_models_reject_migration_aliases_and_auxiliary_mode_is_opt_in() {
977        for model in [
978            "gpt-5.4",
979            "gpt-5-codex",
980            "gpt",
981            "gpt-5.1-mini",
982            "openai/gpt-6-luna",
983            "gpt-6-future",
984        ] {
985            assert!(!supports_openai_explicit_prompt_cache(model), "{model}");
986        }
987        for model in ["gpt-5.6", "gpt-5.6-terra", "gpt-6-astra", "gpt-6-sol"] {
988            assert!(supports_openai_explicit_prompt_cache(model), "{model}");
989        }
990        let defaults: OpenAIPromptCacheSettings = toml::from_str("").unwrap();
991        assert!(!defaults.auxiliary_explicit_cache);
992        let configured: OpenAIPromptCacheSettings = toml::from_str("auxiliary_explicit_cache = true").unwrap();
993        assert!(configured.auxiliary_explicit_cache);
994    }
995
996    #[test]
997    fn gap_threshold_respects_disable_switches() {
998        let cfg = PromptCachingConfig { gap_warning_enabled: false, ..Default::default() };
999        assert_eq!(cfg.gap_threshold_secs("anthropic"), None);
1000
1001        let mut cfg = PromptCachingConfig::default();
1002        cfg.providers.anthropic.enabled = false;
1003        assert_eq!(cfg.gap_threshold_secs("anthropic"), None);
1004        assert_eq!(cfg.gap_threshold_secs("minimax"), None);
1005
1006        let cfg = PromptCachingConfig { enabled: false, ..Default::default() };
1007        assert_eq!(cfg.gap_threshold_secs("openai"), None);
1008    }
1009
1010    #[test]
1011    fn gap_threshold_treats_extended_retention_as_an_advisory_not_a_guarantee() {
1012        let mut cfg = PromptCachingConfig::default();
1013        cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::H24);
1014        assert_eq!(cfg.gap_threshold_secs("openai"), Some(1800));
1015        cfg.gap_warning_threshold_secs = Some(42);
1016        assert_eq!(cfg.gap_threshold_secs("openai"), Some(42));
1017        cfg.gap_warning_threshold_secs = None;
1018
1019        cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::InMemory);
1020        assert_eq!(cfg.gap_threshold_secs("openai"), Some(prompt_cache::OPENAI_CACHE_GAP_WARNING_SECONDS));
1021    }
1022
1023    #[test]
1024    fn gap_threshold_honors_explicit_override() {
1025        let cfg = PromptCachingConfig {
1026            gap_warning_threshold_secs: Some(42),
1027            ..Default::default()
1028        };
1029        assert_eq!(cfg.gap_threshold_secs("anthropic"), Some(42));
1030        assert_eq!(cfg.gap_threshold_secs("openai"), Some(42));
1031    }
1032
1033    #[test]
1034    fn gap_warning_fields_parse_from_toml() {
1035        let parsed: PromptCachingConfig = toml::from_str(
1036            r#"
1037gap_warning_enabled = false
1038gap_warning_threshold_secs = 120
1039"#,
1040        )
1041        .expect("prompt cache config should parse");
1042
1043        assert!(!parsed.gap_warning_enabled);
1044        assert_eq!(parsed.gap_warning_threshold_secs, Some(120));
1045
1046        let defaults: PromptCachingConfig = toml::from_str("").expect("empty config");
1047        assert!(defaults.gap_warning_enabled);
1048        assert_eq!(defaults.gap_warning_threshold_secs, None);
1049    }
1050
1051    #[test]
1052    fn bundled_config_templates_match_prompt_cache_defaults() {
1053        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1054        let bootstrap_template =
1055            fs::read_to_string(manifest_dir.join("data/default_config.toml")).expect("bootstrap config template");
1056        let config: crate::VTCodeConfig = toml::from_str(&bootstrap_template).expect("parse bootstrap template");
1057        assert!(config.prompt_cache.enabled);
1058        assert!(config.prompt_cache.cache_friendly_prompt_shaping);
1059        assert!(bootstrap_template.contains("# prompt_cache_retention = \"24h\""));
1060
1061        let workspace_root = find_workspace_root();
1062        let example_config =
1063            fs::read_to_string(workspace_root.join("vtcode.toml.example")).expect("vtcode.toml.example");
1064        if example_config.contains("[prompt_cache]") {
1065            assert!(example_config.contains("enabled = true"));
1066            assert!(example_config.contains("cache_friendly_prompt_shaping = true"));
1067            assert!(example_config.contains("# prompt_cache_retention = \"24h\""));
1068        }
1069
1070        let prompt_cache_guide = fs::read_to_string(workspace_root.join("docs/tools/PROMPT_CACHING_GUIDE.md"))
1071            .expect("prompt caching guide");
1072        // The guide is markdownlint-wrapped, so sentences may be reflowed across
1073        // lines; match against whitespace-normalized text instead of raw lines.
1074        let normalized_guide = vtcode_commons::formatting::collapse_whitespace(&prompt_cache_guide);
1075        assert!(
1076            normalized_guide
1077                .contains("VT Code enables `prompt_cache.cache_friendly_prompt_shaping = true` by default.")
1078        );
1079        // The retention doc block (guide lines 44-48) states `prompt_cache_retention` is
1080        // opt-in for older supported Responses models; assert that intent survives doc rewrites.
1081        assert!(normalized_guide.contains("`prompt_cache_retention` is opt-in for older supported Responses models"));
1082
1083        let field_reference = fs::read_to_string(workspace_root.join("docs/config/CONFIG_FIELD_REFERENCE.md"))
1084            .expect("config field reference");
1085        assert!(field_reference.contains("`prompt_cache.cache_friendly_prompt_shaping`"));
1086        assert!(field_reference.contains("`prompt_cache.providers.openai.prompt_cache_retention`"));
1087    }
1088
1089    #[test]
1090    fn bundled_example_config_parses_and_validates() {
1091        // Guards the tracked template against stale keys and schema drift:
1092        // the shipped example must load as VTCodeConfig and pass validation.
1093        let workspace_root = find_workspace_root();
1094        let example = fs::read_to_string(workspace_root.join("vtcode.toml.example")).expect("vtcode.toml.example");
1095        let config: crate::VTCodeConfig = toml::from_str(&example).expect("example config should parse");
1096        config.validate().expect("example config should validate");
1097    }
1098}