1pub mod streaming {
5 use serde::{Deserialize, Serialize};
8
9 use crate::chat::ServiceTier;
10
11 fn null_to_empty_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
17 where
18 D: serde::Deserializer<'de>,
19 T: Deserialize<'de>,
20 {
21 Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
22 }
23
24 #[derive(Debug, Deserialize, Serialize, Clone)]
25 pub struct ChatCompletionChunk {
26 pub id: String,
28 #[serde(default, deserialize_with = "null_to_empty_vec")]
34 pub choices: Vec<CompletionChunkChoice>,
35 pub created: u64,
38 pub model: String,
40 pub object: Option<ChatCompletionChunkObject>,
45 pub service_tier: Option<ServiceTier>,
52 pub system_fingerprint: Option<String>,
56 pub usage: Option<CompletionUsage>,
64 pub moderation: Option<crate::chat::ChatModeration>,
69 }
70
71 crate::wire_string_enum! {
72 pub enum ChatCompletionChunkObject {
74 ChatCompletionChunk => "chat.completion.chunk",
75 }
76 }
77
78 #[derive(Debug, Deserialize, Serialize, Clone)]
79 pub struct CompletionChunkChoice {
80 pub delta: ChoiceDelta,
82 pub index: u32,
84 pub logprobs: Option<ChoiceLogprobs>,
86 pub finish_reason: Option<FinishReason>,
94 }
95
96 pub use crate::chat::FinishReason;
97
98 #[derive(Debug, Deserialize, Serialize, Clone)]
99 pub struct ChoiceDelta {
100 pub content: Option<String>,
102 #[cfg(feature = "reasoning")]
106 pub reasoning_content: Option<String>,
107 pub function_call: Option<ChoiceDeltaFunctionCall>,
112 pub refusal: Option<String>,
114 pub role: Option<CompletionRole>,
116 pub tool_calls: Option<Vec<ChoiceDeltaToolCall>>,
118 pub annotations: Option<Vec<crate::chat::Annotation>>,
125 pub audio: Option<crate::chat::ChatCompletionAudio>,
130 }
131
132 #[derive(Debug, Deserialize, Serialize, Clone)]
133 pub struct ChoiceDeltaToolCallFunction {
134 pub arguments: Option<String>,
139 pub name: Option<String>,
141 }
142
143 #[derive(Debug, Deserialize, Serialize, Clone)]
144 pub struct ChoiceDeltaFunctionCall {
145 pub arguments: Option<String>,
150 pub name: Option<String>,
152 }
153
154 #[derive(Debug, Deserialize, Serialize, Clone)]
155 pub struct ChoiceDeltaToolCall {
156 pub index: u32,
158 pub id: Option<String>,
160 pub function: Option<ChoiceDeltaToolCallFunction>,
162 #[serde(rename = "type")]
164 pub type_: Option<ChoiceDeltaToolCallType>,
165 }
166
167 crate::wire_string_enum! {
168 pub enum ChoiceDeltaToolCallType {
170 Function => "function",
172 Custom => "custom",
174 }
175 }
176
177 pub use crate::chat::Role as CompletionRole;
178
179 #[derive(Debug, Deserialize, Serialize, Clone)]
181 pub struct ChoiceLogprobs {
182 pub content: Option<Vec<LogprobeContent>>,
184 #[cfg(feature = "reasoning")]
187 pub reasoning_content: Option<Vec<LogprobeContent>>,
188 pub refusal: Option<Vec<LogprobeContent>>,
190 }
191
192 #[derive(Debug, Deserialize, Serialize, Clone)]
194 pub struct LogprobeContent {
195 pub token: String,
196 pub logprob: f32,
197 pub bytes: Option<Vec<u8>>,
198 pub top_logprobs: Vec<TopLogprob>,
199 }
200
201 #[derive(Debug, Deserialize, Serialize, Clone)]
204 pub struct TopLogprob {
205 pub token: String,
206 pub logprob: f32,
207 pub bytes: Option<Vec<u8>>,
208 }
209
210 pub use crate::chat::{CompletionTokensDetails, CompletionUsage, PromptTokensDetails};
211
212 crate::impl_from_str!(ChatCompletionChunk);
213
214 #[cfg(test)]
215 mod test {
216 use std::str::FromStr;
217
218 use super::*;
219
220 #[test]
221 fn streaming_example_deepseek() {
222 let streams = vec![
223 r#"{"id": "1f633d8bfc032625086f14113c411638", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant"}, "finish_reason": null, "logprobs": null}], "created": 1718345013, "model": "deepseek-chat", "system_fingerprint": "fp_a49d71b8a1", "object": "chat.completion.chunk", "usage": null}"#,
224 r#"{"choices": [{"delta": {"content": "Hello", "role": "assistant"}, "finish_reason": null, "index": 0, "logprobs": null}], "created": 1718345013, "id": "1f633d8bfc032625086f14113c411638", "model": "deepseek-chat", "object": "chat.completion.chunk", "system_fingerprint": "fp_a49d71b8a1"}"#,
225 r#"{"choices": [{"delta": {"content": "!", "role": "assistant"}, "finish_reason": null, "index": 0, "logprobs": null}], "created": 1718345013, "id": "1f633d8bfc032625086f14113c411638", "model": "deepseek-chat", "object": "chat.completion.chunk", "system_fingerprint": "fp_a49d71b8a1"}"#,
226 r#"{"choices": [{"delta": {"content": " How", "role": "assistant"}, "finish_reason": null, "index": 0, "logprobs": null}], "created": 1718345013, "id": "1f633d8bfc032625086f14113c411638", "model": "deepseek-chat", "object": "chat.completion.chunk", "system_fingerprint": "fp_a49d71b8a1"}"#,
227 r#"{"choices": [{"delta": {"content": " can", "role": "assistant"}, "finish_reason": null, "index": 0, "logprobs": null}], "created": 1718345013, "id": "1f633d8bfc032625086f14113c411638", "model": "deepseek-chat", "object": "chat.completion.chunk", "system_fingerprint": "fp_a49d71b8a1"}"#,
228 r#"{"choices": [{"delta": {"content": " I", "role": "assistant"}, "finish_reason": null, "index": 0, "logprobs": null}], "created": 1718345013, "id": "1f633d8bfc032625086f14113c411638", "model": "deepseek-chat", "object": "chat.completion.chunk", "system_fingerprint": "fp_a49d71b8a1"}"#,
229 r#"{"choices": [{"delta": {"content": " assist", "role": "assistant"}, "finish_reason": null, "index": 0, "logprobs": null}], "created": 1718345013, "id": "1f633d8bfc032625086f14113c411638", "model": "deepseek-chat", "object": "chat.completion.chunk", "system_fingerprint": "fp_a49d71b8a1"}"#,
230 r#"{"choices": [{"delta": {"content": " you", "role": "assistant"}, "finish_reason": null, "index": 0, "logprobs": null}], "created": 1718345013, "id": "1f633d8bfc032625086f14113c411638", "model": "deepseek-chat", "object": "chat.completion.chunk", "system_fingerprint": "fp_a49d71b8a1"}"#,
231 r#"{"choices": [{"delta": {"content": " today", "role": "assistant"}, "finish_reason": null, "index": 0, "logprobs": null}], "created": 1718345013, "id": "1f633d8bfc032625086f14113c411638", "model": "deepseek-chat", "object": "chat.completion.chunk", "system_fingerprint": "fp_a49d71b8a1"}"#,
232 r#"{"choices": [{"delta": {"content": "?", "role": "assistant"}, "finish_reason": null, "index": 0, "logprobs": null}], "created": 1718345013, "id": "1f633d8bfc032625086f14113c411638", "model": "deepseek-chat", "object": "chat.completion.chunk", "system_fingerprint": "fp_a49d71b8a1"}"#,
233 r#"{"choices": [{"delta": {"content": "", "role": null}, "finish_reason": "stop", "index": 0, "logprobs": null}], "created": 1718345013, "id": "1f633d8bfc032625086f14113c411638", "model": "deepseek-chat", "object": "chat.completion.chunk", "system_fingerprint": "fp_a49d71b8a1", "usage": {"completion_tokens": 9, "prompt_tokens": 17, "total_tokens": 26}}"#,
234 ];
235
236 for stream in streams {
237 let parsed = ChatCompletionChunk::from_str(stream);
238 match parsed {
239 Ok(completion) => {
240 println!("Deserialized: {:#?}", completion);
241 }
242 Err(e) => {
243 panic!("Failed to deserialize {}: {}", stream, e);
244 }
245 }
246 }
247 }
248
249 #[test]
250 fn streaming_example_qwen() {
251 let streams = vec![
252 r#"{"id":"chatcmpl-e30f5ae7-3063-93c4-90fe-beb5f900bd57","choices":[{"delta":{"content":"","function_call":null,"refusal":null,"role":"assistant","tool_calls":null},"finish_reason":null,"index":0,"logprobs":null}],"created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","service_tier":null,"system_fingerprint":null,"usage":null}"#,
253 r#"{"id":"chatcmpl-e30f5ae7-3063-93c4-90fe-beb5f900bd57","choices":[{"delta":{"content":"我是","function_call":null,"refusal":null,"role":null,"tool_calls":null},"finish_reason":null,"index":0,"logprobs":null}],"created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","service_tier":null,"system_fingerprint":null,"usage":null}"#,
254 r#"{"id":"chatcmpl-e30f5ae7-3063-93c4-90fe-beb5f900bd57","choices":[{"delta":{"content":"来自","function_call":null,"refusal":null,"role":null,"tool_calls":null},"finish_reason":null,"index":0,"logprobs":null}],"created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","service_tier":null,"system_fingerprint":null,"usage":null}"#,
255 r#"{"id":"chatcmpl-e30f5ae7-3063-93c4-90fe-beb5f900bd57","choices":[{"delta":{"content":"阿里","function_call":null,"refusal":null,"role":null,"tool_calls":null},"finish_reason":null,"index":0,"logprobs":null}],"created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","service_tier":null,"system_fingerprint":null,"usage":null}"#,
256 r#"{"id":"chatcmpl-e30f5ae7-3063-93c4-90fe-beb5f900bd57","choices":[{"delta":{"content":"云的超大规模","function_call":null,"refusal":null,"role":null,"tool_calls":null},"finish_reason":null,"index":0,"logprobs":null}],"created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","service_tier":null,"system_fingerprint":null,"usage":null}"#,
257 r#"{"id":"chatcmpl-e30f5ae7-3063-93c4-90fe-beb5f900bd57","choices":[{"delta":{"content":"语言","function_call":null,"refusal":null,"role":null,"tool_calls":null},"finish_reason":null,"index":0,"logprobs":null}],"created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","service_tier":null,"system_fingerprint":null,"usage":null}"#,
258 r#"{"id":"chatcmpl-e30f5ae7-3063-93c4-90fe-beb5f900bd57","choices":[{"delta":{"content":"模型","function_call":null,"refusal":null,"role":null,"tool_calls":null},"finish_reason":null,"index":0,"logprobs":null}],"created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","service_tier":null,"system_fingerprint":null,"usage":null}"#,
259 r#"{"id":"chatcmpl-e30f5ae7-3063-93c4-90fe-beb5f900bd57","choices":[{"delta":{"content":"。","function_call":null,"refusal":null,"role":null,"tool_calls":null},"finish_reason":null,"index":0,"logprobs":null}],"created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","service_tier":null,"system_fingerprint":null,"usage":null}"#,
260 r#"{"id":"chatcmpl-e30f5ae7-3063-93c4-90fe-beb5f900bd57","choices":[{"delta":{"content":"问。","function_call":null,"refusal":null,"role":null,"tool_calls":null},"finish_reason":null,"index":0,"logprobs":null}],"created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","service_tier":null,"system_fingerprint":null,"usage":null}"#,
261 r#"{"id":"chatcmpl-e30f5ae7-3063-93c4-90fe-beb5f900bd57","choices":[{"delta":{"content":"","function_call":null,"refusal":null,"role":null,"tool_calls":null},"finish_reason":"stop","index":0,"logprobs":null}],"created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","service_tier":null,"system_fingerprint":null,"usage":null}"#,
262 r#"{"id":"chatcmpl-e30f5ae7-3063-93c4-90fe-beb5f900bd57","choices":[],"created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","service_tier":null,"system_fingerprint":null,"usage":{"completion_tokens":17,"prompt_tokens":22,"total_tokens":39,"completion_tokens_details":null,"prompt_tokens_details":{"audio_tokens":null,"cached_tokens":0}}}"#,
263 ];
264
265 for stream in streams {
266 let parsed = ChatCompletionChunk::from_str(stream);
267 match parsed {
268 Ok(completion) => {
269 println!("Deserialized: {:#?}", completion);
270 }
271 Err(e) => {
272 panic!("Failed to deserialize {}: {}", stream, e);
273 }
274 }
275 }
276 }
277
278 #[test]
283 fn streaming_example_azure_annotations_and_audio() {
284 let chunk = ChatCompletionChunk::from_str(
285 r#"{"id":"chatcmpl-abc","choices":[{"delta":{"content":"According to the doc","annotations":[{"type":"url_citation","url_citation":{"start_index":0,"end_index":20,"title":"Azure Docs","url":"https://learn.microsoft.com/azure"}}],"audio":{"id":"audio_abc","data":"SGVsbG8=","expires_at":1735113344,"transcript":"Hello"}},"finish_reason":null,"index":0,"logprobs":null}],"created":1735113344,"model":"gpt-4o","object":"chat.completion.chunk","system_fingerprint":"fp_abc"}"#,
286 )
287 .expect("chunk with annotations and audio must deserialize");
288
289 let delta = &chunk.choices[0].delta;
290 let annotations = delta.annotations.as_ref().expect("annotations");
291 assert_eq!(annotations.len(), 1);
292 assert_eq!(annotations[0].url_citation.title, "Azure Docs");
293 assert_eq!(
294 annotations[0].url_citation.url,
295 "https://learn.microsoft.com/azure"
296 );
297
298 let audio = delta.audio.as_ref().expect("audio");
299 assert_eq!(audio.id, "audio_abc");
300 assert_eq!(audio.transcript, "Hello");
301 }
302
303 #[test]
307 fn usage_chunk_with_null_choices_parses() {
308 let parsed = ChatCompletionChunk::from_str(
309 r#"{"id":"chatcmpl-1","choices":null,"created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","usage":{"completion_tokens":17,"prompt_tokens":22,"total_tokens":39}}"#,
310 )
311 .expect("usage chunk with null choices must deserialize");
312 assert!(parsed.choices.is_empty());
313 assert_eq!(parsed.usage.expect("usage").total_tokens, 39);
314 }
315
316 #[test]
317 fn usage_chunk_with_missing_choices_parses() {
318 let parsed = ChatCompletionChunk::from_str(
319 r#"{"id":"chatcmpl-1","created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","usage":{"completion_tokens":1,"prompt_tokens":2,"total_tokens":3}}"#,
320 )
321 .expect("usage chunk without a choices key must deserialize");
322 assert!(parsed.choices.is_empty());
323 }
324
325 #[test]
328 fn unknown_finish_reason_is_preserved() {
329 let parsed = ChatCompletionChunk::from_str(
330 r#"{"id":"1","choices":[{"index":0,"delta":{},"finish_reason":"eos"}],"created":1,"model":"m","object":"chat.completion.chunk"}"#,
331 )
332 .expect("chunk with unknown finish_reason must deserialize");
333
334 let finish_reason = parsed.choices[0]
335 .finish_reason
336 .as_ref()
337 .expect("finish_reason");
338 assert_eq!(finish_reason.as_str(), "eos");
339 assert_eq!(finish_reason.to_string(), "eos");
340 assert_eq!(
341 serde_json::to_value(finish_reason).unwrap(),
342 serde_json::json!("eos")
343 );
344 }
345
346 #[test]
347 fn unknown_role_is_preserved() {
348 let parsed = ChatCompletionChunk::from_str(
349 r#"{"id":"1","choices":[{"index":0,"delta":{"role":"model","content":"hi"},"finish_reason":null}],"created":1,"model":"m","object":"chat.completion.chunk"}"#,
350 )
351 .expect("chunk with unknown role must deserialize");
352 let role = parsed.choices[0].delta.role.as_ref().expect("role");
353 assert_eq!(role.as_str(), "model");
354 }
355
356 #[test]
357 fn missing_or_unknown_object_field_parses() {
358 let missing =
359 ChatCompletionChunk::from_str(r#"{"id":"1","choices":[],"created":1,"model":"m"}"#)
360 .expect("chunk without object must deserialize");
361 assert!(missing.object.is_none());
362
363 let weird = ChatCompletionChunk::from_str(
364 r#"{"id":"1","choices":[],"created":1,"model":"m","object":"vendor.custom.chunk"}"#,
365 )
366 .expect("chunk with unknown object must deserialize");
367 assert_eq!(
368 weird.object.expect("object").as_str(),
369 "vendor.custom.chunk"
370 );
371 }
372
373 #[test]
375 fn chunk_round_trips_through_json() {
376 let parsed = ChatCompletionChunk::from_str(
377 r#"{"id":"1","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},"finish_reason":null}],"created":1,"model":"m","object":"chat.completion.chunk"}"#,
378 )
379 .expect("chunk must deserialize");
380
381 let json = serde_json::to_value(&parsed).unwrap();
382 assert_eq!(json["object"], "chat.completion.chunk");
383 assert_eq!(json["choices"][0]["delta"]["content"], "Hi");
384
385 let reparsed = serde_json::from_value::<ChatCompletionChunk>(json).unwrap();
386 assert_eq!(reparsed.id, parsed.id);
387 assert_eq!(
388 reparsed.choices[0].delta.content,
389 parsed.choices[0].delta.content
390 );
391 }
392 }
393}
394
395pub mod no_streaming {
396 pub type ChatCompletion = crate::chat::ChatCompletion;
401}