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;