Skip to main content

rig_core/providers/gemini/
caching.rs

1//! Automatic explicit caching for long Gemini runs.
2//!
3//! [`Caching`](super::Caching) is a transport that wraps another. Every `generateContent`
4//! and `streamGenerateContent` request passes through it with its encoded
5//! body, and a shared [`CacheBook`] decides what the request reads from a
6//! cache and when a new cache is worth creating:
7//!
8//! ```no_run
9//! use rig_core::providers::gemini::{AutoCache, CacheBook, Gemini};
10//!
11//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
12//! let gemini = Gemini::from_env()?;
13//! let book = CacheBook::new(AutoCache::default());
14//! let model = gemini.completion("gemini-3.8-flash").caching(&book);
15//! // ... run an agent or a chat on `model` ...
16//! book.close(&gemini.cached_contents()).await;
17//! println!("{:?}", book.report());
18//! # Ok(())
19//! # }
20//! ```
21//!
22//! # What it does
23//!
24//! Gemini's implicit caching reuses an earlier request's prefix at no cost,
25//! but on gemini-3.8-flash a chat-sized request that extends the previous one
26//! almost never hits (measured: 1 hit in 138). An explicit cache
27//! (`cachedContents`) can hold the whole conversation so far: system
28//! instruction, tools, tool config, every model turn with its signatures, and
29//! function calls and responses. A request that reads it sends only what
30//! came after. Caches cannot be extended or chained, so a growing
31//! conversation needs a new cache from time to time ("rolling"), and each new
32//! cache is billed again at the input price.
33//!
34//! The `Auto` policy rolls when rolling has already paid for itself: it
35//! counts the input premium the conversation paid for tokens a cache could
36//! have held, and creates a new cache once that premium covers the new
37//! cache's creation and storage (ski rental). So:
38//!
39//! - a cache read once never pays, and is never made;
40//! - rolling beats one fixed prefix cache from about 20 calls, and the saving
41//!   grows with the run;
42//! - where implicit caching already serves a conversation (large contexts,
43//!   repeated documents), the book stays inline instead of paying to cache
44//!   what is already cheap. It goes inline above `implicit_ceiling` tokens
45//!   until implicit caching is seen failing.
46//!
47//! Cost savings top out below 90%, because a cached read costs 10% of the
48//! input price. How close a run gets depends on its shape: a large stable
49//! prefix with little new content per call approaches 99% cached (a batch of
50//! short questions over a 5.5k-token prefix measured 99.6%); a chat on a small
51//! preamble cannot, because each call's new turn is never cached, and on
52//! Gemini 3 each earlier thought a request replays through its signature is
53//! billed again as input that no explicit cache holds (see
54//! [`ThoughtReplay`](super::completion::ThoughtReplay)).
55//!
56//! # Lifecycle
57//!
58//! - The book tracks each cache's expiry and never sends one it knows has
59//!   expired. A new cache's TTL follows the gaps between calls (at least
60//!   twice the longest gap seen, never less than [`AutoCache::ttl`], never more
61//!   than 81 minutes, past which keeping a cache costs more than it saves at
62//!   the default prices), and a cache read close to expiry is extended.
63//! - A replaced cache is deleted when the roll replaces it, and a
64//!   conversation cache nobody has read for a while is deleted too.
65//! - A cache that answers 403 (deleted elsewhere, or expired early) is
66//!   forgotten and the request is sent again inline, once.
67//! - [`CacheBook::close`] deletes everything still live.
68//!
69//! # Resume
70//!
71//! A checkpoint stores [`CacheBook::leases`] beside the run. A new book in a
72//! new process calls [`CacheBook::restore`] with them, then
73//! [`CacheBook::prove`], which keeps a lease only if Google still has it
74//! under its name. The resumed run then reads the surviving cache instead of
75//! creating a new one.
76//!
77//! # Scope
78//!
79//! This covers `generateContent` and `streamGenerateContent` on the Gemini
80//! Developer API. The Interactions API has no explicit caching. Vertex AI
81//! and `rig-gemini-grpc` are not covered, nor are hosted-tool parts, which
82//! this crate's Gemini decoder does not keep in history.
83
84use std::collections::{BTreeMap, HashMap, HashSet};
85use std::sync::{Arc, Mutex, PoisonError};
86use std::time::Duration;
87
88use serde::{Deserialize, Serialize};
89use serde_json::value::RawValue;
90use sha2::{Digest, Sha256};
91
92use crate::completion::CacheCost;
93use crate::json_utils::remove_preserving_order;
94
95/// Unix seconds.
96pub type Clock = Arc<dyn Fn() -> u64 + Send + Sync>;
97
98/// Seconds since the Unix epoch, from the system clock where the target has
99/// one. On `wasm32-unknown-unknown` there is none: pass a clock with
100/// [`CacheBook::with_clock`] there, or every cache looks forever young and
101/// expiry is found by Gemini's 403 instead.
102fn system_now() -> u64 {
103    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
104    {
105        std::time::SystemTime::now()
106            .duration_since(std::time::UNIX_EPOCH)
107            .map_or(0, |elapsed| elapsed.as_secs())
108    }
109    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
110    {
111        0
112    }
113}
114
115/// The longest TTL the book gives a cache: past about 81 minutes between
116/// reads, storage costs more than a cached read saves at the default prices.
117const MAX_TTL_SECS: u64 = 81 * 60;
118
119/// A cache is used only with more than this many seconds left.
120const EXPIRY_MARGIN_SECS: u64 = 5;
121
122/// A conversation cache unread for this long, or three times its line's
123/// longest gap if longer, belongs to a conversation that moved on.
124const MIN_IDLE_SECS: u64 = 60;
125
126/// The `Auto` policy's parameters. The price ratios default to
127/// gemini-3.8-flash standard prices relative to its input price (cached read
128/// $0.075 and storage $0.50 per 1M tokens per hour, against $0.75 input);
129/// [`AutoCache::for_spec`] reads the cached-read ratio of another model
130/// from its catalog spec. The catalog holds no storage price.
131#[derive(Clone, Copy, Debug, PartialEq)]
132pub struct AutoCache {
133    /// The shortest TTL a new cache gets.
134    pub ttl: Duration,
135    /// Smallest request implicit caching serves (4,096 tokens on Gemini 3).
136    pub implicit_floor: u64,
137    /// Above this many cacheable tokens the book sends requests inline and
138    /// lets implicit caching serve them, until implicit caching is seen
139    /// failing.
140    pub implicit_ceiling: u64,
141    /// Gemini's minimum cache size.
142    pub min_tokens: u64,
143    /// The fewest tokens a new cache must add over the one it replaces.
144    pub min_gain: u64,
145    /// Cached-read price over input price.
146    pub cached_ratio: f64,
147    /// Storage price per hour over input price.
148    pub storage_ratio_per_hour: f64,
149}
150
151impl Default for AutoCache {
152    fn default() -> Self {
153        Self {
154            ttl: Duration::from_secs(60 * 60),
155            implicit_floor: 4_096,
156            implicit_ceiling: 16_000,
157            min_tokens: 1_024,
158            min_gain: 256,
159            cached_ratio: 0.1,
160            storage_ratio_per_hour: 0.5 / 0.75,
161        }
162    }
163}
164
165impl AutoCache {
166    /// The default policy with the cached-read ratio of `spec`'s pricing,
167    /// such as the spec a model carries ([`DynModel::spec`]). A spec with no
168    /// usable price keeps the default ratio.
169    ///
170    /// [`DynModel::spec`]: crate::DynModel::spec
171    pub fn for_spec(spec: &crate::catalog::ModelSpec) -> Self {
172        Self::priced(spec.pricing.as_ref())
173    }
174
175    /// The default policy with the cached-read ratio of `pricing`. Without
176    /// a cached-read price, with a negative one, or with no positive input
177    /// price to divide by, the default ratio stays.
178    fn priced(pricing: Option<&crate::catalog::Pricing>) -> Self {
179        let ratio = pricing
180            .and_then(|pricing| Some((pricing.cache_read?, pricing.input)))
181            .filter(|(read, input)| *read >= 0.0 && *input > 0.0)
182            .map(|(read, input)| read / input);
183        let default = Self::default();
184        Self {
185            cached_ratio: ratio.unwrap_or(default.cached_ratio),
186            ..default
187        }
188    }
189}
190
191/// One cache the book holds.
192#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
193pub struct Lease {
194    /// `cachedContents/<id>`.
195    pub name: String,
196    /// Hex SHA-256 of the prefix the cache holds. Its first 40 characters
197    /// end the cache's display name, which is how [`CacheBook::prove`]
198    /// recognizes it.
199    pub digest: String,
200    /// How many contents the cache holds.
201    pub covers: usize,
202    /// Its size, as Gemini counted it when it was created.
203    pub tokens: u64,
204    /// Unix seconds.
205    pub expires_at: u64,
206    /// The model the cache belongs to.
207    pub model: String,
208}
209
210/// Something that happened to a cache.
211#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
212pub enum CacheEvent {
213    /// A cache was created.
214    Created {
215        /// `cachedContents/<id>`.
216        name: String,
217        /// Its size, as Gemini counted it.
218        tokens: u64,
219        /// The book's estimate of its size before creating it.
220        estimated: u64,
221        /// How many contents it holds.
222        covers: usize,
223        /// Its TTL.
224        ttl_secs: u64,
225        /// Unix seconds.
226        at: u64,
227    },
228    /// The book deleted a cache it no longer needs.
229    Retired {
230        /// `cachedContents/<id>`.
231        name: String,
232        /// Unix seconds.
233        at: u64,
234    },
235    /// A cache answered 403 when a request named it, and was forgotten.
236    Lost {
237        /// `cachedContents/<id>`.
238        name: String,
239        /// Unix seconds.
240        at: u64,
241    },
242    /// A cache's expiry was pushed back.
243    Extended {
244        /// `cachedContents/<id>`.
245        name: String,
246        /// The new expiry, Unix seconds.
247        expires_at: u64,
248        /// Unix seconds.
249        at: u64,
250    },
251    /// Gemini refused to create a cache; the request went out without it.
252    CreateFailed {
253        /// The HTTP status, when there was one.
254        status: Option<u16>,
255        /// Gemini's message.
256        message: String,
257        /// Unix seconds.
258        at: u64,
259    },
260}
261
262/// One cache the book created.
263#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
264pub struct CreatedCache {
265    /// `cachedContents/<id>`.
266    pub name: String,
267    /// Its size.
268    pub tokens: u64,
269    /// Unix seconds.
270    pub at: u64,
271}
272
273/// What the book's caches did and cost, for pricing a run. Creation is
274/// billed like input (`tokens` × input price); storage is `token_hours` ×
275/// the storage price.
276#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
277pub struct CacheReport {
278    /// Every cache created, in order.
279    pub created: Vec<CreatedCache>,
280    /// How many requests read each cache, by name.
281    pub reads: BTreeMap<String, u64>,
282    /// Caches the book deleted because a roll replaced them, their
283    /// conversation moved on, or [`CacheBook::close`] ran.
284    pub retired: Vec<String>,
285    /// Caches that answered 403 and were forgotten.
286    pub lost: Vec<String>,
287    /// Σ tokens × hours each cache lived, until it was deleted, lost or
288    /// expired, or until now for a live one.
289    pub token_hours: f64,
290    /// The caches still live.
291    pub live: Vec<Lease>,
292}
293
294/// The book's caches as billed beside the calls: every created token as a
295/// cache write (priced at the input price) and `token_hours` of storage. Add
296/// it to the calls' [`CacheCost::from_usage`] to price a run.
297impl From<&CacheReport> for CacheCost {
298    fn from(report: &CacheReport) -> Self {
299        Self {
300            cache_writes: report.created.iter().map(|created| created.tokens).sum(),
301            storage_token_hours: report.token_hours,
302            ..Self::default()
303        }
304    }
305}
306
307#[derive(Clone, Debug, Default)]
308struct Line {
309    /// Premium tokens: cacheable input paid at full price, weighted by
310    /// `1 - cached_ratio`, since the line last rolled.
311    premium: f64,
312    /// EWMA of implicit coverage on inline calls big enough for it.
313    implicit: Option<f64>,
314    rolled_at: u64,
315    last_call: Option<u64>,
316    longest_gap: u64,
317    /// Coverable size at the last refused creation: no retry until the line
318    /// grows past it.
319    failed_at: Option<u64>,
320}
321
322#[derive(Clone, Debug)]
323struct Life {
324    tokens: u64,
325    created: u64,
326    expires_at: u64,
327    ended: Option<u64>,
328    last_read: u64,
329    line_gap: u64,
330}
331
332#[derive(Default)]
333pub(crate) struct Book {
334    pub(crate) leases: HashMap<String, Lease>,
335    lives: BTreeMap<String, Life>,
336    /// For each prefix, the conversations seen on it, by their first content.
337    lineages: HashMap<String, HashSet<String>>,
338    lines: HashMap<String, Line>,
339    events: Vec<CacheEvent>,
340    report: CacheReport,
341    /// Gemini's tokens over this book's estimate, learned from each cache it
342    /// creates.
343    calibration: Option<f64>,
344}
345
346/// The shared, content-addressed record of the caches a set of models
347/// reads. Clone it into every model that should share caches: sub-agents on
348/// one prefix, a resumed run and its successor.
349#[derive(Clone)]
350pub struct CacheBook {
351    inner: Arc<Mutex<Book>>,
352    /// Serializes cache creation, so a prefix is created once.
353    pub(crate) create: Arc<futures::lock::Mutex<()>>,
354    policy: AutoCache,
355    clock: Clock,
356    pub(crate) display_prefix: Arc<str>,
357}
358
359impl std::fmt::Debug for CacheBook {
360    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361        f.debug_struct("CacheBook")
362            .field("policy", &self.policy)
363            .field("display_prefix", &self.display_prefix)
364            .finish_non_exhaustive()
365    }
366}
367
368impl CacheBook {
369    /// A book with `policy`, on the system clock.
370    pub fn new(policy: AutoCache) -> Self {
371        Self {
372            inner: Arc::default(),
373            create: Arc::default(),
374            policy,
375            clock: Arc::new(system_now),
376            display_prefix: Arc::from("rig-cache-"),
377        }
378    }
379
380    /// The same book reading time from `clock` (Unix seconds).
381    pub fn with_clock(mut self, clock: impl Fn() -> u64 + Send + Sync + 'static) -> Self {
382        self.clock = Arc::new(clock);
383        self
384    }
385
386    /// The same book naming its caches `<prefix><digest>`, so they can be
387    /// listed and swept. Defaults to `rig-cache-`.
388    pub fn with_display_prefix(mut self, prefix: &str) -> Self {
389        self.display_prefix = Arc::from(prefix);
390        self
391    }
392
393    /// The book's policy.
394    pub fn policy(&self) -> AutoCache {
395        self.policy
396    }
397
398    pub(crate) fn book(&self) -> std::sync::MutexGuard<'_, Book> {
399        self.inner.lock().unwrap_or_else(PoisonError::into_inner)
400    }
401
402    pub(crate) fn now(&self) -> u64 {
403        (self.clock)()
404    }
405
406    /// Everything that happened to the book's caches, in order.
407    pub fn events(&self) -> Vec<CacheEvent> {
408        self.book().events.clone()
409    }
410
411    /// The caches the book holds, for a checkpoint.
412    pub fn leases(&self) -> Vec<Lease> {
413        let mut leases: Vec<Lease> = self.book().leases.values().cloned().collect();
414        leases.sort_by(|a, b| a.name.cmp(&b.name));
415        leases
416    }
417
418    /// Put checkpointed leases back. Call [`Self::prove`] next.
419    pub fn restore(&self, leases: Vec<Lease>) {
420        let now = self.now();
421        let mut book = self.book();
422        for lease in leases {
423            book.lives.entry(lease.name.clone()).or_insert(Life {
424                tokens: lease.tokens,
425                created: now,
426                expires_at: lease.expires_at,
427                ended: None,
428                last_read: now,
429                line_gap: 0,
430            });
431            book.leases.insert(lease.digest.clone(), lease);
432        }
433    }
434
435    /// What the caches did and cost so far.
436    pub fn report(&self) -> CacheReport {
437        let now = self.now();
438        let book = self.book();
439        let mut report = book.report.clone();
440        report.token_hours = book
441            .lives
442            .values()
443            .map(|life| {
444                let end = life.ended.unwrap_or(now).min(life.expires_at);
445                life.tokens as f64 * end.saturating_sub(life.created) as f64 / 3600.0
446            })
447            .sum();
448        report.live = book.leases.values().cloned().collect();
449        report.live.sort_by(|a, b| a.name.cmp(&b.name));
450        report
451    }
452
453    pub(crate) fn retired(&self, lease: &Lease) {
454        let now = self.now();
455        let mut book = self.book();
456        book.leases.remove(&lease.digest);
457        end_life(&mut book, &lease.name, now);
458        book.events.push(CacheEvent::Retired {
459            name: lease.name.clone(),
460            at: now,
461        });
462        book.report.retired.push(lease.name.clone());
463        tracing::info!(target: "gemini.cache.retired", name = %lease.name);
464    }
465
466    pub(crate) fn lost(&self, lease: &Lease) {
467        let now = self.now();
468        let mut book = self.book();
469        book.leases.remove(&lease.digest);
470        end_life(&mut book, &lease.name, now);
471        book.events.push(CacheEvent::Lost {
472            name: lease.name.clone(),
473            at: now,
474        });
475        book.report.lost.push(lease.name.clone());
476        tracing::info!(target: "gemini.cache.lost", name = %lease.name);
477    }
478}
479
480pub(crate) fn end_life(book: &mut Book, name: &str, now: u64) {
481    if let Some(life) = book.lives.get_mut(name)
482        && life.ended.is_none()
483    {
484        life.ended = Some(now);
485    }
486}
487
488pub(crate) fn short_digest(digest: &str) -> &str {
489    digest.get(..40).unwrap_or(digest)
490}
491
492// ---------------------------------------------------------------------------
493// Request bodies: parsing, digests, stripping and cache bodies.
494
495const PREFIX_KEYS: [&str; 3] = ["systemInstruction", "tools", "toolConfig"];
496
497/// A `generateContent` body, its contents kept as their exact bytes.
498pub(crate) struct Parsed {
499    /// Every other top-level field, in order, as exact bytes.
500    pub(crate) rest: Vec<(String, Box<RawValue>)>,
501    /// `systemInstruction`, `tools` and `toolConfig`, when present and not null.
502    pub(crate) prefix: [Option<Box<RawValue>>; 3],
503    pub(crate) contents: Vec<Box<RawValue>>,
504    pub(crate) has_cached_content: bool,
505}
506
507/// A JSON object's members in their order, each as its exact bytes. A
508/// repeated key keeps its first place and its last value, as a map does.
509struct Members(Vec<(String, Box<RawValue>)>);
510
511impl<'de> Deserialize<'de> for Members {
512    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
513        struct Visitor;
514        impl<'de> serde::de::Visitor<'de> for Visitor {
515            type Value = Members;
516            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
517                formatter.write_str("a JSON object")
518            }
519            fn visit_map<A: serde::de::MapAccess<'de>>(
520                self,
521                mut map: A,
522            ) -> Result<Members, A::Error> {
523                let mut members: Vec<(String, Box<RawValue>)> = Vec::new();
524                while let Some((key, value)) = map.next_entry::<String, Box<RawValue>>()? {
525                    match members.iter_mut().find(|(held, _)| *held == key) {
526                        Some((_, held)) => *held = value,
527                        None => members.push((key, value)),
528                    }
529                }
530                Ok(Members(members))
531            }
532        }
533        deserializer.deserialize_map(Visitor)
534    }
535}
536
537pub(crate) fn parse(bytes: &[u8]) -> Option<Parsed> {
538    let Members(members) = serde_json::from_slice(bytes).ok()?;
539    let mut parsed = Parsed {
540        rest: Vec::new(),
541        prefix: [None, None, None],
542        contents: Vec::new(),
543        has_cached_content: false,
544    };
545    for (key, value) in members {
546        if key == "contents" {
547            parsed.contents = serde_json::from_str(value.get()).ok()?;
548        } else if let Some(slot) = PREFIX_KEYS.iter().position(|prefix| *prefix == key) {
549            if value.get() != "null"
550                && let Some(field) = parsed.prefix.get_mut(slot)
551            {
552                *field = Some(value);
553            }
554        } else {
555            if key == "cachedContent" && value.get() != "null" {
556                parsed.has_cached_content = true;
557            }
558            parsed.rest.push((key, value));
559        }
560    }
561    Some(parsed)
562}
563
564fn hex(bytes: &[u8]) -> String {
565    use std::fmt::Write;
566    bytes.iter().fold(String::new(), |mut out, byte| {
567        let _ = write!(out, "{byte:02x}");
568        out
569    })
570}
571
572/// What the book reads off a body, before it takes its lock.
573pub(crate) struct Digested {
574    /// `d[k]`: the digest of the model, the prefix and `contents[..k]`.
575    pub(crate) d: Vec<String>,
576    /// The token estimate of the prefix.
577    pub(crate) prefix: u64,
578    /// The token estimate of each content.
579    pub(crate) contents: Vec<u64>,
580}
581
582pub(crate) fn digests(model: &str, parsed: &Parsed) -> Digested {
583    let mut hasher = Sha256::new();
584    hasher.update(model.as_bytes());
585    let mut prefix = 0;
586    for field in &parsed.prefix {
587        hasher.update([0u8]);
588        if let Some(raw) = field {
589            prefix += digest(&mut hasher, raw.get());
590        }
591    }
592    let mut state = hasher.finalize().to_vec();
593    let mut d = vec![hex(&state)];
594    let mut contents = Vec::with_capacity(parsed.contents.len());
595    for content in &parsed.contents {
596        let mut hasher = Sha256::new();
597        hasher.update(&state);
598        contents.push(digest(&mut hasher, content.get()));
599        state = hasher.finalize().to_vec();
600        d.push(hex(&state));
601    }
602    Digested {
603        d,
604        prefix,
605        contents,
606    }
607}
608
609/// Hash `fragment` with its keys sorted (a history replays a provider part
610/// with the key order it arrived in, which a recording does not keep, so
611/// the digests read the content, not its spelling), and return a rough
612/// token count of it: its bytes over four, without thought signatures,
613/// which Gemini expands into restored thoughts that no explicit cache holds.
614fn digest(hasher: &mut Sha256, fragment: &str) -> u64 {
615    let Ok(mut value) = serde_json::from_str::<serde_json::Value>(fragment) else {
616        hasher.update(fragment.as_bytes());
617        return (fragment.len() / 4) as u64;
618    };
619    hasher.update(crate::json_utils::to_canonical_string(&value).as_bytes());
620    fn scrub(value: &mut serde_json::Value) {
621        match value {
622            serde_json::Value::Object(map) => {
623                remove_preserving_order(map, "thoughtSignature");
624                map.values_mut().for_each(scrub);
625            }
626            serde_json::Value::Array(items) => items.iter_mut().for_each(scrub),
627            _ => {}
628        }
629    }
630    scrub(&mut value);
631    (value.to_string().len() / 4) as u64
632}
633
634/// Whether a content is the user's text: a new user turn, not a tool result.
635pub(crate) fn is_user_text(raw: &RawValue) -> bool {
636    #[derive(Deserialize)]
637    struct Content {
638        role: Option<String>,
639        #[serde(default)]
640        parts: Vec<serde_json::Map<String, serde_json::Value>>,
641    }
642    serde_json::from_str::<Content>(raw.get()).is_ok_and(|content| {
643        content.role.as_deref() == Some("user")
644            && content.parts.iter().any(|part| part.contains_key("text"))
645            && !content
646                .parts
647                .iter()
648                .any(|part| part.contains_key("functionResponse"))
649    })
650}
651
652/// The body that reads `lease` instead of what it holds.
653pub(crate) fn stripped(parsed: &Parsed, lease: &Lease) -> Option<Vec<u8>> {
654    let mut out: Vec<(String, &RawValue)> = Vec::new();
655    let name = serde_json::value::to_raw_value(&lease.name).ok()?;
656    let contents = serde_json::value::to_raw_value(&parsed.contents.get(lease.covers..)?).ok()?;
657    out.push(("cachedContent".to_owned(), &name));
658    out.push(("contents".to_owned(), &contents));
659    for (key, value) in &parsed.rest {
660        if key != "cachedContent" {
661            out.push((key.clone(), value));
662        }
663    }
664    let mut bytes = b"{".to_vec();
665    for (index, (key, value)) in out.iter().enumerate() {
666        if index > 0 {
667            bytes.push(b',');
668        }
669        bytes.extend(serde_json::to_vec(key).ok()?);
670        bytes.push(b':');
671        bytes.extend(value.get().as_bytes());
672    }
673    bytes.push(b'}');
674    Some(bytes)
675}
676
677/// The `cachedContents` create body for the first `covers` contents, from
678/// the request's own bytes.
679pub(crate) fn cache_body(
680    model: &str,
681    parsed: &Parsed,
682    covers: usize,
683    display_name: &str,
684    ttl_secs: u64,
685) -> Option<Vec<u8>> {
686    let mut bytes = b"{".to_vec();
687    let mut field = |key: &str, value: &str| {
688        if bytes.len() > 1 {
689            bytes.push(b',');
690        }
691        bytes.extend(format!("\"{key}\":").as_bytes());
692        bytes.extend(value.as_bytes());
693    };
694    field(
695        "model",
696        &serde_json::to_string(&format!("models/{model}")).ok()?,
697    );
698    field("displayName", &serde_json::to_string(display_name).ok()?);
699    field("ttl", &format!("\"{ttl_secs}s\""));
700    for (key, value) in PREFIX_KEYS.iter().zip(&parsed.prefix) {
701        if let Some(value) = value {
702            field(key, value.get());
703        }
704    }
705    if covers > 0 {
706        let contents = serde_json::value::to_raw_value(&parsed.contents.get(..covers)?).ok()?;
707        field("contents", contents.get());
708    }
709    bytes.push(b'}');
710    Some(bytes)
711}
712
713// ---------------------------------------------------------------------------
714// The plan.
715
716/// What one request reads, creates, retires and extends.
717pub(crate) struct Plan {
718    pub(crate) read: Option<Lease>,
719    pub(crate) create: Option<Create>,
720    pub(crate) retire: Vec<Lease>,
721    pub(crate) extend: Option<(Lease, u64)>,
722    pub(crate) line: String,
723    pub(crate) coverable: u64,
724}
725
726pub(crate) struct Create {
727    pub(crate) covers: usize,
728    pub(crate) digest: String,
729    pub(crate) ttl_secs: u64,
730    /// Uncalibrated bytes/4 estimate of what the cache holds.
731    estimate: u64,
732    /// The calibrated estimate the plan used.
733    expected: u64,
734    pub(crate) replaces: Option<Lease>,
735}
736
737impl CacheBook {
738    pub(crate) fn plan(&self, model: &str, digested: &Digested, roll_allowed: bool) -> Plan {
739        let p = self.policy;
740        let now = self.now();
741        let n = digested.contents.len();
742        // `d` holds one digest per content boundary, `0..=n`.
743        let at = |k: usize| digested.d.get(k).map_or("", String::as_str);
744        let (prefix_estimate, content_estimates) = (digested.prefix, &digested.contents);
745        let mut book = self.book();
746        let calibration = book.calibration.unwrap_or(1.0);
747
748        // Who shares this prefix: a second conversation on it (sub-agents,
749        // concurrent users, or the conversation a compaction started).
750        if n >= 1 {
751            let first = at(1).to_owned();
752            book.lineages
753                .entry(at(0).to_owned())
754                .or_default()
755                .insert(first);
756        }
757        let shared = book.lineages.get(at(0)).is_some_and(|seen| seen.len() >= 2);
758
759        // Retire conversation caches nobody reads any more.
760        let idle: Vec<Lease> = book
761            .leases
762            .values()
763            .filter(|lease| lease.covers > 0 && lease.model == model)
764            .filter(|lease| {
765                book.lives.get(&lease.name).is_some_and(|life| {
766                    now.saturating_sub(life.last_read)
767                        > MIN_IDLE_SECS.max(life.line_gap.saturating_mul(3))
768                })
769            })
770            .cloned()
771            .collect();
772
773        // The longest live cache this request starts with; never the newest content.
774        let read = (0..n.max(1))
775            .rev()
776            .find_map(|k| {
777                book.leases
778                    .get(at(k))
779                    .filter(|lease| lease.model == model)
780                    .filter(|lease| lease.expires_at > now + EXPIRY_MARGIN_SECS)
781                    .cloned()
782            })
783            .filter(|lease| !idle.iter().any(|gone| gone.name == lease.name));
784        let covered = read.as_ref().map_or(0, |lease| lease.covers);
785        let tail: u64 = content_estimates
786            .get(covered..n.saturating_sub(1).max(covered))
787            .unwrap_or_default()
788            .iter()
789            .sum();
790        let coverable = match &read {
791            Some(lease) => lease.tokens + (tail as f64 * calibration) as u64,
792            None => ((prefix_estimate + tail) as f64 * calibration) as u64,
793        };
794
795        // The conversation's line: its cache, or its first content when inline.
796        let line_key = read
797            .as_ref()
798            .filter(|lease| lease.covers > 0)
799            .map_or_else(|| at(1.min(n)).to_owned(), |lease| lease.digest.clone());
800        let prefix_leased = book.leases.contains_key(at(0));
801        let line = book.lines.entry(line_key.clone()).or_insert_with(|| Line {
802            rolled_at: now,
803            ..Line::default()
804        });
805        if let Some(last) = line.last_call {
806            line.longest_gap = line.longest_gap.max(now.saturating_sub(last));
807        }
808        line.last_call = Some(now);
809        let longest_gap = line.longest_gap;
810        let ttl_secs = p
811            .ttl
812            .as_secs()
813            .max(longest_gap.saturating_mul(2).min(MAX_TTL_SECS))
814            .max(1);
815
816        let mut plan = Plan {
817            read: read.clone(),
818            create: None,
819            retire: idle,
820            extend: None,
821            line: line_key,
822            coverable,
823        };
824
825        // Past the ceiling, implicit caching serves: go inline until it fails.
826        let implicit_serving = line.implicit.is_none_or(|ratio| ratio >= 0.5);
827        if coverable >= p.implicit_ceiling && implicit_serving {
828            plan.read = None;
829            return plan;
830        }
831
832        let failed_below = line.failed_at.is_some_and(|at| coverable < at + p.min_gain);
833        if read.is_none()
834            && shared
835            && !prefix_leased
836            && (prefix_estimate as f64 * calibration) as u64 >= p.min_tokens
837            && !failed_below
838        {
839            plan.create = Some(Create {
840                covers: 0,
841                digest: at(0).to_owned(),
842                ttl_secs,
843                estimate: prefix_estimate,
844                expected: (prefix_estimate as f64 * calibration) as u64,
845                replaces: None,
846            });
847        } else if n >= 2 && roll_allowed && !failed_below {
848            let cached = read.as_ref().map_or(0, |lease| lease.tokens);
849            let gain = coverable.saturating_sub(cached);
850            let held_hours = now.saturating_sub(line.rolled_at).max(60) as f64 / 3600.0;
851            let cost = coverable as f64 * (1.0 + p.storage_ratio_per_hour * held_hours);
852            if gain >= p.min_gain && coverable >= p.min_tokens && line.premium >= cost {
853                plan.create = Some(Create {
854                    covers: n - 1,
855                    digest: at(n - 1).to_owned(),
856                    ttl_secs,
857                    estimate: prefix_estimate
858                        + content_estimates
859                            .get(..n - 1)
860                            .unwrap_or_default()
861                            .iter()
862                            .sum::<u64>(),
863                    expected: coverable,
864                    replaces: read.clone().filter(|lease| lease.covers > 0),
865                });
866            }
867        }
868
869        // Extend a cache that would expire before its line's next call.
870        if plan.create.is_none()
871            && let Some(lease) = &read
872            && longest_gap > 0
873            && lease.expires_at.saturating_sub(now) < longest_gap.saturating_mul(3) / 2
874        {
875            plan.extend = Some((lease.clone(), ttl_secs));
876        }
877        plan
878    }
879
880    /// Account for one reply's usage on `line`.
881    pub(crate) fn observe(&self, line: &str, read: Option<&str>, coverable: u64, cached: u64) {
882        let p = self.policy;
883        let mut book = self.book();
884        if let Some(name) = read {
885            *book.report.reads.entry(name.to_owned()).or_default() += 1;
886        }
887        let entry = book.lines.entry(line.to_owned()).or_default();
888        let mut missed = coverable.saturating_sub(cached) as f64;
889        if read.is_none() && coverable >= p.implicit_floor {
890            let ratio = cached as f64 / coverable.max(1) as f64;
891            let implicit = entry
892                .implicit
893                .map_or(ratio, |ewma| 0.5 * ewma + 0.5 * ratio);
894            entry.implicit = Some(implicit);
895            if implicit >= 0.5 {
896                missed = 0.0;
897            }
898        }
899        entry.premium += missed * (1.0 - p.cached_ratio);
900    }
901
902    pub(crate) fn created(
903        &self,
904        from_line: &str,
905        create: &Create,
906        name: String,
907        tokens: u64,
908        model: &str,
909    ) -> Lease {
910        let now = self.now();
911        let lease = Lease {
912            name: name.clone(),
913            digest: create.digest.clone(),
914            covers: create.covers,
915            tokens,
916            expires_at: now + create.ttl_secs,
917            model: model.to_owned(),
918        };
919        let mut book = self.book();
920        if create.estimate > 0 && tokens > 0 {
921            let ratio = tokens as f64 / create.estimate as f64;
922            book.calibration = Some(book.calibration.map_or(ratio, |c| 0.5 * c + 0.5 * ratio));
923        }
924        let previous = book.lines.get(from_line).cloned().unwrap_or_default();
925        if create.covers > 0 {
926            book.lines.insert(
927                lease.digest.clone(),
928                Line {
929                    premium: 0.0,
930                    implicit: previous.implicit,
931                    rolled_at: now,
932                    last_call: previous.last_call,
933                    longest_gap: previous.longest_gap,
934                    failed_at: None,
935                },
936            );
937        }
938        book.lives.insert(
939            name.clone(),
940            Life {
941                tokens,
942                created: now,
943                expires_at: lease.expires_at,
944                ended: None,
945                last_read: now,
946                line_gap: previous.longest_gap,
947            },
948        );
949        book.events.push(CacheEvent::Created {
950            name: name.clone(),
951            tokens,
952            estimated: create.expected,
953            covers: create.covers,
954            ttl_secs: create.ttl_secs,
955            at: now,
956        });
957        book.report.created.push(CreatedCache {
958            name,
959            tokens,
960            at: now,
961        });
962        book.leases.insert(lease.digest.clone(), lease.clone());
963        tracing::info!(target: "gemini.cache.created", name = %lease.name, tokens, covers = create.covers);
964        lease
965    }
966
967    pub(crate) fn create_failed(
968        &self,
969        line: &str,
970        coverable: u64,
971        status: Option<u16>,
972        message: String,
973    ) {
974        let now = self.now();
975        let mut book = self.book();
976        if let Some(entry) = book.lines.get_mut(line) {
977            entry.failed_at = Some(coverable);
978        }
979        book.events.push(CacheEvent::CreateFailed {
980            status,
981            message,
982            at: now,
983        });
984    }
985
986    pub(crate) fn extended(&self, lease: &Lease, ttl_secs: u64) {
987        let now = self.now();
988        let expires_at = now + ttl_secs;
989        let mut book = self.book();
990        if let Some(held) = book.leases.get_mut(&lease.digest) {
991            held.expires_at = expires_at;
992        }
993        if let Some(life) = book.lives.get_mut(&lease.name) {
994            life.expires_at = expires_at;
995        }
996        book.events.push(CacheEvent::Extended {
997            name: lease.name.clone(),
998            expires_at,
999            at: now,
1000        });
1001        tracing::info!(target: "gemini.cache.extended", name = %lease.name, expires_at);
1002    }
1003
1004    pub(crate) fn touched(&self, lease: &Lease) {
1005        let now = self.now();
1006        let mut book = self.book();
1007        let gap = book
1008            .lines
1009            .get(&lease.digest)
1010            .map_or(0, |line| line.longest_gap);
1011        if let Some(life) = book.lives.get_mut(&lease.name) {
1012            life.last_read = now;
1013            life.line_gap = life.line_gap.max(gap);
1014        }
1015    }
1016}
1017
1018#[cfg(test)]
1019mod tests;