Skip to main content

rig_core/providers/gemini/interactions_api/
mod.rs

1//! Wires for the [Gemini Interactions API](https://ai.google.dev/api/interactions-api).
2//! A request is built as JSON steps, and a reply, a whole interaction or a
3//! stream of step events, is read by [`InteractionsDecoder`](streaming::InteractionsDecoder).
4//!
5//! ```no_run
6//! use rig_core::providers::gemini::{Gemini, completion::GEMINI_2_5_FLASH};
7//!
8//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
9//! let wire = Gemini::from_env()?.interactions(GEMINI_2_5_FLASH);
10//! # Ok(())
11//! # }
12//! ```
13
14use serde_json::{Map, Value, json};
15use url::form_urlencoded;
16
17use crate::completion::{CompletionRequest, Media, Replay, ReplayTarget};
18use crate::error::EncodeError;
19use crate::message::{
20    AssistantContent, DocumentData, DocumentMediaType, DocumentSourceKind as Source, Message,
21    MimeType, ToolChoice as Choice, ToolResultContent, UserContent,
22};
23use crate::providers::internal::wire_ids::WireIds;
24use crate::telemetry::GenAiOperation;
25use crate::wire::{Descriptor, Mode};
26
27/// Streaming helpers for the Interactions API.
28pub mod streaming;
29
30use super::completion::PROVIDER_NAME;
31
32/// `deep-research-preview-04-2026`, the Deep Research agent, built for
33/// speed. Name it with
34/// [`InteractionsOptions::agent`](super::extension::InteractionsOptions::agent).
35pub const DEEP_RESEARCH_PREVIEW_04_2026: &str = "deep-research-preview-04-2026";
36/// `deep-research-max-preview-04-2026`, Deep Research Max, built for
37/// thoroughness.
38pub const DEEP_RESEARCH_MAX_PREVIEW_04_2026: &str = "deep-research-max-preview-04-2026";
39
40/// The wire format both Interactions wires speak.
41const API: crate::message::Api = crate::message::Api::from_static("gemini.interactions");
42
43/// The Interactions `speech_config`: one voice, or a voice per named
44/// speaker.
45#[non_exhaustive]
46#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
47#[serde(untagged)]
48pub enum InteractionSpeechConfig {
49    /// An array of one voice.
50    Voice([InteractionSpeech; 1]),
51    /// `{"speakers": [...]}`: each speaker's voice, matched by the name the
52    /// input's `speech_metadata` gives the speaker.
53    Speakers {
54        /// `speakers`.
55        speakers: Vec<InteractionSpeech>,
56    },
57}
58
59impl InteractionSpeechConfig {
60    /// Speak with `voice`.
61    pub fn voice(voice: InteractionSpeech) -> Self {
62        Self::Voice([voice])
63    }
64
65    /// Speak each `(speaker, voice)` pair's turns in its voice.
66    pub fn speakers<S: Into<String>, V: Into<String>>(
67        speakers: impl IntoIterator<Item = (S, V)>,
68    ) -> Self {
69        Self::Speakers {
70            speakers: speakers
71                .into_iter()
72                .map(|(speaker, voice)| InteractionSpeech::voice(voice).speaker(speaker))
73                .collect(),
74        }
75    }
76}
77
78/// One voice of the Interactions `speech_config`.
79#[non_exhaustive]
80#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
81pub struct InteractionSpeech {
82    /// `voice`.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub voice: Option<String>,
85    /// `language`.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub language: Option<String>,
88    /// `speaker`: the name the prompt gives the speaker.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub speaker: Option<String>,
91}
92
93impl InteractionSpeech {
94    /// Speak with `voice`.
95    pub fn voice(voice: impl Into<String>) -> Self {
96        Self {
97            voice: Some(voice.into()),
98            ..Self::default()
99        }
100    }
101
102    /// `language`.
103    pub fn language(mut self, language: impl Into<String>) -> Self {
104        self.language = Some(language.into());
105        self
106    }
107
108    /// `speaker`.
109    pub fn speaker(mut self, speaker: impl Into<String>) -> Self {
110        self.speaker = Some(speaker.into());
111        self
112    }
113}
114
115/// Create interactions with `POST /v1beta/interactions`.
116/// Streaming mode sets `alt=sse` and `stream: true`; unary mode reads a whole resource.
117#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
118pub struct Interactions {
119    /// The key and the API root.
120    pub provider: crate::providers::gemini::GeminiConfig,
121    /// The model to address.
122    pub model: String,
123    /// The model facts the encoder reads and replies are priced by.
124    #[serde(skip)]
125    pub facts: crate::catalog::ModelFacts,
126}
127
128impl Interactions {
129    /// The wire for `model`.
130    pub fn new(provider: crate::providers::gemini::GeminiConfig, model: impl Into<String>) -> Self {
131        Self {
132            provider,
133            model: model.into(),
134            facts: crate::catalog::ModelFacts::default(),
135        }
136    }
137
138    /// The same wire, encoding with `facts` and pricing its replies by
139    /// them.
140    pub fn with_facts(mut self, facts: crate::catalog::ModelFacts) -> Self {
141        self.facts = facts;
142        self
143    }
144}
145
146impl crate::wire::Wire for Interactions {
147    type Op = crate::operation::Completion;
148    type Payload = crate::wire::Encoded;
149    type Frame = crate::wire::WireFrame;
150    type Event = streaming::InteractionsEvent;
151    type Decoder<'id> = streaming::InteractionsDecoder;
152    type Reassembler = streaming::document::Interaction;
153
154    fn describe(&self) -> Descriptor<'_> {
155        Descriptor::new(PROVIDER_NAME)
156            .model(self.model.as_str())
157            .facts(&self.facts)
158            .telemetry(|_| GenAiOperation::Chat)
159            .replay(self)
160    }
161
162    fn encode(
163        &self,
164        request: CompletionRequest,
165        mode: Mode,
166    ) -> Result<crate::wire::Encoded, EncodeError> {
167        use crate::providers::internal::LogTarget;
168        // `stream` is part of the request body on this wire, so the mode is
169        // in the bytes as well as in the path.
170        let streaming = matches!(mode, Mode::Streaming);
171        let body = create_request_body(self, &request, Some(streaming))?;
172        let (path, framing, target) = match streaming {
173            true => (
174                "/v1beta/interactions?alt=sse",
175                crate::wire::Framing::Sse,
176                LogTarget::Streaming,
177            ),
178            false => (
179                "/v1beta/interactions",
180                crate::wire::Framing::Whole,
181                LogTarget::Completions,
182            ),
183        };
184        crate::providers::internal::trace_json(
185            target,
186            "Gemini interactions completion request",
187            &body,
188        );
189        let request = http::Request::post(self.provider.interactions_uri(path))
190            .header("Content-Type", "application/json")
191            .header(
192                crate::providers::gemini::GeminiConfig::INTERACTIONS_KEY_HEADER,
193                self.provider.api_key.expose(),
194            )
195            .body(body.into_body())?;
196        // Gemini supplies no transport request-id response header.
197        Ok(crate::wire::Encoded::new(request, framing))
198    }
199
200    fn decoder<'id>(&self) -> Self::Decoder<'id> {
201        streaming::InteractionsDecoder::default()
202    }
203}
204
205impl ReplayTarget for Interactions {
206    /// Section 6.4 of the typed-options design, for Interactions.
207    fn map_options(
208        &self,
209        request: &CompletionRequest,
210        fields: crate::completion::options::OptionFields<'_>,
211    ) -> crate::completion::options::OptionMap {
212        let model = request.model.as_deref().unwrap_or(&self.model);
213        super::options::interactions(&self.facts, model, fields)
214    }
215
216    fn facts(&self) -> Option<&crate::catalog::ModelFacts> {
217        Some(&self.facts)
218    }
219
220    fn api(&self) -> crate::message::Api {
221        API
222    }
223
224    fn provider(&self) -> &str {
225        PROVIDER_NAME
226    }
227
228    fn model(&self) -> &str {
229        &self.model
230    }
231
232    /// What the model reads, by the classifier every Gemini wire shares.
233    fn accepts(&self, model: &str) -> crate::completion::Accepts {
234        super::completion::accepts(model)
235    }
236
237    fn encodes(&self, _model: &str, media: Media<'_>) -> bool {
238        encodes(media)
239    }
240
241    /// A request naming `previous_interaction_id` continues an interaction
242    /// the API stored, which holds the calls its first results answer.
243    fn continues_stored(&self, request: &CompletionRequest) -> bool {
244        crate::completion::options::param(self, request, "previous_interaction_id")
245            .is_some_and(|id| !id.is_null())
246    }
247
248    fn normalize_tool_call_id(
249        &self,
250        id: &str,
251        _model: &str,
252        _: Option<&crate::message::Origin>,
253    ) -> String {
254        crate::providers::internal::wire_ids::legal_call_id(id, 64)
255    }
256
257    /// Gemini takes system text only in `systemInstruction`: later system
258    /// messages fold into the leading one.
259    fn later_system(&self, _model: &str) -> crate::completion::LaterSystem {
260        crate::completion::LaterSystem::Leading
261    }
262
263    fn call_id_slot(&self) -> Option<&'static str> {
264        Some("/id")
265    }
266
267    /// A step's `id` survives an edit of its block.
268    fn identity(&self, item: &Value) -> Map<String, Value> {
269        item.get("id")
270            .map(|id| Map::from_iter([("id".to_owned(), id.clone())]))
271            .unwrap_or_default()
272    }
273}
274
275/// Whether this API takes `media`: data or a URL with a media type, an
276/// image of a type Gemini reads, a PDF, and a text document, which is sent
277/// as text. A file id is never taken.
278fn encodes(media: Media<'_>) -> bool {
279    let carried = |source: &Source| matches!(source, Source::Url(_) | Source::Base64(_));
280    match media {
281        Media::Image(image, place) => {
282            super::completion::reads_image(image.media_type.as_ref(), place) && carried(&image.data)
283        }
284        Media::Audio(audio) => audio.media_type.is_some() && carried(&audio.data),
285        Media::Video(video) => video.media_type.is_some() && carried(&video.data),
286        Media::Document(document) => match (&document.media_type, &document.data) {
287            (_, DocumentData::Text(_)) => true,
288            (Some(DocumentMediaType::PDF), DocumentData::File(data)) => carried(data),
289            _ => false,
290        },
291    }
292}
293
294/// Read an existing interaction resource or resume its event stream.
295/// Unary mode retrieves the resource once. Streaming mode resumes after
296/// `last_event_id`, or from the beginning if no event id is supplied.
297#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
298pub struct InteractionResume {
299    /// The key and the API root.
300    pub provider: crate::providers::gemini::GeminiConfig,
301    /// The interaction to read.
302    pub interaction_id: String,
303    /// The last event the consumer saw, so a resumed stream does not
304    /// redeliver it. `None` resumes from the beginning, as the API defaults.
305    pub last_event_id: Option<String>,
306    /// The model facts the reply, from the model the interaction names, is
307    /// priced by.
308    #[serde(skip)]
309    pub facts: crate::catalog::ModelFacts,
310}
311
312impl InteractionResume {
313    /// The wire for the interaction `interaction_id`.
314    pub fn new(
315        provider: crate::providers::gemini::GeminiConfig,
316        interaction_id: impl Into<String>,
317    ) -> Self {
318        Self {
319            provider,
320            interaction_id: interaction_id.into(),
321            last_event_id: None,
322            facts: crate::catalog::ModelFacts::default(),
323        }
324    }
325
326    /// The same wire, pricing its replies by `facts`.
327    pub fn with_facts(mut self, facts: crate::catalog::ModelFacts) -> Self {
328        self.facts = facts;
329        self
330    }
331
332    /// Resume a streamed read after the event `last_event_id`.
333    pub fn after_event(mut self, last_event_id: impl Into<String>) -> Self {
334        self.last_event_id = Some(last_event_id.into());
335        self
336    }
337}
338
339impl crate::wire::Wire for InteractionResume {
340    type Op = crate::operation::Completion;
341    type Payload = crate::wire::Encoded;
342    type Frame = crate::wire::WireFrame;
343    type Event = streaming::InteractionsEvent;
344    type Decoder<'id> = streaming::InteractionsDecoder;
345    type Reassembler = streaming::document::Interaction;
346
347    /// The interaction names its own model; this wire addresses no model id.
348    /// The decoder reports the model the interaction names, which the turn's
349    /// origin takes.
350    fn describe(&self) -> Descriptor<'_> {
351        Descriptor::new(PROVIDER_NAME)
352            .facts(&self.facts)
353            .telemetry(|_| GenAiOperation::Chat)
354            .replay(self)
355    }
356
357    /// Reads an existing interaction, so the request carries no body: what
358    /// to read is the wire's own data. A request that sets options,
359    /// provider options or `additional_params` is refused, since nothing
360    /// would send them.
361    fn encode(
362        &self,
363        request: CompletionRequest,
364        mode: Mode,
365    ) -> Result<crate::wire::Encoded, EncodeError> {
366        let params = crate::completion::options::request_params(
367            self,
368            &request,
369            // `request_params` takes `additional_params.tools` (provider
370            // tools included) out of the raw layer for the base to append,
371            // so the base refuses them here or they would vanish.
372            |input| {
373                if input.raw_tools()?.is_empty() {
374                    Ok(Map::new())
375                } else {
376                    Err(EncodeError::request(
377                        "a resumed interaction takes no `additional_params.tools` or provider \
378                         tools: it is read, not created",
379                    ))
380                }
381            },
382            crate::completion::options::RawAt::Top,
383            &[],
384        )?;
385        if !params.is_empty() {
386            return Err(EncodeError::request(
387                "a resumed interaction takes no provider options or `additional_params`: it is \
388                 read, not created",
389            ));
390        }
391        let id = &self.interaction_id;
392        let (path, framing) = match mode {
393            Mode::Unary => (
394                format!("/v1beta/interactions/{id}"),
395                crate::wire::Framing::Whole,
396            ),
397            Mode::Streaming => {
398                let mut query = form_urlencoded::Serializer::new(String::new());
399                query.append_pair("stream", "true");
400                if let Some(last_event_id) = &self.last_event_id {
401                    query.append_pair("last_event_id", last_event_id);
402                }
403                let path = format!("/v1beta/interactions/{id}?{}&alt=sse", query.finish());
404                (path, crate::wire::Framing::Sse)
405            }
406        };
407        let request = http::Request::get(self.provider.interactions_uri(&path))
408            .header(
409                crate::providers::gemini::GeminiConfig::INTERACTIONS_KEY_HEADER,
410                self.provider.api_key.expose(),
411            )
412            .body(crate::wire::Body::empty())?;
413        Ok(crate::wire::Encoded::new(request, framing))
414    }
415
416    fn decoder<'id>(&self) -> Self::Decoder<'id> {
417        streaming::InteractionsDecoder::resuming(self.interaction_id.as_str())
418    }
419}
420
421impl ReplayTarget for InteractionResume {
422    /// A resumed interaction is read, not created, so every set option is
423    /// refused.
424    fn map_options(
425        &self,
426        _request: &CompletionRequest,
427        fields: crate::completion::options::OptionFields<'_>,
428    ) -> crate::completion::options::OptionMap {
429        crate::providers::internal::refuse_all(
430            fields,
431            "a resumed interaction is read, not created; its options were fixed when it was \
432             created",
433        )
434    }
435
436    fn facts(&self) -> Option<&crate::catalog::ModelFacts> {
437        Some(&self.facts)
438    }
439
440    fn api(&self) -> crate::message::Api {
441        API
442    }
443
444    fn provider(&self) -> &str {
445        PROVIDER_NAME
446    }
447
448    fn model(&self) -> &str {
449        ""
450    }
451
452    fn accepts(&self, model: &str) -> crate::completion::Accepts {
453        super::completion::accepts(model)
454    }
455
456    fn normalize_tool_call_id(
457        &self,
458        id: &str,
459        _model: &str,
460        _: Option<&crate::message::Origin>,
461    ) -> String {
462        crate::providers::internal::wire_ids::legal_call_id(id, 64)
463    }
464
465    /// Gemini takes system text only in `systemInstruction`: later system
466    /// messages fold into the leading one.
467    fn later_system(&self, _model: &str) -> crate::completion::LaterSystem {
468        crate::completion::LaterSystem::Leading
469    }
470
471    fn call_id_slot(&self) -> Option<&'static str> {
472        Some("/id")
473    }
474}
475
476/// The create-interaction body `request` sends on `wire`: the wire's own
477/// encoding, then the mapped options, then `additional_params`, merged by
478/// [`request_params`](crate::completion::options::request_params). Its
479/// `generation_config` merges over the typed fields key by key, its `tools`
480/// add to the request's, and an `agent` takes the place of the model.
481/// System messages become the system instruction. `stream`, when set,
482/// overrides the one the parameters name.
483///
484/// # Errors
485///
486/// When an option is refused, `additional_params` is not an object, its
487/// `tools` are not an array, or it sets `response_format` without
488/// `response_mime_type`.
489pub(crate) fn create_request_body(
490    wire: &Interactions,
491    request: &CompletionRequest,
492    stream: Option<bool>,
493) -> Result<crate::completion::options::FinalBody, EncodeError> {
494    let rewrites: Vec<_> = stream
495        .map(crate::completion::options::Rewrite::Stream)
496        .into_iter()
497        .collect();
498    crate::completion::options::request_params(
499        wire,
500        request,
501        |input| create_base(wire, request, input),
502        crate::completion::options::RawAt::Top,
503        &rewrites,
504    )
505}
506
507/// The wire's own encoding of `request`: model, input steps, system
508/// instruction, tools and the typed fields in `generation_config`.
509fn create_base(
510    wire: &Interactions,
511    request: &CompletionRequest,
512    input: &mut crate::completion::options::BaseInput<'_>,
513) -> Result<Map<String, Value>, EncodeError> {
514    let model = request.model.clone().unwrap_or_else(|| wire.model.clone());
515    let set = |key: &str| input.param(key).is_some_and(|value| !value.is_null());
516    let agent = set("agent");
517    if set("response_format") && !set("response_mime_type") {
518        return Err(EncodeError::request(
519            "response_mime_type is required when response_format is set",
520        ));
521    }
522    let choice = request.tool_choice.clone().map(|choice| match choice {
523        Choice::Auto => json!("auto"),
524        Choice::None => json!("none"),
525        Choice::Required => json!("any"),
526        Choice::Specific { function_names } => {
527            json!({ "allowed_tools": { "mode": "validated", "tools": function_names } })
528        }
529    });
530    let config = crate::providers::internal::set_fields([
531        ("temperature", request.temperature.map(Value::from)),
532        ("max_output_tokens", request.max_tokens.map(Value::from)),
533        ("tool_choice", choice),
534    ]);
535    let mut tools: Vec<Value> = request
536        .tools
537        .iter()
538        .map(|tool| json!({ "type": "function", "name": tool.name, "description": tool.description, "parameters": tool.parameters }))
539        .collect();
540    tools.extend(input.raw_tools()?);
541    let (mut system, mut history) = (Vec::new(), Vec::new());
542    for message in &request.chat_history {
543        match &**message {
544            Message::System { content } => system.push(content.as_str()),
545            message => history.push(message),
546        }
547    }
548    let mut body = Map::new();
549    if !config.is_empty() {
550        body.insert("generation_config".to_owned(), Value::Object(config));
551    }
552    if !tools.is_empty() {
553        body.insert("tools".to_owned(), Value::Array(tools));
554    }
555    if !system.is_empty() {
556        body.insert("system_instruction".to_owned(), json!(system.join("\n\n")));
557    }
558    if !agent {
559        body.insert("model".to_owned(), json!(model));
560    }
561    body.insert(
562        "input".to_owned(),
563        Value::Array(steps(&history, wire, &model)?),
564    );
565    Ok(body)
566}
567
568/// The steps `history` sends: a user message's content grouped into
569/// `user_input` steps around its function results, each a step of its own,
570/// and one step per assistant block.
571fn steps(
572    history: &[&Message],
573    target: &dyn ReplayTarget,
574    model: &str,
575) -> Result<Vec<Value>, EncodeError> {
576    let ids = WireIds::for_target(history, target, model);
577    let mut steps = Vec::new();
578    for message in history {
579        match message {
580            Message::System { content } => {
581                steps.push(json!({ "type": "user_input", "content": [{ "type": "text", "text": content }] }));
582            }
583            Message::User { content } => {
584                let mut run = Vec::new();
585                for part in content {
586                    let UserContent::ToolResult(result) = part else {
587                        run.push(user_content(part)?);
588                        continue;
589                    };
590                    if !run.is_empty() {
591                        steps.push(user_input(std::mem::take(&mut run)));
592                    }
593                    let value = match result.content.as_slice() {
594                        [ToolResultContent::Text(text)] => Value::from(text.text.as_str()),
595                        // A scalar or array JSON result is wrapped as the
596                        // generate wire wraps it: sent as a text block it is a
597                        // multimodal response, which the models refuse.
598                        [
599                            ToolResultContent::Json {
600                                value: value @ (Value::String(_) | Value::Object(_)),
601                            },
602                        ] => value.clone(),
603                        [ToolResultContent::Json { value }] => json!({ "result": value }),
604                        contents => Value::Array(
605                            contents
606                                .iter()
607                                .map(result_content)
608                                .collect::<Result<_, _>>()?,
609                        ),
610                    };
611                    let mut step = Map::from_iter([
612                        ("type".to_owned(), Value::from("function_result")),
613                        ("name".to_owned(), Value::from(result.name.as_str())),
614                        ("call_id".to_owned(), Value::from(ids.of(&result.call))),
615                        ("result".to_owned(), value),
616                    ]);
617                    if result.is_error {
618                        step.insert("is_error".to_owned(), Value::Bool(true));
619                    }
620                    steps.push(Value::Object(step));
621                }
622                if !run.is_empty() {
623                    steps.push(user_input(run));
624                }
625            }
626            Message::Assistant(turn) => {
627                for block in &turn.content {
628                    steps.extend(assistant_step(block, target, &ids)?);
629                }
630            }
631        }
632    }
633    Ok(steps)
634}
635
636/// A `user_input` step of `content`.
637fn user_input(content: Vec<Value>) -> Value {
638    Value::Object(Map::from_iter([
639        ("type".to_owned(), Value::from("user_input")),
640        ("content".to_owned(), Value::Array(content)),
641    ]))
642}
643
644/// A block of a tool result as a content item.
645fn result_content(content: &ToolResultContent) -> Result<Value, EncodeError> {
646    Ok(match content {
647        ToolResultContent::Text(text) => json!({ "type": "text", "text": text.text }),
648        ToolResultContent::Json { value } => json!({ "type": "text", "text": value.to_string() }),
649        ToolResultContent::Image(image) => media("image", image.media_type.clone(), &image.data)?,
650    })
651}
652
653/// A user part as a content item. A text document goes as text, so that RAG
654/// context reads as prose.
655fn user_content(part: &UserContent) -> Result<Value, EncodeError> {
656    match part {
657        UserContent::Text(text) => Ok(json!({ "type": "text", "text": text.text })),
658        UserContent::Image(image) => media("image", image.media_type.clone(), &image.data),
659        UserContent::Audio(audio) => media("audio", audio.media_type.clone(), &audio.data),
660        UserContent::Video(video) => media("video", video.media_type.clone(), &video.data),
661        UserContent::Document(document) => match &document.data {
662            DocumentData::Text(text) => Ok(json!({ "type": "text", "text": text })),
663            DocumentData::File(data) => media("document", document.media_type.clone(), data),
664        },
665        UserContent::ToolResult(_) => {
666            Err(EncodeError::request("a tool result is a step of its own"))
667        }
668    }
669}
670
671/// A media content item of `kind`: a URL as its `uri`, and base64 data as its
672/// `data`. [`encodes`] refuses every other form, so the adapter passes none.
673fn media<M: MimeType>(
674    kind: &str,
675    media_type: Option<M>,
676    source: &Source,
677) -> Result<Value, EncodeError> {
678    let unsendable = || {
679        EncodeError::request(format!(
680            "Gemini Interactions cannot receive this {kind} in its form"
681        ))
682    };
683    let mime_type = media_type.ok_or_else(unsendable)?.to_mime_type().to_owned();
684    let key = match super::completion::carried(source)? {
685        (true, uri) => ("uri", uri),
686        (false, data) => ("data", data),
687    };
688    Ok(json!({ "type": kind, key.0: key.1, "mime_type": mime_type }))
689}
690
691/// An assistant block as its step: the provider's step while the block is
692/// current, else one rebuilt from its canonical fields, keeping the keys
693/// its edited step names; `None` for reasoning with nothing to send.
694fn assistant_step(
695    block: &AssistantContent,
696    target: &dyn ReplayTarget,
697    ids: &WireIds,
698) -> Result<Option<Value>, EncodeError> {
699    let identity = match block.replay(target, ids) {
700        Replay::Item(item) => return Ok(Some(item.into_owned())),
701        Replay::Identity(identity) => identity,
702        Replay::Rebuild => Map::new(),
703    };
704    let output = |content: Value| {
705        Value::Object(Map::from_iter([
706            ("type".to_owned(), Value::from("model_output")),
707            ("content".to_owned(), Value::Array(vec![content])),
708        ]))
709    };
710    let mut step = match block {
711        AssistantContent::Text(text) => output(json!({ "type": "text", "text": text.text })),
712        AssistantContent::Reasoning(reasoning)
713            if reasoning.redacted || reasoning.text.trim().is_empty() =>
714        {
715            return Ok(None);
716        }
717        AssistantContent::Reasoning(reasoning) => {
718            json!({ "type": "thought", "summary": [{ "type": "text", "text": reasoning.text }] })
719        }
720        AssistantContent::ToolCall(call) => json!({
721            "type": "function_call",
722            "name": call.function.name,
723            "arguments": call.function.arguments,
724        }),
725        AssistantContent::Image(image) => {
726            output(media("image", image.media_type.clone(), &image.data)?)
727        }
728        AssistantContent::Opaque(opaque) => return Ok(Some(opaque.item.clone())),
729    };
730    if let Some(fields) = step.as_object_mut() {
731        fields.extend(identity);
732        if let AssistantContent::ToolCall(call) = block {
733            fields.insert("id".to_owned(), json!(ids.of(&call.id)));
734        }
735    }
736    Ok(Some(step))
737}
738
739#[cfg(test)]
740mod tests;
741
742#[cfg(test)]
743mod history_tests;