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 #[cfg(feature = "zai")]
83 pub request_id: Option<String>,
84 }
85
86 crate::wire_string_enum! {
87 pub enum ChatCompletionChunkObject {
89 ChatCompletionChunk => "chat.completion.chunk",
90 }
91 }
92
93 #[derive(Debug, Deserialize, Serialize, Clone)]
94 pub struct CompletionChunkChoice {
95 pub delta: ChoiceDelta,
97 pub index: u32,
99 pub logprobs: Option<ChoiceLogprobs>,
101 pub finish_reason: Option<FinishReason>,
109
110 #[cfg(feature = "vllm")]
114 pub stop_reason: Option<crate::vllm::StopReason>,
115 #[cfg(feature = "vllm")]
118 pub token_ids: Option<Vec<u32>>,
119 }
120
121 pub use crate::chat::FinishReason;
122
123 #[derive(Debug, Deserialize, Serialize, Clone)]
124 pub struct ChoiceDelta {
125 pub content: Option<String>,
127 #[cfg(feature = "reasoning")]
131 pub reasoning_content: Option<String>,
132 #[cfg(feature = "vllm")]
141 pub reasoning: Option<String>,
142 pub function_call: Option<ChoiceDeltaFunctionCall>,
147 pub refusal: Option<String>,
149 pub role: Option<CompletionRole>,
151 pub tool_calls: Option<Vec<ChoiceDeltaToolCall>>,
153 pub annotations: Option<Vec<crate::chat::Annotation>>,
160 pub audio: Option<crate::chat::ChatCompletionAudio>,
165 }
166
167 #[derive(Debug, Deserialize, Serialize, Clone)]
168 pub struct ChoiceDeltaToolCallFunction {
169 pub arguments: Option<String>,
174 pub name: Option<String>,
176 }
177
178 #[derive(Debug, Deserialize, Serialize, Clone)]
179 pub struct ChoiceDeltaFunctionCall {
180 pub arguments: Option<String>,
185 pub name: Option<String>,
187 }
188
189 #[derive(Debug, Deserialize, Serialize, Clone)]
190 pub struct ChoiceDeltaToolCall {
191 pub index: u32,
193 pub id: Option<String>,
195 pub function: Option<ChoiceDeltaToolCallFunction>,
197 #[serde(rename = "type")]
199 pub type_: Option<ChoiceDeltaToolCallType>,
200 }
201
202 crate::wire_string_enum! {
203 pub enum ChoiceDeltaToolCallType {
205 Function => "function",
207 Custom => "custom",
209 }
210 }
211
212 pub use crate::chat::Role as CompletionRole;
213
214 #[derive(Debug, Deserialize, Serialize, Clone)]
216 pub struct ChoiceLogprobs {
217 pub content: Option<Vec<LogprobeContent>>,
219 #[cfg(feature = "reasoning")]
222 pub reasoning_content: Option<Vec<LogprobeContent>>,
223 pub refusal: Option<Vec<LogprobeContent>>,
225 }
226
227 #[derive(Debug, Deserialize, Serialize, Clone)]
229 pub struct LogprobeContent {
230 pub token: String,
231 pub logprob: f32,
232 pub bytes: Option<Vec<u8>>,
233 pub top_logprobs: Vec<TopLogprob>,
234 }
235
236 #[derive(Debug, Deserialize, Serialize, Clone)]
239 pub struct TopLogprob {
240 pub token: String,
241 pub logprob: f32,
242 pub bytes: Option<Vec<u8>>,
243 }
244
245 pub use crate::chat::{CompletionTokensDetails, CompletionUsage, PromptTokensDetails};
246
247 crate::impl_from_str!(ChatCompletionChunk);
248
249 #[cfg(test)]
250 mod test {
251 use std::str::FromStr;
252
253 use super::*;
254
255 #[test]
256 fn streaming_example_deepseek() {
257 let streams = vec![
258 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}"#,
259 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"}"#,
260 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"}"#,
261 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"}"#,
262 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"}"#,
263 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"}"#,
264 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"}"#,
265 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"}"#,
266 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"}"#,
267 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"}"#,
268 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}}"#,
269 ];
270
271 for stream in streams {
272 let parsed = ChatCompletionChunk::from_str(stream);
273 match parsed {
274 Ok(completion) => {
275 println!("Deserialized: {:#?}", completion);
276 }
277 Err(e) => {
278 panic!("Failed to deserialize {}: {}", stream, e);
279 }
280 }
281 }
282 }
283
284 #[test]
285 fn streaming_example_qwen() {
286 let streams = vec![
287 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}"#,
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":null,"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":[{"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}"#,
292 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}"#,
293 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}"#,
294 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}"#,
295 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}"#,
296 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}"#,
297 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}}}"#,
298 ];
299
300 for stream in streams {
301 let parsed = ChatCompletionChunk::from_str(stream);
302 match parsed {
303 Ok(completion) => {
304 println!("Deserialized: {:#?}", completion);
305 }
306 Err(e) => {
307 panic!("Failed to deserialize {}: {}", stream, e);
308 }
309 }
310 }
311 }
312
313 #[test]
318 fn streaming_example_azure_annotations_and_audio() {
319 let chunk = ChatCompletionChunk::from_str(
320 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"}"#,
321 )
322 .expect("chunk with annotations and audio must deserialize");
323
324 let delta = &chunk.choices[0].delta;
325 let annotations = delta.annotations.as_ref().expect("annotations");
326 assert_eq!(annotations.len(), 1);
327 assert_eq!(annotations[0].url_citation.title, "Azure Docs");
328 assert_eq!(
329 annotations[0].url_citation.url,
330 "https://learn.microsoft.com/azure"
331 );
332
333 let audio = delta.audio.as_ref().expect("audio");
334 assert_eq!(audio.id, "audio_abc");
335 assert_eq!(audio.transcript, "Hello");
336 }
337
338 #[test]
342 fn usage_chunk_with_null_choices_parses() {
343 let parsed = ChatCompletionChunk::from_str(
344 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}}"#,
345 )
346 .expect("usage chunk with null choices must deserialize");
347 assert!(parsed.choices.is_empty());
348 assert_eq!(parsed.usage.expect("usage").total_tokens, 39);
349 }
350
351 #[test]
352 fn usage_chunk_with_missing_choices_parses() {
353 let parsed = ChatCompletionChunk::from_str(
354 r#"{"id":"chatcmpl-1","created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","usage":{"completion_tokens":1,"prompt_tokens":2,"total_tokens":3}}"#,
355 )
356 .expect("usage chunk without a choices key must deserialize");
357 assert!(parsed.choices.is_empty());
358 }
359
360 #[test]
363 fn unknown_finish_reason_is_preserved() {
364 let parsed = ChatCompletionChunk::from_str(
365 r#"{"id":"1","choices":[{"index":0,"delta":{},"finish_reason":"eos"}],"created":1,"model":"m","object":"chat.completion.chunk"}"#,
366 )
367 .expect("chunk with unknown finish_reason must deserialize");
368
369 let finish_reason = parsed.choices[0]
370 .finish_reason
371 .as_ref()
372 .expect("finish_reason");
373 assert_eq!(finish_reason.as_str(), "eos");
374 assert_eq!(finish_reason.to_string(), "eos");
375 assert_eq!(
376 serde_json::to_value(finish_reason).unwrap(),
377 serde_json::json!("eos")
378 );
379 }
380
381 #[test]
382 fn unknown_role_is_preserved() {
383 let parsed = ChatCompletionChunk::from_str(
384 r#"{"id":"1","choices":[{"index":0,"delta":{"role":"model","content":"hi"},"finish_reason":null}],"created":1,"model":"m","object":"chat.completion.chunk"}"#,
385 )
386 .expect("chunk with unknown role must deserialize");
387 let role = parsed.choices[0].delta.role.as_ref().expect("role");
388 assert_eq!(role.as_str(), "model");
389 }
390
391 #[test]
392 fn missing_or_unknown_object_field_parses() {
393 let missing =
394 ChatCompletionChunk::from_str(r#"{"id":"1","choices":[],"created":1,"model":"m"}"#)
395 .expect("chunk without object must deserialize");
396 assert!(missing.object.is_none());
397
398 let weird = ChatCompletionChunk::from_str(
399 r#"{"id":"1","choices":[],"created":1,"model":"m","object":"vendor.custom.chunk"}"#,
400 )
401 .expect("chunk with unknown object must deserialize");
402 assert_eq!(
403 weird.object.expect("object").as_str(),
404 "vendor.custom.chunk"
405 );
406 }
407
408 #[test]
410 fn chunk_round_trips_through_json() {
411 let parsed = ChatCompletionChunk::from_str(
412 r#"{"id":"1","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},"finish_reason":null}],"created":1,"model":"m","object":"chat.completion.chunk"}"#,
413 )
414 .expect("chunk must deserialize");
415
416 let json = serde_json::to_value(&parsed).unwrap();
417 assert_eq!(json["object"], "chat.completion.chunk");
418 assert_eq!(json["choices"][0]["delta"]["content"], "Hi");
419
420 let reparsed = serde_json::from_value::<ChatCompletionChunk>(json).unwrap();
421 assert_eq!(reparsed.id, parsed.id);
422 assert_eq!(
423 reparsed.choices[0].delta.content,
424 parsed.choices[0].delta.content
425 );
426 }
427 }
428}
429
430pub mod no_streaming {
431 pub type ChatCompletion = crate::chat::ChatCompletion;
436}