1use crate::config::ProviderConfig;
2use crate::error::{LiteLLMError, Result};
3use crate::http::send_json;
4use crate::providers::{merge_extra_body, resolve_api_key};
5use crate::stream::{parse_gemini_sse_stream, ChatStream};
6use crate::types::{
7 ChatContentPart, ChatContentPartFile, ChatContentPartImageUrl, ChatContentPartInputAudio,
8 ChatContentPartText, ChatFile, ChatImageUrl, ChatInputAudio, ChatMessage, ChatMessageContent,
9 ChatRequest, ChatResponse, EmbeddingRequest, EmbeddingResponse, ImageData, ImageEditRequest,
10 ImageInputData, ImageRequest, ImageResponse, Reasoning, Usage, VideoRequest, VideoResponse,
11};
12use base64::{engine::general_purpose, Engine as _};
13use mime_guess::MimeGuess;
14use reqwest::header::CONTENT_TYPE;
15use reqwest::Client;
16use serde_json::{Map, Value};
17use std::env;
18use tokio::time::{sleep, Duration};
19
20pub const DEFAULT_VIDEO_MAX_POLL_ATTEMPTS: u32 = 240;
22pub const DEFAULT_VIDEO_POLL_INTERVAL_SECS: u64 = 5;
24
25pub async fn chat(client: &Client, cfg: &ProviderConfig, req: ChatRequest) -> Result<ChatResponse> {
26 let base = cfg
27 .base_url
28 .clone()
29 .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
30 let key = resolve_api_key(cfg)?
31 .ok_or_else(|| LiteLLMError::MissingApiKey("GEMINI_API_KEY".into()))?;
32 let model = req.model.trim_start_matches("models/").to_string();
33 let url = format!(
34 "{}/models/{}:generateContent",
35 base.trim_end_matches('/'),
36 model
37 );
38 let body = build_chat_body(client, req, &model).await?;
39
40 let mut builder = client.post(url).header("x-goog-api-key", key).json(&body);
41 for (k, v) in &cfg.extra_headers {
42 builder = builder.header(k, v);
43 }
44
45 let (resp, _headers) = send_json::<Value>(builder).await?;
46 let (content, reasoning) = split_text_and_reasoning(&resp);
47 let usage = parse_usage(&resp);
48 let debug = env::var("LITELLM_GEMINI_DEBUG").ok().as_deref() == Some("1")
49 || env::var("YOETZ_GEMINI_DEBUG").ok().as_deref() == Some("1");
50 if debug {
51 if let Ok(pretty) = serde_json::to_string_pretty(&resp) {
52 eprintln!("litellm-rust gemini raw response:\n{pretty}");
53 }
54 }
55
56 let tool_calls = extract_gemini_tool_calls(&resp);
57 Ok(ChatResponse {
58 content,
59 reasoning,
60 tool_calls,
61 usage,
62 response_id: None,
63 header_cost: None,
64 raw: if debug { Some(resp) } else { None },
65 })
66}
67
68pub async fn chat_stream(
70 client: &Client,
71 cfg: &ProviderConfig,
72 req: ChatRequest,
73) -> Result<ChatStream> {
74 let base = cfg
75 .base_url
76 .clone()
77 .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
78 let key = resolve_api_key(cfg)?
79 .ok_or_else(|| LiteLLMError::MissingApiKey("GEMINI_API_KEY".into()))?;
80 let model = req.model.trim_start_matches("models/").to_string();
81 let url = format!(
82 "{}/models/{}:streamGenerateContent",
83 base.trim_end_matches('/'),
84 model
85 );
86 let body = build_chat_body(client, req, &model).await?;
87
88 let mut builder = client
89 .post(url)
90 .query(&[("alt", "sse")])
91 .header("x-goog-api-key", key)
92 .header("accept", "text/event-stream")
93 .json(&body);
94 for (k, v) in &cfg.extra_headers {
95 builder = builder.header(k, v);
96 }
97
98 let resp = builder.send().await.map_err(LiteLLMError::from)?;
99 let status = resp.status();
100 if !status.is_success() {
101 let text = resp.text().await.map_err(LiteLLMError::from)?;
102 return Err(LiteLLMError::http(format!(
103 "http {}: {}",
104 status.as_u16(),
105 text
106 )));
107 }
108
109 Ok(parse_gemini_sse_stream(resp.bytes_stream()))
110}
111
112async fn build_chat_body(client: &Client, req: ChatRequest, model: &str) -> Result<Value> {
115 let generation_config = build_generation_config(&req, model)?;
116
117 let mut messages = req.messages;
118 let system_instruction = extract_system_instruction(&mut messages)?;
119 if messages.is_empty() && !system_instruction.is_empty() {
120 messages.push(ChatMessage {
121 role: "user".to_string(),
122 content: ChatMessageContent::Text(" ".to_string()),
123 name: None,
124 tool_call_id: None,
125 tool_calls: None,
126 function_call: None,
127 provider_specific_fields: None,
128 });
129 }
130
131 let contents = gemini_contents_from_messages(client, messages, model).await?;
132
133 let mut body = serde_json::json!({ "contents": contents });
134 if !system_instruction.is_empty() {
135 body["system_instruction"] = serde_json::json!({ "parts": system_instruction });
136 }
137 if !generation_config.is_empty() {
138 body["generationConfig"] = Value::Object(generation_config);
139 }
140 merge_extra_body(
141 &mut body,
142 req.extra_body.as_ref(),
143 &["contents", "system_instruction", "generationConfig"],
144 )?;
145 Ok(body)
146}
147
148fn build_generation_config(req: &ChatRequest, model: &str) -> Result<Map<String, Value>> {
149 let mut generation_config = Map::new();
150 if let Some(temp) = req.temperature {
151 generation_config.insert("temperature".to_string(), serde_json::json!(temp));
152 }
153 if let Some(max_tokens) = req.max_tokens {
154 generation_config.insert("maxOutputTokens".to_string(), serde_json::json!(max_tokens));
155 }
156 if let Some(thinking_config) =
157 build_thinking_config(req.thinking.as_ref(), req.reasoning_effort.as_ref(), model)?
158 {
159 generation_config.insert("thinkingConfig".to_string(), thinking_config);
160 }
161 Ok(generation_config)
162}
163
164fn build_thinking_config(
191 thinking: Option<&Value>,
192 reasoning_effort: Option<&Value>,
193 model: &str,
194) -> Result<Option<Value>> {
195 let pre_gemini_3 = is_pre_gemini_3(model);
196 let mut cfg = Map::new();
197 match thinking {
198 Some(Value::Object(obj)) if obj.contains_key("type") => {
199 let budget = obj.get("budget_tokens");
200 let include_thoughts = match obj.get("type").and_then(Value::as_str) {
201 Some("enabled") => budget.and_then(Value::as_i64) != Some(0),
202 Some("disabled") => false,
203 _ => {
204 return Err(LiteLLMError::Config(
205 "thinking.type must be \"enabled\" or \"disabled\"".into(),
206 ))
207 }
208 };
209 cfg.insert("includeThoughts".to_string(), include_thoughts.into());
210 if let (true, Some(budget)) = (pre_gemini_3, budget) {
211 let budget = budget.as_i64().ok_or_else(|| {
212 LiteLLMError::Config("thinking.budget_tokens must be an integer".into())
213 })?;
214 cfg.insert("thinkingBudget".to_string(), budget.into());
215 }
216 }
217 Some(Value::Object(obj)) => cfg.extend(obj.clone()),
218 Some(Value::Bool(b)) => {
219 cfg.insert("includeThoughts".to_string(), Value::Bool(*b));
220 }
221 Some(Value::Number(n)) => {
222 cfg.insert("thinkingBudget".to_string(), Value::Number(n.clone()));
223 }
224 _ => {}
225 }
226 let has_level_or_budget =
227 cfg.contains_key("thinkingLevel") || cfg.contains_key("thinkingBudget");
228 if let (false, Some(effort)) = (has_level_or_budget, reasoning_effort) {
229 let effort = effort.as_str().ok_or_else(|| {
230 LiteLLMError::Config("gemini reasoning_effort must be a string".into())
231 })?;
232 let (key, value) = if pre_gemini_3 {
233 let budget = effort_to_thinking_budget(effort, model)
234 .ok_or_else(|| unsupported_reasoning_effort(effort))?;
235 ("thinkingBudget", Value::from(budget))
236 } else {
237 let level = effort_to_thinking_level(effort)
238 .ok_or_else(|| unsupported_reasoning_effort(effort))?;
239 ("thinkingLevel", Value::from(level))
240 };
241 cfg.insert(key.to_string(), value);
242 let include_thoughts = !matches!(effort, "none" | "disable");
243 cfg.entry("includeThoughts")
244 .or_insert(Value::Bool(include_thoughts));
245 }
246 Ok((!cfg.is_empty()).then_some(Value::Object(cfg)))
247}
248
249fn unsupported_reasoning_effort(effort: &str) -> LiteLLMError {
250 LiteLLMError::Config(format!(
251 "unsupported gemini reasoning_effort `{effort}`; expected none, disable, minimal, low, medium, or high"
252 ))
253}
254
255fn effort_to_thinking_budget(effort: &str, model: &str) -> Option<u32> {
258 let name = model.rsplit('/').next().unwrap_or(model).to_lowercase();
259 let budget = match effort {
260 "none" | "disable" if name.contains("gemini-2.5-pro") => 128,
261 "none" | "disable" => 0,
262 "minimal" if name.contains("flash-lite") => 512,
263 "minimal" => 128,
264 "low" => 1024,
265 "medium" => 2048,
266 "high" => 4096,
267 _ => return None,
268 };
269 Some(budget)
270}
271
272fn effort_to_thinking_level(effort: &str) -> Option<&'static str> {
275 match effort {
276 "none" | "disable" | "minimal" | "low" => Some("LOW"),
277 "medium" => Some("MEDIUM"),
278 "high" => Some("HIGH"),
279 _ => None,
280 }
281}
282
283fn is_pre_gemini_3(model: &str) -> bool {
289 let name = model.rsplit('/').next().unwrap_or(model).to_lowercase();
290 let Some(rest) = name.strip_prefix("gemini-") else {
291 return true;
292 };
293 let head = rest.split('-').next().unwrap_or_default();
294 match head {
295 "exp" => true,
296 "pro" | "flash" => !matches!(
297 rest,
298 "pro-latest" | "flash-latest" | "pro-lite-latest" | "flash-lite-latest"
299 ),
300 _ => {
301 let (major, minor) = head.split_once('.').unwrap_or((head, "0"));
302 matches!(major, "1" | "2")
303 && !minor.is_empty()
304 && minor.chars().all(|c| c.is_ascii_digit())
305 }
306 }
307}
308
309pub async fn embeddings(
312 client: &Client,
313 cfg: &ProviderConfig,
314 req: EmbeddingRequest,
315) -> Result<EmbeddingResponse> {
316 let base = cfg
317 .base_url
318 .clone()
319 .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
320 let key = resolve_api_key(cfg)?
321 .ok_or_else(|| LiteLLMError::MissingApiKey("GEMINI_API_KEY".into()))?;
322 let model = req.model.trim_start_matches("models/").to_string();
323 let inputs = embedding_inputs(&req.input)?;
324 if inputs.is_empty() {
325 return Err(LiteLLMError::Config(
326 "embeddings input must not be empty".into(),
327 ));
328 }
329 let content = |text: &str| serde_json::json!({ "parts": [{ "text": text }] });
330 let extra_body = req.extra_body.as_ref();
331
332 let single = inputs.len() == 1;
335 let (method, body) = if single {
336 let mut body = serde_json::json!({ "content": content(&inputs[0]) });
337 merge_extra_body(&mut body, extra_body, &["model", "content"])?;
338 ("embedContent", body)
339 } else {
340 let requests = inputs
341 .iter()
342 .map(|text| {
343 let mut item = serde_json::json!({
344 "model": format!("models/{model}"),
345 "content": content(text),
346 });
347 merge_extra_body(&mut item, extra_body, &["model", "content"])?;
348 Ok(item)
349 })
350 .collect::<Result<Vec<Value>>>()?;
351 (
352 "batchEmbedContents",
353 serde_json::json!({ "requests": requests }),
354 )
355 };
356 let url = format!("{}/models/{}:{}", base.trim_end_matches('/'), model, method);
357
358 let mut builder = client.post(url).header("x-goog-api-key", key).json(&body);
359 for (k, v) in &cfg.extra_headers {
360 builder = builder.header(k, v);
361 }
362
363 let (resp, _headers) = send_json::<Value>(builder).await?;
364 let vectors = if single {
365 vec![embedding_values(resp.get("embedding"))?]
366 } else {
367 resp.get("embeddings")
368 .and_then(Value::as_array)
369 .ok_or_else(|| LiteLLMError::Parse("gemini batch response has no embeddings".into()))?
370 .iter()
371 .map(|item| embedding_values(Some(item)))
372 .collect::<Result<Vec<_>>>()?
373 };
374 if vectors.len() != inputs.len() {
375 return Err(LiteLLMError::Parse(format!(
376 "gemini returned {} embeddings for {} inputs",
377 vectors.len(),
378 inputs.len()
379 )));
380 }
381 let usage = Usage {
383 prompt_tokens: resp
384 .pointer("/usageMetadata/promptTokenCount")
385 .and_then(Value::as_u64),
386 ..Usage::default()
387 };
388
389 Ok(EmbeddingResponse {
390 vectors,
391 usage,
392 raw: None,
393 })
394}
395
396fn embedding_values(embedding: Option<&Value>) -> Result<Vec<f32>> {
398 embedding
399 .and_then(|e| e.get("values"))
400 .and_then(Value::as_array)
401 .ok_or_else(|| LiteLLMError::Parse("gemini embedding has no values array".into()))?
402 .iter()
403 .map(|v| {
404 v.as_f64()
405 .map(|f| f as f32)
406 .ok_or_else(|| LiteLLMError::Parse("gemini embedding value is not a number".into()))
407 })
408 .collect()
409}
410
411fn embedding_inputs(input: &Value) -> Result<Vec<String>> {
414 match input {
415 Value::String(s) => Ok(vec![s.clone()]),
416 Value::Array(items) => items
417 .iter()
418 .map(|item| {
419 item.as_str().map(str::to_string).ok_or_else(|| {
420 LiteLLMError::Config(
421 "gemini embeddings input array must contain strings".into(),
422 )
423 })
424 })
425 .collect(),
426 _ => Err(LiteLLMError::Config(
427 "gemini embeddings input must be a string or array of strings".into(),
428 )),
429 }
430}
431
432fn extract_gemini_tool_calls(resp: &serde_json::Value) -> Option<serde_json::Value> {
438 let parts = resp
439 .get("candidates")?
440 .as_array()?
441 .first()?
442 .get("content")?
443 .get("parts")?
444 .as_array()?;
445 let mut calls: Vec<serde_json::Value> = Vec::new();
446 for (idx, part) in parts.iter().enumerate() {
447 let Some(fc) = part.get("functionCall") else {
448 continue;
449 };
450 let name = fc.get("name").cloned().unwrap_or(serde_json::Value::Null);
451 let args = fc
452 .get("args")
453 .cloned()
454 .unwrap_or_else(|| serde_json::json!({}));
455 let arguments = serde_json::to_string(&args).unwrap_or_else(|_| "{}".into());
456 calls.push(serde_json::json!({
457 "id": format!("call_{idx}"),
458 "type": "function",
459 "function": { "name": name, "arguments": arguments },
460 }));
461 }
462 if calls.is_empty() {
463 None
464 } else {
465 Some(serde_json::Value::Array(calls))
466 }
467}
468
469#[derive(Debug, Clone)]
471pub struct VideoGenerationOptions {
472 pub max_poll_attempts: u32,
474 pub poll_interval_secs: u64,
476}
477
478impl Default for VideoGenerationOptions {
479 fn default() -> Self {
480 Self {
481 max_poll_attempts: DEFAULT_VIDEO_MAX_POLL_ATTEMPTS,
482 poll_interval_secs: DEFAULT_VIDEO_POLL_INTERVAL_SECS,
483 }
484 }
485}
486
487pub async fn video_generation(
488 client: &Client,
489 cfg: &ProviderConfig,
490 req: VideoRequest,
491) -> Result<VideoResponse> {
492 video_generation_with_options(client, cfg, req, VideoGenerationOptions::default()).await
493}
494
495pub async fn video_generation_with_options(
496 client: &Client,
497 cfg: &ProviderConfig,
498 req: VideoRequest,
499 options: VideoGenerationOptions,
500) -> Result<VideoResponse> {
501 let base = cfg
502 .base_url
503 .clone()
504 .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
505 let key = resolve_api_key(cfg)?
506 .ok_or_else(|| LiteLLMError::MissingApiKey("GEMINI_API_KEY".into()))?;
507 let model = req.model.trim_start_matches("models/");
508
509 let url = format!(
510 "{}/models/{}:predictLongRunning",
511 base.trim_end_matches('/'),
512 model
513 );
514
515 let mut parameters = serde_json::Map::new();
516 if let Some(seconds) = req.seconds {
517 parameters.insert("durationSeconds".to_string(), serde_json::json!(seconds));
518 }
519 if let Some(size) = req.size {
520 parameters.insert("resolution".to_string(), serde_json::json!(size));
521 }
522
523 let body = serde_json::json!({
524 "instances": [{ "prompt": req.prompt }],
525 "parameters": Value::Object(parameters),
526 });
527
528 let mut builder = client.post(url).header("x-goog-api-key", &key).json(&body);
529 for (k, v) in &cfg.extra_headers {
530 builder = builder.header(k, v);
531 }
532
533 let (resp, _headers) = send_json::<Value>(builder).await?;
534 let op_name = resp
535 .get("name")
536 .and_then(|v| v.as_str())
537 .ok_or_else(|| LiteLLMError::Parse("missing operation name".into()))?;
538
539 let op_url = if op_name.starts_with("http") {
540 op_name.to_string()
541 } else {
542 format!("{}/{}", base.trim_end_matches('/'), op_name)
543 };
544
545 let poll_interval = Duration::from_secs(options.poll_interval_secs);
546
547 for attempt in 0..options.max_poll_attempts {
548 let mut poll = client.get(&op_url).header("x-goog-api-key", &key);
549 for (k, v) in &cfg.extra_headers {
550 poll = poll.header(k, v);
551 }
552 let (op_resp, _headers) = send_json::<Value>(poll).await?;
553
554 if op_resp.get("done").and_then(|v| v.as_bool()) == Some(true) {
555 if op_resp.get("error").is_some() {
556 return Err(LiteLLMError::http("video generation failed"));
557 }
558
559 let uri = extract_video_uri(&op_resp)
560 .ok_or_else(|| LiteLLMError::Parse("missing video uri".into()))?;
561
562 return Ok(VideoResponse {
563 video_url: Some(uri),
564 raw: None,
565 });
566 }
567
568 if attempt + 1 >= options.max_poll_attempts {
569 return Err(LiteLLMError::http(format!(
570 "video generation timed out after {} attempts",
571 options.max_poll_attempts
572 )));
573 }
574 sleep(poll_interval).await;
575 }
576
577 Err(LiteLLMError::http("video generation timed out"))
578}
579
580pub async fn image_generation(
581 client: &Client,
582 cfg: &ProviderConfig,
583 req: ImageRequest,
584) -> Result<ImageResponse> {
585 let base = cfg
586 .base_url
587 .clone()
588 .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
589 let key = resolve_api_key(cfg)?
590 .ok_or_else(|| LiteLLMError::MissingApiKey("GEMINI_API_KEY".into()))?;
591 let model = req.model.trim_start_matches("models/");
592 let n = req.n.unwrap_or(1);
593
594 if model.contains("gemini") {
595 let url = format!(
597 "{}/models/{}:generateContent",
598 base.trim_end_matches('/'),
599 model
600 );
601
602 let mut body = serde_json::json!({
603 "contents": [{ "parts": [{ "text": req.prompt }] }],
604 "generationConfig": { "response_modalities": ["IMAGE", "TEXT"] }
605 });
606 merge_extra_body(
607 &mut body,
608 req.extra_body.as_ref(),
609 &["contents", "generationConfig"],
610 )?;
611
612 let mut builder = client.post(url).header("x-goog-api-key", &key).json(&body);
613 for (k, v) in &cfg.extra_headers {
614 builder = builder.header(k, v);
615 }
616
617 let (resp, _headers) = send_json::<Value>(builder).await?;
618 let usage = parse_usage(&resp);
619 let images = extract_images_from_candidates(&resp);
620
621 Ok(ImageResponse {
622 images,
623 usage,
624 raw: Some(resp),
625 })
626 } else {
627 let url = format!("{}/models/{}:predict", base.trim_end_matches('/'), model);
629
630 let mut body = serde_json::json!({
631 "instances": [{ "prompt": req.prompt }],
632 "parameters": { "sampleCount": n }
633 });
634 merge_extra_body(
635 &mut body,
636 req.extra_body.as_ref(),
637 &["instances", "parameters"],
638 )?;
639
640 let mut builder = client.post(url).header("x-goog-api-key", &key).json(&body);
641 for (k, v) in &cfg.extra_headers {
642 builder = builder.header(k, v);
643 }
644
645 let (resp, _headers) = send_json::<Value>(builder).await?;
646 let images = extract_images_from_predictions(&resp);
647
648 Ok(ImageResponse {
649 images,
650 usage: Usage::default(),
651 raw: Some(resp),
652 })
653 }
654}
655
656pub async fn image_editing(
657 client: &Client,
658 cfg: &ProviderConfig,
659 req: ImageEditRequest,
660) -> Result<ImageResponse> {
661 let base = cfg
662 .base_url
663 .clone()
664 .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
665 let key = resolve_api_key(cfg)?
666 .ok_or_else(|| LiteLLMError::MissingApiKey("GEMINI_API_KEY".into()))?;
667 let model = req.model.trim_start_matches("models/");
668
669 let url = format!(
670 "{}/models/{}:generateContent",
671 base.trim_end_matches('/'),
672 model
673 );
674
675 let mut parts = Vec::with_capacity(req.images.len() + 1);
676 parts.push(serde_json::json!({ "text": req.prompt }));
677 for image in &req.images {
678 parts.push(image_input_to_part(client, image).await?);
679 }
680
681 let body = serde_json::json!({
682 "contents": [{ "role": "user", "parts": parts }],
683 "generationConfig": {
684 "response_modalities": ["IMAGE", "TEXT"]
685 }
686 });
687
688 let mut builder = client.post(url).header("x-goog-api-key", &key).json(&body);
689 for (k, v) in &cfg.extra_headers {
690 builder = builder.header(k, v);
691 }
692
693 let (resp, _headers) = send_json::<Value>(builder).await?;
694 let usage = parse_usage(&resp);
695 let images = extract_images_from_candidates(&resp);
696
697 Ok(ImageResponse {
698 images,
699 usage,
700 raw: Some(resp),
701 })
702}
703
704async fn image_input_to_part(_client: &Client, input: &ImageInputData) -> Result<Value> {
705 if let Some(b64) = &input.b64_json {
706 let mime = input
707 .mime_type
708 .as_deref()
709 .unwrap_or("image/png")
710 .to_string();
711 return Ok(serde_json::json!({
712 "inline_data": { "mime_type": mime, "data": b64 }
713 }));
714 }
715 if let Some(url) = &input.url {
716 return process_gemini_media_url(url, input.mime_type.as_deref(), None, None, "");
717 }
718 Err(LiteLLMError::Config(
719 "ImageInputData requires b64_json or url".into(),
720 ))
721}
722
723fn extract_images_from_candidates(resp: &Value) -> Vec<ImageData> {
724 let mut images = Vec::new();
725 if let Some(candidates) = resp.get("candidates").and_then(|v| v.as_array()) {
726 for candidate in candidates {
727 if let Some(parts) = candidate
728 .get("content")
729 .and_then(|v| v.get("parts"))
730 .and_then(|v| v.as_array())
731 {
732 for part in parts {
733 if let Some(data) = part
734 .get("inlineData")
735 .and_then(|v| v.get("data"))
736 .and_then(|v| v.as_str())
737 {
738 let mime = part
739 .get("inlineData")
740 .and_then(|v| v.get("mimeType"))
741 .and_then(|v| v.as_str())
742 .map(|s| s.to_string());
743 images.push(ImageData {
744 b64_json: Some(data.to_string()),
745 url: None,
746 revised_prompt: None,
747 mime_type: mime,
748 });
749 }
750 }
751 }
752 }
753 }
754 images
755}
756
757fn extract_images_from_predictions(resp: &Value) -> Vec<ImageData> {
758 let mut images = Vec::new();
759 if let Some(predictions) = resp.get("predictions").and_then(|v| v.as_array()) {
760 for prediction in predictions {
761 if let Some(data) = prediction
762 .get("bytesBase64Encoded")
763 .and_then(|v| v.as_str())
764 {
765 let mime = prediction
766 .get("mimeType")
767 .and_then(|v| v.as_str())
768 .map(|s| s.to_string());
769 images.push(ImageData {
770 b64_json: Some(data.to_string()),
771 url: None,
772 revised_prompt: None,
773 mime_type: mime,
774 });
775 }
776 }
777 }
778 images
779}
780
781pub(crate) fn split_text_and_reasoning(resp: &Value) -> (String, Option<Reasoning>) {
786 let mut content = String::new();
787 let mut thoughts = String::new();
788 let parts = resp
789 .pointer("/candidates/0/content/parts")
790 .and_then(Value::as_array);
791 for part in parts.into_iter().flatten() {
792 let Some(text) = part.get("text").and_then(Value::as_str) else {
793 continue;
794 };
795 if part.get("thought").and_then(Value::as_bool) == Some(true) {
796 thoughts.push_str(text);
797 } else {
798 content.push_str(text);
799 }
800 }
801 (content, Reasoning::from_text(thoughts))
802}
803
804pub(crate) fn parse_usage(resp: &Value) -> Usage {
805 if let Some(meta) = resp.get("usageMetadata").and_then(|v| v.as_object()) {
806 return Usage {
807 prompt_tokens: meta.get("promptTokenCount").and_then(|v| v.as_u64()),
808 completion_tokens: meta.get("candidatesTokenCount").and_then(|v| v.as_u64()),
809 thoughts_tokens: meta.get("thoughtsTokenCount").and_then(|v| v.as_u64()),
810 total_tokens: meta.get("totalTokenCount").and_then(|v| v.as_u64()),
811 cost_usd: None,
812 ..Default::default()
813 };
814 }
815 Usage::default()
816}
817
818fn extract_system_instruction(messages: &mut Vec<ChatMessage>) -> Result<Vec<Value>> {
819 let mut parts = Vec::new();
820 let mut indices = Vec::new();
821 for (idx, msg) in messages.iter().enumerate() {
822 if msg.role == "system" {
823 match &msg.content {
824 ChatMessageContent::Text(text) => {
825 if !text.is_empty() {
826 parts.push(serde_json::json!({ "text": text }));
827 }
828 }
829 ChatMessageContent::Parts(content_parts) => {
830 let mut system_text = String::new();
831 for part in content_parts {
832 if let ChatContentPart::Text(ChatContentPartText { text, .. }) = part {
833 system_text.push_str(text);
834 }
835 }
836 if !system_text.is_empty() {
837 parts.push(serde_json::json!({ "text": system_text }));
838 }
839 }
840 }
841 indices.push(idx);
842 }
843 }
844 for idx in indices.into_iter().rev() {
845 messages.remove(idx);
846 }
847 Ok(parts)
848}
849
850async fn gemini_contents_from_messages(
851 client: &Client,
852 messages: Vec<ChatMessage>,
853 model: &str,
854) -> Result<Vec<Value>> {
855 let mut contents: Vec<Value> = Vec::new();
856 let mut msg_i = 0;
857 let mut tool_call_responses: Vec<Value> = Vec::new();
858 let mut last_tool_calls: Vec<ToolCallInfo> = Vec::new();
859
860 while msg_i < messages.len() {
861 let role = messages[msg_i].role.as_str();
862 if role == "user" {
863 let mut parts: Vec<Value> = Vec::new();
864 while msg_i < messages.len() && messages[msg_i].role == "user" {
865 parts.extend(
866 gemini_parts_from_content(client, &messages[msg_i].content, model).await?,
867 );
868 msg_i += 1;
869 }
870 if !parts.is_empty() {
871 contents.push(serde_json::json!({ "role": "user", "parts": parts }));
872 }
873 continue;
874 }
875
876 if role == "assistant" {
877 let mut parts: Vec<Value> = Vec::new();
878 while msg_i < messages.len() && messages[msg_i].role == "assistant" {
879 let message = &messages[msg_i];
880 parts.extend(gemini_parts_from_content(client, &message.content, model).await?);
881 let (tool_parts, tool_infos) = tool_call_parts_from_message(message)?;
882 if !tool_parts.is_empty() {
883 parts.extend(tool_parts);
884 if !tool_infos.is_empty() {
885 last_tool_calls = tool_infos;
886 }
887 }
888 msg_i += 1;
889 }
890 if !parts.is_empty() {
891 contents.push(serde_json::json!({ "role": "model", "parts": parts }));
892 }
893 continue;
894 }
895
896 if role == "tool" || role == "function" {
897 while msg_i < messages.len()
898 && (messages[msg_i].role == "tool" || messages[msg_i].role == "function")
899 {
900 let response_parts =
901 tool_response_parts(client, &messages[msg_i], &last_tool_calls).await?;
902 tool_call_responses.extend(response_parts);
903 msg_i += 1;
904 }
905 if !tool_call_responses.is_empty() {
906 contents.push(serde_json::json!({ "parts": tool_call_responses }));
907 tool_call_responses = Vec::new();
908 }
909 continue;
910 }
911
912 let parts = gemini_parts_from_content(client, &messages[msg_i].content, model).await?;
913 if !parts.is_empty() {
914 contents.push(serde_json::json!({ "role": "user", "parts": parts }));
915 }
916 msg_i += 1;
917 }
918
919 if !tool_call_responses.is_empty() {
920 contents.push(serde_json::json!({ "parts": tool_call_responses }));
921 }
922 if contents.is_empty() {
923 contents.push(serde_json::json!({
924 "role": "user",
925 "parts": [{ "text": " " }]
926 }));
927 }
928 Ok(contents)
929}
930
931async fn gemini_parts_from_content(
932 _client: &Client,
933 content: &ChatMessageContent,
934 model: &str,
935) -> Result<Vec<Value>> {
936 match content {
937 ChatMessageContent::Text(text) => Ok(vec![serde_json::json!({ "text": text })]),
938 ChatMessageContent::Parts(parts) => {
939 let mut out = Vec::new();
940 for part in parts {
941 match part {
942 ChatContentPart::Text(ChatContentPartText { text, .. }) => {
943 if !text.is_empty() {
944 out.push(serde_json::json!({ "text": text }));
945 }
946 }
947 ChatContentPart::ImageUrl(ChatContentPartImageUrl { image_url, .. }) => {
948 let detail = match image_url {
949 ChatImageUrl::Object(obj) => obj.detail.as_deref(),
950 ChatImageUrl::Url(_) => None,
951 };
952 out.push(process_gemini_media(image_url, detail, None, None, model)?);
953 }
954 ChatContentPart::InputAudio(ChatContentPartInputAudio {
955 input_audio, ..
956 }) => {
957 out.push(process_gemini_audio(input_audio)?);
958 }
959 ChatContentPart::File(ChatContentPartFile { file, .. }) => {
960 out.push(process_gemini_file(file, model)?);
961 }
962 ChatContentPart::Other(value) => {
963 return Err(LiteLLMError::Config(format!(
964 "unsupported gemini content part: {}",
965 value
966 )));
967 }
968 }
969 }
970 Ok(out)
971 }
972 }
973}
974
975#[derive(Debug, Clone)]
976struct ToolCallInfo {
977 id: Option<String>,
978 name: String,
979}
980
981fn tool_call_parts_from_message(message: &ChatMessage) -> Result<(Vec<Value>, Vec<ToolCallInfo>)> {
982 let mut parts = Vec::new();
983 let mut infos = Vec::new();
984
985 if let Some(tool_calls) = &message.tool_calls {
986 if let Some(array) = tool_calls.as_array() {
987 for tool in array {
988 let function = tool
989 .get("function")
990 .ok_or_else(|| LiteLLMError::Config("tool_call missing function".into()))?;
991 let name = function
992 .get("name")
993 .and_then(|v| v.as_str())
994 .ok_or_else(|| LiteLLMError::Config("tool_call missing name".into()))?;
995 let args_raw = function
996 .get("arguments")
997 .and_then(|v| v.as_str())
998 .unwrap_or("");
999 let args = if args_raw.trim().is_empty() {
1000 Value::Object(serde_json::Map::new())
1001 } else {
1002 serde_json::from_str(args_raw)
1003 .map_err(|e| LiteLLMError::Parse(e.to_string()))?
1004 };
1005 parts.push(serde_json::json!({
1006 "function_call": { "name": name, "args": args }
1007 }));
1008 let id = tool
1009 .get("id")
1010 .and_then(|v| v.as_str())
1011 .map(|s| s.to_string());
1012 infos.push(ToolCallInfo {
1013 id,
1014 name: name.to_string(),
1015 });
1016 }
1017 return Ok((parts, infos));
1018 }
1019 }
1020
1021 if let Some(function_call) = &message.function_call {
1022 let name = function_call
1023 .get("name")
1024 .and_then(|v| v.as_str())
1025 .ok_or_else(|| LiteLLMError::Config("function_call missing name".into()))?;
1026 let args_raw = function_call
1027 .get("arguments")
1028 .and_then(|v| v.as_str())
1029 .unwrap_or("");
1030 let args = if args_raw.trim().is_empty() {
1031 Value::Object(serde_json::Map::new())
1032 } else {
1033 serde_json::from_str(args_raw).map_err(|e| LiteLLMError::Parse(e.to_string()))?
1034 };
1035 parts.push(serde_json::json!({
1036 "function_call": { "name": name, "args": args }
1037 }));
1038 infos.push(ToolCallInfo {
1039 id: None,
1040 name: name.to_string(),
1041 });
1042 }
1043
1044 Ok((parts, infos))
1045}
1046
1047async fn tool_response_parts(
1048 client: &Client,
1049 message: &ChatMessage,
1050 last_tool_calls: &[ToolCallInfo],
1051) -> Result<Vec<Value>> {
1052 let name = resolve_tool_name(message, last_tool_calls)?;
1053 let (content_str, mut inline_parts) = extract_tool_content(client, &message.content).await?;
1054 let response = parse_tool_response_data(&content_str);
1055 let function_part = serde_json::json!({
1056 "function_response": { "name": name, "response": response }
1057 });
1058 let mut parts = vec![function_part];
1059 parts.append(&mut inline_parts);
1060 Ok(parts)
1061}
1062
1063fn resolve_tool_name(message: &ChatMessage, last_tool_calls: &[ToolCallInfo]) -> Result<String> {
1064 if let Some(name) = &message.name {
1065 return Ok(name.clone());
1066 }
1067 if let Some(tool_call_id) = &message.tool_call_id {
1068 if let Some(info) = last_tool_calls
1069 .iter()
1070 .find(|info| info.id.as_deref() == Some(tool_call_id.as_str()))
1071 {
1072 return Ok(info.name.clone());
1073 }
1074 }
1075 Err(LiteLLMError::Config(
1076 "missing tool name for tool response".into(),
1077 ))
1078}
1079
1080async fn extract_tool_content(
1081 client: &Client,
1082 content: &ChatMessageContent,
1083) -> Result<(String, Vec<Value>)> {
1084 let mut text = String::new();
1085 let mut inline_parts = Vec::new();
1086 match content {
1087 ChatMessageContent::Text(t) => {
1088 text.push_str(t);
1089 }
1090 ChatMessageContent::Parts(parts) => {
1091 for part in parts {
1092 match part {
1093 ChatContentPart::Text(ChatContentPartText { text: t, .. }) => {
1094 text.push_str(t);
1095 }
1096 ChatContentPart::ImageUrl(ChatContentPartImageUrl { image_url, .. }) => {
1097 inline_parts.push(inline_data_from_image_url(client, image_url).await?);
1098 }
1099 ChatContentPart::InputAudio(ChatContentPartInputAudio {
1100 input_audio, ..
1101 }) => {
1102 inline_parts.push(inline_data_from_audio(input_audio)?);
1103 }
1104 ChatContentPart::File(ChatContentPartFile { file, .. }) => {
1105 inline_parts.push(inline_data_from_file(client, file).await?);
1106 }
1107 ChatContentPart::Other(_) => {}
1108 }
1109 }
1110 }
1111 }
1112 Ok((text, inline_parts))
1113}
1114
1115fn parse_tool_response_data(content: &str) -> Value {
1116 let trimmed = content.trim();
1117 if trimmed.starts_with('{') || trimmed.starts_with('[') {
1118 if let Ok(value) = serde_json::from_str(trimmed) {
1119 return value;
1120 }
1121 }
1122 serde_json::json!({ "content": content })
1123}
1124
1125async fn inline_data_from_image_url(client: &Client, image_url: &ChatImageUrl) -> Result<Value> {
1126 let (url, format) = match image_url {
1127 ChatImageUrl::Url(url) => (url.as_str(), None),
1128 ChatImageUrl::Object(obj) => (obj.url.as_str(), obj.format.as_deref()),
1129 };
1130 inline_data_from_url(client, url, format).await
1131}
1132
1133fn inline_data_from_audio(input_audio: &ChatInputAudio) -> Result<Value> {
1134 let format = if input_audio.format.starts_with("audio/") {
1135 input_audio.format.clone()
1136 } else {
1137 format!("audio/{}", input_audio.format)
1138 };
1139 Ok(serde_json::json!({
1140 "inline_data": { "mime_type": format, "data": input_audio.data }
1141 }))
1142}
1143
1144async fn inline_data_from_file(client: &Client, file: &ChatFile) -> Result<Value> {
1145 let passed = file
1146 .file_id
1147 .as_ref()
1148 .or(file.file_data.as_ref())
1149 .ok_or_else(|| LiteLLMError::Config("file_id or file_data required".into()))?;
1150 inline_data_from_url(client, passed, file.format.as_deref()).await
1151}
1152
1153async fn inline_data_from_url(client: &Client, url: &str, format: Option<&str>) -> Result<Value> {
1154 if let Some((mime, data)) = parse_data_url(url, format) {
1155 return Ok(serde_json::json!({
1156 "inline_data": { "mime_type": mime, "data": data }
1157 }));
1158 }
1159 if url.starts_with("http://") || url.starts_with("https://") {
1160 let (mime, data) = fetch_bytes_with_mime(client, url, format).await?;
1161 return Ok(serde_json::json!({
1162 "inline_data": { "mime_type": mime, "data": data }
1163 }));
1164 }
1165 Err(LiteLLMError::Config("unsupported inline data url".into()))
1166}
1167
1168async fn fetch_bytes_with_mime(
1169 client: &Client,
1170 url: &str,
1171 format: Option<&str>,
1172) -> Result<(String, String)> {
1173 let resp = client.get(url).send().await.map_err(LiteLLMError::from)?;
1174 let headers = resp.headers().clone();
1175 let bytes = resp.bytes().await.map_err(LiteLLMError::from)?;
1176 let header_mime = headers
1177 .get(CONTENT_TYPE)
1178 .and_then(|v| v.to_str().ok())
1179 .map(|v| v.split(';').next().unwrap_or(v).trim().to_string());
1180 let mime = format
1181 .map(|v| v.to_string())
1182 .or(header_mime)
1183 .or_else(|| mime_type_from_url(url))
1184 .unwrap_or_else(|| "application/octet-stream".to_string());
1185 let data = general_purpose::STANDARD.encode(bytes);
1186 Ok((mime, data))
1187}
1188
1189fn process_gemini_audio(input_audio: &ChatInputAudio) -> Result<Value> {
1190 let format = if input_audio.format.starts_with("audio/") {
1191 input_audio.format.clone()
1192 } else {
1193 format!("audio/{}", input_audio.format)
1194 };
1195 Ok(serde_json::json!({
1196 "inline_data": {
1197 "mime_type": format,
1198 "data": input_audio.data
1199 }
1200 }))
1201}
1202
1203fn process_gemini_file(file: &ChatFile, model: &str) -> Result<Value> {
1204 let passed = file
1205 .file_id
1206 .as_ref()
1207 .or(file.file_data.as_ref())
1208 .ok_or_else(|| LiteLLMError::Config("file_id or file_data required".into()))?;
1209 process_gemini_media_url(
1210 passed,
1211 file.format.as_deref(),
1212 file.detail.as_deref(),
1213 file.video_metadata.as_ref(),
1214 model,
1215 )
1216}
1217
1218fn process_gemini_media(
1219 image_url: &ChatImageUrl,
1220 detail: Option<&str>,
1221 video_metadata: Option<&Value>,
1222 format: Option<&str>,
1223 model: &str,
1224) -> Result<Value> {
1225 let (url, format) = match image_url {
1226 ChatImageUrl::Url(url) => (url.as_str(), format),
1227 ChatImageUrl::Object(obj) => (obj.url.as_str(), obj.format.as_deref().or(format)),
1228 };
1229 process_gemini_media_url(url, format, detail, video_metadata, model)
1230}
1231
1232fn process_gemini_media_url(
1233 url: &str,
1234 format: Option<&str>,
1235 detail: Option<&str>,
1236 video_metadata: Option<&Value>,
1237 _model: &str,
1238) -> Result<Value> {
1239 if url.starts_with("gs://") || url.starts_with("https://") || url.starts_with("http://") {
1240 let mime_type = format
1241 .map(|v| v.to_string())
1242 .or_else(|| mime_type_from_url(url))
1243 .ok_or_else(|| LiteLLMError::Config("missing media mime type".into()))?;
1244 let mut part = serde_json::json!({
1245 "file_data": { "mime_type": mime_type, "file_uri": url }
1246 });
1247 apply_gemini_metadata(&mut part, detail, video_metadata);
1248 return Ok(part);
1249 }
1250
1251 if let Some((media_type, data)) = parse_data_url(url, format) {
1252 let mut part = serde_json::json!({
1253 "inline_data": { "mime_type": media_type, "data": data }
1254 });
1255 apply_gemini_metadata(&mut part, detail, video_metadata);
1256 return Ok(part);
1257 }
1258
1259 Err(LiteLLMError::Config("unsupported gemini media url".into()))
1260}
1261
1262fn apply_gemini_metadata(part: &mut Value, detail: Option<&str>, video_metadata: Option<&Value>) {
1263 if let Some(detail) = detail {
1264 if let Some(level) = detail_to_media_resolution(detail) {
1265 if let Some(obj) = part.as_object_mut() {
1266 obj.insert(
1267 "media_resolution".to_string(),
1268 serde_json::json!({ "level": level }),
1269 );
1270 }
1271 }
1272 }
1273 if let Some(video_metadata) = video_metadata {
1274 if let Some(obj) = part.as_object_mut() {
1275 obj.insert("video_metadata".to_string(), video_metadata.clone());
1276 }
1277 }
1278}
1279
1280fn detail_to_media_resolution(detail: &str) -> Option<&'static str> {
1281 match detail {
1282 "low" => Some("MEDIA_RESOLUTION_LOW"),
1283 "medium" => Some("MEDIA_RESOLUTION_MEDIUM"),
1284 "high" => Some("MEDIA_RESOLUTION_HIGH"),
1285 "ultra_high" => Some("MEDIA_RESOLUTION_ULTRA_HIGH"),
1286 _ => None,
1287 }
1288}
1289
1290fn parse_data_url(url: &str, override_format: Option<&str>) -> Option<(String, String)> {
1291 if url.starts_with("data:") {
1292 let stripped = url.strip_prefix("data:").unwrap_or(url);
1293 if let Some((meta, data)) = stripped.split_once(',') {
1294 let mut media_type = meta.split(';').next().unwrap_or("application/octet-stream");
1295 if let Some(fmt) = override_format {
1296 media_type = fmt;
1297 }
1298 return Some((media_type.to_string(), data.to_string()));
1299 }
1300 }
1301 None
1302}
1303
1304fn mime_type_from_url(url: &str) -> Option<String> {
1305 let path = url.split('?').next().unwrap_or(url);
1306 MimeGuess::from_path(path)
1307 .first_raw()
1308 .map(|m| m.to_string())
1309}
1310
1311fn extract_video_uri(resp: &Value) -> Option<String> {
1312 let response = resp.get("response")?;
1313 if let Some(uri) = response
1314 .pointer("/generateVideoResponse/generatedSamples/0/video/uri")
1315 .and_then(|v| v.as_str())
1316 {
1317 return Some(uri.to_string());
1318 }
1319 if let Some(uri) = response
1320 .pointer("/generatedVideos/0/uri")
1321 .and_then(|v| v.as_str())
1322 {
1323 return Some(uri.to_string());
1324 }
1325 if let Some(uri) = response.pointer("/videos/0/uri").and_then(|v| v.as_str()) {
1326 return Some(uri.to_string());
1327 }
1328 None
1329}