1use futures::StreamExt;
7use serde::Deserialize;
8
9use crate::driver::{Exchange, Model, Opened, Opening, Transport};
10use crate::error::ProviderError;
11use crate::providers::gemini::cached_content::CachedContents;
12use crate::providers::gemini::caching::{
13 CacheBook, Create, Lease, Parsed, cache_body, digests, is_user_text, parse, short_digest,
14 stripped,
15};
16use crate::providers::gemini::completion::{GenerateContent, ThoughtReplay};
17use crate::providers::internal::wire::classify_untyped_line;
18use crate::wire::{Body, Encoded, Framing, Mode, WireEvent, WireFrame};
19
20impl CacheBook {
21 pub async fn prove<T>(&self, caches: &Model<CachedContents, T>) -> usize
24 where
25 T: Transport<CachedContents>,
26 {
27 let mut survived = 0;
28 for lease in self.leases() {
29 let alive = match caches.get(&lease.name).await {
30 Ok(resource) => resource
31 .display_name
32 .as_deref()
33 .is_some_and(|name| name.ends_with(short_digest(&lease.digest))),
34 Err(_) => false,
35 };
36 if alive {
37 survived += 1;
38 } else {
39 self.lost(&lease);
40 }
41 }
42 survived
43 }
44
45 pub async fn close<T>(&self, caches: &Model<CachedContents, T>)
48 where
49 T: Transport<CachedContents>,
50 {
51 for lease in self.leases() {
52 let result = caches.delete(&lease.name).await;
53 let gone = matches!(result, Ok(()) | Err(ProviderError::CacheExpired { .. }));
54 if !gone {
55 tracing::warn!(target: "gemini.cache", name = %lease.name, "cache delete failed");
56 }
57 self.retired(&lease);
58 }
59 }
60}
61
62#[derive(Clone)]
65pub struct Caching<T> {
66 inner: T,
67 config: crate::providers::gemini::GeminiConfig,
68 book: CacheBook,
69 thought_replay: ThoughtReplay,
70}
71
72impl<T> std::fmt::Debug for Caching<T> {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.debug_struct("Caching")
75 .field("book", &self.book)
76 .field("thought_replay", &self.thought_replay)
77 .finish_non_exhaustive()
78 }
79}
80
81impl<T> Model<GenerateContent, T> {
82 pub fn caching(self, book: &CacheBook) -> Model<GenerateContent, Caching<T>> {
85 let caching = Caching {
86 inner: self.transport,
87 config: self.wire.provider.clone(),
88 book: book.clone(),
89 thought_replay: self.wire.thought_replay,
90 };
91 Model::new(self.wire, caching)
92 }
93}
94
95fn model_of(path: &str) -> Option<String> {
96 let rest = path.split("/models/").nth(1)?;
97 let (model, verb) = rest.split_once(':')?;
98 let verb = verb.split('?').next()?;
99 matches!(verb, "generateContent" | "streamGenerateContent").then(|| model.to_owned())
100}
101
102fn with_body(payload: &Encoded, bytes: Vec<u8>) -> Result<Encoded, ProviderError> {
104 let mut builder = http::Request::builder()
105 .method(payload.request.method().clone())
106 .uri(payload.request.uri().clone());
107 for (name, value) in payload.request.headers() {
108 builder = builder.header(name, value);
109 }
110 let request = builder
111 .body(Body::Bytes(bytes))
112 .map_err(|error| ProviderError::request(error.to_string()))?;
113 Ok(Encoded {
114 request,
115 framing: payload.framing,
116 request_id_header: payload.request_id_header,
117 relaxed_content_type: payload.relaxed_content_type,
118 route: payload.route,
119 project: payload.project,
120 analysis_only: payload.analysis_only,
121 })
122}
123
124struct Reply {
126 status: Option<u16>,
127 body: String,
128 error: Option<String>,
129}
130
131impl<T> Caching<T>
132where
133 T: Transport<GenerateContent>,
134{
135 async fn resource(&self, method: http::Method, path: &str, body: Option<Vec<u8>>) -> Reply {
137 let request = http::Request::builder()
138 .method(method)
139 .uri(self.config.uri(path))
140 .header("Content-Type", "application/json")
141 .body(Body::Bytes(body.unwrap_or_default()));
142 let request = match request {
143 Ok(request) => request,
144 Err(error) => {
145 return Reply {
146 status: None,
147 body: String::new(),
148 error: Some(error.to_string()),
149 };
150 }
151 };
152 let exchange = Exchange {
153 mode: Mode::Unary,
154 observation: None,
155 extensions: http::Extensions::new(),
156 };
157 let opened = match self
158 .inner
159 .send(Encoded::new(request, Framing::Whole), exchange)
160 .await
161 {
162 Ok(opened) => opened,
163 Err(error) => {
164 return Reply {
165 status: error.provider_response_status().map(|s| s.as_u16()),
166 body: String::new(),
167 error: Some(error.to_string()),
168 };
169 }
170 };
171 let mut frames = opened.frames;
172 let mut body = String::new();
173 while let Some(frame) = frames.next().await {
174 match frame {
175 Ok(frame) => body.push_str(&frame.as_str()),
176 Err(error) => {
177 return Reply {
178 status: error
179 .provider_response_status()
180 .map(|status| status.as_u16()),
181 body: error
182 .provider_response_body()
183 .unwrap_or_default()
184 .to_owned(),
185 error: Some(error.to_string()),
186 };
187 }
188 }
189 }
190 Reply {
191 status: None,
192 body,
193 error: None,
194 }
195 }
196
197 async fn delete(&self, lease: &Lease) {
198 let reply = self
199 .resource(
200 http::Method::DELETE,
201 &format!("/v1beta/{}", lease.name),
202 None,
203 )
204 .await;
205 if reply.error.is_some() && reply.status != Some(403) && reply.status != Some(404) {
206 let status = reply.status.unwrap_or_default();
207 tracing::warn!(target: "gemini.cache", name = %lease.name, status, "cache delete failed");
208 }
209 self.book.retired(lease);
210 }
211
212 async fn extend(&self, lease: &Lease, ttl_secs: u64) {
213 let body = format!("{{\"ttl\":\"{ttl_secs}s\"}}").into_bytes();
214 let reply = self
215 .resource(
216 http::Method::PATCH,
217 &format!("/v1beta/{}?updateMask=ttl", lease.name),
218 Some(body),
219 )
220 .await;
221 if reply.error.is_none() {
222 self.book.extended(lease, ttl_secs);
223 }
224 }
225
226 async fn create(
229 &self,
230 model: &str,
231 parsed: &Parsed,
232 create: &Create,
233 line: &str,
234 coverable: u64,
235 ) -> Option<Lease> {
236 let _single = self.book.create.lock().await;
237 if let Some(existing) = self.book.book().leases.get(&create.digest).cloned() {
238 return Some(existing);
239 }
240 let display_name = format!(
241 "{}{}",
242 self.book.display_prefix,
243 short_digest(&create.digest)
244 );
245 let body = cache_body(model, parsed, create.covers, &display_name, create.ttl_secs)?;
246 let reply = self
247 .resource(http::Method::POST, "/v1beta/cachedContents", Some(body))
248 .await;
249 if reply.error.is_some() {
250 #[derive(Deserialize)]
251 struct Envelope {
252 error: ErrorBody,
253 }
254 #[derive(Deserialize)]
255 struct ErrorBody {
256 message: String,
257 }
258 let message = known(classify_untyped_line::<Envelope>(reply.body.as_bytes()))
259 .map(|envelope| envelope.error.message)
260 .or(reply.error)
261 .unwrap_or_default();
262 self.book
263 .create_failed(line, coverable, reply.status, message);
264 return None;
265 }
266 #[derive(Deserialize)]
267 #[serde(rename_all = "camelCase")]
268 struct Resource {
269 name: String,
270 #[serde(default)]
271 usage_metadata: Option<Usage>,
272 }
273 #[derive(Deserialize)]
274 #[serde(rename_all = "camelCase")]
275 struct Usage {
276 #[serde(default)]
277 total_token_count: u64,
278 }
279 let resource = known(classify_untyped_line::<Resource>(reply.body.as_bytes()))?;
280 let tokens = resource
281 .usage_metadata
282 .map_or(0, |usage| usage.total_token_count);
283 Some(
284 self.book
285 .created(line, create, resource.name, tokens, model),
286 )
287 }
288}
289
290fn known<T>(event: WireEvent<T>) -> Option<T> {
292 match event {
293 WireEvent::Known(value) => Some(value),
294 _ => None,
295 }
296}
297
298fn final_cached(frame: &WireFrame) -> Option<u64> {
301 #[derive(Deserialize)]
302 #[serde(rename_all = "camelCase")]
303 struct Usage {
304 #[serde(default)]
305 cached_content_token_count: u64,
306 }
307 #[derive(Deserialize)]
308 #[serde(rename_all = "camelCase")]
309 struct Candidate {
310 finish_reason: Option<String>,
311 }
312 #[derive(Deserialize)]
313 #[serde(rename_all = "camelCase")]
314 struct Reply {
315 usage_metadata: Option<Usage>,
316 #[serde(default)]
317 candidates: Vec<Candidate>,
318 }
319 let reply = known(classify_untyped_line::<Reply>(frame.as_str().as_bytes()))?;
320 let finished = reply
321 .candidates
322 .iter()
323 .any(|candidate| candidate.finish_reason.is_some());
324 finished.then_some(reply.usage_metadata?.cached_content_token_count)
325}
326
327impl<T> Transport<GenerateContent> for Caching<T>
328where
329 T: Transport<GenerateContent>,
330{
331 fn send(&self, payload: Encoded, exchange: Exchange) -> Opening<WireFrame> {
332 let this = self.clone();
333 Opening::new(async move {
334 let path = payload.request.uri().path().to_owned();
335 let parsed = match payload.request.body() {
336 Body::Bytes(bytes) => parse(bytes),
337 Body::Multipart(_) => None,
338 };
339 let (Some(model), Some(parsed)) = (model_of(&path), parsed) else {
340 return this.inner.send(payload, exchange).await;
341 };
342 if parsed.has_cached_content {
343 return this.inner.send(payload, exchange).await;
344 }
345
346 let digested = digests(&model, &parsed);
347 let roll_allowed = match this.thought_replay {
348 ThoughtReplay::All => true,
349 ThoughtReplay::CurrentTurn => {
350 parsed.contents.last().is_some_and(|c| is_user_text(c))
351 }
352 };
353 let plan = this.book.plan(&model, &digested, roll_allowed);
354 for idle in &plan.retire {
355 this.delete(idle).await;
356 }
357 let mut read = plan.read.clone();
358 let mut line = plan.line.clone();
359 if let Some(create) = &plan.create
360 && let Some(lease) = this
361 .create(&model, &parsed, create, &plan.line, plan.coverable)
362 .await
363 {
364 if let Some(replaced) = &create.replaces
365 && replaced.name != lease.name
366 {
367 this.delete(replaced).await;
368 }
369 if lease.covers > 0 {
370 line = lease.digest.clone();
371 }
372 read = Some(lease);
373 }
374 if let Some((lease, ttl_secs)) = &plan.extend
375 && read.as_ref().is_some_and(|read| read.name == lease.name)
376 {
377 this.extend(lease, *ttl_secs).await;
378 }
379
380 let (sent, inline) = match read.as_ref().and_then(|lease| stripped(&parsed, lease)) {
383 Some(body) => (with_body(&payload, body)?, Some(payload)),
384 None => {
385 read = None;
386 (payload, None)
387 }
388 };
389 let mut opened: Opened<WireFrame> = this.inner.send(sent, exchange.clone()).await?;
390 if let Some(lease) = read.clone() {
391 let first = opened.frames.next().await;
392 let forbidden = matches!(
393 &first,
394 Some(Err(error)) if error.provider_response_status() == Some(http::StatusCode::FORBIDDEN)
395 );
396 if let (true, Some(inline)) = (forbidden, inline) {
397 this.book.lost(&lease);
399 read = None;
400 opened = this.inner.send(inline, exchange).await?;
401 } else {
402 this.book.touched(&lease);
403 let rest =
404 std::mem::replace(&mut opened.frames, Box::pin(futures::stream::empty()));
405 opened.frames = Box::pin(futures::stream::iter(first).chain(rest));
406 }
407 }
408
409 let book = this.book.clone();
410 let read_name = read.map(|lease| lease.name);
411 let coverable = plan.coverable;
412 Ok(opened.map_frames(move |frames| {
413 frames.inspect(move |frame| {
414 if let Ok(frame) = frame
415 && let Some(cached) = final_cached(frame)
416 {
417 book.observe(&line, read_name.as_deref(), coverable, cached);
418 }
419 })
420 }))
421 })
422 }
423}