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:
2026-10-01 14:32:19 -04:00
parent 0e29deac1a
commit 9185eab8c9
42 changed files with 2227 additions and 78 deletions
+5 -1
View File
@@ -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",
+26
View File
@@ -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,
+1 -1
View File
@@ -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` 是两回事,严禁混用:
============== ==================================================
+29 -2
View File
@@ -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);
+2
View File
@@ -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,
+6
View File
@@ -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)
+99 -41
View File
@@ -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,
+74
View File
@@ -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: