gemini_adk_rs/llm/
gemini.rs1use std::collections::HashMap;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use regex::Regex;
12use std::sync::LazyLock;
13
14#[cfg(feature = "gemini-llm")]
15use crate::llm::TokenUsage;
16use crate::llm::{
17 BaseLlm, EnvTokenProvider, GcloudTokenProvider, LlmError, LlmRequest, LlmResponse,
18 TokenProvider,
19};
20use crate::utils::variant::{GoogleLlmVariant, get_google_llm_variant};
21
22#[derive(Default)]
24pub struct GeminiLlmParams {
25 pub model: Option<String>,
29 pub api_key: Option<String>,
31 pub vertexai: Option<bool>,
33 pub project: Option<String>,
35 pub location: Option<String>,
37 pub headers: Option<HashMap<String, String>>,
39 pub token_provider: Option<Arc<dyn TokenProvider>>,
41}
42
43pub struct GeminiLlm {
49 model: String,
50 variant: GoogleLlmVariant,
51 #[allow(dead_code)]
53 params: GeminiLlmParams,
54 #[allow(dead_code)]
56 token_provider: Arc<dyn TokenProvider>,
57 #[cfg(feature = "gemini-llm")]
59 client: gemini_genai_rs::Client,
60}
61
62static SUPPORTED_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
63 vec![
64 Regex::new(r"^gemini-.*$").unwrap(),
65 Regex::new(r"^projects/.*/endpoints/.*$").unwrap(),
66 Regex::new(r"^projects/.*/models/gemini.*$").unwrap(),
67 ]
68});
69
70impl GeminiLlm {
71 pub fn new(mut params: GeminiLlmParams) -> Self {
77 let variant = if let Some(true) = params.vertexai {
79 GoogleLlmVariant::VertexAi
80 } else if let Some(false) = params.vertexai {
81 GoogleLlmVariant::GeminiApi
82 } else {
83 get_google_llm_variant()
84 };
85
86 let model = params
91 .model
92 .clone()
93 .or_else(|| {
94 ["GEMINI_TEXT_MODEL", "GEMINI_MODEL"]
95 .iter()
96 .find_map(|k| std::env::var(k).ok())
97 .filter(|m| !m.trim().is_empty())
98 })
99 .unwrap_or_else(|| match variant {
100 GoogleLlmVariant::GeminiApi => "gemini-flash-latest".to_string(),
101 GoogleLlmVariant::VertexAi => "gemini-2.5-flash".to_string(),
102 });
103 if model.contains("native-audio") || model.contains("-live-") {
104 tracing::warn!(
105 model = %model,
106 "GeminiLlm resolved a Live (bidi) model name for generateContent; set \
107 GEMINI_TEXT_MODEL (or GeminiLlmParams::model) to a text model — a shared \
108 GEMINI_MODEL pointing at the Live model 404s here"
109 );
110 }
111
112 if params.api_key.is_none() && variant == GoogleLlmVariant::GeminiApi {
114 params.api_key = std::env::var("GOOGLE_GENAI_API_KEY")
117 .or_else(|_| std::env::var("GEMINI_API_KEY"))
118 .or_else(|_| std::env::var("GOOGLE_API_KEY"))
119 .ok();
120 }
121
122 if variant == GoogleLlmVariant::VertexAi {
124 if params.project.is_none() {
125 params.project = std::env::var("GOOGLE_CLOUD_PROJECT").ok();
126 }
127 if params.location.is_none() {
128 params.location = std::env::var("GOOGLE_CLOUD_LOCATION").ok();
129 }
130 }
131
132 let token_provider: Arc<dyn TokenProvider> =
137 params.token_provider.take().unwrap_or_else(|| {
138 if variant == GoogleLlmVariant::VertexAi {
139 Arc::new(GcloudTokenProvider::new(std::time::Duration::from_secs(
140 45 * 60,
141 )))
142 } else {
143 Arc::new(EnvTokenProvider)
144 }
145 });
146
147 #[cfg(feature = "gemini-llm")]
152 let client = {
153 use gemini_genai_rs::{Client, prelude::ModelId};
154 match variant {
155 GoogleLlmVariant::GeminiApi => {
156 let api_key = params.api_key.as_deref().unwrap_or("");
157 Client::from_api_key(api_key).model(ModelId::new(model.clone()))
158 }
159 GoogleLlmVariant::VertexAi => {
160 let project = params.project.as_deref().unwrap_or("").to_string();
161 let location = params
162 .location
163 .as_deref()
164 .unwrap_or("us-central1")
165 .to_string();
166 let tp = token_provider.clone();
167 Client::from_vertex_refreshable(project, location, move || tp.token())
168 .model(ModelId::new(model.clone()))
169 }
170 }
171 };
172
173 Self {
174 model,
175 variant,
176 params,
177 token_provider,
178 #[cfg(feature = "gemini-llm")]
179 client,
180 }
181 }
182
183 pub fn is_supported(model: &str) -> bool {
185 SUPPORTED_PATTERNS.iter().any(|re| re.is_match(model))
186 }
187
188 pub fn variant(&self) -> GoogleLlmVariant {
190 self.variant
191 }
192
193 fn preprocess_request(_request: &mut LlmRequest) {
195 }
199}
200
201#[async_trait]
202impl BaseLlm for GeminiLlm {
203 fn model_id(&self) -> &str {
204 &self.model
205 }
206
207 async fn generate(&self, mut request: LlmRequest) -> Result<LlmResponse, LlmError> {
208 Self::preprocess_request(&mut request);
209
210 #[cfg(feature = "gemini-llm")]
212 {
213 use gemini_genai_rs::generate::GenerateContentConfig;
214 use gemini_genai_rs::prelude::*;
215
216 let mut config = if request.contents.is_empty() {
218 GenerateContentConfig::from_text("")
219 } else {
220 GenerateContentConfig::from_contents(std::mem::take(&mut request.contents))
221 };
222
223 if let Some(sys) = request.system_instruction.take() {
224 config = config.system_instruction(&sys);
225 }
226 if !request.tools.is_empty() {
227 config.tools = std::mem::take(&mut request.tools);
228 }
229 if let Some(temp) = request.temperature {
230 config = config.temperature(temp);
231 }
232 if let Some(max) = request.max_output_tokens {
233 config = config.max_output_tokens(max);
234 }
235 if request.response_mime_type.is_some() || request.response_json_schema.is_some() {
236 let gc = config
237 .generation_config
238 .get_or_insert_with(gemini_genai_rs::prelude::GenerationConfig::default);
239 if let Some(mime) = request.response_mime_type.take() {
240 gc.response_mime_type = Some(mime);
241 }
242 if let Some(schema) = request.response_json_schema.take() {
243 gc.response_json_schema = Some(schema);
244 }
245 }
246
247 let response = self
248 .client
249 .generate_content_with(config, None)
250 .await
251 .map_err(|e| LlmError::RequestFailed(e.to_string()))?;
252
253 let content = response
254 .candidates
255 .first()
256 .and_then(|c| c.content.clone())
257 .unwrap_or_else(|| Content {
258 role: Some(Role::Model),
259 parts: vec![],
260 });
261
262 let finish_reason = response
263 .candidates
264 .first()
265 .and_then(|c| c.finish_reason)
266 .map(|r| format!("{r:?}"));
267
268 let usage = response.usage_metadata.map(|u| TokenUsage {
269 prompt_tokens: u.prompt_token_count.unwrap_or(0),
270 completion_tokens: u.response_token_count.unwrap_or(0),
271 total_tokens: u.total_token_count.unwrap_or(0),
272 });
273
274 Ok(LlmResponse {
275 content,
276 finish_reason,
277 usage,
278 })
279 }
280
281 #[cfg(not(feature = "gemini-llm"))]
282 {
283 let _ = request;
285 Err(LlmError::RequestFailed(
286 "GeminiLlm requires the 'gemini-llm' feature flag \
287 (depends on gemini-live HTTP client)"
288 .into(),
289 ))
290 }
291 }
292
293 async fn warm_up(&self) -> Result<(), LlmError> {
299 #[cfg(feature = "gemini-llm")]
300 {
301 use gemini_genai_rs::generate::GenerateContentConfig;
302 let config = GenerateContentConfig::from_text(".").max_output_tokens(1);
303 let _ = self.client.generate_content_with(config, None).await;
304 }
305 Ok(())
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn default_model_is_the_rolling_flash_alias() {
315 let llm = GeminiLlm::new(GeminiLlmParams {
316 vertexai: Some(false),
317 ..Default::default()
318 });
319 let expected = std::env::var("GEMINI_MODEL")
320 .ok()
321 .filter(|m| !m.trim().is_empty())
322 .unwrap_or_else(|| "gemini-flash-latest".to_string());
323 assert_eq!(llm.model_id(), expected);
324 }
325
326 #[test]
327 fn default_model_on_vertex_is_versioned() {
328 if std::env::var("GEMINI_MODEL").is_ok_and(|m| !m.trim().is_empty()) {
329 return; }
331 let llm = GeminiLlm::new(GeminiLlmParams {
332 vertexai: Some(true),
333 ..Default::default()
334 });
335 assert_eq!(llm.model_id(), "gemini-2.5-flash");
336 }
337
338 #[test]
339 fn explicit_model() {
340 let llm = GeminiLlm::new(GeminiLlmParams {
341 model: Some("gemini-2.0-pro".into()),
342 ..Default::default()
343 });
344 assert_eq!(llm.model_id(), "gemini-2.0-pro");
345 }
346
347 #[test]
348 fn variant_from_params_vertex() {
349 let llm = GeminiLlm::new(GeminiLlmParams {
350 vertexai: Some(true),
351 ..Default::default()
352 });
353 assert_eq!(llm.variant(), GoogleLlmVariant::VertexAi);
354 }
355
356 #[test]
357 fn variant_from_params_gemini_api() {
358 let llm = GeminiLlm::new(GeminiLlmParams {
359 vertexai: Some(false),
360 ..Default::default()
361 });
362 assert_eq!(llm.variant(), GoogleLlmVariant::GeminiApi);
363 }
364
365 #[test]
366 fn is_supported_gemini_models() {
367 assert!(GeminiLlm::is_supported("gemini-2.5-flash"));
368 assert!(GeminiLlm::is_supported("gemini-2.0-pro"));
369 assert!(GeminiLlm::is_supported("gemini-1.5-pro-001"));
370 }
371
372 #[test]
373 fn is_supported_non_gemini_models() {
374 assert!(!GeminiLlm::is_supported("gpt-4"));
375 assert!(!GeminiLlm::is_supported("claude-3-opus"));
376 assert!(!GeminiLlm::is_supported("llama-3"));
377 }
378
379 #[test]
380 fn is_supported_vertex_ai_resource_paths() {
381 assert!(GeminiLlm::is_supported(
382 "projects/my-project/endpoints/12345"
383 ));
384 assert!(GeminiLlm::is_supported(
385 "projects/my-project/models/gemini-2.5-flash"
386 ));
387 }
388
389 #[test]
390 fn model_id_returns_correct_string() {
391 let llm = GeminiLlm::new(GeminiLlmParams {
392 model: Some("gemini-2.5-flash-preview-04-17".into()),
393 ..Default::default()
394 });
395 assert_eq!(llm.model_id(), "gemini-2.5-flash-preview-04-17");
396 }
397
398 #[test]
399 fn base_llm_is_object_safe() {
400 fn _assert_object_safe(_: &dyn BaseLlm) {}
401 }
402
403 #[test]
404 fn gemini_llm_is_send_sync() {
405 fn _assert_send_sync<T: Send + Sync>() {}
406 _assert_send_sync::<GeminiLlm>();
407 }
408}