1#![allow(clippy::cast_possible_truncation)]
24
25use bytes::Bytes;
26use futures::StreamExt;
27use serde_json::{Map, Value, json};
28
29use crate::codecs::codec::{BoxByteStream, BoxDeltaStream, Codec, EncodedRequest};
30use crate::error::{Error, Result};
31use crate::ir::{
32 Capabilities, CitationSource, ContentPart, MediaSource, ModelRequest, ModelResponse,
33 ModelWarning, OutputStrategy, ProviderEchoSnapshot, ReasoningEffort, RefusalReason,
34 ResponseFormat, Role, SafetyCategory, SafetyLevel, SafetyRating, StopReason, ToolChoice,
35 ToolKind, ToolResultContent, Usage,
36};
37use crate::stream::StreamDelta;
38
39const DEFAULT_MAX_CONTEXT_TOKENS: u32 = 1_000_000;
40
41const PROVIDER_KEY: &str = "gemini";
46
47const WIRE_THOUGHT_SIGNATURE: &str = "thought_signature";
54const WIRE_THOUGHT_SIGNATURE_LEGACY: &str = "thoughtSignature";
55
56fn decode_thought_signature(obj: &Value) -> Option<ProviderEchoSnapshot> {
61 let sig = obj
62 .get(WIRE_THOUGHT_SIGNATURE)
63 .or_else(|| obj.get(WIRE_THOUGHT_SIGNATURE_LEGACY)) .and_then(Value::as_str)?;
65 Some(ProviderEchoSnapshot::for_provider(
66 PROVIDER_KEY,
67 WIRE_THOUGHT_SIGNATURE,
68 sig.to_owned(),
69 ))
70}
71
72fn encode_thought_signature(echoes: &[ProviderEchoSnapshot]) -> Option<&str> {
76 ProviderEchoSnapshot::find_in(echoes, PROVIDER_KEY)
77 .and_then(|e| e.payload_str(WIRE_THOUGHT_SIGNATURE))
78}
79
80#[derive(Clone, Copy, Debug, Default)]
82pub struct GeminiCodec;
83
84impl GeminiCodec {
85 pub const fn new() -> Self {
87 Self
88 }
89}
90
91impl Codec for GeminiCodec {
92 fn name(&self) -> &'static str {
93 PROVIDER_KEY
94 }
95
96 fn capabilities(&self, _model: &str) -> Capabilities {
97 Capabilities {
98 streaming: true,
99 tools: true,
100 multimodal_image: true,
101 multimodal_audio: true,
102 multimodal_video: true,
103 multimodal_document: true,
104 system_prompt: true,
105 structured_output: true,
106 prompt_caching: true,
107 thinking: true,
108 citations: true,
109 web_search: true,
110 computer_use: false,
111 max_context_tokens: DEFAULT_MAX_CONTEXT_TOKENS,
112 }
113 }
114
115 fn encode(&self, request: &ModelRequest) -> Result<EncodedRequest> {
116 let (body, warnings) = build_body(request)?;
117 finalize_request(&request.model, &body, warnings, false)
118 }
119
120 fn encode_streaming(&self, request: &ModelRequest) -> Result<EncodedRequest> {
121 let (body, warnings) = build_body(request)?;
122 let mut encoded = finalize_request(&request.model, &body, warnings, true)?;
123 encoded.headers.insert(
124 http::header::ACCEPT,
125 http::HeaderValue::from_static("text/event-stream"),
126 );
127 Ok(encoded.into_streaming())
128 }
129
130 fn decode(&self, body: &[u8], warnings_in: Vec<ModelWarning>) -> Result<ModelResponse> {
131 let raw: Value = super::codec::parse_response_body(body, "Gemini")?;
132 let mut warnings = warnings_in;
133 let id = String::new(); let model = str_field(&raw, "modelVersion").to_owned();
135 let mut usage = decode_usage(raw.get("usageMetadata"));
136 if let Some(candidate) = raw
139 .get("candidates")
140 .and_then(Value::as_array)
141 .and_then(|a| a.first())
142 {
143 usage.safety_ratings = decode_safety_ratings(candidate);
144 }
145 let (content, stop_reason) = decode_candidate(&raw, &mut warnings);
146 Ok(ModelResponse {
147 id,
148 model,
149 stop_reason,
150 content,
151 usage,
152 rate_limit: None,
153 warnings,
154 provider_echoes: Vec::new(),
155 })
156 }
157
158 fn decode_stream<'a>(
159 &'a self,
160 bytes: BoxByteStream<'a>,
161 warnings_in: Vec<ModelWarning>,
162 ) -> BoxDeltaStream<'a> {
163 Box::pin(stream_gemini(bytes, warnings_in))
164 }
165}
166
167fn build_body(request: &ModelRequest) -> Result<(Value, Vec<ModelWarning>)> {
170 if request.messages.is_empty() && request.system.is_empty() {
171 return Err(Error::invalid_request(
172 "Gemini generateContent requires at least one message",
173 ));
174 }
175 let mut warnings = Vec::new();
176 let (system_text, contents) = encode_messages(request, &mut warnings);
177
178 let mut body = Map::new();
179 body.insert("contents".into(), Value::Array(contents));
180 if let Some(text) = system_text {
181 body.insert(
182 "systemInstruction".into(),
183 json!({ "parts": [{ "text": text }] }),
184 );
185 }
186
187 let mut generation_config = Map::new();
188 if let Some(t) = request.max_tokens {
189 generation_config.insert("maxOutputTokens".into(), json!(t));
190 }
191 if let Some(t) = request.temperature {
192 generation_config.insert("temperature".into(), json!(t));
193 }
194 if let Some(p) = request.top_p {
195 generation_config.insert("topP".into(), json!(p));
196 }
197 if let Some(k) = request.top_k {
198 generation_config.insert("topK".into(), json!(k));
199 }
200 if !request.stop_sequences.is_empty() {
201 generation_config.insert("stopSequences".into(), json!(request.stop_sequences));
202 }
203 if let Some(format) = &request.response_format {
204 encode_gemini_structured_output(format, &mut generation_config, &mut body, &mut warnings)?;
205 }
206 if let Some(effort) = &request.reasoning_effort {
207 encode_gemini_thinking(
208 &request.model,
209 effort,
210 &mut generation_config,
211 &mut warnings,
212 );
213 }
214 if !generation_config.is_empty() {
215 body.insert("generationConfig".into(), Value::Object(generation_config));
216 }
217 if !request.tools.is_empty() {
218 body.insert("tools".into(), encode_tools(&request.tools, &mut warnings));
219 body.insert(
220 "toolConfig".into(),
221 encode_tool_choice(&request.tool_choice),
222 );
223 }
224 apply_provider_extensions(request, &mut body, &mut warnings);
225 Ok((Value::Object(body), warnings))
226}
227
228fn apply_provider_extensions(
234 request: &ModelRequest,
235 body: &mut Map<String, Value>,
236 warnings: &mut Vec<ModelWarning>,
237) {
238 let ext = &request.provider_extensions;
239 if request.parallel_tool_calls.is_some() {
244 warnings.push(ModelWarning::LossyEncode {
245 field: "parallel_tool_calls".into(),
246 detail: "Gemini exposes no parallel-tool toggle — setting dropped".into(),
247 });
248 }
249 if let Some(gemini) = &ext.gemini {
250 if !gemini.safety_settings.is_empty() {
251 let arr: Vec<Value> = gemini
252 .safety_settings
253 .iter()
254 .map(|o| {
255 json!({
256 "category": o.category,
257 "threshold": o.threshold,
258 })
259 })
260 .collect();
261 body.insert("safetySettings".into(), Value::Array(arr));
262 }
263 if let Some(n) = gemini.candidate_count {
264 let entry = body
265 .entry("generationConfig")
266 .or_insert_with(|| Value::Object(Map::new()));
267 if let Some(map) = entry.as_object_mut() {
268 map.insert("candidateCount".into(), json!(n));
269 }
270 }
271 if let Some(name) = &gemini.cached_content {
272 body.insert("cachedContent".into(), Value::String(name.clone()));
273 }
274 if gemini.url_context.is_some() {
275 let entry = body
279 .entry("tools")
280 .or_insert_with(|| Value::Array(Vec::new()));
281 if let Some(arr) = entry.as_array_mut() {
282 arr.push(json!({ "url_context": {} }));
283 }
284 }
285 }
286 if let Some(seed) = request.seed {
287 let entry = body
288 .entry("generationConfig")
289 .or_insert_with(|| Value::Object(Map::new()));
290 if let Some(map) = entry.as_object_mut() {
291 map.insert("seed".into(), json!(seed));
292 }
293 }
294 if request.end_user_id.is_some() {
295 warnings.push(ModelWarning::LossyEncode {
296 field: "end_user_id".into(),
297 detail: "Gemini has no end-user attribution channel — drop the field".into(),
298 });
299 }
300 if ext.anthropic.is_some() {
301 warnings.push(ModelWarning::ProviderExtensionIgnored {
302 vendor: "anthropic".into(),
303 });
304 }
305 if ext.openai_chat.is_some() {
306 warnings.push(ModelWarning::ProviderExtensionIgnored {
307 vendor: "openai_chat".into(),
308 });
309 }
310 if ext.openai_responses.is_some() {
311 warnings.push(ModelWarning::ProviderExtensionIgnored {
312 vendor: "openai_responses".into(),
313 });
314 }
315 if ext.bedrock.is_some() {
316 warnings.push(ModelWarning::ProviderExtensionIgnored {
317 vendor: "bedrock".into(),
318 });
319 }
320}
321
322fn encode_gemini_structured_output(
327 format: &ResponseFormat,
328 generation_config: &mut Map<String, Value>,
329 body: &mut Map<String, Value>,
330 warnings: &mut Vec<ModelWarning>,
331) -> Result<()> {
332 let strategy = match format.strategy {
333 OutputStrategy::Auto | OutputStrategy::Native => OutputStrategy::Native,
334 explicit => explicit,
335 };
336 match strategy {
337 OutputStrategy::Native => {
338 generation_config.insert("responseMimeType".into(), json!("application/json"));
339 generation_config.insert(
340 "responseJsonSchema".into(),
341 format.json_schema.schema.clone(),
342 );
343 if !format.strict {
344 warnings.push(ModelWarning::LossyEncode {
345 field: "response_format.strict".into(),
346 detail: "Gemini always strict-validates structured output; \
347 the strict=false request was approximated"
348 .into(),
349 });
350 }
351 }
352 OutputStrategy::Tool => {
353 let tool_name = format.json_schema.name.clone();
359 let synthetic_decl = json!({
360 "name": tool_name,
361 "description": format!(
362 "Emit the response as a JSON object matching the {tool_name} schema."
363 ),
364 "parameters": format.json_schema.schema.clone(),
365 });
366 body.insert(
367 "tools".into(),
368 json!([{
369 "functionDeclarations": [synthetic_decl],
370 }]),
371 );
372 body.insert(
373 "toolConfig".into(),
374 json!({
375 "functionCallingConfig": {
376 "mode": "ANY",
377 "allowedFunctionNames": [format.json_schema.name],
378 }
379 }),
380 );
381 if !format.strict {
382 warnings.push(ModelWarning::LossyEncode {
383 field: "response_format.strict".into(),
384 detail: "Gemini Tool-strategy structured output is always \
385 schema-validated; strict=false was approximated"
386 .into(),
387 });
388 }
389 }
390 OutputStrategy::Prompted => {
391 return Err(Error::invalid_request(
392 "OutputStrategy::Prompted is deferred to entelix 1.1; use \
393 OutputStrategy::Native or OutputStrategy::Tool",
394 ));
395 }
396 OutputStrategy::Auto => unreachable!("Auto resolved above"),
397 }
398 Ok(())
399}
400
401fn is_gemini_3(model: &str) -> bool {
408 model.starts_with("gemini-3")
409}
410
411fn is_gemini_25_flash(model: &str) -> bool {
414 model.starts_with("gemini-2.5-flash") || model.starts_with("gemini-2.5-flash-lite")
415}
416
417fn encode_gemini_thinking(
436 model: &str,
437 effort: &ReasoningEffort,
438 generation_config: &mut Map<String, Value>,
439 warnings: &mut Vec<ModelWarning>,
440) {
441 let mut thinking_config = Map::new();
442 if is_gemini_3(model) {
443 let level = match effort {
444 ReasoningEffort::Off => {
445 warnings.push(ModelWarning::LossyEncode {
446 field: "reasoning_effort".into(),
447 detail: "Gemini 3 cannot disable thinking — snapped to `\"minimal\"`".into(),
448 });
449 "minimal"
450 }
451 ReasoningEffort::Minimal => "minimal",
452 ReasoningEffort::Low => "low",
453 ReasoningEffort::Medium => "medium",
454 ReasoningEffort::High => "high",
455 ReasoningEffort::Auto => {
456 warnings.push(ModelWarning::LossyEncode {
457 field: "reasoning_effort".into(),
458 detail: "Gemini 3 has no `Auto` bucket — snapped to `\"high\"`".into(),
459 });
460 "high"
461 }
462 ReasoningEffort::VendorSpecific(literal) => {
463 thinking_config.insert("thinkingLevel".into(), Value::String(literal.clone()));
464 generation_config.insert("thinkingConfig".into(), Value::Object(thinking_config));
465 return;
466 }
467 };
468 thinking_config.insert("thinkingLevel".into(), Value::String(level.into()));
469 } else {
470 let budget: i32 = match effort {
473 ReasoningEffort::Off => {
474 if is_gemini_25_flash(model) {
475 0
476 } else {
477 warnings.push(ModelWarning::LossyEncode {
478 field: "reasoning_effort".into(),
479 detail: format!(
480 "Gemini 2.5 Pro ({model}) cannot disable thinking — snapped to `512`"
481 ),
482 });
483 512
484 }
485 }
486 ReasoningEffort::Minimal => 512,
487 ReasoningEffort::Low => 1024,
488 ReasoningEffort::Medium => 8192,
489 ReasoningEffort::High => 24576,
490 ReasoningEffort::Auto => -1,
491 ReasoningEffort::VendorSpecific(literal) => {
492 if let Ok(parsed) = literal.parse::<i32>() {
493 parsed
494 } else {
495 warnings.push(ModelWarning::LossyEncode {
496 field: "reasoning_effort".into(),
497 detail: format!(
498 "Gemini 2.5 vendor-specific reasoning_effort {literal:?} is not \
499 a numeric thinkingBudget — falling through to `Medium`"
500 ),
501 });
502 8192
503 }
504 }
505 };
506 thinking_config.insert("thinkingBudget".into(), json!(budget));
507 }
508 generation_config.insert("thinkingConfig".into(), Value::Object(thinking_config));
509}
510
511fn finalize_request(
512 model: &str,
513 body: &Value,
514 warnings: Vec<ModelWarning>,
515 streaming: bool,
516) -> Result<EncodedRequest> {
517 let bytes = serde_json::to_vec(body)?;
518 let path = if streaming {
519 format!("/v1beta/models/{model}:streamGenerateContent?alt=sse")
520 } else {
521 format!("/v1beta/models/{model}:generateContent")
522 };
523 let mut encoded = EncodedRequest::post_json(path, Bytes::from(bytes));
524 encoded.warnings = warnings;
525 Ok(encoded)
526}
527
528fn encode_messages(
531 request: &ModelRequest,
532 warnings: &mut Vec<ModelWarning>,
533) -> (Option<String>, Vec<Value>) {
534 let mut system_parts: Vec<String> = request
535 .system
536 .blocks()
537 .iter()
538 .map(|b| b.text.clone())
539 .collect();
540 if request.system.any_cached() {
541 warnings.push(ModelWarning::LossyEncode {
542 field: "system.cache_control".into(),
543 detail: "Gemini has no native prompt-cache control on \
544 systemInstruction; block text is concatenated and \
545 the cache directive is dropped"
546 .into(),
547 });
548 }
549 let mut contents = Vec::new();
550
551 for (idx, msg) in request.messages.iter().enumerate() {
552 match msg.role {
553 Role::System => {
554 let mut lossy_non_text = false;
555 let mut text = String::new();
556 for part in &msg.content {
557 if let ContentPart::Text { text: t, .. } = part {
558 text.push_str(t);
559 } else {
560 lossy_non_text = true;
561 }
562 }
563 if lossy_non_text {
564 warnings.push(ModelWarning::LossyEncode {
565 field: format!("messages[{idx}].content"),
566 detail: "non-text parts dropped from system message (Gemini routes \
567 system into systemInstruction)"
568 .into(),
569 });
570 }
571 if !text.is_empty() {
572 system_parts.push(text);
573 }
574 }
575 Role::User => {
576 contents.push(json!({
577 "role": "user",
578 "parts": encode_user_parts(&msg.content, warnings, idx),
579 }));
580 }
581 Role::Assistant => {
582 contents.push(json!({
583 "role": "model",
584 "parts": encode_assistant_parts(&msg.content, warnings, idx),
585 }));
586 }
587 Role::Tool => {
588 contents.push(json!({
589 "role": "user",
590 "parts": encode_tool_response_parts(&msg.content, warnings, idx),
591 }));
592 }
593 }
594 }
595
596 let system_text = if system_parts.is_empty() {
597 None
598 } else {
599 Some(system_parts.join("\n\n"))
600 };
601 (system_text, contents)
602}
603
604fn encode_user_parts(
605 parts: &[ContentPart],
606 warnings: &mut Vec<ModelWarning>,
607 msg_idx: usize,
608) -> Vec<Value> {
609 let mut out = Vec::new();
610 for (part_idx, part) in parts.iter().enumerate() {
611 let path = || format!("messages[{msg_idx}].content[{part_idx}]");
612 match part {
613 ContentPart::Text { text, .. } => out.push(json!({ "text": text })),
614 ContentPart::Image { source, .. } => out.push(encode_media_gemini(source, "image/*")),
615 ContentPart::Audio { source, .. } => out.push(encode_media_gemini(source, "audio/wav")),
616 ContentPart::Video { source, .. } => out.push(encode_media_gemini(source, "video/mp4")),
617 ContentPart::Document { source, .. } => {
618 out.push(encode_media_gemini(source, "application/pdf"));
619 }
620 ContentPart::Thinking { .. } => warnings.push(ModelWarning::LossyEncode {
621 field: path(),
622 detail: "Gemini does not accept thinking blocks on input; block dropped".into(),
623 }),
624 ContentPart::Citation { .. } => warnings.push(ModelWarning::LossyEncode {
625 field: path(),
626 detail: "Gemini does not echo citations on input; block dropped".into(),
627 }),
628 ContentPart::ToolUse { .. } | ContentPart::ToolResult { .. } => {
629 warnings.push(ModelWarning::LossyEncode {
630 field: path(),
631 detail: "tool_use / tool_result not allowed on user role for Gemini".into(),
632 });
633 }
634 ContentPart::ImageOutput { .. } | ContentPart::AudioOutput { .. } => {
635 warnings.push(ModelWarning::LossyEncode {
636 field: path(),
637 detail: "Gemini does not accept assistant-produced image / audio output \
638 as input — block dropped"
639 .into(),
640 });
641 }
642 ContentPart::RedactedThinking { .. } => {
643 warnings.push(ModelWarning::LossyEncode {
644 field: path(),
645 detail: "Gemini does not accept redacted_thinking blocks; block dropped".into(),
646 });
647 }
648 }
649 }
650 out
651}
652
653fn encode_media_gemini(source: &MediaSource, fallback_mime: &str) -> Value {
654 match source {
655 MediaSource::Base64 { media_type, data } => json!({
656 "inlineData": { "mimeType": media_type, "data": data },
657 }),
658 MediaSource::Url { url, media_type } => {
659 let mime = media_type.as_deref().unwrap_or(fallback_mime); json!({
661 "fileData": { "mimeType": mime, "fileUri": url },
662 })
663 }
664 MediaSource::FileId { id, media_type } => {
665 let mime = media_type.as_deref().unwrap_or(fallback_mime); json!({
667 "fileData": { "mimeType": mime, "fileUri": id },
668 })
669 }
670 }
671}
672
673fn encode_assistant_parts(
674 parts: &[ContentPart],
675 warnings: &mut Vec<ModelWarning>,
676 msg_idx: usize,
677) -> Vec<Value> {
678 let mut out = Vec::new();
679 for (part_idx, part) in parts.iter().enumerate() {
680 let path = || format!("messages[{msg_idx}].content[{part_idx}]");
681 match part {
682 ContentPart::Text {
683 text,
684 provider_echoes,
685 ..
686 } => {
687 let mut o = Map::new();
688 o.insert("text".into(), Value::String(text.clone()));
689 if let Some(sig) = encode_thought_signature(provider_echoes) {
690 o.insert(WIRE_THOUGHT_SIGNATURE.into(), Value::String(sig.to_owned()));
691 }
692 out.push(Value::Object(o));
693 }
694 ContentPart::ToolUse {
695 name,
696 input,
697 provider_echoes,
698 ..
699 } => {
700 let mut o = Map::new();
706 o.insert(
707 "functionCall".into(),
708 json!({ "name": name, "args": input }),
709 );
710 if let Some(sig) = encode_thought_signature(provider_echoes) {
711 o.insert(WIRE_THOUGHT_SIGNATURE.into(), Value::String(sig.to_owned()));
712 }
713 out.push(Value::Object(o));
714 }
715 ContentPart::Thinking {
716 text,
717 provider_echoes,
718 ..
719 } => {
720 let mut o = Map::new();
721 o.insert("text".into(), Value::String(text.clone()));
722 o.insert("thought".into(), Value::Bool(true));
723 if let Some(sig) = encode_thought_signature(provider_echoes) {
724 o.insert(WIRE_THOUGHT_SIGNATURE.into(), Value::String(sig.to_owned()));
725 }
726 out.push(Value::Object(o));
727 }
728 ContentPart::Citation { snippet, .. } => out.push(json!({ "text": snippet })),
729 other => {
730 warnings.push(ModelWarning::LossyEncode {
731 field: path(),
732 detail: format!(
733 "{} not supported on model role for Gemini — dropped",
734 debug_part_kind(other)
735 ),
736 });
737 }
738 }
739 }
740 out
741}
742
743fn encode_tool_response_parts(
744 parts: &[ContentPart],
745 warnings: &mut Vec<ModelWarning>,
746 msg_idx: usize,
747) -> Vec<Value> {
748 let mut out = Vec::new();
749 for (part_idx, part) in parts.iter().enumerate() {
750 if let ContentPart::ToolResult {
751 tool_use_id: _,
752 name,
753 content,
754 is_error,
755 ..
756 } = part
757 {
758 let response_value = match content {
759 ToolResultContent::Json(v) => v.clone(),
760 ToolResultContent::Text(t) => json!({ "text": t }),
761 };
762 out.push(json!({
767 "functionResponse": {
768 "name": name,
769 "response": response_value,
770 },
771 }));
772 if *is_error {
773 warnings.push(ModelWarning::LossyEncode {
774 field: format!("messages[{msg_idx}].content[{part_idx}].is_error"),
775 detail: "Gemini has no functionResponse error flag — passing through content"
776 .into(),
777 });
778 }
779 } else {
780 warnings.push(ModelWarning::LossyEncode {
781 field: format!("messages[{msg_idx}].content[{part_idx}]"),
782 detail: "non-tool_result part on Role::Tool dropped".into(),
783 });
784 }
785 }
786 out
787}
788
789fn encode_tools(tools: &[crate::ir::ToolSpec], warnings: &mut Vec<ModelWarning>) -> Value {
790 let mut declarations = Vec::new();
791 let mut tool_entries: Vec<Value> = Vec::new();
792 for (idx, t) in tools.iter().enumerate() {
793 match &t.kind {
794 ToolKind::Function { input_schema } => declarations.push(json!({
795 "name": t.name,
796 "description": t.description,
797 "parameters": input_schema,
798 })),
799 ToolKind::WebSearch { .. } => {
800 tool_entries.push(json!({ "google_search": {} }));
803 }
804 ToolKind::CodeExecution => {
805 tool_entries.push(json!({ "code_execution": {} }));
809 }
810 ToolKind::Computer { .. }
812 | ToolKind::TextEditor
813 | ToolKind::Bash
814 | ToolKind::FileSearch { .. }
815 | ToolKind::CodeInterpreter
816 | ToolKind::ImageGeneration
817 | ToolKind::McpConnector { .. }
818 | ToolKind::Memory => warnings.push(ModelWarning::LossyEncode {
819 field: format!("tools[{idx}]"),
820 detail: "Gemini natively ships google_search and code_execution — other \
821 vendor built-ins (computer, text_editor, file_search, …) have no \
822 Gemini equivalent; tool dropped"
823 .into(),
824 }),
825 }
826 }
827 if !declarations.is_empty() {
828 tool_entries.insert(0, json!({ "functionDeclarations": declarations }));
829 }
830 Value::Array(tool_entries)
831}
832
833fn encode_tool_choice(choice: &ToolChoice) -> Value {
834 let mode = match choice {
835 ToolChoice::Auto => "AUTO",
836 ToolChoice::Required | ToolChoice::Specific { .. } => "ANY",
839 ToolChoice::None => "NONE",
840 };
841 let mut config = json!({ "functionCallingConfig": { "mode": mode } });
842 if let ToolChoice::Specific { name } = choice
843 && let Some(cfg) = config
844 .get_mut("functionCallingConfig")
845 .and_then(Value::as_object_mut)
846 {
847 cfg.insert("allowedFunctionNames".into(), json!([name]));
848 }
849 config
850}
851
852const fn debug_part_kind(part: &ContentPart) -> &'static str {
853 match part {
854 ContentPart::Text { .. } => "text",
855 ContentPart::Image { .. } => "image",
856 ContentPart::Audio { .. } => "audio",
857 ContentPart::Video { .. } => "video",
858 ContentPart::Document { .. } => "document",
859 ContentPart::Thinking { .. } => "thinking",
860 ContentPart::Citation { .. } => "citation",
861 ContentPart::ToolUse { .. } => "tool_use",
862 ContentPart::ToolResult { .. } => "tool_result",
863 ContentPart::ImageOutput { .. } => "image_output",
864 ContentPart::AudioOutput { .. } => "audio_output",
865 ContentPart::RedactedThinking { .. } => "redacted_thinking",
866 }
867}
868
869fn decode_candidate(
872 raw: &Value,
873 warnings: &mut Vec<ModelWarning>,
874) -> (Vec<ContentPart>, StopReason) {
875 let candidate = raw
876 .get("candidates")
877 .and_then(Value::as_array)
878 .and_then(|a| a.first())
879 .cloned()
880 .unwrap_or(Value::Null); let parts_raw = candidate
882 .get("content")
883 .and_then(|c| c.get("parts"))
884 .and_then(Value::as_array)
885 .cloned()
886 .unwrap_or_default(); let mut parts = Vec::new();
888 let mut tool_seq: usize = 0;
896 for (idx, part) in parts_raw.iter().enumerate() {
897 if part.get("thought").and_then(Value::as_bool) == Some(true) {
899 let text = str_field(part, "text").to_owned();
900 let provider_echoes = decode_thought_signature(part).map_or_else(Vec::new, |e| vec![e]);
901 parts.push(ContentPart::Thinking {
902 text,
903 cache_control: None,
904 provider_echoes,
905 });
906 continue;
907 }
908 if let Some(text) = part.get("text").and_then(Value::as_str)
909 && !text.is_empty()
910 {
911 let provider_echoes = decode_thought_signature(part).map_or_else(Vec::new, |e| vec![e]);
914 parts.push(ContentPart::Text {
915 text: text.to_owned(),
916 cache_control: None,
917 provider_echoes,
918 });
919 continue;
920 }
921 if let Some(call) = part.get("functionCall") {
922 let name = str_field(call, "name").to_owned();
923 let args = call.get("args").cloned().unwrap_or_else(|| json!({})); let provider_echoes = decode_thought_signature(part).map_or_else(Vec::new, |e| vec![e]);
930 parts.push(ContentPart::ToolUse {
937 id: format!("{name}#{tool_seq}"),
938 name,
939 input: args,
940 provider_echoes,
941 });
942 tool_seq = tool_seq.saturating_add(1);
943 continue;
944 }
945 warnings.push(ModelWarning::LossyEncode {
946 field: format!("candidates[0].content.parts[{idx}]"),
947 detail: "unknown Gemini part type dropped".into(),
948 });
949 }
950 if let Some(meta) = candidate.get("groundingMetadata")
952 && let Some(chunks) = meta.get("groundingChunks").and_then(Value::as_array)
953 {
954 for chunk in chunks {
955 if let Some(web) = chunk.get("web") {
956 let url = str_field(web, "uri").to_owned();
957 let title = web.get("title").and_then(Value::as_str).map(str::to_owned);
958 if !url.is_empty() {
959 parts.push(ContentPart::Citation {
960 snippet: title.clone().unwrap_or_default(), source: CitationSource::Url { url, title },
962 cache_control: None,
963 provider_echoes: Vec::new(),
964 });
965 }
966 }
967 }
968 }
969 let stop_reason = decode_finish_reason(
970 candidate.get("finishReason").and_then(Value::as_str),
971 warnings,
972 );
973 (parts, stop_reason)
974}
975
976fn decode_finish_reason(reason: Option<&str>, warnings: &mut Vec<ModelWarning>) -> StopReason {
977 match reason {
978 Some("STOP") => StopReason::EndTurn,
979 Some("MAX_TOKENS") => StopReason::MaxTokens,
980 Some("SAFETY") => StopReason::Refusal {
985 reason: RefusalReason::Safety,
986 },
987 Some("RECITATION") => StopReason::Refusal {
988 reason: RefusalReason::Recitation,
989 },
990 Some(other) => {
991 warnings.push(ModelWarning::UnknownStopReason {
992 raw: other.to_owned(),
993 });
994 StopReason::Other {
995 raw: other.to_owned(),
996 }
997 }
998 None => {
999 warnings.push(ModelWarning::LossyEncode {
1003 field: "finishReason".into(),
1004 detail: "Gemini candidate carried no finishReason — \
1005 IR records `Other{raw:\"missing\"}`"
1006 .into(),
1007 });
1008 StopReason::Other {
1009 raw: "missing".to_owned(),
1010 }
1011 }
1012 }
1013}
1014
1015fn decode_usage(usage: Option<&Value>) -> Usage {
1016 let visible = u_field(usage, "candidatesTokenCount");
1026 let thoughts = u_field(usage, "thoughtsTokenCount");
1027 Usage {
1028 input_tokens: u_field(usage, "promptTokenCount"),
1029 output_tokens: visible.saturating_add(thoughts),
1030 cached_input_tokens: u_field(usage, "cachedContentTokenCount"),
1031 cache_creation_input_tokens: 0,
1032 reasoning_tokens: thoughts,
1033 safety_ratings: Vec::new(),
1034 }
1035}
1036
1037fn decode_safety_ratings(candidate: &Value) -> Vec<SafetyRating> {
1038 let Some(raw) = candidate.get("safetyRatings").and_then(Value::as_array) else {
1039 return Vec::new();
1040 };
1041 raw.iter()
1042 .filter_map(|r| {
1043 let category = match r.get("category").and_then(Value::as_str)? {
1044 "HARM_CATEGORY_HARASSMENT" => SafetyCategory::Harassment,
1045 "HARM_CATEGORY_HATE_SPEECH" => SafetyCategory::HateSpeech,
1046 "HARM_CATEGORY_SEXUALLY_EXPLICIT" => SafetyCategory::SexuallyExplicit,
1047 "HARM_CATEGORY_DANGEROUS_CONTENT" => SafetyCategory::DangerousContent,
1048 other => SafetyCategory::Other(other.to_owned()),
1049 };
1050 let level = match r.get("probability").and_then(Value::as_str)? {
1051 "LOW" => SafetyLevel::Low,
1052 "MEDIUM" => SafetyLevel::Medium,
1053 "HIGH" => SafetyLevel::High,
1054 _ => SafetyLevel::Negligible,
1058 };
1059 Some(SafetyRating { category, level })
1060 })
1061 .collect()
1062}
1063
1064fn str_field<'a>(v: &'a Value, key: &str) -> &'a str {
1065 v.get(key).and_then(Value::as_str).unwrap_or("") }
1067
1068fn u_field(v: Option<&Value>, key: &str) -> u32 {
1069 v.and_then(|inner| inner.get(key))
1070 .and_then(Value::as_u64)
1071 .map_or(0, |n| u32::try_from(n).unwrap_or(u32::MAX)) }
1073
1074#[allow(tail_expr_drop_order, clippy::too_many_lines)]
1077fn stream_gemini(
1078 bytes: BoxByteStream<'_>,
1079 warnings_in: Vec<ModelWarning>,
1080) -> impl futures::Stream<Item = Result<StreamDelta>> + Send + '_ {
1081 async_stream::stream! {
1082 let mut bytes = bytes;
1083 let mut buf: Vec<u8> = Vec::new();
1084 let mut started = false;
1085 let mut warnings_emitted = false;
1086 let mut last_stop = StopReason::EndTurn;
1087 let mut current_tool_open = false;
1088 let mut tool_synth_idx: u64 = 0;
1089
1090 while let Some(chunk) = bytes.next().await {
1091 match chunk {
1092 Ok(b) => buf.extend_from_slice(&b),
1093 Err(e) => {
1094 yield Err(e);
1095 return;
1096 }
1097 }
1098 if !warnings_emitted {
1099 warnings_emitted = true;
1100 for w in &warnings_in {
1101 yield Ok(StreamDelta::Warning(w.clone()));
1102 }
1103 }
1104 while let Some(pos) = find_double_newline(&buf) {
1105 let frame: Vec<u8> = buf.drain(..pos.saturating_add(2)).collect();
1106 let Ok(frame_str) = std::str::from_utf8(&frame) else {
1107 continue;
1108 };
1109 let Some(payload) = parse_sse_data(frame_str) else {
1110 continue;
1111 };
1112 let Ok(event) = serde_json::from_str::<Value>(&payload) else {
1113 yield Err(Error::invalid_request(format!(
1114 "Gemini stream: malformed chunk: {payload}"
1115 )));
1116 return;
1117 };
1118 if !started {
1119 started = true;
1120 let model = str_field(&event, "modelVersion").to_owned();
1121 yield Ok(StreamDelta::Start {
1122 id: String::new(),
1123 model,
1124 provider_echoes: Vec::new(),
1125 });
1126 }
1127 if let Some(usage) = event.get("usageMetadata") {
1128 yield Ok(StreamDelta::Usage(decode_usage(Some(usage))));
1129 }
1130 let Some(candidate) = event
1131 .get("candidates")
1132 .and_then(Value::as_array)
1133 .and_then(|a| a.first())
1134 else {
1135 continue;
1136 };
1137 if let Some(reason) = candidate.get("finishReason").and_then(Value::as_str) {
1138 last_stop = decode_finish_reason(Some(reason), &mut Vec::new());
1139 }
1140 let Some(parts) = candidate
1141 .get("content")
1142 .and_then(|c| c.get("parts"))
1143 .and_then(Value::as_array)
1144 else {
1145 continue;
1146 };
1147 for part in parts {
1148 if part.get("thought").and_then(Value::as_bool) == Some(true) {
1152 if current_tool_open {
1153 yield Ok(StreamDelta::ToolUseStop);
1154 current_tool_open = false;
1155 }
1156 let text = part
1157 .get("text")
1158 .and_then(Value::as_str)
1159 .unwrap_or("") .to_owned();
1161 let provider_echoes =
1162 decode_thought_signature(part).map_or_else(Vec::new, |e| vec![e]);
1163 if !text.is_empty() || !provider_echoes.is_empty() {
1164 yield Ok(StreamDelta::ThinkingDelta {
1165 text,
1166 provider_echoes,
1167 });
1168 }
1169 continue;
1170 }
1171 if let Some(text) = part.get("text").and_then(Value::as_str)
1172 && !text.is_empty()
1173 {
1174 if current_tool_open {
1175 yield Ok(StreamDelta::ToolUseStop);
1176 current_tool_open = false;
1177 }
1178 let provider_echoes =
1179 decode_thought_signature(part).map_or_else(Vec::new, |e| vec![e]);
1180 yield Ok(StreamDelta::TextDelta {
1181 text: text.to_owned(),
1182 provider_echoes,
1183 });
1184 continue;
1185 }
1186 if let Some(call) = part.get("functionCall") {
1187 if current_tool_open {
1188 yield Ok(StreamDelta::ToolUseStop);
1189 }
1190 let name = str_field(call, "name").to_owned();
1191 let args = call.get("args").cloned().unwrap_or_else(|| json!({})); let synth_id = format!("{name}#{tool_synth_idx}");
1193 tool_synth_idx = tool_synth_idx.saturating_add(1);
1194 let provider_echoes =
1195 decode_thought_signature(part).map_or_else(Vec::new, |e| vec![e]);
1196 yield Ok(StreamDelta::ToolUseStart {
1197 id: synth_id,
1198 name,
1199 provider_echoes,
1200 });
1201 yield Ok(StreamDelta::ToolUseInputDelta {
1202 partial_json: args.to_string(),
1203 });
1204 current_tool_open = true;
1205 }
1206 }
1207 }
1208 }
1209 if current_tool_open {
1210 yield Ok(StreamDelta::ToolUseStop);
1211 }
1212 yield Ok(StreamDelta::Stop {
1213 stop_reason: last_stop,
1214 });
1215 }
1216}
1217
1218fn find_double_newline(buf: &[u8]) -> Option<usize> {
1219 let lf = buf.windows(2).position(|w| w == b"\n\n");
1220 let crlf = buf.windows(4).position(|w| w == b"\r\n\r\n");
1221 match (lf, crlf) {
1222 (Some(a), Some(b)) => Some(a.min(b)),
1223 (Some(a), None) => Some(a),
1224 (None, Some(b)) => Some(b),
1225 (None, None) => None,
1226 }
1227}
1228
1229fn parse_sse_data(frame: &str) -> Option<String> {
1230 let mut out: Option<String> = None;
1231 for line in frame.lines() {
1232 if let Some(rest) = line.strip_prefix("data:") {
1233 let trimmed = rest.strip_prefix(' ').unwrap_or(rest); match &mut out {
1235 Some(existing) => {
1236 existing.push('\n');
1237 existing.push_str(trimmed);
1238 }
1239 None => out = Some(trimmed.to_owned()),
1240 }
1241 }
1242 }
1243 out
1244}