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