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:
+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");
|
||||
|
||||
Reference in New Issue
Block a user