Skip to main content

litellm_rust/providers/
gemini.rs

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
20/// Default maximum polling attempts for video generation (240 * 5s = 20 minutes)
21pub const DEFAULT_VIDEO_MAX_POLL_ATTEMPTS: u32 = 240;
22/// Default polling interval for video generation status checks
23pub 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
68/// Stream a chat completion through `streamGenerateContent?alt=sse`.
69pub 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
112/// Build the `generateContent` / `streamGenerateContent` request body. Both
113/// endpoints take the same `GenerateContentRequest` shape.
114async 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
164/// Map `thinking` / `reasoning_effort` onto Gemini's
165/// `generationConfig.thinkingConfig` (`includeThoughts`, `thinkingBudget`,
166/// `thinkingLevel`; see <https://ai.google.dev/api/generate-content#ThinkingConfig>).
167///
168/// - `thinking` as an object with a `type` key is the LiteLLM/Anthropic shape
169///   (`{"type": "enabled", "budget_tokens": 1024}`). `type` sets
170///   `includeThoughts`; `budget_tokens` becomes `thinkingBudget` on models
171///   before Gemini 3 only, as in Python LiteLLM.
172/// - `thinking` as an object without `type` is Gemini-native (for example
173///   `{"thinkingBudget": 1024}`) and is sent as is.
174/// - `thinking` as a bool sets `includeThoughts`; as a number it sets
175///   `thinkingBudget`.
176/// - `reasoning_effort` is used only when `thinking` did not already set a
177///   budget or a level, because Gemini rejects both in one request.
178///   - Gemini 3 and newer take a `thinkingLevel`: `low`, `medium`, `high`
179///     become `LOW`, `MEDIUM`, `HIGH`; `minimal`, `none` and `disable` become
180///     `LOW`. Gemini 3 cannot turn thinking off, and not every Gemini 3 model
181///     accepts `MINIMAL`, so `LOW` is the lowest level that all of them take.
182///   - Older models reject `thinkingLevel`, so the effort becomes a
183///     `thinkingBudget` with Python LiteLLM's token values (`none` /
184///     `disable` 0, `minimal` 128, `low` 1024, `medium` 2048, `high` 4096),
185///     raised to the model's minimum: `gemini-2.5-pro` cannot turn thinking
186///     off, so `none` / `disable` send 128; Flash-Lite models accept 0 or
187///     512 and more, so `minimal` sends 512.
188///   - Unless `thinking` set it, `includeThoughts` is `true`, or `false` for
189///     `none` / `disable`. Other effort values are a config error.
190fn 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
255/// Token budgets used by Python LiteLLM for pre-Gemini-3 `reasoning_effort`,
256/// raised to the minimum the model accepts.
257fn 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
272/// `ThinkingLevel` enum values for Gemini 3 and newer. `LOW` is the lowest
273/// level every Gemini 3 model accepts.
274fn 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
283/// True for every model that must not get `thinkingLevel`: `gemini-1.x`,
284/// `gemini-2.x`, `gemini-exp*`, the old `gemini-pro` / `gemini-flash` names,
285/// and names without the `gemini-` prefix (Gemma, tuned models). The
286/// `*-latest` aliases point at newer models. This is Python LiteLLM's
287/// `_is_gemini_3_or_newer`, inverted.
288fn 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
309/// Embed text through `embedContent` (one input) or `batchEmbedContents`
310/// (an array of inputs).
311pub 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    // `extra_body` holds per-request `EmbedContentRequest` fields such as
333    // `taskType` or `outputDimensionality`. A batch carries them on each item.
334    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    // Embedding usage only reports `promptTokenCount`.
382    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
396/// Read `values` from a Gemini `ContentEmbedding`.
397fn 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
411/// Accepts the OpenAI-style embeddings `input`: one string or an array of
412/// strings. Gemini embedding requests here are text only.
413fn 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
432/// Map Gemini `functionCall` parts to the OpenAI tool_calls shape. Gemini emits
433/// `candidates[0].content.parts[].functionCall = { name, args }`; the OpenAI
434/// shape is `{ id, type: "function", function: { name, arguments } }` where
435/// `arguments` is a JSON string. Gemini doesn't give per-call ids, so we
436/// synthesize `call_<idx>` to keep IDs stable within a response.
437fn 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/// Video generation options for configurable timeouts.
470#[derive(Debug, Clone)]
471pub struct VideoGenerationOptions {
472    /// Maximum number of polling attempts
473    pub max_poll_attempts: u32,
474    /// Interval between polling attempts in seconds
475    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        // Path A: Gemini native image generation via generateContent
596        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        // Path B: Imagen models via predict endpoint
628        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
781/// Split the first candidate's text parts into visible content and
782/// `"thought": true` parts (returned when `includeThoughts` is set) into
783/// reasoning. Used for both `generateContent` responses and each
784/// `streamGenerateContent` event.
785pub(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}