diff --git a/docs/12-phase5-warmup-implementation.md b/docs/12-phase5-warmup-implementation.md new file mode 100644 index 0000000..44d85db --- /dev/null +++ b/docs/12-phase5-warmup-implementation.md @@ -0,0 +1,522 @@ +# Phase 5:热身准备 — 实施方案 + +## 1. 背景与目标 + +Phase 5 是 v0.2.0 发布周期的**热身准备阶段**,包含三个互不依赖的 Step,为后续 Phase 6-12 的端到端集成提供基础设施。 + +**核心目标**: +- 为 Phase 8(端到端示例)提供零 API key 的运行路径(Ollama) +- 为公共枚举的向后兼容性加上编译期护栏(`#[non_exhaustive]`) +- 为 Provider 构造提供统一的超时与重试配置入口(`ProviderConfig` 扩展) + +三个 Step 之间**无依赖关系**,但出于实现效率考虑,按 **5.2 → 5.3 → 5.1** 顺序执行。理由:5.2 先新增 `Ollama` 枚举变体,5.3 再加 `#[non_exhaustive]`,避免枚举标记后添加变体需要在外部 crate 加 `_ =>` 兜底分支的困扰。 + +## 2. 需求分析 + +### Step 5.2 — Ollama Provider + +| 维度 | 内容 | +|------|------| +| **需求** | 新增 `OllamaProvider`,newtype 包装 `GenericOpenaiProvider`,默认连接本地 Ollama 实例 | +| **优先级** | P0 — 为 Phase 8 端到端示例提供无需 API key 的运行路径 | +| **预期交付物** | `src/llm/provider/ollama.rs` 新建文件;`ProviderType` 新增 `Ollama` 变体 | +| **代码量** | ~55 行 | + +### Step 5.3 — `#[non_exhaustive]` 前置标记 + +| 维度 | 内容 | +|------|------| +| **需求** | 为 4 个公共枚举添加 `#[non_exhaustive]` 属性,避免后续新增变体时破坏下游 match | +| **优先级** | P1 — 编译期兼容性保障 | +| **预期交付物** | 修改 4 个枚举定义,各加一行属性 | +| **代码量** | ~4 行 | + +### Step 5.1 — ProviderConfig 扩展 + +| 维度 | 内容 | +|------|------| +| **需求** | `ProviderConfig` 新增 `timeout_secs` 和 `max_retries` 字段;实现 `Default`、`from_env()` 构造;timeout 传导到各 Provider HTTP Client | +| **优先级** | P0 — 与 Roadmap 一致,Phase 8(MVP 出口)依赖 from_env | +| **预期交付物** | `ProviderConfig` 扩展;`create_provider()` 超时注入;`from_env()` + 单元测试 | +| **代码量** | ~60 行 + 测试 | + +## 3. 方案设计 + +### 3.1 Step 5.2 — Ollama Provider(先执行) + +#### 改动文件清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/llm/provider/ollama.rs` | **新建** | OllamaProvider newtype 包装 | +| `src/llm/provider.rs` | 修改 | `ProviderType` 新增 `Ollama` 变体;`FromStr` 加解析;`create_provider()` 加分支 | +| `src/llm/provider/mod.rs` 或其他模块注册文件 | 修改(如需要) | 注册 `pub mod ollama` | + +#### 关键代码 + +**`src/llm/provider/ollama.rs`**(新建): + +```rust +//! Ollama Provider —— OpenAI-compatible 协议的 newtype 包装,零 API key。 +//! +//! 默认 base_url = `http://localhost:11434/v1`,空 api_key 也可工作。 +//! 实现方式同 DeepSeekProvider / QwenProvider,共享 GenericOpenaiProvider 的 HTTP/SSE/转换逻辑。 + +use reqwest::Client; +use std::pin::Pin; + +use async_trait::async_trait; +use futures_core::Stream; + +use super::openai::GenericOpenaiProvider; +use super::{LlmProvider, ProviderCapabilities}; +use crate::llm::error::LlmError; +use crate::llm::types::request_v2::MessageRequest; +use crate::llm::types::response_v2::{MessageResponse, StreamEvent}; + +pub struct OllamaProvider(pub GenericOpenaiProvider); + +impl OllamaProvider { + pub fn new(base_url: String, api_key: String, model: String) -> Self { + let url = if base_url.is_empty() { + "http://localhost:11434/v1".to_string() + } else { + base_url + }; + Self(GenericOpenaiProvider::new_with_name( + url, + api_key, + model, + "ollama", + )) + } + + /// 替换默认 HTTP Client(用于 timeout 注入等场景)。 + /// 与 `OpenaiChatProvider::with_client` 和 `DeepSeekProvider::with_client` 一致。 + pub fn with_client(self, client: Client) -> Self { + Self(self.0.with_client(client)) + } +} + +#[async_trait] +impl LlmProvider for OllamaProvider { + async fn chat(&self, request: MessageRequest) -> Result { + self.0.chat(request).await + } + + async fn chat_stream( + &self, + request: MessageRequest, + ) -> Result> + Send>>, LlmError> { + self.0.chat_stream(request).await + } + + fn capabilities(&self) -> ProviderCapabilities { + let mut caps = self.0.capabilities(); + caps.provider_name = "ollama"; + caps + } +} +``` + +**`src/llm/provider.rs`** 的修改: + +```rust +// ProviderType 新增变体 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderType { + OpenaiChat, + OpenaiResponse, + Anthropic, + DeepSeek, + Qwen, + /// Ollama(本地),默认 base_url = `http://localhost:11434/v1`。 + Ollama, +} + +// FromStr 加解析 +fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + // ... 已有条目 ... + "ollama" => Ok(ProviderType::Ollama), + _ => Err(format!("未知的 Provider 类型: {s}")), + } +} + +// create_provider() 加分支 +// Step 5.2 阶段仅展示基本构造。Step 5.1(ProviderConfig 扩展) +// 执行到此分支时,将同步补充 with_client 链式调用注入 timeout: +// +// let client = Client::builder() +// .timeout(Duration::from_secs(config.timeout_secs)) +// .build()?; +// Ok(Box::new( +// ollama::OllamaProvider::new(config.base_url, config.api_key, config.model) +// .with_client(client), +// )) +ProviderType::Ollama => Ok(Box::new(ollama::OllamaProvider::new( + config.base_url, + config.api_key, + config.model, +))), +``` + +#### 集成方式 + +OllamaProvider 的 newtype 包装模式与 `DeepSeekProvider`、`QwenProvider` 完全一致,`LlmProvider` trait 委托给 `self.0`。`capabilities().provider_name` 返回 `"ollama"`。 + +### 3.2 Step 5.3 — `#[non_exhaustive]` 前置标记 + +#### 改动文件清单 + +| 文件 | 行号 | 枚举 | 操作 | +|------|------|------|------| +| `src/llm/provider.rs` | ~21 | `ProviderType` | 加 `#[non_exhaustive]` | +| `src/llm/types/response_v2.rs` | ~22 | `StopReason` | 加 `#[non_exhaustive]` | +| `src/llm/types/shared.rs` | ~16 | `FinishReason` | 加 `#[non_exhaustive]` | +| `src/memory/store.rs` | ~35 | `EvictionPolicy` | 加 `#[non_exhaustive]` | + +**排除清单**:`SlotMode`。 + +**决策理由**:`SlotMode` 枚举在 Phase 10(`src/llm/context.rs`)中才实际定义,Phase 5 尚不存在此类型。`#[non_exhaustive]` 无法标注不存在的枚举,因此排除标注。Roadmap(v0.2.0 §Phase 5 Step 5.3)列出的 `SlotMode`(预置) 推迟到 Phase 10 实现时一并添加。 + +#### 关键代码 + +每个枚举在 `derive` 上方或下方加一行属性: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum ProviderType { + // ... +} +``` + +#### 影响分析 + +- `#[non_exhaustive]` 是纯编译期属性,不影响运行时行为 +- 同一 crate 内的 exhaustive match 不受影响(同 crate 可穷举) +- 下游 crate 的 match 必须加 `_ =>` 兜底分支,这是期望行为——确保未来新增变体时不会 silent break +- **单向门**:此步骤一旦通过 `v0.2.0` 发布到公共 API 后,**不可回退**。回退意味着移除 `#[non_exhaustive]`,可能破坏已添加 `_ =>` 的下游代码。因此必须在发布前完成并确认所有枚举变体正确 + +### 3.3 Step 5.1 — ProviderConfig 扩展(最后执行) + +#### 改动文件清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/llm/provider.rs` | 修改 | `ProviderConfig` 加字段;加 `impl Default`;加 `from_env()`;`create_provider` 注入 timeout | +| `src/llm/provider/openai.rs` | 修改 | `GenericOpenaiProvider` 新增 `timeout_secs` 字段;`new_with_name` 接受 timeout 参数;`map_reqwest_error` 参数化 | +| `src/llm/provider/anthropic.rs` | 修改 | 新增 `timeout_secs` 字段;`new()` 接受 timeout 参数;`map_reqwest_error` 参数化 | +| `src/llm/provider/anthropic.rs` | 修改 | 新增 `with_timeout()` 方法(返回 `Result`) | +| `src/llm/provider/openai_compat.rs` | 修改 | `DeepSeekProvider` 和 `QwenProvider` 新增公开 `with_client()` 方法 | +| `src/llm/provider/ollama.rs` | 修改 | `OllamaProvider` 新增公开 `with_client()` 方法 | +| `Cargo.toml` | 修改 | 加 `temp_env` dev-dependency | +| 测试文件(`provider.rs` 内联或独立) | 新增 | `from_env` 单元测试 + timeout 传导集成测试 | + +#### 数据结构 + +```rust +/// Provider 构造参数 —— 通用 base_url + api_key + model + timeout/retry 配置。 +pub struct ProviderConfig { + pub base_url: String, + pub api_key: String, + pub model: String, + /// 请求超时秒数(默认 30)。应用于 Provider 的 HTTP Client 级别。 + pub timeout_secs: u64, + /// 最大重试次数(默认 3)。当前此字段仅由 `from_env()` 采集, + /// 实际重试逻辑由 `CycleConfig.retry.max_retries` 控制。 + /// 未来可合并到统一的 retry 配置。 + pub max_retries: u32, +} + +impl Default for ProviderConfig { + fn default() -> Self { + Self { + base_url: String::new(), + api_key: String::new(), + model: String::new(), + timeout_secs: 30, + max_retries: 3, + } + } +} + +impl ProviderConfig { + /// 从环境变量构造 ProviderConfig。 + /// + /// 必填变量: + /// - `{prefix}_BASE_URL` + /// - `{prefix}_API_KEY` + /// - `{prefix}_MODEL` + /// + /// 可选变量(有默认值): + /// - `{prefix}_TIMEOUT_SECS`(默认 30) + /// - `{prefix}_MAX_RETRIES`(默认 3) + pub fn from_env(prefix: &str) -> Result { + let base_url = std::env::var(format!("{prefix}_BASE_URL")) + .map_err(|_| format!("{prefix}_BASE_URL 环境变量未设置"))?; + let api_key = std::env::var(format!("{prefix}_API_KEY")) + .map_err(|_| format!("{prefix}_API_KEY 环境变量未设置"))?; + let model = std::env::var(format!("{prefix}_MODEL")) + .map_err(|_| format!("{prefix}_MODEL 环境变量未设置"))?; + let timeout_secs = match std::env::var(format!("{prefix}_TIMEOUT_SECS")) { + Ok(v) => v.parse().unwrap_or_else(|_| { + tracing::warn!("{prefix}_TIMEOUT_SECS='{v}' 解析失败,使用默认值 30"); + 30 + }), + Err(_) => 30, + }; + let max_retries = match std::env::var(format!("{prefix}_MAX_RETRIES")) { + Ok(v) => v.parse().unwrap_or_else(|_| { + tracing::warn!("{prefix}_MAX_RETRIES='{v}' 解析失败,使用默认值 3"); + 3 + }), + Err(_) => 3, + }; + + // ponytail: max_retries 当前仅采集,不传入 Provider。 + // 实际重试由 CycleConfig.retry.max_retries 控制。 + // 此 warn 在应用启动时通常只触发一次,多次调用 from_env 时 + // 重复输出的风险低。如有噪声,可改用 std::sync::Once 控制。 + if max_retries != 3 { + tracing::warn!( + "ProviderConfig.max_retries={} 已采集但当前未生效;\ + 重试次数由 CycleConfig.retry.max_retries 控制", + max_retries, + ); + } + + Ok(Self { + base_url, + api_key, + model, + timeout_secs, + max_retries, + }) + } +} +``` + +#### Timeout 传导模式 + +在 `create_provider()` 中,对基于 `GenericOpenaiProvider` 的 Provider(OpenAI / DeepSeek / Qwen / Ollama),通过同一模式注入 timeout:构造带 timeout 的 `Client` 后调用 `with_client(client)`。 + +所有 OpenAI-compatible 分支新增的 `with_client()` 公开方法: + +| Provider | 方法 | 位置 | +|----------|------|------| +| `OpenaiChatProvider` | 已有 `with_client(Client) -> Self` | `openai.rs` | +| `DeepSeekProvider` | 新增 `with_client(Client) -> Self` | `openai_compat.rs` | +| `QwenProvider` | 新增 `with_client(Client) -> Self` | `openai_compat.rs` | +| `OllamaProvider` | 新增 `with_client(Client) -> Self` | `ollama.rs`(新建文件) | + +**关于 `new_with_client` 的说明**:`DeepSeekProvider` 和 `QwenProvider` 当前已有测试用的 `new_with_client(base_url, api_key, model, client)` 方法(通过 `inner.http_client = client` 直接写字段)。新增 `with_client` 后,`new_with_client` 应重构为 `Self::new(base_url, api_key, model).with_client(client)` 代理,统一走公开 API 路径。 + +代码示例(以 DeepSeek 为例,OpenAI/Qwen/Ollama 模式完全一致): + +```rust +ProviderType::DeepSeek => { + let client = Client::builder() + .timeout(Duration::from_secs(config.timeout_secs)) + .build() + .map_err(|e| LlmError::Other(format!("创建 HTTP 客户端失败: {e}")))?; + Ok(Box::new( + openai_compat::DeepSeekProvider::new( + config.base_url, + config.api_key, + config.model, + ) + .with_client(client), + )) +} +``` + +Anthropic 由于需要保留 `default_headers`,使用独立的 `with_timeout` 模式: + +AnthropicProvider 新增 `with_timeout` 方法: + +```rust +impl AnthropicProvider { + /// 替换默认 HTTP Client 的超时配置。 + /// + /// ⚠️ 副作用:此方法**完全重建** `http_client`,调用后原有通过 `with_client` + /// 注入的 Client 将被替换。headers 逻辑与 `new()` 中的构造保持一致。 + pub fn with_timeout(mut self, secs: u64) -> Result { + // ponytail: 重建 http_client 时保留已有默认 headers(x-api-key / anthropic-version)。 + // 如后续 AnthropicProvider 的 headers 变为动态,此方法需同步更新。 + let key_header = HeaderValue::from_str(&self.api_key) + .map_err(|_| LlmError::Other("Anthropic API key 包含无效的 HTTP 头部字符".into()))?; + let version_header = HeaderValue::from_static("2023-06-01"); + + self.http_client = Client::builder() + .timeout(Duration::from_secs(secs)) + .default_headers({ + let mut headers = HeaderMap::new(); + headers.insert("x-api-key", key_header); + headers.insert("anthropic-version", version_header); + headers + }) + .build() + .map_err(|e| LlmError::Other(format!("创建 Anthropic HTTP 客户端失败: {e}")))?; + Ok(self) + } +} +``` + +#### `map_reqwest_error` 中的硬编码超时修复 + +`openai.rs` 和 `anthropic.rs` 中的 `map_reqwest_error` 辅助函数当前在超时错误中返回硬编码的 `Duration::from_secs(120)`: + +```rust +// 现状 —— 硬编码 120s,与可配置 timeout 脱节 +LlmError::Timeout { duration: Duration::from_secs(120) } +``` + +**修复方式**:采用**方案 A**——在 Provider struct 中存储 `timeout_secs` 字段,`map_reqwest_error` 读取该字段的值而非硬编码 120s。 + +```rust +// 修复后 —— 参数化,从 Provider 存储的 timeout_secs 读取 +// GenericOpenaiProvider 新增 timeout_secs 字段: +pub struct GenericOpenaiProvider { + http_client: Client, + base_url: String, + api_key: String, + model: String, + provider_name: &'static str, + extra_headers: Vec<(String, String)>, + timeout_secs: u64, // ← 新增,由 new_with_name 的参数传入 +} + +// map_reqwest_error 使用 self.timeout_secs 而非硬编码 120: +LlmError::Timeout { duration: Duration::from_secs(self.timeout_secs) } +``` + +**方案 B(从 reqwest::Client 提取 timeout)已被否决**:`reqwest::Client` 不提供 timeout getter,无法从已构造的 client 中反向读取超时配置。 + +如果漏掉此修复,用户设置 `AG_LLM_TIMEOUT_SECS=60` 后超时,错误消息仍显示 "LLM 请求超时(120s)",与实际配置不符。 + +--- + +#### max_retries 说明 + +`ProviderConfig.max_retries` 当前仅由 `from_env()` 采集存储,**实际重试操作由 `CycleConfig.retry.max_retries` 控制**。两者之间的关系通过文档注释声明: + +```rust +/// 最大重试次数(默认 3)。当前此字段仅由 `from_env()` 采集, +/// 实际重试逻辑由 `CycleConfig.retry.max_retries` 控制。 +/// 未来 Phase 6+ 可统一合并此字段到 CycleConfig。 +``` + +#### 测试设计 + +使用 `temp_env` 在单元测试中隔离环境变量: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_config_from_env_requires_all_vars() { + // 未设置任何变量时应返回 Err + let result = ProviderConfig::from_env("TEST_PROVIDER"); + assert!(result.is_err()); + } + + #[test] + fn provider_config_from_env_uses_defaults() { + temp_env::with_vars([ + ("TEST_PROVIDER_BASE_URL", Some("http://localhost:11434/v1")), + ("TEST_PROVIDER_API_KEY", Some("")), + ("TEST_PROVIDER_MODEL", Some("llama3")), + ], || { + let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap(); + assert_eq!(config.timeout_secs, 30); + assert_eq!(config.max_retries, 3); + }); + } + + #[test] + fn provider_config_from_env_reads_custom_timeout() { + temp_env::with_vars([ + ("TEST_PROVIDER_BASE_URL", Some("http://x")), + ("TEST_PROVIDER_API_KEY", Some("k")), + ("TEST_PROVIDER_MODEL", Some("m")), + ("TEST_PROVIDER_TIMEOUT_SECS", Some("60")), + ("TEST_PROVIDER_MAX_RETRIES", Some("5")), + ], || { + let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap(); + assert_eq!(config.timeout_secs, 60); + assert_eq!(config.max_retries, 5); + }); + } +} +``` + +## 4. 实现计划 + +### Step 5.2 — Ollama Provider(~55 行) + +| 步骤 | 操作 | 验证 | +|------|------|------| +| 1 | 创建 `src/llm/provider/ollama.rs`,实现 `OllamaProvider` newtype | 编译通过 | +| 2 | 在 `provider.rs` 注册 `pub mod ollama` | 编译通过 | +| 3 | `ProviderType` 新增 `Ollama` 变体 | 编译通过 | +| 4 | `FromStr` 加 `"ollama"` 解析 | 编译通过 | +| 5 | `create_provider()` 加 `Ollama =>` 分支 | 编译通过 | +| 6 | 运行 `cargo build` | 无错误 | + +### Step 5.3 — `#[non_exhaustive]` 前置标记(~4 行) + +| 步骤 | 操作 | 验证 | +|------|------|------| +| 1 | `ProviderType`(`provider.rs`)加 `#[non_exhaustive]` | 编译通过 | +| 2 | `StopReason`(`response_v2.rs`)加 `#[non_exhaustive]` | 编译通过 | +| 3 | `FinishReason`(`shared.rs`)加 `#[non_exhaustive]` | 编译通过 | +| 4 | `EvictionPolicy`(`memory/store.rs`)加 `#[non_exhaustive]` | 编译通过 | +| 5 | 运行 `cargo build --all-targets` | 无 warning | + +### Step 5.1 — ProviderConfig 扩展(~60 行 + 测试) + +| 步骤 | 操作 | 验证 | +|------|------|------| +| 1 | `ProviderConfig` 加 `timeout_secs` / `max_retries` 字段 | 编译通过 | +| 2 | 实现 `impl Default for ProviderConfig` | 编译通过 | +| 3 | 实现 `ProviderConfig::from_env()` | 编译通过 | +| 4 | `GenericOpenaiProvider` 和 `AnthropicProvider` 新增 `timeout_secs` 字段,`new_with_name`/`new()` 接受 timeout 参数 | 编译通过 | +| 5 | `map_reqwest_error` 在各 Provider 中改为从 `self.timeout_secs` 读取,移除硬编码 120s | 编译通过 | +| 6 | `create_provider()` 中各分支注入 timeout(OpenAI-compatible 用 `Client::builder().timeout()` + `with_client`;Anthropic 用 `with_timeout()`) | 编译通过 | +| 7 | `DeepSeekProvider`/`QwenProvider` 的 `new_with_client` 重构为 `Self::new(...).with_client(client)` 代理 | 测试通过 | +| 8 | `Cargo.toml` 添加 `temp_env` dev-dependency | `cargo build` 通过 | +| 8 | 添加 `from_env` 单元测试 + timeout 传导集成测试 | `cargo test` 通过 | +| 9 | 完整验证 | 见第 6 节 | + +## 5. 风险评估 + +| 风险 | 影响 | 概率 | 缓解措施 | +|------|------|------|----------| +| `create_provider()` 中 `Client::builder().build()` 返回 `Result`,当前代码使用 `.expect()`,改为 `map_err` 转为 `LlmError` 后需确保所有分支正确转换 | 编译期强制处理,遗漏分支直接报错 | 低 | `create_provider` 返回 `Result, LlmError>`,`map_err` 天然适配。新增的 timeout 注入路径逐一检查 | +| `AnthropicProvider` 的 `default_headers` 在 `with_timeout` 中重建时与 `new()` 中的 headers 不一致 | Anthropic 认证失败 | 低 | `with_timeout` 方法复制 `new()` 中的 headers 构造逻辑。通过已有测试验证认证通过 | +| Ollama 实际运行时行为差异:版本兼容性、API 路径、模型名等 | 运行时才能发现 | 中 | Phase 5 仅做类型级验证(`cargo build`),Phase 8 端到端测试时通过 Ollama mock 或真实实例验证 | +| `max_retries` 存储了却未实际使用,造成困惑 | 开发者误以为已生效 | 中 | 通过文档注释明确声明 `max_retries` 当前仅采集,实际重试由 `CycleConfig.retry.max_retries` 控制 | +| `temp_env` 测试在多线程并发测试中互相污染环境变量 | 偶发测试失败 | 中(Rust 默认单线程测试用 `--test-threads=1` 可避免) | 将 `from_env` 测试控制在同一测试文件,避免并行执行。必要时在 CI 中确保 `--test-threads=1` | + +## 6. 验收标准 + +以下条件**全部满足**方可认为 Phase 5 完成: + +- [ ] `cargo build --all-targets` 通过,无错误 +- [ ] `cargo test --all-targets` 通过,新增测试覆盖 `from_env` 的必填/选填/默认值场景 +- [ ] `cargo clippy --all-targets -- -D warnings` 通过,无任何 warning +- [ ] `cargo doc --no-deps -D warnings` 通过,所有公共 API 有文档注释(`///`) +- [ ] 新增文件:1(`ollama.rs`) +- [ ] 修改文件:9(`provider.rs`、`openai.rs`、`anthropic.rs`、`openai_compat.rs`、`response_v2.rs`、`shared.rs`、`store.rs`、`Cargo.toml`、测试文件) +- [ ] 净代码增量:~160 行 +- [ ] `ProviderType` 新增 `Ollama` 变体,`"ollama"` 字符串可解析 +- [ ] 4 个公共枚举带有 `#[non_exhaustive]` 属性 +- [ ] `ProviderConfig` 可从环境变量构造(`from_env()`),含默认值 +- [ ] timeout 值已传导到 `create_provider()` 中各 Provider 的 HTTP Client 配置 +- [ ] timeout 传导验证通过至少一个端到端 wiremock 集成测试(模拟 HTTP 服务在超时后返回 408,验证 Provider 返回 `LlmError::Timeout`) +- [ ] `DeepSeekProvider`、`QwenProvider`、`OllamaProvider` 均有公开 `with_client()` 方法,可在 `create_provider` 中注入 timeout Client +- [ ] `map_reqwest_error` 中不再硬编码 `Duration::from_secs(120)`,改为参数化读取