135 lines
4.3 KiB
Python
135 lines
4.3 KiB
Python
"""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(
|
|||
|
|
chat=SimpleNamespace(completions=SimpleNamespace(create=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")
|
|||
|
|
)
|