1pub const GEMINI_2_5_FLASH: &str = "gemini-2.5-flash";
7pub const GEMINI_2_0_FLASH_LITE: &str = "gemini-2.0-flash-lite";
9pub 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#[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
84pub(crate) fn rpc_error(status: tonic::Status) -> CompletionError {
92 CompletionError::from_provider_body(status.to_string())
93}
94
95pub(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 for msg in chat_history {
119 contents.push(rig_message_to_grpc_content(msg)?);
120 }
121
122 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 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 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
198fn 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
245fn 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
355fn 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
394impl 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
507impl 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 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
677fn 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 #[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 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 #[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 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}