2026-01-21 18:07:10 +08:00
|
|
|
|
"""
|
|
|
|
|
|
CliMessageHandlerBase._convert_sse_line 单元测试
|
|
|
|
|
|
|
|
|
|
|
|
测试覆盖:
|
|
|
|
|
|
1. 基本转换(空行、非 data 行、JSON 解析失败)
|
|
|
|
|
|
2. 一入多出场景
|
|
|
|
|
|
3. 状态追踪
|
|
|
|
|
|
4. 错误处理
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
from src.api.handlers.base.stream_context import StreamContext
|
2026-01-27 02:17:18 +08:00
|
|
|
|
from src.core.api_format.conversion.stream_state import StreamState
|
2026-01-21 18:07:10 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Mock CliMessageHandlerBase 用于测试
|
|
|
|
|
|
class MockCliHandler:
|
|
|
|
|
|
"""Mock handler for testing _convert_sse_line"""
|
|
|
|
|
|
|
|
|
|
|
|
def _convert_sse_line(
|
|
|
|
|
|
self,
|
|
|
|
|
|
ctx: StreamContext,
|
|
|
|
|
|
line: str,
|
|
|
|
|
|
events: list,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
) -> list[str]:
|
2026-01-21 18:07:10 +08:00
|
|
|
|
"""复制自 CliMessageHandlerBase._convert_sse_line"""
|
2026-01-27 02:17:18 +08:00
|
|
|
|
from src.core.api_format.conversion import (
|
|
|
|
|
|
format_conversion_registry,
|
|
|
|
|
|
register_default_normalizers,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
register_default_normalizers()
|
2026-01-21 18:07:10 +08:00
|
|
|
|
|
|
|
|
|
|
# 如果是空行或特殊控制行,直接返回
|
|
|
|
|
|
if not line or line.strip() == "" or line == "data: [DONE]":
|
|
|
|
|
|
return [line] if line else []
|
|
|
|
|
|
|
|
|
|
|
|
# 如果不是 data 行,直接透传
|
|
|
|
|
|
if not line.startswith("data:"):
|
|
|
|
|
|
return [line]
|
|
|
|
|
|
|
|
|
|
|
|
# 提取 data 内容
|
|
|
|
|
|
data_content = line[5:].strip()
|
|
|
|
|
|
|
|
|
|
|
|
# 尝试解析 JSON
|
|
|
|
|
|
try:
|
|
|
|
|
|
data_obj = json.loads(data_content)
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
return [line]
|
|
|
|
|
|
|
|
|
|
|
|
# 初始化流式转换状态
|
2026-01-29 10:32:36 +08:00
|
|
|
|
# 使用客户端请求的模型名(ctx.model),而非映射后的模型名(ctx.mapped_model)
|
2026-01-21 18:07:10 +08:00
|
|
|
|
if ctx.stream_conversion_state is None:
|
2026-01-27 02:17:18 +08:00
|
|
|
|
ctx.stream_conversion_state = StreamState(
|
2026-01-29 10:32:36 +08:00
|
|
|
|
model=ctx.model,
|
2026-01-21 18:07:10 +08:00
|
|
|
|
message_id=ctx.response_id or ctx.request_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
provider_format = ctx.provider_api_format or ""
|
|
|
|
|
|
client_format = ctx.client_api_format or ""
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-01-27 02:17:18 +08:00
|
|
|
|
converted_events = format_conversion_registry.convert_stream_chunk(
|
2026-01-21 18:07:10 +08:00
|
|
|
|
data_obj,
|
|
|
|
|
|
provider_format,
|
|
|
|
|
|
client_format,
|
|
|
|
|
|
state=ctx.stream_conversion_state,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = []
|
|
|
|
|
|
for evt in converted_events:
|
|
|
|
|
|
result.append(f"data: {json.dumps(evt, ensure_ascii=False)}")
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return [line]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConvertSseLineBasic:
|
|
|
|
|
|
"""基本转换测试"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_empty_line_returns_empty_list(self) -> None:
|
|
|
|
|
|
handler = MockCliHandler()
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ctx = StreamContext(model="test", api_format="openai:chat")
|
2026-01-21 18:07:10 +08:00
|
|
|
|
|
|
|
|
|
|
result = handler._convert_sse_line(ctx, "", [])
|
|
|
|
|
|
|
|
|
|
|
|
assert result == []
|
|
|
|
|
|
|
|
|
|
|
|
def test_whitespace_line_returns_line(self) -> None:
|
|
|
|
|
|
handler = MockCliHandler()
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ctx = StreamContext(model="test", api_format="openai:chat")
|
2026-01-21 18:07:10 +08:00
|
|
|
|
|
|
|
|
|
|
result = handler._convert_sse_line(ctx, " ", [])
|
|
|
|
|
|
|
|
|
|
|
|
assert result == [" "]
|
|
|
|
|
|
|
|
|
|
|
|
def test_done_marker_returns_as_is(self) -> None:
|
|
|
|
|
|
handler = MockCliHandler()
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ctx = StreamContext(model="test", api_format="openai:chat")
|
2026-01-21 18:07:10 +08:00
|
|
|
|
|
|
|
|
|
|
result = handler._convert_sse_line(ctx, "data: [DONE]", [])
|
|
|
|
|
|
|
|
|
|
|
|
assert result == ["data: [DONE]"]
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_data_line_passthrough(self) -> None:
|
|
|
|
|
|
handler = MockCliHandler()
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ctx = StreamContext(model="test", api_format="openai:chat")
|
2026-01-21 18:07:10 +08:00
|
|
|
|
|
|
|
|
|
|
result = handler._convert_sse_line(ctx, "event: message_start", [])
|
|
|
|
|
|
|
|
|
|
|
|
assert result == ["event: message_start"]
|
|
|
|
|
|
|
|
|
|
|
|
def test_invalid_json_passthrough(self) -> None:
|
|
|
|
|
|
handler = MockCliHandler()
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ctx = StreamContext(model="test", api_format="openai:chat")
|
2026-01-21 18:07:10 +08:00
|
|
|
|
|
|
|
|
|
|
result = handler._convert_sse_line(ctx, "data: {invalid json}", [])
|
|
|
|
|
|
|
|
|
|
|
|
assert result == ["data: {invalid json}"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConvertSseLineWithMockConverter:
|
|
|
|
|
|
"""使用 Mock 转换器的测试"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_same_format_returns_original(self) -> None:
|
|
|
|
|
|
"""同格式无需转换"""
|
|
|
|
|
|
handler = MockCliHandler()
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ctx = StreamContext(model="test", api_format="openai:chat")
|
|
|
|
|
|
ctx.provider_api_format = "openai:chat"
|
|
|
|
|
|
ctx.client_api_format = "openai:chat"
|
2026-01-21 18:07:10 +08:00
|
|
|
|
|
|
|
|
|
|
chunk = {"choices": [{"delta": {"content": "hello"}}]}
|
|
|
|
|
|
line = f"data: {json.dumps(chunk)}"
|
|
|
|
|
|
|
|
|
|
|
|
result = handler._convert_sse_line(ctx, line, [])
|
|
|
|
|
|
|
|
|
|
|
|
assert len(result) == 1
|
|
|
|
|
|
assert json.loads(result[0][6:]) == chunk
|
|
|
|
|
|
|
|
|
|
|
|
def test_state_initialization(self) -> None:
|
2026-01-29 10:32:36 +08:00
|
|
|
|
"""测试状态自动初始化
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-29 10:32:36 +08:00
|
|
|
|
流式转换状态应使用用户请求的原始模型名(ctx.model),
|
|
|
|
|
|
而非映射后的模型名(ctx.mapped_model),确保返回给客户端的响应使用原始模型名。
|
|
|
|
|
|
"""
|
2026-01-21 18:07:10 +08:00
|
|
|
|
handler = MockCliHandler()
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ctx = StreamContext(model="gpt-4", api_format="openai:chat")
|
|
|
|
|
|
ctx.provider_api_format = "openai:chat"
|
|
|
|
|
|
ctx.client_api_format = "openai:chat"
|
2026-01-29 10:32:36 +08:00
|
|
|
|
ctx.mapped_model = "claude-3-5-sonnet" # 映射后的模型名(发给上游的)
|
2026-01-21 18:07:10 +08:00
|
|
|
|
ctx.request_id = "req_123"
|
|
|
|
|
|
|
|
|
|
|
|
chunk = {"choices": [{"delta": {"content": "test"}}]}
|
|
|
|
|
|
line = f"data: {json.dumps(chunk)}"
|
|
|
|
|
|
|
|
|
|
|
|
handler._convert_sse_line(ctx, line, [])
|
|
|
|
|
|
|
2026-01-29 10:32:36 +08:00
|
|
|
|
# 验证状态已初始化,使用用户请求的原始模型名
|
2026-01-21 18:07:10 +08:00
|
|
|
|
assert ctx.stream_conversion_state is not None
|
2026-01-29 10:32:36 +08:00
|
|
|
|
assert ctx.stream_conversion_state.model == "gpt-4" # 应使用原始模型名,非 mapped_model
|
2026-01-21 18:07:10 +08:00
|
|
|
|
assert ctx.stream_conversion_state.message_id == "req_123"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConvertSseLineOneInManyOut:
|
|
|
|
|
|
"""一入多出测试(需要注册转换器)"""
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
|
def setup_converters(self):
|
2026-01-27 02:17:18 +08:00
|
|
|
|
"""确保 Canonical normalizers 已注册"""
|
|
|
|
|
|
from src.core.api_format.conversion import register_default_normalizers
|
2026-01-21 18:07:10 +08:00
|
|
|
|
|
2026-01-27 02:17:18 +08:00
|
|
|
|
register_default_normalizers()
|
2026-01-21 18:07:10 +08:00
|
|
|
|
|
|
|
|
|
|
yield
|
|
|
|
|
|
|
|
|
|
|
|
def test_openai_to_claude_conversion(self) -> None:
|
|
|
|
|
|
"""测试 OpenAI -> Claude 流式转换"""
|
|
|
|
|
|
handler = MockCliHandler()
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ctx = StreamContext(model="gpt-4", api_format="openai:chat")
|
|
|
|
|
|
ctx.provider_api_format = "openai:chat"
|
|
|
|
|
|
ctx.client_api_format = "claude:chat"
|
2026-01-21 18:07:10 +08:00
|
|
|
|
ctx.mapped_model = "claude-3-5-sonnet"
|
|
|
|
|
|
ctx.request_id = "req_test"
|
|
|
|
|
|
|
|
|
|
|
|
# 第一个 chunk:带 role
|
|
|
|
|
|
chunk1 = {
|
|
|
|
|
|
"id": "chatcmpl-123",
|
|
|
|
|
|
"object": "chat.completion.chunk",
|
|
|
|
|
|
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
|
|
|
|
|
|
}
|
|
|
|
|
|
line1 = f"data: {json.dumps(chunk1)}"
|
|
|
|
|
|
|
|
|
|
|
|
result1 = handler._convert_sse_line(ctx, line1, [])
|
|
|
|
|
|
|
|
|
|
|
|
# 应返回 message_start 事件
|
|
|
|
|
|
assert len(result1) >= 1
|
|
|
|
|
|
first_event = json.loads(result1[0][6:])
|
|
|
|
|
|
assert first_event.get("type") == "message_start"
|
|
|
|
|
|
|
|
|
|
|
|
def test_claude_to_openai_conversion(self) -> None:
|
|
|
|
|
|
"""测试 Claude -> OpenAI 流式转换"""
|
|
|
|
|
|
handler = MockCliHandler()
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ctx = StreamContext(model="claude-3-5-sonnet", api_format="claude:chat")
|
|
|
|
|
|
ctx.provider_api_format = "claude:chat"
|
|
|
|
|
|
ctx.client_api_format = "openai:chat"
|
2026-01-21 18:07:10 +08:00
|
|
|
|
ctx.mapped_model = "gpt-4"
|
|
|
|
|
|
ctx.request_id = "msg_test"
|
|
|
|
|
|
|
|
|
|
|
|
# Claude message_start 事件
|
|
|
|
|
|
event = {"type": "message_start", "message": {"id": "msg_123", "role": "assistant"}}
|
|
|
|
|
|
line = f"data: {json.dumps(event)}"
|
|
|
|
|
|
|
|
|
|
|
|
result = handler._convert_sse_line(ctx, line, [])
|
|
|
|
|
|
|
|
|
|
|
|
# 应返回 OpenAI 格式的 chunk
|
|
|
|
|
|
assert len(result) >= 1
|
|
|
|
|
|
chunk = json.loads(result[0][6:])
|
|
|
|
|
|
assert "choices" in chunk
|
|
|
|
|
|
|
|
|
|
|
|
def test_multiple_chunks_state_persistence(self) -> None:
|
|
|
|
|
|
"""测试多个 chunk 之间状态持久化"""
|
|
|
|
|
|
handler = MockCliHandler()
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ctx = StreamContext(model="gpt-4", api_format="openai:chat")
|
|
|
|
|
|
ctx.provider_api_format = "openai:chat"
|
|
|
|
|
|
ctx.client_api_format = "claude:chat"
|
2026-01-21 18:07:10 +08:00
|
|
|
|
ctx.mapped_model = "claude-3-5-sonnet"
|
|
|
|
|
|
|
|
|
|
|
|
# 第一个 chunk
|
|
|
|
|
|
chunk1 = {"choices": [{"delta": {"role": "assistant"}}]}
|
|
|
|
|
|
handler._convert_sse_line(ctx, f"data: {json.dumps(chunk1)}", [])
|
|
|
|
|
|
|
|
|
|
|
|
state_after_first = ctx.stream_conversion_state
|
|
|
|
|
|
|
|
|
|
|
|
# 第二个 chunk
|
|
|
|
|
|
chunk2 = {"choices": [{"delta": {"content": "hello"}}]}
|
|
|
|
|
|
handler._convert_sse_line(ctx, f"data: {json.dumps(chunk2)}", [])
|
|
|
|
|
|
|
|
|
|
|
|
# 状态应该是同一个对象
|
|
|
|
|
|
assert ctx.stream_conversion_state is state_after_first
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestStreamContextIntegration:
|
|
|
|
|
|
"""StreamContext 集成测试"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_stream_conversion_state_reset_on_retry(self) -> None:
|
|
|
|
|
|
"""测试重试时重置流式转换状态"""
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ctx = StreamContext(model="test", api_format="openai:chat")
|
2026-01-27 02:17:18 +08:00
|
|
|
|
ctx.stream_conversion_state = StreamState(model="test", message_id="123")
|
2026-01-21 18:07:10 +08:00
|
|
|
|
|
|
|
|
|
|
ctx.reset_for_retry()
|
|
|
|
|
|
|
|
|
|
|
|
assert ctx.stream_conversion_state is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_stream_conversion_state_field_exists(self) -> None:
|
|
|
|
|
|
"""测试 StreamContext 有 stream_conversion_state 字段"""
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ctx = StreamContext(model="test", api_format="openai:chat")
|
2026-01-21 18:07:10 +08:00
|
|
|
|
|
|
|
|
|
|
assert hasattr(ctx, "stream_conversion_state")
|
|
|
|
|
|
assert ctx.stream_conversion_state is None
|