feat: preserve governed OpenAI tool messages in 1.4.0
Add faithful single-choice chat results, tool stream activity, error-frame retries, and isolated protocol caching. Reject empty cached completions with the same value predicate as transport, add regression coverage, and adopt Apache-2.0.
This commit is contained in:
@@ -47,12 +47,14 @@ from polygateway.types import (
|
||||
OcrLayoutElement,
|
||||
OcrLayoutResult,
|
||||
OcrTextResult,
|
||||
OpenAIChatCompletion,
|
||||
ResponseMode,
|
||||
SourceConfig,
|
||||
TelemetryStatus,
|
||||
ThinkingObservation,
|
||||
)
|
||||
|
||||
__version__ = "1.3.8"
|
||||
__version__ = "1.4.0"
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PROFILES",
|
||||
@@ -70,6 +72,8 @@ __all__ = [
|
||||
"GatewayUnavailableError",
|
||||
"GovernanceBackendError",
|
||||
"LLMResponse",
|
||||
"ResponseMode",
|
||||
"OpenAIChatCompletion",
|
||||
"ModelPrice",
|
||||
"OcrClient",
|
||||
"OcrLayoutElement",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
@@ -48,6 +49,7 @@ from polygateway.types import (
|
||||
ChatRequest,
|
||||
Effort,
|
||||
LLMResponse,
|
||||
ResponseMode,
|
||||
TelemetryStatus,
|
||||
_CallContext,
|
||||
coerce_effort,
|
||||
@@ -372,9 +374,15 @@ class GatewayClient:
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
call_deadline_s: float | None = None,
|
||||
response_mode: ResponseMode = "text",
|
||||
) -> LLMResponse:
|
||||
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
|
||||
|
||||
`response_mode="openai"` 返回 `completion` 完整单 choice 消息快照;
|
||||
不支持与 structured 并用,不执行工具。空正文但完整工具是合法结果;
|
||||
缺 [DONE] 不打捞。默认 text 保持既有文本投影。返回模式不绕过受管推理
|
||||
校验;raw-only 要求源级与请求级推理意图均为 None。
|
||||
|
||||
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级
|
||||
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
|
||||
参数不影响既有调用点(issue #4)。
|
||||
@@ -393,6 +401,23 @@ class GatewayClient:
|
||||
`CallDeadlineExceeded`,但到期**不等于未产出、未计费**——在途请求可能已发出、
|
||||
已被上游计费,且清理仍在 `finally` 里完成,故返回时刻 = 期限 + 清理耗时。
|
||||
"""
|
||||
if response_mode not in ("text", "openai"):
|
||||
raise ValueError("response_mode 必须为 text 或 openai")
|
||||
if response_mode == "openai":
|
||||
if structured is not None:
|
||||
raise ValueError("openai 模式不能与 structured 并用")
|
||||
if (
|
||||
overlay is not None
|
||||
and "n" in overlay
|
||||
and (type(overlay["n"]) is not int or overlay["n"] != 1)
|
||||
):
|
||||
raise ValueError("openai 模式仅支持 n=1")
|
||||
try:
|
||||
json.dumps(messages, allow_nan=False)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError("messages 必须可 JSON 序列化") from exc
|
||||
messages = copy.deepcopy(messages)
|
||||
overlay = copy.deepcopy(dict(overlay)) if overlay is not None else None
|
||||
if structured is not None and not self._structured_available:
|
||||
raise ImportError(
|
||||
"结构化输出未启用: 安装 pip install 'polygateway[structured]' 后重新装配"
|
||||
@@ -429,6 +454,7 @@ class GatewayClient:
|
||||
context = _CallContext(now=self._now)
|
||||
request = ChatRequest(
|
||||
messages=messages,
|
||||
response_mode=response_mode,
|
||||
session_id=session_id,
|
||||
parent_call_id=parent_call_id,
|
||||
cache_salt=cache_salt,
|
||||
|
||||
@@ -37,7 +37,7 @@ SOURCE_REASONS = frozenset(
|
||||
class PolyGatewayError(Exception):
|
||||
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。
|
||||
|
||||
`body_text` 是**非 2xx 响应体的摘要**——网关拒绝这次调用时说的话(issue #10)。
|
||||
`body_text` 是**HTTP 失败或 200 chat error 帧的摘要**——网关拒绝这次调用时说的话(issue #10)。
|
||||
它与 `ResultInvalidError.raw_text` 是两回事,严禁混用:
|
||||
|
||||
============== ==================================================
|
||||
|
||||
@@ -17,7 +17,15 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.types import ChatRequest, Effort, LLMResponse, ThinkingObservation
|
||||
from polygateway.types import (
|
||||
ChatRequest,
|
||||
Effort,
|
||||
LLMResponse,
|
||||
OpenAIChatCompletion,
|
||||
ResponseMode,
|
||||
ThinkingObservation,
|
||||
_has_completion_output,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
@@ -107,6 +115,7 @@ def build_cache_key(
|
||||
*,
|
||||
sampling: Mapping[str, Any] | None = None,
|
||||
reasoning_effort: Effort | None = None,
|
||||
response_mode: ResponseMode = "text",
|
||||
) -> str:
|
||||
"""缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。
|
||||
|
||||
@@ -127,6 +136,8 @@ def build_cache_key(
|
||||
"messages": digest_messages(messages),
|
||||
"namespace": namespace,
|
||||
}
|
||||
if response_mode != "text":
|
||||
key_obj["response_mode"] = response_mode
|
||||
if salt is not None:
|
||||
key_obj["salt"] = salt
|
||||
if sampling:
|
||||
@@ -176,6 +187,7 @@ class CacheMW:
|
||||
# 请求级档位必须逐次进 key: `self._fingerprint` 是装配期的集合级指纹,
|
||||
# 同一个 client 上 low 与 max 两次调用在它眼里毫无分别(issue #20)
|
||||
reasoning_effort=request.reasoning_effort,
|
||||
response_mode=request.response_mode,
|
||||
)
|
||||
cached = await self._safe_get(key)
|
||||
if cached is not None:
|
||||
@@ -183,7 +195,12 @@ class CacheMW:
|
||||
if hit is not None:
|
||||
return hit
|
||||
response = await call_next(request)
|
||||
await self._safe_set(key, self._serialize(response))
|
||||
try:
|
||||
serialized = self._serialize(response)
|
||||
except Exception as exc:
|
||||
logger.warning("缓存序列化失败,跳过缓存: {}", type(exc).__name__)
|
||||
else:
|
||||
await self._safe_set(key, serialized)
|
||||
return response
|
||||
|
||||
# —— 命中路径 ——
|
||||
@@ -193,6 +210,16 @@ class CacheMW:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
|
||||
completion = fields.get("completion")
|
||||
if completion is not None:
|
||||
if not isinstance(completion, dict):
|
||||
raise ValueError("缓存 completion 必须为对象")
|
||||
fields["completion"] = OpenAIChatCompletion(**completion)
|
||||
if request.response_mode == "openai":
|
||||
if fields.get("completion") is None:
|
||||
raise ValueError("保真缓存缺 completion")
|
||||
if not _has_completion_output(fields.get("content", ""), fields["completion"]):
|
||||
raise ValueError("保真缓存无有效正文或工具产物")
|
||||
structured_data = self._rebuild_structured(fields.get("content", ""), request)
|
||||
# JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉
|
||||
# (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False);
|
||||
|
||||
@@ -357,6 +357,7 @@ class RetryMW:
|
||||
gen_started = self._now()
|
||||
result = await self._transport.complete(
|
||||
messages=request.messages,
|
||||
response_mode=request.response_mode,
|
||||
source=source,
|
||||
stream=request.stream,
|
||||
overlay=request.overlay,
|
||||
@@ -739,6 +740,7 @@ class RetryMW:
|
||||
) -> LLMResponse:
|
||||
return LLMResponse(
|
||||
content=result.content,
|
||||
completion=result.completion,
|
||||
thinking=result.thinking,
|
||||
model=source.model,
|
||||
provider=source.provider,
|
||||
|
||||
@@ -20,6 +20,7 @@ from .types import (
|
||||
OcrLayoutTransportResult,
|
||||
OcrTextResult,
|
||||
OcrTextTransportResult,
|
||||
ResponseMode,
|
||||
SourceConfig,
|
||||
SourceStats,
|
||||
TelemetryStatus,
|
||||
@@ -40,6 +41,10 @@ class Middleware(Protocol):
|
||||
class Transport(Protocol):
|
||||
"""一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。
|
||||
|
||||
`response_mode` 为必填返回契约:openai 成功必须提供 completion,
|
||||
不支持者应 RequestRejected,不能静默退回文本。工具的实际增量与首次身份
|
||||
同正文/推理共用活性事件;同帧只置位一次,重复身份不算进展。
|
||||
|
||||
`reasoning_effort` 是本次调用要求的推理档位(`None` = 不表态,随源级配置)。
|
||||
它必须走**协议参数**而不能让 transport 自己去读 `ChatRequest`: 端口只收拆开的
|
||||
请求要素,是为了让 transport 不依赖洋葱内部的请求类型(P7 端口最内层)。
|
||||
@@ -70,6 +75,7 @@ class Transport(Protocol):
|
||||
reasoning_effort: Effort | None,
|
||||
first_token_event: asyncio.Event | None,
|
||||
content_event: asyncio.Event | None,
|
||||
response_mode: ResponseMode,
|
||||
) -> TransportResult: ...
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""OpenAI 单 choice 消息聚合:仅协议值处理,不做 I/O 或治理。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from polygateway.errors import TransientError
|
||||
from polygateway.types import OpenAIChatCompletion, ResponseMode
|
||||
|
||||
|
||||
def _invalid(reason: str) -> TransientError:
|
||||
"""协议形态失败,不回显可能敏感的工具参数。"""
|
||||
return TransientError(f"OpenAI 协议失败: {reason}", operation="chat")
|
||||
|
||||
|
||||
def _object(value: Any, name: str) -> dict[str, Any]:
|
||||
"""外部对象先校验再读取。"""
|
||||
if not isinstance(value, dict):
|
||||
raise _invalid(f"{name} 必须为对象")
|
||||
return value
|
||||
|
||||
|
||||
def _identity(target: dict[str, Any], key: str, value: Any) -> bool:
|
||||
"""身份字段只接受同值重复,不把重复完整 ID 当分片拼接。"""
|
||||
if value is None:
|
||||
return False
|
||||
if not isinstance(value, str):
|
||||
raise _invalid(f"{key} 必须为字符串")
|
||||
if key in target and target[key] != value:
|
||||
raise _invalid(f"{key} 冲突")
|
||||
changed = key not in target and bool(value)
|
||||
target[key] = value
|
||||
return changed
|
||||
|
||||
|
||||
def _append(target: dict[str, Any], key: str, value: Any) -> str:
|
||||
"""字符串片段逐字拼接,保留缺省/null/空串区别。"""
|
||||
if value is None:
|
||||
target.setdefault(key, None)
|
||||
return ""
|
||||
if not isinstance(value, str):
|
||||
raise _invalid(f"{key} 必须为字符串或 null")
|
||||
target[key] = (target.get(key) or "") + value
|
||||
return value
|
||||
|
||||
|
||||
def _completion(message: dict[str, Any], metadata: dict[str, Any]) -> OpenAIChatCompletion:
|
||||
"""复用内核最终快照校验并翻译领域错误。"""
|
||||
try:
|
||||
return OpenAIChatCompletion(
|
||||
message, metadata.get("finish_reason"), metadata.get("id"), metadata.get("usage")
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise _invalid(str(exc)) from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatDelta:
|
||||
"""一帧提取结果;progress 表示真实生成进展。"""
|
||||
|
||||
content: str
|
||||
reasoning_content: str
|
||||
progress: bool
|
||||
|
||||
|
||||
class ChatCompletionAccumulator:
|
||||
"""每次 transport 尝试独享的聚合器,文本模式不严格校验工具产物。"""
|
||||
|
||||
def __init__(self, *, response_mode: ResponseMode) -> None:
|
||||
"""初始化尝试私有状态。"""
|
||||
self.mode = response_mode
|
||||
self.message: dict[str, Any] = {}
|
||||
self.metadata: dict[str, Any] = {}
|
||||
self.tools: dict[int, dict[str, Any]] = {}
|
||||
|
||||
def add_chunk(self, chunk: dict[str, Any]) -> ChatDelta:
|
||||
"""聚合一个已解码帧,一帧至多一个活性事件。"""
|
||||
chunk = _object(chunk, "chunk")
|
||||
strict = self.mode == "openai"
|
||||
if strict:
|
||||
_identity(self.metadata, "id", chunk.get("id"))
|
||||
if chunk.get("usage") is not None:
|
||||
_object(chunk["usage"], "usage")
|
||||
self.metadata["usage"] = chunk["usage"]
|
||||
choices = chunk.get("choices", [])
|
||||
if not isinstance(choices, list):
|
||||
raise _invalid("choices 必须为数组")
|
||||
if not choices:
|
||||
return ChatDelta("", "", False)
|
||||
if strict and len(choices) != 1:
|
||||
raise _invalid("仅支持单 choice")
|
||||
choice = _object(choices[0], "choice")
|
||||
if strict:
|
||||
if type(choice.get("index")) is not int or choice["index"] != 0:
|
||||
raise _invalid("choice index 必须为 0")
|
||||
_identity(self.metadata, "finish_reason", choice.get("finish_reason"))
|
||||
delta = _object(choice.get("delta", {}), "delta")
|
||||
if strict and delta.keys() - {"role", "content", "reasoning_content", "tool_calls"}:
|
||||
raise _invalid("未知 delta 字段")
|
||||
if "role" in delta and strict:
|
||||
if delta["role"] != "assistant":
|
||||
raise _invalid("role 必须为 assistant")
|
||||
self.message["role"] = delta["role"]
|
||||
content = _append(self.message, "content", delta["content"]) if "content" in delta else ""
|
||||
reasoning = (
|
||||
_append(self.message, "reasoning_content", delta["reasoning_content"])
|
||||
if "reasoning_content" in delta
|
||||
else ""
|
||||
)
|
||||
progress = bool(content or reasoning)
|
||||
if "tool_calls" in delta:
|
||||
tools = delta["tool_calls"]
|
||||
if not isinstance(tools, list):
|
||||
if strict:
|
||||
raise _invalid("tool_calls 必须为数组")
|
||||
return ChatDelta(content, reasoning, progress)
|
||||
if strict:
|
||||
self.message.setdefault("tool_calls", [])
|
||||
for raw in tools:
|
||||
if not strict:
|
||||
# 文本只消费可识别的工具进展,不承诺工具完整性。
|
||||
if isinstance(raw, dict) and type(raw.get("index")) is int:
|
||||
index = raw["index"]
|
||||
tool = self.tools.setdefault(index, {})
|
||||
for key in ("id", "type"):
|
||||
if isinstance(raw.get(key), str) and raw[key] and key not in tool:
|
||||
tool[key] = raw[key]
|
||||
progress = True
|
||||
fn = raw.get("function")
|
||||
if isinstance(fn, dict):
|
||||
progress |= any(
|
||||
isinstance(fn.get(k), str) and bool(fn[k])
|
||||
for k in ("name", "arguments")
|
||||
)
|
||||
continue
|
||||
raw = _object(raw, "tool")
|
||||
index = raw.get("index")
|
||||
if type(index) is not int or index < 0:
|
||||
raise _invalid("tool index 必须为非负整数")
|
||||
if raw.keys() - {"index", "id", "type", "function"}:
|
||||
raise _invalid("未知工具增量字段")
|
||||
tool = self.tools.setdefault(index, {})
|
||||
for key in ("id", "type"):
|
||||
progress |= _identity(tool, key, raw.get(key))
|
||||
if "function" in raw:
|
||||
fn = _object(raw["function"], "function")
|
||||
if fn.keys() - {"name", "arguments"}:
|
||||
raise _invalid("未知 function 增量字段")
|
||||
target = tool.setdefault("function", {})
|
||||
for key in ("name", "arguments"):
|
||||
if key in fn:
|
||||
if not isinstance(fn[key], str):
|
||||
raise _invalid(f"function.{key} 必须为字符串")
|
||||
progress |= bool(_append(target, key, fn[key]))
|
||||
return ChatDelta(content, reasoning, progress)
|
||||
|
||||
def finish(self) -> OpenAIChatCompletion:
|
||||
"""线路完整性由调用者保证;此处只校验最终消息。"""
|
||||
message = dict(self.message)
|
||||
if self.tools:
|
||||
message["tool_calls"] = [self.tools[index] for index in sorted(self.tools)]
|
||||
return _completion(message, self.metadata)
|
||||
|
||||
|
||||
def parse_chat_completion(body: dict[str, Any]) -> OpenAIChatCompletion:
|
||||
"""非流式完整消息保留扩展字段,拒绝静默截取多 choice。"""
|
||||
body = _object(body, "body")
|
||||
choices = body.get("choices")
|
||||
if not isinstance(choices, list) or len(choices) != 1:
|
||||
raise _invalid("仅支持单 choice")
|
||||
choice = _object(choices[0], "choice")
|
||||
if "index" in choice and (type(choice["index"]) is not int or choice["index"] != 0):
|
||||
raise _invalid("choice index 必须为 0")
|
||||
metadata = {**body, "finish_reason": choice.get("finish_reason")}
|
||||
return _completion(_object(choice.get("message"), "message"), metadata)
|
||||
@@ -38,11 +38,19 @@ from polygateway.thinking import (
|
||||
validate_thinking_raw,
|
||||
)
|
||||
from polygateway.transports._http_errors import compose_message, summarize_body
|
||||
from polygateway.transports._openai_completion import (
|
||||
ChatCompletionAccumulator,
|
||||
ChatDelta,
|
||||
parse_chat_completion,
|
||||
)
|
||||
from polygateway.types import (
|
||||
Effort,
|
||||
EmbeddingTransportResult,
|
||||
OpenAIChatCompletion,
|
||||
ResponseMode,
|
||||
SourceConfig,
|
||||
TransportResult,
|
||||
_has_completion_output,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -62,29 +70,6 @@ def _sse_data_payload(raw: str) -> str | 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"]
|
||||
if "model" not in usage_sink:
|
||||
# 首个**有效**值即固定: 末帧的异常值不得覆盖它;但首帧报空串也不能锁死
|
||||
# sink——否则后续真实版本会丢(issue #3)
|
||||
reported = _coerce_model_reported(chunk.get("model"))
|
||||
if reported is not None:
|
||||
usage_sink["model"] = reported
|
||||
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
|
||||
|
||||
|
||||
def _stall_from_chunk_times(
|
||||
times: list[float], threshold_ms: int
|
||||
) -> tuple[float | None, float | None]:
|
||||
@@ -108,10 +93,25 @@ def _stall_from_chunk_times(
|
||||
return (total, float(count)) if count else (0.0, 0.0)
|
||||
|
||||
|
||||
def _check_chat_error(chunk: Any) -> None:
|
||||
"""所有模式拒绝顶层 error;诊断复用有界摘要。"""
|
||||
if not isinstance(chunk, dict):
|
||||
raise TransientError("chat 响应必须为 JSON 对象", operation="chat")
|
||||
if "error" in chunk:
|
||||
summary = summarize_body(json.dumps({"error": chunk["error"]}, ensure_ascii=False))
|
||||
raise TransientError(
|
||||
compose_message("上游 chat error 帧", summary), operation="chat", body_text=summary
|
||||
)
|
||||
if chunk.get("usage") is not None and not isinstance(chunk["usage"], dict):
|
||||
raise TransientError("chat usage 必须为对象或 null", operation="chat")
|
||||
|
||||
|
||||
async def _iter_sse_deltas(
|
||||
lines: AsyncIterator[str], usage_sink: dict[str, Any]
|
||||
) -> AsyncIterator[tuple[bool, str]]:
|
||||
"""逐行解析 SSE 流;[DONE] 置 sink["done"];畸形 JSON 帧 → 瞬时错误(可重试)。"""
|
||||
lines: AsyncIterator[str],
|
||||
usage_sink: dict[str, Any],
|
||||
accumulator: ChatCompletionAccumulator,
|
||||
) -> AsyncIterator[ChatDelta]:
|
||||
"""解析 SSE,一帧只产生一次进展事件。"""
|
||||
async for raw in lines:
|
||||
data = _sse_data_payload(raw)
|
||||
if data is None:
|
||||
@@ -122,11 +122,16 @@ async def _iter_sse_deltas(
|
||||
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:
|
||||
raise TransientError("SSE 帧畸形(malformed_json)", operation="chat") from exc
|
||||
_check_chat_error(chunk)
|
||||
if chunk.get("usage") is not None:
|
||||
usage_sink["usage"] = chunk["usage"]
|
||||
if "model" not in usage_sink:
|
||||
model = _coerce_model_reported(chunk.get("model"))
|
||||
if model is not None:
|
||||
usage_sink["model"] = model
|
||||
delta = accumulator.add_chunk(chunk)
|
||||
if delta.progress:
|
||||
yield delta
|
||||
|
||||
|
||||
@@ -458,6 +463,7 @@ class OpenAICompatTransport:
|
||||
reasoning_effort: Effort | None,
|
||||
first_token_event: asyncio.Event | None,
|
||||
content_event: asyncio.Event | None,
|
||||
response_mode: ResponseMode,
|
||||
) -> TransportResult:
|
||||
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。
|
||||
|
||||
@@ -468,6 +474,16 @@ class OpenAICompatTransport:
|
||||
`content_event` 为中段沉默对冲处置位(1.3.8): 同款约定,流式路径**每个**
|
||||
增量 chunk 置位一次,非流式路径收它但永不置位。
|
||||
"""
|
||||
if response_mode not in ("text", "openai"):
|
||||
raise RequestRejectedError(
|
||||
"非法 response_mode", source_name=source.name, operation="chat"
|
||||
)
|
||||
if response_mode == "openai":
|
||||
for layer in (source.extra_body, overlay):
|
||||
if "n" in layer and (type(layer["n"]) is not int or layer["n"] != 1):
|
||||
raise RequestRejectedError(
|
||||
"openai 模式仅支持 n=1", source_name=source.name, operation="chat"
|
||||
)
|
||||
profile = get_provider(source.provider, registry=self._registry)
|
||||
try:
|
||||
payload, applied_effort = self._build_payload(
|
||||
@@ -492,12 +508,30 @@ class OpenAICompatTransport:
|
||||
try:
|
||||
if stream:
|
||||
result = await self._complete_stream(
|
||||
client, url, payload, source, profile, first_token_event, content_event
|
||||
client,
|
||||
url,
|
||||
payload,
|
||||
source,
|
||||
profile,
|
||||
first_token_event,
|
||||
content_event,
|
||||
response_mode,
|
||||
)
|
||||
else:
|
||||
result = await self._complete_once(
|
||||
client, url, payload, source, profile, first_token_event, content_event
|
||||
client,
|
||||
url,
|
||||
payload,
|
||||
source,
|
||||
profile,
|
||||
first_token_event,
|
||||
content_event,
|
||||
response_mode,
|
||||
)
|
||||
except TransientError as exc:
|
||||
if exc.source_name is None:
|
||||
exc.source_name = source.name
|
||||
raise
|
||||
except StreamLivenessTimeout as exc:
|
||||
raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc
|
||||
except httpx.TimeoutException as exc:
|
||||
@@ -585,6 +619,7 @@ class OpenAICompatTransport:
|
||||
profile: ProviderProfile,
|
||||
first_token_event: asyncio.Event | None,
|
||||
content_event: asyncio.Event | None,
|
||||
response_mode: ResponseMode,
|
||||
) -> TransportResult:
|
||||
started = time.monotonic()
|
||||
async with client.stream("POST", url, json=payload) as resp:
|
||||
@@ -594,8 +629,9 @@ class OpenAICompatTransport:
|
||||
source, resp.status_code, body, resp.headers, operation="chat"
|
||||
)
|
||||
sink: dict[str, Any] = {}
|
||||
accumulator = ChatCompletionAccumulator(response_mode=response_mode)
|
||||
guarded = stream_with_liveness_timeouts(
|
||||
_iter_sse_deltas(resp.aiter_lines(), sink),
|
||||
_iter_sse_deltas(resp.aiter_lines(), sink, accumulator),
|
||||
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,
|
||||
@@ -606,7 +642,7 @@ class OpenAICompatTransport:
|
||||
last = started
|
||||
max_gap = 0.0
|
||||
chunk_times: list[float] = []
|
||||
async for is_content, text in guarded:
|
||||
async for delta in guarded:
|
||||
now = time.monotonic()
|
||||
# 增量 chunk 时间戳(1.3.8 沉默计量): 循环体只会被真实增量触发——
|
||||
# 空 chunk/usage 帧/SSE keepalive 已在 _sse_data_payload/_sse_delta
|
||||
@@ -625,10 +661,18 @@ class OpenAICompatTransport:
|
||||
else:
|
||||
max_gap = max(max_gap, (now - last) * 1000)
|
||||
last = now
|
||||
(content_parts if is_content else thinking_parts).append(text)
|
||||
if delta.content:
|
||||
content_parts.append(delta.content)
|
||||
if delta.reasoning_content:
|
||||
thinking_parts.append(delta.reasoning_content)
|
||||
if response_mode == "openai" and not sink.get("done"):
|
||||
raise TransientError(
|
||||
"保真响应缺 [DONE],禁止打捞", source_name=source.name, operation="chat"
|
||||
)
|
||||
completion_result = accumulator.finish() if response_mode == "openai" else None
|
||||
salvaged = self._check_done(sink, content_parts, thinking_parts, source)
|
||||
content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
|
||||
self._reject_empty_completion(content, source)
|
||||
self._reject_empty_completion(content, completion_result, source)
|
||||
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
|
||||
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
|
||||
stall_ms, stall_count = _stall_from_chunk_times(chunk_times, source.stall_threshold_ms)
|
||||
@@ -643,6 +687,7 @@ class OpenAICompatTransport:
|
||||
stall_ms=stall_ms,
|
||||
stall_count=stall_count,
|
||||
raw={"usage": sink.get("usage")},
|
||||
completion=completion_result,
|
||||
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
|
||||
model_reported=_coerce_model_reported(sink.get("model")),
|
||||
reasoning_tokens=reasoning_tokens,
|
||||
@@ -670,14 +715,16 @@ class OpenAICompatTransport:
|
||||
raise TransientError(f"{source.name} SSE missing_done: 截断且无 [DONE]", **ctx)
|
||||
return True
|
||||
|
||||
def _reject_empty_completion(self, content: str, source: SourceConfig) -> None:
|
||||
def _reject_empty_completion(
|
||||
self, content: str, completion: OpenAIChatCompletion | None, source: SourceConfig
|
||||
) -> None:
|
||||
"""空补全 → 瞬时错误(2026-07-20 人类裁决,M1 验证发现)。
|
||||
|
||||
服务 200 且流程完整([DONE]/usage 正常)但 content 为空——MiniMax 等
|
||||
网关的间歇异常形态。视为服务抖动: 退避重试/换源,**绝不缓存空响应**;
|
||||
承 CHS "VLM 零 content"归瞬时的先例(invokers.py:309)。
|
||||
"""
|
||||
if not content.strip():
|
||||
if not _has_completion_output(content, completion):
|
||||
raise TransientError(
|
||||
f"{source.name} 空补全(empty_completion): 流程完整但零内容",
|
||||
source_name=source.name,
|
||||
@@ -704,6 +751,7 @@ class OpenAICompatTransport:
|
||||
profile: ProviderProfile,
|
||||
first_token_event: asyncio.Event | None,
|
||||
content_event: asyncio.Event | None,
|
||||
response_mode: ResponseMode,
|
||||
) -> TransportResult:
|
||||
"""非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。
|
||||
|
||||
@@ -723,16 +771,25 @@ class OpenAICompatTransport:
|
||||
raise TransientError(
|
||||
f"{source.name} 非流式响应非法 JSON", source_name=source.name, operation="chat"
|
||||
) from exc
|
||||
_check_chat_error(body)
|
||||
completion_result = parse_chat_completion(body) if response_mode == "openai" else None
|
||||
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 {}
|
||||
if not isinstance(choices, list) or not isinstance(choices[0], dict):
|
||||
raise TransientError("chat choices 形态非法", operation="chat")
|
||||
message = choices[0].get("message", {})
|
||||
if not isinstance(message, dict) or any(
|
||||
message.get(key) is not None and not isinstance(message[key], str)
|
||||
for key in ("content", "reasoning_content")
|
||||
):
|
||||
raise TransientError("chat message 形态非法", operation="chat")
|
||||
content, thinking = self._finalize_text(
|
||||
[message.get("content") or ""], [message.get("reasoning_content") or ""], profile
|
||||
)
|
||||
self._reject_empty_completion(content, source)
|
||||
self._reject_empty_completion(content, completion_result, source)
|
||||
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
|
||||
reasoning_tokens = _coerce_reasoning_tokens(body.get("usage"))
|
||||
return TransportResult(
|
||||
@@ -744,6 +801,7 @@ class OpenAICompatTransport:
|
||||
ttft_ms=None,
|
||||
max_inter_token_ms=None,
|
||||
raw={"usage": body.get("usage")},
|
||||
completion=completion_result,
|
||||
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
|
||||
model_reported=_coerce_model_reported(body.get("model")),
|
||||
reasoning_tokens=reasoning_tokens,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。
|
||||
"""
|
||||
|
||||
import copy
|
||||
import dataclasses
|
||||
import json
|
||||
import math
|
||||
@@ -422,6 +423,73 @@ class _CallContext:
|
||||
return True
|
||||
|
||||
|
||||
ResponseMode = Literal["text", "openai"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenAIChatCompletion:
|
||||
"""单 choice 协议结果,构造时快照隔离;嵌套 JSON 仍可变。"""
|
||||
|
||||
message: dict[str, Any]
|
||||
finish_reason: str | None
|
||||
response_id: str | None
|
||||
usage: dict[str, Any] | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""校验协议快照;transport 翻译错误,缓存坏条目按 miss。"""
|
||||
message = self.message
|
||||
if not isinstance(message, dict) or message.get("role") != "assistant":
|
||||
raise ValueError("completion message 必须含 assistant role")
|
||||
for key in ("content", "reasoning_content"):
|
||||
if message.get(key) is not None and not isinstance(message[key], str):
|
||||
raise ValueError(f"completion {key} 必须为字符串或 null")
|
||||
for value in (self.finish_reason, self.response_id):
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ValueError("completion 身份/结束原因必须为字符串或 null")
|
||||
if self.usage is not None and not isinstance(self.usage, dict):
|
||||
raise ValueError("completion usage 必须为对象或 null")
|
||||
if "tool_calls" in message:
|
||||
tools = message["tool_calls"]
|
||||
if not isinstance(tools, list):
|
||||
raise ValueError("completion tool_calls 必须为数组")
|
||||
seen: set[str] = set()
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict):
|
||||
raise ValueError("completion tool 必须为对象")
|
||||
identity = tool.get("id")
|
||||
fn = tool.get("function")
|
||||
if (
|
||||
not isinstance(identity, str)
|
||||
or not identity
|
||||
or identity in seen
|
||||
or tool.get("type") != "function"
|
||||
or not isinstance(fn, dict)
|
||||
or not isinstance(fn.get("name"), str)
|
||||
or not fn["name"]
|
||||
or not isinstance(fn.get("arguments"), str)
|
||||
):
|
||||
raise ValueError("completion tool 身份/函数字段缺失、冲突或非法")
|
||||
seen.add(identity)
|
||||
json.dumps({"message": message, "usage": self.usage}, allow_nan=False)
|
||||
object.__setattr__(self, "message", copy.deepcopy(message))
|
||||
object.__setattr__(self, "usage", copy.deepcopy(self.usage))
|
||||
|
||||
|
||||
def _has_completion_output(content: str, completion: OpenAIChatCompletion | None) -> bool:
|
||||
"""成功须有投影正文或完整工具;协议原文为空时不信任缓存旧投影。
|
||||
|
||||
只判定产物存在性,不替代协议结构校验,也不收紧快照类型的构造用途。
|
||||
transport 与缓存共用此规则,避免空响应从缓存旁路成功。
|
||||
"""
|
||||
if completion is not None:
|
||||
if completion.message.get("tool_calls"):
|
||||
return True
|
||||
raw_content = completion.message.get("content")
|
||||
if not isinstance(raw_content, str) or not raw_content.strip():
|
||||
return False
|
||||
return isinstance(content, str) and bool(content.strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMResponse:
|
||||
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
|
||||
@@ -487,6 +555,8 @@ class LLMResponse:
|
||||
call_stats: CallStats | None = None
|
||||
"""本次**逻辑调用**的统计快照(1.3.5);`None` = 未知,不得读成 0。"""
|
||||
|
||||
completion: OpenAIChatCompletion | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatRequest:
|
||||
@@ -543,6 +613,8 @@ class ChatRequest:
|
||||
洋葱各层经 `dataclasses.replace` 派生请求时保留**同一引用**(不是拷贝),
|
||||
重试/重问/分批才能共享同一个逻辑 ID 与计数。"""
|
||||
|
||||
response_mode: ResponseMode = "text"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Usage:
|
||||
@@ -627,6 +699,8 @@ class TransportResult:
|
||||
"""超阈值相邻间隔个数(归因成对报告,Gregg 原则);值域与 `None` 语义同
|
||||
`stall_ms`。"""
|
||||
|
||||
completion: OpenAIChatCompletion | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceConfig:
|
||||
|
||||
Reference in New Issue
Block a user