use crate::constants::prompt_cache;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PromptCachingConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_cache_dir")]
pub cache_dir: String,
#[serde(default = "default_max_entries")]
pub max_entries: usize,
#[serde(default = "default_max_age_days")]
pub max_age_days: u64,
#[serde(default = "default_auto_cleanup")]
pub enable_auto_cleanup: bool,
#[serde(default = "default_min_quality_threshold")]
pub min_quality_threshold: f64,
#[serde(default = "default_cache_friendly_prompt_shaping")]
pub cache_friendly_prompt_shaping: bool,
#[serde(default)]
pub providers: ProviderPromptCachingConfig,
}
impl Default for PromptCachingConfig {
fn default() -> Self {
Self {
enabled: default_enabled(),
cache_dir: default_cache_dir(),
max_entries: default_max_entries(),
max_age_days: default_max_age_days(),
enable_auto_cleanup: default_auto_cleanup(),
min_quality_threshold: default_min_quality_threshold(),
cache_friendly_prompt_shaping: default_cache_friendly_prompt_shaping(),
providers: ProviderPromptCachingConfig::default(),
}
}
}
impl PromptCachingConfig {
pub fn resolve_cache_dir(&self, workspace_root: Option<&Path>) -> PathBuf {
resolve_path(&self.cache_dir, workspace_root)
}
pub fn is_provider_enabled(&self, provider_name: &str) -> bool {
if !self.enabled {
return false;
}
match provider_name.to_ascii_lowercase().as_str() {
"openai" => self.providers.openai.enabled,
"anthropic" | "minimax" => self.providers.anthropic.enabled,
"gemini" => {
self.providers.gemini.enabled
&& !matches!(self.providers.gemini.mode, GeminiPromptCacheMode::Off)
}
"openrouter" => self.providers.openrouter.enabled,
"moonshot" => self.providers.moonshot.enabled,
"deepseek" => self.providers.deepseek.enabled,
"zai" => self.providers.zai.enabled,
_ => false,
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ProviderPromptCachingConfig {
#[serde(default = "OpenAIPromptCacheSettings::default")]
pub openai: OpenAIPromptCacheSettings,
#[serde(default = "AnthropicPromptCacheSettings::default")]
pub anthropic: AnthropicPromptCacheSettings,
#[serde(default = "GeminiPromptCacheSettings::default")]
pub gemini: GeminiPromptCacheSettings,
#[serde(default = "OpenRouterPromptCacheSettings::default")]
pub openrouter: OpenRouterPromptCacheSettings,
#[serde(default = "MoonshotPromptCacheSettings::default")]
pub moonshot: MoonshotPromptCacheSettings,
#[serde(default = "DeepSeekPromptCacheSettings::default")]
pub deepseek: DeepSeekPromptCacheSettings,
#[serde(default = "ZaiPromptCacheSettings::default")]
pub zai: ZaiPromptCacheSettings,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OpenAIPromptCacheSettings {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_openai_min_prefix_tokens")]
pub min_prefix_tokens: u32,
#[serde(default = "default_openai_idle_expiration")]
pub idle_expiration_seconds: u64,
#[serde(default = "default_true")]
pub surface_metrics: bool,
#[serde(default = "default_openai_prompt_cache_key_mode")]
pub prompt_cache_key_mode: OpenAIPromptCacheKeyMode,
#[serde(default)]
pub prompt_cache_retention: Option<String>,
}
impl Default for OpenAIPromptCacheSettings {
fn default() -> Self {
Self {
enabled: default_true(),
min_prefix_tokens: default_openai_min_prefix_tokens(),
idle_expiration_seconds: default_openai_idle_expiration(),
surface_metrics: default_true(),
prompt_cache_key_mode: default_openai_prompt_cache_key_mode(),
prompt_cache_retention: None,
}
}
}
impl OpenAIPromptCacheSettings {
pub fn validate(&self) -> anyhow::Result<()> {
if let Some(ref retention) = self.prompt_cache_retention {
validate_openai_retention_policy(retention)
.with_context(|| format!("Invalid prompt_cache_retention: {}", retention))?;
}
Ok(())
}
}
#[must_use]
pub fn build_openai_prompt_cache_key(
prompt_cache_enabled: bool,
prompt_cache_key_mode: &OpenAIPromptCacheKeyMode,
lineage_id: Option<&str>,
) -> Option<String> {
if !prompt_cache_enabled {
return None;
}
let lineage_id = lineage_id.map(str::trim).filter(|value| !value.is_empty());
match prompt_cache_key_mode {
OpenAIPromptCacheKeyMode::Session => {
lineage_id.map(|lineage_id| format!("vtcode:openai:{lineage_id}"))
}
OpenAIPromptCacheKeyMode::Off => None,
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum OpenAIPromptCacheKeyMode {
Off,
#[default]
Session,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AnthropicPromptCacheSettings {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_anthropic_tools_ttl")]
pub tools_ttl_seconds: u64,
#[serde(default = "default_anthropic_messages_ttl")]
pub messages_ttl_seconds: u64,
#[serde(default = "default_anthropic_max_breakpoints")]
pub max_breakpoints: u8,
#[serde(default = "default_true")]
pub cache_system_messages: bool,
#[serde(default = "default_true")]
pub cache_user_messages: bool,
#[serde(default = "default_true")]
pub cache_tool_definitions: bool,
#[serde(default = "default_min_message_length")]
pub min_message_length_for_cache: usize,
#[serde(default = "default_anthropic_extended_ttl")]
pub extended_ttl_seconds: Option<u64>,
}
impl Default for AnthropicPromptCacheSettings {
fn default() -> Self {
Self {
enabled: default_true(),
tools_ttl_seconds: default_anthropic_tools_ttl(),
messages_ttl_seconds: default_anthropic_messages_ttl(),
max_breakpoints: default_anthropic_max_breakpoints(),
cache_system_messages: default_true(),
cache_user_messages: default_true(),
cache_tool_definitions: default_true(),
min_message_length_for_cache: default_min_message_length(),
extended_ttl_seconds: default_anthropic_extended_ttl(),
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GeminiPromptCacheSettings {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_gemini_mode")]
pub mode: GeminiPromptCacheMode,
#[serde(default = "default_gemini_min_prefix_tokens")]
pub min_prefix_tokens: u32,
#[serde(default = "default_gemini_explicit_ttl")]
pub explicit_ttl_seconds: Option<u64>,
}
impl Default for GeminiPromptCacheSettings {
fn default() -> Self {
Self {
enabled: default_true(),
mode: GeminiPromptCacheMode::default(),
min_prefix_tokens: default_gemini_min_prefix_tokens(),
explicit_ttl_seconds: default_gemini_explicit_ttl(),
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum GeminiPromptCacheMode {
#[default]
Implicit,
Explicit,
Off,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OpenRouterPromptCacheSettings {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub propagate_provider_capabilities: bool,
#[serde(default = "default_true")]
pub report_savings: bool,
}
impl Default for OpenRouterPromptCacheSettings {
fn default() -> Self {
Self {
enabled: default_true(),
propagate_provider_capabilities: default_true(),
report_savings: default_true(),
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MoonshotPromptCacheSettings {
#[serde(default = "default_moonshot_enabled")]
pub enabled: bool,
}
impl Default for MoonshotPromptCacheSettings {
fn default() -> Self {
Self {
enabled: default_moonshot_enabled(),
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DeepSeekPromptCacheSettings {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub surface_metrics: bool,
}
impl Default for DeepSeekPromptCacheSettings {
fn default() -> Self {
Self {
enabled: default_true(),
surface_metrics: default_true(),
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ZaiPromptCacheSettings {
#[serde(default = "default_zai_enabled")]
pub enabled: bool,
}
impl Default for ZaiPromptCacheSettings {
fn default() -> Self {
Self {
enabled: default_zai_enabled(),
}
}
}
fn default_enabled() -> bool {
prompt_cache::DEFAULT_ENABLED
}
fn default_cache_dir() -> String {
format!("~/{path}", path = prompt_cache::DEFAULT_CACHE_DIR)
}
fn default_max_entries() -> usize {
prompt_cache::DEFAULT_MAX_ENTRIES
}
fn default_max_age_days() -> u64 {
prompt_cache::DEFAULT_MAX_AGE_DAYS
}
fn default_auto_cleanup() -> bool {
prompt_cache::DEFAULT_AUTO_CLEANUP
}
fn default_min_quality_threshold() -> f64 {
prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD
}
fn default_cache_friendly_prompt_shaping() -> bool {
prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING
}
fn default_true() -> bool {
true
}
fn default_openai_min_prefix_tokens() -> u32 {
prompt_cache::OPENAI_MIN_PREFIX_TOKENS
}
fn default_openai_idle_expiration() -> u64 {
prompt_cache::OPENAI_IDLE_EXPIRATION_SECONDS
}
fn default_openai_prompt_cache_key_mode() -> OpenAIPromptCacheKeyMode {
OpenAIPromptCacheKeyMode::Session
}
#[allow(dead_code)]
fn default_anthropic_default_ttl() -> u64 {
prompt_cache::ANTHROPIC_DEFAULT_TTL_SECONDS
}
#[allow(dead_code)]
fn default_anthropic_extended_ttl() -> Option<u64> {
Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
}
fn default_anthropic_tools_ttl() -> u64 {
prompt_cache::ANTHROPIC_TOOLS_TTL_SECONDS
}
fn default_anthropic_messages_ttl() -> u64 {
prompt_cache::ANTHROPIC_MESSAGES_TTL_SECONDS
}
fn default_anthropic_max_breakpoints() -> u8 {
prompt_cache::ANTHROPIC_MAX_BREAKPOINTS
}
#[allow(dead_code)]
fn default_min_message_length() -> usize {
prompt_cache::ANTHROPIC_MIN_MESSAGE_LENGTH_FOR_CACHE
}
fn default_gemini_min_prefix_tokens() -> u32 {
prompt_cache::GEMINI_MIN_PREFIX_TOKENS
}
fn default_gemini_explicit_ttl() -> Option<u64> {
Some(prompt_cache::GEMINI_EXPLICIT_DEFAULT_TTL_SECONDS)
}
fn default_gemini_mode() -> GeminiPromptCacheMode {
GeminiPromptCacheMode::Implicit
}
fn default_zai_enabled() -> bool {
prompt_cache::ZAI_CACHE_ENABLED
}
fn default_moonshot_enabled() -> bool {
prompt_cache::MOONSHOT_CACHE_ENABLED
}
fn resolve_path(input: &str, workspace_root: Option<&Path>) -> PathBuf {
let trimmed = input.trim();
if trimmed.is_empty() {
return resolve_default_cache_dir();
}
if let Some(stripped) = trimmed
.strip_prefix("~/")
.or_else(|| trimmed.strip_prefix("~\\"))
{
if let Some(home) = dirs::home_dir() {
return home.join(stripped);
}
return PathBuf::from(stripped);
}
let candidate = Path::new(trimmed);
if candidate.is_absolute() {
return candidate.to_path_buf();
}
if let Some(root) = workspace_root {
return root.join(candidate);
}
candidate.to_path_buf()
}
fn resolve_default_cache_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
return home.join(prompt_cache::DEFAULT_CACHE_DIR);
}
PathBuf::from(prompt_cache::DEFAULT_CACHE_DIR)
}
fn validate_openai_retention_policy(input: &str) -> anyhow::Result<()> {
let input = input.trim();
if input.is_empty() {
anyhow::bail!("Empty retention string");
}
if matches!(input, "in_memory" | "24h") {
return Ok(());
}
anyhow::bail!("prompt_cache_retention must be one of: in_memory, 24h");
}
impl PromptCachingConfig {
pub fn validate(&self) -> anyhow::Result<()> {
self.providers.openai.validate()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use assert_fs::TempDir;
use std::fs;
#[test]
fn prompt_caching_defaults_align_with_constants() {
let cfg = PromptCachingConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.max_entries, prompt_cache::DEFAULT_MAX_ENTRIES);
assert_eq!(cfg.max_age_days, prompt_cache::DEFAULT_MAX_AGE_DAYS);
assert!(
(cfg.min_quality_threshold - prompt_cache::DEFAULT_MIN_QUALITY_THRESHOLD).abs()
< f64::EPSILON
);
assert_eq!(
cfg.cache_friendly_prompt_shaping,
prompt_cache::DEFAULT_CACHE_FRIENDLY_PROMPT_SHAPING
);
assert!(cfg.providers.openai.enabled);
assert_eq!(
cfg.providers.openai.min_prefix_tokens,
prompt_cache::OPENAI_MIN_PREFIX_TOKENS
);
assert_eq!(
cfg.providers.openai.prompt_cache_key_mode,
OpenAIPromptCacheKeyMode::Session
);
assert_eq!(
cfg.providers.anthropic.extended_ttl_seconds,
Some(prompt_cache::ANTHROPIC_EXTENDED_TTL_SECONDS)
);
assert_eq!(cfg.providers.gemini.mode, GeminiPromptCacheMode::Implicit);
assert!(cfg.providers.moonshot.enabled);
assert_eq!(cfg.providers.openai.prompt_cache_retention, None);
}
#[test]
fn resolve_cache_dir_expands_home() {
let cfg = PromptCachingConfig {
cache_dir: "~/.custom/cache".to_string(),
..PromptCachingConfig::default()
};
let resolved = cfg.resolve_cache_dir(None);
if let Some(home) = dirs::home_dir() {
assert!(resolved.starts_with(home));
} else {
assert_eq!(resolved, PathBuf::from(".custom/cache"));
}
}
#[test]
fn resolve_cache_dir_uses_workspace_when_relative() {
let temp = TempDir::new().unwrap();
let workspace = temp.path();
let cfg = PromptCachingConfig {
cache_dir: "relative/cache".to_string(),
..PromptCachingConfig::default()
};
let resolved = cfg.resolve_cache_dir(Some(workspace));
assert_eq!(resolved, workspace.join("relative/cache"));
}
#[test]
fn validate_openai_retention_policy_valid_and_invalid() {
assert!(validate_openai_retention_policy("24h").is_ok());
assert!(validate_openai_retention_policy("in_memory").is_ok());
assert!(validate_openai_retention_policy("5m").is_err());
assert!(validate_openai_retention_policy("1d").is_err());
assert!(validate_openai_retention_policy("abc").is_err());
assert!(validate_openai_retention_policy("").is_err());
}
#[test]
fn validate_prompt_cache_rejects_invalid_retention() {
let mut cfg = PromptCachingConfig::default();
cfg.providers.openai.prompt_cache_retention = Some("invalid".to_string());
assert!(cfg.validate().is_err());
}
#[test]
fn prompt_cache_key_mode_parses_from_toml() {
let parsed: PromptCachingConfig = toml::from_str(
r#"
[providers.openai]
prompt_cache_key_mode = "off"
"#,
)
.expect("prompt cache config should parse");
assert_eq!(
parsed.providers.openai.prompt_cache_key_mode,
OpenAIPromptCacheKeyMode::Off
);
}
#[test]
fn build_openai_prompt_cache_key_uses_trimmed_lineage_id() {
let key = build_openai_prompt_cache_key(
true,
&OpenAIPromptCacheKeyMode::Session,
Some(" lineage-abc "),
);
assert_eq!(key.as_deref(), Some("vtcode:openai:lineage-abc"));
}
#[test]
fn build_openai_prompt_cache_key_honors_disabled_or_off_mode() {
assert_eq!(
build_openai_prompt_cache_key(false, &OpenAIPromptCacheKeyMode::Session, Some("id")),
None
);
assert_eq!(
build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Off, Some("id")),
None
);
assert_eq!(
build_openai_prompt_cache_key(true, &OpenAIPromptCacheKeyMode::Session, Some(" ")),
None
);
}
#[test]
fn provider_enablement_respects_global_and_provider_flags() {
let mut cfg = PromptCachingConfig {
enabled: true,
..PromptCachingConfig::default()
};
cfg.providers.openai.enabled = true;
assert!(cfg.is_provider_enabled("openai"));
cfg.enabled = false;
assert!(!cfg.is_provider_enabled("openai"));
}
#[test]
fn provider_enablement_handles_aliases_and_modes() {
let mut cfg = PromptCachingConfig {
enabled: true,
..PromptCachingConfig::default()
};
cfg.providers.anthropic.enabled = true;
assert!(cfg.is_provider_enabled("minimax"));
cfg.providers.gemini.enabled = true;
cfg.providers.gemini.mode = GeminiPromptCacheMode::Off;
assert!(!cfg.is_provider_enabled("gemini"));
}
#[test]
fn bundled_config_templates_match_prompt_cache_defaults() {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let loader_source = fs::read_to_string(manifest_dir.join("src/loader/config.rs"))
.expect("loader config source");
assert!(loader_source.contains("[prompt_cache]"));
assert!(loader_source.contains("enabled = true"));
assert!(loader_source.contains("cache_friendly_prompt_shaping = true"));
assert!(loader_source.contains("# prompt_cache_retention = \"24h\""));
let workspace_root = manifest_dir.parent().expect("workspace root").to_path_buf();
let example_config = fs::read_to_string(workspace_root.join("vtcode.toml.example"))
.expect("vtcode.toml.example");
assert!(example_config.contains("[prompt_cache]"));
assert!(example_config.contains("enabled = true"));
assert!(example_config.contains("cache_friendly_prompt_shaping = true"));
assert!(example_config.contains("# prompt_cache_retention = \"24h\""));
let prompt_cache_guide =
fs::read_to_string(workspace_root.join("docs/tools/PROMPT_CACHING_GUIDE.md"))
.expect("prompt caching guide");
assert!(prompt_cache_guide.contains(
"VT Code enables `prompt_cache.cache_friendly_prompt_shaping = true` by default."
));
assert!(prompt_cache_guide.contains(
"Default: `None` (opt-in) - VT Code does not set prompt_cache_retention by default;"
));
let field_reference =
fs::read_to_string(workspace_root.join("docs/config/CONFIG_FIELD_REFERENCE.md"))
.expect("config field reference");
assert!(field_reference.contains(
"| `prompt_cache.cache_friendly_prompt_shaping` | `boolean` | no | `true` |"
));
assert!(field_reference.contains(
"| `prompt_cache.providers.openai.prompt_cache_retention` | `null \\| string` | no | `null` |"
));
}
}