Skip to main content

rig_core/providers/gemini/interactions_api/
streaming.rs

1//! The decoder of Interactions replies: a whole interaction resource, or a
2//! stream of `event_type`-tagged step events, read as JSON.
3//!
4//! ```
5//! use rig_core::providers::gemini::interactions_api::streaming::InteractionsDecoder;
6//!
7//! let decoder = InteractionsDecoder::default();
8//! # let _ = decoder;
9//! ```
10
11use std::collections::BTreeMap;
12
13use serde_json::{Map, Value, json};
14
15use crate::completion::{FinishReason, Usage};
16use crate::error::ProviderError;
17use crate::json_utils::Lenient;
18use crate::message::{
19    DocumentRange, DocumentSourceKind, Image, ImageMediaType, MimeType, Source, SourceLocation,
20};
21use crate::operation::completion::merge;
22use crate::operation::{Block, CallFragment, Completion, Finish};
23use crate::providers::internal::wire;
24use crate::wire::{Decoder, Flow, Out, SpanUnit, WireCitation, WireEvent, WireFrame, WireSpan};
25
26/// Recognized Interactions SSE tags; unlisted tags classify as unknown.
27const KNOWN_EVENT_TYPES: &[&str] = &[
28    "interaction.created",
29    "interaction.completed",
30    "interaction.status_update",
31    "step.start",
32    "step.delta",
33    "step.stop",
34    "error",
35];
36
37/// Top-level keys that mark an untagged frame as a whole interaction
38/// resource.
39const INTERACTION_MARKER_KEYS: &[&str] = &["steps", "status", "usage", "object", "id"];
40
41/// A decoded frame, verbatim: the decoder reads the fields it needs.
42pub enum InteractionsEvent {
43    /// One `event_type`-tagged streaming event.
44    Sse(SseEvent),
45    /// The whole interaction resource, as the unary reply delivers it.
46    Whole(Map<String, Value>),
47}
48
49/// A streaming event: its tag, and its other fields verbatim.
50#[derive(Debug, serde::Deserialize)]
51pub struct SseEvent {
52    /// The event's `event_type`.
53    pub event_type: String,
54    /// Every other field.
55    #[serde(flatten)]
56    pub fields: Map<String, Value>,
57}
58
59/// The Gemini Interactions wire's decoder. Steps decode in step order: a
60/// thought, a call and any other step is one block, and a model output is
61/// one block per content item, whose provider item is the model output step
62/// holding that item alone. A step opens on `step.start`, grows with its
63/// `step.delta`s, and becomes its blocks' provider item on `step.stop`, where
64/// the API states it complete. A whole interaction states each step
65/// complete. A step still open when the reply ends keeps no provider item.
66/// A text item's annotations, stated with it or streamed after it, cite its
67/// block when the step stops.
68#[derive(Default)]
69pub struct InteractionsDecoder {
70    /// The open steps, by wire index.
71    steps: BTreeMap<usize, Open>,
72    /// The model `interaction.created` named, for a completion that does
73    /// not.
74    model: Option<String>,
75    /// The last status a stream reported.
76    status: Option<String>,
77}
78
79/// What an open step decodes to.
80enum Open {
81    Thought,
82    /// A model output: the step's other fields, and each content item with
83    /// its block's writer index.
84    Output(Value, Vec<(usize, Value)>),
85    /// A function call, with the argument JSON streamed so far.
86    Call(String),
87    Opaque,
88}
89
90/// Rig's usage for an interaction's `usage`, read leniently: input is
91/// `total_input_tokens` plus the tool-use tokens, output
92/// `total_output_tokens` plus the thought tokens, and the total their sum;
93/// without a base count, that side and the total stay absent.
94pub fn usage_of(usage: &Value) -> Usage {
95    let tool_use = usage.u64("total_tool_use_tokens");
96    let thoughts = usage.u64("total_thought_tokens");
97    let add = |key: &str, more: Option<u64>| usage.u64(key).map(|count| count + more.unwrap_or(0));
98    let (input_tokens, output_tokens) = (
99        add("total_input_tokens", tool_use),
100        add("total_output_tokens", thoughts),
101    );
102    Usage {
103        input_tokens,
104        output_tokens,
105        cached_input_tokens: usage.u64("total_cached_tokens"),
106        reasoning_tokens: thoughts,
107        tool_use_prompt_tokens: tool_use,
108        total_tokens: input_tokens
109            .zip(output_tokens)
110            .map(|(input, output)| input + output),
111        cache_creation_input_tokens: None,
112        cost: None,
113    }
114}
115
116/// The image a content item states, when it has data or a URI.
117fn image_of(content: &Value) -> Option<Image> {
118    let data = match (content.str("data"), content.str("uri")) {
119        (Some(data), _) => DocumentSourceKind::Base64(data.to_owned()),
120        (None, Some(uri)) => DocumentSourceKind::Url(uri.to_owned()),
121        (None, None) => return None,
122    };
123    Some(Image {
124        data,
125        media_type: content
126            .str("mime_type")
127            .and_then(ImageMediaType::from_mime_type),
128        detail: None,
129        native: None,
130    })
131}
132
133/// Open the block for `content`, a model output's newest content item.
134fn open_content(
135    content: Value,
136    out: &mut Out<'_, Completion>,
137) -> Result<(usize, Value), ProviderError> {
138    let index = out.fresh_index();
139    let block = match content.str("type") {
140        Some("text") => Block::Text,
141        Some("image") => image_of(&content).map_or(Block::Opaque { replay: true }, Block::Image),
142        _ => Block::Opaque { replay: true },
143    };
144    let text = matches!(block, Block::Text);
145    out.open(index, block, Value::Null)?;
146    if let Some(fragment) = content.str("text").filter(|_| text) {
147        out.push(index, fragment)?;
148    }
149    Ok((index, content))
150}
151
152impl InteractionsDecoder {
153    /// Open the step at `index` as it starts, writing the content it
154    /// already states.
155    fn start(
156        &mut self,
157        index: usize,
158        step: Value,
159        out: &mut Out<'_, Completion>,
160    ) -> Result<(), ProviderError> {
161        let open = match step.str("type") {
162            Some("thought") => {
163                let summary: Vec<String> = step
164                    .arr("summary")
165                    .iter()
166                    .filter(|summary| summary.str("type") == Some("text"))
167                    .filter_map(|summary| summary.str("text").map(str::to_owned))
168                    .collect();
169                out.open(index, Block::Reasoning { redacted: false }, step)?;
170                for text in summary {
171                    out.push(index, &text)?;
172                }
173                Open::Thought
174            }
175            Some("model_output") => {
176                let items = step.arr("content").iter();
177                let items = items.map(|content| open_content(content.clone(), out));
178                let items = items.collect::<Result<_, _>>()?;
179                let mut step = step;
180                if let Some(step) = step.as_object_mut() {
181                    step.insert("content".to_owned(), json!([]));
182                }
183                Open::Output(step, items)
184            }
185            Some("function_call") => {
186                call_fields(index, &step, out)?;
187                out.edit(index, |slot| *slot = step)?;
188                Open::Call(String::new())
189            }
190            // Input a reply restates is kept and never sent back; a
191            // hosted-tool step, or one rig does not know, replays.
192            kind => {
193                let replay = !matches!(kind, Some("user_input" | "function_result"));
194                out.open(index, Block::Opaque { replay }, step)?;
195                Open::Opaque
196            }
197        };
198        self.steps.insert(index, open);
199        Ok(())
200    }
201
202    /// Apply one delta to the step at `index`. A delta for a step that
203    /// never started opens the step it implies: a resumed stream can join
204    /// a step after its start.
205    fn delta(
206        &mut self,
207        index: usize,
208        delta: Map<String, Value>,
209        out: &mut Out<'_, Completion>,
210    ) -> Result<(), ProviderError> {
211        let kind = delta
212            .get("type")
213            .and_then(Value::as_str)
214            .unwrap_or_default()
215            .to_owned();
216        if !self.steps.contains_key(&index) {
217            let step = match kind.as_str() {
218                "text" | "text_annotation_delta" | "image" | "audio" | "document" | "video" => {
219                    "model_output"
220                }
221                "thought_summary" | "thought_signature" => "thought",
222                "arguments_delta" => "function_call",
223                other => other,
224            };
225            self.start(index, json!({ "type": step }), out)?;
226        }
227        let Some(open) = self.steps.get_mut(&index) else {
228            return Ok(());
229        };
230        match (open, kind.as_str()) {
231            // Text and its annotations extend the last text item and its
232            // block; any other delta is a content item of its own.
233            (Open::Output(_, items), _) => match items.last_mut() {
234                Some((block, item))
235                    if matches!(kind.as_str(), "text" | "text_annotation_delta")
236                        && item.str("type") == Some("text") =>
237                {
238                    if let Some(fragment) = delta.get("text").and_then(Value::as_str)
239                        && kind == "text"
240                    {
241                        out.push(*block, fragment)?;
242                    }
243                    let mut delta = delta;
244                    delta.shift_remove("type");
245                    merge(item, &delta);
246                    Ok(())
247                }
248                _ => {
249                    items.push(open_content(Value::Object(delta), out)?);
250                    Ok(())
251                }
252            },
253            (Open::Thought, "thought_summary") => {
254                let content = delta.get("content").cloned().unwrap_or_default();
255                if let Some(text) = content
256                    .str("text")
257                    .filter(|_| content.str("type") == Some("text"))
258                {
259                    out.push(index, text)?;
260                }
261                out.edit(index, |item| {
262                    merge(
263                        item,
264                        &Map::from_iter([("summary".to_owned(), json!([content]))]),
265                    )
266                })
267            }
268            (Open::Call(arguments), "arguments_delta") => {
269                let fragment = match delta.get("arguments") {
270                    Some(Value::String(fragment)) => fragment.clone(),
271                    Some(Value::Null) | None => String::new(),
272                    Some(other) => other.to_string(),
273                };
274                arguments.push_str(&fragment);
275                let fragment = CallFragment {
276                    arguments: Some(&fragment),
277                    ..CallFragment::default()
278                };
279                out.fragment(Some(index), fragment)
280            }
281            (open, _) => {
282                if matches!(open, Open::Call(_)) && kind == "function_call" {
283                    call_fields(index, &Value::Object(delta.clone()), out)?;
284                }
285                // A delta that restates its step (it carries the step's own
286                // `type`) replaces the fields it names; any other merges.
287                out.edit(index, |item| {
288                    if item.get("type") != delta.get("type") {
289                        merge(item, &delta);
290                    } else if let Some(item) = item.as_object_mut() {
291                        item.extend(delta);
292                    }
293                })
294            }
295        }
296    }
297
298    /// Finish the step at `index`: the API states it complete.
299    fn stop(&mut self, index: usize, out: &mut Out<'_, Completion>) -> Result<(), ProviderError> {
300        match self.steps.remove(&index) {
301            None => Ok(()),
302            Some(Open::Call(arguments)) => {
303                // Streamed fragments supersede the arguments the start
304                // announced.
305                if !arguments.is_empty()
306                    && let Ok(arguments) = crate::json_utils::parse_tool_arguments(&arguments)
307                {
308                    out.edit(index, |item| {
309                        if let Some(item) = item.as_object_mut() {
310                            item.insert("arguments".to_owned(), arguments);
311                        }
312                    })?;
313                }
314                out.finish(index)
315            }
316            // An output with no content is one empty text block holding it.
317            Some(Open::Output(step, items)) if items.is_empty() => {
318                let block = out.fresh_index();
319                out.open(block, Block::Text, step)?;
320                out.finish(block)
321            }
322            Some(Open::Output(step, items)) => {
323                for (block, item) in items {
324                    for annotation in item.arr("annotations") {
325                        if let Some(citation) = annotation_citation(annotation) {
326                            out.cite(block, citation);
327                        }
328                    }
329                    let mut step = step.clone();
330                    if let Some(step) = step.as_object_mut() {
331                        step.insert("content".to_owned(), json!([item]));
332                    }
333                    out.edit(block, |slot| *slot = step)?;
334                    out.finish(block)?;
335                }
336                Ok(())
337            }
338            Some(Open::Thought | Open::Opaque) => out.finish(index),
339        }
340    }
341
342    /// End the reply with the interaction resource it completed with.
343    fn complete(&mut self, interaction: Map<String, Value>, out: Out<'_, Completion>) -> Flow {
344        let interaction = Value::Object(interaction);
345        let field = |key: &str| interaction.str(key).map(str::to_owned);
346        // An agent interaction names its agent in place of a model.
347        let model = field("model")
348            .or_else(|| field("agent"))
349            .or_else(|| self.model.take());
350        let status = field("status").or_else(|| self.status.take());
351        let (reason, error) = ending(status.as_deref(), interaction.arr("errors"));
352        let usage = interaction.get("usage").cloned().unwrap_or(Value::Null);
353        let finish = Finish {
354            usage: usage_of(&usage),
355            reason,
356            response_id: field("id"),
357            model,
358            error,
359        };
360        out.end(finish)
361    }
362}
363
364/// The finish an interaction's `status` states, and the failure it reports.
365/// `completed` and `requires_action` (calls wait for results) are
366/// successes; `incomplete` is the token or execution budget running out,
367/// and `budget_exceeded` its deprecated spelling. `failed` and `cancelled`
368/// are failures carrying the interaction's `errors`; `in_progress` and
369/// `queued` are an interaction read before it ended. An unknown status, or
370/// none, is a failure.
371fn ending(status: Option<&str>, errors: &[Value]) -> (Option<FinishReason>, Option<String>) {
372    let messages: Vec<&str> = errors
373        .iter()
374        .filter_map(|error| error.str("message"))
375        .collect();
376    let detail = (!messages.is_empty()).then(|| format!(": {}", messages.join("; ")));
377    let detail = detail.unwrap_or_default();
378    let other = |status: &str| Some(FinishReason::Other(status.to_owned()));
379    match status {
380        Some("completed") => (Some(FinishReason::Stop), None),
381        Some("requires_action") => (Some(FinishReason::ToolCalls), None),
382        Some("incomplete" | "budget_exceeded") => (Some(FinishReason::Length), None),
383        Some(status @ ("failed" | "cancelled")) => (
384            other(status),
385            Some(format!("The interaction {status}{detail}")),
386        ),
387        Some(status @ ("in_progress" | "queued")) => (
388            other(status),
389            Some(format!("The interaction was read while {status}")),
390        ),
391        Some(status) => (other(status), None),
392        None => (None, Some("The interaction states no status".to_owned())),
393    }
394}
395
396/// The citation a text item's annotation states: a `url_citation` cites a
397/// web page, a `file_citation` a document and its page, a `place_citation`
398/// a place by its URL or id. Offsets count bytes of the item's text.
399/// Other annotations cite nothing.
400fn annotation_citation(annotation: &Value) -> Option<WireCitation> {
401    let field = |key: &str| annotation.str(key).map(str::to_owned);
402    let (location, title) = match annotation.str("type")? {
403        "url_citation" => (SourceLocation::Url { url: field("url")? }, field("title")),
404        "file_citation" => {
405            let page = annotation
406                .u64("page_number")
407                .and_then(|page| u32::try_from(page).ok());
408            let within = page.map(|page| DocumentRange::Pages(page..page.saturating_add(1)));
409            let id = field("document_uri");
410            (
411                SourceLocation::Document {
412                    index: None,
413                    id,
414                    within,
415                },
416                field("file_name"),
417            )
418        }
419        "place_citation" => {
420            let location = match (field("url"), field("place_id")) {
421                (Some(url), _) => SourceLocation::Url { url },
422                (None, Some(id)) => SourceLocation::Document {
423                    index: None,
424                    id: Some(id),
425                    within: None,
426                },
427                (None, None) => return None,
428            };
429            (location, field("name"))
430        }
431        _ => return None,
432    };
433    let mut source = Source::new(location);
434    if let Some(title) = title {
435        source = source.title(title);
436    }
437    // Proto3 JSON leaves out a zero start.
438    let span = annotation.u64("end_index").map(|end| {
439        WireSpan::new(
440            annotation.u64("start_index").unwrap_or(0),
441            end,
442            SpanUnit::Bytes,
443        )
444    });
445    Some(WireCitation::new(span, vec![source]))
446}
447
448/// Write the id, name and announced arguments a call step states.
449fn call_fields(
450    index: usize,
451    step: &Value,
452    out: &mut Out<'_, Completion>,
453) -> Result<(), ProviderError> {
454    let (id, name) = (step.str("id"), step.str("name"));
455    out.fragment(
456        Some(index),
457        CallFragment {
458            id,
459            name,
460            arguments: None,
461        },
462    )?;
463    step.get("arguments")
464        .map_or(Ok(()), |arguments| out.announce(index, arguments.clone()))
465}
466
467/// EOF without `interaction.completed` is truncation, not successful
468/// completion, so the decoder has nothing to add at the end of the reply.
469impl<'id> Decoder<'id, Completion> for InteractionsDecoder {
470    type Event = InteractionsEvent;
471
472    /// Classify a frame by its `event_type` tag, or as a whole resource by
473    /// its marker keys when it carries no tag. A tagged frame never passes
474    /// for a whole resource.
475    fn classify(&self, frame: WireFrame) -> WireEvent<InteractionsEvent> {
476        wire::classify_or_untagged(
477            &frame.as_str(),
478            "event_type",
479            |data| {
480                wire::classify_tagged_frame(data, "event_type", |tag| {
481                    KNOWN_EVENT_TYPES.contains(&tag)
482                })
483                .map(InteractionsEvent::Sse)
484            },
485            |data| {
486                wire::classify_marker_keyed_frame(data, INTERACTION_MARKER_KEYS)
487                    .map(InteractionsEvent::Whole)
488            },
489        )
490    }
491
492    fn decode(
493        &mut self,
494        event: InteractionsEvent,
495        mut out: Out<'id, Completion>,
496    ) -> Result<Flow, ProviderError> {
497        let SseEvent {
498            event_type,
499            fields: event,
500        } = match event {
501            InteractionsEvent::Sse(event) => event,
502            InteractionsEvent::Whole(interaction) => {
503                let steps = Value::Object(interaction.clone());
504                for (index, step) in steps.arr("steps").iter().enumerate() {
505                    self.start(index, step.clone(), &mut out)?;
506                    self.stop(index, &mut out)?;
507                }
508                return Ok(self.complete(interaction, out));
509            }
510        };
511        let event = Value::Object(event);
512        let index = || {
513            event
514                .u64("index")
515                .and_then(|index| usize::try_from(index).ok())
516                .ok_or_else(|| {
517                    ProviderError::Response(format!(
518                        "an Interactions `{event_type}` event names no step index"
519                    ))
520                })
521        };
522        let malformed = |field: &str| {
523            ProviderError::Response(format!(
524                "an Interactions `{event_type}` event carries no `{field}` object"
525            ))
526        };
527        match event_type.as_str() {
528            "step.start" => match event.get("step") {
529                // A start that states no step leaves its deltas to open it.
530                None => {}
531                Some(step @ Value::Object(_)) => self.start(index()?, step.clone(), &mut out)?,
532                Some(_) => return Err(malformed("step")),
533            },
534            "step.delta" => match event.get("delta") {
535                Some(Value::Object(delta)) => self.delta(index()?, delta.clone(), &mut out)?,
536                _ => return Err(malformed("delta")),
537            },
538            "step.stop" => self.stop(index()?, &mut out)?,
539            "interaction.created" => {
540                let field = |key: &str| {
541                    event
542                        .at(&format!("/interaction/{key}"))
543                        .and_then(Value::as_str)
544                        .map(str::to_owned)
545                };
546                self.model = field("model").or_else(|| field("agent"));
547                self.status = field("status");
548            }
549            "interaction.status_update" => {
550                if let Some(status) = event.str("status") {
551                    self.status = Some(status.to_owned());
552                }
553            }
554            "interaction.completed" => {
555                let interaction = event.obj("interaction").cloned().unwrap_or_default();
556                return Ok(self.complete(interaction, out));
557            }
558            "error" => {
559                let Value::Object(mut body) = event else {
560                    return Err(ProviderError::from_provider_body(event_type));
561                };
562                body.insert("event_type".to_owned(), Value::from(event_type));
563                return Err(ProviderError::from_provider_body(
564                    Value::Object(body).to_string(),
565                ));
566            }
567            _ => {}
568        }
569        Ok(Flow::More)
570    }
571}
572
573pub mod document;
574
575#[cfg(test)]
576mod tests;