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