refactor(api_format): 重构为结构化的 (ApiFamily, EndpointKind) 标识体系

将扁平的 APIFormat 枚举 (CLAUDE, OPENAI, GEMINI, *_CLI) 重构为二维结构:
- ApiFamily: 协议族 (openai, claude, gemini)
- EndpointKind: 端点变体 (chat, cli, video, image)

新增 EndpointSignature 数据类和 `family:kind` 签名键工具函数,
统一 JSON dict/metrics/logs 中的字符串标识格式。
This commit is contained in:
fawney19
2026-02-01 17:25:28 +08:00
parent 2101a957ce
commit c246ccfc91
51 changed files with 1078 additions and 869 deletions

View File

@@ -262,7 +262,9 @@ def test_claude_stream_chunk_and_event_roundtrip_basic() -> None:
assert any(isinstance(e, MessageStartEvent) for e in events)
assert [e.text_delta for e in events if isinstance(e, ContentDeltaEvent)] == ["Hel", "lo"]
assert any(isinstance(e, ToolCallDeltaEvent) and e.tool_id == "toolu_1" for e in events)
assert any(isinstance(e, MessageStopEvent) and e.stop_reason == StopReason.END_TURN for e in events)
assert any(
isinstance(e, MessageStopEvent) and e.stop_reason == StopReason.END_TURN for e in events
)
# internal events -> Claude events
state2 = StreamState()
@@ -273,7 +275,11 @@ def test_claude_stream_chunk_and_event_roundtrip_basic() -> None:
assert out_events[0]["type"] == "message_start"
assert out_events[0]["message"]["id"] == "msg_1"
assert any(ev.get("type") == "content_block_delta" and ev.get("delta", {}).get("type") == "input_json_delta" for ev in out_events)
assert any(
ev.get("type") == "content_block_delta"
and ev.get("delta", {}).get("type") == "input_json_delta"
for ev in out_events
)
assert out_events[-1]["type"] == "message_stop"
@@ -305,9 +311,7 @@ def test_claude_request_metadata_preserved() -> None:
{"type": "text", "text": "System prompt 1"},
{"type": "text", "text": "System prompt 2"},
],
"metadata": {
"user_id": "user_abc123_session_xyz456"
},
"metadata": {"user_id": "user_abc123_session_xyz456"},
"max_tokens": 32000,
"stream": True,
}

View File

@@ -33,9 +33,9 @@ def _make_registry_with_cli() -> FormatConversionRegistry:
def test_registry_can_convert_full_with_cli_stream() -> None:
reg = _make_registry_with_cli()
assert reg.can_convert_full("OPENAI_CLI", "OPENAI", require_stream=True) is True
assert reg.can_convert_full("OPENAI_CLI", "CLAUDE_CLI", require_stream=True) is True
assert reg.can_convert_full("GEMINI_CLI", "CLAUDE", require_stream=True) is True
assert reg.can_convert_full("openai:cli", "openai:chat", require_stream=True) is True
assert reg.can_convert_full("openai:cli", "claude:cli", require_stream=True) is True
assert reg.can_convert_full("gemini:cli", "claude:chat", require_stream=True) is True
def test_openai_cli_request_to_claude() -> None:
@@ -48,7 +48,7 @@ def test_openai_cli_request_to_claude() -> None:
"max_output_tokens": 12,
}
claude_req = reg.convert_request(openai_cli_req, "OPENAI_CLI", "CLAUDE")
claude_req = reg.convert_request(openai_cli_req, "openai:cli", "claude:chat")
assert claude_req["model"] == "gpt-4o-mini"
assert claude_req["stream"] is True
assert isinstance(claude_req.get("messages"), list)
@@ -69,7 +69,7 @@ def test_claude_response_to_openai_cli() -> None:
"usage": {"input_tokens": 5, "output_tokens": 7},
}
openai_cli_resp = reg.convert_response(claude_resp, "CLAUDE", "OPENAI_CLI")
openai_cli_resp = reg.convert_response(claude_resp, "claude:chat", "openai:cli")
assert openai_cli_resp["object"] == "response"
assert isinstance(openai_cli_resp.get("output"), list)
msg = cast(dict[str, Any], openai_cli_resp["output"][0])
@@ -89,10 +89,12 @@ def test_stream_openai_to_openai_cli_delta() -> None:
"object": "chat.completion.chunk",
"created": 1,
"model": "gpt-4o-mini",
"choices": [{"index": 0, "delta": {"role": "assistant", "content": "hi"}, "finish_reason": None}],
"choices": [
{"index": 0, "delta": {"role": "assistant", "content": "hi"}, "finish_reason": None}
],
}
out_events = reg.convert_stream_chunk(chunk, "OPENAI", "OPENAI_CLI", state=state)
out_events = reg.convert_stream_chunk(chunk, "openai:chat", "openai:cli", state=state)
assert isinstance(out_events, list) and out_events
assert out_events[0].get("type") == "response.created"
assert out_events[1].get("type") == "response.output_text.delta"
@@ -109,7 +111,7 @@ def test_stream_openai_cli_to_openai_delta() -> None:
"response": {"id": "resp_1", "model": "gpt-4o-mini"},
}
out_events = reg.convert_stream_chunk(chunk, "OPENAI_CLI", "OPENAI", state=state)
out_events = reg.convert_stream_chunk(chunk, "openai:cli", "openai:chat", state=state)
assert isinstance(out_events, list) and out_events
# 第一个 chunk 先补齐 assistant role
@@ -149,7 +151,7 @@ def test_openai_cli_function_call_to_claude() -> None:
"stream": True,
}
claude_req = reg.convert_request(openai_cli_req, "OPENAI_CLI", "CLAUDE")
claude_req = reg.convert_request(openai_cli_req, "openai:cli", "claude:chat")
messages = claude_req.get("messages", [])
assert len(messages) == 3
@@ -203,14 +205,16 @@ def test_openai_cli_reasoning_preserved_in_roundtrip() -> None:
}
# 转换到 internal 再转回 OPENAI_CLI
converted = reg.convert_request(openai_cli_req, "OPENAI_CLI", "OPENAI_CLI")
converted = reg.convert_request(openai_cli_req, "openai:cli", "openai:cli")
input_items = converted.get("input", [])
# 应该有 user message, reasoning, assistant message
assert len(input_items) >= 2
# 找到 reasoning block
reasoning_items = [i for i in input_items if isinstance(i, dict) and i.get("type") == "reasoning"]
reasoning_items = [
i for i in input_items if isinstance(i, dict) and i.get("type") == "reasoning"
]
assert len(reasoning_items) == 1
assert "summary" in reasoning_items[0]
@@ -247,7 +251,7 @@ def test_claude_tool_use_to_openai_cli() -> None:
],
}
openai_cli_req = reg.convert_request(claude_req, "CLAUDE", "OPENAI_CLI")
openai_cli_req = reg.convert_request(claude_req, "claude:chat", "openai:cli")
input_items = openai_cli_req.get("input", [])
assert len(input_items) >= 3
@@ -259,7 +263,9 @@ def test_claude_tool_use_to_openai_cli() -> None:
assert fc_items[0]["call_id"] == "tool_123"
# 找到 function_call_output
fco_items = [i for i in input_items if isinstance(i, dict) and i.get("type") == "function_call_output"]
fco_items = [
i for i in input_items if isinstance(i, dict) and i.get("type") == "function_call_output"
]
assert len(fco_items) == 1
assert fco_items[0]["call_id"] == "tool_123"
assert fco_items[0]["output"] == "Hello World"
@@ -281,7 +287,7 @@ def test_stream_openai_cli_in_progress_event() -> None:
},
}
events1 = reg.convert_stream_chunk(created_chunk, "OPENAI_CLI", "CLAUDE", state=state)
events1 = reg.convert_stream_chunk(created_chunk, "openai:cli", "claude:chat", state=state)
assert isinstance(events1, list) and events1
assert events1[0].get("type") == "message_start"
@@ -296,7 +302,7 @@ def test_stream_openai_cli_in_progress_event() -> None:
},
}
events2 = reg.convert_stream_chunk(in_progress_chunk, "OPENAI_CLI", "CLAUDE", state=state)
events2 = reg.convert_stream_chunk(in_progress_chunk, "openai:cli", "claude:chat", state=state)
# response.in_progress 不应产生任何事件
assert events2 == []
@@ -311,7 +317,7 @@ def test_stream_openai_cli_function_call_events() -> None:
"type": "response.created",
"response": {"id": "resp_456", "model": "gpt-5"},
}
reg.convert_stream_chunk(created_chunk, "OPENAI_CLI", "CLAUDE", state=state)
reg.convert_stream_chunk(created_chunk, "openai:cli", "claude:chat", state=state)
# response.output_item.added (function_call)
output_item_chunk = {
@@ -323,7 +329,7 @@ def test_stream_openai_cli_function_call_events() -> None:
},
}
events1 = reg.convert_stream_chunk(output_item_chunk, "OPENAI_CLI", "CLAUDE", state=state)
events1 = reg.convert_stream_chunk(output_item_chunk, "openai:cli", "claude:chat", state=state)
assert isinstance(events1, list) and events1
assert events1[0].get("type") == "content_block_start"
@@ -333,7 +339,7 @@ def test_stream_openai_cli_function_call_events() -> None:
"delta": '{"city":',
}
events2 = reg.convert_stream_chunk(args_delta_chunk, "OPENAI_CLI", "CLAUDE", state=state)
events2 = reg.convert_stream_chunk(args_delta_chunk, "openai:cli", "claude:chat", state=state)
assert isinstance(events2, list) and events2
# ToolCallDeltaEvent 转换为 Claude 的 content_block_delta
assert events2[0].get("type") == "content_block_delta"
@@ -352,7 +358,7 @@ def test_stream_openai_cli_function_call_events() -> None:
},
}
events3 = reg.convert_stream_chunk(output_done_chunk, "OPENAI_CLI", "CLAUDE", state=state)
events3 = reg.convert_stream_chunk(output_done_chunk, "openai:cli", "claude:chat", state=state)
assert isinstance(events3, list) and events3
assert events3[0].get("type") == "content_block_stop"
@@ -486,7 +492,7 @@ def test_real_claude_cli_stream_response_conversion() -> None:
# 收集所有转换后的 OpenAI 格式事件
all_openai_events: list[dict[str, Any]] = []
for chunk in chunks:
events = reg.convert_stream_chunk(chunk, "CLAUDE_CLI", "OPENAI", state=state)
events = reg.convert_stream_chunk(chunk, "claude:cli", "openai:chat", state=state)
all_openai_events.extend(events)
# 验证转换结果
@@ -542,8 +548,16 @@ def test_real_claude_cli_stream_to_openai_cli() -> None:
},
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
{"type": "ping"},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": " World"}},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "Hello"},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": " World"},
},
{"type": "content_block_stop", "index": 0},
{
"type": "message_delta",
@@ -555,7 +569,7 @@ def test_real_claude_cli_stream_to_openai_cli() -> None:
all_events: list[dict[str, Any]] = []
for chunk in chunks:
events = reg.convert_stream_chunk(chunk, "CLAUDE_CLI", "OPENAI_CLI", state=state)
events = reg.convert_stream_chunk(chunk, "claude:cli", "openai:cli", state=state)
all_events.extend(events)
# 验证 OpenAI CLI 格式事件
@@ -573,5 +587,7 @@ def test_real_claude_cli_stream_to_openai_cli() -> None:
assert " World" in deltas
# 应该有 response.completed 或 response.done 事件
done_events = [e for e in all_events if e.get("type") in ("response.completed", "response.done")]
done_events = [
e for e in all_events if e.get("type") in ("response.completed", "response.done")
]
assert len(done_events) >= 1

View File

@@ -16,8 +16,8 @@ from src.core.api_format.conversion.compatibility import is_format_compatible
def test_same_format_is_compatible() -> None:
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"CLAUDE",
"claude:chat",
"claude:chat",
endpoint_format_acceptance_config=None,
is_stream=False,
global_conversion_enabled=False,
@@ -33,8 +33,8 @@ def test_cli_format_convertible_when_converter_supports_full() -> None:
registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible(
"CLAUDE_CLI",
"OPENAI",
"claude:cli",
"openai:chat",
endpoint_format_acceptance_config={"enabled": True},
is_stream=False,
global_conversion_enabled=True,
@@ -48,8 +48,8 @@ def test_cli_format_convertible_when_converter_supports_full() -> None:
def test_global_switch_disabled_blocks_conversion() -> None:
"""全局开关关闭时(环境变量 FORMAT_CONVERSION_ENABLED=false阻止转换"""
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
"claude:chat",
"openai:chat",
endpoint_format_acceptance_config={"enabled": True},
is_stream=False,
global_conversion_enabled=False,
@@ -62,8 +62,8 @@ def test_global_switch_disabled_blocks_conversion() -> None:
def test_endpoint_config_none_blocks_conversion() -> None:
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
"claude:chat",
"openai:chat",
endpoint_format_acceptance_config=None,
is_stream=False,
global_conversion_enabled=True,
@@ -76,8 +76,8 @@ def test_endpoint_config_none_blocks_conversion() -> None:
def test_endpoint_disabled_blocks_conversion() -> None:
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
"claude:chat",
"openai:chat",
endpoint_format_acceptance_config={"enabled": False},
is_stream=False,
global_conversion_enabled=True,
@@ -90,9 +90,9 @@ def test_endpoint_disabled_blocks_conversion() -> None:
def test_accept_formats_allows_only_whitelist() -> None:
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
endpoint_format_acceptance_config={"enabled": True, "accept_formats": ["OPENAI"]},
"claude:chat",
"openai:chat",
endpoint_format_acceptance_config={"enabled": True, "accept_formats": ["openai:chat"]},
is_stream=False,
global_conversion_enabled=True,
registry=MagicMock(),
@@ -104,9 +104,9 @@ def test_accept_formats_allows_only_whitelist() -> None:
def test_reject_formats_blocks_blacklist() -> None:
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
endpoint_format_acceptance_config={"enabled": True, "reject_formats": ["CLAUDE"]},
"claude:chat",
"openai:chat",
endpoint_format_acceptance_config={"enabled": True, "reject_formats": ["claude:chat"]},
is_stream=False,
global_conversion_enabled=True,
registry=MagicMock(),
@@ -118,8 +118,8 @@ def test_reject_formats_blocks_blacklist() -> None:
def test_stream_conversion_disabled_blocks_stream() -> None:
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
"claude:chat",
"openai:chat",
endpoint_format_acceptance_config={"enabled": True, "stream_conversion": False},
is_stream=True,
global_conversion_enabled=True,
@@ -135,8 +135,8 @@ def test_converter_support_required() -> None:
registry.can_convert_full.return_value = False
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
"claude:chat",
"openai:chat",
endpoint_format_acceptance_config={"enabled": True},
is_stream=False,
global_conversion_enabled=True,
@@ -152,9 +152,9 @@ def test_conversion_allowed_when_converter_supports_full() -> None:
registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
endpoint_format_acceptance_config={"enabled": True, "accept_formats": ["CLAUDE"]},
"claude:chat",
"openai:chat",
endpoint_format_acceptance_config={"enabled": True, "accept_formats": ["claude:chat"]},
is_stream=False,
global_conversion_enabled=True,
registry=registry,
@@ -170,8 +170,8 @@ def test_conversion_allowed_when_converter_supports_full() -> None:
def test_claude_cli_to_claude_no_conversion_needed() -> None:
"""CLAUDE 和 CLAUDE_CLI 格式相同,只是认证不同,可透传(需开关启用)"""
ok, needs_conv, reason = is_format_compatible(
"CLAUDE_CLI",
"CLAUDE",
"claude:cli",
"claude:chat",
endpoint_format_acceptance_config={"enabled": True},
is_stream=False,
global_conversion_enabled=True,
@@ -185,8 +185,8 @@ def test_claude_cli_to_claude_no_conversion_needed() -> None:
def test_claude_to_claude_cli_no_conversion_needed() -> None:
"""CLAUDE 和 CLAUDE_CLI 格式相同,只是认证不同,可透传(需开关启用)"""
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"CLAUDE_CLI",
"claude:chat",
"claude:cli",
endpoint_format_acceptance_config={"enabled": True},
is_stream=False,
global_conversion_enabled=True,
@@ -200,8 +200,8 @@ def test_claude_to_claude_cli_no_conversion_needed() -> None:
def test_gemini_cli_to_gemini_no_conversion_needed() -> None:
"""GEMINI 和 GEMINI_CLI 格式相同,只是认证不同,可透传(需开关启用)"""
ok, needs_conv, reason = is_format_compatible(
"GEMINI_CLI",
"GEMINI",
"gemini:cli",
"gemini:chat",
endpoint_format_acceptance_config={"enabled": True},
is_stream=False,
global_conversion_enabled=True,
@@ -215,8 +215,8 @@ def test_gemini_cli_to_gemini_no_conversion_needed() -> None:
def test_claude_cli_to_claude_blocked_when_global_switch_disabled() -> None:
"""透传格式CLAUDE_CLI -> CLAUDE也受全局开关限制"""
ok, needs_conv, reason = is_format_compatible(
"CLAUDE_CLI",
"CLAUDE",
"claude:cli",
"claude:chat",
endpoint_format_acceptance_config={"enabled": True},
is_stream=False,
global_conversion_enabled=False,
@@ -229,8 +229,8 @@ def test_claude_cli_to_claude_blocked_when_global_switch_disabled() -> None:
def test_claude_cli_to_claude_blocked_when_endpoint_not_configured() -> None:
"""透传格式CLAUDE_CLI -> CLAUDE也需要端点配置"""
ok, needs_conv, reason = is_format_compatible(
"CLAUDE_CLI",
"CLAUDE",
"claude:cli",
"claude:chat",
endpoint_format_acceptance_config=None,
is_stream=False,
global_conversion_enabled=True,
@@ -246,8 +246,8 @@ def test_openai_cli_to_openai_needs_conversion() -> None:
registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI",
"OPENAI",
"openai:cli",
"openai:chat",
endpoint_format_acceptance_config={"enabled": True}, # 同族转换也需要端点配置
is_stream=False,
global_conversion_enabled=True, # 同族转换也需要全局开关
@@ -264,8 +264,8 @@ def test_openai_to_openai_cli_needs_conversion() -> None:
registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible(
"OPENAI",
"OPENAI_CLI",
"openai:chat",
"openai:cli",
endpoint_format_acceptance_config={"enabled": True}, # 同族转换也需要端点配置
is_stream=False,
global_conversion_enabled=True, # 同族转换也需要全局开关
@@ -282,8 +282,8 @@ def test_openai_cli_to_openai_stream_needs_conversion() -> None:
registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI",
"OPENAI",
"openai:cli",
"openai:chat",
endpoint_format_acceptance_config={"enabled": True}, # 同族转换也需要端点配置
is_stream=True,
global_conversion_enabled=True, # 同族转换也需要全局开关
@@ -300,8 +300,8 @@ def test_openai_cli_to_openai_fails_without_converter() -> None:
registry.can_convert_full.return_value = False
ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI",
"OPENAI",
"openai:cli",
"openai:chat",
endpoint_format_acceptance_config={"enabled": True},
is_stream=False,
global_conversion_enabled=True,
@@ -318,8 +318,8 @@ def test_openai_cli_to_openai_blocked_when_global_switch_disabled() -> None:
registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI",
"OPENAI",
"openai:cli",
"openai:chat",
endpoint_format_acceptance_config={"enabled": True},
is_stream=False,
global_conversion_enabled=False, # 全局开关关闭
@@ -336,8 +336,8 @@ def test_openai_cli_to_openai_blocked_when_endpoint_disabled() -> None:
registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI",
"OPENAI",
"openai:cli",
"openai:chat",
endpoint_format_acceptance_config={"enabled": False}, # 端点开关关闭
is_stream=False,
global_conversion_enabled=True,
@@ -354,8 +354,8 @@ def test_openai_cli_to_openai_blocked_when_endpoint_not_configured() -> None:
registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI",
"OPENAI",
"openai:cli",
"openai:chat",
endpoint_format_acceptance_config=None, # 无端点配置
is_stream=False,
global_conversion_enabled=True,

View File

@@ -34,7 +34,7 @@ def test_error_conversion_openai_to_claude() -> None:
"error": {"message": "bad request", "type": "invalid_request_error", "code": "bad_request"}
}
out = reg.convert_error_response(openai_error, "OPENAI", "CLAUDE")
out = reg.convert_error_response(openai_error, "openai:chat", "claude:chat")
assert out.get("type") == "error"
assert isinstance(out.get("error"), dict)
assert out["error"]["message"] == "bad request"
@@ -44,7 +44,7 @@ def test_error_conversion_claude_to_openai() -> None:
reg = _make_registry()
claude_error = {"type": "error", "error": {"type": "invalid_request_error", "message": "nope"}}
out = reg.convert_error_response(claude_error, "CLAUDE", "OPENAI")
out = reg.convert_error_response(claude_error, "claude:chat", "openai:chat")
assert isinstance(out.get("error"), dict)
assert out["error"]["message"] == "nope"
@@ -63,7 +63,7 @@ def test_error_event_stream_openai_to_claude_via_registry() -> None:
# OpenAI 流式错误块
chunk = {"error": {"message": "bad", "type": "invalid_request_error"}}
out = reg.convert_stream_chunk(chunk, "OPENAI", "CLAUDE", state=StreamState())
out = reg.convert_stream_chunk(chunk, "openai:chat", "claude:chat", state=StreamState())
assert isinstance(out, list) and out
evt0 = cast(dict[str, Any], out[0])
assert evt0.get("type") == "error"

View File

@@ -103,14 +103,17 @@ def test_gemini_request_parts_image_tool_and_unknown_drop() -> None:
},
{
"role": "model",
"parts": [
{"function_call": {"name": "get_weather", "args": {"city": "SF"}}}
],
"parts": [{"function_call": {"name": "get_weather", "args": {"city": "SF"}}}],
},
{
"role": "user",
"parts": [
{"function_response": {"name": "call_1", "response": {"result": {"temp_c": 20}}}}
{
"function_response": {
"name": "call_1",
"response": {"result": {"temp_c": 20}},
}
}
],
},
]
@@ -194,16 +197,10 @@ def test_gemini_stream_chunk_and_event_roundtrip_basic() -> None:
chunks = [
{
"candidates": [
{"content": {"parts": [{"text": "Hel"}], "role": "model"}, "index": 0}
],
"candidates": [{"content": {"parts": [{"text": "Hel"}], "role": "model"}, "index": 0}],
"modelVersion": "gemini-1.5",
},
{
"candidates": [
{"content": {"parts": [{"text": "lo"}], "role": "model"}, "index": 0}
]
},
{"candidates": [{"content": {"parts": [{"text": "lo"}], "role": "model"}, "index": 0}]},
{
"candidates": [
{
@@ -226,7 +223,11 @@ def test_gemini_stream_chunk_and_event_roundtrip_basic() -> None:
"index": 0,
}
],
"usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 2, "totalTokenCount": 3},
"usageMetadata": {
"promptTokenCount": 1,
"candidatesTokenCount": 2,
"totalTokenCount": 3,
},
"modelVersion": "gemini-1.5",
},
]
@@ -237,8 +238,13 @@ def test_gemini_stream_chunk_and_event_roundtrip_basic() -> None:
assert any(isinstance(e, MessageStartEvent) for e in events)
assert [e.text_delta for e in events if isinstance(e, ContentDeltaEvent)] == ["Hel", "lo"]
assert any(isinstance(e, ToolCallDeltaEvent) and json.loads(e.input_delta) == {"city": "SF"} for e in events)
assert any(isinstance(e, MessageStopEvent) and e.stop_reason == StopReason.END_TURN for e in events)
assert any(
isinstance(e, ToolCallDeltaEvent) and json.loads(e.input_delta) == {"city": "SF"}
for e in events
)
assert any(
isinstance(e, MessageStopEvent) and e.stop_reason == StopReason.END_TURN for e in events
)
state2 = StreamState()
out_chunks: list[dict[str, Any]] = []
@@ -253,7 +259,9 @@ def test_gemini_stream_chunk_and_event_roundtrip_basic() -> None:
if c["candidates"][0]["content"]["parts"]
and "functionCall" in c["candidates"][0]["content"]["parts"][0]
)
assert tool_chunk["candidates"][0]["content"]["parts"][0]["functionCall"]["name"] == "get_weather"
assert (
tool_chunk["candidates"][0]["content"]["parts"][0]["functionCall"]["name"] == "get_weather"
)
assert out_chunks[-1]["candidates"][0]["finishReason"] == "STOP"

View File

@@ -18,7 +18,6 @@ from src.core.api_format.conversion.normalizers.openai import OpenAINormalizer
from src.core.api_format.conversion.registry import FormatConversionRegistry
from src.core.api_format.conversion.stream_state import StreamState
GOLDEN_DIR = Path(__file__).resolve().parent / "golden_data"
INPUT_DIR = GOLDEN_DIR / "inputs"
EXPECTED_DIR = GOLDEN_DIR / "expected"
@@ -53,12 +52,12 @@ def _make_registry() -> FormatConversionRegistry:
def test_golden_requests() -> None:
reg = _make_registry()
formats = ["OPENAI", "CLAUDE", "GEMINI"]
formats = ["openai:chat", "claude:chat", "gemini:chat"]
inputs = {
"OPENAI": _load_json(INPUT_DIR / "request_openai.json"),
"CLAUDE": _load_json(INPUT_DIR / "request_claude.json"),
"GEMINI": _load_json(INPUT_DIR / "request_gemini.json"),
"openai:chat": _load_json(INPUT_DIR / "request_openai.json"),
"claude:chat": _load_json(INPUT_DIR / "request_claude.json"),
"gemini:chat": _load_json(INPUT_DIR / "request_gemini.json"),
}
for source in formats:
@@ -72,12 +71,12 @@ def test_golden_requests() -> None:
def test_golden_responses() -> None:
reg = _make_registry()
formats = ["OPENAI", "CLAUDE", "GEMINI"]
formats = ["openai:chat", "claude:chat", "gemini:chat"]
inputs = {
"OPENAI": _load_json(INPUT_DIR / "response_openai.json"),
"CLAUDE": _load_json(INPUT_DIR / "response_claude.json"),
"GEMINI": _load_json(INPUT_DIR / "response_gemini.json"),
"openai:chat": _load_json(INPUT_DIR / "response_openai.json"),
"claude:chat": _load_json(INPUT_DIR / "response_claude.json"),
"gemini:chat": _load_json(INPUT_DIR / "response_gemini.json"),
}
for source in formats:
@@ -91,12 +90,12 @@ def test_golden_responses() -> None:
def test_golden_streams() -> None:
reg = _make_registry()
formats = ["OPENAI", "CLAUDE", "GEMINI"]
formats = ["openai:chat", "claude:chat", "gemini:chat"]
inputs: dict[str, list[dict[str, Any]]] = {
"OPENAI": _load_json(INPUT_DIR / "stream_openai.json"),
"CLAUDE": _load_json(INPUT_DIR / "stream_claude.json"),
"GEMINI": _load_json(INPUT_DIR / "stream_gemini.json"),
"openai:chat": _load_json(INPUT_DIR / "stream_openai.json"),
"claude:chat": _load_json(INPUT_DIR / "stream_claude.json"),
"gemini:chat": _load_json(INPUT_DIR / "stream_gemini.json"),
}
for source in formats:
@@ -106,7 +105,7 @@ def test_golden_streams() -> None:
expected = _load_json(EXPECTED_DIR / f"stream_{source}_to_{target}.json")
state = StreamState()
if source == "GEMINI":
if source == "gemini:chat":
state.message_id = "gemini_1"
out: list[dict[str, Any]] = []

View File

@@ -39,7 +39,6 @@ from src.core.api_format.conversion.internal import (
)
from src.core.api_format.conversion.stream_state import StreamState
# ============================================================================
# Enum 类型测试
# ============================================================================
@@ -302,7 +301,9 @@ class TestInternalMessage:
role=Role.ASSISTANT,
content=[
TextBlock(text="Let me check the weather"),
ToolUseBlock(tool_id="t1", tool_name="get_weather", tool_input={"city": "Shanghai"}),
ToolUseBlock(
tool_id="t1", tool_name="get_weather", tool_input={"city": "Shanghai"}
),
],
)
assert msg.role == Role.ASSISTANT

View File

@@ -297,7 +297,9 @@ def test_openai_stream_chunk_and_event_roundtrip_basic() -> None:
assert any(isinstance(e, ContentBlockStartEvent) for e in events)
assert [e.text_delta for e in events if isinstance(e, ContentDeltaEvent)] == ["Hel", "lo"]
assert any(isinstance(e, ToolCallDeltaEvent) for e in events)
assert any(isinstance(e, MessageStopEvent) and e.stop_reason == StopReason.TOOL_USE for e in events)
assert any(
isinstance(e, MessageStopEvent) and e.stop_reason == StopReason.TOOL_USE for e in events
)
# internal events -> OpenAI chunks验证关键字段与 tool_calls index 稳定)
state2 = StreamState()
@@ -313,14 +315,20 @@ def test_openai_stream_chunk_and_event_roundtrip_basic() -> None:
# tool_calls start chunk
tool_start = next(
c for c in out_chunks if c["choices"][0]["delta"].get("tool_calls") and c["choices"][0]["delta"]["tool_calls"][0]["function"].get("name")
c
for c in out_chunks
if c["choices"][0]["delta"].get("tool_calls")
and c["choices"][0]["delta"]["tool_calls"][0]["function"].get("name")
)
assert tool_start["choices"][0]["delta"]["tool_calls"][0]["id"] == "call_1"
assert tool_start["choices"][0]["delta"]["tool_calls"][0]["index"] == 0
# tool_calls delta chunkarguments 片段)
tool_delta = next(
c for c in out_chunks if c["choices"][0]["delta"].get("tool_calls") and "arguments" in c["choices"][0]["delta"]["tool_calls"][0]["function"]
c
for c in out_chunks
if c["choices"][0]["delta"].get("tool_calls")
and "arguments" in c["choices"][0]["delta"]["tool_calls"][0]["function"]
)
assert tool_delta["choices"][0]["delta"]["tool_calls"][0]["id"] == "call_1"
assert tool_delta["choices"][0]["delta"]["tool_calls"][0]["index"] == 0

View File

@@ -37,9 +37,9 @@ def _first_openai_choice_message(resp: dict[str, Any]) -> dict[str, Any]:
def test_registry_canonical_can_convert_full_stream() -> None:
reg = _make_registry()
assert reg.can_convert_full("OPENAI", "CLAUDE", require_stream=True) is True
assert reg.can_convert_full("OPENAI", "GEMINI", require_stream=True) is True
assert reg.can_convert_full("CLAUDE", "GEMINI", require_stream=True) is True
assert reg.can_convert_full("openai:chat", "claude:chat", require_stream=True) is True
assert reg.can_convert_full("openai:chat", "gemini:chat", require_stream=True) is True
assert reg.can_convert_full("claude:chat", "gemini:chat", require_stream=True) is True
def test_registry_canonical_request_openai_to_claude() -> None:
@@ -57,7 +57,7 @@ def test_registry_canonical_request_openai_to_claude() -> None:
"stream": True,
}
claude_req = reg.convert_request(openai_req, "OPENAI", "CLAUDE")
claude_req = reg.convert_request(openai_req, "openai:chat", "claude:chat")
assert claude_req["model"] == "gpt-4o-mini"
assert claude_req["system"] == "sys\n\ndev"
assert claude_req["stream"] is True
@@ -79,7 +79,7 @@ def test_registry_canonical_response_claude_to_openai() -> None:
"usage": {"input_tokens": 5, "output_tokens": 7},
}
openai_resp = reg.convert_response(claude_resp, "CLAUDE", "OPENAI")
openai_resp = reg.convert_response(claude_resp, "claude:chat", "openai:chat")
assert openai_resp["object"] == "chat.completion"
msg = _first_openai_choice_message(openai_resp)
assert msg["role"] == "assistant"
@@ -94,11 +94,13 @@ def test_registry_canonical_stream_openai_to_claude() -> None:
"object": "chat.completion.chunk",
"created": 1,
"model": "gpt-4o-mini",
"choices": [{"index": 0, "delta": {"role": "assistant", "content": "hi"}, "finish_reason": None}],
"choices": [
{"index": 0, "delta": {"role": "assistant", "content": "hi"}, "finish_reason": None}
],
}
state = StreamState()
out_events = reg.convert_stream_chunk(chunk, "OPENAI", "CLAUDE", state=state)
out_events = reg.convert_stream_chunk(chunk, "openai:chat", "claude:chat", state=state)
assert isinstance(out_events, list) and out_events
types = [cast(dict[str, Any], e).get("type") for e in cast(list[dict[str, Any]], out_events)]