From ac4c17d94c742033831120fd32ba0b6cd1520ad4 Mon Sep 17 00:00:00 2001 From: MirrorD <38676873+ZirrorDmage@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:32:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DeepSeek=20thinking=20?= =?UTF-8?q?=E5=A4=9A=E8=BD=AE=E5=AF=B9=E8=AF=9D=20HTTP=20400=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek 的 Anthropic 端点对 reasoning_content 的回传有两条规则: - assistant 消息中包含 tool_use 时,后续所有轮次必须回传 reasoning_content - assistant 消息中无 tool_use 时,reasoning_content 可忽略 但 Claude Science 按 Anthropic 原生规范无条件剥离所有thinking 块,导致第二轮请求中 thinking 块缺失,DeepSeek 返回 HTTP 400: "The content[].thinking in the thinking mode must be passed back to the API." 新增 `strip_thinking_from_history()`,在请求发送前按DeepSeek官方文档的规则处理历史消息中的 thinking 块: - 有 tool_use → 保留 thinking(API 要求必须回传,否则 400) - 无 tool_use → 剥离 thinking(API 忽略,且 Science 已丢弃) 同时移除 `normalize_thinking` 中 `auto → adaptive` 的映射。 DeepSeek Anthropic 端点原生接受标准 Anthropic thinking 取值,无需 provider 特定的重映射。 参考: - https://api-docs.deepseek.com/zh-cn/guides/anthropic_api - https://api-docs.deepseek.com/zh-cn/guides/thinking_mode --- desktop/gateway/src/policy.rs | 119 ++++++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 11 deletions(-) diff --git a/desktop/gateway/src/policy.rs b/desktop/gateway/src/policy.rs index 5b8ffae..bb1d85b 100644 --- a/desktop/gateway/src/policy.rs +++ b/desktop/gateway/src/policy.rs @@ -21,16 +21,47 @@ pub fn normalize_thinking(body: &mut Value) { body["thinking"] = serde_json::json!({"type": "disabled"}); return; } - if body - .get("thinking") - .and_then(Value::as_object) - .and_then(|th| th.get("type")) - .and_then(Value::as_str) - == Some("auto") - { - if let Some(thinking) = body.get_mut("thinking").and_then(Value::as_object_mut) { - thinking.insert("type".to_string(), Value::String("adaptive".to_string())); + // Claude Science sends "auto"; DeepSeek's Anthropic endpoint handles it + // natively — no provider-specific remapping needed. +} + +/// DeepSeek thinking mode has two distinct rules for multi-turn history: +/// - No tool calls in the assistant turn: reasoning_content is optional and +/// ignored by the API. We strip it for consistency with Claude Science. +/// - Tool calls present: reasoning_content MUST be echoed back in ALL +/// subsequent turns, otherwise the API returns 400. +fn strip_thinking_from_history(messages: &mut Value) { + let Some(arr) = messages.as_array_mut() else { + return; + }; + for msg in arr.iter_mut() { + let Some(role) = msg.get("role").and_then(Value::as_str) else { + continue; + }; + if role != "assistant" { + continue; } + let Some(content) = msg.get("content") else { + continue; + }; + let Some(items) = content.as_array() else { + continue; + }; + + // This assistant turn involved tool use → preserve thinking + let has_tool_use = items + .iter() + .any(|item| item.get("type").and_then(Value::as_str) == Some("tool_use")); + + if has_tool_use { + continue; + } + + // No tool use → strip thinking (API ignores it anyway) + let Some(items_mut) = msg.get_mut("content").and_then(Value::as_array_mut) else { + continue; + }; + items_mut.retain(|item| item.get("type").and_then(Value::as_str) != Some("thinking")); } } @@ -51,12 +82,13 @@ pub fn transform_request(mut body: Value, target_model: &str) -> Result, ); } normalize_thinking(&mut body); + strip_thinking_from_history(&mut body["messages"]); serde_json::to_vec(&body).map_err(|e| e.to_string()) } #[cfg(test)] mod tests { - use super::{clamp_max_tokens, transform_request}; + use super::{clamp_max_tokens, strip_thinking_from_history, transform_request}; use serde_json::json; #[test] @@ -80,7 +112,7 @@ mod tests { let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(v["model"], "deepseek-v4-pro"); assert_eq!(v["max_tokens"], 65536); - assert_eq!(v["thinking"]["type"], "adaptive"); + assert_eq!(v["thinking"]["type"], "auto"); } #[test] @@ -95,4 +127,69 @@ mod tests { let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(v["thinking"]["type"], "disabled"); } + + #[test] + fn strips_thinking_from_assistant_messages_in_history() { + let mut messages = json!([ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "let me think...", "signature": "sig1"}, + {"type": "text", "text": "hi there"} + ]}, + {"role": "user", "content": "more"} + ]); + strip_thinking_from_history(&mut messages); + let arr = messages.as_array().unwrap(); + // user messages unchanged + assert_eq!(arr[0]["content"], "hello"); + // assistant: thinking removed, text kept + let content = arr[1]["content"].as_array().unwrap(); + assert_eq!(content.len(), 1); + assert_eq!(content[0]["type"], "text"); + // last user unchanged + assert_eq!(arr[2]["content"], "more"); + } + + #[test] + fn strips_thinking_from_multi_turn_request() { + let raw = json!({ + "model": "claude-sonnet-5", + "max_tokens": 1000, + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "hmm...", "signature": "abc"}, + {"type": "text", "text": "hi"} + ]}, + {"role": "user", "content": "what else?"} + ] + }); + let bytes = transform_request(raw, "deepseek-v4-flash").unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let msgs = v["messages"].as_array().unwrap(); + let content = msgs[1]["content"].as_array().unwrap(); + // No tool_use → thinking stripped + assert_eq!(content.len(), 1); + assert_eq!(content[0]["type"], "text"); + } + + #[test] + fn preserves_thinking_when_assistant_has_tool_use() { + // With tool_use → thinking MUST be preserved per DeepSeek docs + let mut messages = json!([ + {"role": "user", "content": "what's the weather?"}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "I need to call the weather tool", "signature": "sig"}, + {"type": "tool_use", "id": "tool_1", "name": "get_weather", "input": {}} + ]}, + {"role": "user", "content": "and tomorrow?"} + ]); + strip_thinking_from_history(&mut messages); + let arr = messages.as_array().unwrap(); + let content = arr[1]["content"].as_array().unwrap(); + // tool_use present → thinking preserved + assert_eq!(content.len(), 2); + assert_eq!(content[0]["type"], "thinking"); + assert_eq!(content[1]["type"], "tool_use"); + } } From ca634207fab0e0ef7ba6654481709fb9e46fdb79 Mon Sep 17 00:00:00 2001 From: MirrorD <38676873+ZirrorDmage@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:27:43 +0800 Subject: [PATCH 2/2] Update policy.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # DeepSeek Thinking 多轮对话修复 ## 问题 DeepSeek Anthropic 端点在 thinking 模式下,多轮对话返回 HTTP 400: ``` The content[].thinking in the thinking mode must be passed back to the API. ``` ## 根因 DeepSeek 与 Anthropic 对 thinking 块的多轮处理语义不同: | | Anthropic API | DeepSeek API | |---|---|---| | thinking 是否需回传 | 不需要(Claude Science 自动剥离) | tool_use 存在时**必须回传** | | 默认 thinking 状态 | 由请求控制 | **默认 enabled** | 当 Claude Science 在多轮对话中按 Anthropic 规范剥离了 assistant 消息中的 thinking 块后,DeepSeek 检测到 thinking 启用但历史中 tool_use 关联的 thinking 缺失 → 400。 ## 迭代记录 ### v1 — 初始分析(被否决) 对照 DeepSeek 官方文档后发现,代码中 `auto → adaptive` 的映射和 `strip_ignored_params` 均非 Anthropic 端点所需。保留以下函数并部署: | 函数 | 作用 | |---|---| | `strip_thinking_from_history` | 无 tool_use → 剥离 thinking;有 tool_use → 保留 | | `map_effort` | `budget_tokens` → `output_config.effort` | | `strip_ignored_params` | 移除 `temperature`、`top_p`、`top_k` 等 | **验证结果**: - `map_effort` 设置 `effort=high` 导致 reasoning 消耗大量 token,产生空响应 → **移除** - `strip_ignored_params` 删除的温度/采样参数 DeepSeek 自己会处理(传了不报错,忽略即可)→ **移除** ### v2 — 精简为最小修复(被否决) 仅保留 `strip_thinking_from_history` 和 `auto` 透传。 **验证结果**:有 tool_use 时,Science 在到达 Gateway **之前**已经剥离 thinking。Gateway 看到 tool_use → 保留(不做任何事),但 thinking 块早已缺失 → **400 复现**。 关键发现:Science 按 Anthropic 规范**无条件**剥离所有 thinking 块,包括含 tool_use 的消息。Gateway 无法"保留"已经不存在的数据。 ### v3 — 链断裂检测(当前方案) 放弃主动剥离/保留逻辑,改为被动检测: - 移除 `strip_thinking_from_history` — DeepSeek 官方文档明确无 tool_use 时 API 自己会忽略 thinking,无需 Gateway 干预 - 新增 `thinking_chain_broken` — 仅检测 tool_use 存在但 thinking 缺失的场景,此时链已断裂,必须设 `disabled` 避免 400 - 保留 `auto` 透传 这是最小可行方案:只在**已经必报 400** 的边界场景下干预,不影响正常单轮和多轮对话。 ## 最终改动 新增 `thinking_chain_broken()` 函数,按 DeepSeek 官方文档规则检测 thinking 链是否断裂: - assistant 消息中包含 `tool_use` 但缺少 `thinking` → 链断裂(Science 已剥离) - 链断裂时,当前请求设 `thinking: disabled`,避免 400 同时移除原代码中 `auto → adaptive` 的 thinking type 映射。DeepSeek Anthropic 端点原生接受标准 Anthropic thinking 取值,无需 provider 特定重映射。 ## 改动范围 单文件:`desktop/gateway/src/policy.rs` ### normalize_thinking ```diff - if body.get("thinking")... == Some("auto") { - thinking.insert("type", "adaptive"); - } + // Claude Science sends "auto"; DeepSeek's Anthropic endpoint + // handles it natively — no provider-specific remapping needed. ``` ### 新增 thinking_chain_broken ```rust fn thinking_chain_broken(messages: &Value) -> bool { // 遍历历史 assistant 消息 // 有 tool_use 但无 thinking → 链断裂 → true // 其他情况 → false } ``` ### transform_request 调用 ```diff normalize_thinking(&mut body); + if thinking_chain_broken(&body["messages"]) { + body["thinking"] = serde_json::json!({"type": "disabled"}); + } serde_json::to_vec(&body) ``` ## 行为矩阵 | 场景 | thinking 状态 | 结果 | |---|---|---| | 单轮对话 | 保持 `auto` | 正常 | | 多轮纯文本 | 保持 `auto`,thinking 原样透传 | 正常(API 会忽略) | | 多轮 tool_use + thinking 完整 | 保持 `auto` | 正常 | | 多轮 tool_use + thinking 缺失 | → `disabled` | 本轮放弃 thinking,避免 400 | ## 后续方向 Gateway 可在响应阶段缓存 DeepSeek 返回的 thinking 块,在下一轮请求中检测缺失时补回,从而实现完整的 thinking 连续性和更接近 DeepSeek 官方示例的行为。当前修复为最小可行方案。 ## 参考 - [DeepSeek Anthropic API 文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) - [DeepSeek 思考模式文档](https://api-docs.deepseek.com/zh-cn/guides/thinking_mode) - BUG-083-DEEPSEEK-THINKING --- desktop/gateway/src/policy.rs | 136 +++++++++++++++------------------- 1 file changed, 59 insertions(+), 77 deletions(-) diff --git a/desktop/gateway/src/policy.rs b/desktop/gateway/src/policy.rs index bb1d85b..bbb6ead 100644 --- a/desktop/gateway/src/policy.rs +++ b/desktop/gateway/src/policy.rs @@ -25,44 +25,31 @@ pub fn normalize_thinking(body: &mut Value) { // natively — no provider-specific remapping needed. } -/// DeepSeek thinking mode has two distinct rules for multi-turn history: -/// - No tool calls in the assistant turn: reasoning_content is optional and -/// ignored by the API. We strip it for consistency with Claude Science. -/// - Tool calls present: reasoning_content MUST be echoed back in ALL -/// subsequent turns, otherwise the API returns 400. -fn strip_thinking_from_history(messages: &mut Value) { - let Some(arr) = messages.as_array_mut() else { - return; +/// Check whether the thinking chain is broken: any assistant message that +/// contains tool_use but is missing thinking blocks means Claude Science +/// already stripped them (native Anthropic semantics). DeepSeek requires +/// thinking to be echoed back when tool_use was present, so further +/// thinking must be disabled for this request. +fn thinking_chain_broken(messages: &Value) -> bool { + let Some(arr) = messages.as_array() else { + return false; }; - for msg in arr.iter_mut() { - let Some(role) = msg.get("role").and_then(Value::as_str) else { - continue; - }; + arr.iter().any(|msg| { + let role = msg.get("role").and_then(Value::as_str).unwrap_or(""); if role != "assistant" { - continue; + return false; } - let Some(content) = msg.get("content") else { - continue; - }; - let Some(items) = content.as_array() else { - continue; + let Some(items) = msg.get("content").and_then(Value::as_array) else { + return false; }; - - // This assistant turn involved tool use → preserve thinking let has_tool_use = items .iter() .any(|item| item.get("type").and_then(Value::as_str) == Some("tool_use")); - - if has_tool_use { - continue; - } - - // No tool use → strip thinking (API ignores it anyway) - let Some(items_mut) = msg.get_mut("content").and_then(Value::as_array_mut) else { - continue; - }; - items_mut.retain(|item| item.get("type").and_then(Value::as_str) != Some("thinking")); - } + let has_thinking = items + .iter() + .any(|item| item.get("type").and_then(Value::as_str) == Some("thinking")); + has_tool_use && !has_thinking + }) } pub fn transform_request(mut body: Value, target_model: &str) -> Result, String> { @@ -82,13 +69,15 @@ pub fn transform_request(mut body: Value, target_model: &str) -> Result, ); } normalize_thinking(&mut body); - strip_thinking_from_history(&mut body["messages"]); + if thinking_chain_broken(&body["messages"]) { + body["thinking"] = serde_json::json!({"type": "disabled"}); + } serde_json::to_vec(&body).map_err(|e| e.to_string()) } #[cfg(test)] mod tests { - use super::{clamp_max_tokens, strip_thinking_from_history, transform_request}; + use super::{clamp_max_tokens, thinking_chain_broken, transform_request}; use serde_json::json; #[test] @@ -129,67 +118,60 @@ mod tests { } #[test] - fn strips_thinking_from_assistant_messages_in_history() { - let mut messages = json!([ + fn chain_intact_when_tool_use_has_thinking() { + let messages = json!([ {"role": "user", "content": "hello"}, {"role": "assistant", "content": [ - {"type": "thinking", "thinking": "let me think...", "signature": "sig1"}, - {"type": "text", "text": "hi there"} + {"type": "thinking", "thinking": "I'll use a tool", "signature": "sig"}, + {"type": "tool_use", "id": "t1", "name": "read", "input": {}} ]}, {"role": "user", "content": "more"} ]); - strip_thinking_from_history(&mut messages); - let arr = messages.as_array().unwrap(); - // user messages unchanged - assert_eq!(arr[0]["content"], "hello"); - // assistant: thinking removed, text kept - let content = arr[1]["content"].as_array().unwrap(); - assert_eq!(content.len(), 1); - assert_eq!(content[0]["type"], "text"); - // last user unchanged - assert_eq!(arr[2]["content"], "more"); + assert!(!thinking_chain_broken(&messages)); } #[test] - fn strips_thinking_from_multi_turn_request() { + fn chain_broken_when_tool_use_missing_thinking() { + // Science stripped thinking → broken + let messages = json!([ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "t1", "name": "read", "input": {}} + ]}, + {"role": "user", "content": "more"} + ]); + assert!(thinking_chain_broken(&messages)); + } + + #[test] + fn chain_intact_without_tool_use() { + let messages = json!([ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "hmm", "signature": "sig"}, + {"type": "text", "text": "hi"} + ]}, + {"role": "user", "content": "more"} + ]); + assert!(!thinking_chain_broken(&messages)); + } + + #[test] + fn chain_broken_disables_thinking_in_transform() { let raw = json!({ "model": "claude-sonnet-5", "max_tokens": 1000, + "thinking": {"type": "auto"}, "messages": [ {"role": "user", "content": "hello"}, {"role": "assistant", "content": [ - {"type": "thinking", "thinking": "hmm...", "signature": "abc"}, - {"type": "text", "text": "hi"} + {"type": "tool_use", "id": "t1", "name": "read", "input": {}} ]}, - {"role": "user", "content": "what else?"} + {"role": "user", "content": "more"} ] }); - let bytes = transform_request(raw, "deepseek-v4-flash").unwrap(); + let bytes = transform_request(raw, "deepseek-v4-pro").unwrap(); let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - let msgs = v["messages"].as_array().unwrap(); - let content = msgs[1]["content"].as_array().unwrap(); - // No tool_use → thinking stripped - assert_eq!(content.len(), 1); - assert_eq!(content[0]["type"], "text"); - } - - #[test] - fn preserves_thinking_when_assistant_has_tool_use() { - // With tool_use → thinking MUST be preserved per DeepSeek docs - let mut messages = json!([ - {"role": "user", "content": "what's the weather?"}, - {"role": "assistant", "content": [ - {"type": "thinking", "thinking": "I need to call the weather tool", "signature": "sig"}, - {"type": "tool_use", "id": "tool_1", "name": "get_weather", "input": {}} - ]}, - {"role": "user", "content": "and tomorrow?"} - ]); - strip_thinking_from_history(&mut messages); - let arr = messages.as_array().unwrap(); - let content = arr[1]["content"].as_array().unwrap(); - // tool_use present → thinking preserved - assert_eq!(content.len(), 2); - assert_eq!(content[0]["type"], "thinking"); - assert_eq!(content[1]["type"], "tool_use"); + assert_eq!(v["thinking"]["type"], "disabled"); } }