28ca43ccb2
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
523 lines
22 KiB
Markdown
523 lines
22 KiB
Markdown
# 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<MessageResponse, LlmError> {
|
||
self.0.chat(request).await
|
||
}
|
||
|
||
async fn chat_stream(
|
||
&self,
|
||
request: MessageRequest,
|
||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + 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<Self, Self::Err> {
|
||
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<Self, LlmError>`) |
|
||
| `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<Self, String> {
|
||
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<Self, LlmError> {
|
||
// 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<Box<dyn LlmProvider>, 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)`,改为参数化读取
|