//! 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 impl(OpenAICompatProvider / AnthropicCompatProvider)+ 业务逻辑 //! (ContextManager / AiToolRegistry / build_provider 工厂)留在 df-ai crate。 use async_trait::async_trait; // 透明 re-export:types.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) -> Self { ContentPart::Text { text: text.into() } } pub fn image_base64(media_type: impl Into, data: impl Into) -> Self { ContentPart::Image { url: None, base64: Some(data.into()), media_type: Some(media_type.into()), alt: None, } } pub fn image_url(url: impl Into) -> 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) -> 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, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) } } pub fn user(content: impl Into) -> 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, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) } } pub fn assistant(content: impl Into) -> 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, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) } } pub fn assistant_with_tools(content: impl Into, tool_calls: Vec) -> 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, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) } } pub fn tool_result(call_id: impl Into, content: impl Into) -> 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, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) } } /// 多模态 user 消息:content 文本 + parts(含 Image 片)。 /// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。 pub fn user_parts(content: impl Into, parts: Vec) -> 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, prompt_tokens: None, completion_tokens: 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 { let mut out: Vec = 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, description: impl Into, 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, name: impl Into, arguments: impl Into) -> 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):某些 provider(SenseNova 兼容缺陷) /// 返回空 `tool_call.id`("")。OpenAI 协议要求 id 唯一。DevFlow 多 tool_call 按 id /// 路由结果,id 空时所有结果落到同一 key(`audit/mod.rs:203` 的 `seen_ids` 去重把空 id /// 视为相同,只留首个 tool_call)→ AI 看到「所有调用同一结果」,工具全失败。 /// /// 兜底在**解析点**生成 fallback id:raw 非空用 raw,空用 `format!("{prefix}_{n}")` /// (n 取自下方 `FALLBACK_ID_COUNTER` **全局递增计数器**,跨轮跨 assistant 唯一)。 /// 下游(工具执行 / tool 结果回填 tool_call_id)从解析后的 `ToolCall.id` 取,不重复生成, /// 确保 assistant tool_call.id 与 tool 结果 tool_call_id 匹配(防 sanitize 三元组断裂)。 /// /// 三处解析点共用本 helper(DRY):OpenAI 同步 `parse_tool_calls`(prefix=`gen_tool`)、 /// OpenAI 流式 chunk(prefix=`gen_stream`)、Anthropic 同步 + 流式(prefix=`gen_anthropic` / /// `gen_anthropic_stream`)。正常 provider(OpenAI/Claude/GLM id 非空)原样透传零介入。 /// /// # 为何用全局计数器而非单轮 index(实证 af2fab4e) /// /// 旧实现 fallback 用 `format!("{prefix}_{index}")`,index 是**单轮** tool_call 数组 /// 位置。跨轮(不同 assistant)index 都从 0 起 → `gen_stream_0` 跨轮重复。agentic 的 /// `id_to_name`(`insert(id, name)`)后者覆盖前者 → run_command 的 exit=1 被误标 /// grep::exit=1 → L1 误熔断 grep(冤枉)→ loop 停 → 最后 assistant 空 content tool_calls /// 没执行(空气泡)。更严重:id 重复 → tool 结果配错 tool_call(三元组配对错位)。 /// /// 全局 `AtomicU64`(SeqCst)跨轮跨 assistant 严格递增,fallback id 永不重复。`index` /// 参数保留仅为签名兼容(4 处调用点 parse_tool_calls / 流式 chunk / push / agentic 都传), /// fallback 内部不再使用 index。 /// /// 单测跨进程实例计数器从 0 起;并发场景下两线程拿到的 fallback id 也严格递增(SeqCst), /// 保证全局唯一。 pub fn tool_call_id_or_fallback(raw: &str, _index: usize, prefix: &str) -> String { if !raw.is_empty() { raw.to_string() } else { let n = FALLBACK_ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst); format!("{prefix}_{n}") } } /// fallback id 全局计数器:跨轮跨 assistant 严格递增,保证空 id fallback 永不重复。 /// /// 见 `tool_call_id_or_fallback` 文档说明(实证 af2fab4e 跨轮重复根因)。 static FALLBACK_ID_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); /// LLM Provider trait #[async_trait] pub trait LlmProvider: Send + Sync { /// 同步调用 async fn complete(&self, request: CompletionRequest) -> anyhow::Result; /// 流式调用(返回异步流) async fn stream( &self, request: CompletionRequest, ) -> anyhow::Result; /// 文本嵌入:批量文本 → 语义向量(供知识库向量检索) /// /// 默认实现返回 Err(协议不支持)。OpenAI 兼容协议覆盖实现(/v1/embeddings); /// Anthropic 无 embedding API,保持默认。 async fn embed(&self, _model: &str, _texts: Vec) -> anyhow::Result>> { 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_parts:content 非空 → 前置 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-空 id:tool_call_id_or_fallback 共享 helper —— 空 raw → fallback,非空原样。 #[test] fn tool_call_id_or_fallback_non_empty_passthrough() { // 非空 raw 原样透传(provider 真 id 如 call_xxx 保留),与 index/prefix 无关 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_starts_with_prefix() { // 空 raw → "{prefix}_{n}",n 取自全局计数器(跨进程实例从 0 起,单测不假设具体值) let a = tool_call_id_or_fallback("", 0, "gen_tool"); assert!(a.starts_with("gen_tool_"), "空 fallback 应以 gen_tool_ 开头, got: {a}"); let b = tool_call_id_or_fallback("", 7, "gen_stream"); assert!(b.starts_with("gen_stream_"), "空 fallback 应以 gen_stream_ 开头, got: {b}"); } #[test] fn tool_call_id_or_fallback_empty_globally_unique() { // 跨轮跨 assistant 唯一:连续两次空 fallback id 必不同(全局计数器递增)。 // 这是修复 af2fab4e 跨轮重复(旧单轮 index 跨轮都从 0 起 → 重复)的核心断言。 let a = tool_call_id_or_fallback("", 0, "gen_tool"); let b = tool_call_id_or_fallback("", 0, "gen_tool"); assert_ne!(a, b, "两次空 fallback 应不同(全局计数器跨轮唯一): {a} vs {b}"); // 即使同 index(模拟跨轮 index 都从 0 起),fallback 也必唯一 let c = tool_call_id_or_fallback("", 0, "gen_tool"); let mut set = std::collections::HashSet::new(); assert!(set.insert(a), "fallback a 应唯一"); assert!(set.insert(b), "fallback b 应唯一"); assert!(set.insert(c), "fallback c 应唯一"); } #[test] fn tool_call_id_or_fallback_index_unused() { // index 参数仅为签名兼容保留(4 处调用点都传),fallback 不再使用 index。 // 同 prefix + 同 index 连续两次 → 不同 fallback(全局计数器递增,与 index 无关)。 let a = tool_call_id_or_fallback("", 3, "gen_tool"); let b = tool_call_id_or_fallback("", 3, "gen_tool"); assert_ne!(a, b, "同 index 两次空 fallback 应不同: {a} vs {b}"); } #[test] fn tool_call_id_or_fallback_prefix_distinguishes_sources() { // 不同 prefix 区分来源(同步 gen_tool / 流式 gen_stream / anthropic gen_anthropic) // 注意:两次空 fallback 因全局计数器递增 id 不同,故只比 prefix 前缀 let a = tool_call_id_or_fallback("", 0, "gen_tool"); let b = tool_call_id_or_fallback("", 0, "gen_stream"); assert!(a.starts_with("gen_tool_")); assert!(b.starts_with("gen_stream_")); } }