"""LLM 错误分类测试:鉴权/限流可区分上报(两后端错误码等价的关键)。 对照 FastGPT 后端:401 -> ChatBackendAuthenticationError、 429 -> ChatBackendRateLimitError(endpoint 已有对应响应分支)。 LangGraph 链路由 OpenAILLMService 抛出可区分子类、 LangGraphBackend 翻译为 ChatBackend* 错误。 """ 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, OpenAILLMService, ) from src.backends.chat import ( ChatBackendAuthenticationError, ChatBackendRateLimitError, ChatInput, ) from src.backends.langgraph import LangGraphBackend def _status_error(cls, status: int) -> Exception: request = httpx.Request("POST", "http://llm.local/v1/chat/completions") response = httpx.Response(status, request=request) return cls("error", response=response, body=None) def _service_with_failing_llm(exc: Exception, max_retries: int = 0): """complete 固定抛 exc 的 OpenAILLMService(client 打桩)。""" llm = OpenAILLMService(model="test", api_key="test", max_retries=max_retries) create = AsyncMock(side_effect=exc) llm._client = SimpleNamespace(ainvoke=create) return llm, create @pytest.mark.asyncio async def test_authentication_error_raises_llm_auth_error_without_retry(): llm, create = _service_with_failing_llm( _status_error(AuthenticationError, 401), max_retries=2 ) with pytest.raises(LLMAuthError): await llm.complete("系统", "输入") # 401 不可重试:只调用一次 assert create.call_count == 1 @pytest.mark.asyncio async def test_rate_limit_error_retries_then_raises_llm_rate_limit_error(): llm, create = _service_with_failing_llm( _status_error(RateLimitError, 429), max_retries=2 ) with pytest.raises(LLMRateLimitError): await llm.complete("系统", "输入") assert create.call_count == 3 # 首次 + 2 次重试 @pytest.mark.asyncio async def test_llm_error_subclasses_are_api_errors(): """子类兼容既有 LLMAPIError 捕获(不破坏现有 except 顺序)。""" assert issubclass(LLMAuthError, LLMAPIError) assert issubclass(LLMRateLimitError, LLMAPIError) class _GraphRaising: """ainvoke 固定抛指定异常的图替身。""" def __init__(self, exc: Exception): self._exc = exc async def ainvoke(self, *args, **kwargs): raise self._exc @pytest.mark.asyncio async def test_langgraph_backend_maps_auth_error_to_backend_error(): backend = LangGraphBackend( graph=_GraphRaising(LLMAuthError("bad key")), llm=SimpleNamespace(), ) with pytest.raises(ChatBackendAuthenticationError): await backend.complete( ChatInput(session_id="s1", text="你好", client_mode="direct") ) @pytest.mark.asyncio async def test_langgraph_backend_maps_rate_limit_to_backend_error(): backend = LangGraphBackend( graph=_GraphRaising(LLMRateLimitError("限流")), llm=SimpleNamespace(), ) with pytest.raises(ChatBackendRateLimitError): await backend.complete( ChatInput(session_id="s1", text="你好", client_mode="direct") ) @pytest.mark.asyncio async def test_langgraph_backend_stream_propagates_mapped_errors(): """stream 经由 complete 实现,映射自动生效。""" backend = LangGraphBackend( graph=_GraphRaising(LLMAuthError("bad key")), llm=SimpleNamespace(), ) with pytest.raises(ChatBackendAuthenticationError): async for _ in backend.stream( ChatInput(session_id="s1", text="你好", client_mode="direct") ): pass @pytest.mark.asyncio async def test_langgraph_backend_other_llm_errors_still_raise_api_error(): """其余 LLM 错误保持原样上抛(endpoint 落 500 通用分支)。""" from src.agent.llm import LLMAPIError, LLMTimeoutError backend = LangGraphBackend( graph=_GraphRaising(LLMTimeoutError("超时")), llm=SimpleNamespace(), ) with pytest.raises(LLMTimeoutError): await backend.complete( ChatInput(session_id="s1", text="你好", client_mode="direct") )