mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
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:
6
tests/core/api_format/conversion/fixtures/__init__.py
Normal file
6
tests/core/api_format/conversion/fixtures/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Format conversion test fixtures.
|
||||
|
||||
Provides golden internal representations, format-specific fixtures,
|
||||
stream fixtures, error fixtures, and assertion helpers.
|
||||
"""
|
||||
361
tests/core/api_format/conversion/fixtures/assertions.py
Normal file
361
tests/core/api_format/conversion/fixtures/assertions.py
Normal 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"
|
||||
258
tests/core/api_format/conversion/fixtures/error_fixtures.py
Normal file
258
tests/core/api_format/conversion/fixtures/error_fixtures.py
Normal 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())
|
||||
1300
tests/core/api_format/conversion/fixtures/format_fixtures.py
Normal file
1300
tests/core/api_format/conversion/fixtures/format_fixtures.py
Normal file
File diff suppressed because it is too large
Load Diff
425
tests/core/api_format/conversion/fixtures/golden_internal.py
Normal file
425
tests/core/api_format/conversion/fixtures/golden_internal.py
Normal 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
|
||||
1384
tests/core/api_format/conversion/fixtures/schema_validators.py
Normal file
1384
tests/core/api_format/conversion/fixtures/schema_validators.py
Normal file
File diff suppressed because it is too large
Load Diff
504
tests/core/api_format/conversion/fixtures/stream_fixtures.py
Normal file
504
tests/core/api_format/conversion/fixtures/stream_fixtures.py
Normal 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())
|
||||
@@ -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
|
||||
|
||||
101
tests/core/api_format/conversion/test_cross_format_roundtrip.py
Normal file
101
tests/core/api_format/conversion/test_cross_format_roundtrip.py
Normal 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
|
||||
)
|
||||
104
tests/core/api_format/conversion/test_error_conversion_full.py
Normal file
104
tests/core/api_format/conversion/test_error_conversion_full.py
Normal 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
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"
|
||||
159
tests/core/api_format/conversion/test_normalizer_to_internal.py
Normal file
159
tests/core/api_format/conversion/test_normalizer_to_internal.py
Normal 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
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
110
tests/core/api_format/conversion/test_stream_conversion.py
Normal file
110
tests/core/api_format/conversion/test_stream_conversion.py
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user