Skip to main content

rig_core/providers/openai/wire/
dto.rs

1//! Chat Completions reply shapes for unary messages and streamed deltas.
2//! Unary messages are converted to delta events during classification.
3
4use serde::{Deserialize, Serialize};
5
6use crate::json_utils;
7use crate::providers::openai::completion::{Message, Usage, joined_text_parts};
8
9/// A streamed tool-call fragment's function half.
10#[derive(Default, Deserialize, Debug, Clone)]
11pub(crate) struct StreamingFunction {
12    pub(crate) name: Option<String>,
13    #[serde(
14        default,
15        deserialize_with = "crate::json_utils::deserialize_json_string_or_value"
16    )]
17    pub(crate) arguments: Option<String>,
18}
19
20/// One streamed tool-call fragment.
21#[derive(Deserialize, Debug, Clone)]
22pub(crate) struct StreamingToolCall {
23    // Optional in several compatible dialects (e.g. Mistral); missing means
24    // a single in-flight tool call.
25    #[serde(default)]
26    pub(crate) index: usize,
27    pub(crate) id: Option<String>,
28    #[serde(default, deserialize_with = "json_utils::null_or_default")]
29    pub(crate) function: StreamingFunction,
30}
31
32impl StreamingToolCall {
33    fn has_nonempty_name(&self) -> bool {
34        self.function
35            .name
36            .as_ref()
37            .is_some_and(|name| !name.is_empty())
38    }
39
40    fn starts_new_tool_call(&self) -> bool {
41        self.has_nonempty_name()
42            && self
43                .function
44                .arguments
45                .as_ref()
46                .is_none_or(String::is_empty)
47    }
48
49    /// Whether this one fragment carries a whole call: the shape
50    /// llama.cpp-based servers emit.
51    pub(crate) fn is_complete_single_chunk(&self) -> bool {
52        self.has_nonempty_name()
53            && self
54                .function
55                .arguments
56                .as_ref()
57                .is_some_and(|arguments| !arguments.is_empty())
58    }
59
60    /// Whether this fragment belongs to a different call than the one open
61    /// at its index. Some gateways stream two distinct calls under one
62    /// `index`: a new id plus either a different name or an argument-less
63    /// opening fragment is a second call; anything else continues the call
64    /// already open.
65    pub(crate) fn evicts(&self, existing_id: &str, existing_name: &str) -> bool {
66        if let Some(new_id) = &self.id
67            && !new_id.is_empty()
68            && let Some(new_name) = &self.function.name
69            && self.has_nonempty_name()
70            && !existing_id.is_empty()
71            && existing_id != *new_id
72            && !existing_name.is_empty()
73        {
74            return existing_name != *new_name || self.starts_new_tool_call();
75        }
76
77        false
78    }
79}
80
81fn deserialize_delta_content<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
82where
83    D: serde::Deserializer<'de>,
84{
85    // Some compatible providers (e.g. Mistral's reasoning models) stream
86    // delta content as an array of content parts rather than a string.
87    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
88    Ok(value.and_then(|value| match value {
89        serde_json::Value::String(text) => Some(text),
90        serde_json::Value::Array(parts) => {
91            let text = joined_text_parts(&parts);
92            (!text.is_empty()).then_some(text)
93        }
94        _ => None,
95    }))
96}
97
98/// One streamed choice's delta.
99#[derive(Deserialize, Debug, Default, Clone)]
100pub(crate) struct StreamingDelta {
101    #[serde(default, deserialize_with = "deserialize_delta_content")]
102    pub(crate) content: Option<String>,
103    /// Refusal text used when content is absent or empty; see [`delta_text`].
104    #[serde(default)]
105    pub(crate) refusal: Option<String>,
106    #[serde(default)]
107    pub(crate) reasoning_content: Option<String>,
108    // Not part of the official OpenAI API; some compatible providers (e.g.
109    // Groq) send the same payload under `reasoning`. A separate field rather
110    // than a serde alias so a delta carrying BOTH keys is not a
111    // duplicate-field error that drops the whole chunk.
112    #[serde(default)]
113    pub(crate) reasoning: Option<String>,
114    #[serde(default, deserialize_with = "json_utils::null_or_default")]
115    pub(crate) tool_calls: Vec<StreamingToolCall>,
116    #[serde(default, deserialize_with = "json_utils::null_or_default")]
117    pub(crate) reasoning_details: Vec<serde_json::Value>,
118}
119
120/// A chat-completions terminal reason, in the wire's own vocabulary.
121#[derive(Deserialize, Debug, PartialEq, Clone)]
122#[serde(rename_all = "snake_case")]
123pub enum FinishReason {
124    /// The model ended the turn to call tools.
125    ToolCalls,
126    /// The model stopped naturally.
127    Stop,
128    /// The provider's content filter ended the turn.
129    ContentFilter,
130    /// The output cap ended the turn.
131    Length,
132    /// Anything else the wire sent, preserved verbatim (including the
133    /// deprecated `function_call`).
134    #[serde(untagged)]
135    Other(String),
136}
137
138impl FinishReason {
139    /// Return the provider's wire spelling, preserving unknown values.
140    pub(crate) fn as_wire(&self) -> &str {
141        match self {
142            Self::ToolCalls => "tool_calls",
143            Self::Stop => "stop",
144            Self::ContentFilter => "content_filter",
145            Self::Length => "length",
146            Self::Other(other) => other,
147        }
148    }
149}
150
151/// Return nonempty content, falling back to nonempty refusal text.
152/// Preserve empty content when no nonempty refusal is available.
153pub(crate) fn delta_text(delta: &StreamingDelta) -> Option<String> {
154    match delta.content.as_deref() {
155        Some(content) if !content.is_empty() => delta.content.clone(),
156        content => delta
157            .refusal
158            .clone()
159            .filter(|refusal| !refusal.is_empty())
160            .or_else(|| content.map(str::to_owned)),
161    }
162}
163
164/// Chat Completions accounting with dialect-specific fields preserved for
165/// the response's `raw`.
166// Serde derives a U: Default bound for StreamingCompletionResponse<U>.
167#[derive(Clone, Debug, Default, Serialize, Deserialize)]
168pub struct ChatUsage {
169    /// The OpenAI-compatible accounting.
170    #[serde(flatten)]
171    pub openai: Usage,
172    /// Fields this dialect adds.
173    #[serde(flatten)]
174    pub extra: serde_json::Map<String, serde_json::Value>,
175}
176
177impl ChatUsage {
178    /// A `u64` counter from the dialect's extra fields.
179    fn extra_count(&self, key: &str) -> Option<u64> {
180        self.extra.get(key).and_then(serde_json::Value::as_u64)
181    }
182
183    /// Normalize this accounting.
184    ///
185    /// `cached_input_tokens` falls back to DeepSeek's `prompt_cache_hit_tokens`,
186    /// which reports cache activity outside `prompt_tokens_details`; reading
187    /// only the OpenAI spelling would report no cache hit on a turn that was
188    /// entirely served from cache. (Mistral's `num_cached_tokens` is a typed
189    /// field of [`Usage`] and handled by its own `to_normalized`.)
190    pub fn to_normalized(&self) -> crate::completion::Usage {
191        let mut usage = self.openai.to_normalized();
192        if usage.cached_input_tokens.is_none() {
193            usage.cached_input_tokens = self.extra_count("prompt_cache_hit_tokens");
194        }
195        usage
196    }
197
198    /// Normalize this accounting for a dialect with `quirks`, as the chat
199    /// wire does: [`Self::to_normalized`], with the reasoning count left
200    /// unreported where the dialect's count cannot be trusted
201    /// ([`Quirks::reliable_reasoning_count`](super::Quirks::reliable_reasoning_count)).
202    pub fn to_normalized_for(&self, quirks: &super::Quirks) -> crate::completion::Usage {
203        let mut usage = self.to_normalized();
204        if !quirks.reliable_reasoning_count {
205            usage.reasoning_tokens = None;
206        }
207        usage
208    }
209}
210
211impl From<ChatUsage> for crate::completion::Usage {
212    fn from(value: ChatUsage) -> Self {
213        value.to_normalized()
214    }
215}
216
217/// One choice of a chat-completions frame, in either reply's shape.
218#[derive(Deserialize, Debug)]
219pub struct ChatChoice {
220    /// The streamed shape's fragment. Defaulted because a choice on the wire
221    /// is not guaranteed to carry one: Azure prepends a
222    /// `prompt_filter_results` chunk (delta-less choice) to every stream when
223    /// content filtering is enabled.
224    #[serde(default)]
225    pub(crate) delta: StreamingDelta,
226    /// The unary shape's whole assistant message. Absent on a streamed
227    /// frame; present exactly when this frame is the unary reply.
228    #[serde(default)]
229    pub(crate) message: Option<Message>,
230    pub(crate) finish_reason: Option<FinishReason>,
231    /// Upstream provider spelling forwarded by gateways such as OpenRouter.
232    /// Direct providers omit it.
233    #[serde(default)]
234    pub(crate) native_finish_reason: Option<String>,
235    /// Which candidate this belongs to when the caller asked for `n > 1`.
236    /// Optional because providers streaming a single candidate may omit it;
237    /// absent is read as candidate 0.
238    #[serde(default)]
239    pub(crate) index: Option<usize>,
240    /// Per-token probabilities. Kept as provider metadata: compatible
241    /// services extend the object independently, and the raw terminal record
242    /// must retain every chunk rather than pick a token schema here.
243    #[serde(
244        default,
245        deserialize_with = "crate::message::optional_additional_params"
246    )]
247    pub(crate) logprobs: Option<crate::message::AdditionalParams>,
248}
249
250/// One frame of the chat-completions wire.
251#[derive(Deserialize, Debug)]
252pub struct ChatFrame {
253    pub(crate) id: Option<String>,
254    pub(crate) model: Option<String>,
255    #[serde(default, deserialize_with = "json_utils::null_or_default")]
256    pub(crate) choices: Vec<ChatChoice>,
257    pub(crate) usage: Option<ChatUsage>,
258    /// Provider-specific top-level fields. Chat-completions-compatible
259    /// services add fields independently (`service_tier`, `provider`,
260    /// `system_fingerprint`), and the terminal record must not erase them
261    /// merely because the shared wire shape does not know their names yet.
262    #[serde(flatten)]
263    pub(crate) additional_params: serde_json::Map<String, serde_json::Value>,
264}
265
266impl ChatFrame {
267    /// Whether this frame is the unary `chat.completion` body.
268    ///
269    /// The `object` tag decides it when the dialect sends one; a choice
270    /// carrying a whole `message` decides it when the dialect does not. Both
271    /// are needed: `object` is the authoritative tag, and several gateways
272    /// omit it entirely.
273    pub(crate) fn is_whole(&self) -> bool {
274        match self.object() {
275            // Stream chunks may include whole messages, so an explicit tag wins.
276            Some(object) => object == "chat.completion",
277            // No tag: several gateways omit it, and then a choice carrying a
278            // whole `message` rather than a `delta` is the unary body.
279            None => self.choices.iter().any(|choice| choice.message.is_some()),
280        }
281    }
282
283    /// Borrow the `object` tag without removing it from terminal metadata.
284    pub(crate) fn object(&self) -> Option<&str> {
285        self.additional_params
286            .get("object")
287            .and_then(serde_json::Value::as_str)
288    }
289
290    /// The primary candidate.
291    ///
292    /// `n > 1` streams as interleaved chunks distinguished only by
293    /// `choices[].index`. Taking each frame's *first* choice would
294    /// concatenate every candidate into one garbled answer, while the unary
295    /// reply is normalized from candidate 0 alone; selecting by index keeps
296    /// the two agreeing.
297    pub(crate) fn primary(&self) -> Option<&ChatChoice> {
298        self.choices
299            .iter()
300            .find(|choice| choice.index.is_none_or(|index| index == 0))
301    }
302
303    /// The primary candidate, taken out of the frame.
304    pub(crate) fn into_primary(self) -> Option<ChatChoice> {
305        self.choices
306            .into_iter()
307            .find(|choice| choice.index.is_none_or(|index| index == 0))
308    }
309}
310
311/// The provider's own terminal record for one chat-completions reply.
312///
313/// `U` is the accounting: [`ChatUsage`] on the wire path. This is what the
314/// decoder serializes onto the response's `raw`, so a caller reaches every
315/// provider field rig does not normalize.
316#[derive(Clone, Debug, Serialize, Deserialize)]
317pub struct StreamingCompletionResponse<U = Usage> {
318    /// Usage reported on the reply's terminal event; `None` when the reply
319    /// never carried one (a compatible service that ignores
320    /// `stream_options.include_usage`, or a `usage: null` terminal chunk).
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub usage: Option<U>,
323    /// Why the model stopped generating, when the provider reported it.
324    ///
325    /// Normalized out of the OpenAI-compatible `finish_reason` vocabulary,
326    /// with unrecognized values preserved verbatim. The `Stop` -> `ToolCalls`
327    /// upgrade is deliberately *not* applied here: it belongs to
328    /// [`CompletionStream`](crate::streaming::CompletionStream), the only
329    /// place that sees which tool calls the reply actually emitted.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub finish_reason: Option<crate::completion::FinishReason>,
332    /// Provider-assigned response identifier, when the reply emitted one.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub response_id: Option<String>,
335    /// Provider-reported model identifier, when the reply emitted one.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub model: Option<String>,
338    /// Token log probabilities accumulated from all primary-choice chunks.
339    ///
340    /// This stays provider-native: normalized completions do not model log
341    /// probabilities, just as the unary path omits `Choice::logprobs` while
342    /// its raw response retains them.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub logprobs: Option<serde_json::Value>,
345    /// Provider-specific top-level fields accumulated from the reply, such
346    /// as OpenAI's `service_tier` and `system_fingerprint` or OpenRouter's
347    /// routed `provider`.
348    #[serde(
349        default,
350        skip_serializing_if = "Option::is_none",
351        deserialize_with = "crate::message::optional_additional_params"
352    )]
353    pub additional_params: Option<crate::message::AdditionalParams>,
354}
355
356impl<U> StreamingCompletionResponse<U> {
357    /// Create a terminal record carrying `usage`; the optional metadata
358    /// starts unset.
359    pub fn new(usage: Option<U>) -> Self {
360        Self {
361            usage,
362            finish_reason: None,
363            response_id: None,
364            model: None,
365            logprobs: None,
366            additional_params: None,
367        }
368    }
369}
370
371impl<U> StreamingCompletionResponse<U>
372where
373    U: Into<crate::completion::Usage>,
374{
375    /// The provider's end of the reply: normalized usage and terminal
376    /// metadata.
377    pub fn into_finish(self) -> crate::operation::Finish {
378        crate::operation::Finish {
379            usage: self.usage.map(Into::into).unwrap_or_default(),
380            reason: self.finish_reason,
381            response_id: self.response_id,
382            model: self.model,
383            ..crate::operation::Finish::default()
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests;