use std::collections::BTreeMap;
use std::path::Path;
use base64::Engine;
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::error::{LlmError, Result, SkadooshError};
use crate::llm::splitter::ClauseSplitter;
use crate::tools::{ShellExecutor, ToolExecutor};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentBlock {
#[serde(rename = "text")]
Text {
text: String,
},
#[serde(rename = "image_url")]
Image {
image_url: ImageUrl,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageUrl {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MessageContent {
Text(String),
Blocks(Vec<ContentBlock>),
}
impl MessageContent {
pub fn as_text(&self) -> Option<&str> {
match self {
MessageContent::Text(s) => Some(s),
MessageContent::Blocks(_) => None,
}
}
}
impl PartialEq<&str> for MessageContent {
fn eq(&self, other: &&str) -> bool {
matches!(self, MessageContent::Text(s) if s == other)
}
}
impl PartialEq<MessageContent> for &str {
fn eq(&self, other: &MessageContent) -> bool {
matches!(other, MessageContent::Text(s) if s == *self)
}
}
impl From<String> for MessageContent {
fn from(s: String) -> Self {
MessageContent::Text(s)
}
}
impl From<&str> for MessageContent {
fn from(s: &str) -> Self {
MessageContent::Text(s.to_string())
}
}
impl From<serde_json::Value> for MessageContent {
fn from(v: serde_json::Value) -> Self {
match v {
serde_json::Value::String(s) => MessageContent::Text(s),
serde_json::Value::Array(ref arr) => {
let val = serde_json::Value::Array(arr.clone());
if let Ok(blocks) = serde_json::from_value::<Vec<ContentBlock>>(val) {
MessageContent::Blocks(blocks)
} else {
MessageContent::Text(v.to_string())
}
}
other => MessageContent::Text(other.to_string()),
}
}
}
pub fn image_to_data_uri(path: &Path) -> std::result::Result<String, std::io::Error> {
let bytes = std::fs::read(path)?;
let mime = mime_from_ext(path);
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(format!("data:{mime};base64,{b64}"))
}
pub fn load_tools_file(path: &Path) -> std::result::Result<Vec<Tool>, std::io::Error> {
let bytes = std::fs::read(path)?;
let tools: Vec<Tool> = serde_json::from_slice(&bytes).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("invalid tools JSON: {e}"),
)
})?;
Ok(tools)
}
fn mime_from_ext(path: &Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("bmp") => "image/bmp",
Some("tiff") | Some("tif") => "image/tiff",
Some("pdf") => "application/pdf",
_ => "image/png", }
}
pub(crate) const CLAUSE_MIN_LEN: usize = 4;
pub(crate) const CLAUSE_MAX_LEN: usize = 160;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub role: String,
pub content: MessageContent,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionDef {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub parameters: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tool {
#[serde(rename = "type")]
pub tool_type: String,
pub function: FunctionDef,
}
impl Tool {
pub fn function(name: &str, description: &str, parameters: serde_json::Value) -> Self {
Self {
tool_type: "function".to_string(),
function: FunctionDef {
name: name.to_string(),
description: Some(description.to_string()),
parameters,
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallFunction {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(rename = "type")]
#[serde(skip_serializing_if = "Option::is_none")]
pub call_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub function: Option<ToolCallFunction>,
}
#[derive(Debug, Clone)]
pub enum SseDelta {
Text(String),
ToolCall(ToolCall),
Done,
}
pub struct LlmClient {
http: reqwest::Client,
base_url: String,
model: String,
api_key: Option<String>,
max_history_turns: usize,
system_prompt: String,
history: Vec<Message>,
image_paths: Vec<std::path::PathBuf>,
tools: Vec<Tool>,
max_tool_rounds: usize,
tool_executor: Option<Box<dyn ToolExecutor>>,
}
impl LlmClient {
pub fn new(
base_url: &str,
model: &str,
system_prompt: &str,
max_history_turns: usize,
api_key: Option<String>,
) -> Self {
Self {
http: reqwest::Client::new(),
base_url: base_url.trim_end_matches('/').to_string(),
model: model.to_string(),
api_key,
max_history_turns,
system_prompt: system_prompt.to_string(),
history: vec![Message {
role: "system".to_string(),
content: MessageContent::Text(system_prompt.to_string()),
tool_call_id: None,
tool_calls: None,
}],
image_paths: Vec::new(),
tools: Vec::new(),
max_tool_rounds: 5,
tool_executor: None,
}
}
pub fn model_name(&self) -> &str {
&self.model
}
pub fn with_images(mut self, paths: Vec<std::path::PathBuf>) -> Self {
self.image_paths = paths;
self
}
pub fn clear_images(&mut self) {
self.image_paths.clear();
}
pub fn with_tools(mut self, tools: Vec<Tool>) -> Self {
self.tools = tools;
self
}
pub fn with_max_tool_rounds(mut self, max: usize) -> Self {
self.max_tool_rounds = max;
self
}
pub fn with_tool_executor(mut self, executor: Box<dyn ToolExecutor>) -> Self {
self.tool_executor = Some(executor);
self
}
pub(crate) fn from_config(config: &crate::config::Config) -> Self {
let tools = if let Some(ref path) = config.tools_file {
load_tools_file(path).unwrap_or_default()
} else {
Vec::new()
};
let tool_executor: Option<Box<dyn ToolExecutor>> = if config.tools_file.is_some() {
Some(Box::new(ShellExecutor::new()))
} else {
None
};
Self {
image_paths: config.images.clone(),
tools,
max_tool_rounds: config.max_tool_rounds,
tool_executor,
..Self::new(
&config.llm_url,
&config.llm_model,
&config.system_prompt,
config.max_history_turns,
config.api_key.clone(),
)
}
}
pub async fn stream_reply(
&mut self,
user: &str,
clauses: mpsc::Sender<String>,
cancel: CancellationToken,
) -> Result<()> {
let images = std::mem::take(&mut self.image_paths);
let user_content = if images.is_empty() {
MessageContent::Text(user.to_string())
} else {
let mut blocks = vec![ContentBlock::Text {
text: user.to_string(),
}];
for path in &images {
match image_to_data_uri(path) {
Ok(data_uri) => blocks.push(ContentBlock::Image {
image_url: ImageUrl {
url: data_uri,
detail: Some("auto".to_string()),
},
}),
Err(e) => {
tracing::warn!(path=%path.display(), error=%e, "failed to load image; skipping");
}
}
}
if blocks.len() == 1 {
MessageContent::Text(user.to_string())
} else {
MessageContent::Blocks(blocks)
}
};
self.history.push(Message {
role: "user".to_string(),
content: user_content,
tool_call_id: None,
tool_calls: None,
});
let result = self.stream_reply_inner(&clauses, &cancel).await;
if matches!(result, Err(SkadooshError::Llm(LlmError::Cancelled))) {
self.truncate_history();
}
result
}
async fn stream_reply_inner(
&mut self,
clauses: &mpsc::Sender<String>,
cancel: &CancellationToken,
) -> Result<()> {
let tool_count = self.tools.len();
let mut total_reply = String::new();
for tool_round in 0..=self.max_tool_rounds {
let send_tools = tool_round < self.max_tool_rounds && tool_count > 0;
let mut body = serde_json::json!({
"model": self.model,
"messages": self.history,
"stream": true,
});
if send_tools {
body["tools"] = serde_json::to_value(&self.tools).unwrap_or_default();
}
let url = format!("{}/chat/completions", self.base_url);
let mut request = self.http.post(&url).json(&body);
if let Some(key) = &self.api_key {
request = request.bearer_auth(key);
}
let resp = tokio::select! {
_ = cancel.cancelled() => return Err(LlmError::Cancelled.into()),
r = request.send() => r.map_err(LlmError::Http)?,
};
let resp = ensure_success(resp).await?;
let mut stream = resp.bytes_stream();
let mut splitter = ClauseSplitter::new(CLAUSE_MIN_LEN, CLAUSE_MAX_LEN);
let mut round_reply = String::new();
let mut tool_calls: BTreeMap<usize, ToolCall> = BTreeMap::new();
let mut lines = SseLineBuffer::default();
let mut done = false;
let mut eof = false;
while !done && !eof {
let chunk = tokio::select! {
_ = cancel.cancelled() => return Err(LlmError::Cancelled.into()),
c = stream.next() => c,
};
match chunk {
Some(Ok(bytes)) => lines.feed(&bytes),
Some(Err(e)) => return Err(LlmError::Http(e).into()),
None => {
lines.close();
eof = true;
}
}
while let Some(line) = lines.next_line() {
match parse_sse_delta(&line) {
None => {}
Some(Ok(SseDelta::Done)) => {
done = true;
break;
}
Some(Ok(SseDelta::Text(token))) => {
total_reply.push_str(&token);
round_reply.push_str(&token);
for clause in splitter.push(&token) {
if !send_clause(clauses, cancel, clause).await? {
tracing::debug!("clauses receiver dropped");
return Ok(());
}
}
}
Some(Ok(SseDelta::ToolCall(tc))) => {
let idx = tc.index.unwrap_or(0) as usize;
let entry = tool_calls.entry(idx).or_insert_with(|| ToolCall {
index: None,
id: None,
call_type: None,
function: None,
});
if tc.index.is_some() {
entry.index = tc.index;
}
if tc.id.is_some() {
entry.id = tc.id;
}
if tc.call_type.is_some() {
entry.call_type = tc.call_type;
}
if let Some(ref f) = tc.function {
let ef = entry.function.get_or_insert(ToolCallFunction {
name: None,
arguments: None,
});
if f.name.is_some() {
ef.name = f.name.clone();
}
if let Some(ref args) = f.arguments {
ef.arguments =
Some(ef.arguments.take().unwrap_or_default() + args);
}
}
}
Some(Err(e)) => {
tracing::warn!(error = %e, "skipping malformed SSE data line");
}
}
}
}
if let Some(rest) = splitter.flush() {
if !send_clause(clauses, cancel, rest).await? {
return Ok(());
}
}
if tool_calls.is_empty() {
self.history.push(Message {
role: "assistant".to_string(),
content: MessageContent::Text(round_reply),
tool_call_id: None,
tool_calls: None,
});
self.truncate_history();
return Ok(());
}
let calls: Vec<ToolCall> = tool_calls.into_values().collect();
tracing::info!(
round = tool_round,
count = calls.len(),
"tool calls received"
);
self.history.push(Message {
role: "assistant".to_string(),
content: MessageContent::Text(String::new()),
tool_call_id: None,
tool_calls: Some(calls.clone()),
});
for tc in &calls {
let name = tc
.function
.as_ref()
.and_then(|f| f.name.as_deref())
.unwrap_or("?");
let args = tc
.function
.as_ref()
.and_then(|f| f.arguments.as_deref())
.unwrap_or("{}");
let _ = clauses.send(format!("\x00TOOL:{name}:{args}")).await;
}
for tc in &calls {
let call_id = tc.id.clone().unwrap_or_else(|| "call_unknown".to_string());
let name = tc
.function
.as_ref()
.and_then(|f| f.name.as_deref())
.unwrap_or("");
let args = tc
.function
.as_ref()
.and_then(|f| f.arguments.as_deref())
.unwrap_or("{}");
let content = match self.tool_executor.as_ref() {
Some(executor) => match executor.execute(name, args) {
Ok(out) => out,
Err(e) => {
tracing::warn!(tool = %name, error = %e, "tool execution failed");
let body = serde_json::to_string(&e.to_string())
.unwrap_or_else(|_| "\"<unprintable error>\"".to_string());
format!("{{\"error\":{body}}}")
}
},
None => "{\"error\":\"tool execution not configured; respond with text\"}"
.to_string(),
};
self.history.push(Message {
role: "tool".to_string(),
content: MessageContent::Text(content),
tool_call_id: Some(call_id),
tool_calls: None,
});
}
}
self.history.push(Message {
role: "assistant".to_string(),
content: MessageContent::Text(total_reply),
tool_call_id: None,
tool_calls: None,
});
self.truncate_history();
Ok(())
}
pub fn history(&self) -> &[Message] {
&self.history
}
pub fn clear_history(&mut self) {
self.history.clear();
self.history.push(Message {
role: "system".to_string(),
content: MessageContent::Text(self.system_prompt.clone()),
tool_call_id: None,
tool_calls: None,
});
}
fn truncate_history(&mut self) {
let keep = 2 * self.max_history_turns;
if self.history.len() > 1 + keep {
let drop = self.history.len() - 1 - keep;
self.history.drain(1..=drop);
}
}
}
pub(crate) async fn ensure_success(resp: reqwest::Response) -> Result<reqwest::Response> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
let text = resp.text().await.unwrap_or_default();
Err(LlmError::Api {
status: status.as_u16(),
body: text.chars().take(1024).collect(),
}
.into())
}
async fn send_clause(
clauses: &mpsc::Sender<String>,
cancel: &CancellationToken,
clause: String,
) -> std::result::Result<bool, LlmError> {
tokio::select! {
_ = cancel.cancelled() => Err(LlmError::Cancelled),
sent = clauses.send(clause) => Ok(sent.is_ok()),
}
}
#[derive(Default)]
pub(crate) struct SseLineBuffer {
buf: Vec<u8>,
eof: bool,
}
impl SseLineBuffer {
pub(crate) fn feed(&mut self, chunk: &[u8]) {
self.buf.extend_from_slice(chunk);
}
pub(crate) fn close(&mut self) {
self.eof = true;
}
pub(crate) fn next_line(&mut self) -> Option<String> {
if let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
let line_bytes: Vec<u8> = self.buf.drain(..=nl).collect();
return Some(String::from_utf8_lossy(&line_bytes).into_owned());
}
if self.eof && !self.buf.is_empty() {
let rest = std::mem::take(&mut self.buf);
return Some(String::from_utf8_lossy(&rest).into_owned());
}
None
}
}
pub fn parse_sse_line(line: &str) -> Option<Result<Option<String>>> {
match parse_sse_delta(line)? {
Ok(SseDelta::Text(t)) => Some(Ok(Some(t))),
Ok(SseDelta::Done) => Some(Ok(None)),
Ok(SseDelta::ToolCall(_)) => None, Err(e) => Some(Err(e)),
}
}
pub fn parse_sse_delta(line: &str) -> Option<Result<SseDelta>> {
let line = line.trim();
if line.is_empty() || line.starts_with(':') {
return None;
}
let data = line.strip_prefix("data:")?;
let data = data.trim();
if data == "[DONE]" {
return Some(Ok(SseDelta::Done));
}
let parsed: serde_json::Value = match serde_json::from_str(data) {
Ok(v) => v,
Err(e) => {
return Some(Err(LlmError::Sse(format!("malformed SSE data: {e}")).into()));
}
};
let choices = parsed.get("choices")?.as_array()?;
let choice = choices.first()?;
let delta = choice.get("delta")?;
if let Some(tool_calls) = delta.get("tool_calls").and_then(|v| v.as_array()) {
if let Some(tc) = tool_calls.first() {
if let Ok(tool_call) = serde_json::from_value::<ToolCall>(tc.clone()) {
return Some(Ok(SseDelta::ToolCall(tool_call)));
}
}
return None;
}
let token = delta.get("content")?.as_str()?;
if token.is_empty() {
None
} else {
Some(Ok(SseDelta::Text(token.to_string())))
}
}