"""OpenAICompatTransport 测试(M1 设计 §4.4/§6):SSE 解析、错误翻译、缺 DONE 语义。 SSE 帧样本按三项目真实网关响应形态二次构造(OpenAI 兼容 chunk 结构)。 """ import json import httpx import pytest from polygateway.errors import ( RequestRejectedError, SourceDeadError, TransientError, ) from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.pricing import ModelPrice, PricingTable from polygateway.transports.openai_compat import ( OpenAICompatTransport, _iter_sse_deltas, _sse_data_payload, ) from polygateway.types import ChatRequest, LLMResponse, SourceConfig def _source(**overrides): base = { "name": "qwen_1", "provider": "qwen", "base_url": "https://gw.example/v1", "api_key": "sk-test", "model": "qwen-max", "timeout_s": 5.0, } base.update(overrides) return SourceConfig(**base) def _chunk(content=None, reasoning=None, usage=None): delta = {} if content is not None: delta["content"] = content if reasoning is not None: delta["reasoning_content"] = reasoning body = {"choices": [{"delta": delta}]} if (delta or usage is None) else {"choices": []} if usage is not None: body["usage"] = usage return f"data: {json.dumps(body)}\n\n" _USAGE = {"prompt_tokens": 11, "completion_tokens": 7} def _sse_stream(*frames, done=True): text = "".join(frames) + ("data: [DONE]\n\n" if done else "") return httpx.Response(200, content=text.encode(), headers={"content-type": "text/event-stream"}) def _transport_for(handler): mock = httpx.MockTransport(handler) return OpenAICompatTransport( client_factory=lambda src: httpx.AsyncClient(base_url=src.base_url, transport=mock) ) async def _complete(transport, source, *, stream=True, overlay=None): return await transport.complete( messages=[{"role": "user", "content": "hi"}], source=source, stream=stream, overlay=overlay or {}, call_id="cid-1", ) # 单价刻意取"输出贵于输入"的真实形态: est_tokens 兜底把整估值塞进 completion # 时,虚高才显形(设计 §1 的 26 倍算例即此单价)。 _PRICING = PricingTable({"qwen-max": ModelPrice(input_per_1m=1.0, output_per_1m=8.0)}) class _MemoryRecorder: def __init__(self): self.rows = [] async def record_llm_call(self, **fields): self.rows.append(fields) async def _recorded_cost(result, source): """把 transport 产物走一遍真实计费路径,返回落库的 cost。 `unavailable` → cost=None 的判定在 `TelemetryEmitter` 里(设计 §3.2 #5), 直接调 `PricingTable.cost` 对 `0/0` 只会得到 `0.0`——那正是本组用例要防的 假金额,故断言必须穿过 emitter 而不是单测 pricing。 """ recorder = _MemoryRecorder() response = LLMResponse( content=result.content, thinking=result.thinking, model=source.model, provider=source.provider, prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, latency_ms=1, ttft_ms=result.ttft_ms, max_inter_token_ms=result.max_inter_token_ms, cache_hit=False, call_id="cid-1", source_name=source.name, usage_source=result.usage_source, ) await TelemetryEmitter(recorder, pricing=_PRICING).emit_attempt( request=ChatRequest(messages=[{"role": "user", "content": "hi"}]), source=source, call_id="cid-1", latency_ms=1, response=response, error=None, ) return recorder.rows[0]["cost"] class TestSsePureFunctions: def test_data_payload_filters_noise(self): assert _sse_data_payload("") is None assert _sse_data_payload(": ping") is None assert _sse_data_payload("event: x") is None assert _sse_data_payload("data: {}") == "{}" assert _sse_data_payload("data: [DONE]") == "[DONE]" async def test_iter_deltas_yields_and_flags_done(self): async def lines(): for raw in [ _chunk(content="he"), ": ping", _chunk(reasoning="think"), _chunk(usage=_USAGE), "data: [DONE]", ]: for line in raw.splitlines(): yield line sink = {} out = [d async for d in _iter_sse_deltas(lines(), sink)] assert out == [(True, "he"), (False, "think")] assert sink["done"] is True and sink["usage"] == _USAGE async def test_malformed_frame_raises_transient(self): async def lines(): yield "data: {not-json" with pytest.raises(TransientError, match="malformed"): _ = [d async for d in _iter_sse_deltas(lines(), {})] class TestStreamHappyPath: async def test_full_stream_with_usage(self): def handler(request): return _sse_stream( _chunk(reasoning="ponder"), _chunk(content="hello"), _chunk(content=" world"), _chunk(usage=_USAGE), ) result = await _complete(_transport_for(handler), _source()) assert result.content == "hello world" assert result.thinking == "ponder" assert result.prompt_tokens == 11 and result.completion_tokens == 7 assert result.usage_source == "measured" assert result.ttft_ms is not None and result.ttft_ms >= 0 async def test_qwen_think_tag_stripped(self): def handler(request): return _sse_stream(_chunk(content="hmmanswer"), _chunk(usage=_USAGE)) result = await _complete(_transport_for(handler), _source()) assert result.content == "answer" assert result.thinking == "hmm" async def test_usage_missing_is_unavailable_with_null_cost(self): """usage 帧缺失 → 0/0 + unavailable + cost NULL(设计 §3.2 #3)。 改前拿 `est_tokens` 当实测并整估值塞 completion,同一条调用记成 `0/4000` → cost 0.032(设计 §1 的 26 倍虚高)。 """ def handler(request): return _sse_stream(_chunk(content="ok")) source = _source(tpm=1000, est_tokens=4000) result = await _complete(_transport_for(handler), source) assert result.usage_source == "unavailable" assert result.prompt_tokens == 0 and result.completion_tokens == 0 assert await _recorded_cost(result, source) is None class TestMissingDoneSemantics: def _no_done_handler(self, request): return _sse_stream(_chunk(content="partial"), _chunk(usage=_USAGE), done=False) async def test_default_retry_policy_raises_transient(self): with pytest.raises(TransientError, match="missing_done|truncated"): await _complete(_transport_for(self._no_done_handler), _source()) async def test_salvage_with_usage_frame_degrades_to_estimated(self): """打捞且收到 usage 帧: 数字真实、可信度降级 → estimated 且照常计费。""" source = _source(missing_done="salvage", tpm=1000, est_tokens=4000) result = await _complete(_transport_for(self._no_done_handler), source) assert result.content == "partial" assert result.usage_source == "estimated" assert result.prompt_tokens == 11 and result.completion_tokens == 7 assert await _recorded_cost(result, source) == pytest.approx( 11 / 1_000_000 * 1.0 + 7 / 1_000_000 * 8.0 ) async def test_salvage_without_usage_frame_stays_unavailable(self): """打捞且 usage 帧缺失: 0/0 不得被洗成 estimated,否则算出假的 0.0(设计 §3.2 #4)。""" def handler(request): return _sse_stream(_chunk(content="partial"), done=False) source = _source(missing_done="salvage", tpm=1000, est_tokens=4000) result = await _complete(_transport_for(handler), source) assert result.content == "partial" assert result.usage_source == "unavailable" assert await _recorded_cost(result, source) is None async def test_early_eof_always_transient_even_under_salvage(self): def handler(request): return _sse_stream(done=False) # 零内容提前断流 with pytest.raises(TransientError, match="early_eof"): await _complete(_transport_for(handler), _source(missing_done="salvage")) class TestEmptyCompletion: """空补全 → TransientError(2026-07-20 人类裁决;MiniMax 间歇形态,绝不缓存)。""" async def test_stream_zero_content_with_done_is_transient(self): def handler(request): return _sse_stream(_chunk(reasoning="only thinking"), _chunk(usage=_USAGE)) with pytest.raises(TransientError, match="empty_completion"): await _complete(_transport_for(handler), _source()) async def test_non_stream_empty_content_is_transient(self): def handler(request): return httpx.Response( 200, json={"choices": [{"message": {"content": ""}}], "usage": _USAGE} ) with pytest.raises(TransientError, match="empty_completion"): await _complete(_transport_for(handler), _source(), stream=False) async def test_think_only_content_after_strip_is_transient(self): def handler(request): return _sse_stream(_chunk(content="hmm"), _chunk(usage=_USAGE)) with pytest.raises(TransientError, match="empty_completion"): await _complete(_transport_for(handler), _source()) class TestNonStreamFastPath: async def test_non_stream_parses_message(self): def handler(request): body = json.loads(request.content) assert body.get("stream") is False and "stream_options" not in body return httpx.Response( 200, json={ "choices": [{"message": {"content": "42", "reasoning_content": "count"}}], "usage": _USAGE, }, ) result = await _complete(_transport_for(handler), _source(), stream=False) assert result.content == "42" and result.thinking == "count" assert result.usage_source == "measured" assert result.ttft_ms is None class TestRequestShaping: @pytest.mark.parametrize( ("enable_thinking", "expected"), [(True, {"enable_thinking": True}), (False, {"enable_thinking": False}), (None, {})], ) async def test_thinking_tri_state_injection(self, enable_thinking, expected): seen = {} def handler(request): seen.update(json.loads(request.content)) return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE)) await _complete(_transport_for(handler), _source(enable_thinking=enable_thinking)) assert {k: seen[k] for k in expected} == expected if enable_thinking is None: assert "enable_thinking" not in seen assert seen["stream_options"] == {"include_usage": True} async def test_overlay_merged_into_payload(self): seen = {} def handler(request): seen.update(json.loads(request.content)) return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE)) await _complete( _transport_for(handler), _source(), overlay={"response_format": {"type": "json_object"}}, ) assert seen["response_format"] == {"type": "json_object"} class TestErrorTranslation: @pytest.mark.parametrize( ("status", "body", "headers", "exc", "match"), [ (401, "{}", {}, SourceDeadError, None), (403, "{}", {}, SourceDeadError, None), (400, "{}", {}, RequestRejectedError, None), (418, "{}", {}, RequestRejectedError, None), (500, "{}", {}, TransientError, None), (503, "{}", {}, TransientError, None), (429, json.dumps({"error": {"type": "insufficient_quota"}}), {}, SourceDeadError, None), (429, "{}", {"retry-after": "2.5"}, TransientError, None), ], ) async def test_status_translation(self, status, body, headers, exc, match): def handler(request): return httpx.Response(status, content=body.encode(), headers=headers) with pytest.raises(exc) as ei: await _complete(_transport_for(handler), _source()) assert ei.value.status_code == status assert ei.value.source_name == "qwen_1" if status == 429 and exc is TransientError: assert ei.value.retry_after_s == 2.5 async def test_retry_after_http_date_ignored(self): def handler(request): return httpx.Response( 429, content=b"{}", headers={"retry-after": "Wed, 21 Oct 2026 07:28:00 GMT"} ) with pytest.raises(TransientError) as ei: await _complete(_transport_for(handler), _source()) assert ei.value.retry_after_s is None # 仅支持秒数形态(CHS 同款) async def test_httpx_timeout_becomes_transient(self): def handler(request): raise httpx.ConnectTimeout("boom") with pytest.raises(TransientError): await _complete(_transport_for(handler), _source()) async def test_httpx_transport_error_becomes_transient(self): def handler(request): raise httpx.RemoteProtocolError("broken pipe") with pytest.raises(TransientError): await _complete(_transport_for(handler), _source()) class TestLifecycle: async def test_aclose_idempotent(self): def handler(request): return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE)) transport = _transport_for(handler) await _complete(transport, _source()) await transport.aclose() await transport.aclose()