diff --git a/desktop-tauri/src-tauri/server/src/server/api/pipeline.rs b/desktop-tauri/src-tauri/server/src/server/api/pipeline.rs index f90d4276..2f2364a9 100644 --- a/desktop-tauri/src-tauri/server/src/server/api/pipeline.rs +++ b/desktop-tauri/src-tauri/server/src/server/api/pipeline.rs @@ -1231,6 +1231,10 @@ mod tests { limit: Some(5), model: None, provider: None, + client: None, + api_key: None, + production_only: false, + completed_only: false, status: None, start: None, end: None, diff --git a/desktop-tauri/src-tauri/server/src/server/core/protocol/responses_outbound.rs b/desktop-tauri/src-tauri/server/src/server/core/protocol/responses_outbound.rs index 0dc7498d..f4346810 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/protocol/responses_outbound.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/protocol/responses_outbound.rs @@ -199,9 +199,13 @@ fn push_assistant_items(items: &mut Vec, message: &Value) { .and_then(Value::as_str) .filter(|text| !text.is_empty()); if !reasoning.is_empty() || encrypted.is_some() { + // 与下面的 message / function_call 同一条取舍:这是重建的 input 项, + // 不是上游已持久化的 output 项,`id` 可省且必须省。尤其不能给带 + // `encrypted_content` 的项配一个现造的 `rs_`:严格上游(官方 + // store:false)按 item id 查已存项,伪造 id 会整轮被拒,比不带 id 更糟。 + // 连续性只靠密文本身承载,不需要 id 参与。 let mut item = json!({ "type": "reasoning", - "id": random_id("rs"), "summary": if reasoning.is_empty() { Vec::::new() } else { @@ -219,12 +223,14 @@ fn push_assistant_items(items: &mut Vec, message: &Value) { items.push(item); } // 正文:Responses 不需要空的 assistant message(只带工具调用的轮次 - // 只发 function_call 项);空正文跳过也能避免上游把空消息判成错误 + // 只发 function_call 项);空正文跳过也能避免上游把空消息判成错误。 + // 这里是从 Chat 历史重建的 input 项,不是上游已持久化的 output 项,不能 + // 生成 `id`:部分 Responses 上游会把带 id 的 input 项当成 persisted-item + // 引用并尝试查找,导致整轮请求被拒绝。input 项的 id 是可选的。 let text = content_text(message.get("content").unwrap_or(&Value::Null)); if !text.is_empty() { items.push(json!({ "type": "message", - "id": random_id("msg"), "role": "assistant", "content": [{ "type": "output_text", "text": text }], })); @@ -251,9 +257,10 @@ fn push_assistant_items(items: &mut Vec, message: &Value) { raw } }; + // `call_id` 是工具输出配对键,不是 input item 的持久化 `id`。 + // 无状态重建时保留前者,省略后者。 items.push(json!({ "type": "function_call", - "id": random_id("fc"), "call_id": call_id, "name": name, "arguments": arguments, @@ -885,3 +892,108 @@ fn chat_usage_from_responses(usage: &Value) -> Value { ); Value::Object(out) } + +#[cfg(test)] +mod tests { + use super::responses_request_from_chat; + use serde_json::json; + + #[test] + fn rebuilt_assistant_input_items_omit_ids_and_preserve_tool_pairing() { + let chat = json!({ + "model": "m", + "messages": [ + { "role": "assistant", "content": "answer" }, + { "role": "assistant", "content": null, "tool_calls": [ + { "id": "call_original", "type": "function", + "function": { "name": "f", "arguments": "{}" } }, + ] }, + { "role": "tool", "tool_call_id": "call_original", "content": "done" }, + ], + }); + + let body = responses_request_from_chat(&chat, "m").unwrap(); + let items = body["input"].as_array().unwrap(); + assert_eq!(items.len(), 3); + assert_eq!(items[0]["type"], "message"); + assert!(items[0].get("id").is_none(), "{items:?}"); + assert_eq!(items[1]["type"], "function_call"); + assert!(items[1].get("id").is_none(), "{items:?}"); + assert_eq!(items[1]["call_id"], "call_original"); + assert_eq!(items[2]["type"], "function_call_output"); + assert_eq!(items[2]["call_id"], "call_original"); + } + + /// 重建的 reasoning 项同样不能带 id —— 尤其是带加密密文的那条:现造的 + /// `rs_` 配真实 `encrypted_content` 会让严格上游按 id 查不到已存项而整轮 + /// 拒绝。连续性只靠密文本体,去掉 id 不影响下一轮接续。 + #[test] + fn rebuilt_reasoning_item_omits_id_but_keeps_encrypted_content() { + let chat = json!({ + "model": "m", + "messages": [ + { "role": "assistant", "content": "answer", + "reasoning_content": "thought", + "_wb_encrypted_content": "enc" }, + ], + }); + + let body = responses_request_from_chat(&chat, "m").unwrap(); + let items = body["input"].as_array().unwrap(); + assert_eq!(items.len(), 2, "{items:?}"); + let reasoning = &items[0]; + assert_eq!(reasoning["type"], "reasoning"); + assert!(reasoning.get("id").is_none(), "重建的 reasoning 不该带 id: {reasoning}"); + assert_eq!(reasoning["encrypted_content"], "enc"); + assert_eq!(reasoning["summary"][0]["text"], "thought"); + assert_eq!(items[1]["type"], "message"); + assert!(items[1].get("id").is_none(), "{items:?}"); + } + + /// 没有原文 `id` 的 tool_calls:`call_id` 由兜底生成,仍然必须让 + /// `function_call` 与 `function_call_output` 用同一个键 —— 配对只认 + /// `call_id`,与 item `id` 无关(官方 schema 中 `FunctionCall.id` 为 + /// optional,`call_id` 才是工具输出配对键)。 + #[test] + fn generated_call_id_keeps_tool_output_pairing() { + let chat = json!({ + "model": "m", + "messages": [ + { "role": "assistant", "content": null, "tool_calls": [ + { "type": "function", "function": { "name": "f", "arguments": "{}" } }, + ] }, + ], + }); + + let body = responses_request_from_chat(&chat, "m").unwrap(); + let items = body["input"].as_array().unwrap(); + assert_eq!(items.len(), 1, "{items:?}"); + assert_eq!(items[0]["type"], "function_call"); + assert!(items[0].get("id").is_none(), "{items:?}"); + assert!( + items[0]["call_id"].as_str().is_some_and(|id| !id.is_empty()), + "缺少 call_id 时也要生成一个可配对的键: {items:?}" + ); + } + + /// 空正文的 assistant 轮次只发 function_call 项:不补一条空 message, + /// 否则上游可能把空 assistant 消息判成错误。 + #[test] + fn tool_only_turn_emits_no_empty_assistant_message() { + let chat = json!({ + "model": "m", + "messages": [ + { "role": "assistant", "content": "", "tool_calls": [ + { "id": "call_1", "type": "function", + "function": { "name": "f", "arguments": "{}" } }, + ] }, + ], + }); + + let body = responses_request_from_chat(&chat, "m").unwrap(); + let items = body["input"].as_array().unwrap(); + assert_eq!(items.len(), 1, "{items:?}"); + assert_eq!(items[0]["type"], "function_call"); + assert_eq!(items[0]["call_id"], "call_1"); + } +} diff --git a/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop/tests.rs b/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop/tests.rs index fc9cd5e6..8457b408 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop/tests.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop/tests.rs @@ -332,8 +332,9 @@ async fn a_real_413_response_is_sent_once_and_handed_back() { &transport, &mut budget, Some(&capture), - &RequestTelemetry::new(), + &Arc::new(RequestTelemetry::new()), false, + None, ) .await; task.abort();