feat: add kernel completion verdict functions and response fields

Pure functions in types.py classify finish reasons, judge stream
completeness and outcome, and decide cacheability; TransportResult and
LLMResponse gain finish_reason and stream_end; snapshots accept
tool_calls null.

Red: tests/unit/test_completion_verdict.py failed at collection
(ImportError: _classify_finish et al. undefined) before this change.
This commit is contained in:
2026-10-14 23:12:00 -04:00
parent ab0e924849
commit e5ad67408a
3 changed files with 486 additions and 32 deletions
+2
View File
@@ -50,6 +50,7 @@ from polygateway.types import (
OpenAIChatCompletion, OpenAIChatCompletion,
ResponseMode, ResponseMode,
SourceConfig, SourceConfig,
StreamEnd,
TelemetryStatus, TelemetryStatus,
ThinkingObservation, ThinkingObservation,
) )
@@ -73,6 +74,7 @@ __all__ = [
"GovernanceBackendError", "GovernanceBackendError",
"LLMResponse", "LLMResponse",
"ResponseMode", "ResponseMode",
"StreamEnd",
"OpenAIChatCompletion", "OpenAIChatCompletion",
"ModelPrice", "ModelPrice",
"OcrClient", "OcrClient",
+238 -32
View File
@@ -425,6 +425,13 @@ class _CallContext:
ResponseMode = Literal["text", "openai"] ResponseMode = Literal["text", "openai"]
StreamEnd = Literal["done", "eof_after_finish", "salvaged"]
"""流式结束方式(1.3.10, #31): `done` = 收到 `[DONE]`;`eof_after_finish` = 未发
`[DONE]` 但 finish_reason 与其后的实测 usage 齐全、连接干净关闭;`salvaged` = 文本
模式按 `missing_done=salvage` 打捞的截断结果。非流式/缓存命中/失败行为 `None`。"""
FinishClass = Literal["natural", "truncated", "filtered", "upstream_error", "unknown", "absent"]
@dataclass(frozen=True) @dataclass(frozen=True)
class OpenAIChatCompletion: class OpenAIChatCompletion:
@@ -438,43 +445,228 @@ class OpenAIChatCompletion:
def __post_init__(self) -> None: def __post_init__(self) -> None:
"""校验协议快照;transport 翻译错误,缓存坏条目按 miss。""" """校验协议快照;transport 翻译错误,缓存坏条目按 miss。"""
message = self.message message = self.message
if not isinstance(message, dict) or message.get("role") != "assistant": _check_completion_shape(message, self.finish_reason, self.response_id, self.usage)
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) json.dumps({"message": message, "usage": self.usage}, allow_nan=False)
object.__setattr__(self, "message", copy.deepcopy(message)) object.__setattr__(self, "message", copy.deepcopy(message))
object.__setattr__(self, "usage", copy.deepcopy(self.usage)) object.__setattr__(self, "usage", copy.deepcopy(self.usage))
def _check_completion_shape(message: Any, finish_reason: Any, response_id: Any, usage: Any) -> None:
"""快照字段形态校验;违约抛 ValueError(由 transport/缓存各自翻译)。"""
if not isinstance(message, dict) or message.get("role") != "assistant":
raise ValueError("completion message 必须含 assistant role")
if not all(_str_or_none(message.get(key)) for key in ("content", "reasoning_content")):
raise ValueError("completion content/reasoning_content 必须为字符串或 null")
if not (_str_or_none(finish_reason) and _str_or_none(response_id)):
raise ValueError("completion 身份/结束原因必须为字符串或 null")
if not isinstance(usage, dict | None):
raise ValueError("completion usage 必须为对象或 null")
if not isinstance(message.get("tool_calls", []), list | None):
raise ValueError("completion tool_calls 必须为数组或 null")
if not _tool_snapshot_ok(message):
raise ValueError("completion tool 身份/函数字段缺失、冲突或非法")
def _str_or_none(value: Any) -> bool:
"""协议字符串字段的形态判据: 字符串或 null。"""
return value is None or isinstance(value, str)
def _tool_ok(tool: Any, seen: set[str]) -> bool:
"""单个工具条目是否满足快照不变式;成立时登记其 id 供查重。"""
if not isinstance(tool, dict):
return False
identity, fn = tool.get("id"), tool.get("function")
ok = (
isinstance(identity, str)
and bool(identity)
and identity not in seen
and tool.get("type") == "function"
and isinstance(fn, dict)
and isinstance(fn.get("name"), str)
and bool(fn["name"])
and isinstance(fn.get("arguments"), str)
)
if ok:
seen.add(identity)
return ok
def _tool_snapshot_ok(message: Mapping[str, Any]) -> bool:
"""工具快照不变式(1.3.9 规则): 缺省/null 视无工具;null 元素跳过。
抽成纯判据而非留在快照构造里: 结局判定(#30)要在构造**之前**知道工具是否
成立——截断的半截工具归 ResultInvalid,构造时抛 Transient 就到不了那一行。
"""
tools = message.get("tool_calls")
if tools is None:
return True
if not isinstance(tools, list):
return False
seen: set[str] = set()
return all(tool is None or _tool_ok(tool, seen) for tool in tools)
_FINISH_CLASSES: Mapping[str, FinishClass] = MappingProxyType(
{
"stop": "natural",
"tool_calls": "natural",
"function_call": "natural",
"length": "truncated",
"content_filter": "filtered",
"sensitive": "filtered", # GLM: 安全审核拦截
"network_error": "upstream_error", # GLM: 模型推理异常
"error": "upstream_error",
}
)
def _classify_finish(value: str | None) -> FinishClass:
"""finish_reason → 类别;大小写敏感(上游原值),未登记非空值为 unknown。"""
if value is None:
return "absent"
return _FINISH_CLASSES.get(value, "unknown")
def _stream_is_complete(
*, done: bool, finish_reason: str | None, usage_after_finish: bool, usage_measured: bool
) -> StreamEnd | None:
"""流完整性(设计 §4.2): None = 截断。
缺 `[DONE]` 时只认"上游声明结束 + 其后到达的实测 usage": 前者证明生成结束,
后者证明流走到了 include_usage 的自然末尾(finish 之前的累计 usage 不算)。
"""
if done:
return "done"
if finish_reason is not None and usage_after_finish and usage_measured:
return "eof_after_finish"
return None
@dataclass(frozen=True)
class _OutputSummary:
"""一次结果的产物摘要(设计 §4.3 定义),判定的唯一输入形态。"""
any_output: bool
body_output: bool
tools_present: bool
tools_ok: bool
def _summarize_text(content: str, thinking: str) -> _OutputSummary:
"""文本模式: 产物只看投影正文与思考;空白也算产物,正文须非空白。"""
return _OutputSummary(
any_output=content != "" or thinking != "",
body_output=content.strip() != "",
tools_present=False,
tools_ok=True,
)
def _has_string(value: Any) -> bool:
"""递归查找任一非空字符串片段。"""
if isinstance(value, str):
return value != ""
if isinstance(value, dict):
return any(_has_string(item) for item in value.values())
if isinstance(value, list):
return any(_has_string(item) for item in value)
return False
def _summarize_message(message: Mapping[str, Any], projected_content: str) -> _OutputSummary:
"""保真模式: 任一非空字符串字段(role 除外)或工具条目即产物。"""
tools = message.get("tool_calls")
tools_present = isinstance(tools, list) and any(tool is not None for tool in tools)
tools_ok = _tool_snapshot_ok(message)
fields = {k: v for k, v in message.items() if k not in ("role", "tool_calls")}
return _OutputSummary(
any_output=tools_present or _has_string(fields),
body_output=projected_content.strip() != "" or (tools_present and tools_ok),
tools_present=tools_present,
tools_ok=tools_ok,
)
@dataclass(frozen=True)
class _Verdict:
"""结局判定;`deliver` 为真时另两字段为 None。"""
deliver: bool
error_kind: Literal["transient", "result_invalid"] | None
code: str | None
_DELIVER = _Verdict(True, None, None)
_EMPTY = _Verdict(False, "transient", "empty_completion")
_WITHOUT_CONTENT: Mapping[str, str] = MappingProxyType(
{"truncated": "length_without_content", "filtered": "filtered_without_content"}
)
def _judge_broken(
*, mode: ResponseMode, summary: _OutputSummary, finish: FinishClass
) -> _Verdict | None:
"""行 1-2: 上游自报失败、坏工具——先于产物判断,正文不得掩盖。"""
if finish == "upstream_error":
return _Verdict(False, "transient", "upstream_finish_error")
if mode == "openai" and summary.tools_present and not summary.tools_ok:
if finish == "truncated":
return _Verdict(False, "result_invalid", "tool_truncated")
return _Verdict(False, "transient", "tool_incomplete")
return None
def _judge_outcome(*, mode: ResponseMode, summary: _OutputSummary, finish: FinishClass) -> _Verdict:
"""结局分类(设计 §4.3),自上而下首个匹配行生效。
确定性结局(length/过滤)在文本模式归 ResultInvalid 而非 Transient: 重抽只会
再花一份钱,还会把健康源推向熔断;保真模式原样交回,由下游按 finish 决定。
"""
broken = _judge_broken(mode=mode, summary=summary, finish=finish)
if broken is not None:
return broken
if summary.body_output:
return _DELIVER
if finish in _WITHOUT_CONTENT: # 行 3/6/7: 确定性结局
if mode == "openai":
return _DELIVER
return _Verdict(False, "result_invalid", _WITHOUT_CONTENT[finish])
# 行 4/8/9: 零产物恒为抖动;有产物无正文时,只有保真且上游明确声明结束才交回
if summary.any_output and mode == "openai" and finish != "absent":
return _DELIVER
return _EMPTY
def _effective_finish(finish_reason: Any, completion: OpenAIChatCompletion | None) -> str | None:
"""缓存读写共用的 finish 口径: 顶层优先,缺省回退协议快照(1.3.9 条目无顶层)。"""
if finish_reason is None and completion is not None:
return completion.finish_reason
return finish_reason
def _is_cacheable(
*,
mode: ResponseMode,
content: str,
finish_reason: str | None,
stream_end: str | None,
completion: OpenAIChatCompletion | None,
) -> bool:
"""可缓存 ⊂ 可交付(设计 §6): 截断、空正文、非自然结束一律不进 TTL。
缓存回放的是"同一请求的确定答案";length/过滤/未知结束带随机性或不可证明
是自然结束,固化进 TTL 会把一次偶然变成必然(无缓存毒化铁律)。
"""
if stream_end not in (None, "done", "eof_after_finish"):
return False
if _classify_finish(finish_reason) not in ("natural", "absent"):
return False
if mode == "openai":
return completion is not None and _has_completion_output(content, completion)
return content.strip() != ""
def _has_completion_output(content: str, completion: OpenAIChatCompletion | None) -> bool: def _has_completion_output(content: str, completion: OpenAIChatCompletion | None) -> bool:
"""成功须有投影正文或完整工具;协议原文为空时不信任缓存旧投影。 """成功须有投影正文或完整工具;协议原文为空时不信任缓存旧投影。
@@ -557,6 +749,13 @@ class LLMResponse:
completion: OpenAIChatCompletion | None = None completion: OpenAIChatCompletion | None = None
finish_reason: str | None = None
"""上游结束原因原值(1.3.10, #30/#31),两模式同口径;`None` = 未上报或不可观测。"""
stream_end: StreamEnd | None = None
"""流式结束方式(1.3.10, #31),见 `StreamEnd`。与 `usage_source` 分工: 那个答
"账单可信吗",这个答"内容完整吗";`eof_after_finish` 必为 measured。"""
@dataclass(frozen=True) @dataclass(frozen=True)
class ChatRequest: class ChatRequest:
@@ -701,6 +900,13 @@ class TransportResult:
completion: OpenAIChatCompletion | None = None completion: OpenAIChatCompletion | None = None
finish_reason: str | None = None
"""上游结束原因原值(1.3.10, #30/#31),两模式同口径;`None` = 未上报或不可观测。"""
stream_end: StreamEnd | None = None
"""流式结束方式(1.3.10, #31),见 `StreamEnd`。与 `usage_source` 分工: 那个答
"账单可信吗",这个答"内容完整吗";`eof_after_finish` 必为 measured。"""
@dataclass(frozen=True) @dataclass(frozen=True)
class SourceConfig: class SourceConfig:
+246
View File
@@ -0,0 +1,246 @@
"""统一完成判定(#30/#31): 内核纯函数真值表。"""
import pytest
from polygateway.types import (
LLMResponse,
OpenAIChatCompletion,
TransportResult,
_classify_finish,
_effective_finish,
_is_cacheable,
_judge_outcome,
_OutputSummary,
_stream_is_complete,
_summarize_message,
_summarize_text,
_tool_snapshot_ok,
)
_TOOL = {"id": "t1", "type": "function", "function": {"name": "f", "arguments": "{}"}}
@pytest.mark.parametrize(
("value", "expected"),
[
("stop", "natural"),
("tool_calls", "natural"),
("function_call", "natural"),
("length", "truncated"),
("content_filter", "filtered"),
("sensitive", "filtered"),
("network_error", "upstream_error"),
("error", "upstream_error"),
("end_turn", "unknown"),
("STOP", "unknown"),
(None, "absent"),
],
)
def test_classify_finish(value, expected):
assert _classify_finish(value) == expected
@pytest.mark.parametrize(
("done", "finish", "after", "measured", "expected"),
[
(True, None, False, False, "done"),
(True, "stop", True, True, "done"),
(False, "stop", True, True, "eof_after_finish"),
(False, "length", True, True, "eof_after_finish"),
(False, None, True, True, None), # 无 finish 即截断
(False, "stop", False, True, None), # usage 早于 finish(累计 usage)
(False, "stop", True, False, None), # finish 后 usage 不可用
],
)
def test_stream_is_complete(done, finish, after, measured, expected):
got = _stream_is_complete(
done=done, finish_reason=finish, usage_after_finish=after, usage_measured=measured
)
assert got == expected
def test_tool_snapshot_ok():
assert _tool_snapshot_ok({"role": "assistant"})
assert _tool_snapshot_ok({"tool_calls": None})
assert _tool_snapshot_ok({"tool_calls": [None, _TOOL]})
assert not _tool_snapshot_ok({"tool_calls": {}})
assert not _tool_snapshot_ok({"tool_calls": [{"id": "t1", "type": "function"}]})
assert not _tool_snapshot_ok({"tool_calls": [_TOOL, _TOOL]}) # 重复 id
def test_summarize_text():
assert _summarize_text("", "") == _OutputSummary(False, False, False, True)
assert _summarize_text(" ", "") == _OutputSummary(True, False, False, True)
assert _summarize_text("", "think") == _OutputSummary(True, False, False, True)
assert _summarize_text("hi", "") == _OutputSummary(True, True, False, True)
@pytest.mark.parametrize(
("message", "projected", "expected"),
[
({"role": "assistant"}, "", _OutputSummary(False, False, False, True)),
({"role": "assistant", "content": None}, "", _OutputSummary(False, False, False, True)),
({"role": "assistant", "refusal": "no"}, "", _OutputSummary(True, False, False, True)),
(
{"role": "assistant", "reasoning_content": "r"},
"",
_OutputSummary(True, False, False, True),
),
({"role": "assistant", "content": " "}, " ", _OutputSummary(True, False, False, True)),
(
{"role": "assistant", "x": {"y": ["", "z"]}},
"",
_OutputSummary(True, False, False, True),
),
(
{"role": "assistant", "tool_calls": [None]},
"",
_OutputSummary(False, False, False, True),
),
(
{"role": "assistant", "tool_calls": [_TOOL]},
"",
_OutputSummary(True, True, True, True),
),
(
{"role": "assistant", "tool_calls": [{"id": "t"}]},
"",
_OutputSummary(True, False, True, False),
),
({"role": "assistant", "content": "a"}, "a", _OutputSummary(True, True, False, True)),
],
)
def test_summarize_message(message, projected, expected):
assert _summarize_message(message, projected) == expected
_NONE = _OutputSummary(False, False, False, True)
_THINK = _OutputSummary(True, False, False, True)
_BODY = _OutputSummary(True, True, False, True)
_BAD_TOOL = _OutputSummary(True, True, True, False)
@pytest.mark.parametrize(
("mode", "summary", "finish", "expected"),
[
# 行 1: upstream_error 优先于一切
("openai", _BODY, "upstream_error", ("transient", "upstream_finish_error")),
("text", _BODY, "upstream_error", ("transient", "upstream_finish_error")),
# 行 2: 坏工具(仅保真),正文不得掩盖
("openai", _BAD_TOOL, "truncated", ("result_invalid", "tool_truncated")),
("openai", _BAD_TOOL, "natural", ("transient", "tool_incomplete")),
("text", _BAD_TOOL, "natural", None),
# 行 3: 无产物 + truncated/filtered
("openai", _NONE, "truncated", None),
("openai", _NONE, "filtered", None),
("text", _NONE, "truncated", ("result_invalid", "length_without_content")),
("text", _NONE, "filtered", ("result_invalid", "filtered_without_content")),
# 行 4: 零增量抖动(2026-07-20 裁决)
("openai", _NONE, "natural", ("transient", "empty_completion")),
("openai", _NONE, "absent", ("transient", "empty_completion")),
("openai", _NONE, "unknown", ("transient", "empty_completion")),
("text", _NONE, "absent", ("transient", "empty_completion")),
# 行 5: 有正文
("openai", _BODY, "truncated", None),
("text", _BODY, "filtered", None),
("text", _BODY, "absent", None),
# 行 6-9: 有产物无正文
("openai", _THINK, "truncated", None),
("text", _THINK, "truncated", ("result_invalid", "length_without_content")),
("openai", _THINK, "filtered", None),
("text", _THINK, "filtered", ("result_invalid", "filtered_without_content")),
("openai", _THINK, "natural", None),
("openai", _THINK, "unknown", None),
("text", _THINK, "natural", ("transient", "empty_completion")),
("openai", _THINK, "absent", ("transient", "empty_completion")),
("text", _THINK, "absent", ("transient", "empty_completion")),
],
)
def test_judge_outcome_rows(mode, summary, finish, expected):
verdict = _judge_outcome(mode=mode, summary=summary, finish=finish)
if expected is None:
assert verdict.deliver and verdict.error_kind is None and verdict.code is None
else:
assert not verdict.deliver
assert (verdict.error_kind, verdict.code) == expected
def _completion(content="a", finish="stop", tools=None):
message = {"role": "assistant", "content": content}
if tools is not None:
message["tool_calls"] = tools
return OpenAIChatCompletion(message, finish, None, None)
@pytest.mark.parametrize(
("kwargs", "expected"),
[
({"mode": "text", "content": "a", "finish_reason": "stop", "stream_end": "done"}, True),
({"mode": "text", "content": "a", "finish_reason": None, "stream_end": None}, True),
(
{
"mode": "text",
"content": "a",
"finish_reason": "stop",
"stream_end": "eof_after_finish",
},
True,
),
({"mode": "text", "content": "a", "finish_reason": None, "stream_end": "salvaged"}, False),
({"mode": "text", "content": " ", "finish_reason": "stop", "stream_end": "done"}, False),
({"mode": "text", "content": "a", "finish_reason": "length", "stream_end": None}, False),
({"mode": "text", "content": "a", "finish_reason": "sensitive", "stream_end": None}, False),
({"mode": "text", "content": "a", "finish_reason": "end_turn", "stream_end": None}, False),
({"mode": "text", "content": "a", "finish_reason": "stop", "stream_end": "bogus"}, False),
],
)
def test_is_cacheable_text(kwargs, expected):
assert _is_cacheable(completion=None, **kwargs) is expected
def test_is_cacheable_openai():
ok = _completion()
assert _is_cacheable(
mode="openai", content="a", finish_reason="stop", stream_end="done", completion=ok
)
assert not _is_cacheable(
mode="openai", content="a", finish_reason="stop", stream_end="done", completion=None
)
# 原文为空且无工具: 不信任旧投影
assert not _is_cacheable(
mode="openai",
content="a",
finish_reason="stop",
stream_end="done",
completion=_completion(content=""),
)
tool_only = _completion(content=None, finish="tool_calls", tools=[_TOOL])
assert _is_cacheable(
mode="openai", content="", finish_reason="tool_calls", stream_end=None, completion=tool_only
)
def test_effective_finish():
assert _effective_finish("stop", _completion(finish="length")) == "stop"
assert _effective_finish(None, _completion(finish="length")) == "length"
assert _effective_finish(None, None) is None
def test_completion_accepts_null_tool_calls():
snap = OpenAIChatCompletion({"role": "assistant", "tool_calls": None}, "stop", None, None)
assert "tool_calls" in snap.message and snap.message["tool_calls"] is None
with pytest.raises(ValueError):
OpenAIChatCompletion({"role": "assistant", "tool_calls": {}}, "stop", None, None)
def test_response_fields_default_none():
result = TransportResult("a", "", 0, 0, "unavailable", None, None, {})
assert result.finish_reason is None and result.stream_end is None
response = LLMResponse("a", "", "m", "p", 0, 0, 0, None, None, False, "c")
assert response.finish_reason is None and response.stream_end is None
def test_stream_end_exported():
import polygateway
assert "StreamEnd" in polygateway.__all__