2026-01-22 14:17:59 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Thinking 整流器(Rectifier)
|
|
|
|
|
|
|
|
|
|
|
|
采用 cc-switch 的"错误触发"模式,在遇到 Thinking 签名/结构错误时触发整流。
|
|
|
|
|
|
|
|
|
|
|
|
核心功能:
|
|
|
|
|
|
1. 移除所有 thinking 和 redacted_thinking 块
|
|
|
|
|
|
2. 移除非 thinking 块上的 signature 字段
|
|
|
|
|
|
3. 条件删除顶层 thinking 参数
|
|
|
|
|
|
|
|
|
|
|
|
使用场景:
|
|
|
|
|
|
当遇到 ThinkingSignatureException 时,调用 rectify() 整流请求体后重试一次。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import copy
|
2026-02-05 15:57:52 +08:00
|
|
|
|
import json
|
2026-01-30 03:10:21 +08:00
|
|
|
|
from typing import Any
|
2026-01-22 14:17:59 +08:00
|
|
|
|
|
|
|
|
|
|
from src.core.logger import logger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ThinkingRectifier:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Thinking 整流器
|
|
|
|
|
|
|
|
|
|
|
|
在遇到 Thinking 签名/结构错误时,整流请求体以便重试。
|
|
|
|
|
|
采用"彻底清洗 + 条件禁用 thinking"策略。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def rectify(request_body: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
2026-01-22 14:17:59 +08:00
|
|
|
|
"""
|
|
|
|
|
|
整流请求体
|
|
|
|
|
|
|
|
|
|
|
|
执行以下操作:
|
|
|
|
|
|
1. 移除所有 thinking 和 redacted_thinking 块
|
|
|
|
|
|
2. 移除非 thinking 块上的 signature 字段
|
|
|
|
|
|
3. 条件删除顶层 thinking 参数
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
request_body: 原始请求体
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Tuple[整流后的请求体, 是否有修改]
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not request_body:
|
|
|
|
|
|
return request_body, False
|
|
|
|
|
|
|
|
|
|
|
|
# 深拷贝以避免修改原始数据
|
|
|
|
|
|
rectified_body = copy.deepcopy(request_body)
|
|
|
|
|
|
modified = False
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 整流 messages
|
|
|
|
|
|
messages = rectified_body.get("messages", [])
|
|
|
|
|
|
if messages:
|
|
|
|
|
|
rectified_messages, messages_modified = ThinkingRectifier._rectify_messages(messages)
|
|
|
|
|
|
if messages_modified:
|
|
|
|
|
|
rectified_body["messages"] = rectified_messages
|
|
|
|
|
|
modified = True
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 条件删除顶层 thinking 参数(使用整流后的 messages 判断)
|
|
|
|
|
|
# 与 cc-switch 行为一致:在整流 messages 之后获取快照进行判断
|
|
|
|
|
|
if ThinkingRectifier._should_remove_top_level_thinking(rectified_body):
|
|
|
|
|
|
if "thinking" in rectified_body:
|
|
|
|
|
|
del rectified_body["thinking"]
|
|
|
|
|
|
modified = True
|
|
|
|
|
|
logger.info("ThinkingRectifier: 已移除顶层 thinking 参数")
|
|
|
|
|
|
|
|
|
|
|
|
return rectified_body, modified
|
|
|
|
|
|
|
2026-02-05 15:57:52 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def rectify_signature_sensitive_blocks(
|
|
|
|
|
|
request_body: dict[str, Any],
|
|
|
|
|
|
) -> tuple[dict[str, Any], bool]:
|
|
|
|
|
|
"""Second-stage rectification for signature-related failures.
|
|
|
|
|
|
|
|
|
|
|
|
This is a more aggressive fallback than `rectify()`:
|
|
|
|
|
|
- Removes all thinking/redacted_thinking blocks
|
|
|
|
|
|
- Removes signature fields on remaining blocks
|
|
|
|
|
|
- Degrades tool_use/tool_result blocks into plain text blocks
|
|
|
|
|
|
- Disables top-level `thinking` when enabled
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not request_body:
|
|
|
|
|
|
return request_body, False
|
|
|
|
|
|
|
|
|
|
|
|
rectified_body = copy.deepcopy(request_body)
|
|
|
|
|
|
modified = False
|
|
|
|
|
|
|
|
|
|
|
|
messages = rectified_body.get("messages", [])
|
|
|
|
|
|
if isinstance(messages, list) and messages:
|
|
|
|
|
|
new_messages: list[Any] = []
|
|
|
|
|
|
for message in messages:
|
|
|
|
|
|
if not isinstance(message, dict):
|
|
|
|
|
|
new_messages.append(message)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
new_message = dict(message)
|
|
|
|
|
|
content = message.get("content")
|
|
|
|
|
|
if isinstance(content, list):
|
|
|
|
|
|
new_content: list[Any] = []
|
|
|
|
|
|
for block in content:
|
|
|
|
|
|
if not isinstance(block, dict):
|
|
|
|
|
|
new_content.append(block)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
block_type = block.get("type")
|
|
|
|
|
|
|
|
|
|
|
|
if block_type in ("thinking", "redacted_thinking"):
|
|
|
|
|
|
modified = True
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if block_type == "tool_use":
|
|
|
|
|
|
# Degrade into text to avoid strict structure/signature validation.
|
|
|
|
|
|
name = block.get("name")
|
|
|
|
|
|
inp = block.get("input")
|
|
|
|
|
|
try:
|
|
|
|
|
|
inp_text = json.dumps(inp, ensure_ascii=False)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
inp_text = str(inp)
|
|
|
|
|
|
new_content.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "text",
|
|
|
|
|
|
"text": f"[tool_use] name={name} input={inp_text}",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
modified = True
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if block_type == "tool_result":
|
|
|
|
|
|
raw = block.get("content")
|
|
|
|
|
|
try:
|
|
|
|
|
|
raw_text = json.dumps(raw, ensure_ascii=False)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
raw_text = str(raw)
|
|
|
|
|
|
new_content.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "text",
|
|
|
|
|
|
"text": f"[tool_result] {raw_text}",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
modified = True
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Remove signature field (for any non-thinking block).
|
|
|
|
|
|
if "signature" in block:
|
|
|
|
|
|
new_block = {k: v for k, v in block.items() if k != "signature"}
|
|
|
|
|
|
new_content.append(new_block)
|
|
|
|
|
|
modified = True
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
new_content.append(block)
|
|
|
|
|
|
|
|
|
|
|
|
new_message["content"] = new_content
|
|
|
|
|
|
|
|
|
|
|
|
new_messages.append(new_message)
|
|
|
|
|
|
|
|
|
|
|
|
rectified_body["messages"] = new_messages
|
|
|
|
|
|
|
|
|
|
|
|
# Stage-2: disable top-level thinking unconditionally when enabled.
|
|
|
|
|
|
thinking_param = rectified_body.get("thinking")
|
|
|
|
|
|
if isinstance(thinking_param, dict) and thinking_param.get("type") == "enabled":
|
|
|
|
|
|
del rectified_body["thinking"]
|
|
|
|
|
|
modified = True
|
|
|
|
|
|
logger.info("ThinkingRectifier(stage2): 已移除顶层 thinking 参数")
|
|
|
|
|
|
|
|
|
|
|
|
return rectified_body, modified
|
|
|
|
|
|
|
2026-01-22 14:17:59 +08:00
|
|
|
|
@staticmethod
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def _rectify_messages(messages: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]:
|
2026-01-22 14:17:59 +08:00
|
|
|
|
"""
|
|
|
|
|
|
整流消息列表
|
|
|
|
|
|
|
|
|
|
|
|
移除所有 thinking/redacted_thinking 块和 signature 字段
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
messages: 原始消息列表
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Tuple[整流后的消息列表, 是否有修改]
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not messages:
|
|
|
|
|
|
return messages, False
|
|
|
|
|
|
|
|
|
|
|
|
modified = False
|
2026-01-30 03:10:21 +08:00
|
|
|
|
result_messages: list[dict[str, Any]] = []
|
2026-01-22 14:17:59 +08:00
|
|
|
|
thinking_removed = 0
|
|
|
|
|
|
signature_removed = 0
|
|
|
|
|
|
|
|
|
|
|
|
for message in messages:
|
|
|
|
|
|
# 类型保护:跳过非 dict 消息
|
|
|
|
|
|
if not isinstance(message, dict):
|
|
|
|
|
|
result_messages.append(message)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# 消息级浅拷贝:外层 rectify() 已深拷贝整个 request_body
|
|
|
|
|
|
# content 会被重建为新列表,不会影响原始数据
|
|
|
|
|
|
new_message = dict(message)
|
|
|
|
|
|
content = message.get("content")
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(content, list):
|
|
|
|
|
|
new_content = []
|
|
|
|
|
|
for block in content:
|
|
|
|
|
|
if isinstance(block, dict):
|
|
|
|
|
|
block_type = block.get("type")
|
|
|
|
|
|
|
|
|
|
|
|
# 移除 thinking 和 redacted_thinking 块
|
|
|
|
|
|
if block_type in ("thinking", "redacted_thinking"):
|
|
|
|
|
|
thinking_removed += 1
|
|
|
|
|
|
modified = True
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# 移除非 thinking 块上的 signature 字段
|
|
|
|
|
|
if "signature" in block:
|
|
|
|
|
|
new_block = {k: v for k, v in block.items() if k != "signature"}
|
|
|
|
|
|
new_content.append(new_block)
|
|
|
|
|
|
signature_removed += 1
|
|
|
|
|
|
modified = True
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
new_content.append(block)
|
|
|
|
|
|
else:
|
|
|
|
|
|
new_content.append(block)
|
|
|
|
|
|
|
|
|
|
|
|
# 更新 content
|
|
|
|
|
|
new_message["content"] = new_content
|
|
|
|
|
|
|
|
|
|
|
|
# 如果整流后 assistant 消息的 content 为空,记录警告
|
|
|
|
|
|
# (空 content 本身不是"修改",只是检测到的状态,不设置 modified)
|
|
|
|
|
|
# 保留消息是必要的:跳过会破坏对话结构(后续 tool_result 消息需要前置 assistant 消息)
|
|
|
|
|
|
if new_message.get("role") == "assistant":
|
|
|
|
|
|
effective_content = new_message.get("content")
|
|
|
|
|
|
is_empty = not effective_content or (
|
|
|
|
|
|
isinstance(effective_content, list) and len(effective_content) == 0
|
|
|
|
|
|
)
|
|
|
|
|
|
if is_empty:
|
|
|
|
|
|
msg_idx = len(result_messages)
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"ThinkingRectifier: assistant 消息整流后 content 为空 (message_index={msg_idx})"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result_messages.append(new_message)
|
|
|
|
|
|
|
|
|
|
|
|
if thinking_removed > 0 or signature_removed > 0:
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"ThinkingRectifier: 移除了 {thinking_removed} 个 thinking 块, "
|
|
|
|
|
|
f"{signature_removed} 个 signature 字段"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return result_messages, modified
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def _should_remove_top_level_thinking(body: dict[str, Any]) -> bool:
|
2026-01-22 14:17:59 +08:00
|
|
|
|
"""
|
|
|
|
|
|
判断是否应该删除顶层 thinking 参数
|
|
|
|
|
|
|
|
|
|
|
|
与 cc-switch 行为一致:只检查最后一条 assistant 消息
|
|
|
|
|
|
|
|
|
|
|
|
设计思路:
|
|
|
|
|
|
- body 中的 messages 是整流后的状态,thinking 块已被移除
|
|
|
|
|
|
- Claude API 只校验最后一条 assistant 消息的结构
|
|
|
|
|
|
- 如果最后一条有 tool_use 但首块不是 thinking,需要禁用 thinking 参数
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
body: 整流后的请求体
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
是否应该删除顶层 thinking 参数
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 条件 1: thinking 参数存在且已启用
|
|
|
|
|
|
thinking_param = body.get("thinking")
|
|
|
|
|
|
if not isinstance(thinking_param, dict) or thinking_param.get("type") != "enabled":
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# 从 body 中获取 messages
|
|
|
|
|
|
messages = body.get("messages", [])
|
|
|
|
|
|
|
|
|
|
|
|
# 类型保护:确保 messages 是 list
|
|
|
|
|
|
if not isinstance(messages, list) or not messages:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# 条件 2: 找到最后一条 assistant 消息
|
|
|
|
|
|
last_assistant = None
|
|
|
|
|
|
for message in reversed(messages):
|
|
|
|
|
|
if isinstance(message, dict) and message.get("role") == "assistant":
|
|
|
|
|
|
last_assistant = message
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if not last_assistant:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
content = last_assistant.get("content")
|
|
|
|
|
|
if not isinstance(content, list) or not content:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# 注意:传入的 messages 是整流后的状态,thinking 块已被移除
|
|
|
|
|
|
# 因此只需检查是否有 tool_use,如果有则需要禁用 thinking 参数
|
|
|
|
|
|
# (因为整流后的 assistant 消息不再以 thinking 块开头)
|
|
|
|
|
|
|
|
|
|
|
|
# 检查是否有 tool_use
|
|
|
|
|
|
has_tool_use = any(
|
|
|
|
|
|
isinstance(block, dict) and block.get("type") == "tool_use" for block in content
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 整流后 assistant 消息不再以 thinking 块开头,如果有 tool_use 则需要禁用 thinking 参数
|
|
|
|
|
|
# (Claude API 要求:启用 thinking 时,有 tool_use 的 assistant 消息必须以 thinking 块开头)
|
|
|
|
|
|
if has_tool_use:
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"ThinkingRectifier: 整流后 assistant 消息有 tool_use 但无 thinking 前缀,"
|
|
|
|
|
|
"禁用 thinking 参数以通过 API 校验"
|
|
|
|
|
|
)
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
logger.debug("ThinkingRectifier: 整流后 assistant 消息无 tool_use,保留 thinking 参数")
|
|
|
|
|
|
return False
|