feat(llm): 重写 Provider 适配层,支持 OpenAI Chat / Anthropic / DeepSeek / Qwen

重写 OpenaiChatProvider:移除 Phase 0 临时桥接,实现真实流式状态机
(ContentBlockStart / *Delta / ContentBlockEnd / ToolCallEnd),
MessageComplete.full_response 由 PartialMessageResponse::finalize 产出。

新增 AnthropicProvider:实现 Messages API + SSE 事件序列
(message_start → content_block_start → content_block_delta →
content_block_stop → message_delta → message_stop),
529(overloaded)映射为 RateLimit;thinking signature 由
partial.set_thinking_signature 内部写入。

新增 DeepSeekProvider / QwenProvider:OpenAI-compatible 协议的
newtype 包装,共享 GenericOpenaiProvider 的 HTTP / SSE / 转换逻辑;
Qwen 通过 extra_headers 注入 X-DashScope-SSE: enable。

新增 convert.rs 公共转换模块:从 Phase 0 cycle.rs / Phase 0
OpenaiProvider 桥接层提取 from_openai / to_openai /
content_to_blocks / blocks_to_content,避免跨 Provider 重复逻辑。

新增 wiremock dev-dependency + 14 个集成测试:
- OpenaiChatProvider:基础文本 / 401 / 500 / 流式 MessageComplete
- AnthropicProvider:基础 / 401 / 529 / 流式 SSE 序列 / max_tokens 默认值
- DeepSeek / Qwen:基础文本响应

171 个测试全部通过(之前 157,新增 14)。
This commit is contained in:
徐涛
2026-07-02 22:24:30 +08:00
parent 7c299f1cfd
commit a74c24b6fe
9 changed files with 2550 additions and 379 deletions
File diff suppressed because it is too large Load Diff
+814 -362
View File
File diff suppressed because it is too large Load Diff
+234
View File
@@ -0,0 +1,234 @@
//! DeepSeek / Qwen Provider —— OpenAI-compatible 协议的 newtype 包装。
//!
//! DeepSeek 与 Qwen 都是 OpenAI-compatible(共享 `/v1/chat/completions` 协议),
//! 但二者 base_url 与 Qwen 需要额外请求头(`X-DashScope-SSE: enable`)。
//! 通过 newtype 包装 `GenericOpenaiProvider` 提供:
//! - 独立的 `capabilities().provider_name`
//! - 未来可独立扩展(如 Qwen 的特殊错误映射、DeepSeek 的特殊响应解析)
//!
//! 类型别名方案(`pub type DeepSeekProvider = GenericOpenaiProvider`)被否决:类型别名
//! 无法在编译期区分 DeepSeek vs OpenAI 调用,编译期安全检查失效。
//! (参考 `docs/10b-phase1-provider-adaptation.md` §"OpenAI-compatible 复用策略"
use std::pin::Pin;
use async_trait::async_trait;
use futures_core::Stream;
use super::openai::GenericOpenaiProvider;
use super::ProviderCapabilities;
use crate::llm::error::LlmError;
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
use crate::llm::provider::LlmProvider;
// =============================================================================
// DeepSeek
// =============================================================================
pub struct DeepSeekProvider(pub GenericOpenaiProvider);
impl DeepSeekProvider {
pub fn new(base_url: String, api_key: String, model: String) -> Self {
let url = if base_url.is_empty() {
"https://api.deepseek.com".to_string()
} else {
base_url
};
Self(GenericOpenaiProvider::new_with_name(
url,
api_key,
model,
"deepseek",
))
}
}
impl DeepSeekProvider {
/// 测试中(带 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)
}
}
#[async_trait]
impl LlmProvider for DeepSeekProvider {
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 = "deepseek";
caps
}
}
// =============================================================================
// Qwen
// =============================================================================
pub struct QwenProvider(pub GenericOpenaiProvider);
impl QwenProvider {
pub fn new(base_url: String, api_key: String, model: String) -> Self {
let url = if base_url.is_empty() {
"https://dashscope.aliyuncs.com/compatible-mode/v1".to_string()
} else {
base_url
};
// Qwen 兼容模式流式需要 DashScope 特定的 SSE 启用头。
let inner = GenericOpenaiProvider::new_with_name_and_headers(
url,
api_key,
model,
"qwen",
vec![("X-DashScope-SSE".to_string(), "enable".to_string())],
);
Self(inner)
}
/// 测试构造器。
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)
}
}
#[async_trait]
impl LlmProvider for QwenProvider {
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 = "qwen";
caps
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::message::Message as IrMessage;
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn deepseek_chat_basic_text_response() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "ds-1",
"object": "chat.completion",
"created": 1718000000,
"model": "deepseek-chat",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "DeepSeek hi"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}
})))
.mount(&server)
.await;
let provider = DeepSeekProvider::new(
server.uri(),
"sk-test".into(),
"deepseek-chat".into(),
);
let response = provider
.chat(MessageRequest {
model: "deepseek-chat".into(),
messages: vec![IrMessage::user_text("hi")],
..Default::default()
})
.await
.unwrap();
assert_eq!(response.text(), "DeepSeek hi");
assert_eq!(provider.capabilities().provider_name, "deepseek");
}
#[tokio::test]
async fn qwen_chat_basic_text_response() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "qw-1",
"object": "chat.completion",
"created": 1718000000,
"model": "qwen-plus",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Qwen 你好"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 6, "completion_tokens": 2, "total_tokens": 8}
})))
.mount(&server)
.await;
let provider = QwenProvider::new(server.uri(), "sk-test".into(), "qwen-plus".into());
let response = provider
.chat(MessageRequest {
model: "qwen-plus".into(),
messages: vec![IrMessage::user_text("hi")],
..Default::default()
})
.await
.unwrap();
assert_eq!(response.text(), "Qwen 你好");
assert_eq!(provider.capabilities().provider_name, "qwen");
}
}