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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
|
||||
@@ -83,6 +83,7 @@ class _DummyTransport:
|
||||
reasoning_effort,
|
||||
first_token_event,
|
||||
content_event,
|
||||
response_mode,
|
||||
):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user