拒绝上游宣告未完成的工具调用
流式的收尾门禁只防传输层截断,把模型侧截断当成了正常收尾:is_completion 只 判断 finish_reason 非空,length / content_filter / max_tokens 一律放行;三条 非流式路径同样原样接受 Chat finish_reason、Responses status 和 Anthropic stop_reason。下游没有任何一处对该字段做分支,因此参数恰好闭合成合法 JSON 的 截断调用会被真的执行。格式修复循环对这种形态无从察觉,它看到的 JSON 是合法的。 新增 reject_incomplete_tool_calls,流式与三条非流式路径统一拦截已知的截断、 过滤和失败终态:Chat 的 length / content_filter,Responses 的 incomplete / failed / cancelled,Anthropic 的 max_tokens / pause_turn / refusal。 刻意用黑名单而非白名单,未知值与缺失一律放行,否则会误杀不发或自定义该字段的 兼容网关。只在存在工具调用时生效——正文被 max_tokens 截断仍是可用的降级结果, 一并拒绝会打死所有触及输出上限的长文本回答。 与非流式畸形参数透传同时成立时本检查优先,原用例的 finish_reason 相应改为 tool_calls 以只验证透传本身。补 6 个用例覆盖三协议截断、纯文本截断放行和未知 finish_reason 放行。
This commit is contained in:
@@ -263,6 +263,8 @@ arguments 是否必须是完整 JSON **按流式与非流式区分,两者的
|
||||
|
||||
静默丢弃是明确禁止的实现方式:它会把“上游给了工具调用但我们没解出来”伪装成“上游只回了正文”——响应同时带解说文本时更会被当作普通回复成功返回,而带 `tool_choice=required` 的请求随后退化为格式修复循环,审计里只能看到“模型没按协议调用工具”,看不出真正成因在解析层。非流式 DTO 为兼容流式分片把字段改成可选后尤其要注意:可选字段解除了 serde 的强制校验,缺失必须在归一层重新拦截。
|
||||
|
||||
工具调用还必须来自**没有被上游宣告为未完成**的响应。上游给出明确的截断 / 过滤 / 失败终态时,即使参数恰好闭合成合法 JSON 也必须返回 `Deserialize`:字节完整不代表模型把本轮工具计划表达完了,而下游拿到 `tool_calls` 就会真的去执行,格式修复循环对"参数合法但内容被砍断"完全无从察觉。已知终态为——Chat 的 `length` / `content_filter`,Responses 的 `incomplete` / `failed` / `cancelled`,Anthropic 的 `max_tokens` / `pause_turn` / `refusal`。这里必须用黑名单而非白名单,未知值与缺失一律放行,否则会误杀不发或自定义该字段的兼容网关。该检查**只在存在工具调用时生效**:正文被 `max_tokens` 截断仍是可用的降级结果,一并拒绝会打死所有触及输出上限的长文本回答。与非流式畸形参数透传同时成立时,本检查优先。
|
||||
|
||||
流式工具调用必须来自已收尾的流:只要聚合出过工具 slot,收尾时就必须已观察到本协议的完成信号,否则按截断返回 `Deserialize`。完成信号按协议判定——Chat 为非空 `choices[].finish_reason` 或 `data: [DONE]`,Responses 为 `response.completed`,Anthropic 为带 `stop_reason` 的 `message_delta` 或 `message_stop`。不能用 `data: [DONE]` 作为统一判据:MiniMax 兼容层不发该标记,只发 `finish_reason`。也不能只用 “参数是合法 JSON” 当完成证明——顶层花括号闭合只说明单个参数对象字节完整,说明不了模型是否还要发下一个工具块,更说明不了上游随后会不会报 `max_tokens` 或 error;代理超时、网关自行掐断和 HTTP/2 提前 `END_STREAM` 都表现为干净 EOF,与正常收尾在字节层无法区分。该门禁当前只覆盖工具路径;纯文本响应缺完成信号仍按成功返回并打 warn,改动前必须先确认所有在用网关的文本收尾行为。流在任何工具分片到达前就断掉时槽位为空,门禁无从触发,这是已知残留缺口。
|
||||
|
||||
反过来,已经收尾的流遇到尾部传输 / 解析错误时必须保留结果,不能重跑 Provider。判断“有没有值得保留的东西”要看正文或工具调用任一非空,不能只看正文——纯工具调用响应的正文本来就是空的(MiniMax 的 Anthropic 工具流恒定如此),只看正文会让这类响应每次都被丢弃,白白多跑一轮往返。保留的安全性由“协议完成信号已到 + `finish_reason` 已到 + 工具参数完整 + 错误属可容忍尾部错误”共同保证,与正常路径判据一致。Anthropic 的 `message_stop` 与 Responses 的 `response.completed` 目前只标记完成、不标记流终止,因此收尾事件之后仍会读到 EOF,这条尾部路径是常态而非边缘情况。
|
||||
|
||||
@@ -638,6 +638,49 @@ struct PendingToolCall {
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
// 上游明确表示本轮输出被截断、过滤或失败的终态。这里刻意用黑名单而不是白名单:
|
||||
// 兼容网关常常不发这个字段或发自定义值,白名单会把它们全部误杀,而“保留缺失 reason
|
||||
// 的兼容路径”是本项检查的前提。新增上游时按需补充已知值即可。
|
||||
fn is_incomplete_finish_reason(api_kind: LlmApiKind, finish_reason: &str) -> bool {
|
||||
let reason = finish_reason.trim().to_ascii_lowercase();
|
||||
match api_kind {
|
||||
LlmApiKind::OpenAiChat => matches!(reason.as_str(), "length" | "content_filter"),
|
||||
LlmApiKind::OpenAiResponses => {
|
||||
matches!(reason.as_str(), "incomplete" | "failed" | "cancelled")
|
||||
}
|
||||
LlmApiKind::Anthropic => {
|
||||
matches!(reason.as_str(), "max_tokens" | "pause_turn" | "refusal")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上游已经说了这一轮没写完,工具调用就不可信:参数恰好闭合成合法 JSON 只说明字节完整,
|
||||
// 不代表模型把本轮工具计划表达完了,而下游拿到 tool_calls 就会真的去执行。仅在存在工具
|
||||
// 调用时拒绝——正文被 max_tokens 截断仍是可用的降级结果,砍掉它会打死所有长文本回答。
|
||||
//
|
||||
// 与畸形参数透传(非流式)的关系:两者同时成立时本检查优先。畸形参数交给调用方的格式
|
||||
// 修复循环是因为那时“不完整”可被察觉;截断且参数恰好合法时修复循环察觉不到,只会直接执行。
|
||||
fn reject_incomplete_tool_calls(
|
||||
api_kind: LlmApiKind,
|
||||
finish_reason: Option<&str>,
|
||||
tool_calls: &[LlmToolCall],
|
||||
context: &str,
|
||||
) -> Result<(), LlmError> {
|
||||
if tool_calls.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(reason) = finish_reason else {
|
||||
return Ok(());
|
||||
};
|
||||
if !is_incomplete_finish_reason(api_kind, reason) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(LlmError::Deserialize(format!(
|
||||
"LLM {context}工具调用来自未完成的响应:finish_reason={reason}, calls={}",
|
||||
tool_calls.len()
|
||||
)))
|
||||
}
|
||||
|
||||
// 三协议、流式与非流式共用的工具调用中间形态。协议层只负责把自己的 DTO 映射成它,
|
||||
// 不做任何取舍判断;要不要接受、缺省怎么补,全部由 normalize_tool_calls 决定。
|
||||
#[derive(Debug)]
|
||||
@@ -1518,6 +1561,24 @@ impl LlmClient {
|
||||
error
|
||||
})?;
|
||||
|
||||
reject_incomplete_tool_calls(
|
||||
request.api_kind,
|
||||
accumulation.finish_reason.as_deref(),
|
||||
&tool_calls,
|
||||
"流式",
|
||||
)
|
||||
.map_err(|error| {
|
||||
log_llm_raw_failure(
|
||||
&self.config,
|
||||
&request,
|
||||
true,
|
||||
1,
|
||||
"stream_tool_calls_incomplete_finish",
|
||||
parser.raw_text().as_str(),
|
||||
);
|
||||
error
|
||||
})?;
|
||||
|
||||
// 一致性断言:上游已表明本轮是工具调用,却一个都没累加出来,说明该网关的事件形状
|
||||
// 不在已支持范围内。此时必须显式失败让调用方回退非流式,不能静默丢掉调用。
|
||||
if tool_calls.is_empty()
|
||||
@@ -2356,6 +2417,12 @@ fn parse_chat_completions_response(
|
||||
.trim()
|
||||
.to_string();
|
||||
let tool_calls = extract_chat_tool_calls(first_choice)?;
|
||||
reject_incomplete_tool_calls(
|
||||
LlmApiKind::OpenAiChat,
|
||||
first_choice.finish_reason.as_deref(),
|
||||
&tool_calls,
|
||||
"Chat 非流式",
|
||||
)?;
|
||||
|
||||
if content.is_empty() && tool_calls.is_empty() {
|
||||
return Err(LlmError::EmptyResponse);
|
||||
@@ -2385,6 +2452,12 @@ fn parse_responses_response(
|
||||
.trim()
|
||||
.to_string();
|
||||
let tool_calls = extract_responses_tool_calls(&parsed)?;
|
||||
reject_incomplete_tool_calls(
|
||||
LlmApiKind::OpenAiResponses,
|
||||
parsed.status.as_deref(),
|
||||
&tool_calls,
|
||||
"Responses 非流式",
|
||||
)?;
|
||||
|
||||
if content.is_empty() && tool_calls.is_empty() {
|
||||
return Err(LlmError::EmptyResponse);
|
||||
@@ -2414,6 +2487,12 @@ fn parse_anthropic_response(
|
||||
LlmError::Deserialize(format!("解析 LLM Anthropic JSON 响应失败:{error}"))
|
||||
})?;
|
||||
let tool_calls = extract_anthropic_tool_calls(&parsed)?;
|
||||
reject_incomplete_tool_calls(
|
||||
LlmApiKind::Anthropic,
|
||||
parsed.stop_reason.as_deref(),
|
||||
&tool_calls,
|
||||
"Anthropic 非流式",
|
||||
)?;
|
||||
let content = extract_anthropic_text(&parsed)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
@@ -4989,9 +5068,11 @@ mod tests {
|
||||
// 外层 body 已完整,参数半截是模型输出问题而非流被截断,平台层必须原样透传:
|
||||
// 调用方的格式修复循环要靠 call id、函数名和原始参数把畸形响应回灌给模型重写,
|
||||
// 在这里报错会把这些信息全部丢掉,退化成一轮无谓的 Provider 重试。
|
||||
// finish_reason 用 tool_calls 而非 length:本用例只验证参数透传。上游明确报截断
|
||||
// 时由 reject_incomplete_tool_calls 优先拦截,那条由下面的用例单独锁定。
|
||||
let response = run_non_stream_tool_body(
|
||||
LlmApiKind::OpenAiChat,
|
||||
r#"{"id":"resp_01","choices":[{"message":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":"}}]},"finish_reason":"length"}]}"#,
|
||||
r#"{"id":"resp_01","choices":[{"message":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":"}}]},"finish_reason":"tool_calls"}]}"#,
|
||||
)
|
||||
.await
|
||||
.expect("半截 arguments 必须原样透传给调用方");
|
||||
@@ -5006,6 +5087,96 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_stream_chat_rejects_tool_calls_truncated_by_length() {
|
||||
// 参数恰好闭合成合法 JSON,但上游已明确报 length:这正是修复循环察觉不到、
|
||||
// 会被直接执行的危险形态,必须在平台层拦下。
|
||||
let error = run_non_stream_tool_body(
|
||||
LlmApiKind::OpenAiChat,
|
||||
r#"{"id":"resp_01","choices":[{"message":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":\"杭州\"}"}}]},"finish_reason":"length"}]}"#,
|
||||
)
|
||||
.await
|
||||
.expect_err("length 截断的工具调用必须失败");
|
||||
|
||||
expect_tool_call_deserialize_error(error, "工具调用来自未完成的响应");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_stream_responses_rejects_tool_calls_with_incomplete_status() {
|
||||
let error = run_non_stream_tool_body(
|
||||
LlmApiKind::OpenAiResponses,
|
||||
r#"{"id":"resp_01","status":"incomplete","output":[{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"杭州\"}"}]}"#,
|
||||
)
|
||||
.await
|
||||
.expect_err("incomplete 状态的工具调用必须失败");
|
||||
|
||||
expect_tool_call_deserialize_error(error, "工具调用来自未完成的响应");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_stream_anthropic_rejects_tool_calls_stopped_by_max_tokens() {
|
||||
let error = run_non_stream_tool_body(
|
||||
LlmApiKind::Anthropic,
|
||||
r#"{"id":"msg_01","content":[{"type":"tool_use","id":"call_1","name":"get_weather","input":{"city":"杭州"}}],"stop_reason":"max_tokens"}"#,
|
||||
)
|
||||
.await
|
||||
.expect_err("max_tokens 截断的工具调用必须失败");
|
||||
|
||||
expect_tool_call_deserialize_error(error, "工具调用来自未完成的响应");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_stream_truncated_text_without_tool_calls_still_succeeds() {
|
||||
// 作用域守卫:截断拦截只针对工具调用。正文被 max_tokens 砍断仍是可用的降级结果,
|
||||
// 一并拒绝会打死所有触及输出上限的长文本回答。
|
||||
let response = run_non_stream_tool_body(
|
||||
LlmApiKind::OpenAiChat,
|
||||
r#"{"id":"resp_01","choices":[{"message":{"content":"杭州今天多云,气温"},"finish_reason":"length"}]}"#,
|
||||
)
|
||||
.await
|
||||
.expect("截断的纯文本回复仍应成功返回");
|
||||
|
||||
assert_eq!(response.text, "杭州今天多云,气温");
|
||||
assert!(response.tool_calls.is_empty());
|
||||
assert_eq!(response.finish_reason.as_deref(), Some("length"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_stream_tool_calls_with_unknown_finish_reason_still_succeed() {
|
||||
// 兼容网关路径:只拒绝已知的截断 / 过滤 / 失败终态,未知值与缺失一律放行。
|
||||
let response = run_non_stream_tool_body(
|
||||
LlmApiKind::OpenAiChat,
|
||||
r#"{"id":"resp_01","choices":[{"message":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":\"杭州\"}"}}]},"finish_reason":"vendor_specific_done"}]}"#,
|
||||
)
|
||||
.await
|
||||
.expect("未知 finish_reason 不应被误杀");
|
||||
|
||||
assert_eq!(response.tool_calls.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_chat_rejects_tool_calls_truncated_by_length() {
|
||||
let server_url = spawn_mock_server(vec![MockResponse {
|
||||
status_line: "200 OK",
|
||||
content_type: "text/event-stream; charset=utf-8",
|
||||
body: concat!(
|
||||
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":\"杭州\"}"}}]}}]}"#, "\n\n",
|
||||
r#"data: {"choices":[{"finish_reason":"length"}]}"#, "\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
)
|
||||
.to_string(),
|
||||
extra_headers: Vec::new(),
|
||||
}]);
|
||||
|
||||
let client = build_test_client(server_url, 0);
|
||||
let error = client
|
||||
.stream_run(weather_tool_request(LlmApiKind::OpenAiChat), |_| {})
|
||||
.await
|
||||
.expect_err("流式 length 截断的工具调用必须失败");
|
||||
|
||||
expect_tool_call_deserialize_error(error, "流式工具调用来自未完成的响应");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_chat_tool_call_with_incomplete_arguments_json_still_fails() {
|
||||
// 与上一条成对:流式的参数半截意味着流被截断,是传输层事实,必须报错。
|
||||
|
||||
Reference in New Issue
Block a user