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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user