M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,774 @@
//! Layer-2 stream transform for the Chat Completions API.
//!
//! Consumes a raw `ChatCompletionChunk` stream and produces
//! [`SamplingEvent`]s. Pure: no I/O, no shell coupling.
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use futures_util::stream::{BoxStream, Stream};
use kigi_sampling_types::{
AssistantItem, ChatCompletionChunk, ConversationItem, ConversationResponse,
ResponseModelMetadata, SamplingError, StopReason, TokenUsage, ToolCall,
};
use crate::events::{SamplingChannel, SamplingErrorInfo, SamplingEvent};
use crate::metrics::InferenceLatencyStats;
use crate::types::RequestId;
/// Transform a raw Chat Completions chunk stream into a stream of
/// [`SamplingEvent`]s.
///
/// The output stream emits exactly one terminal event per request:
/// [`SamplingEvent::Completed`] on normal stream end, or
/// [`SamplingEvent::Failed`] on error / idle timeout. Callers must not
/// consume past the terminal event (the implementation `return`s after
/// yielding it).
///
/// `idle_timeout` covers two cases:
/// 1. The transport stops yielding chunks at all (`tokio::time::timeout`).
/// 2. The transport keeps yielding empty / keepalive chunks but no
/// meaningful content (separate `last_content_chunk_at` timer).
///
/// Both produce `SamplingEvent::Failed { kind: IdleTimeout }`.
pub fn stream_chat_completions<'a>(
raw_stream: BoxStream<'a, Result<ChatCompletionChunk, SamplingError>>,
model_metadata: Option<ResponseModelMetadata>,
request_id: RequestId,
idle_timeout: Duration,
) -> impl Stream<Item = SamplingEvent> + Send + 'a {
async_stream::stream! {
let stream_start = Instant::now();
let mut chunk_timestamps: Vec<Instant> = Vec::new();
// Emit StreamStarted before reading any chunks so subscribers
// can record TTFB / TTLB baselines.
yield SamplingEvent::StreamStarted {
request_id: request_id.clone(),
timestamp_ms: chrono::Utc::now().timestamp_millis(),
};
if let Some(metadata) = model_metadata {
yield SamplingEvent::ModelMetadata {
request_id: request_id.clone(),
metadata,
};
}
// Per-response accumulators
let mut first_chunk_seen = false;
let mut first_choice_seen = false;
let mut first_token_emitted = false;
let mut model: String = String::new();
let mut model_fingerprint: Option<String> = None;
let mut usage: Option<TokenUsage> = None;
let mut cost_usd_ticks: Option<i64> = None;
let mut finish_reason: Option<StopReason> = None;
let mut content_acc = String::new();
let mut reasoning_acc = String::new();
// Tool call deltas keyed by positional index. Each entry is
// (id, name, arguments_buffer); the first chunk for an index
// carries id+name and starts the arguments buffer, subsequent
// chunks append to arguments only.
let mut tool_call_acc: BTreeMap<u32, (String, String, String)> = BTreeMap::new();
// Index counter spanning text + reasoning chunks (matches the
// shell's chunk_index used for notification correlation).
let mut chunk_index: u64 = 0;
// Separate counter for AgentMessageChunk (text-only) emissions;
// mirrored onto ConversationResponse.message_chunks_emitted so
// downstream can detect lost-streaming-events scenarios.
let mut message_chunk_count: u64 = 0;
// Content-aware idle timer: the outer
// `tokio::time::timeout(idle_timeout, stream.next())` already
// catches "transport stops yielding chunks". This second timer
// catches the more subtle case where the model keeps emitting
// keepalive / empty-delta SSE events that satisfy the outer
// timer but make no real progress -- some inference engines
// do exactly that.
let mut last_content_chunk_at = Instant::now();
let mut stream = raw_stream;
loop {
let next = match tokio::time::timeout(idle_timeout, stream.next()).await {
Ok(Some(next)) => next,
Ok(None) => break, // stream ended normally
Err(_elapsed) => {
let err = SamplingError::IdleTimeout {
elapsed_secs: idle_timeout.as_secs(),
};
yield SamplingEvent::Failed {
request_id: request_id.clone(),
error: SamplingErrorInfo::from(&err),
};
return;
}
};
let chunk = match next {
Ok(chunk) => chunk,
Err(err) => {
yield SamplingEvent::Failed {
request_id: request_id.clone(),
error: SamplingErrorInfo::from(&err),
};
return;
}
};
if !first_chunk_seen {
model = chunk.model.clone();
model_fingerprint = chunk
.system_fingerprint
.clone()
.filter(|s| !s.is_empty());
first_chunk_seen = true;
}
if let Some(u) = chunk.usage.clone() {
// Wire cost is cumulative for the response, so last-write-wins.
// Never clobber a known cost with missing/unreported.
let chunk_cost = kigi_sampling_types::reported_cost_ticks(u.cost_in_usd_ticks);
cost_usd_ticks = match (cost_usd_ticks, chunk_cost) {
(_, Some(n)) => Some(n),
(prev, None) => prev,
};
usage = Some(u.into());
}
// Track whether this chunk carried meaningful content.
// Set inside the choices loop and checked at the end.
let mut chunk_has_content = false;
for choice in chunk.choices.into_iter() {
first_choice_seen = true;
if let Some(fr) = choice.finish_reason {
finish_reason = Some(fr.into());
chunk_has_content = true;
}
let delta = choice.delta;
if let Some(text) = delta.content
&& !text.is_empty()
{
if !first_token_emitted {
first_token_emitted = true;
yield SamplingEvent::FirstToken {
request_id: request_id.clone(),
};
}
chunk_has_content = true;
chunk_timestamps.push(Instant::now());
chunk_index += 1;
message_chunk_count += 1;
content_acc.push_str(&text);
yield SamplingEvent::ChannelToken {
request_id: request_id.clone(),
channel: SamplingChannel::Text,
text,
chunk_index,
};
}
if let Some(thought) = delta.reasoning_content
&& !thought.is_empty()
{
if !first_token_emitted {
first_token_emitted = true;
yield SamplingEvent::FirstToken {
request_id: request_id.clone(),
};
}
chunk_has_content = true;
chunk_index += 1;
reasoning_acc.push_str(&thought);
yield SamplingEvent::ChannelToken {
request_id: request_id.clone(),
channel: SamplingChannel::Reasoning,
text: thought,
chunk_index,
};
}
for tc_delta in delta.tool_calls.into_iter() {
chunk_has_content = true;
let entry = tool_call_acc
.entry(tc_delta.index)
.or_insert_with(|| (String::new(), String::new(), String::new()));
let mut id_for_event: Option<String> = None;
let mut name_for_event: Option<String> = None;
let mut args_for_event: Option<String> = None;
if let Some(id) = tc_delta.id {
entry.0 = id.clone();
id_for_event = Some(id);
}
if let Some(func) = tc_delta.function {
if let Some(name) = func.name {
entry.1 = name.clone();
name_for_event = Some(name);
}
if let Some(args) = func.arguments {
entry.2.push_str(&args);
args_for_event = Some(args);
}
}
yield SamplingEvent::ToolCallDelta {
request_id: request_id.clone(),
tool_index: tc_delta.index,
id: id_for_event,
name: name_for_event,
arguments_delta: args_for_event,
};
}
}
if chunk_has_content {
last_content_chunk_at = Instant::now();
} else if last_content_chunk_at.elapsed() > idle_timeout {
let err = SamplingError::IdleTimeout {
elapsed_secs: idle_timeout.as_secs(),
};
yield SamplingEvent::Failed {
request_id: request_id.clone(),
error: SamplingErrorInfo::from(&err),
};
return;
}
}
// ── Build the final response ─────────────────────────────────
let tool_calls: Vec<ToolCall> = tool_call_acc
.into_values()
.map(|(id, name, arguments)| ToolCall {
id: std::sync::Arc::<str>::from(id),
name,
arguments: std::sync::Arc::<str>::from(arguments),
})
.collect();
// Honor tool calls by overriding the stop reason if the model
// forgot to set it (mirrors the shell's behavior).
if !tool_calls.is_empty() {
finish_reason = Some(StopReason::ToolCalls);
}
// Build the trailing Assistant + any reasoning sibling.
let mut items: Vec<ConversationItem> = Vec::new();
if first_choice_seen {
if !reasoning_acc.is_empty() {
items.push(ConversationItem::Reasoning(
kigi_sampling_types::synthesized_reasoning_item(reasoning_acc),
));
}
items.push(ConversationItem::Assistant(AssistantItem {
content: std::sync::Arc::<str>::from(content_acc),
tool_calls,
model_id: Some(model),
model_fingerprint,
// Chat Completions does not echo the applied reasoning effort.
reasoning_effort: None,
}));
} else {
items.push(ConversationItem::assistant(""));
}
let stream_end = Instant::now();
let metrics =
InferenceLatencyStats::from_timestamps(stream_start, &chunk_timestamps, stream_end);
let response = ConversationResponse {
items,
stop_reason: finish_reason,
usage,
cost_usd_ticks,
message_chunks_emitted: message_chunk_count,
doom_loop_signals: Vec::new(),
stop_message: None,
};
yield SamplingEvent::Completed {
request_id: request_id.clone(),
response: Box::new(response),
metrics,
};
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures_util::stream;
use kigi_sampling_types::{
ChatChunkChoice, ChatChunkDelta, FinishReason, Role, ToolCallDelta as ChunkToolCallDelta,
ToolCallFunctionDelta, Usage, rs,
};
use std::pin::pin;
fn rid() -> RequestId {
RequestId::from("test-req")
}
fn make_chunk(deltas: Vec<ChatChunkDelta>) -> ChatCompletionChunk {
ChatCompletionChunk {
id: "chunk-1".into(),
object: "chat.completion.chunk".into(),
created: 0,
model: "test-model".into(),
choices: deltas
.into_iter()
.enumerate()
.map(|(i, delta)| ChatChunkChoice {
index: i as u32,
delta,
finish_reason: None,
})
.collect(),
usage: None,
system_fingerprint: None,
}
}
fn text_chunk(text: &str) -> ChatCompletionChunk {
make_chunk(vec![ChatChunkDelta {
role: Some(Role::Assistant),
content: Some(text.to_string()),
reasoning_content: None,
tool_calls: vec![],
tool_call_id: None,
}])
}
fn final_chunk(reason: FinishReason) -> ChatCompletionChunk {
let mut chunk = make_chunk(vec![ChatChunkDelta::default()]);
chunk.choices[0].finish_reason = Some(reason);
chunk
}
async fn collect(s: impl Stream<Item = SamplingEvent>) -> Vec<SamplingEvent> {
let mut out = Vec::new();
let mut s = pin!(s);
while let Some(ev) = s.next().await {
out.push(ev);
}
out
}
#[tokio::test]
async fn empty_stream_yields_started_then_completed() {
let raw = stream::iter(Vec::<Result<ChatCompletionChunk, SamplingError>>::new()).boxed();
let events = collect(stream_chat_completions(
raw,
None,
rid(),
Duration::from_secs(60),
))
.await;
assert_eq!(events.len(), 2);
assert!(matches!(events[0], SamplingEvent::StreamStarted { .. }));
match &events[1] {
SamplingEvent::Completed { response, .. } => {
assert!(response.is_empty());
}
other => panic!("expected Completed, got {other:?}"),
}
}
#[tokio::test]
async fn text_only_stream_emits_first_token_then_channel_tokens_then_completed() {
let chunks: Vec<Result<ChatCompletionChunk, SamplingError>> = vec![
Ok(text_chunk("Hello, ")),
Ok(text_chunk("world!")),
Ok(final_chunk(FinishReason::Stop)),
];
let raw = stream::iter(chunks).boxed();
let events = collect(stream_chat_completions(
raw,
None,
rid(),
Duration::from_secs(60),
))
.await;
// Expected sequence: StreamStarted, FirstToken, ChannelToken(Text)
// x 2, Completed.
assert!(matches!(events[0], SamplingEvent::StreamStarted { .. }));
assert!(matches!(events[1], SamplingEvent::FirstToken { .. }));
let text_tokens: Vec<&str> = events
.iter()
.filter_map(|e| match e {
SamplingEvent::ChannelToken {
channel: SamplingChannel::Text,
text,
..
} => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(text_tokens, vec!["Hello, ", "world!"]);
match events.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
let a = response.assistant().expect("assistant item present");
assert_eq!(a.content.as_ref(), "Hello, world!");
assert_eq!(response.stop_reason, Some(StopReason::Stop));
assert_eq!(response.message_chunks_emitted, 2);
}
other => panic!("expected Completed, got {other:?}"),
}
}
#[tokio::test]
async fn reasoning_chunk_emits_reasoning_channel_and_first_token_once() {
let mut reasoning_chunk = make_chunk(vec![ChatChunkDelta {
role: Some(Role::Assistant),
content: None,
reasoning_content: Some("thinking...".into()),
tool_calls: vec![],
tool_call_id: None,
}]);
reasoning_chunk.choices[0].finish_reason = None;
let chunks: Vec<Result<ChatCompletionChunk, SamplingError>> = vec![
Ok(reasoning_chunk),
Ok(text_chunk("done")),
Ok(final_chunk(FinishReason::Stop)),
];
let raw = stream::iter(chunks).boxed();
let events = collect(stream_chat_completions(
raw,
None,
rid(),
Duration::from_secs(60),
))
.await;
// FirstToken should appear exactly once.
let first_token_count = events
.iter()
.filter(|e| matches!(e, SamplingEvent::FirstToken { .. }))
.count();
assert_eq!(first_token_count, 1);
let mut saw_reasoning = false;
let mut saw_text = false;
for e in &events {
if let SamplingEvent::ChannelToken { channel, text, .. } = e {
match channel {
SamplingChannel::Reasoning => {
assert_eq!(text, "thinking...");
saw_reasoning = true;
}
SamplingChannel::Text => {
assert_eq!(text, "done");
saw_text = true;
}
}
}
}
assert!(saw_reasoning && saw_text);
match events.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
let r = response
.reasoning_items()
.next()
.expect("reasoning sibling preserved");
let rs::SummaryPart::SummaryText(t) = &r.summary[0];
assert_eq!(t.text, "thinking...");
}
other => panic!("expected Completed, got {other:?}"),
}
}
#[tokio::test]
async fn tool_call_stream_emits_deltas_and_assembles_final_call() {
// First chunk has id + name + part of arguments.
let chunk1 = make_chunk(vec![ChatChunkDelta {
role: None,
content: None,
reasoning_content: None,
tool_calls: vec![ChunkToolCallDelta {
index: 0,
id: Some("call_abc".into()),
kind: Some("function".into()),
function: Some(ToolCallFunctionDelta {
name: Some("do_thing".into()),
arguments: Some("{\"x\":".into()),
}),
}],
tool_call_id: None,
}]);
// Second chunk has only argument fragment.
let chunk2 = make_chunk(vec![ChatChunkDelta {
role: None,
content: None,
reasoning_content: None,
tool_calls: vec![ChunkToolCallDelta {
index: 0,
id: None,
kind: None,
function: Some(ToolCallFunctionDelta {
name: None,
arguments: Some("1}".into()),
}),
}],
tool_call_id: None,
}]);
let raw = stream::iter::<Vec<Result<ChatCompletionChunk, SamplingError>>>(vec![
Ok(chunk1),
Ok(chunk2),
])
.boxed();
let events = collect(stream_chat_completions(
raw,
None,
rid(),
Duration::from_secs(60),
))
.await;
let deltas: Vec<_> = events
.iter()
.filter_map(|e| match e {
SamplingEvent::ToolCallDelta {
tool_index,
id,
name,
arguments_delta,
..
} => Some((
*tool_index,
id.clone(),
name.clone(),
arguments_delta.clone(),
)),
_ => None,
})
.collect();
assert_eq!(deltas.len(), 2);
assert_eq!(deltas[0].0, 0);
assert_eq!(deltas[0].1.as_deref(), Some("call_abc"));
assert_eq!(deltas[0].2.as_deref(), Some("do_thing"));
assert_eq!(deltas[0].3.as_deref(), Some("{\"x\":"));
assert_eq!(deltas[1].1, None);
assert_eq!(deltas[1].2, None);
assert_eq!(deltas[1].3.as_deref(), Some("1}"));
match events.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
let calls = response.tool_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id.as_ref(), "call_abc");
assert_eq!(calls[0].name, "do_thing");
assert_eq!(calls[0].arguments.as_ref(), "{\"x\":1}");
// Tool calls force ToolCalls stop reason.
assert_eq!(response.stop_reason, Some(StopReason::ToolCalls));
}
other => panic!("expected Completed, got {other:?}"),
}
}
#[tokio::test]
async fn mid_stream_error_yields_failed_no_completed() {
let chunks: Vec<Result<ChatCompletionChunk, SamplingError>> = vec![
Ok(text_chunk("hi")),
Err(SamplingError::EventStreamError("conn reset".into())),
];
let raw = stream::iter(chunks).boxed();
let events = collect(stream_chat_completions(
raw,
None,
rid(),
Duration::from_secs(60),
))
.await;
assert!(
events
.iter()
.any(|e| matches!(e, SamplingEvent::Failed { .. }))
);
assert!(
!events
.iter()
.any(|e| matches!(e, SamplingEvent::Completed { .. }))
);
}
#[tokio::test(start_paused = true)]
async fn idle_timeout_when_stream_stalls() {
// A stream that yields one chunk then hangs forever.
let raw = stream::iter(vec![Ok(text_chunk("hello"))])
.chain(stream::pending())
.boxed();
let events = collect(stream_chat_completions(
raw,
None,
rid(),
Duration::from_millis(100),
))
.await;
// Stream should emit StreamStarted, FirstToken, ChannelToken
// then Failed(IdleTimeout) when the stall hits the deadline.
match events.last().unwrap() {
SamplingEvent::Failed { error, .. } => {
assert_eq!(error.kind, crate::events::SamplingErrorKind::IdleTimeout);
}
other => panic!("expected Failed(IdleTimeout), got {other:?}"),
}
}
#[tokio::test]
async fn model_metadata_yielded_after_stream_started() {
let raw = stream::iter(Vec::<Result<ChatCompletionChunk, SamplingError>>::new()).boxed();
let metadata = ResponseModelMetadata {
context_window: Some(8192),
max_completion_tokens: Some(4096),
models_etag: None,
};
let events = collect(stream_chat_completions(
raw,
Some(metadata.clone()),
rid(),
Duration::from_secs(60),
))
.await;
assert!(matches!(events[0], SamplingEvent::StreamStarted { .. }));
match &events[1] {
SamplingEvent::ModelMetadata { metadata: m, .. } => {
assert_eq!(m.context_window, Some(8192));
assert_eq!(m.max_completion_tokens, Some(4096));
}
other => panic!("expected ModelMetadata second, got {other:?}"),
}
}
#[tokio::test]
async fn usage_is_extracted_from_chunk() {
let mut chunk_with_usage = make_chunk(vec![ChatChunkDelta::default()]);
chunk_with_usage.usage = Some(Usage {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
prompt_tokens_details: None,
completion_tokens_details: None,
cost_in_usd_ticks: None,
});
let chunks: Vec<Result<ChatCompletionChunk, SamplingError>> = vec![
Ok(text_chunk("ok")),
Ok(chunk_with_usage),
Ok(final_chunk(FinishReason::Stop)),
];
let raw = stream::iter(chunks).boxed();
let events = collect(stream_chat_completions(
raw,
None,
rid(),
Duration::from_secs(60),
))
.await;
match events.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
let u = response.usage.as_ref().expect("usage extracted");
assert_eq!(u.prompt_tokens, 100);
assert_eq!(u.completion_tokens, 50);
assert_eq!(u.total_tokens, 150);
}
other => panic!("expected Completed, got {other:?}"),
}
}
/// Server-reported cost lands on the response; the REST mapper's `0`
/// backfill means "unreported" and must yield `None`.
#[tokio::test]
async fn cost_is_extracted_and_zero_is_unreported() {
for (wire, expected) in [(Some(78), Some(78)), (Some(0), None), (None, None)] {
let mut chunk_with_usage = make_chunk(vec![ChatChunkDelta::default()]);
chunk_with_usage.usage = Some(Usage {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
prompt_tokens_details: None,
completion_tokens_details: None,
cost_in_usd_ticks: wire,
});
let chunks: Vec<Result<ChatCompletionChunk, SamplingError>> = vec![
Ok(text_chunk("ok")),
Ok(chunk_with_usage),
Ok(final_chunk(FinishReason::Stop)),
];
let raw = stream::iter(chunks).boxed();
let events = collect(stream_chat_completions(
raw,
None,
rid(),
Duration::from_secs(60),
))
.await;
match events.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
assert_eq!(response.cost_usd_ticks, expected, "wire {wire:?}");
}
other => panic!("expected Completed, got {other:?}"),
}
}
}
#[tokio::test]
async fn later_missing_cost_does_not_clobber_earlier_ticks() {
let mut first = make_chunk(vec![ChatChunkDelta::default()]);
first.usage = Some(Usage {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
prompt_tokens_details: None,
completion_tokens_details: None,
cost_in_usd_ticks: Some(99),
});
let mut second = make_chunk(vec![ChatChunkDelta::default()]);
second.usage = Some(Usage {
prompt_tokens: 12,
completion_tokens: 6,
total_tokens: 18,
prompt_tokens_details: None,
completion_tokens_details: None,
cost_in_usd_ticks: Some(0),
});
let chunks: Vec<Result<ChatCompletionChunk, SamplingError>> = vec![
Ok(text_chunk("ok")),
Ok(first),
Ok(second),
Ok(final_chunk(FinishReason::Stop)),
];
let raw = stream::iter(chunks).boxed();
let events = collect(stream_chat_completions(
raw,
None,
rid(),
Duration::from_secs(60),
))
.await;
match events.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
assert_eq!(response.cost_usd_ticks, Some(99));
}
other => panic!("expected Completed, got {other:?}"),
}
}
}
@@ -0,0 +1,182 @@
//! Buffered consumer for [`SamplingEvent`] streams.
//!
//! Drains a Layer-2 event stream into the final
//! `(ConversationResponse, InferenceLatencyStats)` pair. Used by
//! callers that don't need streaming UI updates (e.g., compaction,
//! `/btw`, dream-model calls).
use futures_util::StreamExt;
use futures_util::stream::Stream;
use kigi_sampling_types::ConversationResponse;
use crate::events::{SamplingErrorInfo, SamplingErrorKind, SamplingEvent};
use crate::metrics::InferenceLatencyStats;
/// Drain a [`SamplingEvent`] stream, returning the final response.
///
/// Returns `Ok((response, metrics))` on the first
/// [`SamplingEvent::Completed`] and `Err(error)` on the first
/// [`SamplingEvent::Failed`]. Intermediate events (deltas, retries,
/// metadata) are silently consumed -- this function is for callers
/// that only need the final result.
///
/// If the stream ends without yielding either terminal event,
/// returns an `Err` of kind [`SamplingErrorKind::Api`] indicating
/// truncation. The Layer-2 transforms guarantee a terminal event in
/// every successful return path, so this only fires for streams that
/// are dropped mid-flight (e.g., the producer panicked or the
/// underlying `tokio::spawn` was cancelled).
pub async fn collect_response(
stream: impl Stream<Item = SamplingEvent>,
) -> Result<(ConversationResponse, InferenceLatencyStats), SamplingErrorInfo> {
tokio::pin!(stream);
while let Some(event) = stream.next().await {
match event {
SamplingEvent::Completed {
response, metrics, ..
} => return Ok((*response, metrics)),
SamplingEvent::Failed { error, .. } => return Err(error),
// Drop intermediate events; this is a buffered collector.
_ => {}
}
}
Err(SamplingErrorInfo {
kind: SamplingErrorKind::Api,
status_code: None,
message: "stream ended without Completed or Failed".to_string(),
is_retryable: false,
retry_after_secs: None,
model_metadata: None,
empty_response_context: None,
doom_loop_triggers: None,
doom_loop_aborted_at_chunk: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use futures_util::stream;
use kigi_sampling_types::{ConversationItem, SamplingError, StopReason};
use crate::events::SamplingChannel;
use crate::stream::stream_chat_completions;
use crate::types::RequestId;
use kigi_sampling_types::{
ChatChunkChoice, ChatChunkDelta, ChatCompletionChunk, FinishReason, Role,
};
use std::time::Duration;
fn rid() -> RequestId {
RequestId::from("collect-test")
}
fn text_chunk(text: &str) -> ChatCompletionChunk {
ChatCompletionChunk {
id: "chunk".into(),
object: "chat.completion.chunk".into(),
created: 0,
model: "test-model".into(),
choices: vec![ChatChunkChoice {
index: 0,
delta: ChatChunkDelta {
role: Some(Role::Assistant),
content: Some(text.to_string()),
reasoning_content: None,
tool_calls: vec![],
tool_call_id: None,
},
finish_reason: None,
}],
usage: None,
system_fingerprint: None,
}
}
fn final_chunk() -> ChatCompletionChunk {
ChatCompletionChunk {
id: "chunk".into(),
object: "chat.completion.chunk".into(),
created: 0,
model: "test-model".into(),
choices: vec![ChatChunkChoice {
index: 0,
delta: ChatChunkDelta::default(),
finish_reason: Some(FinishReason::Stop),
}],
usage: None,
system_fingerprint: None,
}
}
#[tokio::test]
async fn happy_path_returns_response_and_metrics() {
let chunks: Vec<Result<ChatCompletionChunk, SamplingError>> =
vec![Ok(text_chunk("hello")), Ok(final_chunk())];
let raw = stream::iter(chunks).boxed();
let events = stream_chat_completions(raw, None, rid(), Duration::from_secs(60));
let (response, _metrics) = collect_response(events)
.await
.expect("happy path returns Ok");
let a = response.assistant().expect("assistant item present");
assert_eq!(a.content.as_ref(), "hello");
assert_eq!(response.stop_reason, Some(StopReason::Stop));
}
#[tokio::test]
async fn failure_path_returns_error() {
let chunks: Vec<Result<ChatCompletionChunk, SamplingError>> = vec![
Ok(text_chunk("partial")),
Err(SamplingError::EventStreamError("boom".into())),
];
let raw = stream::iter(chunks).boxed();
let events = stream_chat_completions(raw, None, rid(), Duration::from_secs(60));
let err = collect_response(events).await.expect_err("error returned");
assert!(err.message.contains("boom"));
}
#[tokio::test]
async fn truncated_stream_returns_error() {
let truncated = stream::iter(vec![SamplingEvent::StreamStarted {
request_id: rid(),
timestamp_ms: 0,
}]);
let err = collect_response(truncated)
.await
.expect_err("truncated stream returns Err");
assert_eq!(err.kind, SamplingErrorKind::Api);
assert!(err.message.contains("stream ended without"));
}
#[tokio::test]
async fn intermediate_events_are_dropped() {
let token = SamplingEvent::ChannelToken {
request_id: rid(),
channel: SamplingChannel::Text,
text: "hi".into(),
chunk_index: 1,
};
let completed = SamplingEvent::Completed {
request_id: rid(),
response: Box::new(ConversationResponse {
items: vec![ConversationItem::assistant("hi")],
stop_reason: Some(StopReason::Stop),
usage: None,
cost_usd_ticks: None,
message_chunks_emitted: 1,
doom_loop_signals: Vec::new(),
stop_message: None,
}),
metrics: InferenceLatencyStats::default(),
};
let s = stream::iter(vec![token, completed]);
let (response, _) = collect_response(s).await.expect("ok");
let a = response.assistant().expect("assistant item present");
assert_eq!(a.content.as_ref(), "hi");
}
}
@@ -0,0 +1,530 @@
//! Layer-2 stream transform for the Anthropic Messages API.
//!
//! Consumes a raw `MessageStreamEvent` stream and produces
//! [`SamplingEvent`]s. Pure: no I/O, no shell coupling.
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use futures_util::stream::{BoxStream, Stream};
use kigi_sampling_types::messages::{self, MessageStreamEvent};
use kigi_sampling_types::{
AssistantItem, ConversationItem, ConversationResponse, ResponseModelMetadata, SamplingError,
StopReason, TokenUsage, ToolCall, rs,
};
use crate::events::{SamplingChannel, SamplingErrorInfo, SamplingEvent};
use crate::metrics::InferenceLatencyStats;
use crate::types::RequestId;
/// Returns whether a Messages API event reflects real model progress
/// rather than a liveness-only heartbeat (Ping).
pub(crate) fn messages_event_has_meaningful_content(event: &MessageStreamEvent) -> bool {
match event {
MessageStreamEvent::Ping => false,
MessageStreamEvent::MessageStart { .. }
| MessageStreamEvent::MessageDelta { .. }
| MessageStreamEvent::MessageStop
| MessageStreamEvent::ContentBlockStart { .. }
| MessageStreamEvent::ContentBlockDelta { .. }
| MessageStreamEvent::ContentBlockStop { .. }
| MessageStreamEvent::Error { .. } => true,
}
}
/// Per-block streaming accumulator. The Anthropic Messages API reports
/// content as a sequence of indexed blocks (text / thinking /
/// tool_use), each with start / delta / stop events. We accumulate
/// per-index and finalize each block on `ContentBlockStop`.
struct BlockState {
block_type: BlockType,
text_acc: String,
tool_name: String,
tool_id: String,
args_acc: String,
thinking_acc: String,
signature: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BlockType {
Text,
ToolUse,
Thinking,
}
/// Transform a raw Anthropic Messages API stream into a stream of
/// [`SamplingEvent`]s.
///
/// Yields exactly one terminal event ([`SamplingEvent::Completed`] or
/// [`SamplingEvent::Failed`]) per request. Server-side `Error` events
/// translate to `SamplingError::Api { status: 500, .. }` so the actor's
/// retry loop treats them as retryable transport-level errors.
pub fn stream_messages<'a>(
raw_stream: BoxStream<'a, Result<MessageStreamEvent, SamplingError>>,
model_metadata: Option<ResponseModelMetadata>,
request_id: RequestId,
idle_timeout: Duration,
) -> impl Stream<Item = SamplingEvent> + Send + 'a {
async_stream::stream! {
use messages::{ContentBlock, StreamDelta};
let stream_start = Instant::now();
let mut chunk_timestamps: Vec<Instant> = Vec::new();
yield SamplingEvent::StreamStarted {
request_id: request_id.clone(),
timestamp_ms: chrono::Utc::now().timestamp_millis(),
};
if let Some(metadata) = model_metadata {
yield SamplingEvent::ModelMetadata {
request_id: request_id.clone(),
metadata,
};
}
// Per-block accumulators keyed by content block index.
let mut blocks: BTreeMap<u32, BlockState> = BTreeMap::new();
// Final-message-level accumulators
let mut final_model: Option<String> = None;
// Anthropic Messages API `input_tokens` is the uncached portion; cache hits and writes are reported
// in separate buckets and must be summed for the true total prompt size.
let mut final_input_tokens: u32 = 0;
let mut final_cache_read_input_tokens: u32 = 0;
let mut final_cache_creation_input_tokens: u32 = 0;
let mut final_output_tokens: u32 = 0;
let mut final_stop_reason: Option<StopReason> = None;
let mut final_stop_message: Option<String> = None;
// Assistant-response accumulators (built up as ContentBlockStop
// events fire). Reasoning is collected into a synthesized
// `rs::ReasoningItem` and emitted as a sibling
// `ConversationItem::Reasoning` before the trailing Assistant.
let mut assistant_text = String::new();
let mut assistant_tool_calls: Vec<ToolCall> = Vec::new();
let mut assistant_reasoning: Option<rs::ReasoningItem> = None;
// Index counters
let mut chunk_index: u64 = 0;
let mut message_chunk_count: u64 = 0;
let mut first_token_emitted = false;
let mut last_content_chunk_at = Instant::now();
// Tool-call index counter for per-tool deltas (separate from
// the block index, which can be interleaved with text/thinking
// blocks).
let mut next_tool_index: u32 = 0;
let mut block_to_tool_index: BTreeMap<u32, u32> = BTreeMap::new();
let mut stream = raw_stream;
loop {
let event_result = match tokio::time::timeout(idle_timeout, stream.next()).await {
Ok(Some(event_result)) => event_result,
Ok(None) => break,
Err(_elapsed) => {
let err = SamplingError::IdleTimeout {
elapsed_secs: idle_timeout.as_secs(),
};
yield SamplingEvent::Failed {
request_id: request_id.clone(),
error: SamplingErrorInfo::from(&err),
};
return;
}
};
let event = match event_result {
Ok(event) => event,
Err(err) => {
yield SamplingEvent::Failed {
request_id: request_id.clone(),
error: SamplingErrorInfo::from(&err),
};
return;
}
};
let event_has_content = messages_event_has_meaningful_content(&event);
match event {
MessageStreamEvent::MessageStart { message } => {
final_model = Some(message.model.clone());
final_input_tokens = message.usage.input_tokens;
final_cache_read_input_tokens = message.usage.cache_read_input_tokens;
final_cache_creation_input_tokens = message.usage.cache_creation_input_tokens;
}
MessageStreamEvent::ContentBlockStart {
index,
content_block,
} => match content_block {
ContentBlock::Thinking {
thinking,
signature,
} => {
blocks.insert(
index,
BlockState {
block_type: BlockType::Thinking,
text_acc: String::new(),
tool_name: String::new(),
tool_id: String::new(),
args_acc: String::new(),
thinking_acc: thinking.clone(),
signature: signature.clone(),
},
);
if !first_token_emitted {
first_token_emitted = true;
yield SamplingEvent::FirstToken {
request_id: request_id.clone(),
};
}
}
ContentBlock::Text { text, .. } => {
blocks.insert(
index,
BlockState {
block_type: BlockType::Text,
text_acc: text.clone(),
tool_name: String::new(),
tool_id: String::new(),
args_acc: String::new(),
thinking_acc: String::new(),
signature: String::new(),
},
);
if !first_token_emitted {
first_token_emitted = true;
yield SamplingEvent::FirstToken {
request_id: request_id.clone(),
};
}
}
ContentBlock::ToolUse {
id,
name,
input: _,
} => {
let tool_index = next_tool_index;
next_tool_index += 1;
block_to_tool_index.insert(index, tool_index);
blocks.insert(
index,
BlockState {
block_type: BlockType::ToolUse,
text_acc: String::new(),
tool_name: name.clone(),
tool_id: id.clone(),
// Anthropic Messages API streams arguments via
// InputJsonDelta events; starting from
// "{}" then appending fragments would
// produce invalid JSON.
args_acc: String::new(),
thinking_acc: String::new(),
signature: String::new(),
},
);
// Emit initial id+name so subscribers can pre-allocate
// UI for the tool call before arguments stream in.
yield SamplingEvent::ToolCallDelta {
request_id: request_id.clone(),
tool_index,
id: Some(id),
name: Some(name),
arguments_delta: None,
};
}
_ => {} // Image / ToolResult are not expected in assistant streams.
},
MessageStreamEvent::ContentBlockDelta { index, delta } => {
if let Some(state) = blocks.get_mut(&index) {
match delta {
StreamDelta::ThinkingDelta { thinking } => {
if !thinking.is_empty() {
state.thinking_acc.push_str(&thinking);
if !first_token_emitted {
first_token_emitted = true;
yield SamplingEvent::FirstToken {
request_id: request_id.clone(),
};
}
chunk_index += 1;
yield SamplingEvent::ChannelToken {
request_id: request_id.clone(),
channel: SamplingChannel::Reasoning,
text: thinking,
chunk_index,
};
}
}
StreamDelta::SignatureDelta { signature } => {
state.signature = signature;
}
StreamDelta::TextDelta { text } => {
if !text.is_empty() {
state.text_acc.push_str(&text);
if !first_token_emitted {
first_token_emitted = true;
yield SamplingEvent::FirstToken {
request_id: request_id.clone(),
};
}
chunk_timestamps.push(Instant::now());
chunk_index += 1;
message_chunk_count += 1;
yield SamplingEvent::ChannelToken {
request_id: request_id.clone(),
channel: SamplingChannel::Text,
text,
chunk_index,
};
}
}
StreamDelta::InputJsonDelta { partial_json } => {
state.args_acc.push_str(&partial_json);
if let Some(&tool_index) = block_to_tool_index.get(&index) {
yield SamplingEvent::ToolCallDelta {
request_id: request_id.clone(),
tool_index,
id: None,
name: None,
arguments_delta: Some(partial_json),
};
}
}
}
}
}
MessageStreamEvent::ContentBlockStop { index } => {
if let Some(state) = blocks.remove(&index) {
match state.block_type {
BlockType::Text => {
if !state.text_acc.is_empty() {
if !assistant_text.is_empty() {
assistant_text.push('\n');
}
assistant_text.push_str(&state.text_acc);
}
}
BlockType::Thinking => {
if !state.thinking_acc.is_empty() || !state.signature.is_empty() {
// Anthropic Messages API `Thinking` blocks uniquely
// carry an encrypted `signature` distinct
// from the text; either field may be
// empty. Build directly rather than via
// `synthesized_reasoning_item` since the
// helper assumes a non-empty summary.
let summary = if state.thinking_acc.is_empty() {
vec![]
} else {
vec![rs::SummaryPart::SummaryText(
rs::SummaryTextContent {
text: state.thinking_acc,
},
)]
};
let encrypted_content = if state.signature.is_empty() {
None
} else {
Some(state.signature)
};
assistant_reasoning = Some(rs::ReasoningItem {
id: String::new(),
summary,
content: None,
encrypted_content,
status: None,
});
}
}
BlockType::ToolUse => {
assistant_tool_calls.push(ToolCall {
id: std::sync::Arc::<str>::from(state.tool_id),
name: state.tool_name,
arguments: std::sync::Arc::<str>::from(state.args_acc),
});
}
}
}
}
MessageStreamEvent::MessageDelta { delta, usage } => {
// Normalize the provider's stop detail to a plain message;
// the shell logs it when it surfaces a refusal.
if let Some(details) = delta.stop_details {
final_stop_message = details.explanation;
}
final_stop_reason = delta.stop_reason.map(|sr| match sr {
messages::StopReason::EndTurn => StopReason::Stop,
messages::StopReason::MaxTokens => StopReason::Length,
messages::StopReason::StopSequence => StopReason::Stop,
messages::StopReason::ToolUse => StopReason::ToolCalls,
// The model declined to continue; whatever streamed is
// the complete response, so end the turn cleanly.
messages::StopReason::Refusal => StopReason::ContentFilter,
messages::StopReason::PauseTurn => {
// Anthropic Messages API expects a resend-to-continue; we end the
// turn instead, so leave a triage trail.
tracing::warn!(
wire_stop_reason = "pause_turn",
"pause_turn ended the turn like stop (no auto-continue)"
);
StopReason::Stop
}
messages::StopReason::ModelContextWindowExceeded => {
// Output-side overflow on a successful stream: stays in the
// max_tokens truncation class — compact-on-error recovery needs
// an Api error carrying model metadata plus a prompt-side
// overflow, neither of which exists here.
tracing::warn!(
wire_stop_reason = "model_context_window_exceeded",
"context window hit mid-generation; surfacing as max_tokens truncation"
);
StopReason::Length
}
messages::StopReason::Unknown(wire) => {
tracing::warn!(
wire_stop_reason = %wire,
"unrecognized stop_reason in messages stream; treating as stop"
);
StopReason::Stop
}
});
final_output_tokens = usage.output_tokens;
// Optional on the delta; preserve message_start values when omitted.
if let Some(input) = usage.input_tokens {
final_input_tokens = input;
}
if let Some(cache_read) = usage.cache_read_input_tokens {
final_cache_read_input_tokens = cache_read;
}
if let Some(cache_creation) = usage.cache_creation_input_tokens {
final_cache_creation_input_tokens = cache_creation;
}
}
MessageStreamEvent::MessageStop => {
// Final message complete; the loop exits naturally
// when the underlying stream ends.
}
MessageStreamEvent::Ping => {
// Liveness only, no action; the inner timeout was
// already reset above by the successful `next()`.
}
MessageStreamEvent::Error { error } => {
let error_message = format!("{}: {}", error.r#type, error.message);
let err = SamplingError::Api {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
message: error_message,
model_metadata: None,
retry_after_secs: None,
should_retry: None,
};
yield SamplingEvent::Failed {
request_id: request_id.clone(),
error: SamplingErrorInfo::from(&err),
};
return;
}
}
if event_has_content {
last_content_chunk_at = Instant::now();
} else if last_content_chunk_at.elapsed() > idle_timeout {
let err = SamplingError::IdleTimeout {
elapsed_secs: idle_timeout.as_secs(),
};
yield SamplingEvent::Failed {
request_id: request_id.clone(),
error: SamplingErrorInfo::from(&err),
};
return;
}
}
if final_stop_reason == Some(StopReason::Length) {
yield SamplingEvent::Failed {
request_id: request_id.clone(),
error: SamplingErrorInfo::from(&SamplingError::MaxTokensTruncation),
};
return;
}
// ── Build the final response ─────────────────────────────────
let model_id = final_model.unwrap_or_default();
// Match the OAI Responses convention: prompt_tokens = full prompt, cached_prompt_tokens = cache hits only.
let total_prompt_tokens = final_input_tokens
.saturating_add(final_cache_read_input_tokens)
.saturating_add(final_cache_creation_input_tokens);
let usage = if total_prompt_tokens > 0 || final_output_tokens > 0 {
Some(TokenUsage {
prompt_tokens: total_prompt_tokens,
completion_tokens: final_output_tokens,
total_tokens: total_prompt_tokens.saturating_add(final_output_tokens),
reasoning_tokens: 0,
cached_prompt_tokens: final_cache_read_input_tokens,
})
} else {
None
};
let stop_reason = if !assistant_tool_calls.is_empty() {
// Completed tool_use blocks win even over Refusal: the calls are
// real model output the agent loop must resolve.
Some(StopReason::ToolCalls)
} else {
final_stop_reason
};
let assistant_item = ConversationItem::Assistant(AssistantItem {
content: std::sync::Arc::<str>::from(assistant_text),
tool_calls: assistant_tool_calls,
model_id: Some(model_id),
model_fingerprint: None,
// The Messages API does not echo the applied reasoning effort.
reasoning_effort: None,
});
let mut items: Vec<ConversationItem> = Vec::new();
if let Some(r) = assistant_reasoning {
items.push(ConversationItem::Reasoning(r));
}
items.push(assistant_item);
let stream_end = Instant::now();
let metrics =
InferenceLatencyStats::from_timestamps(stream_start, &chunk_timestamps, stream_end);
let response = ConversationResponse {
items,
stop_reason,
usage,
// Anthropic Messages API carries no cost on the wire.
cost_usd_ticks: None,
message_chunks_emitted: message_chunk_count,
doom_loop_signals: Vec::new(),
stop_message: final_stop_message,
};
yield SamplingEvent::Completed {
request_id: request_id.clone(),
response: Box::new(response),
metrics,
};
}
}
#[cfg(test)]
#[path = "messages_tests.rs"]
mod tests;
@@ -0,0 +1,655 @@
//! Unit tests for the [`super`] Messages L2 stream transform. Extracted
//! from `messages.rs` so the implementation reads top-to-bottom; wired in
//! via `#[path = "messages_tests.rs"] mod tests;` in messages.rs.
use super::*;
use futures_util::stream;
use kigi_sampling_types::messages::{
ContentBlock, MessageDeltaBody, MessageDeltaUsage, MessagesResponse, MessagesUsage,
StreamDelta, StreamError,
};
use std::pin::pin;
fn rid() -> RequestId {
RequestId::from("msg-test")
}
fn message_start() -> MessageStreamEvent {
MessageStreamEvent::MessageStart {
message: MessagesResponse {
id: "msg_1".into(),
r#type: "message".into(),
role: "assistant".into(),
content: vec![],
model: "messages-compatible-model".into(),
stop_reason: None,
usage: MessagesUsage {
input_tokens: 10,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
}
}
fn text_block_start(index: u32) -> MessageStreamEvent {
MessageStreamEvent::ContentBlockStart {
index,
content_block: ContentBlock::Text {
text: String::new(),
cache_control: None,
},
}
}
fn text_delta(index: u32, text: &str) -> MessageStreamEvent {
MessageStreamEvent::ContentBlockDelta {
index,
delta: StreamDelta::TextDelta { text: text.into() },
}
}
fn block_stop(index: u32) -> MessageStreamEvent {
MessageStreamEvent::ContentBlockStop { index }
}
fn message_delta_with_stop(stop: messages::StopReason) -> MessageStreamEvent {
MessageStreamEvent::MessageDelta {
delta: MessageDeltaBody {
stop_reason: Some(stop),
stop_details: None,
},
usage: MessageDeltaUsage {
output_tokens: 5,
input_tokens: Some(10),
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
},
}
}
/// A refusal `message_delta` carrying a provider `stop_details.explanation`,
/// mirroring the Anthropic Messages API ToS auto-refusal wire shape.
fn message_delta_refusal_with_explanation(explanation: &str) -> MessageStreamEvent {
MessageStreamEvent::MessageDelta {
delta: MessageDeltaBody {
stop_reason: Some(messages::StopReason::Refusal),
stop_details: Some(messages::StopDetails {
r#type: Some("refusal".to_string()),
category: Some("frontier_llm".to_string()),
explanation: Some(explanation.to_string()),
}),
},
usage: MessageDeltaUsage {
output_tokens: 0,
input_tokens: Some(10),
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
},
}
}
async fn collect(s: impl Stream<Item = SamplingEvent>) -> Vec<SamplingEvent> {
let mut out = Vec::new();
let mut s = pin!(s);
while let Some(ev) = s.next().await {
out.push(ev);
}
out
}
#[tokio::test]
async fn empty_stream_yields_started_then_completed() {
let raw = stream::iter(Vec::<Result<MessageStreamEvent, SamplingError>>::new()).boxed();
let events = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
assert_eq!(events.len(), 2);
assert!(matches!(events[0], SamplingEvent::StreamStarted { .. }));
assert!(matches!(events[1], SamplingEvent::Completed { .. }));
}
#[tokio::test]
async fn text_block_assembles_into_completed_response() {
let events: Vec<Result<MessageStreamEvent, SamplingError>> = vec![
Ok(message_start()),
Ok(text_block_start(0)),
Ok(text_delta(0, "Hello, ")),
Ok(text_delta(0, "world!")),
Ok(block_stop(0)),
Ok(message_delta_with_stop(messages::StopReason::EndTurn)),
Ok(MessageStreamEvent::MessageStop),
];
let raw = stream::iter(events).boxed();
let evs = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
let text_tokens: Vec<&str> = evs
.iter()
.filter_map(|e| match e {
SamplingEvent::ChannelToken {
channel: SamplingChannel::Text,
text,
..
} => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(text_tokens, vec!["Hello, ", "world!"]);
match evs.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
let a = response.assistant().expect("assistant item present");
assert_eq!(a.content.as_ref(), "Hello, world!");
assert_eq!(a.model_id.as_deref(), Some("messages-compatible-model"));
assert_eq!(response.stop_reason, Some(StopReason::Stop));
let u = response.usage.as_ref().expect("usage extracted");
assert_eq!(u.prompt_tokens, 10);
assert_eq!(u.completion_tokens, 5);
}
other => panic!("expected Completed, got {other:?}"),
}
}
#[tokio::test]
async fn thinking_block_emits_reasoning_channel_and_preserved_in_response() {
let thinking_start = MessageStreamEvent::ContentBlockStart {
index: 0,
content_block: ContentBlock::Thinking {
thinking: String::new(),
signature: String::new(),
},
};
let thinking_delta = MessageStreamEvent::ContentBlockDelta {
index: 0,
delta: StreamDelta::ThinkingDelta {
thinking: "let me think...".into(),
},
};
let sig_delta = MessageStreamEvent::ContentBlockDelta {
index: 0,
delta: StreamDelta::SignatureDelta {
signature: "abc123".into(),
},
};
let events: Vec<Result<MessageStreamEvent, SamplingError>> = vec![
Ok(message_start()),
Ok(thinking_start),
Ok(thinking_delta),
Ok(sig_delta),
Ok(block_stop(0)),
Ok(MessageStreamEvent::MessageStop),
];
let raw = stream::iter(events).boxed();
let evs = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
let reasoning_tokens: Vec<&str> = evs
.iter()
.filter_map(|e| match e {
SamplingEvent::ChannelToken {
channel: SamplingChannel::Reasoning,
text,
..
} => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(reasoning_tokens, vec!["let me think..."]);
match evs.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
let r = response
.reasoning_items()
.next()
.expect("reasoning sibling preserved");
let rs::SummaryPart::SummaryText(t) = &r.summary[0];
assert_eq!(t.text, "let me think...");
assert_eq!(r.encrypted_content.as_deref(), Some("abc123"));
}
other => panic!("expected Completed, got {other:?}"),
}
}
#[tokio::test]
async fn tool_use_block_assembles_into_tool_call() {
let tool_start = MessageStreamEvent::ContentBlockStart {
index: 0,
content_block: ContentBlock::ToolUse {
id: "call_xyz".into(),
name: "do_thing".into(),
input: serde_json::json!({}),
},
};
let arg_delta_1 = MessageStreamEvent::ContentBlockDelta {
index: 0,
delta: StreamDelta::InputJsonDelta {
partial_json: "{\"x\":".into(),
},
};
let arg_delta_2 = MessageStreamEvent::ContentBlockDelta {
index: 0,
delta: StreamDelta::InputJsonDelta {
partial_json: "1}".into(),
},
};
let events: Vec<Result<MessageStreamEvent, SamplingError>> = vec![
Ok(message_start()),
Ok(tool_start),
Ok(arg_delta_1),
Ok(arg_delta_2),
Ok(block_stop(0)),
Ok(message_delta_with_stop(messages::StopReason::ToolUse)),
Ok(MessageStreamEvent::MessageStop),
];
let raw = stream::iter(events).boxed();
let evs = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
// Should yield three ToolCallDelta events: id+name, then two
// arguments fragments.
let deltas: Vec<_> = evs
.iter()
.filter_map(|e| match e {
SamplingEvent::ToolCallDelta {
tool_index,
id,
name,
arguments_delta,
..
} => Some((
*tool_index,
id.clone(),
name.clone(),
arguments_delta.clone(),
)),
_ => None,
})
.collect();
assert_eq!(deltas.len(), 3);
assert_eq!(deltas[0].0, 0);
assert_eq!(deltas[0].1.as_deref(), Some("call_xyz"));
assert_eq!(deltas[0].2.as_deref(), Some("do_thing"));
assert_eq!(deltas[0].3, None);
assert_eq!(deltas[1].3.as_deref(), Some("{\"x\":"));
assert_eq!(deltas[2].3.as_deref(), Some("1}"));
match evs.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
let calls = response.tool_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id.as_ref(), "call_xyz");
assert_eq!(calls[0].name, "do_thing");
assert_eq!(calls[0].arguments.as_ref(), "{\"x\":1}");
assert_eq!(response.stop_reason, Some(StopReason::ToolCalls));
}
other => panic!("expected Completed, got {other:?}"),
}
}
/// Regression: a stream whose terminal `message_delta` carries
/// `stop_reason: "refusal"` must complete cleanly — not error out and
/// discard the already-streamed response.
#[tokio::test]
async fn refusal_stop_reason_completes_stream() {
let events: Vec<Result<MessageStreamEvent, SamplingError>> = vec![
Ok(message_start()),
Ok(text_block_start(0)),
Ok(text_delta(0, "I can't help with that.")),
Ok(block_stop(0)),
Ok(message_delta_with_stop(messages::StopReason::Refusal)),
Ok(MessageStreamEvent::MessageStop),
];
let raw = stream::iter(events).boxed();
let evs = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
assert!(
!evs.iter()
.any(|e| matches!(e, SamplingEvent::Failed { .. })),
"refusal stream must not yield Failed: {evs:?}"
);
match evs.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
let a = response.assistant().expect("assistant item present");
assert_eq!(a.content.as_ref(), "I can't help with that.");
assert_eq!(response.stop_reason, Some(StopReason::ContentFilter));
}
other => panic!("expected Completed, got {other:?}"),
}
}
/// A refusal `stop_details.explanation` on the terminal delta must be
/// normalized onto the completed `ConversationResponse.stop_message` so the
/// agent loop can surface the provider's reason (empty-turn silence otherwise).
#[tokio::test]
async fn refusal_stop_message_flows_to_response() {
let explanation = "This request was blocked by the provider's content policy.";
let events: Vec<Result<MessageStreamEvent, SamplingError>> = vec![
Ok(message_start()),
Ok(message_delta_refusal_with_explanation(explanation)),
Ok(MessageStreamEvent::MessageStop),
];
let raw = stream::iter(events).boxed();
let evs = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
match evs.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
assert_eq!(response.stop_reason, Some(StopReason::ContentFilter));
assert_eq!(
response.stop_message.as_deref(),
Some(explanation),
"provider explanation normalized onto stop_message"
);
}
other => panic!("expected Completed, got {other:?}"),
}
}
#[tokio::test]
async fn pause_turn_and_unknown_stop_reasons_complete_as_stop() {
for stop in [
messages::StopReason::PauseTurn,
messages::StopReason::Unknown("mystery_reason".to_string()),
] {
let label = format!("{stop:?}");
let events: Vec<Result<MessageStreamEvent, SamplingError>> = vec![
Ok(message_start()),
Ok(text_block_start(0)),
Ok(text_delta(0, "partial answer")),
Ok(block_stop(0)),
Ok(message_delta_with_stop(stop)),
Ok(MessageStreamEvent::MessageStop),
];
let raw = stream::iter(events).boxed();
let evs = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
match evs.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
assert_eq!(
response.stop_reason,
Some(StopReason::Stop),
"{label} must end the turn like stop"
);
}
other => panic!("{label}: expected Completed, got {other:?}"),
}
}
}
/// Pins the model_context_window_exceeded decision: it stays in the
/// max_tokens truncation class (fatal, non-retryable), not the
/// context-length Api class.
#[tokio::test]
async fn model_context_window_exceeded_fails_as_max_tokens_truncation() {
let events: Vec<Result<MessageStreamEvent, SamplingError>> = vec![
Ok(message_start()),
Ok(text_block_start(0)),
Ok(text_delta(0, "truncated answ")),
Ok(block_stop(0)),
Ok(message_delta_with_stop(
messages::StopReason::ModelContextWindowExceeded,
)),
Ok(MessageStreamEvent::MessageStop),
];
let raw = stream::iter(events).boxed();
let evs = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
assert!(
!evs.iter()
.any(|e| matches!(e, SamplingEvent::Completed { .. })),
"context-window truncation must not complete: {evs:?}"
);
match evs.last().unwrap() {
SamplingEvent::Failed { error, .. } => {
assert_eq!(
error.kind,
crate::events::SamplingErrorKind::MaxTokensTruncation
);
assert!(!error.is_retryable, "truncation is deterministic");
}
other => panic!("expected Failed(MaxTokensTruncation), got {other:?}"),
}
}
/// Pins the pre-existing override: completed tool_use blocks beat a terminal
/// Refusal, so the agent loop still resolves the calls.
#[tokio::test]
async fn refusal_after_tool_use_blocks_keeps_tool_calls_stop_reason() {
let tool_start = MessageStreamEvent::ContentBlockStart {
index: 0,
content_block: ContentBlock::ToolUse {
id: "call_refused".into(),
name: "do_thing".into(),
input: serde_json::json!({}),
},
};
let arg_delta = MessageStreamEvent::ContentBlockDelta {
index: 0,
delta: StreamDelta::InputJsonDelta {
partial_json: "{}".into(),
},
};
let events: Vec<Result<MessageStreamEvent, SamplingError>> = vec![
Ok(message_start()),
Ok(tool_start),
Ok(arg_delta),
Ok(block_stop(0)),
Ok(message_delta_with_stop(messages::StopReason::Refusal)),
Ok(MessageStreamEvent::MessageStop),
];
let raw = stream::iter(events).boxed();
let evs = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
match evs.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
assert_eq!(response.tool_calls().len(), 1);
assert_eq!(
response.stop_reason,
Some(StopReason::ToolCalls),
"tool_use blocks must win over the refusal stop_reason"
);
}
other => panic!("expected Completed, got {other:?}"),
}
}
#[tokio::test]
async fn server_error_event_yields_failed_500() {
let err_event = MessageStreamEvent::Error {
error: StreamError {
r#type: "overloaded_error".into(),
message: "rate limit hit".into(),
},
};
let raw = stream::iter(vec![Ok(message_start()), Ok(err_event)]).boxed();
let evs = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
match evs.last().unwrap() {
SamplingEvent::Failed { error, .. } => {
assert_eq!(error.kind, crate::events::SamplingErrorKind::Api);
assert_eq!(error.status_code, Some(500));
assert!(error.message.contains("overloaded_error"));
}
other => panic!("expected Failed, got {other:?}"),
}
}
#[tokio::test]
async fn mid_stream_transport_error_yields_failed() {
let raw = stream::iter(vec![
Ok(message_start()),
Err(SamplingError::EventStreamError("conn reset".into())),
])
.boxed();
let evs = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
assert!(
evs.iter()
.any(|e| matches!(e, SamplingEvent::Failed { .. }))
);
assert!(
!evs.iter()
.any(|e| matches!(e, SamplingEvent::Completed { .. }))
);
}
#[tokio::test(start_paused = true)]
async fn idle_timeout_when_stream_stalls() {
let raw = stream::iter(vec![Ok(message_start())])
.chain(stream::pending())
.boxed();
let evs = collect(stream_messages(
raw,
None,
rid(),
Duration::from_millis(100),
))
.await;
match evs.last().unwrap() {
SamplingEvent::Failed { error, .. } => {
assert_eq!(error.kind, crate::events::SamplingErrorKind::IdleTimeout);
}
other => panic!("expected Failed(IdleTimeout), got {other:?}"),
}
}
#[tokio::test]
async fn model_metadata_yielded_after_stream_started() {
let raw = stream::iter(vec![Ok(MessageStreamEvent::MessageStop)]).boxed();
let metadata = ResponseModelMetadata {
context_window: Some(200_000),
..Default::default()
};
let evs = collect(stream_messages(
raw,
Some(metadata),
rid(),
Duration::from_secs(60),
))
.await;
assert!(matches!(evs[0], SamplingEvent::StreamStarted { .. }));
assert!(matches!(evs[1], SamplingEvent::ModelMetadata { .. }));
}
#[test]
fn meaningful_content_classifier_treats_ping_as_keepalive() {
assert!(!messages_event_has_meaningful_content(
&MessageStreamEvent::Ping
));
assert!(messages_event_has_meaningful_content(
&MessageStreamEvent::MessageStop
));
}
// ── Token usage: Anthropic Messages API cache-bucket accounting ────────────
fn message_start_with_cache(
input: u32,
cache_read: u32,
cache_creation: u32,
) -> MessageStreamEvent {
MessageStreamEvent::MessageStart {
message: MessagesResponse {
id: "msg_cache".into(),
r#type: "message".into(),
role: "assistant".into(),
content: vec![],
model: "messages-compatible-model".into(),
stop_reason: None,
usage: MessagesUsage {
input_tokens: input,
output_tokens: 0,
cache_creation_input_tokens: cache_creation,
cache_read_input_tokens: cache_read,
},
},
}
}
fn message_delta_with_cache(
output: u32,
input: Option<u32>,
cache_read: Option<u32>,
cache_creation: Option<u32>,
) -> MessageStreamEvent {
MessageStreamEvent::MessageDelta {
delta: MessageDeltaBody {
stop_reason: Some(messages::StopReason::EndTurn),
stop_details: None,
},
usage: MessageDeltaUsage {
output_tokens: output,
input_tokens: input,
cache_read_input_tokens: cache_read,
cache_creation_input_tokens: cache_creation,
},
}
}
/// Helper: drive a minimal stream with the supplied usage events and
/// pluck the `TokenUsage` out of the terminal `Completed` event.
async fn usage_from_stream(events: Vec<MessageStreamEvent>) -> TokenUsage {
let raw = stream::iter(
events
.into_iter()
.map(Ok::<_, SamplingError>)
.collect::<Vec<_>>(),
)
.boxed();
let evs = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
match evs.last().expect("at least one event") {
SamplingEvent::Completed { response, .. } => response
.usage
.clone()
.expect("usage should be emitted when prompt or output tokens > 0"),
other => panic!("expected Completed, got {other:?}"),
}
}
#[tokio::test]
async fn prompt_tokens_sums_all_three_anthropic_buckets() {
// prompt_tokens = uncached + cache_read + cache_creation;
// cached_prompt_tokens = cache_read only (writes aren't a hit).
let usage = usage_from_stream(vec![
message_start_with_cache(100, 5000, 200),
text_block_start(0),
text_delta(0, "ok"),
block_stop(0),
message_delta_with_cache(7, None, None, None),
MessageStreamEvent::MessageStop,
])
.await;
assert_eq!(usage.prompt_tokens, 100 + 5000 + 200);
assert_eq!(usage.cached_prompt_tokens, 5000);
assert_eq!(usage.completion_tokens, 7);
assert_eq!(usage.total_tokens, 100 + 5000 + 200 + 7);
}
#[tokio::test]
async fn message_delta_cache_fields_override_message_start() {
// Providers can report zero cache at message_start and emit the real
// values on the final delta; honor the delta when present.
let usage = usage_from_stream(vec![
message_start_with_cache(10, 0, 0),
message_delta_with_cache(4, Some(10), Some(900), Some(50)),
MessageStreamEvent::MessageStop,
])
.await;
assert_eq!(usage.prompt_tokens, 10 + 900 + 50);
assert_eq!(usage.cached_prompt_tokens, 900);
assert_eq!(usage.completion_tokens, 4);
}
#[tokio::test]
async fn pure_cache_hit_with_zero_uncached_still_emits_usage() {
// 100% cache hit: Anthropic Messages API reports input_tokens=0 with cache_read>0.
// The emit-guard must still fire so callers see the cached cost.
let usage = usage_from_stream(vec![
message_start_with_cache(0, 2500, 0),
message_delta_with_cache(1, None, None, None),
MessageStreamEvent::MessageStop,
])
.await;
assert_eq!(usage.prompt_tokens, 2500);
assert_eq!(usage.cached_prompt_tokens, 2500);
assert_eq!(usage.total_tokens, 2501);
}
@@ -0,0 +1,19 @@
//! Layer-2 stream transforms: turn raw HTTP chunk streams into
//! [`SamplingEvent`](crate::events::SamplingEvent) streams.
//!
//! Each backend has its own transform because the raw chunk types
//! differ; backend dispatch happens in M4's
//! [`actor::request_task`](crate::actor::request_task), which knows
//! the API backend from `SamplerConfig.api_backend` and calls the
//! matching `SamplingClient::conversation_stream*` method before
//! handing the result to the corresponding transform here.
pub mod chat_completions;
pub mod collect;
pub mod messages;
pub mod responses;
pub use chat_completions::stream_chat_completions;
pub use collect::collect_response;
pub use messages::stream_messages;
pub use responses::stream_responses;
File diff suppressed because it is too large Load Diff