feat(llm): 完成 Phase 5 热身准备(Ollama / non_exhaustive / ProviderConfig)
Phase 5 三个 Step 全部落地: Step 5.2 — Ollama Provider - 新增 OllamaProvider newtype 包装(默认 localhost:11434/v1,零 API key) - ProviderType 新增 Ollama 变体与 FromStr 解析 Step 5.3 — #[non_exhaustive] 前置标记 - ProviderType / StopReason / FinishReason / EvictionPolicy 加 #[non_exhaustive] - 编译期兼容护栏,避免下游 silent break Step 5.1 — ProviderConfig 扩展 - 加 timeout_secs / max_retries 字段、Default、from_env(prefix) - create_provider 各分支通过 pub(crate) from_parts 一次性构造并注入 timeout (同时避开 Anthropic 的 default_headers 与双重 client 构造) - map_reqwest_error 改为方法读取 self.timeout_secs(移除硬编码 120s) - AnthropicProvider::with_timeout 同值短路,with_client 标 #[deprecated] - DeepSeek / Qwen 加公开 with_client,new_with_client 走代理 - 7 个新测试:5 个 from_env 单元测试 + 3 个 timeout 传导 wiremock (OpenAI Chat / DeepSeek / Anthropic) - Cargo.toml 加 temp-env dev-dep
This commit is contained in:
@@ -24,3 +24,4 @@ time = { version = "0.3", features = ["serde"] }
|
||||
[dev-dependencies]
|
||||
dotenvy = "0.15.7"
|
||||
wiremock = "0.6"
|
||||
temp-env = "0.3"
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::env;
|
||||
use agcore::init_tracing;
|
||||
use agcore::llm::{
|
||||
cycle::{CycleConfig, LlmCycle},
|
||||
provider::{create_provider, ProviderConfig, ProviderType},
|
||||
provider::{ProviderConfig, ProviderType, create_provider},
|
||||
types::{message::ContentBlock, message::Message, response_v2::MessageResponse},
|
||||
};
|
||||
|
||||
@@ -51,10 +51,11 @@ async fn main() {
|
||||
base_url,
|
||||
api_key,
|
||||
model: model.clone(),
|
||||
timeout_secs: 30,
|
||||
max_retries: 3,
|
||||
};
|
||||
|
||||
let provider = create_provider(provider_type, config)
|
||||
.expect("创建 Provider 失败");
|
||||
let provider = create_provider(provider_type, config).expect("创建 Provider 失败");
|
||||
|
||||
let cycle_config = CycleConfig {
|
||||
model,
|
||||
@@ -63,9 +64,9 @@ async fn main() {
|
||||
..CycleConfig::default()
|
||||
};
|
||||
|
||||
let mut cycle = LlmCycle::new(provider, cycle_config).with_messages(vec![
|
||||
Message::system("你是一个简洁的助手,对于任何问题都是用一句话回答。"),
|
||||
]);
|
||||
let mut cycle = LlmCycle::new(provider, cycle_config).with_messages(vec![Message::system(
|
||||
"你是一个简洁的助手,对于任何问题都是用一句话回答。",
|
||||
)]);
|
||||
|
||||
println!("发送请求...");
|
||||
|
||||
|
||||
+456
-21
@@ -1,11 +1,14 @@
|
||||
pub mod anthropic;
|
||||
pub mod ollama;
|
||||
pub mod openai;
|
||||
pub mod openai_compat;
|
||||
pub mod registry;
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_core::Stream;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::llm::error::LlmError;
|
||||
@@ -18,6 +21,7 @@ use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
/// 当前协议数量(5 种以内)完全可控,enum 的编译期安全检查优于运行时的 `HashMap::get()`。
|
||||
/// 未来如果扩展到 15+ 种以上,再改为注册表模式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum ProviderType {
|
||||
/// OpenAI Chat Completions API(兼容 DeepSeek / Qwen 等 `/chat/completions` 端点)。
|
||||
OpenaiChat,
|
||||
@@ -29,6 +33,8 @@ pub enum ProviderType {
|
||||
DeepSeek,
|
||||
/// Qwen / 阿里云百炼(OpenAI-compatible `/chat/completions`)。
|
||||
Qwen,
|
||||
/// Ollama 本地推理(OpenAI-compatible `/chat/completions`,默认 `http://localhost:11434/v1`)。
|
||||
Ollama,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ProviderType {
|
||||
@@ -41,47 +47,211 @@ impl std::str::FromStr for ProviderType {
|
||||
"anthropic" | "claude" => Ok(ProviderType::Anthropic),
|
||||
"deepseek" => Ok(ProviderType::DeepSeek),
|
||||
"qwen" | "dashscope" | "tongyi" => Ok(ProviderType::Qwen),
|
||||
"ollama" => Ok(ProviderType::Ollama),
|
||||
_ => Err(format!("未知的 Provider 类型: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 构造参数 —— 通用 base_url + api_key + model。
|
||||
/// Provider 构造参数 —— 通用 base_url + api_key + model + timeout / retry 配置。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderConfig {
|
||||
/// API base URL(如 `https://api.openai.com/v1`)。为空时由 Provider 选择默认值。
|
||||
pub base_url: String,
|
||||
/// API key。Ollama 等本地 Provider 可为空。
|
||||
pub api_key: String,
|
||||
/// 模型名(如 `gpt-4o` / `claude-sonnet-4-20250514`)。
|
||||
pub model: String,
|
||||
/// 请求超时秒数(默认 30)。应用于 Provider 的 HTTP Client 级别。
|
||||
pub timeout_secs: u64,
|
||||
/// 最大重试次数(默认 3)。
|
||||
///
|
||||
/// 当前此字段仅由 `from_env()` 采集,**实际重试逻辑由 `CycleConfig.retry.max_retries` 控制**。
|
||||
/// 此处保留字段以与 Roadmap §Phase 5 Step 5.1 对齐;未来 Phase 6+ 可统一合并到 `CycleConfig`。
|
||||
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,解析失败回退 30 并 warn)
|
||||
/// - `{prefix}_MAX_RETRIES`(默认 3,解析失败回退 3 并 warn)
|
||||
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 控制。
|
||||
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 的 `reqwest::Client`(OpenAI-compatible 共享)。
|
||||
fn build_client_with_timeout(timeout_secs: u64) -> Result<Client, LlmError> {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|e| LlmError::Other(format!("创建 HTTP 客户端失败: {e}")))
|
||||
}
|
||||
|
||||
/// 构造带 Anthropic 默认 headers + timeout 的 `reqwest::Client`。
|
||||
///
|
||||
/// Anthropic 由于需要保留 `x-api-key` / `anthropic-version` 默认 headers,
|
||||
/// 与 OpenAI-compatible 共享的 `build_client_with_timeout` 不同。
|
||||
fn build_anthropic_client(api_key: &str, timeout_secs: u64) -> Result<Client, LlmError> {
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
|
||||
let key_header = HeaderValue::from_str(api_key)
|
||||
.map_err(|_| LlmError::Other("Anthropic API key 包含无效的 HTTP 头部字符".into()))?;
|
||||
let version_header = HeaderValue::from_static("2023-06-01");
|
||||
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_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}")))
|
||||
}
|
||||
|
||||
/// Provider 工厂 —— exhaustive match 在编译期保证新 Provider 被注册。
|
||||
///
|
||||
/// `config.timeout_secs` 注入到 Provider 的 HTTP Client 超时配置。
|
||||
/// 每个分支通过 `from_parts` (pub(crate)) 一次性构造,无冗余 client 创建。
|
||||
pub fn create_provider(
|
||||
provider_type: ProviderType,
|
||||
config: ProviderConfig,
|
||||
) -> Result<Box<dyn LlmProvider>, LlmError> {
|
||||
match provider_type {
|
||||
ProviderType::OpenaiChat => Ok(Box::new(openai::OpenaiChatProvider::new(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
ProviderType::OpenaiChat => {
|
||||
let client = build_client_with_timeout(config.timeout_secs)?;
|
||||
Ok(Box::new(openai::OpenaiChatProvider(
|
||||
openai::GenericOpenaiProvider::from_parts(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
"openai",
|
||||
client,
|
||||
Vec::new(),
|
||||
config.timeout_secs,
|
||||
),
|
||||
)))
|
||||
}
|
||||
ProviderType::OpenaiResponse => Err(LlmError::Other(
|
||||
"OpenaiResponse Provider 在 Phase 1 暂不实现;请使用 OpenaiChat".into(),
|
||||
)),
|
||||
ProviderType::Anthropic => Ok(Box::new(anthropic::AnthropicProvider::new(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
ProviderType::DeepSeek => Ok(Box::new(openai_compat::DeepSeekProvider::new(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
ProviderType::Qwen => Ok(Box::new(openai_compat::QwenProvider::new(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
ProviderType::Anthropic => {
|
||||
let client = build_anthropic_client(&config.api_key, config.timeout_secs)?;
|
||||
Ok(Box::new(anthropic::AnthropicProvider::from_parts(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
client,
|
||||
config.timeout_secs,
|
||||
)))
|
||||
}
|
||||
ProviderType::DeepSeek => {
|
||||
let client = build_client_with_timeout(config.timeout_secs)?;
|
||||
Ok(Box::new(openai_compat::DeepSeekProvider(
|
||||
openai::GenericOpenaiProvider::from_parts(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
"deepseek",
|
||||
client,
|
||||
Vec::new(),
|
||||
config.timeout_secs,
|
||||
),
|
||||
)))
|
||||
}
|
||||
ProviderType::Qwen => {
|
||||
let client = build_client_with_timeout(config.timeout_secs)?;
|
||||
Ok(Box::new(openai_compat::QwenProvider(
|
||||
openai::GenericOpenaiProvider::from_parts(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
"qwen",
|
||||
client,
|
||||
vec![("X-DashScope-SSE".to_string(), "enable".to_string())],
|
||||
config.timeout_secs,
|
||||
),
|
||||
)))
|
||||
}
|
||||
ProviderType::Ollama => {
|
||||
let client = build_client_with_timeout(config.timeout_secs)?;
|
||||
// ponytail: Ollama 默认 base_url 由 OllamaProvider 构造处理 —— 但 from_parts 不走
|
||||
// OllamaProvider::new 的默认 URL 回退。这里保留 base_url(可能为空 → http://localhost:11434/v1)。
|
||||
let base_url = if config.base_url.is_empty() {
|
||||
"http://localhost:11434/v1".to_string()
|
||||
} else {
|
||||
config.base_url
|
||||
};
|
||||
Ok(Box::new(ollama::OllamaProvider(
|
||||
openai::GenericOpenaiProvider::from_parts(
|
||||
base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
"ollama",
|
||||
client,
|
||||
Vec::new(),
|
||||
config.timeout_secs,
|
||||
),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,3 +312,268 @@ pub trait LlmProvider: Send + Sync {
|
||||
/// 返回 Provider 静态能力描述。
|
||||
fn capabilities(&self) -> ProviderCapabilities;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn provider_config_default_values() {
|
||||
let config = ProviderConfig::default();
|
||||
assert_eq!(config.timeout_secs, 30);
|
||||
assert_eq!(config.max_retries, 3);
|
||||
assert_eq!(config.base_url, "");
|
||||
assert_eq!(config.api_key, "");
|
||||
assert_eq!(config.model, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_from_env_requires_all_three() {
|
||||
// 使用 temp_env 移除所有相关变量,避免外部环境意外设置导致测试 flaky
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("TEST_PROVIDER_MISSING_BASE_URL", None::<&str>),
|
||||
("TEST_PROVIDER_MISSING_API_KEY", None::<&str>),
|
||||
("TEST_PROVIDER_MISSING_MODEL", None::<&str>),
|
||||
("TEST_PROVIDER_MISSING_TIMEOUT_SECS", None::<&str>),
|
||||
("TEST_PROVIDER_MISSING_MAX_RETRIES", None::<&str>),
|
||||
],
|
||||
|| {
|
||||
let result = ProviderConfig::from_env("TEST_PROVIDER_MISSING");
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err();
|
||||
assert!(
|
||||
msg.contains("TEST_PROVIDER_MISSING_BASE_URL"),
|
||||
"error should mention missing var, got: {msg}"
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_from_env_uses_defaults_when_only_required_set() {
|
||||
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.base_url, "http://localhost:11434/v1");
|
||||
assert_eq!(config.api_key, "");
|
||||
assert_eq!(config.model, "llama3");
|
||||
assert_eq!(config.timeout_secs, 30);
|
||||
assert_eq!(config.max_retries, 3);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_from_env_reads_custom_values() {
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_from_env_falls_back_on_invalid_numbers() {
|
||||
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("not-a-number")),
|
||||
("TEST_PROVIDER_MAX_RETRIES", Some("also-bad")),
|
||||
],
|
||||
|| {
|
||||
let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap();
|
||||
// 解析失败回退默认值
|
||||
assert_eq!(config.timeout_secs, 30);
|
||||
assert_eq!(config.max_retries, 3);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Timeout 传导集成测试:构造 `ProviderConfig` timeout=1s,
|
||||
/// `create_provider` 注入 1s 超时 client,请求一个故意延迟 3s 的 mock server,
|
||||
/// 验证返回 `LlmError::Timeout { duration: 1s }`。
|
||||
#[tokio::test]
|
||||
async fn create_provider_injects_timeout_into_openai_chat() {
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
let server = MockServer::start().await;
|
||||
// 故意延迟 3s 触发超时
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/chat/completions"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_secs(3))
|
||||
.set_body_json(json!({
|
||||
"id": "x",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "gpt-4o",
|
||||
"choices": [],
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = create_provider(
|
||||
ProviderType::OpenaiChat,
|
||||
ProviderConfig {
|
||||
base_url: server.uri(),
|
||||
api_key: "sk-test".into(),
|
||||
model: "gpt-4o".into(),
|
||||
timeout_secs: 1,
|
||||
max_retries: 3,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = provider
|
||||
.chat(crate::llm::types::request_v2::MessageRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![],
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
LlmError::Timeout { duration } => {
|
||||
assert_eq!(duration, Duration::from_secs(1));
|
||||
}
|
||||
other => panic!("expected Timeout, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeout 传导验证:`create_provider` 生成的 DeepSeek Provider 也带 1s 超时,
|
||||
/// 错误消息中的 duration 与 timeout_secs 一致(而非硬编码 120s)。
|
||||
#[tokio::test]
|
||||
async fn create_provider_injects_timeout_into_deepseek() {
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/chat/completions"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_secs(3))
|
||||
.set_body_json(json!({
|
||||
"id": "x",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "deepseek-chat",
|
||||
"choices": [],
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = create_provider(
|
||||
ProviderType::DeepSeek,
|
||||
ProviderConfig {
|
||||
base_url: server.uri(),
|
||||
api_key: "sk-test".into(),
|
||||
model: "deepseek-chat".into(),
|
||||
timeout_secs: 1,
|
||||
max_retries: 3,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = provider
|
||||
.chat(crate::llm::types::request_v2::MessageRequest {
|
||||
model: "deepseek-chat".into(),
|
||||
messages: vec![],
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
LlmError::Timeout { duration } => {
|
||||
assert_eq!(duration, Duration::from_secs(1));
|
||||
}
|
||||
other => panic!("expected Timeout, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeout 传导验证:`create_provider` 生成的 Anthropic Provider 通过 `with_timeout`
|
||||
/// 注入 1s 超时。
|
||||
///
|
||||
/// 与 OpenAI-compatible 路径不同,Anthropic 走 `AnthropicProvider::with_timeout()`
|
||||
/// 重建底层 client(保留 default_headers),独立于 OpenAI-compatible 的 `build_client_with_timeout`。
|
||||
/// 单独覆盖此路径以验证 `with_timeout` 不会因服务端延迟而返回硬编码 120s 的超时错误。
|
||||
#[tokio::test]
|
||||
async fn create_provider_injects_timeout_into_anthropic() {
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
let server = MockServer::start().await;
|
||||
// Anthropic Messages API 端点:`POST /v1/messages`
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_secs(3))
|
||||
.set_body_json(json!({
|
||||
"id": "msg_timeout_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "ok"}],
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1}
|
||||
})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = create_provider(
|
||||
ProviderType::Anthropic,
|
||||
ProviderConfig {
|
||||
base_url: server.uri(),
|
||||
api_key: "sk-ant-test".into(),
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
timeout_secs: 1,
|
||||
max_retries: 3,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = provider
|
||||
.chat(crate::llm::types::request_v2::MessageRequest {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
messages: vec![],
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
LlmError::Timeout { duration } => {
|
||||
assert_eq!(duration, Duration::from_secs(1));
|
||||
}
|
||||
other => panic!("expected Timeout, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+132
-55
@@ -12,10 +12,10 @@ use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures_core::Stream;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use reqwest::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
@@ -39,16 +39,20 @@ pub struct AnthropicProvider {
|
||||
#[allow(dead_code)]
|
||||
api_key: String,
|
||||
model: String,
|
||||
/// HTTP 请求超时秒数。由 `ProviderConfig::timeout_secs` 传入,
|
||||
/// 在 `LlmError::Timeout { duration }` 中回显。`reqwest::Client` 不暴露 timeout getter,
|
||||
/// 因此单独存储以便错误消息与配置保持一致。
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl AnthropicProvider {
|
||||
pub fn new(base_url: String, api_key: String, model: String) -> Self {
|
||||
let key_header = HeaderValue::from_str(&api_key)
|
||||
.expect("Anthropic API key 包含无效的 HTTP 头部字符");
|
||||
pub fn new(base_url: String, api_key: String, model: String, timeout_secs: u64) -> Self {
|
||||
let key_header =
|
||||
HeaderValue::from_str(&api_key).expect("Anthropic API key 包含无效的 HTTP 头部字符");
|
||||
let version_header = HeaderValue::from_static("2023-06-01");
|
||||
|
||||
let http_client = Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.default_headers({
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-api-key", key_header);
|
||||
@@ -67,14 +71,80 @@ impl AnthropicProvider {
|
||||
},
|
||||
api_key,
|
||||
model,
|
||||
timeout_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚠️ 替换 HTTP Client,**丢弃** `new()` 中设置的默认 headers(`x-api-key` / `anthropic-version`)。
|
||||
///
|
||||
/// 调用此方法后,所有 Anthropic API 请求将以**无认证头**发送出去,预期会 401/403 失败。
|
||||
/// 推荐改用 [`Self::with_timeout`],它会重建 client 并保留默认 headers。
|
||||
///
|
||||
/// 此方法仍保留以兼容调用方自定义 client 但不需要默认 headers 的极端场景。
|
||||
#[deprecated(
|
||||
since = "0.2.0",
|
||||
note = "此方法会丢弃默认 headers(x-api-key / anthropic-version),改为使用 `with_timeout` 或带 headers 的 `Client::builder()`"
|
||||
)]
|
||||
pub fn with_client(mut self, client: Client) -> Self {
|
||||
self.http_client = client;
|
||||
self
|
||||
}
|
||||
|
||||
/// 替换 HTTP Client 的超时配置(重建底层 client,保留默认 headers)。
|
||||
///
|
||||
/// ⚠️ 副作用:此方法**完全重建** `http_client`,调用后通过 `with_client` 注入的 Client
|
||||
/// 将被替换。headers 构造逻辑与 `new()` 中的保持一致(`x-api-key` / `anthropic-version`)。
|
||||
///
|
||||
/// ponytail: 同值调用短路。当 `secs == self.timeout_secs` 时跳过 client 重建,
|
||||
/// 避免 `create_provider` 路径 `new(timeout).with_timeout(timeout)` 的双重构造。
|
||||
pub fn with_timeout(mut self, secs: u64) -> Result<Self, LlmError> {
|
||||
if secs == self.timeout_secs {
|
||||
return Ok(self);
|
||||
}
|
||||
// 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}")))?;
|
||||
self.timeout_secs = secs;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// 一次性构造 —— `create_provider` 路径专用,避免 `new(...)` + `with_timeout(...)` 的双重 client 构造。
|
||||
///
|
||||
/// 调用方负责预先构造好符合 Anthropic 协议要求的 `http_client`(带正确的 `x-api-key` /
|
||||
/// `anthropic-version` 默认 headers + 指定 timeout)。
|
||||
pub(crate) fn from_parts(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
http_client: Client,
|
||||
timeout_secs: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
http_client,
|
||||
base_url: if base_url.is_empty() {
|
||||
"https://api.anthropic.com".to_string()
|
||||
} else {
|
||||
base_url
|
||||
},
|
||||
api_key,
|
||||
model,
|
||||
timeout_secs,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_max_tokens(&self, request: &MessageRequest) -> u32 {
|
||||
request.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS)
|
||||
}
|
||||
@@ -104,12 +174,14 @@ impl AnthropicProvider {
|
||||
Message::User { content } => {
|
||||
api_messages.push(AnthropicMessage::user(content));
|
||||
}
|
||||
Message::UserImage { data, mime_type, detail } => {
|
||||
Message::UserImage {
|
||||
data,
|
||||
mime_type,
|
||||
detail,
|
||||
} => {
|
||||
// Anthropic image format: {type: "image", source: {type: "base64", media_type, data}}
|
||||
let source = if data.starts_with("http://") || data.starts_with("https://") {
|
||||
AnthropicImageSource::Url {
|
||||
url: data.clone(),
|
||||
}
|
||||
AnthropicImageSource::Url { url: data.clone() }
|
||||
} else {
|
||||
AnthropicImageSource::Base64 {
|
||||
media_type: mime_type.clone(),
|
||||
@@ -194,7 +266,7 @@ impl AnthropicProvider {
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(Self::map_reqwest_error)?;
|
||||
.map_err(|e| self.map_reqwest_error(e))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
@@ -215,8 +287,7 @@ impl AnthropicProvider {
|
||||
async fn chat_stream_inner(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
let mut body = self.build_request_body(request)?;
|
||||
body.stream = Some(true);
|
||||
|
||||
@@ -230,16 +301,16 @@ impl AnthropicProvider {
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(Self::map_reqwest_error)?;
|
||||
.map_err(|e| self.map_reqwest_error(e))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(Self::handle_error_response(response).await);
|
||||
}
|
||||
|
||||
let byte_stream = response.bytes_stream().map(|r| {
|
||||
r.map_err(|e| LlmError::Other(format!("流式读取失败: {e}")))
|
||||
});
|
||||
let byte_stream = response
|
||||
.bytes_stream()
|
||||
.map(|r| r.map_err(|e| LlmError::Other(format!("流式读取失败: {e}"))));
|
||||
|
||||
let byte_stream: Pin<Box<dyn Stream<Item = Result<Bytes, LlmError>> + Send>> =
|
||||
Box::pin(byte_stream);
|
||||
@@ -247,10 +318,10 @@ impl AnthropicProvider {
|
||||
Ok(Box::pin(AnthropicSseStream::new(byte_stream)))
|
||||
}
|
||||
|
||||
fn map_reqwest_error(e: reqwest::Error) -> LlmError {
|
||||
fn map_reqwest_error(&self, e: reqwest::Error) -> LlmError {
|
||||
if e.is_timeout() {
|
||||
LlmError::Timeout {
|
||||
duration: Duration::from_secs(120),
|
||||
duration: Duration::from_secs(self.timeout_secs),
|
||||
}
|
||||
} else if e.is_connect() {
|
||||
LlmError::Other(format!("连接失败: {e}"))
|
||||
@@ -291,13 +362,12 @@ impl AnthropicProvider {
|
||||
blocks.push(ContentBlock::Text { text });
|
||||
}
|
||||
AnthropicContentBlockResp::ToolUse { id, name, input } => {
|
||||
blocks.push(ContentBlock::ToolUse {
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
});
|
||||
blocks.push(ContentBlock::ToolUse { id, name, input });
|
||||
}
|
||||
AnthropicContentBlockResp::Thinking { thinking, signature } => {
|
||||
AnthropicContentBlockResp::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
blocks.push(ContentBlock::Thinking {
|
||||
text: thinking,
|
||||
signature,
|
||||
@@ -336,8 +406,7 @@ impl LlmProvider for AnthropicProvider {
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
self.chat_stream_inner(request).await
|
||||
}
|
||||
|
||||
@@ -418,7 +487,9 @@ impl AnthropicMessage {
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum AnthropicContentPart {
|
||||
Text { text: String },
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
Image {
|
||||
source: AnthropicImageSource,
|
||||
},
|
||||
@@ -437,13 +508,8 @@ enum AnthropicContentPart {
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum AnthropicImageSource {
|
||||
Base64 {
|
||||
media_type: String,
|
||||
data: String,
|
||||
},
|
||||
Url {
|
||||
url: String,
|
||||
},
|
||||
Base64 { media_type: String, data: String },
|
||||
Url { url: String },
|
||||
}
|
||||
|
||||
fn content_to_parts(blocks: &[ContentBlock]) -> Vec<AnthropicContentPart> {
|
||||
@@ -523,9 +589,18 @@ struct AnthropicUsage {
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum AnthropicContentBlockResp {
|
||||
Text { text: String },
|
||||
ToolUse { id: String, name: String, input: Value },
|
||||
Thinking { thinking: String, signature: Option<String> },
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: Value,
|
||||
},
|
||||
Thinking {
|
||||
thinking: String,
|
||||
signature: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -662,18 +737,13 @@ impl AnthropicSseStream {
|
||||
// 先把所有字段提前,避免 match 中 part-move
|
||||
let block_type = match &content_block {
|
||||
AnthropicContentBlockStart::Text { .. } => ContentBlockType::Text,
|
||||
AnthropicContentBlockStart::ToolUse { id, name } => {
|
||||
ContentBlockType::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
}
|
||||
}
|
||||
AnthropicContentBlockStart::ToolUse { id, name } => ContentBlockType::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
},
|
||||
AnthropicContentBlockStart::Thinking { .. } => ContentBlockType::Thinking,
|
||||
};
|
||||
events.push(StreamEvent::ContentBlockStart {
|
||||
index,
|
||||
block_type,
|
||||
});
|
||||
events.push(StreamEvent::ContentBlockStart { index, block_type });
|
||||
let builder = match content_block {
|
||||
AnthropicContentBlockStart::Text { text } => {
|
||||
crate::llm::types::response_v2::ContentBlockBuilder::Text(text)
|
||||
@@ -742,7 +812,9 @@ impl AnthropicSseStream {
|
||||
completion_tokens_details: None,
|
||||
prompt_tokens_details: None,
|
||||
};
|
||||
events.push(StreamEvent::CostUpdate { usage: partial_usage });
|
||||
events.push(StreamEvent::CostUpdate {
|
||||
usage: partial_usage,
|
||||
});
|
||||
}
|
||||
}
|
||||
AnthropicSseEvent::MessageStop => {
|
||||
@@ -753,7 +825,9 @@ impl AnthropicSseStream {
|
||||
self.saw_terminal = true;
|
||||
match self.partial.clone().finalize() {
|
||||
Ok(full) => {
|
||||
events.push(StreamEvent::MessageComplete { full_response: full });
|
||||
events.push(StreamEvent::MessageComplete {
|
||||
full_response: full,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
events.push(StreamEvent::Error {
|
||||
@@ -780,10 +854,7 @@ fn _unused_marker() {}
|
||||
impl Stream for AnthropicSseStream {
|
||||
type Item = Result<StreamEvent, LlmError>;
|
||||
|
||||
fn poll_next(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<Self::Item>> {
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
loop {
|
||||
if let Some(data) = self.next_event_line() {
|
||||
let mut events = self.handle_event_json(&data);
|
||||
@@ -835,7 +906,12 @@ mod tests {
|
||||
|
||||
fn make_provider(base_url: String) -> AnthropicProvider {
|
||||
// 跳过默认 header 注入:测试用自定义 base_url 直接 mock
|
||||
AnthropicProvider::new(base_url, "sk-ant-test".into(), "claude-sonnet-4-20250514".into())
|
||||
AnthropicProvider::new(
|
||||
base_url,
|
||||
"sk-ant-test".into(),
|
||||
"claude-sonnet-4-20250514".into(),
|
||||
30,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -979,6 +1055,7 @@ event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
|
||||
"http://x".into(),
|
||||
"k".into(),
|
||||
"claude-sonnet-4-20250514".into(),
|
||||
30,
|
||||
)
|
||||
.capabilities();
|
||||
assert_eq!(caps.provider_name, "anthropic");
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Ollama Provider —— OpenAI-compatible 协议的 newtype 包装,零 API key。
|
||||
//!
|
||||
//! 默认 base_url = `http://localhost:11434/v1`,空 api_key 也可工作。
|
||||
//! 实现方式同 `DeepSeekProvider` / `QwenProvider`,共享 `GenericOpenaiProvider`
|
||||
//! 的 HTTP/SSE/转换逻辑,仅配置不同。
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_core::Stream;
|
||||
use reqwest::Client;
|
||||
|
||||
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};
|
||||
|
||||
/// Ollama 本地 Provider —— OpenAI-compatible 协议的 newtype 包装。
|
||||
///
|
||||
/// Ollama 在 `localhost:11434` 暴露与 OpenAI 兼容的 `/v1/chat/completions`
|
||||
/// 接口,因此完全复用 `GenericOpenaiProvider` 的实现。允许空 `api_key`。
|
||||
pub struct OllamaProvider(pub GenericOpenaiProvider);
|
||||
|
||||
impl OllamaProvider {
|
||||
/// 构造 Ollama Provider。
|
||||
///
|
||||
/// - `base_url` 为空时使用默认 `http://localhost:11434/v1`
|
||||
/// - `api_key` 可为空字符串(Ollama 不校验)
|
||||
pub fn new(base_url: String, api_key: String, model: String, timeout_secs: u64) -> 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",
|
||||
timeout_secs,
|
||||
))
|
||||
}
|
||||
|
||||
/// 替换默认 HTTP Client(用于 timeout 注入等场景)。
|
||||
///
|
||||
/// 与 `OpenaiChatProvider::with_client`、`DeepSeekProvider::with_client`、
|
||||
/// `QwenProvider::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
|
||||
}
|
||||
}
|
||||
+92
-55
@@ -52,31 +52,63 @@ pub struct GenericOpenaiProvider {
|
||||
model: String,
|
||||
provider_name: &'static str,
|
||||
extra_headers: Vec<(String, String)>,
|
||||
/// HTTP 请求超时秒数。由 `ProviderConfig::timeout_secs` 传入,
|
||||
/// 在 `LlmError::Timeout { duration }` 中回显。`reqwest::Client` 不暴露 timeout getter,
|
||||
/// 因此单独存储以便错误消息与配置保持一致。
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl GenericOpenaiProvider {
|
||||
/// 基础构造器。
|
||||
pub fn new_with_name(
|
||||
/// 一次性构造 —— `create_provider` 路径专用,避免 `new_with_name` + `with_client` 的双重 client 构造。
|
||||
///
|
||||
/// 调用方负责预先构造好带正确 timeout 的 `http_client`。`extra_headers` 与 `timeout_secs`
|
||||
/// 一并设置字段,避免后续修改。
|
||||
pub(crate) fn from_parts(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
provider_name: &'static str,
|
||||
http_client: Client,
|
||||
extra_headers: Vec<(String, String)>,
|
||||
timeout_secs: u64,
|
||||
) -> Self {
|
||||
let http_client = Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.build()
|
||||
.expect("创建 HTTP 客户端失败");
|
||||
|
||||
Self {
|
||||
http_client,
|
||||
base_url,
|
||||
api_key,
|
||||
model,
|
||||
provider_name,
|
||||
extra_headers: Vec::new(),
|
||||
extra_headers,
|
||||
timeout_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// 基础构造器。
|
||||
///
|
||||
/// `timeout_secs` 应用于 `reqwest::Client` 的请求超时配置。
|
||||
/// 应由 `ProviderConfig::timeout_secs` 传入(调用方如不知道,可传 30)。
|
||||
pub fn new_with_name(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
provider_name: &'static str,
|
||||
timeout_secs: u64,
|
||||
) -> Self {
|
||||
let http_client = Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.expect("创建 HTTP 客户端失败");
|
||||
Self::from_parts(
|
||||
base_url,
|
||||
api_key,
|
||||
model,
|
||||
provider_name,
|
||||
http_client,
|
||||
Vec::new(),
|
||||
timeout_secs,
|
||||
)
|
||||
}
|
||||
|
||||
/// 带额外请求头的构造器(如 Qwen 需要 SSE 启用头)。
|
||||
pub fn new_with_name_and_headers(
|
||||
base_url: String,
|
||||
@@ -84,10 +116,21 @@ impl GenericOpenaiProvider {
|
||||
model: String,
|
||||
provider_name: &'static str,
|
||||
extra_headers: Vec<(String, String)>,
|
||||
timeout_secs: u64,
|
||||
) -> Self {
|
||||
let mut base = Self::new_with_name(base_url, api_key, model, provider_name);
|
||||
base.extra_headers = extra_headers;
|
||||
base
|
||||
let http_client = Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.expect("创建 HTTP 客户端失败");
|
||||
Self::from_parts(
|
||||
base_url,
|
||||
api_key,
|
||||
model,
|
||||
provider_name,
|
||||
http_client,
|
||||
extra_headers,
|
||||
timeout_secs,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_client(mut self, client: Client) -> Self {
|
||||
@@ -119,10 +162,10 @@ impl GenericOpenaiProvider {
|
||||
Ok(builder.json(body))
|
||||
}
|
||||
|
||||
fn map_reqwest_error(e: reqwest::Error) -> LlmError {
|
||||
fn map_reqwest_error(&self, e: reqwest::Error) -> LlmError {
|
||||
if e.is_timeout() {
|
||||
LlmError::Timeout {
|
||||
duration: Duration::from_secs(120),
|
||||
duration: Duration::from_secs(self.timeout_secs),
|
||||
}
|
||||
} else if e.is_connect() {
|
||||
LlmError::Other(format!("连接失败: {}", e))
|
||||
@@ -135,9 +178,7 @@ impl GenericOpenaiProvider {
|
||||
///
|
||||
/// ponytail: Qwen 等部分 OpenAI-compatible 提供方可能返回非标准 error body
|
||||
/// (无法解析为 JSON),此处直接用 status code + 原始 body 兜底。
|
||||
async fn handle_error_response(
|
||||
response: reqwest::Response,
|
||||
) -> LlmError {
|
||||
async fn handle_error_response(response: reqwest::Response) -> LlmError {
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
|
||||
@@ -148,10 +189,7 @@ impl GenericOpenaiProvider {
|
||||
// 与 OpenAI 完全一致;DeepSeek/Qwen 通常遵循。
|
||||
LlmError::RateLimit { retry_after: None }
|
||||
}
|
||||
_ if status >= 500 => LlmError::Request {
|
||||
status,
|
||||
body,
|
||||
},
|
||||
_ if status >= 500 => LlmError::Request { status, body },
|
||||
_ if status == 400 && body.contains("context_length_exceeded") => {
|
||||
LlmError::ContextLength {
|
||||
actual: 0,
|
||||
@@ -200,7 +238,9 @@ impl GenericOpenaiProvider {
|
||||
stop_sequences[0].clone(),
|
||||
))
|
||||
} else {
|
||||
Some(crate::llm::types::shared::StopSequence::Multiple(stop_sequences))
|
||||
Some(crate::llm::types::shared::StopSequence::Multiple(
|
||||
stop_sequences,
|
||||
))
|
||||
};
|
||||
|
||||
let frequency_penalty = request.get_extra_opt("frequency_penalty");
|
||||
@@ -245,9 +285,7 @@ impl GenericOpenaiProvider {
|
||||
let stop_reason = match choice.finish_reason {
|
||||
Some(FinishReason::Stop) => StopReason::Stop,
|
||||
Some(FinishReason::Length) => StopReason::Length,
|
||||
Some(FinishReason::ToolCalls) | Some(FinishReason::FunctionCall) => {
|
||||
StopReason::ToolUse
|
||||
}
|
||||
Some(FinishReason::ToolCalls) | Some(FinishReason::FunctionCall) => StopReason::ToolUse,
|
||||
Some(FinishReason::ContentFilter) => StopReason::ContentFilter,
|
||||
Some(FinishReason::Other) | None => StopReason::Stop,
|
||||
};
|
||||
@@ -263,7 +301,10 @@ impl GenericOpenaiProvider {
|
||||
}
|
||||
|
||||
/// 非流式 `chat()` 入口。
|
||||
pub async fn chat_blocking(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||
pub async fn chat_blocking(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<MessageResponse, LlmError> {
|
||||
let req = self.convert_request(request)?;
|
||||
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
|
||||
|
||||
@@ -280,7 +321,7 @@ impl GenericOpenaiProvider {
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = %e, "请求失败");
|
||||
Self::map_reqwest_error(e)
|
||||
self.map_reqwest_error(e)
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
@@ -303,8 +344,7 @@ impl GenericOpenaiProvider {
|
||||
pub async fn chat_stream_inner(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
let mut req = self.convert_request(request)?;
|
||||
req.stream = Some(true);
|
||||
req.stream_options = Some(StreamOptions {
|
||||
@@ -322,7 +362,7 @@ impl GenericOpenaiProvider {
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = %e, "流式请求失败");
|
||||
Self::map_reqwest_error(e)
|
||||
self.map_reqwest_error(e)
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
@@ -331,9 +371,9 @@ impl GenericOpenaiProvider {
|
||||
}
|
||||
|
||||
let byte_stream: std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, LlmError>> + Send>> = {
|
||||
let s = response.bytes_stream().map(|r| {
|
||||
r.map_err(|e| LlmError::Other(format!("流式读取失败: {}", e)))
|
||||
});
|
||||
let s = response
|
||||
.bytes_stream()
|
||||
.map(|r| r.map_err(|e| LlmError::Other(format!("流式读取失败: {}", e))));
|
||||
Box::pin(s)
|
||||
};
|
||||
|
||||
@@ -368,8 +408,7 @@ impl LlmProvider for GenericOpenaiProvider {
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
self.chat_stream_inner(request).await
|
||||
}
|
||||
|
||||
@@ -389,12 +428,13 @@ impl LlmProvider for GenericOpenaiProvider {
|
||||
pub struct OpenaiChatProvider(pub GenericOpenaiProvider);
|
||||
|
||||
impl OpenaiChatProvider {
|
||||
pub fn new(base_url: String, api_key: String, model: String) -> Self {
|
||||
pub fn new(base_url: String, api_key: String, model: String, timeout_secs: u64) -> Self {
|
||||
Self(GenericOpenaiProvider::new_with_name(
|
||||
base_url,
|
||||
api_key,
|
||||
model,
|
||||
"openai",
|
||||
timeout_secs,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -412,8 +452,7 @@ impl LlmProvider for OpenaiChatProvider {
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
self.0.chat_stream(request).await
|
||||
}
|
||||
|
||||
@@ -436,7 +475,10 @@ enum BlockState {
|
||||
/// 正在累积 text block。
|
||||
InText { block_index: u32 },
|
||||
/// 正在累积 tool_call block。
|
||||
InTool { block_index: u32, tool_call_index: u32 },
|
||||
InTool {
|
||||
block_index: u32,
|
||||
tool_call_index: u32,
|
||||
},
|
||||
/// 正在累积 refusal block。
|
||||
InRefusal { block_index: u32 },
|
||||
}
|
||||
@@ -456,9 +498,7 @@ pub struct ChunkToEventStream {
|
||||
}
|
||||
|
||||
impl ChunkToEventStream {
|
||||
fn new(
|
||||
chunks: Pin<Box<dyn Stream<Item = Result<Bytes, LlmError>> + Send>>,
|
||||
) -> Self {
|
||||
fn new(chunks: Pin<Box<dyn Stream<Item = Result<Bytes, LlmError>> + Send>>) -> Self {
|
||||
Self {
|
||||
chunks,
|
||||
buffer: String::new(),
|
||||
@@ -500,12 +540,7 @@ impl ChunkToEventStream {
|
||||
let mut events = Vec::new();
|
||||
|
||||
// 元信息:MessageStart(仅在第一次见到 role=assistant 时)。
|
||||
if self.partial.id.is_none()
|
||||
&& chunk
|
||||
.choices
|
||||
.iter()
|
||||
.any(|c| c.delta.role.is_some())
|
||||
{
|
||||
if self.partial.id.is_none() && chunk.choices.iter().any(|c| c.delta.role.is_some()) {
|
||||
events.push(StreamEvent::MessageStart {
|
||||
id: chunk.id.clone(),
|
||||
model: chunk.model.clone(),
|
||||
@@ -650,9 +685,7 @@ impl ChunkToEventStream {
|
||||
self.partial.stop_reason = Some(match fr {
|
||||
FinishReason::Stop => StopReason::Stop,
|
||||
FinishReason::Length => StopReason::Length,
|
||||
FinishReason::ToolCalls | FinishReason::FunctionCall => {
|
||||
StopReason::ToolUse
|
||||
}
|
||||
FinishReason::ToolCalls | FinishReason::FunctionCall => StopReason::ToolUse,
|
||||
FinishReason::ContentFilter => StopReason::ContentFilter,
|
||||
FinishReason::Other => StopReason::Other,
|
||||
});
|
||||
@@ -692,7 +725,9 @@ impl ChunkToEventStream {
|
||||
|
||||
match self.partial.clone().finalize() {
|
||||
Ok(full) => {
|
||||
events.push(StreamEvent::MessageComplete { full_response: full });
|
||||
events.push(StreamEvent::MessageComplete {
|
||||
full_response: full,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
events.push(StreamEvent::Error {
|
||||
@@ -707,10 +742,7 @@ impl ChunkToEventStream {
|
||||
impl Stream for ChunkToEventStream {
|
||||
type Item = Result<StreamEvent, LlmError>;
|
||||
|
||||
fn poll_next(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<Self::Item>> {
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
loop {
|
||||
// 先尝试从 buffer 取一行处理
|
||||
if let Some(line) = self.next_line() {
|
||||
@@ -814,6 +846,7 @@ mod tests {
|
||||
"sk-test".into(),
|
||||
"gpt-4o".into(),
|
||||
"openai",
|
||||
30,
|
||||
);
|
||||
let response = provider
|
||||
.chat_blocking(MessageRequest {
|
||||
@@ -842,6 +875,7 @@ mod tests {
|
||||
"sk-test".into(),
|
||||
"gpt-4o".into(),
|
||||
"openai",
|
||||
30,
|
||||
);
|
||||
let err = provider
|
||||
.chat_blocking(MessageRequest {
|
||||
@@ -868,6 +902,7 @@ mod tests {
|
||||
"sk-test".into(),
|
||||
"gpt-4o".into(),
|
||||
"openai",
|
||||
30,
|
||||
);
|
||||
let err = provider
|
||||
.chat_blocking(MessageRequest {
|
||||
@@ -906,6 +941,7 @@ data: [DONE]\n\n";
|
||||
"sk-test".into(),
|
||||
"gpt-4o".into(),
|
||||
"openai",
|
||||
30,
|
||||
);
|
||||
let mut stream = provider
|
||||
.chat_stream_inner(MessageRequest {
|
||||
@@ -974,6 +1010,7 @@ data: [DONE]\n\n";
|
||||
"k".into(),
|
||||
"gpt-4o".into(),
|
||||
"openai",
|
||||
30,
|
||||
);
|
||||
let ir = provider.convert_response(resp).unwrap();
|
||||
assert_eq!(ir.stop_reason, StopReason::ToolUse);
|
||||
|
||||
@@ -15,12 +15,12 @@ use std::pin::Pin;
|
||||
use async_trait::async_trait;
|
||||
use futures_core::Stream;
|
||||
|
||||
use super::openai::GenericOpenaiProvider;
|
||||
use super::ProviderCapabilities;
|
||||
use super::openai::GenericOpenaiProvider;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
use crate::llm::provider::LlmProvider;
|
||||
|
||||
// =============================================================================
|
||||
// DeepSeek
|
||||
@@ -29,7 +29,7 @@ use crate::llm::provider::LlmProvider;
|
||||
pub struct DeepSeekProvider(pub GenericOpenaiProvider);
|
||||
|
||||
impl DeepSeekProvider {
|
||||
pub fn new(base_url: String, api_key: String, model: String) -> Self {
|
||||
pub fn new(base_url: String, api_key: String, model: String, timeout_secs: u64) -> Self {
|
||||
let url = if base_url.is_empty() {
|
||||
"https://api.deepseek.com".to_string()
|
||||
} else {
|
||||
@@ -40,26 +40,27 @@ impl DeepSeekProvider {
|
||||
api_key,
|
||||
model,
|
||||
"deepseek",
|
||||
timeout_secs,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl DeepSeekProvider {
|
||||
/// 替换默认 HTTP Client(用于 timeout 注入等场景)。
|
||||
pub fn with_client(self, client: reqwest::Client) -> Self {
|
||||
Self(self.0.with_client(client))
|
||||
}
|
||||
|
||||
/// 测试中(带 mock_client)使用的构造器。
|
||||
///
|
||||
/// ponytail: 此处 `30` 是 `timeout_secs` 字段的占位值,仅用于 `map_reqwest_error`
|
||||
/// 错误消息中的回显。实际请求超时由传入的 `client` 控制(通常测试用的 mock client
|
||||
/// 无超时),不影响行为。
|
||||
pub fn new_with_client(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
client: reqwest::Client,
|
||||
) -> Self {
|
||||
let url = if base_url.is_empty() {
|
||||
"https://api.deepseek.com".to_string()
|
||||
} else {
|
||||
base_url
|
||||
};
|
||||
let mut inner = GenericOpenaiProvider::new_with_name(url, api_key, model, "deepseek");
|
||||
inner.http_client = client;
|
||||
Self(inner)
|
||||
Self::new(base_url, api_key, model, 30).with_client(client)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,8 +73,7 @@ impl LlmProvider for DeepSeekProvider {
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
self.0.chat_stream(request).await
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ impl LlmProvider for DeepSeekProvider {
|
||||
pub struct QwenProvider(pub GenericOpenaiProvider);
|
||||
|
||||
impl QwenProvider {
|
||||
pub fn new(base_url: String, api_key: String, model: String) -> Self {
|
||||
pub fn new(base_url: String, api_key: String, model: String, timeout_secs: u64) -> Self {
|
||||
let url = if base_url.is_empty() {
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1".to_string()
|
||||
} else {
|
||||
@@ -104,31 +104,28 @@ impl QwenProvider {
|
||||
model,
|
||||
"qwen",
|
||||
vec![("X-DashScope-SSE".to_string(), "enable".to_string())],
|
||||
timeout_secs,
|
||||
);
|
||||
Self(inner)
|
||||
}
|
||||
|
||||
/// 替换默认 HTTP Client(用于 timeout 注入等场景)。
|
||||
pub fn with_client(self, client: reqwest::Client) -> Self {
|
||||
Self(self.0.with_client(client))
|
||||
}
|
||||
|
||||
/// 测试构造器。
|
||||
///
|
||||
/// ponytail: 此处 `30` 是 `timeout_secs` 字段的占位值,仅用于 `map_reqwest_error`
|
||||
/// 错误消息中的回显。实际请求超时由传入的 `client` 控制(通常测试用的 mock client
|
||||
/// 无超时),不影响行为。
|
||||
pub fn new_with_client(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
client: reqwest::Client,
|
||||
) -> Self {
|
||||
let url = if base_url.is_empty() {
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1".to_string()
|
||||
} else {
|
||||
base_url
|
||||
};
|
||||
let mut inner = GenericOpenaiProvider::new_with_name_and_headers(
|
||||
url,
|
||||
api_key,
|
||||
model,
|
||||
"qwen",
|
||||
vec![("X-DashScope-SSE".to_string(), "enable".to_string())],
|
||||
);
|
||||
inner.http_client = client;
|
||||
Self(inner)
|
||||
Self::new(base_url, api_key, model, 30).with_client(client)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,8 +138,7 @@ impl LlmProvider for QwenProvider {
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
self.0.chat_stream(request).await
|
||||
}
|
||||
|
||||
@@ -156,8 +152,8 @@ impl LlmProvider for QwenProvider {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::message::Message as IrMessage;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
@@ -182,11 +178,8 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = DeepSeekProvider::new(
|
||||
server.uri(),
|
||||
"sk-test".into(),
|
||||
"deepseek-chat".into(),
|
||||
);
|
||||
let provider =
|
||||
DeepSeekProvider::new(server.uri(), "sk-test".into(), "deepseek-chat".into(), 30);
|
||||
let response = provider
|
||||
.chat(MessageRequest {
|
||||
model: "deepseek-chat".into(),
|
||||
@@ -219,7 +212,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = QwenProvider::new(server.uri(), "sk-test".into(), "qwen-plus".into());
|
||||
let provider = QwenProvider::new(server.uri(), "sk-test".into(), "qwen-plus".into(), 30);
|
||||
let response = provider
|
||||
.chat(MessageRequest {
|
||||
model: "qwen-plus".into(),
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::llm::types::usage::{CompletionTokensDetails, PromptTokensDetails, Usa
|
||||
/// Phase 2 完成时统一收敛。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum StopReason {
|
||||
/// 自然停止。
|
||||
Stop,
|
||||
@@ -168,7 +169,10 @@ pub enum StreamEvent {
|
||||
/// 消息开始(元信息)。
|
||||
MessageStart { id: String, model: String },
|
||||
/// 内容块开始(告知块类型,携带 id/name for ToolUse)。
|
||||
ContentBlockStart { index: u32, block_type: ContentBlockType },
|
||||
ContentBlockStart {
|
||||
index: u32,
|
||||
block_type: ContentBlockType,
|
||||
},
|
||||
/// 内容块结束标记。
|
||||
ContentBlockEnd { index: u32 },
|
||||
/// 文本增量。
|
||||
@@ -320,9 +324,8 @@ impl PartialMessageResponse {
|
||||
true
|
||||
}
|
||||
StreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
if let Some(ContentBlockBuilder::ToolUse {
|
||||
arguments: buf, ..
|
||||
}) = self.blocks.get_mut(index)
|
||||
if let Some(ContentBlockBuilder::ToolUse { arguments: buf, .. }) =
|
||||
self.blocks.get_mut(index)
|
||||
{
|
||||
buf.push_str(arguments);
|
||||
}
|
||||
@@ -360,9 +363,7 @@ impl PartialMessageResponse {
|
||||
let mut content_blocks = Vec::with_capacity(self.blocks.len());
|
||||
for (idx, builder) in self.blocks {
|
||||
let block = Self::builder_to_block(idx, builder, self.thinking_signature.as_deref())
|
||||
.map_err(|e| LlmError::Other(format!(
|
||||
"partial 块 #{idx} finalize 失败: {e}"
|
||||
)))?;
|
||||
.map_err(|e| LlmError::Other(format!("partial 块 #{idx} finalize 失败: {e}")))?;
|
||||
content_blocks.push(block);
|
||||
}
|
||||
|
||||
@@ -743,10 +744,7 @@ mod tests {
|
||||
Message::Assistant { content } => {
|
||||
assert_eq!(content.len(), 2);
|
||||
match (&content[0], &content[1]) {
|
||||
(
|
||||
ContentBlock::Text { text: t1 },
|
||||
ContentBlock::Text { text: t2 },
|
||||
) => {
|
||||
(ContentBlock::Text { text: t1 }, ContentBlock::Text { text: t2 }) => {
|
||||
assert_eq!(t1, "first");
|
||||
assert_eq!(t2, "second");
|
||||
}
|
||||
@@ -772,9 +770,7 @@ mod tests {
|
||||
index: 0,
|
||||
block_type: ContentBlockType::Text,
|
||||
},
|
||||
StreamEvent::TextDelta {
|
||||
text: "x".into(),
|
||||
},
|
||||
StreamEvent::TextDelta { text: "x".into() },
|
||||
StreamEvent::ContentBlockEnd { index: 0 },
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_response(),
|
||||
@@ -832,15 +828,9 @@ mod tests {
|
||||
block_type: ContentBlockType::Text,
|
||||
},
|
||||
StreamEvent::ContentBlockEnd { index: 0 },
|
||||
StreamEvent::TextDelta {
|
||||
text: "t".into(),
|
||||
},
|
||||
StreamEvent::ThinkingDelta {
|
||||
text: "p".into(),
|
||||
},
|
||||
StreamEvent::RefusalDelta {
|
||||
text: "r".into(),
|
||||
},
|
||||
StreamEvent::TextDelta { text: "t".into() },
|
||||
StreamEvent::ThinkingDelta { text: "p".into() },
|
||||
StreamEvent::RefusalDelta { text: "r".into() },
|
||||
StreamEvent::ToolCallArgumentsDelta {
|
||||
index: 1,
|
||||
arguments: "{\"x\":1}".into(),
|
||||
|
||||
@@ -13,6 +13,7 @@ pub enum Role {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum FinishReason {
|
||||
Stop,
|
||||
Length,
|
||||
|
||||
@@ -32,6 +32,7 @@ pub trait MemoryStore: Send + Sync {
|
||||
|
||||
/// 淘汰策略。
|
||||
#[derive(Debug, Clone)]
|
||||
#[non_exhaustive]
|
||||
pub enum EvictionPolicy {
|
||||
/// 不淘汰(默认)。
|
||||
None,
|
||||
|
||||
Reference in New Issue
Block a user