Files
DevFlow/crates/df-ai/src/openai_compat.rs
绝尘 a3835e2acb 重构: 拆openai_compat协议适配(strategy核心库·对齐anthropic)
- 新建 df-ai/openai_helpers.rs(231行): apply_openai_sse纯函数 + 13 struct(OpenAi*字段pub(crate))
- openai_compat.rs 809→597: 删抽离+use引入, Provider impl保留(impl块约束: new/convert_request/parse_tool_calls/LlmProvider trait)
- lib.rs: pub mod openai_helpers
主代兜底: cargo check --workspace 0 + test df-ai 119
strategy: 核心库自底向上, impl块约束(Provider方法保留只抽纯函数/类型), 对齐anthropic_helpers模式
git add指定(df-ai/*)
协议适配双拆完整: anthropic_compat + openai_compat 均helpers抽离
2026-06-19 04:36:42 +08:00

598 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! OpenAI 兼容 Provider — 通过 /v1/chat/completions 端点实现
//!
//! 覆盖: OpenAI / GLM (open.bigmodel.cn) / DeepSeek / Claude OpenAI 兼容模式
//! 支持: 同步调用 + SSE 流式 + Function Calling / Tool Use
use std::time::Duration;
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use reqwest::Client;
use tracing::{debug, error, warn};
use crate::provider::{
CompletionRequest, CompletionResponse, LlmProvider, StreamResult, TokenUsage, ToolCall,
};
// ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。
#[cfg(test)]
use crate::provider::ChatMessage;
use crate::retry::{
retry_with_backoff, AttemptOutcome, is_reqwest_error_retryable, is_status_retryable,
};
// 协议数据结构OpenAiRequest / OpenAiResponse / OpenAiStreamChunk …)与 SSE 解析纯函数
// apply_openai_sse 抽离到 openai_helpers.rs结构对齐 anthropic_helpers.rs。
// Rust impl 块不可跨文件Provider struct + impl 仍留本文件。
use crate::openai_helpers::{
apply_openai_sse, OpenAiMessage, OpenAiRequest, OpenAiResponse, OpenAiToolCallResp,
};
// ============================================================
// 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 {
// SW-260618-10: reqwest Client 构建抽 crate::build_provider_client与 Anthropic 共用 DRY
// connect_timeout/回退策略集中此处,未来改一处即可(见 lib.rs::build_provider_client
let client = crate::build_provider_client();
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/completionsOpenAI 约定)
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,
}
}
/// 构建 embeddings API URL与 chat_url 同套智能拼接规则)
fn embed_url(&self) -> String {
let base = self.base_url.trim_end_matches('/');
if base.ends_with("/embeddings") {
return base.to_string();
}
if Self::ends_with_version(base) {
return format!("{}/embeddings", base);
}
format!("{}/v1/embeddings", base)
}
/// 将通用请求转换为 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",
};
// F-260614-05 Phase 2a: 多模态 content须在 move m.tool_calls 之前算,借用 m
// 含图消息走 content 数组text/image_url纯文本走字符串简写
// 保持与现有纯文本端点零回归。image_url 支持 data URIbase64与 http(s) URL。
let content = if m.has_image() {
let parts: Vec<serde_json::Value> = m
.flattened_parts()
.into_iter()
.map(|p| match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text",
"text": text,
}),
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
let final_url = match (base64, url, media_type) {
(Some(b), _, Some(mt)) => {
format!("data:{};base64,{}", mt, b)
}
(None, Some(u), _) => u,
// 完整性兜底:当前 image_base64 构造器强制 media_type:Some
// image_url 构造器提供 url:Some二者分别命中上两个分支
// 此分支仅在 parts 来源被外部直接构造且字段均缺时才可达
//(如 url:None+base64:None 或 base64:Some+media_type:None
// 退化为空串OpenAI 对空 image_url.url 会 400
// 由调用方保证 parts 合法性provider 层不做静默伪造。
_ => String::new(),
};
serde_json::json!({
"type": "image_url",
"image_url": { "url": final_url },
})
}
})
.collect();
serde_json::Value::Array(parts)
} else {
serde_json::Value::String(m.content.clone())
};
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,
tool_call_id: m.tool_call_id,
tool_calls,
reasoning_content: m.reasoning_content,
}
})
.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,
reasoning_content: req.reasoning_content,
// 流式请求末 chunk 带 usage同步调用 complete 不需要)
stream_options: if req.stream {
Some(serde_json::json!({ "include_usage": true }))
} else {
None
},
}
}
/// 解析同步响应中的工具调用
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 {
/// 文本嵌入: POST /v1/embeddings(OpenAI 兼容,智谱/阿里百炼/OpenAI 通用)
async fn embed(&self, model: &str, texts: Vec<String>) -> anyhow::Result<Vec<Vec<f32>>> {
#[derive(serde::Deserialize)]
struct EmbedData { embedding: Vec<f32>, index: usize }
#[derive(serde::Deserialize)]
struct EmbedResponse { data: Vec<EmbedData> }
let resp = self
.client
.post(self.embed_url())
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&serde_json::json!({ "model": model, "input": texts }))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Embedding API 错误 {}: {}", status, body);
}
let mut body: EmbedResponse = resp.json().await?;
// 按 index 排序保证与输入顺序一致(API 不保证返回顺序)
body.data.sort_by_key(|d| d.index);
Ok(body.data.into_iter().map(|d| d.embedding).collect())
}
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 同步调用");
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
// 单请求 60s timeout 保持不变(FR-R4),重试是额外层: 3 次 × 60s 最坏 180s
// 由 retry_with_backoff 内部 30s 总预算主动止损。
let label = format!("OpenAI[{}]", openai_req.model);
retry_with_backoff(&label, move |_| {
let client = self.client.clone();
let url = self.chat_url();
let api_key = self.api_key.clone();
let openai_req = openai_req.clone();
async move {
// send
let resp = client
.post(url)
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.timeout(Duration::from_secs(60))
.json(&openai_req)
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => {
if is_reqwest_error_retryable(&e) {
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
}
return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e));
}
};
// 状态码判定
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
let msg = format!("LLM API 错误 {}: {}", status, body);
if is_status_retryable(status) {
warn!(%status, "OpenAI 同步调用可重试状态码");
return AttemptOutcome::Retryable(msg);
}
error!(%status, %body, "LLM API 调用失败(不可重试)");
return AttemptOutcome::Fatal(msg);
}
// body 解析: 解析错属 Fatal(响应已成功送达,重试也会因同样格式失败)
let body: OpenAiResponse = match resp.json().await {
Ok(b) => b,
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
};
let choice = match body.choices.into_iter().next() {
Some(c) => c,
None => return AttemptOutcome::Fatal("LLM 响应无 choices".to_string()),
};
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,
});
AttemptOutcome::Ok(CompletionResponse {
text,
model: body.model,
usage,
tool_calls,
reasoning_content: choice.message.reasoning_content,
})
}
})
.await
}
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);
}
// 累积流式 usage开 include_usage 后,末段正常 chunkfinish_reason及额外 usage-only chunkchoices=[])都带 usage。
// usage 解析/累积逻辑抽到 apply_openai_sse 纯函数,便于单测;此处闭包只负责传 data 与传递 last_usage。
let mut last_usage: Option<TokenUsage> = None;
let stream = resp
.bytes_stream()
.eventsource()
.map(move |event| match event {
Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)),
Err(e) => {
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
// Display 不变(仍为 "SSE 流错误: {e}"), 且 e 作为 .source() 可追溯。
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
let ctx = format!("SSE 流错误: {}", e);
error!("{}", ctx);
Err(anyhow::Error::from(e).context(ctx))
}
});
Ok(Box::pin(stream))
}
fn name(&self) -> &str {
&self.default_model
}
fn endpoint(&self) -> String {
self.chat_url()
}
}
// ============================================================
// 单测(不发真实 HTTP喂构造的 SSE data 字符串序列)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
/// 辅助:构造普通文本 delta chunk 的 SSE data
fn text_chunk(content: &str, finish_reason: Option<&str>) -> String {
let fr = match finish_reason {
Some(r) => format!(", \"finish_reason\": \"{}\"", r),
None => String::from(", \"finish_reason\": null"),
};
format!(
r#"{{"choices":[{{"delta":{{"content":"{}"}}{}}}]}}"#,
content, fr
)
}
/// 辅助:构造带 usage 的 chunkchoices 为空 → usage-only 末 chunk对应 include_usage
fn usage_only_chunk(prompt: u32, completion: u32) -> String {
format!(
r#"{{"choices":[],"usage":{{"prompt_tokens":{},"completion_tokens":{},"total_tokens":{}}}}}"#,
prompt,
completion,
prompt + completion
)
}
/// 辅助:构造既有 content 又带 usage 的末段 chunk部分兼容端点会把 usage 挂到正常末 chunk 上)
fn text_chunk_with_usage(content: &str, finish_reason: &str, prompt: u32, completion: u32) -> String {
format!(
r#"{{"choices":[{{"delta":{{"content":"{}"}},"finish_reason":"{}"}}],"usage":{{"prompt_tokens":{},"completion_tokens":{},"total_tokens":{}}}}}"#,
content,
finish_reason,
prompt,
completion,
prompt + completion
)
}
/// 多 chunk 文本流后,末 chunk 携带 usageinclude_usage 覆盖语义)
#[test]
fn openai_sse_multi_chunk_with_terminal_usage() {
let mut acc: Option<TokenUsage> = None;
// 1) 首个文本增量,无 usage
let c = apply_openai_sse(&text_chunk("Hello", None), &mut acc);
assert_eq!(c.delta, "Hello");
assert!(!c.finished);
assert!(c.usage.is_none());
assert!(acc.is_none(), "无 usage 的 chunk 不应改累加器");
// 2) 第二个文本增量
let c = apply_openai_sse(&text_chunk(" world", None), &mut acc);
assert_eq!(c.delta, " world");
assert!(!c.finished);
assert!(acc.is_none());
// 3) 末段正常 chunk 带 finish_reason=stop仍是文本 delta不带 usage
let c = apply_openai_sse(&text_chunk("", Some("stop")), &mut acc);
assert!(c.finished);
assert_eq!(c.delta, "");
assert!(acc.is_none(), "此 chunk 无 usage 字段,累加器仍为 None");
// 4) usage-only chunkchoices=[])携带累计 usage → 覆盖累加器
let c = apply_openai_sse(&usage_only_chunk(12, 34), &mut acc);
assert!(!c.finished);
assert!(c.usage.is_none(), "非 [DONE] chunk 不带出 usage");
let acc = acc.expect("累加器应已被 usage-only chunk 覆盖写入");
assert_eq!(acc.prompt_tokens, 12);
assert_eq!(acc.completion_tokens, 34);
assert_eq!(acc.total_tokens, 46);
}
/// usage 挂在正常末段 chunk含 finish_reason而非独立 usage-only chunk
#[test]
fn openai_sse_usage_on_terminal_text_chunk() {
let mut acc: Option<TokenUsage> = None;
let c = apply_openai_sse(&text_chunk_with_usage("", "stop", 100, 200), &mut acc);
assert!(c.finished);
assert!(c.usage.is_none(), "非 [DONE] 不带出 usage仅覆盖累加器");
let acc = acc.expect("末段 chunk 的 usage 应已覆盖累加器");
assert_eq!(acc.prompt_tokens, 100);
assert_eq!(acc.completion_tokens, 200);
assert_eq!(acc.total_tokens, 300);
}
/// [DONE] 时 take() 带出累积 usage且取走后累加器清空
#[test]
fn openai_sse_done_takes_accumulated_usage() {
let mut acc: Option<TokenUsage> = None;
apply_openai_sse(&text_chunk("x", None), &mut acc);
apply_openai_sse(&usage_only_chunk(5, 7), &mut acc);
let c = apply_openai_sse("[DONE]", &mut acc);
assert!(c.finished);
let u = c.usage.expect("[DONE] 应带出累积 usage");
assert_eq!(u.prompt_tokens, 5);
assert_eq!(u.completion_tokens, 7);
assert_eq!(u.total_tokens, 12);
assert!(acc.is_none(), "take() 后累加器应清空");
}
/// 无 usage 的流:[DONE] 时 usage 字段为 None
#[test]
fn openai_sse_done_without_usage() {
let mut acc: Option<TokenUsage> = None;
apply_openai_sse(&text_chunk("hi", None), &mut acc);
let c = apply_openai_sse("[DONE]", &mut acc);
assert!(c.finished);
assert!(c.usage.is_none(), "全程无 usage 时 [DONE] usage 应为 None");
assert!(acc.is_none());
}
/// 后续 usage chunk 覆盖先前 usage多轮 / 重发场景)
#[test]
fn openai_sse_later_usage_overrides_earlier() {
let mut acc: Option<TokenUsage> = None;
apply_openai_sse(&usage_only_chunk(1, 1), &mut acc);
apply_openai_sse(&usage_only_chunk(50, 60), &mut acc);
let c = apply_openai_sse("[DONE]", &mut acc);
let u = c.usage.unwrap();
assert_eq!(u.prompt_tokens, 50, "末 usage 应覆盖前值");
assert_eq!(u.completion_tokens, 60);
assert_eq!(u.total_tokens, 110);
}
/// finish_reason=lengthmax_tokens 截断)按正常终止处理
#[test]
fn openai_sse_length_finish_reason_treated_as_finished() {
let mut acc: Option<TokenUsage> = None;
let c = apply_openai_sse(&text_chunk("...", Some("length")), &mut acc);
assert!(c.finished, "length 应视为正常终止");
assert!(acc.is_none());
}
/// 非法 JSON data → 返回空 chunk不 panic、不改累加器
#[test]
fn openai_sse_malformed_json_yields_empty_chunk() {
let mut acc: Option<TokenUsage> = None;
let c = apply_openai_sse("not a json", &mut acc);
assert_eq!(c.delta, "");
assert!(!c.finished);
assert!(c.usage.is_none());
assert!(acc.is_none());
}
/// tool_calls 增量解析
#[test]
fn openai_sse_tool_call_delta() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"q\":"}}]}}]}"#;
let c = apply_openai_sse(data, &mut acc);
assert!(acc.is_none());
let tcs = c.tool_calls.expect("应有 tool_calls 增量");
assert_eq!(tcs.len(), 1);
assert_eq!(tcs[0].index, 0);
assert_eq!(tcs[0].id.as_deref(), Some("call_1"));
assert_eq!(tcs[0].function_name.as_deref(), Some("get_weather"));
assert_eq!(tcs[0].function_arguments.as_deref(), Some("{\"q\":"));
assert!(!c.finished);
}
// ---------- F-260614-05 Phase 2a 多模态 convert_request ----------
/// 含图消息 → content 数组text + image_url data URI纯文本 → 字符串简写
#[test]
fn openai_convert_multimodal_content() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![ChatMessage::user_parts(
"看图",
vec![
crate::provider::ContentPart::image_base64("image/png", "iVBOR"),
crate::provider::ContentPart::image_url("https://x/a.png"),
],
)],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let msg = &out.messages[0];
// content 是数组:[text "看图", image_url(data URI), image_url(http url)]
let arr = msg.content.as_array().expect("含图 → content 数组");
assert_eq!(arr.len(), 3);
assert_eq!(arr[0]["type"], "text");
assert_eq!(arr[0]["text"], "看图");
assert_eq!(arr[1]["type"], "image_url");
assert_eq!(
arr[1]["image_url"]["url"],
"data:image/png;base64,iVBOR"
);
assert_eq!(arr[2]["image_url"]["url"], "https://x/a.png");
}
/// 纯文本消息 → content 仍是字符串简写(无图不数组化,对齐纯文本端点兼容)
#[test]
fn openai_convert_text_only_remains_string() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![ChatMessage::user("hello")],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let msg = &out.messages[0];
assert_eq!(msg.content, serde_json::Value::String("hello".into()));
}
}