use std::collections::HashSet;
use std::io::{self, Write};
use serde_json::Value;
use super::ExtensionStreamSimpleProvider;
use crate::error::{Error, Result};
use crate::model::{AssistantMessageEvent, ContentBlock, StopReason, StreamEvent};
mod blocks;
const MAX_EVENTS: usize = 262_144;
const MAX_EVENT_BYTES: usize = 32 * 1024 * 1024;
const MAX_EVENT_NODES: usize = 262_144;
const MAX_EVENT_DEPTH: usize = 128;
const MAX_DELTA_BYTES: usize = 16 * 1024 * 1024;
const MAX_BLOCKS: usize = 1024;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode {
Undetermined,
Text,
Events,
Finished,
}
pub(super) struct Decoder {
model: String,
provider: String,
api: String,
mode: Mode,
text: String,
events: usize,
delta_bytes: usize,
blocks: blocks::Ledger,
}
fn protocol(message: &'static str) -> Error {
Error::extension(format!("PI_EXTENSION_STREAM_PROTOCOL: {message}"))
}
fn limit() -> Error {
Error::extension(
"PI_EXTENSION_STREAM_LIMIT: extension provider stream exceeds its event, text, or message-shape limit",
)
}
struct ByteBudget(usize);
impl Write for ByteBudget {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.0 = self.0.checked_sub(bytes.len()).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "extension event byte limit")
})?;
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn admit_shape(value: &Value, depth: usize, remaining: &mut usize) -> Result<()> {
if depth == 0 || *remaining == 0 {
return Err(limit());
}
*remaining -= 1;
match value {
Value::Array(items) => {
for item in items {
admit_shape(item, depth - 1, remaining)?;
}
}
Value::Object(items) => {
for item in items.values() {
admit_shape(item, depth - 1, remaining)?;
}
}
_ => {}
}
Ok(())
}
impl Decoder {
pub(super) const fn new(model: String, provider: String, api: String) -> Self {
Self {
model,
provider,
api,
mode: Mode::Undetermined,
text: String::new(),
events: 0,
delta_bytes: 0,
blocks: blocks::Ledger::new(),
}
}
pub(super) fn finished(&self) -> bool {
self.mode == Mode::Finished
}
pub(super) fn push(&mut self, value: Value) -> Result<Vec<StreamEvent>> {
let result = self.push_inner(value);
if result.is_err() {
self.mode = Mode::Finished;
self.text.clear();
}
result
}
fn charge_delta(&mut self, bytes: usize) -> Result<()> {
self.delta_bytes = self.delta_bytes.checked_add(bytes).ok_or_else(limit)?;
if self.delta_bytes > MAX_DELTA_BYTES {
return Err(limit());
}
Ok(())
}
fn push_inner(&mut self, value: Value) -> Result<Vec<StreamEvent>> {
if self.finished() {
return Err(protocol("event received after stream termination"));
}
self.events = self.events.checked_add(1).ok_or_else(limit)?;
if self.events > MAX_EVENTS {
return Err(limit());
}
if let Value::String(chunk) = value {
if self.mode == Mode::Events {
return Err(protocol(
"structured events and raw text chunks cannot be mixed",
));
}
self.charge_delta(chunk.len())?;
let first = self.mode == Mode::Undetermined;
self.mode = Mode::Text;
self.text.push_str(&chunk);
let mut events = Vec::with_capacity(if first { 3 } else { 1 });
if first {
events.push(StreamEvent::Start {
partial: ExtensionStreamSimpleProvider::make_partial(
&self.model,
&self.provider,
&self.api,
"",
),
});
events.push(StreamEvent::TextStart { content_index: 0 });
}
events.push(StreamEvent::TextDelta {
content_index: 0,
delta: chunk,
});
return Ok(events);
}
if self.mode == Mode::Text {
return Err(protocol(
"raw text chunks and structured events cannot be mixed",
));
}
if !value.is_object() {
return Err(protocol(
"expected a text chunk or structured assistant event",
));
}
let mut remaining = MAX_EVENT_NODES;
admit_shape(&value, MAX_EVENT_DEPTH, &mut remaining)?;
serde_json::to_writer(&mut ByteBudget(MAX_EVENT_BYTES), &value).map_err(|_| limit())?;
let event: AssistantMessageEvent = serde_json::from_value(value)
.map_err(|_| protocol("invalid structured assistant event"))?;
if matches!(event, AssistantMessageEvent::Start { .. }) && self.events != 1 {
return Err(protocol("start must be the first structured event"));
}
self.mode = Mode::Events;
validate_event(&event)?;
self.blocks.admit(&event)?;
match &event {
AssistantMessageEvent::TextDelta { delta, .. }
| AssistantMessageEvent::ThinkingDelta { delta, .. }
| AssistantMessageEvent::ToolCallDelta { delta, .. } => {
self.charge_delta(delta.len())?;
}
_ => {}
}
let terminal = matches!(
event,
AssistantMessageEvent::Done { .. } | AssistantMessageEvent::Error { .. }
);
let output = ExtensionStreamSimpleProvider::assistant_event_to_stream_event(event);
if terminal {
self.mode = Mode::Finished;
}
Ok(vec![output])
}
pub(super) fn finish(&mut self) -> Result<Vec<StreamEvent>> {
let mode = std::mem::replace(&mut self.mode, Mode::Finished);
if mode == Mode::Finished {
return Ok(Vec::new());
}
if mode != Mode::Text {
return Err(Error::extension(
"PI_EXTENSION_STREAM_INCOMPLETE: structured or empty provider stream ended without a terminal event; partial output was not completed",
));
}
let text = std::mem::take(&mut self.text);
let message = ExtensionStreamSimpleProvider::make_partial(
&self.model,
&self.provider,
&self.api,
&text,
);
Ok(vec![
StreamEvent::TextEnd {
content_index: 0,
content: text,
},
StreamEvent::Done {
reason: StopReason::Stop,
message,
},
])
}
}
#[allow(clippy::too_many_lines)] fn validate_event(event: &AssistantMessageEvent) -> Result<()> {
let (message, indexed) = match event {
AssistantMessageEvent::Start { partial } => (partial, None),
AssistantMessageEvent::TextStart {
content_index,
partial,
}
| AssistantMessageEvent::TextDelta {
content_index,
partial,
..
}
| AssistantMessageEvent::TextEnd {
content_index,
partial,
..
} => (partial, Some((*content_index, "text"))),
AssistantMessageEvent::ThinkingStart {
content_index,
partial,
}
| AssistantMessageEvent::ThinkingDelta {
content_index,
partial,
..
}
| AssistantMessageEvent::ThinkingEnd {
content_index,
partial,
..
} => (partial, Some((*content_index, "thinking"))),
AssistantMessageEvent::ToolCallStart {
content_index,
partial,
}
| AssistantMessageEvent::ToolCallDelta {
content_index,
partial,
..
}
| AssistantMessageEvent::ToolCallEnd {
content_index,
partial,
..
} => (partial, Some((*content_index, "toolCall"))),
AssistantMessageEvent::Done { reason, message } => {
if *reason != message.stop_reason
|| matches!(reason, StopReason::Error | StopReason::Aborted)
|| message.error_message.is_some()
{
return Err(protocol(
"done event carries a failed or inconsistent terminal message",
));
}
let mut ids = HashSet::new();
for block in &message.content {
if let ContentBlock::ToolCall(call) = block {
if !matches!(reason, StopReason::ToolUse | StopReason::PauseTurn) {
return Err(protocol(
"tool calls require an explicit tool-use or paused-turn terminal reason",
));
}
if call.id.trim().is_empty()
|| call.name.trim().is_empty()
|| !call.arguments.is_object()
|| !ids.insert(call.id.as_str())
{
return Err(protocol(
"terminal tool calls need unique nonempty IDs, names and object arguments",
));
}
}
}
if *reason == StopReason::ToolUse && ids.is_empty() {
return Err(protocol("tool-use terminal message contains no tool calls"));
}
(message, None)
}
AssistantMessageEvent::Error { reason, error } => {
if *reason != error.stop_reason
|| !matches!(reason, StopReason::Error | StopReason::Aborted)
{
return Err(protocol(
"error event carries an inconsistent terminal reason",
));
}
(error, None)
}
};
if message.content.len() > MAX_BLOCKS {
return Err(limit());
}
if let Some((index, kind)) = indexed {
let valid = matches!(
(kind, message.content.get(index)),
("text", Some(ContentBlock::Text(_)))
| ("thinking", Some(ContentBlock::Thinking(_)))
| ("toolCall", Some(ContentBlock::ToolCall(_)))
);
if !valid {
return Err(protocol(
"content index does not identify the event's block type",
));
}
}
Ok(())
}
#[cfg(test)]
mod tests;