Skip to main content

rig_core/providers/gemini/
completion.rs

1//! The [Gemini GenerateContent API](https://ai.google.dev/api/generate-content)
2//! completion wire. Its request is built as REST JSON by [`request_body`],
3//! which the Vertex AI and gRPC wires transcode, and its replies are read by
4//! [`GenerateContentDecoder`](super::streaming::GenerateContentDecoder).
5//!
6//! ```no_run
7//! use rig_core::providers::gemini::{Gemini, completion::GEMINI_2_5_FLASH};
8//!
9//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
10//! let wire = Gemini::from_env()?.completion(GEMINI_2_5_FLASH);
11//! # Ok(())
12//! # }
13//! ```
14
15/// `gemini-3.8-flash` completion model
16pub const GEMINI_3_8_FLASH: &str = "gemini-3.8-flash";
17/// `gemini-3.1-flash-lite-preview` completion model
18pub const GEMINI_3_1_FLASH_LITE_PREVIEW: &str = "gemini-3.1-flash-lite-preview";
19/// `gemini-3-flash-preview` completion model
20pub const GEMINI_3_FLASH_PREVIEW: &str = "gemini-3-flash-preview";
21/// `gemini-2.5-pro-preview-06-05` completion model
22pub const GEMINI_2_5_PRO_PREVIEW_06_05: &str = "gemini-2.5-pro-preview-06-05";
23/// `gemini-2.5-pro-exp-03-25` experimental completion model
24pub const GEMINI_2_5_PRO_EXP_03_25: &str = "gemini-2.5-pro-exp-03-25";
25/// `gemini-2.5-flash` completion model
26pub const GEMINI_2_5_FLASH: &str = "gemini-2.5-flash";
27/// `gemini-2.0-flash` completion model
28pub const GEMINI_2_0_FLASH: &str = "gemini-2.0-flash";
29
30use serde_json::{Map, Value, json};
31
32pub use super::cached_content::with_cached_content;
33#[doc(hidden)]
34pub use super::options::{Route, generate_content_options};
35use crate::completion::options::{BaseInput, FinalBody, RawAt, Rewrite, request_params};
36use crate::completion::{Accepts, CompletionRequest, Media, Place, Replay, ReplayTarget};
37use crate::error::{EncodeError, ProviderError};
38use crate::json_utils::Lenient;
39use crate::message::{
40    AssistantContent, DocumentData, DocumentMediaType, DocumentSourceKind as Source, Message,
41    MimeType, ToolChoice, ToolResultContent, UserContent,
42};
43use crate::operation::Completion;
44use crate::providers::internal::set_fields;
45use crate::providers::internal::wire_ids::WireIds;
46use crate::telemetry::GenAiOperation;
47use crate::wire::{Descriptor, Encoded, Framing, Mode, Wire};
48
49/// Provider name used in normalized responses, streams, and telemetry.
50pub const PROVIDER_NAME: &str = "gcp.gemini";
51
52/// Completion wire for unary `generateContent` and SSE `streamGenerateContent`.
53/// Both modes use [`GenerateContentDecoder`](super::streaming::GenerateContentDecoder).
54#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
55pub struct GenerateContent {
56    /// The key and the API root.
57    pub provider: super::GeminiConfig,
58    /// The model to address, e.g. [`GEMINI_2_5_FLASH`].
59    pub model: String,
60    /// Handle of a `cachedContents` resource every request reads its prefix
61    /// from. See [`Self::with_cached_content`].
62    pub cached_content: Option<String>,
63    /// Which thought signatures requests re-send. See [`ThoughtReplay`].
64    #[serde(default)]
65    pub thought_replay: ThoughtReplay,
66    /// The model facts the encoder reads and replies are priced by.
67    #[serde(skip)]
68    pub facts: crate::catalog::ModelFacts,
69}
70
71/// Which earlier thought signatures a request re-sends.
72///
73/// On Gemini 3, every thought signature a request carries is expanded back
74/// into the reasoning it came from, and that reasoning is billed again as
75/// input on every later call. No explicit cache holds it. Measured on
76/// gemini-3.8-flash: a history whose earlier turns carried their signatures
77/// cost 6,708 prompt tokens; the same history without them cost 2,681, and
78/// Gemini answered normally. Over a 30-turn chat, dropping them cut input by
79/// 42%, and by 83% together with automatic caching.
80///
81/// Google's thinking guide says to send thought signatures back exactly as
82/// received. [`ThoughtReplay::CurrentTurn`] departs from that for turns the
83/// model has finished; its effect on answer quality is unmeasured. Hence
84/// [`ThoughtReplay::All`] is the default.
85#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
86pub enum ThoughtReplay {
87    /// Re-send every signature, as Google's guidance asks.
88    #[default]
89    All,
90    /// Re-send only the signatures at or after the newest user message: the
91    /// current turn, whose function calls Gemini validates. Parts before it
92    /// go out without their signature. The history itself is unchanged.
93    CurrentTurn,
94}
95
96impl GenerateContent {
97    /// The wire for `model`.
98    pub fn new(provider: super::GeminiConfig, model: impl Into<String>) -> Self {
99        Self {
100            provider,
101            model: model.into(),
102            cached_content: None,
103            thought_replay: ThoughtReplay::All,
104            facts: crate::catalog::ModelFacts::default(),
105        }
106    }
107
108    /// The same wire, encoding with `facts` and pricing its replies by
109    /// them.
110    pub fn with_facts(mut self, facts: crate::catalog::ModelFacts) -> Self {
111        self.facts = facts;
112        self
113    }
114
115    /// Re-send thought signatures as `replay` says. See [`ThoughtReplay`].
116    pub fn thought_replay(mut self, replay: ThoughtReplay) -> Self {
117        self.thought_replay = replay;
118        self
119    }
120
121    /// Use an explicit `cachedContents/<id>` handle as every request's prefix.
122    /// Encoding rejects requests with their own system instruction, tools, or
123    /// tool choice. See [`crate::providers::gemini::cached_content`] for cache ownership.
124    pub fn with_cached_content(mut self, name: impl Into<String>) -> Self {
125        self.cached_content = Some(name.into());
126        self
127    }
128}
129
130impl<T> crate::driver::Model<GenerateContent, T> {
131    /// This model, re-sending thought signatures as `replay` says. See
132    /// [`ThoughtReplay`]. Call it before [`caching`](Self::caching), which
133    /// reads it.
134    pub fn thought_replay(mut self, replay: ThoughtReplay) -> Self {
135        self.wire.thought_replay = replay;
136        self
137    }
138}
139
140/// Remove thought signatures from every part before the newest user text
141/// content: the turns the model has finished. Only the signature keys go;
142/// every other field is unchanged.
143fn drop_finished_signatures(contents: &mut [Value]) {
144    let current = contents.iter().rposition(|content| {
145        let parts = content.arr("parts");
146        content.str("role") == Some("user")
147            && parts.iter().any(|part| part.get("text").is_some())
148            && !parts
149                .iter()
150                .any(|part| part.get("functionResponse").is_some())
151    });
152    for content in contents.iter_mut().take(current.unwrap_or(0)) {
153        let parts = content.get_mut("parts").and_then(Value::as_array_mut);
154        for part in parts.into_iter().flatten().filter_map(Value::as_object_mut) {
155            part.shift_remove("thoughtSignature");
156        }
157    }
158}
159
160impl Wire for GenerateContent {
161    type Op = Completion;
162    type Payload = Encoded;
163    type Frame = crate::wire::WireFrame;
164    type Event = super::streaming::GenerateContentChunk;
165    type Decoder<'id> = super::streaming::GenerateContentDecoder;
166    type Reassembler = super::streaming::document::GenerateContentResponse;
167
168    fn describe(&self) -> Descriptor<'_> {
169        Descriptor::new(PROVIDER_NAME)
170            .model(self.model.as_str())
171            .facts(&self.facts)
172            .telemetry(|_| GenAiOperation::GenerateContent)
173            .replay(self)
174    }
175
176    fn encode(&self, request: CompletionRequest, mode: Mode) -> Result<Encoded, EncodeError> {
177        // The request may name a model of its own; the wire's is the default.
178        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
179        let replay = self.thought_replay;
180        let body = request_body(
181            &request,
182            self,
183            &model,
184            self.cached_content.as_deref(),
185            |body| {
186                if let (ThoughtReplay::CurrentTurn, Some(Value::Array(contents))) =
187                    (replay, body.get_mut("contents"))
188                {
189                    drop_finished_signatures(contents);
190                }
191            },
192        )?;
193        use crate::providers::internal::LogTarget;
194        // `alt=sse` is what makes the streamed reply an event stream rather
195        // than a JSON array of the same chunks.
196        let (verb, framing, target) = match mode {
197            Mode::Unary => ("generateContent", Framing::Whole, LogTarget::Completions),
198            Mode::Streaming => (
199                "streamGenerateContent?alt=sse",
200                Framing::Sse,
201                LogTarget::Streaming,
202            ),
203        };
204        crate::providers::internal::trace_json(target, "Gemini completion request", &body);
205        let uri = self.provider.uri(&format!("/v1beta/models/{model}:{verb}"));
206        let request = http::Request::post(uri)
207            .header("Content-Type", "application/json")
208            .body(body.into_body())?;
209        // Gemini supplies no transport request-id response header.
210        Ok(Encoded::new(request, framing)
211            .with_projection(super::streaming::GenerateContentDecoder::project)
212            .with_analysis_only(super::streaming::GenerateContentDecoder::is_analysis_only))
213    }
214
215    fn decoder<'id>(&self) -> Self::Decoder<'id> {
216        super::streaming::GenerateContentDecoder::default()
217    }
218}
219
220impl ReplayTarget for GenerateContent {
221    fn facts(&self) -> Option<&crate::catalog::ModelFacts> {
222        Some(&self.facts)
223    }
224
225    fn api(&self) -> crate::message::Api {
226        crate::message::Api::from_static("gemini.generate_content")
227    }
228
229    fn provider(&self) -> &str {
230        PROVIDER_NAME
231    }
232
233    fn model(&self) -> &str {
234        &self.model
235    }
236
237    fn accepts(&self, model: &str) -> Accepts {
238        accepts(model)
239    }
240
241    fn encodes(&self, _model: &str, media: Media<'_>) -> bool {
242        encodes(media, false)
243    }
244
245    fn normalize_tool_call_id(
246        &self,
247        id: &str,
248        model: &str,
249        _source: Option<&crate::message::Origin>,
250    ) -> String {
251        normalize_tool_call_id(model, id)
252    }
253
254    /// Gemini takes system text only in `systemInstruction`: later system
255    /// messages fold into the leading one, as pi's `collapseSystemMessages`.
256    fn later_system(&self, _model: &str) -> crate::completion::LaterSystem {
257        crate::completion::LaterSystem::Leading
258    }
259
260    fn call_id_slot(&self) -> Option<&'static str> {
261        CALL_ID_SLOT
262    }
263
264    fn declares_tools(&self, request: &crate::completion::CompletionRequest) -> bool {
265        self.cached_content.is_some() || declares_tools(self, request)
266    }
267
268    /// Section 6.4 of the typed-options design, for the Gemini API.
269    fn map_options(
270        &self,
271        request: &CompletionRequest,
272        fields: crate::completion::options::OptionFields<'_>,
273    ) -> crate::completion::options::OptionMap {
274        let model = request.model.as_deref().unwrap_or(&self.model);
275        generate_content_options(&self.facts, model, Route::Rest, fields)
276    }
277}
278
279/// Whether `request` declares tools on `target`, a GenerateContent wire: in
280/// `tools`, in the `tools` of its `additional_params`, or through a
281/// `cachedContent` handle there, since a cache holds its function
282/// declarations.
283pub fn declares_tools(
284    target: &dyn ReplayTarget,
285    request: &crate::completion::CompletionRequest,
286) -> bool {
287    let param = |key: &str| crate::completion::options::param(target, request, key);
288    !request.tools.is_empty()
289        || param("tools").is_some_and(|tools| !tools.as_array().is_none_or(Vec::is_empty))
290        || CACHED_CONTENT
291            .iter()
292            .any(|spelling| param(spelling).is_some_and(|value| !value.is_null()))
293}
294
295/// Where a GenerateContent part holds its call's id, on every wire that
296/// speaks it.
297pub const CALL_ID_SLOT: Option<&str> = Some("/functionCall/id");
298
299/// The major version of a Gemini model id, read past a `models/` prefix:
300/// `gemini-<major>…` or `gemini-live-<major>…`, or `None` for an id that is
301/// not Gemini's. A Gemini id that names no version, such as the alias
302/// `gemini-flash-latest`, is the current generation (`u32::MAX`), so every
303/// rule reads an alias as the newest model.
304pub(super) fn gemini_major(model: &str) -> Option<u32> {
305    let model = model.to_ascii_lowercase();
306    let model = model.strip_prefix("models/").unwrap_or(&model);
307    let rest = model.strip_prefix("gemini-")?;
308    let rest = rest.strip_prefix("live-").unwrap_or(rest);
309    let end = rest
310        .find(|c: char| !c.is_ascii_digit())
311        .unwrap_or(rest.len());
312    Some(
313        rest.get(..end)
314            .and_then(|major| major.parse().ok())
315            .unwrap_or(u32::MAX),
316    )
317}
318
319/// Whether `model` is Gemini 3 or later, which validates the thought
320/// signatures of function calls. A Gemini alias counts as current.
321pub(super) fn gemini_3_or_later(model: &str) -> bool {
322    gemini_major(model).is_some_and(|major| major >= 3)
323}
324
325/// Whether `model` takes function-call ids on its calls and their
326/// responses: Claude, gpt-oss, and Gemini 3 or later (pi's rule).
327pub fn requires_tool_call_id(model: &str) -> bool {
328    let model = model.to_ascii_lowercase();
329    model.starts_with("claude-") || model.starts_with("gpt-oss-") || gemini_3_or_later(&model)
330}
331
332/// What `model` reads on every Gemini wire (REST, Vertex AI, gRPC and
333/// Interactions): images in every role, and images inside function
334/// responses only from Gemini 3 on. A Gemini alias reads them as the
335/// current generation, and a model that is not Gemini (Claude behind
336/// Vertex AI) is assumed to read them, as pi assumes.
337pub fn accepts(model: &str) -> Accepts {
338    Accepts {
339        tool_result_images: gemini_major(model).is_none_or(|major| major >= 3),
340        ..Accepts::ALL
341    }
342}
343
344/// Whether a GenerateContent wire takes `media`: data or a URL of a media
345/// type Gemini reads, or a text document, which goes as text. A YouTube video
346/// needs no media type and a file id is never taken. A function response
347/// takes image data, and a URL only when `response_files`: Vertex AI declares
348/// `fileData` there and the Gemini API does not.
349pub fn encodes(media: Media<'_>, response_files: bool) -> bool {
350    match media {
351        Media::Image(image, place) => {
352            reads_image(image.media_type.as_ref(), place)
353                && match image.data {
354                    Source::Base64(_) => true,
355                    Source::Url(_) => place != Place::ToolResult || response_files,
356                    _ => false,
357                }
358        }
359        Media::Audio(audio) => {
360            audio.media_type.is_some() && matches!(audio.data, Source::Url(_) | Source::Base64(_))
361        }
362        Media::Video(video) => match &video.data {
363            Source::Url(url) if url.starts_with("https://www.youtube.com") => true,
364            data => {
365                video.media_type.is_some() && matches!(data, Source::Url(_) | Source::Base64(_))
366            }
367        },
368        Media::Document(document) => match (&document.media_type, &document.data) {
369            (_, DocumentData::Text(_)) => true,
370            (Some(_), DocumentData::File(Source::Url(_))) => true,
371            (Some(media_type), DocumentData::File(Source::Base64(_))) => {
372                *media_type == DocumentMediaType::PDF
373            }
374            _ => false,
375        },
376    }
377}
378
379/// Whether Gemini reads an image of `media_type` at `place`: JPEG, PNG,
380/// WEBP, HEIC or HEIF, and only the first three in a function response.
381pub(crate) fn reads_image(
382    media_type: Option<&crate::message::ImageMediaType>,
383    place: Place,
384) -> bool {
385    use crate::message::ImageMediaType::{HEIC, HEIF, JPEG, PNG, WEBP};
386    match place {
387        Place::ToolResult => matches!(media_type, Some(JPEG | PNG | WEBP)),
388        _ => matches!(media_type, Some(JPEG | PNG | WEBP | HEIC | HEIF)),
389    }
390}
391
392/// `id` as `model` takes another model's call id: when the model takes ids
393/// at all, characters outside `[a-zA-Z0-9_-]` become `_` and it keeps at
394/// most 64 of them.
395pub fn normalize_tool_call_id(model: &str, id: &str) -> String {
396    if !requires_tool_call_id(model) {
397        return id.to_owned();
398    }
399    crate::providers::internal::wire_ids::legal_call_id(id, 64)
400}
401
402/// The REST `GenerateContentRequest` body `request` sends to `model` on
403/// `target`, a GenerateContent wire. The REST wire sends it; the Vertex AI
404/// and gRPC wires read it into their SDK and protobuf requests.
405///
406/// System messages become the system instruction, and the rest the
407/// contents. `adjust` finishes the wire's own encoding (a transport's
408/// spellings of `contents`, a `model` key) before [`request_params`] merges
409/// the mapped options and then `additional_params` over it: its `tools`
410/// add to the request's, its `generationConfig` merges over the typed
411/// fields key by key, and a `cachedContent` handle, like `cached_content`,
412/// is checked as [`GenerateContent::with_cached_content`] checks one. The
413/// fields Gemini's request shape leaves unset are sent as `null`, as
414/// recorded requests spell them.
415///
416/// # Errors
417///
418/// When an option is refused, `additional_params` is malformed, a tool's
419/// parameters are no schema Gemini reads, or a field is set two ways.
420pub fn request_body(
421    request: &CompletionRequest,
422    target: &dyn ReplayTarget,
423    model: &str,
424    cached_content: Option<&str>,
425    adjust: impl FnOnce(&mut Map<String, Value>),
426) -> Result<FinalBody, EncodeError> {
427    request_params(
428        target,
429        request,
430        |input| {
431            let mut body = base(request, target, model, input)?;
432            adjust(&mut body);
433            Ok(body)
434        },
435        RawAt::Top,
436        &[Rewrite::GeminiCachedContent(
437            cached_content.map(str::to_owned),
438        )],
439    )
440}
441
442/// The wire's own encoding of `request`: contents, system instruction,
443/// tools, tool choice and the typed fields in `generationConfig`.
444fn base(
445    request: &CompletionRequest,
446    target: &dyn ReplayTarget,
447    model: &str,
448    input: &mut BaseInput<'_>,
449) -> Result<Map<String, Value>, EncodeError> {
450    let extra_tools = input.raw_tools()?;
451    let schema = request
452        .output_schema
453        .clone()
454        .map(|schema| schema.to_value());
455    let mime = schema.as_ref().map(|_| json!("application/json"));
456    let (temperature, max_tokens) = (
457        request.temperature.map(Value::from),
458        request.max_tokens.map(Value::from),
459    );
460    let config = set_fields([
461        ("responseMimeType", mime),
462        ("responseJsonSchema", schema),
463        ("temperature", temperature),
464        ("maxOutputTokens", max_tokens),
465    ]);
466    let (mut system, mut history) = (Vec::new(), Vec::new());
467    for message in &request.chat_history {
468        match &**message {
469            Message::System { content } if content.is_empty() => {}
470            Message::System { content } => system.push(text_part(content)),
471            message => history.push(message),
472        }
473    }
474    // Gemini rejects a field set twice, and merges a tool choice set twice,
475    // which unions the allowed functions.
476    let preamble = "system instruction twice: as a preamble or system message";
477    let choice = "tool choice twice: as `tool_choice`, which Gemini merges,";
478    let twice = [
479        (!system.is_empty(), &SYSTEM_INSTRUCTION, preamble),
480        (request.tool_choice.is_some(), &TOOL_CONFIG, choice),
481    ];
482    for (set, spellings, what) in twice {
483        let raw = spellings
484            .iter()
485            .find(|spelling| input.param(spelling).is_some_and(|value| !value.is_null()));
486        if let (true, Some(spelling)) = (set, raw) {
487            return Err(EncodeError::request(format!(
488                "a Gemini request set the {what} and as `additional_params.{spelling}`. Set it \
489                 one way or the other"
490            )));
491        }
492    }
493    let declared: Vec<Value> = request.tools.iter().map(|tool| declaration(tool)).collect();
494    let declared = (!declared.is_empty()).then(|| {
495        Value::Object(set_fields([
496            ("functionDeclarations", Some(Value::Array(declared))),
497            ("codeExecution", Some(Value::Null)),
498        ]))
499    });
500    let tools: Vec<Value> = declared.into_iter().chain(extra_tools).collect();
501    let contents = Value::Array(contents(&history, target, model)?);
502    let system = (!system.is_empty()).then(|| content(system, "model"));
503    let tool_config = request
504        .tool_choice
505        .clone()
506        .map_or(Value::Null, calling_config);
507    Ok(set_fields([
508        ("contents", Some(contents)),
509        (
510            "generationConfig",
511            Some(if config.is_empty() {
512                Value::Null
513            } else {
514                Value::Object(config)
515            }),
516        ),
517        ("safetySettings", Some(Value::Null)),
518        ("toolConfig", Some(tool_config)),
519        ("systemInstruction", Some(system.unwrap_or(Value::Null))),
520        ("tools", (!tools.is_empty()).then_some(Value::Array(tools))),
521    ]))
522}
523
524/// A tool as its function declaration: its JSON Schema goes as
525/// `parametersJsonSchema`, which reads full JSON Schema, as pi sends it. A
526/// tool with no schema declares none.
527fn declaration(tool: &crate::completion::ToolDefinition) -> Value {
528    let parameters = (!tool.parameters.is_null()).then(|| tool.parameters.clone());
529    Value::Object(set_fields([
530        ("name", Some(Value::from(tool.name.as_str()))),
531        ("description", Some(Value::from(tool.description.as_str()))),
532        ("parametersJsonSchema", parameters),
533    ]))
534}
535
536/// Proto3 JSON accepts both lowerCamelCase and original proto field names.
537pub(super) const SYSTEM_INSTRUCTION: [&str; 2] = ["systemInstruction", "system_instruction"];
538pub(super) const TOOL_CONFIG: [&str; 2] = ["toolConfig", "tool_config"];
539const CACHED_CONTENT: [&str; 2] = ["cachedContent", "cached_content"];
540
541/// The first of `spellings` set to a value other than `null` in `body`.
542pub(super) fn present<'a>(body: &Map<String, Value>, spellings: &[&'a str]) -> Option<&'a str> {
543    let set = |spelling: &&&str| body.get(**spelling).is_some_and(|value| !value.is_null());
544    spellings.iter().find(set).copied()
545}
546
547/// The `functionCallingConfig` of `choice`.
548fn calling_config(choice: ToolChoice) -> Value {
549    let (mode, names) = match choice {
550        ToolChoice::Auto => ("AUTO", None),
551        ToolChoice::None => ("NONE", None),
552        ToolChoice::Required => ("ANY", None),
553        ToolChoice::Specific { function_names } => ("ANY", Some(json!(function_names))),
554    };
555    let config = set_fields([
556        ("mode", Some(json!(mode))),
557        ("allowed_function_names", names),
558    ]);
559    json!({ "functionCallingConfig": config })
560}
561
562/// The Gemini contents for `history`, a history without system messages,
563/// sent to `model` on `target`. A user message's function responses and
564/// its other parts go in contents of their own, in order, as pi sends
565/// them: Gemini answers text that shares a content with function responses
566/// poorly. Calls and their responses carry the ids [`WireIds`] spells when
567/// `model` takes ids.
568fn contents(
569    history: &[&Message],
570    target: &dyn ReplayTarget,
571    model: &str,
572) -> Result<Vec<Value>, EncodeError> {
573    let ids = WireIds::for_target(history, target, model);
574    let (with_ids, gemini3) = (requires_tool_call_id(model), gemini_3_or_later(model));
575    let mut contents = Vec::with_capacity(history.len());
576    let mut push = |role: &str, parts: Vec<Value>| {
577        // Gemini rejects a content with no parts, as pi skips one.
578        if !parts.is_empty() {
579            contents.push(content(parts, role));
580        }
581    };
582    for message in history {
583        match message {
584            Message::System { content } => push("user", vec![text_part(content)]),
585            Message::User { content } => {
586                let mut run = Vec::new();
587                let mut responses = false;
588                for part in content {
589                    let id = match part {
590                        UserContent::ToolResult(result) => {
591                            Some(ids.of(&result.call).filter(|_| with_ids))
592                        }
593                        _ => None,
594                    };
595                    if id.is_some() != responses {
596                        push("user", std::mem::take(&mut run));
597                    }
598                    responses = id.is_some();
599                    run.push(user_part(part, id.flatten())?);
600                }
601                push("user", run);
602            }
603            Message::Assistant(turn) => {
604                let parts = turn
605                    .content
606                    .iter()
607                    .map(|block| assistant_part(block, target, &ids, with_ids, gemini3));
608                push(
609                    "model",
610                    parts
611                        .collect::<Result<Vec<_>, _>>()?
612                        .into_iter()
613                        .flatten()
614                        .collect(),
615                );
616            }
617        }
618    }
619    Ok(contents)
620}
621
622/// A content of `parts` in `role`.
623fn content(parts: Vec<Value>, role: &str) -> Value {
624    Value::Object(set_fields([
625        ("parts", Some(Value::Array(parts))),
626        ("role", Some(Value::from(role))),
627    ]))
628}
629
630fn text_part(text: &str) -> Value {
631    json!({ "text": text, "thought": false })
632}
633
634pub(super) fn mime<M: MimeType>(media_type: Option<M>) -> Option<String> {
635    media_type.map(|media_type| media_type.to_mime_type().to_owned())
636}
637
638/// Where `source` puts media on every Gemini wire, REST and Interactions
639/// alike: a URL by reference (`true`) and base64 data inline. Each wire's
640/// `encodes` refuses every other form, so the adapter passes none.
641pub(super) fn carried(source: &Source) -> Result<(bool, &str), EncodeError> {
642    match source {
643        Source::Url(uri) => Ok((true, uri)),
644        Source::Base64(data) => Ok((false, data)),
645        _ => Err(EncodeError::request(
646            "Gemini cannot receive this media in its form",
647        )),
648    }
649}
650
651/// `source` of `mime_type` as GenerateContent part data, file data by URI
652/// or inline data, which needs a media type; a whole `part` is marked as no
653/// thought.
654pub(super) fn media(
655    mime_type: Option<String>,
656    source: &Source,
657    part: bool,
658) -> Result<Value, EncodeError> {
659    let (uri, data) = carried(source)?;
660    let data = match (uri, mime_type) {
661        (true, mime) => ("fileData", json!({ "mimeType": mime, "fileUri": data })),
662        (false, Some(mime)) => ("inlineData", json!({ "mimeType": mime, "data": data })),
663        (false, None) => {
664            return Err(EncodeError::request(
665                "Gemini cannot receive media without its type",
666            ));
667        }
668    };
669    Ok(Value::Object(set_fields([
670        (data.0, Some(data.1)),
671        ("thought", part.then_some(Value::Bool(false))),
672    ])))
673}
674
675/// A user part as Gemini takes it. A function response carries `id`, its
676/// call's wire spelling, when the model takes ids, and the error key when
677/// the tool failed, as the Gemini SDKs spell a function's failure. Images
678/// the adapter leaves in a result (a model that reads them) go in its
679/// `parts`, in order.
680fn user_part(part: &UserContent, id: Option<&str>) -> Result<Value, EncodeError> {
681    Ok(match part {
682        UserContent::Text(text) => text_part(&text.text),
683        UserContent::ToolResult(result) => {
684            let (mut values, mut parts) = (Vec::new(), Vec::new());
685            for item in &result.content {
686                match item {
687                    ToolResultContent::Text(text) => values.push(Value::from(text.text.as_str())),
688                    ToolResultContent::Json { value } => values.push(value.clone()),
689                    ToolResultContent::Image(image) => {
690                        parts.push(media(mime(image.media_type.clone()), &image.data, false)?);
691                    }
692                }
693            }
694            let key = if result.is_error { "error" } else { "result" };
695            let value = match values.len() {
696                0 => None,
697                1 => values.pop(),
698                _ => Some(Value::Array(values)),
699            };
700            let response = set_fields([
701                ("name", Some(json!(result.name))),
702                ("id", id.map(Value::from)),
703                (
704                    "response",
705                    value.map(|value| Value::Object(Map::from_iter([(key.into(), value)]))),
706                ),
707                ("parts", (!parts.is_empty()).then_some(Value::Array(parts))),
708            ]);
709            Value::Object(set_fields([
710                ("functionResponse", Some(Value::Object(response))),
711                ("thought", Some(Value::Bool(false))),
712            ]))
713        }
714        UserContent::Image(image) => media(mime(image.media_type.clone()), &image.data, true)?,
715        // A text document goes as text, so that RAG context reads as prose.
716        UserContent::Document(document) => match &document.data {
717            DocumentData::Text(text) => text_part(text),
718            DocumentData::File(data) => media(mime(document.media_type.clone()), data, true)?,
719        },
720        UserContent::Audio(audio) => media(mime(audio.media_type.clone()), &audio.data, true)?,
721        UserContent::Video(video) => {
722            let mut part = media(mime(video.media_type.clone()), &video.data, true)?;
723            if let (Some(Value::Object(extra)), Some(part)) =
724                (&video.additional_params, part.as_object_mut())
725            {
726                part.extend(extra.clone());
727            }
728            part
729        }
730    })
731}
732
733/// One assistant block as a Gemini part: its provider item while that is
734/// current, else a part rebuilt from its canonical fields, or `None` when
735/// it has nothing to send. Calls carry ids only `with_ids`. A rebuilt call
736/// on Gemini 3 (`gemini3`) carries Google's placeholder signature, since
737/// Gemini 3 rejects a call it did not sign without one ("Function call is
738/// missing a thought_signature in functionCall parts").
739fn assistant_part(
740    block: &AssistantContent,
741    target: &dyn ReplayTarget,
742    ids: &WireIds,
743    with_ids: bool,
744    gemini3: bool,
745) -> Result<Option<Value>, EncodeError> {
746    if let Replay::Item(item) = block.replay(target, ids) {
747        let mut item = item.into_owned();
748        if let Some(part) = item.as_object_mut() {
749            if let (false, Some(Value::Object(call))) = (with_ids, part.get_mut("functionCall")) {
750                call.shift_remove("id");
751            }
752            // Gemini rejects the whole request over a signature that is
753            // not base64 ("Base64 decoding failed").
754            if part
755                .get("thoughtSignature")
756                .and_then(Value::as_str)
757                .is_some_and(|signature| !is_base64(signature))
758            {
759                part.shift_remove("thoughtSignature");
760            }
761        }
762        return Ok(Some(item));
763    }
764    Ok(Some(match block {
765        AssistantContent::Text(text) => json!({ "text": text.text }),
766        AssistantContent::Reasoning(reasoning)
767            if reasoning.redacted || reasoning.text.trim().is_empty() =>
768        {
769            return Ok(None);
770        }
771        AssistantContent::Reasoning(reasoning) => {
772            json!({ "thought": true, "text": reasoning.text })
773        }
774        AssistantContent::ToolCall(call) => {
775            let id = ids.of(&call.id).filter(|_| with_ids).map(Value::from);
776            let name = Some(json!(call.function.name));
777            let args = Some(json!(call.function.arguments));
778            let signature = gemini3.then(|| json!("skip_thought_signature_validator"));
779            let call = Value::Object(set_fields([("name", name), ("args", args), ("id", id)]));
780            Value::Object(set_fields([
781                ("functionCall", Some(call)),
782                ("thoughtSignature", signature),
783            ]))
784        }
785        AssistantContent::Image(image) => media(mime(image.media_type.clone()), &image.data, true)?,
786        AssistantContent::Opaque(opaque) => opaque.item.clone(),
787    }))
788}
789
790/// Whether `text` is padded standard base64.
791fn is_base64(text: &str) -> bool {
792    let body = text.trim_end_matches('=');
793    let alphabet = |byte: u8| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/');
794    !body.is_empty()
795        && text.len().is_multiple_of(4)
796        && text.len() - body.len() <= 2
797        && body.bytes().all(alphabet)
798}
799
800/// Convert a specified prompt block reason into a provider error with its
801/// safety ratings. `feedback` is the reply's `promptFeedback` JSON, read
802/// leniently. Content refusals are final; `OTHER` and unknown reasons are
803/// transient. The zero value names no block: Gemini spells it
804/// `BLOCK_REASON_`, Vertex AI `BLOCKED_REASON_`.
805pub(crate) fn blocked_prompt_error(feedback: &Value) -> Option<ProviderError> {
806    let reason = match feedback.get("blockReason")? {
807        Value::String(reason) => reason.clone(),
808        Value::Number(number) => format!("BLOCK_REASON_{number}"),
809        _ => return None,
810    };
811    if matches!(
812        reason.as_str(),
813        "BLOCK_REASON_UNSPECIFIED" | "BLOCKED_REASON_UNSPECIFIED"
814    ) {
815        return None;
816    }
817    let spelled = |value: Option<&Value>| match value {
818        Some(Value::String(name)) => name.clone(),
819        Some(other) => other.to_string(),
820        None => "<unset>".to_owned(),
821    };
822    let ratings = feedback.arr("safetyRatings").iter();
823    let ratings = ratings.map(|rating| {
824        format!(
825            "{}={}",
826            spelled(rating.get("category")),
827            spelled(rating.get("probability"))
828        )
829    });
830    let ratings: Vec<String> = ratings.collect();
831    let ratings = match ratings.is_empty() {
832        true => String::new(),
833        false => format!(", safety_ratings=[{}]", ratings.join(", ")),
834    };
835    let message = format!("Gemini blocked the prompt: block_reason={reason}{ratings}");
836    let error = crate::provider_response::ProviderResponseError::without_status(message)
837        .with_code(Some(reason.clone()));
838    Some(ProviderError::ProviderResponse(match reason.as_str() {
839        "SAFETY" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "IMAGE_SAFETY" | "MODEL_ARMOR"
840        | "JAILBREAK" => error.with_refusal(true),
841        _ => error.with_transient(Some(true)),
842    }))
843}
844
845/// Map a Google `finishReason` in its SCREAMING_SNAKE_CASE wire spelling.
846/// `STOP` is a stop (a tool use when the turn holds calls) and `MAX_TOKENS`
847/// a length stop. Every other reason, documented or not, is a failure: the
848/// content filters as
849/// [`ContentFilter`](crate::completion::FinishReason::ContentFilter), the
850/// rest as [`Other`](crate::completion::FinishReason::Other) with the
851/// reason's name.
852pub fn map_google_finish_reason(wire_name: &str) -> crate::completion::FinishReason {
853    use crate::completion::FinishReason;
854    match wire_name {
855        "STOP" => FinishReason::Stop,
856        "MAX_TOKENS" => FinishReason::Length,
857        "SAFETY"
858        | "BLOCKLIST"
859        | "PROHIBITED_CONTENT"
860        | "SPII"
861        | "IMAGE_SAFETY"
862        | "IMAGE_PROHIBITED_CONTENT"
863        | "MODEL_ARMOR" => FinishReason::ContentFilter,
864        other => FinishReason::Other(other.to_owned()),
865    }
866}
867
868/// A whole `generateContent` reply, as the image and transcription wires
869/// read one.
870pub(crate) fn classify_reply(body: &str) -> crate::wire::WireEvent<Value> {
871    crate::providers::internal::wire::classify_marker_keyed_frame(
872        body,
873        &["candidates", "promptFeedback", "usageMetadata"],
874    )
875}
876
877/// Rig's usage for a `usageMetadata` document, read leniently: a count that
878/// is absent or not a non-negative integer is unreported, and no other field
879/// is read, so usage never fails a reply. Rig's input is the prompt plus the
880/// tool-use prompt, its output the candidates plus the thoughts, and its
881/// total their sum. A count Gemini leaves out is zero in those sums, as its
882/// JSON omits zeros. The REST, Vertex AI and gRPC wires all read usage here.
883pub fn usage_of(usage: &Value) -> crate::completion::Usage {
884    let count = |key: &str| usage.get(key).and_then(Value::as_u64);
885    let tool_use = count("toolUsePromptTokenCount");
886    let thoughts = count("thoughtsTokenCount");
887    let input = count("promptTokenCount")
888        .unwrap_or(0)
889        .saturating_add(tool_use.unwrap_or(0));
890    let output = count("candidatesTokenCount")
891        .unwrap_or(0)
892        .saturating_add(thoughts.unwrap_or(0));
893    crate::completion::Usage {
894        input_tokens: Some(input),
895        output_tokens: Some(output),
896        cached_input_tokens: count("cachedContentTokenCount"),
897        reasoning_tokens: thoughts,
898        tool_use_prompt_tokens: tool_use,
899        total_tokens: Some(input.saturating_add(output)),
900        cache_creation_input_tokens: None,
901        cost: None,
902    }
903}
904
905#[cfg(test)]
906mod tests;
907
908#[cfg(test)]
909mod cached_content_conflict_matrix;
910#[cfg(test)]
911mod cached_content_request_tests;