重构: 后端 df-ai/commands 拆分+df-nodes/workflow 改造+P0 bug 修复

- df-ai: context 历史中毒三档自愈 sanitize_messages(AC3)+anthropic_compat tool_use_id None 跳过(AC1/AC2)+删 router/stream 死码
- df-core: events 加 select_type+decisions 多选审批契约(F-260615-01)
- df-execute: shell run_command 工具复用(F-260615-05)
- df-nodes: human_node 多选校验+2 端到端测(F-01)+取消跳 set_failed(B-03b-R1/R2/R8)
- df-workflow: executor/dag/state cancel 闭环(B-06/07/03a/b)+provider approve options(R-PD-5)
- df-storage: find_path_conflict 抽公共(R-PD-11)+COLS 常量断言
- df-ideas: 删 IdeaPromoter/PromotionPolicy 死码(R-PD-14)
- src-tauri/commands/ai: secret keyring 迁移(FR-S1/R-PD-4)+GeneratingGuard RAII+disarm(B-09/26)+newConversation 软复位(B-10)+stream 心跳/stop select/空回复判错(B-02/04/05/15)+run_command(F-05)+mask audit(AR-3)
- src-tauri/commands/{project,task,workflow,mod,lib,state}: task detail IPC(F-02)+approve decisions+task list 联动(B-29)
- Cargo.lock+Cargo.toml 依赖同步
This commit is contained in:
2026-06-15 05:14:42 +08:00
parent 04032a2a8d
commit 2de0c6ecb7
37 changed files with 2457 additions and 484 deletions

View File

@@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
use tracing::{debug, error, warn};
use crate::provider::{
CompletionRequest, CompletionResponse, LlmProvider, MessageRole, ProviderFeatures,
CompletionRequest, CompletionResponse, LlmProvider, MessageRole,
StreamChunk, StreamResult, TokenUsage, ToolCall, ToolCallDelta,
};
@@ -102,7 +102,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
let v: serde_json::Value = match serde_json::from_str(data) {
Ok(v) => v,
Err(_) => {
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
}
};
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
@@ -121,7 +121,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
total_tokens: inp as u32,
});
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
}
// 消息增量output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
"message_delta" => {
@@ -131,14 +131,14 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
acc.completion_tokens = out as u32;
acc.total_tokens = acc.prompt_tokens + acc.completion_tokens;
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
}
// 文本增量
"content_block_delta" => {
if let Some(delta) = v.get("delta") {
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None };
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None };
}
// 工具入参增量
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
@@ -154,10 +154,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
function_arguments: Some(partial),
}]),
usage: None,
error: None,
};
}
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
}
// 工具块开始:带 id + name
"content_block_start" => {
@@ -185,10 +186,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
function_arguments: None,
}]),
usage: None,
error: None,
};
}
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
}
// 消息结束:带出累积 usage
"message_stop" => StreamChunk {
@@ -196,15 +198,17 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
finished: true,
tool_calls: None,
usage: usage_accum.take(),
error: None,
},
// 错误事件
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
"error" => {
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error");
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string();
error!(%msg, "Anthropic 流式错误事件");
StreamChunk { delta: String::new(), finished: true, tool_calls: None, usage: None }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg) }
}
// content_block_stop / ping 等不产出 chunk
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None },
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None },
}
}
@@ -236,7 +240,7 @@ impl AnthropicCompatProvider {
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
warn!("reqwest builder 失败,降级为默认 client: {}", e);
warn!("reqwest builder 失败,回退默认 client: {}", e);
Client::new()
});
Self {
@@ -508,12 +512,8 @@ impl LlmProvider for AnthropicCompatProvider {
"anthropic-compat"
}
fn supported_features(&self) -> ProviderFeatures {
ProviderFeatures {
streaming: true,
function_calling: true,
vision: false,
}
fn endpoint(&self) -> String {
self.messages_url()
}
}
@@ -663,17 +663,29 @@ mod tests {
assert!(!c.finished);
}
/// error 事件 → finished=true 终态空 chunk
/// error 事件 → error=Some + finished=falseR-P1-1避免残缺响应被当正常完成入库
/// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致)
#[test]
fn anthropic_error_event_finishes_stream() {
fn anthropic_error_event_yields_error_not_finished() {
let mut acc: Option<TokenUsage> = None;
apply_anthropic_event(&message_start(10), &mut acc);
let c = apply_anthropic_event(r#"{"type":"error","error":{"message":"overloaded"}}"#, &mut acc);
assert!(c.finished, "error 应终止流");
assert!(!c.finished, "error 不走 finished 完成路径,否则残缺响应会被当正常完成");
assert_eq!(c.error.as_deref(), Some("overloaded"), "error 事件应携带错误消息");
assert!(c.usage.is_none(), "error 不带出 usage");
assert!(c.delta.is_empty(), "error 不带文本增量");
assert!(acc.is_some(), "error 不应清空已累积的 usage与原实现一致");
}
/// error 事件无 error.message 字段时兜底为 "stream error"
#[test]
fn anthropic_error_event_missing_message_falls_back() {
let mut acc: Option<TokenUsage> = None;
let c = apply_anthropic_event(r#"{"type":"error"}"#, &mut acc);
assert_eq!(c.error.as_deref(), Some("stream error"));
assert!(!c.finished);
}
/// ping / content_block_stop 等事件 → 空且不 finished
#[test]
fn anthropic_ping_and_block_stop_yield_empty_chunk() {

View File

@@ -10,7 +10,7 @@
use serde::{Deserialize, Serialize};
use crate::provider::{ChatMessage, MessageRole};
use crate::provider::{ChatMessage, MessageRole, ToolCall};
// ============================================================
// Token 估算器(零依赖粗估)
@@ -155,6 +155,10 @@ pub struct ContextManager {
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
const PROTECT_COUNT: usize = 6;
/// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176
/// 此类 id 必然无匹配 tool_result是历史中毒的标志sanitize 时据此剔除畸形三元组。
const TOOL_MISSING_PREFIX: &str = "tool_missing_";
impl ContextManager {
pub fn new(config: ContextConfig) -> Self {
Self {
@@ -224,9 +228,9 @@ impl ContextManager {
);
}
// 未超预算 → 直接返回全量
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
if self.history_tokens <= available {
return (self.all_messages_clone(), false);
return (Self::sanitize_messages(self.all_messages_clone()), false);
}
// 超预算 → 视图裁剪(不修改 self.messages保证 all_messages_clone 仍返回全量)
@@ -260,7 +264,137 @@ impl ContextManager {
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
trim_end, removed
);
(msgs, true)
(Self::sanitize_messages(msgs), true)
}
/// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史
///
/// 根因Anthropic 流式 `tool_use` 块缺 id 时anthropic_compat.rs 170-176
/// 生成 `tool_missing_{idx}` 占位 id对应的 `tool_result` 一旦推入历史,
/// 下次 `build_for_request` 把畸形三元组原样回传 → 服务端 500 → stream_recv
/// 不清历史 → agentic 重发 → 永久卡死。此处仅在「发送视图」剔除畸形配对,
/// 不改持久化self.messages 全量保留),让卡死的会话能自愈继续。
///
/// provider 协议铁律assistant 头里每个 tool_call.id 都必须在后续有对应的
/// tool_result否则服务端 400/500。本函数按此自愈分三档处理每个头
///
/// - 全闭合:所有 tool_call.id 都有匹配 tool_result → 原样保留。
/// - 全未闭合(含占位 id 必然无匹配):整头丢弃,其无主 tool_result 一并丢弃。
/// - 部分闭合:保留头但只留已闭合的 tool_call丢弃未闭合的 tool_call 及其
/// 无主 tool_result「不能整条删」——保住已发生的合法工具交互历史
///
/// 单次遍历、保序过滤、不重排user/assistant/tool 角色交替不被打乱)。
fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet;
// step 1已闭合的 tool_call_id 集合role=Tool 消息全部提供过的 id
let resolved_ids: HashSet<&str> = messages
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.filter_map(|m| m.tool_call_id.as_deref())
.collect();
// step 2判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合
// orphaned_ids未闭合的 tool_call.id含占位 id其 tool_result 要丢
// partial_heads部分闭合的头——保留但需重写 tool_calls只留已闭合的
// full_drop_heads全未闭合的头——整条丢弃
let mut orphaned_ids: HashSet<String> = HashSet::new();
let mut full_drop_heads = 0u32;
let mut partial_heads = 0u32;
// 记录部分闭合头中「应保留的 id」用于 step3 精确重写
let mut partial_keep: std::collections::HashMap<usize, Vec<ToolCall>> =
std::collections::HashMap::new();
for (i, m) in messages.iter().enumerate() {
if !matches!(m.role, MessageRole::Assistant) {
continue;
}
let Some(calls) = m.tool_calls.as_ref() else { continue };
if calls.is_empty() {
continue;
}
let (resolved, unresolved): (Vec<&ToolCall>, Vec<&ToolCall>) = calls
.iter()
.partition(|c| {
!c.id.starts_with(TOOL_MISSING_PREFIX)
&& resolved_ids.contains(c.id.as_str())
});
match (resolved.is_empty(), unresolved.is_empty()) {
// 全未闭合 → 整头丢弃,未闭合 id 入 orphaned_ids
(true, false) => {
full_drop_heads += 1;
for c in &unresolved {
orphaned_ids.insert(c.id.clone());
}
}
// 全闭合 → 原样保留
(false, true) => {}
// 部分闭合 → 保留头,只留 resolved 的 tool_callunresolved 入 orphaned_ids
(false, false) => {
partial_heads += 1;
for c in &unresolved {
orphaned_ids.insert(c.id.clone());
}
partial_keep.insert(i, resolved.into_iter().cloned().collect());
}
// resolved/unresolved 都空不可能calls 非空已保证)
(true, true) => {}
}
}
if orphaned_ids.is_empty() && partial_keep.is_empty() {
return messages;
}
// step 3保序过滤 + 部分闭合头重写 tool_calls
let sanitized: Vec<ChatMessage> = messages
.into_iter()
.enumerate()
.filter_map(|(i, mut m)| match m.role {
MessageRole::Assistant => {
let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
if calls.is_empty() {
return Some(m);
}
if let Some(keep) = partial_keep.get(&i) {
// 部分闭合:重写 tool_calls 为仅已闭合子集
m.tool_calls = Some(keep.clone());
Some(m)
} else {
// 全闭合保留全未闭合id 全在 orphaned_ids丢弃
let all_orphaned =
calls.iter().all(|c| orphaned_ids.contains(&c.id));
if all_orphaned {
None
} else {
Some(m)
}
}
}
MessageRole::Tool => {
// 无主 tool_result其 id 命中 orphaned_ids丢弃其余保留
if m
.tool_call_id
.as_deref()
.is_some_and(|id| orphaned_ids.contains(id))
{
None
} else {
Some(m)
}
}
_ => Some(m),
})
.collect();
tracing::warn!(
full_drop_heads,
partial_rewrite_heads = partial_heads,
orphaned_tool_results = orphaned_ids.len(),
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
);
sanitized
}
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
@@ -355,7 +489,6 @@ struct EvictionUnit {
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ToolCall;
fn cfg(max_tokens: u32) -> ContextConfig {
ContextConfig {
@@ -365,6 +498,168 @@ mod tests {
}
}
#[test]
fn sanitize_drops_placeholder_id_triplet() {
// 中毒场景assistant 头带 tool_missing_ 占位 id + 无主 tool_result
// → build_for_request 必须剔除两者,否则下次回传触发 provider 500 死循环
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("调用工具"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("tool_missing_0", "read_file", "{}")],
));
mgr.push(ChatMessage::tool_result("tool_missing_0", "结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 中毒的 head + 无主 tool_result 全部剔除,只剩首条 user
assert_eq!(msgs.len(), 1, "占位 id 三元组应被 sanitize 剔除");
assert_eq!(msgs[0].content, "调用工具");
// 持久化全量保留(自愈只改发送视图)
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
}
#[test]
fn sanitize_drops_unresolved_tool_call_head() {
// 真实 id 但全无 tool_result流式中断/异常)→ 全未闭合档,整头丢弃
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
));
// 两个 call 都无 result → 整头丢弃
let (msgs, _trimmed) = mgr.build_for_request(0);
assert!(msgs.iter().all(|m| !matches!(m.role, MessageRole::Assistant)
|| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
"全未闭合的 assistant 头应被剔除");
assert!(msgs.is_empty(), "全未闭合应整条删");
assert_eq!(mgr.all_messages_clone().len(), 1, "全量保留");
}
#[test]
fn sanitize_partial_resolve_rewrites_head() {
// 多 tool_call部分有匹配部分无 → 保留头但只留已闭合的,丢弃无主 result
// provider 协议铁律head 的每个 tool_call.id 必须都有 tool_result。
// 「不能整条删」——保住已发生的合法工具交互call_b历史。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("触发工具"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
));
// 只有 call_b 有 result → call_a 未闭合
mgr.push(ChatMessage::tool_result("call_b", "b 结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 头被保留(不是整条删),但 tool_calls 重写为只剩已闭合的 call_b
let assistant_heads: Vec<_> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
.collect();
assert_eq!(assistant_heads.len(), 1, "部分闭合头应保留(重写不删)");
let head_calls = assistant_heads[0].tool_calls.as_ref().unwrap();
assert_eq!(head_calls.len(), 1, "重写后只保留 1 个已闭合 tool_call");
assert_eq!(head_calls[0].id, "call_b", "保留的应是已闭合的 call_b");
assert!(
head_calls.iter().all(|c| c.id != "call_a"),
"未闭合的 call_a 应从重写后的 tool_calls 中移除"
);
// tool_result 只剩 call_b 的user 前置保留
let tool_msgs: Vec<_> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.collect();
assert_eq!(tool_msgs.len(), 1, "只保留 call_b 的 tool_result");
assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("call_b"));
assert!(
msgs.iter()
.any(|m| matches!(m.role, MessageRole::User) && m.content == "触发工具"),
"无关 user 消息不应被误删"
);
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
}
#[test]
fn sanitize_keeps_well_formed_triplet() {
// 正常三元组head 的每个 tool_call 都有匹配 tool_result → 不剔除
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "fn_a", "{}")],
));
mgr.push(ChatMessage::tool_result("call_a", "结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
assert_eq!(msgs.len(), 2, "正常三元组不应被 sanitize 剔除");
}
#[test]
fn sanitize_mixed_multi_message_sequence() {
// 场景 4连续多条消息混合——
// [user] [全闭合三元组] [中毒占位 id 三元组] [部分闭合头] [user]
// 预期全闭合原样保留、中毒占位整删、部分闭合头重写为只剩已闭合、user 不动
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("开场"));
// 全闭合三元组
mgr.push(ChatMessage::assistant_with_tools(
"调1",
vec![ToolCall::new("ok_1", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("ok_1", "ok 结果"));
// 中毒占位 id 三元组(全未闭合)→ 整删
mgr.push(ChatMessage::assistant_with_tools(
"调2",
vec![ToolCall::new("tool_missing_0", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("tool_missing_0", "占位结果"));
// 部分闭合头keep_1 闭合drop_1 未闭合 → 重写为只剩 keep_1
mgr.push(ChatMessage::assistant_with_tools(
"调3",
vec![ToolCall::new("keep_1", "fn", "{}"), ToolCall::new("drop_1", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("keep_1", "keep 结果"));
mgr.push(ChatMessage::user("收尾"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 任何保留头里的 id 都不得含占位前缀、不得含未闭合的 drop_1
let head_ids: Vec<Vec<String>> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
.map(|m| {
m.tool_calls
.as_ref()
.unwrap()
.iter()
.map(|c| c.id.clone())
.collect()
})
.collect();
assert!(
head_ids.iter().all(|ids| {
ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1")
}),
"保留的 assistant 头中不应再有中毒 id 或未闭合 id, 实际 {:?}", head_ids
);
let tool_ids: Vec<String> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.filter_map(|m| m.tool_call_id.clone())
.collect();
// ok_1全闭合保留、keep_1部分闭合重写后保留应在占位/drop_1 不在
assert!(tool_ids.contains(&"ok_1".to_string()), "全闭合 ok_1 应保留");
assert!(tool_ids.contains(&"keep_1".to_string()), "部分闭合 keep_1 应保留");
assert!(
tool_ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1"),
"无主 tool_result 应被剔除, 实际 {:?}", tool_ids
);
// 两条 user 消息原样保留
let user_contents: Vec<&str> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::User))
.map(|m| m.content.as_str())
.collect();
assert_eq!(user_contents, vec!["开场", "收尾"], "无关 user 消息不应被误删");
assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量");
}
#[test]
fn short_history_no_trim() {
let mut mgr = ContextManager::new(cfg(100_000));

View File

@@ -1,4 +1,4 @@
//! df-ai: AI 编排 — LLM Provider、模型路由、Agent 协调、上下文管理、流式处理、工具注册
//! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册
pub mod ai_tools;
pub mod anthropic_compat;
@@ -6,8 +6,6 @@ pub mod context;
pub mod coordinator;
pub mod openai_compat;
pub mod provider;
pub mod router;
pub mod stream;
use provider::LlmProvider;

View File

@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use tracing::{debug, error, warn};
use crate::provider::{
CompletionRequest, CompletionResponse, LlmProvider, ProviderFeatures, StreamChunk, StreamResult,
CompletionRequest, CompletionResponse, LlmProvider, StreamChunk, StreamResult,
TokenUsage, ToolCall, ToolCallDelta,
};
@@ -148,6 +148,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
finished: true,
tool_calls: None,
usage: usage_accum.take(),
error: None,
};
}
@@ -184,6 +185,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
finished,
tool_calls,
usage: None,
error: None,
}
} else {
// choices 为空 = usage-only chunk不输出文本usage 已累积)
@@ -192,6 +194,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
finished: false,
tool_calls: None,
usage: None,
error: None,
}
}
}
@@ -202,6 +205,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
finished: false,
tool_calls: None,
usage: None,
error: None,
}
}
}
@@ -233,7 +237,7 @@ impl OpenAICompatProvider {
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
warn!("reqwest builder 失败,降级为默认 client(无 connect_timeout: {}", e);
warn!("reqwest builder 失败,回退默认 client: {}", e);
Client::new()
});
Self {
@@ -495,12 +499,8 @@ impl LlmProvider for OpenAICompatProvider {
&self.default_model
}
fn supported_features(&self) -> ProviderFeatures {
ProviderFeatures {
streaming: true,
function_calling: true,
vision: false,
}
fn endpoint(&self) -> String {
self.chat_url()
}
}

View File

@@ -151,14 +151,6 @@ pub struct TokenUsage {
pub total_tokens: u32,
}
/// Provider 支持的特性标志
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderFeatures {
pub streaming: bool,
pub function_calling: bool,
pub vision: bool,
}
/// 流式输出的 chunk
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamChunk {
@@ -172,6 +164,10 @@ pub struct StreamChunk {
/// Token 用量(流末 chunk 携带,由 provider 解析自 SSE usage 事件)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
/// provider 流式错误事件(如 Anthropic SSE `type=="error"`)。
/// 非空表示流中途出错不应视为正常完成finished 路径),由 stream_llm 转 AiError。
#[serde(skip)]
pub error: Option<String>,
}
/// 工具调用增量(流式中的片段)
@@ -216,6 +212,10 @@ pub trait LlmProvider: Send + Sync {
/// Provider 名称
fn name(&self) -> &str;
/// 支持的特性
fn supported_features(&self) -> ProviderFeatures;
/// 实际请求端点(含 base_url + 关键路径,如 chat completions / messages
/// 默认回落 `name()`provider 实现覆盖返真实 URL供 401/网络错误诊断打印
/// —— 旧路径只能近似打印 provider_type看不到实际请求端点。
fn endpoint(&self) -> String {
self.name().to_string()
}
}

View File

@@ -1,51 +0,0 @@
//! 模型路由 — 根据任务类型选择最优模型
use serde::{Deserialize, Serialize};
/// 任务类型
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskType {
/// 代码生成
CodeGeneration,
/// 代码审查
CodeReview,
/// 文档生成
Documentation,
/// 分析推理
Analysis,
/// 摘要总结
Summarization,
/// 通用对话
Chat,
}
/// 模型路由器
///
/// 根据任务类型、成本、延迟等选择最优模型
pub struct ModelRouter {
/// 默认模型
default_model: String,
}
impl ModelRouter {
/// 创建路由器
pub fn new(default_model: &str) -> Self {
Self {
default_model: default_model.to_string(),
}
}
/// 根据任务类型路由到合适的模型
pub fn route(&self, task_type: &TaskType) -> String {
// TODO: 实现基于规则的模型路由
match task_type {
TaskType::CodeGeneration => self.default_model.clone(),
TaskType::CodeReview => self.default_model.clone(),
TaskType::Documentation => self.default_model.clone(),
TaskType::Analysis => self.default_model.clone(),
TaskType::Summarization => self.default_model.clone(),
TaskType::Chat => self.default_model.clone(),
}
}
}

View File

@@ -1,45 +0,0 @@
//! 流式处理 — LLM 响应的流式输出管理
use crate::provider::StreamChunk;
/// 流式响应收集器
pub struct StreamCollector {
/// 已收集的文本
text: String,
/// 是否完成
finished: bool,
}
impl StreamCollector {
/// 创建收集器
pub fn new() -> Self {
Self {
text: String::new(),
finished: false,
}
}
/// 追加一个 chunk
pub fn push(&mut self, chunk: &StreamChunk) {
self.text.push_str(&chunk.delta);
if chunk.finished {
self.finished = true;
}
}
/// 获取已收集的文本
pub fn text(&self) -> &str {
&self.text
}
/// 是否已完成
pub fn is_finished(&self) -> bool {
self.finished
}
}
impl Default for StreamCollector {
fn default() -> Self {
Self::new()
}
}