Files
DevFlow/crates/df-ai-core/src/provider.rs
T
lxy 864c696b70 优化: token消息级持久化 + 技能注入修复 + TopBar减法/UI调
token持久化(方案A,治压缩/切会话后历史token不显):ChatMessage/AiMessageRecord 加 prompt_tokens/completion_tokens(serde + DB V38 迁移 + message_repo 映射);agentic push_assistant_message 设本轮 token + provider/title 构造默认 None;前端 AiMessage 加字段 + switchConversation reload 映射 tokenUsage(双轨:消息级新+会话级旧累计保留)

技能注入修复:read_skill_content_stripped 改 skills_cached 扫盘(防御 SKILLS None 致不注入)+ 细化诊断(缓存/path/fs 各步)

TopBar减法/UI:删铅笔新建(与侧栏+重复)/删垃圾桶clear-chat(危险,clear-context归档替代)/删系统就绪装饰占位;更多菜单popout CSS补全(修样式错乱);provider绿点有信息化(绿/红/灰基于AI请求成败)+垂直居中;goals面板补top:100%(修位置飘)+dot/check/remove CSS+goals/history item统一+history index边距;4面板互斥(点一个收其他)

MessageList token v-if 去 !streaming(修发新消息历史token消失)
2026-08-02 18:17:30 +08:00

534 lines
24 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, prompt_tokens: None, completion_tokens: 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, prompt_tokens: None, completion_tokens: 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, prompt_tokens: None, completion_tokens: 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, prompt_tokens: None, completion_tokens: 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, 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<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, 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<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}_{n}")`
/// n 取自下方 `FALLBACK_ID_COUNTER` **全局递增计数器**,跨轮跨 assistant 唯一)。
/// 下游(工具执行 / 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 非空)原样透传零介入。
///
/// # 为何用全局计数器而非单轮 index(实证 af2fab4e
///
/// 旧实现 fallback 用 `format!("{prefix}_{index}")`index 是**单轮** tool_call 数组
/// 位置。跨轮(不同 assistantindex 都从 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<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() {
// 非空 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_"));
}
}