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

@@ -15,16 +15,19 @@ from typing import Any, cast
from src.core.api_format.conversion.internal import (
ErrorType,
ImageBlock,
InternalMessage,
InternalRequest,
Role,
StopReason,
TextBlock,
ToolChoice,
ToolChoiceType,
ToolResultBlock,
ToolUseBlock,
UnknownBlock,
)
from src.core.api_format.conversion.normalizers.claude import ClaudeNormalizer
from src.core.api_format.conversion.stream_events import (
ContentBlockStartEvent,
ContentDeltaEvent,
MessageStartEvent,
MessageStopEvent,
@@ -389,3 +392,117 @@ def test_claude_system_array_format() -> None:
assert "You are Claude Code" in internal.system
assert "Extract file paths" in internal.system
assert "\n\n" in internal.system
def test_claude_request_reuses_raw_tool_choice_and_stabilizes_message_sequence() -> None:
n = ClaudeNormalizer()
internal = n.request_to_internal(
{
"model": "claude-3-sonnet",
"messages": [{"role": "assistant", "content": [{"type": "text", "text": "hi"}]}],
"tool_choice": {"type": "tool", "name": "read_file", "disable_parallel_tool_use": True},
"max_tokens": 16,
}
)
internal.messages = [
InternalMessage(role=Role.ASSISTANT, content=internal.messages[0].content),
InternalMessage(role=Role.ASSISTANT, content=[TextBlock(text="again")]),
]
internal.tool_choice = ToolChoice(
type=ToolChoiceType.TOOL,
tool_name="read_file",
extra={"claude": {"type": "tool", "name": "read_file", "disable_parallel_tool_use": True}},
)
out = n.request_from_internal(internal)
assert out["tool_choice"] == {
"type": "tool",
"name": "read_file",
"disable_parallel_tool_use": True,
}
assert [m["role"] for m in out["messages"]] == ["user", "assistant"]
assert out["messages"][0]["content"] == []
assert isinstance(out["messages"][1]["content"], str)
assert out["messages"][1]["content"] == "hi\nagain"
def test_claude_request_preserves_tool_and_text_order_when_merging_adjacent_roles() -> None:
n = ClaudeNormalizer()
internal = InternalRequest(
model="claude-3-sonnet",
messages=[
InternalMessage(role=Role.USER, content=[TextBlock(text="weather?")]),
InternalMessage(
role=Role.ASSISTANT,
content=[
ToolUseBlock(
tool_id="toolu_1",
tool_name="get_weather",
tool_input={"city": "SF"},
)
],
),
InternalMessage(role=Role.ASSISTANT, content=[TextBlock(text="Use this result.")]),
InternalMessage(
role=Role.USER,
content=[ToolResultBlock(tool_use_id="toolu_1", output={"temp_c": 20})],
),
InternalMessage(role=Role.USER, content=[TextBlock(text="Received.")]),
],
max_tokens=16,
)
out = n.request_from_internal(internal)
out_messages: list[dict[str, Any]] = out["messages"]
assert [m["role"] for m in out_messages] == ["user", "assistant", "user"]
assistant_blocks = cast(list[dict[str, Any]], out_messages[1]["content"])
assert [block["type"] for block in assistant_blocks] == ["tool_use", "text"]
assert assistant_blocks[1]["text"] == "Use this result."
user_blocks = cast(list[dict[str, Any]], out_messages[2]["content"])
assert [block["type"] for block in user_blocks] == ["tool_result", "text"]
assert user_blocks[0]["content"] == {"temp_c": 20}
assert user_blocks[1]["text"] == "Received."
def test_claude_request_preserves_cache_control_system_block_shape() -> None:
n = ClaudeNormalizer()
internal = n.request_to_internal(
{
"model": "claude-3-sonnet",
"messages": [{"role": "user", "content": "hello"}],
"system": [
{"type": "text", "text": "one", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "two"},
],
"max_tokens": 16,
}
)
out = n.request_from_internal(internal)
assert isinstance(out["system"], list)
assert out["system"] == [
{"type": "text", "text": "one", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "two"},
]
def test_claude_request_string_system_stays_string_without_cache_control() -> None:
n = ClaudeNormalizer()
internal = n.request_to_internal(
{
"model": "claude-3-sonnet",
"messages": [{"role": "user", "content": "hello"}],
"system": [
{"type": "text", "text": "one"},
{"type": "text", "text": "two"},
],
"max_tokens": 16,
}
)
out = n.request_from_internal(internal)
assert out["system"] == "one\n\ntwo"
assert isinstance(out["system"], str)

View File

@@ -275,6 +275,47 @@ def test_openai_chat_prompt_cache_key_preserved_when_convert_to_openai_cli() ->
assert out["prompt_cache_key"] == "cache-key-123"
def test_openai_chat_tool_payload_preserves_raw_strings_when_convert_to_openai_cli() -> None:
reg = _make_registry_with_cli()
openai_chat_req = {
"model": "gpt-5",
"messages": [
{"role": "user", "content": "帮我读取 README"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "read_file",
"arguments": '{"b":2,"a":1}',
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": '{"z":1,"a":2}',
},
],
}
out = reg.convert_request(openai_chat_req, "openai:chat", "openai:cli")
input_items = cast(list[dict[str, Any]], out.get("input") or [])
function_call = next((i for i in input_items if i.get("type") == "function_call"), {})
function_call_output = next(
(i for i in input_items if i.get("type") == "function_call_output"), {}
)
assert function_call["arguments"] == '{"b":2,"a":1}'
assert function_call_output["output"] == '{"z":1,"a":2}'
def test_openai_chat_text_config_maps_to_openai_cli_text_block() -> None:
reg = _make_registry_with_cli()
@@ -601,6 +642,44 @@ def test_claude_tool_use_to_openai_cli() -> None:
assert fco_items[0]["output"] == "Hello World"
def test_openai_cli_request_omits_implicit_empty_defaults_for_standard_responses() -> None:
normalizer = OpenAICliNormalizer()
internal = normalizer.request_to_internal({"model": "gpt-test", "input": []})
out = normalizer.request_from_internal(internal)
assert "instructions" not in out
assert "stream" not in out
assert "store" not in out
def test_openai_cli_request_preserves_explicit_empty_instructions_and_stream_false() -> None:
normalizer = OpenAICliNormalizer()
internal = normalizer.request_to_internal(
{"model": "gpt-test", "input": [], "instructions": "", "stream": False}
)
out = normalizer.request_from_internal(internal)
assert out["instructions"] == ""
assert out["stream"] is False
assert "store" not in out
def test_openai_cli_request_tool_choice_flat_function_roundtrip_preserved() -> None:
normalizer = OpenAICliNormalizer()
request = {
"model": "gpt-test",
"input": [],
"tool_choice": {"type": "function", "name": "read_file"},
}
internal = normalizer.request_to_internal(request)
out = normalizer.request_from_internal(internal)
assert out["tool_choice"] == {"type": "function", "name": "read_file"}
def test_claude_explicit_effort_preserved_in_openai_cli() -> None:
reg = _make_registry_with_cli()

View File

@@ -23,7 +23,10 @@ from src.core.api_format.conversion.internal import (
ToolUseBlock,
UnknownBlock,
)
from src.core.api_format.conversion.normalizers.gemini import GeminiNormalizer
from src.core.api_format.conversion.normalizers.gemini import (
GeminiNormalizer,
compact_gemini_contents,
)
from src.core.api_format.conversion.stream_events import (
ContentDeltaEvent,
MessageStartEvent,
@@ -31,6 +34,7 @@ from src.core.api_format.conversion.stream_events import (
ToolCallDeltaEvent,
)
from src.core.api_format.conversion.stream_state import StreamState
from src.core.api_format.schema_utils import clean_gemini_schema
def test_gemini_request_system_and_generation_config_roundtrip() -> None:
@@ -85,7 +89,7 @@ def test_gemini_request_system_and_generation_config_roundtrip() -> None:
assert out["generation_config"]["max_output_tokens"] == 10
assert out["generation_config"]["stop_sequences"] == ["A", "B"]
assert out["tools"][0]["function_declarations"][0]["name"] == "get_weather"
assert out["tool_config"]["function_calling_config"]["mode"] == "ANY"
assert out["tool_config"] == {"functionCallingConfig": {"mode": "ANY"}}
def test_gemini_request_parts_image_tool_and_unknown_drop() -> None:
@@ -279,3 +283,81 @@ def test_gemini_error_conversion() -> None:
out = n.error_from_internal(internal)
assert out["error"]["status"] == "RESOURCE_EXHAUSTED"
assert out["error"]["message"] == "slow down"
def test_gemini_compact_contents_drops_invalid_and_merges_same_role() -> None:
contents: list[dict[str, Any]] = [
{"role": "user", "parts": [{"text": "hi"}, {"invalid": True}]},
{"role": "user", "parts": [{"text": "again"}]},
{"role": "model", "parts": [{"bad": 1}]},
{"role": "model", "parts": [{"text": "ok"}]},
{"role": "user", "parts": "bad"},
]
compacted = compact_gemini_contents(contents)
assert compacted == [
{"role": "user", "parts": [{"text": "hi"}, {"text": "again"}]},
{"role": "model", "parts": [{"text": "ok"}]},
]
def test_gemini_request_reuses_raw_tool_config_and_cleans_schema_deterministically() -> None:
n = GeminiNormalizer()
req = {
"model": "gemini-1.5",
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"tools": [
{
"functionDeclarations": [
{
"name": "read_file",
"description": "Read file",
"parameters": {
"allOf": [
{
"type": "object",
"properties": {"b": {"type": "string"}},
"required": ["b", "a"],
},
{
"type": "object",
"properties": {"a": {"type": "string"}},
"required": ["a", "c"],
},
]
},
}
]
}
],
"toolConfig": {
"functionCallingConfig": {
"mode": "ANY",
"allowedFunctionNames": ["read_file"],
}
},
}
internal = n.request_to_internal(req)
out = n.request_from_internal(internal)
assert out["tool_config"] == {
"functionCallingConfig": {
"mode": "ANY",
"allowedFunctionNames": ["read_file"],
}
}
params = out["tools"][0]["function_declarations"][0]["parameters"]
assert params["required"] == ["b", "a"]
assert list(params["properties"].keys()) == ["b", "a"]
def test_clean_gemini_schema_allof_required_order_is_deterministic() -> None:
schema = {
"allOf": [
{"type": "object", "properties": {"z": {"type": "string"}}, "required": ["z", "a"]},
{"type": "object", "properties": {"a": {"type": "string"}}, "required": ["a", "m"]},
]
}
clean_gemini_schema(schema)
assert schema["required"] == ["z", "a"]

View File

@@ -15,7 +15,6 @@ import json
from typing import Any, cast
from src.core.api_format.conversion.internal import (
ContentType,
ErrorType,
ImageBlock,
StopReason,
@@ -184,6 +183,60 @@ def test_openai_request_tool_calls_and_tool_role_roundtrip() -> None:
assert json.loads(tool_out["content"]) == {"temp_c": 20, "unit": "C"}
def test_openai_request_preserves_empty_string_tool_call_arguments() -> None:
n = OpenAINormalizer()
req = {
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "ping"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_empty",
"type": "function",
"function": {"name": "noop", "arguments": ""},
}
],
},
],
}
internal = n.request_to_internal(req)
tool_use = next(b for b in internal.messages[1].content if isinstance(b, ToolUseBlock))
assert tool_use.tool_input == {}
assert tool_use.extra["raw"]["arguments"] == ""
out = n.request_from_internal(internal)
assert out["messages"][1]["tool_calls"][0]["function"]["arguments"] == ""
def test_openai_request_preserves_empty_string_legacy_function_call_arguments() -> None:
n = OpenAINormalizer()
req = {
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "ping"},
{
"role": "assistant",
"content": "",
"function_call": {"name": "noop", "arguments": ""},
},
],
}
internal = n.request_to_internal(req)
tool_use = next(b for b in internal.messages[1].content if isinstance(b, ToolUseBlock))
assert tool_use.tool_input == {}
assert tool_use.extra["raw"]["arguments"] == ""
out = n.request_from_internal(internal)
assert out["messages"][1]["tool_calls"][0]["function"]["arguments"] == ""
def test_openai_request_content_image_and_unknown_drop() -> None:
n = OpenAINormalizer()