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:
fawney19
2026-01-27 02:17:18 +08:00
parent 6cf251b19a
commit add477fece
84 changed files with 9169 additions and 3932 deletions

View File

@@ -0,0 +1 @@

View File

@@ -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"
}
}
]
}
]
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"
}
}
]
}
]
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
]

View File

@@ -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"
}
]

View File

@@ -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"
}
]

View File

@@ -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"
}
]

View File

@@ -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"
}
]

View File

@@ -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"
}
]

View File

@@ -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
}

View File

@@ -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"
}
}
]
}
]
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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"
}
]

View File

@@ -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
}
}
]

View File

@@ -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"
}
]

View 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

View File

@@ -0,0 +1,577 @@
"""
CLI 格式参与转换的单元测试
覆盖:
- OPENAI_CLIResponses与其他格式的 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

View File

@@ -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

View 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"

View File

@@ -0,0 +1,273 @@
"""
GeminiNormalizer 单元测试
覆盖重点:
- systemInstruction/system_instruction -> instructions 的提取与还原
- partstext/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"

View File

@@ -0,0 +1,116 @@
"""
Golden testsCanonical
说明:
- 这些 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

View 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

View File

@@ -0,0 +1,317 @@
"""
OpenAINormalizer 单元测试
覆盖重点:
- system/developer -> instructions 的提取与还原
- tool_calls 与 tool role 的往返转换
- content partstext/image/unknownUnknownBlock 内部保留、输出默认丢弃
- 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 chunkarguments 片段)
tool_delta = next(
c for c in out_chunks if c["choices"][0]["delta"].get("tool_calls") and "arguments" in c["choices"][0]["delta"]["tool_calls"][0]["function"]
)
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"

View File

@@ -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]

View 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"]