Skip to main content

rig_core/providers/gemini/interactions_api/
streaming.rs

1use async_stream::stream;
2use futures::{Stream, StreamExt};
3use serde::{Deserialize, Serialize};
4use std::pin::Pin;
5use tracing::{Level, enabled};
6use tracing_futures::Instrument;
7
8use super::InteractionsCompletionModel;
9use super::create_request_body;
10use super::interactions_api_types::{
11    Content, ContentDelta, FunctionCallContent, FunctionCallDelta, Interaction,
12    InteractionSseEvent, InteractionUsage, Step, TextDelta, ThoughtSummaryContent,
13    ThoughtSummaryDelta,
14};
15use crate::completion::{CompletionError, CompletionRequest, GetTokenUsage};
16use crate::http_client::HttpClientExt;
17use crate::http_client::Request;
18use crate::http_client::sse::{Event, GenericEventSource};
19use crate::streaming;
20use crate::telemetry::{CompletionOperation, CompletionSpanBuilder, SpanCombinator};
21use serde_json::{Map, Value};
22
23/// Final metadata yielded by an Interactions streaming response.
24#[derive(Debug, Serialize, Deserialize, Default, Clone)]
25pub struct StreamingCompletionResponse {
26    pub usage: Option<InteractionUsage>,
27    pub interaction: Option<Interaction>,
28    /// Resolved model identifier (e.g. `gemini-2.5-pro-preview-05-06`), extracted from
29    /// `Interaction.model`. The Interactions API has no `FinishReason` field; use
30    /// `interaction.status` for lifecycle state.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub model_version: Option<String>,
33}
34
35#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
36pub type InteractionEventStream =
37    Pin<Box<dyn Stream<Item = Result<InteractionSseEvent, CompletionError>> + Send>>;
38
39#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
40pub type InteractionEventStream =
41    Pin<Box<dyn Stream<Item = Result<InteractionSseEvent, CompletionError>>>>;
42
43impl GetTokenUsage for StreamingCompletionResponse {
44    fn token_usage(&self) -> crate::completion::Usage {
45        self.usage
46            .as_ref()
47            .map(|usage| usage.token_usage())
48            .unwrap_or_default()
49    }
50}
51
52impl<T> InteractionsCompletionModel<T>
53where
54    T: HttpClientExt + Clone + Default + std::fmt::Debug + 'static,
55{
56    pub(crate) async fn stream(
57        &self,
58        completion_request: CompletionRequest,
59    ) -> Result<streaming::StreamingCompletionResponse<StreamingCompletionResponse>, CompletionError>
60    {
61        let span = CompletionSpanBuilder::new(
62            "gcp.gemini",
63            &self.model,
64            CompletionOperation::InteractionsStreaming,
65        )
66        .system_instructions(
67            completion_request.preamble.as_deref(),
68            completion_request.record_telemetry_content,
69        )
70        .build();
71
72        let request = create_request_body(self.model.clone(), completion_request, Some(true))?;
73
74        if enabled!(Level::TRACE) {
75            tracing::trace!(
76                target: "rig::streaming",
77                "Gemini interactions streaming request: {}",
78                serde_json::to_string_pretty(&request)?
79            );
80        }
81
82        let body = serde_json::to_vec(&request)?;
83        let req = self
84            .client
85            .post_sse("/v1beta/interactions")?
86            .header("Content-Type", "application/json")
87            .body(body)
88            .map_err(|e| CompletionError::HttpError(e.into()))?;
89
90        let mut event_source = GenericEventSource::new(self.client.clone(), req);
91
92        let stream = stream! {
93            let mut final_interaction: Option<Interaction> = None;
94            let mut final_usage: Option<InteractionUsage> = None;
95
96            while let Some(event_result) = event_source.next().await {
97                match event_result {
98                    Ok(Event::Open) => {
99                        tracing::debug!("SSE connection opened");
100                        continue;
101                    }
102                    Ok(Event::Message(message)) => {
103                        if message.data.trim().is_empty() {
104                            continue;
105                        }
106
107                        let data = match serde_json::from_str::<InteractionSseEvent>(&message.data)
108                        {
109                            Ok(data) => data,
110                            Err(err) => {
111                                tracing::debug!(
112                                    "Failed to deserialize interactions SSE event: {err}"
113                                );
114                                continue;
115                            }
116                        };
117
118                        match data {
119                            InteractionSseEvent::StepDelta { delta, .. } => {
120                                if let Some(choice) = content_delta_to_choice(delta) {
121                                    yield Ok(choice);
122                                }
123                            }
124                            InteractionSseEvent::StepStart { step, .. } => {
125                                if let Some(choice) = step_start_to_choice(step) {
126                                    yield Ok(choice);
127                                }
128                            }
129                            InteractionSseEvent::InteractionCompleted { interaction, .. } => {
130                                let span = tracing::Span::current();
131                                span.record("gen_ai.response.id", &interaction.id);
132                                if let Some(model) = interaction.model.clone() {
133                                    span.record("gen_ai.response.model", model);
134                                }
135
136                                if let Some(usage) = interaction.usage.clone() {
137                                    span.record_token_usage(&usage);
138                                    final_usage = Some(usage);
139                                }
140                                final_interaction = Some(interaction);
141                            }
142                            InteractionSseEvent::Error { .. } => {
143                                // Preserve the full provider error payload (code +
144                                // message) by reusing the raw SSE event JSON, matching
145                                // the SSE path's `completion_error_from_body`. The error
146                                // arrives over an established stream, so there is no HTTP
147                                // status to attach (status: None).
148                                yield Err(crate::provider_response::completion_error_from_body(
149                                    message.data,
150                                ));
151                                break;
152                            }
153                            _ => continue,
154                        }
155                    }
156                    Err(crate::http_client::Error::StreamEnded) => {
157                        break;
158                    }
159                    Err(error) => {
160                        tracing::error!(?error, "SSE error");
161                        yield Err(CompletionError::from_stream_transport(error));
162                        break;
163                    }
164                }
165            }
166
167            event_source.close();
168
169            let model_version = final_interaction.as_ref().and_then(|i| i.model.clone());
170            yield Ok(streaming::RawStreamingChoice::FinalResponse(StreamingCompletionResponse {
171                usage: final_usage.or_else(|| final_interaction.as_ref().and_then(|i| i.usage.clone())),
172                interaction: final_interaction,
173                model_version,
174            }));
175        }
176        .instrument(span);
177
178        Ok(streaming::StreamingCompletionResponse::stream(Box::pin(
179            stream,
180        )))
181    }
182}
183
184pub(crate) fn stream_interaction_events<T>(
185    client: super::InteractionsClient<T>,
186    request: Request<Vec<u8>>,
187) -> InteractionEventStream
188where
189    T: HttpClientExt + Clone + Default + std::fmt::Debug + 'static,
190{
191    let mut event_source = GenericEventSource::new(client.clone(), request);
192
193    let stream = stream! {
194        while let Some(event_result) = event_source.next().await {
195            match event_result {
196                Ok(Event::Open) => continue,
197                Ok(Event::Message(message)) => {
198                    if message.data.trim().is_empty() {
199                        continue;
200                    }
201
202                    let data = serde_json::from_str::<InteractionSseEvent>(&message.data);
203                    let Ok(data) = data else {
204                        let Err(err) = data else {
205                            continue;
206                        };
207                        tracing::debug!("Failed to deserialize interactions SSE event: {err}");
208                        continue;
209                    };
210
211                    yield Ok(data);
212                }
213                Err(crate::http_client::Error::StreamEnded) => break,
214                Err(error) => {
215                    tracing::error!(?error, "SSE error");
216                    yield Err(CompletionError::from_stream_transport(error));
217                    break;
218                }
219            }
220        }
221
222        event_source.close();
223    };
224
225    Box::pin(stream)
226}
227
228fn step_start_to_choice(
229    step: Step,
230) -> Option<streaming::RawStreamingChoice<StreamingCompletionResponse>> {
231    match step {
232        Step::ModelOutput { content } => content.into_iter().find_map(content_to_choice),
233        Step::FunctionCall(FunctionCallContent {
234            name,
235            arguments,
236            id,
237        }) => {
238            let name = name?;
239            let call_id = id.unwrap_or_else(|| name.clone());
240            Some(streaming::RawStreamingChoice::ToolCall(
241                streaming::RawStreamingToolCall::new(
242                    name.clone(),
243                    name,
244                    arguments.unwrap_or(Value::Object(Map::new())),
245                )
246                .with_call_id(call_id),
247            ))
248        }
249        _ => None,
250    }
251}
252
253fn content_to_choice(
254    content: Content,
255) -> Option<streaming::RawStreamingChoice<StreamingCompletionResponse>> {
256    match content {
257        Content::Text(text) if !text.text.is_empty() => {
258            Some(streaming::RawStreamingChoice::Message(text.text))
259        }
260        Content::FunctionCall(content) => step_start_to_choice(Step::FunctionCall(content)),
261        _ => None,
262    }
263}
264
265fn content_delta_to_choice(
266    delta: ContentDelta,
267) -> Option<streaming::RawStreamingChoice<StreamingCompletionResponse>> {
268    match delta {
269        ContentDelta::Text(TextDelta {
270            text: Some(text), ..
271        }) => Some(streaming::RawStreamingChoice::Message(text)),
272        ContentDelta::FunctionCall(FunctionCallDelta {
273            name,
274            arguments,
275            id,
276        }) => {
277            let name = name?;
278            let call_id = id.unwrap_or_else(|| name.clone());
279            Some(streaming::RawStreamingChoice::ToolCall(
280                streaming::RawStreamingToolCall::new(
281                    name.clone(),
282                    name,
283                    arguments.unwrap_or(Value::Object(Map::new())),
284                )
285                .with_call_id(call_id),
286            ))
287        }
288        ContentDelta::ThoughtSummary(ThoughtSummaryDelta { content }) => {
289            let text = match content {
290                ThoughtSummaryContent::Text(text) => text.text,
291                _ => return None,
292            };
293            Some(streaming::RawStreamingChoice::ReasoningDelta {
294                id: None,
295                reasoning: text,
296            })
297        }
298        _ => None,
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use serde_json::json;
306
307    #[test]
308    fn test_streaming_completion_response_has_model_version() {
309        let response = StreamingCompletionResponse {
310            usage: None,
311            interaction: None,
312            model_version: Some("gemini-2.5-pro-preview-05-06".to_string()),
313        };
314
315        assert_eq!(
316            response.model_version.as_deref(),
317            Some("gemini-2.5-pro-preview-05-06")
318        );
319
320        let json = serde_json::to_string(&response).unwrap();
321        let deserialized: StreamingCompletionResponse = serde_json::from_str(&json).unwrap();
322        assert_eq!(
323            deserialized.model_version.as_deref(),
324            Some("gemini-2.5-pro-preview-05-06")
325        );
326    }
327
328    #[test]
329    fn test_content_delta_text_event() {
330        let event_json = json!({
331            "event_type": "step.delta",
332            "index": 0,
333            "delta": {
334                "type": "text",
335                "text": "Hello"
336            }
337        });
338
339        let event: InteractionSseEvent = serde_json::from_value(event_json).unwrap();
340        let InteractionSseEvent::StepDelta { delta, .. } = event else {
341            panic!("expected step delta");
342        };
343
344        let choice = content_delta_to_choice(delta).expect("choice should exist");
345        match choice {
346            crate::streaming::RawStreamingChoice::Message(text) => {
347                assert_eq!(text, "Hello");
348            }
349            other => panic!("unexpected choice: {other:?}"),
350        }
351    }
352
353    #[test]
354    fn test_content_delta_function_call_event() {
355        let event_json = json!({
356            "event_type": "step.delta",
357            "index": 0,
358            "delta": {
359                "type": "function_call",
360                "name": "get_weather",
361                "arguments": {"location": "Paris"},
362                "id": "call-1"
363            }
364        });
365
366        let event: InteractionSseEvent = serde_json::from_value(event_json).unwrap();
367        let InteractionSseEvent::StepDelta { delta, .. } = event else {
368            panic!("expected step delta");
369        };
370
371        let choice = content_delta_to_choice(delta).expect("choice should exist");
372        match choice {
373            crate::streaming::RawStreamingChoice::ToolCall(call) => {
374                assert_eq!(call.name, "get_weather");
375                assert_eq!(call.call_id.as_deref(), Some("call-1"));
376            }
377            other => panic!("unexpected choice: {other:?}"),
378        }
379    }
380}