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
21pub(crate) mod shared_parts {
25 use serde_json::Value;
26
27 use crate::streaming::{MintKind, RawStreamingChoice, RawStreamingToolCall, StreamPartId};
28
29 pub(crate) const REASONING_ID: StreamPartId = StreamPartId::minted(MintKind::Reasoning, 0);
34
35 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 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 call_id: None,
66 name,
67 arguments: args,
68 signature,
69 additional_params: None,
70 };
71 RawStreamingChoice::ToolCall(tool_call)
72 }
73}
74
75pub type PartialUsage = UsageMetadata;
81
82#[derive(Debug, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct StreamGenerateContentResponse {
85 pub response_id: Option<String>,
86 #[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
118fn 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
141const RECOGNIZABLE_CHUNK_KEYS: &[&str] = &["candidates", "usageMetadata"];
145
146struct GeminiRestAdapter {
153 reasoning: crate::providers::internal::chunk_lifecycle::MintedReasoningLifecycle,
157 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 saw_finish_reason: bool,
178 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 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 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 if !self.saw_finish_reason {
276 return;
277 }
278
279 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}