feat: preserve governed OpenAI tool messages in 1.4.0

Add faithful single-choice chat results, tool stream activity, error-frame retries, and isolated protocol caching.

Reject empty cached completions with the same value predicate as transport, add regression coverage, and adopt Apache-2.0.
This commit is contained in:
2026-10-01 14:32:19 -04:00
parent 0e29deac1a
commit 9185eab8c9
42 changed files with 2227 additions and 78 deletions
+9 -1
View File
@@ -22,7 +22,13 @@ from polygateway.client import (
)
from polygateway.providers import get_provider
from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import Effort, EmbeddingTransportResult, SourceConfig, TransportResult
from polygateway.types import (
Effort,
EmbeddingTransportResult,
ResponseMode,
SourceConfig,
TransportResult,
)
from tests.live_evidence import (
AttemptEvidence,
HttpEvidence,
@@ -293,6 +299,7 @@ class ObservedTransport:
reasoning_effort: Effort | None,
first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
response_mode: ResponseMode,
) -> TransportResult:
"""与生产端口逐参数同签名。"""
with self._capture.attempt_context(call_id):
@@ -306,6 +313,7 @@ class ObservedTransport:
reasoning_effort=reasoning_effort,
first_token_event=first_token_event,
content_event=content_event,
response_mode=response_mode,
)
async def embed(
+114
View File
@@ -0,0 +1,114 @@
"""真实网关工具双轮冒烟;复用安全 Markdown 报告,不运行工具副作用。"""
import dataclasses
import json
import os
from pathlib import Path
from uuid import uuid4
import pytest
from dotenv import dotenv_values
from polygateway import GatewayClient, GatewaySettings
from tests.live_evidence import write_live_round
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
pytestmark = [pytest.mark.slow, pytest.mark.requires_llm]
@pytest.mark.parametrize("stream", [True, False])
async def test_tools_two_rounds(stream):
"""已配置源中选首源,只改变测试隔离与 raw-only 意图,不收紧生产超时。"""
if not any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV):
pytest.skip("未配置真实源,未覆盖")
settings = GatewaySettings.from_env("LLM", env=_ENV)
source = dataclasses.replace(settings.sources[0], enable_thinking=None, reasoning_effort=None)
settings = dataclasses.replace(
settings,
sources=(source,),
cache_backend="none",
cache_namespace=None,
cache_ttl_s=None,
telemetry_backend="none",
telemetry_auto_migrate=False,
limiter_backend="memory",
breaker_backend="memory",
hedge_after_s=None,
hedge_stall_after_s=None,
)
tools = [
{
"type": "function",
"function": {
"name": "echo_value",
"description": "Return the supplied value",
"parameters": {
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
},
}
]
messages = [
{
"role": "user",
"content": "Call echo_value with value hello. After receiving the tool result, reply with hello.",
}
]
status, error_type, counts = "FAIL", None, {}
try:
async with GatewayClient.from_settings(settings) as client:
first = await client.chat(
messages,
stream=stream,
response_mode="openai",
overlay={
"tools": tools,
"tool_choice": {"type": "function", "function": {"name": "echo_value"}},
},
)
completion = first.completion
assert completion is not None and completion.message["role"] == "assistant"
assert completion.response_id and completion.finish_reason == "tool_calls"
calls = completion.message["tool_calls"]
counts["tools"] = len(calls)
assert calls
messages.append(completion.message)
for call in calls:
assert call["function"]["name"] == "echo_value"
arguments = json.loads(call["function"]["arguments"])
assert arguments["value"] == "hello"
messages.append(
{"role": "tool", "tool_call_id": call["id"], "content": arguments["value"]}
)
second = await client.chat(
messages,
stream=stream,
response_mode="openai",
overlay={"tools": tools, "tool_choice": "none"},
)
assert (
second.completion is not None and second.completion.message["role"] == "assistant"
)
assert "hello" in (second.completion.message.get("content") or "").lower()
counts["attempts"] = first.call_stats.attempts + second.call_stats.attempts
status = "PASS"
except Exception as exc:
error_type = type(exc).__name__
raise
finally:
write_live_round(
Path("tests/outputs/issue29/live"),
run_id=uuid4().hex,
matrix_id="tools",
round_index=1,
safe_fields={
"status": status,
"stream": stream,
"error_type": error_type,
"counts": counts,
"planned_rounds": 2,
"completed_rounds": 2 if status == "PASS" else 0,
},
)
+19
View File
@@ -172,3 +172,22 @@ class TestClientWithRealRedis:
second = await client.chat(_MSGS)
assert first.cache_hit is False and second.cache_hit is True
assert second.content == "hello"
async def test_real_completion_roundtrip(redis_cache, namespace):
"""真实 Redis 保存完整嵌套协议对象,命中仍为值类型。"""
from dataclasses import replace
from tests.unit.test_openai_completion import accumulate
completion = accumulate().finish()
mw = CacheMW(backend=redis_cache, model_fingerprint="m", default_namespace=namespace, ttl_s=60)
async def terminal(_):
return replace(_resp(), completion=completion)
request = ChatRequest(messages=_MSGS, response_mode="openai")
first = await mw(request, terminal)
second = await mw(request, terminal)
assert second.cache_hit and second.completion == first.completion
assert second.completion is not first.completion
@@ -86,6 +86,7 @@ class ScriptedTransport:
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
self.calls.append(source.name)
if self.hang:
+1
View File
@@ -221,6 +221,7 @@ class ClockAdvancingTransport:
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
self.calls.append((source.name, call_id))
advance, action = self.script.pop(0)
+111
View File
@@ -15,6 +15,7 @@ from polygateway.types import (
ChatRequest,
Effort,
LLMResponse,
OpenAIChatCompletion,
SourceConfig,
ThinkingObservation,
)
@@ -891,3 +892,113 @@ class TestCallStatsNotPoisoned:
without = ChatRequest(messages=_MSGS)
assert with_ctx.cache_namespace == without.cache_namespace
assert digest_messages(with_ctx.messages) == digest_messages(without.messages)
class TestOpenAICompletionCache:
"""协议缓存不得混模式或把字典冒充公共值对象。"""
def test_mode_isolation_preserves_text_key(self):
assert build_cache_key("m", _MSGS, "proj", None) == build_cache_key(
"m", _MSGS, "proj", None, response_mode="text"
)
assert build_cache_key("m", _MSGS, "proj", None) != build_cache_key(
"m", _MSGS, "proj", None, response_mode="openai"
)
@pytest.mark.parametrize(
"completion",
[
None,
{},
{
"message": {"content": "ok"},
"finish_reason": "stop",
"response_id": None,
"usage": None,
},
],
)
async def test_missing_or_bad_completion_misses(self, completion):
backend = InMemoryCache()
mw = CacheMW(backend=backend, model_fingerprint="m", default_namespace="proj", ttl_s=60)
raw = json.loads(mw._serialize(_resp()))
raw["completion"] = completion
assert (
mw._rehydrate(json.dumps(raw), ChatRequest(messages=_MSGS, response_mode="openai"))
is None
)
async def test_serialization_failure_does_not_fail_call(self):
mw = CacheMW(
backend=InMemoryCache(), model_fingerprint="m", default_namespace="proj", ttl_s=60
)
response = _resp(cost=object())
async def terminal(_):
return response
assert await mw(ChatRequest(messages=_MSGS), terminal) is response
@pytest.mark.parametrize(
"message",
[
{"role": "assistant"},
{"role": "assistant", "content": None, "tool_calls": []},
{"role": "assistant", "content": "", "tool_calls": []},
{"role": "assistant", "content": " \n", "tool_calls": []},
{"role": "assistant", "reasoning_content": "reason only"},
],
)
@pytest.mark.parametrize("cached_content", ["", "stale projection"])
async def test_openai_cache_without_output_misses_and_refills(message, cached_content):
"""空协议产物不能靠旧投影正文伪装成功;miss 后回源并覆写。"""
backend = InMemoryCache()
mw = _mw(backend)
request = ChatRequest(messages=_MSGS, response_mode="openai")
key = build_cache_key("m", _MSGS, "proj", None, response_mode="openai")
bad = _resp(
content=cached_content,
completion=OpenAIChatCompletion(message, "stop", "bad", None),
)
await backend.set(key, mw._serialize(bad), ttl_s=60)
fresh = _resp(
completion=OpenAIChatCompletion(
{"role": "assistant", "content": "cached"}, "stop", "fresh", None
)
)
terminal = _Terminal(fresh)
result = await mw(request, terminal)
assert terminal.calls == 1 and result is fresh and not result.cache_hit
hit = await mw(request, terminal)
assert terminal.calls == 1 and hit.cache_hit
assert hit.completion.response_id == "fresh"
async def test_openai_tool_only_cache_hits_without_origin():
"""有效工具本身是产物,空文本投影不妨碍命中。"""
backend = InMemoryCache()
mw = _mw(backend)
completion = OpenAIChatCompletion(
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call-1",
"type": "function",
"function": {"name": "echo", "arguments": "{}"},
}
],
},
"tool_calls",
"tool-response",
None,
)
request = ChatRequest(messages=_MSGS, response_mode="openai")
key = build_cache_key("m", _MSGS, "proj", None, response_mode="openai")
await backend.set(key, mw._serialize(_resp(content="", completion=completion)), ttl_s=60)
terminal = _Terminal(_resp())
hit = await mw(request, terminal)
assert hit.cache_hit and terminal.calls == 0
assert hit.completion == completion
+2
View File
@@ -232,6 +232,7 @@ class _ScriptedGenClockTransport:
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
self.calls.append(call_id)
result = self._results.pop(0)
@@ -1844,6 +1845,7 @@ class _ClockJumpTransport:
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
self.calls.append(call_id)
self._clock.advance(self._jump)
+2 -6
View File
@@ -1397,9 +1397,7 @@ class TestStallConfig:
def test_stall_after_s_single_source_warns(self):
"""单源 scope 设 stall 阈值: 装配期 warning 放行(1.3.7 §5 同款)。"""
with _captured_warnings() as warnings:
s = GatewaySettings.from_env(
"LLM", env=_env(**{"LLM__HEDGE__STALL_AFTER_S": "8"})
)
s = GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__STALL_AFTER_S": "8"}))
assert s.hedge_stall_after_s == 8.0
assert any("单源" in m for m in warnings)
@@ -1422,9 +1420,7 @@ class TestStallConfig:
"EMBED__BATCH_SIZE": "64",
}
with _captured_warnings() as warnings:
EmbeddingSettings.from_env(
"EMBED", env={**embed_env, "EMBED__HEDGE__AFTER_S": "8"}
)
EmbeddingSettings.from_env("EMBED", env={**embed_env, "EMBED__HEDGE__AFTER_S": "8"})
assert any("仅对 chat scope 生效" in m for m in warnings)
with _captured_warnings() as warnings:
EmbeddingSettings.from_env(
+1
View File
@@ -65,6 +65,7 @@ class HedgeTransport:
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
self.calls.append(source.name)
self.ft_events.append(first_token_event)
+2
View File
@@ -279,6 +279,7 @@ async def _complete(observed, *, call_id="a", stream=False):
reasoning_effort=None,
first_token_event=None,
content_event=None,
response_mode="text",
)
@@ -1270,6 +1271,7 @@ async def test_structured_first_attempt_requires_exact_initial_messages():
reasoning_effort=None,
first_token_event=None,
content_event=None,
response_mode="text",
)
event = capture.attempts(session_id="first", parent_call_id="parent")[0].http[0]
assert not request_is_valid(event)
+19 -3
View File
@@ -90,6 +90,7 @@ async def _complete(
reasoning_effort=None,
first_token_event=None,
content_event=None,
response_mode="text",
):
return await transport.complete(
messages=[{"role": "user", "content": "hi"}],
@@ -100,6 +101,7 @@ async def _complete(
reasoning_effort=reasoning_effort,
first_token_event=first_token_event,
content_event=content_event,
response_mode="text",
)
@@ -161,6 +163,8 @@ class TestSsePureFunctions:
assert _sse_data_payload("data: [DONE]") == "[DONE]"
async def test_iter_deltas_yields_and_flags_done(self):
from polygateway.transports._openai_completion import ChatCompletionAccumulator, ChatDelta
async def lines():
for raw in [
_chunk(content="he"),
@@ -173,16 +177,28 @@ class TestSsePureFunctions:
yield line
sink = {}
out = [d async for d in _iter_sse_deltas(lines(), sink)]
assert out == [(True, "he"), (False, "think")]
out = [
d
async for d in _iter_sse_deltas(
lines(), sink, ChatCompletionAccumulator(response_mode="text")
)
]
assert out == [ChatDelta("he", "", True), ChatDelta("", "think", True)]
assert sink["done"] is True and sink["usage"] == _USAGE
async def test_malformed_frame_raises_transient(self):
from polygateway.transports._openai_completion import ChatCompletionAccumulator
async def lines():
yield "data: {not-json"
with pytest.raises(TransientError, match="malformed"):
_ = [d async for d in _iter_sse_deltas(lines(), {})]
_ = [
d
async for d in _iter_sse_deltas(
lines(), {}, ChatCompletionAccumulator(response_mode="text")
)
]
class TestStreamHappyPath:
+227
View File
@@ -0,0 +1,227 @@
"""协议聚合器的分片、防御与快照契约。"""
import copy
import json
import pytest
from polygateway.errors import TransientError
from polygateway.transports._openai_completion import (
ChatCompletionAccumulator,
parse_chat_completion,
)
def chunk(delta, **extra):
"""构造单 choice 协议帧。"""
return (
"data: "
+ json.dumps({"id": "upstream-1", "choices": [{"index": 0, "delta": delta, **extra}]})
+ "\n\n"
)
def tool_frames():
"""两个交错分片工具,保留空白及 Unicode。"""
return [
chunk({"role": "assistant", "content": None}),
chunk(
{
"tool_calls": [
{
"index": 1,
"id": "b",
"type": "function",
"function": {"name": "echo", "arguments": '{"v":'},
},
{
"index": 0,
"id": "a",
"type": "function",
"function": {"name": "echo", "arguments": "{"},
},
]
}
),
chunk(
{
"tool_calls": [
{"index": 0, "function": {"arguments": '"v": "中" }'}},
{"index": 1, "function": {"arguments": "2}"}},
]
},
finish_reason="tool_calls",
),
]
def accumulate():
"""复用双工具网关帧样本。"""
state = ChatCompletionAccumulator(response_mode="openai")
for frame in tool_frames():
state.add_chunk(json.loads(frame.removeprefix("data: ")))
return state
@pytest.mark.parametrize("role", [None, "user", "tool"])
def test_role_is_required_everywhere(role):
"""缺角色不能被协议成功掩盖。"""
state = accumulate()
state.message.pop("role")
if role:
state.message["role"] = role
with pytest.raises(TransientError, match="role"):
state.finish()
with pytest.raises(TransientError, match="role"):
parse_chat_completion({"choices": [{"message": state.message}]})
@pytest.mark.parametrize(
"change",
[
lambda t: t.pop("id"),
lambda t: t.update(type="custom"),
lambda t: t["function"].pop("name"),
lambda t: t["function"].pop("arguments"),
lambda t: t["function"].update(arguments=12),
],
)
def test_incomplete_tool_is_not_success(change):
"""工具必需字段不能靠正文兜底。"""
state = accumulate()
state.message["content"] = "text"
change(state.tools[0])
with pytest.raises(TransientError):
state.finish()
@pytest.mark.parametrize(
"delta",
[
[],
{"audio": {}},
{"tool_calls": {}},
{"content": []},
{"tool_calls": [{"index": True}]},
{"tool_calls": [{"index": -1}]},
{"tool_calls": [{"index": 0, "extra": 1}]},
{"tool_calls": [{"index": 0, "function": {"arguments": None}}]},
],
)
def test_invalid_delta_is_domain_error(delta):
"""畸形输入不泄漏裸类型错误。"""
state = ChatCompletionAccumulator(response_mode="openai")
with pytest.raises(TransientError):
state.add_chunk({"choices": [{"index": 0, "delta": delta}]})
def test_identity_repeat_does_not_refresh_progress():
"""身份只初次生效算进展;同值重复不能续命。"""
state = accumulate()
repeated = {
"choices": [
{"index": 0, "delta": {"tool_calls": [{"index": 0, "id": "a", "type": "function"}]}}
]
}
assert not state.add_chunk(repeated).progress
repeated["choices"][0]["delta"]["tool_calls"][0]["id"] = "other"
with pytest.raises(TransientError, match="冲突"):
state.add_chunk(repeated)
@pytest.mark.parametrize("delta", [{"role": "assistant"}, {}, {"content": ""}])
def test_metadata_not_progress(delta):
"""空帧和角色不算 token。"""
state = ChatCompletionAccumulator(response_mode="openai")
assert not state.add_chunk({"choices": [{"index": 0, "delta": delta}]}).progress
def test_nonstream_snapshot_and_length_preserved():
"""非流式扩展字段与参数原文独立保存,不修复 length 产物。"""
message = accumulate().finish().message
message["extension"] = {"nested": [1]}
message["tool_calls"][0]["function"]["arguments"] = '{"unfinished":'
original = copy.deepcopy(message)
result = parse_chat_completion(
{
"id": "id",
"choices": [{"message": message, "finish_reason": "length"}],
"usage": {"extra": 3},
}
)
message["extension"]["nested"].append(2)
assert result.message == original
assert result.finish_reason == "length" and result.usage == {"extra": 3}
@pytest.mark.parametrize("choices", [[], [{"index": 1, "message": {}}], [{}, {}]])
def test_nonstream_single_choice(choices):
"""不会静默取第一个 choice。"""
with pytest.raises(TransientError):
parse_chat_completion({"choices": choices})
def test_explicit_empty_tools_remains_present():
"""显式空工具列表与缺省不同,不删除已接收字段。"""
state = ChatCompletionAccumulator(response_mode="openai")
state.add_chunk(
{
"choices": [
{"index": 0, "delta": {"role": "assistant", "content": "ok", "tool_calls": []}}
]
}
)
assert state.finish().message["tool_calls"] == []
def test_fragmented_name_and_arguments_with_usage():
"""名称/参数分片逐字拼接,usage 扩展原样快照。"""
state = ChatCompletionAccumulator(response_mode="openai")
state.add_chunk(
{
"id": "response",
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"tool_calls": [
{
"index": 3,
"id": "call",
"type": "function",
"function": {"name": "ec", "arguments": " "},
}
],
},
}
],
}
)
progress = state.add_chunk(
{
"choices": [
{
"index": 0,
"delta": {
"content": "text",
"reasoning_content": "reason",
"tool_calls": [
{"index": 3, "function": {"name": "ho", "arguments": "{} "}}
],
},
"finish_reason": "length",
}
]
}
)
assert (
progress.content == "text" and progress.reasoning_content == "reason" and progress.progress
)
usage = {"prompt_tokens": 7, "completion_tokens": 2, "extra": {"nested": 9}}
assert not state.add_chunk({"choices": [], "usage": usage}).progress
result = state.finish()
usage["extra"]["nested"] = 10
assert result.message["tool_calls"][0]["function"] == {"name": "echo", "arguments": " {} "}
assert result.usage["extra"]["nested"] == 9
assert result.finish_reason == "length"
+354
View File
@@ -0,0 +1,354 @@
"""OpenAI 消息保真回归:按网关 Chat Completions 形态构造。"""
import json
import httpx
import pytest
from polygateway.errors import AllSourcesExhausted, TransientError
from tests.unit.test_client import _client
from tests.unit.test_openai_compat import _complete, _source, _sse_stream, _transport_for
from tests.unit.test_openai_completion import accumulate, chunk, tool_frames
async def test_error_before_done_is_failure():
"""文本模式也不能接受 error 后的 DONE。"""
transport = _transport_for(
lambda _: _sse_stream(chunk({"content": "partial"}), 'data: {"error": null}\n\n')
)
try:
with pytest.raises(TransientError):
await _complete(transport, _source())
finally:
await transport.aclose()
async def test_mixed_content_reasoning_survive():
"""同一帧双文本不互斥。"""
transport = _transport_for(
lambda _: _sse_stream(chunk({"content": "answer", "reasoning_content": "reason"}))
)
try:
result = await _complete(transport, _source())
assert (result.content, result.thinking) == ("answer", "reason")
finally:
await transport.aclose()
async def test_tools_roundtrip_and_retry():
"""真实 transport 经过治理,失败重试不污染工具,消息可直接往返。"""
requests = []
def handler(request):
requests.append(json.loads(request.content))
if len(requests) == 1:
return _sse_stream(
chunk({"content": "partial"}),
'data: {"error": {"code": "upstream_stream_read_error"}}\n\n',
)
return _sse_stream(*tool_frames())
client = _client(handler=handler)
try:
response = await client.chat(
[{"role": "user", "content": "hi"}],
response_mode="openai",
overlay={"reasoning_effort": "max"},
)
assert response.call_stats.attempts == 2
message = response.completion.message
assert message["role"] == "assistant" and message["content"] is None
assert [t["id"] for t in message["tool_calls"]] == ["a", "b"]
assert message["tool_calls"][0]["function"]["arguments"] == '{"v": "中" }'
assert response.completion.response_id == "upstream-1"
assert response.completion.finish_reason == "tool_calls"
messages = [message, {"role": "tool", "tool_call_id": "a", "content": "ok"}]
await client.chat(messages, response_mode="openai")
assert requests[-1]["messages"] == messages
assert requests[0]["reasoning_effort"] == "max"
finally:
await client._transport.aclose()
@pytest.mark.parametrize("role", [None, "user"])
async def test_final_role_required(role):
"""非流式缺失或错误 role 必须协议失败。"""
message = {"content": "ok"}
if role:
message["role"] = role
client = _client(
handler=lambda _: httpx.Response(200, json={"choices": [{"message": message}]})
)
try:
with pytest.raises(AllSourcesExhausted):
await client.chat(
[{"role": "user", "content": "hi"}], stream=False, response_mode="openai"
)
finally:
await client._transport.aclose()
@pytest.mark.parametrize("stream", [True, False])
async def test_completion_cache_roundtrip(stream):
"""完整结果回放,修改返回对象不毒化缓存;模式隔离。"""
from polygateway.backends.memory.cache import InMemoryCache
completion = accumulate().finish()
def handler(_):
if stream:
return _sse_stream(*tool_frames())
return httpx.Response(
200,
json={
"id": "upstream-1",
"choices": [{"message": completion.message, "finish_reason": "tool_calls"}],
},
)
client = _client(
handler=handler, cache=InMemoryCache(), cache_namespace="tools", cache_ttl_s=60
)
try:
first = await client.chat([], stream=stream, response_mode="openai")
second = await client.chat([], stream=stream, response_mode="openai")
assert not first.cache_hit and second.cache_hit
assert second.completion == first.completion
assert second.call_id != first.call_id and second.call_stats.attempts == 0
second.completion.message["tool_calls"].clear()
third = await client.chat([], stream=stream, response_mode="openai")
assert len(third.completion.message["tool_calls"]) == 2
finally:
await client._transport.aclose()
@pytest.mark.parametrize(
"kwargs",
[
{"response_mode": "bad"},
{"response_mode": "openai", "structured": "json"},
{"response_mode": "openai", "overlay": {"n": 2}},
{"response_mode": "openai", "overlay": {"n": True}},
],
)
async def test_invalid_mode_combinations_fail_before_http(kwargs):
"""组合错误入口拒绝。"""
def unexpected(_):
pytest.fail("不应发送 HTTP")
client = _client(handler=unexpected)
try:
with pytest.raises(ValueError):
await client.chat([], **kwargs)
finally:
await client._transport.aclose()
async def test_openai_never_salvages():
"""已收完整工具也必须有 DONE。"""
transport = _transport_for(lambda _: _sse_stream(*tool_frames(), done=False))
try:
with pytest.raises(TransientError, match="DONE"):
await transport.complete(
messages=[],
source=_source(missing_done="salvage"),
stream=True,
overlay={},
call_id="c",
reasoning_effort=None,
first_token_event=None,
content_event=None,
response_mode="openai",
)
finally:
await transport.aclose()
async def test_tool_progress_sets_events():
"""工具首进展驱动两个事件与时间观测。"""
import asyncio
first, progress = asyncio.Event(), asyncio.Event()
transport = _transport_for(lambda _: _sse_stream(*tool_frames()))
try:
result = await transport.complete(
messages=[],
source=_source(),
stream=True,
overlay={},
call_id="c",
reasoning_effort=None,
first_token_event=first,
content_event=progress,
response_mode="openai",
)
assert first.is_set() and progress.is_set()
assert result.ttft_ms is not None and result.stall_count == 0
finally:
await transport.aclose()
async def test_real_transport_stall_hedge_cancels_loser():
"""真实工具流沉默触发异源对冲,连接关闭且赢家不混入半成品。"""
import asyncio
import time
from polygateway.sources import RoundRobinSelector
from polygateway.types import ChatRequest, _CallContext
from tests.unit.test_retry import _src
from tests.unit.test_stall_hedge import _harness
entered, closed = asyncio.Event(), asyncio.Event()
class Hanging(httpx.AsyncByteStream):
"""工具首帧后阻塞,取消由真实 httpx 流处理。"""
async def __aiter__(self):
yield tool_frames()[0].encode()
yield tool_frames()[1].encode()
entered.set()
await asyncio.Event().wait()
async def aclose(self):
closed.set()
count = 0
def handler(_):
nonlocal count
count += 1
if count == 1:
return httpx.Response(200, stream=Hanging())
return _sse_stream(*tool_frames())
transport = _transport_for(handler)
sources = [_src("s1"), _src("s2")]
mw, limiter, *_ = _harness(
sources, transport, stall_after_s=0.05, selector=RoundRobinSelector()
)
context = _CallContext(now=time.monotonic)
try:
response = await asyncio.wait_for(
mw(ChatRequest(messages=[], response_mode="openai", call_context=context)), 3
)
assert entered.is_set() and closed.is_set()
assert response.source_name == "s2" and len(response.completion.message["tool_calls"]) == 2
assert context.snapshot().hedges == 1 and context.snapshot().attempts == 2
finally:
await transport.aclose()
@pytest.mark.parametrize("stream", [True, False])
async def test_bad_usage_is_domain_failure(stream):
"""坏 usage 不能穿透成裸 AttributeError,包括默认文本模式。"""
transport = _transport_for(
lambda _: (
_sse_stream(chunk({"content": "ok"}), 'data: {"choices": [], "usage": [1]}\n\n')
if stream
else httpx.Response(
200, json={"choices": [{"message": {"content": "ok"}}], "usage": [1]}
)
)
)
try:
with pytest.raises(TransientError):
await _complete(transport, _source(), stream=stream)
finally:
await transport.aclose()
async def test_nonstream_tool_result_has_no_liveness_events():
"""非流式工具不制造流式观测。"""
import asyncio
completion = accumulate().finish()
first, progress = asyncio.Event(), asyncio.Event()
transport = _transport_for(
lambda _: httpx.Response(
200, json={"choices": [{"message": completion.message, "finish_reason": "tool_calls"}]}
)
)
try:
result = await transport.complete(
messages=[],
source=_source(),
stream=False,
overlay={},
call_id="c",
reasoning_effort=None,
first_token_event=first,
content_event=progress,
response_mode="openai",
)
assert result.completion.message == completion.message
assert not first.is_set() and not progress.is_set()
assert result.ttft_ms is None and result.stall_ms is None
finally:
await transport.aclose()
async def test_openai_does_not_bypass_managed_conflict():
"""保真模式不静默撤销源级受管意图。"""
from polygateway.errors import RequestRejectedError
def unexpected(_):
pytest.fail("冲突请求不应发送 HTTP")
transport = _transport_for(unexpected)
try:
with pytest.raises(RequestRejectedError):
await transport.complete(
messages=[],
source=_source(enable_thinking=True),
stream=True,
overlay={"reasoning_effort": "max"},
call_id="c",
reasoning_effort=None,
first_token_event=None,
content_event=None,
response_mode="openai",
)
finally:
await transport.aclose()
async def test_cancel_tool_stream_releases_resources():
"""显式取消真实工具流,连接关闭且配额归还。"""
import asyncio
from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.types import GlobalLimits
entered, closed = asyncio.Event(), asyncio.Event()
class Hanging(httpx.AsyncByteStream):
"""可确定取消窗口的 HTTP 流。"""
async def __aiter__(self):
for frame in tool_frames()[:2]:
yield frame.encode()
entered.set()
await asyncio.Event().wait()
async def aclose(self):
closed.set()
source = _source(max_concurrency=1)
limiter = InMemoryLimiter(
scope="llm", sources={source.name: source}, global_limits=GlobalLimits(1, 0, 0)
)
client = _client(
sources=[source], limiter=limiter, handler=lambda _: httpx.Response(200, stream=Hanging())
)
task = asyncio.create_task(client.chat([], response_mode="openai"))
try:
await asyncio.wait_for(entered.wait(), 2)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert closed.is_set()
assert (await limiter.source_stats(source.name)).inflight == 0
finally:
task.cancel()
await client._transport.aclose()
+12
View File
@@ -88,3 +88,15 @@ def test_every_promised_export_is_actually_importable():
"""
missing = [name for name in polygateway.__all__ if not hasattr(polygateway, name)]
assert not missing, f"__all__ 承诺了但没绑上的符号: {missing}"
def test_apache_license_metadata():
"""许可正文和包元数据同时存在,不以版本说明替代许可。"""
import tomllib
from pathlib import Path
root = Path(__file__).resolve().parents[2]
assert "Apache License" in (root / "LICENSE").read_text()
assert "Version 2.0" in (root / "LICENSE").read_text()
project = tomllib.loads((root / "pyproject.toml").read_text())["project"]
assert project["license"] == {"file": "LICENSE"}
+1
View File
@@ -83,6 +83,7 @@ class _DummyTransport:
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
raise NotImplementedError
+3
View File
@@ -90,6 +90,7 @@ class FakeTransport:
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
self.calls.append((source.name, call_id))
self.efforts.append(reasoning_effort)
@@ -132,6 +133,7 @@ class _GenClockTransport:
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
advance = self._advances.pop(0)
stall = self._stalls.pop(0) if self._stalls is not None else None
@@ -144,6 +146,7 @@ class _GenClockTransport:
reasoning_effort=reasoning_effort,
first_token_event=first_token_event,
content_event=content_event,
response_mode=response_mode,
)
if stall is not None:
result = dataclasses.replace(result, stall_ms=stall[0], stall_count=stall[1])
+4 -9
View File
@@ -78,6 +78,7 @@ class StallHedgeTransport:
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
self.calls.append(source.name)
self.ft_events.append(first_token_event)
@@ -271,9 +272,7 @@ class TestStallHedgeTrigger:
"""参数化四格(设计 §5.2 逐字): 两阈值独立生效,各格触发语义不串。"""
# 格 1: 仅 AFTER_S + 流式 pre-TTFT 挂起 → AFTER_S 触发(1.3.7 语义不变)
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]}
)
transport = StallHedgeTransport({"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]})
mw, _, _ = _harness([s1, s2], transport, hedge_after_s=_STALL_AFTER_S)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
@@ -312,9 +311,7 @@ class TestStallHedgeTrigger:
# 格 3b: 两者皆设 + pre-TTFT 挂起 → 阶段 1 AFTER_S 触发(两阶段各管一段)
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]}
)
transport = StallHedgeTransport({"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]})
mw, _, _ = _harness(
[s1, s2], transport, hedge_after_s=_STALL_AFTER_S, stall_after_s=_STALL_AFTER_S
)
@@ -340,9 +337,7 @@ class TestStallHedgeTrigger:
async def test_stall_only_non_stream_degrades_to_single_leg(self):
"""仅 stall 已设 + `stream=False` → 逐字单路(中段沉默无非流式概念,入口门)。"""
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{"s1": [("hang",), ("ok", "x")], "s2": [("ok", "other")]}
)
transport = StallHedgeTransport({"s1": [("hang",), ("ok", "x")], "s2": [("ok", "other")]})
mw, _, _ = _harness([s1, s2], transport, stall_after_s=_STALL_AFTER_S)
ctx = _ctx()
task = asyncio.ensure_future(mw(_req(stream=False, ctx=ctx)))
+1
View File
@@ -141,6 +141,7 @@ async def test_salvage_override_stays_in_domain(usage):
reasoning_effort=None,
first_token_event=None,
content_event=None,
response_mode="text",
)
assert result.usage_source in USAGE_SOURCES