use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::content::{AssistantContent, TextContent, ThinkingContent, ToolCall};
use crate::json::{JsonObject, JsonValue, js_f64_opt};
use crate::message::{AssistantMessage, Message};
use crate::primitives::{CacheRetention, StopReason, ThinkingLevel};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DoneReason {
Stop,
Length,
ToolUse,
Deferred,
}
impl From<DoneReason> for StopReason {
fn from(reason: DoneReason) -> Self {
match reason {
DoneReason::Stop => Self::Stop,
DoneReason::Length => Self::Length,
DoneReason::ToolUse => Self::ToolUse,
DoneReason::Deferred => Self::Deferred,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ErrorReason {
Aborted,
Error,
}
impl From<ErrorReason> for StopReason {
fn from(reason: ErrorReason) -> Self {
match reason {
ErrorReason::Aborted => Self::Aborted,
ErrorReason::Error => Self::Error,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AssistantMessageEvent {
Start {
partial: AssistantMessage,
},
#[serde(rename_all = "camelCase")]
TextStart {
content_index: usize,
partial: AssistantMessage,
},
#[serde(rename_all = "camelCase")]
TextDelta {
content_index: usize,
delta: String,
partial: AssistantMessage,
},
#[serde(rename_all = "camelCase")]
TextEnd {
content_index: usize,
content: String,
partial: AssistantMessage,
},
#[serde(rename_all = "camelCase")]
ThinkingStart {
content_index: usize,
partial: AssistantMessage,
},
#[serde(rename_all = "camelCase")]
ThinkingDelta {
content_index: usize,
delta: String,
partial: AssistantMessage,
},
#[serde(rename_all = "camelCase")]
ThinkingEnd {
content_index: usize,
content: String,
partial: AssistantMessage,
},
#[serde(rename = "toolcall_start", rename_all = "camelCase")]
ToolcallStart {
content_index: usize,
partial: AssistantMessage,
},
#[serde(rename = "toolcall_delta", rename_all = "camelCase")]
ToolcallDelta {
content_index: usize,
delta: String,
partial: AssistantMessage,
},
#[serde(rename = "toolcall_end", rename_all = "camelCase")]
ToolcallEnd {
content_index: usize,
tool_call: ToolCall,
partial: AssistantMessage,
},
Done {
reason: DoneReason,
message: AssistantMessage,
},
Error {
reason: ErrorReason,
error: AssistantMessage,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ProviderEvent {
Start {},
#[serde(rename_all = "camelCase")]
TextStart {
content_index: usize,
},
#[serde(rename_all = "camelCase")]
TextDelta {
content_index: usize,
delta: String,
},
#[serde(rename_all = "camelCase")]
TextEnd {
content_index: usize,
content: String,
},
#[serde(rename_all = "camelCase")]
ThinkingStart {
content_index: usize,
},
#[serde(rename_all = "camelCase")]
ThinkingDelta {
content_index: usize,
delta: String,
},
#[serde(rename_all = "camelCase")]
ThinkingEnd {
content_index: usize,
content: String,
},
#[serde(rename = "toolcall_start", rename_all = "camelCase")]
ToolcallStart {
content_index: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_name: Option<String>,
},
#[serde(rename = "toolcall_delta", rename_all = "camelCase")]
ToolcallDelta {
content_index: usize,
delta: String,
},
#[serde(rename = "toolcall_end", rename_all = "camelCase")]
ToolcallEnd {
content_index: usize,
tool_call: ToolCall,
},
Done {
reason: DoneReason,
message: AssistantMessage,
},
Error {
reason: ErrorReason,
error: AssistantMessage,
},
}
#[derive(Clone, Debug)]
pub struct PartialAssembler {
partial: AssistantMessage,
tool_args: IndexMap<usize, String>,
}
impl PartialAssembler {
#[must_use]
pub fn new(mut template: AssistantMessage) -> Self {
template.content.clear();
template.stop_reason = StopReason::Pending;
Self {
partial: template,
tool_args: IndexMap::new(),
}
}
#[must_use]
pub const fn partial(&self) -> &AssistantMessage {
&self.partial
}
#[allow(
clippy::too_many_lines,
reason = "one arm per provider event keeps the 1:1 mapping readable"
)]
pub fn apply(&mut self, event: ProviderEvent) -> AssistantMessageEvent {
match event {
ProviderEvent::Start {} => AssistantMessageEvent::Start {
partial: self.partial.clone(),
},
ProviderEvent::TextStart { content_index } => {
self.put(
content_index,
AssistantContent::Text(TextContent::default()),
);
AssistantMessageEvent::TextStart {
content_index,
partial: self.partial.clone(),
}
}
ProviderEvent::TextDelta {
content_index,
delta,
} => {
if let Some(AssistantContent::Text(block)) = self.slot(content_index) {
block.text.push_str(&delta);
}
AssistantMessageEvent::TextDelta {
content_index,
delta,
partial: self.partial.clone(),
}
}
ProviderEvent::TextEnd {
content_index,
content,
} => {
if let Some(AssistantContent::Text(block)) = self.slot(content_index) {
block.text.clone_from(&content);
}
AssistantMessageEvent::TextEnd {
content_index,
content,
partial: self.partial.clone(),
}
}
ProviderEvent::ThinkingStart { content_index } => {
self.put(
content_index,
AssistantContent::Thinking(ThinkingContent::default()),
);
AssistantMessageEvent::ThinkingStart {
content_index,
partial: self.partial.clone(),
}
}
ProviderEvent::ThinkingDelta {
content_index,
delta,
} => {
if let Some(AssistantContent::Thinking(block)) = self.slot(content_index) {
block.thinking.push_str(&delta);
}
AssistantMessageEvent::ThinkingDelta {
content_index,
delta,
partial: self.partial.clone(),
}
}
ProviderEvent::ThinkingEnd {
content_index,
content,
} => {
if let Some(AssistantContent::Thinking(block)) = self.slot(content_index) {
block.thinking.clone_from(&content);
}
AssistantMessageEvent::ThinkingEnd {
content_index,
content,
partial: self.partial.clone(),
}
}
ProviderEvent::ToolcallStart {
content_index,
id,
tool_name,
} => {
self.put(
content_index,
AssistantContent::ToolCall(ToolCall {
id: id.unwrap_or_default(),
name: tool_name.unwrap_or_default(),
..ToolCall::default()
}),
);
self.tool_args.insert(content_index, String::new());
AssistantMessageEvent::ToolcallStart {
content_index,
partial: self.partial.clone(),
}
}
ProviderEvent::ToolcallDelta {
content_index,
delta,
} => {
let buffer = self.tool_args.entry(content_index).or_default();
buffer.push_str(&delta);
let parsed = serde_json::from_str::<JsonObject>(buffer).ok();
if let (Some(arguments), Some(AssistantContent::ToolCall(call))) =
(parsed, self.slot(content_index))
{
call.arguments = arguments;
}
AssistantMessageEvent::ToolcallDelta {
content_index,
delta,
partial: self.partial.clone(),
}
}
ProviderEvent::ToolcallEnd {
content_index,
tool_call,
} => {
self.tool_args.shift_remove(&content_index);
self.put(content_index, AssistantContent::ToolCall(tool_call.clone()));
AssistantMessageEvent::ToolcallEnd {
content_index,
tool_call,
partial: self.partial.clone(),
}
}
ProviderEvent::Done { reason, message } => {
self.partial = message.clone();
AssistantMessageEvent::Done { reason, message }
}
ProviderEvent::Error { reason, error } => {
self.partial = error.clone();
AssistantMessageEvent::Error { reason, error }
}
}
}
fn put(&mut self, index: usize, block: AssistantContent) {
let content = &mut self.partial.content;
if index < content.len() {
content[index] = block;
} else {
while content.len() < index {
content.push(AssistantContent::Text(TextContent::default()));
}
content.push(block);
}
}
fn slot(&mut self, index: usize) -> Option<&mut AssistantContent> {
self.partial.content.get_mut(index)
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Context {
pub messages: Vec<Message>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Transport {
Sse,
Websocket,
WebsocketCached,
Auto,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThinkingBudgets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub minimal: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub low: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub medium: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub high: Option<u64>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<ThinkingLevel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "js_f64_opt")]
pub temperature: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sampling_params: Option<JsonObject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_retention: Option<CacheRetention>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transport: Option<Transport>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking_budgets: Option<ThinkingBudgets>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<JsonValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_retries: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_retry_delay_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<JsonObject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers: Option<IndexMap<String, Option<String>>>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn template() -> AssistantMessage {
AssistantMessage::empty("faux", "faux", "faux-1", 0)
}
#[test]
fn assembler_rebuilds_partials() {
let mut assembler = PartialAssembler::new(template());
let events: Vec<ProviderEvent> = serde_json::from_value(json!([
{"type":"start"},
{"type":"thinking_start","contentIndex":0},
{"type":"thinking_delta","contentIndex":0,"delta":"pl"},
{"type":"thinking_end","contentIndex":0,"content":"plan"},
{"type":"text_start","contentIndex":1},
{"type":"text_delta","contentIndex":1,"delta":"Hel"},
{"type":"text_delta","contentIndex":1,"delta":"lo"},
{"type":"toolcall_start","contentIndex":2,"id":"call_1","toolName":"read"},
{"type":"toolcall_delta","contentIndex":2,"delta":"{\"path\":"},
{"type":"toolcall_delta","contentIndex":2,"delta":"\"a\"}"},
]))
.unwrap_or_default();
assert_eq!(events.len(), 10);
let mut last = None;
for event in events {
last = Some(assembler.apply(event));
}
let partial = assembler.partial();
assert_eq!(partial.content.len(), 3);
assert_eq!(
serde_json::to_value(&partial.content).ok(),
Some(
json!([{"type":"thinking","thinking":"plan"},{"type":"text","text":"Hello"},
{"type":"toolCall","id":"call_1","name":"read","arguments":{"path":"a"}}])
)
);
let wire = last
.and_then(|e| serde_json::to_value(e).ok())
.unwrap_or_default();
assert_eq!(wire["type"], json!("toolcall_delta"));
assert_eq!(wire["contentIndex"], json!(2));
assert_eq!(wire["partial"]["stopReason"], json!("pending"));
}
#[test]
fn stream_options_wire_names() {
let options = StreamOptions {
reasoning: Some(ThinkingLevel::High),
transport: Some(Transport::WebsocketCached),
temperature: Some(1.0),
..StreamOptions::default()
};
assert_eq!(
serde_json::to_value(options).ok(),
Some(json!({"reasoning":"high","temperature":1,"transport":"websocket-cached"}))
);
}
}