use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde_json::{Value, json};
use crate::attachment::Attachment;
use crate::chat::resolve_model;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::providers::Provider;
use crate::transport::Connection;
#[derive(Debug, Clone, PartialEq)]
pub enum Ttl {
Seconds(i64),
Duration(String),
}
impl From<i64> for Ttl {
fn from(s: i64) -> Self {
Ttl::Seconds(s)
}
}
impl From<&str> for Ttl {
fn from(s: &str) -> Self {
Ttl::Duration(s.to_string())
}
}
impl Ttl {
fn render(&self) -> String {
match self {
Ttl::Seconds(s) => format!("{s}s"),
Ttl::Duration(d) => d.clone(),
}
}
}
#[derive(Default)]
pub struct CacheOptions<'a> {
pub model: &'a str,
pub provider: Option<&'a str>,
pub ttl: Option<Ttl>,
pub instructions: Option<&'a str>,
pub with: Vec<Attachment>,
pub config: Option<Arc<Config>>,
}
#[derive(Debug, Clone)]
pub struct CachedContent {
pub name: String,
pub model: Option<String>,
pub provider: String,
pub created_at: Option<DateTime<Utc>>,
pub expires_at: Option<DateTime<Utc>>,
pub tokens: Option<i64>,
pub metadata: Value,
connection: Option<Connection>,
}
pub async fn cache(content: &str, options: CacheOptions<'_>) -> Result<CachedContent> {
CachedContent::create(content, options).await
}
impl CachedContent {
pub fn new(name: impl Into<String>) -> CachedContent {
CachedContent {
name: name.into(),
metadata: json!({}),
..cache_from(&Value::Null)
}
}
pub async fn create(content: &str, options: CacheOptions<'_>) -> Result<CachedContent> {
let config = options.config.clone().unwrap_or_else(crate::config);
let (model, provider) = resolve_model(options.model, options.provider, false)?;
let connection = connection_for(provider, config)?;
let mut with = options.with;
for a in &mut with {
a.load(connection.client()).await?;
}
let payload = render_cache_payload(
content,
&model.id,
options.ttl.as_ref(),
options.instructions,
&with,
)?;
let resp = connection
.send(
reqwest::Method::POST,
"cachedContents",
&[],
false,
&|req| req.json(&payload),
)
.await?;
let body = crate::transport::json_response(resp, Value::Null)
.await?
.body;
Ok(parse_cache_response(&body, connection))
}
pub async fn find(name: &str, provider: Option<&str>) -> Result<CachedContent> {
CachedContent::find_with_config(crate::config(), name, provider).await
}
pub async fn find_with_config(
config: Arc<Config>,
name: &str,
provider: Option<&str>,
) -> Result<CachedContent> {
let provider = match provider {
Some(p) => Provider::resolve_or_err(p)?,
None => resolve_model(&config.default_model, None, false)?.1,
};
let connection = connection_for(provider, config)?;
let body = connection.get(&cache_name(name), &[]).await?.body;
Ok(parse_cache_response(&body, connection))
}
pub async fn delete(&self) -> Result<&Self> {
self.connection()?
.delete(&cache_name(&self.name), &[])
.await?;
Ok(self)
}
pub async fn renew(&mut self, ttl: impl Into<Ttl>) -> Result<&mut Self> {
let connection = self.connection()?.clone();
let payload = render_cache_update_payload(&ttl.into());
let resp = connection
.send(
reqwest::Method::PATCH,
&cache_name(&self.name),
&[],
true,
&|req| req.json(&payload),
)
.await?;
let body = crate::transport::json_response(resp, Value::Null)
.await?
.body;
let refreshed = parse_cache_response(&body, connection);
self.expires_at = refreshed.expires_at;
self.metadata = refreshed.metadata;
Ok(self)
}
fn connection(&self) -> Result<&Connection> {
self.connection
.as_ref()
.ok_or_else(|| Error::Argument("This cache has no provider connection".into()))
}
}
fn connection_for(provider: Provider, config: Arc<Config>) -> Result<Connection> {
if provider != Provider::Gemini {
return Err(Error::Api(
format!(
"{} doesn't support explicit content caching",
provider.display()
),
None,
));
}
provider.ensure_configured(&config)?;
Connection::new(provider, config)
}
pub fn render_cache_payload(
content: &str,
model: &str,
ttl: Option<&Ttl>,
instructions: Option<&str>,
attachments: &[Attachment],
) -> Result<Value> {
let parts = crate::protocols::gemini::format_content(Some(content), attachments)?;
let mut payload = json!({ "model": format!("models/{model}"), "contents": [{ "role": "user", "parts": parts }] });
if let Some(text) = instructions {
payload["systemInstruction"] = json!({ "parts": [{ "text": text }] });
}
if let Some(ttl) = ttl {
payload["ttl"] = ttl.render().into();
}
Ok(payload)
}
pub fn render_cache_update_payload(ttl: &Ttl) -> Value {
json!({ "ttl": ttl.render() })
}
impl AsRef<str> for CachedContent {
fn as_ref(&self) -> &str {
&self.name
}
}
pub fn cache_name(name: impl AsRef<str>) -> String {
let name = name.as_ref();
if name.contains('/') {
name.to_string()
} else {
format!("cachedContents/{name}")
}
}
fn time(v: Option<&Value>) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(v?.as_str()?)
.ok()
.map(|t| t.with_timezone(&Utc))
}
fn parse_cache_response(data: &Value, connection: Connection) -> CachedContent {
CachedContent {
connection: Some(connection),
..cache_from(data)
}
}
fn cache_from(data: &Value) -> CachedContent {
CachedContent {
name: data
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
model: data
.get("model")
.and_then(Value::as_str)
.and_then(|m| m.rsplit('/').next())
.map(str::to_string),
provider: Provider::Gemini.slug().into(),
created_at: time(data.get("createTime")),
expires_at: time(data.get("expireTime")),
tokens: data
.pointer("/usageMetadata/totalTokenCount")
.and_then(Value::as_i64),
metadata: data.clone(),
connection: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_the_model_contents_system_instruction_and_ttl() {
let payload = render_cache_payload(
"A long stable prefix.",
"gemini-2.5-flash",
Some(&300.into()),
Some("You are a careful analyst."),
&[],
)
.unwrap();
assert_eq!(
payload,
json!({
"model": "models/gemini-2.5-flash",
"contents": [{ "role": "user", "parts": [{ "text": "A long stable prefix." }] }],
"systemInstruction": { "parts": [{ "text": "You are a careful analyst." }] },
"ttl": "300s"
})
);
}
#[test]
fn omits_system_instruction_and_ttl_when_not_given() {
let payload = render_cache_payload("Prefix.", "gemini-2.5-flash", None, None, &[]).unwrap();
assert_eq!(
payload.as_object().unwrap().keys().collect::<Vec<_>>(),
["model", "contents"]
);
}
#[test]
fn passes_duration_strings_through_as_ttl() {
let payload = render_cache_payload(
"Prefix.",
"gemini-2.5-flash",
Some(&"450s".into()),
None,
&[],
)
.unwrap();
assert_eq!(payload["ttl"], "450s");
}
#[test]
fn prefixes_bare_ids_with_the_collection_name() {
assert_eq!(cache_name("abc123"), "cachedContents/abc123");
}
#[test]
fn keeps_full_resource_names_unchanged() {
assert_eq!(cache_name("cachedContents/abc123"), "cachedContents/abc123");
}
#[test]
fn unwraps_cached_content_instances() {
let cache = cache_from(&json!({ "name": "cachedContents/abc123" }));
assert_eq!(cache_name(&cache), "cachedContents/abc123");
}
#[test]
fn formats_attachments_through_the_gemini_media_handling() {
let attachment = Attachment::from_bytes(b"fake-png".to_vec(), "diagram.png", None);
let payload = render_cache_payload(
"See the diagram.",
"gemini-2.5-flash",
None,
None,
&[attachment],
)
.unwrap();
let parts = payload["contents"][0]["parts"].as_array().unwrap();
assert_eq!(
parts.first().unwrap(),
&json!({ "text": "See the diagram." })
);
assert_eq!(
parts.last().unwrap()["inline_data"]["mime_type"],
"image/png"
);
}
#[test]
fn converts_numeric_ttls_into_duration_strings() {
assert_eq!(
render_cache_update_payload(&600.into()),
json!({ "ttl": "600s" })
);
}
#[test]
fn builds_a_cached_content_from_the_resource() {
let data = json!({
"name": "cachedContents/abc123",
"model": "models/gemini-2.5-flash",
"createTime": "2026-08-11T10:00:00Z",
"expireTime": "2026-08-11T11:00:00Z",
"usageMetadata": { "totalTokenCount": 7809 }
});
let cache = cache_from(&data);
let at = |s| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
assert_eq!(cache.name, "cachedContents/abc123");
assert_eq!(cache.model.as_deref(), Some("gemini-2.5-flash"));
assert_eq!(cache.provider, "gemini");
assert_eq!(cache.created_at, Some(at("2026-08-11T10:00:00Z")));
assert_eq!(cache.expires_at, Some(at("2026-08-11T11:00:00Z")));
assert_eq!(cache.tokens, Some(7809));
assert_eq!(cache.metadata, data);
}
}