pub mod streaming {
use serde::{Deserialize, Serialize};
use crate::chat::ServiceTier;
fn null_to_empty_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ChatCompletionChunk {
pub id: String,
#[serde(default, deserialize_with = "null_to_empty_vec")]
pub choices: Vec<CompletionChunkChoice>,
pub created: u64,
pub model: String,
pub object: Option<ChatCompletionChunkObject>,
pub service_tier: Option<ServiceTier>,
pub system_fingerprint: Option<String>,
pub usage: Option<CompletionUsage>,
pub moderation: Option<crate::chat::ChatModeration>,
#[cfg(feature = "vllm")]
pub prompt_token_ids: Option<Vec<u32>>,
#[cfg(feature = "vllm")]
pub prompt_text: Option<String>,
}
crate::wire_string_enum! {
pub enum ChatCompletionChunkObject {
ChatCompletionChunk => "chat.completion.chunk",
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct CompletionChunkChoice {
pub delta: ChoiceDelta,
pub index: u32,
pub logprobs: Option<ChoiceLogprobs>,
pub finish_reason: Option<FinishReason>,
#[cfg(feature = "vllm")]
pub stop_reason: Option<crate::vllm::StopReason>,
#[cfg(feature = "vllm")]
pub token_ids: Option<Vec<u32>>,
}
pub use crate::chat::FinishReason;
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ChoiceDelta {
pub content: Option<String>,
#[cfg(feature = "reasoning")]
pub reasoning_content: Option<String>,
#[cfg(feature = "vllm")]
pub reasoning: Option<String>,
pub function_call: Option<ChoiceDeltaFunctionCall>,
pub refusal: Option<String>,
pub role: Option<CompletionRole>,
pub tool_calls: Option<Vec<ChoiceDeltaToolCall>>,
pub annotations: Option<Vec<crate::chat::Annotation>>,
pub audio: Option<crate::chat::ChatCompletionAudio>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ChoiceDeltaToolCallFunction {
pub arguments: Option<String>,
pub name: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ChoiceDeltaFunctionCall {
pub arguments: Option<String>,
pub name: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ChoiceDeltaToolCall {
pub index: u32,
pub id: Option<String>,
pub function: Option<ChoiceDeltaToolCallFunction>,
#[serde(rename = "type")]
pub type_: Option<ChoiceDeltaToolCallType>,
}
crate::wire_string_enum! {
pub enum ChoiceDeltaToolCallType {
Function => "function",
Custom => "custom",
}
}
pub use crate::chat::Role as CompletionRole;
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ChoiceLogprobs {
pub content: Option<Vec<LogprobeContent>>,
#[cfg(feature = "reasoning")]
pub reasoning_content: Option<Vec<LogprobeContent>>,
pub refusal: Option<Vec<LogprobeContent>>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LogprobeContent {
pub token: String,
pub logprob: f32,
pub bytes: Option<Vec<u8>>,
pub top_logprobs: Vec<TopLogprob>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct TopLogprob {
pub token: String,
pub logprob: f32,
pub bytes: Option<Vec<u8>>,
}
pub use crate::chat::{CompletionTokensDetails, CompletionUsage, PromptTokensDetails};
crate::impl_from_str!(ChatCompletionChunk);
#[cfg(test)]
mod test {
use std::str::FromStr;
use super::*;
#[test]
fn streaming_example_deepseek() {
let streams = vec![
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}"#,
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"}"#,
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"}"#,
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"}"#,
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"}"#,
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"}"#,
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"}"#,
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"}"#,
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"}"#,
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"}"#,
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}}"#,
];
for stream in streams {
let parsed = ChatCompletionChunk::from_str(stream);
match parsed {
Ok(completion) => {
println!("Deserialized: {:#?}", completion);
}
Err(e) => {
panic!("Failed to deserialize {}: {}", stream, e);
}
}
}
}
#[test]
fn streaming_example_qwen() {
let streams = vec![
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}"#,
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}"#,
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}"#,
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}"#,
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}"#,
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}"#,
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}"#,
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}"#,
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}"#,
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}"#,
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}}}"#,
];
for stream in streams {
let parsed = ChatCompletionChunk::from_str(stream);
match parsed {
Ok(completion) => {
println!("Deserialized: {:#?}", completion);
}
Err(e) => {
panic!("Failed to deserialize {}: {}", stream, e);
}
}
}
}
#[test]
fn streaming_example_azure_annotations_and_audio() {
let chunk = ChatCompletionChunk::from_str(
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"}"#,
)
.expect("chunk with annotations and audio must deserialize");
let delta = &chunk.choices[0].delta;
let annotations = delta.annotations.as_ref().expect("annotations");
assert_eq!(annotations.len(), 1);
assert_eq!(annotations[0].url_citation.title, "Azure Docs");
assert_eq!(
annotations[0].url_citation.url,
"https://learn.microsoft.com/azure"
);
let audio = delta.audio.as_ref().expect("audio");
assert_eq!(audio.id, "audio_abc");
assert_eq!(audio.transcript, "Hello");
}
#[test]
fn usage_chunk_with_null_choices_parses() {
let parsed = ChatCompletionChunk::from_str(
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}}"#,
)
.expect("usage chunk with null choices must deserialize");
assert!(parsed.choices.is_empty());
assert_eq!(parsed.usage.expect("usage").total_tokens, 39);
}
#[test]
fn usage_chunk_with_missing_choices_parses() {
let parsed = ChatCompletionChunk::from_str(
r#"{"id":"chatcmpl-1","created":1735113344,"model":"qwen-plus","object":"chat.completion.chunk","usage":{"completion_tokens":1,"prompt_tokens":2,"total_tokens":3}}"#,
)
.expect("usage chunk without a choices key must deserialize");
assert!(parsed.choices.is_empty());
}
#[test]
fn unknown_finish_reason_is_preserved() {
let parsed = ChatCompletionChunk::from_str(
r#"{"id":"1","choices":[{"index":0,"delta":{},"finish_reason":"eos"}],"created":1,"model":"m","object":"chat.completion.chunk"}"#,
)
.expect("chunk with unknown finish_reason must deserialize");
let finish_reason = parsed.choices[0]
.finish_reason
.as_ref()
.expect("finish_reason");
assert_eq!(finish_reason.as_str(), "eos");
assert_eq!(finish_reason.to_string(), "eos");
assert_eq!(
serde_json::to_value(finish_reason).unwrap(),
serde_json::json!("eos")
);
}
#[test]
fn unknown_role_is_preserved() {
let parsed = ChatCompletionChunk::from_str(
r#"{"id":"1","choices":[{"index":0,"delta":{"role":"model","content":"hi"},"finish_reason":null}],"created":1,"model":"m","object":"chat.completion.chunk"}"#,
)
.expect("chunk with unknown role must deserialize");
let role = parsed.choices[0].delta.role.as_ref().expect("role");
assert_eq!(role.as_str(), "model");
}
#[test]
fn missing_or_unknown_object_field_parses() {
let missing =
ChatCompletionChunk::from_str(r#"{"id":"1","choices":[],"created":1,"model":"m"}"#)
.expect("chunk without object must deserialize");
assert!(missing.object.is_none());
let weird = ChatCompletionChunk::from_str(
r#"{"id":"1","choices":[],"created":1,"model":"m","object":"vendor.custom.chunk"}"#,
)
.expect("chunk with unknown object must deserialize");
assert_eq!(
weird.object.expect("object").as_str(),
"vendor.custom.chunk"
);
}
#[test]
fn chunk_round_trips_through_json() {
let parsed = ChatCompletionChunk::from_str(
r#"{"id":"1","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},"finish_reason":null}],"created":1,"model":"m","object":"chat.completion.chunk"}"#,
)
.expect("chunk must deserialize");
let json = serde_json::to_value(&parsed).unwrap();
assert_eq!(json["object"], "chat.completion.chunk");
assert_eq!(json["choices"][0]["delta"]["content"], "Hi");
let reparsed = serde_json::from_value::<ChatCompletionChunk>(json).unwrap();
assert_eq!(reparsed.id, parsed.id);
assert_eq!(
reparsed.choices[0].delta.content,
parsed.choices[0].delta.content
);
}
}
}
pub mod no_streaming {
pub type ChatCompletion = crate::chat::ChatCompletion;
}