"""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(): """无 finish 后 usage 的截断流即使收全工具也不打捞(1.3.10: 判据由 DONE 改为完成判定)。""" transport = _transport_for(lambda _: _sse_stream(*tool_frames(), done=False)) try: with pytest.raises(TransientError, match="missing_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_openai_finish_with_trailing_usage_is_complete_without_done(): """对照: 同一工具流补上 finish 之后的 usage 帧,即使无 DONE 也完整交回(#31)。""" usage = 'data: {"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 4}}\n\n' transport = _transport_for(lambda _: _sse_stream(*tool_frames(), usage, done=False)) try: result = await transport.complete( messages=[], source=_source(), stream=True, overlay={}, call_id="c", reasoning_effort=None, first_token_event=None, content_event=None, response_mode="openai", ) finally: await transport.aclose() assert result.stream_end == "eof_after_finish" assert [t["id"] for t in result.completion.message["tool_calls"]] == ["a", "b"] 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()