use std::{collections::VecDeque, str::FromStr};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::{
request::{FinishReason, ToolCall, ToolCallFunction, ToolCallFunctionObj},
response::{
Annotation, ChatCompletionAudio, Choice as ChatCompletionChoice, Logprobs, Message,
Response as ChatCompletionResponse, Role, ServiceTier, Usage, WithReasoningContentField,
},
};
#[derive(Debug, Error)]
pub enum ParserError {
#[error("Failed to parse JSON: {0}")]
JsonError(#[from] serde_json::Error),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Chunk {
Done,
Data(ChunkResponse),
}
impl FromStr for Chunk {
type Err = serde_json::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"[DONE]" => Ok(Chunk::Done),
_ => {
let response = serde_json::from_str::<ChunkResponse>(s)?;
Ok(Chunk::Data(response))
}
}
}
}
impl Chunk {
pub fn try_to_string(&self) -> Result<String, serde_json::Error> {
match self {
Chunk::Done => Ok("[DONE]".to_string()),
Chunk::Data(response) => serde_json::to_string(response),
}
}
/// Like [`Chunk::try_to_string`], but renames `reasoning_content` values on serialize (see
/// [`WithReasoningContentField`]). `[DONE]` is unchanged.
///
/// # Examples
///
/// ```ignore
/// serde_json::to_string(&chunk_response.with_reasoning_content_field("reasoning_content"))?;
/// chunk.try_to_string_with_reasoning_content_field("thinking")?;
/// ```
pub fn try_to_string_with_reasoning_content_field(
&self,
field: &str,
) -> Result<String, serde_json::Error> {
match self {
Chunk::Done => Ok("[DONE]".to_string()),
Chunk::Data(response) => {
serde_json::to_string(&response.with_reasoning_content_field(field))
}
}
}
/// Creates a [DONE] chunk
pub fn done() -> Self {
Chunk::Done
}
/// Creates a starter chunk with role: assistant
pub fn starter(id: String, model: String) -> Self {
Chunk::Data(ChunkResponse {
id,
model,
object: "chat.completion.chunk".to_string(),
created: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
choices: vec![Choice {
index: 0,
delta: DeltaMessage {
role: Some(Role::Assistant),
content: Some(String::new()),
..Default::default()
},
..Default::default()
}],
..Default::default()
})
}
/// Creates a chunk with string content
pub fn with_content(id: String, model: String, content: String) -> Self {
Chunk::Data(ChunkResponse {
id,
model,
object: "chat.completion.chunk".to_string(),
created: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
choices: vec![Choice {
index: 0,
delta: DeltaMessage {
content: Some(content),
..Default::default()
},
..Default::default()
}],
..Default::default()
})
}
/// Creates a builder for constructing chunks
pub fn builder(id: String, model: String) -> ChunkBuilder {
ChunkBuilder::new(id, model)
}
}
/// Builder for constructing chunks with custom fields
#[derive(Debug, Default, Clone)]
pub struct ChunkBuilder {
id: String,
model: String,
content: Option<String>,
reasoning: Option<String>,
role: Option<Role>,
finish_reason: Option<FinishReason>,
usage: Option<Usage>,
created: Option<u64>,
system_fingerprint: Option<String>,
tool_calls: Option<Vec<ToolCallChunk>>,
}
impl ChunkBuilder {
/// Creates a new chunk builder with required id and model
pub fn new(id: String, model: String) -> Self {
Self {
id,
model,
..Default::default()
}
}
/// Sets the content
pub fn content(mut self, content: String) -> Self {
self.content = Some(content);
self
}
/// Sets the reasoning content
pub fn reasoning(mut self, reasoning: String) -> Self {
self.reasoning = Some(reasoning);
self
}
/// Sets the role
pub fn role(mut self, role: Role) -> Self {
self.role = Some(role);
self
}
/// Sets the finish reason
pub fn finish_reason(mut self, reason: FinishReason) -> Self {
self.finish_reason = Some(reason);
self
}
/// Sets the usage
pub fn usage(mut self, usage: Usage) -> Self {
self.usage = Some(usage);
self
}
/// Sets the created timestamp
pub fn created(mut self, created: u64) -> Self {
self.created = Some(created);
self
}
/// Sets the system fingerprint
pub fn system_fingerprint(mut self, fingerprint: String) -> Self {
self.system_fingerprint = Some(fingerprint);
self
}
/// Sets the tool calls
pub fn tool_calls(mut self, tool_calls: Vec<ToolCallChunk>) -> Self {
self.tool_calls = Some(tool_calls);
self
}
/// Builds the chunk
pub fn build(self) -> Chunk {
let created = self.created.unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
});
Chunk::Data(ChunkResponse {
id: self.id,
model: self.model,
object: "chat.completion.chunk".to_string(),
created,
system_fingerprint: self.system_fingerprint,
service_tier: None,
usage: self.usage,
choices: vec![Choice {
index: 0,
delta: DeltaMessage {
content: self.content,
reasoning: self.reasoning,
role: self.role,
tool_calls: self.tool_calls,
..Default::default()
},
finish_reason: self.finish_reason,
..Default::default()
}],
})
}
}
#[derive(Debug, Default, Deserialize, Clone, PartialEq, Serialize)]
pub struct ChunkResponse {
/// A unique identifier for the completion.
pub id: String,
pub choices: Vec<Choice>,
/// The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same timestamp.
pub created: u64,
/// The model used for completion.
pub model: String,
/// This fingerprint represents the backend configuration that the model runs with.
///
/// Can be used in conjunction with the `seed` request parameter to understand when backend changes have been
/// made that might impact determinism.
#[serde(skip_serializing_if = "Option::is_none")]
pub system_fingerprint: Option<String>,
/// The object type, which is always "chat.completion.chunk"
pub object: String,
/// The service tier used for processing the request.
/// This field is only included if the service_tier parameter is specified in the request.
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
/// Usage statistics for the completion request.
/// Note that usage statistics are only included in the final chunk.
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
}
impl ChunkResponse {
/// Serialize this chunk with a custom JSON key for `reasoning_content` values under
/// `choices[].delta`. See [`WithReasoningContentField`].
///
/// # Examples
///
/// ```ignore
/// serde_json::to_string(&chunk_response.with_reasoning_content_field("reasoning_content"))?;
/// ```
pub fn with_reasoning_content_field<'a>(
&'a self,
field: &'a str,
) -> WithReasoningContentField<'a, Self> {
WithReasoningContentField::new(self, field)
}
}
#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
pub struct Choice {
pub index: usize,
pub delta: DeltaMessage,
/// The reason the model stopped generating tokens. This will be `stop` if the model hit a natural stop point or a provided stop sequence,
/// `length` if the maximum number of tokens specified in the request was reached,
/// `content_filter` if content was omitted due to a flag from our content filters,
/// `tool_calls` if the model called a tool, o\ `function_call` (deprecated) if the model called a function.
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<FinishReason>,
/// Log probability information for the choice.
#[serde(skip_serializing_if = "Option::is_none")]
pub logprobs: Option<Logprobs>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone, PartialEq)]
pub struct DeltaMessage {
/// The contents of the message.
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// Open router compatible field
/// https://openrouter.ai/announcements/reasoning-tokens-for-thinking-models
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "reasoning_content",
alias = "thinking"
)]
pub reasoning: Option<String>,
/// The tool calls generated by the model, such as function calls.
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCallChunk>>,
/// The role of the author of this message.
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<Role>,
/// The refusal message generated by the model.
#[serde(skip_serializing_if = "Option::is_none")]
pub refusal: Option<String>,
/// Annotations for the message, when applicable, as when using the web search tool.
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Vec<Annotation>>,
/// If the audio output modality is requested, this object contains data about the audio response from the model.
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<ChatCompletionAudio>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone, PartialEq)]
pub struct ToolCallChunk {
pub index: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub r#type: Option<String>,
pub function: ToolCallFunctionObjChunk,
}
#[derive(Debug, Deserialize, Default, Serialize, Clone, PartialEq)]
pub struct ToolCallFunctionObjChunk {
/// The name of the function to call.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// The arguments to call the function with, as generated by the model in JSON format. Note that the model does not always generate valid JSON, and may hallucinate parameters not defined by your function schema. Validate the arguments in your code before calling your function.
pub arguments: String,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct ChunkParser {
pub id: String,
tool_call_chunk: Option<ToolCallChunk>,
pub object: String,
pub created: u64,
pub model: String,
system_fingerprint: Option<String>,
tool_calls: VecDeque<ToolCall>,
pub content: String,
refusal: Option<String>,
annotations: Option<Vec<Annotation>>,
audio: Option<ChatCompletionAudio>,
logprobs: Option<Logprobs>,
finish_reason: Option<FinishReason>,
pub think_content: String,
usage: Option<Usage>,
}
impl ChunkParser {
pub fn parse(
&mut self,
data: &str,
) -> Result<(Option<ChatCompletionResponse>, Option<ToolCall>), ParserError> {
let chunk = Chunk::from_str(data)?;
match chunk {
Chunk::Data(response) => {
self.update_basic_info(&response);
let tool_call = if let Some(choice) = response.choices.first() {
self.logprobs.clone_from(&choice.logprobs);
if let Some(reason) = choice.finish_reason {
self.finish_reason = Some(reason);
}
//simple content
if let Some(c) = choice.delta.content.as_ref() {
self.content.push_str(c);
}
if let Some(c) = choice.delta.reasoning.as_ref() {
self.think_content.push_str(c);
}
if let Some(refusal) = choice.delta.refusal.as_ref() {
self.refusal = Some(refusal.clone());
}
if let Some(annotations) = choice.delta.annotations.as_ref() {
self.annotations = Some(annotations.clone());
}
if let Some(audio) = choice.delta.audio.as_ref() {
self.audio = Some(audio.clone());
}
if let Some(tool_calls) = choice.delta.tool_calls.as_ref() {
self.parse_tool_call_chunk(tool_calls.first())
} else {
self.parse_tool_call_chunk(None)
}
} else {
None
};
Ok((None, tool_call))
}
Chunk::Done => {
let response = self.clone().response();
Ok((Some(response), None))
}
}
}
pub fn response(self) -> ChatCompletionResponse {
let mut m = Message {
role: Role::Assistant,
..Default::default()
};
if !self.think_content.is_empty() {
m.reasoning = Some(self.think_content);
}
if !self.content.is_empty() {
m.content = Some(self.content);
}
if let Some(refusal) = self.refusal {
m.refusal = Some(refusal);
}
if let Some(annotations) = self.annotations {
m.annotations = Some(annotations);
}
if let Some(audio) = self.audio {
m.audio = Some(audio);
}
if !self.tool_calls.is_empty() {
m.tool_calls = Some(self.tool_calls.into());
}
ChatCompletionResponse {
id: self.id,
object: self.object,
created: self.created,
model: self.model,
system_fingerprint: self.system_fingerprint.clone(),
service_tier: None,
choices: vec![ChatCompletionChoice {
index: 0,
message: m,
finish_reason: self.finish_reason,
logprobs: self.logprobs,
}],
usage: self.usage.unwrap_or_default(),
}
}
pub fn update_id_if_empty(&mut self, id: &str) {
if !self.id.is_empty() {
return;
}
self.id = id.to_string();
}
pub fn update_model_if_empty(&mut self, model: &str) {
if !self.model.is_empty() {
return;
}
self.model = model.to_string();
}
pub fn set_system_fingerprint(&mut self, system_fingerprint: Option<String>) {
self.system_fingerprint = system_fingerprint;
}
pub fn set_finish_reason(&mut self, finish_reason: Option<FinishReason>) {
self.finish_reason = finish_reason;
}
pub fn push_content(&mut self, content: &str) {
self.content.push_str(content);
}
pub fn push_thinking(&mut self, content: &str) {
self.think_content.push_str(content);
}
pub fn push_tool_call(&mut self, tool_call: ToolCall) {
self.tool_calls.push_back(tool_call);
}
}
impl ChunkParser {
fn update_basic_info(&mut self, response: &ChunkResponse) {
if self.id.is_empty() {
self.id = response.id.to_string();
}
self.object = response.object.to_string();
self.created = response.created;
self.model = response.model.to_string();
self.system_fingerprint = response.system_fingerprint.clone();
if response.usage.is_some() {
self.usage = response.usage.clone();
}
}
fn parse_tool_call_chunk(
&mut self,
tool_call_chunk: Option<&ToolCallChunk>,
) -> Option<ToolCall> {
match (self.tool_call_chunk.take(), tool_call_chunk) {
(Some(mut prev_tool_call), Some(new_tool_call)) => {
// If we see a new tool call with different id
// return the prev tool call and start fresh with the new one
if new_tool_call.id.is_some() && prev_tool_call.id != new_tool_call.id {
let tool_call = ToolCall::Function(ToolCallFunction {
id: prev_tool_call.id.unwrap_or_default(),
function: ToolCallFunctionObj {
name: prev_tool_call.function.name.unwrap_or_default(),
arguments: prev_tool_call.function.arguments.clone(),
},
});
self.tool_calls.push_back(tool_call.clone());
// Start fresh with the new tool call exactly as received
self.tool_call_chunk = Some(new_tool_call.clone());
return Some(tool_call);
}
// Continue building the same tool call by appending arguments
prev_tool_call
.function
.arguments
.push_str(new_tool_call.function.arguments.as_str());
self.tool_call_chunk = Some(prev_tool_call);
None
}
(None, Some(tool)) => {
//set the new tool call
self.tool_call_chunk = Some(tool.clone());
None
}
(Some(prev_tool_call), None) => {
let tool_call = ToolCall::Function(ToolCallFunction {
id: prev_tool_call.id.unwrap_or_default(),
function: ToolCallFunctionObj {
name: prev_tool_call.function.name.unwrap_or_default(),
arguments: prev_tool_call.function.arguments.clone(),
},
});
self.tool_calls.push_back(tool_call.clone());
Some(tool_call)
}
(None, None) => None,
}
}
}
#[cfg(test)]
mod tests {
use crate::completions::response::{CompletionTokensDetails, PromptTokensDetails};
use super::*;
#[test]
fn serde() {
let tests = vec![
(
"start",
r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo-0613", "system_fingerprint": "fp_44709d6fcb", "choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}]}"#,
ChunkResponse {
id: "chatcmpl-123".to_string(),
object: "chat.completion.chunk".to_string(),
created: 1694268190,
model: "gpt-3.5-turbo-0613".to_string(),
system_fingerprint: Some("fp_44709d6fcb".to_string()),
choices: vec![Choice {
index: 0,
delta: DeltaMessage {
role: Some(Role::Assistant),
content: Some("".to_string()),
..Default::default()
},
..Default::default()
}],
service_tier: None,
usage: None,
},
),
(
"data",
r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo-0613", "system_fingerprint": "fp_44709d6fcb", "choices":[{"index":0,"delta":{"content":"!"},"logprobs":null,"finish_reason":null}]}"#,
ChunkResponse {
id: "chatcmpl-123".to_string(),
object: "chat.completion.chunk".to_string(),
created: 1694268190,
model: "gpt-3.5-turbo-0613".to_string(),
system_fingerprint: Some("fp_44709d6fcb".to_string()),
service_tier: None,
choices: vec![Choice {
index: 0,
delta: DeltaMessage {
content: Some("!".to_string()),
..Default::default()
},
..Default::default()
}],
usage: None,
},
),
(
"end",
r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo-0613", "system_fingerprint": "fp_44709d6fcb", "choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}]}"#,
ChunkResponse {
id: "chatcmpl-123".to_string(),
object: "chat.completion.chunk".to_string(),
created: 1694268190,
model: "gpt-3.5-turbo-0613".to_string(),
system_fingerprint: Some("fp_44709d6fcb".to_string()),
service_tier: None,
choices: vec![Choice {
index: 0,
delta: DeltaMessage {
..Default::default()
},
finish_reason: Some(FinishReason::Stop),
..Default::default()
}],
usage: None,
},
),
(
"function_call",
r#"{"id":"chatcmpl-8v4PobBwtSalCtjghlORb2l72yfPM","object":"chat.completion.chunk","created":1708612360,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_cbdb91ce3f","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_UjeNA45J26mfwbeEXi3AfNL1","type":"function","function":{"name":"get_current_weather","arguments":""}}]},"logprobs":null,"finish_reason":null}]}"#,
ChunkResponse {
id: "chatcmpl-8v4PobBwtSalCtjghlORb2l72yfPM".to_string(),
object: "chat.completion.chunk".to_string(),
created: 1708612360,
model: "gpt-3.5-turbo-0125".to_string(),
system_fingerprint: Some("fp_cbdb91ce3f".to_string()),
service_tier: None,
choices: vec![Choice {
index: 0,
delta: DeltaMessage {
tool_calls: Some(vec![ToolCallChunk {
index: 0,
id: Some("call_UjeNA45J26mfwbeEXi3AfNL1".to_string()),
r#type: Some("function".to_string()),
function: ToolCallFunctionObjChunk {
name: Some("get_current_weather".to_string()),
arguments: "".to_string(),
},
}]),
..Default::default()
},
..Default::default()
}],
usage: None,
},
),
];
for (name, json, expected) in tests {
//test deserialize
let actual: ChunkResponse = serde_json::from_str(json).unwrap();
assert_eq!(actual, expected, "deserialize test failed: {}", name);
//test serialize
let serialized = serde_json::to_string(&expected).unwrap();
let actual: ChunkResponse = serde_json::from_str(&serialized).unwrap();
assert_eq!(actual, expected, "serialize test failed: {}", name);
//test enum
let got: Chunk = json.parse().unwrap();
let want = Chunk::Data(expected);
assert_eq!(got, want, "enum test failed: {}", name)
}
}
#[test]
fn test_done() {
let input = "[DONE]";
let want = Chunk::Done;
let got: Chunk = input.parse().unwrap();
assert_eq!(want, got, "test [DONE]");
}
#[test]
fn test_parser_for_tool_call() {
let test_cases = vec![
(
"start",
r#"{"nonce": "3cc0e9", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"role":"assistant","content":null},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data1",
r#"{"nonce": "3cc0e9", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_UjeNA45J26mfwbeEXi3AfNL1","type":"function","function":{"name":"get_current_weather","arguments":""}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data2",
r#"{"nonce": "3cc0e9", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data3",
r#"{"nonce": "3cc0e9", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"locatio"}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data4",
r#"{"nonce": "3cc0e9", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"n\": \"N"}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data5",
r#"{"nonce": "3cc0e9", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ew Y"}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data6",
r#"{"nonce": "3cc0e9", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ork\","}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data7",
r#"{"nonce": "3cc0e9", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" \"unit"}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data8",
r#"{"nonce": "3cc0e9", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\": \""}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data9",
r#"{"nonce": "3cc0e9", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"celsi"}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data10",
r#"{"nonce": "3cc0e9", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"us\"}"}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data11",
r#"{"nonce": "1684c1", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_7e8O5F7pyvxpqYLPiiIL2FMH","type":"function","function":{"name":"get_current_weather","arguments":""}}]},"logprobs":null,"finish_reason":null}]}"#,
Some(ToolCall::Function(ToolCallFunction {
id: "call_UjeNA45J26mfwbeEXi3AfNL1".to_string(),
function: ToolCallFunctionObj {
name: "get_current_weather".to_string(),
arguments: "{\"location\": \"New York\", \"unit\": \"celsius\"}"
.to_string(),
},
})),
),
(
"data12",
r#"{"nonce": "1684c1", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\"lo"}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data13",
r#"{"nonce": "1684c1", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"catio"}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data14",
r#"{"nonce": "1684c1", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"n\": \"T"}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data15",
r#"{"nonce": "1684c1", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"okyo"}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data16",
r#"{"nonce": "1684c1", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\", \"u"}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data17",
r#"{"nonce": "1684c1", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"nit\": "}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data18",
r#"{"nonce": "1684c1", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\"cel"}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data19",
r#"{"nonce": "1684c1", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"sius\""}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data20",
r#"{"nonce": "1684c1", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"}"}}]},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data21",
r#"{"nonce": "dd25883214", "id":"chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU","object":"chat.completion.chunk","created":1710744883,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}]}"#,
Some(ToolCall::Function(ToolCallFunction {
id: "call_7e8O5F7pyvxpqYLPiiIL2FMH".to_string(),
function: ToolCallFunctionObj {
name: "get_current_weather".to_string(),
arguments: "{\"location\": \"Tokyo\", \"unit\": \"celsius\"}".to_string(),
},
})),
),
("Done", "[DONE]", None),
];
let mut parser = ChunkParser::default();
let mut final_response = None;
for (name, data, want) in test_cases {
let (response, tool_call) = parser
.parse(data)
.map_err(|e| {
panic!("test_parser failed: {} with err: {}", name, e);
})
.unwrap();
if let Some(resp) = response {
final_response = Some(resp);
}
assert_eq!(tool_call, want, "test_parser failed: {}", name);
}
let res = final_response.expect("Expected final response from [DONE]");
assert_eq!(
res,
ChatCompletionResponse {
id: "chatcmpl-941BLfWSKMsoPyCbL3UpYjvQG3oTU".to_string(),
object: "chat.completion.chunk".to_string(),
created: 1710744883,
model: "gpt-3.5-turbo-0125".to_string(),
system_fingerprint: Some("fp_4f2ebda25a".to_string()),
service_tier: None,
choices: vec![ChatCompletionChoice {
index: 0,
message: Message {
role: Role::Assistant,
content: None,
tool_calls: Some(vec![
ToolCall::Function(ToolCallFunction {
id: "call_UjeNA45J26mfwbeEXi3AfNL1".to_string(),
function: ToolCallFunctionObj {
name: "get_current_weather".to_string(),
arguments:
"{\"location\": \"New York\", \"unit\": \"celsius\"}"
.to_string(),
}
}),
ToolCall::Function(ToolCallFunction {
id: "call_7e8O5F7pyvxpqYLPiiIL2FMH".to_string(),
function: ToolCallFunctionObj {
name: "get_current_weather".to_string(),
arguments: "{\"location\": \"Tokyo\", \"unit\": \"celsius\"}"
.to_string(),
}
})
]),
refusal: None,
annotations: None,
audio: None,
reasoning: None,
},
finish_reason: Some(FinishReason::ToolCalls),
logprobs: None,
},],
usage: Usage::default(),
}
)
}
#[test]
//openrouter gemini response
fn test_last_chunk_with_content_and_finish_reason() {
let test_cases = vec![
(
"start",
r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo-0613", "system_fingerprint": "fp_44709d6fcb", "choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo-0613", "system_fingerprint": "fp_44709d6fcb", "choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"last_chunk_with_content_and_finish",
r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo-0613", "system_fingerprint": "fp_44709d6fcb", "choices":[{"index":0,"delta":{"content":" world!"},"logprobs":null,"finish_reason":"stop"}]}"#,
None,
),
("Done", "[DONE]", None),
];
let mut parser = ChunkParser::default();
let mut final_response = None;
for (name, data, want) in test_cases {
let (response, tool_call) = parser
.parse(data)
.map_err(|e| {
panic!("test_parser failed: {} with err: {}", name, e);
})
.unwrap();
if let Some(resp) = response {
final_response = Some(resp);
}
assert_eq!(tool_call, want, "test_parser failed: {}", name);
}
let res = final_response.expect("Expected final response from [DONE]");
assert_eq!(
res,
ChatCompletionResponse {
id: "chatcmpl-123".to_string(),
object: "chat.completion.chunk".to_string(),
created: 1694268190,
model: "gpt-3.5-turbo-0613".to_string(),
system_fingerprint: Some("fp_44709d6fcb".to_string()),
service_tier: None,
choices: vec![ChatCompletionChoice {
index: 0,
message: Message {
role: Role::Assistant,
content: Some("Hello world!".to_string()),
tool_calls: None,
refusal: None,
annotations: None,
audio: None,
reasoning: None,
},
finish_reason: Some(FinishReason::Stop),
logprobs: None,
},],
usage: Usage::default(),
}
);
}
#[test]
fn test_parser_for_content() {
let test_cases = vec![
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":"!"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" I"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":"'m"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" just"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" a"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" computer"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" program"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" so"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" I"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" don"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":"'t"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" have"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" feelings"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" but"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" I"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":"'m"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" ready"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" to"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" assist"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" with"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" anything"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" need"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":"."},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" How"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" can"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" I"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" help"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":" today"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{"content":"?"},"logprobs":null,"finish_reason":null}]}"#,
None,
),
(
"data",
r#"{"id":"chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L","object":"chat.completion.chunk","created":1710814154,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_4f2ebda25a","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}]}"#,
None,
),
("data", r#"[DONE]"#, None),
];
let mut parser = ChunkParser::default();
let mut final_response = None;
for (name, data, want) in test_cases {
let (response, tool_call) = parser
.parse(data)
.map_err(|e| {
panic!("test_parser failed: {} with err: {}", name, e);
})
.unwrap();
if let Some(resp) = response {
final_response = Some(resp);
}
assert_eq!(tool_call, want, "test_parser failed: {}", name);
}
let res = final_response.expect("Expected final response from [DONE]");
let want_res = ChatCompletionResponse {
id: "chatcmpl-94JCcQJ9TY5hHx1el8uXAzojc511L".to_string(),
object: "chat.completion.chunk".to_string(),
created: 1710814154,
model: "gpt-3.5-turbo-0125".to_string(),
system_fingerprint: Some("fp_4f2ebda25a".to_string()),
service_tier: None,
choices: vec![ChatCompletionChoice {
index: 0,
message: Message {
role: Role::Assistant,
content: Some("Hello! I'm just a computer program, so I don't have feelings, but I'm ready to assist you with anything you need. How can I help you today?".to_string()),
tool_calls: None,
refusal: None,
annotations: None,
audio: None,
reasoning: None,
},
finish_reason: Some(FinishReason::Stop),
logprobs: None,
}],
usage: Usage::default(),
};
assert_eq!(res, want_res, "get_response failed")
}
#[test]
fn test_parser_for_openrouter_response_with_usage() {
// Test case based on OpenRouter response with extra fields (provider, native_finish_reason)
// and usage information in a later chunk
let test_cases = vec![
(
"start",
r#"{"id":"gen-1766041629-pbTHRQZM89qKcWTyTsEM","provider":"OpenAI","model":"openai/gpt-4.1","object":"chat.completion.chunk","created":1766041629,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}"#,
None,
),
(
"content_1",
r#"{"id":"gen-1766041629-pbTHRQZM89qKcWTyTsEM","provider":"OpenAI","model":"openai/gpt-4.1","object":"chat.completion.chunk","created":1766041629,"choices":[{"index":0,"delta":{"role":"assistant","content":"很"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}"#,
None,
),
(
"content_2",
r#"{"id":"gen-1766041629-pbTHRQZM89qKcWTyTsEM","provider":"OpenAI","model":"openai/gpt-4.1","object":"chat.completion.chunk","created":1766041629,"choices":[{"index":0,"delta":{"role":"assistant","content":"抱"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}"#,
None,
),
(
"content_3",
r#"{"id":"gen-1766041629-pbTHRQZM89qKcWTyTsEM","provider":"OpenAI","model":"openai/gpt-4.1","object":"chat.completion.chunk","created":1766041629,"choices":[{"index":0,"delta":{"role":"assistant","content":"歉"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}"#,
None,
),
(
"content_4",
r#"{"id":"gen-1766041629-pbTHRQZM89qKcWTyTsEM","provider":"OpenAI","model":"openai/gpt-4.1","object":"chat.completion.chunk","created":1766041629,"choices":[{"index":0,"delta":{"role":"assistant","content":",我"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}"#,
None,
),
(
"content_5",
r#"{"id":"gen-1766041629-pbTHRQZM89qKcWTyTsEM","provider":"OpenAI","model":"openai/gpt-4.1","object":"chat.completion.chunk","created":1766041629,"choices":[{"index":0,"delta":{"role":"assistant","content":"无法"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}"#,
None,
),
(
"content_6",
r#"{"id":"gen-1766041629-pbTHRQZM89qKcWTyTsEM","provider":"OpenAI","model":"openai/gpt-4.1","object":"chat.completion.chunk","created":1766041629,"choices":[{"index":0,"delta":{"role":"assistant","content":"满足"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}"#,
None,
),
(
"content_7",
r#"{"id":"gen-1766041629-pbTHRQZM89qKcWTyTsEM","provider":"OpenAI","model":"openai/gpt-4.1","object":"chat.completion.chunk","created":1766041629,"choices":[{"index":0,"delta":{"role":"assistant","content":"该"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}"#,
None,
),
(
"content_8",
r#"{"id":"gen-1766041629-pbTHRQZM89qKcWTyTsEM","provider":"OpenAI","model":"openai/gpt-4.1","object":"chat.completion.chunk","created":1766041629,"choices":[{"index":0,"delta":{"role":"assistant","content":"请求"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}"#,
None,
),
(
"content_9",
r#"{"id":"gen-1766041629-pbTHRQZM89qKcWTyTsEM","provider":"OpenAI","model":"openai/gpt-4.1","object":"chat.completion.chunk","created":1766041629,"choices":[{"index":0,"delta":{"role":"assistant","content":"。"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}"#,
None,
),
(
"finish_with_stop",
r#"{"id":"gen-1766041629-pbTHRQZM89qKcWTyTsEM","provider":"OpenAI","model":"openai/gpt-4.1","object":"chat.completion.chunk","created":1766041629,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"completed","logprobs":null}]}"#,
None,
),
(
"usage_chunk",
r#"{"id":"gen-1766041629-pbTHRQZM89qKcWTyTsEM","provider":"OpenAI","model":"openai/gpt-4.1","object":"chat.completion.chunk","created":1766041629,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":148,"completion_tokens":10,"total_tokens":158,"cost":0.000376,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0,"video_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.000296,"upstream_inference_completions_cost":0.00008},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}}"#,
None,
),
("done", "[DONE]", None),
];
let mut parser = ChunkParser::default();
let mut final_response = None;
for (name, data, want) in test_cases {
let (response, tool_call) = parser
.parse(data)
.map_err(|e| {
panic!(
"test_parser_for_openrouter_response_with_usage failed: {} with err: {}",
name, e
);
})
.unwrap();
if let Some(resp) = response {
final_response = Some(resp);
}
assert_eq!(
tool_call, want,
"test_parser_for_openrouter_response_with_usage failed: {}",
name
);
}
let res = final_response.expect("Expected final response from [DONE]");
assert_eq!(
res,
ChatCompletionResponse {
id: "gen-1766041629-pbTHRQZM89qKcWTyTsEM".to_string(),
object: "chat.completion.chunk".to_string(),
created: 1766041629,
model: "openai/gpt-4.1".to_string(),
system_fingerprint: None,
service_tier: None,
choices: vec![ChatCompletionChoice {
index: 0,
message: Message {
role: Role::Assistant,
content: Some("很抱歉,我无法满足该请求。".to_string()),
tool_calls: None,
refusal: None,
annotations: None,
audio: None,
reasoning: None,
},
finish_reason: Some(FinishReason::Stop),
logprobs: None,
}],
usage: Usage {
prompt_tokens: 148,
completion_tokens: 10,
total_tokens: 158,
completion_tokens_details: Some(CompletionTokensDetails {
reasoning_tokens: Some(0),
..Default::default()
}),
prompt_tokens_details: Some(PromptTokensDetails {
cached_tokens: Some(0),
audio_tokens: Some(0),
}),
},
}
);
}
#[test]
fn try_to_string_with_reasoning_content_field_renames_delta_reasoning() {
let chunk = Chunk::Data(ChunkResponse {
id: "id".into(),
model: "m".into(),
object: "chat.completion.chunk".into(),
created: 0,
choices: vec![Choice {
index: 0,
delta: DeltaMessage {
reasoning: Some("r".into()),
..Default::default()
},
finish_reason: None,
logprobs: None,
}],
..Default::default()
});
let s = chunk
.try_to_string_with_reasoning_content_field("thinking")
.unwrap();
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["choices"][0]["delta"]["thinking"], "r");
assert!(v["choices"][0]["delta"].get("reasoning").is_none());
let cr = match &chunk {
Chunk::Data(r) => r,
Chunk::Done => panic!("expected data chunk"),
};
let sw =
serde_json::to_string(&cr.with_reasoning_content_field("reasoning_content")).unwrap();
let v2: serde_json::Value = serde_json::from_str(&sw).unwrap();
assert_eq!(v2["choices"][0]["delta"]["reasoning_content"], "r");
assert_eq!(
Chunk::Done
.try_to_string_with_reasoning_content_field("thinking")
.unwrap(),
"[DONE]"
);
}
}