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:
File diff suppressed because it is too large
Load Diff
+814
-362
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user