Files
DevFlow/crates/df-ai-core/src/provider.rs
T
lxy 57d6a2d066 修复: AI 对话/工具可靠性(sanitize 三元组 + G2 签名重复 + handshake 不杀 loop + 空 tool_call id 兜底)
治 5 个对话停止/工具失败根因:sanitize 三元组按 id 配对治 400;G2 探索熔断从结果空
改签名重复判定(治误停正常探索);handshake 删越权强杀活 loop(generating 归 guard 单源);
空 tool_call id 兜底 gen_<index>(治 SenseNova 工具结果路由错位)。
2026-08-02 02:21:30 +08:00

495 lines
21 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.
//! LLM Provider trait — 统一的 LLM 调用抽象
//!
//! 支持 OpenAI 兼容 API(覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式),
//! 含 function calling / tool use 能力。
//!
//! 本文件含 trait + 类型 impl 块(零 IO)。纯类型定义(CompletionRequest /
//! ChatMessage / ContentPart 等 `#[derive]` struct/enum)已拆分到 `types.rs`
//! 经下方 `pub use crate::types::*` re-export 保持外部路径
//! `df_ai_core::provider::*` / `df_ai::provider::*` 不变(编译期验证)。
//! HTTP implOpenAICompatProvider / AnthropicCompatProvider+ 业务逻辑
//! ContextManager / AiToolRegistry / build_provider 工厂)留在 df-ai crate。
use async_trait::async_trait;
// 透明 re-exporttypes.rs 中所有 pub 类型经此回流到 provider::* glob,
// 使 df_ai::provider::*df-ai/src/provider.rs:15 的 `pub use df_ai_core::provider::*`
// 与直接 `df_ai_core::provider::Type` 限定路径全部继续可用。
pub use crate::types::*;
use crate::types::{new_message_id, now_millis_i64};
impl ContentPart {
pub fn text(text: impl Into<String>) -> Self {
ContentPart::Text { text: text.into() }
}
pub fn image_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
ContentPart::Image {
url: None,
base64: Some(data.into()),
media_type: Some(media_type.into()),
alt: None,
}
}
pub fn image_url(url: impl Into<String>) -> Self {
ContentPart::Image { url: Some(url.into()), base64: None, media_type: None, alt: None }
}
/// 是否图片片
pub fn is_image(&self) -> bool {
matches!(self, ContentPart::Image { .. })
}
}
impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
pub fn user(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
pub fn assistant(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
/// 是否含图片片(供 provider 判定走多模态分支)。
pub fn has_image(&self) -> bool {
self.parts.as_ref().map(|ps| ps.iter().any(|p| p.is_image())).unwrap_or(false)
}
/// parts 若存在则返回引用,否则 None。
pub fn parts(&self) -> Option<&[ContentPart]> {
self.parts.as_deref()
}
/// 把 content + parts 拍平为有序 ContentPart 序列:先 content 作 Text 片(非空时),
/// 再追加 parts(若有)。供 provider 生成 content blocks(保证文本在前、图片在后)。
pub fn flattened_parts(&self) -> Vec<ContentPart> {
let mut out: Vec<ContentPart> = Vec::new();
if !self.content.is_empty() {
out.push(ContentPart::Text { text: self.content.clone() });
}
if let Some(ps) = &self.parts {
for p in ps {
out.push(p.clone());
}
}
out
}
/// 是否处于 active 态(status 为 None 或 "active")。其余状态一律 false。
///
/// 正面白名单:仅认 None / "active",新状态
/// (如"archived_segment" / "compressed")自动落入不 active 分支,
/// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated")
/// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。
pub fn is_active(&self) -> bool {
matches!(self.status, None | Some(MessageStatus::Active))
}
}
impl ToolDefinition {
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
Self {
tool_type: ToolType::new("function"),
function: ToolFunction { name: name.into(), description: description.into(), parameters },
}
}
}
impl ToolCall {
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: impl Into<String>) -> Self {
Self {
id: id.into(),
call_type: "function".into(),
function: ToolCallFunction { name: name.into(), arguments: arguments.into() },
}
}
}
/// 解析点统一兜底:tool_call.id 空 → 生成唯一 fallback,非空原样。
///
/// 根因(实证会话 01f05167 SenseNova flash-lite):某些 providerSenseNova 兼容缺陷)
/// 返回空 `tool_call.id`"")。OpenAI 协议要求 id 唯一。DevFlow 多 tool_call 按 id
/// 路由结果,id 空时所有结果落到同一 key`audit/mod.rs:203` 的 `seen_ids` 去重把空 id
/// 视为相同,只留首个 tool_call)→ AI 看到「所有调用同一结果」,工具全失败。
///
/// 兜底在**解析点**生成 fallback idraw 非空用 raw,空用 `format!("{prefix}_{index}")`
/// index 取 tool_call 在数组中的位置,保证同 assistant 内多 tool_call id 唯一)。
/// 下游(工具执行 / tool 结果回填 tool_call_id)从解析后的 `ToolCall.id` 取,不重复生成,
/// 确保 assistant tool_call.id 与 tool 结果 tool_call_id 匹配(防 sanitize 三元组断裂)。
///
/// 三处解析点共用本 helperDRY):OpenAI 同步 `parse_tool_calls`prefix=`gen_tool`)、
/// OpenAI 流式 chunkprefix=`gen_stream`)、Anthropic 同步 + 流式(prefix=`gen_anthropic` /
/// `gen_anthropic_stream`)。正常 providerOpenAI/Claude/GLM id 非空)原样透传零介入。
pub fn tool_call_id_or_fallback(raw: &str, index: usize, prefix: &str) -> String {
if !raw.is_empty() {
raw.to_string()
} else {
format!("{prefix}_{index}")
}
}
/// LLM Provider trait
#[async_trait]
pub trait LlmProvider: Send + Sync {
/// 同步调用
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse>;
/// 流式调用(返回异步流)
async fn stream(
&self,
request: CompletionRequest,
) -> anyhow::Result<StreamResult>;
/// 文本嵌入:批量文本 → 语义向量(供知识库向量检索)
///
/// 默认实现返回 Err(协议不支持)。OpenAI 兼容协议覆盖实现(/v1/embeddings);
/// Anthropic 无 embedding API,保持默认。
async fn embed(&self, _model: &str, _texts: Vec<String>) -> anyhow::Result<Vec<Vec<f32>>> {
anyhow::bail!("该 Provider 不支持 embedding({})", self.name())
}
/// Provider 名称
fn name(&self) -> &str;
/// 实际请求端点(含 base_url + 关键路径,如 chat completions / messages)。
/// 默认回落 `name()`provider 实现覆盖返真实 URL,供 401/网络错误诊断打印
/// —— 旧路径只能近似打印 provider_type,看不到实际请求端点。
fn endpoint(&self) -> String {
self.name().to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_active_whitelist() {
// 正面白名单:仅 None / "active" 为 true,其余一律 false。
// 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价;
// "archived_segment" / "compressed" 由白名单 matches! 只认 None/active 自动落入 false。
// None(构造默认值,向前兼容老 JSON)
let m = ChatMessage::user("hi");
assert!(m.is_active(), "None 应 active");
// "active"
let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::Active);
assert!(m.is_active(), "Some(active) 应 active");
// "truncated" — 当前取值,与旧实现等价(false)
let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::Truncated);
assert!(!m.is_active(), "truncated 应不 active");
// "archived_segment" — 白名单自动隔离
let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::ArchivedSegment);
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
// "compressed" — 白名单自动隔离
let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::Compressed);
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
}
// ---------- ContentPart ----------
/// 老 JSON(无 parts 字段)反序列化时 parts 应为 None(向前兼容)
#[test]
fn contentpart_legacy_json_no_parts() {
let json = r#"{"role":"user","content":"hello"}"#;
let m: ChatMessage = serde_json::from_str(json).expect("老 JSON 应可反序列化");
assert_eq!(m.content, "hello");
assert!(m.parts.is_none(), "老 JSON 无 parts 字段 → None");
assert!(!m.has_image());
}
/// 新 JSON 带 parts(含 Image 片)反序列化 round-trip
#[test]
fn contentpart_with_image_roundtrip() {
let m = ChatMessage::user_parts(
"看这张图",
vec![
ContentPart::image_base64("image/png", "iVBORw0KGgo="),
ContentPart::text("说明"),
],
);
let json = serde_json::to_string(&m).expect("序列化");
// parts 顺序:[image_base64, text] → 首元素是 image
assert!(json.contains(r#""parts":[{"type":"image""#));
assert!(json.contains(r#""type":"text""#));
assert!(json.contains(r#""base64":"iVBORw0KGgo=""#));
assert!(json.contains(r#""media_type":"image/png""#));
let back: ChatMessage = serde_json::from_str(&json).expect("反序列化 round-trip");
assert_eq!(back.content, "看这张图");
assert!(back.has_image());
let parts = back.parts.expect("parts 应存在");
assert_eq!(parts.len(), 2);
assert!(parts[0].is_image());
}
/// has_image 判定
#[test]
fn contentpart_has_image_detection() {
assert!(!ChatMessage::user("纯文本").has_image());
let m = ChatMessage::user_parts("t", vec![ContentPart::text("只文本片")]);
assert!(!m.has_image(), "仅 Text 片不算 has_image");
let m = ChatMessage::user_parts(
"t",
vec![ContentPart::text("前缀"), ContentPart::image_url("https://x/a.png")],
);
assert!(m.has_image());
}
/// flattened_partscontent 非空 → 前置 Text 片 + parts 追加
#[test]
fn contentpart_flattened_parts() {
let m = ChatMessage::user("hi");
let flat = m.flattened_parts();
assert_eq!(flat.len(), 1);
assert_eq!(flat[0], ContentPart::Text { text: "hi".into() });
let m = ChatMessage::user_parts(
"cap",
vec![ContentPart::image_url("u"), ContentPart::text("尾")],
);
let flat = m.flattened_parts();
assert_eq!(flat.len(), 3);
assert_eq!(flat[0], ContentPart::Text { text: "cap".into() });
assert!(flat[1].is_image());
assert_eq!(flat[2], ContentPart::Text { text: "尾".into() });
let mut m = ChatMessage::user("");
m.parts = Some(vec![ContentPart::text("x")]);
let flat = m.flattened_parts();
assert_eq!(flat.len(), 1, "空 content 不应产生空 Text 片");
}
/// 向后兼容:旧代码 ChatMessage 字面量构造仍合法(audit/title/commands 零回归)
#[test]
fn contentpart_struct_literal_compat() {
let m = ChatMessage {
id: None,
role: MessageRole::User,
content: "字面量构造".into(),
parts: None,
tool_call_id: None,
tool_calls: None,
model: None,
status: None,
reasoning_content: None,
timestamp: None,
};
assert_eq!(m.content, "字面量构造");
assert!(m.parts.is_none());
}
// ---------- DeepSeek reasoning_content ----------
/// 有 reasoning_content 时应序列化出来;None 时不出现
#[test]
fn completion_request_reasoning_content_serialization() {
let req = CompletionRequest {
model: "deepseek-r1".to_string(),
messages: vec![ChatMessage::user("hello")],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: Some("let me think...".to_string()),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("reasoning_content"), "reasoning_content 应出现在 JSON 中, got: {}", json);
// None 时不应出现(skip_serializing_if
let req_none = CompletionRequest { reasoning_content: None, ..req };
let json_none = serde_json::to_string(&req_none).unwrap();
assert!(!json_none.contains("reasoning_content"), "None 的 reasoning_content 不应序列化, got: {}", json_none);
}
/// 所有便捷构造函数默认 reasoning_content 为 None
#[test]
fn chat_message_reasoning_content_constructors() {
assert!(ChatMessage::system("sys").reasoning_content.is_none());
assert!(ChatMessage::user("hi").reasoning_content.is_none());
assert!(ChatMessage::assistant("resp").reasoning_content.is_none());
assert!(ChatMessage::tool_result("call_1", "result").reasoning_content.is_none());
}
/// ChatMessage reasoning_content 序列化 round-trip
#[test]
fn chat_message_reasoning_content_roundtrip() {
let m = ChatMessage {
id: None,
role: MessageRole::Assistant,
content: "answer".to_string(),
parts: None,
tool_call_id: None,
tool_calls: None,
model: None,
status: None,
reasoning_content: Some("thinking process".to_string()),
timestamp: None,
};
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("reasoning_content"));
let deserialized: ChatMessage = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string()));
}
// ---------- 消息级溯源:id 字段 ----------
/// 所有便捷构造函数默认生成非 None 的 id(ULID 风格)
#[test]
fn chat_message_id_generated_by_constructors() {
assert!(ChatMessage::system("sys").id.is_some(), "system 应有 id");
assert!(ChatMessage::user("hi").id.is_some(), "user 应有 id");
assert!(ChatMessage::assistant("resp").id.is_some(), "assistant 应有 id");
assert!(
ChatMessage::assistant_with_tools("r", vec![]).id.is_some(),
"assistant_with_tools 应有 id"
);
assert!(
ChatMessage::tool_result("call_1", "result").id.is_some(),
"tool_result 应有 id"
);
assert!(
ChatMessage::user_parts("t", vec![ContentPart::text("x")]).id.is_some(),
"user_parts 应有 id"
);
}
/// id 全局唯一性:连续构造 100 条不重复(AtomicU64 计数器保证)
#[test]
fn chat_message_id_uniqueness() {
let mut ids = std::collections::HashSet::new();
for _ in 0..100 {
let m = ChatMessage::user("x");
let id = m.id.expect("构造的消息应有 id");
assert!(ids.insert(id), "100 条消息 id 应全部唯一");
}
}
/// id 序列化 round-trip:有 id 时序列化保留,反序列化回来一致
#[test]
fn chat_message_id_roundtrip() {
let m = ChatMessage {
id: Some("msg_1718800000000_42".to_string()),
role: MessageRole::Assistant,
content: "answer".to_string(),
parts: None,
tool_call_id: None,
tool_calls: None,
model: None,
status: None,
reasoning_content: None,
timestamp: None,
};
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains(r#""id":"msg_1718800000000_42""#), "id 应序列化, got: {}", json);
let back: ChatMessage = serde_json::from_str(&json).unwrap();
assert_eq!(back.id.as_deref(), Some("msg_1718800000000_42"));
}
/// 向前兼容:老 JSON 无 id 字段 → 反序列化为 None(serde default)
#[test]
fn chat_message_id_legacy_json_no_id() {
let json = r#"{"role":"user","content":"hello"}"#;
let m: ChatMessage = serde_json::from_str(json).expect("老 JSON 应可反序列化");
assert_eq!(m.content, "hello");
assert!(m.id.is_none(), "老 JSON 无 id 字段 → None");
// 重新序列化:id=None 时不应出现 id 字段(skip_serializing_if)
let re = serde_json::to_string(&m).unwrap();
assert!(!re.contains(r#""id""#), "id=None 不应序列化, got: {}", re);
}
/// StreamChunk reasoning_content 序列化
#[test]
fn stream_chunk_reasoning_content() {
let chunk = StreamChunk {
delta: "text".to_string(),
finished: false,
tool_calls: None,
usage: None,
error: None,
reasoning_content: Some("thought".to_string()),
};
let json = serde_json::to_string(&chunk).unwrap();
assert!(json.contains("reasoning_content"));
let chunk_none = StreamChunk { reasoning_content: None, ..chunk };
let json_none = serde_json::to_string(&chunk_none).unwrap();
assert!(!json_none.contains("reasoning_content"));
}
/// CompletionResponse reasoning_content 序列化
#[test]
fn completion_response_reasoning_content() {
let resp = CompletionResponse {
text: "ok".to_string(),
model: "r1".to_string(),
usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
tool_calls: None,
reasoning_content: Some("r1 thought".to_string()),
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("reasoning_content"));
let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string()));
}
/// CR-空 idtool_call_id_or_fallback 共享 helper —— 空 raw → fallback,非空原样。
#[test]
fn tool_call_id_or_fallback_non_empty_passthrough() {
assert_eq!(tool_call_id_or_fallback("call_abc", 0, "gen_tool"), "call_abc");
assert_eq!(tool_call_id_or_fallback("x", 5, "p"), "x");
}
#[test]
fn tool_call_id_or_fallback_empty_generates_with_index() {
assert_eq!(tool_call_id_or_fallback("", 0, "gen_tool"), "gen_tool_0");
assert_eq!(tool_call_id_or_fallback("", 1, "gen_tool"), "gen_tool_1");
assert_eq!(tool_call_id_or_fallback("", 7, "gen_stream"), "gen_stream_7");
}
#[test]
fn tool_call_id_or_fallback_empty_unique_per_index() {
// 同 prefix 不同 index → 不同 fallback(保证同 assistant 多 tool_call id 唯一)
let ids: Vec<String> = (0..3).map(|i| tool_call_id_or_fallback("", i, "gen_tool")).collect();
let mut sorted = ids.clone();
sorted.sort();
sorted.dedup();
assert_eq!(ids.len(), sorted.len(), "各 index fallback 应唯一: {:?}", ids);
}
#[test]
fn tool_call_id_or_fallback_prefix_distinguishes_sources() {
// 不同 prefix 区分来源(同步 gen_tool / 流式 gen_stream / anthropic gen_anthropic
assert_ne!(
tool_call_id_or_fallback("", 0, "gen_tool"),
tool_call_id_or_fallback("", 0, "gen_stream")
);
}
}