step2
This commit is contained in:
+100
-2
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user