48805cb9fb
The verifier caught that the disable-direction evidence only proved "no regression", not "actually took effect": on M3 the disabled runs and the no-opinion baseline are identically distributed, because that model does not reason by default anyway. So the disable runs alone cannot rule out the very failure mode issue #5 is about -- the parameter being silently dropped upstream. The bogus-value experiment that does rule it out was sitting in the findings document instead of the test suite; it is now case L3b, and the L3 assertion that could never fail is gone. Also from the review: the e2e helper caught bare Exception, which would have disguised a library bug as an unavailable source, exactly the silence the reporting discipline exists to prevent; the unregistered model warning fired on every request instead of once per source; and the transport caught ValueError broadly enough to mislabel unrelated errors, now narrowed to a dedicated ThinkingUnsupportedError. The design and plan still described the original judgement criteria, which the measurements had already overturned. Both now match what the tests actually do, and the design no longer claims the only new failure surface is the openai one -- dissect configures MiniMax-M2.7 with ENABLE_THINKING=false and will fail at assembly, which has to be coordinated before this merges.
703 lines
27 KiB
Python
703 lines
27 KiB
Python
"""OpenAICompatTransport 测试(M1 设计 §4.4/§6):SSE 解析、错误翻译、缺 DONE 语义。
|
|
|
|
SSE 帧样本按三项目真实网关响应形态二次构造(OpenAI 兼容 chunk 结构)。
|
|
"""
|
|
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
from loguru import logger
|
|
|
|
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_unregistered_model_warns_only_once_per_source(self):
|
|
"""未登记模型的告警不能打在请求热路径上: 装配期已喊过,逐次再喊是刷屏。"""
|
|
|
|
def handler(request):
|
|
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
|
|
|
|
source = _source(name="mm", provider="minimax", model="MiniMax-M99", enable_thinking=False)
|
|
transport = _transport_for(handler)
|
|
messages: list[str] = []
|
|
sink_id = logger.add(messages.append, level="WARNING")
|
|
try:
|
|
await _complete(transport, source)
|
|
await _complete(transport, source)
|
|
await _complete(transport, source)
|
|
finally:
|
|
logger.remove(sink_id)
|
|
hits = [m for m in messages if "MiniMax-M99" in m]
|
|
assert len(hits) == 1, f"三次调用应只告警一次,实得 {len(hits)} 次"
|
|
|
|
async def test_unrelated_value_error_is_not_mislabelled(self, monkeypatch):
|
|
"""只捕 ThinkingUnsupportedError: 无关的 ValueError 不该被贴成推理开关的错。
|
|
|
|
今天 `_build_payload` 里只有 resolve_thinking 会抛 ValueError,所以这条
|
|
是防御未来 —— 但正因如此才要钉住: 将来谁在那里加一处校验,宽 catch 会
|
|
把它的错误信息盖掉,而这个用例会先红。
|
|
"""
|
|
|
|
def handler(request): # pragma: no cover - 不该走到发请求
|
|
raise AssertionError("请求不该发出")
|
|
|
|
def _boom(*args, **kwargs):
|
|
raise ValueError("故意的无关错误")
|
|
|
|
monkeypatch.setattr("polygateway.transports.openai_compat.resolve_thinking", _boom)
|
|
with pytest.raises(ValueError, match="故意的无关错误") as exc:
|
|
await _complete(_transport_for(handler), _source(enable_thinking=False))
|
|
assert "推理开关" not in str(exc.value)
|
|
assert not isinstance(exc.value, RequestRejectedError)
|
|
|
|
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()
|