From 454a8b5e0fea259f2ea1e110c8ae56d9c8bee33c Mon Sep 17 00:00:00 2001 From: iomgaa Date: Mon, 20 Jul 2026 06:45:55 -0400 Subject: [PATCH] feat: add openai-compatible transport with sse parsing and error translation --- src/polygateway/transports/openai_compat.py | 304 ++++++++++++++++++++ tests/unit/test_openai_compat.py | 255 ++++++++++++++++ 2 files changed, 559 insertions(+) create mode 100644 src/polygateway/transports/openai_compat.py create mode 100644 tests/unit/test_openai_compat.py diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py new file mode 100644 index 0000000..511a70f --- /dev/null +++ b/src/polygateway/transports/openai_compat.py @@ -0,0 +1,304 @@ +"""OpenAI 兼容 transport(D2 默认): 手写 httpx + SSE 解析 + 错误翻译。 + +SSE 纯函数移植 VT `adapters/llm.py:51-124`;错误翻译移植 CHS +`app/providers/invokers.py:127-227`。职责只到"一次原始调用"——重试/限流/ +缓存归中间件。看门狗活性口径: content 与 reasoning_content 增量都作为流 +元素产出,思考流天然刷新计时(CHS 迁移约束 R1)。 +""" + +from __future__ import annotations + +import json +import re +import time +from typing import TYPE_CHECKING, Any + +import httpx + +from polygateway.errors import RequestRejectedError, SourceDeadError, TransientError +from polygateway.providers import ProviderProfile, get_provider +from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts +from polygateway.types import SourceConfig, TransportResult + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Callable, Mapping + +_THINK_PATTERN = re.compile(r"(.*?)", re.DOTALL) + +# —— SSE 纯函数(VT llm.py 同款)—— + + +def _sse_data_payload(raw: str) -> str | None: + """提取 SSE data 行载荷;ping(: 开头)/空行/非 data 行返回 None 跳过。""" + line = raw.strip() + if not line or line.startswith(":") or not line.startswith("data:"): + return None + return line[len("data:") :].strip() + + +def _sse_delta(chunk: dict[str, Any], usage_sink: dict[str, Any]) -> tuple[bool, str] | None: + """从 chunk 提取增量: (True, content) 或 (False, reasoning);usage 帧旁路进 sink。""" + if chunk.get("usage"): + usage_sink["usage"] = chunk["usage"] + choices = chunk.get("choices") or [] + if not choices: + return None + delta = choices[0].get("delta") or {} + content = delta.get("content") + if content: + return (True, content) + reasoning = delta.get("reasoning_content") + if reasoning: + return (False, reasoning) + return None + + +async def _iter_sse_deltas( + lines: AsyncIterator[str], usage_sink: dict[str, Any] +) -> AsyncIterator[tuple[bool, str]]: + """逐行解析 SSE 流;[DONE] 置 sink["done"];畸形 JSON 帧 → 瞬时错误(可重试)。""" + async for raw in lines: + data = _sse_data_payload(raw) + if data is None: + continue + if data == "[DONE]": + usage_sink["done"] = True + return + try: + chunk = json.loads(data) + except json.JSONDecodeError as exc: + raise TransientError(f"SSE 帧畸形(malformed_json): {data[:80]!r}", operation="chat") from exc + delta = _sse_delta(chunk, usage_sink) + if delta is not None: + yield delta + + +# —— 错误翻译(CHS invokers.py 同款)—— + + +def _parse_retry_after(raw: str | None) -> float | None: + """解析 Retry-After 头;仅支持秒数形态,HTTP-date 返回 None(CHS 同款)。""" + if raw is None: + return None + try: + seconds = float(raw.strip()) + except ValueError: + return None + return seconds if seconds > 0 else None + + +def _translate_429(source: SourceConfig, body_text: str, headers: Mapping[str, str]) -> Exception: + try: + err_type = json.loads(body_text).get("error", {}).get("type", "") + except (json.JSONDecodeError, AttributeError): + err_type = "" + if err_type == "insufficient_quota": + return SourceDeadError( + f"{source.name} 配额耗尽(insufficient_quota)", + source_name=source.name, status_code=429, operation="chat", + ) + return TransientError( + f"{source.name} 限速: 429", + retry_after_s=_parse_retry_after(headers.get("retry-after")), + source_name=source.name, status_code=429, operation="chat", + ) + + +def _status_to_error( + source: SourceConfig, status: int, body_text: str, headers: Mapping[str, str] +) -> Exception: + ctx: dict[str, Any] = {"source_name": source.name, "status_code": status, "operation": "chat"} + if status in (401, 403): + return SourceDeadError(f"{source.name} 凭据失效/欠费: {status}", **ctx) + if status == 400: + return RequestRejectedError(f"{source.name} 请求被拒: 400", **ctx) + if status == 429: + return _translate_429(source, body_text, headers) + if status >= 500: + return TransientError(f"{source.name} 瞬时错误: {status}", **ctx) + return RequestRejectedError(f"{source.name} 客户端错误: {status}", **ctx) + + +def _strip_think(content: str) -> tuple[str, str]: + """剥离 标签(qwen 系),返回 (正文, 思考流)。VT llm.py:147-164 同款。""" + match = _THINK_PATTERN.search(content) + if match is None: + return content, "" + return _THINK_PATTERN.sub("", content).strip(), match.group(1).strip() + + +def _resolve_usage(usage: dict[str, Any], source: SourceConfig) -> tuple[int, int, str]: + """usage 帧读取;缺失/非法按 est_tokens 保守兜底并标 estimated(CHS invokers.py:241)。""" + prompt, completion = usage.get("prompt_tokens"), usage.get("completion_tokens") + if isinstance(prompt, int) and isinstance(completion, int) and prompt + completion > 0: + return prompt, completion, "measured" + return 0, source.est_tokens, "estimated" + + +def _default_client_factory(source: SourceConfig) -> httpx.AsyncClient: + return httpx.AsyncClient( + headers={"Authorization": f"Bearer {source.api_key}"}, + timeout=httpx.Timeout(source.timeout_s), + trust_env=source.trust_env, + ) + + +class OpenAICompatTransport: + """默认 transport: 每源一个预配 httpx client,懒创建,aclose 统一释放。""" + + def __init__( + self, + *, + registry: Mapping[str, ProviderProfile] | None = None, + client_factory: Callable[[SourceConfig], httpx.AsyncClient] | None = None, + ) -> None: + self._registry = registry + self._client_factory = client_factory or _default_client_factory + self._clients: dict[str, httpx.AsyncClient] = {} + + def _client_for(self, source: SourceConfig) -> httpx.AsyncClient: + client = self._clients.get(source.name) + if client is None: + client = self._client_factory(source) + self._clients[source.name] = client + return client + + def _build_payload( + self, *, messages: list[dict[str, Any]], source: SourceConfig, + profile: ProviderProfile, stream: bool, overlay: dict[str, Any], + ) -> dict[str, Any]: + payload: dict[str, Any] = {"model": source.model, "messages": messages, "stream": stream} + if stream: + payload["stream_options"] = {"include_usage": True} # 强制 usage 帧(三项目同款) + if source.enable_thinking is True: + payload.update(profile.thinking_on) + elif source.enable_thinking is False: + payload.update(profile.thinking_off) + payload.update(overlay) + return payload + + async def complete( + self, *, messages: list[dict[str, Any]], source: SourceConfig, + stream: bool, overlay: dict[str, Any], call_id: str, + ) -> TransportResult: + """一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。""" + profile = get_provider(source.provider, registry=self._registry) + payload = self._build_payload( + messages=messages, source=source, profile=profile, stream=stream, overlay=overlay + ) + url = source.base_url.rstrip("/") + "/chat/completions" + client = self._client_for(source) + ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"} + try: + if stream: + return await self._complete_stream(client, url, payload, source, profile) + return await self._complete_once(client, url, payload, source, profile) + except StreamLivenessTimeout as exc: + raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc + except httpx.TimeoutException as exc: + raise TransientError(f"{source.name} 超时: {exc}", **ctx) from exc + except httpx.TransportError as exc: + # VT 宽集: 覆盖断连/协议错误/读写失败(设计 §9 行 8) + raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc + + async def _complete_stream( + self, client: httpx.AsyncClient, url: str, payload: dict[str, Any], + source: SourceConfig, profile: ProviderProfile, + ) -> TransportResult: + started = time.monotonic() + async with client.stream("POST", url, json=payload) as resp: + if resp.status_code != 200: + body = (await resp.aread()).decode("utf-8", errors="replace") + raise _status_to_error(source, resp.status_code, body, resp.headers) + sink: dict[str, Any] = {} + guarded = stream_with_liveness_timeouts( + _iter_sse_deltas(resp.aiter_lines(), sink), + ttft_s=source.ttft_timeout_s or source.timeout_s, + inter_token_s=source.inter_token_timeout_s or source.timeout_s, + total_s=source.timeout_s, + ) + content_parts: list[str] = [] + thinking_parts: list[str] = [] + ttft_ms: float | None = None + last = started + max_gap = 0.0 + async for is_content, text in guarded: + now = time.monotonic() + if ttft_ms is None: + ttft_ms = (now - started) * 1000 + else: + max_gap = max(max_gap, (now - last) * 1000) + last = now + (content_parts if is_content else thinking_parts).append(text) + salvaged = self._check_done(sink, content_parts, thinking_parts, source) + content, thinking = self._finalize_text(content_parts, thinking_parts, profile) + prompt, completion, usage_source = _resolve_usage(sink.get("usage") or {}, source) + if salvaged: + usage_source = "estimated" # 打捞路径强制 estimated(设计 §6) + return TransportResult( + content=content, thinking=thinking, prompt_tokens=prompt, + completion_tokens=completion, usage_source=usage_source, + ttft_ms=ttft_ms, max_inter_token_ms=(max_gap if ttft_ms is not None else None), + raw={"usage": sink.get("usage")}, + ) + + def _check_done( + self, sink: dict[str, Any], content_parts: list[str], + thinking_parts: list[str], source: SourceConfig, + ) -> bool: + """缺 [DONE] 语义(设计 §6): 零内容恒 retry;有内容按 missing_done 策略。""" + if sink.get("done"): + return False + ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"} + if not content_parts and not thinking_parts: + raise TransientError(f"{source.name} SSE early_eof: 零内容断流", **ctx) + if source.missing_done == "retry": + raise TransientError(f"{source.name} SSE missing_done: 截断且无 [DONE]", **ctx) + return True + + def _finalize_text( + self, content_parts: list[str], thinking_parts: list[str], profile: ProviderProfile + ) -> tuple[str, str]: + content = "".join(content_parts) + thinking = "".join(thinking_parts) + if profile.strip_think_tags and "" in content: + content, tag_thinking = _strip_think(content) + if tag_thinking: + thinking = tag_thinking + return content, thinking + + async def _complete_once( + self, client: httpx.AsyncClient, url: str, payload: dict[str, Any], + source: SourceConfig, profile: ProviderProfile, + ) -> TransportResult: + """非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。""" + resp = await client.post(url, json=payload) + if resp.status_code != 200: + raise _status_to_error(source, resp.status_code, resp.text, resp.headers) + try: + body = resp.json() + except json.JSONDecodeError as exc: + raise TransientError( + f"{source.name} 非流式响应非法 JSON", source_name=source.name, operation="chat" + ) from exc + choices = body.get("choices") or [] + if not choices: + raise TransientError( + f"{source.name} 响应缺 choices", source_name=source.name, operation="chat" + ) + message = choices[0].get("message") or {} + content, thinking = self._finalize_text( + [message.get("content") or ""], [message.get("reasoning_content") or ""], profile + ) + prompt, completion, usage_source = _resolve_usage(body.get("usage") or {}, source) + return TransportResult( + content=content, thinking=thinking, prompt_tokens=prompt, + completion_tokens=completion, usage_source=usage_source, + ttft_ms=None, max_inter_token_ms=None, raw={"usage": body.get("usage")}, + ) + + async def aclose(self) -> None: + """幂等关闭全部源 client。""" + clients, self._clients = self._clients, {} + for client in clients.values(): + await client.aclose() diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py new file mode 100644 index 0000000..9440225 --- /dev/null +++ b/tests/unit/test_openai_compat.py @@ -0,0 +1,255 @@ +"""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.transports.openai_compat import ( + OpenAICompatTransport, + _iter_sse_deltas, + _sse_data_payload, +) +from polygateway.types import SourceConfig + + +def _source(**overrides): + base = dict( + 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", + ) + + +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_falls_back_to_est(self): + def handler(request): + return _sse_stream(_chunk(content="ok")) + + result = await _complete(_transport_for(handler), _source(tpm=1000, est_tokens=333)) + assert result.usage_source == "estimated" + assert result.prompt_tokens == 0 and result.completion_tokens == 333 + + +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_policy_keeps_content_as_estimated(self): + result = await _complete( + _transport_for(self._no_done_handler), _source(missing_done="salvage") + ) + assert result.content == "partial" + assert result.usage_source == "estimated" # 打捞路径强制 estimated + + 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 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()