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.
317 lines
12 KiB
Rust
317 lines
12 KiB
Rust
#![allow(
|
|
clippy::cast_possible_truncation,
|
|
clippy::cast_sign_loss,
|
|
clippy::cast_precision_loss,
|
|
clippy::cast_possible_wrap
|
|
)]
|
|
//! Provider-facing DTOs: chat completion request, response, streaming types,
|
|
//! and the SSE stream parser.
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Chat request / response
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible
|
|
/// provider.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ChatRequest {
|
|
pub model: String,
|
|
pub messages: Vec<super::message::ChatMessage>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub max_tokens: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub temperature: Option<f32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub tools: Option<Vec<ToolDef>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub stream: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub top_p: Option<f32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub stop: Option<Vec<String>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub stream_options: Option<StreamOptions>,
|
|
}
|
|
|
|
/// Streaming options for the request; `include_usage` asks the provider to
|
|
/// emit a final usage chunk in the SSE stream.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct StreamOptions {
|
|
pub include_usage: bool,
|
|
}
|
|
|
|
/// Wire format for a single tool definition sent to the provider.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ToolDef {
|
|
#[serde(rename = "type")]
|
|
pub type_: String,
|
|
pub function: ToolFunctionDef,
|
|
}
|
|
|
|
/// Name, description, and JSON schema parameters for a tool definition.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ToolFunctionDef {
|
|
pub name: String,
|
|
pub description: String,
|
|
pub parameters: Value,
|
|
}
|
|
|
|
/// Non-streaming chat completion response returned by the provider.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ChatResponse {
|
|
pub id: String,
|
|
pub model: String,
|
|
pub choices: Vec<Choice>,
|
|
pub usage: Option<Usage>,
|
|
pub created: Option<i64>,
|
|
}
|
|
|
|
/// One completion candidate within a `ChatResponse.choices` list.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Choice {
|
|
pub index: u32,
|
|
pub message: super::message::ChatMessage,
|
|
pub finish_reason: Option<String>,
|
|
}
|
|
|
|
/// Token counts and optional cost breakdown for a single completion request.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct Usage {
|
|
pub prompt_tokens: Option<u32>,
|
|
pub completion_tokens: Option<u32>,
|
|
pub total_tokens: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub prompt_tokens_cost: Option<f64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub completion_tokens_cost: Option<f64>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SSE streaming
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// 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:") {
|
|
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).
|
|
///
|
|
/// 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(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(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(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(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
|
|
}
|
|
}
|
|
|
|
impl Default for SseParser {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|