use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A cache capability is opt-in because `/chat/completions` is shared by many
/// incompatible OpenAI-style routes. An unknown route remains unsupported.
///
/// The variants are named by the request fields they authorize, not by a model family: the
/// field names are what a route accepts or rejects, and a route that takes `prompt_cache_key`
/// is not necessarily a route that takes `prompt_cache_retention`.
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CacheCapability {
#[default]
Unsupported,
/// `prompt_cache_key` only.
OpenAiPromptCacheKey,
/// `prompt_cache_key` plus `prompt_cache_retention` (`in_memory` or `24h`).
OpenAiPromptCacheRetention,
/// `prompt_cache_key` plus `prompt_cache_options: {"ttl": "30m"}`.
OpenAiPromptCacheOptions,
}
/// How long a cached prefix stays eligible for reuse, in the terms the route states it.
///
/// The value is a request to the route, not a promise about the cache: a route may retain an
/// entry longer, and may evict it sooner under load. It is still worth declaring, because the
/// default for a retention-policy-bearing organization can be much shorter than the session
/// gaps a waiting agent actually has.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CacheRetention {
/// Roughly 5-10 minutes of inactivity, up to an hour.
InMemory,
/// Around 30 minutes typically, retained up to 24 hours.
OneDay,
/// Minimum cache lifetime on the routes that express retention as an options object.
ThirtyMinutes,
}
impl CacheRetention {
pub fn parse(name: &str) -> Option<Self> {
match name.trim().to_ascii_lowercase().as_str() {
"in_memory" | "in-memory" | "memory" => Some(Self::InMemory),
"24h" | "one_day" => Some(Self::OneDay),
"30m" | "thirty_minutes" => Some(Self::ThirtyMinutes),
_ => None,
}
}
/// The literal the retention-shaped field carries.
pub const fn field_value(self) -> &'static str {
match self {
Self::InMemory => "in_memory",
Self::OneDay => "24h",
Self::ThirtyMinutes => "30m",
}
}
pub const fn name(self) -> &'static str {
self.field_value()
}
}
impl CacheCapability {
pub fn parse(name: &str) -> Option<Self> {
match name.trim().to_ascii_lowercase().as_str() {
"unsupported" | "none" => Some(Self::Unsupported),
"openai" | "openai_prompt_cache_key" => Some(Self::OpenAiPromptCacheKey),
"openai_prompt_cache_retention" => Some(Self::OpenAiPromptCacheRetention),
"openai_prompt_cache_options" => Some(Self::OpenAiPromptCacheOptions),
_ => None,
}
}
pub const fn name(&self) -> &'static str {
match self {
Self::Unsupported => "unsupported",
Self::OpenAiPromptCacheKey => "openai_prompt_cache_key",
Self::OpenAiPromptCacheRetention => "openai_prompt_cache_retention",
Self::OpenAiPromptCacheOptions => "openai_prompt_cache_options",
}
}
/// Whether this route can be told how long to retain a cached prefix.
///
/// Two shapes express the same intent on different routes, and each rejects the other's
/// field: sending both would be a request with a parameter the route does not know.
pub const fn retention_field(&self) -> Option<&'static str> {
match self {
Self::OpenAiPromptCacheRetention => Some("prompt_cache_retention"),
Self::OpenAiPromptCacheOptions => Some("prompt_cache_options"),
Self::Unsupported | Self::OpenAiPromptCacheKey => None,
}
}
/// The retention values this route can express.
pub const fn retention_values(&self) -> &'static [CacheRetention] {
match self {
Self::OpenAiPromptCacheRetention => &[CacheRetention::InMemory, CacheRetention::OneDay],
Self::OpenAiPromptCacheOptions => &[CacheRetention::ThirtyMinutes],
Self::Unsupported | Self::OpenAiPromptCacheKey => &[],
}
}
}
/// Native cache settings for one configured provider route.
///
/// `cache_affinity` is an application-owned grouping identity. It is not a
/// credential, URL, model, tool schema revision, or cache hit claim. The
/// provider may still decline to cache a request, which is reported by
/// [`crate::models::Usage::cached_prompt_tokens`].
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct CacheProfile {
pub capability: CacheCapability,
pub cache_affinity: Option<String>,
/// How long the route should keep a cached prefix eligible, when the operator declared it.
///
/// Absent means the route's own default, which is a real choice and not the same as asking
/// for the shortest one: an agent that idles between turns is exactly the case the longer
/// retention exists for.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retention: Option<CacheRetention>,
}
impl CacheProfile {
pub fn unsupported() -> Self {
Self::default()
}
pub fn openai_prompt_cache_key(cache_affinity: impl Into<String>) -> Self {
Self::openai(CacheCapability::OpenAiPromptCacheKey, cache_affinity)
}
/// A profile on a route that declares which cache fields it accepts.
pub fn openai(capability: CacheCapability, cache_affinity: impl Into<String>) -> Self {
Self {
capability,
cache_affinity: Some(cache_affinity.into()),
retention: None,
}
}
/// Declare a retention value, refusing one this capability cannot express.
///
/// The refusal is here rather than at the wire because the wire would answer with a
/// parameter error from the route, after the request was paid for.
pub fn with_retention(mut self, retention: CacheRetention) -> Result<Self, String> {
if !self.capability.retention_values().contains(&retention) {
return Err(format!(
"cache profile `{}` cannot express retention `{}`",
self.capability.name(),
retention.name()
));
}
self.retention = Some(retention);
Ok(self)
}
/// The retention field this route accepts, when the profile declares one.
pub fn retention_field(&self) -> Option<(&'static str, serde_json::Value)> {
let field = self.capability.retention_field()?;
let retention = self.retention?;
let value = match field {
"prompt_cache_options" => serde_json::json!({ "ttl": retention.field_value() }),
_ => serde_json::Value::String(retention.field_value().into()),
};
Some((field, value))
}
pub fn supports_native_cache(&self) -> bool {
!matches!(self.capability, CacheCapability::Unsupported)
}
/// Return fields allowed by this route's declared native capability.
/// Empty affinity intentionally means no native cache field is sent.
pub fn native_request_fields(&self) -> Map<String, Value> {
let mut fields = Map::new();
if matches!(self.capability, CacheCapability::Unsupported) {
return fields;
}
if let Some(affinity) = self
.cache_affinity
.as_deref()
.filter(|affinity| !affinity.trim().is_empty())
{
fields.insert("prompt_cache_key".into(), Value::String(affinity.into()));
}
if let Some((field, value)) = self.retention_field() {
fields.insert(field.into(), value);
}
fields
}
/// Remove cache fields from arbitrary route options, then apply only the
/// fields authorized by this profile.
pub fn apply_to_request(&self, body: &mut Map<String, Value>) {
for field in [
"cache_prompt",
"cache_control",
"prompt_cache_key",
"prompt_cache_retention",
"prompt_cache_options",
] {
body.remove(field);
}
body.extend(self.native_request_fields());
}
}
#[cfg(test)]
mod cache_profile_tests {
use super::*;
use serde_json::json;
fn body(profile: &CacheProfile) -> serde_json::Map<String, Value> {
let mut body = serde_json::Map::new();
body.insert("model".into(), json!("m"));
profile.apply_to_request(&mut body);
body
}
#[test]
fn a_route_that_declares_nothing_sends_no_cache_fields() {
// The field would be a parameter the route may not know, so absence is the safe default
// rather than sending the key and hoping.
let profile = CacheProfile::unsupported();
assert!(profile.native_request_fields().is_empty());
let mut body = json!({"model": "m", "prompt_cache_key": "smuggled"})
.as_object()
.unwrap()
.clone();
profile.apply_to_request(&mut body);
assert_eq!(
body.get("prompt_cache_key"),
None,
"route options cannot smuggle cache fields"
);
}
#[test]
fn a_key_capability_sends_only_the_key() {
let profile = CacheProfile::openai_prompt_cache_key("klbr:deepseek");
let fields = profile.native_request_fields();
assert_eq!(
fields.get("prompt_cache_key"),
Some(&json!("klbr:deepseek"))
);
assert_eq!(fields.get("prompt_cache_retention"), None);
assert_eq!(fields.get("prompt_cache_options"), None);
}
#[test]
fn a_retention_capability_sends_the_retention_literal() {
let profile = CacheProfile::openai(CacheCapability::OpenAiPromptCacheRetention, "klbr:gpt")
.with_retention(CacheRetention::OneDay)
.expect("24h on a retention-shaped route");
let fields = profile.native_request_fields();
assert_eq!(fields.get("prompt_cache_retention"), Some(&json!("24h")));
assert_eq!(fields.get("prompt_cache_options"), None);
}
#[test]
fn an_options_capability_sends_an_options_object() {
let profile = CacheProfile::openai(CacheCapability::OpenAiPromptCacheOptions, "klbr:gpt56")
.with_retention(CacheRetention::ThirtyMinutes)
.expect("30m on an options-shaped route");
let fields = profile.native_request_fields();
assert_eq!(
fields.get("prompt_cache_options"),
Some(&json!({"ttl": "30m"}))
);
assert_eq!(fields.get("prompt_cache_retention"), None);
}
#[test]
fn a_retention_value_the_route_cannot_express_is_refused_here() {
// The alternative is a parameter error from the route, after the request was paid for.
let err = CacheProfile::openai(CacheCapability::OpenAiPromptCacheOptions, "klbr:gpt56")
.with_retention(CacheRetention::OneDay)
.expect_err("24h is not an options-shaped value");
assert!(err.contains("cannot express"), "{err}");
let err = CacheProfile::openai(CacheCapability::OpenAiPromptCacheRetention, "klbr:gpt")
.with_retention(CacheRetention::ThirtyMinutes)
.expect_err("30m is not a retention-shaped value");
assert!(err.contains("cannot express"), "{err}");
}
#[test]
fn a_profile_without_retention_sends_none_and_keeps_the_default() {
let profile = CacheProfile::openai_prompt_cache_key("klbr:gpt");
let body = body(&profile);
assert_eq!(body.get("prompt_cache_key"), Some(&json!("klbr:gpt")));
assert_eq!(
body.get("prompt_cache_retention"),
None,
"the route's own default stands"
);
}
#[test]
fn route_options_cannot_reintroduce_a_stripped_field() {
let profile = CacheProfile::openai_prompt_cache_key("klbr:gpt");
let mut body = json!({
"model": "m",
"prompt_cache_retention": "24h",
"prompt_cache_options": {"ttl": "30m"},
"cache_control": {"type": "ephemeral"},
})
.as_object()
.unwrap()
.clone();
profile.apply_to_request(&mut body);
assert_eq!(body.get("prompt_cache_retention"), None);
assert_eq!(body.get("prompt_cache_options"), None);
assert_eq!(body.get("cache_control"), None);
}
}