mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user