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
625#[allow(dead_code, reason = "Intentional compatibility, platform, or test-only suppression.")]
626fn default_anthropic_extended_ttl() -> Option<u64> {
627    Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
628}
629
630fn default_anthropic_tools_ttl() -> u64 {
631    prompt_cache::ANTHROPIC_TOOLS_TTL_SECONDS
632}
633
634fn default_anthropic_messages_ttl() -> u64 {
635    prompt_cache::ANTHROPIC_MESSAGES_TTL_SECONDS
636}
637
638fn default_anthropic_max_breakpoints() -> u8 {
639    prompt_cache::ANTHROPIC_MAX_BREAKPOINTS
640}
641
642#[allow(dead_code, reason = "Intentional compatibility, platform, or test-only suppression.")]
643fn default_min_message_length() -> usize {
644    prompt_cache::ANTHROPIC_MIN_MESSAGE_LENGTH_FOR_CACHE
645}
646
647fn default_gemini_min_prefix_tokens() -> u32 {
648    prompt_cache::GEMINI_MIN_PREFIX_TOKENS
649}
650
651fn default_gemini_explicit_ttl() -> Option<u64> {
652    Some(prompt_cache::GEMINI_EXPLICIT_DEFAULT_TTL_SECONDS)
653}
654
655fn default_gemini_mode() -> GeminiPromptCacheMode {
656    GeminiPromptCacheMode::Implicit
657}
658
659fn default_zai_enabled() -> bool {
660    prompt_cache::ZAI_CACHE_ENABLED
661}
662
663fn default_moonshot_enabled() -> bool {
664    prompt_cache::MOONSHOT_CACHE_ENABLED
665}
666
667fn resolve_path(input: &str, workspace_root: Option<&Path>) -> PathBuf {
668    let trimmed = input.trim();
669    if trimmed.is_empty() {
670        return resolve_default_cache_dir();
671    }
672
673    if let Some(stripped) = trimmed.strip_prefix("~/").or_else(|| trimmed.strip_prefix("~\\")) {
674        if let Some(home) = dirs::home_dir() {
675            return home.join(stripped);
676        }
677        return PathBuf::from(stripped);
678    }
679
680    let candidate = Path::new(trimmed);
681    if candidate.is_absolute() {
682        return candidate.to_path_buf();
683    }
684
685    if let Some(root) = workspace_root {
686        return root.join(candidate);
687    }
688
689    candidate.to_path_buf()
690}
691
692fn resolve_default_cache_dir() -> PathBuf {
693    VtCodePaths::resolve()
694        .map(|paths| paths.cache_dir().join("prompts"))
695        .unwrap_or_default()
696}
697
698impl PromptCachingConfig {
699    /// Validate prompt cache config and provider overrides
700    pub(crate) fn validate(&self) -> anyhow::Result<()> {
701        // Validate OpenAI provider settings
702        self.providers.openai.validate()?;
703        Ok(())
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use assert_fs::TempDir;
711    use std::fs;
712
713    /// Walk up from `CARGO_MANIFEST_DIR` to the directory whose `Cargo.toml`
714    /// contains `[workspace]`. Hardcoding `parent()` breaks when crates are
715    /// nested (e.g. `crates/codegen/<crate>` — three levels deep, not one).
716    fn find_workspace_root() -> PathBuf {
717        let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
718        let mut current = manifest.as_path();
719        while let Some(parent) = current.parent() {
720            let cargo_toml = parent.join("Cargo.toml");
721            if cargo_toml.is_file() && fs::read_to_string(&cargo_toml).is_ok_and(|c| c.contains("[workspace]")) {
722                return parent.to_path_buf();
723            }
724            current = parent;
725        }
726        panic!("could not find workspace root from CARGO_MANIFEST_DIR");
727    }
728
729    #[test]
730    fn prompt_caching_defaults_align_with_constants() {
731        let cfg = PromptCachingConfig::default();
732        assert!(cfg.enabled);
733        assert_eq!(cfg.max_entries, prompt_cache::DEFAULT_MAX_ENTRIES);
734        assert_eq!(cfg.max_age_days, prompt_cache::DEFAULT_MAX_AGE_DAYS);
735        assert!((cfg.min_quality_threshold - prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD).abs() < f64::EPSILON);
736        assert_eq!(cfg.cache_friendly_prompt_shaping, prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING);
737        assert!(cfg.providers.openai.enabled);
738        assert_eq!(cfg.providers.openai.min_prefix_tokens, prompt_cache::OPENAI_MIN_PREFIX_TOKENS);
739        assert_eq!(cfg.providers.openai.prompt_cache_key_mode, OpenAIPromptCacheKeyMode::Session);
740        assert_eq!(cfg.providers.anthropic.extended_ttl_seconds, Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS));
741        assert_eq!(cfg.providers.gemini.mode, GeminiPromptCacheMode::Implicit);
742        assert!(cfg.providers.moonshot.enabled);
743        assert_eq!(cfg.providers.openai.prompt_cache_retention, None);
744    }
745
746    #[test]
747    fn resolve_cache_dir_expands_home() {
748        let cfg = PromptCachingConfig {
749            cache_dir: "~/.custom/cache".to_string(),
750            ..PromptCachingConfig::default()
751        };
752        let resolved = cfg.resolve_cache_dir(None);
753        if let Some(home) = dirs::home_dir() {
754            assert!(resolved.starts_with(home));
755        } else {
756            assert_eq!(resolved, PathBuf::from(".custom/cache"));
757        }
758    }
759
760    #[test]
761    fn resolve_cache_dir_uses_workspace_when_relative() {
762        let temp = TempDir::new().unwrap();
763        let workspace = temp.path();
764        let cfg = PromptCachingConfig {
765            cache_dir: "relative/cache".to_string(),
766            ..PromptCachingConfig::default()
767        };
768        let resolved = cfg.resolve_cache_dir(Some(workspace));
769        assert_eq!(resolved, workspace.join("relative/cache"));
770    }
771
772    #[test]
773    fn prompt_cache_retention_deserializes_valid_values() {
774        let cfg: OpenAIPromptCacheSettings = toml::from_str(
775            r#"
776            prompt_cache_retention = "in_memory"
777            "#,
778        )
779        .unwrap();
780        assert_eq!(cfg.prompt_cache_retention, Some(PromptCacheRetention::InMemory));
781
782        let cfg2: OpenAIPromptCacheSettings = toml::from_str(
783            r#"
784            prompt_cache_retention = "24h"
785            "#,
786        )
787        .unwrap();
788        assert_eq!(cfg2.prompt_cache_retention, Some(PromptCacheRetention::H24));
789    }
790
791    #[test]
792    fn prompt_cache_retention_unknown_values_deserialize_as_unknown() {
793        let cfg: OpenAIPromptCacheSettings = toml::from_str(
794            r#"
795            prompt_cache_retention = "5m"
796            "#,
797        )
798        .unwrap();
799        assert_eq!(cfg.prompt_cache_retention, Some(PromptCacheRetention::Unknown));
800    }
801
802    /// `PromptCacheRetention` exposes the same value through `as_str()`/
803    /// `Display` and the serde wire form. Both the config table and the OpenAI
804    /// API wire format read this enum, so a one-sided change (e.g. a bare
805    /// `#[serde(other)]` that serializes `Unknown` as `"Unknown"`) would desync
806    /// them. Pin every variant.
807    #[test]
808    fn prompt_cache_retention_as_str_matches_serde() {
809        for retention in [
810            PromptCacheRetention::InMemory,
811            PromptCacheRetention::H24,
812            PromptCacheRetention::Unknown,
813        ] {
814            assert_eq!(
815                serde_json::to_value(retention).unwrap(),
816                serde_json::json!(retention.as_str()),
817                "serde wire form drifted from as_str() for {retention:?}"
818            );
819        }
820    }
821
822    #[test]
823    fn validate_prompt_cache_rejects_unknown_retention() {
824        let mut cfg = PromptCachingConfig::default();
825        cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::Unknown);
826        assert!(cfg.validate().is_err());
827    }
828
829    #[test]
830    fn prompt_cache_key_mode_parses_from_toml() {
831        let parsed: PromptCachingConfig = toml::from_str(
832            r#"
833[providers.openai]
834prompt_cache_key_mode = "off"
835"#,
836        )
837        .expect("prompt cache config should parse");
838
839        assert_eq!(parsed.providers.openai.prompt_cache_key_mode, OpenAIPromptCacheKeyMode::Off);
840    }
841
842    #[test]
843    fn build_openai_prompt_cache_key_uses_trimmed_lineage_id() {
844        let key = build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Session, Some(" lineage-abc "));
845
846        assert_eq!(key.as_deref(), Some("vtcode:openai:lineage-abc"));
847    }
848
849    #[test]
850    fn map_prompt_cache_key_namespaces_merge_gateway() {
851        assert_eq!(
852            map_prompt_cache_key_for_provider("merge-gateway", "vtcode:openai:abc".to_string()),
853            "vtcode:merge:abc"
854        );
855        assert_eq!(map_prompt_cache_key_for_provider("openai", "vtcode:openai:abc".to_string()), "vtcode:openai:abc");
856        assert_eq!(
857            map_prompt_cache_key_for_provider("openrouter", "vtcode:openai:abc".to_string()),
858            "vtcode:openrouter:abc"
859        );
860        assert_eq!(map_prompt_cache_key_for_provider("xai", "vtcode:openai:abc".to_string()), "vtcode:xai:abc");
861    }
862
863    #[test]
864    fn session_affinity_key_gate_covers_openrouter_and_xai() {
865        assert!(session_affinity_key_enabled("openrouter", true, false));
866        assert!(session_affinity_key_enabled("xai", true, false));
867        assert!(session_affinity_key_enabled("openai", true, true));
868        assert!(!session_affinity_key_enabled("openai", true, false));
869        assert!(!session_affinity_key_enabled("openrouter", false, true));
870        assert!(!session_affinity_key_enabled("anthropic", true, true));
871        assert!(session_affinity_provider("openrouter"));
872        assert!(session_affinity_provider("xai"));
873        assert!(!session_affinity_provider("anthropic"));
874    }
875
876    #[test]
877    fn build_session_affinity_prompt_cache_key_namespaces_providers() {
878        let mode = OpenAIPromptCacheKeyMode::Session;
879        assert_eq!(
880            build_session_affinity_prompt_cache_key("openai", true, &mode, Some("lineage-1")).as_deref(),
881            Some("vtcode:openai:lineage-1")
882        );
883        assert_eq!(
884            build_session_affinity_prompt_cache_key("merge-gateway", true, &mode, Some("lineage-1")).as_deref(),
885            Some("vtcode:merge:lineage-1")
886        );
887        assert_eq!(
888            build_session_affinity_prompt_cache_key("openrouter", true, &mode, Some("lineage-1")).as_deref(),
889            Some("vtcode:openrouter:lineage-1")
890        );
891        assert_eq!(
892            build_session_affinity_prompt_cache_key("xai", true, &mode, Some("lineage-1")).as_deref(),
893            Some("vtcode:xai:lineage-1")
894        );
895        // OpenAI/Merge honor Off mode; OpenRouter/xAI ignore it for sticky routing.
896        assert_eq!(
897            build_session_affinity_prompt_cache_key("openai", true, &OpenAIPromptCacheKeyMode::Off, Some("lineage-1")),
898            None
899        );
900        assert_eq!(
901            build_session_affinity_prompt_cache_key(
902                "openrouter",
903                true,
904                &OpenAIPromptCacheKeyMode::Off,
905                Some("lineage-1")
906            )
907            .as_deref(),
908            Some("vtcode:openrouter:lineage-1")
909        );
910        // Disabled gate or blank lineage yields None.
911        assert_eq!(build_session_affinity_prompt_cache_key("openrouter", false, &mode, Some("lineage-1")), None);
912        assert_eq!(build_session_affinity_prompt_cache_key("openrouter", true, &mode, Some("  ")), None);
913        assert_eq!(build_session_affinity_prompt_cache_key("anthropic", true, &mode, Some("lineage-1")), None);
914    }
915
916    #[test]
917    fn build_openai_prompt_cache_key_honors_disabled_or_off_mode() {
918        assert_eq!(build_openai_prompt_cache_key(false, &OpenAIPromptCacheKeyMode::Session, Some("id")), None);
919        assert_eq!(build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Off, Some("id")), None);
920        assert_eq!(build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Session, Some("  ")), None);
921    }
922
923    #[test]
924    fn provider_enablement_respects_global_and_provider_flags() {
925        let mut cfg = PromptCachingConfig { enabled: true, ..PromptCachingConfig::default() };
926        cfg.providers.openai.enabled = true;
927        assert!(cfg.is_provider_enabled("openai"));
928        assert!(cfg.is_provider_enabled("merge-gateway"));
929
930        cfg.enabled = false;
931        assert!(!cfg.is_provider_enabled("openai"));
932        assert!(!cfg.is_provider_enabled("merge-gateway"));
933    }
934
935    #[test]
936    fn provider_enablement_handles_aliases_and_modes() {
937        let mut cfg = PromptCachingConfig { enabled: true, ..PromptCachingConfig::default() };
938
939        cfg.providers.anthropic.enabled = true;
940        assert!(cfg.is_provider_enabled("minimax"));
941
942        cfg.providers.gemini.enabled = true;
943        cfg.providers.gemini.mode = GeminiPromptCacheMode::Off;
944        assert!(!cfg.is_provider_enabled("gemini"));
945    }
946
947    #[test]
948    fn gap_threshold_uses_provider_defaults() {
949        let cfg = PromptCachingConfig::default();
950        assert_eq!(cfg.gap_threshold_secs("anthropic"), Some(prompt_cache::ANTHROPIC_CACHE_GAP_WARNING_SECONDS));
951        assert_eq!(cfg.gap_threshold_secs("minimax"), Some(prompt_cache::ANTHROPIC_CACHE_GAP_WARNING_SECONDS));
952        assert_eq!(cfg.gap_threshold_secs("openai"), Some(prompt_cache::OPENAI_CACHE_GAP_WARNING_SECONDS));
953        assert_eq!(cfg.gap_threshold_secs("gemini"), Some(prompt_cache::DEFAULT_CACHE_GAP_WARNING_SECONDS));
954    }
955
956    #[test]
957    fn gap_threshold_uses_model_ttl_and_ignores_legacy_retention_for_modern_models() {
958        let mut cfg = PromptCachingConfig::default();
959        for retention in [
960            None,
961            Some(PromptCacheRetention::H24),
962            Some(PromptCacheRetention::InMemory),
963        ] {
964            cfg.providers.openai.prompt_cache_retention = retention;
965            for model in ["gpt-5.6-sol", "gpt-6-luna", "gpt-6.1-sol"] {
966                assert_eq!(cfg.gap_threshold_secs_for_model("openai", model), Some(1800), "{model}");
967            }
968        }
969        assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-5.1-mini"), Some(600));
970        assert_eq!(cfg.gap_threshold_secs_for_model("merge-gateway", "gpt-6-luna"), Some(300));
971        cfg.gap_warning_threshold_secs = Some(77);
972        assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-6-luna"), Some(77));
973        cfg.enabled = false;
974        assert_eq!(cfg.gap_threshold_secs_for_model("openai", "gpt-6-luna"), None);
975    }
976
977    #[test]
978    fn explicit_cache_models_reject_migration_aliases_and_auxiliary_mode_is_opt_in() {
979        for model in [
980            "gpt-5.4",
981            "gpt-5-codex",
982            "gpt",
983            "gpt-5.1-mini",
984            "openai/gpt-6-luna",
985            "gpt-6-future",
986        ] {
987            assert!(!supports_openai_explicit_prompt_cache(model), "{model}");
988        }
989        for model in ["gpt-5.6", "gpt-5.6-terra", "gpt-6-astra", "gpt-6-sol"] {
990            assert!(supports_openai_explicit_prompt_cache(model), "{model}");
991        }
992        let defaults: OpenAIPromptCacheSettings = toml::from_str("").unwrap();
993        assert!(!defaults.auxiliary_explicit_cache);
994        let configured: OpenAIPromptCacheSettings = toml::from_str("auxiliary_explicit_cache = true").unwrap();
995        assert!(configured.auxiliary_explicit_cache);
996    }
997
998    #[test]
999    fn gap_threshold_respects_disable_switches() {
1000        let cfg = PromptCachingConfig { gap_warning_enabled: false, ..Default::default() };
1001        assert_eq!(cfg.gap_threshold_secs("anthropic"), None);
1002
1003        let mut cfg = PromptCachingConfig::default();
1004        cfg.providers.anthropic.enabled = false;
1005        assert_eq!(cfg.gap_threshold_secs("anthropic"), None);
1006        assert_eq!(cfg.gap_threshold_secs("minimax"), None);
1007
1008        let cfg = PromptCachingConfig { enabled: false, ..Default::default() };
1009        assert_eq!(cfg.gap_threshold_secs("openai"), None);
1010    }
1011
1012    #[test]
1013    fn gap_threshold_treats_extended_retention_as_an_advisory_not_a_guarantee() {
1014        let mut cfg = PromptCachingConfig::default();
1015        cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::H24);
1016        assert_eq!(cfg.gap_threshold_secs("openai"), Some(1800));
1017        cfg.gap_warning_threshold_secs = Some(42);
1018        assert_eq!(cfg.gap_threshold_secs("openai"), Some(42));
1019        cfg.gap_warning_threshold_secs = None;
1020
1021        cfg.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::InMemory);
1022        assert_eq!(cfg.gap_threshold_secs("openai"), Some(prompt_cache::OPENAI_CACHE_GAP_WARNING_SECONDS));
1023    }
1024
1025    #[test]
1026    fn gap_threshold_honors_explicit_override() {
1027        let cfg = PromptCachingConfig {
1028            gap_warning_threshold_secs: Some(42),
1029            ..Default::default()
1030        };
1031        assert_eq!(cfg.gap_threshold_secs("anthropic"), Some(42));
1032        assert_eq!(cfg.gap_threshold_secs("openai"), Some(42));
1033    }
1034
1035    #[test]
1036    fn gap_warning_fields_parse_from_toml() {
1037        let parsed: PromptCachingConfig = toml::from_str(
1038            r#"
1039gap_warning_enabled = false
1040gap_warning_threshold_secs = 120
1041"#,
1042        )
1043        .expect("prompt cache config should parse");
1044
1045        assert!(!parsed.gap_warning_enabled);
1046        assert_eq!(parsed.gap_warning_threshold_secs, Some(120));
1047
1048        let defaults: PromptCachingConfig = toml::from_str("").expect("empty config");
1049        assert!(defaults.gap_warning_enabled);
1050        assert_eq!(defaults.gap_warning_threshold_secs, None);
1051    }
1052
1053    #[test]
1054    fn bundled_config_templates_match_prompt_cache_defaults() {
1055        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1056        let bootstrap_template =
1057            fs::read_to_string(manifest_dir.join("data/default_config.toml")).expect("bootstrap config template");
1058        let config: crate::VTCodeConfig = toml::from_str(&bootstrap_template).expect("parse bootstrap template");
1059        assert!(config.prompt_cache.enabled);
1060        assert!(config.prompt_cache.cache_friendly_prompt_shaping);
1061        assert!(bootstrap_template.contains("# prompt_cache_retention = \"24h\""));
1062
1063        let workspace_root = find_workspace_root();
1064        let example_config =
1065            fs::read_to_string(workspace_root.join("vtcode.toml.example")).expect("vtcode.toml.example");
1066        if example_config.contains("[prompt_cache]") {
1067            assert!(example_config.contains("enabled = true"));
1068            assert!(example_config.contains("cache_friendly_prompt_shaping = true"));
1069            assert!(example_config.contains("# prompt_cache_retention = \"24h\""));
1070        }
1071
1072        let prompt_cache_guide = fs::read_to_string(workspace_root.join("docs/tools/PROMPT_CACHING_GUIDE.md"))
1073            .expect("prompt caching guide");
1074        // The guide is markdownlint-wrapped, so sentences may be reflowed across
1075        // lines; match against whitespace-normalized text instead of raw lines.
1076        let normalized_guide = vtcode_commons::formatting::collapse_whitespace(&prompt_cache_guide);
1077        assert!(
1078            normalized_guide
1079                .contains("VT Code enables `prompt_cache.cache_friendly_prompt_shaping = true` by default.")
1080        );
1081        // The retention doc block (guide lines 44-48) states `prompt_cache_retention` is
1082        // opt-in for older supported Responses models; assert that intent survives doc rewrites.
1083        assert!(normalized_guide.contains("`prompt_cache_retention` is opt-in for older supported Responses models"));
1084
1085        let field_reference = fs::read_to_string(workspace_root.join("docs/config/CONFIG_FIELD_REFERENCE.md"))
1086            .expect("config field reference");
1087        assert!(field_reference.contains("`prompt_cache.cache_friendly_prompt_shaping`"));
1088        assert!(field_reference.contains("`prompt_cache.providers.openai.prompt_cache_retention`"));
1089    }
1090
1091    #[test]
1092    fn bundled_example_config_parses_and_validates() {
1093        // Guards the tracked template against stale keys and schema drift:
1094        // the shipped example must load as VTCodeConfig and pass validation.
1095        let workspace_root = find_workspace_root();
1096        let example = fs::read_to_string(workspace_root.join("vtcode.toml.example")).expect("vtcode.toml.example");
1097        let config: crate::VTCodeConfig = toml::from_str(&example).expect("example config should parse");
1098        config.validate().expect("example config should validate");
1099    }
1100}