Skip to main content

rig_core/providers/openai/wire/
chat.rs

1//! Chat Completions: the request built as JSON, and one lenient decoder for
2//! a whole reply and a stream of chunks.
3//!
4//! ```
5//! use rig_core::providers::openai::OpenAI;
6//! let wire = OpenAI::new("key").chat("gpt-5.2");
7//! ```
8
9use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value, json};
11use std::borrow::Cow;
12use std::sync::Arc;
13
14use crate::catalog::ModelFacts;
15use crate::completion::options::{BaseInput, RawAt, Rewrite, request_params};
16use crate::completion::{CompletionRequest, FinishReason, ProviderCapabilities, Replay};
17use crate::error::{EncodeError, ProviderError};
18use crate::json_utils::Lenient;
19use crate::message::{
20    AssistantContent, AssistantMessage, DocumentData, DocumentMediaType,
21    DocumentSourceKind as Source, Message, MimeType, ToolCall, ToolResult, ToolResultContent,
22    UserContent,
23};
24use crate::observe::ObservedError;
25use crate::operation::{Block, CallFragment, Completion, Finish};
26use crate::providers::internal::object;
27use crate::providers::internal::wire::classify_chat_completions_frame;
28use crate::providers::internal::wire_ids::WireIds;
29use crate::wire::{
30    AdapterVerdict, Capabilities, Decoder, Descriptor, Encoded, Flow, Framing, Mode,
31    ObservationSink, Out, Wire, WireEvent, WireFrame,
32};
33use finish::{finish_reason, native_finish_reason, provider_error_envelope};
34
35use super::dto::merge_fields;
36use super::{BodyRewrite, OpenAIConfig, OutputCap, Quirks};
37
38/// The chat-completions wire: a provider configuration, a model, and the
39/// per-turn options the endpoint takes.
40#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
41pub struct Chat {
42    /// Which provider, and how to reach it.
43    pub provider: OpenAIConfig,
44    /// The model this wire addresses.
45    pub model: String,
46    /// Whether tool schemas are sanitized for OpenAI's strict mode:
47    /// `additionalProperties: false` on every object, every property
48    /// required, and `strict: true` on each function definition.
49    pub strict_tools: bool,
50    /// Whether tool-result messages serialize their content as arrays.
51    pub tool_result_array_content: bool,
52    /// The model facts the encoder reads and replies are priced by.
53    #[serde(skip)]
54    pub facts: ModelFacts,
55}
56
57/// The error for media in a form Chat Completions cannot carry, which the
58/// adapter replaces before a request is encoded
59/// ([`ReplayTarget::encodes`](crate::completion::ReplayTarget::encodes)).
60fn unsendable(what: &str) -> EncodeError {
61    EncodeError::request(format!("Chat Completions cannot carry {what} in this form"))
62}
63
64/// `data` as a URL a content part names: a URL, or typed base64 data as a
65/// data URI.
66fn media_url(data: &Source, mime: Option<&str>) -> Option<String> {
67    match (data, mime) {
68        (Source::Url(url), _) => Some(url.clone()),
69        (Source::Base64(data), Some(mime)) => Some(format!("data:{mime};base64,{data}")),
70        _ => None,
71    }
72}
73
74/// The `image_url` part an image is sent as; a user image always names a
75/// detail level, `auto` by default.
76fn image_url(image: &crate::message::Image, user: bool) -> Result<Value, EncodeError> {
77    let mime = image.media_type.as_ref().map(MimeType::to_mime_type);
78    let url = media_url(&image.data, mime).ok_or_else(|| unsendable("an image"))?;
79    let mut part = Map::from_iter([("url".to_owned(), Value::String(url))]);
80    let detail = match &image.detail {
81        Some(detail) => Some(detail.clone()),
82        None => user.then(Default::default),
83    };
84    if let Some(detail) = detail {
85        part.insert("detail".to_owned(), serde_json::to_value(detail)?);
86    }
87    Ok(object! {"type": "image_url", "image_url": part})
88}
89
90/// One user content part as Chat Completions carries it.
91fn user_part(part: &UserContent) -> Result<Value, EncodeError> {
92    let file = |file: Value| object! {"type": "file", "file": file};
93    Ok(match part {
94        UserContent::Text(text) => json!({"type": "text", "text": text.text}),
95        UserContent::Image(image) => image_url(image, true)?,
96        UserContent::Document(document) => {
97            let pdf = document.media_type == Some(DocumentMediaType::PDF);
98            match &document.data {
99                DocumentData::Text(text) => json!({"type": "text", "text": text}),
100                DocumentData::File(Source::FileId(id)) => file(json!({ "file_id": id })),
101                DocumentData::File(Source::Base64(data)) if pdf => file(object! {
102                    "file_data": format!("data:application/pdf;base64,{data}"),
103                    "filename": "document.pdf",
104                }),
105                // OpenRouter and Mistral fetch a PDF a URL names.
106                DocumentData::File(Source::Url(url)) if pdf => {
107                    file(json!({"file_data": url, "filename": "document.pdf"}))
108                }
109                DocumentData::File(_) => return Err(unsendable("a document")),
110            }
111        }
112        UserContent::Audio(audio) => match &audio.data {
113            Source::Base64(data) => json!({"type": "input_audio", "input_audio": {
114                "data": data,
115                "format": audio.media_type.clone().unwrap_or(crate::message::AudioMediaType::MP3),
116            }}),
117            _ => return Err(unsendable("audio")),
118        },
119        UserContent::Video(video) => {
120            let mime = video.media_type.as_ref().map(MimeType::to_mime_type);
121            let url = media_url(&video.data, mime).ok_or_else(|| unsendable("a video"))?;
122            object! {"type": "video_url", "video_url": object! {"url": url}}
123        }
124        UserContent::ToolResult(_) => return Err(unsendable("a tool result as a content part")),
125    })
126}
127
128/// A result as the `tool` message that answers its call: its text joined
129/// into one string, or its parts as an array when the wire asks for one or
130/// it holds an image, which has no string form.
131fn tool_message(result: &ToolResult, ids: &WireIds, array: bool) -> Result<Value, EncodeError> {
132    fn text(part: &ToolResultContent) -> Option<Cow<'_, str>> {
133        match part {
134            ToolResultContent::Text(text) => Some(Cow::Borrowed(&text.text)),
135            ToolResultContent::Json { value } => Some(Cow::Owned(value.to_string())),
136            ToolResultContent::Image(_) => None,
137        }
138    }
139    let image = |part: &ToolResultContent| matches!(part, ToolResultContent::Image(_));
140    let content = if array || result.content.iter().any(image) {
141        let parts = result.content.iter().map(|part| match part {
142            ToolResultContent::Image(image) => image_url(image, false),
143            _ => Ok(object! {"type": "text", "text": text(part).unwrap_or_default()}),
144        });
145        Value::Array(parts.collect::<Result<_, EncodeError>>()?)
146    } else {
147        let texts: Vec<Cow<'_, str>> = result.content.iter().filter_map(text).collect();
148        Value::String(texts.join("\n"))
149    };
150    let id = ids.spell(&result.call);
151    Ok(object! {"role": "tool", "tool_call_id": id, "content": content})
152}
153
154/// Push the user parts gathered so far as one message: a lone text part as
155/// a plain string.
156fn user_message(messages: &mut Vec<Value>, parts: &mut Vec<Value>) {
157    let content = match parts.as_mut_slice() {
158        [] => return,
159        [part] if part.str("type") == Some("text") => {
160            part.get_mut("text").map(Value::take).unwrap_or_default()
161        }
162        _ => Value::Array(std::mem::take(parts)),
163    };
164    parts.clear();
165    messages.push(object! {"role": "user", "content": content});
166}
167
168impl Chat {
169    /// The field every assistant message carries its reasoning under,
170    /// empty when the turn has none: the dialect's own,
171    /// `reasoning_content` for DeepSeek at any base URL, and the field a
172    /// catalog entry names for the model on this dialect, on Moonshot's own
173    /// API under its last path segment (Kimi K3 behind any gateway), or on
174    /// OpenRouter under its full id (`moonshotai/kimi-k2.6` behind any
175    /// gateway). Each lookup is asked for the field, not just the entry, so a
176    /// gateway's own row without one does not hide the vendor's.
177    fn reasoning_field<'a>(&'a self, model: &str) -> Option<&'a str> {
178        use crate::providers::openai::wire::{MOONSHOT, OPENROUTER};
179        let deepseek = self
180            .provider
181            .base_url
182            .to_ascii_lowercase()
183            .contains("deepseek.com");
184        let name = model.rsplit('/').next().unwrap_or(model);
185        let field = |vendor: &str, id: &str| {
186            self.facts
187                .for_model(vendor, id)
188                .and_then(|spec| spec.compat.reasoning_field.as_deref())
189        };
190        let listed = field(self.provider.dialect.name, model)
191            .or_else(|| field(MOONSHOT.name, name))
192            .or_else(|| field(OPENROUTER.name, model));
193        self.provider
194            .dialect
195            .quirks
196            .reasoning_field
197            .or(deepseek.then_some("reasoning_content"))
198            .or(listed)
199    }
200
201    /// The wire for `model` on `provider`, with every option off.
202    pub fn new(provider: OpenAIConfig, model: impl Into<String>) -> Self {
203        Self {
204            provider,
205            model: model.into(),
206            strict_tools: false,
207            tool_result_array_content: false,
208            facts: ModelFacts::default(),
209        }
210    }
211
212    /// The same wire, encoding with `facts` and pricing its replies by
213    /// them.
214    pub fn with_facts(mut self, facts: ModelFacts) -> Self {
215        self.facts = facts;
216        self
217    }
218
219    /// Sanitize tool schemas for OpenAI's strict mode, so the provider can
220    /// guarantee a tool call matches its schema exactly.
221    pub fn with_strict_tools(mut self) -> Self {
222        self.strict_tools = true;
223        self
224    }
225
226    /// Serialize tool-result content as arrays.
227    pub fn with_tool_result_array_content(mut self) -> Self {
228        self.tool_result_array_content = true;
229        self
230    }
231
232    /// The wire's own encoding of `request`: messages, tools and the typed
233    /// fields. The mapped options and `additional_params` merge over it.
234    fn base(
235        &self,
236        request: &CompletionRequest,
237        input: &mut BaseInput<'_>,
238    ) -> Result<Map<String, Value>, EncodeError> {
239        let quirks = &self.provider.dialect.quirks;
240        let mut model = request.model.clone().unwrap_or_else(|| self.model.clone());
241        if quirks.rewrite == BodyRewrite::HuggingFaceRouter {
242            // Some sub-providers (Fireworks) address models by a qualified id.
243            model = self.provider.route().model_identifier(&model);
244        }
245        let passthrough = input.raw_tools()?;
246        // A call to a custom tool the request declares is a custom call (pi).
247        let custom: Vec<String> = passthrough
248            .iter()
249            .filter(|tool| tool.str("type") == Some("custom"))
250            .filter_map(|tool| tool.at("/custom/name").and_then(Value::as_str))
251            .map(str::to_owned)
252            .collect();
253        let messages = self.messages(&request.chat_history, &model, &custom)?;
254
255        let mut tools: Vec<Value> = Vec::new();
256        let mut tool_choice = None;
257        if quirks.supports_tools {
258            for tool in &request.tools {
259                let mut parameters = tool.parameters.clone();
260                let mut function = json!({"name": tool.name, "description": tool.description});
261                if self.strict_tools {
262                    crate::providers::openai::sanitize_schema(&mut parameters);
263                }
264                if let Some(function) = function.as_object_mut() {
265                    function.insert("parameters".to_owned(), parameters);
266                    if self.strict_tools {
267                        function.insert("strict".to_owned(), Value::Bool(true));
268                    }
269                }
270                tools.push(object! {"type": "function", "function": function});
271            }
272            // Passthrough tools, function or native (Groq's `browser_search`),
273            // join the typed ones in one array.
274            tools.extend(passthrough);
275            tool_choice = request
276                .tool_choice
277                .clone()
278                .map(tool_choice_value)
279                .transpose()?
280                .filter(|_| !tools.is_empty());
281        } else {
282            if !request.tools.is_empty() {
283                tracing::warn!("Tool use is not supported by this provider; tools will be ignored");
284            }
285            if request.tool_choice.is_some() {
286                tracing::warn!("Tool choice is not supported by this provider and will be ignored");
287            }
288            // The caller's own tools still reach a dialect that takes none,
289            // as `additional_params` always did.
290            tools.extend(passthrough);
291        }
292
293        if request.output_schema.is_some() && !quirks.supports_response_format {
294            tracing::warn!(
295                "Structured outputs are not supported by this provider; ignoring output_schema"
296            );
297        }
298        // Defer schemas until a tool result exists unless the dialect supports both at once.
299        let answered = messages
300            .iter()
301            .any(|message| message.str("role") == Some("tool"));
302        let response_format = match request.output_schema.clone() {
303            Some(schema)
304                if quirks.supports_response_format
305                    && (quirks.response_format_with_tools || tools.is_empty() || answered) =>
306            {
307                let (name, schema) = crate::providers::openai::structured_output_schema(schema);
308                Some(json!({"type": "json_schema",
309                    "json_schema": {"name": name, "strict": true, "schema": schema}}))
310            }
311            _ => None,
312        };
313
314        Ok(crate::providers::internal::set_fields([
315            ("model", Some(Value::String(model))),
316            ("messages", Some(Value::Array(messages))),
317            ("tools", (!tools.is_empty()).then_some(Value::Array(tools))),
318            ("tool_choice", tool_choice),
319            ("temperature", request.temperature.map(Value::from)),
320            ("max_tokens", request.max_tokens.map(Value::from)),
321            ("response_format", response_format),
322        ]))
323    }
324
325    /// The history as Chat messages, each call and result spelled by one
326    /// [`WireIds`].
327    fn messages(
328        &self,
329        history: &[Arc<Message>],
330        model: &str,
331        custom: &[String],
332    ) -> Result<Vec<Value>, EncodeError> {
333        let ids = WireIds::for_target(history, self, model);
334        let reasoning_field = self.reasoning_field(model);
335        let mut messages = Vec::new();
336        for message in history {
337            match &**message {
338                Message::System { content } => messages.push(json!({"role": "system",
339                    "content": [{"type": "text", "text": content}]})),
340                Message::User { content } => {
341                    let mut parts = Vec::new();
342                    for part in content {
343                        if let UserContent::ToolResult(result) = part {
344                            user_message(&mut messages, &mut parts);
345                            let array = self.tool_result_array_content;
346                            messages.push(tool_message(result, &ids, array)?);
347                        } else {
348                            parts.push(user_part(part)?);
349                        }
350                    }
351                    user_message(&mut messages, &mut parts);
352                }
353                Message::Assistant(turn) => {
354                    messages.extend(self.assistant(turn, &ids, custom, reasoning_field));
355                }
356            }
357        }
358        if messages.is_empty() {
359            return Err(EncodeError::request(
360                "OpenAI Chat Completions request has no provider-compatible messages after \
361                 conversion",
362            ));
363        }
364        Ok(messages)
365    }
366
367    /// One assistant turn rebuilt for the wire: text joined into a `content`
368    /// string, or a part array when a block is a content part (Mistral's
369    /// thinking); reasoning under the field it arrived in, or the dialect's;
370    /// an item's other fields beside it; and each call with its canonical
371    /// name and arguments. A message with neither content nor calls is
372    /// `None`.
373    fn assistant(
374        &self,
375        turn: &AssistantMessage,
376        ids: &WireIds,
377        custom: &[String],
378        reasoning_field: Option<&str>,
379    ) -> Option<Value> {
380        let (mut text, mut parts, mut has_parts) = (String::new(), Vec::new(), false);
381        let mut reasoning: Vec<(String, String)> = Vec::new();
382        let (mut fields, mut calls) = (Map::new(), Vec::new());
383        for block in &turn.content {
384            let replay = block.replay(self, ids);
385            match block {
386                AssistantContent::Text(block) => {
387                    text.push_str(&block.text);
388                    parts.push(json!({"type": "text", "text": block.text}));
389                    // A text item is message fields: an answer's audio.
390                    if let Replay::Item(item) = replay
391                        && let Value::Object(item) = item.into_owned()
392                    {
393                        fields.extend(item);
394                    }
395                }
396                AssistantContent::Reasoning(block) => {
397                    let field = match replay {
398                        Replay::Item(item) if item.get("type").is_some() => {
399                            has_parts = true;
400                            parts.push(item.into_owned());
401                            None
402                        }
403                        Replay::Item(item) => {
404                            let mut field = None;
405                            for (key, value) in item.as_object().into_iter().flatten() {
406                                if value.is_string() {
407                                    field.get_or_insert_with(|| key.clone());
408                                } else {
409                                    let detail = Map::from_iter([(key.clone(), value.clone())]);
410                                    merge_fields(&mut fields, &detail);
411                                }
412                            }
413                            field
414                        }
415                        Replay::Identity(identity)
416                            if identity.get("type").and_then(Value::as_str) == Some("thinking") =>
417                        {
418                            has_parts = true;
419                            parts.push(json!({"type": "thinking",
420                                "thinking": [{"type": "text", "text": block.text}]}));
421                            None
422                        }
423                        Replay::Identity(identity) => identity.keys().next().cloned(),
424                        Replay::Rebuild => reasoning_field.map(str::to_owned),
425                    };
426                    if let Some(field) = field {
427                        match reasoning.iter_mut().find(|(name, _)| *name == field) {
428                            Some((_, joined)) => {
429                                joined.push('\n');
430                                joined.push_str(&block.text);
431                            }
432                            None => reasoning.push((field, block.text.clone())),
433                        }
434                    }
435                }
436                AssistantContent::ToolCall(call) => {
437                    calls.push(call_item(call, replay, ids, custom));
438                }
439                // An opaque is a content part; one with no `type` is not
440                // sent, so its keys never land on the message.
441                AssistantContent::Opaque(opaque) if opaque.replay => {
442                    if opaque.item.get("type").is_some() {
443                        has_parts = true;
444                        parts.push(opaque.item.clone());
445                    }
446                }
447                AssistantContent::Opaque(_) | AssistantContent::Image(_) => {}
448            }
449        }
450        let mut message = Map::from_iter([("role".to_owned(), Value::from("assistant"))]);
451        if has_parts {
452            message.insert("content".to_owned(), Value::Array(parts));
453        } else if !text.is_empty() {
454            message.insert("content".to_owned(), Value::String(text));
455        }
456        for (field, text) in reasoning {
457            message.insert(field, Value::String(text));
458        }
459        message.extend(fields);
460        if !calls.is_empty() {
461            message.insert("tool_calls".to_owned(), Value::Array(calls));
462        }
463        let has_content = message.contains_key("audio")
464            || message.contains_key("tool_calls")
465            || match message.get("content") {
466                Some(Value::String(text)) => !text.is_empty(),
467                Some(Value::Array(parts)) => !parts.is_empty(),
468                _ => false,
469            };
470        if let Some(field) = reasoning_field {
471            message
472                .entry(field)
473                .or_insert_with(|| Value::String(String::new()));
474        }
475        has_content.then_some(Value::Object(message))
476    }
477}
478
479/// The dialect's rewrite of the finished body ([`BodyRewrite`]): a refusal
480/// where the provider would answer with an error or silently do something
481/// else, Moonshot's steering for `required`, and each dialect's spellings and
482/// content shapes. It runs after the merge and reads the merged body.
483pub(crate) fn rewrite_body(
484    kind: BodyRewrite,
485    map: &mut Map<String, Value>,
486) -> Result<(), EncodeError> {
487    let forced = map
488        .get("tool_choice")
489        .and_then(|choice| choice.at("/function/name"))
490        .and_then(Value::as_str)
491        .map(str::to_owned);
492    match kind {
493        BodyRewrite::LlamaCpp => {
494            if let Some(name) = forced {
495                return Err(EncodeError::request(format!(
496                    "llama.cpp cannot force a specific tool: `llama-server` accepts only \
497                     `auto`, `none` or `required` for tool_choice and silently treats \
498                     anything else as `auto`, so requesting `{name}` would return whichever \
499                     tool the model picked. Use `ToolChoice::Required` to force a call, or \
500                     advertise only `{name}` in `tools`."
501                )));
502            }
503        }
504        BodyRewrite::Moonshot => {
505            if forced.is_some() {
506                return Err(EncodeError::request(
507                    "Moonshot does not support forcing a specific tool",
508                ));
509            }
510            if map.get("tool_choice").and_then(Value::as_str) == Some("required") {
511                tracing::warn!(
512                    "Moonshot does not support tool_choice=required; coercing to auto with an \
513                     additional steering message"
514                );
515                map.insert("tool_choice".to_owned(), Value::from("auto"));
516                if let Some(Value::Array(messages)) = map.get_mut("messages") {
517                    messages.push(json!({"role": "user",
518                        "content": "Please select a tool to handle the current issue."}));
519                }
520            }
521        }
522        // The gateway takes every message's content as one string.
523        // Perplexity spends output budget on a text-part array (a short
524        // `max_tokens` comes back empty at `length`, checked live), so
525        // text-only arrays go as one string; the gateway takes every
526        // content as one string.
527        BodyRewrite::Perplexity | BodyRewrite::Mira => {
528            let all = kind == BodyRewrite::Mira;
529            for content in messages_mut(map).filter_map(|message| message.get_mut("content")) {
530                if let Value::Array(parts) = content
531                    && (all || parts.iter().all(|part| part.str("type") == Some("text")))
532                {
533                    let texts: Vec<&str> =
534                        parts.iter().filter_map(|part| part.str("text")).collect();
535                    *content = Value::String(texts.join("\n"));
536                }
537            }
538        }
539        BodyRewrite::DeepSeek => finalize_deepseek(map),
540        BodyRewrite::Mistral => finalize_mistral(map),
541        BodyRewrite::Ollama => finalize_ollama(map)?,
542        BodyRewrite::None | BodyRewrite::OpenRouter | BodyRewrite::HuggingFaceRouter => {}
543    }
544    Ok(())
545}
546
547/// `call` as the item the wire sends, from what replay hands the encoder:
548/// the current item, an edited one's kind, or nothing. A call is `custom`
549/// when its item or kept kind says so, or when it is rebuilt and the request
550/// declares a custom tool by its name; its arguments' `input` is then
551/// its input. Any other is a function call with its arguments as JSON text.
552fn call_item(call: &ToolCall, replay: Replay<'_>, ids: &WireIds, custom: &[String]) -> Value {
553    let mut item = match replay {
554        Replay::Item(item) => match item.into_owned() {
555            Value::Object(item) => item,
556            _ => Map::new(),
557        },
558        Replay::Identity(identity) => identity,
559        Replay::Rebuild => Map::new(),
560    };
561    let name = call.function.name.as_str();
562    let custom = match item.get("type").and_then(Value::as_str) {
563        Some(kind) => kind == "custom",
564        None => {
565            let declared = custom.iter().any(|tool| tool == name);
566            let kind = if declared { "custom" } else { "function" };
567            item.insert("type".to_owned(), Value::from(kind));
568            declared
569        }
570    };
571    let id = ids.spell(&call.id);
572    item.insert("id".to_owned(), Value::String(id));
573    let (slot, key, value) = if custom {
574        let input = match call.function.arguments.get("input") {
575            Some(Value::String(input)) => input.clone(),
576            Some(input) => input.to_string(),
577            None => String::new(),
578        };
579        ("custom", "input", input)
580    } else {
581        let arguments = serde_json::to_string(&call.function.arguments).unwrap_or_default();
582        ("function", "arguments", arguments)
583    };
584    let slot = item
585        .entry(slot)
586        .or_insert_with(|| Value::Object(Map::new()));
587    if !slot.is_object() {
588        *slot = Value::Object(Map::new());
589    }
590    if let Value::Object(fields) = slot {
591        fields.insert("name".to_owned(), Value::from(name));
592        fields.insert(key.to_owned(), Value::String(value));
593    }
594    Value::Object(item)
595}
596
597/// The Chat `tool_choice` for a canonical one.
598fn tool_choice_value(choice: crate::message::ToolChoice) -> Result<Value, EncodeError> {
599    use crate::message::ToolChoice;
600    Ok(match choice {
601        ToolChoice::Auto => Value::from("auto"),
602        ToolChoice::None => Value::from("none"),
603        ToolChoice::Required => Value::from("required"),
604        ToolChoice::Specific { function_names } => {
605            let [name] = function_names.as_slice() else {
606                return Err(EncodeError::request(
607                    "Provider only supports forcing exactly one specific tool",
608                ));
609            };
610            json!({"type": "function", "function": {"name": name}})
611        }
612    })
613}
614
615/// Ollama's OpenAI-compatible body: `num_ctx` and `options` are refused,
616/// because this API ignores them and only the native route sends them.
617fn finalize_ollama(map: &mut Map<String, Value>) -> Result<(), EncodeError> {
618    if let Some(key) = ["num_ctx", "options"]
619        .into_iter()
620        .find(|key| map.contains_key(*key))
621    {
622        return Err(EncodeError::request(format!(
623            "Ollama's OpenAI-compatible API ignores `{key}`; send it through the native \
624             route (`Ollama::native_completion`)"
625        )));
626    }
627    Ok(())
628}
629
630/// The body's messages, each as its object.
631fn messages_mut(map: &mut Map<String, Value>) -> impl Iterator<Item = &mut Map<String, Value>> {
632    let messages = map.get_mut("messages").and_then(Value::as_array_mut);
633    messages
634        .into_iter()
635        .flatten()
636        .filter_map(Value::as_object_mut)
637}
638
639/// DeepSeek rejects a forced tool choice while thinking, and every model
640/// but `deepseek-chat` thinks unless `thinking` says `disabled` (checked
641/// live), so the choice is relaxed to the default there.
642fn finalize_deepseek(map: &mut Map<String, Value>) {
643    let thinking = match map
644        .get("thinking")
645        .and_then(|thinking| thinking.str("type"))
646    {
647        Some(mode) => !mode.eq_ignore_ascii_case("disabled"),
648        None => map.get("model").and_then(Value::as_str) != Some("deepseek-chat"),
649    };
650    let forced = map
651        .get("tool_choice")
652        .is_some_and(|choice| choice.is_object() || choice.as_str() == Some("required"));
653    if thinking && forced {
654        tracing::debug!(
655            "dropping tool_choice: DeepSeek rejects a forced tool choice while thinking"
656        );
657        map.shift_remove("tool_choice");
658    }
659}
660
661/// Mistral's wire-level differences: a forced tool choice relaxed beside a
662/// structured format, and its content chunks.
663fn finalize_mistral(map: &mut Map<String, Value>) {
664    // Mistral rejects forced tool calls beside JSON response formats: the
665    // choice is relaxed rather than the schema discarded.
666    let forces_a_tool_call = map
667        .get("tool_choice")
668        .is_some_and(|choice| !matches!(choice.as_str(), Some("auto" | "none")));
669    let has_tools = map
670        .get("tools")
671        .and_then(Value::as_array)
672        .is_some_and(|tools| !tools.is_empty());
673    let structured = map
674        .get("response_format")
675        .and_then(|format| format.str("type"))
676        .is_some_and(|kind| matches!(kind, "json_schema" | "json_object"));
677    if forces_a_tool_call && has_tools && structured {
678        tracing::debug!(
679            "relaxing tool_choice to `auto`: Mistral rejects a forced tool choice \
680             alongside a response format"
681        );
682        map.insert("tool_choice".to_owned(), Value::from("auto"));
683    }
684    for content in messages_mut(map).filter_map(|message| message.get_mut("content")) {
685        for part in content.as_array_mut().into_iter().flatten() {
686            *part = mistral_chunk(part);
687        }
688    }
689}
690
691/// One content part as the Mistral chunk that carries it, by its `type`:
692/// text and refusal parts are `text` chunks, a file's data is a
693/// `document_url` and its id a `file` chunk, audio is its base64 string,
694/// and an image keeps only its `image_url`, for every chunk forbids unknown
695/// keys. A part the conversion does not emit stays as it is.
696fn mistral_chunk(part: &Value) -> Value {
697    let field = |pointer: &str| part.at(pointer).and_then(Value::as_str);
698    match part.str("type") {
699        Some("image_url") => {
700            json!({"type": "image_url", "image_url": part.get("image_url")})
701        }
702        Some("input_audio") => json!({"type": "input_audio",
703            "input_audio": field("/input_audio/data")}),
704        Some("file") => match field("/file/file_data") {
705            Some(data) => json!({"type": "document_url", "document_url": data,
706                "document_name": field("/file/filename")}),
707            None => json!({"type": "file", "file_id": field("/file/file_id")}),
708        },
709        _ => part.clone(),
710    }
711}
712
713impl Wire for Chat {
714    type Op = crate::operation::Completion;
715    type Payload = crate::wire::Encoded;
716    type Frame = crate::wire::WireFrame;
717    type Event = ChatEvent;
718    type Decoder<'id> = ChatDecoder;
719    type Reassembler = document::ChatCompletion;
720
721    /// Format deferral permits tool composition; dialects without schema
722    /// support require the agent's tool-mode enforcement instead.
723    fn describe(&self) -> Descriptor<'_> {
724        Descriptor::new(self.provider.dialect.name)
725            .model(self.model.as_str())
726            .facts(&self.facts)
727            .capabilities(Capabilities::completion(
728                ProviderCapabilities::default().with_native_output_tool_composition(
729                    self.provider.dialect.quirks.supports_response_format,
730                ),
731            ))
732            .replay(self)
733    }
734
735    fn encode(&self, request: CompletionRequest, mode: Mode) -> Result<Encoded, EncodeError> {
736        let quirks = &self.provider.dialect.quirks;
737        // Azure's deployment URL remains pinned to the handle, not a request override.
738        let uri = self.provider.uri(
739            quirks.completion_path,
740            self.provider.deployment(&self.model),
741        );
742        let builder = self.provider.completion_headers(
743            &request,
744            http::Request::post(uri).header("Content-Type", "application/json"),
745        );
746        let mut rewrites = Vec::new();
747        if quirks.output_cap == OutputCap::OpenAiReasoningFamilies {
748            rewrites.push(Rewrite::OutputCapRename);
749        }
750        if mode == Mode::Streaming {
751            // A caller's own stream options, `include_usage` among them, stand.
752            if quirks.stream_include_usage {
753                rewrites.push(Rewrite::StreamUsage);
754            }
755            rewrites.push(Rewrite::Stream(true));
756        }
757        rewrites.push(Rewrite::ChatDialect(quirks.rewrite));
758        let raw_at = match quirks.rewrite {
759            BodyRewrite::Mira => RawAt::Ignored(
760                "Additional parameters are not supported by Mira and will be ignored",
761            ),
762            _ => RawAt::Top,
763        };
764        let body = request_params(
765            self,
766            &request,
767            |input| self.base(&request, input),
768            raw_at,
769            &rewrites,
770        )?;
771        let body = crate::providers::openai::options::check_body(
772            self,
773            &self.facts,
774            &request,
775            body,
776            crate::providers::openai::options::Endpoint::ChatCompletions,
777        )?;
778        crate::providers::internal::trace_json(
779            crate::providers::internal::LogTarget::Completions,
780            "OpenAI Chat Completions request",
781            &body,
782        );
783        let request = builder.body(body.into_body())?;
784        let framing = match mode {
785            Mode::Streaming => Framing::Sse,
786            Mode::Unary => Framing::Whole,
787        };
788        Ok(Encoded::new(request, framing)
789            .with_request_id_header(self.provider.dialect.request_id_header)
790            .with_projection(ChatDecoder::project)
791            .with_route(Some(quirks.completion_path)))
792    }
793
794    fn decoder<'id>(&self) -> Self::Decoder<'id> {
795        ChatDecoder::new(self.provider.dialect.quirks)
796    }
797}
798
799impl crate::completion::ReplayTarget for Chat {
800    /// Section 6.2 of the typed-options design, by dialect.
801    fn map_options(
802        &self,
803        request: &CompletionRequest,
804        fields: crate::completion::options::OptionFields<'_>,
805    ) -> crate::completion::options::OptionMap {
806        crate::providers::openai::options::chat_options(self, request, fields)
807    }
808
809    fn facts(&self) -> Option<&ModelFacts> {
810        Some(&self.facts)
811    }
812
813    fn api(&self) -> crate::message::Api {
814        crate::message::Api::from_static("openai.chat")
815    }
816
817    fn provider(&self) -> &str {
818        self.provider.dialect.name
819    }
820
821    fn model(&self) -> &str {
822        &self.model
823    }
824
825    /// Chat reads no images in assistant messages, images in tool results
826    /// only on a dialect that says so, and tools where the dialect takes
827    /// them. Which models read user images follows each provider's
828    /// documented model rules (`reads_images`). Another model's reasoning
829    /// goes under the model's reasoning field, when it has one.
830    fn accepts(&self, model: &str) -> crate::completion::Accepts {
831        let quirks = &self.provider.dialect.quirks;
832        let user_images = reads_images(&self.facts, &self.provider.dialect, model);
833        crate::completion::Accepts {
834            user_images,
835            assistant_images: false,
836            tool_result_images: user_images && quirks.supports_image_tool_results,
837            tools: quirks.supports_tools,
838            foreign_reasoning: self.reasoning_field(model).is_some(),
839        }
840    }
841
842    /// What the Chat encoder carries, by dialect: an image as a URL or
843    /// typed data; audio as data; video as a URL or typed data, except to
844    /// OpenAI, Azure and Mistral; a PDF as data, or as a URL OpenRouter or
845    /// Mistral fetches; a file id where the dialect takes one; and a text
846    /// document as text. DeepSeek and Mira take text only; Perplexity and
847    /// Cohere take no media but images, and Ollama only images as data.
848    fn encodes(&self, _model: &str, media: crate::completion::Media<'_>) -> bool {
849        use crate::completion::Media;
850        let dialect = &self.provider.dialect;
851        let rewrite = dialect.quirks.rewrite;
852        let parts = !matches!(rewrite, BodyRewrite::DeepSeek | BodyRewrite::Mira);
853        // Cohere and Ollama read images and text, and no other media.
854        let files = parts
855            && rewrite != BodyRewrite::Perplexity
856            && ![super::dialects::COHERE.name, super::dialects::OLLAMA.name]
857                .contains(&dialect.name);
858        let linked = |source: &Source, typed: bool| match source {
859            Source::Url(_) => true,
860            Source::Base64(_) => typed,
861            Source::Raw(_) | Source::FileId(_) | Source::Unknown => false,
862        };
863        match media {
864            Media::Image(image, place) => {
865                parts
866                    && place != crate::completion::Place::Assistant
867                    && linked(&image.data, image.media_type.is_some())
868                    // Ollama reads an image as data and never fetches a URL.
869                    && !(dialect.name == super::dialects::OLLAMA.name
870                        && matches!(image.data, Source::Url(_)))
871            }
872            Media::Audio(audio) => files && matches!(audio.data, Source::Base64(_)),
873            Media::Video(video) => {
874                files
875                    && rewrite != BodyRewrite::Mistral
876                    && ![super::dialects::OPENAI.name, super::dialects::AZURE.name]
877                        .contains(&dialect.name)
878                    && linked(&video.data, video.media_type.is_some())
879            }
880            Media::Document(document) => {
881                let pdf = document.media_type == Some(DocumentMediaType::PDF);
882                match &document.data {
883                    DocumentData::Text(_) => true,
884                    DocumentData::File(Source::FileId(_)) => {
885                        files && dialect.quirks.accepts_file_ids
886                    }
887                    DocumentData::File(Source::Base64(_)) => files && pdf,
888                    DocumentData::File(Source::Url(_)) => {
889                        pdf && matches!(rewrite, BodyRewrite::OpenRouter | BodyRewrite::Mistral)
890                    }
891                    DocumentData::File(Source::Raw(_) | Source::Unknown) => false,
892                }
893            }
894        }
895    }
896
897    /// A `call|item` id joins its sanitized halves with `_`, ending a result
898    /// over 40 characters in a hash of the whole id; OpenAI's own ids are
899    /// cut to 40; any other id is kept. Mistral takes any such id (checked
900    /// live on eight models).
901    fn normalize_tool_call_id(
902        &self,
903        id: &str,
904        _model: &str,
905        _: Option<&crate::message::Origin>,
906    ) -> String {
907        let sanitized =
908            |part: &str| crate::providers::internal::wire_ids::legal_call_id(part, usize::MAX);
909        if let Some((call, item)) = id.split_once('|') {
910            let call = sanitized(call);
911            let item = sanitized(item);
912            let combined = if item.is_empty() {
913                call.clone()
914            } else {
915                format!("{call}_{item}")
916            };
917            if combined.len() <= 40 {
918                return combined;
919            }
920            let hash: String = crate::providers::internal::wire_ids::short_hash(id)
921                .chars()
922                .take(8)
923                .collect();
924            let prefix: String = call.chars().take((40 - hash.len() - 1).max(1)).collect();
925            return format!("{prefix}_{hash}");
926        }
927        if self.provider.dialect.name == super::dialects::OPENAI.name {
928            return id.chars().take(40).collect();
929        }
930        id.to_owned()
931    }
932
933    /// What an edited block keeps of its item: a custom call's kind and
934    /// Mistral's thinking part (`type`), or the field reasoning arrived in.
935    /// Signed data such as `reasoning_details` never survives an edit.
936    fn identity(&self, item: &Value) -> Map<String, Value> {
937        if let Some(kind @ ("custom" | "thinking")) = item.str("type") {
938            return Map::from_iter([("type".to_owned(), Value::from(kind))]);
939        }
940        REASONING_TEXT_KEYS
941            .iter()
942            .find(|key| item.get(**key).is_some_and(Value::is_string))
943            .map(|key| Map::from_iter([((*key).to_owned(), Value::String(String::new()))]))
944            .unwrap_or_default()
945    }
946
947    fn call_id_slot(&self) -> Option<&'static str> {
948        Some("/id")
949    }
950
951    fn later_system(&self, _model: &str) -> crate::completion::LaterSystem {
952        self.provider.dialect.quirks.later_system
953    }
954
955    /// In array mode a result's parts go as an array.
956    fn result_parts(&self, _model: &str) -> bool {
957        self.tool_result_array_content
958    }
959
960    /// A Chat message needs content or calls: text
961    /// only when it is not empty or its item carries audio, reasoning only
962    /// as a content part (Mistral's thinking), an opaque item only as a
963    /// typed part, and never an image.
964    fn sends_alone(&self, block: &AssistantContent) -> bool {
965        let replay = || block.replay(self, &WireIds::default());
966        match block {
967            AssistantContent::ToolCall(_) => true,
968            AssistantContent::Text(text) => {
969                !text.text.is_empty()
970                    || matches!(replay(), Replay::Item(item) if item.get("audio").is_some())
971            }
972            AssistantContent::Reasoning(_) => match replay() {
973                Replay::Item(item) => item.get("type").is_some(),
974                Replay::Identity(identity) => {
975                    identity.get("type").and_then(Value::as_str) == Some("thinking")
976                }
977                Replay::Rebuild => false,
978            },
979            AssistantContent::Opaque(opaque) => opaque.item.get("type").is_some(),
980            AssistantContent::Image(_) => false,
981        }
982    }
983}
984
985/// The catalog vendor whose entries say which models `vendor` serves read
986/// images, and the naming rule for an id the catalog does not list:
987/// `vendor` is a dialect's name or the vendor an OpenRouter model id starts
988/// with. Groq reads images on its Llama 4 models; Mistral's Codestral and
989/// Devstral are text models; OpenAI (on Azure too), xAI, Cohere, Z.AI,
990/// Moonshot, MiniMax and MiMo apply the rules their other wires share; any
991/// other vendor's unlisted model reads them. `None` for DeepSeek's API,
992/// which takes text content only (it answers an image part with a 400), and
993/// Mira's gateway, which takes text, whatever the model.
994fn image_rule(vendor: &str) -> Option<(&str, fn(&str) -> bool)> {
995    use crate::providers::{minimax, moonshot, xiaomimimo, zai};
996    Some(match vendor {
997        "deepseek" | "mira" => return None,
998        "groq" => ("groq", |model| model.contains("llama-4")),
999        "mistral" | "mistralai" => ("mistral", |model| {
1000            !(model.starts_with("codestral") || model.starts_with("devstral"))
1001        }),
1002        "openai" | "azure.openai" => ("openai", crate::providers::openai::reads_images),
1003        "xai" | "x-ai" => ("xai", crate::providers::xai::reads_images),
1004        "cohere" => ("cohere", crate::providers::cohere::reads_images),
1005        "zai" | "z-ai" => ("zai", zai::reads_images),
1006        "moonshot" | "moonshotai" => ("moonshot", moonshot::reads_images),
1007        "minimax" => ("minimax", minimax::reads_images),
1008        "xiaomimimo" | "xiaomi" => ("xiaomimimo", xiaomimimo::reads_images),
1009        vendor => (vendor, |_| true),
1010    })
1011}
1012
1013/// Whether `model` reads user images on `dialect`, as the model catalog
1014/// lists it, or by the naming rule of [`image_rule`] for a model it does not
1015/// list. OpenRouter names a model `vendor/model`: its own catalog entry
1016/// decides, or failing that the vendor's naming rule.
1017fn reads_images(facts: &ModelFacts, dialect: &super::Dialect, model: &str) -> bool {
1018    match model.split_once('/') {
1019        Some((vendor, upstream)) if dialect.quirks.rewrite == BodyRewrite::OpenRouter => facts
1020            .reads_images_or(dialect.name, model, |_| {
1021                image_rule(vendor).is_some_and(|(_, rule)| rule(upstream))
1022            }),
1023        _ => image_rule(dialect.name)
1024            .is_some_and(|(vendor, rule)| facts.reads_images_or(vendor, model, rule)),
1025    }
1026}
1027
1028/// Classified Chat Completions frame, including whole replies and terminal signals.
1029pub enum ChatEvent {
1030    /// A `chat.completion.chunk`: one step of a streamed turn.
1031    Chunk(Value),
1032    /// A `chat.completion`: the whole turn in one frame.
1033    Whole(Value),
1034    /// The `[DONE]` sentinel: the provider ended the stream.
1035    Done,
1036    /// The wire's in-band error envelope, delivered with a 200 status.
1037    Failure(ProviderError),
1038    /// A bare JSON string where an envelope belongs: the whole answer, with
1039    /// no metadata and no terminal reason. Mira's gateway sends this.
1040    BareText(String),
1041}
1042
1043/// The keys an assistant message carries reasoning under, in the order
1044/// their text is read: compatible servers that send several send the same
1045/// text under each, so the first that carries text is the one replayed.
1046const REASONING_TEXT_KEYS: [&str; 3] = ["reasoning_content", "reasoning", "reasoning_text"];
1047
1048/// The structured reasoning key, kept verbatim with the reasoning text.
1049const REASONING_DETAILS: &str = "reasoning_details";
1050
1051/// What one content part of a Chat message is. Every part becomes a block.
1052#[derive(Debug, PartialEq)]
1053pub(crate) enum Part {
1054    /// Answer text: a `text` part, or a `refusal` part's text.
1055    Text(String),
1056    /// Mistral and Magistral reasoning: a `thinking` part's text.
1057    Thinking(String),
1058    /// An image the model produced.
1059    Image,
1060    /// A part rig has no canonical meaning for, kept as it came.
1061    Unknown,
1062}
1063
1064impl Part {
1065    /// The part `part` is, by its `type`. A part with no `type` and a
1066    /// string `text` is text.
1067    pub(crate) fn of(part: &Value) -> Self {
1068        let text = |key: &str| part.str(key).unwrap_or_default().to_owned();
1069        match part.str("type") {
1070            Some("text") => Self::Text(text("text")),
1071            None if part.str("text").is_some() => Self::Text(text("text")),
1072            Some("refusal") => Self::Text(text("refusal")),
1073            Some("thinking") => Self::Thinking(match part.get("thinking") {
1074                Some(Value::Array(chunks)) => chunks
1075                    .iter()
1076                    .filter_map(|chunk| chunk.str("text"))
1077                    .collect(),
1078                _ => text("thinking"),
1079            }),
1080            Some("image_url") => Self::Image,
1081            _ => Self::Unknown,
1082        }
1083    }
1084}
1085
1086/// What one tool call of a Chat message is.
1087#[derive(Debug, PartialEq)]
1088pub(crate) enum CallKind {
1089    /// A function call: its arguments are JSON text.
1090    Function,
1091    /// A custom tool call: its input is free text, the call's `{"input"}`.
1092    Custom,
1093    /// A call kind rig cannot answer, kept but never sent back.
1094    Unknown,
1095}
1096
1097impl CallKind {
1098    /// The kind of `call`, by its `type`; a call without one is a function
1099    /// call.
1100    pub(crate) fn of(call: &Value) -> Self {
1101        match call.str("type") {
1102            None | Some("function") => Self::Function,
1103            Some("custom") => Self::Custom,
1104            Some(_) => Self::Unknown,
1105        }
1106    }
1107}
1108
1109/// The kind of run a decoder is writing: answer text, or Mistral's
1110/// thinking parts, kept apart.
1111#[derive(Clone, Copy, PartialEq)]
1112enum Writing {
1113    Text,
1114    Thinking,
1115}
1116
1117/// A tool call the reply has open: its writer index, the id it states,
1118/// whether its kind is one rig cannot answer (an opaque item), and the
1119/// function arguments streamed so far.
1120struct OpenCall {
1121    at: usize,
1122    id: Option<String>,
1123    opaque: bool,
1124    arguments: String,
1125}
1126
1127/// The chat-completions decoder: one state machine for a whole reply and a
1128/// stream of chunks, reading every frame leniently as JSON. A whole reply
1129/// is the one chunk carrying its message. All of a message's reasoning is
1130/// one block, open until the message
1131/// ends; the turn holds it first however late it arrived, while its events
1132/// keep their arrival order. A run of text is a block, as is a run of
1133/// Mistral thinking parts, and each tool call, image and unknown content
1134/// part is a block of its own; a run closes when another block starts. An
1135/// answer's audio transcript is its text when it has no other, and its
1136/// first text block holds the audio's id.
1137#[derive(Default)]
1138pub struct ChatDecoder {
1139    quirks: Quirks,
1140    /// The run being written, its writer index, and a thinking run's text.
1141    writing: Option<(Writing, usize)>,
1142    thinking_text: String,
1143    /// The writer index of the message's reasoning block while it is open,
1144    /// and its item as it will hold it: the field its text arrived in, its
1145    /// text, and every `reasoning_details` entry in order.
1146    reasoning: Option<usize>,
1147    reasoning_field: Option<&'static str>,
1148    reasoning_text: String,
1149    reasoning_details: Map<String, Value>,
1150    /// The id of the answer's audio.
1151    audio_id: Option<Value>,
1152    /// The calls still open, in the order they opened.
1153    calls: Vec<OpenCall>,
1154    usage: Option<Value>,
1155    /// The top-level [`reported::REPLY_FIELDS`] as last stated.
1156    reply_fields: Map<String, Value>,
1157    /// The writer index of the message's first text block, which the
1158    /// message-level citations cite.
1159    first_text: Option<usize>,
1160    /// The writer index of the latest text block, which a Mistral
1161    /// `reference` chunk after it cites.
1162    last_text: Option<usize>,
1163    /// Citations of the turn's first text block held until it opens:
1164    /// message annotations, and references that came before any text.
1165    turn_citations: Vec<crate::wire::WireCitation>,
1166    finish: Option<FinishReason>,
1167    response_id: Option<String>,
1168    response_model: Option<String>,
1169    /// Whether a finish reason or a whole reply ended the turn.
1170    ended: bool,
1171    /// Whether a stream chunk arrived.
1172    chunked: bool,
1173}
1174
1175impl ChatDecoder {
1176    fn new(quirks: Quirks) -> Self {
1177        Self {
1178            quirks,
1179            ..Self::default()
1180        }
1181    }
1182
1183    /// Absorb the metadata every frame carries, and take its primary choice.
1184    /// Usage is the frame's, or its choice's where Moonshot reports it.
1185    fn absorb<'f>(&mut self, frame: &'f Value) -> Option<&'f Value> {
1186        // Every chunk restates these; each is stored only when it changes.
1187        keep_text(&mut self.response_id, frame.str("id"));
1188        keep_text(&mut self.response_model, frame.str("model"));
1189        // `n > 1` streams interleave candidates told apart only by
1190        // `choices[].index`; candidate 0 is the turn, as in a whole reply.
1191        let choice = frame.arr("choices").iter().find(|choice| {
1192            let index = choice.get("index").and_then(Value::as_u64);
1193            index.is_none_or(|index| index == 0)
1194        });
1195        if let Some(usage) = frame
1196            .at("/usage")
1197            .or_else(|| choice.and_then(|choice| choice.at("/usage")))
1198            && self.usage.as_ref() != Some(usage)
1199        {
1200            self.usage = Some(usage.clone());
1201        }
1202        for key in reported::REPLY_FIELDS {
1203            if let Some(value) = frame.get(key).filter(|value| !value.is_null())
1204                && self.reply_fields.get(key) != Some(value)
1205            {
1206                self.reply_fields.insert(key.to_owned(), value.clone());
1207            }
1208        }
1209        let choice = choice?;
1210        // A gateway's upstream-native reason is consulted only when the
1211        // normalized field is absent or empty (OpenRouter's precedence).
1212        let reason = match choice
1213            .str("finish_reason")
1214            .filter(|reason| !reason.is_empty())
1215        {
1216            Some(reason) => Some(finish_reason(reason, &self.quirks)),
1217            None => choice
1218                .str("native_finish_reason")
1219                .filter(|reason| self.quirks.native_finish_reason && !reason.is_empty())
1220                .map(native_finish_reason),
1221        };
1222        if let Some(reason) = reason {
1223            self.finish = Some(reason);
1224            self.ended = true;
1225        }
1226        Some(choice)
1227    }
1228
1229    /// One delta of the assistant message: its reasoning text and
1230    /// `reasoning_details` into the reasoning block, then the content,
1231    /// images and calls.
1232    fn delta(
1233        &mut self,
1234        delta: &Map<String, Value>,
1235        out: &mut Out<'_, Completion>,
1236    ) -> Result<(), ProviderError> {
1237        let reasoning = REASONING_TEXT_KEYS.iter().find_map(|key| {
1238            delta
1239                .get(*key)
1240                .and_then(Value::as_str)
1241                .filter(|text| !text.is_empty())
1242                .map(|text| (*key, text))
1243        });
1244        if let Some((key, text)) = reasoning {
1245            self.reason(text, out)?;
1246            self.reasoning_field.get_or_insert(key);
1247        }
1248        if let Some(Value::Array(details)) = delta.get(REASONING_DETAILS)
1249            && !details.is_empty()
1250        {
1251            self.reason("", out)?;
1252            merge_details(&mut self.reasoning_details, details.clone());
1253        }
1254        let audio = delta.get("audio");
1255        if let Some(id) = audio.and_then(|audio| audio.at("/id")) {
1256            self.audio_id = Some(id.clone());
1257        }
1258        match delta.get("content") {
1259            Some(Value::Array(parts)) => {
1260                for part in parts {
1261                    self.part(part, out)?;
1262                }
1263            }
1264            Some(part @ Value::Object(_)) => self.part(part, out)?,
1265            _ => {
1266                let text = ["content", "refusal"]
1267                    .iter()
1268                    .find_map(|key| {
1269                        delta
1270                            .get(*key)
1271                            .and_then(Value::as_str)
1272                            .filter(|text| !text.is_empty())
1273                    })
1274                    .or_else(|| audio.and_then(|audio| audio.str("transcript")))
1275                    .filter(|text| !text.is_empty());
1276                if let Some(text) = text {
1277                    self.write(Writing::Text, text, out)?;
1278                }
1279            }
1280        }
1281        for image in delta
1282            .get("images")
1283            .and_then(Value::as_array)
1284            .into_iter()
1285            .flatten()
1286        {
1287            self.image(image, out)?;
1288        }
1289        for call in delta
1290            .get("tool_calls")
1291            .and_then(Value::as_array)
1292            .into_iter()
1293            .flatten()
1294        {
1295            self.call(call, out)?;
1296        }
1297        self.turn_citations.extend(
1298            delta
1299                .get("annotations")
1300                .and_then(Value::as_array)
1301                .into_iter()
1302                .flatten()
1303                .filter_map(reported::annotation),
1304        );
1305        self.cite_turn(out);
1306        Ok(())
1307    }
1308
1309    /// Cite the turn's first text block with the citations held for it, as
1310    /// soon as it exists, so a reply cut off before its end keeps them.
1311    fn cite_turn(&mut self, out: &mut Out<'_, Completion>) {
1312        if let Some(text) = self.first_text {
1313            for citation in self.turn_citations.drain(..) {
1314                out.cite(text, citation);
1315            }
1316        }
1317    }
1318
1319    /// Append `text` to the message's reasoning block, opening it first.
1320    fn reason(&mut self, text: &str, out: &mut Out<'_, Completion>) -> Result<(), ProviderError> {
1321        let at = match self.reasoning {
1322            Some(at) => at,
1323            None => {
1324                let at = out.fresh_index();
1325                out.open(at, Block::Reasoning { redacted: false }, Value::Null)?;
1326                out.lead(at)?;
1327                self.reasoning = Some(at);
1328                at
1329            }
1330        };
1331        self.reasoning_text.push_str(text);
1332        out.push(at, text)
1333    }
1334
1335    /// Close the message's reasoning block, holding its item: the field its
1336    /// text arrived in and its details.
1337    fn close_reasoning(&mut self, out: &mut Out<'_, Completion>) -> Result<(), ProviderError> {
1338        let Some(at) = self.reasoning.take() else {
1339            return Ok(());
1340        };
1341        let text = std::mem::take(&mut self.reasoning_text);
1342        let mut item = Map::new();
1343        if let Some(field) = self.reasoning_field.take() {
1344            item.insert(field.to_owned(), text.into());
1345        }
1346        item.append(&mut self.reasoning_details);
1347        out.edit(at, |slot| *slot = Value::Object(item))?;
1348        out.finish(at)
1349    }
1350
1351    /// Append `text` to a block of kind `writing`, first closing the block
1352    /// being written and opening a new one when that is of another kind.
1353    fn write(
1354        &mut self,
1355        writing: Writing,
1356        text: &str,
1357        out: &mut Out<'_, Completion>,
1358    ) -> Result<(), ProviderError> {
1359        let index = match self.writing {
1360            Some((current, index)) if current == writing => index,
1361            _ => {
1362                self.close_writing(out)?;
1363                let index = out.fresh_index();
1364                let block = match writing {
1365                    Writing::Thinking => Block::Reasoning { redacted: false },
1366                    Writing::Text => {
1367                        self.first_text.get_or_insert(index);
1368                        self.last_text = Some(index);
1369                        Block::Text
1370                    }
1371                };
1372                out.open(index, block, Value::Null)?;
1373                self.writing = Some((writing, index));
1374                index
1375            }
1376        };
1377        if writing == Writing::Thinking {
1378            self.thinking_text.push_str(text);
1379        }
1380        out.push(index, text)
1381    }
1382
1383    /// Close the run being written, holding its item: the thinking part,
1384    /// or the audio's id.
1385    fn close_writing(&mut self, out: &mut Out<'_, Completion>) -> Result<(), ProviderError> {
1386        let (index, item) = match self.writing.take() {
1387            None => return Ok(()),
1388            Some((Writing::Text, index)) => (
1389                index,
1390                self.audio_id
1391                    .take()
1392                    .map(|id| json!({ "audio": { "id": id } })),
1393            ),
1394            Some((Writing::Thinking, index)) => {
1395                let text = std::mem::take(&mut self.thinking_text);
1396                (
1397                    index,
1398                    Some(json!({"type": "thinking", "thinking": [{"type": "text", "text": text}]})),
1399                )
1400            }
1401        };
1402        if let Some(item) = item {
1403            out.edit(index, |slot| *slot = item)?;
1404        }
1405        out.finish(index)
1406    }
1407
1408    /// One content part, through [`Part::of`].
1409    #[deny(clippy::wildcard_enum_match_arm)]
1410    fn part(&mut self, part: &Value, out: &mut Out<'_, Completion>) -> Result<(), ProviderError> {
1411        match Part::of(part) {
1412            Part::Text(text) => self.write(Writing::Text, &text, out),
1413            Part::Thinking(text) => self.write(Writing::Thinking, &text, out),
1414            Part::Image => self.image(part, out),
1415            Part::Unknown => {
1416                self.close_writing(out)?;
1417                if let Some(citation) = reported::reference(part) {
1418                    match self.last_text {
1419                        Some(text) => out.cite(text, citation),
1420                        None => self.turn_citations.push(citation),
1421                    }
1422                }
1423                let index = out.fresh_index();
1424                out.whole(index, Block::Opaque { replay: true }, part.clone(), "")
1425            }
1426        }
1427    }
1428
1429    /// One image the model produced, an `image_url` part, whole: a data URL
1430    /// becomes base64 data, any other URL stays a URL.
1431    fn image(&mut self, part: &Value, out: &mut Out<'_, Completion>) -> Result<(), ProviderError> {
1432        use crate::message::{Image, ImageMediaType};
1433        let url = part
1434            .at("/image_url/url")
1435            .or_else(|| part.get("image_url"))
1436            .and_then(Value::as_str)
1437            .unwrap_or_default();
1438        let inline = url
1439            .strip_prefix("data:")
1440            .and_then(|rest| rest.split_once(";base64,"));
1441        let mut image = Image::default();
1442        let data = match inline {
1443            Some((mime, data)) => {
1444                image.data = Source::Base64(String::new());
1445                image.media_type = ImageMediaType::from_mime_type(mime);
1446                data
1447            }
1448            None => {
1449                image.data = Source::Url(url.to_owned());
1450                ""
1451            }
1452        };
1453        self.close_writing(out)?;
1454        let index = out.fresh_index();
1455        out.whole(index, Block::Image(image), part.clone(), data)
1456    }
1457
1458    /// One tool-call fragment. It goes to the call at its wire index, where
1459    /// the writer tells a new id under it from the call it held. Without an
1460    /// index (or with `null`) it goes to the open call that states its id;
1461    /// one that states no id continues the latest call while that call's
1462    /// arguments are not yet a whole object, and otherwise opens a new call
1463    /// when it brings a name or arguments. Its fields merge into the call's
1464    /// item.
1465    fn call(&mut self, call: &Value, out: &mut Out<'_, Completion>) -> Result<(), ProviderError> {
1466        self.close_writing(out)?;
1467        let id = match call.get("id") {
1468            Some(Value::String(id)) if !id.is_empty() && id != "null" => Some(id.clone()),
1469            Some(id @ Value::Number(_)) => Some(id.to_string()),
1470            _ => None,
1471        };
1472        let index = call
1473            .get("index")
1474            .and_then(Value::as_u64)
1475            .and_then(|index| usize::try_from(index).ok());
1476        let arguments = call
1477            .at("/function/arguments")
1478            .map(crate::json_utils::value_to_json_string);
1479        let name = call
1480            .at("/function/name")
1481            .or_else(|| call.at("/custom/name"))
1482            .and_then(Value::as_str);
1483        let starts = name.is_some_and(|name| !name.is_empty())
1484            || arguments.as_deref().is_some_and(|text| !text.is_empty());
1485        let at = match index {
1486            Some(index) => index,
1487            None => match id.as_deref() {
1488                Some(id) => self
1489                    .calls
1490                    .iter()
1491                    .find(|open| open.id.as_deref() == Some(id)),
1492                None => self.calls.last().filter(|open| {
1493                    open.opaque || !crate::json_utils::is_whole_object(&open.arguments) || !starts
1494                }),
1495            }
1496            .map_or_else(|| out.fresh_index(), |open| open.at),
1497        };
1498        let opaque = match self.calls.iter_mut().find(|open| open.at == at) {
1499            Some(open) => {
1500                if id.is_some() {
1501                    open.id.clone_from(&id);
1502                }
1503                open.arguments
1504                    .push_str(arguments.as_deref().unwrap_or_default());
1505                open.opaque
1506            }
1507            None => {
1508                let opaque = CallKind::of(call) == CallKind::Unknown;
1509                if opaque {
1510                    out.open(at, Block::Opaque { replay: false }, Value::Null)?;
1511                }
1512                self.calls.push(OpenCall {
1513                    at,
1514                    id: id.clone(),
1515                    opaque,
1516                    arguments: arguments.clone().unwrap_or_default(),
1517                });
1518                opaque
1519            }
1520        };
1521        if !opaque {
1522            out.fragment(
1523                Some(at),
1524                CallFragment {
1525                    id: id.as_deref(),
1526                    name,
1527                    arguments: arguments.as_deref(),
1528                },
1529            )?;
1530        }
1531        out.edit(at, |item| {
1532            if !item.is_object() {
1533                *item = Value::Object(Map::new());
1534            }
1535            if let (Value::Object(item), Some(fields)) = (item, call.as_object()) {
1536                merge_fields(item, fields);
1537                // The index orders the stream; the call it assembles has none.
1538                item.shift_remove("index");
1539            }
1540        })
1541    }
1542
1543    /// Close the open calls, with their items as natives: every one at the
1544    /// end (`all`), only those whose arguments are whole at a finish chunk,
1545    /// since a provider may still send fragments after it. A custom call's
1546    /// arguments are its `{"input"}`. A call the output budget cut before
1547    /// its arguments were a whole object closes with no native: the
1548    /// provider never stated it complete.
1549    fn close_calls(
1550        &mut self,
1551        out: &mut Out<'_, Completion>,
1552        all: bool,
1553    ) -> Result<(), ProviderError> {
1554        let cut = self.finish == Some(FinishReason::Length);
1555        let mut waiting = Vec::new();
1556        for call in std::mem::take(&mut self.calls) {
1557            if call.opaque {
1558                out.finish(call.at)?;
1559                continue;
1560            }
1561            let (mut input, mut complete) = (None, false);
1562            out.edit(call.at, |item| {
1563                if CallKind::of(item) == CallKind::Custom {
1564                    let custom = item.at("/custom/input").cloned();
1565                    input = Some(custom.unwrap_or_else(|| Value::from("")));
1566                }
1567                complete = match item.at("/function/arguments") {
1568                    Some(Value::String(text)) => matches!(
1569                        crate::json_utils::parse_tool_arguments(text),
1570                        Ok(Value::Object(_))
1571                    ),
1572                    Some(arguments) => arguments.is_object(),
1573                    None => input.is_some(),
1574                };
1575            })?;
1576            if !all && !complete {
1577                waiting.push(call);
1578                continue;
1579            }
1580            if let Some(input) = input {
1581                out.announce(call.at, json!({ "input": input }))?;
1582            }
1583            if cut && !complete {
1584                out.close(call.at)?;
1585            } else {
1586                out.finish(call.at)?;
1587            }
1588        }
1589        self.calls = waiting;
1590        Ok(())
1591    }
1592
1593    /// One `chat.completion.chunk`.
1594    fn chunk(&mut self, frame: &Value, out: &mut Out<'_, Completion>) -> Result<(), ProviderError> {
1595        let Some(choice) = self.absorb(frame) else {
1596            return Ok(());
1597        };
1598        if let Some(delta) = choice.obj("delta") {
1599            self.delta(delta, out)?;
1600        }
1601        // Text and reasoning stay open until the stream ends, as pi closes
1602        // its blocks (`openai-completions.ts` around 680): a provider may
1603        // still send either after the finish chunk.
1604        if self.finish == Some(FinishReason::ToolCalls) {
1605            self.close_calls(out, false)?;
1606        }
1607        Ok(())
1608    }
1609
1610    /// The `chat.completion` body: the one chunk whose delta is its whole
1611    /// message, each call at its position in the list, then the end.
1612    fn whole(
1613        &mut self,
1614        frame: &Value,
1615        mut out: Out<'_, Completion>,
1616    ) -> Result<Flow, ProviderError> {
1617        let Some(choice) = self.absorb(frame) else {
1618            return Err(ProviderError::Response(
1619                "Response contained no choices".to_owned(),
1620            ));
1621        };
1622        let Some(mut message) = choice.obj("message").cloned() else {
1623            return Err(ProviderError::Response(
1624                "Response did not contain a valid message or tool call".to_owned(),
1625            ));
1626        };
1627        self.ended = true;
1628        if let Some(Value::Array(calls)) = message.get_mut("tool_calls") {
1629            for (index, call) in calls.iter_mut().enumerate() {
1630                if let Some(call) = call.as_object_mut() {
1631                    call.insert("index".to_owned(), index.into());
1632                }
1633            }
1634        }
1635        self.delta(&message, &mut out)?;
1636        self.close_calls(&mut out, true)?;
1637        self.end(out)
1638    }
1639
1640    /// Write the provider's end of the reply. Its `raw` is the wire's
1641    /// reassembler's.
1642    fn end(&mut self, mut out: Out<'_, Completion>) -> Result<Flow, ProviderError> {
1643        self.close_writing(&mut out)?;
1644        self.close_reasoning(&mut out)?;
1645        self.cite_turn(&mut out);
1646        if let Some(text) = self.first_text {
1647            for citation in reported::listed(&self.reply_fields) {
1648                out.cite(text, citation);
1649            }
1650        }
1651        let cost = reported::cost(self.usage.as_ref(), &self.reply_fields);
1652        let usage = self
1653            .usage
1654            .as_ref()
1655            .map(|usage| normalized_usage(usage, &self.quirks))
1656            .unwrap_or_default()
1657            .cost(cost);
1658        Ok(out.end(Finish {
1659            usage,
1660            reason: self.finish.take(),
1661            response_id: self.response_id.take(),
1662            model: self.response_model.take(),
1663            ..Finish::default()
1664        }))
1665    }
1666
1667    /// The stream ended: flush the calls the provider delivered, then end
1668    /// the reply. A stream no finish reason ended was cut short.
1669    fn finish(&mut self, mut out: Out<'_, Completion>) -> Result<Flow, ProviderError> {
1670        if !self.ended {
1671            return Err(ProviderError::Truncated);
1672        }
1673        self.close_calls(&mut out, true)?;
1674        self.end(out)
1675    }
1676
1677    /// The `[DONE]` sentinel. A dialect whose streams omit the finish reason
1678    /// ends a turn that streamed a chunk as a tool call when it holds one,
1679    /// else a stop.
1680    fn done(&mut self, out: Out<'_, Completion>) -> Result<Flow, ProviderError> {
1681        if !self.ended && self.chunked && self.quirks.done_without_finish_reason {
1682            self.finish = Some(if self.calls.is_empty() {
1683                FinishReason::Stop
1684            } else {
1685                FinishReason::ToolCalls
1686            });
1687            self.ended = true;
1688        }
1689        self.finish(out)
1690    }
1691}
1692
1693/// Append streamed reasoning details to the block's: a
1694/// text or summary fragment continues the last entry of its type and
1695/// index, appending its text and filling the fields that entry lacks, and an
1696/// encrypted entry stays whole.
1697/// `slot` holding `text` when it is given, allocating only when it changed.
1698fn keep_text(slot: &mut Option<String>, text: Option<&str>) {
1699    if let Some(text) = text
1700        && slot.as_deref() != Some(text)
1701    {
1702        *slot = Some(text.to_owned());
1703    }
1704}
1705
1706fn merge_details(block: &mut Map<String, Value>, details: Vec<Value>) {
1707    let Value::Array(merged) = block
1708        .entry(REASONING_DETAILS)
1709        .or_insert_with(|| Value::Array(Vec::new()))
1710    else {
1711        return;
1712    };
1713    for detail in details {
1714        let kind = detail.str("type");
1715        let continues = matches!(kind, Some("reasoning.text" | "reasoning.summary"))
1716            && merged.last().is_some_and(|last| {
1717                last.str("type") == kind && last.get("index") == detail.get("index")
1718            });
1719        match (continues, merged.last_mut(), detail) {
1720            (true, Some(Value::Object(last)), Value::Object(fields)) => {
1721                for (key, value) in fields {
1722                    let missing = last
1723                        .get(&key)
1724                        .is_none_or(|existing| existing.is_null() || existing.as_str() == Some(""));
1725                    match (last.get_mut(&key), value) {
1726                        (Some(Value::String(text)), Value::String(more))
1727                            if matches!(key.as_str(), "text" | "summary") =>
1728                        {
1729                            text.push_str(&more);
1730                        }
1731                        (_, value) if missing => {
1732                            last.insert(key, value);
1733                        }
1734                        _ => {}
1735                    }
1736                }
1737            }
1738            (_, _, detail) => merged.push(detail),
1739        }
1740    }
1741}
1742
1743#[deny(clippy::wildcard_enum_match_arm)]
1744impl<'id> Decoder<'id, Completion> for ChatDecoder {
1745    type Event = ChatEvent;
1746
1747    fn classify(&self, frame: WireFrame) -> WireEvent<ChatEvent> {
1748        let data = frame.as_str();
1749        // `[DONE]` is the wire's terminal sentinel, not JSON.
1750        if data == "[DONE]" {
1751            return WireEvent::Known(ChatEvent::Done);
1752        }
1753        // The in-band error envelope arrives with a 200 status and is this
1754        // wire's own terminal failure, whatever else the frame is.
1755        let envelope = |value: &Value| provider_error_envelope(value, &data);
1756        match classify_chat_completions_frame::<Value>(&data) {
1757            WireEvent::Known(frame) => WireEvent::Known(match envelope(&frame) {
1758                Some(error) => ChatEvent::Failure(error),
1759                None => Self::chunk_or_whole(frame),
1760            }),
1761            WireEvent::Unknown { event_type, value } => {
1762                if let Some(error) = envelope(value.value()) {
1763                    WireEvent::Known(ChatEvent::Failure(error))
1764                } else if let (true, Some(text)) = (
1765                    self.quirks.accepts_bare_string_reply,
1766                    value.value().as_str(),
1767                ) {
1768                    WireEvent::Known(ChatEvent::BareText(text.to_owned()))
1769                } else {
1770                    WireEvent::Unknown { event_type, value }
1771                }
1772            }
1773            // A frame with a duplicate `object` or `choices` is still JSON.
1774            WireEvent::Corrupt(error) => match serde_json::from_str::<Value>(&data)
1775                .ok()
1776                .and_then(|value| envelope(&value))
1777            {
1778                Some(error) => WireEvent::Known(ChatEvent::Failure(error)),
1779                None => WireEvent::Corrupt(error),
1780            },
1781        }
1782    }
1783
1784    fn decode(
1785        &mut self,
1786        event: ChatEvent,
1787        mut out: Out<'id, Completion>,
1788    ) -> Result<Flow, ProviderError> {
1789        match event {
1790            ChatEvent::Chunk(frame) => {
1791                self.chunked = true;
1792                self.chunk(&frame, &mut out)?;
1793                Ok(Flow::More)
1794            }
1795            ChatEvent::Whole(frame) => self.whole(&frame, out),
1796            ChatEvent::Done => self.done(out),
1797            // A bare string is the whole answer, ended: pi's stop for a
1798            // reply that names no reason.
1799            ChatEvent::BareText(text) => {
1800                self.ended = true;
1801                self.finish = Some(FinishReason::Stop);
1802                self.write(Writing::Text, &text, &mut out)?;
1803                self.end(out)
1804            }
1805            ChatEvent::Failure(error) => Err(error),
1806        }
1807    }
1808
1809    /// A stream that stops after a finish reason without `[DONE]` still
1810    /// ended: some dialects (Perplexity) never send the sentinel.
1811    fn eof(&mut self, out: Out<'id, Completion>) -> Result<Flow, ProviderError> {
1812        self.finish(out)
1813    }
1814}
1815
1816/// A Chat usage object, normalized for a dialect with `quirks` and read
1817/// leniently, so no usage shape fails a reply: a counter that is absent,
1818/// `null` or not a non-negative integer is unreported, and one inside a
1819/// details object the usage has is zero. Cached input falls back to
1820/// Mistral's `num_cached_tokens` and then DeepSeek's
1821/// `prompt_cache_hit_tokens`; audio input counts as input when the total
1822/// says it sits beside the prompt; output is the remainder of the total when
1823/// unreported; and the reasoning count is left out where the dialect's
1824/// cannot be trusted ([`Quirks::reliable_reasoning_count`]).
1825fn normalized_usage(usage: &Value, quirks: &Quirks) -> crate::completion::Usage {
1826    let count = |pointer: &str| usage.at(pointer).and_then(Value::as_u64);
1827    let detail = |object: &str, key: &str| {
1828        count(&format!("/{object}/{key}")).or(usage.obj(object).map(|_| 0))
1829    };
1830    let (prompt, completion, total) = (
1831        count("/prompt_tokens"),
1832        count("/completion_tokens"),
1833        count("/total_tokens"),
1834    );
1835    let audio = count("/prompt_tokens_details/audio_tokens").unwrap_or(0);
1836    let input = prompt.map(|prompt| {
1837        let beside = prompt.saturating_add(audio);
1838        let accounted = beside.saturating_add(completion.unwrap_or(0));
1839        if audio != 0 && Some(accounted) == total {
1840            beside
1841        } else {
1842            prompt
1843        }
1844    });
1845    crate::completion::Usage {
1846        input_tokens: input,
1847        output_tokens: completion
1848            .or_else(|| total.map(|total| total.saturating_sub(input.unwrap_or(0)))),
1849        total_tokens: total,
1850        cached_input_tokens: detail("prompt_tokens_details", "cached_tokens")
1851            .or(count("/num_cached_tokens"))
1852            .or(count("/prompt_cache_hit_tokens")),
1853        cache_creation_input_tokens: count("/prompt_tokens_details/cache_write_tokens"),
1854        reasoning_tokens: detail("completion_tokens_details", "reasoning_tokens")
1855            .filter(|_| quirks.reliable_reasoning_count),
1856        ..Default::default()
1857    }
1858}
1859
1860impl ChatDecoder {
1861    /// A classified frame as the chunk or whole reply it is. The `object`
1862    /// tag decides when the dialect sends one: stream chunks may carry whole
1863    /// messages. Several gateways omit it.
1864    fn chunk_or_whole(frame: Value) -> ChatEvent {
1865        let whole = match frame.str("object") {
1866            Some(object) => object == "chat.completion",
1867            None => frame
1868                .arr("choices")
1869                .iter()
1870                .any(|choice| choice.obj("message").is_some()),
1871        };
1872        if whole {
1873            ChatEvent::Whole(frame)
1874        } else {
1875            ChatEvent::Chunk(frame)
1876        }
1877    }
1878
1879    /// Verdict, model, response id and error envelope, read off a raw
1880    /// payload before normalization discards them. The driver calls it for
1881    /// the unary reply and for every stream frame.
1882    pub(crate) fn project(payload: &[u8], sink: &mut ObservationSink<'_>) {
1883        let Ok(payload) = serde_json::from_slice::<Value>(payload) else {
1884            return;
1885        };
1886        // Only the chunk that carries the finish reason is a verdict, so the
1887        // model rides with it rather than on each delta.
1888        let verdict = match payload
1889            .at("/choices/0/finish_reason")
1890            .and_then(Value::as_str)
1891        {
1892            Some(reason) => AdapterVerdict {
1893                finish_reason: Some(sink.scrub(reason)),
1894                block_reason: None,
1895                detail: None,
1896                model: payload.str("model").map(|value| sink.scrub(value)),
1897            },
1898            None => AdapterVerdict::default(),
1899        };
1900        let response_id = payload.str("id").map(|value| sink.scrub(value));
1901        sink.provider(verdict, response_id);
1902        if let Some(error) = ObservedError::of(&payload) {
1903            error.emit(sink);
1904        }
1905    }
1906}
1907
1908mod document;
1909pub(crate) mod finish;
1910mod reported;
1911
1912#[cfg(test)]
1913mod tests;
1914
1915#[cfg(test)]
1916mod hard_case_tests;
1917
1918#[cfg(test)]
1919mod history_tests;
1920
1921#[cfg(test)]
1922mod request_tests;