"""OpenAI-compatible 流式适配器(WP-05)。 - 只消费逻辑请求(messages + 逻辑模型),输出标准化的流事件与 usage。 - 密钥从环境变量读取(endpoint.api_key_env),不落库、不进日志。 - Provider 未返回的 usage 字段保持 None(ADR-003:不估算伪装)。 - transport 可注入,供测试与故障注入。 """ import json import os from collections.abc import AsyncIterator from dataclasses import dataclass from typing import Any import httpx class GatewayError(RuntimeError): """网关错误:稳定分类,供 ModelCall 失败记录与上层重试决策。""" @dataclass class StreamChunk: kind: str # "delta" | "usage" | "done" text: str | None = None usage: dict[str, int] | None = None finish_reason: str | None = None @dataclass class AdapterRequest: messages: list[dict[str, str]] # {"role", "content"}:纯文本最小集 model: str # 上游模型 ID def _parse_sse_line(line: str) -> dict[str, Any] | None: if not line.startswith("data:"): return None payload = line.removeprefix("data:").strip() if not payload or payload == "[DONE]": return None try: parsed: dict[str, Any] = json.loads(payload) return parsed except json.JSONDecodeError: return None def _usage_from_final(chunk: dict[str, Any]) -> dict[str, int] | None: usage = chunk.get("usage") if not isinstance(usage, dict): return None result: dict[str, int] = {} keys = ( "prompt_tokens", "completion_tokens", "total_tokens", "cache_creation_input_tokens", "cache_read_input_tokens", ) for key in keys: value = usage.get(key) if isinstance(value, int): result[key] = value return result or None async def stream_chat( base_url: str, model_id: str, api_key_env: str, request: AdapterRequest, *, request_timeout_s: float = 120.0, transport: httpx.AsyncBaseTransport | None = None, ) -> AsyncIterator[StreamChunk]: """流式调用 OpenAI-compatible /chat/completions,产出标准化块。""" api_key = os.environ.get(api_key_env, "") if not api_key: raise GatewayError(f"环境变量 {api_key_env} 未配置 Provider 密钥") url = base_url.rstrip("/") + "/chat/completions" body = { "model": model_id, "messages": request.messages, "stream": True, "stream_options": {"include_usage": True}, } headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} async with httpx.AsyncClient(timeout=request_timeout_s, transport=transport) as client: async with client.stream("POST", url, json=body, headers=headers) as resp: if resp.status_code != 200: detail = (await resp.aread()).decode("utf-8", "replace")[:500] raise GatewayError(f"上游返回 {resp.status_code}: {detail}") async for line in resp.aiter_lines(): chunk = _parse_sse_line(line) if chunk is None: continue for choice in chunk.get("choices", []): text = choice.get("delta", {}).get("content") if text: yield StreamChunk(kind="delta", text=text) finish = choice.get("finish_reason") if finish: yield StreamChunk(kind="done", finish_reason=finish) usage = _usage_from_final(chunk) if usage is not None: yield StreamChunk(kind="usage", usage=usage)