新增: 初始化 DevFlow 项目仓库
Tauri 2 + Vue 3 + Vite 6 桌面应用,Rust workspace 含 13 个 crate (df-ai / df-storage / df-workflow / df-core / df-execute 等)。 核心能力:AI 聊天 agentic 循环(工具调用+人工审批)、工作流引擎、 任务/想法/项目/阶段管理、可追溯性,及配套前端组件。
This commit is contained in:
410
crates/df-ai/src/openai_compat.rs
Normal file
410
crates/df-ai/src/openai_compat.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
//! OpenAI 兼容 Provider — 通过 /v1/chat/completions 端点实现
|
||||
//!
|
||||
//! 覆盖: OpenAI / GLM (open.bigmodel.cn) / DeepSeek / Claude OpenAI 兼容模式
|
||||
//! 支持: 同步调用 + SSE 流式 + Function Calling / Tool Use
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::{Stream, StreamExt};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, ProviderFeatures, StreamChunk, StreamResult,
|
||||
TokenUsage, ToolCall, ToolCallDelta,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// OpenAI API 请求/响应结构体
|
||||
// ============================================================
|
||||
|
||||
/// OpenAI 兼容请求体
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OpenAiRequest {
|
||||
model: String,
|
||||
messages: Vec<OpenAiMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_tokens: Option<u32>,
|
||||
stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// OpenAI 消息格式
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct OpenAiMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// OpenAI 同步响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiResponse {
|
||||
choices: Vec<OpenAiChoice>,
|
||||
model: String,
|
||||
usage: Option<OpenAiUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiChoice {
|
||||
message: OpenAiMessageResp,
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiMessageResp {
|
||||
content: Option<String>,
|
||||
tool_calls: Option<Vec<OpenAiToolCallResp>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiToolCallResp {
|
||||
id: String,
|
||||
#[serde(rename = "type")]
|
||||
call_type: String,
|
||||
function: OpenAiFunctionResp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiFunctionResp {
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiUsage {
|
||||
prompt_tokens: u32,
|
||||
completion_tokens: u32,
|
||||
total_tokens: u32,
|
||||
}
|
||||
|
||||
/// SSE 流式响应 chunk
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamChunk {
|
||||
choices: Vec<OpenAiStreamChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamChoice {
|
||||
delta: OpenAiStreamDelta,
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamDelta {
|
||||
content: Option<String>,
|
||||
tool_calls: Option<Vec<OpenAiStreamToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamToolCall {
|
||||
index: u32,
|
||||
id: Option<String>,
|
||||
function: Option<OpenAiStreamFunction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamFunction {
|
||||
name: Option<String>,
|
||||
arguments: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OpenAI Compat Provider
|
||||
// ============================================================
|
||||
|
||||
/// OpenAI 兼容 LLM Provider
|
||||
pub struct OpenAICompatProvider {
|
||||
client: Client,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
default_model: String,
|
||||
}
|
||||
|
||||
impl OpenAICompatProvider {
|
||||
/// 创建 Provider
|
||||
///
|
||||
/// - `base_url`: 如 "https://api.openai.com", "https://open.bigmodel.cn/api/paas", "https://api.deepseek.com"
|
||||
/// - `api_key`: API 密钥
|
||||
/// - `default_model`: 默认模型名称
|
||||
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
|
||||
// connect_timeout 防连接阶段无限 hang(网络静默断);不设总 timeout——
|
||||
// reqwest 的 .timeout() 会限制整个响应 body 时长,流式长生成任务会被误砍。
|
||||
// 读取阶段的中途静默由上层 stream_llm 的 idle timeout 兜底。
|
||||
let client = Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("reqwest builder 失败,降级为默认 client(无 connect_timeout): {}", e);
|
||||
Client::new()
|
||||
});
|
||||
Self {
|
||||
client,
|
||||
api_key: api_key.into(),
|
||||
base_url: base_url.into(),
|
||||
default_model: default_model.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建完整 API URL
|
||||
///
|
||||
/// 智能拼接,兼容三种 base_url 约定:
|
||||
/// - 已含完整端点(…/chat/completions)→ 直接用
|
||||
/// - 已含版本段(…/v1 …/v4 等,如 GLM 的 /api/paas/v4)→ 补 /chat/completions
|
||||
/// - 仅域名无版本(如 api.openai.com / api.deepseek.com)→ 补 /v1/chat/completions(OpenAI 约定)
|
||||
fn chat_url(&self) -> String {
|
||||
let base = self.base_url.trim_end_matches('/');
|
||||
if base.ends_with("/chat/completions") {
|
||||
return base.to_string();
|
||||
}
|
||||
if Self::ends_with_version(base) {
|
||||
return format!("{}/chat/completions", base);
|
||||
}
|
||||
format!("{}/v1/chat/completions", base)
|
||||
}
|
||||
|
||||
/// base_url 是否以 `/v<数字>` 结尾(如 /v1 /v4)
|
||||
fn ends_with_version(base: &str) -> bool {
|
||||
match base.rsplit_once('/') {
|
||||
Some((_, last)) if last.starts_with('v') && last.len() > 1 => {
|
||||
last[1..].bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 将通用请求转换为 OpenAI 格式
|
||||
fn convert_request(&self, req: CompletionRequest) -> OpenAiRequest {
|
||||
let model = if req.model.is_empty() {
|
||||
self.default_model.clone()
|
||||
} else {
|
||||
req.model
|
||||
};
|
||||
|
||||
let messages: Vec<OpenAiMessage> = req
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let role = match m.role {
|
||||
crate::provider::MessageRole::System => "system",
|
||||
crate::provider::MessageRole::User => "user",
|
||||
crate::provider::MessageRole::Assistant => "assistant",
|
||||
crate::provider::MessageRole::Tool => "tool",
|
||||
};
|
||||
let tool_calls = m.tool_calls.map(|calls| {
|
||||
calls
|
||||
.into_iter()
|
||||
.map(|tc| {
|
||||
serde_json::json!({
|
||||
"id": tc.id,
|
||||
"type": tc.call_type,
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
OpenAiMessage {
|
||||
role: role.to_string(),
|
||||
content: m.content,
|
||||
tool_call_id: m.tool_call_id,
|
||||
tool_calls,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tools = req.tools.map(|defs| {
|
||||
defs.into_iter()
|
||||
.map(|d| serde_json::to_value(d).unwrap_or_default())
|
||||
.collect()
|
||||
});
|
||||
|
||||
OpenAiRequest {
|
||||
model,
|
||||
messages,
|
||||
temperature: req.temperature,
|
||||
max_tokens: req.max_tokens,
|
||||
stream: req.stream,
|
||||
tools,
|
||||
tool_choice: req.tool_choice,
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析同步响应中的工具调用
|
||||
fn parse_tool_calls(calls: Vec<OpenAiToolCallResp>) -> Vec<ToolCall> {
|
||||
calls
|
||||
.into_iter()
|
||||
.map(|c| ToolCall::new(c.id, c.function.name, c.function.arguments))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for OpenAICompatProvider {
|
||||
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse> {
|
||||
let mut req = request;
|
||||
req.stream = false;
|
||||
let openai_req = self.convert_request(req);
|
||||
|
||||
debug!(model = %openai_req.model, "OpenAI 同步调用");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(self.chat_url())
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&openai_req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
error!(%status, %body, "LLM API 调用失败");
|
||||
anyhow::bail!("LLM API 错误 {}: {}", status, body);
|
||||
}
|
||||
|
||||
let body: OpenAiResponse = resp.json().await?;
|
||||
let choice = body
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("LLM 响应无 choices"))?;
|
||||
|
||||
let text = choice.message.content.unwrap_or_default();
|
||||
let tool_calls = choice.message.tool_calls.map(Self::parse_tool_calls);
|
||||
|
||||
let usage = body.usage.map(|u| TokenUsage {
|
||||
prompt_tokens: u.prompt_tokens,
|
||||
completion_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
}).unwrap_or(TokenUsage {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
});
|
||||
|
||||
Ok(CompletionResponse {
|
||||
text,
|
||||
model: body.model,
|
||||
usage,
|
||||
tool_calls,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stream(&self, request: CompletionRequest) -> anyhow::Result<StreamResult> {
|
||||
let mut req = request;
|
||||
req.stream = true;
|
||||
let openai_req = self.convert_request(req);
|
||||
|
||||
debug!(model = %openai_req.model, "OpenAI 流式调用");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(self.chat_url())
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&openai_req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
error!(%status, %body, "LLM 流式 API 调用失败");
|
||||
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
|
||||
}
|
||||
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.eventsource()
|
||||
.map(move |event| {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
// OpenAI 发送 "data: [DONE]" 表示流结束
|
||||
if event.data == "[DONE]" {
|
||||
return Ok(StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: true,
|
||||
tool_calls: None,
|
||||
});
|
||||
}
|
||||
|
||||
match serde_json::from_str::<OpenAiStreamChunk>(&event.data) {
|
||||
Ok(chunk) => {
|
||||
if let Some(choice) = chunk.choices.into_iter().next() {
|
||||
let delta_text = choice.delta.content.unwrap_or_default();
|
||||
// "length" = max_tokens 截断,属正常终止(非断连),纳入 finished
|
||||
let finished = choice.finish_reason.as_deref() == Some("stop")
|
||||
|| choice.finish_reason.as_deref() == Some("tool_calls")
|
||||
|| choice.finish_reason.as_deref() == Some("length");
|
||||
|
||||
let tool_calls = choice.delta.tool_calls.map(|tcs| {
|
||||
tcs.into_iter()
|
||||
.map(|tc| ToolCallDelta {
|
||||
index: tc.index,
|
||||
id: tc.id,
|
||||
function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
|
||||
function_arguments: tc.function.and_then(|f| f.arguments),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
Ok(StreamChunk {
|
||||
delta: delta_text,
|
||||
finished,
|
||||
tool_calls,
|
||||
})
|
||||
} else {
|
||||
Ok(StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("SSE 数据解析失败: {} — data: {}", e, event.data);
|
||||
Ok(StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("SSE 流错误: {}", e);
|
||||
Err(anyhow::anyhow!("SSE 流错误: {}", e))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
&self.default_model
|
||||
}
|
||||
|
||||
fn supported_features(&self) -> ProviderFeatures {
|
||||
ProviderFeatures {
|
||||
streaming: true,
|
||||
function_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user