Files
zesdex/apps/domain/src/core/provider.rs
T
asepharyana da2ed6da25 feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators
feat(tui): create workflow panel for agent status and progress visualization
feat(web): introduce web frontend interface with static file serving
feat(ws): add WebSocket interface for real-time communication and session management
2026-07-20 09:04:57 +07:00

388 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Provider-facing DTOs: chat completion request, response, streaming types,
//! and the SSE stream parser.
//!
//! # Flow
//!
//! 1. **Request** — [`ChatRequest`] is built with model, messages, tools,
//! streaming options and sent to the LLM provider.
//! 2. **Response** — Non-streaming responses arrive as [`ChatResponse`] with
//! [`Choice`]s containing the full [`ChatMessage`](super::message::ChatMessage).
//! 3. **Streaming** — SSE chunks are fed into [`SseParser::feed`] which yields
//! [`StreamEvent`]s: token/text, reasoning, tool-call deltas, usage, done.
//!
//! # Components
//!
//! - `ChatRequest` / `StreamOptions` / `ToolDef` / `ToolFunctionDef` — outbound
//! - `ChatResponse` / `Choice` / `Delta` / `TokenUsage` — non-streaming inbound
//! - `StreamEvent` — one atomic streaming event (Token, Reasoning,
//! ToolCallDelta, Usage, Done, Error)
//! - `SseParser` — incremental SSE frame parser: `feed()` → `Vec<StreamEvent>`
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing;
// ---------------------------------------------------------------------------
// Chat request / response
// ---------------------------------------------------------------------------
/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible
/// provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRequest {
/// Model identifier, e.g. `"anthropic/claude-opus-4-8"`.
pub model: String,
/// Full message history (system + user + assistant + tool turns).
pub messages: Vec<super::message::ChatMessage>,
/// Maximum number of output tokens.
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
/// Sampling temperature (0.0 2.0).
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
/// Tool definitions available to the model.
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ToolDef>>,
/// Controls which (if any) function is called by the model.
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>,
/// Whether to use SSE streaming (`true`) or a single response.
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
/// Nucleus sampling threshold.
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
/// Sequences where the model should stop generation.
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>,
/// Additional streaming options (e.g. `include_usage`).
#[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 {
/// The tool type discriminator, e.g. `"function"`.
#[serde(rename = "type")]
pub type_: String,
/// The function definition (name, description, JSON schema).
pub function: ToolFunctionDef,
}
/// Name, description, and JSON schema parameters for a tool definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunctionDef {
/// The function name the model may invoke.
pub name: String,
/// Human-readable description of what the function does.
pub description: String,
/// JSON Schema object describing the expected arguments.
pub parameters: Value,
}
/// Non-streaming chat completion response returned by the provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatResponse {
/// Unique response identifier from the provider.
pub id: String,
/// Object type, e.g. `"chat.completion"`.
pub object: Option<String>,
/// Model identifier that produced this response.
pub model: String,
/// One or more completion candidates.
pub choices: Vec<Choice>,
/// Token usage statistics (prompt, completion, total).
pub usage: Option<TokenUsage>,
/// Unix-timestamp of response creation.
pub created: Option<i64>,
}
/// One completion candidate within a `ChatResponse.choices` list.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Choice {
/// Zero-based index of this choice in the candidate list.
pub index: u32,
/// Full message (non-streaming response).
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<super::message::ChatMessage>,
/// Incremental delta (streaming response).
#[serde(skip_serializing_if = "Option::is_none")]
pub delta: Option<Delta>,
/// Why the model stopped: `"stop"`, `"tool_calls"`, `"length"`, etc.
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>,
}
/// Incremental delta emitted in a streaming SSE chunk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Delta {
/// Role being set for the first streaming chunk.
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<super::message::Role>,
/// Incremental text content delta.
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// Incremental tool-call delta (partial name/arguments).
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
}
/// Token counts and optional cost breakdown for a single completion request.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TokenUsage {
/// Tokens consumed by the prompt (input).
pub prompt_tokens: u32,
/// Tokens consumed by the completion (output).
pub completion_tokens: u32,
/// Sum of prompt + completion tokens.
pub total_tokens: u32,
/// Estimated cost for prompt tokens (provider-specific).
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_tokens_cost: Option<f64>,
/// Estimated cost for completion tokens (provider-specific).
#[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 {
/// An incremental text token.
Token(String),
/// An incremental reasoning token (Anthropic `reasoning_content`).
Reasoning(String),
/// An incremental tool-call delta (partial ID, name, or arguments).
ToolCallDelta {
/// Tool-call index (multiple calls in one response).
index: usize,
/// Optional tool-call ID (usually in the first delta for a call).
id: Option<String>,
/// Optional function name (usually in the first delta for a call).
name: Option<String>,
/// Partial JSON arguments delta for this tool call.
arguments_delta: String,
},
/// Final usage chunk with token counts.
Usage {
prompt_tokens: u64,
completion_tokens: u64,
total_tokens: u64,
},
/// Stream complete (all tokens have been delivered).
Done,
/// A stream-level error occurred.
Error(String),
}
/// Buffered SSE frame parser that accumulates raw `data:` lines and
/// flushes a `StreamEvent` on each blank-line boundary.
pub struct SseParser {
/// Leftover bytes from the last chunk that did not end with `\n`.
buffer: String,
/// The current `event:` type (set by `event:` lines, cleared on flush).
event_type: Option<String>,
/// Accumulated `data:` lines for the current event frame.
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
},
);
let index = usize::try_from(index).unwrap_or(0);
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()
}
}