use std::pin::Pin;
use anyhow::Result;
use futures_util::Stream;
use tokio_util::sync::CancellationToken;
use crate::entities::sampling::SamplingConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiRole {
#[allow(dead_code)]
System,
User,
Assistant,
Tool,
}
impl ApiRole {
pub fn as_wire(self) -> &'static str {
match self {
ApiRole::System => "system",
ApiRole::User => "user",
ApiRole::Assistant => "assistant",
ApiRole::Tool => "tool",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ApiToolCall {
pub id: String,
pub name: String,
pub arguments: String,
pub thought_signature: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThinkingBlock {
pub text: String,
pub signature: String,
pub id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ThinkingRef {
pub id: Option<String>,
pub signature: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiImage {
pub mime: String,
pub data: std::sync::Arc<str>,
pub label: Option<String>,
}
impl ApiImage {
pub fn new(mime: impl Into<String>, data: &str, label: Option<String>) -> Self {
Self {
mime: mime.into(),
data: std::sync::Arc::from(data),
label,
}
}
}
#[derive(Debug, Clone)]
pub struct ApiMessage {
pub role: ApiRole,
pub content: String,
pub images: Vec<ApiImage>,
pub tool_call_id: Option<String>,
pub tool_calls: Vec<ApiToolCall>,
pub thinking: Vec<ThinkingBlock>,
}
impl ApiMessage {
pub fn user(content: impl Into<String>) -> Self {
Self {
role: ApiRole::User,
content: content.into(),
tool_call_id: None,
tool_calls: Vec::new(),
thinking: Vec::new(),
images: Vec::new(),
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: ApiRole::Assistant,
content: content.into(),
tool_call_id: None,
tool_calls: Vec::new(),
thinking: Vec::new(),
images: Vec::new(),
}
}
pub fn assistant_tool_calls(content: impl Into<String>, tool_calls: Vec<ApiToolCall>) -> Self {
Self {
role: ApiRole::Assistant,
content: content.into(),
tool_call_id: None,
tool_calls,
thinking: Vec::new(),
images: Vec::new(),
}
}
pub fn with_thinking_blocks(mut self, thinking: Vec<ThinkingBlock>) -> Self {
self.thinking = thinking;
self
}
pub fn with_images(mut self, images: Vec<ApiImage>) -> Self {
self.images = images;
self
}
pub fn tool(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self {
role: ApiRole::Tool,
content: content.into(),
tool_call_id: Some(tool_call_id.into()),
tool_calls: Vec::new(),
thinking: Vec::new(),
images: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct ToolSchema {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
#[derive(Debug, Clone, Default)]
pub struct ChatRequest {
pub system: Option<String>,
pub messages: Vec<ApiMessage>,
pub sampling: SamplingConfig,
pub tools: Vec<ToolSchema>,
pub continue_final: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FinishReason {
Stop,
Length,
ToolCalls,
Cancelled,
Error,
Filtered,
}
impl FinishReason {
pub fn from_wire(s: &str) -> Self {
match s {
"stop" => FinishReason::Stop,
"length" => FinishReason::Length,
"tool_calls" => FinishReason::ToolCalls,
"content_filter" => FinishReason::Filtered,
_ => FinishReason::Stop,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ModelCapabilities {
pub context_length: Option<u32>,
pub sampling_fields: Option<std::sync::Arc<[String]>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TokenUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub reasoning_tokens: u32,
pub prefill: Option<Prefill>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Prefill {
pub tokens: u32,
pub ms: u32,
}
impl Prefill {
pub fn tokens_per_second(self) -> Option<f64> {
(self.tokens > 0 && self.ms > 0)
.then(|| f64::from(self.tokens) * 1000.0 / f64::from(self.ms))
}
pub fn keep_larger(kept: &mut Option<Prefill>, sample: Option<Prefill>) {
if let Some(p) = sample
&& kept.is_none_or(|k| p.tokens > k.tokens)
{
*kept = Some(p);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ToolCallDelta {
pub index: usize,
pub id: Option<String>,
pub name: Option<String>,
pub arguments: String,
pub thought_signature: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChatChunk {
Text(String),
Thoughts(String),
ThoughtsSignature(ThinkingRef),
ToolCall(ToolCallDelta),
Usage(TokenUsage),
Error { message: String, transient: bool },
Retry {
attempt: u32,
max: u32,
delay: std::time::Duration,
},
Finished(FinishReason),
}
impl ChatChunk {
pub fn failure(message: String, transient: bool) -> [ChatChunk; 2] {
[
ChatChunk::Error { message, transient },
ChatChunk::Finished(FinishReason::Error),
]
}
}
#[derive(Debug, Default)]
pub struct ToolCallAccumulator {
calls: Vec<ApiToolCall>,
}
impl ToolCallAccumulator {
pub fn push(&mut self, delta: ToolCallDelta) {
while self.calls.len() <= delta.index {
self.calls.push(ApiToolCall::default());
}
let call = &mut self.calls[delta.index];
if let Some(id) = delta.id
&& !id.is_empty()
{
call.id = id;
}
if let Some(name) = delta.name
&& !name.is_empty()
{
call.name = name;
}
if let Some(sig) = delta.thought_signature
&& !sig.is_empty()
{
call.thought_signature = Some(sig);
}
call.arguments.push_str(&delta.arguments);
}
pub fn finish(self) -> Vec<ApiToolCall> {
self.calls
.into_iter()
.filter(|c| !c.name.is_empty())
.collect()
}
}
#[derive(Debug, Default)]
pub struct ThinkingAccumulator {
refs: Vec<ThinkingRef>,
}
impl ThinkingAccumulator {
pub fn push(&mut self, r: ThinkingRef) {
match (r.id, self.refs.last_mut()) {
(None, Some(last)) if last.id.is_none() => last.signature.push_str(&r.signature),
(id, _) => self.refs.push(ThinkingRef {
id,
signature: r.signature,
}),
}
}
pub fn finish(self) -> Vec<ThinkingRef> {
self.refs
}
}
pub type ChatStream = Pin<Box<dyn Stream<Item = ChatChunk> + Send>>;
#[async_trait::async_trait]
pub trait EngineBackend: Send + Sync {
async fn chat_stream(&self, req: ChatRequest, cancel: CancellationToken) -> Result<ChatStream>;
async fn context_budget(&self) -> Option<u32> {
None
}
async fn model_capabilities(&self) -> Option<ModelCapabilities> {
None
}
async fn vision(&self) -> VisionSupport {
VisionSupport::Unknown
}
async fn model_id(&self) -> Option<String> {
None
}
async fn parallel_slots(&self) -> Option<u32> {
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VisionSupport {
#[default]
Unknown,
Supported,
Unsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EmbedRole {
Query,
Passage,
}
#[async_trait::async_trait]
pub trait Embedder: Send + Sync {
async fn embed(&self, texts: Vec<String>, role: EmbedRole) -> Result<Vec<Vec<f32>>>;
}
pub struct UnavailableEmbedder;
#[async_trait::async_trait]
impl Embedder for UnavailableEmbedder {
async fn embed(&self, _texts: Vec<String>, _role: EmbedRole) -> Result<Vec<Vec<f32>>> {
anyhow::bail!("embedding server is not configured — RAG unavailable")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finish_reason_mapping() {
assert_eq!(FinishReason::from_wire("stop"), FinishReason::Stop);
assert_eq!(FinishReason::from_wire("length"), FinishReason::Length);
assert_eq!(
FinishReason::from_wire("tool_calls"),
FinishReason::ToolCalls
);
assert_eq!(
FinishReason::from_wire("content_filter"),
FinishReason::Filtered
);
assert_eq!(FinishReason::from_wire("weird"), FinishReason::Stop);
}
#[test]
fn role_wire_strings() {
assert_eq!(ApiRole::System.as_wire(), "system");
assert_eq!(ApiRole::Tool.as_wire(), "tool");
}
#[test]
fn accumulator_assembles_split_tool_call() {
let mut acc = ToolCallAccumulator::default();
acc.push(ToolCallDelta {
thought_signature: None,
index: 0,
id: Some("call_1".into()),
name: Some("note_save".into()),
arguments: "{\"con".into(),
});
acc.push(ToolCallDelta {
thought_signature: None,
index: 0,
id: None,
name: None,
arguments: "tent\":\"hi\"}".into(),
});
let calls = acc.finish();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].name, "note_save");
assert_eq!(calls[0].arguments, "{\"content\":\"hi\"}");
}
#[test]
fn accumulator_carries_thought_signature() {
let mut acc = ToolCallAccumulator::default();
acc.push(ToolCallDelta {
index: 0,
id: Some("calc-0".into()),
name: Some("calc".into()),
arguments: "{}".into(),
thought_signature: Some("SIG".into()),
});
let calls = acc.finish();
assert_eq!(calls[0].thought_signature.as_deref(), Some("SIG"));
}
#[test]
fn accumulator_handles_two_parallel_calls() {
let mut acc = ToolCallAccumulator::default();
acc.push(ToolCallDelta {
thought_signature: None,
index: 0,
id: Some("a".into()),
name: Some("f".into()),
arguments: "{}".into(),
});
acc.push(ToolCallDelta {
thought_signature: None,
index: 1,
id: Some("b".into()),
name: Some("g".into()),
arguments: "{}".into(),
});
assert_eq!(acc.finish().len(), 2);
}
#[test]
fn thinking_accumulator_keeps_one_entry_per_reasoning_item() {
let mut acc = ThinkingAccumulator::default();
for (id, enc) in [("rs_1", "AAA"), ("rs_2", "BBB"), ("rs_3", "CCC")] {
acc.push(ThinkingRef {
id: Some(id.into()),
signature: enc.into(),
});
}
let refs = acc.finish();
assert_eq!(refs.len(), 3);
assert_eq!(refs[0].id.as_deref(), Some("rs_1"));
assert_eq!(refs[0].signature, "AAA");
assert_eq!(refs[1].id.as_deref(), Some("rs_2"));
assert_eq!(refs[2].id.as_deref(), Some("rs_3"));
assert_eq!(refs[2].signature, "CCC");
}
#[test]
fn thinking_accumulator_appends_idless_deltas_into_one_block() {
let mut acc = ThinkingAccumulator::default();
acc.push(ThinkingRef {
id: None,
signature: "sig-".into(),
});
acc.push(ThinkingRef {
id: None,
signature: "tail".into(),
});
let refs = acc.finish();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].id, None);
assert_eq!(refs[0].signature, "sig-tail");
}
#[test]
fn thinking_accumulator_never_appends_to_an_item_with_an_id() {
let mut acc = ThinkingAccumulator::default();
acc.push(ThinkingRef {
id: Some("rs_1".into()),
signature: "AAA".into(),
});
acc.push(ThinkingRef {
id: None,
signature: "sig".into(),
});
let refs = acc.finish();
assert_eq!(refs.len(), 2);
assert_eq!(refs[0].signature, "AAA");
assert_eq!(refs[1].id, None);
assert_eq!(refs[1].signature, "sig");
}
#[test]
fn thinking_accumulator_without_input_gives_no_blocks() {
assert!(ThinkingAccumulator::default().finish().is_empty());
}
#[test]
fn keep_larger_keeps_the_larger_by_tokens_and_ignores_none() {
let mut kept = None;
super::Prefill::keep_larger(&mut kept, None);
assert!(kept.is_none());
super::Prefill::keep_larger(&mut kept, Some(super::Prefill { tokens: 40, ms: 20 }));
assert_eq!(kept.map(|p| p.tokens), Some(40));
super::Prefill::keep_larger(
&mut kept,
Some(super::Prefill {
tokens: 3236,
ms: 1615,
}),
);
assert_eq!(kept.map(|p| p.tokens), Some(3236));
super::Prefill::keep_larger(&mut kept, Some(super::Prefill { tokens: 45, ms: 20 }));
assert_eq!(
kept.map(|p| p.tokens),
Some(3236),
"a smaller sample changes nothing"
);
super::Prefill::keep_larger(&mut kept, None);
assert_eq!(kept.map(|p| p.ms), Some(1615), "nor does none");
}
}