refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture
Transform the single binary crate into a 9-crate workspace monorepo: - Root Cargo.toml as [workspace] manager with resolver = "2" - zesdex-entities: Domain entity types (session, settings, store, message, etc.) - zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard) - zesdex-dto: Data Transfer Objects for LLM provider API communication - zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol) - zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure) - zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure) - zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting) - zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2) - zesdex-backend: Main binary entry point + seed/migrate binaries - DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates - Remove dead root src/ and src-misc/ directories All crate re-exports maintain backward compatibility with original crate::model::*, crate::dto::*, crate::ipc::* module paths. Feature crates enforce strict layer separation: domain -> application -> infrastructure with generic trait-based dependency injection.
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
|
||||
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
|
||||
pub mod turn;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// One atomic event extracted from an LLM streaming response stream.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum StreamEvent {
|
||||
Token(String),
|
||||
Reasoning(String),
|
||||
ToolCallDelta {
|
||||
index: usize,
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
arguments_delta: String,
|
||||
},
|
||||
Usage {
|
||||
prompt_tokens: u64,
|
||||
completion_tokens: u64,
|
||||
total_tokens: u64,
|
||||
},
|
||||
Done,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Buffered SSE frame parser that accumulates raw `data:` lines and
|
||||
/// flushes a `StreamEvent` on each blank-line boundary.
|
||||
pub struct SseParser {
|
||||
buffer: String,
|
||||
event_type: Option<String>,
|
||||
data_lines: Vec<String>,
|
||||
}
|
||||
|
||||
impl SseParser {
|
||||
/// Create a new parser with an empty buffer.
|
||||
pub fn new() -> Self {
|
||||
SseParser {
|
||||
buffer: String::new(),
|
||||
event_type: None,
|
||||
data_lines: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a raw SSE chunk and produce any completed events.
|
||||
///
|
||||
/// Flow: append chunk to buffer → scan for '\n' → strip '\r' → on
|
||||
/// blank line, call `flush_event` to parse the accumulated data →
|
||||
/// on `event:` line, store the event type → on `data:` line, append
|
||||
/// to data accumulator → continue until buffer exhausted.
|
||||
///
|
||||
/// Edge case: a chunk may split mid-line; the remainder stays in the
|
||||
/// buffer for the next `feed()` call.
|
||||
///
|
||||
/// Return: all `StreamEvent`s completed by this chunk.
|
||||
pub fn feed(&mut self, chunk: &str) -> Vec<StreamEvent> {
|
||||
self.buffer.push_str(chunk);
|
||||
let mut events = Vec::new();
|
||||
while let Some(line_end) = self.buffer.find('\n') {
|
||||
let line = self.buffer[..line_end].trim_end_matches('\r').to_string();
|
||||
self.buffer = self.buffer[line_end + 1..].to_string();
|
||||
if line.is_empty() {
|
||||
events.extend(self.flush_event());
|
||||
} else if let Some(ty) = line.strip_prefix("event: ") {
|
||||
self.event_type = Some(ty.trim().to_string());
|
||||
} else if let Some(data) = line.strip_prefix("data:") {
|
||||
// Handle both "data: {...}" (with space) and "data:{...}"
|
||||
// (without space). Some providers omit the trailing space.
|
||||
let data = data.trim_start().to_string();
|
||||
self.data_lines.push(data);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// Flush the current buffered `data:` lines as one or more `StreamEvent`s.
|
||||
///
|
||||
/// Flow: join data lines → handle `[DONE]` sentinel → JSON-parse →
|
||||
/// emit `Usage` if a usage object is present → else match `event_type`
|
||||
/// ("message.stop", "message.delta", etc.) → extract content,
|
||||
/// reasoning, tool-call deltas, or finish-reason from the delta
|
||||
/// structure (supporting both Anthropic-style top-level delta and
|
||||
/// OpenAI-style `choices` array).
|
||||
///
|
||||
/// Why: dual-format support in one method avoids a separate
|
||||
/// provider-specific parsing layer.
|
||||
///
|
||||
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
|
||||
|
||||
fn flush_event(&mut self) -> Vec<StreamEvent> {
|
||||
let data = self.data_lines.join("\n");
|
||||
self.data_lines.clear();
|
||||
let event_type = self.event_type.take().unwrap_or_default();
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
if data == "[DONE]" {
|
||||
return vec![StreamEvent::Done];
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
let value: Value = match serde_json::from_str(&data) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("[stream] failed to parse chunk: {}", e);
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
||||
if let Some(usage) = value.get("usage") {
|
||||
if !usage.is_null() {
|
||||
let prompt_tokens = usage
|
||||
.get("prompt_tokens")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] prompt_tokens missing in usage chunk");
|
||||
0
|
||||
});
|
||||
let completion_tokens = usage
|
||||
.get("completion_tokens")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] completion_tokens missing in usage chunk");
|
||||
0
|
||||
});
|
||||
let total_tokens = usage
|
||||
.get("total_tokens")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] total_tokens missing in usage chunk");
|
||||
prompt_tokens + completion_tokens
|
||||
});
|
||||
events.push(StreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut other_events = match event_type.as_str() {
|
||||
"message.stop" => vec![StreamEvent::Done],
|
||||
"message.delta" | "" => {
|
||||
let mut d_events = Vec::new();
|
||||
if let Some(delta) = value.get("delta").or_else(|| value.get("choices")) {
|
||||
if let Some(choices) = delta.as_array() {
|
||||
if let Some(choice) = choices.first() {
|
||||
if let Some(d) = choice.get("delta") {
|
||||
// Content token
|
||||
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
|
||||
d_events.push(StreamEvent::Token(content.to_string()));
|
||||
}
|
||||
|
||||
// Reasoning token
|
||||
if let Some(reasoning) =
|
||||
d.get("reasoning_content").and_then(|r| r.as_str())
|
||||
{
|
||||
d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
|
||||
}
|
||||
|
||||
// Tool calls — iterate ALL entries, not just first()
|
||||
if let Some(tool_calls) =
|
||||
d.get("tool_calls").and_then(|tc| tc.as_array())
|
||||
{
|
||||
for tc in tool_calls {
|
||||
let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] tool call delta missing index, defaulting to 0");
|
||||
0
|
||||
}) as usize;
|
||||
let id = tc
|
||||
.get("id")
|
||||
.and_then(|i| i.as_str())
|
||||
.map(std::string::ToString::to_string);
|
||||
let name = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.map(std::string::ToString::to_string);
|
||||
let args_delta = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("arguments"))
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
d_events.push(StreamEvent::ToolCallDelta {
|
||||
index,
|
||||
id,
|
||||
name,
|
||||
arguments_delta: args_delta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Finish reason
|
||||
if let Some(reason) =
|
||||
choice.get("finish_reason").and_then(|r| r.as_str())
|
||||
{
|
||||
if reason == "stop" || reason == "tool_calls" {
|
||||
d_events.push(StreamEvent::Done);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
||||
d_events.push(StreamEvent::Token(content.to_string()));
|
||||
}
|
||||
}
|
||||
d_events
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
events.append(&mut other_events);
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn feed_parses_single_token_chunk() {
|
||||
let mut p = SseParser::new();
|
||||
let events = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n");
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
StreamEvent::Token(t) => assert_eq!(t, "hello"),
|
||||
other => panic!("expected Token, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_handles_chunk_split_mid_line() {
|
||||
let mut p = SseParser::new();
|
||||
let e1 = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"partial");
|
||||
assert!(
|
||||
e1.is_empty(),
|
||||
"no event until the line and blank separator complete"
|
||||
);
|
||||
let e2 = p.feed("\"}}]}\n\n");
|
||||
assert_eq!(e2.len(), 1);
|
||||
match &e2[0] {
|
||||
StreamEvent::Token(t) => assert_eq!(t, "partial"),
|
||||
other => panic!("expected Token, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_emits_done_on_done_sentinel() {
|
||||
let mut p = SseParser::new();
|
||||
let events = p.feed("data: [DONE]\n\n");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(events[0], StreamEvent::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_emits_done_on_finish_reason_stop() {
|
||||
let mut p = SseParser::new();
|
||||
let events = p.feed("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(events[0], StreamEvent::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_parses_tool_call_delta() {
|
||||
let mut p = SseParser::new();
|
||||
let events = p.feed(
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"bash\",\"arguments\":\"{\\\"cmd\\\"\"}}]}}]}\n\n",
|
||||
);
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
StreamEvent::ToolCallDelta {
|
||||
index,
|
||||
id,
|
||||
name,
|
||||
arguments_delta,
|
||||
} => {
|
||||
assert_eq!(*index, 0);
|
||||
assert_eq!(id.as_deref(), Some("call_1"));
|
||||
assert_eq!(name.as_deref(), Some("bash"));
|
||||
assert_eq!(arguments_delta, "{\"cmd\"");
|
||||
}
|
||||
other => panic!("expected ToolCallDelta, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_parses_usage_chunk() {
|
||||
let mut p = SseParser::new();
|
||||
let events = p.feed(
|
||||
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n",
|
||||
);
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
StreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
} => {
|
||||
assert_eq!(*prompt_tokens, 10);
|
||||
assert_eq!(*completion_tokens, 5);
|
||||
assert_eq!(*total_tokens, 15);
|
||||
}
|
||||
other => panic!("expected Usage, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_parses_usage_and_content_bundled_chunk() {
|
||||
let mut p = SseParser::new();
|
||||
let events = p.feed(
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n",
|
||||
);
|
||||
assert_eq!(events.len(), 2);
|
||||
match (&events[0], &events[1]) {
|
||||
(
|
||||
StreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
},
|
||||
StreamEvent::Token(t),
|
||||
) => {
|
||||
assert_eq!(*prompt_tokens, 10);
|
||||
assert_eq!(*completion_tokens, 5);
|
||||
assert_eq!(*total_tokens, 15);
|
||||
assert_eq!(t, "hello");
|
||||
}
|
||||
other => panic!("expected [Usage, Token], got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_ignores_empty_data_lines() {
|
||||
let mut p = SseParser::new();
|
||||
let events = p.feed(": comment\n\n");
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_multiple_events_across_one_chunk() {
|
||||
let mut p = SseParser::new();
|
||||
let chunk = "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"b\"}}]}\n\n";
|
||||
let events = p.feed(chunk);
|
||||
assert_eq!(events.len(), 2);
|
||||
match (&events[0], &events[1]) {
|
||||
(StreamEvent::Token(a), StreamEvent::Token(b)) => {
|
||||
assert_eq!(a, "a");
|
||||
assert_eq!(b, "b");
|
||||
}
|
||||
other => panic!("expected two Tokens, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
//! Accumulates streaming LLM responses into complete message/tool-call
|
||||
//! representation via `StreamedTurn`, and provides a standalone tool-call
|
||||
//! accumulator in `tools::ToolCallAccumulator`.
|
||||
use super::StreamEvent;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
use crate::dto::chat::tool::{ToolCall, ToolFunction};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Try to repair truncated JSON by closing open strings, braces, and brackets.
|
||||
///
|
||||
/// Flow: scan character-by-character tracking string/escape state. For
|
||||
/// every `{` or `[` seen outside a string, push onto a LIFO stack; on
|
||||
/// `}`/`]` pop the matching opener (tracking remaining depth only).
|
||||
/// At the end, if the last char was a backslash (start of an escape
|
||||
/// sequence), remove it; if inside a string, append `"`; then close
|
||||
/// every unclosed opener in reverse (LIFO) order.
|
||||
///
|
||||
/// Why: LLM responses can be cut off (`max_tokens`, network) mid‑JSON
|
||||
/// string, but we want tools to receive whatever arguments were already
|
||||
/// emitted so the partial work can proceed.
|
||||
///
|
||||
/// Why LIFO vs. depth counters: `{` inside `[` must be closed with `}`
|
||||
/// *before* the `]`, not after it. Simple depth counters get the order
|
||||
/// wrong for nested heterogenous structures.
|
||||
fn repair_incomplete_json(s: &str) -> String {
|
||||
let mut stack: Vec<char> = Vec::new();
|
||||
let mut in_string = false;
|
||||
let mut prev_was_backslash = false;
|
||||
// `true` only when the very last character consumed was a bare `\`
|
||||
// inside a string (i.e. the start of an escape that was never completed).
|
||||
let mut ends_with_unclosed_escape = false;
|
||||
|
||||
for c in s.chars() {
|
||||
if prev_was_backslash {
|
||||
// Consume the character that was being escaped — the escape is
|
||||
// complete, so clear the unclosed-escape flag.
|
||||
prev_was_backslash = false;
|
||||
ends_with_unclosed_escape = false;
|
||||
continue;
|
||||
}
|
||||
if c == '\\' && in_string {
|
||||
prev_was_backslash = true;
|
||||
ends_with_unclosed_escape = true;
|
||||
continue;
|
||||
}
|
||||
ends_with_unclosed_escape = false;
|
||||
if c == '"' {
|
||||
in_string = !in_string;
|
||||
continue;
|
||||
}
|
||||
if in_string {
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'{' | '[' => stack.push(c),
|
||||
'}' | ']' => {
|
||||
stack.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = s.to_string();
|
||||
if ends_with_unclosed_escape {
|
||||
// The last character is a dangling backslash that started an escape
|
||||
// but got cut off before the escaped char — remove it.
|
||||
result.pop();
|
||||
}
|
||||
if in_string {
|
||||
result.push('"');
|
||||
}
|
||||
for &opener in stack.iter().rev() {
|
||||
match opener {
|
||||
'{' => result.push('}'),
|
||||
'[' => result.push(']'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Accumulates a single streaming assistant turn into its final
|
||||
/// `ChatMessage` form, including tool-call deltas and content/reasoning.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamedTurn {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub tool_calls: Vec<ParsedToolCall>,
|
||||
pub is_complete: bool,
|
||||
pub done_received: bool,
|
||||
pub accumulated_content: String,
|
||||
pub accumulated_reasoning: String,
|
||||
}
|
||||
|
||||
/// A single tool call being built up from streaming deltas.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ParsedToolCall {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
pub is_complete: bool,
|
||||
}
|
||||
|
||||
impl ParsedToolCall {}
|
||||
|
||||
impl StreamedTurn {
|
||||
/// Create an empty turn accumulator.
|
||||
pub fn new() -> Self {
|
||||
StreamedTurn {
|
||||
messages: Vec::new(),
|
||||
tool_calls: Vec::new(),
|
||||
is_complete: false,
|
||||
done_received: false,
|
||||
accumulated_content: String::new(),
|
||||
accumulated_reasoning: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a `StreamEvent` to the turn, updating accumulated content,
|
||||
/// reasoning, and tool-call deltas.
|
||||
///
|
||||
/// Flow: match on variant — `Token` appends to `accumulated_content`,
|
||||
/// `Reasoning` to `accumulated_reasoning`, `ToolCallDelta` fills or
|
||||
/// grows the `tool_calls` vector, `Done` sets `is_complete = true`.
|
||||
pub fn apply_event(&mut self, event: &StreamEvent) {
|
||||
match event {
|
||||
StreamEvent::Token(token) => {
|
||||
self.accumulated_content.push_str(token);
|
||||
}
|
||||
StreamEvent::Reasoning(reasoning) => {
|
||||
self.accumulated_reasoning.push_str(reasoning);
|
||||
}
|
||||
StreamEvent::ToolCallDelta {
|
||||
index,
|
||||
id,
|
||||
name,
|
||||
arguments_delta,
|
||||
} => {
|
||||
while self.tool_calls.len() <= *index {
|
||||
self.tool_calls.push(ParsedToolCall {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
arguments: String::new(),
|
||||
is_complete: false,
|
||||
});
|
||||
}
|
||||
let tc = &mut self.tool_calls[*index];
|
||||
if let Some(new_id) = id {
|
||||
if !new_id.is_empty() {
|
||||
tc.id.clone_from(new_id);
|
||||
}
|
||||
}
|
||||
if let Some(new_name) = name {
|
||||
if !new_name.is_empty() {
|
||||
tc.name.clone_from(new_name);
|
||||
}
|
||||
}
|
||||
tc.arguments.push_str(arguments_delta);
|
||||
}
|
||||
StreamEvent::Done => {
|
||||
self.is_complete = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finalise the turn into a `ChatMessage`, combining accumulated
|
||||
/// reasoning (wrapped in `<think>` tags) with content and tool calls.
|
||||
///
|
||||
/// Flow: if tool calls exist, build a `ChatMessage` with `tool_calls`
|
||||
/// set; otherwise build a plain assistant message → set `content` to
|
||||
/// the combined reasoning+content string (or `None` if empty).
|
||||
///
|
||||
/// Return: a complete `ChatMessage` with role `Assistant`.
|
||||
pub fn build_assistant_message(&self) -> ChatMessage {
|
||||
let mut msg = if self.tool_calls.is_empty() {
|
||||
ChatMessage::assistant(None)
|
||||
} else {
|
||||
let tool_dtos: Vec<ToolCall> = self
|
||||
.tool_calls
|
||||
.iter()
|
||||
.filter(|tc| !tc.name.is_empty())
|
||||
.map(|tc| {
|
||||
let args_value: serde_json::Value = match serde_json::from_str(&tc.arguments) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let repaired = repair_incomplete_json(&tc.arguments);
|
||||
match serde_json::from_str(&repaired) {
|
||||
Ok(v) => {
|
||||
tracing::warn!(
|
||||
"[stream] tool call '{}' had truncated JSON \
|
||||
arguments — repaired successfully: {}",
|
||||
tc.name,
|
||||
e,
|
||||
);
|
||||
v
|
||||
}
|
||||
Err(e2) => {
|
||||
tracing::warn!(
|
||||
"[stream] tool call '{}' has invalid JSON \
|
||||
arguments: {} (after repair: {}) — falling \
|
||||
back to raw string",
|
||||
tc.name,
|
||||
e,
|
||||
e2,
|
||||
);
|
||||
serde_json::Value::String(tc.arguments.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
ToolCall {
|
||||
id: tc.id.clone(),
|
||||
type_: "function".to_string(),
|
||||
function: ToolFunction {
|
||||
name: tc.name.clone(),
|
||||
arguments: args_value,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut msg = ChatMessage::assistant(None);
|
||||
if !tool_dtos.is_empty() {
|
||||
msg.tool_calls = Some(tool_dtos);
|
||||
}
|
||||
msg
|
||||
};
|
||||
let full_content = if self.accumulated_reasoning.is_empty() {
|
||||
self.accumulated_content.clone()
|
||||
} else {
|
||||
format!(
|
||||
"<think>\n{}\n</think>\n\n{}",
|
||||
self.accumulated_reasoning, self.accumulated_content
|
||||
)
|
||||
};
|
||||
let content = if full_content.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(full_content)
|
||||
};
|
||||
msg.content = content;
|
||||
msg
|
||||
}
|
||||
|
||||
/// Find the first named tool call whose accumulated `arguments` do not
|
||||
/// parse as valid JSON.
|
||||
///
|
||||
/// Why: a connection that closes mid-stream (no `[DONE]` event) still
|
||||
/// leaves partial argument text in the accumulator — e.g. a `write`
|
||||
/// tool call cut off mid-string. Parsing that fragment always fails,
|
||||
/// so a parse failure at end-of-stream is a reliable signal that the
|
||||
/// response was truncated, not that the model legitimately finished
|
||||
/// without sending `[DONE]`.
|
||||
///
|
||||
/// Return: `Some((name, parse_error))` for the first bad tool call, or
|
||||
/// `None` if every tool call's arguments are complete, parsable JSON.
|
||||
pub fn incomplete_tool_call(&self) -> Option<(&str, String)> {
|
||||
self.tool_calls
|
||||
.iter()
|
||||
.filter(|tc| !tc.name.is_empty())
|
||||
.find_map(|tc| {
|
||||
serde_json::from_str::<Value>(&tc.arguments)
|
||||
.err()
|
||||
.map(|e| (tc.name.as_str(), e.to_string()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StreamedTurn {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tool_call(name: &str, arguments: &str) -> ParsedToolCall {
|
||||
ParsedToolCall {
|
||||
id: "call_1".to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
is_complete: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_closes_unclosed_string() {
|
||||
let result = repair_incomplete_json("{\"key\": \"value");
|
||||
assert_eq!(result, "{\"key\": \"value\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_closes_unclosed_object() {
|
||||
let result = repair_incomplete_json("{\"key\": \"value\"");
|
||||
assert_eq!(result, "{\"key\": \"value\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_closes_nested_structures() {
|
||||
let result = repair_incomplete_json("{\"a\": [1, 2, {\"b\": 3");
|
||||
assert_eq!(result, "{\"a\": [1, 2, {\"b\": 3}]}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_leaves_complete_json_unchanged() {
|
||||
let s = "{\"a\": 1, \"b\": \"hello\"}";
|
||||
assert_eq!(repair_incomplete_json(s), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_handles_trailing_backslash_before_cut() {
|
||||
// Truncated inside an escape sequence like "hello\"
|
||||
let result = repair_incomplete_json("{\"text\": \"hello\\");
|
||||
assert_eq!(result, "{\"text\": \"hello\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_handles_escaped_quotes_inside_string() {
|
||||
// Input ends with `\"` where the `"` is the escaped character
|
||||
// (consumed by the backslash handler), so the string is still
|
||||
// unterminated. Repair adds `"` to close the string and `}` to
|
||||
// close the object.
|
||||
let result = repair_incomplete_json("{\"msg\": \"he said \\\"hello\\\"");
|
||||
assert_eq!(result, "{\"msg\": \"he said \\\"hello\\\"\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_assistant_message_repairs_truncated_tool_call() {
|
||||
let mut turn = StreamedTurn::new();
|
||||
turn.tool_calls.push(tool_call(
|
||||
"write",
|
||||
"{\"path\": \"a.txt\", \"content\": \"short\", \"reason\": \"trunc",
|
||||
));
|
||||
let msg = turn.build_assistant_message();
|
||||
let tcs = msg.tool_calls.expect("should produce tool calls");
|
||||
assert_eq!(tcs.len(), 1);
|
||||
let args = &tcs[0].function.arguments;
|
||||
assert!(
|
||||
args.is_object(),
|
||||
"args should be an object after repair: {args:?}"
|
||||
);
|
||||
assert_eq!(args.get("path").and_then(|v| v.as_str()), Some("a.txt"));
|
||||
assert_eq!(args.get("content").and_then(|v| v.as_str()), Some("short"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_tool_call_flags_truncated_json() {
|
||||
let mut turn = StreamedTurn::new();
|
||||
turn.tool_calls.push(tool_call(
|
||||
"write",
|
||||
"{\"path\": \"a.txt\", \"content\": \"unterm",
|
||||
));
|
||||
let bad = turn.incomplete_tool_call();
|
||||
assert_eq!(bad.map(|(name, _)| name), Some("write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_tool_call_accepts_complete_json() {
|
||||
let mut turn = StreamedTurn::new();
|
||||
turn.tool_calls.push(tool_call(
|
||||
"write",
|
||||
"{\"path\": \"a.txt\", \"content\": \"done\"}",
|
||||
));
|
||||
assert!(turn.incomplete_tool_call().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_tool_call_ignores_calls_without_a_name() {
|
||||
let mut turn = StreamedTurn::new();
|
||||
turn.tool_calls.push(tool_call("", "not json at all"));
|
||||
assert!(turn.incomplete_tool_call().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_tool_call_accepts_repaired_json() {
|
||||
// `incomplete_tool_call` uses raw `serde_json::from_str` (no repair)
|
||||
// so it should still flag truncated JSON even though
|
||||
// `build_assistant_message` will later repair it.
|
||||
let mut turn = StreamedTurn::new();
|
||||
turn.tool_calls.push(tool_call(
|
||||
"write",
|
||||
"{\"path\": \"a.txt\", \"content\": \"unterm",
|
||||
));
|
||||
// Even though it's repairable, raw parse should still fail
|
||||
assert!(serde_json::from_str::<Value>(&turn.tool_calls[0].arguments).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user