use std::convert::Infallible;
use axum::response::sse::{Event, KeepAlive, Sse};
use futures::stream::Stream;
use tokio::sync::mpsc;
use super::schema::{
ChatCompletionChunk, ChoiceLogprobs, ChunkChoice, ChunkDelta, CompletionTokensDetails,
PromptTokensDetails, UsageStats,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeltaKind {
Content,
Reasoning,
}
#[derive(Debug, Clone, Default)]
pub struct StreamStats {
pub prefill_time_secs: Option<f64>,
pub decode_time_secs: Option<f64>,
pub total_time_secs: Option<f64>,
pub time_to_first_token_ms: Option<f64>,
pub prefill_tokens_per_sec: Option<f64>,
pub decode_tokens_per_sec: Option<f64>,
pub gpu_sync_count: Option<u64>,
pub gpu_dispatch_count: Option<u64>,
pub cached_prompt_tokens: Option<usize>,
pub reasoning_tokens: Option<usize>,
}
#[derive(Debug)]
pub enum GenerationEvent {
Delta { kind: DeltaKind, text: String },
ToolCallDelta {
index: usize,
id: Option<String>,
call_type: Option<String>,
name: Option<String>,
arguments: Option<String>,
},
Logprobs(ChoiceLogprobs),
Done {
finish_reason: &'static str,
prompt_tokens: usize,
completion_tokens: usize,
stats: StreamStats,
},
Error(String),
}
#[derive(Debug, Clone, Default)]
pub struct SseStreamOptions {
pub include_usage: bool,
pub logprobs: bool,
pub system_fingerprint: Option<String>,
}
pub fn generation_events_stream(
mut rx: mpsc::Receiver<GenerationEvent>,
request_id: String,
model_name: String,
created: i64,
opts: SseStreamOptions,
) -> impl Stream<Item = Result<Event, Infallible>> {
async_stream::stream! {
let sfp = opts.system_fingerprint.clone();
let include_usage = opts.include_usage;
let role_chunk = ChatCompletionChunk {
id: request_id.clone(),
object: "chat.completion.chunk",
created,
model: model_name.clone(),
system_fingerprint: sfp.clone(),
choices: vec![ChunkChoice {
index: 0,
delta: ChunkDelta {
role: Some("assistant".into()),
content: None,
reasoning_content: None,
tool_calls: None,
},
finish_reason: None,
logprobs: None,
}],
usage: None,
};
yield Ok(Event::default().data(serde_json::to_string(&role_chunk).unwrap_or_default()));
let mut pending_logprobs: Option<ChoiceLogprobs> = None;
while let Some(event) = rx.recv().await {
match event {
GenerationEvent::Delta { kind, text } => {
let (content, reasoning) = match kind {
DeltaKind::Content => (Some(text), None),
DeltaKind::Reasoning => (None, Some(text)),
};
let chunk = ChatCompletionChunk {
id: request_id.clone(),
object: "chat.completion.chunk",
created,
model: model_name.clone(),
system_fingerprint: sfp.clone(),
choices: vec![ChunkChoice {
index: 0,
delta: ChunkDelta {
role: None,
content,
reasoning_content: reasoning,
tool_calls: None,
},
finish_reason: None,
logprobs: pending_logprobs.take(),
}],
usage: None,
};
yield Ok(Event::default()
.data(serde_json::to_string(&chunk).unwrap_or_default()));
}
GenerationEvent::ToolCallDelta {
index,
id,
call_type,
name,
arguments,
} => {
use super::schema::{ToolCallDelta, ToolCallFunctionDelta};
let function = if name.is_some() || arguments.is_some() {
Some(ToolCallFunctionDelta { name, arguments })
} else {
None
};
let chunk = ChatCompletionChunk {
id: request_id.clone(),
object: "chat.completion.chunk",
created,
model: model_name.clone(),
system_fingerprint: sfp.clone(),
choices: vec![ChunkChoice {
index: 0,
delta: ChunkDelta {
role: None,
content: None,
reasoning_content: None,
tool_calls: Some(vec![ToolCallDelta {
index,
id,
call_type,
function,
}]),
},
finish_reason: None,
logprobs: None,
}],
usage: None,
};
yield Ok(Event::default()
.data(serde_json::to_string(&chunk).unwrap_or_default()));
}
GenerationEvent::Logprobs(lp) => {
if opts.logprobs {
pending_logprobs = Some(lp);
}
}
GenerationEvent::Done {
finish_reason,
prompt_tokens,
completion_tokens,
stats,
} => {
let usage = if include_usage {
Some(UsageStats {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
prompt_tokens_details: stats
.cached_prompt_tokens
.map(|cached_tokens| PromptTokensDetails { cached_tokens }),
completion_tokens_details: stats
.reasoning_tokens
.map(|reasoning_tokens| CompletionTokensDetails { reasoning_tokens }),
})
} else {
None
};
let final_chunk = ChatCompletionChunk {
id: request_id.clone(),
object: "chat.completion.chunk",
created,
model: model_name.clone(),
system_fingerprint: sfp.clone(),
choices: vec![ChunkChoice {
index: 0,
delta: ChunkDelta {
role: None,
content: None,
reasoning_content: None,
tool_calls: None,
},
finish_reason: Some(finish_reason.into()),
logprobs: pending_logprobs.take(),
}],
usage,
};
yield Ok(Event::default()
.data(serde_json::to_string(&final_chunk).unwrap_or_default()));
yield Ok(Event::default().data("[DONE]"));
return;
}
GenerationEvent::Error(msg) => {
tracing::error!(error = %msg, "Generation error during streaming");
let message_chunk = ChatCompletionChunk {
id: request_id.clone(),
object: "chat.completion.chunk",
created,
model: model_name.clone(),
system_fingerprint: sfp.clone(),
choices: vec![ChunkChoice {
index: 0,
delta: ChunkDelta {
role: None,
content: Some(msg.clone()),
reasoning_content: None,
tool_calls: None,
},
finish_reason: None,
logprobs: None,
}],
usage: None,
};
yield Ok(Event::default()
.data(serde_json::to_string(&message_chunk).unwrap_or_default()));
let error_chunk = ChatCompletionChunk {
id: request_id.clone(),
object: "chat.completion.chunk",
created,
model: model_name.clone(),
system_fingerprint: sfp.clone(),
choices: vec![ChunkChoice {
index: 0,
delta: ChunkDelta {
role: None,
content: None,
reasoning_content: None,
tool_calls: None,
},
finish_reason: Some("error".into()),
logprobs: None,
}],
usage: None,
};
yield Ok(Event::default()
.data(serde_json::to_string(&error_chunk).unwrap_or_default()));
yield Ok(Event::default().data("[DONE]"));
return;
}
}
}
tracing::warn!("Generation channel closed unexpectedly");
let error_chunk = ChatCompletionChunk {
id: request_id.clone(),
object: "chat.completion.chunk",
created,
model: model_name.clone(),
system_fingerprint: sfp.clone(),
choices: vec![ChunkChoice {
index: 0,
delta: ChunkDelta {
role: None,
content: None,
reasoning_content: None,
tool_calls: None,
},
finish_reason: Some("error".into()),
logprobs: None,
}],
usage: None,
};
yield Ok(Event::default().data(serde_json::to_string(&error_chunk).unwrap_or_default()));
yield Ok(Event::default().data("[DONE]"));
}
}
pub const SSE_KEEPALIVE_INTERVAL_SECS: u64 = 15;
pub const SSE_KEEPALIVE_TEXT: &str = "";
pub fn generation_events_to_sse(
rx: mpsc::Receiver<GenerationEvent>,
request_id: String,
model_name: String,
created: i64,
opts: SseStreamOptions,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
generation_events_to_sse_with_slot(rx, request_id, model_name, created, opts, None)
}
pub fn generation_events_to_sse_with_slot(
rx: mpsc::Receiver<GenerationEvent>,
request_id: String,
model_name: String,
created: i64,
opts: SseStreamOptions,
slot_id: Option<u32>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
if let Some(slot_id_value) = slot_id {
tracing::trace!(
request_id = %request_id,
slot_id = slot_id_value,
keepalive_interval_secs = SSE_KEEPALIVE_INTERVAL_SECS,
"sse stream constructed (ADR-040 C3 per-slot seam)"
);
}
Sse::new(generation_events_stream(
rx, request_id, model_name, created, opts,
))
.keep_alive(
KeepAlive::new()
.interval(std::time::Duration::from_secs(SSE_KEEPALIVE_INTERVAL_SECS))
.text(SSE_KEEPALIVE_TEXT),
)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::to_bytes;
use axum::response::IntoResponse;
use tokio::sync::mpsc::Sender;
async fn drain_sse<S>(sse: Sse<S>) -> Vec<String>
where
S: Stream<Item = Result<Event, Infallible>> + Send + 'static,
{
let resp = sse.into_response();
let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let text = std::str::from_utf8(&bytes).unwrap().to_string();
let mut out = Vec::new();
for frame in text.split("\n\n") {
let trimmed = frame.trim_end();
if trimmed.is_empty() {
continue;
}
let data_lines: Vec<&str> = trimmed
.lines()
.filter_map(|l| l.strip_prefix("data: "))
.collect();
if !data_lines.is_empty() {
out.push(data_lines.join("\n"));
}
}
out
}
async fn spawn_feeder(tx: Sender<GenerationEvent>, events: Vec<GenerationEvent>) {
for ev in events {
tx.send(ev).await.unwrap();
}
drop(tx);
}
fn make_sse(
rx: mpsc::Receiver<GenerationEvent>,
opts: SseStreamOptions,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
generation_events_to_sse(
rx,
"req-test".into(),
"gemma4-test".into(),
1700000000,
opts,
)
}
#[tokio::test]
async fn emits_role_chunk_first_then_content_then_done() {
let (tx, rx) = mpsc::channel(8);
let events = vec![
GenerationEvent::Delta {
kind: DeltaKind::Content,
text: "Hello".into(),
},
GenerationEvent::Delta {
kind: DeltaKind::Content,
text: ", world!".into(),
},
GenerationEvent::Done {
finish_reason: "stop",
prompt_tokens: 5,
completion_tokens: 3,
stats: StreamStats::default(),
},
];
let sse = make_sse(rx, SseStreamOptions::default());
tokio::spawn(spawn_feeder(tx, events));
let payloads = drain_sse(sse).await;
assert!(
payloads.len() >= 4,
"got {} payloads: {:?}",
payloads.len(),
payloads
);
let role: serde_json::Value = serde_json::from_str(&payloads[0]).unwrap();
assert_eq!(role["choices"][0]["delta"]["role"], "assistant");
let c0: serde_json::Value = serde_json::from_str(&payloads[1]).unwrap();
assert_eq!(c0["choices"][0]["delta"]["content"], "Hello");
let c1: serde_json::Value = serde_json::from_str(&payloads[2]).unwrap();
assert_eq!(c1["choices"][0]["delta"]["content"], ", world!");
let done: serde_json::Value = serde_json::from_str(&payloads[3]).unwrap();
assert_eq!(done["choices"][0]["finish_reason"], "stop");
assert!(done.get("usage").is_none() || done["usage"].is_null());
assert_eq!(payloads.last().unwrap(), "[DONE]");
}
#[tokio::test]
async fn reasoning_delta_routes_to_reasoning_content_slot() {
let (tx, rx) = mpsc::channel(8);
let events = vec![
GenerationEvent::Delta {
kind: DeltaKind::Reasoning,
text: "let me think...".into(),
},
GenerationEvent::Delta {
kind: DeltaKind::Content,
text: "42".into(),
},
GenerationEvent::Done {
finish_reason: "stop",
prompt_tokens: 3,
completion_tokens: 4,
stats: StreamStats::default(),
},
];
let sse = make_sse(rx, SseStreamOptions::default());
tokio::spawn(spawn_feeder(tx, events));
let payloads = drain_sse(sse).await;
assert_eq!(payloads.len(), 5);
let reasoning: serde_json::Value = serde_json::from_str(&payloads[1]).unwrap();
assert_eq!(
reasoning["choices"][0]["delta"]["reasoning_content"],
"let me think..."
);
assert!(reasoning["choices"][0]["delta"].get("content").is_none());
let content: serde_json::Value = serde_json::from_str(&payloads[2]).unwrap();
assert_eq!(content["choices"][0]["delta"]["content"], "42");
assert!(content["choices"][0]["delta"]
.get("reasoning_content")
.is_none());
}
#[tokio::test]
async fn include_usage_true_yields_usage_in_final_chunk() {
let (tx, rx) = mpsc::channel(8);
let stats = StreamStats {
cached_prompt_tokens: Some(2),
reasoning_tokens: Some(1),
..Default::default()
};
let events = vec![
GenerationEvent::Delta {
kind: DeltaKind::Content,
text: "ok".into(),
},
GenerationEvent::Done {
finish_reason: "stop",
prompt_tokens: 7,
completion_tokens: 5,
stats,
},
];
let opts = SseStreamOptions {
include_usage: true,
system_fingerprint: Some("hf2q-test-mlx-native".into()),
..Default::default()
};
let sse = make_sse(rx, opts);
tokio::spawn(spawn_feeder(tx, events));
let payloads = drain_sse(sse).await;
let done: serde_json::Value = serde_json::from_str(&payloads[payloads.len() - 2]).unwrap();
assert_eq!(done["usage"]["prompt_tokens"], 7);
assert_eq!(done["usage"]["completion_tokens"], 5);
assert_eq!(done["usage"]["total_tokens"], 12);
assert_eq!(done["usage"]["prompt_tokens_details"]["cached_tokens"], 2);
assert_eq!(
done["usage"]["completion_tokens_details"]["reasoning_tokens"],
1
);
assert_eq!(done["system_fingerprint"], "hf2q-test-mlx-native");
}
#[tokio::test]
async fn error_event_emits_error_message_then_finish_reason_then_done() {
let (tx, rx) = mpsc::channel(4);
let events = vec![
GenerationEvent::Delta {
kind: DeltaKind::Content,
text: "partial".into(),
},
GenerationEvent::Error("metal panic".into()),
];
let sse = make_sse(rx, SseStreamOptions::default());
tokio::spawn(spawn_feeder(tx, events));
let payloads = drain_sse(sse).await;
assert_eq!(payloads.len(), 5);
let msg_chunk: serde_json::Value = serde_json::from_str(&payloads[2]).unwrap();
assert_eq!(
msg_chunk["choices"][0]["delta"]["content"], "metal panic",
"Phase-2c: error message MUST be emitted as a content delta \
before the finish_reason chunk so streaming clients see \
the diagnostic"
);
assert!(
msg_chunk["choices"][0]["finish_reason"].is_null(),
"message chunk must NOT carry finish_reason (the next chunk does)"
);
let err: serde_json::Value = serde_json::from_str(&payloads[3]).unwrap();
assert_eq!(err["choices"][0]["finish_reason"], "error");
assert_eq!(payloads.last().unwrap(), "[DONE]");
}
#[tokio::test]
async fn channel_closed_without_done_emits_error_and_terminator() {
let (tx, rx) = mpsc::channel(4);
let events = vec![GenerationEvent::Delta {
kind: DeltaKind::Content,
text: "fragment".into(),
}];
let sse = make_sse(rx, SseStreamOptions::default());
tokio::spawn(spawn_feeder(tx, events));
let payloads = drain_sse(sse).await;
assert_eq!(payloads.last().unwrap(), "[DONE]");
let err: serde_json::Value = serde_json::from_str(&payloads[payloads.len() - 2]).unwrap();
assert_eq!(err["choices"][0]["finish_reason"], "error");
}
#[tokio::test]
async fn tool_call_delta_round_trips_through_sse() {
let (tx, rx) = mpsc::channel(4);
let events = vec![
GenerationEvent::ToolCallDelta {
index: 0,
id: Some("call_abc".into()),
call_type: Some("function".into()),
name: Some("get_weather".into()),
arguments: None,
},
GenerationEvent::ToolCallDelta {
index: 0,
id: None,
call_type: None,
name: None,
arguments: Some("{\"city\":".into()),
},
GenerationEvent::ToolCallDelta {
index: 0,
id: None,
call_type: None,
name: None,
arguments: Some("\"NYC\"}".into()),
},
GenerationEvent::Done {
finish_reason: "tool_calls",
prompt_tokens: 9,
completion_tokens: 7,
stats: StreamStats::default(),
},
];
let sse = make_sse(rx, SseStreamOptions::default());
tokio::spawn(spawn_feeder(tx, events));
let payloads = drain_sse(sse).await;
assert_eq!(payloads.len(), 6);
let first_tc: serde_json::Value = serde_json::from_str(&payloads[1]).unwrap();
let tc = &first_tc["choices"][0]["delta"]["tool_calls"][0];
assert_eq!(tc["index"], 0);
assert_eq!(tc["id"], "call_abc");
assert_eq!(tc["type"], "function");
assert_eq!(tc["function"]["name"], "get_weather");
let second_tc: serde_json::Value = serde_json::from_str(&payloads[2]).unwrap();
let tc2 = &second_tc["choices"][0]["delta"]["tool_calls"][0];
assert!(tc2.get("id").is_none() || tc2["id"].is_null());
assert_eq!(tc2["function"]["arguments"], "{\"city\":");
let done: serde_json::Value = serde_json::from_str(&payloads[4]).unwrap();
assert_eq!(done["choices"][0]["finish_reason"], "tool_calls");
}
#[tokio::test]
async fn logprobs_attach_to_next_content_chunk_when_enabled() {
use super::super::schema::{ChoiceLogprobs, TokenLogprob};
let (tx, rx) = mpsc::channel(8);
let lp = ChoiceLogprobs {
content: vec![TokenLogprob {
token: "Hello".into(),
logprob: -0.1,
bytes: None,
top_logprobs: Vec::new(),
}],
};
let events = vec![
GenerationEvent::Logprobs(lp),
GenerationEvent::Delta {
kind: DeltaKind::Content,
text: "Hello".into(),
},
GenerationEvent::Done {
finish_reason: "stop",
prompt_tokens: 2,
completion_tokens: 1,
stats: StreamStats::default(),
},
];
let opts = SseStreamOptions {
logprobs: true,
..Default::default()
};
let sse = make_sse(rx, opts);
tokio::spawn(spawn_feeder(tx, events));
let payloads = drain_sse(sse).await;
let content: serde_json::Value = serde_json::from_str(&payloads[1]).unwrap();
assert_eq!(
content["choices"][0]["logprobs"]["content"][0]["token"],
"Hello"
);
}
#[tokio::test]
async fn logprobs_ignored_when_disabled_in_opts() {
use super::super::schema::{ChoiceLogprobs, TokenLogprob};
let (tx, rx) = mpsc::channel(8);
let lp = ChoiceLogprobs {
content: vec![TokenLogprob {
token: "Hi".into(),
logprob: -0.3,
bytes: None,
top_logprobs: Vec::new(),
}],
};
let events = vec![
GenerationEvent::Logprobs(lp),
GenerationEvent::Delta {
kind: DeltaKind::Content,
text: "Hi".into(),
},
GenerationEvent::Done {
finish_reason: "stop",
prompt_tokens: 1,
completion_tokens: 1,
stats: StreamStats::default(),
},
];
let sse = make_sse(rx, SseStreamOptions::default());
tokio::spawn(spawn_feeder(tx, events));
let payloads = drain_sse(sse).await;
let content: serde_json::Value = serde_json::from_str(&payloads[1]).unwrap();
assert!(
content["choices"][0].get("logprobs").is_none()
|| content["choices"][0]["logprobs"].is_null()
);
}
#[tokio::test]
async fn c3_sse_keepalive_per_slot_state_is_isolated() {
let (tx_a, rx_a) = mpsc::channel(4);
let sse_a = generation_events_to_sse_with_slot(
rx_a,
"req-slot0".into(),
"test-model".into(),
1700000000,
SseStreamOptions::default(),
Some(0),
);
let events_a = vec![
GenerationEvent::Delta {
kind: DeltaKind::Content,
text: "alpha".into(),
},
GenerationEvent::Done {
finish_reason: "stop",
prompt_tokens: 1,
completion_tokens: 1,
stats: StreamStats::default(),
},
];
let (tx_b, rx_b) = mpsc::channel(4);
let sse_b = generation_events_to_sse_with_slot(
rx_b,
"req-slot1".into(),
"test-model".into(),
1700000000,
SseStreamOptions::default(),
Some(1),
);
let events_b = vec![
GenerationEvent::Delta {
kind: DeltaKind::Content,
text: "beta".into(),
},
GenerationEvent::Done {
finish_reason: "stop",
prompt_tokens: 1,
completion_tokens: 1,
stats: StreamStats::default(),
},
];
tokio::spawn(spawn_feeder(tx_a, events_a));
tokio::spawn(spawn_feeder(tx_b, events_b));
let payloads_a = drain_sse(sse_a).await;
let payloads_b = drain_sse(sse_b).await;
let content_a: serde_json::Value = serde_json::from_str(&payloads_a[1]).unwrap();
let content_b: serde_json::Value = serde_json::from_str(&payloads_b[1]).unwrap();
assert_eq!(
content_a["choices"][0]["delta"]["content"], "alpha",
"ADR-040 C3: slot 0 stream MUST surface only its own \
feed; per-slot keepalive seam requires per-stream \
encoder state isolation"
);
assert_eq!(
content_b["choices"][0]["delta"]["content"], "beta",
"ADR-040 C3: slot 1 stream MUST surface only its own \
feed; per-slot keepalive seam requires per-stream \
encoder state isolation"
);
let role_a: serde_json::Value = serde_json::from_str(&payloads_a[0]).unwrap();
let role_b: serde_json::Value = serde_json::from_str(&payloads_b[0]).unwrap();
assert_eq!(role_a["id"], "req-slot0");
assert_eq!(role_b["id"], "req-slot1");
assert_ne!(
role_a["id"], role_b["id"],
"ADR-040 C3: each per-slot stream carries its own \
request_id (vacuous-test guard)"
);
}
#[test]
fn c3_sse_keepalive_15s_interval_unchanged_under_fifo_serial() {
assert_eq!(
SSE_KEEPALIVE_INTERVAL_SECS, 15,
"ADR-040 §1.4 + Decision #20: SSE keepalive interval MUST \
remain 15s under SchedulerPolicy::FifoSerial. Changing \
this constant breaks the byte-invariance contract for \
clients at N=1."
);
assert_eq!(
SSE_KEEPALIVE_TEXT, "",
"ADR-040 §1.4 + Decision #20: SSE keepalive frame text \
MUST remain empty (`:\\n\\n` comment frame). Non-empty \
keepalive text would emit a `data:` line which SDK \
clients would parse as a generation chunk."
);
}
#[tokio::test]
async fn c3_sse_keepalive_no_byte_change_at_n1_under_serialfifo() {
let (tx1, rx1) = mpsc::channel(4);
let sse1 = generation_events_to_sse(
rx1,
"req-legacy".into(),
"test-model".into(),
1700000000,
SseStreamOptions::default(),
);
let (tx2, rx2) = mpsc::channel(4);
let sse2 = generation_events_to_sse_with_slot(
rx2,
"req-legacy".into(),
"test-model".into(),
1700000000,
SseStreamOptions::default(),
None,
);
let events_factory = || {
vec![
GenerationEvent::Delta {
kind: DeltaKind::Content,
text: "Hello".into(),
},
GenerationEvent::Delta {
kind: DeltaKind::Content,
text: ", world!".into(),
},
GenerationEvent::Done {
finish_reason: "stop",
prompt_tokens: 5,
completion_tokens: 3,
stats: StreamStats::default(),
},
]
};
tokio::spawn(spawn_feeder(tx1, events_factory()));
tokio::spawn(spawn_feeder(tx2, events_factory()));
let payloads1 = drain_sse(sse1).await;
let payloads2 = drain_sse(sse2).await;
assert_eq!(
payloads1, payloads2,
"ADR-040 §1.4: generation_events_to_sse (legacy 4-arg) \
must be byte-identical to \
generation_events_to_sse_with_slot(.., slot_id=None) — \
the C3 helper's None branch IS the FifoSerial path and \
MUST NOT change the wire output at N=1"
);
assert_eq!(payloads1.len(), 5);
assert_eq!(payloads1.last().unwrap(), "[DONE]");
}
}