Skip to main content

rig_gemini_grpc/
completion.rs

1// ================================================================
2//! Google Gemini gRPC Completion Integration
3// ================================================================
4
5/// `gemini-2.5-flash` completion model
6pub const GEMINI_2_5_FLASH: &str = "gemini-2.5-flash";
7/// `gemini-2.0-flash-lite` completion model
8pub const GEMINI_2_0_FLASH_LITE: &str = "gemini-2.0-flash-lite";
9/// `gemini-2.0-flash` completion model
10pub const GEMINI_2_0_FLASH: &str = "gemini-2.0-flash";
11
12use base64::Engine as _;
13use rig_core::OneOrMany;
14use rig_core::completion::{self, CompletionError, CompletionRequest};
15use rig_core::message::{self, MimeType, Reasoning};
16use rig_core::providers::gemini::completion::gemini_api_types::{
17    Schema as GeminiSchema, tool_parameters_to_schema,
18};
19use rig_core::telemetry::ProviderResponseExt;
20use std::convert::TryFrom;
21
22use super::Client;
23use super::proto::{self, GenerateContentRequest, GenerateContentResponse};
24
25// =================================================================
26// Rig Implementation Types
27// =================================================================
28
29#[derive(Clone, Debug)]
30pub struct CompletionModel {
31    pub(crate) client: Client,
32    pub model: String,
33}
34
35impl CompletionModel {
36    pub fn new(client: Client, model: impl Into<String>) -> Self {
37        Self {
38            client,
39            model: model.into(),
40        }
41    }
42}
43
44impl completion::CompletionModel for CompletionModel {
45    type Response = GenerateContentResponse;
46    type StreamingResponse = super::streaming::StreamingCompletionResponse;
47    type Client = super::Client;
48
49    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
50        Self::new(client.clone(), model)
51    }
52
53    async fn completion(
54        &self,
55        completion_request: CompletionRequest,
56    ) -> Result<completion::CompletionResponse<GenerateContentResponse>, CompletionError> {
57        let request = create_grpc_request(self.model.clone(), completion_request)?;
58
59        let mut grpc_client = self
60            .client
61            .grpc_client()
62            .map_err(|e| CompletionError::ProviderError(e.to_string()))?;
63
64        let response = grpc_client
65            .generate_content(request)
66            .await
67            .map_err(rpc_error)?
68            .into_inner();
69
70        response.try_into()
71    }
72
73    async fn stream(
74        &self,
75        request: CompletionRequest,
76    ) -> Result<
77        rig_core::streaming::StreamingCompletionResponse<Self::StreamingResponse>,
78        CompletionError,
79    > {
80        super::streaming::stream(self.client.clone(), self.model.clone(), request).await
81    }
82}
83
84// Map a failed gRPC call into a `CompletionError` that preserves the provider's
85// error payload verbatim. gRPC is a non-HTTP transport, so there is no
86// `http::StatusCode`; the body is preserved via `from_provider_body` (status:
87// None) rather than a Rig-prefixed `ProviderError` diagnostic. Note: tonic does
88// not distinguish a server-returned gRPC error from a transport/connection
89// failure, so a pure connection error is also preserved here rather than gated
90// out as a Rig diagnostic the way Bedrock's typed service errors are.
91pub(crate) fn rpc_error(status: tonic::Status) -> CompletionError {
92    CompletionError::from_provider_body(status.to_string())
93}
94
95// Helper function to create gRPC request from Rig's CompletionRequest
96pub(crate) fn create_grpc_request(
97    model: String,
98    completion_request: CompletionRequest,
99) -> Result<GenerateContentRequest, CompletionError> {
100    let CompletionRequest {
101        model: _,
102        preamble,
103        chat_history,
104        documents: _,
105        tools,
106        temperature,
107        max_tokens,
108        tool_choice: _,
109        additional_params: _,
110        output_schema: _,
111        record_telemetry_content: _,
112    } = completion_request;
113
114    let (history_system, chat_history) = split_system_messages_from_history(chat_history);
115    let mut contents = Vec::new();
116
117    // Convert chat history to gRPC Content messages
118    for msg in chat_history {
119        contents.push(rig_message_to_grpc_content(msg)?);
120    }
121
122    // Handle system instruction (preamble)
123    let mut system_parts = Vec::new();
124    if let Some(preamble) = preamble
125        && !preamble.is_empty()
126    {
127        system_parts.push(proto::Part {
128            data: Some(proto::part::Data::Text(preamble)),
129            thought: false,
130            thought_signature: Vec::new(),
131            part_metadata: None,
132        });
133    }
134    for content in history_system {
135        if !content.is_empty() {
136            system_parts.push(proto::Part {
137                data: Some(proto::part::Data::Text(content)),
138                thought: false,
139                thought_signature: Vec::new(),
140                part_metadata: None,
141            });
142        }
143    }
144    let system_instruction = if system_parts.is_empty() {
145        None
146    } else {
147        Some(proto::Content {
148            parts: system_parts,
149            role: "model".to_string(),
150        })
151    };
152
153    // Handle generation config
154    let generation_config = if temperature.is_some() || max_tokens.is_some() {
155        Some(proto::GenerationConfig {
156            temperature: temperature.map(|t| t as f32),
157            max_output_tokens: max_tokens.map(|t| t as i32),
158            ..Default::default()
159        })
160    } else {
161        None
162    };
163
164    // Handle tools (functions)
165    let tools = if !tools.is_empty() {
166        let function_declarations = tools
167            .into_iter()
168            .map(|tool| {
169                Ok(proto::FunctionDeclaration {
170                    name: tool.name,
171                    description: tool.description,
172                    parameters: tool_parameters_to_proto_schema(&tool.parameters)?,
173                    ..Default::default()
174                })
175            })
176            .collect::<Result<Vec<_>, CompletionError>>()?;
177
178        vec![proto::Tool {
179            function_declarations,
180            code_execution: None,
181        }]
182    } else {
183        vec![]
184    };
185
186    Ok(GenerateContentRequest {
187        model: format!("models/{}", model),
188        contents,
189        tools,
190        safety_settings: vec![],
191        generation_config,
192        tool_config: None,
193        system_instruction,
194        cached_content: String::new(),
195    })
196}
197
198// Convert Rig message to gRPC Content
199fn rig_message_to_grpc_content(msg: message::Message) -> Result<proto::Content, CompletionError> {
200    match msg {
201        message::Message::System { .. } => Err(CompletionError::RequestError(
202            "System messages must be sent via Gemini gRPC system_instruction".into(),
203        )),
204        message::Message::User { content } => {
205            let parts = content
206                .into_iter()
207                .map(rig_user_content_to_grpc_part)
208                .collect::<Result<Vec<_>, _>>()?;
209
210            Ok(proto::Content {
211                parts,
212                role: "user".to_string(),
213            })
214        }
215        message::Message::Assistant { content, .. } => {
216            let parts = content
217                .into_iter()
218                .map(rig_assistant_content_to_grpc_part)
219                .collect::<Result<Vec<_>, _>>()?;
220
221            Ok(proto::Content {
222                parts,
223                role: "model".to_string(),
224            })
225        }
226    }
227}
228
229fn split_system_messages_from_history(
230    history: OneOrMany<message::Message>,
231) -> (Vec<String>, Vec<message::Message>) {
232    let mut system = Vec::new();
233    let mut remaining = Vec::new();
234
235    for message in history {
236        match message {
237            message::Message::System { content } => system.push(content),
238            other => remaining.push(other),
239        }
240    }
241
242    (system, remaining)
243}
244
245// Convert Rig UserContent to gRPC Part
246fn rig_user_content_to_grpc_part(
247    content: message::UserContent,
248) -> Result<proto::Part, CompletionError> {
249    match content {
250        message::UserContent::Text(message::Text { text, .. }) => Ok(proto::Part {
251            data: Some(proto::part::Data::Text(text)),
252            thought: false,
253            thought_signature: Vec::new(),
254            part_metadata: None,
255        }),
256        message::UserContent::ToolResult(result) => {
257            let mut values = result
258                .content
259                .into_iter()
260                .map(|content| match content {
261                    message::ToolResultContent::Text(t) => Ok(serde_json::Value::String(t.text)),
262                    message::ToolResultContent::Json { value } => Ok(value),
263                    message::ToolResultContent::Image(_) => Err(CompletionError::RequestError(
264                        "Gemini gRPC does not support images in tool results".into(),
265                    )),
266                })
267                .collect::<Result<Vec<_>, _>>()?;
268            let result_value = if values.len() == 1 {
269                values.remove(0)
270            } else {
271                serde_json::Value::Array(values)
272            };
273
274            let response_struct =
275                json_to_prost_struct(serde_json::json!({ "result": result_value }))?;
276
277            Ok(proto::Part {
278                data: Some(proto::part::Data::FunctionResponse(
279                    proto::FunctionResponse {
280                        name: result.id,
281                        response: Some(response_struct),
282                        id: result.call_id.unwrap_or_default(),
283                    },
284                )),
285                thought: false,
286                thought_signature: Vec::new(),
287                part_metadata: None,
288            })
289        }
290        message::UserContent::Image(img) => {
291            let Some(media_type) = img.media_type else {
292                return Err(CompletionError::RequestError(
293                    "Media type for image is required for Gemini".into(),
294                ));
295            };
296
297            match media_type {
298                message::ImageMediaType::JPEG
299                | message::ImageMediaType::PNG
300                | message::ImageMediaType::WEBP
301                | message::ImageMediaType::HEIC
302                | message::ImageMediaType::HEIF => {}
303                _ => {
304                    return Err(CompletionError::RequestError(
305                        format!("Unsupported image media type {media_type:?}").into(),
306                    ));
307                }
308            }
309
310            let mime_type = media_type.to_mime_type().to_string();
311
312            let data = match img.data {
313                message::DocumentSourceKind::Url(file_uri) => {
314                    return Ok(proto::Part {
315                        data: Some(proto::part::Data::FileData(proto::FileData {
316                            mime_type,
317                            file_uri,
318                        })),
319                        thought: false,
320                        thought_signature: Vec::new(),
321                        part_metadata: None,
322                    });
323                }
324                message::DocumentSourceKind::Raw(bytes) => bytes,
325                message::DocumentSourceKind::Base64(data)
326                | message::DocumentSourceKind::String(data) => decode_base64_bytes(&data)?,
327                message::DocumentSourceKind::Unknown => {
328                    return Err(CompletionError::RequestError(
329                        "Image content has no body".into(),
330                    ));
331                }
332                _ => {
333                    return Err(CompletionError::RequestError(
334                        "Unsupported document source kind".into(),
335                    ));
336                }
337            };
338
339            Ok(proto::Part {
340                data: Some(proto::part::Data::InlineData(proto::Blob {
341                    mime_type,
342                    data,
343                })),
344                thought: false,
345                thought_signature: Vec::new(),
346                part_metadata: None,
347            })
348        }
349        _ => Err(CompletionError::RequestError(
350            "Unsupported user content type".into(),
351        )),
352    }
353}
354
355// Convert Rig AssistantContent to gRPC Part
356fn rig_assistant_content_to_grpc_part(
357    content: message::AssistantContent,
358) -> Result<proto::Part, CompletionError> {
359    match content {
360        message::AssistantContent::Text(message::Text { text, .. }) => Ok(proto::Part {
361            data: Some(proto::part::Data::Text(text)),
362            thought: false,
363            thought_signature: Vec::new(),
364            part_metadata: None,
365        }),
366        message::AssistantContent::ToolCall(tool_call) => {
367            let args = json_to_prost_struct(tool_call.function.arguments)?;
368
369            Ok(proto::Part {
370                data: Some(proto::part::Data::FunctionCall(proto::FunctionCall {
371                    name: tool_call.function.name,
372                    args: Some(args),
373                    id: tool_call.call_id.unwrap_or(tool_call.id),
374                })),
375                thought: false,
376                thought_signature: decode_optional_base64(tool_call.signature)?,
377                part_metadata: None,
378            })
379        }
380        message::AssistantContent::Reasoning(reasoning) => Ok(proto::Part {
381            data: Some(proto::part::Data::Text(reasoning.display_text())),
382            thought: true,
383            thought_signature: decode_optional_base64(
384                reasoning.first_signature().map(|s| s.to_string()),
385            )?,
386            part_metadata: None,
387        }),
388        _ => Err(CompletionError::RequestError(
389            "Unsupported assistant content type".into(),
390        )),
391    }
392}
393
394// Convert gRPC GenerateContentResponse to Rig CompletionResponse
395impl TryFrom<GenerateContentResponse> for completion::CompletionResponse<GenerateContentResponse> {
396    type Error = CompletionError;
397
398    fn try_from(response: GenerateContentResponse) -> Result<Self, Self::Error> {
399        let candidate = response.candidates.first().ok_or_else(|| {
400            CompletionError::ResponseError("No response candidates in response".into())
401        })?;
402
403        let content_ref = candidate.content.as_ref().ok_or_else(|| {
404            CompletionError::ResponseError(format!(
405                "Gemini candidate missing content (finish_reason={})",
406                candidate.finish_reason
407            ))
408        })?;
409
410        let mut assistant_contents = Vec::new();
411
412        for part in &content_ref.parts {
413            let assistant_content = match &part.data {
414                Some(proto::part::Data::Text(text)) => {
415                    if part.thought {
416                        completion::AssistantContent::Reasoning(Reasoning::new_with_signature(
417                            text,
418                            encode_optional_base64(&part.thought_signature),
419                        ))
420                    } else {
421                        completion::AssistantContent::text(text)
422                    }
423                }
424                Some(proto::part::Data::InlineData(inline_data)) => {
425                    let mime_type = message::MediaType::from_mime_type(&inline_data.mime_type);
426                    match mime_type {
427                        Some(message::MediaType::Image(media_type)) => {
428                            let b64 =
429                                base64::engine::general_purpose::STANDARD.encode(&inline_data.data);
430                            completion::AssistantContent::image_base64(
431                                b64,
432                                Some(media_type),
433                                Some(message::ImageDetail::default()),
434                            )
435                        }
436                        _ => {
437                            return Err(CompletionError::ResponseError(format!(
438                                "Unsupported media type {mime_type:?}"
439                            )));
440                        }
441                    }
442                }
443                Some(proto::part::Data::FunctionCall(function_call)) => {
444                    let args = function_call
445                        .args
446                        .as_ref()
447                        .map(prost_struct_to_json)
448                        .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
449
450                    let mut tool_call = message::ToolCall::new(
451                        if function_call.id.is_empty() {
452                            function_call.name.clone()
453                        } else {
454                            function_call.id.clone()
455                        },
456                        message::ToolFunction::new(function_call.name.clone(), args),
457                    );
458
459                    if !function_call.id.is_empty() {
460                        tool_call = tool_call.with_call_id(function_call.id.clone());
461                    }
462
463                    tool_call =
464                        tool_call.with_signature(encode_optional_base64(&part.thought_signature));
465
466                    completion::AssistantContent::ToolCall(tool_call)
467                }
468                _ => {
469                    return Err(CompletionError::ResponseError(
470                        "Response did not contain a message or tool call".into(),
471                    ));
472                }
473            };
474
475            assistant_contents.push(assistant_content);
476        }
477
478        let choice = OneOrMany::many(assistant_contents).map_err(|_| {
479            CompletionError::ResponseError(
480                "Response contained no message or tool call (empty)".to_owned(),
481            )
482        })?;
483
484        let usage = response
485            .usage_metadata
486            .as_ref()
487            .map(|usage| completion::Usage {
488                input_tokens: usage.prompt_token_count as u64,
489                output_tokens: usage.candidates_token_count as u64,
490                total_tokens: usage.total_token_count as u64,
491                cached_input_tokens: usage.cached_content_token_count as u64,
492                cache_creation_input_tokens: 0,
493                tool_use_prompt_tokens: 0,
494                reasoning_tokens: 0,
495            })
496            .unwrap_or_default();
497
498        Ok(completion::CompletionResponse {
499            choice,
500            usage,
501            raw_response: response,
502            message_id: None,
503        })
504    }
505}
506
507// Implement ProviderResponseExt for telemetry
508impl ProviderResponseExt for GenerateContentResponse {
509    type OutputMessage = proto::Candidate;
510    type Usage = proto::UsageMetadata;
511
512    fn get_response_id(&self) -> Option<String> {
513        if self.response_id.is_empty() {
514            None
515        } else {
516            Some(self.response_id.clone())
517        }
518    }
519
520    fn get_response_model_name(&self) -> Option<String> {
521        if self.model_version.is_empty() {
522            None
523        } else {
524            Some(self.model_version.clone())
525        }
526    }
527
528    fn get_output_messages(&self) -> Vec<Self::OutputMessage> {
529        self.candidates.clone()
530    }
531
532    fn get_text_response(&self) -> Option<String> {
533        self.candidates.first().and_then(|c| {
534            c.content.as_ref().and_then(|content| {
535                let text: Vec<String> = content
536                    .parts
537                    .iter()
538                    .filter_map(|part| {
539                        if let Some(proto::part::Data::Text(text)) = &part.data {
540                            Some(text.clone())
541                        } else {
542                            None
543                        }
544                    })
545                    .collect();
546
547                if text.is_empty() {
548                    None
549                } else {
550                    Some(text.join("\n"))
551                }
552            })
553        })
554    }
555
556    fn get_usage(&self) -> Option<Self::Usage> {
557        self.usage_metadata
558    }
559}
560
561fn decode_base64_bytes(input: &str) -> Result<Vec<u8>, CompletionError> {
562    let data = input.trim();
563
564    // Allow `data:<mime>;base64,<data>` inputs.
565    let data = if let Some(rest) = data.strip_prefix("data:") {
566        rest.split_once(',').map(|(_, b64)| b64).unwrap_or(data)
567    } else {
568        data
569    };
570
571    let mut last_err: Option<String> = None;
572
573    for engine in [
574        &base64::engine::general_purpose::STANDARD,
575        &base64::engine::general_purpose::URL_SAFE,
576        &base64::engine::general_purpose::STANDARD_NO_PAD,
577        &base64::engine::general_purpose::URL_SAFE_NO_PAD,
578    ] {
579        match engine.decode(data) {
580            Ok(bytes) => return Ok(bytes),
581            Err(err) => last_err = Some(err.to_string()),
582        }
583    }
584
585    let err = last_err.unwrap_or_else(|| "unknown base64 decode error".to_string());
586    Err(CompletionError::RequestError(
587        format!("Invalid base64 data: {err}").into(),
588    ))
589}
590
591fn decode_optional_base64(sig: Option<String>) -> Result<Vec<u8>, CompletionError> {
592    let Some(sig) = sig else {
593        return Ok(Vec::new());
594    };
595    decode_base64_bytes(&sig)
596}
597
598fn encode_optional_base64(bytes: &[u8]) -> Option<String> {
599    if bytes.is_empty() {
600        None
601    } else {
602        Some(base64::engine::general_purpose::STANDARD.encode(bytes))
603    }
604}
605
606fn json_to_prost_struct(value: serde_json::Value) -> Result<proto::Struct, CompletionError> {
607    match value {
608        serde_json::Value::Object(map) => Ok(proto::Struct {
609            fields: map
610                .into_iter()
611                .map(|(k, v)| (k, json_to_prost_value(v)))
612                .collect(),
613        }),
614        _ => Err(CompletionError::RequestError(
615            "Expected a JSON object for google.protobuf.Struct".into(),
616        )),
617    }
618}
619
620fn json_to_prost_value(value: serde_json::Value) -> proto::Value {
621    match value {
622        serde_json::Value::Null => proto::Value {
623            kind: Some(proto::value::Kind::NullValue(
624                proto::NullValue::NullValue as i32,
625            )),
626        },
627        serde_json::Value::Bool(b) => proto::Value {
628            kind: Some(proto::value::Kind::BoolValue(b)),
629        },
630        serde_json::Value::Number(n) => proto::Value {
631            kind: Some(proto::value::Kind::NumberValue(
632                n.as_f64().unwrap_or_default(),
633            )),
634        },
635        serde_json::Value::String(s) => proto::Value {
636            kind: Some(proto::value::Kind::StringValue(s)),
637        },
638        serde_json::Value::Array(items) => proto::Value {
639            kind: Some(proto::value::Kind::ListValue(proto::ListValue {
640                values: items.into_iter().map(json_to_prost_value).collect(),
641            })),
642        },
643        serde_json::Value::Object(map) => proto::Value {
644            kind: Some(proto::value::Kind::StructValue(proto::Struct {
645                fields: map
646                    .into_iter()
647                    .map(|(k, v)| (k, json_to_prost_value(v)))
648                    .collect(),
649            })),
650        },
651    }
652}
653
654fn prost_struct_to_json(st: &proto::Struct) -> serde_json::Value {
655    let mut out = serde_json::Map::with_capacity(st.fields.len());
656    for (k, v) in &st.fields {
657        out.insert(k.clone(), prost_value_to_json(v));
658    }
659    serde_json::Value::Object(out)
660}
661
662fn prost_value_to_json(v: &proto::Value) -> serde_json::Value {
663    match &v.kind {
664        None | Some(proto::value::Kind::NullValue(_)) => serde_json::Value::Null,
665        Some(proto::value::Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
666        Some(proto::value::Kind::NumberValue(n)) => serde_json::Number::from_f64(*n)
667            .map(serde_json::Value::Number)
668            .unwrap_or(serde_json::Value::Null),
669        Some(proto::value::Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
670        Some(proto::value::Kind::StructValue(st)) => prost_struct_to_json(st),
671        Some(proto::value::Kind::ListValue(list)) => {
672            serde_json::Value::Array(list.values.iter().map(prost_value_to_json).collect())
673        }
674    }
675}
676
677// Convert the JSON Schema carried by `ToolDefinition.parameters` into the typed
678// `proto::Schema` expected by `FunctionDeclaration.parameters`.
679//
680// Without this, every tool was sent to Gemini with `parameters = None`, which
681// caused the model to invoke tools with no argument shape (issue #1710).
682//
683// An empty object schema (`{"type": "object", "properties": {}}`, the default
684// when a tool takes no arguments) is mapped to `None` rather than a vacuous
685// schema, matching the convention used by `rig-core::providers::gemini`.
686fn tool_parameters_to_proto_schema(
687    value: &serde_json::Value,
688) -> Result<Option<proto::Schema>, CompletionError> {
689    tool_parameters_to_schema(value.clone()).map(|schema| schema.map(gemini_schema_to_proto_schema))
690}
691
692fn gemini_schema_to_proto_schema(schema: GeminiSchema) -> proto::Schema {
693    proto::Schema {
694        r#type: json_type_to_proto_type(&schema.r#type) as i32,
695        format: schema.format.unwrap_or_default(),
696        description: schema.description.unwrap_or_default(),
697        nullable: schema.nullable.unwrap_or(false),
698        r#enum: schema.r#enum.unwrap_or_default(),
699        items: schema
700            .items
701            .map(|items| Box::new(gemini_schema_to_proto_schema(*items))),
702        properties: schema
703            .properties
704            .unwrap_or_default()
705            .into_iter()
706            .map(|(name, schema)| (name, gemini_schema_to_proto_schema(schema)))
707            .collect(),
708        required: schema.required.unwrap_or_default(),
709    }
710}
711
712fn json_type_to_proto_type(t: &str) -> proto::Type {
713    match t {
714        "string" => proto::Type::String,
715        "number" => proto::Type::Number,
716        "integer" => proto::Type::Integer,
717        "boolean" => proto::Type::Boolean,
718        "array" => proto::Type::Array,
719        "object" => proto::Type::Object,
720        "null" => proto::Type::Null,
721        _ => proto::Type::Unspecified,
722    }
723}
724
725#[cfg(test)]
726#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
727mod tests {
728    use super::*;
729
730    // ============================================================
731    // rpc_error — pins the from_provider_body usage on the RPC error path
732    // ============================================================
733
734    #[test]
735    fn rpc_error_preserves_status_text_without_http_status() {
736        let status = tonic::Status::unavailable("boom");
737        let expected = status.to_string();
738
739        let err = rpc_error(status);
740
741        // The raw provider error text is preserved verbatim, and there is no
742        // HTTP status because gRPC is a non-HTTP transport.
743        assert_eq!(err.provider_response_body(), Some(expected.as_str()));
744        assert_eq!(err.provider_response_status(), None);
745    }
746
747    #[test]
748    fn test_decode_base64_bytes_accepts_url_safe_with_padding() {
749        assert!(matches!(
750            decode_base64_bytes("_-wgVQA="),
751            Ok(bytes) if bytes == vec![0xFF, 0xEC, 0x20, 0x55, 0x00]
752        ));
753    }
754
755    #[test]
756    fn test_decode_base64_bytes_accepts_url_safe_no_pad() {
757        assert!(matches!(
758            decode_base64_bytes("_-wgVQA"),
759            Ok(bytes) if bytes == vec![0xFF, 0xEC, 0x20, 0x55, 0x00]
760        ));
761    }
762
763    #[test]
764    fn test_decode_base64_bytes_accepts_standard_no_pad() {
765        assert!(matches!(
766            decode_base64_bytes("Zg"),
767            Ok(bytes) if bytes == b"f".to_vec()
768        ));
769    }
770
771    #[test]
772    fn test_decode_base64_bytes_accepts_data_uri_prefix() {
773        assert!(matches!(
774            decode_base64_bytes("data:text/plain;base64,Zm9v"),
775            Ok(bytes) if bytes == b"foo".to_vec()
776        ));
777    }
778
779    // ============================================================
780    // tool_parameters_to_proto_schema — regression coverage for #1710
781    // ============================================================
782
783    #[test]
784    fn tool_params_empty_object_maps_to_none() {
785        let v = serde_json::json!({"type": "object", "properties": {}});
786        assert!(tool_parameters_to_proto_schema(&v).unwrap().is_none());
787    }
788
789    #[test]
790    fn tool_params_null_maps_to_none() {
791        assert!(
792            tool_parameters_to_proto_schema(&serde_json::Value::Null)
793                .unwrap()
794                .is_none()
795        );
796    }
797
798    #[test]
799    fn tool_params_object_with_scalar_properties_round_trips() {
800        let v = serde_json::json!({
801            "type": "object",
802            "properties": {
803                "city":      { "type": "string",  "description": "City name" },
804                "max_price": { "type": "integer", "description": "Cap, USD"  }
805            },
806            "required": ["city"]
807        });
808
809        let schema = tool_parameters_to_proto_schema(&v)
810            .expect("schema conversion")
811            .expect("schema");
812        assert_eq!(schema.r#type, proto::Type::Object as i32);
813        assert_eq!(schema.required, vec!["city".to_string()]);
814        assert_eq!(schema.properties.len(), 2);
815
816        let city = schema.properties.get("city").expect("city prop");
817        assert_eq!(city.r#type, proto::Type::String as i32);
818        assert_eq!(city.description, "City name");
819
820        let max_price = schema.properties.get("max_price").expect("max_price prop");
821        assert_eq!(max_price.r#type, proto::Type::Integer as i32);
822    }
823
824    #[test]
825    fn tool_params_array_with_typed_items() {
826        let v = serde_json::json!({
827            "type": "array",
828            "items": { "type": "string" }
829        });
830
831        let schema = tool_parameters_to_proto_schema(&v)
832            .expect("schema conversion")
833            .expect("schema");
834        assert_eq!(schema.r#type, proto::Type::Array as i32);
835        let items = schema.items.expect("items");
836        assert_eq!(items.r#type, proto::Type::String as i32);
837    }
838
839    #[test]
840    fn tool_params_enum_strings_preserved() {
841        let v = serde_json::json!({
842            "type": "string",
843            "enum": ["celsius", "fahrenheit"]
844        });
845
846        let schema = tool_parameters_to_proto_schema(&v)
847            .expect("schema conversion")
848            .expect("schema");
849        assert_eq!(schema.r#type, proto::Type::String as i32);
850        assert_eq!(
851            schema.r#enum,
852            vec!["celsius".to_string(), "fahrenheit".to_string()]
853        );
854    }
855
856    #[test]
857    fn tool_params_resolves_defs_ref_properties() {
858        let v = serde_json::json!({
859            "type": "object",
860            "properties": {
861                "destination": { "$ref": "#/$defs/Destination" }
862            },
863            "required": ["destination"],
864            "$defs": {
865                "Destination": {
866                    "type": "object",
867                    "properties": {
868                        "city": { "type": "string" },
869                        "country_code": { "type": "string" }
870                    },
871                    "required": ["city"]
872                }
873            }
874        });
875
876        let schema = tool_parameters_to_proto_schema(&v)
877            .expect("schema conversion")
878            .expect("schema");
879        let destination = schema
880            .properties
881            .get("destination")
882            .expect("destination prop");
883
884        assert_eq!(destination.r#type, proto::Type::Object as i32);
885        assert_eq!(destination.required, vec!["city".to_string()]);
886        assert_eq!(
887            destination
888                .properties
889                .get("city")
890                .expect("city prop")
891                .r#type,
892            proto::Type::String as i32
893        );
894    }
895
896    #[test]
897    fn tool_params_nullable_type_array_preserves_non_null_type() {
898        let v = serde_json::json!({
899            "type": "object",
900            "properties": {
901                "nickname": { "type": ["null", "string"] }
902            }
903        });
904
905        let schema = tool_parameters_to_proto_schema(&v)
906            .expect("schema conversion")
907            .expect("schema");
908        let nickname = schema.properties.get("nickname").expect("nickname prop");
909
910        assert_eq!(nickname.r#type, proto::Type::String as i32);
911        assert!(nickname.nullable);
912    }
913
914    #[test]
915    fn tool_params_any_of_uses_non_null_schema() {
916        let v = serde_json::json!({
917            "anyOf": [
918                { "type": "null" },
919                {
920                    "type": "object",
921                    "properties": {
922                        "query": { "type": "string" }
923                    },
924                    "required": ["query"]
925                }
926            ]
927        });
928
929        let schema = tool_parameters_to_proto_schema(&v)
930            .expect("schema conversion")
931            .expect("schema");
932
933        assert_eq!(schema.r#type, proto::Type::Object as i32);
934        assert!(schema.nullable);
935        assert_eq!(schema.required, vec!["query".to_string()]);
936        assert_eq!(
937            schema.properties.get("query").expect("query prop").r#type,
938            proto::Type::String as i32
939        );
940    }
941
942    #[test]
943    fn tool_params_array_without_items_defaults_to_string_items() {
944        let v = serde_json::json!({ "type": "array" });
945
946        let schema = tool_parameters_to_proto_schema(&v)
947            .expect("schema conversion")
948            .expect("schema");
949
950        assert_eq!(schema.r#type, proto::Type::Array as i32);
951        assert_eq!(
952            schema.items.expect("items").r#type,
953            proto::Type::String as i32
954        );
955    }
956
957    #[test]
958    fn create_grpc_request_populates_tool_parameters() {
959        use rig_core::completion::ToolDefinition;
960
961        let tool = ToolDefinition {
962            name: "get_weather".to_string(),
963            description: "Look up the current weather for a city.".to_string(),
964            parameters: serde_json::json!({
965                "type": "object",
966                "properties": {
967                    "city": { "type": "string", "description": "City name" }
968                },
969                "required": ["city"]
970            }),
971        };
972
973        let req = create_grpc_request(
974            "gemini-2.5-flash".to_string(),
975            CompletionRequest {
976                model: None,
977                preamble: None,
978                chat_history: OneOrMany::one(message::Message::user("forecast in Berlin?")),
979                documents: Vec::new(),
980                tools: vec![tool],
981                temperature: None,
982                max_tokens: None,
983                tool_choice: None,
984                additional_params: None,
985                output_schema: None,
986                record_telemetry_content: false,
987            },
988        )
989        .expect("request build");
990
991        assert_eq!(req.tools.len(), 1);
992        let tool = req.tools.first().expect("tool entry");
993        let decl = tool
994            .function_declarations
995            .first()
996            .expect("function declaration");
997        assert_eq!(decl.name, "get_weather");
998
999        // The regression in #1710 was `parameters: None` here.
1000        let params = decl.parameters.as_ref().expect("parameters populated");
1001        assert_eq!(params.r#type, proto::Type::Object as i32);
1002        assert_eq!(params.required, vec!["city".to_string()]);
1003        assert!(params.properties.contains_key("city"));
1004    }
1005}