use crate::constants::prompt_cache;
use crate::env_helpers::default_true;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use vtcode_commons::VtCodePaths;
#[must_use]
pub fn supports_openai_explicit_prompt_cache(model: &str) -> bool {
use crate::models::ModelId;
let Ok(model_id) = model.parse::<ModelId>() else {
return false;
};
(model_id.as_str() == model || model == crate::constants::models::openai::GPT_5_6)
&& matches!(
model_id,
ModelId::GPT56Sol
| ModelId::GPT56Terra
| ModelId::GPT56Luna
| ModelId::GPT6Astra
| ModelId::GPT6Sol
| ModelId::GPT61Sol
| ModelId::GPT61Terra
| ModelId::GPT6Luna
)
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
pub enum PromptCacheTtl {
#[default]
#[serde(rename = "30m")]
M30,
#[serde(rename = "12h")]
H12,
}
impl PromptCacheTtl {
pub const fn as_str(self) -> &'static str {
match self {
Self::M30 => "30m",
Self::H12 => "12h",
}
}
pub const fn seconds(self) -> u64 {
match self {
Self::M30 => 1_800,
Self::H12 => 43_200,
}
}
}
pub fn resolved_openai_prompt_cache_ttl(configured: Option<PromptCacheTtl>, model: &str) -> PromptCacheTtl {
configured
.or_else(|| {
crate::models::model_catalog_entry("openai", model)
.and_then(|entry| entry.prompt_cache_ttl)
.and_then(|ttl| match ttl {
"30m" => Some(PromptCacheTtl::M30),
"12h" => Some(PromptCacheTtl::H12),
_ => None,
})
})
.unwrap_or_default()
}
#[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 = "default_true")]
pub stable_tool_catalog_across_modes: bool,
#[serde(default = "default_true")]
pub gap_warning_enabled: bool,
#[serde(default)]
pub gap_warning_threshold_secs: Option<u64>,
#[serde(default)]
pub providers: ProviderPromptCachingConfig,
}
impl Default for PromptCachingConfig {
fn default() -> Self {
let cache_dir = default_cache_dir();
Self {
enabled: default_enabled() && !cache_dir.is_empty(),
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(),
stable_tool_catalog_across_modes: true,
gap_warning_enabled: default_true(),
gap_warning_threshold_secs: None,
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" | "merge-gateway" => 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,
}
}
pub fn gap_threshold_secs(&self, provider_name: &str) -> Option<u64> {
self.gap_threshold_secs_for_model(provider_name, "")
}
pub fn gap_threshold_secs_for_route(
&self,
provider: &str,
model: &str,
ttl: Option<PromptCacheTtl>,
) -> Option<u64> {
if !self.gap_warning_enabled
|| !self.enabled
|| !(self.is_provider_enabled(provider) || (ttl.is_some() && self.providers.openai.enabled))
{
return None;
}
if let Some(threshold) = self.gap_warning_threshold_secs {
return Some(threshold);
}
if let Some(ttl) = ttl {
return Some(ttl.seconds());
}
Some(match provider.to_ascii_lowercase().as_str() {
"anthropic" | "minimax" => prompt_cache::ANTHROPIC_CACHE_GAP_WARNING_SECONDS,
"openai"
if supports_openai_explicit_prompt_cache(model)
|| self.providers.openai.prompt_cache_retention == Some(PromptCacheRetention::H24) =>
{
30 * 60
}
"openai" => prompt_cache::OPENAI_CACHE_GAP_WARNING_SECONDS,
_ => prompt_cache::DEFAULT_CACHE_GAP_WARNING_SECONDS,
})
}
pub fn gap_threshold_secs_for_model(&self, provider_name: &str, model: &str) -> Option<u64> {
let ttl = (provider_name.eq_ignore_ascii_case("openai") && supports_openai_explicit_prompt_cache(model))
.then(|| resolved_openai_prompt_cache_ttl(self.providers.openai.prompt_cache_ttl, model));
self.gap_threshold_secs_for_route(provider_name, model, ttl)
}
}
#[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")]
moonshot: MoonshotPromptCacheSettings,
#[serde(default = "DeepSeekPromptCacheSettings::default")]
pub deepseek: DeepSeekPromptCacheSettings,
#[serde(default = "ZaiPromptCacheSettings::default")]
zai: ZaiPromptCacheSettings,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum PromptCacheRetention {
#[serde(rename = "in_memory")]
InMemory,
#[default]
#[serde(rename = "24h")]
H24,
#[serde(other, rename = "unknown")]
Unknown,
}
impl PromptCacheRetention {
pub fn as_str(&self) -> &str {
match self {
Self::InMemory => "in_memory",
Self::H24 => "24h",
Self::Unknown => "unknown",
}
}
}
impl std::fmt::Display for PromptCacheRetention {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[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")]
min_prefix_tokens: u32,
#[serde(default = "default_openai_idle_expiration")]
idle_expiration_seconds: u64,
#[serde(default = "default_true")]
pub surface_metrics: bool,
#[serde(default)]
pub auxiliary_explicit_cache: bool,
#[serde(default = "default_openai_prompt_cache_key_mode")]
pub prompt_cache_key_mode: OpenAIPromptCacheKeyMode,
#[serde(default)]
pub prompt_cache_retention: Option<PromptCacheRetention>,
#[serde(default)]
pub prompt_cache_ttl: Option<PromptCacheTtl>,
}
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(),
auxiliary_explicit_cache: false,
prompt_cache_key_mode: default_openai_prompt_cache_key_mode(),
prompt_cache_retention: None,
prompt_cache_ttl: None,
}
}
}
impl OpenAIPromptCacheSettings {
fn validate(&self) -> anyhow::Result<()> {
if let Some(PromptCacheRetention::Unknown) = self.prompt_cache_retention {
anyhow::bail!("prompt_cache_retention must be one of: in_memory, 24h");
}
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,
}
}
#[must_use]
pub fn map_prompt_cache_key_for_provider(provider_name: &str, key: String) -> String {
let provider = provider_name.trim().to_ascii_lowercase();
match provider.as_str() {
"merge-gateway" => key.replacen("vtcode:openai:", "vtcode:merge:", 1),
"openrouter" => key.replacen("vtcode:openai:", "vtcode:openrouter:", 1),
"xai" => key.replacen("vtcode:openai:", "vtcode:xai:", 1),
_ => key,
}
}
#[must_use]
pub fn session_affinity_provider(provider_name: &str) -> bool {
matches!(
provider_name.trim().to_ascii_lowercase().as_str(),
"openai" | "merge-gateway" | "openrouter" | "xai"
)
}
#[must_use]
pub fn session_affinity_key_enabled(
provider_name: &str,
global_prompt_cache_enabled: bool,
openai_prompt_cache_enabled: bool,
) -> bool {
if !global_prompt_cache_enabled {
return false;
}
match provider_name.trim().to_ascii_lowercase().as_str() {
"openai" | "merge-gateway" => openai_prompt_cache_enabled,
"openrouter" | "xai" => true,
_ => false,
}
}
#[must_use]
pub fn build_session_affinity_prompt_cache_key(
provider_name: &str,
session_affinity_enabled: bool,
prompt_cache_key_mode: &OpenAIPromptCacheKeyMode,
lineage_id: Option<&str>,
) -> Option<String> {
if !session_affinity_enabled {
return None;
}
let provider = provider_name.trim().to_ascii_lowercase();
let lineage = lineage_id.map(str::trim).filter(|value| !value.is_empty())?;
match provider.as_str() {
"openai" | "merge-gateway" => match prompt_cache_key_mode {
OpenAIPromptCacheKeyMode::Session => {
Some(map_prompt_cache_key_for_provider(&provider, format!("vtcode:openai:{lineage}")))
}
OpenAIPromptCacheKeyMode::Off => None,
},
"openrouter" | "xai" => Some(format!("vtcode:{provider}:{lineage}")),
_ => 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>,
#[serde(default)]
pub prefer_extended_ttl: bool,
}
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(),
prefer_extended_ttl: false,
}
}
}
#[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")]
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")]
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")]
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")]
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 {
VtCodePaths::resolve()
.map(|paths| paths.cache_dir().join("prompts").display().to_string())
.unwrap_or_default()
}
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_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
}
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
}
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 {
VtCodePaths::resolve()
.map(|paths| paths.cache_dir().join("prompts"))
.unwrap_or_default()
}
impl PromptCachingConfig {
pub(crate) fn validate(&self) -> anyhow::Result<()> {
self.providers.openai.validate()?;
Ok(())
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod ttl_tests;