feat(llm): 引入 rig-core 并建立新 LLM 门面(Ollama 首个打通)

- 依赖改用 rig-core 0.41(仅 reqwest+rustls,不引入 agent/derive)
- 新门面:provider 枚举 + 统一配置 + 单一生成入口 + 凭据校验 + 错误映射
- Ollama 走 rig /api/chat,参数映射与错误文案与旧实现一致
- 用 rig 请求录制 mock 断言请求形状;8 个新单元测试
This commit is contained in:
2026-08-17 15:33:00 +08:00
parent bba15501c6
commit 29c6ff3935
3 changed files with 385 additions and 1 deletions

View File

@@ -11,6 +11,7 @@ pub mod openai;
pub mod openrouter;
pub mod parsing;
pub mod prompts;
pub mod rig;
pub mod thinking;
pub use anthropic::AnthropicClient;

378
src/llm/rig/mod.rs Normal file
View File

@@ -0,0 +1,378 @@
//! 基于 rig-core 的新 LLM 门面。
//!
//! 迁移扩张期:本模块与旧的手写实现并存,按 ticket 逐个接入 provider。
//! 全部 provider 接入并切换后,旧实现将被删除。
use crate::config::manager::ConfigManager;
use anyhow::{Context, Result, bail};
use rig_core::{
client::{CompletionClient, Nothing, VerifyClient},
completion::{AssistantContent, CompletionError, CompletionModel},
http_client::{HttpClientExt, ReqwestClient},
providers::ollama,
wasm_compat::{WasmCompatSend, WasmCompatSync},
};
use std::time::Duration;
/// LLM 客户端运行时配置(与用户配置解耦的参数)。
#[derive(Debug, Clone)]
pub struct LlmClientConfig {
pub max_tokens: u64,
pub temperature: f64,
pub timeout: Duration,
}
impl Default for LlmClientConfig {
fn default() -> Self {
Self {
max_tokens: 500,
temperature: 0.7,
timeout: Duration::from_secs(30),
}
}
}
/// Provider 后端枚举。
///
/// 泛型 `H` 是 HTTP 后端(生产环境为 reqwest 客户端),作为测试注入点:
/// 单测注入 rig 提供的 mock 后端以断言请求形状与响应处理。
#[derive(Clone)]
pub enum Backend<H = ReqwestClient> {
Ollama(ollama::Client<H>),
}
/// 基于 rig 的 LLM 客户端门面。
#[derive(Clone)]
pub struct LlmClient<H = ReqwestClient> {
backend: Backend<H>,
model: String,
provider: String,
config: LlmClientConfig,
thinking_enabled: bool,
}
impl LlmClient {
/// 从配置管理器构建(默认 reqwest 后端)。
pub async fn from_config(manager: &ConfigManager) -> Result<Self> {
Self::from_config_with_think(manager, manager.config().llm.thinking_enabled).await
}
/// 从配置构建thinking 由参数显式指定。
pub async fn from_config_with_think(
manager: &ConfigManager,
thinking_enabled: bool,
) -> Result<Self> {
let config = manager.config();
let cfg = LlmClientConfig {
max_tokens: config.llm.max_tokens as u64,
temperature: config.llm.temperature as f64,
timeout: Duration::from_secs(config.llm.timeout),
};
let provider = manager.llm_provider().to_string();
let model = manager.llm_model().to_string();
let base_url = manager.llm_base_url();
let http = ReqwestClient::builder()
.timeout(cfg.timeout)
.build()
.context("Failed to create HTTP client")?;
let backend = match provider.as_str() {
"ollama" => Backend::Ollama(build_ollama_client(&base_url, http)?),
// 其余 provider 在后续 ticket 接入,扩张期内新门面尚未被应用使用。
"openai" | "anthropic" | "kimi" | "deepseek" | "openrouter" => {
bail!("Provider '{}' is not available in the new LLM backend yet", provider)
}
_ => bail!("Unknown LLM provider: {}", provider),
};
Ok(Self {
backend,
model,
provider,
config: cfg,
thinking_enabled,
})
}
}
impl<H> LlmClient<H> {
/// 内部与测试构造入口。
pub(crate) fn new(
backend: Backend<H>,
model: impl Into<String>,
provider: impl Into<String>,
config: LlmClientConfig,
thinking_enabled: bool,
) -> Self {
Self {
backend,
model: model.into(),
provider: provider.into(),
config,
thinking_enabled,
}
}
/// 当前是否处于 thinking 模式。
pub(crate) fn thinking_enabled(&self) -> bool {
self.thinking_enabled
}
}
impl<H> LlmClient<H>
where
H: HttpClientExt
+ Clone
+ Default
+ std::fmt::Debug
+ Send
+ Sync
+ WasmCompatSend
+ WasmCompatSync
+ 'static,
{
/// 生成文本thinking=false 的非流式路径)。
pub async fn generate(&self, system: Option<&str>, user: &str) -> Result<String> {
match &self.backend {
Backend::Ollama(client) => {
let model = client.completion_model(self.model.as_str());
complete(&model, &self.provider, system, user, &self.config).await
}
}
}
/// 检查 provider 是否可用(走 rig 凭据校验接口)。
pub async fn is_available(&self) -> bool {
match &self.backend {
Backend::Ollama(client) => client.verify().await.is_ok(),
}
}
}
/// 构建 Ollama 客户端(无 API key支持自定义 base_url 与注入的 HTTP 后端)。
fn build_ollama_client(base_url: &str, http: ReqwestClient) -> Result<ollama::Client> {
ollama::Client::builder()
.api_key(Nothing)
.base_url(base_url)
.http_client(http)
.build()
.map_err(|e| anyhow::anyhow!("Failed to build Ollama client: {}", e))
}
/// 非流式补全系统提示、温度、max_tokens 映射到统一请求,聚合正文文本。
async fn complete<M>(
model: &M,
provider: &str,
system: Option<&str>,
user: &str,
config: &LlmClientConfig,
) -> Result<String>
where
M: CompletionModel,
{
let mut builder = model.completion_request(user);
if let Some(sys) = system {
builder = builder.preamble(sys.to_string());
}
let request = builder
.temperature(config.temperature)
.max_tokens(config.max_tokens)
.build();
let response = model
.completion(request)
.await
.map_err(|e| map_completion_error(provider, e))?;
let text = collect_text(response.choice.iter());
let text = text.trim().to_string();
if text.is_empty() {
bail!("No response from {}", provider_display_name(provider));
}
Ok(text)
}
/// 从响应内容中聚合正文文本(忽略 reasoning/tool call 等块)。
fn collect_text<'a>(items: impl Iterator<Item = &'a AssistantContent>) -> String {
items
.filter_map(|item| match item {
AssistantContent::Text(text) => Some(text.text.as_str()),
_ => None,
})
.collect()
}
/// provider 用户可见展示名(与旧实现文案一致,首字母大写)。
pub(crate) fn provider_display_name(provider: &str) -> &str {
match provider {
"ollama" => "Ollama",
"openai" => "OpenAI",
"anthropic" => "Anthropic",
"kimi" => "Kimi",
"deepseek" => "DeepSeek",
"openrouter" => "OpenRouter",
other => other,
}
}
/// 把 rig 的类型化补全错误映射为与旧实现一致的用户可见文案。
pub(crate) fn map_completion_error(provider: &str, e: CompletionError) -> anyhow::Error {
// rig 在 provider 响应没有任何正文内容时的统一错误 → 映射为旧文案风格。
if matches!(&e, CompletionError::ResponseError(msg) if msg == "No content provided") {
return anyhow::anyhow!("No response from {}", provider_display_name(provider));
}
let name = provider_display_name(provider);
match (e.provider_response_status(), e.provider_response_body()) {
(Some(status), Some(body)) => anyhow::anyhow!("{} API error: {} - {}", name, status, body),
(Some(status), None) => anyhow::anyhow!("{} API error: {}", name, status),
(None, Some(body)) => anyhow::anyhow!("{} API error: {}", name, body),
(None, None) => anyhow::anyhow!("{} API request failed: {}", name, e),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rig_core::test_utils::{MockHttpResponse, RecordingHttpClient};
const OLLAMA_OK: &str = r#"{
"model": "llama3.2",
"created_at": "2024-01-01T00:00:00Z",
"message": {"role": "assistant", "content": "hello from ollama"},
"done": true
}"#;
/// 用注入的 HTTP 后端构建 Ollama 客户端(与生产构造路径一致)。
fn build_backend(recorder: RecordingHttpClient) -> Backend<RecordingHttpClient> {
Backend::Ollama(
ollama::Client::builder()
.api_key(Nothing)
.base_url("http://localhost:11434")
.http_client(recorder)
.build()
.expect("build ollama client with mock backend"),
)
}
fn client_with(
recorder: RecordingHttpClient,
) -> (LlmClient<RecordingHttpClient>, RecordingHttpClient) {
let backend = build_backend(recorder.clone());
let client = LlmClient::new(
backend,
"llama3.2",
"ollama",
LlmClientConfig {
max_tokens: 123,
temperature: 0.5,
timeout: Duration::from_secs(30),
},
false,
);
(client, recorder)
}
#[tokio::test]
async fn generate_maps_request_params_and_returns_text() {
let recorder = RecordingHttpClient::new(OLLAMA_OK);
let (client, recorder) = client_with(recorder);
let text = client.generate(Some("be helpful"), "say hi").await.unwrap();
assert_eq!(text, "hello from ollama");
let captured = recorder.requests();
assert_eq!(captured.len(), 1);
assert_eq!(captured[0].uri, "http://localhost:11434/api/chat");
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
assert_eq!(body["model"], "llama3.2");
assert_eq!(body["stream"], false);
assert_eq!(body["messages"][0]["role"], "system");
assert_eq!(body["messages"][0]["content"], "be helpful");
assert_eq!(body["messages"][1]["role"], "user");
assert_eq!(body["messages"][1]["content"], "say hi");
assert_eq!(body["options"]["temperature"], 0.5);
assert_eq!(body["options"]["num_predict"], 123);
}
#[tokio::test]
async fn generate_without_system_omits_preamble() {
let recorder = RecordingHttpClient::new(OLLAMA_OK);
let (client, recorder) = client_with(recorder);
client.generate(None, "say hi").await.unwrap();
let captured = recorder.requests();
let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap();
assert_eq!(body["messages"][0]["role"], "user");
assert_eq!(body["messages"].as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn http_error_maps_to_provider_error_message() {
let recorder = RecordingHttpClient::new("ignored");
recorder.set_response(MockHttpResponse::ErrorResponse(
http::StatusCode::BAD_GATEWAY,
"upstream exploded".into(),
));
let (client, _) = client_with(recorder);
let err = client.generate(None, "hi").await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Ollama API error:"), "got: {msg}");
assert!(msg.contains("502"), "got: {msg}");
assert!(msg.contains("upstream exploded"), "got: {msg}");
}
#[tokio::test]
async fn empty_response_errors() {
let recorder = RecordingHttpClient::new(
r#"{"model":"llama3.2","created_at":"x","message":{"role":"assistant","content":""},"done":true}"#,
);
let (client, _) = client_with(recorder);
let err = client.generate(None, "hi").await.unwrap_err();
assert_eq!(err.to_string(), "No response from Ollama");
}
#[tokio::test]
async fn is_available_uses_verify_endpoint() {
let recorder = RecordingHttpClient::new("");
let (client, recorder) = client_with(recorder);
assert!(client.is_available().await);
let captured = recorder.requests();
assert_eq!(captured.len(), 1);
assert_eq!(captured[0].uri, "http://localhost:11434/api/tags");
}
#[tokio::test]
async fn is_available_false_on_authentication_error() {
let recorder = RecordingHttpClient::new("");
recorder.set_response(MockHttpResponse::ErrorResponse(
http::StatusCode::UNAUTHORIZED,
"nope".into(),
));
let (client, _) = client_with(recorder);
assert!(!client.is_available().await);
}
#[test]
fn map_error_prefers_status_and_body() {
let err = map_completion_error(
"Ollama",
CompletionError::from_http_response(http::StatusCode::TOO_MANY_REQUESTS, "slow down"),
);
let msg = err.to_string();
assert!(msg.contains("Ollama API error: 429"), "got: {msg}");
assert!(msg.contains("slow down"), "got: {msg}");
}
#[test]
fn map_error_without_body_keeps_provider_prefix() {
let err = map_completion_error("Ollama", CompletionError::ProviderError("boom".into()));
assert!(err.to_string().contains("Ollama API request failed: ProviderError: boom"));
}
}