use serde_json::{Value, json};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopReason {
EndTurn,
ToolUse,
MaxTokens,
}
impl StopReason {
pub fn as_str(self) -> &'static str {
match self {
StopReason::EndTurn => "end_turn",
StopReason::ToolUse => "tool_use",
StopReason::MaxTokens => "max_tokens",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Usage {
pub input: i64,
pub output: i64,
pub cache_read: i64,
pub cache_write: i64,
}
impl Usage {
fn to_json(self) -> Value {
json!({
"input_tokens": self.input,
"output_tokens": self.output,
"cache_creation_input_tokens": self.cache_write,
"cache_read_input_tokens": self.cache_read,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReduceEvent {
ThinkingStart,
ThinkingDelta(String),
ThinkingStop,
TextStart,
TextDelta(String),
TextStop,
ToolStart {
id: String,
name: String,
},
ToolDelta(String),
ToolStop,
Finish {
stop_reason: StopReason,
usage: Usage,
},
Error {
message: String,
},
}
pub struct AnthropicSseEncoder {
message_id: String,
model: String,
started: bool,
next_index: i64,
current_index: i64,
stopped: bool,
}
impl AnthropicSseEncoder {
pub fn new(model: impl Into<String>) -> Self {
Self {
message_id: format!("msg_{}", uuid::Uuid::new_v4().simple()),
model: model.into(),
started: false,
next_index: 0,
current_index: 0,
stopped: false,
}
}
pub fn encode(&mut self, event: &ReduceEvent, out: &mut String) {
if !self.started && !matches!(event, ReduceEvent::Error { .. }) {
self.emit_message_start(out);
}
match event {
ReduceEvent::ThinkingStart => {
let idx = self.open_block(
out,
json!({"type": "thinking", "thinking": "", "signature": ""}),
);
self.current_index = idx;
}
ReduceEvent::ThinkingDelta(text) => {
self.block_delta(out, json!({"type": "thinking_delta", "thinking": text}))
}
ReduceEvent::ThinkingStop => self.close_block(out),
ReduceEvent::TextStart => {
let idx = self.open_block(out, json!({"type": "text", "text": ""}));
self.current_index = idx;
}
ReduceEvent::TextDelta(text) => {
self.block_delta(out, json!({"type": "text_delta", "text": text}))
}
ReduceEvent::TextStop => self.close_block(out),
ReduceEvent::ToolStart { id, name } => {
let idx = self.open_block(
out,
json!({"type": "tool_use", "id": id, "name": name, "input": {}}),
);
self.current_index = idx;
}
ReduceEvent::ToolDelta(partial) => self.block_delta(
out,
json!({"type": "input_json_delta", "partial_json": partial}),
),
ReduceEvent::ToolStop => self.close_block(out),
ReduceEvent::Finish { stop_reason, usage } => {
self.emit_finish(out, *stop_reason, *usage)
}
ReduceEvent::Error { message } => self.emit_error(out, message),
}
}
pub fn finish_if_open(&mut self, out: &mut String) {
if self.started && !self.stopped {
self.emit_finish(out, StopReason::EndTurn, Usage::default());
}
}
fn emit_message_start(&mut self, out: &mut String) {
self.started = true;
let data = json!({
"type": "message_start",
"message": {
"id": self.message_id,
"type": "message",
"role": "assistant",
"model": self.model,
"content": [],
"stop_reason": Value::Null,
"stop_sequence": Value::Null,
"usage": {"input_tokens": 0, "output_tokens": 0},
}
});
frame(out, "message_start", &data);
}
fn open_block(&mut self, out: &mut String, content_block: Value) -> i64 {
let index = self.next_index;
self.next_index += 1;
let data = json!({
"type": "content_block_start",
"index": index,
"content_block": content_block,
});
frame(out, "content_block_start", &data);
index
}
fn block_delta(&self, out: &mut String, delta: Value) {
let data = json!({
"type": "content_block_delta",
"index": self.current_index,
"delta": delta,
});
frame(out, "content_block_delta", &data);
}
fn close_block(&self, out: &mut String) {
let data = json!({"type": "content_block_stop", "index": self.current_index});
frame(out, "content_block_stop", &data);
}
fn emit_finish(&mut self, out: &mut String, stop_reason: StopReason, usage: Usage) {
if self.stopped {
return;
}
self.stopped = true;
let delta = json!({
"type": "message_delta",
"delta": {"stop_reason": stop_reason.as_str(), "stop_sequence": Value::Null},
"usage": usage.to_json(),
});
frame(out, "message_delta", &delta);
frame(out, "message_stop", &json!({"type": "message_stop"}));
}
fn emit_error(&mut self, out: &mut String, message: &str) {
self.stopped = true;
let data = json!({
"type": "error",
"error": {"type": "api_error", "message": message},
});
frame(out, "error", &data);
}
}
fn frame(out: &mut String, event: &str, data: &Value) {
out.push_str("event: ");
out.push_str(event);
out.push('\n');
out.push_str("data: ");
out.push_str(&data.to_string());
out.push_str("\n\n");
}
#[derive(Default)]
pub struct SseLineParser {
buf: String,
}
impl SseLineParser {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, chunk: &[u8]) -> Vec<Value> {
self.buf.push_str(&String::from_utf8_lossy(chunk));
let mut out = Vec::new();
while let Some(nl) = self.buf.find('\n') {
let line: String = self.buf.drain(..=nl).collect();
let line = line.trim_end_matches(['\r', '\n']);
let Some(data) = line.strip_prefix("data:") else {
continue;
};
let data = data.trim();
if data.is_empty() || data == "[DONE]" {
continue;
}
if let Ok(v) = serde_json::from_str::<Value>(data) {
out.push(v);
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn encode_all(model: &str, events: &[ReduceEvent]) -> String {
let mut enc = AnthropicSseEncoder::new(model);
let mut out = String::new();
for e in events {
enc.encode(e, &mut out);
}
enc.finish_if_open(&mut out);
out
}
#[test]
fn text_turn_emits_well_formed_anthropic_sse() {
let out = encode_all(
"gpt-5.5",
&[
ReduceEvent::TextStart,
ReduceEvent::TextDelta("Hello".into()),
ReduceEvent::TextStop,
ReduceEvent::Finish {
stop_reason: StopReason::EndTurn,
usage: Usage {
input: 10,
output: 2,
cache_read: 3,
cache_write: 0,
},
},
],
);
assert!(out.contains("event: message_start"));
assert!(out.contains("\"model\":\"gpt-5.5\""));
assert!(out.contains("event: content_block_start"));
assert!(out.contains("\"text_delta\""));
assert!(out.contains("event: content_block_stop"));
assert!(out.contains("\"stop_reason\":\"end_turn\""));
assert!(out.contains("\"cache_read_input_tokens\":3"));
assert!(out.contains("event: message_stop"));
}
#[test]
fn block_indices_increment_per_block() {
let out = encode_all(
"m",
&[
ReduceEvent::ThinkingStart,
ReduceEvent::ThinkingDelta("t".into()),
ReduceEvent::ThinkingStop,
ReduceEvent::TextStart,
ReduceEvent::TextDelta("x".into()),
ReduceEvent::TextStop,
ReduceEvent::ToolStart {
id: "toolu_1".into(),
name: "Read".into(),
},
ReduceEvent::ToolDelta("{\"path\":".into()),
ReduceEvent::ToolStop,
ReduceEvent::Finish {
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
},
],
);
assert!(out.contains("\"index\":0"));
assert!(out.contains("\"index\":1"));
assert!(out.contains("\"index\":2"));
assert!(out.contains("\"type\":\"tool_use\""));
assert!(out.contains("\"stop_reason\":\"tool_use\""));
}
#[test]
fn finish_if_open_is_idempotent() {
let mut enc = AnthropicSseEncoder::new("m");
let mut out = String::new();
enc.encode(&ReduceEvent::TextStart, &mut out);
enc.encode(&ReduceEvent::TextStop, &mut out);
enc.encode(
&ReduceEvent::Finish {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
&mut out,
);
let len_after_finish = out.len();
enc.finish_if_open(&mut out);
assert_eq!(
out.len(),
len_after_finish,
"finish_if_open must not double-emit"
);
}
#[test]
fn sse_line_parser_reassembles_split_frames() {
let mut p = SseLineParser::new();
assert!(p.push(b"data: {\"a\":").is_empty(), "partial line buffered");
let got = p.push(b"1}\n\ndata: [DONE]\n");
assert_eq!(got.len(), 1);
assert_eq!(got[0]["a"], 1);
}
#[test]
fn sse_line_parser_skips_non_json_and_keepalive() {
let mut p = SseLineParser::new();
let got = p.push(b": keepalive\ndata: \ndata: not json\ndata: {\"ok\":true}\n");
assert_eq!(got.len(), 1);
assert_eq!(got[0]["ok"], true);
}
}