1#![allow(clippy::cast_possible_truncation)]
29
30use bytes::Bytes;
31use futures::StreamExt;
32use serde_json::{Map, Value, json};
33
34use crate::codecs::codec::{BoxByteStream, BoxDeltaStream, Codec, EncodedRequest};
35use crate::error::{Error, Result};
36use crate::ir::{
37 Capabilities, CitationSource, ContentPart, MediaSource, ModelRequest, ModelResponse,
38 ModelWarning, OutputStrategy, ProviderEchoSnapshot, ReasoningEffort, RefusalReason,
39 ResponseFormat, Role, SafetyCategory, SafetyLevel, SafetyRating, StopReason, ToolChoice,
40 ToolKind, ToolResultContent, Usage,
41};
42use crate::stream::StreamDelta;
43
44const DEFAULT_MAX_CONTEXT_TOKENS: u32 = 1_000_000;
45
46const PROVIDER_KEY: &str = "gemini";
51
52const WIRE_THOUGHT_SIGNATURE: &str = "thought_signature";
59const WIRE_THOUGHT_SIGNATURE_LEGACY: &str = "thoughtSignature";
60
61fn decode_thought_signature(obj: &Value) -> Option<ProviderEchoSnapshot> {
66 let sig = obj
67 .get(WIRE_THOUGHT_SIGNATURE)
68 .or_else(|| obj.get(WIRE_THOUGHT_SIGNATURE_LEGACY)) .and_then(Value::as_str)?;
70 Some(ProviderEchoSnapshot::for_provider(
71 PROVIDER_KEY,
72 WIRE_THOUGHT_SIGNATURE,
73 sig.to_owned(),
74 ))
75}
76
77fn encode_thought_signature(echoes: &[ProviderEchoSnapshot]) -> Option<&str> {
81 ProviderEchoSnapshot::find_in(echoes, PROVIDER_KEY)
82 .and_then(|e| e.payload_str(WIRE_THOUGHT_SIGNATURE))
83}
84
85#[derive(Clone, Copy, Debug, Default)]
87pub struct GeminiCodec;
88
89impl GeminiCodec {
90 pub const fn new() -> Self {
92 Self
93 }
94}
95
96impl Codec for GeminiCodec {
97 fn name(&self) -> &'static str {
98 PROVIDER_KEY
99 }
100
101 fn capabilities(&self, _model: &str) -> Capabilities {
102 Capabilities {
103 streaming: true,
104 tools: true,
105 multimodal_image: true,
106 multimodal_audio: true,
107 multimodal_video: true,
108 multimodal_document: true,
109 system_prompt: true,
110 structured_output: true,
111 prompt_caching: true,
112 thinking: true,
113 citations: true,
114 web_search: true,
115 computer_use: false,
116 max_context_tokens: DEFAULT_MAX_CONTEXT_TOKENS,
117 }
118 }
119
120 fn encode(&self, request: &ModelRequest) -> Result<EncodedRequest> {
121 let (body, warnings) = build_body(request)?;
122 finalize_request(&request.model, &body, warnings, false)
123 }
124
125 fn encode_streaming(&self, request: &ModelRequest) -> Result<EncodedRequest> {
126 let (body, warnings) = build_body(request)?;
127 let mut encoded = finalize_request(&request.model, &body, warnings, true)?;
128 encoded.headers.insert(
129 http::header::ACCEPT,
130 http::HeaderValue::from_static("text/event-stream"),
131 );
132 Ok(encoded.into_streaming())
133 }
134
135 fn decode(&self, body: &[u8], warnings_in: Vec<ModelWarning>) -> Result<ModelResponse> {
136 let raw: Value = super::codec::parse_response_body(body, "Gemini")?;
137 let mut warnings = warnings_in;
138 let id = String::new(); let model = str_field(&raw, "modelVersion").to_owned();
140 let mut usage = decode_usage(raw.get("usageMetadata"));
141 if let Some(candidate) = raw
144 .get("candidates")
145 .and_then(Value::as_array)
146 .and_then(|a| a.first())
147 {
148 usage.safety_ratings = decode_safety_ratings(candidate);
149 }
150 let (content, stop_reason) = decode_candidate(&raw, &mut warnings);
151 Ok(ModelResponse {
152 id,
153 model,
154 stop_reason,
155 content,
156 usage,
157 rate_limit: None,
158 warnings,
159 provider_echoes: Vec::new(),
160 })
161 }
162
163 fn decode_stream<'a>(
164 &'a self,
165 bytes: BoxByteStream<'a>,
166 warnings_in: Vec<ModelWarning>,
167 ) -> BoxDeltaStream<'a> {
168 Box::pin(stream_gemini(bytes, warnings_in))
169 }
170}
171
172fn build_body(request: &ModelRequest) -> Result<(Value, Vec<ModelWarning>)> {
175 if request.messages.is_empty() && request.system.is_empty() {
176 return Err(Error::invalid_request(
177 "Gemini generateContent requires at least one message",
178 ));
179 }
180 let mut warnings = Vec::new();
181 let (system_text, contents) = encode_messages(request, &mut warnings);
182
183 let mut body = Map::new();
184 body.insert("contents".into(), Value::Array(contents));
185 if let Some(text) = system_text {
186 body.insert(
187 "systemInstruction".into(),
188 json!({ "parts": [{ "text": text }] }),
189 );
190 }
191
192 let mut generation_config = Map::new();
193 if let Some(t) = request.max_tokens {
194 generation_config.insert("maxOutputTokens".into(), json!(t));
195 }
196 if let Some(t) = request.temperature {
197 generation_config.insert("temperature".into(), json!(t));
198 }
199 if let Some(p) = request.top_p {
200 generation_config.insert("topP".into(), json!(p));
201 }
202 if let Some(k) = request.top_k {
203 generation_config.insert("topK".into(), json!(k));
204 }
205 if !request.stop_sequences.is_empty() {
206 generation_config.insert("stopSequences".into(), json!(request.stop_sequences));
207 }
208 if let Some(format) = &request.response_format {
209 encode_gemini_structured_output(format, &mut generation_config, &mut body, &mut warnings)?;
210 }
211 if let Some(effort) = &request.reasoning_effort {
212 encode_gemini_thinking(
213 &request.model,
214 effort,
215 &mut generation_config,
216 &mut warnings,
217 );
218 }
219 if !generation_config.is_empty() {
220 body.insert("generationConfig".into(), Value::Object(generation_config));
221 }
222 if !request.tools.is_empty() {
223 body.insert("tools".into(), encode_tools(&request.tools, &mut warnings));
224 body.insert(
225 "toolConfig".into(),
226 encode_tool_choice(&request.tool_choice),
227 );
228 }
229 apply_provider_extensions(request, &mut body, &mut warnings);
230 Ok((Value::Object(body), warnings))
231}
232
233fn apply_provider_extensions(
239 request: &ModelRequest,
240 body: &mut Map<String, Value>,
241 warnings: &mut Vec<ModelWarning>,
242) {
243 let ext = &request.provider_extensions;
244 if request.parallel_tool_calls.is_some() {
249 warnings.push(ModelWarning::LossyEncode {
250 field: "parallel_tool_calls".into(),
251 detail: "Gemini exposes no parallel-tool toggle — setting dropped".into(),
252 });
253 }
254 if let Some(gemini) = &ext.gemini {
255 if !gemini.safety_settings.is_empty() {
256 let arr: Vec<Value> = gemini
257 .safety_settings
258 .iter()
259 .map(|o| {
260 json!({
261 "category": o.category,
262 "threshold": o.threshold,
263 })
264 })
265 .collect();
266 body.insert("safetySettings".into(), Value::Array(arr));
267 }
268 if let Some(n) = gemini.candidate_count {
269 let entry = body
270 .entry("generationConfig")
271 .or_insert_with(|| Value::Object(Map::new()));
272 if let Some(map) = entry.as_object_mut() {
273 map.insert("candidateCount".into(), json!(n));
274 }
275 }
276 if let Some(name) = &gemini.cached_content {
277 body.insert("cachedContent".into(), Value::String(name.clone()));
278 }
279 if gemini.url_context.is_some() {
280 let entry = body
284 .entry("tools")
285 .or_insert_with(|| Value::Array(Vec::new()));
286 if let Some(arr) = entry.as_array_mut() {
287 arr.push(json!({ "url_context": {} }));
288 }
289 }
290 }
291 if let Some(seed) = request.seed {
292 let entry = body
293 .entry("generationConfig")
294 .or_insert_with(|| Value::Object(Map::new()));
295 if let Some(map) = entry.as_object_mut() {
296 map.insert("seed".into(), json!(seed));
297 }
298 }
299 if request.end_user_id.is_some() {
300 warnings.push(ModelWarning::LossyEncode {
301 field: "end_user_id".into(),
302 detail: "Gemini has no end-user attribution channel — drop the field".into(),
303 });
304 }
305 if ext.anthropic.is_some() {
306 warnings.push(ModelWarning::ProviderExtensionIgnored {
307 vendor: "anthropic".into(),
308 });
309 }
310 if ext.openai_chat.is_some() {
311 warnings.push(ModelWarning::ProviderExtensionIgnored {
312 vendor: "openai_chat".into(),
313 });
314 }
315 if ext.openai_responses.is_some() {
316 warnings.push(ModelWarning::ProviderExtensionIgnored {
317 vendor: "openai_responses".into(),
318 });
319 }
320 if ext.bedrock.is_some() {
321 warnings.push(ModelWarning::ProviderExtensionIgnored {
322 vendor: "bedrock".into(),
323 });
324 }
325}
326
327fn encode_gemini_structured_output(
332 format: &ResponseFormat,
333 generation_config: &mut Map<String, Value>,
334 body: &mut Map<String, Value>,
335 warnings: &mut Vec<ModelWarning>,
336) -> Result<()> {
337 let stripped = crate::LlmFacingSchema::strip(&format.json_schema.schema);
347 let strategy = match format.strategy {
348 OutputStrategy::Auto | OutputStrategy::Native => OutputStrategy::Native,
349 explicit => explicit,
350 };
351 match strategy {
352 OutputStrategy::Native => {
353 generation_config.insert("responseMimeType".into(), json!("application/json"));
354 generation_config.insert("responseJsonSchema".into(), stripped);
355 if !format.strict {
356 warnings.push(ModelWarning::LossyEncode {
357 field: "response_format.strict".into(),
358 detail: "Gemini always strict-validates structured output; \
359 the strict=false request was approximated"
360 .into(),
361 });
362 }
363 }
364 OutputStrategy::Tool => {
365 let tool_name = format.json_schema.name.clone();
371 let parameters =
372 encode_input_schema(&stripped, "response_format.json_schema.schema", warnings);
373 let synthetic_decl = json!({
374 "name": tool_name,
375 "description": format!(
376 "Emit the response as a JSON object matching the {tool_name} schema."
377 ),
378 "parameters": parameters,
379 });
380 body.insert(
381 "tools".into(),
382 json!([{
383 "functionDeclarations": [synthetic_decl],
384 }]),
385 );
386 body.insert(
387 "toolConfig".into(),
388 json!({
389 "functionCallingConfig": {
390 "mode": "ANY",
391 "allowedFunctionNames": [format.json_schema.name],
392 }
393 }),
394 );
395 if !format.strict {
396 warnings.push(ModelWarning::LossyEncode {
397 field: "response_format.strict".into(),
398 detail: "Gemini Tool-strategy structured output is always \
399 schema-validated; strict=false was approximated"
400 .into(),
401 });
402 }
403 }
404 OutputStrategy::Prompted => {
405 return Err(Error::invalid_request(
406 "OutputStrategy::Prompted is deferred to entelix 1.1; use \
407 OutputStrategy::Native or OutputStrategy::Tool",
408 ));
409 }
410 OutputStrategy::Auto => unreachable!("Auto resolved above"),
411 }
412 Ok(())
413}
414
415fn is_gemini_3(model: &str) -> bool {
422 model.starts_with("gemini-3")
423}
424
425fn is_gemini_25_flash(model: &str) -> bool {
428 model.starts_with("gemini-2.5-flash") || model.starts_with("gemini-2.5-flash-lite")
429}
430
431fn encode_gemini_thinking(
450 model: &str,
451 effort: &ReasoningEffort,
452 generation_config: &mut Map<String, Value>,
453 warnings: &mut Vec<ModelWarning>,
454) {
455 let mut thinking_config = Map::new();
456 if is_gemini_3(model) {
457 let level = match effort {
458 ReasoningEffort::Off => {
459 warnings.push(ModelWarning::LossyEncode {
460 field: "reasoning_effort".into(),
461 detail: "Gemini 3 cannot disable thinking — snapped to `\"minimal\"`".into(),
462 });
463 "minimal"
464 }
465 ReasoningEffort::Minimal => "minimal",
466 ReasoningEffort::Low => "low",
467 ReasoningEffort::Medium => "medium",
468 ReasoningEffort::High => "high",
469 ReasoningEffort::Auto => {
470 warnings.push(ModelWarning::LossyEncode {
471 field: "reasoning_effort".into(),
472 detail: "Gemini 3 has no `Auto` bucket — snapped to `\"high\"`".into(),
473 });
474 "high"
475 }
476 ReasoningEffort::VendorSpecific(literal) => {
477 thinking_config.insert("thinkingLevel".into(), Value::String(literal.clone()));
478 generation_config.insert("thinkingConfig".into(), Value::Object(thinking_config));
479 return;
480 }
481 };
482 thinking_config.insert("thinkingLevel".into(), Value::String(level.into()));
483 } else {
484 let budget: i32 = match effort {
487 ReasoningEffort::Off => {
488 if is_gemini_25_flash(model) {
489 0
490 } else {
491 warnings.push(ModelWarning::LossyEncode {
492 field: "reasoning_effort".into(),
493 detail: format!(
494 "Gemini 2.5 Pro ({model}) cannot disable thinking — snapped to `512`"
495 ),
496 });
497 512
498 }
499 }
500 ReasoningEffort::Minimal => 512,
501 ReasoningEffort::Low => 1024,
502 ReasoningEffort::Medium => 8192,
503 ReasoningEffort::High => 24576,
504 ReasoningEffort::Auto => -1,
505 ReasoningEffort::VendorSpecific(literal) => {
506 if let Ok(parsed) = literal.parse::<i32>() {
507 parsed
508 } else {
509 warnings.push(ModelWarning::LossyEncode {
510 field: "reasoning_effort".into(),
511 detail: format!(
512 "Gemini 2.5 vendor-specific reasoning_effort {literal:?} is not \
513 a numeric thinkingBudget — falling through to `Medium`"
514 ),
515 });
516 8192
517 }
518 }
519 };
520 thinking_config.insert("thinkingBudget".into(), json!(budget));
521 }
522 generation_config.insert("thinkingConfig".into(), Value::Object(thinking_config));
523}
524
525fn finalize_request(
526 model: &str,
527 body: &Value,
528 warnings: Vec<ModelWarning>,
529 streaming: bool,
530) -> Result<EncodedRequest> {
531 let bytes = serde_json::to_vec(body)?;
532 let path = if streaming {
533 format!("/v1beta/models/{model}:streamGenerateContent?alt=sse")
534 } else {
535 format!("/v1beta/models/{model}:generateContent")
536 };
537 let mut encoded = EncodedRequest::post_json(path, Bytes::from(bytes));
538 encoded.warnings = warnings;
539 Ok(encoded)
540}
541
542fn encode_messages(
545 request: &ModelRequest,
546 warnings: &mut Vec<ModelWarning>,
547) -> (Option<String>, Vec<Value>) {
548 let mut system_parts: Vec<String> = request
549 .system
550 .blocks()
551 .iter()
552 .map(|b| b.text.clone())
553 .collect();
554 if request.system.any_cached() {
555 warnings.push(ModelWarning::LossyEncode {
556 field: "system.cache_control".into(),
557 detail: "Gemini has no native prompt-cache control on \
558 systemInstruction; block text is concatenated and \
559 the cache directive is dropped"
560 .into(),
561 });
562 }
563 let mut contents = Vec::new();
564
565 for (idx, msg) in request.messages.iter().enumerate() {
566 match msg.role {
567 Role::System => {
568 let mut lossy_non_text = false;
569 let mut text = String::new();
570 for part in &msg.content {
571 if let ContentPart::Text { text: t, .. } = part {
572 text.push_str(t);
573 } else {
574 lossy_non_text = true;
575 }
576 }
577 if lossy_non_text {
578 warnings.push(ModelWarning::LossyEncode {
579 field: format!("messages[{idx}].content"),
580 detail: "non-text parts dropped from system message (Gemini routes \
581 system into systemInstruction)"
582 .into(),
583 });
584 }
585 if !text.is_empty() {
586 system_parts.push(text);
587 }
588 }
589 Role::User => {
590 contents.push(json!({
591 "role": "user",
592 "parts": encode_user_parts(&msg.content, warnings, idx),
593 }));
594 }
595 Role::Assistant => {
596 contents.push(json!({
597 "role": "model",
598 "parts": encode_assistant_parts(&msg.content, warnings, idx),
599 }));
600 }
601 Role::Tool => {
602 contents.push(json!({
603 "role": "user",
604 "parts": encode_tool_response_parts(&msg.content, warnings, idx),
605 }));
606 }
607 }
608 }
609
610 let system_text = if system_parts.is_empty() {
611 None
612 } else {
613 Some(system_parts.join("\n\n"))
614 };
615 (system_text, contents)
616}
617
618fn encode_user_parts(
619 parts: &[ContentPart],
620 warnings: &mut Vec<ModelWarning>,
621 msg_idx: usize,
622) -> Vec<Value> {
623 let mut out = Vec::new();
624 for (part_idx, part) in parts.iter().enumerate() {
625 let path = || format!("messages[{msg_idx}].content[{part_idx}]");
626 match part {
627 ContentPart::Text { text, .. } => out.push(json!({ "text": text })),
628 ContentPart::Image { source, .. } => out.push(encode_media_gemini(source, "image/*")),
629 ContentPart::Audio { source, .. } => out.push(encode_media_gemini(source, "audio/wav")),
630 ContentPart::Video { source, .. } => out.push(encode_media_gemini(source, "video/mp4")),
631 ContentPart::Document { source, .. } => {
632 out.push(encode_media_gemini(source, "application/pdf"));
633 }
634 ContentPart::Thinking { .. } => warnings.push(ModelWarning::LossyEncode {
635 field: path(),
636 detail: "Gemini does not accept thinking blocks on input; block dropped".into(),
637 }),
638 ContentPart::Citation { .. } => warnings.push(ModelWarning::LossyEncode {
639 field: path(),
640 detail: "Gemini does not echo citations on input; block dropped".into(),
641 }),
642 ContentPart::ToolUse { .. } | ContentPart::ToolResult { .. } => {
643 warnings.push(ModelWarning::LossyEncode {
644 field: path(),
645 detail: "tool_use / tool_result not allowed on user role for Gemini".into(),
646 });
647 }
648 ContentPart::ImageOutput { .. } | ContentPart::AudioOutput { .. } => {
649 warnings.push(ModelWarning::LossyEncode {
650 field: path(),
651 detail: "Gemini does not accept assistant-produced image / audio output \
652 as input — block dropped"
653 .into(),
654 });
655 }
656 ContentPart::RedactedThinking { .. } => {
657 warnings.push(ModelWarning::LossyEncode {
658 field: path(),
659 detail: "Gemini does not accept redacted_thinking blocks; block dropped".into(),
660 });
661 }
662 }
663 }
664 out
665}
666
667fn encode_media_gemini(source: &MediaSource, fallback_mime: &str) -> Value {
668 match source {
669 MediaSource::Base64 { media_type, data } => json!({
670 "inlineData": { "mimeType": media_type, "data": data },
671 }),
672 MediaSource::Url { url, media_type } => {
673 let mime = media_type.as_deref().unwrap_or(fallback_mime); json!({
675 "fileData": { "mimeType": mime, "fileUri": url },
676 })
677 }
678 MediaSource::FileId { id, media_type } => {
679 let mime = media_type.as_deref().unwrap_or(fallback_mime); json!({
681 "fileData": { "mimeType": mime, "fileUri": id },
682 })
683 }
684 }
685}
686
687fn encode_assistant_parts(
688 parts: &[ContentPart],
689 warnings: &mut Vec<ModelWarning>,
690 msg_idx: usize,
691) -> Vec<Value> {
692 let mut out = Vec::new();
693 for (part_idx, part) in parts.iter().enumerate() {
694 let path = || format!("messages[{msg_idx}].content[{part_idx}]");
695 match part {
696 ContentPart::Text {
697 text,
698 provider_echoes,
699 ..
700 } => {
701 let mut o = Map::new();
702 o.insert("text".into(), Value::String(text.clone()));
703 if let Some(sig) = encode_thought_signature(provider_echoes) {
704 o.insert(WIRE_THOUGHT_SIGNATURE.into(), Value::String(sig.to_owned()));
705 }
706 out.push(Value::Object(o));
707 }
708 ContentPart::ToolUse {
709 name,
710 input,
711 provider_echoes,
712 ..
713 } => {
714 let mut o = Map::new();
720 o.insert(
721 "functionCall".into(),
722 json!({ "name": name, "args": input }),
723 );
724 if let Some(sig) = encode_thought_signature(provider_echoes) {
725 o.insert(WIRE_THOUGHT_SIGNATURE.into(), Value::String(sig.to_owned()));
726 }
727 out.push(Value::Object(o));
728 }
729 ContentPart::Thinking {
730 text,
731 provider_echoes,
732 ..
733 } => {
734 let mut o = Map::new();
735 o.insert("text".into(), Value::String(text.clone()));
736 o.insert("thought".into(), Value::Bool(true));
737 if let Some(sig) = encode_thought_signature(provider_echoes) {
738 o.insert(WIRE_THOUGHT_SIGNATURE.into(), Value::String(sig.to_owned()));
739 }
740 out.push(Value::Object(o));
741 }
742 ContentPart::Citation { snippet, .. } => out.push(json!({ "text": snippet })),
743 other => {
744 warnings.push(ModelWarning::LossyEncode {
745 field: path(),
746 detail: format!(
747 "{} not supported on model role for Gemini — dropped",
748 debug_part_kind(other)
749 ),
750 });
751 }
752 }
753 }
754 out
755}
756
757fn encode_tool_response_parts(
758 parts: &[ContentPart],
759 warnings: &mut Vec<ModelWarning>,
760 msg_idx: usize,
761) -> Vec<Value> {
762 let mut out = Vec::new();
763 for (part_idx, part) in parts.iter().enumerate() {
764 if let ContentPart::ToolResult {
765 tool_use_id: _,
766 name,
767 content,
768 is_error,
769 ..
770 } = part
771 {
772 let response_value = match content {
773 ToolResultContent::Json(v) => v.clone(),
774 ToolResultContent::Text(t) => json!({ "text": t }),
775 };
776 out.push(json!({
781 "functionResponse": {
782 "name": name,
783 "response": response_value,
784 },
785 }));
786 if *is_error {
787 warnings.push(ModelWarning::LossyEncode {
788 field: format!("messages[{msg_idx}].content[{part_idx}].is_error"),
789 detail: "Gemini has no functionResponse error flag — passing through content"
790 .into(),
791 });
792 }
793 } else {
794 warnings.push(ModelWarning::LossyEncode {
795 field: format!("messages[{msg_idx}].content[{part_idx}]"),
796 detail: "non-tool_result part on Role::Tool dropped".into(),
797 });
798 }
799 }
800 out
801}
802
803fn encode_tools(tools: &[crate::ir::ToolSpec], warnings: &mut Vec<ModelWarning>) -> Value {
804 let mut declarations = Vec::new();
805 let mut tool_entries: Vec<Value> = Vec::new();
806 for (idx, t) in tools.iter().enumerate() {
807 match &t.kind {
808 ToolKind::Function { input_schema } => {
809 let parameters = encode_input_schema(
810 input_schema,
811 &format!("tools[{idx}].input_schema"),
812 warnings,
813 );
814 declarations.push(json!({
815 "name": t.name,
816 "description": t.description,
817 "parameters": parameters,
818 }));
819 }
820 ToolKind::WebSearch { .. } => {
821 tool_entries.push(json!({ "google_search": {} }));
824 }
825 ToolKind::CodeExecution => {
826 tool_entries.push(json!({ "code_execution": {} }));
830 }
831 ToolKind::Computer { .. }
833 | ToolKind::TextEditor
834 | ToolKind::Bash
835 | ToolKind::FileSearch { .. }
836 | ToolKind::CodeInterpreter
837 | ToolKind::ImageGeneration
838 | ToolKind::McpConnector { .. }
839 | ToolKind::Memory => warnings.push(ModelWarning::LossyEncode {
840 field: format!("tools[{idx}]"),
841 detail: "Gemini natively ships google_search and code_execution — other \
842 vendor built-ins (computer, text_editor, file_search, …) have no \
843 Gemini equivalent; tool dropped"
844 .into(),
845 }),
846 }
847 }
848 if !declarations.is_empty() {
849 tool_entries.insert(0, json!({ "functionDeclarations": declarations }));
850 }
851 Value::Array(tool_entries)
852}
853
854fn encode_tool_choice(choice: &ToolChoice) -> Value {
855 let mode = match choice {
856 ToolChoice::Auto => "AUTO",
857 ToolChoice::Required | ToolChoice::Specific { .. } => "ANY",
860 ToolChoice::None => "NONE",
861 };
862 let mut config = json!({ "functionCallingConfig": { "mode": mode } });
863 if let ToolChoice::Specific { name } = choice
864 && let Some(cfg) = config
865 .get_mut("functionCallingConfig")
866 .and_then(Value::as_object_mut)
867 {
868 cfg.insert("allowedFunctionNames".into(), json!([name]));
869 }
870 config
871}
872
873fn encode_input_schema(schema: &Value, path: &str, warnings: &mut Vec<ModelWarning>) -> Value {
919 let Some(obj) = schema.as_object() else {
920 return schema.clone();
924 };
925
926 if let Some(collapsed) = try_collapse_const_alternatives(obj, path, warnings) {
927 return collapsed.emit(obj, path, warnings);
928 }
929
930 if obj.contains_key("const") && !obj.contains_key("enum") {
931 return translate_const_literal(obj, path, warnings);
932 }
933
934 let mut out = Map::new();
935 for (key, value) in obj {
936 if key == "type" {
937 apply_type_rule(value, path, warnings, &mut out);
938 continue;
939 }
940 out.insert(key.clone(), walk_schema_key(key, value, path, warnings));
941 }
942 Value::Object(out)
943}
944
945fn walk_schema_key(
958 key: &str,
959 value: &Value,
960 path: &str,
961 warnings: &mut Vec<ModelWarning>,
962) -> Value {
963 match key {
964 "properties" => walk_properties(value, path, warnings),
965 "items" => match value {
966 Value::Array(arr) => Value::Array(
972 arr.iter()
973 .enumerate()
974 .map(|(i, v)| encode_input_schema(v, &format!("{path}.items[{i}]"), warnings))
975 .collect(),
976 ),
977 other => encode_input_schema(other, &format!("{path}.items"), warnings),
978 },
979 "additionalProperties" | "not" => {
980 encode_input_schema(value, &format!("{path}.{key}"), warnings)
981 }
982 "anyOf" | "oneOf" | "allOf" => walk_schema_array(value, path, key, warnings),
983 _ => value.clone(),
984 }
985}
986
987struct ConstCollapse {
995 source_key: &'static str,
999 scalar_type: &'static str,
1002 values: Vec<Value>,
1005 descriptions: Vec<Option<String>>,
1011}
1012
1013impl ConstCollapse {
1014 fn emit(self, obj: &Map<String, Value>, path: &str, warnings: &mut Vec<ModelWarning>) -> Value {
1020 let parent_desc = obj
1021 .get("description")
1022 .and_then(Value::as_str)
1023 .map(str::to_owned);
1024 let alt_block = self.fold_alternative_descriptions();
1025
1026 let mut out = Map::new();
1027 out.insert("type".into(), Value::String(self.scalar_type.to_owned()));
1028 out.insert("enum".into(), Value::Array(self.values));
1029 if let Some(description) = merge_descriptions(parent_desc, alt_block) {
1030 out.insert("description".into(), Value::String(description));
1031 }
1032
1033 for (key, value) in obj {
1034 if matches!(
1035 key.as_str(),
1036 "oneOf" | "anyOf" | "type" | "enum" | "description"
1037 ) {
1038 if key == "type"
1039 && let Some(sibling) = value.as_str()
1040 && sibling != self.scalar_type
1041 {
1042 warnings.push(ModelWarning::LossyEncode {
1047 field: format!("{path}.type"),
1048 detail: format!(
1049 "schema mixes `{source}` of consts (inferred type `{inferred}`) \
1050 with a sibling `type: \"{sibling}\"` — the inferred type is \
1051 authoritative",
1052 source = self.source_key,
1053 inferred = self.scalar_type,
1054 ),
1055 });
1056 }
1057 continue;
1058 }
1059 out.insert(key.clone(), walk_schema_key(key, value, path, warnings));
1060 }
1061 Value::Object(out)
1062 }
1063
1064 fn fold_alternative_descriptions(&self) -> Option<String> {
1068 if self.descriptions.iter().all(Option::is_none) {
1069 return None;
1070 }
1071 let mut lines: Vec<String> = Vec::with_capacity(self.values.len());
1072 for (value, desc) in self.values.iter().zip(self.descriptions.iter()) {
1073 let value_str = value
1074 .as_str()
1075 .map_or_else(|| value.to_string(), str::to_owned);
1076 match desc {
1077 Some(text) => lines.push(format!("- `{value_str}`: {text}")),
1078 None => lines.push(format!("- `{value_str}`")),
1079 }
1080 }
1081 Some(format!("Values:\n{}", lines.join("\n")))
1082 }
1083}
1084
1085fn merge_descriptions(parent: Option<String>, alternatives: Option<String>) -> Option<String> {
1089 match (parent, alternatives) {
1090 (Some(p), Some(a)) => Some(format!("{p}\n\n{a}")),
1091 (Some(p), None) => Some(p),
1092 (None, Some(a)) => Some(a),
1093 (None, None) => None,
1094 }
1095}
1096
1097fn infer_scalar_type(value: &Value) -> Option<&'static str> {
1104 match value {
1105 Value::String(_) => Some("string"),
1106 Value::Bool(_) => Some("boolean"),
1107 Value::Number(n) if n.is_i64() || n.is_u64() => Some("integer"),
1108 Value::Number(_) => Some("number"),
1109 _ => None,
1110 }
1111}
1112
1113fn translate_const_literal(
1128 obj: &Map<String, Value>,
1129 path: &str,
1130 warnings: &mut Vec<ModelWarning>,
1131) -> Value {
1132 let const_value = obj["const"].clone();
1133 let inferred = infer_scalar_type(&const_value);
1134 let sibling_type = obj.get("type").and_then(Value::as_str);
1135
1136 let mut out = Map::new();
1137 match (sibling_type, inferred) {
1138 (Some(sibling), Some(inferred_type)) if sibling != inferred_type => {
1139 warnings.push(ModelWarning::LossyEncode {
1140 field: format!("{path}.type"),
1141 detail: format!(
1142 "standalone `const` inferred type `{inferred_type}` disagrees \
1143 with sibling `type: \"{sibling}\"` — using inferred"
1144 ),
1145 });
1146 out.insert("type".into(), Value::String((*inferred_type).to_owned()));
1147 }
1148 (Some(sibling), _) => {
1149 out.insert("type".into(), Value::String(sibling.to_owned()));
1150 }
1151 (None, Some(inferred_type)) => {
1152 out.insert("type".into(), Value::String((*inferred_type).to_owned()));
1153 }
1154 (None, None) => {
1155 warnings.push(ModelWarning::LossyEncode {
1162 field: format!("{path}.const"),
1163 detail: "non-scalar `const` (object/array/null) lacks an explicit \
1164 sibling `type`; emitted as bare `enum: [<value>]` with no \
1165 type signal"
1166 .into(),
1167 });
1168 }
1169 }
1170 out.insert("enum".into(), Value::Array(vec![const_value]));
1171
1172 for (key, value) in obj {
1175 if matches!(key.as_str(), "const" | "type" | "enum") {
1176 continue;
1177 }
1178 out.insert(key.clone(), walk_schema_key(key, value, path, warnings));
1179 }
1180 Value::Object(out)
1181}
1182
1183fn try_collapse_const_alternatives(
1214 obj: &Map<String, Value>,
1215 path: &str,
1216 warnings: &mut Vec<ModelWarning>,
1217) -> Option<ConstCollapse> {
1218 if obj.contains_key("enum") || obj.contains_key("const") {
1219 return None;
1220 }
1221 if STRUCTURAL_OBJECT_KEYS.iter().any(|k| obj.contains_key(*k)) {
1222 return None;
1223 }
1224 let (source_key, alternatives) = match (obj.get("oneOf"), obj.get("anyOf")) {
1225 (Some(Value::Array(arr)), None) => ("oneOf", arr),
1226 (None, Some(Value::Array(arr))) => ("anyOf", arr),
1227 _ => return None,
1228 };
1229 if alternatives.is_empty() {
1230 return None;
1231 }
1232 let mut values: Vec<Value> = Vec::with_capacity(alternatives.len());
1233 let mut descriptions: Vec<Option<String>> = Vec::with_capacity(alternatives.len());
1234 let mut inferred: Option<&'static str> = None;
1235 for alt in alternatives {
1236 let alt_obj = alt.as_object()?;
1237 let const_value = alt_obj.get("const")?;
1238 let Some(alt_type) = infer_scalar_type(const_value) else {
1239 return decline_collapse(source_key, path, warnings);
1241 };
1242 for key in alt_obj.keys() {
1246 if !matches!(key.as_str(), "const" | "description" | "title" | "type") {
1247 return decline_collapse(source_key, path, warnings);
1248 }
1249 }
1250 if let Some(sibling_type) = alt_obj.get("type").and_then(Value::as_str)
1255 && sibling_type != alt_type
1256 {
1257 return decline_collapse(source_key, path, warnings);
1258 }
1259 match inferred {
1260 None => inferred = Some(alt_type),
1261 Some(existing) if existing == alt_type => {}
1262 Some("integer") if alt_type == "number" => inferred = Some("number"),
1264 Some("number") if alt_type == "integer" => {}
1265 Some(_) => return decline_collapse(source_key, path, warnings),
1266 }
1267 values.push(const_value.clone());
1268 descriptions.push(
1269 alt_obj
1270 .get("description")
1271 .and_then(Value::as_str)
1272 .map(str::to_owned),
1273 );
1274 }
1275 let scalar_type = inferred?;
1276 Some(ConstCollapse {
1277 source_key,
1278 scalar_type,
1279 values,
1280 descriptions,
1281 })
1282}
1283
1284const STRUCTURAL_OBJECT_KEYS: &[&str] = &[
1290 "properties",
1291 "items",
1292 "additionalProperties",
1293 "not",
1294 "allOf",
1295];
1296
1297fn decline_collapse(
1307 source_key: &'static str,
1308 path: &str,
1309 warnings: &mut Vec<ModelWarning>,
1310) -> Option<ConstCollapse> {
1311 warnings.push(ModelWarning::LossyEncode {
1312 field: format!("{path}.{source_key}"),
1313 detail: format!(
1314 "`{source_key}` of consts cannot collapse to a single `type` + `enum` — \
1315 alternatives carry extra keys, mixed JSON types, or non-scalar \
1316 const values; walking the disjunction node-by-node instead"
1317 ),
1318 });
1319 None
1320}
1321
1322fn apply_type_rule(
1329 value: &Value,
1330 path: &str,
1331 warnings: &mut Vec<ModelWarning>,
1332 out: &mut Map<String, Value>,
1333) {
1334 match value {
1335 Value::String(_) => {
1336 out.insert("type".into(), value.clone());
1337 }
1338 Value::Array(types) => {
1339 let mut non_null: Vec<&str> = Vec::with_capacity(types.len());
1340 let mut saw_null = false;
1341 let mut malformed = false;
1342 for entry in types {
1343 match entry.as_str() {
1344 Some("null") => saw_null = true,
1345 Some(other) => non_null.push(other),
1346 None => malformed = true,
1347 }
1348 }
1349 if malformed {
1350 warnings.push(ModelWarning::LossyEncode {
1351 field: format!("{path}.type"),
1352 detail: "type array contains a non-string entry; Gemini will reject".into(),
1353 });
1354 out.insert("type".into(), value.clone());
1355 return;
1356 }
1357 match (non_null.as_slice(), saw_null) {
1358 ([], false) => {
1359 warnings.push(ModelWarning::LossyEncode {
1360 field: format!("{path}.type"),
1361 detail: "empty `type` array; Gemini will reject".into(),
1362 });
1363 out.insert("type".into(), value.clone());
1364 }
1365 ([], true) => {
1366 warnings.push(ModelWarning::LossyEncode {
1374 field: format!("{path}.type"),
1375 detail: "type containing only `null` is degenerate; \
1376 Gemini OpenAPI 3.0 subset has no representation"
1377 .into(),
1378 });
1379 out.insert("type".into(), value.clone());
1380 }
1381 ([single], saw_null) => {
1382 out.insert("type".into(), Value::String((*single).to_owned()));
1383 if saw_null {
1384 out.insert("nullable".into(), Value::Bool(true));
1385 }
1386 }
1387 ([first, rest @ ..], saw_null) => {
1388 let detail = if saw_null {
1389 format!(
1390 "Gemini does not accept multi-type unions; coerced to \
1391 nullable first scalar `{first}` (dropped: {rest:?})"
1392 )
1393 } else {
1394 format!(
1395 "Gemini does not accept multi-type unions; coerced to \
1396 first scalar `{first}` (dropped: {rest:?})"
1397 )
1398 };
1399 warnings.push(ModelWarning::LossyEncode {
1400 field: format!("{path}.type"),
1401 detail,
1402 });
1403 out.insert("type".into(), Value::String((*first).to_owned()));
1404 if saw_null {
1405 out.insert("nullable".into(), Value::Bool(true));
1406 }
1407 }
1408 }
1409 }
1410 _ => {
1411 warnings.push(ModelWarning::LossyEncode {
1412 field: format!("{path}.type"),
1413 detail: "type field is neither a string nor an array; Gemini will reject".into(),
1414 });
1415 out.insert("type".into(), value.clone());
1416 }
1417 }
1418}
1419
1420fn walk_properties(value: &Value, path: &str, warnings: &mut Vec<ModelWarning>) -> Value {
1423 let Value::Object(map) = value else {
1424 return value.clone();
1425 };
1426 let walked: Map<String, Value> = map
1427 .iter()
1428 .map(|(k, v)| {
1429 let child_path = format!("{path}.properties.{k}");
1430 (k.clone(), encode_input_schema(v, &child_path, warnings))
1431 })
1432 .collect();
1433 Value::Object(walked)
1434}
1435
1436fn walk_schema_array(
1440 value: &Value,
1441 path: &str,
1442 key: &str,
1443 warnings: &mut Vec<ModelWarning>,
1444) -> Value {
1445 let Value::Array(arr) = value else {
1446 return value.clone();
1447 };
1448 let walked: Vec<Value> = arr
1449 .iter()
1450 .enumerate()
1451 .map(|(i, v)| {
1452 let child_path = format!("{path}.{key}[{i}]");
1453 encode_input_schema(v, &child_path, warnings)
1454 })
1455 .collect();
1456 Value::Array(walked)
1457}
1458
1459const fn debug_part_kind(part: &ContentPart) -> &'static str {
1460 match part {
1461 ContentPart::Text { .. } => "text",
1462 ContentPart::Image { .. } => "image",
1463 ContentPart::Audio { .. } => "audio",
1464 ContentPart::Video { .. } => "video",
1465 ContentPart::Document { .. } => "document",
1466 ContentPart::Thinking { .. } => "thinking",
1467 ContentPart::Citation { .. } => "citation",
1468 ContentPart::ToolUse { .. } => "tool_use",
1469 ContentPart::ToolResult { .. } => "tool_result",
1470 ContentPart::ImageOutput { .. } => "image_output",
1471 ContentPart::AudioOutput { .. } => "audio_output",
1472 ContentPart::RedactedThinking { .. } => "redacted_thinking",
1473 }
1474}
1475
1476fn decode_candidate(
1479 raw: &Value,
1480 warnings: &mut Vec<ModelWarning>,
1481) -> (Vec<ContentPart>, StopReason) {
1482 let candidate = raw
1483 .get("candidates")
1484 .and_then(Value::as_array)
1485 .and_then(|a| a.first())
1486 .cloned()
1487 .unwrap_or(Value::Null); let parts_raw = candidate
1489 .get("content")
1490 .and_then(|c| c.get("parts"))
1491 .and_then(Value::as_array)
1492 .cloned()
1493 .unwrap_or_default(); let mut parts = Vec::new();
1495 let mut tool_seq: usize = 0;
1503 for (idx, part) in parts_raw.iter().enumerate() {
1504 if part.get("thought").and_then(Value::as_bool) == Some(true) {
1506 let text = str_field(part, "text").to_owned();
1507 let provider_echoes = decode_thought_signature(part).map_or_else(Vec::new, |e| vec![e]);
1508 parts.push(ContentPart::Thinking {
1509 text,
1510 cache_control: None,
1511 provider_echoes,
1512 });
1513 continue;
1514 }
1515 if let Some(text) = part.get("text").and_then(Value::as_str)
1516 && !text.is_empty()
1517 {
1518 let provider_echoes = decode_thought_signature(part).map_or_else(Vec::new, |e| vec![e]);
1521 parts.push(ContentPart::Text {
1522 text: text.to_owned(),
1523 cache_control: None,
1524 provider_echoes,
1525 });
1526 continue;
1527 }
1528 if let Some(call) = part.get("functionCall") {
1529 let name = str_field(call, "name").to_owned();
1530 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]);
1537 parts.push(ContentPart::ToolUse {
1544 id: format!("{name}#{tool_seq}"),
1545 name,
1546 input: args,
1547 provider_echoes,
1548 });
1549 tool_seq = tool_seq.saturating_add(1);
1550 continue;
1551 }
1552 warnings.push(ModelWarning::LossyEncode {
1553 field: format!("candidates[0].content.parts[{idx}]"),
1554 detail: "unknown Gemini part type dropped".into(),
1555 });
1556 }
1557 if let Some(meta) = candidate.get("groundingMetadata")
1559 && let Some(chunks) = meta.get("groundingChunks").and_then(Value::as_array)
1560 {
1561 for chunk in chunks {
1562 if let Some(web) = chunk.get("web") {
1563 let url = str_field(web, "uri").to_owned();
1564 let title = web.get("title").and_then(Value::as_str).map(str::to_owned);
1565 if !url.is_empty() {
1566 parts.push(ContentPart::Citation {
1567 snippet: title.clone().unwrap_or_default(), source: CitationSource::Url { url, title },
1569 cache_control: None,
1570 provider_echoes: Vec::new(),
1571 });
1572 }
1573 }
1574 }
1575 }
1576 let stop_reason = decode_finish_reason(
1577 candidate.get("finishReason").and_then(Value::as_str),
1578 warnings,
1579 );
1580 (parts, stop_reason)
1581}
1582
1583fn decode_finish_reason(reason: Option<&str>, warnings: &mut Vec<ModelWarning>) -> StopReason {
1584 match reason {
1585 Some("STOP") => StopReason::EndTurn,
1586 Some("MAX_TOKENS") => StopReason::MaxTokens,
1587 Some("SAFETY") => StopReason::Refusal {
1592 reason: RefusalReason::Safety,
1593 },
1594 Some("RECITATION") => StopReason::Refusal {
1595 reason: RefusalReason::Recitation,
1596 },
1597 Some(other) => {
1598 warnings.push(ModelWarning::UnknownStopReason {
1599 raw: other.to_owned(),
1600 });
1601 StopReason::Other {
1602 raw: other.to_owned(),
1603 }
1604 }
1605 None => {
1606 warnings.push(ModelWarning::LossyEncode {
1610 field: "finishReason".into(),
1611 detail: "Gemini candidate carried no finishReason — \
1612 IR records `Other{raw:\"missing\"}`"
1613 .into(),
1614 });
1615 StopReason::Other {
1616 raw: "missing".to_owned(),
1617 }
1618 }
1619 }
1620}
1621
1622fn decode_usage(usage: Option<&Value>) -> Usage {
1623 let visible = u_field(usage, "candidatesTokenCount");
1633 let thoughts = u_field(usage, "thoughtsTokenCount");
1634 Usage {
1635 input_tokens: u_field(usage, "promptTokenCount"),
1636 output_tokens: visible.saturating_add(thoughts),
1637 cached_input_tokens: u_field(usage, "cachedContentTokenCount"),
1638 cache_creation_input_tokens: 0,
1639 reasoning_tokens: thoughts,
1640 safety_ratings: Vec::new(),
1641 }
1642}
1643
1644fn decode_safety_ratings(candidate: &Value) -> Vec<SafetyRating> {
1645 let Some(raw) = candidate.get("safetyRatings").and_then(Value::as_array) else {
1646 return Vec::new();
1647 };
1648 raw.iter()
1649 .filter_map(|r| {
1650 let category = match r.get("category").and_then(Value::as_str)? {
1651 "HARM_CATEGORY_HARASSMENT" => SafetyCategory::Harassment,
1652 "HARM_CATEGORY_HATE_SPEECH" => SafetyCategory::HateSpeech,
1653 "HARM_CATEGORY_SEXUALLY_EXPLICIT" => SafetyCategory::SexuallyExplicit,
1654 "HARM_CATEGORY_DANGEROUS_CONTENT" => SafetyCategory::DangerousContent,
1655 other => SafetyCategory::Other(other.to_owned()),
1656 };
1657 let level = match r.get("probability").and_then(Value::as_str)? {
1658 "LOW" => SafetyLevel::Low,
1659 "MEDIUM" => SafetyLevel::Medium,
1660 "HIGH" => SafetyLevel::High,
1661 _ => SafetyLevel::Negligible,
1665 };
1666 Some(SafetyRating { category, level })
1667 })
1668 .collect()
1669}
1670
1671fn str_field<'a>(v: &'a Value, key: &str) -> &'a str {
1672 v.get(key).and_then(Value::as_str).unwrap_or("") }
1674
1675fn u_field(v: Option<&Value>, key: &str) -> u32 {
1676 v.and_then(|inner| inner.get(key))
1677 .and_then(Value::as_u64)
1678 .map_or(0, |n| u32::try_from(n).unwrap_or(u32::MAX)) }
1680
1681#[allow(tail_expr_drop_order, clippy::too_many_lines)]
1684fn stream_gemini(
1685 bytes: BoxByteStream<'_>,
1686 warnings_in: Vec<ModelWarning>,
1687) -> impl futures::Stream<Item = Result<StreamDelta>> + Send + '_ {
1688 async_stream::stream! {
1689 let mut bytes = bytes;
1690 let mut buf: Vec<u8> = Vec::new();
1691 let mut started = false;
1692 let mut warnings_emitted = false;
1693 let mut last_stop = StopReason::EndTurn;
1694 let mut current_tool_open = false;
1695 let mut tool_synth_idx: u64 = 0;
1696
1697 while let Some(chunk) = bytes.next().await {
1698 match chunk {
1699 Ok(b) => buf.extend_from_slice(&b),
1700 Err(e) => {
1701 yield Err(e);
1702 return;
1703 }
1704 }
1705 if !warnings_emitted {
1706 warnings_emitted = true;
1707 for w in &warnings_in {
1708 yield Ok(StreamDelta::Warning(w.clone()));
1709 }
1710 }
1711 while let Some(pos) = find_double_newline(&buf) {
1712 let frame: Vec<u8> = buf.drain(..pos.saturating_add(2)).collect();
1713 let Ok(frame_str) = std::str::from_utf8(&frame) else {
1714 continue;
1715 };
1716 let Some(payload) = parse_sse_data(frame_str) else {
1717 continue;
1718 };
1719 let Ok(event) = serde_json::from_str::<Value>(&payload) else {
1720 yield Err(Error::invalid_request(format!(
1721 "Gemini stream: malformed chunk: {payload}"
1722 )));
1723 return;
1724 };
1725 if !started {
1726 started = true;
1727 let model = str_field(&event, "modelVersion").to_owned();
1728 yield Ok(StreamDelta::Start {
1729 id: String::new(),
1730 model,
1731 provider_echoes: Vec::new(),
1732 });
1733 }
1734 if let Some(usage) = event.get("usageMetadata") {
1735 yield Ok(StreamDelta::Usage(decode_usage(Some(usage))));
1736 }
1737 let Some(candidate) = event
1738 .get("candidates")
1739 .and_then(Value::as_array)
1740 .and_then(|a| a.first())
1741 else {
1742 continue;
1743 };
1744 if let Some(reason) = candidate.get("finishReason").and_then(Value::as_str) {
1745 last_stop = decode_finish_reason(Some(reason), &mut Vec::new());
1746 }
1747 let Some(parts) = candidate
1748 .get("content")
1749 .and_then(|c| c.get("parts"))
1750 .and_then(Value::as_array)
1751 else {
1752 continue;
1753 };
1754 for part in parts {
1755 if part.get("thought").and_then(Value::as_bool) == Some(true) {
1759 if current_tool_open {
1760 yield Ok(StreamDelta::ToolUseStop);
1761 current_tool_open = false;
1762 }
1763 let text = part
1764 .get("text")
1765 .and_then(Value::as_str)
1766 .unwrap_or("") .to_owned();
1768 let provider_echoes =
1769 decode_thought_signature(part).map_or_else(Vec::new, |e| vec![e]);
1770 if !text.is_empty() || !provider_echoes.is_empty() {
1771 yield Ok(StreamDelta::ThinkingDelta {
1772 text,
1773 provider_echoes,
1774 });
1775 }
1776 continue;
1777 }
1778 if let Some(text) = part.get("text").and_then(Value::as_str)
1779 && !text.is_empty()
1780 {
1781 if current_tool_open {
1782 yield Ok(StreamDelta::ToolUseStop);
1783 current_tool_open = false;
1784 }
1785 let provider_echoes =
1786 decode_thought_signature(part).map_or_else(Vec::new, |e| vec![e]);
1787 yield Ok(StreamDelta::TextDelta {
1788 text: text.to_owned(),
1789 provider_echoes,
1790 });
1791 continue;
1792 }
1793 if let Some(call) = part.get("functionCall") {
1794 if current_tool_open {
1795 yield Ok(StreamDelta::ToolUseStop);
1796 }
1797 let name = str_field(call, "name").to_owned();
1798 let args = call.get("args").cloned().unwrap_or_else(|| json!({})); let synth_id = format!("{name}#{tool_synth_idx}");
1800 tool_synth_idx = tool_synth_idx.saturating_add(1);
1801 let provider_echoes =
1802 decode_thought_signature(part).map_or_else(Vec::new, |e| vec![e]);
1803 yield Ok(StreamDelta::ToolUseStart {
1804 id: synth_id,
1805 name,
1806 provider_echoes,
1807 });
1808 yield Ok(StreamDelta::ToolUseInputDelta {
1809 partial_json: args.to_string(),
1810 });
1811 current_tool_open = true;
1812 }
1813 }
1814 }
1815 }
1816 if current_tool_open {
1817 yield Ok(StreamDelta::ToolUseStop);
1818 }
1819 yield Ok(StreamDelta::Stop {
1820 stop_reason: last_stop,
1821 });
1822 }
1823}
1824
1825fn find_double_newline(buf: &[u8]) -> Option<usize> {
1826 let lf = buf.windows(2).position(|w| w == b"\n\n");
1827 let crlf = buf.windows(4).position(|w| w == b"\r\n\r\n");
1828 match (lf, crlf) {
1829 (Some(a), Some(b)) => Some(a.min(b)),
1830 (Some(a), None) => Some(a),
1831 (None, Some(b)) => Some(b),
1832 (None, None) => None,
1833 }
1834}
1835
1836fn parse_sse_data(frame: &str) -> Option<String> {
1837 let mut out: Option<String> = None;
1838 for line in frame.lines() {
1839 if let Some(rest) = line.strip_prefix("data:") {
1840 let trimmed = rest.strip_prefix(' ').unwrap_or(rest); match &mut out {
1842 Some(existing) => {
1843 existing.push('\n');
1844 existing.push_str(trimmed);
1845 }
1846 None => out = Some(trimmed.to_owned()),
1847 }
1848 }
1849 }
1850 out
1851}