"""OpenAILLMService.stream_tokens 测试(模型侧流式)。 mock 客户端返回 token 事件流;验证消息构造与 complete 一致、 错误分类(401/429/超时/连接)与不重试语义。 """ import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock import httpx import pytest from openai import AuthenticationError, RateLimitError from src.agent.llm import ( LLMAPIError, LLMAuthError, LLMRateLimitError, LLMTimeoutError, OpenAILLMService, ) def _event(content): return SimpleNamespace( choices=[SimpleNamespace(delta=SimpleNamespace(content=content))] ) def _service_with_stream(events): llm = OpenAILLMService( model="test-model", api_key="test", base_url="http://llm.local/v1", timeout_seconds=5, temperature=0, extra_body={"reasoning_effort": "none"}, ) class _Stream: def __init__(self, items): self._items = list(items) def __aiter__(self): return self._aiter() async def _aiter(self): for item in self._items: yield item create = AsyncMock(return_value=_Stream(events)) llm._client = SimpleNamespace( chat=SimpleNamespace(completions=SimpleNamespace(create=create)) ) return llm, create @pytest.mark.asyncio async def test_stream_tokens_yields_incrementally_and_forwards_kwargs(): llm, create = _service_with_stream([_event("你"), _event(""), _event("好")]) tokens = [ token async for token in llm.stream_tokens("系统", "输入", history=[{"role": "assistant", "content": "h"}]) ] assert tokens == ["你", "好"] # 空 delta 被跳过 kwargs = create.call_args.kwargs assert kwargs["model"] == "test-model" assert kwargs["stream"] is True assert kwargs["temperature"] == 0 assert kwargs["extra_body"]["reasoning_effort"] == "none" # extra_body 透传 assert kwargs["messages"][0] == {"role": "system", "content": "系统"} assert kwargs["messages"][-1] == {"role": "user", "content": "输入"} @pytest.mark.asyncio async def test_stream_tokens_maps_authentication_error(): request = httpx.Request("POST", "http://llm.local/v1/chat/completions") exc = AuthenticationError("bad key", response=httpx.Response(401, request=request), body=None) llm, create = _service_with_stream([]) create.side_effect = exc with pytest.raises(LLMAuthError): async for _ in llm.stream_tokens("系统", "输入"): pass @pytest.mark.asyncio async def test_stream_tokens_maps_rate_limit_error(): request = httpx.Request("POST", "http://llm.local/v1/chat/completions") exc = RateLimitError("slow down", response=httpx.Response(429, request=request), body=None) llm, create = _service_with_stream([]) create.side_effect = exc with pytest.raises(LLMRateLimitError): async for _ in llm.stream_tokens("系统", "输入"): pass @pytest.mark.asyncio async def test_stream_tokens_times_out_on_stale_stream(): """末 token 迟到同样算超时(asyncio.timeout 包整个迭代)。""" class _HangingStream: def __aiter__(self): return self._gen() async def _gen(self): yield _event("你") await asyncio.sleep(30) # 超过 timeout_seconds=5 yield _event("好") llm = OpenAILLMService(model="m", api_key="k", timeout_seconds=0.2) create = AsyncMock(return_value=_HangingStream()) llm._client = SimpleNamespace( chat=SimpleNamespace(completions=SimpleNamespace(create=create)) ) tokens = [] with pytest.raises(LLMTimeoutError): async for token in llm.stream_tokens("系统", "输入"): tokens.append(token) assert tokens == ["你"] # 超时前已流出的 token 保留