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:
@@ -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
Reference in New Issue
Block a user