refactor(conversion): body_rules 保护 cache-sensitive 字段,normalizer 保真优化与诊断日志

- RequestBuilder 新增 protected_body_keys 机制,按 provider API 格式阻止 body_rules
  改写 prompt cache 相关的顶层请求字段(messages/tools/system 等)
- Claude/Gemini normalizer 优先复用原始 raw tool_choice,避免 round-trip 丢失信息
- Claude normalizer 修复 content blocks 输出顺序(flush_text_parts),
  _coerce_claude_message_sequence 返回结构化诊断
- OpenAI normalizer 保留 raw tool call arguments 字符串与原始 tool 定义 extra 字段
- schema_utils allOf 合并保持 required 字段插入顺序
- 各转换环节增加结构化 debug 日志用于调试
This commit is contained in:
fawney19
2026-03-17 01:25:29 +08:00
parent c97c9332eb
commit 4ecaefbade
22 changed files with 1191 additions and 115 deletions

View File

@@ -1,6 +1,9 @@
from typing import Any
from src.api.handlers.base.request_builder import apply_body_rules
from src.api.handlers.base.request_builder import (
apply_body_rules,
get_cache_sensitive_protected_body_keys,
)
class TestApplyBodyRulesNestedPaths:
@@ -47,7 +50,7 @@ class TestApplyBodyRulesNestedPaths:
assert result["extra"]["model"] == "y" # extra 不受保护
def test_escaped_dot(self) -> None:
body = {}
body: dict[str, Any] = {}
result = apply_body_rules(
body,
[
@@ -80,7 +83,7 @@ class TestApplyBodyRulesNestedPaths:
assert result == {"a": {"b": 2}}
def test_set_complex_value(self) -> None:
body = {}
body: dict[str, Any] = {}
result = apply_body_rules(
body,
[
@@ -976,7 +979,7 @@ class TestConditionalBodyRules:
assert result["feature"] is True
def test_condition_on_missing_nested_path(self) -> None:
body = {"config": {}}
body: dict[str, Any] = {"config": {}}
result = apply_body_rules(
body,
[
@@ -1511,3 +1514,117 @@ class TestItemCondition:
original_body=original_body,
)
assert result["matched"] is True
class TestProtectedBodyKeys:
def test_get_cache_sensitive_protected_body_keys_by_format(self) -> None:
assert get_cache_sensitive_protected_body_keys("openai:chat") == frozenset(
{"model", "stream", "messages", "tools", "tool_choice"}
)
assert get_cache_sensitive_protected_body_keys("openai:cli") == frozenset(
{
"model",
"stream",
"input",
"instructions",
"tools",
"tool_choice",
"prompt_cache_key",
}
)
assert get_cache_sensitive_protected_body_keys("claude:chat") == frozenset(
{"model", "stream", "messages", "system", "tools", "tool_choice"}
)
assert get_cache_sensitive_protected_body_keys("gemini:chat") == frozenset(
{
"model",
"stream",
"contents",
"system_instruction",
"systemInstruction",
"tools",
"tool_config",
"toolConfig",
"generation_config",
"generationConfig",
}
)
def test_gemini_camelcase_alias_prompt_fields_are_protected(self) -> None:
body = {
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"systemInstruction": {"parts": [{"text": "system"}]},
"toolConfig": {"functionCallingConfig": {"mode": "AUTO"}},
"generationConfig": {"temperature": 0.1},
"metadata": {"safe": True},
}
protected_keys = get_cache_sensitive_protected_body_keys("gemini:chat")
result = apply_body_rules(
body,
[
{"action": "set", "path": "systemInstruction.parts[0].text", "value": "mutated"},
{"action": "drop", "path": "toolConfig"},
{"action": "rename", "from": "generationConfig", "to": "generation_config"},
{"action": "append", "path": "contents", "value": {"role": "model", "parts": []}},
{
"action": "insert",
"path": "contents",
"index": 0,
"value": {"role": "user", "parts": [{"text": "preface"}]},
},
{"action": "set", "path": "metadata.safe", "value": False},
],
protected_keys=protected_keys,
)
assert result["contents"] == [{"role": "user", "parts": [{"text": "hi"}]}]
assert result["systemInstruction"] == {"parts": [{"text": "system"}]}
assert result["toolConfig"] == {"functionCallingConfig": {"mode": "AUTO"}}
assert result["generationConfig"] == {"temperature": 0.1}
assert "generation_config" not in result
assert result["metadata"]["safe"] is False
def test_protected_prompt_fields_block_all_mutating_actions(self) -> None:
body = {
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"name": "ReadFile"}],
"tool_choice": {"type": "function", "function": {"name": "ReadFile"}},
"metadata": {"safe": True},
}
protected_keys = get_cache_sensitive_protected_body_keys("openai:chat")
result = apply_body_rules(
body,
[
{"action": "set", "path": "messages", "value": []},
{"action": "drop", "path": "tools"},
{"action": "rename", "from": "tool_choice", "to": "choice"},
{
"action": "append",
"path": "messages",
"value": {"role": "assistant", "content": "x"},
},
{
"action": "insert",
"path": "messages",
"index": 0,
"value": {"role": "system", "content": "x"},
},
{
"action": "regex_replace",
"path": "tools[0].name",
"pattern": "Read",
"replacement": "Write",
},
{"action": "name_style", "path": "tools[*].name", "style": "snake_case"},
{"action": "set", "path": "metadata.safe", "value": False},
],
protected_keys=protected_keys,
)
assert result["messages"] == [{"role": "user", "content": "hi"}]
assert result["tools"] == [{"name": "ReadFile"}]
assert result["tool_choice"] == {"type": "function", "function": {"name": "ReadFile"}}
assert "choice" not in result
assert result["metadata"]["safe"] is False