Files
PolyLoop/tests/contract/test_model_client.py
T
iomgaa a14bf8d288 test(contract): 落成五个接缝的契约骨架,用它逼出七个设计洞
第 ④ 阶段的核心交付物。这套测试不针对任何具体实现,写的是「不管你怎么实现
都必须满足这些行为」,下游写完自己的实现接到 fixture 上跑一遍即可,
是任何新适配器的准入标准(CLAUDE.md §0)。

现在全部跳过,因为公共类型与 Protocol 还没落地。价值不在跑,在写:
写一条契约要求把每次调用逐字写出来——方法叫什么、参数填什么、返回值怎么取,
而散文里读着通顺的地方,落到这一步就露出来了。

三轮文档评审没报出的七个洞,写这套测试时全部撞了出来:
没有动作的步 result_id 填什么;三个 ActionStatus 取值的触发条件;
动作被拒绝时观察的来源(执行器与 SyntheticObservations 两处都有);
解释器能不能抛异常;Event 没有字段所以事件出口的契约只写得出一半;
read_log 读不存在的运行必须返回空日志而不是抛异常;
以及原子性与前缀持久性这两条 0005 的承诺根本没有机器兜底——
写这套测试之前我们默认它们会被契约测试接住。

洞标成 xfail(strict=True) 而不是常驻 fail:一个永远红的套件会训练所有人忽略红。
它们都不带 fixture,否则会被「实现还没有」那个跳过挡住,
于是「答不上来」就伪装成了「还没轮到」。补上之后 XPASS 会报错,逼人回来删标记。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 23:37:13 -04:00

75 lines
3.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""模型调用接缝的行为契约。
**这一层不打真实网关**——那是 e2e 的事。这里断言的是返回结构体的形状与失败的表达方式,
用一个受控替身就能验。
两个已知形态:一个按三本账各记一条并自己按价格表算成本,一个在调用外面套退避并累加本次
运行的 token。
"""
import pytest
pytestmark = pytest.mark.contract
async def test_returns_three_fields(model_client, records):
"""返回三个字段:调用标识、可见回复、推理段。
可见回复与推理段的长度由库自己数字符,不从任何用量对象取——实测中转网关会用本地分词器
补算并整体替换用量对象,把明细一起吃掉,某次标定里 24 次调用的推理 token 全部没上报。
"""
reply = await model_client.call(records.model_call(call_index=0, result_id="m0"))
assert isinstance(reply.content, str)
assert isinstance(reply.thinking, str)
assert reply.call_id is None or isinstance(reply.call_id, str)
async def test_call_id_is_never_an_empty_string(model_client, records):
"""调用标识可以是「没有」,但绝不能是空串。
它是轨迹与账目之间唯一的连接键。空串是个「看起来合法」的键,连表时静默匹配不上;显式
的「没有」至少能被筛出来。它为空的合法含义只有一个:调用在记账之前就失败了。
"""
reply = await model_client.call(records.model_call(call_index=0, result_id="m0"))
assert reply.call_id != ""
async def test_failure_is_expressed_as_an_exception(model_client, records):
"""调用失败以异常表达,不以「返回一个空回复」表达。
库接住它、翻译成模型故障、记一条调用标识为空的步。如果失败被表达成一个内容为空串的
正常返回,库没有任何办法把它和「模型真的回了空字符串」分开——而后者是模型行为,前者
是基础设施故障,两者在分析里属于完全不同的类别。
"""
with pytest.raises(Exception): # noqa: B017 具体异常类型归实现,契约只要求「抛」
await model_client.call(records.model_call(call_index=0, result_id="fail"))
async def test_cancellation_propagates_and_is_not_swallowed(model_client, records):
"""取消要能穿过模型调用,`CancelledError` 不许被捕获吞没。"""
import asyncio
task = asyncio.ensure_future(
model_client.call(records.model_call(call_index=0, result_id="m0"))
)
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
def test_signature_carries_no_retry_or_rate_limit_parameters(model_client):
"""签名里不出现重试次数、退避时长、限流配额。
出现即意味着库在治理一次模型调用,而那归 PolyGateway`CLAUDE.md` §1.5)。这条断言的是
名字,不是行为——按 §1.8,公共 Protocol 的签名本身就是对下游的承诺,断言它是应该的。
"""
import inspect
names = set(inspect.signature(model_client.call).parameters)
assert not (names & {"retries", "max_retries", "backoff", "timeout", "rate_limit"})