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 #[cfg(feature = "vllm")]
73 pub prompt_token_ids: Option<Vec<u32>>,
74 #[cfg(feature = "vllm")]
77 pub prompt_text: Option<String>,
78 }
79
80 crate::wire_string_enum! {
81 pub enum ChatCompletionChunkObject {
83 ChatCompletionChunk => "chat.completion.chunk",
84 }
85 }
86
87 #[derive(Debug, Deserialize, Serialize, Clone)]
88 pub struct CompletionChunkChoice {
89 pub delta: ChoiceDelta,
91 pub index: u32,
93 pub logprobs: Option<ChoiceLogprobs>,
95 pub finish_reason: Option<FinishReason>,
103
104 #[cfg(feature = "vllm")]
108 pub stop_reason: Option<crate::vllm::StopReason>,
109 #[cfg(feature = "vllm")]
112 pub token_ids: Option<Vec<u32>>,
113 }
114
115 pub use crate::chat::FinishReason;
116
117 #[derive(Debug, Deserialize, Serialize, Clone)]
118 pub struct ChoiceDelta {
119 pub content: Option<String>,
121 #[cfg(feature = "reasoning")]
125 pub reasoning_content: Option<String>,
126 #[cfg(feature = "vllm")]
135 pub reasoning: Option<String>,
136 pub function_call: Option<ChoiceDeltaFunctionCall>,
141 pub refusal: Option<String>,
143 pub role: Option<CompletionRole>,
145 pub tool_calls: Option<Vec<ChoiceDeltaToolCall>>,
147 pub annotations: Option<Vec<crate::chat::Annotation>>,
154 pub audio: Option<crate::chat::ChatCompletionAudio>,
159 }
160
161 #[derive(Debug, Deserialize, Serialize, Clone)]
162 pub struct ChoiceDeltaToolCallFunction {
163 pub arguments: Option<String>,
168 pub name: Option<String>,
170 }
171
172 #[derive(Debug, Deserialize, Serialize, Clone)]
173 pub struct ChoiceDeltaFunctionCall {
174 pub arguments: Option<String>,
179 pub name: Option<String>,
181 }
182
183 #[derive(Debug, Deserialize, Serialize, Clone)]
184 pub struct ChoiceDeltaToolCall {
185 pub index: u32,
187 pub id: Option<String>,
189 pub function: Option<ChoiceDeltaToolCallFunction>,
191 #[serde(rename = "type")]
193 pub type_: Option<ChoiceDeltaToolCallType>,
194 }
195
196 crate::wire_string_enum! {
197 pub enum ChoiceDeltaToolCallType {
199 Function => "function",
201 Custom => "custom",
203 }
204 }
205
206 pub use crate::chat::Role as CompletionRole;
207
208 #[derive(Debug, Deserialize, Serialize, Clone)]
210 pub struct ChoiceLogprobs {
211 pub content: Option<Vec<LogprobeContent>>,
213 #[cfg(feature = "reasoning")]
216 pub reasoning_content: Option<Vec<LogprobeContent>>,
217 pub refusal: Option<Vec<LogprobeContent>>,
219 }
220
221 #[derive(Debug, Deserialize, Serialize, Clone)]
223 pub struct LogprobeContent {
224 pub token: String,
225 pub logprob: f32,
226 pub bytes: Option<Vec<u8>>,
227 pub top_logprobs: Vec<TopLogprob>,
228 }
229
230 #[derive(Debug, Deserialize, Serialize, Clone)]
233 pub struct TopLogprob {
234 pub token: String,
235 pub logprob: f32,
236 pub bytes: Option<Vec<u8>>,
237 }
238
239 pub use crate::chat::{CompletionTokensDetails, CompletionUsage, PromptTokensDetails};
240
241 crate::impl_from_str!(ChatCompletionChunk);
242
243 #[cfg(test)]
244 mod test {
245 use std::str::FromStr;
246
247 use super::*;
248
249 #[test]
250 fn streaming_example_deepseek() {
251 let streams = vec![
252 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}"#,
253 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"}"#,
254 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"}"#,
255 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"}"#,
256 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"}"#,
257 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"}"#,
258 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"}"#,
259 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"}"#,
260 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"}"#,
261 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"}"#,
262 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}}"#,
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]
279 fn streaming_example_qwen() {
280 let streams = vec![
281 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}"#,
282 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}"#,
283 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}"#,
284 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}"#,
285 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}"#,
286 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}"#,
287 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}"#,
288 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}"#,
289 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}"#,
290 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}"#,
291 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}}}"#,
292 ];
293
294 for stream in streams {
295 let parsed = ChatCompletionChunk::from_str(stream);
296 match parsed {
297 Ok(completion) => {
298 println!("Deserialized: {:#?}", completion);
299 }
300 Err(e) => {
301 panic!("Failed to deserialize {}: {}", stream, e);
302 }
303 }
304 }
305 }
306
307 #[test]
312 fn streaming_example_azure_annotations_and_audio() {
313 let chunk = ChatCompletionChunk::from_str(
314 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"}"#,
315 )
316 .expect("chunk with annotations and audio must deserialize");
317
318 let delta = &chunk.choices[0].delta;
319 let annotations = delta.annotations.as_ref().expect("annotations");
320 assert_eq!(annotations.len(), 1);
321 assert_eq!(annotations[0].url_citation.title, "Azure Docs");
322 assert_eq!(
323 annotations[0].url_citation.url,
324 "https://learn.microsoft.com/azure"
325 );
326
327 let audio = delta.audio.as_ref().expect("audio");
328 assert_eq!(audio.id, "audio_abc");
329 assert_eq!(audio.transcript, "Hello");
330 }
331
332 #[test]
336 fn usage_chunk_with_null_choices_parses() {
337 let parsed = ChatCompletionChunk::from_str(
338 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}}"#,
339 )
340 .expect("usage chunk with null choices must deserialize");
341 assert!(parsed.choices.is_empty());
342 assert_eq!(parsed.usage.expect("usage").total_tokens, 39);
343 }
344
345 #[test]
346 fn usage_chunk_with_missing_choices_parses() {
347 let parsed = ChatCompletionChunk::from_str(
348 r#"{"id":"chatcmpl-1","created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","usage":{"completion_tokens":1,"prompt_tokens":2,"total_tokens":3}}"#,
349 )
350 .expect("usage chunk without a choices key must deserialize");
351 assert!(parsed.choices.is_empty());
352 }
353
354 #[test]
357 fn unknown_finish_reason_is_preserved() {
358 let parsed = ChatCompletionChunk::from_str(
359 r#"{"id":"1","choices":[{"index":0,"delta":{},"finish_reason":"eos"}],"created":1,"model":"m","object":"chat.completion.chunk"}"#,
360 )
361 .expect("chunk with unknown finish_reason must deserialize");
362
363 let finish_reason = parsed.choices[0]
364 .finish_reason
365 .as_ref()
366 .expect("finish_reason");
367 assert_eq!(finish_reason.as_str(), "eos");
368 assert_eq!(finish_reason.to_string(), "eos");
369 assert_eq!(
370 serde_json::to_value(finish_reason).unwrap(),
371 serde_json::json!("eos")
372 );
373 }
374
375 #[test]
376 fn unknown_role_is_preserved() {
377 let parsed = ChatCompletionChunk::from_str(
378 r#"{"id":"1","choices":[{"index":0,"delta":{"role":"model","content":"hi"},"finish_reason":null}],"created":1,"model":"m","object":"chat.completion.chunk"}"#,
379 )
380 .expect("chunk with unknown role must deserialize");
381 let role = parsed.choices[0].delta.role.as_ref().expect("role");
382 assert_eq!(role.as_str(), "model");
383 }
384
385 #[test]
386 fn missing_or_unknown_object_field_parses() {
387 let missing =
388 ChatCompletionChunk::from_str(r#"{"id":"1","choices":[],"created":1,"model":"m"}"#)
389 .expect("chunk without object must deserialize");
390 assert!(missing.object.is_none());
391
392 let weird = ChatCompletionChunk::from_str(
393 r#"{"id":"1","choices":[],"created":1,"model":"m","object":"vendor.custom.chunk"}"#,
394 )
395 .expect("chunk with unknown object must deserialize");
396 assert_eq!(
397 weird.object.expect("object").as_str(),
398 "vendor.custom.chunk"
399 );
400 }
401
402 #[test]
404 fn chunk_round_trips_through_json() {
405 let parsed = ChatCompletionChunk::from_str(
406 r#"{"id":"1","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},"finish_reason":null}],"created":1,"model":"m","object":"chat.completion.chunk"}"#,
407 )
408 .expect("chunk must deserialize");
409
410 let json = serde_json::to_value(&parsed).unwrap();
411 assert_eq!(json["object"], "chat.completion.chunk");
412 assert_eq!(json["choices"][0]["delta"]["content"], "Hi");
413
414 let reparsed = serde_json::from_value::<ChatCompletionChunk>(json).unwrap();
415 assert_eq!(reparsed.id, parsed.id);
416 assert_eq!(
417 reparsed.choices[0].delta.content,
418 parsed.choices[0].delta.content
419 );
420 }
421 }
422}
423
424pub mod no_streaming {
425 pub type ChatCompletion = crate::chat::ChatCompletion;
430}