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