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:
@@ -50,6 +50,7 @@ from polygateway.types import (
|
||||
OpenAIChatCompletion,
|
||||
ResponseMode,
|
||||
SourceConfig,
|
||||
StreamEnd,
|
||||
TelemetryStatus,
|
||||
ThinkingObservation,
|
||||
)
|
||||
@@ -73,6 +74,7 @@ __all__ = [
|
||||
"GovernanceBackendError",
|
||||
"LLMResponse",
|
||||
"ResponseMode",
|
||||
"StreamEnd",
|
||||
"OpenAIChatCompletion",
|
||||
"ModelPrice",
|
||||
"OcrClient",
|
||||
|
||||
+238
-32
@@ -425,6 +425,13 @@ class _CallContext:
|
||||
|
||||
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)
|
||||
class OpenAIChatCompletion:
|
||||
@@ -438,43 +445,228 @@ class OpenAIChatCompletion:
|
||||
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)
|
||||
_check_completion_shape(message, self.finish_reason, self.response_id, self.usage)
|
||||
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 _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:
|
||||
"""成功须有投影正文或完整工具;协议原文为空时不信任缓存旧投影。
|
||||
|
||||
@@ -557,6 +749,13 @@ class LLMResponse:
|
||||
|
||||
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)
|
||||
class ChatRequest:
|
||||
@@ -701,6 +900,13 @@ class TransportResult:
|
||||
|
||||
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)
|
||||
class SourceConfig:
|
||||
|
||||
@@ -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__
|
||||
Reference in New Issue
Block a user