"""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 关闭)