119 lines
4.9 KiB
Python
119 lines
4.9 KiB
Python
"""llm.chat failover 接线端到端测试(mock OpenAI 客户端,不依赖网络)。"""
|
||||
|
|
from types import SimpleNamespace
|
|||
|
|
from unittest.mock import patch, MagicMock
|
|||
|
|
|
|||
|
|
import app.llm as llm_mod
|
|||
|
|
from app.llm import chat
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _provider_row(pid, name, models):
|
|||
|
|
return SimpleNamespace(id=pid, name=name, models=list(models),
|
|||
|
|
default_model=models[0] if models else "", enabled=True,
|
|||
|
|
base_url="https://mock.local/v1", api_key="mock-key")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class FakeSession:
|
|||
|
|
"""最小 session mock:get 返回 None(无 campaign/run 归属),exec 供 failover 查询。"""
|
|||
|
|
|
|||
|
|
def __init__(self, providers):
|
|||
|
|
self._providers = providers
|
|||
|
|
|
|||
|
|
def get(self, model, row_id):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def exec(self, *_a, **_k):
|
|||
|
|
rows = list(self._providers.values())
|
|||
|
|
return SimpleNamespace(all=lambda: rows,
|
|||
|
|
first=lambda: None)
|
|||
|
|
|
|||
|
|
def commit(self):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def add(self, _obj):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _fake_resp(text="你好,正文内容。"):
|
|||
|
|
resp = MagicMock()
|
|||
|
|
resp.choices = [SimpleNamespace(message=SimpleNamespace(
|
|||
|
|
content=text, tool_calls=None))]
|
|||
|
|
resp.usage = SimpleNamespace(prompt_tokens=10, completion_tokens=20)
|
|||
|
|
return resp
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_chat_failover_on_auth_error():
|
|||
|
|
"""主 Provider 认证失败 → 自动切备用 Provider 成功。"""
|
|||
|
|
providers = {1: _provider_row(1, "主Provider", ["deepseek-chat"]),
|
|||
|
|
2: _provider_row(2, "备Provider", ["deepseek-chat"])}
|
|||
|
|
calls = {"n": 0}
|
|||
|
|
|
|||
|
|
def fake_create(**request):
|
|||
|
|
calls["n"] += 1
|
|||
|
|
if calls["n"] == 1:
|
|||
|
|
raise RuntimeError("Error code: 401 - {'error': {'message': 'Authentication Fails'}}")
|
|||
|
|
return _fake_resp()
|
|||
|
|
|
|||
|
|
session = FakeSession(providers)
|
|||
|
|
with patch.object(llm_mod, "get_provider", side_effect=lambda s, pid: providers[pid]), \
|
|||
|
|
patch.object(llm_mod, "require_ai_points", return_value=None), \
|
|||
|
|
patch.object(llm_mod, "_log", return_value=True), \
|
|||
|
|
patch.object(llm_mod, "record_usage_event", return_value=SimpleNamespace(id=1)), \
|
|||
|
|
patch.object(llm_mod.OpenAI, "chat") as mock_chat:
|
|||
|
|
mock_chat.completions.create.side_effect = fake_create
|
|||
|
|
result = chat(session, [{"role": "user", "content": "测试"}], 1, "deepseek-chat",
|
|||
|
|
user_id=1, organization_id=1)
|
|||
|
|
assert result["status"] == "ok"
|
|||
|
|
assert result["text"] == "你好,正文内容。"
|
|||
|
|
assert result["failover"]["from_provider"] == "主Provider"
|
|||
|
|
assert result["failover"]["from_error"] == "auth"
|
|||
|
|
assert calls["n"] == 2
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_chat_no_failover_on_other_error():
|
|||
|
|
"""不可切换错误(other)不触发 failover。"""
|
|||
|
|
providers = {1: _provider_row(1, "主Provider", ["deepseek-chat"]),
|
|||
|
|
2: _provider_row(2, "备Provider", ["deepseek-chat"])}
|
|||
|
|
calls = {"n": 0}
|
|||
|
|
|
|||
|
|
def fake_create(**request):
|
|||
|
|
calls["n"] += 1
|
|||
|
|
raise RuntimeError("some mysterious failure")
|
|||
|
|
|
|||
|
|
session = FakeSession(providers)
|
|||
|
|
with patch.object(llm_mod, "get_provider", side_effect=lambda s, pid: providers[pid]), \
|
|||
|
|
patch.object(llm_mod, "require_ai_points", return_value=None), \
|
|||
|
|
patch.object(llm_mod, "_log", return_value=True), \
|
|||
|
|
patch.object(llm_mod, "record_usage_event", return_value=SimpleNamespace(id=1)), \
|
|||
|
|
patch.object(llm_mod.OpenAI, "chat") as mock_chat:
|
|||
|
|
mock_chat.completions.create.side_effect = fake_create
|
|||
|
|
result = chat(session, [{"role": "user", "content": "测试"}], 1, "deepseek-chat",
|
|||
|
|
user_id=1, organization_id=1, max_retries=0)
|
|||
|
|
assert result["status"] == "failed"
|
|||
|
|
assert "failover" not in result
|
|||
|
|
assert calls["n"] == 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_chat_failover_all_providers_down():
|
|||
|
|
"""备用 Provider 也失败 → 返回原始失败并注明主 Provider 错误。"""
|
|||
|
|
providers = {1: _provider_row(1, "主Provider", ["deepseek-chat"]),
|
|||
|
|
2: _provider_row(2, "备Provider", ["deepseek-chat"])}
|
|||
|
|
calls = {"n": 0}
|
|||
|
|
|
|||
|
|
def fake_create(**request):
|
|||
|
|
calls["n"] += 1
|
|||
|
|
if calls["n"] == 1:
|
|||
|
|
raise RuntimeError("Error code: 429 - rate limit exceeded")
|
|||
|
|
raise RuntimeError("Error code: 401 - Authentication Fails")
|
|||
|
|
|
|||
|
|
session = FakeSession(providers)
|
|||
|
|
with patch.object(llm_mod, "get_provider", side_effect=lambda s, pid: providers[pid]), \
|
|||
|
|
patch.object(llm_mod, "require_ai_points", return_value=None), \
|
|||
|
|
patch.object(llm_mod, "_log", return_value=True), \
|
|||
|
|
patch.object(llm_mod, "record_usage_event", return_value=SimpleNamespace(id=1)), \
|
|||
|
|
patch.object(llm_mod.OpenAI, "chat") as mock_chat:
|
|||
|
|
mock_chat.completions.create.side_effect = fake_create
|
|||
|
|
result = chat(session, [{"role": "user", "content": "测试"}], 1, "deepseek-chat",
|
|||
|
|
user_id=1, organization_id=1, max_retries=0)
|
|||
|
|
assert result["status"] == "failed"
|
|||
|
|
assert calls["n"] == 2 # 主 1 次 + 备 1 次(备的重试被 max_retries=0 关闭)
|