Skip to main content

entelix_core/codecs/
gemini.rs

1//! `GeminiCodec` — IR ⇄ Google Gemini `generateContent` API
2//! (`POST /v1beta/models/{model}:generateContent`,
3//!   `POST /v1beta/models/{model}:streamGenerateContent?alt=sse`).
4//!
5//! Wire format reference:
6//! <https://ai.google.dev/api/rest/v1beta/models/generateContent>.
7//!
8//! Notable mappings:
9//!
10//! - IR `messages` → `contents: [{role: "user"|"model", parts: [...]}]`.
11//!   Gemini uses `"model"` for assistant turns.
12//! - IR `system: Option<String>` + IR `Role::System` → top-level
13//!   `systemInstruction: { parts: [{ text }] }`.
14//! - IR `Role::Tool` → `contents: [{role: "user", parts: [{
15//!   functionResponse: { name, response: { ... } } }]}]`. Gemini does
16//!   not roundtrip `tool_use_id`; the codec records the `LossyEncode`.
17//! - IR `ContentPart::ToolUse` → `parts: [{ functionCall: { name, args } }]`.
18//! - IR `tools` → `tools: [{ functionDeclarations: [...] }]`. Each
19//!   declaration's `parameters` is dialect-translated from JSON
20//!   Schema 2020-12 (`type: [T, "null"]`, `oneOf`/`anyOf` of `{const}`
21//!   alternatives, standalone `{const}` literals) into Gemini's
22//!   OpenAPI 3.0 subset (`nullable: true`, `enum`) at encode time —
23//!   see [`encode_input_schema`] for the three translation rules.
24//! - IR `tool_choice` → `toolConfig: { functionCallingConfig: { mode } }`.
25//! - Streaming SSE: `data: {...}\n\n` per chunk; each chunk is a full
26//!   `GenerateContentResponse` with delta text in `candidates[0].content`.
27
28#![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
46/// Provider key for [`GeminiCodec`] (and its [`super::VertexGeminiCodec`]
47/// composition wrapper) — identifies this vendor's entries in
48/// [`ProviderEchoSnapshot`]. The wire shape is identical across AI
49/// Studio and Vertex AI; only the routing differs.
50const PROVIDER_KEY: &str = "gemini";
51
52/// Wire field name Gemini uses for the opaque thought-signature
53/// round-trip token. Vertex AI strictly rejects camelCase
54/// (`thoughtSignature`) on encode and requires snake_case; AI Studio
55/// accepts both. Encoding always emits snake_case for portability.
56/// Decoder accepts both because Gemini servers have historically
57/// emitted both spellings depending on transport and version.
58const WIRE_THOUGHT_SIGNATURE: &str = "thought_signature";
59const WIRE_THOUGHT_SIGNATURE_LEGACY: &str = "thoughtSignature";
60
61/// Extract a Gemini `thought_signature` from a `Part` (or any JSON
62/// object that may carry it as a sibling field), accepting both
63/// snake_case and the legacy camelCase spelling. Returns the wrapping
64/// `ProviderEchoSnapshot` carrier ready to attach to a `ContentPart`.
65fn 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)) // silent-fallback-ok: snake_case + camelCase are both valid vendor spellings of the same field — accepting either is the contract, no default injected.
69        .and_then(Value::as_str)?;
70    Some(ProviderEchoSnapshot::for_provider(
71        PROVIDER_KEY,
72        WIRE_THOUGHT_SIGNATURE,
73        sig.to_owned(),
74    ))
75}
76
77/// Look up this codec's `thought_signature` payload from a part's
78/// echoes. Returns the raw signature string so the caller can stamp
79/// it onto the appropriate wire-format object.
80fn 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/// Stateless codec for the Gemini `generateContent` family of endpoints.
86#[derive(Clone, Copy, Debug, Default)]
87pub struct GeminiCodec;
88
89impl GeminiCodec {
90    /// Create a fresh codec instance.
91    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(); // Gemini one-shot responses lack a top-level id
139        let model = str_field(&raw, "modelVersion").to_owned();
140        let mut usage = decode_usage(raw.get("usageMetadata"));
141        // Lift the candidate-scoped safetyRatings onto Usage so consumers
142        // see safety on a single canonical channel.
143        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
172// ── body builders ──────────────────────────────────────────────────────────
173
174fn 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
233/// Read [`crate::ir::GeminiExt`] and merge each set field into the
234/// wire body. `candidate_count` lands inside `generationConfig`,
235/// creating the map if `build_body` did not already emit one.
236/// Foreign-vendor extensions surface as
237/// [`ModelWarning::ProviderExtensionIgnored`].
238fn 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    // Gemini has no native parallel-tool toggle on the
245    // generateContent surface. Surface the lossy snap so the operator
246    // sees their `parallel_tool_calls` setting was dropped on the
247    // wire instead of debugging a silently-ignored knob.
248    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            // Append the parameterless `url_context` tool to the
281            // request's tools array (allocate the array if it
282            // wasn't populated by `encode_tools`).
283            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
327/// Resolve [`OutputStrategy`] and emit the Gemini native
328/// `responseJsonSchema` (Native) or a forced-tool surface (Tool).
329/// `Auto` resolves to `Native` — Gemini's `responseJsonSchema` is
330/// the most direct surface and Gemini 2.5+ always strict-validates.
331fn 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    // Operator-supplied `response_format` schemas route through the
338    // same strip funnel every advertised tool receives via
339    // `SchemaToolAdapter`. Both `responseJsonSchema` (Native, vendor-
340    // side validation, but `schemars` envelope keys would still leak
341    // operator content to the wire) and the synthetic function
342    // declaration's `parameters` (Tool, forced-call) consume the
343    // pre-stripped form. The Tool path additionally runs the
344    // dialect translation rules so the synthetic decl is no different
345    // from a registered function tool on the Gemini wire.
346    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            // Forced single function call. Gemini wraps tools as
366            // `tools[0].functionDeclarations[0]` and `toolConfig`
367            // narrows the selection; `mode: "ANY"` +
368            // `allowedFunctionNames: [name]` is the canonical
369            // forced-call shape.
370            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
415/// Gemini model family detection — 3.x uses `thinkingLevel`
416/// (discrete bucket), 2.5 uses `thinkingBudget` (integer token
417/// count, with `-1` = auto and `0` = disable on Flash only).
418/// Detection by model-string prefix because Gemini's API does not
419/// expose a wire signal for "this model accepts which thinking
420/// shape".
421fn is_gemini_3(model: &str) -> bool {
422    model.starts_with("gemini-3")
423}
424
425/// Gemini 2.5 Flash accepts `thinkingBudget: 0` to disable thinking;
426/// Pro cannot disable. Detection by model-string prefix.
427fn 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
431/// Translate the cross-vendor [`ReasoningEffort`] knob onto
432/// Gemini's `generationConfig.thinkingConfig`. Per:
433///
434/// 2.5 (`thinkingBudget` integer):
435/// - `Off` → `0` (Flash only — Pro emits LossyEncode → `512`)
436/// - `Minimal` → `512`
437/// - `Low` → `1024`
438/// - `Medium` → `8192`
439/// - `High` → `24576`
440/// - `Auto` → `-1`
441/// - `VendorSpecific(s)` — `s` parses as decimal `thinkingBudget`;
442///   non-numeric emits LossyEncode → `Medium`.
443///
444/// 3.x (`thinkingLevel` enum):
445/// - `Off` → LossyEncode → `"minimal"` (Gemini 3 cannot disable)
446/// - `Minimal/Low/Medium/High` → `"minimal"/"low"/"medium"/"high"`
447/// - `Auto` → LossyEncode → `"high"` (no auto bucket)
448/// - `VendorSpecific(s)` — literal `thinkingLevel`.
449fn 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        // Gemini 2.5 (default for any non-3.x prefix — falls
485        // through cleanly for 2.5 Pro / Flash / Flash-Lite).
486        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
542// ── encode helpers ─────────────────────────────────────────────────────────
543
544fn 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); // silent-fallback-ok: caller-supplied fallback_mime is the typed MediaSource defaulting policy
674            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); // silent-fallback-ok: caller-supplied fallback_mime is the typed MediaSource defaulting policy
680            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                // Gemini's wire shape uses the assistant-emitted function name
715                // as the round-trip key — there is no separate id field. The
716                // `tool_use_id` round-trip is preserved at the IR layer by
717                // letting the codec re-derive the id on decode from the same
718                // `name + args` shape.
719                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            // Gemini's `functionResponse` keys correlation by
777            // `name`, not by id. The IR carries the original name on
778            // `ContentPart::ToolResult` precisely so this codec can
779            // emit it verbatim — no placeholder, no LossyEncode.
780            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                // Gemini's google_search built-in is parameterless — domain
822                // restrictions and use caps are not exposed on the wire.
823                tool_entries.push(json!({ "google_search": {} }));
824            }
825            ToolKind::CodeExecution => {
826                // Gemini's code_execution built-in: a sandboxed Python REPL
827                // the model invokes autonomously when a turn benefits from
828                // computation. Parameterless on the wire.
829                tool_entries.push(json!({ "code_execution": {} }));
830            }
831            // Anthropic / OpenAI vendor built-ins have no Gemini equivalent.
832            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        // Gemini's "ANY" forces a tool call; for `Specific` we additionally
858        // narrow via `allowedFunctionNames` below.
859        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
873/// Translate an IR JSON Schema (a `serde_json::Value` produced by
874/// `LlmFacingSchema::strip` over the schemars output) into Gemini's
875/// OpenAPI 3.0 subset dialect.
876///
877/// Gemini's `functionDeclarations[].parameters` does **not** accept
878/// three JSON Schema 2020-12 keywords that OpenAI / Anthropic / Bedrock
879/// consume natively. The codec rewrites each at encode time:
880///
881/// - **Rule A — nullable array form.** `type: [T, "null"]` (`schemars`
882///   for `Option<T>`) → `type: T` plus a sibling `nullable: true`.
883///   Gemini's protobuf schema types `type` as a scalar, so any array
884///   on that field fails with `Proto field is not repeating, cannot
885///   start list`.
886/// - **Rule B — `oneOf`/`anyOf` of `{const}` alternatives.** The shape
887///   `schemars` emits for Rust enum variants — bare `{const}` (no
888///   doc comment) or `{const, description, type}` (variant with a
889///   doc comment, the dominant production shape) → a single schema
890///   with `type` + `enum`. Per-variant `description` folds into the
891///   parent's `description` as a Markdown bullet list so the model
892///   retains per-value docstrings.
893/// - **Rule C — standalone `const` literal.** Any schema-walk
894///   position carrying `const` outside a `oneOf`/`anyOf` collapse
895///   target — typically the discriminator property of an
896///   internally-tagged enum (`#[serde(tag = "kind")] enum E { A {…} }`
897///   emits each variant's `kind` field as `{"type": "string",
898///   "const": "<variant>"}`) → `{type, enum: [const_value]}`.
899///
900/// Translations are vendor-specific — OpenAI / Anthropic / Bedrock
901/// accept the JSON Schema 2020-12 forms verbatim — so they live
902/// inside this codec rather than in `LlmFacingSchema::strip` (which
903/// stays vendor-agnostic and only strips envelope-meta keys every
904/// vendor ignores).
905///
906/// Inputs Gemini cannot represent (degenerate `type: ["null"]`,
907/// multi-type unions, mixed-type const disjunctions, type/const
908/// disagreement, …) surface through [`ModelWarning::LossyEncode`]
909/// so the operator sees the coercion rather than chasing the
910/// vendor's 400 down a stack trace.
911///
912/// The walk dispatches in three phases per schema node:
913/// 1. Rule B collapse takes the whole node when the parent is a pure
914///    const disjunction.
915/// 2. Rule C takes the whole node when `const` appears alone.
916/// 3. Otherwise the default key walk handles `type` (Rule A) and
917///    recurses into structural keys via [`walk_schema_key`].
918fn encode_input_schema(schema: &Value, path: &str, warnings: &mut Vec<ModelWarning>) -> Value {
919    let Some(obj) = schema.as_object() else {
920        // Boolean schema (`additionalProperties: false`, `items: true`)
921        // or any non-object leaf — clone through unchanged. The
922        // schema-walk targets only object nodes.
923        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
945/// Walk a single non-`type` schema key. Structural keys
946/// (`properties`, `items`, `additionalProperties`, `not`, `anyOf` /
947/// `oneOf` / `allOf`) recurse into [`encode_input_schema`] so the
948/// dialect rules apply at every depth; everything else (`description`,
949/// `enum`, `default`, `required`, `format`, bounds, vendor metadata)
950/// clones through verbatim because `LlmFacingSchema::strip` already
951/// removed envelope-meta the model should not see.
952///
953/// Shared by `encode_input_schema`'s main walk, `ConstCollapse::emit`'s
954/// sibling walk, and `translate_const_literal`'s post-prologue walk —
955/// new key support (or a key reclassification when Gemini's dialect
956/// expands) lands in one place.
957fn 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            // `items` is either a single schema (homogeneous array)
967            // or an array of schemas (tuple validation — Gemini does
968            // not accept this, but the walk preserves whatever was
969            // there so the vendor's rejection carries the original
970            // shape rather than our re-encoded guess).
971            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
987/// Outcome of the `oneOf` / `anyOf` const-collapse rule. Built only
988/// when every alternative is a `{const: X}` object — possibly
989/// decorated with the per-variant doc keys `schemars 1.x` emits for
990/// Rust enum variants (`description`, `title`) and a redundant
991/// `type` consistent with the const's scalar shape. Other shapes
992/// (struct-variant alternatives with `properties`, mixed-type
993/// consts, non-scalar consts) decline collapse.
994struct ConstCollapse {
995    /// The disjunction keyword (`"oneOf"` or `"anyOf"`) that was
996    /// collapsed. Recorded so the emit step knows which input key to
997    /// drop on its way to the output map.
998    source_key: &'static str,
999    /// Inferred Gemini OpenAPI 3.0 scalar type for the collapsed
1000    /// `enum`. Drives the emitted `type` field.
1001    scalar_type: &'static str,
1002    /// The collected `const` values, in source order, ready to land
1003    /// inside the emitted `enum` array.
1004    values: Vec<Value>,
1005    /// Per-alternative `description` field, in source order — `None`
1006    /// when that alternative omitted the key. Folded into the parent
1007    /// schema's `description` so the model retains the per-value
1008    /// docstring `schemars` generated from each Rust enum variant's
1009    /// doc comment.
1010    descriptions: Vec<Option<String>>,
1011}
1012
1013impl ConstCollapse {
1014    /// Emit the collapsed schema. Drops the source disjunction key
1015    /// and the input's sibling `type` (collapse-inferred type wins).
1016    /// Per-alternative descriptions fold into the parent's
1017    /// `description`; the original parent description (if any) leads
1018    /// and the per-value lines follow.
1019    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                    // Operator-supplied `type` disagrees with the
1043                    // const-inferred one — surface the discarded
1044                    // value so an inconsistent schema does not
1045                    // silently coerce.
1046                    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    /// Render the per-alternative descriptions as a single Markdown
1065    /// bullet block. `None` when every alternative omitted the key
1066    /// (nothing to fold).
1067    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
1085/// Join the parent schema's original `description` (if any) with the
1086/// collapsed alternatives' folded description block (if any). `None`
1087/// only when neither source had a description.
1088fn 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
1097/// Map a JSON value to its Gemini OpenAPI 3.0 scalar `type` name.
1098/// `Number` splits by `is_i64()` / `is_u64()` so int-only consts emit
1099/// `"integer"` and float consts emit `"number"`. Non-scalar values
1100/// (object, array, null) return `None` — Gemini's enum acceptance is
1101/// not documented for non-scalars, so the caller decides whether to
1102/// proceed or decline.
1103fn 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
1113/// Translate a schema carrying a standalone `const` literal into the
1114/// equivalent single-value `enum` shape. Gemini's OpenAPI 3.0 subset
1115/// rejects `const`; `enum: [X]` is the lossless equivalent every
1116/// JSON Schema validator already treats identically.
1117///
1118/// Fires on any schema-walk position whose object has `const` and
1119/// lacks `enum` — typically the discriminator property of an
1120/// internally-tagged enum (`#[serde(tag = "kind")]` → schemars emits
1121/// each variant's `kind` field as `{"type":"string","const":"<name>"}`).
1122///
1123/// Sibling `type` is honoured when consistent with the const's
1124/// inferred scalar type; inconsistent sibling types coerce to the
1125/// inferred type and emit `LossyEncode` so an operator-buggy schema
1126/// surfaces rather than silently coercing.
1127fn 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            // Non-scalar const with no operator-supplied `type` — the
1156            // codec emits a bare `enum: [<value>]` with no type signal,
1157            // because there is no scalar type to infer. Surface the
1158            // missing-type situation so the operator sees what the
1159            // codec produced; the vendor's response to a typeless
1160            // enum is the vendor's call to make.
1161            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    // Walk the remaining keys — `const`, `type`, and `enum` were
1173    // absorbed above.
1174    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
1183/// Try to collapse a `oneOf` / `anyOf` array of const alternatives
1184/// into a single `type` + `enum`. Returns `None` when any condition
1185/// for safe collapse is unmet — the caller then walks the
1186/// disjunction as a normal schema array.
1187///
1188/// Conditions (all must hold):
1189/// 1. The parent has either `oneOf` or `anyOf` (but not both).
1190/// 2. The disjunction has at least one alternative.
1191/// 3. The parent does not carry structural object-shape keys
1192///    (`properties` / `items` / `additionalProperties` / `not` /
1193///    `allOf`). Mixing `oneOf:[{const}]` with object-shape keys is
1194///    an operator anti-pattern that cannot be safely collapsed —
1195///    the resulting `{type:"string", enum:[…], properties:{…}}`
1196///    would be structurally contradictory.
1197/// 4. The parent does not already carry a competing value-spec
1198///    (`enum` or standalone `const`) that the collapse would
1199///    silently override — those signal an operator-buggy schema
1200///    whose original intent must reach the vendor unaltered.
1201/// 5. Every alternative is an object whose key set is a subset of
1202///    `{const, description, title, type}`. The `const` key is
1203///    required; the others are the doc-decoration `schemars 1.x`
1204///    emits for documented Rust enum variants. Any other key
1205///    (e.g. `properties` in struct-variant enums) declines.
1206/// 6. Every `const` value is the same JSON scalar shape (string /
1207///    integer / float / bool). Mixed-type, object, array, and null
1208///    consts decline.
1209/// 7. When an alternative carries a sibling `type`, it must match
1210///    the const's inferred scalar type. Inconsistency declines —
1211///    the schema is operator-buggy and should surface as the
1212///    vendor's rejection rather than a silent coercion.
1213fn 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            // Object / array / null consts: conservative skip.
1240            return decline_collapse(source_key, path, warnings);
1241        };
1242        // Verify the alternative's key set is the allowed subset.
1243        // Any unexpected key signals a richer shape (struct-variant
1244        // enum, vendor extension, …) that cannot be safely folded.
1245        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 the alternative carries a sibling `type`, it must match
1251        // the const-inferred scalar type — otherwise the operator's
1252        // schema is internally inconsistent and the collapse would
1253        // silently pick one side.
1254        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            // Number promotes integer → number when mixed integer + float.
1263            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
1284/// Keys whose presence on a parent schema means the schema is
1285/// structurally object-shaped, not enum-shaped — collapsing a
1286/// `oneOf` of consts in that context would produce a contradictory
1287/// `{type:"string", enum:[…], properties:{…}}`. Used by
1288/// [`try_collapse_const_alternatives`].
1289const STRUCTURAL_OBJECT_KEYS: &[&str] = &[
1290    "properties",
1291    "items",
1292    "additionalProperties",
1293    "not",
1294    "allOf",
1295];
1296
1297/// Helper used by `try_collapse_const_alternatives` when the
1298/// disjunction *looks* like a const-only collapse target but a
1299/// structural condition fails (decorated alternative, type-mixed
1300/// consts, object const, …). Emits one `LossyEncode` so the operator
1301/// sees the pattern was recognised and intentionally not collapsed.
1302/// The warning reports the codec's decision; the disjunction is
1303/// still walked node-by-node, so individual const alternatives
1304/// downstream may still translate via Rule C — predicting the
1305/// vendor's final acceptance is the vendor's job, not the codec's.
1306fn 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
1322/// Apply Rule A (`type: [T, "null"]` → `type: T` + `nullable: true`)
1323/// to a `type` field's value, writing the resulting key(s) into the
1324/// output map. Inputs Gemini cannot represent (`type: ["null"]`,
1325/// empty array, multi-type unions, non-string/array scalar) surface
1326/// through `LossyEncode` and are coerced toward the closest Gemini-
1327/// representable shape; the standalone scalar case passes through.
1328fn 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                    // Degenerate input — a field whose only valid value
1367                    // is `null` has no representation in Gemini's
1368                    // OpenAPI 3.0 subset. Pass the original `type` key
1369                    // through so Vertex's rejection identifies the
1370                    // operator's actual field rather than a synthesized
1371                    // typeless `nullable: true` substitute (which would
1372                    // itself be invalid).
1373                    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
1420/// Recurse into a `properties` map — preserve user-named keys
1421/// verbatim, walk each value as a schema.
1422fn 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
1436/// Recurse into a `oneOf` / `anyOf` / `allOf` array — every element
1437/// is itself a schema. The collapse rule has already declined (the
1438/// caller is in the non-collapse branch), so this is a vanilla walk.
1439fn 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
1476// ── decode helpers ─────────────────────────────────────────────────────────
1477
1478fn 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); // silent-fallback-ok: response with no candidates array → Null (downstream nested accessors propagate as None)
1488    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(); // silent-fallback-ok: candidate with no parts array → empty Vec (downstream loop iterates over zero items)
1494    let mut parts = Vec::new();
1495    // Per-response counter of `functionCall` parts seen so far —
1496    // synthesized into the tool-use id so streaming and
1497    // non-streaming decoders produce the same id sequence
1498    // (`{name}#{tool_seq}`) for the same logical sequence of tool
1499    // calls. Using the part-array index directly would diverge:
1500    // non-streaming sees `[text, fnCall, text, fnCall]` at indices
1501    // 1, 3 while streaming would emit them as 0, 1.
1502    let mut tool_seq: usize = 0;
1503    for (idx, part) in parts_raw.iter().enumerate() {
1504        // Thinking blocks: parts marked `thought: true` carry reasoning text.
1505        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            // Plain `text` parts may also carry `thought_signature`
1519            // on reasoning turns — preserve it for round-trip.
1520            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!({})); // silent-fallback-ok: functionCall without args = empty-args call (vendor sometimes omits when schema has no required fields)
1531            // `thought_signature` rides on the `Part` itself
1532            // (sibling of `functionCall`), not inside the inner
1533            // object — Gemini 3.x rejects the next turn with HTTP
1534            // 400 if the first `functionCall` of a step is missing
1535            // its echo.
1536            let provider_echoes = decode_thought_signature(part).map_or_else(Vec::new, |e| vec![e]);
1537            // Gemini does not round-trip a tool-use id — derive one
1538            // from `(name, tool_seq)` where `tool_seq` is a per-
1539            // response counter of function-call parts. Streaming
1540            // decoder uses the identical counter, so the same
1541            // logical sequence of tool calls produces the same id
1542            // sequence regardless of code path.
1543            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    // Grounding metadata → Citation parts.
1558    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(), // silent-fallback-ok: grounding citation without title → snippet "" (the URL is the load-bearing pointer; title is purely descriptive)
1568                        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        // Gemini distinguishes safety blocks from copyright
1588        // (RECITATION) blocks — preserve the distinction in IR so
1589        // dashboards can split by cause instead of collapsing both
1590        // into a single refusal bucket.
1591        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            // Invariant #15 — silent EndTurn fallback was masking
1607            // truncated stream payloads from callers. Record as
1608            // Other + warning instead.
1609            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    // Cross-vendor `Usage::output_tokens` invariant — *total billable
1624    // output*. Gemini reports the visible-token slice in
1625    // `candidatesTokenCount` and bills thinking tokens separately
1626    // (vendor docs: "response pricing is the sum of output tokens
1627    // and thinking tokens"). OpenAI / Anthropic already include
1628    // their reasoning slices inside `output_tokens` / `completion_tokens`,
1629    // so the codec aligns Gemini to the same shape. `reasoning_tokens`
1630    // remains as an informational sub-counter operators can isolate
1631    // for thinking-only cost attribution.
1632    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                // `"NEGLIGIBLE"` and any unrecognised vendor label collapse
1662                // to the lowest bucket — the IR's four-bucket scale is the
1663                // canonical resolution.
1664                _ => 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("") // silent-fallback-ok: missing optional string field
1673}
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)) // silent-fallback-ok: missing usage metric = 0 (vendor didn't report = unused); u64→u32 saturate
1679}
1680
1681// ── SSE streaming parser ───────────────────────────────────────────────────
1682
1683#[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                    // Thinking branches first so a `text` payload marked
1756                    // `thought: true` routes to ThinkingDelta rather than
1757                    // TextDelta.
1758                    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("") // silent-fallback-ok: missing thinking text → empty body; downstream is_empty() guard suppresses the StreamDelta
1767                            .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!({})); // silent-fallback-ok: streaming functionCall without args = empty-args call
1799                        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); // silent-fallback-ok: SSE data line may or may not have leading space; idiomatic strip-or-pass-through
1841            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}