refactor: 完善跨格式 normalizer 转换精度,统一 CLI 流式 buffer flush 逻辑

- OpenAI normalizer: 修正 file/image 内容块的标准格式解析与输出,
  assistant 有 tool_calls 时 content 输出 null,非流式 tool_calls
  不再包含 index 字段,保留 system_fingerprint/service_tier roundtrip
- OpenAI CLI normalizer: 支持 reasoning/ThinkingConfig 双向转换,
  补全 parallel_tool_calls/required tool_choice,input_file 解析,
  function_call_output.output 强制字符串化,流式事件补全 item_id/
  output_index/content_index 字段
- Gemini normalizer: 扩展 finishReason 映射,自动检测 tool_use
  stop_reason,保留 safetySettings/cachedContent/generationConfig
  额外字段 roundtrip,error_from_internal 使用精确 HTTP status code
- Claude normalizer: 修正 tool_choice type="tool" 输出,扩展
  extra 提取白名单
- internal.py: 为所有 dataclass 补充跨格式映射文档和修改须知
- stream_bridge: aggregator 新增 open_count/final_count 诊断属性,
  build() 时 flush 未关闭的 open blocks
- CLI handler: 提取 _flush_buffer_with_conversion 统一 prefetch/
  stream 两条路径的 buffer + SSE parser flush 逻辑
- upstream_stream_bridge: 新增事件类型计数和聚合器状态诊断日志
- 新增 fixtures 和测试: roundtrip/to_internal/cross_format/error/
  stream 等多维度转换测试
This commit is contained in:
fawney19
2026-02-20 19:45:14 +08:00
parent d7f0a555c0
commit 37758c2032
26 changed files with 5441 additions and 125 deletions

View File

@@ -0,0 +1,6 @@
"""
Format conversion test fixtures.
Provides golden internal representations, format-specific fixtures,
stream fixtures, error fixtures, and assertion helpers.
"""

View File

@@ -0,0 +1,361 @@
"""
Assertion helpers for format conversion tests.
Provides semantic comparison functions that check meaningful equivalence
while tolerating format-specific differences (extra fields, id regeneration, etc.).
"""
from __future__ import annotations
from collections.abc import Sequence
from src.core.api_format.conversion.internal import (
ContentBlock,
ImageBlock,
InternalMessage,
InternalRequest,
InternalResponse,
StopReason,
TextBlock,
ThinkingBlock,
ToolDefinition,
ToolResultBlock,
ToolUseBlock,
UnknownBlock,
)
from src.core.api_format.conversion.stream_events import (
ContentBlockStartEvent,
ContentDeltaEvent,
InternalStreamEvent,
MessageStopEvent,
ToolCallDeltaEvent,
)
def assert_internal_request_matches(
actual: InternalRequest,
expected: InternalRequest,
required_fields: set[str],
) -> None:
"""Verify that actual InternalRequest matches expected on required fields."""
if "model" in required_fields:
assert (
actual.model == expected.model
), f"model mismatch: {actual.model!r} != {expected.model!r}"
if "messages" in required_fields:
# Merge consecutive same-role messages before comparison,
# since normalizers may merge/split them during conversion.
actual_msgs = _merge_consecutive_same_role(actual.messages)
expected_msgs = _merge_consecutive_same_role(expected.messages)
assert len(actual_msgs) == len(
expected_msgs
), f"message count mismatch: {len(actual_msgs)} != {len(expected_msgs)}"
for i, (a, e) in enumerate(zip(actual_msgs, expected_msgs)):
assert a.role == e.role, f"message[{i}] role mismatch: {a.role} != {e.role}"
assert_content_blocks_match_unordered(a.content, e.content, context=f"message[{i}]")
if "system" in required_fields:
# Allow either system or instructions to carry the system prompt
actual_sys = actual.system or _join_instructions(actual.instructions)
expected_sys = expected.system or _join_instructions(expected.instructions)
assert actual_sys == expected_sys, f"system mismatch: {actual_sys!r} != {expected_sys!r}"
if "max_tokens" in required_fields:
assert (
actual.max_tokens == expected.max_tokens
), f"max_tokens mismatch: {actual.max_tokens} != {expected.max_tokens}"
if "stream" in required_fields:
assert actual.stream == expected.stream
if "tools" in required_fields:
assert_tools_match(actual.tools, expected.tools)
if "tool_choice" in required_fields:
if expected.tool_choice is not None:
assert actual.tool_choice is not None, "tool_choice is None but expected non-None"
assert (
actual.tool_choice.type == expected.tool_choice.type
), f"tool_choice.type mismatch: {actual.tool_choice.type} != {expected.tool_choice.type}"
def assert_internal_response_matches(
actual: InternalResponse,
expected: InternalResponse,
required_fields: set[str],
) -> None:
"""Verify that actual InternalResponse matches expected on required fields."""
if "content" in required_fields:
assert_content_blocks_match(actual.content, expected.content, context="response")
if "stop_reason" in required_fields:
assert (
actual.stop_reason == expected.stop_reason
), f"stop_reason mismatch: {actual.stop_reason} != {expected.stop_reason}"
if "usage" in required_fields and expected.usage is not None:
assert actual.usage is not None, "usage is None but expected non-None"
assert actual.usage.input_tokens == expected.usage.input_tokens
assert actual.usage.output_tokens == expected.usage.output_tokens
def assert_content_blocks_match(
actual_blocks: list[ContentBlock],
expected_blocks: list[ContentBlock],
*,
context: str = "",
) -> None:
"""Verify content block lists are semantically equivalent (ignoring extra)."""
# Filter out UnknownBlock (allowed to be lost)
actual_meaningful = [b for b in actual_blocks if not isinstance(b, UnknownBlock)]
expected_meaningful = [b for b in expected_blocks if not isinstance(b, UnknownBlock)]
assert len(actual_meaningful) == len(expected_meaningful), (
f"{context} block count mismatch: {len(actual_meaningful)} != {len(expected_meaningful)}"
f"\n actual types: {[type(b).__name__ for b in actual_meaningful]}"
f"\n expected types: {[type(b).__name__ for b in expected_meaningful]}"
)
for i, (a, e) in enumerate(zip(actual_meaningful, expected_meaningful)):
prefix = f"{context}.block[{i}]" if context else f"block[{i}]"
assert type(a) is type(
e
), f"{prefix} type mismatch: {type(a).__name__} != {type(e).__name__}"
if isinstance(a, TextBlock) and isinstance(e, TextBlock):
assert a.text == e.text, f"{prefix} text mismatch: {a.text!r} != {e.text!r}"
elif isinstance(a, ToolUseBlock) and isinstance(e, ToolUseBlock):
assert (
a.tool_name == e.tool_name
), f"{prefix} tool_name mismatch: {a.tool_name!r} != {e.tool_name!r}"
assert (
a.tool_input == e.tool_input
), f"{prefix} tool_input mismatch: {a.tool_input} != {e.tool_input}"
# tool_id may be regenerated, just verify non-empty
assert bool(a.tool_id), f"{prefix} tool_id is empty"
elif isinstance(a, ToolResultBlock) and isinstance(e, ToolResultBlock):
assert bool(a.tool_use_id), f"{prefix} tool_use_id is empty"
# content_text or output should be semantically equivalent
# Normalizers may parse JSON strings into dicts, so compare semantically
a_val = _normalize_tool_output(a)
e_val = _normalize_tool_output(e)
assert a_val == e_val, f"{prefix} tool result mismatch: {a_val!r} != {e_val!r}"
elif isinstance(a, ThinkingBlock) and isinstance(e, ThinkingBlock):
assert (
a.thinking == e.thinking
), f"{prefix} thinking mismatch: {a.thinking!r} != {e.thinking!r}"
elif isinstance(a, ImageBlock) and isinstance(e, ImageBlock):
if e.url:
assert a.url == e.url, f"{prefix} image url mismatch"
if e.data:
assert a.data == e.data, f"{prefix} image data mismatch"
if e.media_type:
assert a.media_type == e.media_type, f"{prefix} media_type mismatch"
def assert_tools_match(
actual: list[ToolDefinition] | None,
expected: list[ToolDefinition] | None,
) -> None:
"""Verify tool definitions match."""
if expected is None:
return
assert actual is not None, "tools is None but expected non-None"
assert len(actual) == len(expected), f"tools count mismatch: {len(actual)} != {len(expected)}"
for i, (a, e) in enumerate(zip(actual, expected)):
assert a.name == e.name, f"tool[{i}].name mismatch: {a.name!r} != {e.name!r}"
if e.description is not None:
assert a.description == e.description, f"tool[{i}].description mismatch"
if e.parameters is not None:
assert a.parameters == e.parameters, f"tool[{i}].parameters mismatch"
def assert_content_blocks_match_unordered(
actual_blocks: list[ContentBlock],
expected_blocks: list[ContentBlock],
*,
context: str = "",
) -> None:
"""Verify content blocks are semantically equivalent regardless of order.
Groups blocks by type and compares within each group. This tolerates
reordering that normalizers may introduce during roundtrip (e.g. placing
tool_result before or after text within the same message).
"""
actual_meaningful = [b for b in actual_blocks if not isinstance(b, UnknownBlock)]
expected_meaningful = [b for b in expected_blocks if not isinstance(b, UnknownBlock)]
assert len(actual_meaningful) == len(expected_meaningful), (
f"{context} block count mismatch: {len(actual_meaningful)} != {len(expected_meaningful)}"
f"\n actual types: {[type(b).__name__ for b in actual_meaningful]}"
f"\n expected types: {[type(b).__name__ for b in expected_meaningful]}"
)
def _group_by_type(blocks: Sequence[ContentBlock]) -> dict[type, list[ContentBlock]]:
groups: dict[type, list[ContentBlock]] = {}
for b in blocks:
groups.setdefault(type(b), []).append(b)
return groups
actual_groups = _group_by_type(actual_meaningful)
expected_groups = _group_by_type(expected_meaningful)
assert set(actual_groups.keys()) == set(expected_groups.keys()), (
f"{context} block type sets differ: "
f"{[t.__name__ for t in actual_groups]} != {[t.__name__ for t in expected_groups]}"
)
for btype in expected_groups:
a_list = actual_groups[btype]
e_list = expected_groups[btype]
assert len(a_list) == len(
e_list
), f"{context} {btype.__name__} count mismatch: {len(a_list)} != {len(e_list)}"
for i, (a, e) in enumerate(zip(a_list, e_list)):
prefix = f"{context}.{btype.__name__}[{i}]" if context else f"{btype.__name__}[{i}]"
if isinstance(a, TextBlock) and isinstance(e, TextBlock):
assert a.text == e.text, f"{prefix} text mismatch: {a.text!r} != {e.text!r}"
elif isinstance(a, ToolUseBlock) and isinstance(e, ToolUseBlock):
assert a.tool_name == e.tool_name, f"{prefix} tool_name mismatch"
assert a.tool_input == e.tool_input, f"{prefix} tool_input mismatch"
elif isinstance(a, ToolResultBlock) and isinstance(e, ToolResultBlock):
a_val = _normalize_tool_output(a)
e_val = _normalize_tool_output(e)
assert a_val == e_val, f"{prefix} tool result mismatch: {a_val!r} != {e_val!r}"
elif isinstance(a, ThinkingBlock) and isinstance(e, ThinkingBlock):
assert a.thinking == e.thinking, f"{prefix} thinking mismatch"
elif isinstance(a, ImageBlock) and isinstance(e, ImageBlock):
if e.url:
assert a.url == e.url, f"{prefix} image url mismatch"
if e.data:
assert a.data == e.data, f"{prefix} image data mismatch"
def _merge_consecutive_same_role(
messages: list[InternalMessage],
) -> list[InternalMessage]:
"""Merge consecutive messages with the same role into one (for semantic comparison)."""
if not messages:
return []
merged: list[InternalMessage] = []
for msg in messages:
if merged and merged[-1].role == msg.role:
merged[-1] = InternalMessage(
role=msg.role,
content=list(merged[-1].content) + list(msg.content),
)
else:
merged.append(InternalMessage(role=msg.role, content=list(msg.content)))
return merged
def assert_internal_requests_equivalent(
a: InternalRequest,
b: InternalRequest,
lossy_fields: set[str] | None = None,
) -> None:
"""Verify two InternalRequests are semantically equivalent after roundtrip."""
lossy = lossy_fields or set()
assert a.model == b.model
if "messages" not in lossy:
# Merge consecutive same-role messages before comparison,
# since normalizers may merge/split them during roundtrip.
a_msgs = _merge_consecutive_same_role(a.messages)
b_msgs = _merge_consecutive_same_role(b.messages)
assert len(a_msgs) == len(
b_msgs
), f"message count mismatch after merge: {len(a_msgs)} != {len(b_msgs)}"
for i, (am, bm) in enumerate(zip(a_msgs, b_msgs)):
assert am.role == bm.role, f"message[{i}] role mismatch after roundtrip"
# Use order-insensitive comparison: normalizers may reorder blocks
# within a message during roundtrip (e.g. tool_result before/after text).
assert_content_blocks_match_unordered(
am.content, bm.content, context=f"roundtrip.message[{i}]"
)
if "system" not in lossy:
a_sys = a.system or _join_instructions(a.instructions)
b_sys = b.system or _join_instructions(b.instructions)
assert a_sys == b_sys
if "max_tokens" not in lossy:
assert a.max_tokens == b.max_tokens
if "tools" not in lossy:
assert_tools_match(a.tools, b.tools)
def assert_stream_text_matches(
events: list[InternalStreamEvent],
expected_text: str,
) -> None:
"""Verify that stream events produce the expected text when concatenated."""
parts: list[str] = []
for evt in events:
if isinstance(evt, ContentDeltaEvent) and evt.text_delta:
parts.append(evt.text_delta)
actual = "".join(parts)
assert actual == expected_text, f"stream text mismatch: {actual!r} != {expected_text!r}"
def assert_stream_stop_reason_matches(
events: list[InternalStreamEvent],
expected: StopReason,
) -> None:
"""Verify that the stream ends with the expected stop reason."""
stop_events = [e for e in events if isinstance(e, MessageStopEvent)]
assert stop_events, "no MessageStopEvent found in stream events"
last = stop_events[-1]
assert (
last.stop_reason == expected
), f"stream stop_reason mismatch: {last.stop_reason} != {expected}"
def _join_instructions(instructions: list) -> str | None:
if not instructions:
return None
parts = [seg.text for seg in instructions if seg.text]
return "\n\n".join(parts) or None
def _normalize_tool_output(block: ToolResultBlock) -> object:
"""Normalize tool output for comparison (parse JSON strings to dicts)."""
import json
val = block.content_text if block.content_text is not None else block.output
if val is None:
return ""
if isinstance(val, str):
try:
return json.loads(val)
except (json.JSONDecodeError, TypeError):
return val
return val
def assert_stream_has_tool_call(
events: list[InternalStreamEvent],
expected_tool_name: str,
) -> None:
"""Verify that stream events contain a tool call with the expected name."""
from src.core.api_format.conversion.internal import ContentType
tool_starts = [
e
for e in events
if isinstance(e, ContentBlockStartEvent) and e.block_type == ContentType.TOOL_USE
]
assert tool_starts, "no tool call ContentBlockStartEvent found in stream events"
names = [e.tool_name for e in tool_starts]
assert (
expected_tool_name in names
), f"tool name {expected_tool_name!r} not found in stream tool starts: {names}"
# Verify there are ToolCallDeltaEvents with non-empty input
tool_deltas = [e for e in events if isinstance(e, ToolCallDeltaEvent)]
assert tool_deltas, "no ToolCallDeltaEvent found in stream events"
combined = "".join(d.input_delta for d in tool_deltas)
assert combined, "tool call input_delta is empty after concatenation"

View File

@@ -0,0 +1,258 @@
"""
Error fixtures for each format.
Each fixture defines a format-specific error response and the expected
InternalError it should produce.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from src.core.api_format.conversion.internal import ErrorType
@dataclass
class ErrorFixture:
"""A format-specific error fixture."""
error_response: dict[str, Any]
expected_type: ErrorType
expected_message: str
# ===================================================================
# Claude error responses
# ===================================================================
_CLAUDE_ERRORS: dict[str, ErrorFixture] = {
"invalid_request": ErrorFixture(
error_response={
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "max_tokens must be a positive integer",
},
},
expected_type=ErrorType.INVALID_REQUEST,
expected_message="max_tokens must be a positive integer",
),
"rate_limit": ErrorFixture(
error_response={
"type": "error",
"error": {
"type": "rate_limit_error",
"message": "Rate limit exceeded",
},
},
expected_type=ErrorType.RATE_LIMIT,
expected_message="Rate limit exceeded",
),
"auth_error": ErrorFixture(
error_response={
"type": "error",
"error": {
"type": "authentication_error",
"message": "Invalid API key",
},
},
expected_type=ErrorType.AUTHENTICATION,
expected_message="Invalid API key",
),
"overloaded": ErrorFixture(
error_response={
"type": "error",
"error": {
"type": "overloaded_error",
"message": "Overloaded",
},
},
expected_type=ErrorType.OVERLOADED,
expected_message="Overloaded",
),
"server_error": ErrorFixture(
error_response={
"type": "error",
"error": {
"type": "api_error",
"message": "Internal server error",
},
},
expected_type=ErrorType.SERVER_ERROR,
expected_message="Internal server error",
),
"not_found": ErrorFixture(
error_response={
"type": "error",
"error": {
"type": "not_found_error",
"message": "Model not found",
},
},
expected_type=ErrorType.NOT_FOUND,
expected_message="Model not found",
),
}
# ===================================================================
# OpenAI Chat error responses
# ===================================================================
_OPENAI_CHAT_ERRORS: dict[str, ErrorFixture] = {
"invalid_request": ErrorFixture(
error_response={
"error": {
"message": "Invalid value for max_tokens",
"type": "invalid_request_error",
"param": "max_tokens",
"code": None,
}
},
expected_type=ErrorType.INVALID_REQUEST,
expected_message="Invalid value for max_tokens",
),
"rate_limit": ErrorFixture(
error_response={
"error": {
"message": "Rate limit reached",
"type": "rate_limit_exceeded",
"param": None,
"code": "rate_limit_exceeded",
}
},
expected_type=ErrorType.RATE_LIMIT,
expected_message="Rate limit reached",
),
"auth_error": ErrorFixture(
error_response={
"error": {
"message": "Incorrect API key provided",
"type": "invalid_api_key",
"param": None,
"code": "invalid_api_key",
}
},
expected_type=ErrorType.AUTHENTICATION,
expected_message="Incorrect API key provided",
),
"server_error": ErrorFixture(
error_response={
"error": {
"message": "The server had an error",
"type": "server_error",
"param": None,
"code": "server_error",
}
},
expected_type=ErrorType.SERVER_ERROR,
expected_message="The server had an error",
),
}
# ===================================================================
# OpenAI CLI (Responses API) error responses
# ===================================================================
_OPENAI_CLI_ERRORS: dict[str, ErrorFixture] = {
"invalid_request": ErrorFixture(
error_response={
"error": {
"message": "Invalid input",
"type": "invalid_request_error",
"code": None,
}
},
expected_type=ErrorType.INVALID_REQUEST,
expected_message="Invalid input",
),
"rate_limit": ErrorFixture(
error_response={
"error": {
"message": "Rate limit reached",
"type": "rate_limit_exceeded",
"code": "rate_limit_exceeded",
}
},
expected_type=ErrorType.RATE_LIMIT,
expected_message="Rate limit reached",
),
"server_error": ErrorFixture(
error_response={
"error": {
"message": "Internal server error",
"type": "server_error",
"code": "server_error",
}
},
expected_type=ErrorType.SERVER_ERROR,
expected_message="Internal server error",
),
}
# ===================================================================
# Gemini error responses
# ===================================================================
_GEMINI_ERRORS: dict[str, ErrorFixture] = {
"invalid_request": ErrorFixture(
error_response={
"error": {
"code": 400,
"message": "Invalid value for field",
"status": "INVALID_ARGUMENT",
}
},
expected_type=ErrorType.INVALID_REQUEST,
expected_message="Invalid value for field",
),
"rate_limit": ErrorFixture(
error_response={
"error": {
"code": 429,
"message": "Resource exhausted",
"status": "RESOURCE_EXHAUSTED",
}
},
expected_type=ErrorType.RATE_LIMIT,
expected_message="Resource exhausted",
),
"auth_error": ErrorFixture(
error_response={
"error": {
"code": 401,
"message": "API key not valid",
"status": "UNAUTHENTICATED",
}
},
expected_type=ErrorType.AUTHENTICATION,
expected_message="API key not valid",
),
"server_error": ErrorFixture(
error_response={
"error": {
"code": 500,
"message": "Internal error encountered",
"status": "INTERNAL",
}
},
expected_type=ErrorType.SERVER_ERROR,
expected_message="Internal error encountered",
),
}
# ===================================================================
# Registry
# ===================================================================
ERROR_FIXTURES: dict[str, dict[str, ErrorFixture]] = {
"claude:chat": _CLAUDE_ERRORS,
"claude:cli": _CLAUDE_ERRORS,
"openai:chat": _OPENAI_CHAT_ERRORS,
"openai:cli": _OPENAI_CLI_ERRORS,
"gemini:chat": _GEMINI_ERRORS,
"gemini:cli": _GEMINI_ERRORS,
}
ERROR_ALL_FORMATS = list(ERROR_FIXTURES.keys())

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,425 @@
"""
Internal golden fixtures.
Each fixture defines the canonical InternalRequest / InternalResponse
that all normalizers must produce (or consume) for a given scenario.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from src.core.api_format.conversion.internal import (
ImageBlock,
InstructionSegment,
InternalMessage,
InternalRequest,
InternalResponse,
Role,
StopReason,
TextBlock,
ThinkingBlock,
ToolChoice,
ToolChoiceType,
ToolDefinition,
ToolResultBlock,
ToolUseBlock,
UsageInfo,
)
@dataclass
class GoldenFixture:
"""A golden internal fixture for a specific scenario."""
fixture_id: str
description: str
internal_request: InternalRequest
internal_response: InternalResponse
# Fields that MUST be correctly converted by every normalizer
required_fields: set[str] = field(default_factory=set)
# Fields that may be lost during conversion (format-specific extras)
lossy_fields: set[str] = field(default_factory=set)
# ---------------------------------------------------------------------------
# Shared constants
# ---------------------------------------------------------------------------
_MODEL = "test-model"
_SYSTEM = "You are a helpful assistant."
_TOOL_DEF = ToolDefinition(
name="get_weather",
description="Get the current weather for a location.",
parameters={
"type": "object",
"properties": {
"location": {"type": "string", "description": "City name"},
},
"required": ["location"],
},
)
_TOOL_ID = "tool_call_001"
_REQUIRED_REQUEST = {"model", "messages", "system"}
_REQUIRED_RESPONSE = {"content", "stop_reason"}
# ---------------------------------------------------------------------------
# simple_text: single-turn text conversation
# ---------------------------------------------------------------------------
SIMPLE_TEXT = GoldenFixture(
fixture_id="simple_text",
description="Single-turn text conversation with system prompt",
internal_request=InternalRequest(
model=_MODEL,
messages=[
InternalMessage(role=Role.USER, content=[TextBlock(text="Hello, how are you?")]),
],
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
system=_SYSTEM,
max_tokens=1024,
stream=False,
),
internal_response=InternalResponse(
id="resp_001",
model=_MODEL,
content=[TextBlock(text="I'm doing well, thank you!")],
stop_reason=StopReason.END_TURN,
usage=UsageInfo(input_tokens=10, output_tokens=8, total_tokens=18),
),
required_fields={"model", "messages", "system", "max_tokens", "content", "stop_reason"},
)
# ---------------------------------------------------------------------------
# multi_turn: multi-turn conversation
# ---------------------------------------------------------------------------
MULTI_TURN = GoldenFixture(
fixture_id="multi_turn",
description="Multi-turn conversation with user/assistant alternation",
internal_request=InternalRequest(
model=_MODEL,
messages=[
InternalMessage(role=Role.USER, content=[TextBlock(text="What is 2+2?")]),
InternalMessage(role=Role.ASSISTANT, content=[TextBlock(text="4")]),
InternalMessage(role=Role.USER, content=[TextBlock(text="And 3+3?")]),
],
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
system=_SYSTEM,
max_tokens=1024,
stream=False,
),
internal_response=InternalResponse(
id="resp_002",
model=_MODEL,
content=[TextBlock(text="6")],
stop_reason=StopReason.END_TURN,
usage=UsageInfo(input_tokens=20, output_tokens=1, total_tokens=21),
),
required_fields={"model", "messages", "system", "content", "stop_reason"},
)
# ---------------------------------------------------------------------------
# tool_use: tool call + tool result
# ---------------------------------------------------------------------------
TOOL_USE = GoldenFixture(
fixture_id="tool_use",
description="Single tool call with result in conversation history",
internal_request=InternalRequest(
model=_MODEL,
messages=[
InternalMessage(
role=Role.USER, content=[TextBlock(text="What is the weather in Tokyo?")]
),
InternalMessage(
role=Role.ASSISTANT,
content=[
TextBlock(text="Let me check the weather for you."),
ToolUseBlock(
tool_id=_TOOL_ID,
tool_name="get_weather",
tool_input={"location": "Tokyo"},
),
],
),
InternalMessage(
role=Role.USER,
content=[
ToolResultBlock(
tool_use_id=_TOOL_ID,
content_text='{"temperature": 22, "condition": "sunny"}',
),
],
),
InternalMessage(role=Role.USER, content=[TextBlock(text="Thanks!")]),
],
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
system=_SYSTEM,
max_tokens=1024,
stream=False,
tools=[_TOOL_DEF],
),
internal_response=InternalResponse(
id="resp_003",
model=_MODEL,
content=[TextBlock(text="The weather in Tokyo is 22C and sunny.")],
stop_reason=StopReason.END_TURN,
usage=UsageInfo(input_tokens=50, output_tokens=12, total_tokens=62),
),
required_fields={"model", "messages", "system", "tools", "content", "stop_reason"},
)
# ---------------------------------------------------------------------------
# tool_use_response: response that contains a tool call (not end_turn)
# ---------------------------------------------------------------------------
TOOL_USE_RESPONSE = GoldenFixture(
fixture_id="tool_use_response",
description="Response that is a tool call (stop_reason=tool_use)",
internal_request=InternalRequest(
model=_MODEL,
messages=[
InternalMessage(
role=Role.USER, content=[TextBlock(text="What is the weather in Tokyo?")]
),
],
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
system=_SYSTEM,
max_tokens=1024,
stream=False,
tools=[_TOOL_DEF],
),
internal_response=InternalResponse(
id="resp_004",
model=_MODEL,
content=[
ToolUseBlock(
tool_id=_TOOL_ID,
tool_name="get_weather",
tool_input={"location": "Tokyo"},
),
],
stop_reason=StopReason.TOOL_USE,
usage=UsageInfo(input_tokens=30, output_tokens=15, total_tokens=45),
),
required_fields={"model", "messages", "tools", "content", "stop_reason"},
)
# ---------------------------------------------------------------------------
# thinking: response with thinking block
# ---------------------------------------------------------------------------
THINKING = GoldenFixture(
fixture_id="thinking",
description="Response with thinking/reasoning content",
internal_request=InternalRequest(
model=_MODEL,
messages=[
InternalMessage(role=Role.USER, content=[TextBlock(text="Solve: 15 * 23")]),
],
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
system=_SYSTEM,
max_tokens=2048,
stream=False,
),
internal_response=InternalResponse(
id="resp_005",
model=_MODEL,
content=[
ThinkingBlock(thinking="15 * 23 = 15 * 20 + 15 * 3 = 300 + 45 = 345"),
TextBlock(text="345"),
],
stop_reason=StopReason.END_TURN,
usage=UsageInfo(input_tokens=15, output_tokens=20, total_tokens=35),
),
required_fields={"model", "messages", "content", "stop_reason"},
)
# ---------------------------------------------------------------------------
# image_url: image input via URL
# ---------------------------------------------------------------------------
IMAGE_URL = GoldenFixture(
fixture_id="image_url",
description="Image input via URL",
internal_request=InternalRequest(
model=_MODEL,
messages=[
InternalMessage(
role=Role.USER,
content=[
ImageBlock(url="https://example.com/image.png", media_type="image/png"),
TextBlock(text="What is in this image?"),
],
),
],
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
system=_SYSTEM,
max_tokens=1024,
stream=False,
),
internal_response=InternalResponse(
id="resp_006",
model=_MODEL,
content=[TextBlock(text="I see a cat.")],
stop_reason=StopReason.END_TURN,
usage=UsageInfo(input_tokens=100, output_tokens=5, total_tokens=105),
),
required_fields={"model", "messages", "content", "stop_reason"},
)
# ---------------------------------------------------------------------------
# image_base64: image input via base64
# ---------------------------------------------------------------------------
IMAGE_BASE64 = GoldenFixture(
fixture_id="image_base64",
description="Image input via base64 data",
internal_request=InternalRequest(
model=_MODEL,
messages=[
InternalMessage(
role=Role.USER,
content=[
ImageBlock(data="iVBORw0KGgo=", media_type="image/png"),
TextBlock(text="Describe this image."),
],
),
],
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
system=_SYSTEM,
max_tokens=1024,
stream=False,
),
internal_response=InternalResponse(
id="resp_007",
model=_MODEL,
content=[TextBlock(text="A small icon.")],
stop_reason=StopReason.END_TURN,
usage=UsageInfo(input_tokens=80, output_tokens=3, total_tokens=83),
),
required_fields={"model", "messages", "content", "stop_reason"},
)
# ---------------------------------------------------------------------------
# empty_response: response with no content
# ---------------------------------------------------------------------------
EMPTY_RESPONSE = GoldenFixture(
fixture_id="empty_response",
description="Empty response (no content blocks)",
internal_request=InternalRequest(
model=_MODEL,
messages=[
InternalMessage(role=Role.USER, content=[TextBlock(text="Say nothing.")]),
],
instructions=[InstructionSegment(role=Role.SYSTEM, text=_SYSTEM)],
system=_SYSTEM,
max_tokens=1024,
stream=False,
),
internal_response=InternalResponse(
id="resp_008",
model=_MODEL,
content=[], # Normalizers typically drop empty text blocks
stop_reason=StopReason.END_TURN,
usage=UsageInfo(input_tokens=10, output_tokens=0, total_tokens=10),
),
required_fields={"model", "stop_reason"},
)
# ---------------------------------------------------------------------------
# tool_choice_auto: tool_choice=auto
# ---------------------------------------------------------------------------
TOOL_CHOICE_AUTO = GoldenFixture(
fixture_id="tool_choice_auto",
description="Request with tool_choice=auto",
internal_request=InternalRequest(
model=_MODEL,
messages=[
InternalMessage(role=Role.USER, content=[TextBlock(text="Help me.")]),
],
system=_SYSTEM,
max_tokens=1024,
stream=False,
tools=[_TOOL_DEF],
tool_choice=ToolChoice(type=ToolChoiceType.AUTO),
),
internal_response=InternalResponse(
id="resp_009",
model=_MODEL,
content=[TextBlock(text="Sure!")],
stop_reason=StopReason.END_TURN,
),
required_fields={"model", "messages", "tools", "tool_choice"},
)
# ---------------------------------------------------------------------------
# Registry of all golden fixtures
# ---------------------------------------------------------------------------
ALL_GOLDEN_FIXTURES: dict[str, GoldenFixture] = {
f.fixture_id: f
for f in [
SIMPLE_TEXT,
MULTI_TURN,
TOOL_USE,
TOOL_USE_RESPONSE,
THINKING,
IMAGE_URL,
IMAGE_BASE64,
EMPTY_RESPONSE,
TOOL_CHOICE_AUTO,
]
}
# Fixture IDs that all formats must support (core scenarios)
CORE_FIXTURE_IDS = ["simple_text", "multi_turn", "tool_use", "empty_response"]
# Fixture IDs for extended scenarios (some formats may not support)
EXTENDED_FIXTURE_IDS = [
"tool_use_response",
"thinking",
"image_url",
"image_base64",
"tool_choice_auto",
]
ALL_FIXTURE_IDS = CORE_FIXTURE_IDS + EXTENDED_FIXTURE_IDS
# ---------------------------------------------------------------------------
# Known normalizer limitations for extended fixtures.
#
# Maps (format_id, fixture_id, test_layer) -> reason string.
# test_layer: "to_internal", "from_internal", "roundtrip", "cross_request", "cross_response"
#
# These are documented limitations of the current normalizer implementations,
# NOT bugs to fix. Tests will skip these combinations.
# ---------------------------------------------------------------------------
KNOWN_LIMITATIONS: dict[tuple[str, str, str], str] = {}
# Formats where response_to_internal loses ThinkingBlock (source limitation)
_THINKING_RESPONSE_LOSSY_SOURCES = {"openai:cli"}
# Fixtures where the response's thinking block is lost when target format
# doesn't support ThinkingBlock in non-streaming responses.
_THINKING_RESPONSE_LOSSY_TARGETS = {"openai:cli"}
def is_cross_format_limited(
source: str,
target: str,
fixture_id: str,
layer: str,
) -> str | None:
"""Return a reason string if this cross-format combo is a known limitation, else None."""
# thinking response: openai:cli doesn't support ThinkingBlock in non-streaming
if fixture_id == "thinking" and layer == "cross_response":
if source in _THINKING_RESPONSE_LOSSY_SOURCES:
return f"{source} does not parse thinking content into ThinkingBlock"
if target in _THINKING_RESPONSE_LOSSY_TARGETS:
return f"{target} does not preserve ThinkingBlock in non-streaming responses"
return None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,504 @@
"""
Stream fixtures for each format.
Each fixture defines a sequence of format-specific SSE chunks and the
expected internal stream events / final text they should produce.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from src.core.api_format.conversion.internal import StopReason
from .golden_internal import _MODEL
@dataclass
class StreamFixture:
"""A stream fixture for a specific format and scenario."""
chunks: list[dict[str, Any]]
expected_text: str
expected_stop_reason: StopReason
# Fields that may differ across formats
lossy_fields: set[str] = field(default_factory=set)
# ===================================================================
# Claude Chat / CLI stream chunks
# ===================================================================
_CLAUDE_STREAM_TEXT_CHUNKS: list[dict[str, Any]] = [
{
"type": "message_start",
"message": {
"id": "msg_stream_001",
"type": "message",
"role": "assistant",
"model": _MODEL,
"content": [],
"stop_reason": None,
"usage": {"input_tokens": 10, "output_tokens": 0},
},
},
{
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": ""},
},
# PLACEHOLDER_DELTAS
]
# Add text deltas
_CLAUDE_STREAM_TEXT_CHUNKS.extend(
[
{
"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": {"output_tokens": 5},
},
{"type": "message_stop"},
]
)
_CLAUDE_STREAM_TEXT = StreamFixture(
chunks=_CLAUDE_STREAM_TEXT_CHUNKS,
expected_text="Hello, world!",
expected_stop_reason=StopReason.END_TURN,
)
# Claude stream tool call
_CLAUDE_STREAM_TOOL_CALL = StreamFixture(
chunks=[
{
"type": "message_start",
"message": {
"id": "msg_stream_tc_001",
"type": "message",
"role": "assistant",
"model": _MODEL,
"content": [],
"stop_reason": None,
"usage": {"input_tokens": 20, "output_tokens": 0},
},
},
{
"type": "content_block_start",
"index": 0,
"content_block": {"type": "tool_use", "id": "tool_call_s01", "name": "get_weather"},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "input_json_delta", "partial_json": '{"location":'},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "input_json_delta", "partial_json": ' "Tokyo"}'},
},
{"type": "content_block_stop", "index": 0},
{
"type": "message_delta",
"delta": {"stop_reason": "tool_use"},
"usage": {"output_tokens": 10},
},
{"type": "message_stop"},
],
expected_text="",
expected_stop_reason=StopReason.TOOL_USE,
)
# ===================================================================
# OpenAI Chat stream chunks
# ===================================================================
_OPENAI_CHAT_STREAM_TEXT = StreamFixture(
chunks=[
{
"id": "chatcmpl-stream-001",
"object": "chat.completion.chunk",
"model": _MODEL,
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": ""},
"finish_reason": None,
}
],
},
{
"id": "chatcmpl-stream-001",
"object": "chat.completion.chunk",
"model": _MODEL,
"choices": [{"index": 0, "delta": {"content": "Hello, "}, "finish_reason": None}],
},
{
"id": "chatcmpl-stream-001",
"object": "chat.completion.chunk",
"model": _MODEL,
"choices": [{"index": 0, "delta": {"content": "world!"}, "finish_reason": None}],
},
{
"id": "chatcmpl-stream-001",
"object": "chat.completion.chunk",
"model": _MODEL,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
},
],
expected_text="Hello, world!",
expected_stop_reason=StopReason.END_TURN,
)
# OpenAI Chat stream tool call
_OPENAI_CHAT_STREAM_TOOL_CALL = StreamFixture(
chunks=[
{
"id": "chatcmpl-stream-tc-001",
"object": "chat.completion.chunk",
"model": _MODEL,
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"index": 0,
"id": "call_tc_001",
"type": "function",
"function": {"name": "get_weather", "arguments": ""},
}
],
},
"finish_reason": None,
}
],
},
{
"id": "chatcmpl-stream-tc-001",
"object": "chat.completion.chunk",
"model": _MODEL,
"choices": [
{
"index": 0,
"delta": {
"tool_calls": [{"index": 0, "function": {"arguments": '{"location":'}}]
},
"finish_reason": None,
}
],
},
{
"id": "chatcmpl-stream-tc-001",
"object": "chat.completion.chunk",
"model": _MODEL,
"choices": [
{
"index": 0,
"delta": {"tool_calls": [{"index": 0, "function": {"arguments": ' "Tokyo"}'}}]},
"finish_reason": None,
}
],
},
{
"id": "chatcmpl-stream-tc-001",
"object": "chat.completion.chunk",
"model": _MODEL,
"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
},
],
expected_text="",
expected_stop_reason=StopReason.TOOL_USE,
)
# ===================================================================
# OpenAI CLI (Responses API) stream chunks
# ===================================================================
_OPENAI_CLI_STREAM_TEXT = StreamFixture(
chunks=[
{
"type": "response.created",
"response": {
"id": "resp_stream_001",
"object": "response",
"model": _MODEL,
"status": "in_progress",
"output": [],
},
},
{
"type": "response.output_item.added",
"output_index": 0,
"item": {
"type": "message",
"id": "msg_stream_001",
"role": "assistant",
"status": "in_progress",
"content": [],
},
},
{
"type": "response.content_part.added",
"output_index": 0,
"content_index": 0,
"part": {"type": "output_text", "text": ""},
},
{
"type": "response.output_text.delta",
"output_index": 0,
"content_index": 0,
"delta": "Hello, ",
},
{
"type": "response.output_text.delta",
"output_index": 0,
"content_index": 0,
"delta": "world!",
},
{
"type": "response.output_text.done",
"output_index": 0,
"content_index": 0,
"text": "Hello, world!",
},
{
"type": "response.output_item.done",
"output_index": 0,
"item": {
"type": "message",
"id": "msg_stream_001",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "Hello, world!"}],
},
},
{
"type": "response.completed",
"response": {
"id": "resp_stream_001",
"object": "response",
"model": _MODEL,
"status": "completed",
"output": [
{
"type": "message",
"id": "msg_stream_001",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "Hello, world!"}],
}
],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
},
},
],
expected_text="Hello, world!",
expected_stop_reason=StopReason.END_TURN,
)
# OpenAI CLI stream tool call
_OPENAI_CLI_STREAM_TOOL_CALL = StreamFixture(
chunks=[
{
"type": "response.created",
"response": {
"id": "resp_stream_tc_001",
"object": "response",
"model": _MODEL,
"status": "in_progress",
"output": [],
},
},
{
"type": "response.output_item.added",
"output_index": 0,
"item": {
"type": "function_call",
"call_id": "fc_001",
"id": "fc_001",
"name": "get_weather",
"status": "in_progress",
"arguments": "",
},
},
{
"type": "response.function_call_arguments.delta",
"output_index": 0,
"item_id": "fc_001",
"delta": '{"location":',
},
{
"type": "response.function_call_arguments.delta",
"output_index": 0,
"item_id": "fc_001",
"delta": ' "Tokyo"}',
},
{
"type": "response.function_call_arguments.done",
"output_index": 0,
"item_id": "fc_001",
"arguments": '{"location": "Tokyo"}',
},
{
"type": "response.output_item.done",
"output_index": 0,
"item": {
"type": "function_call",
"call_id": "fc_001",
"id": "fc_001",
"name": "get_weather",
"status": "completed",
"arguments": '{"location": "Tokyo"}',
},
},
{
"type": "response.completed",
"response": {
"id": "resp_stream_tc_001",
"object": "response",
"model": _MODEL,
"status": "completed",
"output": [
{
"type": "function_call",
"call_id": "fc_001",
"id": "fc_001",
"name": "get_weather",
"status": "completed",
"arguments": '{"location": "Tokyo"}',
}
],
"usage": {"input_tokens": 20, "output_tokens": 10, "total_tokens": 30},
},
},
],
expected_text="",
expected_stop_reason=StopReason.TOOL_USE,
)
# ===================================================================
# Gemini Chat / CLI stream chunks
# ===================================================================
_GEMINI_STREAM_TEXT = StreamFixture(
chunks=[
{
"candidates": [
{
"content": {"role": "model", "parts": [{"text": "Hello, "}]},
"index": 0,
}
],
"modelVersion": _MODEL,
},
{
"candidates": [
{
"content": {"role": "model", "parts": [{"text": "world!"}]},
"index": 0,
"finishReason": "STOP",
}
],
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 5,
"totalTokenCount": 15,
},
"modelVersion": _MODEL,
},
],
expected_text="Hello, world!",
expected_stop_reason=StopReason.END_TURN,
)
# Gemini stream tool call (Gemini emits complete tool calls atomically)
_GEMINI_STREAM_TOOL_CALL = StreamFixture(
chunks=[
{
"candidates": [
{
"content": {
"role": "model",
"parts": [
{
"functionCall": {
"name": "get_weather",
"args": {"location": "Tokyo"},
}
},
],
},
"index": 0,
"finishReason": "STOP",
}
],
"usageMetadata": {
"promptTokenCount": 20,
"candidatesTokenCount": 10,
"totalTokenCount": 30,
},
"modelVersion": _MODEL,
},
],
expected_text="",
expected_stop_reason=StopReason.END_TURN,
)
# ===================================================================
# Registry
# ===================================================================
STREAM_FIXTURES: dict[str, dict[str, StreamFixture]] = {
"claude:chat": {
"stream_text": _CLAUDE_STREAM_TEXT,
"stream_tool_call": _CLAUDE_STREAM_TOOL_CALL,
},
"claude:cli": {
"stream_text": _CLAUDE_STREAM_TEXT,
"stream_tool_call": _CLAUDE_STREAM_TOOL_CALL,
},
"openai:chat": {
"stream_text": _OPENAI_CHAT_STREAM_TEXT,
"stream_tool_call": _OPENAI_CHAT_STREAM_TOOL_CALL,
},
"openai:cli": {
"stream_text": _OPENAI_CLI_STREAM_TEXT,
"stream_tool_call": _OPENAI_CLI_STREAM_TOOL_CALL,
},
"gemini:chat": {
"stream_text": _GEMINI_STREAM_TEXT,
"stream_tool_call": _GEMINI_STREAM_TOOL_CALL,
},
"gemini:cli": {
"stream_text": _GEMINI_STREAM_TEXT,
"stream_tool_call": _GEMINI_STREAM_TOOL_CALL,
},
}
STREAM_FIXTURE_IDS = ["stream_text", "stream_tool_call"]
STREAM_ALL_FORMATS = list(STREAM_FIXTURES.keys())

View File

@@ -218,7 +218,7 @@ def test_claude_stream_chunk_and_event_roundtrip_basic() -> None:
n = ClaudeNormalizer()
state = StreamState()
chunks = [
chunks: list[dict[str, Any]] = [
{
"type": "message_start",
"message": {
@@ -350,6 +350,7 @@ def test_claude_system_array_format() -> None:
internal = n.request_to_internal(req)
# system 数组中的多个 text 应该用 \n\n 连接
assert internal.system is not None
assert "x-anthropic-billing-header" in internal.system
assert "You are Claude Code" in internal.system
assert "Extract file paths" in internal.system

View File

@@ -0,0 +1,101 @@
"""
Layer 3: Cross-format roundtrip tests.
Verifies that converting A -> internal -> B -> internal preserves
semantic equivalence across all format pairs.
"""
from __future__ import annotations
import itertools
import pytest
from src.core.api_format.conversion.registry import (
format_conversion_registry,
register_default_normalizers,
)
from .fixtures.assertions import assert_internal_request_matches, assert_internal_response_matches
from .fixtures.format_fixtures import ALL_FORMATS, FORMAT_FIXTURES, get_format_fixture
from .fixtures.golden_internal import ALL_FIXTURE_IDS, ALL_GOLDEN_FIXTURES, is_cross_format_limited
@pytest.fixture(autouse=True, scope="module")
def _ensure_normalizers_registered() -> None:
register_default_normalizers()
def _cross_format_combos() -> list[tuple[str, str, str]]:
"""Generate (source, target, fixture_id) where fixture exists for source."""
combos = []
for source, target in itertools.permutations(ALL_FORMATS, 2):
for fid in ALL_FIXTURE_IDS:
if fid in FORMAT_FIXTURES.get(source, {}):
combos.append((source, target, fid))
return combos
_COMBOS = _cross_format_combos()
def _combo_id(combo: tuple[str, str, str]) -> str:
return f"{combo[0]}->{combo[1]}:{combo[2]}"
class TestCrossFormatRequest:
"""source.request -> internal -> target.request -> internal: matches golden."""
@pytest.mark.parametrize("source,target,fixture_id", _COMBOS, ids=_combo_id)
def test_cross_format_request(self, source: str, target: str, fixture_id: str) -> None:
limitation = is_cross_format_limited(source, target, fixture_id, "cross_request")
if limitation:
pytest.skip(limitation)
src_norm = format_conversion_registry.get_normalizer(source)
tgt_norm = format_conversion_registry.get_normalizer(target)
assert src_norm is not None and tgt_norm is not None
fixture = get_format_fixture(source, fixture_id)
golden = ALL_GOLDEN_FIXTURES[fixture_id]
# source -> internal
internal = src_norm.request_to_internal(fixture.request)
# internal -> target native
target_native = tgt_norm.request_from_internal(internal)
# target native -> internal (should still match golden)
internal2 = tgt_norm.request_to_internal(target_native)
assert_internal_request_matches(internal2, golden.internal_request, golden.required_fields)
class TestCrossFormatResponse:
"""source.response -> internal -> target.response -> internal: matches golden."""
@pytest.mark.parametrize("source,target,fixture_id", _COMBOS, ids=_combo_id)
def test_cross_format_response(self, source: str, target: str, fixture_id: str) -> None:
limitation = is_cross_format_limited(source, target, fixture_id, "cross_response")
if limitation:
pytest.skip(limitation)
src_norm = format_conversion_registry.get_normalizer(source)
tgt_norm = format_conversion_registry.get_normalizer(target)
assert src_norm is not None and tgt_norm is not None
fixture = get_format_fixture(source, fixture_id)
golden = ALL_GOLDEN_FIXTURES[fixture_id]
# source -> internal
internal = src_norm.response_to_internal(fixture.response)
# internal -> target native
target_native = tgt_norm.response_from_internal(internal)
# target native -> internal
internal2 = tgt_norm.response_to_internal(target_native)
assert_internal_response_matches(
internal2, golden.internal_response, golden.required_fields
)

View File

@@ -0,0 +1,104 @@
"""
Layer 5: Error conversion tests (fixture-driven).
Verifies that each normalizer correctly converts format-specific error
responses to/from InternalError, and that error type mapping is correct.
"""
from __future__ import annotations
import pytest
from src.core.api_format.conversion.registry import (
format_conversion_registry,
register_default_normalizers,
)
from .fixtures.error_fixtures import ERROR_ALL_FORMATS, ERROR_FIXTURES
from .fixtures.schema_validators import get_error_validator
@pytest.fixture(autouse=True, scope="module")
def _ensure_normalizers_registered() -> None:
register_default_normalizers()
def _error_combos() -> list[tuple[str, str]]:
combos = []
for fmt in ERROR_ALL_FORMATS:
for eid in ERROR_FIXTURES.get(fmt, {}):
combos.append((fmt, eid))
return combos
_COMBOS = _error_combos()
class TestErrorToInternal:
"""Verify format-specific error -> InternalError."""
@pytest.mark.parametrize("format_id,error_id", _COMBOS)
def test_error_to_internal(self, format_id: str, error_id: str) -> None:
normalizer = format_conversion_registry.get_normalizer(format_id)
assert normalizer is not None
fixture = ERROR_FIXTURES[format_id][error_id]
internal = normalizer.error_to_internal(fixture.error_response)
assert (
internal.type == fixture.expected_type
), f"error type mismatch: {internal.type} != {fixture.expected_type}"
assert (
internal.message == fixture.expected_message
), f"error message mismatch: {internal.message!r} != {fixture.expected_message!r}"
class TestErrorRoundtrip:
"""Verify error -> internal -> error -> internal preserves type and message."""
@pytest.mark.parametrize("format_id,error_id", _COMBOS)
def test_error_roundtrip(self, format_id: str, error_id: str) -> None:
normalizer = format_conversion_registry.get_normalizer(format_id)
assert normalizer is not None
fixture = ERROR_FIXTURES[format_id][error_id]
# First pass
internal1 = normalizer.error_to_internal(fixture.error_response)
# Reconstruct
reconstructed = normalizer.error_from_internal(internal1)
# Second pass
internal2 = normalizer.error_to_internal(reconstructed)
assert (
internal1.type == internal2.type
), f"error type changed after roundtrip: {internal1.type} -> {internal2.type}"
assert (
internal1.message == internal2.message
), f"error message changed after roundtrip: {internal1.message!r} -> {internal2.message!r}"
class TestErrorFromInternalSchema:
"""Verify InternalError -> format-specific error conforms to API schema."""
@pytest.mark.parametrize("format_id,error_id", _COMBOS)
def test_error_from_internal_schema(self, format_id: str, error_id: str) -> None:
validator = get_error_validator(format_id)
if validator is None:
pytest.skip(f"No error schema validator for {format_id}")
normalizer = format_conversion_registry.get_normalizer(format_id)
assert normalizer is not None
fixture = ERROR_FIXTURES[format_id][error_id]
internal = normalizer.error_to_internal(fixture.error_response)
reconstructed = normalizer.error_from_internal(internal)
errors = validator(reconstructed)
assert (
not errors
), f"Error schema validation failed for {format_id} ({error_id}):\n" + "\n".join(
f" - {e}" for e in errors
)

View File

@@ -195,7 +195,7 @@ def test_gemini_stream_chunk_and_event_roundtrip_basic() -> None:
n = GeminiNormalizer()
state = StreamState()
chunks = [
chunks: list[dict[str, Any]] = [
{
"candidates": [{"content": {"parts": [{"text": "Hel"}], "role": "model"}, "index": 0}],
"modelVersion": "gemini-1.5",

View File

@@ -0,0 +1,96 @@
"""
Layer 2: Normalizer roundtrip tests.
Verifies that converting A -> internal -> A preserves semantic equivalence.
"""
from __future__ import annotations
import copy
import pytest
from src.core.api_format.conversion.registry import (
format_conversion_registry,
register_default_normalizers,
)
from .fixtures.assertions import assert_internal_requests_equivalent
from .fixtures.format_fixtures import ALL_FORMATS, FORMAT_FIXTURES, get_format_fixture
from .fixtures.golden_internal import ALL_FIXTURE_IDS, KNOWN_LIMITATIONS
@pytest.fixture(autouse=True, scope="module")
def _ensure_normalizers_registered() -> None:
register_default_normalizers()
def _available_combos() -> list[tuple[str, str]]:
combos = []
for fmt in ALL_FORMATS:
for fid in ALL_FIXTURE_IDS:
if fid in FORMAT_FIXTURES.get(fmt, {}):
combos.append((fmt, fid))
return combos
_COMBOS = _available_combos()
class TestRequestRoundtrip:
"""A.request -> internal -> A.request -> internal: two internals should match."""
@pytest.mark.parametrize("format_id,fixture_id", _COMBOS)
def test_request_roundtrip(self, format_id: str, fixture_id: str) -> None:
limitation = KNOWN_LIMITATIONS.get((format_id, fixture_id, "roundtrip"))
if limitation:
pytest.skip(limitation)
normalizer = format_conversion_registry.get_normalizer(format_id)
assert normalizer is not None
fixture = get_format_fixture(format_id, fixture_id)
# First pass: native -> internal
internal1 = normalizer.request_to_internal(fixture.request)
# Reconstruct: internal -> native
# Deep copy because some normalizers mutate the input (e.g. _coerce_claude_message_sequence)
reconstructed = normalizer.request_from_internal(copy.deepcopy(internal1))
# Second pass: native -> internal
internal2 = normalizer.request_to_internal(reconstructed)
# The two internal representations should be semantically equivalent
assert_internal_requests_equivalent(internal1, internal2, lossy_fields=fixture.lossy_fields)
class TestResponseRoundtrip:
"""A.response -> internal -> A.response -> internal: two internals should match."""
@pytest.mark.parametrize("format_id,fixture_id", _COMBOS)
def test_response_roundtrip(self, format_id: str, fixture_id: str) -> None:
normalizer = format_conversion_registry.get_normalizer(format_id)
assert normalizer is not None
fixture = get_format_fixture(format_id, fixture_id)
# First pass
internal1 = normalizer.response_to_internal(fixture.response)
# Reconstruct
reconstructed = normalizer.response_from_internal(internal1)
# Second pass
internal2 = normalizer.response_to_internal(reconstructed)
# Compare content blocks (the core semantic payload)
from .fixtures.assertions import assert_content_blocks_match
assert_content_blocks_match(
internal1.content, internal2.content, context="response roundtrip"
)
# Stop reason should be preserved
assert (
internal1.stop_reason == internal2.stop_reason
), f"stop_reason changed after roundtrip: {internal1.stop_reason} -> {internal2.stop_reason}"

View File

@@ -0,0 +1,159 @@
"""
Layer 1: Normalizer to_internal / from_internal tests.
Verifies that each normalizer correctly converts format-specific
requests/responses to/from the canonical internal representation.
"""
from __future__ import annotations
import pytest
from src.core.api_format.conversion.registry import (
format_conversion_registry,
register_default_normalizers,
)
from .fixtures.assertions import (
assert_internal_request_matches,
assert_internal_response_matches,
)
from .fixtures.format_fixtures import ALL_FORMATS, FORMAT_FIXTURES, get_format_fixture
from .fixtures.golden_internal import ALL_FIXTURE_IDS, ALL_GOLDEN_FIXTURES, KNOWN_LIMITATIONS
from .fixtures.schema_validators import (
get_request_validator,
get_response_validator,
)
@pytest.fixture(autouse=True, scope="module")
def _ensure_normalizers_registered() -> None:
"""Ensure all normalizers are registered before tests run."""
register_default_normalizers()
def _available_combos() -> list[tuple[str, str]]:
"""Generate (format_id, fixture_id) pairs where fixture exists for format."""
combos = []
for fmt in ALL_FORMATS:
for fid in ALL_FIXTURE_IDS:
if fid in FORMAT_FIXTURES.get(fmt, {}):
combos.append((fmt, fid))
return combos
_COMBOS = _available_combos()
class TestRequestToInternal:
"""Verify format-specific request -> InternalRequest."""
@pytest.mark.parametrize("format_id,fixture_id", _COMBOS)
def test_request_to_internal(self, format_id: str, fixture_id: str) -> None:
normalizer = format_conversion_registry.get_normalizer(format_id)
assert normalizer is not None, f"No normalizer for {format_id}"
fixture = get_format_fixture(format_id, fixture_id)
golden = ALL_GOLDEN_FIXTURES[fixture_id]
internal = normalizer.request_to_internal(fixture.request)
# Exclude lossy fields from comparison
effective_required = golden.required_fields - fixture.lossy_fields
assert_internal_request_matches(internal, golden.internal_request, effective_required)
class TestResponseToInternal:
"""Verify format-specific response -> InternalResponse."""
@pytest.mark.parametrize("format_id,fixture_id", _COMBOS)
def test_response_to_internal(self, format_id: str, fixture_id: str) -> None:
limitation = KNOWN_LIMITATIONS.get((format_id, fixture_id, "to_internal_response"))
if limitation:
pytest.skip(limitation)
normalizer = format_conversion_registry.get_normalizer(format_id)
assert normalizer is not None, f"No normalizer for {format_id}"
fixture = get_format_fixture(format_id, fixture_id)
golden = ALL_GOLDEN_FIXTURES[fixture_id]
internal = normalizer.response_to_internal(fixture.response)
assert_internal_response_matches(internal, golden.internal_response, golden.required_fields)
class TestRequestFromInternal:
"""Verify InternalRequest -> format-specific request produces valid output."""
@pytest.mark.parametrize("format_id,fixture_id", _COMBOS)
def test_request_from_internal(self, format_id: str, fixture_id: str) -> None:
normalizer = format_conversion_registry.get_normalizer(format_id)
assert normalizer is not None, f"No normalizer for {format_id}"
golden = ALL_GOLDEN_FIXTURES[fixture_id]
result = normalizer.request_from_internal(golden.internal_request)
# The result should be a valid dict that can be parsed back
assert isinstance(result, dict), f"Expected dict, got {type(result)}"
# Model should be preserved
model_key = "model"
if format_id.startswith("gemini"):
# Gemini doesn't put model in request body
pass
else:
assert result.get(model_key) == golden.internal_request.model
@pytest.mark.parametrize("format_id,fixture_id", _COMBOS)
def test_request_from_internal_schema(self, format_id: str, fixture_id: str) -> None:
"""Validate output structure conforms to the target API schema."""
validator = get_request_validator(format_id)
if validator is None:
pytest.skip(f"No request schema validator for {format_id}")
normalizer = format_conversion_registry.get_normalizer(format_id)
assert normalizer is not None
golden = ALL_GOLDEN_FIXTURES[fixture_id]
result = normalizer.request_from_internal(golden.internal_request)
errors = validator(result)
assert (
not errors
), f"Schema validation failed for {format_id} request ({fixture_id}):\n" + "\n".join(
f" - {e}" for e in errors
)
class TestResponseFromInternal:
"""Verify InternalResponse -> format-specific response produces valid output."""
@pytest.mark.parametrize("format_id,fixture_id", _COMBOS)
def test_response_from_internal(self, format_id: str, fixture_id: str) -> None:
normalizer = format_conversion_registry.get_normalizer(format_id)
assert normalizer is not None, f"No normalizer for {format_id}"
golden = ALL_GOLDEN_FIXTURES[fixture_id]
result = normalizer.response_from_internal(golden.internal_response)
assert isinstance(result, dict), f"Expected dict, got {type(result)}"
@pytest.mark.parametrize("format_id,fixture_id", _COMBOS)
def test_response_from_internal_schema(self, format_id: str, fixture_id: str) -> None:
"""Validate output structure conforms to the target API schema."""
validator = get_response_validator(format_id)
if validator is None:
pytest.skip(f"No response schema validator for {format_id}")
normalizer = format_conversion_registry.get_normalizer(format_id)
assert normalizer is not None
golden = ALL_GOLDEN_FIXTURES[fixture_id]
result = normalizer.response_from_internal(golden.internal_response)
errors = validator(result)
assert (
not errors
), f"Schema validation failed for {format_id} response ({fixture_id}):\n" + "\n".join(
f" - {e}" for e in errors
)

View File

@@ -260,7 +260,7 @@ def test_openai_stream_chunk_and_event_roundtrip_basic() -> None:
n = OpenAINormalizer()
state = StreamState()
chunks = [
chunks: list[dict[str, Any]] = [
{
"id": "chatcmpl_stream_1",
"object": "chat.completion.chunk",

View File

@@ -0,0 +1,110 @@
"""
Layer 4: Stream conversion tests.
Verifies that each normalizer correctly converts format-specific stream
chunks to/from internal stream events.
"""
from __future__ import annotations
import pytest
from src.core.api_format.conversion.registry import (
format_conversion_registry,
register_default_normalizers,
)
from src.core.api_format.conversion.stream_state import StreamState
from .fixtures.assertions import (
assert_stream_has_tool_call,
assert_stream_stop_reason_matches,
assert_stream_text_matches,
)
from .fixtures.schema_validators import get_stream_chunk_validator
from .fixtures.stream_fixtures import (
STREAM_ALL_FORMATS,
STREAM_FIXTURE_IDS,
STREAM_FIXTURES,
)
@pytest.fixture(autouse=True, scope="module")
def _ensure_normalizers_registered() -> None:
register_default_normalizers()
def _stream_combos() -> list[tuple[str, str]]:
combos = []
for fmt in STREAM_ALL_FORMATS:
for fid in STREAM_FIXTURE_IDS:
if fid in STREAM_FIXTURES.get(fmt, {}):
combos.append((fmt, fid))
return combos
_COMBOS = _stream_combos()
class TestStreamToInternal:
"""Verify format-specific stream chunks -> InternalStreamEvent sequence."""
@pytest.mark.parametrize("format_id,fixture_id", _COMBOS)
def test_stream_to_internal(self, format_id: str, fixture_id: str) -> None:
normalizer = format_conversion_registry.get_normalizer(format_id)
assert normalizer is not None
fixture = STREAM_FIXTURES[format_id][fixture_id]
state = StreamState(model=fixture.chunks[0].get("model", ""))
all_events = []
for chunk in fixture.chunks:
events = normalizer.stream_chunk_to_internal(chunk, state)
all_events.extend(events)
assert_stream_text_matches(all_events, fixture.expected_text)
assert_stream_stop_reason_matches(all_events, fixture.expected_stop_reason)
if fixture_id == "stream_tool_call":
assert_stream_has_tool_call(all_events, "get_weather")
class TestStreamFromInternalSchema:
"""Verify internal events -> format-specific chunks conform to API schema."""
@pytest.mark.parametrize("format_id,fixture_id", _COMBOS)
def test_stream_roundtrip_schema(self, format_id: str, fixture_id: str) -> None:
"""Parse chunks -> internal events -> reconstruct chunks, validate schema."""
validator = get_stream_chunk_validator(format_id)
if validator is None:
pytest.skip(f"No stream chunk schema validator for {format_id}")
normalizer = format_conversion_registry.get_normalizer(format_id)
assert normalizer is not None
fixture = STREAM_FIXTURES[format_id][fixture_id]
# Phase 1: chunks -> internal events
in_state = StreamState(model=fixture.chunks[0].get("model", ""))
all_events = []
for chunk in fixture.chunks:
events = normalizer.stream_chunk_to_internal(chunk, in_state)
all_events.extend(events)
# Phase 2: internal events -> output chunks, validate each
out_state = StreamState(
message_id=in_state.message_id or "chatcmpl-test",
model=in_state.model or "test-model",
)
all_errors: list[str] = []
for event in all_events:
output_chunks = normalizer.stream_event_from_internal(event, out_state)
for out_chunk in output_chunks:
errors = validator(out_chunk)
if errors:
all_errors.extend(f"[{type(event).__name__}] {e}" for e in errors)
assert (
not all_errors
), f"Stream schema validation failed for {format_id} ({fixture_id}):\n" + "\n".join(
f" - {e}" for e in all_errors
)