修复: B-260626-01 首条 user 协议合规(sanitize 兜底 + ensure_leading_user)

- context.rs: 兜底全量也过 sanitize(对齐分支1/3,防主 loop 唯一 sanitize 漏洞——大体量 tool_result 致超预算且保护区满时,裸返 all_messages_clone 不过滤 truncated/中毒三元组/首条非法直送 provider)
- anthropic_compat.rs + openai_compat.rs: ensure_leading_user 补 user 占位(Anthropic/OpenAI 协议要求首条非 assistant/tool;上游绕过 sanitize 的调用方——标题生成/知识注入/工作流 AI 节点——可能传入首条 assistant 序列,补占位保留上下文,tool_use/tool_result 配对完整无 orphan,远优于砍丢历史)
This commit is contained in:
lxy
2026-06-27 01:31:00 +08:00
parent bb2cfcbb62
commit 9189877a59
3 changed files with 312 additions and 4 deletions
+86 -1
View File
@@ -105,7 +105,7 @@ impl OpenAICompatProvider {
req.model
};
let messages: Vec<OpenAiMessage> = req
let mut messages: Vec<OpenAiMessage> = req
.messages
.into_iter()
.map(|m| {
@@ -177,6 +177,12 @@ impl OpenAICompatProvider {
})
.collect();
// B-260626-01: 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
// 对齐 AnthropicCompatProvider::ensure_leading_user:上游绕过 sanitize 的调用方
// (标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
Self::ensure_leading_user(&mut messages);
let tools = req.tools.map(|defs| {
defs.into_iter()
.map(|d| serde_json::to_value(d).unwrap_or_default())
@@ -201,6 +207,36 @@ impl OpenAICompatProvider {
}
}
/// B-260626-01: 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。
///
/// 对齐 `AnthropicCompatProvider::ensure_leading_user`。上游绕过 `ContextManager::sanitize_messages`
/// 的调用方(标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
/// assistant 的序列——会话恢复、续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉。
///
/// **用"补"而非"砍"**:开头插一条 user 占位,保留全部上下文(砍会丢工具调用历史,多轮砍到空)。
/// 占位 user 紧贴原首条,不破坏 user/assistant 交替;仅异常路径触发(正常首条本就是 user)。
fn ensure_leading_user(messages: &mut Vec<OpenAiMessage>) {
let first_role = messages.first().map(|m| m.role.as_str()).unwrap_or("");
if first_role == "user" || first_role == "system" {
return;
}
warn!(
first_role,
msg_count = messages.len(),
"ensure_leading_user: 首条非 user/system,补 user 占位(保留上下文,防 OpenAI 首条 assistant/tool 非法)"
);
messages.insert(
0,
OpenAiMessage {
role: "user".into(),
content: serde_json::Value::String("(continued from previous context)".into()),
tool_call_id: None,
tool_calls: None,
reasoning_content: None,
},
);
}
/// 解析同步响应中的工具调用
fn parse_tool_calls(calls: Vec<OpenAiToolCallResp>) -> Vec<ToolCall> {
calls
@@ -594,4 +630,53 @@ mod tests {
let msg = &out.messages[0];
assert_eq!(msg.content, serde_json::Value::String("hello".into()));
}
// ---------- B-260626-01: ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic----------
/// B-260626-01: 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
/// 调用方(title/knowledge_inject/工作流节点)可能传入首条 assistant 序列,补占位保留上下文。
#[test]
fn openai_ensure_leading_user_first_assistant_gets_placeholder() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![
ChatMessage::assistant("我来帮你"),
ChatMessage::user("继续"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
assert_eq!(out.messages.len(), 3, "占位 + 原 2 条");
assert_eq!(out.messages[0].role.as_str(), "user", "首条应为 user(补占位)");
assert_eq!(out.messages[1].role.as_str(), "assistant");
assert_eq!(out.messages[2].role.as_str(), "user");
}
/// B-260626-01: 正常序列(user 开头)不补占位——零回归。
#[test]
fn openai_ensure_leading_user_normal_unchanged() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![
ChatMessage::user("hello"),
ChatMessage::assistant("hi"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
assert_eq!(out.messages.len(), 2, "正常序列不补占位");
assert_eq!(out.messages[0].role.as_str(), "user");
}
}