Skip to main content

vtcode_config/core/
prompt_cache.rs

1use crate::constants::prompt_cache;
2use anyhow::Context;
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8/// Global prompt caching configuration loaded from vtcode.toml
9#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10#[derive(Debug, Clone, Deserialize, Serialize)]
11pub struct PromptCachingConfig {
12    /// Enable prompt caching features globally
13    #[serde(default = "default_enabled")]
14    pub enabled: bool,
15
16    /// Base directory for local prompt cache storage (supports `~` expansion)
17    #[serde(default = "default_cache_dir")]
18    pub cache_dir: String,
19
20    /// Maximum number of cached prompt entries to retain on disk
21    #[serde(default = "default_max_entries")]
22    pub max_entries: usize,
23
24    /// Maximum age (in days) before cached entries are purged
25    #[serde(default = "default_max_age_days")]
26    pub max_age_days: u64,
27
28    /// Automatically evict stale entries on startup/shutdown
29    #[serde(default = "default_auto_cleanup")]
30    pub enable_auto_cleanup: bool,
31
32    /// Minimum quality score required before persisting an entry
33    #[serde(default = "default_min_quality_threshold")]
34    pub min_quality_threshold: f64,
35
36    /// Enable prompt-shaping optimizations that improve provider-side cache locality.
37    /// When enabled, VT Code keeps volatile runtime context at the end of prompt text.
38    #[serde(default = "default_cache_friendly_prompt_shaping")]
39    pub cache_friendly_prompt_shaping: bool,
40
41    /// Provider specific overrides
42    #[serde(default)]
43    pub providers: ProviderPromptCachingConfig,
44}
45
46impl Default for PromptCachingConfig {
47    fn default() -> Self {
48        Self {
49            enabled: default_enabled(),
50            cache_dir: default_cache_dir(),
51            max_entries: default_max_entries(),
52            max_age_days: default_max_age_days(),
53            enable_auto_cleanup: default_auto_cleanup(),
54            min_quality_threshold: default_min_quality_threshold(),
55            cache_friendly_prompt_shaping: default_cache_friendly_prompt_shaping(),
56            providers: ProviderPromptCachingConfig::default(),
57        }
58    }
59}
60
61impl PromptCachingConfig {
62    /// Resolve the configured cache directory to an absolute path
63    ///
64    /// - `~` is expanded to the user's home directory when available
65    /// - Relative paths are resolved against the provided workspace root when supplied
66    /// - Falls back to the configured string when neither applies
67    pub fn resolve_cache_dir(&self, workspace_root: Option<&Path>) -> PathBuf {
68        resolve_path(&self.cache_dir, workspace_root)
69    }
70
71    /// Returns true when prompt caching is active for the given provider runtime name.
72    pub fn is_provider_enabled(&self, provider_name: &str) -> bool {
73        if !self.enabled {
74            return false;
75        }
76
77        match provider_name.to_ascii_lowercase().as_str() {
78            "openai" => self.providers.openai.enabled,
79            "anthropic" | "minimax" => self.providers.anthropic.enabled,
80            "gemini" => {
81                self.providers.gemini.enabled
82                    && !matches!(self.providers.gemini.mode, GeminiPromptCacheMode::Off)
83            }
84            "openrouter" => self.providers.openrouter.enabled,
85            "moonshot" => self.providers.moonshot.enabled,
86            "deepseek" => self.providers.deepseek.enabled,
87            "zai" => self.providers.zai.enabled,
88            _ => false,
89        }
90    }
91}
92
93/// Per-provider configuration overrides
94#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
95#[derive(Debug, Clone, Deserialize, Serialize, Default)]
96pub struct ProviderPromptCachingConfig {
97    #[serde(default = "OpenAIPromptCacheSettings::default")]
98    pub openai: OpenAIPromptCacheSettings,
99
100    #[serde(default = "AnthropicPromptCacheSettings::default")]
101    pub anthropic: AnthropicPromptCacheSettings,
102
103    #[serde(default = "GeminiPromptCacheSettings::default")]
104    pub gemini: GeminiPromptCacheSettings,
105
106    #[serde(default = "OpenRouterPromptCacheSettings::default")]
107    pub openrouter: OpenRouterPromptCacheSettings,
108
109    #[serde(default = "MoonshotPromptCacheSettings::default")]
110    pub moonshot: MoonshotPromptCacheSettings,
111
112    #[serde(default = "DeepSeekPromptCacheSettings::default")]
113    pub deepseek: DeepSeekPromptCacheSettings,
114
115    #[serde(default = "ZaiPromptCacheSettings::default")]
116    pub zai: ZaiPromptCacheSettings,
117}
118
119/// OpenAI prompt caching controls (automatic with metrics)
120#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121#[derive(Debug, Clone, Deserialize, Serialize)]
122pub struct OpenAIPromptCacheSettings {
123    #[serde(default = "default_true")]
124    pub enabled: bool,
125
126    #[serde(default = "default_openai_min_prefix_tokens")]
127    pub min_prefix_tokens: u32,
128
129    #[serde(default = "default_openai_idle_expiration")]
130    pub idle_expiration_seconds: u64,
131
132    #[serde(default = "default_true")]
133    pub surface_metrics: bool,
134
135    /// Strategy for generating OpenAI `prompt_cache_key`.
136    /// Session mode derives one stable key per VT Code conversation.
137    #[serde(default = "default_openai_prompt_cache_key_mode")]
138    pub prompt_cache_key_mode: OpenAIPromptCacheKeyMode,
139
140    /// Optional prompt cache retention string to pass directly into OpenAI Responses API
141    /// Example: "24h" or "1d". If set, VT Code will include `prompt_cache_retention`
142    /// in the request body to extend the model-side prompt caching window.
143    #[serde(default)]
144    pub prompt_cache_retention: Option<String>,
145}
146
147impl Default for OpenAIPromptCacheSettings {
148    fn default() -> Self {
149        Self {
150            enabled: default_true(),
151            min_prefix_tokens: default_openai_min_prefix_tokens(),
152            idle_expiration_seconds: default_openai_idle_expiration(),
153            surface_metrics: default_true(),
154            prompt_cache_key_mode: default_openai_prompt_cache_key_mode(),
155            prompt_cache_retention: None,
156        }
157    }
158}
159
160impl OpenAIPromptCacheSettings {
161    /// Validate OpenAI provider prompt cache settings. Returns Err if the retention value is invalid.
162    pub fn validate(&self) -> anyhow::Result<()> {
163        if let Some(ref retention) = self.prompt_cache_retention {
164            parse_retention_duration(retention)
165                .with_context(|| format!("Invalid prompt_cache_retention: {}", retention))?;
166        }
167        Ok(())
168    }
169}
170
171/// OpenAI prompt cache key derivation mode.
172#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
173#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
174#[serde(rename_all = "snake_case")]
175pub enum OpenAIPromptCacheKeyMode {
176    /// Do not send `prompt_cache_key` in OpenAI requests.
177    Off,
178    /// Send one stable `prompt_cache_key` per VT Code session.
179    #[default]
180    Session,
181}
182
183/// Anthropic Claude cache control settings
184#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
185#[derive(Debug, Clone, Deserialize, Serialize)]
186pub struct AnthropicPromptCacheSettings {
187    #[serde(default = "default_true")]
188    pub enabled: bool,
189
190    /// Default TTL in seconds for the first cache breakpoint (tools/system).
191    /// Anthropic only supports "5m" (300s) or "1h" (3600s) TTL formats.
192    /// Set to >= 3600 for 1-hour cache on tools and system prompts.
193    /// Default: 3600 (1 hour) - recommended for stable tool definitions
194    #[serde(default = "default_anthropic_tools_ttl")]
195    pub tools_ttl_seconds: u64,
196
197    /// TTL for subsequent cache breakpoints (messages).
198    /// Set to >= 3600 for 1-hour cache on messages.
199    /// Default: 300 (5 minutes) - recommended for frequently changing messages
200    #[serde(default = "default_anthropic_messages_ttl")]
201    pub messages_ttl_seconds: u64,
202
203    /// Maximum number of cache breakpoints to use (max 4 per Anthropic spec).
204    /// Default: 4
205    #[serde(default = "default_anthropic_max_breakpoints")]
206    pub max_breakpoints: u8,
207
208    /// Apply cache control to system prompts by default
209    #[serde(default = "default_true")]
210    pub cache_system_messages: bool,
211
212    /// Apply cache control to user messages exceeding threshold
213    #[serde(default = "default_true")]
214    pub cache_user_messages: bool,
215
216    /// Apply cache control to tool definitions by default
217    /// Default: true (tools are typically stable and benefit from longer caching)
218    #[serde(default = "default_true")]
219    pub cache_tool_definitions: bool,
220
221    /// Minimum message length (in characters) before applying cache control
222    /// to avoid caching very short messages that don't benefit from caching.
223    /// Default: 256 characters (~64 tokens)
224    #[serde(default = "default_min_message_length")]
225    pub min_message_length_for_cache: usize,
226
227    /// Extended TTL for Anthropic prompt caching (in seconds)
228    /// Set to >= 3600 for 1-hour cache on messages
229    #[serde(default = "default_anthropic_extended_ttl")]
230    pub extended_ttl_seconds: Option<u64>,
231}
232
233impl Default for AnthropicPromptCacheSettings {
234    fn default() -> Self {
235        Self {
236            enabled: default_true(),
237            tools_ttl_seconds: default_anthropic_tools_ttl(),
238            messages_ttl_seconds: default_anthropic_messages_ttl(),
239            max_breakpoints: default_anthropic_max_breakpoints(),
240            cache_system_messages: default_true(),
241            cache_user_messages: default_true(),
242            cache_tool_definitions: default_true(),
243            min_message_length_for_cache: default_min_message_length(),
244            extended_ttl_seconds: default_anthropic_extended_ttl(),
245        }
246    }
247}
248
249/// Gemini API caching preferences
250#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
251#[derive(Debug, Clone, Deserialize, Serialize)]
252pub struct GeminiPromptCacheSettings {
253    #[serde(default = "default_true")]
254    pub enabled: bool,
255
256    #[serde(default = "default_gemini_mode")]
257    pub mode: GeminiPromptCacheMode,
258
259    #[serde(default = "default_gemini_min_prefix_tokens")]
260    pub min_prefix_tokens: u32,
261
262    /// TTL for explicit caches (ignored in implicit mode)
263    #[serde(default = "default_gemini_explicit_ttl")]
264    pub explicit_ttl_seconds: Option<u64>,
265}
266
267impl Default for GeminiPromptCacheSettings {
268    fn default() -> Self {
269        Self {
270            enabled: default_true(),
271            mode: GeminiPromptCacheMode::default(),
272            min_prefix_tokens: default_gemini_min_prefix_tokens(),
273            explicit_ttl_seconds: default_gemini_explicit_ttl(),
274        }
275    }
276}
277
278/// Gemini prompt caching mode selection
279#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
280#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
281#[serde(rename_all = "snake_case")]
282#[derive(Default)]
283pub enum GeminiPromptCacheMode {
284    #[default]
285    Implicit,
286    Explicit,
287    Off,
288}
289
290/// OpenRouter passthrough caching controls
291#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
292#[derive(Debug, Clone, Deserialize, Serialize)]
293pub struct OpenRouterPromptCacheSettings {
294    #[serde(default = "default_true")]
295    pub enabled: bool,
296
297    /// Propagate provider cache instructions automatically
298    #[serde(default = "default_true")]
299    pub propagate_provider_capabilities: bool,
300
301    /// Surface cache savings reported by OpenRouter
302    #[serde(default = "default_true")]
303    pub report_savings: bool,
304}
305
306impl Default for OpenRouterPromptCacheSettings {
307    fn default() -> Self {
308        Self {
309            enabled: default_true(),
310            propagate_provider_capabilities: default_true(),
311            report_savings: default_true(),
312        }
313    }
314}
315
316/// Moonshot prompt caching configuration (leverages server-side reuse)
317#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
318#[derive(Debug, Clone, Deserialize, Serialize)]
319pub struct MoonshotPromptCacheSettings {
320    #[serde(default = "default_moonshot_enabled")]
321    pub enabled: bool,
322}
323
324impl Default for MoonshotPromptCacheSettings {
325    fn default() -> Self {
326        Self {
327            enabled: default_moonshot_enabled(),
328        }
329    }
330}
331
332/// DeepSeek prompt caching configuration (automatic KV cache reuse)
333#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
334#[derive(Debug, Clone, Deserialize, Serialize)]
335pub struct DeepSeekPromptCacheSettings {
336    #[serde(default = "default_true")]
337    pub enabled: bool,
338
339    /// Emit cache hit/miss metrics from responses when available
340    #[serde(default = "default_true")]
341    pub surface_metrics: bool,
342}
343
344impl Default for DeepSeekPromptCacheSettings {
345    fn default() -> Self {
346        Self {
347            enabled: default_true(),
348            surface_metrics: default_true(),
349        }
350    }
351}
352
353/// Z.AI prompt caching configuration (disabled until platform exposes metrics)
354#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
355#[derive(Debug, Clone, Deserialize, Serialize)]
356pub struct ZaiPromptCacheSettings {
357    #[serde(default = "default_zai_enabled")]
358    pub enabled: bool,
359}
360
361impl Default for ZaiPromptCacheSettings {
362    fn default() -> Self {
363        Self {
364            enabled: default_zai_enabled(),
365        }
366    }
367}
368
369fn default_enabled() -> bool {
370    prompt_cache::DEFAULT_ENABLED
371}
372
373fn default_cache_dir() -> String {
374    format!("~/{path}", path = prompt_cache::DEFAULT_CACHE_DIR)
375}
376
377fn default_max_entries() -> usize {
378    prompt_cache::DEFAULT_MAX_ENTRIES
379}
380
381fn default_max_age_days() -> u64 {
382    prompt_cache::DEFAULT_MAX_AGE_DAYS
383}
384
385fn default_auto_cleanup() -> bool {
386    prompt_cache::DEFAULT_AUTO_CLEANUP
387}
388
389fn default_min_quality_threshold() -> f64 {
390    prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD
391}
392
393fn default_cache_friendly_prompt_shaping() -> bool {
394    prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING
395}
396
397fn default_true() -> bool {
398    true
399}
400
401fn default_openai_min_prefix_tokens() -> u32 {
402    prompt_cache::OPENAI_MIN_PREFIX_TOKENS
403}
404
405fn default_openai_idle_expiration() -> u64 {
406    prompt_cache::OPENAI_IDLE_EXPIRATION_SECONDS
407}
408
409fn default_openai_prompt_cache_key_mode() -> OpenAIPromptCacheKeyMode {
410    OpenAIPromptCacheKeyMode::Session
411}
412
413#[allow(dead_code)]
414fn default_anthropic_default_ttl() -> u64 {
415    prompt_cache::ANTHROPIC_DEFAULT_TTL_SECONDS
416}
417
418#[allow(dead_code)]
419fn default_anthropic_extended_ttl() -> Option<u64> {
420    Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
421}
422
423fn default_anthropic_tools_ttl() -> u64 {
424    prompt_cache::ANTHROPIC_TOOLS_TTL_SECONDS
425}
426
427fn default_anthropic_messages_ttl() -> u64 {
428    prompt_cache::ANTHROPIC_MESSAGES_TTL_SECONDS
429}
430
431fn default_anthropic_max_breakpoints() -> u8 {
432    prompt_cache::ANTHROPIC_MAX_BREAKPOINTS
433}
434
435#[allow(dead_code)]
436fn default_min_message_length() -> usize {
437    prompt_cache::ANTHROPIC_MIN_MESSAGE_LENGTH_FOR_CACHE
438}
439
440fn default_gemini_min_prefix_tokens() -> u32 {
441    prompt_cache::GEMINI_MIN_PREFIX_TOKENS
442}
443
444fn default_gemini_explicit_ttl() -> Option<u64> {
445    Some(prompt_cache::GEMINI_EXPLICIT_DEFAULT_TTL_SECONDS)
446}
447
448fn default_gemini_mode() -> GeminiPromptCacheMode {
449    GeminiPromptCacheMode::Implicit
450}
451
452fn default_zai_enabled() -> bool {
453    prompt_cache::ZAI_CACHE_ENABLED
454}
455
456fn default_moonshot_enabled() -> bool {
457    prompt_cache::MOONSHOT_CACHE_ENABLED
458}
459
460fn resolve_path(input: &str, workspace_root: Option<&Path>) -> PathBuf {
461    let trimmed = input.trim();
462    if trimmed.is_empty() {
463        return resolve_default_cache_dir();
464    }
465
466    if let Some(stripped) = trimmed
467        .strip_prefix("~/")
468        .or_else(|| trimmed.strip_prefix("~\\"))
469    {
470        if let Some(home) = dirs::home_dir() {
471            return home.join(stripped);
472        }
473        return PathBuf::from(stripped);
474    }
475
476    let candidate = Path::new(trimmed);
477    if candidate.is_absolute() {
478        return candidate.to_path_buf();
479    }
480
481    if let Some(root) = workspace_root {
482        return root.join(candidate);
483    }
484
485    candidate.to_path_buf()
486}
487
488fn resolve_default_cache_dir() -> PathBuf {
489    if let Some(home) = dirs::home_dir() {
490        return home.join(prompt_cache::DEFAULT_CACHE_DIR);
491    }
492    PathBuf::from(prompt_cache::DEFAULT_CACHE_DIR)
493}
494
495/// Parse a duration string into a std::time::Duration
496/// Acceptable formats: <number>[s|m|h|d], e.g., "30s", "5m", "24h", "1d".
497fn parse_retention_duration(input: &str) -> anyhow::Result<Duration> {
498    let input = input.trim();
499    if input.is_empty() {
500        anyhow::bail!("Empty retention string");
501    }
502
503    // Strict format: number + unit (s|m|h|d)
504    let re =
505        Regex::new(r"^(\d+)([smhdSMHD])$").context("Failed to compile retention format regex")?;
506    let caps = re
507        .captures(input)
508        .ok_or_else(|| anyhow::anyhow!("Invalid retention format; use <number>[s|m|h|d]"))?;
509
510    let value_str = caps
511        .get(1)
512        .map(|m| m.as_str())
513        .ok_or_else(|| anyhow::anyhow!("Retention value capture missing"))?;
514    let unit = caps
515        .get(2)
516        .map(|m| m.as_str())
517        .ok_or_else(|| anyhow::anyhow!("Retention unit capture missing"))?
518        .chars()
519        .next()
520        .ok_or_else(|| anyhow::anyhow!("Retention unit is empty"))?
521        .to_ascii_lowercase();
522    let value: u64 = value_str
523        .parse()
524        .with_context(|| format!("Invalid numeric value in retention: {}", value_str))?;
525
526    let seconds = match unit {
527        's' => value,
528        'm' => value * 60,
529        'h' => value * 60 * 60,
530        'd' => value * 24 * 60 * 60,
531        _ => anyhow::bail!("Invalid retention unit; expected s,m,h,d"),
532    };
533
534    // Enforce a reasonable retention window: at least 1s and max 30 days
535    const MIN_SECONDS: u64 = 1;
536    const MAX_SECONDS: u64 = 30 * 24 * 60 * 60; // 30 days
537    if !((MIN_SECONDS..=MAX_SECONDS).contains(&seconds)) {
538        anyhow::bail!("prompt_cache_retention must be between 1s and 30d");
539    }
540
541    Ok(Duration::from_secs(seconds))
542}
543
544impl PromptCachingConfig {
545    /// Validate prompt cache config and provider overrides
546    pub fn validate(&self) -> anyhow::Result<()> {
547        // Validate OpenAI provider settings
548        self.providers.openai.validate()?;
549        Ok(())
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use assert_fs::TempDir;
557
558    #[test]
559    fn prompt_caching_defaults_align_with_constants() {
560        let cfg = PromptCachingConfig::default();
561        assert!(cfg.enabled);
562        assert_eq!(cfg.max_entries, prompt_cache::DEFAULT_MAX_ENTRIES);
563        assert_eq!(cfg.max_age_days, prompt_cache::DEFAULT_MAX_AGE_DAYS);
564        assert!(
565            (cfg.min_quality_threshold - prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD).abs()
566                < f64::EPSILON
567        );
568        assert_eq!(
569            cfg.cache_friendly_prompt_shaping,
570            prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING
571        );
572        assert!(cfg.providers.openai.enabled);
573        assert_eq!(
574            cfg.providers.openai.min_prefix_tokens,
575            prompt_cache::OPENAI_MIN_PREFIX_TOKENS
576        );
577        assert_eq!(
578            cfg.providers.openai.prompt_cache_key_mode,
579            OpenAIPromptCacheKeyMode::Session
580        );
581        assert_eq!(
582            cfg.providers.anthropic.extended_ttl_seconds,
583            Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
584        );
585        assert_eq!(cfg.providers.gemini.mode, GeminiPromptCacheMode::Implicit);
586        assert!(cfg.providers.moonshot.enabled);
587        assert_eq!(cfg.providers.openai.prompt_cache_retention, None);
588    }
589
590    #[test]
591    fn resolve_cache_dir_expands_home() {
592        let cfg = PromptCachingConfig {
593            cache_dir: "~/.custom/cache".to_string(),
594            ..PromptCachingConfig::default()
595        };
596        let resolved = cfg.resolve_cache_dir(None);
597        if let Some(home) = dirs::home_dir() {
598            assert!(resolved.starts_with(home));
599        } else {
600            assert_eq!(resolved, PathBuf::from(".custom/cache"));
601        }
602    }
603
604    #[test]
605    fn resolve_cache_dir_uses_workspace_when_relative() {
606        let temp = TempDir::new().unwrap();
607        let workspace = temp.path();
608        let cfg = PromptCachingConfig {
609            cache_dir: "relative/cache".to_string(),
610            ..PromptCachingConfig::default()
611        };
612        let resolved = cfg.resolve_cache_dir(Some(workspace));
613        assert_eq!(resolved, workspace.join("relative/cache"));
614    }
615
616    #[test]
617    fn parse_retention_duration_valid_and_invalid() {
618        assert_eq!(
619            parse_retention_duration("24h").unwrap(),
620            Duration::from_secs(86400)
621        );
622        assert_eq!(
623            parse_retention_duration("5m").unwrap(),
624            Duration::from_secs(300)
625        );
626        assert_eq!(
627            parse_retention_duration("1s").unwrap(),
628            Duration::from_secs(1)
629        );
630        assert!(parse_retention_duration("0s").is_err());
631        assert!(parse_retention_duration("31d").is_err());
632        assert!(parse_retention_duration("abc").is_err());
633        assert!(parse_retention_duration("").is_err());
634        assert!(parse_retention_duration("10x").is_err());
635    }
636
637    #[test]
638    fn validate_prompt_cache_rejects_invalid_retention() {
639        let mut cfg = PromptCachingConfig::default();
640        cfg.providers.openai.prompt_cache_retention = Some("invalid".to_string());
641        assert!(cfg.validate().is_err());
642    }
643
644    #[test]
645    fn prompt_cache_key_mode_parses_from_toml() {
646        let parsed: PromptCachingConfig = toml::from_str(
647            r#"
648[providers.openai]
649prompt_cache_key_mode = "off"
650"#,
651        )
652        .expect("prompt cache config should parse");
653
654        assert_eq!(
655            parsed.providers.openai.prompt_cache_key_mode,
656            OpenAIPromptCacheKeyMode::Off
657        );
658    }
659
660    #[test]
661    fn provider_enablement_respects_global_and_provider_flags() {
662        let mut cfg = PromptCachingConfig {
663            enabled: true,
664            ..PromptCachingConfig::default()
665        };
666        cfg.providers.openai.enabled = true;
667        assert!(cfg.is_provider_enabled("openai"));
668
669        cfg.enabled = false;
670        assert!(!cfg.is_provider_enabled("openai"));
671    }
672
673    #[test]
674    fn provider_enablement_handles_aliases_and_modes() {
675        let mut cfg = PromptCachingConfig {
676            enabled: true,
677            ..PromptCachingConfig::default()
678        };
679
680        cfg.providers.anthropic.enabled = true;
681        assert!(cfg.is_provider_enabled("minimax"));
682
683        cfg.providers.gemini.enabled = true;
684        cfg.providers.gemini.mode = GeminiPromptCacheMode::Off;
685        assert!(!cfg.is_provider_enabled("gemini"));
686    }
687}