This commit is contained in:
valenti
2026-10-17 15:49:20 +01:00
parent 430997bd2e
commit bfed42c3b3
124 changed files with 5646 additions and 249 deletions
+100 -2
View File
@@ -23,6 +23,7 @@
//! - [`LlmClient::chat`] — single blocking request (simplest, no partial output).
use crate::config::Config;
use crate::tools::ToolRegistry;
use anyhow::{anyhow, Result};
use futures_util::StreamExt;
use reqwest::Client;
@@ -53,6 +54,11 @@ impl ChatMessage {
pub fn assistant(content: impl Into<String>) -> Self {
Self { role: "assistant".into(), content: content.into() }
}
/// A tool result handed back to the model after a tool call.
pub fn tool(content: impl Into<String>) -> Self {
Self { role: "tool".into(), content: content.into() }
}
}
/// Client for an OpenAI-compatible `/chat/completions` endpoint.
@@ -72,9 +78,14 @@ pub struct LlmClient {
/// Strip the chain-of-thought (`reasoning_content` / inline think blocks)
/// from the reply before it is spoken and shown in the chat box.
strip_reasoning: bool,
/// Optional tool registry. When set, every request advertises the tools and
/// the orchestrator runs the call → execute → re-prompt loop. See
/// [`crate::tools`].
tools: Option<Arc<ToolRegistry>>,
}
impl LlmClient {
#[allow(clippy::too_many_arguments)]
pub fn new(
base_url: &str,
api_key: &str,
@@ -83,6 +94,7 @@ impl LlmClient {
temperature: f32,
enable_thinking: Option<bool>,
strip_reasoning: bool,
tools: Option<Arc<ToolRegistry>>,
) -> Self {
let endpoint = format!("{}/chat/completions", base_url.trim_end_matches('/'));
// No global request timeout: an SSE stream can legitimately stay open
@@ -100,6 +112,7 @@ impl LlmClient {
temperature,
enable_thinking,
strip_reasoning,
tools,
}
}
@@ -123,6 +136,12 @@ impl LlmClient {
self.strip_reasoning
}
/// The tool registry, if tool calling is configured. Used by the
/// orchestrator to execute parsed calls.
pub fn tools(&self) -> Option<&Arc<ToolRegistry>> {
self.tools.as_ref()
}
fn request_body(&self, messages: &[ChatMessage], stream: bool) -> serde_json::Value {
let mut body = json!({
"model": self.model,
@@ -134,6 +153,15 @@ impl LlmClient {
if let Some(enable) = self.enable_thinking {
body["chat_template_kwargs"] = json!({ "enable_thinking": enable });
}
// Advertise the configured tools so the model can call them. The
// server has no tool parser, so the call comes back as text in the
// native `<tool_call>` format — parsed client-side in `crate::tools`.
if let Some(reg) = &self.tools {
if !reg.is_empty() {
body["tools"] = reg.openai_tools();
body["tool_choice"] = json!("auto");
}
}
body
}
@@ -190,6 +218,52 @@ impl LlmClient {
Ok(self.clean(&self.delta_text(message)))
}
/// One-off non-streaming completion with an explicit system/user pair, its
/// own token budget and temperature, and **no tools advertised**.
///
/// Used by auxiliary calls that must not go through the conversational
/// prompt or the tool loop — language detection and phrase-list generation
/// (see [`crate::lang`]).
pub async fn simple_chat(
&self,
system: &str,
user: &str,
max_tokens: u32,
temperature: f32,
cancel: &CancellationToken,
) -> Result<String> {
let mut body = json!({
"model": self.model,
"messages": [ChatMessage::system(system), ChatMessage::user(user)],
"max_tokens": max_tokens,
"temperature": temperature,
"stream": false,
});
if let Some(enable) = self.enable_thinking {
body["chat_template_kwargs"] = json!({ "enable_thinking": enable });
}
let req = self
.http
.post(&self.endpoint)
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&body)
.send();
let resp = tokio::select! {
r = req => r?,
_ = cancel.cancelled() => return Ok(String::new()),
};
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(anyhow!("LLM HTTP {}: {}", status, text.trim()));
}
let value: serde_json::Value = resp.json().await?;
let message = &value["choices"][0]["message"];
Ok(self.clean(&self.delta_text(message)))
}
/// Streaming request. Every clean (think-stripped) text delta is pushed to
/// `delta_tx` as it arrives; the full clean reply is returned at the end.
///
@@ -304,9 +378,31 @@ pub fn create_llm_client(config: &Config) -> Option<Arc<LlmClient>> {
config.llm_temperature,
config.llm_enable_thinking,
config.llm_strip_reasoning,
load_tools(config),
)))
}
/// Load the tool registry from `LLM_TOOLS_FILE` when tool calling is enabled.
/// A missing or invalid file is logged and tool calling is disabled — the
/// pipeline keeps working without tools.
fn load_tools(config: &Config) -> Option<Arc<ToolRegistry>> {
if !config.llm_tools_enabled || config.llm_tools_file.trim().is_empty() {
return None;
}
let path = std::path::Path::new(config.llm_tools_file.trim());
match ToolRegistry::load(path, config.llm_tool_timeout_secs, config.llm_tool_result_max_chars) {
Ok(reg) if reg.is_empty() => {
warn!("tools file present but empty — tool calling disabled");
None
}
Ok(reg) => Some(Arc::new(reg)),
Err(e) => {
warn!(error = %e, "failed to load tools file — tool calling disabled");
None
}
}
}
// --- Chain-of-thought stripping --------------------------------------------
// Opening / closing chain-of-thought markers emitted by Qwen3-style reasoning
@@ -414,13 +510,15 @@ mod tests {
fn request_body_thinking_is_opt_in() {
let msgs = vec![ChatMessage::user("hi")];
// Unset: the vendor-specific field must not be sent.
let plain = LlmClient::new("http://x/v1", "k", "m", 16, 0.5, None, true);
let plain = LlmClient::new("http://x/v1", "k", "m", 16, 0.5, None, true, None);
assert!(plain.request_body(&msgs, true).get("chat_template_kwargs").is_none());
// Explicit: forwarded verbatim.
let off = LlmClient::new("http://x/v1", "k", "m", 16, 0.5, Some(false), true);
let off = LlmClient::new("http://x/v1", "k", "m", 16, 0.5, Some(false), true, None);
let body = off.request_body(&msgs, false);
assert_eq!(body["chat_template_kwargs"]["enable_thinking"], false);
assert_eq!(body["stream"], false);
// Without a registry no `tools` field is advertised.
assert!(body.get("tools").is_none());
}
#[test]