Skip to main content

rig_core/providers/gemini/
streaming.rs

1use serde::{Deserialize, Serialize};
2
3use super::completion::gemini_api_types::{
4    ContentCandidate, FinishReason, Part, PartKind, UsageMetadata, map_finish_reason,
5};
6use super::completion::{
7    CompletionModel, PROVIDER_NAME, create_request_body, function_call_finish_reason_error,
8    resolve_request_model, streaming_endpoint,
9};
10use crate::completion::{CompletionError, CompletionRequest};
11use crate::http_client::HttpClientExt;
12use crate::http_client::sse::GenericEventSource;
13use crate::providers::internal::adapter::{AdapterOutput, WireAdapter, WireFrame};
14use crate::providers::internal::sse_transport::{
15    OpenLog, SseTransportOptions, open_wire_stream, skip_blank_frames,
16};
17use crate::providers::internal::wire::{self, WireEvent};
18use crate::streaming;
19use crate::telemetry::{CompletionOperation, CompletionSpanBuilder, SpanCombinator};
20
21/// Part-kind interpretation shared by the Gemini wires whose payloads
22/// coincide: REST `streamGenerateContent` and the Interactions API both
23/// deliver whole function calls and identity-less thought fragments.
24pub(crate) mod shared_parts {
25    use serde_json::Value;
26
27    use crate::streaming::{MintKind, RawStreamingChoice, RawStreamingToolCall, StreamPartId};
28
29    /// Gemini thought parts carry no id or block boundaries; a per-stream
30    /// constant minted identity keeps all thought deltas merging into one
31    /// item, and the core accumulator's minted-id boundary splits items
32    /// around other output. Minted, so it can never reach a request.
33    pub(crate) const REASONING_ID: StreamPartId = StreamPartId::minted(MintKind::Reasoning, 0);
34
35    /// A whole function-call part as a canonical tool call (Gemini never
36    /// streams arguments incrementally).
37    pub(crate) fn function_call<R>(
38        name: String,
39        args: Value,
40        wire_id: Option<String>,
41        signature: Option<String>,
42        tool_ids: &mut crate::streaming::SyntheticIds,
43    ) -> RawStreamingChoice<R> {
44        // Never fabricate the identifier that travels upstream: the wire's
45        // own id (when Gemini supplies one) is both the part identity and
46        // the correlation id; an id-less call keys the stream by a minted
47        // identity — counted up per stream, so two id-less calls never
48        // collide on one key — and replays with the id absent. The tool
49        // *name* is never an identity — two calls to the same tool in one
50        // turn must stay distinct, correlated by order and by the
51        // rig-internal call id.
52        let tool_id = wire_id.clone().and_then(crate::streaming::WireId::new);
53        let id = tool_id
54            .as_ref()
55            .map(|id| StreamPartId::wire(id.as_str()))
56            .unwrap_or_else(|| tool_ids.mint());
57        let tool_call = RawStreamingToolCall {
58            id,
59            tool_id,
60            internal_call_id: crate::id::generate(),
61            // Gemini is a single-identifier wire: its one id travels as
62            // `tool_id` and `call_id` stays unset. Filling both from the same
63            // id would take the dual-wire arm downstream and fabricate an
64            // item id Gemini never issued.
65            call_id: None,
66            name,
67            arguments: args,
68            signature,
69            additional_params: None,
70        };
71        RawStreamingChoice::ToolCall(tool_call)
72    }
73}
74
75/// The usage record on a `streamGenerateContent` chunk.
76///
77/// Identical to the unary wire's [`UsageMetadata`] — Gemini sends the same
78/// `usageMetadata` object on streaming frames — so the streaming name is an
79/// alias, not a second declaration that can drift from it.
80pub type PartialUsage = UsageMetadata;
81
82#[derive(Debug, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct StreamGenerateContentResponse {
85    pub response_id: Option<String>,
86    /// Candidate responses from the model.
87    #[serde(default)]
88    pub candidates: Vec<ContentCandidate>,
89    pub model_version: Option<String>,
90    pub usage_metadata: Option<PartialUsage>,
91}
92
93#[derive(Clone, Debug, Serialize, Deserialize)]
94pub struct StreamingCompletionResponse {
95    pub usage_metadata: PartialUsage,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub finish_reason: Option<FinishReason>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub finish_message: Option<String>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub model_version: Option<String>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub response_id: Option<String>,
104}
105
106impl From<&StreamingCompletionResponse> for crate::completion::Usage {
107    fn from(value: &StreamingCompletionResponse) -> crate::completion::Usage {
108        (&value.usage_metadata).into()
109    }
110}
111
112impl From<StreamingCompletionResponse> for crate::completion::Usage {
113    fn from(value: StreamingCompletionResponse) -> crate::completion::Usage {
114        (&value).into()
115    }
116}
117
118/// Normalize Gemini's terminal streaming record.
119///
120/// Infallible in practice, but stated as a `Result` because
121/// [`crate::streaming::normalize_stream`] maps terminal records through a
122/// fallible closure.
123fn map_stream_final(
124    response: StreamingCompletionResponse,
125) -> Result<streaming::StreamFinal, CompletionError> {
126    let finish_reason = response.finish_reason.as_ref().and_then(map_finish_reason);
127
128    Ok(
129        streaming::StreamFinal::new(PROVIDER_NAME, (&response.usage_metadata).into())
130            .with_optional_finish_reason(finish_reason)
131            .with_optional_response_id(response.response_id)
132            .with_optional_model(response.model_version),
133    )
134}
135
136fn tool_protocol_finish_reason_error(choice: &ContentCandidate) -> Option<CompletionError> {
137    let reason = choice.finish_reason.as_ref()?;
138    function_call_finish_reason_error(reason, choice.finish_message.as_deref())
139}
140
141/// The recognizability markers of a `streamGenerateContent` chunk: every
142/// genuine frame carries `candidates` and/or `usageMetadata`. A frame with
143/// either must fully decode (else `Corrupt`); other JSON is `Unknown`.
144const RECOGNIZABLE_CHUNK_KEYS: &[&str] = &["candidates", "usageMetadata"];
145
146/// The Gemini REST (`streamGenerateContent`) SSE wire as a [`WireAdapter`].
147///
148/// Holds the per-stream state (thought-restatement buffer, terminal
149/// metadata); frame-triage policy lives in
150/// [`run_wire_stream`](crate::providers::internal::adapter::run_wire_stream),
151/// not here.
152struct GeminiRestAdapter {
153    /// Owns the constant-key thought lifecycle — the ends this wire never
154    /// announces are derived by the shared lifecycle, not hand-rolled here.
155    /// All accumulation lives in the shared accumulator.
156    reasoning: crate::providers::internal::chunk_lifecycle::MintedReasoningLifecycle,
157    /// Per-stream minter for id-less tool-call keys — a fresh key per call,
158    /// so two id-less calls in one turn never collide on one identity.
159    tool_ids: crate::streaming::SyntheticIds,
160    final_usage: Option<PartialUsage>,
161    final_finish_reason: Option<FinishReason>,
162    final_finish_message: Option<String>,
163    final_model_version: Option<String>,
164    final_response_id: Option<String>,
165    /// The provider sent a `finishReason` on some chunk.
166    ///
167    /// Gemini's `streamGenerateContent` sends an *intermediate* `finishReason`
168    /// when a built-in tool runs a round — a recorded code-execution stream
169    /// reads `[executableCode] [codeExecutionResult] [executableCode +
170    /// finishReason:STOP] [codeExecutionResult] [text] [text +
171    /// finishReason:STOP]` — so a `finishReason` chunk is not, on this wire, the
172    /// provider completing the turn. The terminal record is therefore deferred
173    /// to EOF (see [`WireAdapter::finish`], which names exactly this case);
174    /// pushing it on the first such chunk made the driver stop reading there
175    /// and silently drop the model's whole answer while still reporting a
176    /// successful `STOP`.
177    saw_finish_reason: bool,
178    /// A tool-protocol finish reason ended the turn; later frames are dead —
179    /// the provider aborted, and interpreting more output (or a terminal)
180    /// would dress the failure up as a completed turn.
181    failed: bool,
182}
183
184impl Default for GeminiRestAdapter {
185    fn default() -> Self {
186        Self {
187            reasoning: crate::providers::internal::chunk_lifecycle::MintedReasoningLifecycle::new(
188                shared_parts::REASONING_ID,
189            ),
190            tool_ids: crate::streaming::SyntheticIds::tool(),
191            final_usage: None,
192            final_finish_reason: None,
193            final_finish_message: None,
194            final_model_version: None,
195            final_response_id: None,
196            saw_finish_reason: false,
197            failed: false,
198        }
199    }
200}
201
202impl WireAdapter for GeminiRestAdapter {
203    type Frame = WireFrame;
204    type Event = StreamGenerateContentResponse;
205    type Response = StreamingCompletionResponse;
206
207    fn classify(&self, frame: WireFrame) -> WireEvent<StreamGenerateContentResponse> {
208        wire::classify_marker_keyed_frame(&frame.as_str(), RECOGNIZABLE_CHUNK_KEYS)
209    }
210
211    fn interpret(
212        &mut self,
213        data: StreamGenerateContentResponse,
214        out: &mut AdapterOutput<Self::Response>,
215    ) {
216        if self.failed {
217            return;
218        }
219
220        let span = tracing::Span::current();
221        if let Some(response_id) = data.response_id.as_deref() {
222            span.record("gen_ai.response.id", response_id);
223            self.final_response_id = Some(response_id.to_owned());
224        }
225        if let Some(model_version) = &data.model_version {
226            span.record("gen_ai.response.model", model_version.as_str());
227            self.final_model_version = Some(model_version.clone());
228        }
229        if let Some(usage) = data.usage_metadata.as_ref() {
230            span.record_token_usage(&crate::completion::Usage::from(usage));
231            self.final_usage = Some(usage.clone());
232        }
233
234        let Some(choice) = data.candidates.into_iter().next() else {
235            tracing::debug!("There is no content candidate");
236            return;
237        };
238
239        if let Some(finish_reason) = &choice.finish_reason {
240            // Last one wins: an intermediate `finishReason` is superseded by
241            // the reason the turn actually ended on.
242            self.saw_finish_reason = true;
243            self.final_finish_reason = Some(finish_reason.clone());
244        }
245        if let Some(message) = &choice.finish_message {
246            self.final_finish_message = Some(message.clone());
247        }
248
249        if let Some(err) = tool_protocol_finish_reason_error(&choice) {
250            self.failed = true;
251            out.push(Err(err));
252            return;
253        }
254
255        match choice.content {
256            Some(content) => {
257                if content.parts.is_empty() {
258                    tracing::trace!(reason = ?self.final_finish_reason, "There is no part in the streaming content");
259                }
260                for part in content.parts {
261                    self.interpret_part(part, out);
262                }
263            }
264            None => {
265                // Gemini's final chunk may carry finishReason with no content.
266                tracing::debug!(finish_reason = ?self.final_finish_reason, "Streaming candidate missing content");
267            }
268        }
269    }
270
271    fn finish(&mut self, out: &mut AdapterOutput<Self::Response>) {
272        // EOF without a `finishReason` chunk is truncation: no terminal
273        // record may be synthesized — it would report a successful completion
274        // for a turn the provider aborted.
275        if !self.saw_finish_reason {
276            return;
277        }
278
279        // Deferral, not synthesis: the provider *did* signal the finish, on a
280        // chunk that is not reliably its last (see `saw_finish_reason`).
281        // Holding the record until EOF is what lets the driver read the rest
282        // of the turn, and it means the terminal carries the last reason,
283        // usage, and metadata the stream actually reported.
284        out.push(Ok(streaming::RawStreamingChoice::FinalResponse(
285            StreamingCompletionResponse {
286                usage_metadata: self.final_usage.take().unwrap_or_default(),
287                finish_reason: self.final_finish_reason.take(),
288                finish_message: self.final_finish_message.take(),
289                model_version: self.final_model_version.take(),
290                response_id: self.final_response_id.take(),
291            },
292        )));
293    }
294
295    fn is_finished(&self) -> bool {
296        // A tool-protocol terminal failure is the wire's own in-band
297        // terminal: `interpret` already pushed the `Err` and gates itself on
298        // `failed`, so the driver must stop reading rather than drain the
299        // rest of the transport (and pass through post-error unknown frames).
300        self.failed
301    }
302}
303
304impl GeminiRestAdapter {
305    fn interpret_part(&mut self, part: Part, out: &mut AdapterOutput<StreamingCompletionResponse>) {
306        match part {
307            Part {
308                part: PartKind::Text(text),
309                thought: Some(true),
310                thought_signature,
311                ..
312            } => {
313                // Declare what the part carried; the shared lifecycle
314                // derives the sequence (a signature closes the block; the
315                // shared accumulator signs the accumulated deltas or records
316                // a signature-only part when nothing streamed).
317                self.reasoning.emit_chunk(
318                    crate::providers::internal::chunk_lifecycle::ChunkParts {
319                        reasoning: Some(text),
320                        reasoning_signature: thought_signature,
321                        text: None,
322                        tool_events: Vec::new(),
323                    },
324                    out,
325                );
326            }
327            Part {
328                part: PartKind::Text(text),
329                thought_signature,
330                ..
331            } => {
332                // The wire attaches `thoughtSignature` to a trailing part
333                // that carries no `thought` flag at all — recorded traffic
334                // shows `{"text":"","thoughtSignature":"..."}` — so the
335                // signature must be recognized here as well as in the
336                // `thought: true` arm above, which real streams never reach
337                // for the signature. Dropping it costs the replay-required
338                // provider state Gemini validates (`MISSING_THOUGHT_SIGNATURE`).
339                // A trailing `thoughtSignature` rides a part with no
340                // `thought` flag (recorded traffic:
341                // `{"text":"","thoughtSignature":"..."}`); the shared
342                // lifecycle emits its close before the text, and one end
343                // covers every case — open block (sign the deltas),
344                // already-closed block (sign the block that holds the
345                // chain-of-thought, #2258 B4), nothing streamed
346                // (signature-only part). No per-case branch to forget.
347                self.reasoning.emit_chunk(
348                    crate::providers::internal::chunk_lifecycle::ChunkParts {
349                        reasoning: None,
350                        reasoning_signature: thought_signature,
351                        text: Some(text),
352                        tool_events: Vec::new(),
353                    },
354                    out,
355                );
356            }
357            Part {
358                part: PartKind::FunctionCall(function_call),
359                thought_signature,
360                ..
361            } => {
362                // Tool content interleaving an open thought block: the
363                // shared lifecycle synthesizes the boundary end.
364                self.reasoning.emit_chunk(
365                    crate::providers::internal::chunk_lifecycle::ChunkParts {
366                        reasoning: None,
367                        reasoning_signature: None,
368                        text: None,
369                        tool_events: vec![shared_parts::function_call(
370                            function_call.name,
371                            function_call.args,
372                            function_call.id,
373                            thought_signature,
374                            &mut self.tool_ids,
375                        )],
376                    },
377                    out,
378                );
379            }
380            part => {
381                // Structural metadata only: an unmodeled part can carry
382                // model output, which must not leak into WARN logs.
383                crate::providers::internal::adapter::warn_unmodeled("gemini_part", &part);
384            }
385        }
386    }
387}
388
389impl<T> CompletionModel<T>
390where
391    T: HttpClientExt + Clone + 'static,
392{
393    /// Open a `streamGenerateContent` stream whose terminal record stays
394    /// provider-native.
395    ///
396    /// The normalized [`CompletionModel::stream`](crate::completion::CompletionModel::stream)
397    /// delegates here and maps only the terminal record, so both paths open
398    /// exactly one stream over the same request, telemetry, and error handling.
399    pub async fn raw_stream(
400        &self,
401        completion_request: CompletionRequest,
402    ) -> Result<streaming::RawStreamingResult<StreamingCompletionResponse>, CompletionError> {
403        let request_model = resolve_request_model(&self.model, &completion_request);
404        let span = CompletionSpanBuilder::new(
405            PROVIDER_NAME,
406            &request_model,
407            CompletionOperation::ChatStreaming,
408        )
409        .system_instructions(
410            completion_request.preamble.as_deref(),
411            completion_request.record_telemetry_content,
412        )
413        .build();
414        let request = create_request_body(completion_request)?;
415
416        crate::providers::internal::trace_json(
417            crate::providers::internal::LogTarget::Streaming,
418            "Gemini streaming completion request",
419            &request,
420        );
421
422        let body = serde_json::to_vec(&request)?;
423
424        let req = self
425            .client
426            .post_sse(streaming_endpoint(&request_model))?
427            .header("Content-Type", "application/json")
428            .body(body)
429            .map_err(|e| CompletionError::HttpError(e.into()))?;
430
431        Ok(open_wire_stream(
432            GenericEventSource::new(self.client.clone(), req),
433            SseTransportOptions {
434                open_log: OpenLog::Debug,
435                stream_ended_is_error: false,
436                log_transport_errors: true,
437            },
438            skip_blank_frames,
439            GeminiRestAdapter::default(),
440            span,
441        ))
442    }
443
444    pub(crate) async fn stream(
445        &self,
446        completion_request: CompletionRequest,
447    ) -> Result<streaming::StreamingCompletionResponse, CompletionError> {
448        let inner = self.raw_stream(completion_request).await?;
449
450        Ok(streaming::StreamingCompletionResponse::stream(
451            PROVIDER_NAME,
452            streaming::normalize_stream(inner, map_stream_final),
453        ))
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::providers::gemini::completion::gemini_api_types::TrafficType;
461    use serde_json::json;
462
463    #[test]
464    fn test_deserialize_stream_response_with_single_text_part() {
465        let json_data = json!({
466            "candidates": [{
467                "content": {
468                    "parts": [
469                        {"text": "Hello, world!"}
470                    ],
471                    "role": "model"
472                },
473                "finishReason": "STOP",
474                "index": 0
475            }],
476            "usageMetadata": {
477                "promptTokenCount": 10,
478                "candidatesTokenCount": 5,
479                "totalTokenCount": 15
480            }
481        });
482
483        let response: StreamGenerateContentResponse = serde_json::from_value(json_data).unwrap();
484        assert_eq!(response.candidates.len(), 1);
485        assert!(matches!(
486            response.candidates[0].finish_reason,
487            Some(FinishReason::Stop)
488        ));
489        let content = response.candidates[0]
490            .content
491            .as_ref()
492            .expect("candidate should contain content");
493        assert_eq!(content.parts.len(), 1);
494
495        if let Part {
496            part: PartKind::Text(text),
497            ..
498        } = &content.parts[0]
499        {
500            assert_eq!(text, "Hello, world!");
501        } else {
502            panic!("Expected text part");
503        }
504    }
505
506    #[test]
507    fn test_streaming_tool_protocol_finish_reason_returns_response_error() {
508        for (finish_reason, reason_name, finish_message) in [
509            (
510                "MALFORMED_FUNCTION_CALL",
511                "MalformedFunctionCall",
512                "malformed function call: default_api",
513            ),
514            (
515                "UNEXPECTED_TOOL_CALL",
516                "UnexpectedToolCall",
517                "unexpected tool call: default_api",
518            ),
519            (
520                "MISSING_THOUGHT_SIGNATURE",
521                "MissingThoughtSignature",
522                "missing thought signature for tool call",
523            ),
524            (
525                "TOO_MANY_TOOL_CALLS",
526                "TooManyToolCalls",
527                "too many tool calls in response",
528            ),
529            (
530                "MALFORMED_RESPONSE",
531                "MalformedResponse",
532                "malformed response from provider",
533            ),
534        ] {
535            let json_data = json!({
536                "candidates": [{
537                    "finishReason": finish_reason,
538                    "finishMessage": finish_message,
539                    "index": 0
540                }]
541            });
542
543            let response: StreamGenerateContentResponse =
544                serde_json::from_value(json_data).unwrap();
545            let candidate = response
546                .candidates
547                .first()
548                .expect("expected terminal candidate");
549            let err = tool_protocol_finish_reason_error(candidate)
550                .expect("tool protocol finish reason should be an error");
551
552            assert!(matches!(
553                err,
554                CompletionError::ResponseError(message)
555                    if message.contains(reason_name)
556                        && message.contains(finish_message)
557            ));
558        }
559    }
560
561    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
562    #[tokio::test]
563    async fn tool_protocol_failure_ends_the_stream_without_draining_later_frames() {
564        use crate::client::CompletionClient;
565        use crate::completion::CompletionModel as _;
566        use crate::providers::gemini::Client;
567        use crate::streaming::StreamedAssistantContent;
568        use crate::test_utils::MockStreamingClient;
569        use futures::StreamExt;
570
571        // A tool-protocol terminal failure, then more frames: a well-formed
572        // text chunk, an unknown frame, and a terminal `finishReason` chunk.
573        // The failure must be the LAST item the consumer sees — the driver
574        // stops reading (`is_finished`), so nothing after it is interpreted
575        // or passed through as `Unknown`.
576        let frames = [
577            r#"{"candidates":[{"content":{"parts":[{"text":"hi"}],"role":"model"},"index":0}]}"#,
578            r#"{"candidates":[{"finishReason":"MALFORMED_FUNCTION_CALL","finishMessage":"malformed function call","index":0}]}"#,
579            r#"{"candidates":[{"content":{"parts":[{"text":"dead"}],"role":"model"},"index":0}]}"#,
580            r#"{"someFutureField":{"x":1}}"#,
581            r#"{"candidates":[{"content":{"parts":[],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}"#,
582        ];
583        let sse_bytes = bytes::Bytes::from(
584            frames
585                .iter()
586                .map(|frame| format!("data: {frame}\n\n"))
587                .collect::<String>(),
588        );
589
590        let client = Client::builder()
591            .api_key("test-key")
592            .http_client(MockStreamingClient { sse_bytes })
593            .build()
594            .expect("build client");
595        let model = client.completion_model("gemini-2.5-flash");
596        let request = model.completion_request("hello").build();
597        let mut stream = crate::completion::CompletionModel::stream(&model, request)
598            .await
599            .expect("stream should open");
600
601        let mut texts = Vec::new();
602        let mut saw_error = false;
603        let mut items_after_error = 0usize;
604        while let Some(item) = stream.next().await {
605            if saw_error {
606                items_after_error += 1;
607            }
608            match item {
609                Ok(StreamedAssistantContent::Text(text)) => texts.push(text.text),
610                Ok(_) => {}
611                Err(_) => saw_error = true,
612            }
613        }
614
615        assert_eq!(texts, ["hi"]);
616        assert!(
617            saw_error,
618            "the tool-protocol failure must reach the consumer"
619        );
620        assert_eq!(
621            items_after_error, 0,
622            "the in-band failure must end the stream: no later text, Unknown passthrough, or terminal"
623        );
624        assert!(stream.response.is_none());
625    }
626
627    #[test]
628    fn test_deserialize_stream_response_with_usage_only_chunk() {
629        let json_data = json!({
630            "responseId": "response-123",
631            "modelVersion": "gemini-2.0-flash-001",
632            "usageMetadata": {
633                "promptTokenCount": 10,
634                "candidatesTokenCount": 5,
635                "totalTokenCount": 15
636            }
637        });
638
639        let response: StreamGenerateContentResponse = serde_json::from_value(json_data).unwrap();
640        assert_eq!(response.response_id.as_deref(), Some("response-123"));
641        assert_eq!(
642            response.model_version.as_deref(),
643            Some("gemini-2.0-flash-001")
644        );
645        assert!(response.candidates.is_empty());
646
647        let usage = response
648            .usage_metadata
649            .as_ref()
650            .map(crate::completion::Usage::from)
651            .unwrap();
652        assert_eq!(usage.input_tokens, 10);
653        assert_eq!(usage.output_tokens, 5);
654        assert_eq!(usage.total_tokens, 15);
655    }
656
657    #[test]
658    fn test_deserialize_stream_response_with_multiple_text_parts() {
659        let json_data = json!({
660            "candidates": [{
661                "content": {
662                    "parts": [
663                        {"text": "Hello, "},
664                        {"text": "world!"},
665                        {"text": " How are you?"}
666                    ],
667                    "role": "model"
668                },
669                "finishReason": "STOP",
670                "index": 0
671            }],
672            "usageMetadata": {
673                "promptTokenCount": 10,
674                "candidatesTokenCount": 8,
675                "totalTokenCount": 18
676            }
677        });
678
679        let response: StreamGenerateContentResponse = serde_json::from_value(json_data).unwrap();
680        assert_eq!(response.candidates.len(), 1);
681        let content = response.candidates[0]
682            .content
683            .as_ref()
684            .expect("candidate should contain content");
685        assert_eq!(content.parts.len(), 3);
686
687        // Verify all three text parts are present
688        for (i, expected_text) in ["Hello, ", "world!", " How are you?"].iter().enumerate() {
689            if let Part {
690                part: PartKind::Text(text),
691                ..
692            } = &content.parts[i]
693            {
694                assert_eq!(text, expected_text);
695            } else {
696                panic!("Expected text part at index {}", i);
697            }
698        }
699    }
700
701    #[test]
702    fn test_deserialize_stream_response_with_multiple_tool_calls() {
703        let json_data = json!({
704            "candidates": [{
705                "content": {
706                    "parts": [
707                        {
708                            "functionCall": {
709                                "name": "get_weather",
710                                "args": {"city": "San Francisco"},
711                                "id": "call-weather"
712                            }
713                        },
714                        {
715                            "functionCall": {
716                                "name": "get_temperature",
717                                "args": {"location": "New York"},
718                                "id": "call-temperature"
719                            }
720                        }
721                    ],
722                    "role": "model"
723                },
724                "finishReason": "STOP",
725                "index": 0
726            }],
727            "usageMetadata": {
728                "promptTokenCount": 50,
729                "candidatesTokenCount": 20,
730                "totalTokenCount": 70
731            }
732        });
733
734        let response: StreamGenerateContentResponse = serde_json::from_value(json_data).unwrap();
735        let content = response.candidates[0]
736            .content
737            .as_ref()
738            .expect("candidate should contain content");
739        assert_eq!(content.parts.len(), 2);
740
741        // Verify first tool call
742        if let Part {
743            part: PartKind::FunctionCall(call),
744            ..
745        } = &content.parts[0]
746        {
747            assert_eq!(call.name, "get_weather");
748            assert_eq!(call.id.as_deref(), Some("call-weather"));
749        } else {
750            panic!("Expected function call at index 0");
751        }
752
753        // Verify second tool call
754        if let Part {
755            part: PartKind::FunctionCall(call),
756            ..
757        } = &content.parts[1]
758        {
759            assert_eq!(call.name, "get_temperature");
760            assert_eq!(call.id.as_deref(), Some("call-temperature"));
761        } else {
762            panic!("Expected function call at index 1");
763        }
764    }
765
766    #[test]
767    fn test_deserialize_stream_response_with_mixed_parts() {
768        let json_data = json!({
769            "candidates": [{
770                "content": {
771                    "parts": [
772                        {
773                            "text": "Let me think about this...",
774                            "thought": true
775                        },
776                        {
777                            "text": "Here's my response: "
778                        },
779                        {
780                            "functionCall": {
781                                "name": "search",
782                                "args": {"query": "rust async"}
783                            }
784                        },
785                        {
786                            "text": "I found the answer!"
787                        }
788                    ],
789                    "role": "model"
790                },
791                "finishReason": "STOP",
792                "index": 0
793            }],
794            "usageMetadata": {
795                "promptTokenCount": 100,
796                "candidatesTokenCount": 50,
797                "thoughtsTokenCount": 15,
798                "totalTokenCount": 165
799            }
800        });
801
802        let response: StreamGenerateContentResponse = serde_json::from_value(json_data).unwrap();
803        let content = response.candidates[0]
804            .content
805            .as_ref()
806            .expect("candidate should contain content");
807        let parts = &content.parts;
808        assert_eq!(parts.len(), 4);
809
810        // Verify reasoning (thought) part
811        if let Part {
812            part: PartKind::Text(text),
813            thought: Some(true),
814            ..
815        } = &parts[0]
816        {
817            assert_eq!(text, "Let me think about this...");
818        } else {
819            panic!("Expected thought part at index 0");
820        }
821
822        // Verify regular text
823        if let Part {
824            part: PartKind::Text(text),
825            thought,
826            ..
827        } = &parts[1]
828        {
829            assert_eq!(text, "Here's my response: ");
830            assert!(thought.is_none() || thought == &Some(false));
831        } else {
832            panic!("Expected text part at index 1");
833        }
834
835        // Verify tool call
836        if let Part {
837            part: PartKind::FunctionCall(call),
838            ..
839        } = &parts[2]
840        {
841            assert_eq!(call.name, "search");
842        } else {
843            panic!("Expected function call at index 2");
844        }
845
846        // Verify final text
847        if let Part {
848            part: PartKind::Text(text),
849            ..
850        } = &parts[3]
851        {
852            assert_eq!(text, "I found the answer!");
853        } else {
854            panic!("Expected text part at index 3");
855        }
856    }
857
858    #[test]
859    fn test_deserialize_stream_response_with_empty_parts() {
860        let json_data = json!({
861            "candidates": [{
862                "content": {
863                    "parts": [],
864                    "role": "model"
865                },
866                "finishReason": "STOP",
867                "index": 0
868            }],
869            "usageMetadata": {
870                "promptTokenCount": 10,
871                "candidatesTokenCount": 0,
872                "totalTokenCount": 10
873            }
874        });
875
876        let response: StreamGenerateContentResponse = serde_json::from_value(json_data).unwrap();
877        let content = response.candidates[0]
878            .content
879            .as_ref()
880            .expect("candidate should contain content");
881        assert_eq!(content.parts.len(), 0);
882    }
883
884    #[test]
885    fn test_partial_usage_token_calculation() {
886        let usage = PartialUsage {
887            total_token_count: 100,
888            cached_content_token_count: Some(20),
889            candidates_token_count: Some(30),
890            thoughts_token_count: Some(10),
891            prompt_token_count: 40,
892            prompt_tokens_details: None,
893            cache_tokens_details: None,
894            candidates_tokens_details: None,
895            tool_use_prompt_token_count: Some(12),
896            tool_use_prompt_tokens_details: None,
897            traffic_type: None,
898        };
899
900        let token_usage = crate::completion::Usage::from(&usage);
901        assert_eq!(token_usage.input_tokens, 40);
902        assert_eq!(token_usage.cached_input_tokens, 20);
903        assert_eq!(token_usage.output_tokens, 30);
904        assert_eq!(token_usage.reasoning_tokens, 10);
905        assert_eq!(token_usage.tool_use_prompt_tokens, 12);
906        assert_eq!(token_usage.total_tokens, 100);
907    }
908
909    #[test]
910    fn test_partial_usage_with_missing_counts() {
911        let usage = PartialUsage {
912            total_token_count: 50,
913            cached_content_token_count: None,
914            candidates_token_count: Some(30),
915            thoughts_token_count: None,
916            prompt_token_count: 20,
917            prompt_tokens_details: None,
918            cache_tokens_details: None,
919            candidates_tokens_details: None,
920            tool_use_prompt_token_count: None,
921            tool_use_prompt_tokens_details: None,
922            traffic_type: None,
923        };
924
925        let token_usage = crate::completion::Usage::from(&usage);
926        assert_eq!(token_usage.input_tokens, 20);
927        assert_eq!(token_usage.cached_input_tokens, 0);
928        assert_eq!(token_usage.output_tokens, 30);
929        assert_eq!(token_usage.reasoning_tokens, 0);
930        assert_eq!(token_usage.total_tokens, 50);
931    }
932
933    #[test]
934    fn test_partial_usage_deserializes_without_total_token_count() {
935        // Gemini's proto3-JSON encoding omits fields whose value is the default (0),
936        // so `totalTokenCount` is absent on short/empty/blocked generations.
937        let usage: PartialUsage =
938            serde_json::from_str(r#"{"promptTokenCount": 12}"#).expect("should deserialize");
939        assert_eq!(usage.total_token_count, 0);
940        assert_eq!(usage.prompt_token_count, 12);
941    }
942
943    #[test]
944    fn test_streaming_completion_response_has_finish_reason_and_model_version() {
945        use super::super::completion::gemini_api_types::FinishReason;
946
947        let response = StreamingCompletionResponse {
948            usage_metadata: PartialUsage::default(),
949            finish_reason: Some(FinishReason::Stop),
950            finish_message: None,
951            model_version: Some("gemini-2.5-pro-preview-05-06".to_string()),
952            response_id: None,
953        };
954
955        assert!(matches!(response.finish_reason, Some(FinishReason::Stop)));
956        assert_eq!(
957            response.model_version.as_deref(),
958            Some("gemini-2.5-pro-preview-05-06")
959        );
960
961        let json = serde_json::to_string(&response).unwrap();
962        let deserialized: StreamingCompletionResponse = serde_json::from_str(&json).unwrap();
963        assert!(matches!(
964            deserialized.finish_reason,
965            Some(FinishReason::Stop)
966        ));
967        assert_eq!(
968            deserialized.model_version.as_deref(),
969            Some("gemini-2.5-pro-preview-05-06")
970        );
971    }
972
973    #[test]
974    fn test_streaming_completion_response_token_usage() {
975        let response = StreamingCompletionResponse {
976            usage_metadata: PartialUsage {
977                total_token_count: 150,
978                cached_content_token_count: None,
979                candidates_token_count: Some(75),
980                thoughts_token_count: None,
981                prompt_token_count: 75,
982                prompt_tokens_details: None,
983                cache_tokens_details: None,
984                candidates_tokens_details: None,
985                tool_use_prompt_token_count: None,
986                tool_use_prompt_tokens_details: None,
987                traffic_type: None,
988            },
989            finish_reason: Some(FinishReason::Stop),
990            finish_message: None,
991            model_version: Some("gemini-2.0-flash-001".to_string()),
992            response_id: None,
993        };
994
995        let token_usage = crate::completion::Usage::from(&response);
996        assert_eq!(token_usage.input_tokens, 75);
997        assert_eq!(token_usage.output_tokens, 75);
998        assert_eq!(token_usage.reasoning_tokens, 0);
999        assert_eq!(token_usage.cached_input_tokens, 0);
1000        assert_eq!(token_usage.total_tokens, 150);
1001        assert!(matches!(response.finish_reason, Some(FinishReason::Stop)));
1002        assert_eq!(
1003            response.model_version.as_deref(),
1004            Some("gemini-2.0-flash-001")
1005        );
1006    }
1007
1008    #[test]
1009    fn test_partial_usage_serde_roundtrip_with_all_optional_fields() {
1010        let json_data = serde_json::json!({
1011            "promptTokenCount": 100,
1012            "cachedContentTokenCount": 25,
1013            "candidatesTokenCount": 50,
1014            "thoughtsTokenCount": 15,
1015            "totalTokenCount": 190,
1016            "promptTokensDetails": [
1017                { "modality": "TEXT", "tokenCount": 80 },
1018                { "modality": "IMAGE", "tokenCount": 20 }
1019            ],
1020            "cacheTokensDetails": [
1021                { "modality": "TEXT", "tokenCount": 25 }
1022            ],
1023            "candidatesTokensDetails": [
1024                { "modality": "TEXT", "tokenCount": 50 }
1025            ],
1026            "toolUsePromptTokenCount": 12,
1027            "toolUsePromptTokensDetails": [
1028                { "modality": "TEXT", "tokenCount": 12 }
1029            ],
1030            "trafficType": "PROVISIONED_THROUGHPUT"
1031        });
1032
1033        let usage: PartialUsage = serde_json::from_value(json_data).unwrap();
1034        assert_eq!(usage.prompt_token_count, 100);
1035        assert_eq!(usage.cached_content_token_count, Some(25));
1036        assert_eq!(usage.candidates_token_count, Some(50));
1037        assert_eq!(usage.thoughts_token_count, Some(15));
1038        assert_eq!(usage.total_token_count, 190);
1039        assert!(usage.prompt_tokens_details.is_some());
1040        assert_eq!(usage.prompt_tokens_details.as_ref().unwrap().len(), 2);
1041        assert!(usage.cache_tokens_details.is_some());
1042        assert!(usage.candidates_tokens_details.is_some());
1043        assert_eq!(usage.tool_use_prompt_token_count, Some(12));
1044        assert!(usage.tool_use_prompt_tokens_details.is_some());
1045        assert!(matches!(
1046            usage.traffic_type,
1047            Some(TrafficType::ProvisionedThroughput)
1048        ));
1049
1050        let token_usage = crate::completion::Usage::from(&usage);
1051        assert_eq!(token_usage.input_tokens, 100);
1052        assert_eq!(token_usage.cached_input_tokens, 25);
1053        assert_eq!(token_usage.output_tokens, 50);
1054        assert_eq!(token_usage.reasoning_tokens, 15);
1055        assert_eq!(token_usage.tool_use_prompt_tokens, 12);
1056        assert_eq!(token_usage.total_tokens, 190);
1057    }
1058
1059    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1060    mod terminal_emission {
1061        use crate::client::CompletionClient;
1062        use crate::completion::CompletionModel as _;
1063        use crate::providers::gemini::Client;
1064        use crate::streaming::StreamedAssistantContent;
1065        use crate::test_utils::MockStreamingClient;
1066        use futures::StreamExt;
1067
1068        const CONTENT_CHUNK: &str = r#"{"candidates":[{"content":{"parts":[{"text":"hi"}],"role":"model"}}],"responseId":"resp-1","modelVersion":"gemini-2.5-pro"}"#;
1069        const TERMINAL_CHUNK: &str = r#"{"candidates":[{"content":{"parts":[{"text":"!"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2,"totalTokenCount":7},"responseId":"resp-1","modelVersion":"gemini-2.5-pro"}"#;
1070
1071        fn sse(frames: &[&str]) -> bytes::Bytes {
1072            bytes::Bytes::from(
1073                frames
1074                    .iter()
1075                    .map(|frame| format!("data: {frame}\n\n"))
1076                    .collect::<String>(),
1077            )
1078        }
1079
1080        async fn collect(
1081            sse_bytes: bytes::Bytes,
1082        ) -> (
1083            Vec<String>,
1084            bool,
1085            bool,
1086            crate::streaming::StreamingCompletionResponse,
1087        ) {
1088            let client = Client::builder()
1089                .api_key("test-key")
1090                .http_client(MockStreamingClient { sse_bytes })
1091                .build()
1092                .expect("build client");
1093            let model = client.completion_model(
1094                crate::providers::gemini::completion::GEMINI_2_5_PRO_PREVIEW_06_05,
1095            );
1096            let request = model.completion_request("hello").build();
1097            let mut stream = crate::completion::CompletionModel::stream(&model, request)
1098                .await
1099                .expect("stream should open");
1100
1101            let mut texts = Vec::new();
1102            let mut saw_error = false;
1103            let mut saw_terminal = false;
1104            while let Some(item) = stream.next().await {
1105                match item {
1106                    Ok(StreamedAssistantContent::Text(text)) => texts.push(text.text),
1107                    Ok(StreamedAssistantContent::Final(_)) => saw_terminal = true,
1108                    Ok(_) => {}
1109                    Err(_) => saw_error = true,
1110                }
1111            }
1112            (texts, saw_error, saw_terminal, stream)
1113        }
1114
1115        #[tokio::test]
1116        async fn a_signature_with_no_thought_text_still_emits_a_signed_block() {
1117            // gRPC and Interactions emit signature-only blocks; the REST
1118            // wire must not diverge — the signature is replay-required
1119            // provider state even when no thought text accumulated.
1120            const SIGNATURE_ONLY_CHUNK: &str = r#"{"candidates":[{"content":{"parts":[{"text":"","thought":true,"thoughtSignature":"sig-only"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":1,"totalTokenCount":4}}"#;
1121
1122            let client = Client::builder()
1123                .api_key("test-key")
1124                .http_client(MockStreamingClient {
1125                    sse_bytes: sse(&[SIGNATURE_ONLY_CHUNK]),
1126                })
1127                .build()
1128                .expect("build client");
1129            let model = client.completion_model(
1130                crate::providers::gemini::completion::GEMINI_2_5_PRO_PREVIEW_06_05,
1131            );
1132            let request = model.completion_request("hello").build();
1133            let mut stream = crate::completion::CompletionModel::stream(&model, request)
1134                .await
1135                .expect("stream should open");
1136
1137            let mut signed = None;
1138            while let Some(item) = stream.next().await {
1139                if let StreamedAssistantContent::Reasoning { reasoning, .. } =
1140                    item.expect("stream item should be Ok")
1141                {
1142                    signed = Some(reasoning);
1143                }
1144            }
1145            let signed = signed.expect("signature-only block must be emitted");
1146            assert!(signed.content.iter().any(|content| matches!(
1147                content,
1148                crate::message::ReasoningContent::Text { signature: Some(sig), .. } if sig == "sig-only"
1149            )));
1150        }
1151
1152        #[tokio::test]
1153        async fn truncated_stream_yields_content_but_no_terminal_record() {
1154            let (texts, saw_error, saw_terminal, stream) = collect(sse(&[CONTENT_CHUNK])).await;
1155
1156            assert_eq!(texts, ["hi"]);
1157            assert!(!saw_error);
1158            assert!(
1159                !saw_terminal,
1160                "EOF without a finishReason chunk must not synthesize a terminal record"
1161            );
1162            assert!(stream.response.is_none());
1163        }
1164
1165        #[tokio::test]
1166        async fn errored_stream_forwards_the_error_and_no_terminal_record() {
1167            use crate::test_utils::SequencedStreamingHttpClient;
1168
1169            // A transport failure after some content must reach the consumer
1170            // and must not be papered over with a synthesized terminal record.
1171            let client = Client::builder()
1172                .api_key("test-key")
1173                .http_client(SequencedStreamingHttpClient::new(vec![
1174                    Ok(sse(&[CONTENT_CHUNK])),
1175                    Err(crate::http_client::Error::InvalidStatusCodeWithMessage(
1176                        http::StatusCode::BAD_GATEWAY,
1177                        "connection reset".to_string(),
1178                    )),
1179                ]))
1180                .build()
1181                .expect("build client");
1182            let model = client.completion_model(
1183                crate::providers::gemini::completion::GEMINI_2_5_PRO_PREVIEW_06_05,
1184            );
1185            let request = model.completion_request("hello").build();
1186            let mut stream = crate::completion::CompletionModel::stream(&model, request)
1187                .await
1188                .expect("stream should open");
1189
1190            let mut texts = Vec::new();
1191            let mut saw_error = false;
1192            let mut saw_terminal = false;
1193            while let Some(item) = stream.next().await {
1194                match item {
1195                    Ok(StreamedAssistantContent::Text(text)) => texts.push(text.text),
1196                    Ok(StreamedAssistantContent::Final(_)) => saw_terminal = true,
1197                    Ok(_) => {}
1198                    Err(_) => saw_error = true,
1199                }
1200            }
1201
1202            assert_eq!(texts, ["hi"]);
1203            assert!(saw_error, "the transport failure must reach the consumer");
1204            assert!(
1205                !saw_terminal,
1206                "a failed stream must not synthesize a terminal record"
1207            );
1208            assert!(stream.response.is_none());
1209        }
1210
1211        #[tokio::test]
1212        async fn malformed_frame_then_eof_yields_error_and_no_terminal_record() {
1213            let (texts, saw_error, saw_terminal, stream) =
1214                collect(sse(&[CONTENT_CHUNK, "{not json"])).await;
1215
1216            assert_eq!(texts, ["hi"]);
1217            assert!(saw_error, "the malformed frame must reach the consumer");
1218            assert!(
1219                !saw_terminal,
1220                "a parse error followed by EOF must not read as a completed turn"
1221            );
1222            assert!(stream.response.is_none());
1223        }
1224
1225        #[tokio::test]
1226        async fn malformed_frame_then_real_terminal_still_completes_the_stream() {
1227            let (texts, saw_error, saw_terminal, stream) =
1228                collect(sse(&[CONTENT_CHUNK, "{not json", TERMINAL_CHUNK])).await;
1229
1230            assert_eq!(texts, ["hi", "!"]);
1231            assert!(saw_error, "the malformed frame must reach the consumer");
1232            assert!(
1233                saw_terminal,
1234                "a genuine finishReason chunk after a parse error still completes the stream"
1235            );
1236            let terminal = stream.response.expect("terminal record");
1237            assert_eq!(
1238                terminal.finish_reason,
1239                Some(crate::completion::FinishReason::Stop)
1240            );
1241            assert_eq!(terminal.response_id.as_deref(), Some("resp-1"));
1242        }
1243    }
1244}