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()