use serde_json::Value;
use crate::schema::openai::{ChatCompletionChunk, ChatToolCall};
use crate::translator::{common, openai};
use crate::{ApiBridgeError, ContentBlock, DecodeState, Result, Role, UniversalEvent};
use super::super::response::response_message_id;
pub(super) fn decode_chunk(raw: Value, state: &mut DecodeState) -> Result<Vec<UniversalEvent>> {
let chunk: ChatCompletionChunk = serde_json::from_value(raw)
.map_err(|error| ApiBridgeError::invalid_response(error.to_string()))?;
let mut events = Vec::new();
common::ensure_response_start(&mut events, state, chunk.id.clone(), chunk.model.clone());
let usage = openai::openai_usage_to_universal(chunk.usage.as_ref());
let has_choices = !chunk.choices.is_empty();
for choice in chunk.choices {
let choice_index = choice.index.unwrap_or(0) as usize;
let message_id = response_message_id(chunk.id.as_deref(), choice.index);
if let Some(delta) = choice.delta {
let role = delta
.role
.as_deref()
.and_then(common::role_from_wire)
.unwrap_or(Role::Assistant);
let content_blocks: Vec<_> = openai::openai_content_to_blocks(delta.content.as_ref())
.into_iter()
.filter(|block| match block {
ContentBlock::Text { text } => !text.is_empty(),
_ => true,
})
.collect();
if delta.role.is_some() || !content_blocks.is_empty() || !delta.tool_calls.is_empty() {
common::ensure_message_start(&mut events, state, message_id.clone(), role);
}
for block in content_blocks {
match block {
ContentBlock::Text { text } => {
let content_index = chat_content_index(state, choice_index, "text");
ensure_chat_content_start(
&mut events,
state,
choice_index,
content_index,
ContentBlock::Text {
text: String::new(),
},
);
events.push(UniversalEvent::TextDelta {
index: content_index,
text,
});
}
block => {
let content_index = next_chat_content_index(state, choice_index);
common::push_block_events(&mut events, content_index, block);
}
}
}
if let Some(reasoning_delta) = delta
.extra
.get("reasoning_content")
.and_then(Value::as_str)
.filter(|content| !content.is_empty())
{
common::ensure_message_start(&mut events, state, message_id.clone(), role);
let content_index = chat_content_index(state, choice_index, "reasoning");
ensure_chat_content_start(
&mut events,
state,
choice_index,
content_index,
ContentBlock::Reasoning {
text: None,
encrypted: None,
extensions: common::empty_extensions(),
},
);
events.push(UniversalEvent::ReasoningDelta {
index: content_index,
text: reasoning_delta.to_string(),
});
}
for (fallback_index, tool_call) in delta.tool_calls.into_iter().enumerate() {
let id = stream_tool_call_id(state, choice_index, fallback_index, &tool_call);
let function = tool_call.function;
events.push(UniversalEvent::ToolCallDelta {
id,
name: function.as_ref().and_then(|function| function.name.clone()),
arguments_delta: function
.and_then(|function| function.arguments)
.unwrap_or_default(),
});
}
}
let finish_reason = choice
.finish_reason
.as_deref()
.map(str::trim)
.filter(|reason| !reason.is_empty());
if finish_reason.is_some() {
events.push(UniversalEvent::MessageDone {
finish_reason: openai::finish_from_openai(finish_reason),
usage: usage.clone(),
extensions: common::empty_extensions(),
});
if common::mark_once(state, "response_done") {
events.push(UniversalEvent::ResponseDone {
usage: usage.clone(),
extensions: common::empty_extensions(),
});
}
}
}
if !has_choices && usage.is_some() && common::mark_once(state, "response_done") {
events.push(UniversalEvent::ResponseDone {
usage,
extensions: common::empty_extensions(),
});
}
Ok(events)
}
fn chat_content_index(state: &mut DecodeState, choice_index: usize, kind: &str) -> usize {
let key = format!("openaiChatContentIndex:{choice_index}:{kind}");
if let Some(index) = state.extensions.get(&key).and_then(Value::as_u64) {
return index as usize;
}
let index = next_chat_content_index(state, choice_index);
state
.extensions
.insert(key, Value::Number((index as u64).into()));
index
}
fn next_chat_content_index(state: &mut DecodeState, choice_index: usize) -> usize {
let key = format!("openaiChatNextContentIndex:{choice_index}");
let index = state
.extensions
.get(&key)
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
state
.extensions
.insert(key, Value::Number(((index + 1) as u64).into()));
index
}
fn ensure_chat_content_start(
events: &mut Vec<UniversalEvent>,
state: &mut DecodeState,
choice_index: usize,
index: usize,
block: ContentBlock,
) {
if common::mark_once(
state,
&format!("openaiChatContentStart:{choice_index}:{index}"),
) {
events.push(UniversalEvent::ContentStart { index, block });
}
}
fn stream_tool_call_id(
state: &mut DecodeState,
choice_index: usize,
fallback_index: usize,
tool_call: &ChatToolCall,
) -> String {
let tool_index = tool_call.index.unwrap_or(fallback_index as u64);
let key = format!("openaiChatToolCallId:{choice_index}:{tool_index}");
if let Some(id) = tool_call.id.as_deref().filter(|id| !id.is_empty()) {
state.extensions.insert(key, Value::String(id.to_string()));
return id.to_string();
}
if let Some(id) = state.extensions.get(&key).and_then(Value::as_str) {
return id.to_string();
}
let fallback_id = format!("tool_call_{choice_index}_{tool_index}");
state
.extensions
.insert(key, Value::String(fallback_id.clone()));
fallback_id
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::{DecodeState, UniversalEvent};
use super::decode_chunk;
#[test]
fn keeps_stream_tool_call_id_across_indexed_deltas() {
let mut state = DecodeState::default();
let first = decode_chunk(
json!({
"id": "chatcmpl_1",
"model": "deepseek-v4-pro",
"choices": [{
"index": 0,
"delta": {
"role": "assistant",
"tool_calls": [{
"index": 0,
"id": "call_123",
"type": "function",
"function": {
"name": "exec_command",
"arguments": ""
}
}]
}
}]
}),
&mut state,
)
.expect("first chunk decodes");
let second = decode_chunk(
json!({
"id": "chatcmpl_1",
"model": "deepseek-v4-pro",
"choices": [{
"index": 0,
"delta": {
"tool_calls": [{
"index": 0,
"function": {
"arguments": "{\"cmd\":\"ls\"}"
}
}]
}
}]
}),
&mut state,
)
.expect("second chunk decodes");
let first_tool_delta = first
.iter()
.find_map(|event| match event {
UniversalEvent::ToolCallDelta {
id,
name,
arguments_delta,
} => Some((id.as_str(), name.as_deref(), arguments_delta.as_str())),
_ => None,
})
.expect("first tool delta");
let second_tool_delta = second
.iter()
.find_map(|event| match event {
UniversalEvent::ToolCallDelta {
id,
name,
arguments_delta,
} => Some((id.as_str(), name.as_deref(), arguments_delta.as_str())),
_ => None,
})
.expect("second tool delta");
assert_eq!(first_tool_delta, ("call_123", Some("exec_command"), ""));
assert_eq!(second_tool_delta, ("call_123", None, "{\"cmd\":\"ls\"}"));
}
#[test]
fn emits_reasoning_content_deltas() {
let mut state = DecodeState::default();
let events = decode_chunk(
json!({
"id": "chatcmpl_1",
"model": "deepseek-v4-pro",
"choices": [{
"index": 0,
"delta": {
"role": "assistant",
"reasoning_content": "Need to inspect files."
}
}]
}),
&mut state,
)
.expect("chunk decodes");
assert!(events.iter().any(|event| matches!(
event,
UniversalEvent::ReasoningDelta { text, .. } if text == "Need to inspect files."
)));
}
#[test]
fn ignores_empty_stream_finish_reason() {
let mut state = DecodeState::default();
let events = decode_chunk(
json!({
"id": "chatcmpl_1",
"model": "deepseek-v4-flash",
"choices": [{
"index": 0,
"delta": {
"role": "assistant",
"reasoning_content": "Thinking"
},
"finish_reason": ""
}]
}),
&mut state,
)
.expect("chunk decodes");
assert!(events.iter().any(|event| matches!(
event,
UniversalEvent::ReasoningDelta { text, .. } if text == "Thinking"
)));
assert!(!events.iter().any(|event| matches!(
event,
UniversalEvent::MessageDone { .. } | UniversalEvent::ResponseDone { .. }
)));
}
#[test]
fn ignores_usage_on_non_final_stream_chunks() {
let mut state = DecodeState::default();
let events = decode_chunk(
json!({
"id": "chatcmpl_1",
"model": "deepseek-v4-flash",
"choices": [{
"index": 0,
"delta": {
"role": "assistant",
"content": "Hi"
},
"finish_reason": null
}],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2
}
}),
&mut state,
)
.expect("chunk decodes");
assert!(events.iter().any(|event| matches!(
event,
UniversalEvent::TextDelta { text, .. } if text == "Hi"
)));
assert!(!events
.iter()
.any(|event| matches!(event, UniversalEvent::ResponseDone { .. })));
}
#[test]
fn emits_response_done_for_usage_only_stream_chunk() {
let mut state = DecodeState::default();
let events = decode_chunk(
json!({
"id": "chatcmpl_1",
"model": "deepseek-v4-flash",
"choices": [],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2
}
}),
&mut state,
)
.expect("chunk decodes");
assert!(events.iter().any(|event| matches!(
event,
UniversalEvent::ResponseDone { usage: Some(usage), .. }
if usage.total_tokens == Some(2)
)));
}
#[test]
fn keeps_reasoning_and_text_on_distinct_content_indexes() {
let mut state = DecodeState::default();
let reasoning = decode_chunk(
json!({
"id": "chatcmpl_1",
"model": "deepseek-v4-pro",
"choices": [{
"index": 0,
"delta": {
"role": "assistant",
"reasoning_content": "Think first."
}
}]
}),
&mut state,
)
.expect("reasoning chunk decodes");
let text = decode_chunk(
json!({
"id": "chatcmpl_1",
"model": "deepseek-v4-pro",
"choices": [{
"index": 0,
"delta": {
"content": "Then answer."
}
}]
}),
&mut state,
)
.expect("text chunk decodes");
assert!(reasoning.iter().any(|event| matches!(
event,
UniversalEvent::ReasoningDelta { index: 0, text } if text == "Think first."
)));
assert!(text.iter().any(|event| matches!(
event,
UniversalEvent::TextDelta { index: 1, text } if text == "Then answer."
)));
}
}