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