"""模型调用接缝的行为契约。 **这一层不打真实网关**——那是 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"})