Files
PolyGateway/tests/unit/test_openai_compat.py
T
iomgaa 82f4ec4910 feat: model the thinking switch as shape plus capability (issue #5)
enable_thinking=False was a no-op for minimax and openai sources: both
profiles had empty dicts on each side, so the payload update injected
nothing while the caller believed reasoning had been turned off. A
downstream project was blocked on exactly this.

The root cause is that an empty dict meant two different things -- "no
injection needed" and "we do not know how this provider spells it" --
and that a provider-level table cannot express what turned out to be a
per-model property. Live testing showed MiniMax-M3 can disable
reasoning via reasoning_effort while M2.7 and M2.5 cannot be disabled
at all, which two external registries independently confirm.

So the shape stays at provider level and a capability table joins it at
model level. Unknown, unsupported and no-opinion are now three distinct
values, and resolve_thinking is the single place they meet: it raises at
assembly time when a model cannot honour the request, warns and injects
for unregistered models, and injects silently otherwise. Every registered
capability carries the evidence it was derived from.

enable_thinking also joins the cache fingerprint, since it now really
does change the request body.
2026-08-02 06:20:24 -04:00

663 lines
25 KiB
Python

"""OpenAICompatTransport 测试(M1 设计 §4.4/§6):SSE 解析、错误翻译、缺 DONE 语义。
SSE 帧样本按三项目真实网关响应形态二次构造(OpenAI 兼容 chunk 结构)。
"""
import json
import httpx
import pytest
from polygateway.errors import (
RequestRejectedError,
SourceDeadError,
TransientError,
)
from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.pricing import ModelPrice, PricingTable
from polygateway.transports.openai_compat import (
OpenAICompatTransport,
_iter_sse_deltas,
_sse_data_payload,
)
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
def _source(**overrides):
base = {
"name": "qwen_1",
"provider": "qwen",
"base_url": "https://gw.example/v1",
"api_key": "sk-test",
"model": "qwen-max",
"timeout_s": 5.0,
}
base.update(overrides)
return SourceConfig(**base)
def _chunk(content=None, reasoning=None, usage=None, model=None):
delta = {}
if content is not None:
delta["content"] = content
if reasoning is not None:
delta["reasoning_content"] = reasoning
body = {"choices": [{"delta": delta}]} if (delta or usage is None) else {"choices": []}
if usage is not None:
body["usage"] = usage
if model is not None:
body["model"] = model
return f"data: {json.dumps(body)}\n\n"
_USAGE = {"prompt_tokens": 11, "completion_tokens": 7}
def _sse_stream(*frames, done=True):
text = "".join(frames) + ("data: [DONE]\n\n" if done else "")
return httpx.Response(200, content=text.encode(), headers={"content-type": "text/event-stream"})
def _transport_for(handler):
mock = httpx.MockTransport(handler)
return OpenAICompatTransport(
client_factory=lambda src: httpx.AsyncClient(base_url=src.base_url, transport=mock)
)
async def _complete(transport, source, *, stream=True, overlay=None):
return await transport.complete(
messages=[{"role": "user", "content": "hi"}],
source=source,
stream=stream,
overlay=overlay or {},
call_id="cid-1",
)
# 单价刻意取"输出贵于输入"的真实形态: est_tokens 兜底把整估值塞进 completion
# 时,虚高才显形(设计 §1 的 26 倍算例即此单价)。
_PRICING = PricingTable({"qwen-max": ModelPrice(input_per_1m=1.0, output_per_1m=8.0)})
class _MemoryRecorder:
def __init__(self):
self.rows = []
async def record_llm_call(self, **fields):
self.rows.append(fields)
async def _recorded_cost(result, source):
"""把 transport 产物走一遍真实计费路径,返回落库的 cost。
`unavailable` → cost=None 的判定在 `TelemetryEmitter` 里(设计 §3.2 #5),
直接调 `PricingTable.cost` 对 `0/0` 只会得到 `0.0`——那正是本组用例要防的
假金额,故断言必须穿过 emitter 而不是单测 pricing。
"""
recorder = _MemoryRecorder()
response = LLMResponse(
content=result.content,
thinking=result.thinking,
model=source.model,
provider=source.provider,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
latency_ms=1,
ttft_ms=result.ttft_ms,
max_inter_token_ms=result.max_inter_token_ms,
cache_hit=False,
call_id="cid-1",
source_name=source.name,
usage_source=result.usage_source,
)
await TelemetryEmitter(recorder, pricing=_PRICING).emit_attempt(
request=ChatRequest(messages=[{"role": "user", "content": "hi"}]),
source=source,
call_id="cid-1",
latency_ms=1,
response=response,
error=None,
)
return recorder.rows[0]["cost"]
class TestSsePureFunctions:
def test_data_payload_filters_noise(self):
assert _sse_data_payload("") is None
assert _sse_data_payload(": ping") is None
assert _sse_data_payload("event: x") is None
assert _sse_data_payload("data: {}") == "{}"
assert _sse_data_payload("data: [DONE]") == "[DONE]"
async def test_iter_deltas_yields_and_flags_done(self):
async def lines():
for raw in [
_chunk(content="he"),
": ping",
_chunk(reasoning="think"),
_chunk(usage=_USAGE),
"data: [DONE]",
]:
for line in raw.splitlines():
yield line
sink = {}
out = [d async for d in _iter_sse_deltas(lines(), sink)]
assert out == [(True, "he"), (False, "think")]
assert sink["done"] is True and sink["usage"] == _USAGE
async def test_malformed_frame_raises_transient(self):
async def lines():
yield "data: {not-json"
with pytest.raises(TransientError, match="malformed"):
_ = [d async for d in _iter_sse_deltas(lines(), {})]
class TestStreamHappyPath:
async def test_full_stream_with_usage(self):
def handler(request):
return _sse_stream(
_chunk(reasoning="ponder"),
_chunk(content="hello"),
_chunk(content=" world"),
_chunk(usage=_USAGE),
)
result = await _complete(_transport_for(handler), _source())
assert result.content == "hello world"
assert result.thinking == "ponder"
assert result.prompt_tokens == 11 and result.completion_tokens == 7
assert result.usage_source == "measured"
assert result.ttft_ms is not None and result.ttft_ms >= 0
async def test_qwen_think_tag_stripped(self):
def handler(request):
return _sse_stream(_chunk(content="<think>hmm</think>answer"), _chunk(usage=_USAGE))
result = await _complete(_transport_for(handler), _source())
assert result.content == "answer"
assert result.thinking == "hmm"
async def test_usage_missing_is_unavailable_with_null_cost(self):
"""usage 帧缺失 → 0/0 + unavailable + cost NULL(设计 §3.2 #3)。
改前拿 `est_tokens` 当实测并整估值塞 completion,同一条调用记成
`0/4000` → cost 0.032(设计 §1 的 26 倍虚高)。
"""
def handler(request):
return _sse_stream(_chunk(content="ok"))
source = _source(tpm=1000, est_tokens=4000)
result = await _complete(_transport_for(handler), source)
assert result.usage_source == "unavailable"
assert result.prompt_tokens == 0 and result.completion_tokens == 0
assert await _recorded_cost(result, source) is None
class TestMissingDoneSemantics:
def _no_done_handler(self, request):
return _sse_stream(_chunk(content="partial"), _chunk(usage=_USAGE), done=False)
async def test_default_retry_policy_raises_transient(self):
with pytest.raises(TransientError, match="missing_done|truncated"):
await _complete(_transport_for(self._no_done_handler), _source())
async def test_salvage_with_usage_frame_degrades_to_estimated(self):
"""打捞且收到 usage 帧: 数字真实、可信度降级 → estimated 且照常计费。"""
source = _source(missing_done="salvage", tpm=1000, est_tokens=4000)
result = await _complete(_transport_for(self._no_done_handler), source)
assert result.content == "partial"
assert result.usage_source == "estimated"
assert result.prompt_tokens == 11 and result.completion_tokens == 7
assert await _recorded_cost(result, source) == pytest.approx(
11 / 1_000_000 * 1.0 + 7 / 1_000_000 * 8.0
)
async def test_salvage_without_usage_frame_stays_unavailable(self):
"""打捞且 usage 帧缺失: 0/0 不得被洗成 estimated,否则算出假的 0.0(设计 §3.2 #4)。"""
def handler(request):
return _sse_stream(_chunk(content="partial"), done=False)
source = _source(missing_done="salvage", tpm=1000, est_tokens=4000)
result = await _complete(_transport_for(handler), source)
assert result.content == "partial"
assert result.usage_source == "unavailable"
assert await _recorded_cost(result, source) is None
async def test_early_eof_always_transient_even_under_salvage(self):
def handler(request):
return _sse_stream(done=False) # 零内容提前断流
with pytest.raises(TransientError, match="early_eof"):
await _complete(_transport_for(handler), _source(missing_done="salvage"))
class TestEmptyCompletion:
"""空补全 → TransientError(2026-07-20 人类裁决;MiniMax 间歇形态,绝不缓存)。"""
async def test_stream_zero_content_with_done_is_transient(self):
def handler(request):
return _sse_stream(_chunk(reasoning="only thinking"), _chunk(usage=_USAGE))
with pytest.raises(TransientError, match="empty_completion"):
await _complete(_transport_for(handler), _source())
async def test_non_stream_empty_content_is_transient(self):
def handler(request):
return httpx.Response(
200, json={"choices": [{"message": {"content": ""}}], "usage": _USAGE}
)
with pytest.raises(TransientError, match="empty_completion"):
await _complete(_transport_for(handler), _source(), stream=False)
async def test_think_only_content_after_strip_is_transient(self):
def handler(request):
return _sse_stream(_chunk(content="<think>hmm</think>"), _chunk(usage=_USAGE))
with pytest.raises(TransientError, match="empty_completion"):
await _complete(_transport_for(handler), _source())
class TestObservabilityFields:
"""issue #3: 供应商 prompt cache 命中数与 API 实际返回的模型版本串。
网关报文一律不可信: 形态异常只归 None,绝不因一个可观测字段打断调用。
"""
def _cached_usage(self, cached):
return {**_USAGE, "prompt_tokens_details": {"cached_tokens": cached}}
async def test_stream_reads_cached_tokens(self):
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._cached_usage(128)))
result = await _complete(_transport_for(handler), _source())
assert result.cached_prompt_tokens == 128
async def test_non_stream_reads_cached_tokens(self):
def handler(request):
return httpx.Response(
200,
json={
"choices": [{"message": {"content": "42"}}],
"usage": self._cached_usage(128),
},
)
result = await _complete(_transport_for(handler), _source(), stream=False)
assert result.cached_prompt_tokens == 128
async def test_zero_cached_tokens_is_a_real_zero(self):
"""0(真实零命中)与 None(该源未上报)必须可区分——issue #3 的核心诉求。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._cached_usage(0)))
result = await _complete(_transport_for(handler), _source())
assert result.cached_prompt_tokens == 0
async def test_usage_without_details_is_none(self):
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
result = await _complete(_transport_for(handler), _source())
assert result.cached_prompt_tokens is None
async def test_missing_usage_frame_is_none(self):
def handler(request):
return _sse_stream(_chunk(content="ok"))
result = await _complete(_transport_for(handler), _source())
assert result.cached_prompt_tokens is None
@pytest.mark.parametrize("bad", ["abc", -1, True, 1.5, None, [], {"x": 1}])
async def test_malformed_cached_tokens_degrade_to_none(self, bad):
"""`True` 必须排除: Python 里 isinstance(True, int) 为真。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._cached_usage(bad)))
result = await _complete(_transport_for(handler), _source())
assert result.cached_prompt_tokens is None
async def test_details_not_a_dict_is_none(self):
def handler(request):
usage = {**_USAGE, "prompt_tokens_details": "oops"}
return _sse_stream(_chunk(content="ok"), _chunk(usage=usage))
result = await _complete(_transport_for(handler), _source())
assert result.cached_prompt_tokens is None
async def test_non_stream_reads_reported_model(self):
def handler(request):
return httpx.Response(
200,
json={
"choices": [{"message": {"content": "42"}}],
"usage": _USAGE,
"model": "MiniMax-Text-01-250321",
},
)
result = await _complete(_transport_for(handler), _source(), stream=False)
assert result.model_reported == "MiniMax-Text-01-250321"
async def test_stream_keeps_the_first_reported_model(self):
"""末帧异常值不得覆盖首帧: 首次写入即固定。"""
def handler(request):
return _sse_stream(
_chunk(content="a", model="MiniMax-Text-01-250321"),
_chunk(content="b", model="something-else"),
_chunk(usage=_USAGE),
)
result = await _complete(_transport_for(handler), _source())
assert result.model_reported == "MiniMax-Text-01-250321"
async def test_empty_first_model_does_not_block_a_later_real_one(self):
"""首帧报空串不得锁死 sink: 守卫按"有效值"判断,否则真实版本会丢。"""
def handler(request):
return _sse_stream(
_chunk(content="a", model=""),
_chunk(content="b", model="MiniMax-Text-01-250321"),
_chunk(usage=_USAGE),
)
result = await _complete(_transport_for(handler), _source())
assert result.model_reported == "MiniMax-Text-01-250321"
@pytest.mark.parametrize("bad", [None, "", " ", 123, {}])
async def test_missing_or_malformed_model_is_none(self, bad):
def handler(request):
body = {"choices": [{"message": {"content": "42"}}], "usage": _USAGE}
if bad is not None:
body["model"] = bad
return httpx.Response(200, json=body)
result = await _complete(_transport_for(handler), _source(), stream=False)
assert result.model_reported is None
async def test_raw_payload_is_unchanged(self):
"""新字段是独立格子,不改动 raw 的既有内容。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._cached_usage(5)))
result = await _complete(_transport_for(handler), _source())
assert set(result.raw) == {"usage"}
class TestReasoningTokens:
"""issue #6: 推理消耗的输出 token,与 issue #3 的 cached_tokens 对称。
实测三家供应商在"未推理"时是整个 completion_tokens_details 缺失,无人上报
0;且中转在上游不返回 usage 时会本地补算并吃掉该对象。故 None 的语义是
"本次调用未上报",不是"该源不上报"(findings §4c)。
"""
def _reasoning_usage(self, reasoning):
return {**_USAGE, "completion_tokens_details": {"reasoning_tokens": reasoning}}
async def test_stream_reads_reasoning_tokens(self):
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(7)))
result = await _complete(_transport_for(handler), _source())
assert result.reasoning_tokens == 7
async def test_non_stream_reads_reasoning_tokens(self):
def handler(request):
return httpx.Response(
200,
json={
"choices": [{"message": {"content": "42"}}],
"usage": self._reasoning_usage(7),
},
)
result = await _complete(_transport_for(handler), _source(), stream=False)
assert result.reasoning_tokens == 7
async def test_zero_reasoning_tokens_is_a_real_zero(self):
"""0(上报了且确实没推理)与 None(本次未上报)必须可区分。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(0)))
result = await _complete(_transport_for(handler), _source())
assert result.reasoning_tokens == 0
async def test_usage_without_details_is_none(self):
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
result = await _complete(_transport_for(handler), _source())
assert result.reasoning_tokens is None
@pytest.mark.parametrize("bad", ["abc", -1, True, 1.5, None, [], {"x": 1}])
async def test_malformed_reasoning_tokens_degrade_to_none(self, bad):
"""`True` 必须排除: Python 里 isinstance(True, int) 为真。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(bad)))
result = await _complete(_transport_for(handler), _source())
assert result.reasoning_tokens is None
async def test_details_not_a_dict_is_none(self):
def handler(request):
usage = {**_USAGE, "completion_tokens_details": "oops"}
return _sse_stream(_chunk(content="ok"), _chunk(usage=usage))
result = await _complete(_transport_for(handler), _source())
assert result.reasoning_tokens is None
async def test_salvage_path_records_none_not_zero(self):
"""打捞路径拿不到 usage 帧: 记 None(未知)而非 0(确定没推理)。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), done=False)
result = await _complete(_transport_for(handler), _source(missing_done="salvage"))
assert result.reasoning_tokens is None
class TestNonStreamFastPath:
async def test_non_stream_parses_message(self):
def handler(request):
body = json.loads(request.content)
assert body.get("stream") is False and "stream_options" not in body
return httpx.Response(
200,
json={
"choices": [{"message": {"content": "42", "reasoning_content": "count"}}],
"usage": _USAGE,
},
)
result = await _complete(_transport_for(handler), _source(), stream=False)
assert result.content == "42" and result.thinking == "count"
assert result.usage_source == "measured"
assert result.ttft_ms is None
class TestRequestShaping:
@pytest.mark.parametrize(
("enable_thinking", "expected"),
[(True, {"enable_thinking": True}), (False, {"enable_thinking": False}), (None, {})],
)
async def test_thinking_tri_state_injection(self, enable_thinking, expected):
seen = {}
def handler(request):
seen.update(json.loads(request.content))
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
await _complete(_transport_for(handler), _source(enable_thinking=enable_thinking))
assert {k: seen[k] for k in expected} == expected
if enable_thinking is None:
assert "enable_thinking" not in seen
assert seen["stream_options"] == {"include_usage": True}
@pytest.mark.parametrize(
("enable_thinking", "expected"),
[(True, "medium"), (False, "none")],
)
async def test_minimax_injects_reasoning_effort(self, enable_thinking, expected):
"""issue #5: MiniMax 认的是 reasoning_effort,不是 enable_thinking。"""
seen = {}
def handler(request):
seen.update(json.loads(request.content))
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
source = _source(
name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking
)
await _complete(_transport_for(handler), source)
assert seen["reasoning_effort"] == expected
assert "enable_thinking" not in seen # 旧形态实测被静默丢弃,不再下发
async def test_extra_body_overrides_the_profile_slot(self):
"""注入顺序即优先级: profile → extra_body → overlay,两行不可调换。"""
seen = {}
def handler(request):
seen.update(json.loads(request.content))
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
source = _source(
name="mm",
provider="minimax",
model="MiniMax-M3",
enable_thinking=True,
extra_body={"reasoning_effort": "high"},
)
await _complete(_transport_for(handler), source)
assert seen["reasoning_effort"] == "high"
async def test_model_that_cannot_disable_is_rejected_not_silently_ignored(self):
"""M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。
裸异常会逃出 chat() —— 它不属错误四分类、TelemetryMW 也不捕,结果是一行
遥测都没有就崩了(设计 §5.1)。
"""
def handler(request): # pragma: no cover - 不该走到发请求
raise AssertionError("请求不该发出")
source = _source(name="mm", provider="minimax", model="MiniMax-M2.7", enable_thinking=False)
with pytest.raises(RequestRejectedError, match="MiniMax-M2.7"):
await _complete(_transport_for(handler), source)
async def test_unknown_shape_is_rejected(self):
def handler(request): # pragma: no cover - 不该走到发请求
raise AssertionError("请求不该发出")
source = _source(name="k3", provider="openai", model="kimi-k3", enable_thinking=False)
with pytest.raises(RequestRejectedError, match="register_provider"):
await _complete(_transport_for(handler), source)
async def test_overlay_merged_into_payload(self):
seen = {}
def handler(request):
seen.update(json.loads(request.content))
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
await _complete(
_transport_for(handler),
_source(),
overlay={"response_format": {"type": "json_object"}},
)
assert seen["response_format"] == {"type": "json_object"}
async def test_extra_body_merged_and_outranked_by_overlay(self):
"""顺序即优先级: thinking profile → extra_body → overlay(issue #4)。"""
seen = {}
def handler(request):
seen.update(json.loads(request.content))
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
await _complete(
_transport_for(handler),
_source(extra_body={"temperature": 0, "top_p": 0.9}),
overlay={"temperature": 1},
)
assert seen["temperature"] == 1 # 调用级覆盖配置级
assert seen["top_p"] == 0.9 # 未被顶掉的配置级键保留
async def test_extra_body_cannot_break_governed_keys(self):
"""治理键由 payload 骨架拥有;extra_body 的保护键在构造期已被拦下。"""
with pytest.raises(ValueError, match="stream"):
_source(extra_body={"stream": False})
class TestErrorTranslation:
@pytest.mark.parametrize(
("status", "body", "headers", "exc", "match"),
[
(401, "{}", {}, SourceDeadError, None),
(403, "{}", {}, SourceDeadError, None),
(400, "{}", {}, RequestRejectedError, None),
(418, "{}", {}, RequestRejectedError, None),
(500, "{}", {}, TransientError, None),
(503, "{}", {}, TransientError, None),
(429, json.dumps({"error": {"type": "insufficient_quota"}}), {}, SourceDeadError, None),
(429, "{}", {"retry-after": "2.5"}, TransientError, None),
],
)
async def test_status_translation(self, status, body, headers, exc, match):
def handler(request):
return httpx.Response(status, content=body.encode(), headers=headers)
with pytest.raises(exc) as ei:
await _complete(_transport_for(handler), _source())
assert ei.value.status_code == status
assert ei.value.source_name == "qwen_1"
if status == 429 and exc is TransientError:
assert ei.value.retry_after_s == 2.5
async def test_retry_after_http_date_ignored(self):
def handler(request):
return httpx.Response(
429, content=b"{}", headers={"retry-after": "Wed, 21 Oct 2026 07:28:00 GMT"}
)
with pytest.raises(TransientError) as ei:
await _complete(_transport_for(handler), _source())
assert ei.value.retry_after_s is None # 仅支持秒数形态(CHS 同款)
async def test_httpx_timeout_becomes_transient(self):
def handler(request):
raise httpx.ConnectTimeout("boom")
with pytest.raises(TransientError):
await _complete(_transport_for(handler), _source())
async def test_httpx_transport_error_becomes_transient(self):
def handler(request):
raise httpx.RemoteProtocolError("broken pipe")
with pytest.raises(TransientError):
await _complete(_transport_for(handler), _source())
class TestLifecycle:
async def test_aclose_idempotent(self):
def handler(request):
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
transport = _transport_for(handler)
await _complete(transport, _source())
await transport.aclose()
await transport.aclose()