mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
refactor: 重构格式转换系统为 Hub-and-Spoke Normalizer 架构
- 移除旧的 converters 模块,改用基于 Internal 中间表示的 Normalizer 模式 - 新增 internal.py 定义统一的内部数据结构(InternalRequest/Response/Error) - 新增 normalizer.py 定义 FormatNormalizer 基类接口 - 实现 OpenAI/Claude/Gemini 及其 CLI 格式的 Normalizer - 新增 stream_events.py 和 stream_state.py 支持流式转换 - 重构 registry.py 使用 Normalizer 进行格式转换 - 更新 handlers 适配新的转换架构 - 前端移除 CLI 格式转换按钮的限制 - 添加 golden data 测试确保转换正确性
This commit is contained in:
@@ -15,7 +15,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.core.api_format import StreamConversionState
|
||||
from src.core.api_format.conversion.stream_state import StreamState
|
||||
|
||||
|
||||
# Mock CliMessageHandlerBase 用于测试
|
||||
@@ -29,7 +29,12 @@ class MockCliHandler:
|
||||
events: list,
|
||||
) -> List[str]:
|
||||
"""复制自 CliMessageHandlerBase._convert_sse_line"""
|
||||
from src.core.api_format import converter_registry
|
||||
from src.core.api_format.conversion import (
|
||||
format_conversion_registry,
|
||||
register_default_normalizers,
|
||||
)
|
||||
|
||||
register_default_normalizers()
|
||||
|
||||
# 如果是空行或特殊控制行,直接返回
|
||||
if not line or line.strip() == "" or line == "data: [DONE]":
|
||||
@@ -50,7 +55,7 @@ class MockCliHandler:
|
||||
|
||||
# 初始化流式转换状态
|
||||
if ctx.stream_conversion_state is None:
|
||||
ctx.stream_conversion_state = StreamConversionState(
|
||||
ctx.stream_conversion_state = StreamState(
|
||||
model=ctx.mapped_model or ctx.model,
|
||||
message_id=ctx.response_id or ctx.request_id,
|
||||
)
|
||||
@@ -59,19 +64,13 @@ class MockCliHandler:
|
||||
client_format = ctx.client_api_format or ""
|
||||
|
||||
try:
|
||||
converted_events = converter_registry.convert_stream_chunk_strict(
|
||||
converted_events = format_conversion_registry.convert_stream_chunk(
|
||||
data_obj,
|
||||
provider_format,
|
||||
client_format,
|
||||
state=ctx.stream_conversion_state,
|
||||
)
|
||||
|
||||
if converted_events and not ctx.stream_conversion_state.message_started:
|
||||
for evt in converted_events:
|
||||
if evt.get("type") == "message_start" or evt.get("choices"):
|
||||
ctx.stream_conversion_state.message_started = True
|
||||
break
|
||||
|
||||
result = []
|
||||
for evt in converted_events:
|
||||
result.append(f"data: {json.dumps(evt, ensure_ascii=False)}")
|
||||
@@ -168,25 +167,13 @@ class TestConvertSseLineOneInManyOut:
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_converters(self):
|
||||
"""注册测试用转换器"""
|
||||
from src.core.api_format import (
|
||||
ClaudeToOpenAIConverter,
|
||||
OpenAIToClaudeConverter,
|
||||
converter_registry,
|
||||
)
|
||||
"""确保 Canonical normalizers 已注册"""
|
||||
from src.core.api_format.conversion import register_default_normalizers
|
||||
|
||||
# 保存原始状态
|
||||
original_converters = converter_registry._converters.copy()
|
||||
|
||||
# 注册转换器
|
||||
converter_registry.register("OPENAI", "CLAUDE", OpenAIToClaudeConverter())
|
||||
converter_registry.register("CLAUDE", "OPENAI", ClaudeToOpenAIConverter())
|
||||
register_default_normalizers()
|
||||
|
||||
yield
|
||||
|
||||
# 恢复原始状态
|
||||
converter_registry._converters = original_converters
|
||||
|
||||
def test_openai_to_claude_conversion(self) -> None:
|
||||
"""测试 OpenAI -> Claude 流式转换"""
|
||||
handler = MockCliHandler()
|
||||
@@ -211,9 +198,6 @@ class TestConvertSseLineOneInManyOut:
|
||||
first_event = json.loads(result1[0][6:])
|
||||
assert first_event.get("type") == "message_start"
|
||||
|
||||
# 状态应更新
|
||||
assert ctx.stream_conversion_state.message_started is True
|
||||
|
||||
def test_claude_to_openai_conversion(self) -> None:
|
||||
"""测试 Claude -> OpenAI 流式转换"""
|
||||
handler = MockCliHandler()
|
||||
@@ -247,7 +231,6 @@ class TestConvertSseLineOneInManyOut:
|
||||
handler._convert_sse_line(ctx, f"data: {json.dumps(chunk1)}", [])
|
||||
|
||||
state_after_first = ctx.stream_conversion_state
|
||||
message_started_after_first = state_after_first.message_started
|
||||
|
||||
# 第二个 chunk
|
||||
chunk2 = {"choices": [{"delta": {"content": "hello"}}]}
|
||||
@@ -255,8 +238,6 @@ class TestConvertSseLineOneInManyOut:
|
||||
|
||||
# 状态应该是同一个对象
|
||||
assert ctx.stream_conversion_state is state_after_first
|
||||
# message_started 应该保持 True
|
||||
assert ctx.stream_conversion_state.message_started is True
|
||||
|
||||
|
||||
class TestStreamContextIntegration:
|
||||
@@ -265,9 +246,7 @@ class TestStreamContextIntegration:
|
||||
def test_stream_conversion_state_reset_on_retry(self) -> None:
|
||||
"""测试重试时重置流式转换状态"""
|
||||
ctx = StreamContext(model="test", api_format="OPENAI")
|
||||
ctx.stream_conversion_state = StreamConversionState(
|
||||
model="test", message_id="123", message_started=True
|
||||
)
|
||||
ctx.stream_conversion_state = StreamState(model="test", message_id="123")
|
||||
|
||||
ctx.reset_for_retry()
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
from src.api.handlers.base.response_parser import ParsedResponse, ResponseParser, StreamStats
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.api.handlers.base.stream_processor import StreamProcessor
|
||||
from src.core.api_format import register_all_converters
|
||||
from src.core.api_format.conversion import register_default_normalizers
|
||||
|
||||
|
||||
class DummyParser(ResponseParser):
|
||||
@@ -31,7 +31,7 @@ async def _empty_async_iter():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_response_stream_converts_claude_to_openai() -> None:
|
||||
register_all_converters()
|
||||
register_default_normalizers()
|
||||
|
||||
ctx = StreamContext(model="test-model", api_format="OPENAI")
|
||||
ctx.client_api_format = "OPENAI"
|
||||
|
||||
@@ -6,7 +6,7 @@ import pytest
|
||||
from src.api.handlers.base.parsers import get_parser_for_format
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.api.handlers.base.stream_processor import StreamProcessor
|
||||
from src.core.api_format import GeminiToOpenAIConverter, converter_registry
|
||||
from src.core.api_format.conversion import register_default_normalizers
|
||||
|
||||
|
||||
class _DummyResponseCtx:
|
||||
@@ -26,77 +26,71 @@ async def _iter_bytes(chunks: list[bytes]) -> AsyncIterator[bytes]:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_processor_converts_gemini_json_lines_without_data_prefix() -> None:
|
||||
# Register only what we need, and restore after test.
|
||||
original_converters = converter_registry._converters.copy()
|
||||
try:
|
||||
converter_registry.register("GEMINI", "OPENAI", GeminiToOpenAIConverter())
|
||||
register_default_normalizers()
|
||||
|
||||
ctx = StreamContext(model="gemini-test", api_format="OPENAI")
|
||||
ctx.provider_api_format = "GEMINI"
|
||||
ctx.client_api_format = "OPENAI"
|
||||
ctx.needs_conversion = True
|
||||
ctx.request_id = "req_test"
|
||||
ctx.mapped_model = "gemini-test"
|
||||
ctx = StreamContext(model="gemini-test", api_format="OPENAI")
|
||||
ctx.provider_api_format = "GEMINI"
|
||||
ctx.client_api_format = "OPENAI"
|
||||
ctx.needs_conversion = True
|
||||
ctx.request_id = "req_test"
|
||||
ctx.mapped_model = "gemini-test"
|
||||
|
||||
# Simulate Gemini JSON-array/chunks stream: wrapper lines + two JSON objects.
|
||||
chunk1 = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "Hello"}], "role": "model"},
|
||||
}
|
||||
]
|
||||
}
|
||||
chunk2 = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "Hello world"}], "role": "model"},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
],
|
||||
"usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 2, "totalTokenCount": 3},
|
||||
}
|
||||
|
||||
upstream_lines = [
|
||||
b"[\n",
|
||||
(json.dumps(chunk1) + ",\n").encode("utf-8"),
|
||||
(json.dumps(chunk2) + "\n").encode("utf-8"),
|
||||
b"]\n",
|
||||
# Simulate Gemini JSON-array/chunks stream: wrapper lines + two JSON objects.
|
||||
chunk1 = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "Hello"}], "role": "model"},
|
||||
}
|
||||
]
|
||||
}
|
||||
chunk2 = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "Hello world"}], "role": "model"},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
],
|
||||
"usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 2, "totalTokenCount": 3},
|
||||
}
|
||||
|
||||
processor = StreamProcessor(
|
||||
request_id="req_test",
|
||||
default_parser=get_parser_for_format("OPENAI"),
|
||||
)
|
||||
upstream_lines = [
|
||||
b"[\n",
|
||||
(json.dumps(chunk1) + ",\n").encode("utf-8"),
|
||||
(json.dumps(chunk2) + "\n").encode("utf-8"),
|
||||
b"]\n",
|
||||
]
|
||||
|
||||
out = b""
|
||||
async for b in processor.create_response_stream(
|
||||
ctx=ctx,
|
||||
byte_iterator=_iter_bytes(upstream_lines),
|
||||
response_ctx=_DummyResponseCtx(),
|
||||
http_client=_DummyHTTPClient(), # type: ignore[arg-type]
|
||||
prefetched_chunks=[],
|
||||
start_time=None,
|
||||
):
|
||||
out += b
|
||||
processor = StreamProcessor(
|
||||
request_id="req_test",
|
||||
default_parser=get_parser_for_format("OPENAI"),
|
||||
)
|
||||
|
||||
text = out.decode("utf-8", errors="replace")
|
||||
data_lines = [ln for ln in text.splitlines() if ln.startswith("data: ")]
|
||||
out = b""
|
||||
async for b in processor.create_response_stream(
|
||||
ctx=ctx,
|
||||
byte_iterator=_iter_bytes(upstream_lines),
|
||||
response_ctx=_DummyResponseCtx(),
|
||||
http_client=_DummyHTTPClient(), # type: ignore[arg-type]
|
||||
prefetched_chunks=[],
|
||||
start_time=None,
|
||||
):
|
||||
out += b
|
||||
|
||||
# OpenAI termination marker should be present (StreamProcessor will append if upstream doesn't send it).
|
||||
assert "data: [DONE]" in data_lines
|
||||
text = out.decode("utf-8", errors="replace")
|
||||
data_lines = [ln for ln in text.splitlines() if ln.startswith("data: ")]
|
||||
|
||||
# Parse JSON events (excluding [DONE]) and validate we have expected deltas.
|
||||
events = [json.loads(ln[6:]) for ln in data_lines if ln != "data: [DONE]"]
|
||||
# OpenAI termination marker should be present (StreamProcessor will append if upstream doesn't send it).
|
||||
assert "data: [DONE]" in data_lines
|
||||
|
||||
delta_contents: list[str] = []
|
||||
for evt in events:
|
||||
for choice in evt.get("choices", []) or []:
|
||||
delta = choice.get("delta") or {}
|
||||
if "content" in delta and delta["content"]:
|
||||
delta_contents.append(delta["content"])
|
||||
# Parse JSON events (excluding [DONE]) and validate we have expected deltas.
|
||||
events = [json.loads(ln[6:]) for ln in data_lines if ln != "data: [DONE]"]
|
||||
|
||||
assert "Hello" in "".join(delta_contents)
|
||||
assert " world" in "".join(delta_contents)
|
||||
finally:
|
||||
converter_registry._converters = original_converters
|
||||
delta_contents: list[str] = []
|
||||
for evt in events:
|
||||
for choice in evt.get("choices", []) or []:
|
||||
delta = choice.get("delta") or {}
|
||||
if "content" in delta and delta["content"]:
|
||||
delta_contents.append(delta["content"])
|
||||
|
||||
assert "Hello" in "".join(delta_contents)
|
||||
assert " world" in "".join(delta_contents)
|
||||
|
||||
1
tests/core/api_format/conversion/golden_data/.gitkeep
Normal file
1
tests/core/api_format/conversion/golden_data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"contents": [
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"text": "hi"
|
||||
}
|
||||
],
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"generation_config": {
|
||||
"max_output_tokens": 12,
|
||||
"stop_sequences": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"temperature": 0.2,
|
||||
"top_p": 1.0
|
||||
},
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"system_instruction": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "sys\n\ndev"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tool_config": {
|
||||
"function_calling_config": {
|
||||
"mode": "AUTO"
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"function_declarations": [
|
||||
{
|
||||
"description": "Get weather",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"max_tokens": 12,
|
||||
"messages": [
|
||||
{
|
||||
"content": "sys\n\ndev",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "hi",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"stop": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"stream": true,
|
||||
"temperature": 0.2,
|
||||
"tool_choice": "auto",
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Get weather",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
],
|
||||
"top_p": 1.0
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"max_tokens": 12,
|
||||
"messages": [
|
||||
{
|
||||
"content": "hi",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "gemini-1.5-flash",
|
||||
"stop_sequences": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"system": "sys\n\ndev",
|
||||
"temperature": 0.2,
|
||||
"tool_choice": {
|
||||
"type": "auto"
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"description": "Get weather",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"name": "get_weather"
|
||||
}
|
||||
],
|
||||
"top_p": 1.0
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"max_tokens": 12,
|
||||
"messages": [
|
||||
{
|
||||
"content": "sys\n\ndev",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "hi",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "gemini-1.5-flash",
|
||||
"stop": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"temperature": 0.2,
|
||||
"tool_choice": "auto",
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Get weather",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
],
|
||||
"top_p": 1.0
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"max_tokens": 12,
|
||||
"messages": [
|
||||
{
|
||||
"content": "hi",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"stop_sequences": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"stream": true,
|
||||
"system": "sys\n\ndev",
|
||||
"temperature": 0.2,
|
||||
"tool_choice": {
|
||||
"type": "auto"
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"description": "Get weather",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"name": "get_weather"
|
||||
}
|
||||
],
|
||||
"top_p": 1.0
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"contents": [
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"text": "hi"
|
||||
}
|
||||
],
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"generation_config": {
|
||||
"max_output_tokens": 12,
|
||||
"stop_sequences": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"temperature": 0.2,
|
||||
"top_p": 1.0
|
||||
},
|
||||
"model": "gpt-4o-mini",
|
||||
"system_instruction": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "sys\n\ndev"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tool_config": {
|
||||
"function_calling_config": {
|
||||
"mode": "AUTO"
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"function_declarations": [
|
||||
{
|
||||
"description": "Get weather",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "hello"
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"id": "msg_1",
|
||||
"modelVersion": "claude-3-5-sonnet-latest",
|
||||
"usageMetadata": {
|
||||
"candidatesTokenCount": 7,
|
||||
"promptTokenCount": 5,
|
||||
"totalTokenCount": 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"content": "hello",
|
||||
"role": "assistant"
|
||||
}
|
||||
}
|
||||
],
|
||||
"id": "msg_1",
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"object": "chat.completion",
|
||||
"usage": {
|
||||
"completion_tokens": 7,
|
||||
"prompt_tokens": 5,
|
||||
"total_tokens": 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "hello",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"id": "msg_unknown",
|
||||
"model": "gemini-1.5-flash",
|
||||
"role": "assistant",
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"type": "message",
|
||||
"usage": {
|
||||
"input_tokens": 5,
|
||||
"output_tokens": 7
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"content": "hello",
|
||||
"role": "assistant"
|
||||
}
|
||||
}
|
||||
],
|
||||
"id": "chatcmpl-unknown",
|
||||
"model": "gemini-1.5-flash",
|
||||
"object": "chat.completion",
|
||||
"usage": {
|
||||
"completion_tokens": 7,
|
||||
"prompt_tokens": 5,
|
||||
"total_tokens": 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "hello",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"id": "msg_chatcmpl_1",
|
||||
"model": "gpt-4o-mini",
|
||||
"role": "assistant",
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"type": "message",
|
||||
"usage": {
|
||||
"input_tokens": 5,
|
||||
"output_tokens": 7
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "hello"
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"id": "chatcmpl_1",
|
||||
"modelVersion": "gpt-4o-mini",
|
||||
"usageMetadata": {
|
||||
"candidatesTokenCount": 7,
|
||||
"promptTokenCount": 5,
|
||||
"totalTokenCount": 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
[
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "hello"
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"modelVersion": "claude-3-5-sonnet-latest"
|
||||
},
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"modelVersion": "claude-3-5-sonnet-latest",
|
||||
"usageMetadata": {
|
||||
"candidatesTokenCount": 7,
|
||||
"promptTokenCount": 5,
|
||||
"totalTokenCount": 12
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": null,
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"id": "msg_1",
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"object": "chat.completion.chunk"
|
||||
},
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": "hello"
|
||||
},
|
||||
"finish_reason": null,
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"id": "msg_1",
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"object": "chat.completion.chunk"
|
||||
},
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"id": "msg_1",
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"object": "chat.completion.chunk"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
[
|
||||
{
|
||||
"message": {
|
||||
"content": [],
|
||||
"id": "gemini_1",
|
||||
"model": "gemini-1.5-flash",
|
||||
"role": "assistant",
|
||||
"stop_reason": null,
|
||||
"stop_sequence": null,
|
||||
"type": "message",
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
}
|
||||
},
|
||||
"type": "message_start"
|
||||
},
|
||||
{
|
||||
"content_block": {
|
||||
"text": "",
|
||||
"type": "text"
|
||||
},
|
||||
"index": 0,
|
||||
"type": "content_block_start"
|
||||
},
|
||||
{
|
||||
"delta": {
|
||||
"text": "he",
|
||||
"type": "text_delta"
|
||||
},
|
||||
"index": 0,
|
||||
"type": "content_block_delta"
|
||||
},
|
||||
{
|
||||
"delta": {
|
||||
"text": "llo",
|
||||
"type": "text_delta"
|
||||
},
|
||||
"index": 0,
|
||||
"type": "content_block_delta"
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"type": "content_block_stop"
|
||||
},
|
||||
{
|
||||
"delta": {
|
||||
"stop_reason": "end_turn"
|
||||
},
|
||||
"type": "message_delta",
|
||||
"usage": {
|
||||
"input_tokens": 5,
|
||||
"output_tokens": 7
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "message_stop"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
[
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": null,
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"id": "gemini_1",
|
||||
"model": "gemini-1.5-flash",
|
||||
"object": "chat.completion.chunk"
|
||||
},
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": "he"
|
||||
},
|
||||
"finish_reason": null,
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"id": "gemini_1",
|
||||
"model": "gemini-1.5-flash",
|
||||
"object": "chat.completion.chunk"
|
||||
},
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": "llo"
|
||||
},
|
||||
"finish_reason": null,
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"id": "gemini_1",
|
||||
"model": "gemini-1.5-flash",
|
||||
"object": "chat.completion.chunk"
|
||||
},
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"id": "gemini_1",
|
||||
"model": "gemini-1.5-flash",
|
||||
"object": "chat.completion.chunk"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
[
|
||||
{
|
||||
"message": {
|
||||
"content": [],
|
||||
"id": "chatcmpl_1",
|
||||
"model": "gpt-4o-mini",
|
||||
"role": "assistant",
|
||||
"stop_reason": null,
|
||||
"stop_sequence": null,
|
||||
"type": "message",
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
}
|
||||
},
|
||||
"type": "message_start"
|
||||
},
|
||||
{
|
||||
"content_block": {
|
||||
"text": "",
|
||||
"type": "text"
|
||||
},
|
||||
"index": 0,
|
||||
"type": "content_block_start"
|
||||
},
|
||||
{
|
||||
"delta": {
|
||||
"text": "He",
|
||||
"type": "text_delta"
|
||||
},
|
||||
"index": 0,
|
||||
"type": "content_block_delta"
|
||||
},
|
||||
{
|
||||
"delta": {
|
||||
"text": "llo",
|
||||
"type": "text_delta"
|
||||
},
|
||||
"index": 0,
|
||||
"type": "content_block_delta"
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"type": "content_block_stop"
|
||||
},
|
||||
{
|
||||
"delta": {
|
||||
"stop_reason": "end_turn"
|
||||
},
|
||||
"type": "message_delta"
|
||||
},
|
||||
{
|
||||
"type": "message_stop"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "He"
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"modelVersion": "gpt-4o-mini"
|
||||
},
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "llo"
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"modelVersion": "gpt-4o-mini"
|
||||
},
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"modelVersion": "gpt-4o-mini"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"max_tokens": 12,
|
||||
"messages": [
|
||||
{
|
||||
"content": "hi",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"stop_sequences": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"stream": true,
|
||||
"system": "sys\n\ndev",
|
||||
"temperature": 0.2,
|
||||
"tool_choice": {
|
||||
"type": "auto"
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"description": "Get weather",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"name": "get_weather"
|
||||
}
|
||||
],
|
||||
"top_p": 1.0
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"contents": [
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"text": "hi"
|
||||
}
|
||||
],
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": 12,
|
||||
"stopSequences": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"temperature": 0.2,
|
||||
"topP": 1.0
|
||||
},
|
||||
"model": "gemini-1.5-flash",
|
||||
"systemInstruction": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "sys\n\ndev"
|
||||
}
|
||||
]
|
||||
},
|
||||
"toolConfig": {
|
||||
"functionCallingConfig": {
|
||||
"mode": "AUTO"
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"functionDeclarations": [
|
||||
{
|
||||
"description": "Get weather",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"max_tokens": 12,
|
||||
"messages": [
|
||||
{
|
||||
"content": "sys",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "dev",
|
||||
"role": "developer"
|
||||
},
|
||||
{
|
||||
"content": "hi",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"stop": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"stream": true,
|
||||
"temperature": 0.2,
|
||||
"tool_choice": "auto",
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Get weather",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
],
|
||||
"top_p": 1.0
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "hello",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"id": "msg_1",
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"role": "assistant",
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"type": "message",
|
||||
"usage": {
|
||||
"input_tokens": 5,
|
||||
"output_tokens": 7
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "hello"
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"modelVersion": "gemini-1.5-flash",
|
||||
"usageMetadata": {
|
||||
"cachedContentTokenCount": 0,
|
||||
"candidatesTokenCount": 7,
|
||||
"promptTokenCount": 5,
|
||||
"totalTokenCount": 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"content": "hello",
|
||||
"role": "assistant"
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1,
|
||||
"id": "chatcmpl_1",
|
||||
"model": "gpt-4o-mini",
|
||||
"object": "chat.completion",
|
||||
"usage": {
|
||||
"completion_tokens": 7,
|
||||
"prompt_tokens": 5,
|
||||
"total_tokens": 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"message": {
|
||||
"content": [],
|
||||
"id": "msg_1",
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"role": "assistant",
|
||||
"stop_reason": null,
|
||||
"stop_sequence": null,
|
||||
"type": "message"
|
||||
},
|
||||
"type": "message_start"
|
||||
},
|
||||
{
|
||||
"content_block": {
|
||||
"text": "",
|
||||
"type": "text"
|
||||
},
|
||||
"index": 0,
|
||||
"type": "content_block_start"
|
||||
},
|
||||
{
|
||||
"delta": {
|
||||
"text": "hello",
|
||||
"type": "text_delta"
|
||||
},
|
||||
"index": 0,
|
||||
"type": "content_block_delta"
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"type": "content_block_stop"
|
||||
},
|
||||
{
|
||||
"delta": {
|
||||
"stop_reason": "end_turn"
|
||||
},
|
||||
"type": "message_delta",
|
||||
"usage": {
|
||||
"input_tokens": 5,
|
||||
"output_tokens": 7
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "message_stop"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
[
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "he"
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"modelVersion": "gemini-1.5-flash"
|
||||
},
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "hello"
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"modelVersion": "gemini-1.5-flash",
|
||||
"usageMetadata": {
|
||||
"candidatesTokenCount": 7,
|
||||
"promptTokenCount": 5,
|
||||
"totalTokenCount": 12
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": "He",
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": null,
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"created": 1,
|
||||
"id": "chatcmpl_1",
|
||||
"model": "gpt-4o-mini",
|
||||
"object": "chat.completion.chunk"
|
||||
},
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": "llo"
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"created": 1,
|
||||
"id": "chatcmpl_1",
|
||||
"model": "gpt-4o-mini",
|
||||
"object": "chat.completion.chunk"
|
||||
}
|
||||
]
|
||||
352
tests/core/api_format/conversion/test_claude_normalizer.py
Normal file
352
tests/core/api_format/conversion/test_claude_normalizer.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
ClaudeNormalizer 单元测试
|
||||
|
||||
覆盖重点:
|
||||
- system -> instructions 的提取与还原
|
||||
- tool_use/tool_result 的往返转换
|
||||
- UnknownBlock 内部保留、输出默认丢弃
|
||||
- stop_reason/usage 的映射
|
||||
- streaming event <-> InternalStreamEvent 的基础行为与状态
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, cast
|
||||
|
||||
from src.core.api_format.conversion.internal import (
|
||||
ErrorType,
|
||||
ImageBlock,
|
||||
StopReason,
|
||||
TextBlock,
|
||||
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,
|
||||
ToolCallDeltaEvent,
|
||||
)
|
||||
from src.core.api_format.conversion.stream_state import StreamState
|
||||
|
||||
|
||||
def test_claude_request_system_roundtrip() -> None:
|
||||
n = ClaudeNormalizer()
|
||||
|
||||
req = {
|
||||
"model": "claude-3-opus",
|
||||
"system": "sys",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "ok"}]},
|
||||
],
|
||||
"max_tokens": 10,
|
||||
"stop_sequences": ["A"],
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
internal = n.request_to_internal(req)
|
||||
assert internal.model == "claude-3-opus"
|
||||
assert [seg.role.value for seg in internal.instructions] == ["system"]
|
||||
assert internal.instructions[0].text == "sys"
|
||||
assert internal.system == "sys"
|
||||
assert [m.role.value for m in internal.messages] == ["user", "assistant"]
|
||||
assert internal.max_tokens == 10
|
||||
assert internal.stop_sequences == ["A"]
|
||||
assert internal.stream is True
|
||||
|
||||
out = n.request_from_internal(internal)
|
||||
assert out["model"] == "claude-3-opus"
|
||||
assert out["system"] == "sys"
|
||||
|
||||
out_messages: List[Dict[str, Any]] = out["messages"]
|
||||
assert [m["role"] for m in out_messages] == ["user", "assistant"]
|
||||
assert out_messages[0]["content"] == "hi"
|
||||
assert out_messages[1]["content"] == "ok"
|
||||
|
||||
|
||||
def test_claude_request_tool_blocks_roundtrip() -> None:
|
||||
n = ClaudeNormalizer()
|
||||
|
||||
req = {
|
||||
"model": "claude-3-sonnet",
|
||||
"system": "sys",
|
||||
"messages": [
|
||||
{"role": "user", "content": "weather?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_1",
|
||||
"name": "get_weather",
|
||||
"input": {"city": "SF"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_1",
|
||||
"content": {"temp_c": 20},
|
||||
"is_error": False,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}},
|
||||
}
|
||||
],
|
||||
"tool_choice": {"type": "any"},
|
||||
"max_tokens": 10,
|
||||
}
|
||||
|
||||
internal = n.request_to_internal(req)
|
||||
assert [m.role.value for m in internal.messages] == ["user", "assistant", "user"]
|
||||
|
||||
assistant_msg = internal.messages[1]
|
||||
assert any(isinstance(b, ToolUseBlock) for b in assistant_msg.content)
|
||||
tool_use = next(b for b in assistant_msg.content if isinstance(b, ToolUseBlock))
|
||||
assert tool_use.tool_id == "toolu_1"
|
||||
assert tool_use.tool_name == "get_weather"
|
||||
assert tool_use.tool_input == {"city": "SF"}
|
||||
|
||||
tool_result_msg = internal.messages[2]
|
||||
assert any(isinstance(b, ToolResultBlock) for b in tool_result_msg.content)
|
||||
tool_result = next(b for b in tool_result_msg.content if isinstance(b, ToolResultBlock))
|
||||
assert tool_result.tool_use_id == "toolu_1"
|
||||
assert tool_result.output == {"temp_c": 20}
|
||||
assert tool_result.is_error is False
|
||||
|
||||
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_out = out_messages[1]
|
||||
assert isinstance(assistant_out["content"], list)
|
||||
a_blocks = cast(List[Dict[str, Any]], assistant_out["content"])
|
||||
assert a_blocks[0]["type"] == "tool_use"
|
||||
assert a_blocks[0]["id"] == "toolu_1"
|
||||
assert a_blocks[0]["name"] == "get_weather"
|
||||
|
||||
user_out = out_messages[2]
|
||||
assert isinstance(user_out["content"], list)
|
||||
u_blocks = cast(List[Dict[str, Any]], user_out["content"])
|
||||
assert u_blocks[0]["type"] == "tool_result"
|
||||
assert u_blocks[0]["tool_use_id"] == "toolu_1"
|
||||
assert u_blocks[0]["content"] == {"temp_c": 20}
|
||||
|
||||
|
||||
def test_claude_unknown_block_drop_on_output() -> None:
|
||||
n = ClaudeNormalizer()
|
||||
|
||||
req = {
|
||||
"model": "claude-3-sonnet",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "ok"},
|
||||
{"type": "thinking", "text": "secret"},
|
||||
],
|
||||
}
|
||||
],
|
||||
"max_tokens": 10,
|
||||
}
|
||||
|
||||
internal = n.request_to_internal(req)
|
||||
assert len(internal.messages) == 1
|
||||
blocks = internal.messages[0].content
|
||||
assert any(isinstance(b, TextBlock) for b in blocks)
|
||||
assert any(isinstance(b, UnknownBlock) for b in blocks)
|
||||
u = next(b for b in blocks if isinstance(b, UnknownBlock))
|
||||
assert u.raw_type == "thinking"
|
||||
|
||||
out = n.request_from_internal(internal)
|
||||
# Claude 要求以 user 开头,且会做最小修复:插入空 user
|
||||
out_messages: List[Dict[str, Any]] = out["messages"]
|
||||
assert out_messages[0]["role"] == "user"
|
||||
assert out_messages[1]["role"] == "assistant"
|
||||
assert out_messages[1]["content"] == "ok"
|
||||
|
||||
|
||||
def test_claude_response_stop_reason_and_usage_roundtrip() -> None:
|
||||
n = ClaudeNormalizer()
|
||||
|
||||
resp = {
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-3-sonnet",
|
||||
"content": [{"type": "text", "text": "hi"}],
|
||||
"stop_reason": "tool_use",
|
||||
"usage": {
|
||||
"input_tokens": 5,
|
||||
"output_tokens": 7,
|
||||
"cache_read_input_tokens": 1,
|
||||
"cache_creation_input_tokens": 2,
|
||||
},
|
||||
}
|
||||
|
||||
internal = n.response_to_internal(resp)
|
||||
assert internal.id == "msg_1"
|
||||
assert internal.stop_reason == StopReason.TOOL_USE
|
||||
assert internal.usage is not None
|
||||
assert internal.usage.input_tokens == 5
|
||||
assert internal.usage.output_tokens == 7
|
||||
assert internal.usage.total_tokens == 12
|
||||
assert internal.usage.cache_read_tokens == 1
|
||||
assert internal.usage.cache_write_tokens == 2
|
||||
|
||||
out = n.response_from_internal(internal)
|
||||
assert out["id"] == "msg_1"
|
||||
assert out["stop_reason"] == "tool_use"
|
||||
assert out["usage"]["cache_read_input_tokens"] == 1
|
||||
assert out["usage"]["cache_creation_input_tokens"] == 2
|
||||
|
||||
|
||||
def test_claude_stream_chunk_and_event_roundtrip_basic() -> None:
|
||||
n = ClaudeNormalizer()
|
||||
state = StreamState()
|
||||
|
||||
chunks = [
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-3-sonnet",
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 1, "output_tokens": 0},
|
||||
},
|
||||
},
|
||||
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hel"}},
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "lo"}},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 1,
|
||||
"content_block": {"type": "tool_use", "id": "toolu_1", "name": "get_weather"},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 1,
|
||||
"delta": {"type": "input_json_delta", "partial_json": '{"city":"SF"}'},
|
||||
},
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{"type": "content_block_stop", "index": 1},
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "end_turn"},
|
||||
"usage": {"input_tokens": 1, "output_tokens": 2},
|
||||
},
|
||||
{"type": "message_stop"},
|
||||
]
|
||||
|
||||
events: List[Any] = []
|
||||
for ch in chunks:
|
||||
events.extend(n.stream_chunk_to_internal(ch, state))
|
||||
|
||||
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)
|
||||
|
||||
# internal events -> Claude events
|
||||
state2 = StreamState()
|
||||
out_events: List[Dict[str, Any]] = []
|
||||
for e in events:
|
||||
out_events.extend(n.stream_event_from_internal(e, state2))
|
||||
|
||||
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 out_events[-1]["type"] == "message_stop"
|
||||
|
||||
|
||||
def test_claude_error_conversion() -> None:
|
||||
n = ClaudeNormalizer()
|
||||
err_resp = {
|
||||
"type": "error",
|
||||
"error": {"type": "rate_limit_error", "message": "slow down"},
|
||||
}
|
||||
|
||||
assert n.is_error_response(err_resp) is True
|
||||
internal = n.error_to_internal(err_resp)
|
||||
assert internal.type == ErrorType.RATE_LIMIT
|
||||
assert internal.retryable is True
|
||||
|
||||
out = n.error_from_internal(internal)
|
||||
assert out["type"] == "error"
|
||||
assert out["error"]["type"] == "rate_limit_error"
|
||||
|
||||
|
||||
def test_claude_request_metadata_preserved() -> None:
|
||||
"""测试 Claude 请求中 metadata 字段的保留"""
|
||||
n = ClaudeNormalizer()
|
||||
|
||||
req = {
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
|
||||
"system": [
|
||||
{"type": "text", "text": "System prompt 1"},
|
||||
{"type": "text", "text": "System prompt 2"},
|
||||
],
|
||||
"metadata": {
|
||||
"user_id": "user_abc123_session_xyz456"
|
||||
},
|
||||
"max_tokens": 32000,
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
internal = n.request_to_internal(req)
|
||||
|
||||
# system 数组应该被正确合并
|
||||
assert internal.system == "System prompt 1\n\nSystem prompt 2"
|
||||
|
||||
# metadata 应该在 extra 中
|
||||
assert "claude" in internal.extra
|
||||
assert "metadata" in internal.extra["claude"]
|
||||
assert internal.extra["claude"]["metadata"]["user_id"] == "user_abc123_session_xyz456"
|
||||
|
||||
# 往返转换后 metadata 应该被恢复
|
||||
out = n.request_from_internal(internal)
|
||||
assert "metadata" in out
|
||||
assert out["metadata"]["user_id"] == "user_abc123_session_xyz456"
|
||||
|
||||
|
||||
def test_claude_system_array_format() -> None:
|
||||
"""测试 Claude CLI 风格的 system 数组格式"""
|
||||
n = ClaudeNormalizer()
|
||||
|
||||
req = {
|
||||
"model": "claude-3-opus",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"system": [
|
||||
{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.19"},
|
||||
{"type": "text", "text": "You are Claude Code."},
|
||||
{"type": "text", "text": "Extract file paths."},
|
||||
],
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
|
||||
internal = n.request_to_internal(req)
|
||||
|
||||
# system 数组中的多个 text 应该用 \n\n 连接
|
||||
assert "x-anthropic-billing-header" in internal.system
|
||||
assert "You are Claude Code" in internal.system
|
||||
assert "Extract file paths" in internal.system
|
||||
assert "\n\n" in internal.system
|
||||
577
tests/core/api_format/conversion/test_cli_conversion.py
Normal file
577
tests/core/api_format/conversion/test_cli_conversion.py
Normal file
@@ -0,0 +1,577 @@
|
||||
"""
|
||||
CLI 格式参与转换的单元测试
|
||||
|
||||
覆盖:
|
||||
- OPENAI_CLI(Responses)与其他格式的 request/response/stream 基础互转
|
||||
- CLAUDE_CLI / GEMINI_CLI 的 registry 注册与互转能力
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, cast
|
||||
|
||||
from src.core.api_format.conversion.normalizers.claude import ClaudeNormalizer
|
||||
from src.core.api_format.conversion.normalizers.claude_cli import ClaudeCliNormalizer
|
||||
from src.core.api_format.conversion.normalizers.gemini import GeminiNormalizer
|
||||
from src.core.api_format.conversion.normalizers.gemini_cli import GeminiCliNormalizer
|
||||
from src.core.api_format.conversion.normalizers.openai import OpenAINormalizer
|
||||
from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer
|
||||
from src.core.api_format.conversion.registry import FormatConversionRegistry
|
||||
from src.core.api_format.conversion.stream_state import StreamState
|
||||
|
||||
|
||||
def _make_registry_with_cli() -> FormatConversionRegistry:
|
||||
reg = FormatConversionRegistry()
|
||||
reg.register(OpenAINormalizer())
|
||||
reg.register(OpenAICliNormalizer())
|
||||
reg.register(ClaudeNormalizer())
|
||||
reg.register(ClaudeCliNormalizer())
|
||||
reg.register(GeminiNormalizer())
|
||||
reg.register(GeminiCliNormalizer())
|
||||
return reg
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_openai_cli_request_to_claude() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
openai_cli_req = {
|
||||
"model": "gpt-4o-mini",
|
||||
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
|
||||
"stream": True,
|
||||
"max_output_tokens": 12,
|
||||
}
|
||||
|
||||
claude_req = reg.convert_request(openai_cli_req, "OPENAI_CLI", "CLAUDE")
|
||||
assert claude_req["model"] == "gpt-4o-mini"
|
||||
assert claude_req["stream"] is True
|
||||
assert isinstance(claude_req.get("messages"), list)
|
||||
assert claude_req["messages"][0]["role"] == "user"
|
||||
assert claude_req["messages"][0]["content"] == "hi"
|
||||
|
||||
|
||||
def test_claude_response_to_openai_cli() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
claude_resp = {
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"content": [{"type": "text", "text": "hello"}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 5, "output_tokens": 7},
|
||||
}
|
||||
|
||||
openai_cli_resp = reg.convert_response(claude_resp, "CLAUDE", "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])
|
||||
assert msg["type"] == "message"
|
||||
assert msg["role"] == "assistant"
|
||||
content = cast(List[Dict[str, Any]], msg.get("content") or [])
|
||||
assert content and content[0]["type"] == "output_text"
|
||||
assert content[0]["text"] == "hello"
|
||||
|
||||
|
||||
def test_stream_openai_to_openai_cli_delta() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
state = StreamState()
|
||||
|
||||
chunk = {
|
||||
"id": "chatcmpl_1",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [{"index": 0, "delta": {"role": "assistant", "content": "hi"}, "finish_reason": None}],
|
||||
}
|
||||
|
||||
out_events = reg.convert_stream_chunk(chunk, "OPENAI", "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"
|
||||
assert out_events[1].get("delta") == "hi"
|
||||
|
||||
|
||||
def test_stream_openai_cli_to_openai_delta() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
state = StreamState()
|
||||
|
||||
chunk = {
|
||||
"type": "response.output_text.delta",
|
||||
"delta": "hi",
|
||||
"response": {"id": "resp_1", "model": "gpt-4o-mini"},
|
||||
}
|
||||
|
||||
out_events = reg.convert_stream_chunk(chunk, "OPENAI_CLI", "OPENAI", state=state)
|
||||
assert isinstance(out_events, list) and out_events
|
||||
|
||||
# 第一个 chunk 先补齐 assistant role
|
||||
assert out_events[0].get("object") == "chat.completion.chunk"
|
||||
# 第二个 chunk 才是文本增量
|
||||
choices = out_events[1].get("choices") or []
|
||||
assert isinstance(choices, list) and choices
|
||||
delta = cast(Dict[str, Any], choices[0]).get("delta") or {}
|
||||
assert cast(Dict[str, Any], delta).get("content") == "hi"
|
||||
|
||||
|
||||
def test_openai_cli_function_call_to_claude() -> None:
|
||||
"""测试 OpenAI CLI 的 function_call/function_call_output 转换为 Claude tool_use/tool_result"""
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
openai_cli_req = {
|
||||
"model": "gpt-5",
|
||||
"instructions": "You are helpful.",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "列出当前目录"}],
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": "shell_command",
|
||||
"arguments": '{"command": "ls -la"}',
|
||||
"call_id": "call_abc123",
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_abc123",
|
||||
"output": "file1.txt\nfile2.txt",
|
||||
},
|
||||
],
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
claude_req = reg.convert_request(openai_cli_req, "OPENAI_CLI", "CLAUDE")
|
||||
|
||||
messages = claude_req.get("messages", [])
|
||||
assert len(messages) == 3
|
||||
|
||||
# 第一条:user 消息
|
||||
assert messages[0]["role"] == "user"
|
||||
assert messages[0]["content"] == "列出当前目录"
|
||||
|
||||
# 第二条:assistant + tool_use
|
||||
assert messages[1]["role"] == "assistant"
|
||||
content1 = messages[1]["content"]
|
||||
assert isinstance(content1, list) and len(content1) == 1
|
||||
assert content1[0]["type"] == "tool_use"
|
||||
assert content1[0]["name"] == "shell_command"
|
||||
assert content1[0]["id"] == "call_abc123"
|
||||
assert content1[0]["input"] == {"command": "ls -la"}
|
||||
|
||||
# 第三条:user + tool_result
|
||||
assert messages[2]["role"] == "user"
|
||||
content2 = messages[2]["content"]
|
||||
assert isinstance(content2, list) and len(content2) == 1
|
||||
assert content2[0]["type"] == "tool_result"
|
||||
assert content2[0]["tool_use_id"] == "call_abc123"
|
||||
assert content2[0]["content"] == "file1.txt\nfile2.txt"
|
||||
|
||||
|
||||
def test_openai_cli_reasoning_preserved_in_roundtrip() -> None:
|
||||
"""测试 OpenAI CLI 的 reasoning block 在 roundtrip 中被保留"""
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
openai_cli_req = {
|
||||
"model": "gpt-5",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "思考一下"}],
|
||||
},
|
||||
{
|
||||
"type": "reasoning",
|
||||
"summary": [{"type": "summary_text", "text": "I am thinking about the problem..."}],
|
||||
"content": None,
|
||||
"encrypted_content": "xxx_encrypted",
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "我想好了"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
# 转换到 internal 再转回 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"]
|
||||
assert len(reasoning_items) == 1
|
||||
assert "summary" in reasoning_items[0]
|
||||
|
||||
|
||||
def test_claude_tool_use_to_openai_cli() -> None:
|
||||
"""测试 Claude tool_use/tool_result 转换为 OpenAI CLI function_call/function_call_output"""
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
claude_req = {
|
||||
"model": "claude-3",
|
||||
"messages": [
|
||||
{"role": "user", "content": "查看文件"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool_123",
|
||||
"name": "read_file",
|
||||
"input": {"path": "/tmp/test.txt"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "tool_123",
|
||||
"content": "Hello World",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
openai_cli_req = reg.convert_request(claude_req, "CLAUDE", "OPENAI_CLI")
|
||||
|
||||
input_items = openai_cli_req.get("input", [])
|
||||
assert len(input_items) >= 3
|
||||
|
||||
# 找到 function_call
|
||||
fc_items = [i for i in input_items if isinstance(i, dict) and i.get("type") == "function_call"]
|
||||
assert len(fc_items) == 1
|
||||
assert fc_items[0]["name"] == "read_file"
|
||||
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"]
|
||||
assert len(fco_items) == 1
|
||||
assert fco_items[0]["call_id"] == "tool_123"
|
||||
assert fco_items[0]["output"] == "Hello World"
|
||||
|
||||
|
||||
def test_stream_openai_cli_in_progress_event() -> None:
|
||||
"""测试 OpenAI CLI 流式 response.in_progress 事件"""
|
||||
reg = _make_registry_with_cli()
|
||||
state = StreamState()
|
||||
|
||||
# response.created 事件
|
||||
created_chunk = {
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_123",
|
||||
"object": "response",
|
||||
"model": "gpt-5",
|
||||
"status": "in_progress",
|
||||
},
|
||||
}
|
||||
|
||||
events1 = reg.convert_stream_chunk(created_chunk, "OPENAI_CLI", "CLAUDE", state=state)
|
||||
assert isinstance(events1, list) and events1
|
||||
assert events1[0].get("type") == "message_start"
|
||||
|
||||
# response.in_progress 事件(不应产生内容事件)
|
||||
in_progress_chunk = {
|
||||
"type": "response.in_progress",
|
||||
"response": {
|
||||
"id": "resp_123",
|
||||
"object": "response",
|
||||
"model": "gpt-5",
|
||||
"status": "in_progress",
|
||||
},
|
||||
}
|
||||
|
||||
events2 = reg.convert_stream_chunk(in_progress_chunk, "OPENAI_CLI", "CLAUDE", state=state)
|
||||
# response.in_progress 不应产生任何事件
|
||||
assert events2 == []
|
||||
|
||||
|
||||
def test_stream_openai_cli_function_call_events() -> None:
|
||||
"""测试 OpenAI CLI 流式 function_call 相关事件"""
|
||||
reg = _make_registry_with_cli()
|
||||
state = StreamState()
|
||||
|
||||
# 首先发送 response.created
|
||||
created_chunk = {
|
||||
"type": "response.created",
|
||||
"response": {"id": "resp_456", "model": "gpt-5"},
|
||||
}
|
||||
reg.convert_stream_chunk(created_chunk, "OPENAI_CLI", "CLAUDE", state=state)
|
||||
|
||||
# response.output_item.added (function_call)
|
||||
output_item_chunk = {
|
||||
"type": "response.output_item.added",
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"call_id": "call_xyz",
|
||||
"name": "get_weather",
|
||||
},
|
||||
}
|
||||
|
||||
events1 = reg.convert_stream_chunk(output_item_chunk, "OPENAI_CLI", "CLAUDE", state=state)
|
||||
assert isinstance(events1, list) and events1
|
||||
assert events1[0].get("type") == "content_block_start"
|
||||
|
||||
# response.function_call_arguments.delta
|
||||
args_delta_chunk = {
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"delta": '{"city":',
|
||||
}
|
||||
|
||||
events2 = reg.convert_stream_chunk(args_delta_chunk, "OPENAI_CLI", "CLAUDE", state=state)
|
||||
assert isinstance(events2, list) and events2
|
||||
# ToolCallDeltaEvent 转换为 Claude 的 content_block_delta
|
||||
assert events2[0].get("type") == "content_block_delta"
|
||||
delta_obj = events2[0].get("delta", {})
|
||||
assert delta_obj.get("type") == "input_json_delta"
|
||||
assert delta_obj.get("partial_json") == '{"city":'
|
||||
|
||||
# response.output_item.done (function_call)
|
||||
output_done_chunk = {
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"call_id": "call_xyz",
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "Beijing"}',
|
||||
},
|
||||
}
|
||||
|
||||
events3 = reg.convert_stream_chunk(output_done_chunk, "OPENAI_CLI", "CLAUDE", state=state)
|
||||
assert isinstance(events3, list) and events3
|
||||
assert events3[0].get("type") == "content_block_stop"
|
||||
|
||||
|
||||
def test_real_claude_cli_stream_response_conversion() -> None:
|
||||
"""测试真实的 Claude CLI 流式响应转换(完整事件序列)
|
||||
|
||||
使用来自 Claude Code 的真实流式响应数据,验证:
|
||||
- message_start, content_block_start, ping, content_block_delta,
|
||||
content_block_stop, message_delta, message_stop 的完整处理链路
|
||||
- 文本增量正确拼接
|
||||
- usage 和 stop_reason 正确提取
|
||||
"""
|
||||
reg = _make_registry_with_cli()
|
||||
state = StreamState()
|
||||
|
||||
# 真实的 Claude CLI 流式响应事件序列
|
||||
chunks = [
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"model": "claude-opus-4-5-20251101",
|
||||
"id": "msg_01JEmQ1u53gZndRGBUBLvVZ9",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
"usage": {
|
||||
"input_tokens": 3,
|
||||
"cache_creation_input_tokens": 32760,
|
||||
"cache_read_input_tokens": 61777,
|
||||
"cache_creation": {
|
||||
"ephemeral_5m_input_tokens": 32760,
|
||||
"ephemeral_1h_input_tokens": 0,
|
||||
},
|
||||
"output_tokens": 2,
|
||||
"service_tier": "standard",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
},
|
||||
{"type": "ping"},
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "明"}},
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "白"}},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "了,请"},
|
||||
},
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "把"}},
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "包"}},
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "含 "}},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "tools"},
|
||||
},
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": " 具"}},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "体内"},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "容的 "},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "Claude"},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": " CLI"},
|
||||
},
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": " 请"}},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "求体"},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "发给我,"},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "我会一"},
|
||||
},
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "并"}},
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "审"}},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "查整"},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "个改"},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "动。"},
|
||||
},
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
|
||||
"usage": {
|
||||
"input_tokens": 3,
|
||||
"cache_creation_input_tokens": 32760,
|
||||
"cache_read_input_tokens": 61777,
|
||||
"output_tokens": 42,
|
||||
},
|
||||
},
|
||||
{"type": "message_stop"},
|
||||
]
|
||||
|
||||
# 收集所有转换后的 OpenAI 格式事件
|
||||
all_openai_events: List[Dict[str, Any]] = []
|
||||
for chunk in chunks:
|
||||
events = reg.convert_stream_chunk(chunk, "CLAUDE_CLI", "OPENAI", state=state)
|
||||
all_openai_events.extend(events)
|
||||
|
||||
# 验证转换结果
|
||||
assert len(all_openai_events) > 0
|
||||
|
||||
# 第一个事件应该是 chat.completion.chunk(来自 message_start)
|
||||
assert all_openai_events[0].get("object") == "chat.completion.chunk"
|
||||
assert all_openai_events[0].get("model") == "claude-opus-4-5-20251101"
|
||||
|
||||
# 收集所有文本增量
|
||||
text_deltas = []
|
||||
for evt in all_openai_events:
|
||||
choices = evt.get("choices") or []
|
||||
if choices:
|
||||
delta = choices[0].get("delta", {})
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
text_deltas.append(content)
|
||||
|
||||
# 验证文本拼接结果
|
||||
full_text = "".join(text_deltas)
|
||||
assert "明白了" in full_text
|
||||
assert "tools" in full_text
|
||||
assert "Claude CLI" in full_text
|
||||
assert "请求体发给我" in full_text
|
||||
|
||||
# 验证最后一个事件有 finish_reason
|
||||
last_with_finish = [
|
||||
e for e in all_openai_events if (e.get("choices") or [{}])[0].get("finish_reason")
|
||||
]
|
||||
assert len(last_with_finish) > 0
|
||||
assert last_with_finish[-1]["choices"][0]["finish_reason"] == "stop"
|
||||
|
||||
|
||||
def test_real_claude_cli_stream_to_openai_cli() -> None:
|
||||
"""测试 Claude CLI 流式响应转换为 OpenAI CLI (Responses API) 格式"""
|
||||
reg = _make_registry_with_cli()
|
||||
state = StreamState()
|
||||
|
||||
# 简化的真实事件序列
|
||||
chunks = [
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"model": "claude-opus-4-5-20251101",
|
||||
"id": "msg_test123",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"usage": {"input_tokens": 10, "output_tokens": 0},
|
||||
},
|
||||
},
|
||||
{"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_stop", "index": 0},
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "end_turn"},
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||
},
|
||||
{"type": "message_stop"},
|
||||
]
|
||||
|
||||
all_events: List[Dict[str, Any]] = []
|
||||
for chunk in chunks:
|
||||
events = reg.convert_stream_chunk(chunk, "CLAUDE_CLI", "OPENAI_CLI", state=state)
|
||||
all_events.extend(events)
|
||||
|
||||
# 验证 OpenAI CLI 格式事件
|
||||
assert len(all_events) > 0
|
||||
|
||||
# 应该有 response.created 事件
|
||||
created_events = [e for e in all_events if e.get("type") == "response.created"]
|
||||
assert len(created_events) == 1
|
||||
|
||||
# 应该有 response.output_text.delta 事件
|
||||
delta_events = [e for e in all_events if e.get("type") == "response.output_text.delta"]
|
||||
assert len(delta_events) >= 2
|
||||
deltas = [e.get("delta") for e in delta_events]
|
||||
assert "Hello" in deltas
|
||||
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")]
|
||||
assert len(done_events) >= 1
|
||||
@@ -3,7 +3,7 @@ is_format_compatible 单元测试
|
||||
|
||||
覆盖:
|
||||
- 同格式透传
|
||||
- CLI 格式禁止转换
|
||||
- CLI 格式允许转换(按 registry 能力)
|
||||
- 全局开关/端点开关/白黑名单
|
||||
- 流式转换开关
|
||||
- 转换器能力校验
|
||||
@@ -28,18 +28,21 @@ def test_same_format_is_compatible() -> None:
|
||||
assert reason is None
|
||||
|
||||
|
||||
def test_cli_format_not_convertible() -> None:
|
||||
def test_cli_format_convertible_when_converter_supports_full() -> None:
|
||||
registry = MagicMock()
|
||||
registry.can_convert_full.return_value = True
|
||||
|
||||
ok, needs_conv, reason = is_format_compatible(
|
||||
"CLAUDE_CLI",
|
||||
"OPENAI",
|
||||
endpoint_format_acceptance_config={"enabled": True},
|
||||
is_stream=False,
|
||||
global_conversion_enabled=True,
|
||||
registry=MagicMock(),
|
||||
registry=registry,
|
||||
)
|
||||
assert ok is False
|
||||
assert needs_conv is False
|
||||
assert reason and "CLI" in reason
|
||||
assert ok is True
|
||||
assert needs_conv is True
|
||||
assert reason is None
|
||||
|
||||
|
||||
def test_global_switch_disabled_blocks_conversion() -> None:
|
||||
@@ -158,4 +161,3 @@ def test_conversion_allowed_when_converter_supports_full() -> None:
|
||||
assert ok is True
|
||||
assert needs_conv is True
|
||||
assert reason is None
|
||||
|
||||
|
||||
71
tests/core/api_format/conversion/test_error_conversion.py
Normal file
71
tests/core/api_format/conversion/test_error_conversion.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
错误转换单元测试(Canonical)
|
||||
|
||||
重点:
|
||||
- registry_canonical.convert_error_response(_strict) 的基本链路
|
||||
- ErrorEvent 在 stream_event_from_internal 的输出形态
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, cast
|
||||
|
||||
from src.core.api_format.conversion.internal import ErrorType, InternalError
|
||||
from src.core.api_format.conversion.normalizers.claude import ClaudeNormalizer
|
||||
from src.core.api_format.conversion.normalizers.gemini import GeminiNormalizer
|
||||
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_events import ErrorEvent
|
||||
from src.core.api_format.conversion.stream_state import StreamState
|
||||
|
||||
|
||||
def _make_registry() -> FormatConversionRegistry:
|
||||
reg = FormatConversionRegistry()
|
||||
reg.register(OpenAINormalizer())
|
||||
reg.register(ClaudeNormalizer())
|
||||
reg.register(GeminiNormalizer())
|
||||
return reg
|
||||
|
||||
|
||||
def test_error_conversion_openai_to_claude() -> None:
|
||||
reg = _make_registry()
|
||||
|
||||
openai_error = {
|
||||
"error": {"message": "bad request", "type": "invalid_request_error", "code": "bad_request"}
|
||||
}
|
||||
|
||||
out = reg.convert_error_response(openai_error, "OPENAI", "CLAUDE")
|
||||
assert out.get("type") == "error"
|
||||
assert isinstance(out.get("error"), dict)
|
||||
assert out["error"]["message"] == "bad request"
|
||||
|
||||
|
||||
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")
|
||||
assert isinstance(out.get("error"), dict)
|
||||
assert out["error"]["message"] == "nope"
|
||||
|
||||
|
||||
def test_error_event_stream_output_openai() -> None:
|
||||
n = OpenAINormalizer()
|
||||
state = StreamState(model="gpt-4o-mini", message_id="chatcmpl_1")
|
||||
|
||||
internal = InternalError(type=ErrorType.INVALID_REQUEST, message="bad", retryable=False)
|
||||
events = n.stream_event_from_internal(ErrorEvent(error=internal), state)
|
||||
assert events == [{"error": {"message": "bad", "type": "invalid_request_error"}}]
|
||||
|
||||
|
||||
def test_error_event_stream_openai_to_claude_via_registry() -> None:
|
||||
reg = _make_registry()
|
||||
|
||||
# OpenAI 流式错误块
|
||||
chunk = {"error": {"message": "bad", "type": "invalid_request_error"}}
|
||||
out = reg.convert_stream_chunk(chunk, "OPENAI", "CLAUDE", state=StreamState())
|
||||
assert isinstance(out, list) and out
|
||||
evt0 = cast(Dict[str, Any], out[0])
|
||||
assert evt0.get("type") == "error"
|
||||
assert isinstance(evt0.get("error"), dict)
|
||||
assert evt0["error"]["message"] == "bad"
|
||||
273
tests/core/api_format/conversion/test_gemini_normalizer.py
Normal file
273
tests/core/api_format/conversion/test_gemini_normalizer.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
GeminiNormalizer 单元测试
|
||||
|
||||
覆盖重点:
|
||||
- systemInstruction/system_instruction -> instructions 的提取与还原
|
||||
- parts(text/inline_data/function_call/function_response/unknown)转换
|
||||
- finishReason/usageMetadata 映射
|
||||
- streaming chunk <-> InternalStreamEvent 的基础行为与状态
|
||||
- error <-> InternalError
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, cast
|
||||
|
||||
from src.core.api_format.conversion.internal import (
|
||||
ErrorType,
|
||||
ImageBlock,
|
||||
StopReason,
|
||||
TextBlock,
|
||||
ToolResultBlock,
|
||||
ToolUseBlock,
|
||||
UnknownBlock,
|
||||
)
|
||||
from src.core.api_format.conversion.normalizers.gemini import GeminiNormalizer
|
||||
from src.core.api_format.conversion.stream_events import (
|
||||
ContentDeltaEvent,
|
||||
MessageStartEvent,
|
||||
MessageStopEvent,
|
||||
ToolCallDeltaEvent,
|
||||
)
|
||||
from src.core.api_format.conversion.stream_state import StreamState
|
||||
|
||||
|
||||
def test_gemini_request_system_and_generation_config_roundtrip() -> None:
|
||||
n = GeminiNormalizer()
|
||||
|
||||
req = {
|
||||
"model": "gemini-1.5",
|
||||
"systemInstruction": {"parts": [{"text": "sys"}]},
|
||||
"contents": [
|
||||
{"role": "user", "parts": [{"text": "hi"}]},
|
||||
{"role": "model", "parts": [{"text": "ok"}]},
|
||||
],
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": 10,
|
||||
"temperature": 0.2,
|
||||
"topP": 0.9,
|
||||
"topK": 1,
|
||||
"stopSequences": ["A", "B"],
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"functionDeclarations": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"toolConfig": {"functionCallingConfig": {"mode": "ANY"}},
|
||||
}
|
||||
|
||||
internal = n.request_to_internal(req)
|
||||
assert internal.model == "gemini-1.5"
|
||||
assert [seg.role.value for seg in internal.instructions] == ["system"]
|
||||
assert internal.instructions[0].text == "sys"
|
||||
assert internal.system == "sys"
|
||||
assert internal.max_tokens == 10
|
||||
assert internal.temperature == 0.2
|
||||
assert internal.top_p == 0.9
|
||||
assert internal.top_k == 1
|
||||
assert internal.stop_sequences == ["A", "B"]
|
||||
assert internal.tools is not None and internal.tools[0].name == "get_weather"
|
||||
assert internal.tool_choice is not None and internal.tool_choice.type.value == "required"
|
||||
|
||||
out = n.request_from_internal(internal)
|
||||
assert out["system_instruction"]["parts"][0]["text"] == "sys"
|
||||
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"
|
||||
|
||||
|
||||
def test_gemini_request_parts_image_tool_and_unknown_drop() -> None:
|
||||
n = GeminiNormalizer()
|
||||
|
||||
req = {
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"text": "look"},
|
||||
{"inline_data": {"mime_type": "image/png", "data": "AAAA"}},
|
||||
{"foo": 1},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"function_call": {"name": "get_weather", "args": {"city": "SF"}}}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"function_response": {"name": "call_1", "response": {"result": {"temp_c": 20}}}}
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
internal = n.request_to_internal(req)
|
||||
assert [m.role.value for m in internal.messages] == ["user", "assistant", "user"]
|
||||
|
||||
blocks0 = internal.messages[0].content
|
||||
assert any(isinstance(b, TextBlock) for b in blocks0)
|
||||
assert any(isinstance(b, ImageBlock) for b in blocks0)
|
||||
assert any(isinstance(b, UnknownBlock) for b in blocks0)
|
||||
|
||||
dropped = (internal.extra.get("raw") or {}).get("dropped_blocks") or {}
|
||||
assert dropped.get("gemini_part:foo") == 1
|
||||
|
||||
blocks1 = internal.messages[1].content
|
||||
tool_use = next(b for b in blocks1 if isinstance(b, ToolUseBlock))
|
||||
assert tool_use.tool_name == "get_weather"
|
||||
assert tool_use.tool_input == {"city": "SF"}
|
||||
|
||||
blocks2 = internal.messages[2].content
|
||||
tool_result = next(b for b in blocks2 if isinstance(b, ToolResultBlock))
|
||||
assert tool_result.tool_use_id == "call_1"
|
||||
assert tool_result.output == {"temp_c": 20}
|
||||
|
||||
out = n.request_from_internal(internal)
|
||||
out_contents: List[Dict[str, Any]] = out["contents"]
|
||||
|
||||
# unknown 被丢弃
|
||||
user_parts = cast(List[Dict[str, Any]], out_contents[0]["parts"])
|
||||
assert any(p.get("text") == "look" for p in user_parts)
|
||||
assert any(p.get("inline_data", {}).get("mime_type") == "image/png" for p in user_parts)
|
||||
assert all("foo" not in p for p in user_parts)
|
||||
|
||||
model_parts = cast(List[Dict[str, Any]], out_contents[1]["parts"])
|
||||
assert model_parts[0]["function_call"]["name"] == "get_weather"
|
||||
|
||||
tool_parts = cast(List[Dict[str, Any]], out_contents[2]["parts"])
|
||||
assert tool_parts[0]["function_response"]["name"] == "call_1"
|
||||
assert tool_parts[0]["function_response"]["response"]["result"] == {"temp_c": 20}
|
||||
|
||||
|
||||
def test_gemini_response_finish_reason_and_usage_roundtrip() -> None:
|
||||
n = GeminiNormalizer()
|
||||
|
||||
resp = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "hi"}], "role": "model"},
|
||||
"finishReason": "MAX_TOKENS",
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 5,
|
||||
"candidatesTokenCount": 7,
|
||||
"totalTokenCount": 12,
|
||||
"cachedContentTokenCount": 2,
|
||||
},
|
||||
"modelVersion": "gemini-1.5",
|
||||
}
|
||||
|
||||
internal = n.response_to_internal(resp)
|
||||
assert internal.model == "gemini-1.5"
|
||||
assert internal.stop_reason == StopReason.MAX_TOKENS
|
||||
assert internal.usage is not None
|
||||
assert internal.usage.input_tokens == 5
|
||||
assert internal.usage.output_tokens == 7
|
||||
assert internal.usage.total_tokens == 12
|
||||
assert internal.usage.cache_read_tokens == 2
|
||||
|
||||
out = n.response_from_internal(internal)
|
||||
assert out["candidates"][0]["finishReason"] == "MAX_TOKENS"
|
||||
assert out["usageMetadata"]["cachedContentTokenCount"] == 2
|
||||
|
||||
|
||||
def test_gemini_stream_chunk_and_event_roundtrip_basic() -> None:
|
||||
n = GeminiNormalizer()
|
||||
state = StreamState()
|
||||
|
||||
chunks = [
|
||||
{
|
||||
"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": [
|
||||
{"functionCall": {"name": "get_weather", "args": {"city": "SF"}}}
|
||||
],
|
||||
"role": "model",
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
"modelVersion": "gemini-1.5",
|
||||
},
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [], "role": "model"},
|
||||
"finishReason": "STOP",
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
"usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 2, "totalTokenCount": 3},
|
||||
"modelVersion": "gemini-1.5",
|
||||
},
|
||||
]
|
||||
|
||||
events: List[Any] = []
|
||||
for ch in chunks:
|
||||
events.extend(n.stream_chunk_to_internal(ch, state))
|
||||
|
||||
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)
|
||||
|
||||
state2 = StreamState()
|
||||
out_chunks: List[Dict[str, Any]] = []
|
||||
for e in events:
|
||||
out_chunks.extend(n.stream_event_from_internal(e, state2))
|
||||
|
||||
assert any(c["candidates"][0]["content"]["parts"][0].get("text") == "Hel" for c in out_chunks)
|
||||
|
||||
tool_chunk = next(
|
||||
c
|
||||
for c in out_chunks
|
||||
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 out_chunks[-1]["candidates"][0]["finishReason"] == "STOP"
|
||||
|
||||
|
||||
def test_gemini_error_conversion() -> None:
|
||||
n = GeminiNormalizer()
|
||||
|
||||
err_resp = {"error": {"code": 429, "message": "slow down", "status": "RESOURCE_EXHAUSTED"}}
|
||||
assert n.is_error_response(err_resp) is True
|
||||
|
||||
internal = n.error_to_internal(err_resp)
|
||||
assert internal.type == ErrorType.RATE_LIMIT
|
||||
assert internal.retryable is True
|
||||
|
||||
out = n.error_from_internal(internal)
|
||||
assert out["error"]["status"] == "RESOURCE_EXHAUSTED"
|
||||
assert out["error"]["message"] == "slow down"
|
||||
116
tests/core/api_format/conversion/test_golden_canonical.py
Normal file
116
tests/core/api_format/conversion/test_golden_canonical.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Golden tests(Canonical)
|
||||
|
||||
说明:
|
||||
- 这些 Golden 用于冻结 Canonical registry 的外部输出形态(request/response/stream)。
|
||||
- 文件由 `tools/generate_format_conversion_golden.py` 生成。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from src.core.api_format.conversion.normalizers.claude import ClaudeNormalizer
|
||||
from src.core.api_format.conversion.normalizers.gemini import GeminiNormalizer
|
||||
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"
|
||||
|
||||
|
||||
def _scrub(obj: Any) -> Any:
|
||||
if isinstance(obj, list):
|
||||
return [_scrub(x) for x in obj]
|
||||
if isinstance(obj, dict):
|
||||
out: Dict[str, Any] = {}
|
||||
for k, v in obj.items():
|
||||
if k in {"created"}:
|
||||
continue
|
||||
if k == "system_fingerprint" and v is None:
|
||||
continue
|
||||
out[k] = _scrub(v)
|
||||
return out
|
||||
return obj
|
||||
|
||||
|
||||
def _load_json(path: Path) -> Any:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _make_registry() -> FormatConversionRegistry:
|
||||
reg = FormatConversionRegistry()
|
||||
reg.register(OpenAINormalizer())
|
||||
reg.register(ClaudeNormalizer())
|
||||
reg.register(GeminiNormalizer())
|
||||
return reg
|
||||
|
||||
|
||||
def test_golden_requests() -> None:
|
||||
reg = _make_registry()
|
||||
formats = ["OPENAI", "CLAUDE", "GEMINI"]
|
||||
|
||||
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"),
|
||||
}
|
||||
|
||||
for source in formats:
|
||||
for target in formats:
|
||||
if source == target:
|
||||
continue
|
||||
expected = _load_json(EXPECTED_DIR / f"request_{source}_to_{target}.json")
|
||||
actual = reg.convert_request(inputs[source], source, target)
|
||||
assert _scrub(actual) == expected
|
||||
|
||||
|
||||
def test_golden_responses() -> None:
|
||||
reg = _make_registry()
|
||||
formats = ["OPENAI", "CLAUDE", "GEMINI"]
|
||||
|
||||
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"),
|
||||
}
|
||||
|
||||
for source in formats:
|
||||
for target in formats:
|
||||
if source == target:
|
||||
continue
|
||||
expected = _load_json(EXPECTED_DIR / f"response_{source}_to_{target}.json")
|
||||
actual = reg.convert_response(inputs[source], source, target)
|
||||
assert _scrub(actual) == expected
|
||||
|
||||
|
||||
def test_golden_streams() -> None:
|
||||
reg = _make_registry()
|
||||
formats = ["OPENAI", "CLAUDE", "GEMINI"]
|
||||
|
||||
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"),
|
||||
}
|
||||
|
||||
for source in formats:
|
||||
for target in formats:
|
||||
if source == target:
|
||||
continue
|
||||
expected = _load_json(EXPECTED_DIR / f"stream_{source}_to_{target}.json")
|
||||
|
||||
state = StreamState()
|
||||
if source == "GEMINI":
|
||||
state.message_id = "gemini_1"
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for chunk in inputs[source]:
|
||||
out.extend(reg.convert_stream_chunk(chunk, source, target, state=state))
|
||||
|
||||
assert _scrub(out) == expected
|
||||
643
tests/core/api_format/conversion/test_internal.py
Normal file
643
tests/core/api_format/conversion/test_internal.py
Normal file
@@ -0,0 +1,643 @@
|
||||
"""
|
||||
internal 数据结构单元测试
|
||||
|
||||
目标:
|
||||
- 验证 dataclass/Enum 可正确实例化
|
||||
- 验证 ContentBlock 联合类型可用于运行时判断
|
||||
- 验证 StreamState.substate() 隔离机制
|
||||
- 验证各类型的默认值、字段访问、序列化
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import get_args
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.api_format.conversion.internal import (
|
||||
ContentBlock,
|
||||
ContentType,
|
||||
ErrorType,
|
||||
FormatCapabilities,
|
||||
ImageBlock,
|
||||
InstructionSegment,
|
||||
InternalError,
|
||||
InternalMessage,
|
||||
InternalRequest,
|
||||
InternalResponse,
|
||||
Role,
|
||||
StopReason,
|
||||
TextBlock,
|
||||
ToolChoice,
|
||||
ToolChoiceType,
|
||||
ToolDefinition,
|
||||
ToolResultBlock,
|
||||
ToolUseBlock,
|
||||
UnknownBlock,
|
||||
UsageInfo,
|
||||
)
|
||||
from src.core.api_format.conversion.stream_state import StreamState
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Enum 类型测试
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestRoleEnum:
|
||||
def test_role_values(self) -> None:
|
||||
assert Role.USER.value == "user"
|
||||
assert Role.ASSISTANT.value == "assistant"
|
||||
assert Role.SYSTEM.value == "system"
|
||||
assert Role.DEVELOPER.value == "developer"
|
||||
assert Role.TOOL.value == "tool"
|
||||
assert Role.UNKNOWN.value == "unknown"
|
||||
|
||||
def test_role_is_str_subclass(self) -> None:
|
||||
assert isinstance(Role.USER, str)
|
||||
assert Role.USER == "user"
|
||||
|
||||
|
||||
class TestContentTypeEnum:
|
||||
def test_content_type_values(self) -> None:
|
||||
assert ContentType.TEXT.value == "text"
|
||||
assert ContentType.IMAGE.value == "image"
|
||||
assert ContentType.TOOL_USE.value == "tool_use"
|
||||
assert ContentType.TOOL_RESULT.value == "tool_result"
|
||||
assert ContentType.UNKNOWN.value == "unknown"
|
||||
|
||||
|
||||
class TestStopReasonEnum:
|
||||
def test_stop_reason_values(self) -> None:
|
||||
assert StopReason.END_TURN.value == "end_turn"
|
||||
assert StopReason.MAX_TOKENS.value == "max_tokens"
|
||||
assert StopReason.STOP_SEQUENCE.value == "stop_sequence"
|
||||
assert StopReason.TOOL_USE.value == "tool_use"
|
||||
assert StopReason.PAUSE_TURN.value == "pause_turn"
|
||||
assert StopReason.REFUSAL.value == "refusal"
|
||||
assert StopReason.CONTENT_FILTERED.value == "content_filtered"
|
||||
assert StopReason.UNKNOWN.value == "unknown"
|
||||
|
||||
|
||||
class TestErrorTypeEnum:
|
||||
def test_error_type_values(self) -> None:
|
||||
assert ErrorType.INVALID_REQUEST.value == "invalid_request"
|
||||
assert ErrorType.AUTHENTICATION.value == "authentication"
|
||||
assert ErrorType.PERMISSION_DENIED.value == "permission_denied"
|
||||
assert ErrorType.NOT_FOUND.value == "not_found"
|
||||
assert ErrorType.RATE_LIMIT.value == "rate_limit"
|
||||
assert ErrorType.OVERLOADED.value == "overloaded"
|
||||
assert ErrorType.SERVER_ERROR.value == "server_error"
|
||||
assert ErrorType.CONTENT_FILTERED.value == "content_filtered"
|
||||
assert ErrorType.CONTEXT_LENGTH_EXCEEDED.value == "context_length_exceeded"
|
||||
assert ErrorType.UNKNOWN.value == "unknown"
|
||||
|
||||
|
||||
class TestToolChoiceTypeEnum:
|
||||
def test_tool_choice_type_values(self) -> None:
|
||||
assert ToolChoiceType.AUTO.value == "auto"
|
||||
assert ToolChoiceType.NONE.value == "none"
|
||||
assert ToolChoiceType.REQUIRED.value == "required"
|
||||
assert ToolChoiceType.TOOL.value == "tool"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ContentBlock 类型测试
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_content_block_runtime_check() -> None:
|
||||
block_types = get_args(ContentBlock)
|
||||
assert block_types, "typing.get_args(ContentBlock) 应返回可用的类型列表"
|
||||
|
||||
assert isinstance(TextBlock(text="hi"), block_types)
|
||||
assert isinstance(ImageBlock(url="https://example.com/a.png"), block_types)
|
||||
assert isinstance(ToolUseBlock(tool_id="t1", tool_name="x"), block_types)
|
||||
assert isinstance(ToolResultBlock(tool_use_id="t1", output={"ok": True}), block_types)
|
||||
assert isinstance(UnknownBlock(raw_type="weird", payload={"x": 1}), block_types)
|
||||
|
||||
|
||||
def test_internal_error_to_debug_dict() -> None:
|
||||
err = InternalError(
|
||||
type=ErrorType.INVALID_REQUEST,
|
||||
message="bad request",
|
||||
code="bad_request",
|
||||
param="messages",
|
||||
retryable=False,
|
||||
extra={"raw": {"type": "invalid_request_error"}},
|
||||
)
|
||||
d = err.to_debug_dict()
|
||||
assert d["type"] == "invalid_request"
|
||||
assert d["message"] == "bad request"
|
||||
assert d["code"] == "bad_request"
|
||||
assert d["param"] == "messages"
|
||||
assert d["retryable"] is False
|
||||
assert isinstance(d["extra"], dict)
|
||||
|
||||
|
||||
def test_internal_request_debug_dict_and_serialization() -> None:
|
||||
req = InternalRequest(
|
||||
model="m",
|
||||
messages=[],
|
||||
instructions=[
|
||||
InstructionSegment(role=Role.SYSTEM, text="sys1"),
|
||||
InstructionSegment(role=Role.DEVELOPER, text="dev1"),
|
||||
],
|
||||
system="sys1\n\ndev1",
|
||||
stream=True,
|
||||
extra={"raw": {"openai_messages": []}},
|
||||
)
|
||||
d = req.to_debug_dict()
|
||||
assert d["instruction_count"] == 2
|
||||
assert d["message_count"] == 0
|
||||
assert d["has_system"] is True
|
||||
assert d["stream"] is True
|
||||
|
||||
# asdict 只要能跑通即可(Enum 会保留为对象,属于预期)
|
||||
dumped = asdict(req)
|
||||
assert dumped["model"] == "m"
|
||||
assert isinstance(dumped["instructions"], list)
|
||||
|
||||
|
||||
def test_internal_response_debug_dict() -> None:
|
||||
resp = InternalResponse(
|
||||
id="r1",
|
||||
model="m",
|
||||
content=[TextBlock(text="hi")],
|
||||
stop_reason=StopReason.END_TURN,
|
||||
usage=UsageInfo(input_tokens=1, output_tokens=2, total_tokens=3),
|
||||
)
|
||||
d = resp.to_debug_dict()
|
||||
assert d["id"] == "r1"
|
||||
assert d["stop_reason"] == "end_turn"
|
||||
assert d["usage"] == {"input": 1, "output": 2}
|
||||
|
||||
|
||||
def test_stream_state_substate_isolated() -> None:
|
||||
state = StreamState()
|
||||
openai_state = state.substate("openai")
|
||||
claude_state = state.substate("CLAUDE")
|
||||
|
||||
openai_state["x"] = 1
|
||||
assert "x" not in claude_state
|
||||
assert state.substate("OPENAI") is openai_state
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ContentBlock 各子类型详细测试
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestTextBlock:
|
||||
def test_default_values(self) -> None:
|
||||
block = TextBlock()
|
||||
assert block.type == ContentType.TEXT
|
||||
assert block.text == ""
|
||||
assert block.extra == {}
|
||||
|
||||
def test_with_content(self) -> None:
|
||||
block = TextBlock(text="Hello, world!", extra={"source": "test"})
|
||||
assert block.text == "Hello, world!"
|
||||
assert block.extra == {"source": "test"}
|
||||
|
||||
def test_type_is_readonly(self) -> None:
|
||||
block = TextBlock(text="hi")
|
||||
assert block.type == ContentType.TEXT
|
||||
|
||||
|
||||
class TestImageBlock:
|
||||
def test_with_url(self) -> None:
|
||||
block = ImageBlock(url="https://example.com/img.png")
|
||||
assert block.type == ContentType.IMAGE
|
||||
assert block.url == "https://example.com/img.png"
|
||||
assert block.data is None
|
||||
assert block.media_type is None
|
||||
|
||||
def test_with_base64_data(self) -> None:
|
||||
block = ImageBlock(data="base64encodeddata", media_type="image/png")
|
||||
assert block.data == "base64encodeddata"
|
||||
assert block.media_type == "image/png"
|
||||
assert block.url is None
|
||||
|
||||
|
||||
class TestToolUseBlock:
|
||||
def test_default_values(self) -> None:
|
||||
block = ToolUseBlock()
|
||||
assert block.type == ContentType.TOOL_USE
|
||||
assert block.tool_id == ""
|
||||
assert block.tool_name == ""
|
||||
assert block.tool_input == {}
|
||||
|
||||
def test_with_values(self) -> None:
|
||||
block = ToolUseBlock(
|
||||
tool_id="call_123",
|
||||
tool_name="get_weather",
|
||||
tool_input={"city": "Beijing"},
|
||||
)
|
||||
assert block.tool_id == "call_123"
|
||||
assert block.tool_name == "get_weather"
|
||||
assert block.tool_input == {"city": "Beijing"}
|
||||
|
||||
|
||||
class TestToolResultBlock:
|
||||
def test_default_values(self) -> None:
|
||||
block = ToolResultBlock()
|
||||
assert block.type == ContentType.TOOL_RESULT
|
||||
assert block.tool_use_id == ""
|
||||
assert block.output is None
|
||||
assert block.content_text is None
|
||||
assert block.is_error is False
|
||||
|
||||
def test_with_success_output(self) -> None:
|
||||
block = ToolResultBlock(
|
||||
tool_use_id="call_123",
|
||||
output={"temperature": 25},
|
||||
content_text="Temperature: 25C",
|
||||
)
|
||||
assert block.tool_use_id == "call_123"
|
||||
assert block.output == {"temperature": 25}
|
||||
assert block.is_error is False
|
||||
|
||||
def test_with_error_output(self) -> None:
|
||||
block = ToolResultBlock(
|
||||
tool_use_id="call_456",
|
||||
output="Error: city not found",
|
||||
is_error=True,
|
||||
)
|
||||
assert block.is_error is True
|
||||
|
||||
|
||||
class TestUnknownBlock:
|
||||
def test_default_values(self) -> None:
|
||||
block = UnknownBlock()
|
||||
assert block.type == ContentType.UNKNOWN
|
||||
assert block.raw_type == ""
|
||||
assert block.payload == {}
|
||||
|
||||
def test_with_values(self) -> None:
|
||||
block = UnknownBlock(
|
||||
raw_type="custom_block",
|
||||
payload={"key": "value"},
|
||||
extra={"source": "gemini"},
|
||||
)
|
||||
assert block.raw_type == "custom_block"
|
||||
assert block.payload == {"key": "value"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# InternalMessage 测试
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestInternalMessage:
|
||||
def test_simple_user_message(self) -> None:
|
||||
msg = InternalMessage(role=Role.USER, content=[TextBlock(text="Hello")])
|
||||
assert msg.role == Role.USER
|
||||
assert len(msg.content) == 1
|
||||
assert isinstance(msg.content[0], TextBlock)
|
||||
|
||||
def test_assistant_with_tool_use(self) -> None:
|
||||
msg = InternalMessage(
|
||||
role=Role.ASSISTANT,
|
||||
content=[
|
||||
TextBlock(text="Let me check the weather"),
|
||||
ToolUseBlock(tool_id="t1", tool_name="get_weather", tool_input={"city": "Shanghai"}),
|
||||
],
|
||||
)
|
||||
assert msg.role == Role.ASSISTANT
|
||||
assert len(msg.content) == 2
|
||||
assert msg.content[0].type == ContentType.TEXT
|
||||
assert msg.content[1].type == ContentType.TOOL_USE
|
||||
|
||||
def test_tool_message(self) -> None:
|
||||
msg = InternalMessage(
|
||||
role=Role.TOOL,
|
||||
content=[ToolResultBlock(tool_use_id="t1", output="Sunny, 28C")],
|
||||
)
|
||||
assert msg.role == Role.TOOL
|
||||
|
||||
def test_with_extra(self) -> None:
|
||||
msg = InternalMessage(
|
||||
role=Role.USER,
|
||||
content=[],
|
||||
extra={"original_format": "openai"},
|
||||
)
|
||||
assert msg.extra == {"original_format": "openai"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# InstructionSegment 测试
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestInstructionSegment:
|
||||
def test_system_instruction(self) -> None:
|
||||
seg = InstructionSegment(role=Role.SYSTEM, text="You are a helpful assistant.")
|
||||
assert seg.role == Role.SYSTEM
|
||||
assert seg.text == "You are a helpful assistant."
|
||||
|
||||
def test_developer_instruction(self) -> None:
|
||||
seg = InstructionSegment(role=Role.DEVELOPER, text="Always respond in JSON format.")
|
||||
assert seg.role == Role.DEVELOPER
|
||||
assert seg.text == "Always respond in JSON format."
|
||||
|
||||
def test_with_extra(self) -> None:
|
||||
seg = InstructionSegment(
|
||||
role=Role.SYSTEM,
|
||||
text="test",
|
||||
extra={"cache_control": {"type": "ephemeral"}},
|
||||
)
|
||||
assert seg.extra == {"cache_control": {"type": "ephemeral"}}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ToolDefinition 和 ToolChoice 测试
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestToolDefinition:
|
||||
def test_minimal(self) -> None:
|
||||
tool = ToolDefinition(name="get_time")
|
||||
assert tool.name == "get_time"
|
||||
assert tool.description is None
|
||||
assert tool.parameters is None
|
||||
|
||||
def test_full(self) -> None:
|
||||
tool = ToolDefinition(
|
||||
name="get_weather",
|
||||
description="Get current weather for a city",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {"type": "string", "description": "City name"},
|
||||
},
|
||||
"required": ["city"],
|
||||
},
|
||||
extra={"strict": True},
|
||||
)
|
||||
assert tool.name == "get_weather"
|
||||
assert tool.description == "Get current weather for a city"
|
||||
assert tool.parameters is not None
|
||||
assert "city" in tool.parameters["properties"]
|
||||
|
||||
|
||||
class TestToolChoice:
|
||||
def test_auto(self) -> None:
|
||||
choice = ToolChoice(type=ToolChoiceType.AUTO)
|
||||
assert choice.type == ToolChoiceType.AUTO
|
||||
assert choice.tool_name is None
|
||||
|
||||
def test_none(self) -> None:
|
||||
choice = ToolChoice(type=ToolChoiceType.NONE)
|
||||
assert choice.type == ToolChoiceType.NONE
|
||||
|
||||
def test_required(self) -> None:
|
||||
choice = ToolChoice(type=ToolChoiceType.REQUIRED)
|
||||
assert choice.type == ToolChoiceType.REQUIRED
|
||||
|
||||
def test_specific_tool(self) -> None:
|
||||
choice = ToolChoice(type=ToolChoiceType.TOOL, tool_name="get_weather")
|
||||
assert choice.type == ToolChoiceType.TOOL
|
||||
assert choice.tool_name == "get_weather"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# UsageInfo 测试
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestUsageInfo:
|
||||
def test_default_values(self) -> None:
|
||||
usage = UsageInfo()
|
||||
assert usage.input_tokens == 0
|
||||
assert usage.output_tokens == 0
|
||||
assert usage.total_tokens == 0
|
||||
assert usage.cache_read_tokens == 0
|
||||
assert usage.cache_write_tokens == 0
|
||||
|
||||
def test_with_values(self) -> None:
|
||||
usage = UsageInfo(
|
||||
input_tokens=100,
|
||||
output_tokens=50,
|
||||
total_tokens=150,
|
||||
cache_read_tokens=20,
|
||||
cache_write_tokens=10,
|
||||
)
|
||||
assert usage.input_tokens == 100
|
||||
assert usage.output_tokens == 50
|
||||
assert usage.total_tokens == 150
|
||||
assert usage.cache_read_tokens == 20
|
||||
assert usage.cache_write_tokens == 10
|
||||
|
||||
def test_with_extra(self) -> None:
|
||||
usage = UsageInfo(
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
extra={"reasoning_tokens": 100},
|
||||
)
|
||||
assert usage.extra == {"reasoning_tokens": 100}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FormatCapabilities 测试
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestFormatCapabilities:
|
||||
def test_default_values(self) -> None:
|
||||
caps = FormatCapabilities()
|
||||
assert caps.supports_stream is True
|
||||
assert caps.supports_error_conversion is True
|
||||
assert caps.supports_tools is True
|
||||
assert caps.supports_images is False
|
||||
assert caps.supported_features == frozenset()
|
||||
|
||||
def test_custom_values(self) -> None:
|
||||
caps = FormatCapabilities(
|
||||
supports_stream=False,
|
||||
supports_error_conversion=True,
|
||||
supports_tools=False,
|
||||
supports_images=True,
|
||||
supported_features=frozenset({"vision", "function_calling"}),
|
||||
)
|
||||
assert caps.supports_stream is False
|
||||
assert caps.supports_images is True
|
||||
assert "vision" in caps.supported_features
|
||||
|
||||
def test_is_frozen(self) -> None:
|
||||
caps = FormatCapabilities()
|
||||
with pytest.raises(Exception):
|
||||
caps.supports_stream = False # type: ignore
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# InternalRequest 详细测试
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestInternalRequest:
|
||||
def test_minimal(self) -> None:
|
||||
req = InternalRequest(model="gpt-4", messages=[])
|
||||
assert req.model == "gpt-4"
|
||||
assert req.messages == []
|
||||
assert req.stream is False
|
||||
assert req.max_tokens is None
|
||||
assert req.tools is None
|
||||
|
||||
def test_with_messages(self) -> None:
|
||||
req = InternalRequest(
|
||||
model="claude-3",
|
||||
messages=[
|
||||
InternalMessage(role=Role.USER, content=[TextBlock(text="Hi")]),
|
||||
InternalMessage(role=Role.ASSISTANT, content=[TextBlock(text="Hello!")]),
|
||||
],
|
||||
)
|
||||
assert len(req.messages) == 2
|
||||
assert req.messages[0].role == Role.USER
|
||||
assert req.messages[1].role == Role.ASSISTANT
|
||||
|
||||
def test_with_tools(self) -> None:
|
||||
req = InternalRequest(
|
||||
model="gpt-4",
|
||||
messages=[],
|
||||
tools=[ToolDefinition(name="get_time"), ToolDefinition(name="get_weather")],
|
||||
tool_choice=ToolChoice(type=ToolChoiceType.AUTO),
|
||||
)
|
||||
assert req.tools is not None
|
||||
assert len(req.tools) == 2
|
||||
assert req.tool_choice is not None
|
||||
assert req.tool_choice.type == ToolChoiceType.AUTO
|
||||
|
||||
def test_to_debug_dict_with_tools(self) -> None:
|
||||
req = InternalRequest(
|
||||
model="m",
|
||||
messages=[InternalMessage(role=Role.USER, content=[TextBlock(text="test")])],
|
||||
tools=[ToolDefinition(name="tool1")],
|
||||
stream=True,
|
||||
extra={"key": "value"},
|
||||
)
|
||||
d = req.to_debug_dict()
|
||||
assert d["tool_count"] == 1
|
||||
assert d["message_count"] == 1
|
||||
assert d["stream"] is True
|
||||
assert "key" in d["extra_keys"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# InternalResponse 详细测试
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestInternalResponse:
|
||||
def test_minimal(self) -> None:
|
||||
resp = InternalResponse(id="r1", model="gpt-4", content=[])
|
||||
assert resp.id == "r1"
|
||||
assert resp.model == "gpt-4"
|
||||
assert resp.content == []
|
||||
assert resp.stop_reason is None
|
||||
assert resp.usage is None
|
||||
|
||||
def test_with_tool_use(self) -> None:
|
||||
resp = InternalResponse(
|
||||
id="r1",
|
||||
model="claude-3",
|
||||
content=[
|
||||
TextBlock(text="I'll check that for you"),
|
||||
ToolUseBlock(tool_id="t1", tool_name="search", tool_input={"q": "test"}),
|
||||
],
|
||||
stop_reason=StopReason.TOOL_USE,
|
||||
)
|
||||
assert resp.stop_reason == StopReason.TOOL_USE
|
||||
assert len(resp.content) == 2
|
||||
|
||||
def test_to_debug_dict_without_usage(self) -> None:
|
||||
resp = InternalResponse(id="r1", model="m", content=[])
|
||||
d = resp.to_debug_dict()
|
||||
assert d["usage"] is None
|
||||
|
||||
def test_to_debug_dict_with_all_stop_reasons(self) -> None:
|
||||
for reason in StopReason:
|
||||
resp = InternalResponse(
|
||||
id="r1",
|
||||
model="m",
|
||||
content=[],
|
||||
stop_reason=reason,
|
||||
)
|
||||
d = resp.to_debug_dict()
|
||||
assert d["stop_reason"] == reason.value
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# InternalError 详细测试
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestInternalError:
|
||||
def test_minimal(self) -> None:
|
||||
err = InternalError(type=ErrorType.UNKNOWN, message="Unknown error")
|
||||
assert err.type == ErrorType.UNKNOWN
|
||||
assert err.message == "Unknown error"
|
||||
assert err.code is None
|
||||
assert err.retryable is False
|
||||
|
||||
def test_retryable_error(self) -> None:
|
||||
err = InternalError(
|
||||
type=ErrorType.RATE_LIMIT,
|
||||
message="Rate limit exceeded",
|
||||
code="rate_limit_exceeded",
|
||||
retryable=True,
|
||||
)
|
||||
assert err.type == ErrorType.RATE_LIMIT
|
||||
assert err.retryable is True
|
||||
|
||||
def test_all_error_types_in_debug_dict(self) -> None:
|
||||
for err_type in ErrorType:
|
||||
err = InternalError(type=err_type, message="test")
|
||||
d = err.to_debug_dict()
|
||||
assert d["type"] == err_type.value
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# asdict 序列化测试
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestSerialization:
|
||||
def test_content_block_asdict(self) -> None:
|
||||
block = TextBlock(text="hello")
|
||||
d = asdict(block)
|
||||
assert d["text"] == "hello"
|
||||
assert d["type"] == ContentType.TEXT
|
||||
|
||||
def test_tool_use_block_asdict(self) -> None:
|
||||
block = ToolUseBlock(
|
||||
tool_id="t1",
|
||||
tool_name="test",
|
||||
tool_input={"a": 1},
|
||||
)
|
||||
d = asdict(block)
|
||||
assert d["tool_id"] == "t1"
|
||||
assert d["tool_input"] == {"a": 1}
|
||||
|
||||
def test_message_asdict(self) -> None:
|
||||
msg = InternalMessage(
|
||||
role=Role.USER,
|
||||
content=[TextBlock(text="hi")],
|
||||
)
|
||||
d = asdict(msg)
|
||||
assert d["role"] == Role.USER
|
||||
assert len(d["content"]) == 1
|
||||
|
||||
def test_response_asdict(self) -> None:
|
||||
resp = InternalResponse(
|
||||
id="r1",
|
||||
model="m",
|
||||
content=[TextBlock(text="response")],
|
||||
stop_reason=StopReason.END_TURN,
|
||||
usage=UsageInfo(input_tokens=10, output_tokens=5),
|
||||
)
|
||||
d = asdict(resp)
|
||||
assert d["id"] == "r1"
|
||||
assert d["stop_reason"] == StopReason.END_TURN
|
||||
assert d["usage"]["input_tokens"] == 10
|
||||
317
tests/core/api_format/conversion/test_openai_normalizer.py
Normal file
317
tests/core/api_format/conversion/test_openai_normalizer.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
OpenAINormalizer 单元测试
|
||||
|
||||
覆盖重点:
|
||||
- system/developer -> instructions 的提取与还原
|
||||
- tool_calls 与 tool role 的往返转换
|
||||
- content parts(text/image/unknown):UnknownBlock 内部保留、输出默认丢弃
|
||||
- finish_reason/usage 的映射
|
||||
- streaming chunk <-> InternalStreamEvent 的基础行为与状态
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, cast
|
||||
|
||||
from src.core.api_format.conversion.internal import (
|
||||
ContentType,
|
||||
ErrorType,
|
||||
ImageBlock,
|
||||
StopReason,
|
||||
TextBlock,
|
||||
ToolResultBlock,
|
||||
ToolUseBlock,
|
||||
UnknownBlock,
|
||||
)
|
||||
from src.core.api_format.conversion.normalizers.openai import OpenAINormalizer
|
||||
from src.core.api_format.conversion.stream_events import (
|
||||
ContentBlockStartEvent,
|
||||
ContentDeltaEvent,
|
||||
MessageStartEvent,
|
||||
MessageStopEvent,
|
||||
ToolCallDeltaEvent,
|
||||
)
|
||||
from src.core.api_format.conversion.stream_state import StreamState
|
||||
|
||||
|
||||
def _first_choice_message(response: Dict[str, Any]) -> Dict[str, Any]:
|
||||
choices = response.get("choices") or []
|
||||
assert isinstance(choices, list) and choices
|
||||
c0 = choices[0]
|
||||
assert isinstance(c0, dict)
|
||||
msg = c0.get("message")
|
||||
assert isinstance(msg, dict)
|
||||
return cast(Dict[str, Any], msg)
|
||||
|
||||
|
||||
def test_openai_request_instructions_roundtrip() -> None:
|
||||
n = OpenAINormalizer()
|
||||
|
||||
req = {
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "developer", "content": [{"type": "text", "text": "dev"}]},
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
],
|
||||
"max_tokens": 12,
|
||||
"temperature": 0.2,
|
||||
"stop": ["A", "B"],
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
internal = n.request_to_internal(req)
|
||||
assert internal.model == "gpt-4o-mini"
|
||||
assert [seg.role.value for seg in internal.instructions] == ["system", "developer"]
|
||||
assert [seg.text for seg in internal.instructions] == ["sys", "dev"]
|
||||
assert internal.system == "sys\n\ndev"
|
||||
assert [m.role.value for m in internal.messages] == ["user", "assistant"]
|
||||
assert internal.stop_sequences == ["A", "B"]
|
||||
assert internal.stream is True
|
||||
|
||||
out = n.request_from_internal(internal)
|
||||
assert out["model"] == "gpt-4o-mini"
|
||||
out_messages = out["messages"]
|
||||
assert [m["role"] for m in out_messages[:2]] == ["system", "developer"]
|
||||
assert [m["content"] for m in out_messages[:2]] == ["sys", "dev"]
|
||||
assert [m["role"] for m in out_messages[2:]] == ["user", "assistant"]
|
||||
assert out_messages[2]["content"] == "hi"
|
||||
assert out_messages[3]["content"] == "ok"
|
||||
|
||||
|
||||
def test_openai_request_tool_calls_and_tool_role_roundtrip() -> None:
|
||||
n = OpenAINormalizer()
|
||||
|
||||
req = {
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [
|
||||
{"role": "user", "content": "weather?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": '{"city":"SF"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": '{"temp_c": 20, "unit": "C"}',
|
||||
},
|
||||
{"role": "assistant", "content": "done"},
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
|
||||
},
|
||||
}
|
||||
],
|
||||
"tool_choice": "auto",
|
||||
}
|
||||
|
||||
internal = n.request_to_internal(req)
|
||||
assert [m.role.value for m in internal.messages] == ["user", "assistant", "user", "assistant"]
|
||||
|
||||
assistant_msg = internal.messages[1]
|
||||
assert any(isinstance(b, ToolUseBlock) for b in assistant_msg.content)
|
||||
tool_use = next(b for b in assistant_msg.content if isinstance(b, ToolUseBlock))
|
||||
assert tool_use.tool_id == "call_1"
|
||||
assert tool_use.tool_name == "get_weather"
|
||||
assert tool_use.tool_input == {"city": "SF"}
|
||||
|
||||
tool_result_msg = internal.messages[2]
|
||||
assert any(isinstance(b, ToolResultBlock) for b in tool_result_msg.content)
|
||||
tool_result = next(b for b in tool_result_msg.content if isinstance(b, ToolResultBlock))
|
||||
assert tool_result.tool_use_id == "call_1"
|
||||
assert tool_result.output == {"temp_c": 20, "unit": "C"}
|
||||
|
||||
out = n.request_from_internal(internal)
|
||||
out_messages: List[Dict[str, Any]] = out["messages"]
|
||||
|
||||
roles = [m.get("role") for m in out_messages]
|
||||
assert roles == ["user", "assistant", "tool", "assistant"]
|
||||
|
||||
assistant_out = out_messages[1]
|
||||
assert "tool_calls" in assistant_out
|
||||
assert assistant_out["tool_calls"][0]["function"]["name"] == "get_weather"
|
||||
|
||||
tool_out = out_messages[2]
|
||||
assert tool_out["role"] == "tool"
|
||||
assert tool_out["tool_call_id"] == "call_1"
|
||||
assert json.loads(tool_out["content"]) == {"temp_c": 20, "unit": "C"}
|
||||
|
||||
|
||||
def test_openai_request_content_image_and_unknown_drop() -> None:
|
||||
n = OpenAINormalizer()
|
||||
|
||||
req = {
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "look"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/a.png"}},
|
||||
{"type": "foo", "x": 1},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
internal = n.request_to_internal(req)
|
||||
assert len(internal.messages) == 1
|
||||
blocks = internal.messages[0].content
|
||||
assert any(isinstance(b, TextBlock) for b in blocks)
|
||||
assert any(isinstance(b, ImageBlock) for b in blocks)
|
||||
assert any(isinstance(b, UnknownBlock) for b in blocks)
|
||||
u = next(b for b in blocks if isinstance(b, UnknownBlock))
|
||||
assert u.raw_type == "foo"
|
||||
|
||||
dropped = (internal.extra.get("raw") or {}).get("dropped_blocks") or {}
|
||||
assert dropped.get("openai_part:foo") == 1
|
||||
|
||||
out = n.request_from_internal(internal)
|
||||
out_msg = out["messages"][0]
|
||||
assert out_msg["role"] == "user"
|
||||
assert isinstance(out_msg["content"], list)
|
||||
parts = cast(List[Dict[str, Any]], out_msg["content"])
|
||||
assert any(p.get("type") == "image_url" for p in parts)
|
||||
assert all(p.get("type") != "foo" for p in parts)
|
||||
|
||||
|
||||
def test_openai_response_finish_reason_and_usage_roundtrip() -> None:
|
||||
n = OpenAINormalizer()
|
||||
|
||||
resp = {
|
||||
"id": "chatcmpl_1",
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "hi"},
|
||||
"finish_reason": "length",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 5, "completion_tokens": 7, "total_tokens": 12},
|
||||
}
|
||||
|
||||
internal = n.response_to_internal(resp)
|
||||
assert internal.id == "chatcmpl_1"
|
||||
assert internal.stop_reason == StopReason.MAX_TOKENS
|
||||
assert internal.usage is not None
|
||||
assert internal.usage.input_tokens == 5
|
||||
assert internal.usage.output_tokens == 7
|
||||
assert internal.usage.total_tokens == 12
|
||||
|
||||
out = n.response_from_internal(internal)
|
||||
out_msg = _first_choice_message(out)
|
||||
assert out_msg["role"] == "assistant"
|
||||
assert out_msg["content"] == "hi"
|
||||
assert out["choices"][0]["finish_reason"] == "length"
|
||||
assert out["usage"] == {"prompt_tokens": 5, "completion_tokens": 7, "total_tokens": 12}
|
||||
|
||||
|
||||
def test_openai_stream_chunk_and_event_roundtrip_basic() -> None:
|
||||
n = OpenAINormalizer()
|
||||
state = StreamState()
|
||||
|
||||
chunks = [
|
||||
{
|
||||
"id": "chatcmpl_stream_1",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [{"index": 0, "delta": {"content": "Hel"}, "finish_reason": None}],
|
||||
},
|
||||
{"choices": [{"index": 0, "delta": {"content": "lo"}, "finish_reason": None}]},
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"index": 0,
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": '{"city":"SF"}'},
|
||||
}
|
||||
]
|
||||
},
|
||||
"finish_reason": None,
|
||||
}
|
||||
]
|
||||
},
|
||||
{"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]},
|
||||
]
|
||||
|
||||
events: List[Any] = []
|
||||
for ch in chunks:
|
||||
events.extend(n.stream_chunk_to_internal(ch, state))
|
||||
|
||||
assert any(isinstance(e, MessageStartEvent) for e in events)
|
||||
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)
|
||||
|
||||
# internal events -> OpenAI chunks(验证关键字段与 tool_calls index 稳定)
|
||||
state2 = StreamState()
|
||||
out_chunks: List[Dict[str, Any]] = []
|
||||
for e in events:
|
||||
out_chunks.extend(n.stream_event_from_internal(e, state2))
|
||||
|
||||
# 第一个 chunk 应包含 role=assistant
|
||||
assert out_chunks[0]["choices"][0]["delta"].get("role") == "assistant"
|
||||
|
||||
# 至少包含一个 content delta
|
||||
assert any(c["choices"][0]["delta"].get("content") == "Hel" for c in out_chunks)
|
||||
|
||||
# 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")
|
||||
)
|
||||
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 chunk(arguments 片段)
|
||||
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"]
|
||||
)
|
||||
assert tool_delta["choices"][0]["delta"]["tool_calls"][0]["id"] == "call_1"
|
||||
assert tool_delta["choices"][0]["delta"]["tool_calls"][0]["index"] == 0
|
||||
|
||||
# 最终 stop chunk finish_reason=tool_calls
|
||||
assert out_chunks[-1]["choices"][0]["finish_reason"] == "tool_calls"
|
||||
|
||||
|
||||
def test_openai_error_conversion() -> None:
|
||||
n = OpenAINormalizer()
|
||||
err_resp = {
|
||||
"error": {
|
||||
"message": "bad request",
|
||||
"type": "invalid_request_error",
|
||||
"code": "bad_request",
|
||||
"param": "messages",
|
||||
}
|
||||
}
|
||||
|
||||
internal = n.error_to_internal(err_resp)
|
||||
assert internal.type == ErrorType.INVALID_REQUEST
|
||||
assert internal.message == "bad request"
|
||||
assert internal.retryable is False
|
||||
|
||||
out = n.error_from_internal(internal)
|
||||
assert out["error"]["type"] == "invalid_request_error"
|
||||
assert out["error"]["message"] == "bad request"
|
||||
@@ -1,406 +0,0 @@
|
||||
"""
|
||||
FormatConverterRegistry 单元测试
|
||||
|
||||
测试覆盖:
|
||||
1. 基本注册和查询
|
||||
2. 能力查询方法
|
||||
3. 严格模式转换
|
||||
4. 流式转换签名适配
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.api_format.conversion.exceptions import FormatConversionError
|
||||
from src.core.api_format.conversion.registry import FormatConverterRegistry
|
||||
from src.core.api_format.conversion.state import StreamConversionState
|
||||
|
||||
|
||||
# ==================== Mock 转换器 ====================
|
||||
|
||||
|
||||
class MockRequestResponseConverter:
|
||||
"""只支持请求/响应转换的 Mock 转换器"""
|
||||
|
||||
def convert_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"converted": True, "original": request}
|
||||
|
||||
def convert_response(self, response: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"converted": True, "original": response}
|
||||
|
||||
|
||||
class MockNewSignatureStreamConverter:
|
||||
"""使用新签名 (chunk, state) 的流式转换器"""
|
||||
|
||||
def convert_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return request
|
||||
|
||||
def convert_response(self, response: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return response
|
||||
|
||||
def convert_stream_chunk(
|
||||
self, chunk: Dict[str, Any], state: StreamConversionState
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""新签名:返回多个事件"""
|
||||
events = []
|
||||
# 模拟一入多出:第一个 chunk 返回 2 个事件
|
||||
if not state.message_started:
|
||||
events.append({"type": "message_start", "model": state.model})
|
||||
state.message_started = True
|
||||
events.append({"type": "content", "data": chunk})
|
||||
return events
|
||||
|
||||
|
||||
class MockUnifiedStreamConverter:
|
||||
"""使用统一签名 (chunk, state) 的流式转换器"""
|
||||
|
||||
def convert_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return request
|
||||
|
||||
def convert_response(self, response: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return response
|
||||
|
||||
def convert_stream_chunk(
|
||||
self,
|
||||
chunk: Dict[str, Any],
|
||||
state: StreamConversionState,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""统一签名:返回事件列表"""
|
||||
events = []
|
||||
if not state.message_started:
|
||||
events.append({"type": "message_start", "model": state.model, "id": state.message_id})
|
||||
state.message_started = True
|
||||
events.append({"type": "delta", "chunk": chunk})
|
||||
return events
|
||||
|
||||
|
||||
class MockReturnsNoneConverter:
|
||||
"""流式转换返回空列表的转换器"""
|
||||
|
||||
def convert_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return request
|
||||
|
||||
def convert_response(self, response: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return response
|
||||
|
||||
def convert_stream_chunk(
|
||||
self,
|
||||
chunk: Dict[str, Any],
|
||||
state: StreamConversionState,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""对于不支持的事件类型返回空列表"""
|
||||
event_type = chunk.get("type")
|
||||
if event_type == "message_start":
|
||||
return [{"choices": [{"delta": {"role": "assistant"}}], "model": state.model}]
|
||||
if event_type == "content_block_delta":
|
||||
return [{"choices": [{"delta": {"content": chunk.get("text", "")}}]}]
|
||||
return []
|
||||
|
||||
|
||||
class MockFailingConverter:
|
||||
"""转换时抛出异常的 Mock 转换器"""
|
||||
|
||||
def convert_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise ValueError("Request conversion failed")
|
||||
|
||||
def convert_response(self, response: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise ValueError("Response conversion failed")
|
||||
|
||||
def convert_stream_chunk(
|
||||
self, chunk: Dict[str, Any], state: Any
|
||||
) -> List[Dict[str, Any]]:
|
||||
raise ValueError("Stream conversion failed")
|
||||
|
||||
|
||||
# ==================== 测试类 ====================
|
||||
|
||||
|
||||
class TestFormatConverterRegistryBasic:
|
||||
"""基本注册和查询测试"""
|
||||
|
||||
def test_register_and_get_converter(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
converter = MockRequestResponseConverter()
|
||||
|
||||
registry.register("OPENAI", "CLAUDE", converter)
|
||||
|
||||
assert registry.get_converter("OPENAI", "CLAUDE") is converter
|
||||
assert registry.get_converter("openai", "claude") is converter # 大小写不敏感
|
||||
|
||||
def test_get_nonexistent_converter_returns_none(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
|
||||
assert registry.get_converter("OPENAI", "CLAUDE") is None
|
||||
|
||||
def test_has_converter(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
converter = MockRequestResponseConverter()
|
||||
|
||||
registry.register("OPENAI", "CLAUDE", converter)
|
||||
|
||||
assert registry.has_converter("OPENAI", "CLAUDE") is True
|
||||
assert registry.has_converter("CLAUDE", "OPENAI") is False
|
||||
|
||||
def test_list_converters(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("OPENAI", "CLAUDE", MockRequestResponseConverter())
|
||||
registry.register("CLAUDE", "OPENAI", MockRequestResponseConverter())
|
||||
|
||||
converters = registry.list_converters()
|
||||
|
||||
assert ("OPENAI", "CLAUDE") in converters
|
||||
assert ("CLAUDE", "OPENAI") in converters
|
||||
assert len(converters) == 2
|
||||
|
||||
|
||||
class TestCapabilityQueries:
|
||||
"""能力查询方法测试"""
|
||||
|
||||
def test_can_convert_request(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("A", "B", MockRequestResponseConverter())
|
||||
|
||||
assert registry.can_convert_request("A", "B") is True
|
||||
assert registry.can_convert_request("B", "A") is False
|
||||
|
||||
def test_can_convert_response(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("A", "B", MockRequestResponseConverter())
|
||||
|
||||
assert registry.can_convert_response("A", "B") is True
|
||||
assert registry.can_convert_response("B", "A") is False
|
||||
|
||||
def test_can_convert_stream_with_convert_stream_chunk(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("A", "B", MockNewSignatureStreamConverter())
|
||||
|
||||
assert registry.can_convert_stream("A", "B") is True
|
||||
assert registry.can_convert_stream("B", "A") is False
|
||||
|
||||
def test_can_convert_stream_unified_signature(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("A", "B", MockUnifiedStreamConverter())
|
||||
|
||||
assert registry.can_convert_stream("A", "B") is True
|
||||
|
||||
def test_can_convert_full_request_response_only(self) -> None:
|
||||
"""只有请求/响应转换器,不支持流式"""
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("OPENAI", "CLAUDE", MockRequestResponseConverter())
|
||||
registry.register("CLAUDE", "OPENAI", MockRequestResponseConverter())
|
||||
|
||||
# 不要求流式:通过
|
||||
assert registry.can_convert_full("OPENAI", "CLAUDE", require_stream=False) is True
|
||||
# 要求流式:失败
|
||||
assert registry.can_convert_full("OPENAI", "CLAUDE", require_stream=True) is False
|
||||
|
||||
def test_can_convert_full_with_stream(self) -> None:
|
||||
"""完整双向转换(含流式)"""
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("OPENAI", "CLAUDE", MockNewSignatureStreamConverter())
|
||||
registry.register("CLAUDE", "OPENAI", MockNewSignatureStreamConverter())
|
||||
|
||||
assert registry.can_convert_full("OPENAI", "CLAUDE", require_stream=True) is True
|
||||
|
||||
def test_can_convert_full_missing_reverse(self) -> None:
|
||||
"""缺少反向转换器"""
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("OPENAI", "CLAUDE", MockNewSignatureStreamConverter())
|
||||
# 没有 CLAUDE -> OPENAI
|
||||
|
||||
assert registry.can_convert_full("OPENAI", "CLAUDE", require_stream=False) is False
|
||||
|
||||
def test_get_supported_targets(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("OPENAI", "CLAUDE", MockRequestResponseConverter())
|
||||
registry.register("OPENAI", "GEMINI", MockRequestResponseConverter())
|
||||
registry.register("CLAUDE", "OPENAI", MockRequestResponseConverter())
|
||||
|
||||
targets = registry.get_supported_targets("OPENAI")
|
||||
|
||||
assert "CLAUDE" in targets
|
||||
assert "GEMINI" in targets
|
||||
assert len(targets) == 2
|
||||
|
||||
|
||||
class TestStrictModeConversion:
|
||||
"""严格模式转换测试"""
|
||||
|
||||
def test_convert_request_strict_success(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("A", "B", MockRequestResponseConverter())
|
||||
|
||||
result = registry.convert_request_strict({"foo": "bar"}, "A", "B")
|
||||
|
||||
assert result["converted"] is True
|
||||
assert result["original"] == {"foo": "bar"}
|
||||
|
||||
def test_convert_request_strict_same_format(self) -> None:
|
||||
"""同格式返回原始请求"""
|
||||
registry = FormatConverterRegistry()
|
||||
|
||||
result = registry.convert_request_strict({"foo": "bar"}, "A", "A")
|
||||
|
||||
assert result == {"foo": "bar"}
|
||||
|
||||
def test_convert_request_strict_no_converter(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
|
||||
with pytest.raises(FormatConversionError) as exc_info:
|
||||
registry.convert_request_strict({"foo": "bar"}, "A", "B")
|
||||
|
||||
assert "未找到转换器" in str(exc_info.value)
|
||||
|
||||
def test_convert_request_strict_converter_fails(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("A", "B", MockFailingConverter())
|
||||
|
||||
with pytest.raises(FormatConversionError) as exc_info:
|
||||
registry.convert_request_strict({"foo": "bar"}, "A", "B")
|
||||
|
||||
assert "Request conversion failed" in str(exc_info.value)
|
||||
|
||||
def test_convert_response_strict_success(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("A", "B", MockRequestResponseConverter())
|
||||
|
||||
result = registry.convert_response_strict({"data": "test"}, "A", "B")
|
||||
|
||||
assert result["converted"] is True
|
||||
|
||||
def test_convert_response_strict_no_converter(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
|
||||
with pytest.raises(FormatConversionError):
|
||||
registry.convert_response_strict({"data": "test"}, "A", "B")
|
||||
|
||||
|
||||
class TestStreamChunkStrictConversion:
|
||||
"""流式转换严格模式测试"""
|
||||
|
||||
def test_stream_chunk_strict_same_format(self) -> None:
|
||||
"""同格式返回原始 chunk 包装在列表中"""
|
||||
registry = FormatConverterRegistry()
|
||||
chunk = {"type": "delta", "text": "hello"}
|
||||
|
||||
result = registry.convert_stream_chunk_strict(chunk, "A", "A")
|
||||
|
||||
assert result == [chunk]
|
||||
|
||||
def test_stream_chunk_strict_no_converter(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
|
||||
with pytest.raises(FormatConversionError) as exc_info:
|
||||
registry.convert_stream_chunk_strict({"type": "delta"}, "A", "B")
|
||||
|
||||
assert "未找到转换器" in str(exc_info.value)
|
||||
|
||||
def test_stream_chunk_strict_new_signature(self) -> None:
|
||||
"""测试新签名 (chunk, state) 转换器"""
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("A", "B", MockNewSignatureStreamConverter())
|
||||
|
||||
state = StreamConversionState(model="test-model", message_id="msg_123")
|
||||
chunk = {"content": "hello"}
|
||||
|
||||
# 第一次调用:应返回 2 个事件(message_start + content)
|
||||
result = registry.convert_stream_chunk_strict(chunk, "A", "B", state)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["type"] == "message_start"
|
||||
assert result[1]["type"] == "content"
|
||||
assert state.message_started is True
|
||||
|
||||
# 第二次调用:应返回 1 个事件(只有 content)
|
||||
result2 = registry.convert_stream_chunk_strict({"content": "world"}, "A", "B", state)
|
||||
|
||||
assert len(result2) == 1
|
||||
assert result2[0]["type"] == "content"
|
||||
|
||||
def test_stream_chunk_strict_unified_signature(self) -> None:
|
||||
"""测试统一签名 (chunk, state) 转换器"""
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("A", "B", MockUnifiedStreamConverter())
|
||||
|
||||
state = StreamConversionState(
|
||||
model="gpt-4", message_id="chatcmpl_123", message_started=False
|
||||
)
|
||||
chunk = {"text": "hello"}
|
||||
|
||||
result = registry.convert_stream_chunk_strict(chunk, "A", "B", state)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["type"] == "message_start"
|
||||
assert result[0]["model"] == "gpt-4"
|
||||
assert result[0]["id"] == "chatcmpl_123"
|
||||
assert state.message_started is True
|
||||
|
||||
def test_stream_chunk_strict_returns_empty_list(self) -> None:
|
||||
"""测试转换器返回空列表"""
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("CLAUDE", "OPENAI", MockReturnsNoneConverter())
|
||||
|
||||
state = StreamConversionState(model="gpt-4", message_id="msg_123")
|
||||
event = {"type": "message_start"}
|
||||
|
||||
result = registry.convert_stream_chunk_strict(event, "CLAUDE", "OPENAI", state)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "choices" in result[0]
|
||||
assert result[0]["model"] == "gpt-4"
|
||||
|
||||
def test_stream_chunk_strict_unknown_event_returns_empty(self) -> None:
|
||||
"""不支持的事件类型应返回空列表"""
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("CLAUDE", "OPENAI", MockReturnsNoneConverter())
|
||||
|
||||
state = StreamConversionState(model="gpt-4", message_id="msg_123")
|
||||
event = {"type": "unknown_event"} # 不支持的事件类型
|
||||
|
||||
result = registry.convert_stream_chunk_strict(event, "CLAUDE", "OPENAI", state)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_stream_chunk_strict_converter_fails(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("A", "B", MockFailingConverter())
|
||||
|
||||
state = StreamConversionState(model="test", message_id="123")
|
||||
|
||||
with pytest.raises(FormatConversionError) as exc_info:
|
||||
registry.convert_stream_chunk_strict({"data": "test"}, "A", "B", state)
|
||||
|
||||
assert "流式块转换失败" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestNonStrictConversion:
|
||||
"""非严格模式转换测试(失败时返回原始数据)"""
|
||||
|
||||
def test_convert_request_fallback_on_failure(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("A", "B", MockFailingConverter())
|
||||
|
||||
original = {"foo": "bar"}
|
||||
result = registry.convert_request(original, "A", "B")
|
||||
|
||||
# 非严格模式:失败时返回原始请求
|
||||
assert result == original
|
||||
|
||||
def test_convert_response_fallback_on_failure(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("A", "B", MockFailingConverter())
|
||||
|
||||
original = {"data": "test"}
|
||||
result = registry.convert_response(original, "A", "B")
|
||||
|
||||
assert result == original
|
||||
|
||||
def test_convert_stream_chunk_fallback_on_failure(self) -> None:
|
||||
registry = FormatConverterRegistry()
|
||||
registry.register("A", "B", MockFailingConverter())
|
||||
|
||||
original = {"chunk": "data"}
|
||||
result = registry.convert_stream_chunk(original, "A", "B")
|
||||
|
||||
assert result == [original]
|
||||
105
tests/core/api_format/conversion/test_registry_canonical.py
Normal file
105
tests/core/api_format/conversion/test_registry_canonical.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Canonical Registry 单元测试
|
||||
|
||||
覆盖重点:
|
||||
- request/response/stream 的基本两段式转换(source -> internal -> target)
|
||||
- 严格模式下的可用性(已注册格式可转换)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, cast
|
||||
|
||||
from src.core.api_format.conversion.normalizers.claude import ClaudeNormalizer
|
||||
from src.core.api_format.conversion.normalizers.gemini import GeminiNormalizer
|
||||
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
|
||||
|
||||
|
||||
def _make_registry() -> FormatConversionRegistry:
|
||||
reg = FormatConversionRegistry()
|
||||
reg.register(OpenAINormalizer())
|
||||
reg.register(ClaudeNormalizer())
|
||||
reg.register(GeminiNormalizer())
|
||||
return reg
|
||||
|
||||
|
||||
def _first_openai_choice_message(resp: Dict[str, Any]) -> Dict[str, Any]:
|
||||
choices = resp.get("choices") or []
|
||||
assert isinstance(choices, list) and choices
|
||||
c0 = choices[0]
|
||||
assert isinstance(c0, dict)
|
||||
msg = c0.get("message")
|
||||
assert isinstance(msg, dict)
|
||||
return cast(Dict[str, Any], msg)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_registry_canonical_request_openai_to_claude() -> None:
|
||||
reg = _make_registry()
|
||||
|
||||
openai_req = {
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "developer", "content": "dev"},
|
||||
{"role": "user", "content": "hi"},
|
||||
],
|
||||
"max_tokens": 12,
|
||||
"temperature": 0.2,
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
claude_req = reg.convert_request(openai_req, "OPENAI", "CLAUDE")
|
||||
assert claude_req["model"] == "gpt-4o-mini"
|
||||
assert claude_req["system"] == "sys\n\ndev"
|
||||
assert claude_req["stream"] is True
|
||||
assert isinstance(claude_req.get("messages"), list)
|
||||
assert claude_req["messages"][0]["role"] == "user"
|
||||
assert claude_req["messages"][0]["content"] == "hi"
|
||||
|
||||
|
||||
def test_registry_canonical_response_claude_to_openai() -> None:
|
||||
reg = _make_registry()
|
||||
|
||||
claude_resp = {
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"content": [{"type": "text", "text": "hello"}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 5, "output_tokens": 7},
|
||||
}
|
||||
|
||||
openai_resp = reg.convert_response(claude_resp, "CLAUDE", "OPENAI")
|
||||
assert openai_resp["object"] == "chat.completion"
|
||||
msg = _first_openai_choice_message(openai_resp)
|
||||
assert msg["role"] == "assistant"
|
||||
assert msg["content"] == "hello"
|
||||
|
||||
|
||||
def test_registry_canonical_stream_openai_to_claude() -> None:
|
||||
reg = _make_registry()
|
||||
|
||||
chunk = {
|
||||
"id": "chatcmpl_1",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [{"index": 0, "delta": {"role": "assistant", "content": "hi"}, "finish_reason": None}],
|
||||
}
|
||||
|
||||
state = StreamState()
|
||||
out_events = reg.convert_stream_chunk(chunk, "OPENAI", "CLAUDE", 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)]
|
||||
assert types[:3] == ["message_start", "content_block_start", "content_block_delta"]
|
||||
@@ -1,7 +1,8 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from src.core.api_format import APIFormat, register_all_converters
|
||||
from src.core.api_format import APIFormat
|
||||
from src.core.api_format.conversion import register_default_normalizers
|
||||
from src.services.cache.aware_scheduler import CacheAwareScheduler
|
||||
|
||||
|
||||
@@ -26,7 +27,7 @@ def _mock_endpoint(api_format: str, config: dict | None = None) -> MagicMock:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_candidates_blocks_cross_format_when_global_switch_off() -> None:
|
||||
register_all_converters()
|
||||
register_default_normalizers()
|
||||
|
||||
scheduler = CacheAwareScheduler()
|
||||
scheduler._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[attr-defined]
|
||||
@@ -56,7 +57,7 @@ async def test_build_candidates_blocks_cross_format_when_global_switch_off() ->
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_candidates_includes_cross_format_when_enabled() -> None:
|
||||
register_all_converters()
|
||||
register_default_normalizers()
|
||||
|
||||
scheduler = CacheAwareScheduler()
|
||||
scheduler._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[attr-defined]
|
||||
@@ -88,7 +89,7 @@ async def test_build_candidates_includes_cross_format_when_enabled() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_matches_rank_before_convertible() -> None:
|
||||
register_all_converters()
|
||||
register_default_normalizers()
|
||||
|
||||
scheduler = CacheAwareScheduler()
|
||||
scheduler._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[attr-defined]
|
||||
|
||||
Reference in New Issue
Block a user