1pub mod gemini;
8mod mock;
9pub mod registry;
10
11pub use gemini::{GeminiLlm, GeminiLlmParams};
12pub use mock::MockLlm;
13pub use registry::LlmRegistry;
14
15use async_trait::async_trait;
16use futures_util::StreamExt;
17use serde::{Deserialize, Serialize};
18
19use gemini_genai_rs::prelude::{Content, Part, Tool};
20
21pub trait TokenProvider: Send + Sync {
26 fn token(&self) -> String;
29}
30
31pub struct EnvTokenProvider;
33
34impl TokenProvider for EnvTokenProvider {
35 fn token(&self) -> String {
36 std::env::var("GOOGLE_ACCESS_TOKEN").unwrap_or_default()
37 }
38}
39
40pub struct GcloudTokenProvider {
43 cache: parking_lot::Mutex<(String, std::time::Instant)>,
44 ttl: std::time::Duration,
45}
46
47impl GcloudTokenProvider {
48 pub fn new(ttl: std::time::Duration) -> Self {
50 Self {
51 cache: parking_lot::Mutex::new((String::new(), std::time::Instant::now())),
52 ttl,
53 }
54 }
55}
56
57impl TokenProvider for GcloudTokenProvider {
58 fn token(&self) -> String {
59 let mut guard = self.cache.lock();
60 let (ref mut cached_token, ref mut fetched_at) = *guard;
61 if !cached_token.is_empty() && fetched_at.elapsed() < self.ttl {
62 return cached_token.clone();
63 }
64 match std::process::Command::new("gcloud")
66 .args(["auth", "print-access-token"])
67 .output()
68 {
69 Ok(output) if output.status.success() => {
70 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
71 *cached_token = token.clone();
72 *fetched_at = std::time::Instant::now();
73 token
74 }
75 _ => {
76 std::env::var("GOOGLE_ACCESS_TOKEN").unwrap_or_default()
78 }
79 }
80 }
81}
82
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89pub struct LlmRequest {
90 pub contents: Vec<Content>,
92 #[serde(skip_serializing_if = "Option::is_none")]
95 pub model: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub system_instruction: Option<String>,
99 #[serde(skip_serializing_if = "Vec::is_empty", default)]
101 pub tools: Vec<Tool>,
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub temperature: Option<f32>,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub max_output_tokens: Option<u32>,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub top_p: Option<f32>,
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub top_k: Option<u32>,
114 #[serde(skip_serializing_if = "Vec::is_empty", default)]
116 pub stop_sequences: Vec<String>,
117 #[serde(skip_serializing_if = "Option::is_none")]
119 pub thinking_budget: Option<u32>,
120 #[serde(skip_serializing_if = "Option::is_none")]
122 pub response_mime_type: Option<String>,
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub response_json_schema: Option<serde_json::Value>,
126}
127
128impl LlmRequest {
129 pub fn from_text(text: impl Into<String>) -> Self {
131 Self {
132 contents: vec![Content {
133 role: Some(gemini_genai_rs::prelude::Role::User),
134 parts: vec![Part::Text { text: text.into() }],
135 }],
136 ..Default::default()
137 }
138 }
139
140 pub fn from_contents(contents: Vec<Content>) -> Self {
142 Self {
143 contents,
144 ..Default::default()
145 }
146 }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct LlmResponse {
152 pub content: Content,
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub finish_reason: Option<String>,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub usage: Option<TokenUsage>,
160}
161
162impl LlmResponse {
163 pub fn from_text(text: impl Into<String>) -> Self {
165 Self::from_parts(vec![Part::Text { text: text.into() }], Some("STOP"))
166 }
167
168 pub fn tool_call(name: impl Into<String>, args: serde_json::Value) -> Self {
170 Self::tool_calls([(name, args)])
171 }
172
173 pub fn tool_calls<N: Into<String>>(
175 calls: impl IntoIterator<Item = (N, serde_json::Value)>,
176 ) -> Self {
177 let parts = calls
178 .into_iter()
179 .map(|(name, args)| Part::FunctionCall {
180 function_call: gemini_genai_rs::prelude::FunctionCall {
181 name: name.into(),
182 args,
183 id: None,
184 },
185 })
186 .collect();
187 Self::from_parts(parts, None)
188 }
189
190 fn from_parts(parts: Vec<Part>, finish_reason: Option<&str>) -> Self {
191 Self {
192 content: Content {
193 role: Some(gemini_genai_rs::prelude::Role::Model),
194 parts,
195 },
196 finish_reason: finish_reason.map(str::to_owned),
197 usage: None,
198 }
199 }
200
201 pub fn append(&mut self, chunk: LlmResponse) {
205 for part in chunk.content.parts {
206 match (self.content.parts.last_mut(), part) {
207 (Some(Part::Text { text }), Part::Text { text: more }) => text.push_str(&more),
208 (_, part) => self.content.parts.push(part),
209 }
210 }
211 if chunk.finish_reason.is_some() {
212 self.finish_reason = chunk.finish_reason;
213 }
214 if chunk.usage.is_some() {
215 self.usage = chunk.usage;
216 }
217 }
218
219 pub fn with_usage(mut self, prompt_tokens: u32, completion_tokens: u32) -> Self {
221 self.usage = Some(TokenUsage::new(prompt_tokens, completion_tokens));
222 self
223 }
224
225 pub fn text(&self) -> String {
227 self.content
228 .parts
229 .iter()
230 .filter_map(|p| match p {
231 Part::Text { text } => Some(text.as_str()),
232 _ => None,
233 })
234 .collect::<Vec<_>>()
235 .join("")
236 }
237
238 pub fn function_calls(&self) -> Vec<&gemini_genai_rs::prelude::FunctionCall> {
240 self.content
241 .parts
242 .iter()
243 .filter_map(|p| match p {
244 Part::FunctionCall { function_call } => Some(function_call),
245 _ => None,
246 })
247 .collect()
248 }
249}
250
251#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
253pub struct TokenUsage {
254 pub prompt_tokens: u32,
256 pub completion_tokens: u32,
258 pub total_tokens: u32,
260}
261
262impl TokenUsage {
263 pub fn new(prompt_tokens: u32, completion_tokens: u32) -> Self {
265 Self {
266 prompt_tokens,
267 completion_tokens,
268 total_tokens: prompt_tokens.saturating_add(completion_tokens),
269 }
270 }
271}
272
273impl std::ops::Add for TokenUsage {
274 type Output = Self;
275
276 fn add(self, other: Self) -> Self {
277 Self {
278 prompt_tokens: self.prompt_tokens.saturating_add(other.prompt_tokens),
279 completion_tokens: self
280 .completion_tokens
281 .saturating_add(other.completion_tokens),
282 total_tokens: self.total_tokens.saturating_add(other.total_tokens),
283 }
284 }
285}
286
287impl std::ops::AddAssign for TokenUsage {
288 fn add_assign(&mut self, other: Self) {
289 *self = *self + other;
290 }
291}
292
293#[derive(Debug, thiserror::Error)]
306#[non_exhaustive]
307pub enum LlmError {
308 #[error("the model API returned HTTP {status}: {message}")]
310 Api {
311 status: u16,
313 message: String,
315 },
316 #[error("{0}")]
318 Auth(String),
319 #[error("could not reach the model API: {0}")]
321 Transport(String),
322 #[error("{0}")]
325 Config(String),
326 #[error("LLM request failed: {0}")]
328 RequestFailed(String),
329 #[error("Model not available: {0}")]
331 ModelNotAvailable(String),
332 #[error("Rate limited")]
334 RateLimited,
335 #[error("blocked by content safety: {0}")]
338 ContentFiltered(String),
339 #[error("{0}")]
341 Other(String),
342}
343
344impl LlmError {
345 pub fn status(&self) -> Option<u16> {
347 match self {
348 Self::Api { status, .. } => Some(*status),
349 Self::RateLimited => Some(429),
350 _ => None,
351 }
352 }
353
354 pub fn is_rate_limited(&self) -> bool {
356 self.status() == Some(429)
357 }
358
359 pub fn is_auth(&self) -> bool {
361 matches!(self, Self::Auth(_)) || matches!(self.status(), Some(401 | 403))
362 }
363
364 pub fn is_content_filtered(&self) -> bool {
366 matches!(self, Self::ContentFiltered(_))
367 }
368
369 pub fn is_retryable(&self) -> bool {
373 matches!(self, Self::Transport(_)) || matches!(self.status(), Some(429 | 500..=599))
374 }
375}
376
377#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
380pub struct ModelCapabilities {
381 pub thinking: bool,
383 pub live_bidi: bool,
385 pub audio_output: bool,
387 pub vision_input: bool,
389 pub context_caching: bool,
391}
392
393impl ModelCapabilities {
394 pub fn infer_from_id(id: &str) -> Self {
397 let id = id.to_ascii_lowercase();
398 let gemini = id.contains("gemini");
399 let live = id.contains("live") || id.contains("native-audio");
400 Self {
401 thinking: gemini && (id.contains("2.5") || id.contains("thinking")),
402 live_bidi: live,
403 audio_output: live || id.contains("tts"),
404 vision_input: gemini,
405 context_caching: gemini,
406 }
407 }
408}
409
410pub type LlmStream = futures_util::stream::BoxStream<'static, Result<LlmResponse, LlmError>>;
412
413#[async_trait]
417pub trait BaseLlm: Send + Sync {
418 fn model_id(&self) -> &str;
420
421 fn capabilities(&self) -> ModelCapabilities {
427 ModelCapabilities::infer_from_id(self.model_id())
428 }
429
430 async fn generate(&self, request: LlmRequest) -> Result<LlmResponse, LlmError>;
432
433 async fn generate_stream(&self, request: LlmRequest) -> Result<LlmStream, LlmError> {
440 let response = self.generate(request).await?;
441 Ok(futures_util::stream::once(async move { Ok(response) }).boxed())
442 }
443
444 async fn warm_up(&self) -> Result<(), LlmError> {
450 Ok(())
451 }
452}
453
454#[async_trait]
457impl<L: BaseLlm + ?Sized> BaseLlm for std::sync::Arc<L> {
458 fn model_id(&self) -> &str {
459 (**self).model_id()
460 }
461
462 fn capabilities(&self) -> ModelCapabilities {
463 (**self).capabilities()
464 }
465
466 async fn generate(&self, request: LlmRequest) -> Result<LlmResponse, LlmError> {
467 (**self).generate(request).await
468 }
469
470 async fn generate_stream(&self, request: LlmRequest) -> Result<LlmStream, LlmError> {
471 (**self).generate_stream(request).await
472 }
473
474 async fn warm_up(&self) -> Result<(), LlmError> {
475 (**self).warm_up().await
476 }
477}
478
479#[async_trait]
481impl<L: BaseLlm + ?Sized> BaseLlm for Box<L> {
482 fn model_id(&self) -> &str {
483 (**self).model_id()
484 }
485
486 fn capabilities(&self) -> ModelCapabilities {
487 (**self).capabilities()
488 }
489
490 async fn generate(&self, request: LlmRequest) -> Result<LlmResponse, LlmError> {
491 (**self).generate(request).await
492 }
493
494 async fn generate_stream(&self, request: LlmRequest) -> Result<LlmStream, LlmError> {
495 (**self).generate_stream(request).await
496 }
497
498 async fn warm_up(&self) -> Result<(), LlmError> {
499 (**self).warm_up().await
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn llm_request_from_text() {
509 let req = LlmRequest::from_text("Hello!");
510 assert_eq!(req.contents.len(), 1);
511 assert!(req.system_instruction.is_none());
512 assert!(req.tools.is_empty());
513 }
514
515 #[test]
516 fn llm_request_from_contents() {
517 let contents = vec![Content {
518 role: Some(gemini_genai_rs::prelude::Role::User),
519 parts: vec![Part::Text {
520 text: "Hello".into(),
521 }],
522 }];
523 let req = LlmRequest::from_contents(contents);
524 assert_eq!(req.contents.len(), 1);
525 }
526
527 #[test]
528 fn llm_response_text() {
529 let resp = LlmResponse {
530 content: Content {
531 role: Some(gemini_genai_rs::prelude::Role::Model),
532 parts: vec![
533 Part::Text {
534 text: "Hello ".into(),
535 },
536 Part::Text {
537 text: "world!".into(),
538 },
539 ],
540 },
541 finish_reason: Some("STOP".into()),
542 usage: None,
543 };
544 assert_eq!(resp.text(), "Hello world!");
545 }
546
547 #[test]
548 fn llm_response_function_calls() {
549 let resp = LlmResponse {
550 content: Content {
551 role: Some(gemini_genai_rs::prelude::Role::Model),
552 parts: vec![Part::FunctionCall {
553 function_call: gemini_genai_rs::prelude::FunctionCall {
554 name: "get_weather".into(),
555 args: serde_json::json!({"city": "London"}),
556 id: None,
557 },
558 }],
559 },
560 finish_reason: None,
561 usage: None,
562 };
563 let calls = resp.function_calls();
564 assert_eq!(calls.len(), 1);
565 assert_eq!(calls[0].name, "get_weather");
566 }
567
568 #[test]
569 fn errors_classify_by_status_not_by_message() {
570 let api = |status| LlmError::Api {
571 status,
572 message: String::new(),
573 };
574 assert!(api(429).is_rate_limited() && api(429).is_retryable());
575 assert!(api(503).is_retryable() && !api(503).is_rate_limited());
576 assert!(api(401).is_auth() && api(403).is_auth() && !api(401).is_retryable());
577 assert!(!api(400).is_retryable() && !api(404).is_auth());
578 assert!(LlmError::Transport("reset".into()).is_retryable());
579 assert!(LlmError::RateLimited.is_rate_limited());
580 assert!(LlmError::Auth("no key".into()).is_auth());
581 assert!(LlmError::ContentFiltered("SAFETY".into()).is_content_filtered());
582 assert_eq!(api(418).status(), Some(418));
583 assert_eq!(LlmError::Other("x".into()).status(), None);
584 }
585
586 #[test]
587 fn append_folds_streamed_chunks_into_one_reply() {
588 let mut whole = LlmResponse::from_parts(vec![Part::Text { text: "Hel".into() }], None);
589 whole.append(LlmResponse::from_parts(
590 vec![Part::Text { text: "lo".into() }],
591 None,
592 ));
593 whole.append(LlmResponse::tool_call("f", serde_json::json!({})).with_usage(3, 1));
594 whole.append(LlmResponse::from_text("!").with_usage(3, 2));
595 assert_eq!(whole.text(), "Hello!");
596 assert_eq!(whole.function_calls().len(), 1);
597 assert_eq!(whole.finish_reason.as_deref(), Some("STOP"));
598 assert_eq!(
599 whole.usage,
600 Some(TokenUsage::new(3, 2)),
601 "usage is cumulative, not summed"
602 );
603 }
604
605 #[tokio::test]
606 async fn every_model_can_stream() {
607 let chunks: Vec<_> = MockLlm::text("one two three")
608 .generate_stream(LlmRequest::from_text("x"))
609 .await
610 .unwrap()
611 .map(Result::unwrap)
612 .collect()
613 .await;
614 assert_eq!(chunks.len(), 3, "the mock streams one word at a time");
615 let mut whole = chunks[0].clone();
616 for chunk in chunks.into_iter().skip(1) {
617 whole.append(chunk);
618 }
619 assert_eq!(whole.text(), "one two three");
620 }
621
622 #[test]
623 fn base_llm_is_object_safe() {
624 fn _assert(_: &dyn BaseLlm) {}
625 }
626
627 #[test]
628 fn token_usage() {
629 let usage = TokenUsage::new(10, 20);
630 assert_eq!(usage.total_tokens, 30);
631 assert_eq!((usage + TokenUsage::new(1, 2)).total_tokens, 33);
632 }
633
634 #[test]
635 fn response_constructors_build_model_turns() {
636 let said = LlmResponse::from_text("hi").with_usage(3, 4);
637 assert_eq!(said.text(), "hi");
638 assert_eq!(
639 said.content.role,
640 Some(gemini_genai_rs::prelude::Role::Model)
641 );
642 assert_eq!(said.finish_reason.as_deref(), Some("STOP"));
643 assert_eq!(said.usage, Some(TokenUsage::new(3, 4)));
644
645 let called = LlmResponse::tool_calls([
646 ("a", serde_json::json!({})),
647 ("b", serde_json::json!({ "x": 1 })),
648 ]);
649 let names: Vec<_> = called
650 .function_calls()
651 .iter()
652 .map(|c| c.name.as_str())
653 .collect();
654 assert_eq!(names, ["a", "b"]);
655 assert!(called.text().is_empty());
656 }
657
658 #[tokio::test]
661 async fn shared_and_boxed_models_are_models() {
662 async fn ask(llm: impl BaseLlm) -> String {
663 llm.generate(LlmRequest::from_text("q"))
664 .await
665 .unwrap()
666 .text()
667 }
668 let mock = MockLlm::text("a").with_model_id("m");
669 let shared: std::sync::Arc<dyn BaseLlm> = std::sync::Arc::new(mock.clone());
670 assert_eq!(shared.model_id(), "m");
671 assert_eq!(ask(shared).await, "a");
672 assert_eq!(ask(std::sync::Arc::new(mock.clone())).await, "a");
673 assert_eq!(ask(Box::new(mock.clone()) as Box<dyn BaseLlm>).await, "a");
674 assert_eq!(mock.call_count(), 3);
675 }
676}