feat(aibox): chat_completion cross-profile failover via fallback chain (L4a)
When a profile declares "fallback": ["kimi", "local"], create_llm_client now returns a FailoverClient wrapper holding the ordered chain. For the idempotent non-streaming path (chat_completion — conversation compression, light subagents): - Fail over on transient errors only: HTTP 429/5xx (now raised as TransientLLMError by DeepSeekClient) and connection errors/timeouts (previously swallowed into None by chat_completion's catch-all, now re-raised). Auth/param 4xx failures keep returning None with no failover — config problems stay loud, consistent with the H1/H2 error-early philosophy. - Failed profiles enter a 60s in-process cooldown and are skipped until it expires; chain exhaustion returns None per the existing failure contract. - Config structure errors fail at construction (non-list fallback, unknown profile name, self-reference — H2-style). Environmental problems (e.g. fallback profile missing its API key) skip that fallback with a warning instead of blocking the primary. - Unspecified attributes delegate to the primary client via __getattr__ (name/model/api_key/cancel), so DeepSeekChat and set_client keep working unchanged. stream_chat still delegates to the primary directly; first-token failover is L4b. create_llm_client refactored: single-profile construction extracted into _construct_profile_client, shared by the main path and the fallback chain (flat-api path untouched). Tests: new TestL4aChatCompletionFailover (15 cases: chain build, validation, 429/5xx/connection failover, 401 no-failover, exhaustion, cooldown skip/expiry, missing-key skip with warning, transient raise, 4xx None regression, stream delegation); full suite 330 passed.
This commit is contained in:
1 parent
d29bb5504a
commit
3d7f5ca3cb
3 files changed
+341
-28
No files matched your search
+4
-2
@@ -92,7 +92,7 @@ aibox/
|
||||
│ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation
|
||||
│ ├── proxy_tools.py # B 批:SetProxy/GetProxy
|
||||
│ └── misc_tools.py # B 批:AskUserTool
|
||||
├── tests/ # 单元测试套件(315 项全过)
|
||||
├── tests/ # 单元测试套件(330 项全过)
|
||||
│ ├── conftest.py # sys.path 配置 + websockets 桩
|
||||
│ ├── test_basic_tools.py # A 批工具 43 项
|
||||
│ ├── test_b_tools.py # B 批工具 58 项
|
||||
@@ -553,6 +553,7 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
|
||||
| `timeout` | 请求超时秒数 |
|
||||
| `temperature` | 档案级默认采样温度(可选)。优先级:CLI/调用方显式传入 > 本字段 > 全局默认;reasoning 模型建议按厂商文档设置 |
|
||||
| `display_name` | 展示名(可选,缺省用档案名)。`client.name` 用于对话上下文中的 assistant 名,不再恒显示 "DeepSeek" |
|
||||
| `fallback` | 失败转移档案名列表(可选,如 `["kimi", "local"]`,一层不递归)。主档案瞬时故障(HTTP 429/5xx、连接失败/超时)时按序转移;认证/参数类 4xx **不转移**(配置问题响亮暴露);失败档案进入 60s 冷却期。当前仅 `chat_completion`(对话压缩/轻量子代理)生效,流式路径 L4b 支持 |
|
||||
|
||||
**选择优先级**:`--model <档案名>`(命中 `models` 节)→ `api.default_model` 指向档案 → 回退旧 `api` 扁平节。`--model` 为**进程级覆盖**:WebSocket 服务模式(主对话、每个会话实例、定时任务实例)与 `--once` 一次性模式均生效。
|
||||
|
||||
@@ -901,7 +902,8 @@ elif command == "/mycommand":
|
||||
- **L2(前缀逃逸)**:`--model model:NAME` 强制裸模型名语义、`--model profile:NAME` 强制档案语义(不存在时明确报错并列出可用档案),档案名与模型名歧义可逃逸
|
||||
- **L3(档案级温度)**:档案 `temperature` 字段作为默认采样温度,优先级:CLI/调用方显式传入 > 档案字段 > 全局默认(0.7/0.3),主对话/轻量子代理/compressor 全链路生效
|
||||
- **L5(思考流字段别名)**:`_parse_delta` 识别 `reasoning_content`/`reasoning`/`thinking`/`thinking_content` 四种常见思考字段(首个命中生效),其他厂商思考流不再静默丢失
|
||||
- ✅ **测试**:315 项全过(H1 隔离 4 + H2 校验 6 + H3 传导 6 + M2 提示 5 + M1 替换 2 + M3 契约 4 + L 批 16 项新增)
|
||||
- ✅ **L4a(chat_completion 跨档案失败转移)**:档案新字段 `fallback`(档案名列表,一层不递归);`create_llm_client` 构造 `FailoverClient` 包装链(主档案 + fallback)。瞬时故障(HTTP 429/5xx 抛 `TransientLLMError`、连接失败/超时)按链转移,认证/参数类 4xx 不转移(配置问题响亮暴露);失败档案 60s 冷却;fallback 档案缺 Key 等环境问题跳过 + warning 不阻断主档案;配置结构错误(类型/不存在/自引用)启动期硬性拦截。当前覆盖对话压缩与轻量子代理链路,流式(`stream_chat`)转移属 L4b
|
||||
- ✅ **测试**:330 项全过(H1 隔离 4 + H2 校验 6 + H3 传导 6 + M2 提示 5 + M1 替换 2 + M3 契约 4 + L 批 16 + L4a 转移 15 项新增)
|
||||
|
||||
### v3.3
|
||||
- ✅ **安全审计与修复**(两轮审计报告见 [docs/reports/aibox_audit_20260817.md](../docs/reports/aibox_audit_20260817.md)):
|
||||
|
||||
+152
-26
@@ -22,6 +22,7 @@ from typing import Dict, Iterator, List, Optional
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
|
||||
|
||||
@@ -151,8 +152,17 @@ class DeepSeekClient(LLMClient):
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
return result["choices"][0]["message"]["content"].strip()
|
||||
if response.status_code == 429 or response.status_code >= 500:
|
||||
# L4a:瞬时故障显式抛出——FailoverClient 据此转移下一档案;
|
||||
# 无包装器时由调用方 catch-all 接住,下游仍走失败分支(语义等价)
|
||||
raise TransientLLMError(
|
||||
f"LLM 请求瞬时失败: HTTP {response.status_code}: {response.text[:200]}")
|
||||
self._log("error", f"LLM 压缩请求失败: HTTP {response.status_code}")
|
||||
return None
|
||||
except TransientLLMError:
|
||||
raise # L4a:瞬时故障上抛供 FailoverClient 转移,不吞
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
||||
raise # L4a:连接类瞬时故障同样上抛(原被吞成 None,无法转移)
|
||||
except Exception as e:
|
||||
self._log("error", f"LLM 压缩请求异常: {e}")
|
||||
return None
|
||||
@@ -249,6 +259,86 @@ class DeepSeekClient(LLMClient):
|
||||
}
|
||||
|
||||
|
||||
class TransientLLMError(RuntimeError):
|
||||
"""瞬时 LLM 故障(HTTP 429/5xx)——FailoverClient 据此转移下一档案(L4a)。"""
|
||||
|
||||
|
||||
class FailoverClient:
|
||||
"""跨档案失败转移包装器(L4a)。
|
||||
|
||||
持有序 client 链(主档案 + fallback 档案),对外实现与 LLMClient 相同的
|
||||
消费面(未显式定义的属性经 __getattr__ 委托主 client)。
|
||||
|
||||
L4a 范围——chat_completion(非流式幂等)按链转移:
|
||||
- 转移:HTTP 429/5xx(TransientLLMError)、连接失败/超时(requests 连接类异常)
|
||||
- 不转移:认证/参数类 4xx(底层返回 None,直接透传——配置问题应响亮暴露)
|
||||
失败档案进入冷却期(cooldown_seconds,默认 60s),期间直接跳过,
|
||||
避免每次请求都撞刚失败的端点。链耗尽返回 None(与 chat_completion 失败契约一致)。
|
||||
|
||||
stream_chat 暂直接委托主 client(L4b 实现首 token 前转移)。
|
||||
"""
|
||||
|
||||
def __init__(self, clients: List, logger=None, cooldown_seconds: float = 60.0):
|
||||
self._clients = list(clients)
|
||||
self.logger = logger
|
||||
self.cooldown_seconds = cooldown_seconds
|
||||
self._fail_at = {} # client.name → 失败时刻(time.monotonic)
|
||||
|
||||
def __getattr__(self, item):
|
||||
# 未显式定义的属性/方法委托主 client(name/model/api_key/cancel 等);
|
||||
# 下划线属性直接报错,防 __init__ 前访问 self._clients 的无限递归
|
||||
if item.startswith("_"):
|
||||
raise AttributeError(item)
|
||||
return getattr(self._clients[0], item)
|
||||
|
||||
def _log(self, level: str, msg: str):
|
||||
if self.logger is not None and hasattr(self.logger, level):
|
||||
try:
|
||||
getattr(self.logger, level)(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _cooling(self, client) -> bool:
|
||||
t = self._fail_at.get(client.name)
|
||||
return t is not None and (time.monotonic() - t) < self.cooldown_seconds
|
||||
|
||||
def _mark_fail(self, client):
|
||||
self._fail_at[client.name] = time.monotonic()
|
||||
|
||||
def chat_completion(self, messages: List[Dict], temperature: float = None,
|
||||
max_tokens: int = 1024, **kwargs):
|
||||
last_exc = None
|
||||
tried = 0
|
||||
for client in self._clients:
|
||||
if self._cooling(client):
|
||||
self._log("info", f"模型档案 {client.name} 处于失败冷却期,跳过")
|
||||
continue
|
||||
tried += 1
|
||||
try:
|
||||
result = client.chat_completion(messages, temperature=temperature,
|
||||
max_tokens=max_tokens, **kwargs)
|
||||
if result is not None:
|
||||
return result
|
||||
# None:认证/参数类 4xx 或响应异常——不可转移,直接透传
|
||||
return None
|
||||
except (TransientLLMError, requests.exceptions.RequestException) as e:
|
||||
last_exc = e
|
||||
self._mark_fail(client)
|
||||
self._log("warning", f"模型档案 {client.name} 瞬时故障({e}),转移下一档案")
|
||||
self._log("error", f"全部 {len(self._clients)} 个模型档案均不可用(尝试 {tried} 个):{last_exc}")
|
||||
return None
|
||||
|
||||
def stream_chat(self, messages: List[Dict], temperature: float = None,
|
||||
max_tokens: int = 8192, tools: Optional[List[Dict]] = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
|
||||
# L4a:暂不转移(L4b 实现首 token 前转移),直接委托主 client
|
||||
yield from self._clients[0].stream_chat(
|
||||
messages, temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, tool_choice=tool_choice,
|
||||
reasoning_enabled=reasoning_enabled, **extra)
|
||||
|
||||
|
||||
def resolve_api_key(api_key: Optional[str], config=None,
|
||||
env_name: Optional[str] = None,
|
||||
allow_missing: bool = False,
|
||||
@@ -302,6 +392,12 @@ def _validate_profile(name: str, profile) -> None:
|
||||
raise ValueError(
|
||||
f"模型档案 '{name}' 缺少 api_url 字段,"
|
||||
f"请在 config.json 的 models.{name} 中补齐(OpenAI Chat Completions 端点 URL)")
|
||||
fb = profile.get("fallback")
|
||||
if fb is not None and (not isinstance(fb, list) or not all(isinstance(x, str) for x in fb)):
|
||||
raise ValueError(
|
||||
f"模型档案 '{name}' 的 fallback 字段无效:应为档案名字符串列表"
|
||||
f"(如 [\"kimi\", \"local\"]),实际为 {type(fb).__name__},"
|
||||
f"请检查 config.json 的 models.{name}")
|
||||
|
||||
|
||||
def _require_model(name: str, effective_model) -> None:
|
||||
@@ -367,6 +463,28 @@ def _resolve_model_profile(config, model: Optional[str]):
|
||||
return None, model, None
|
||||
|
||||
|
||||
def _construct_profile_client(config, profile: Dict, profile_name: str,
|
||||
effective_model: str, logger=None,
|
||||
api_key: Optional[str] = None) -> LLMClient:
|
||||
"""L4a:按档案构造单个 DeepSeekClient(主路径与 fallback 链复用,不处理 fallback 字段)。"""
|
||||
env_name = profile.get("api_key_env")
|
||||
allow_missing = (env_name == "") # 档案显式空串 → 本地免密端点
|
||||
resolved_key = resolve_api_key(api_key, config, env_name=env_name,
|
||||
allow_missing=allow_missing, allow_key_file=False)
|
||||
return DeepSeekClient(
|
||||
api_key=resolved_key,
|
||||
model=effective_model,
|
||||
base_url=profile.get("api_url"),
|
||||
timeout=profile.get("timeout") or config.get("api.timeout", 30),
|
||||
config=config,
|
||||
logger=logger,
|
||||
reasoning_enabled=profile.get("reasoning_enabled",
|
||||
config.get("api.reasoning_enabled", False)),
|
||||
display_name=profile.get("display_name") or profile_name,
|
||||
default_temperature=profile.get("temperature"),
|
||||
)
|
||||
|
||||
|
||||
def create_llm_client(config, api_key: Optional[str] = None,
|
||||
logger=None, model: Optional[str] = None) -> LLMClient:
|
||||
"""根据配置创建对应厂商的 LLM 客户端。
|
||||
@@ -388,26 +506,45 @@ def create_llm_client(config, api_key: Optional[str] = None,
|
||||
"""
|
||||
profile, model, profile_name = _resolve_model_profile(config, model)
|
||||
|
||||
env_name = None
|
||||
allow_missing = False
|
||||
if profile is not None:
|
||||
base_url = profile.get("api_url")
|
||||
timeout = profile.get("timeout") or config.get("api.timeout", 30)
|
||||
env_name = profile.get("api_key_env")
|
||||
if env_name == "":
|
||||
allow_missing = True # 档案显式空串 → 本地免密端点
|
||||
else:
|
||||
if profile is None:
|
||||
# 旧 api 扁平节(唯一允许回退 ~/.cert/deepseek.key 的路径,H1)
|
||||
provider = (config.get("api.provider", "deepseek") or "deepseek").lower()
|
||||
base_url = config.get("api.deepseek_api_url")
|
||||
model = model or config.get("api.deepseek_model", "deepseek-v4-flash")
|
||||
timeout = config.get("api.timeout", 30)
|
||||
|
||||
resolved_key = resolve_api_key(api_key, config, env_name=env_name,
|
||||
allow_missing=allow_missing,
|
||||
allow_key_file=profile is None)
|
||||
|
||||
if profile is not None:
|
||||
resolved_key = resolve_api_key(api_key, config, env_name=None,
|
||||
allow_missing=False, allow_key_file=True)
|
||||
else:
|
||||
provider = "openai_compatible"
|
||||
primary = _construct_profile_client(config, profile, profile_name,
|
||||
model, logger=logger, api_key=api_key)
|
||||
fallback_names = profile.get("fallback") or []
|
||||
if not fallback_names:
|
||||
return primary
|
||||
# L4a:构造 fallback 链(一层,不递归——fallback 档案自身的 fallback 字段被忽略)
|
||||
models = config.get("models") or {}
|
||||
chain = [primary]
|
||||
for fb_name in fallback_names:
|
||||
if fb_name == profile_name:
|
||||
raise ValueError(
|
||||
f"模型档案 '{profile_name}' 的 fallback 不能包含自身,"
|
||||
f"请检查 config.json 的 models.{profile_name}.fallback")
|
||||
fb_profile = models.get(fb_name)
|
||||
if fb_profile is None:
|
||||
raise ValueError(
|
||||
f"模型档案 '{profile_name}' 的 fallback 指向不存在的档案 '{fb_name}',"
|
||||
f"可用档案:{', '.join(models) or '(无)'}")
|
||||
try:
|
||||
chain.append(_construct_profile_client(
|
||||
config, fb_profile, fb_name, fb_profile.get("model"),
|
||||
logger=logger))
|
||||
except RuntimeError as e:
|
||||
# 环境问题(如 fallback 档案缺 API Key):跳过该 fallback 并告警,
|
||||
# 不阻断主档案可用性(配置结构错误在上方已硬性拦截)
|
||||
if logger is not None and hasattr(logger, "warning"):
|
||||
logger.warning(f"fallback 档案 '{fb_name}' 构造失败,已忽略:{e}")
|
||||
return FailoverClient(chain, logger=logger)
|
||||
|
||||
if provider in ("deepseek", "deepseek-chat", "openai_compatible", "openai"):
|
||||
client = DeepSeekClient(
|
||||
api_key=resolved_key,
|
||||
@@ -416,17 +553,6 @@ def create_llm_client(config, api_key: Optional[str] = None,
|
||||
timeout=timeout,
|
||||
config=config,
|
||||
logger=logger,
|
||||
# M3:档案级思考模式作为构造参数(None=未配置,消费方回退全局),
|
||||
# 不再构造后挂实例属性——契约显式化,杜绝外部误设属性静默覆盖全局
|
||||
reasoning_enabled=(profile.get("reasoning_enabled",
|
||||
config.get("api.reasoning_enabled", False))
|
||||
if profile is not None else None),
|
||||
# L1:展示名——档案 display_name > 档案名;旧扁平节 None → "DeepSeek"
|
||||
display_name=(profile.get("display_name") or profile_name
|
||||
if profile is not None else None),
|
||||
# L3:档案级默认采样温度(档案 temperature 字段,未配置为 None)
|
||||
default_temperature=(profile.get("temperature")
|
||||
if profile is not None else None),
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import types
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
# ---- 环境桩:保证 import aibox.py 不失败 ----
|
||||
if "websockets" not in sys.modules:
|
||||
@@ -684,6 +685,190 @@ class TestLBatch:
|
||||
assert events == [{"type": "content", "text": "hello"}]
|
||||
|
||||
|
||||
# ==================== L4a:chat_completion 跨档案失败转移 ====================
|
||||
|
||||
def _resp(status=200, text="", content="ok"):
|
||||
m = mock.MagicMock()
|
||||
m.status_code = status
|
||||
m.text = text
|
||||
m.json.return_value = {"choices": [{"message": {"content": content}}]}
|
||||
return m
|
||||
|
||||
|
||||
class _RecordingLogger:
|
||||
def __init__(self):
|
||||
self.records = []
|
||||
|
||||
def info(self, m): self.records.append(("info", m))
|
||||
def warning(self, m): self.records.append(("warning", m))
|
||||
def error(self, m): self.records.append(("error", m))
|
||||
|
||||
|
||||
class TestL4aChatCompletionFailover:
|
||||
"""L4a:档案带 fallback 时 chat_completion 在瞬时故障(429/5xx/连接异常)按链转移。
|
||||
|
||||
- 转移:TransientLLMError(429/5xx)与 requests 连接类异常
|
||||
- 不转移:4xx 认证/参数失败(底层返回 None 直接透传——配置问题响亮暴露)
|
||||
- 失败档案进入冷却期(60s 内跳过);链耗尽返回 None
|
||||
- fallback 档案构造失败(缺 Key 等环境问题)→ 跳过 + warning,不阻断主档案
|
||||
- stream_chat 转移属 L4b,本批仅验证委托主 client
|
||||
"""
|
||||
|
||||
FB_MODELS = None # 延迟构造(依赖 MODELS)
|
||||
|
||||
def _models(self):
|
||||
return {
|
||||
"deepseek": dict(MODELS["deepseek"], fallback=["kimi"]),
|
||||
"kimi": dict(MODELS["kimi"]),
|
||||
}
|
||||
|
||||
def _fc(self, models=None, env=None, logger=None):
|
||||
models = models or self._models()
|
||||
env = env or {"DEEPSEEK_API_KEY": "sk-ds", "MOONSHOT_API_KEY": "sk-moon"}
|
||||
with mock.patch.dict(os.environ, env):
|
||||
return create_llm_client(_cfg(models=models), logger=logger)
|
||||
|
||||
def test_no_fallback_returns_plain_client(self):
|
||||
"""无 fallback 字段 → 返回普通 DeepSeekClient(零破坏)"""
|
||||
from llm_client import DeepSeekClient, FailoverClient
|
||||
c = self._fc(models={"deepseek": dict(MODELS["deepseek"]), "kimi": dict(MODELS["kimi"])})
|
||||
assert isinstance(c, DeepSeekClient) and not isinstance(c, FailoverClient)
|
||||
|
||||
def test_fallback_returns_wrapper_with_chain(self):
|
||||
"""带 fallback → FailoverClient,链 = [主, fallback],属性委托主档案"""
|
||||
from llm_client import FailoverClient
|
||||
fc = self._fc()
|
||||
assert isinstance(fc, FailoverClient)
|
||||
assert [c.name for c in fc._clients] == ["deepseek", "kimi"]
|
||||
assert fc.model == "deepseek-v4-flash" # __getattr__ 委托主 client
|
||||
assert fc.api_key == "sk-ds"
|
||||
|
||||
def test_fallback_type_validated(self):
|
||||
"""fallback 非字符串列表 → ValueError(H2 风格源头校验)"""
|
||||
models = self._models()
|
||||
models["deepseek"] = dict(models["deepseek"], fallback="kimi")
|
||||
with pytest.raises(ValueError, match="fallback.*无效"):
|
||||
self._fc(models=models)
|
||||
|
||||
def test_fallback_unknown_profile_raises(self):
|
||||
"""fallback 指向不存在档案 → 报错并列出可用档案"""
|
||||
models = self._models()
|
||||
models["deepseek"] = dict(models["deepseek"], fallback=["nope"])
|
||||
with pytest.raises(ValueError, match="nope.*可用档案"):
|
||||
self._fc(models=models)
|
||||
|
||||
def test_fallback_self_reference_raises(self):
|
||||
"""fallback 包含自身 → 报错"""
|
||||
models = self._models()
|
||||
models["deepseek"] = dict(models["deepseek"], fallback=["deepseek"])
|
||||
with pytest.raises(ValueError, match="不能包含自身"):
|
||||
self._fc(models=models)
|
||||
|
||||
def test_failover_on_429(self):
|
||||
"""主档案 429 → 转移 kimi 成功;请求按链顺序发出"""
|
||||
fc = self._fc()
|
||||
with mock.patch("llm_client.requests.post",
|
||||
side_effect=[_resp(429, "rate limited"), _resp(200, content="from-kimi")]) as mp:
|
||||
result = fc.chat_completion([{"role": "user", "content": "hi"}])
|
||||
assert result == "from-kimi"
|
||||
urls = [c.args[0] for c in mp.call_args_list]
|
||||
assert urls[0].startswith("https://api.deepseek.com")
|
||||
assert urls[1].startswith("https://api.moonshot.cn")
|
||||
|
||||
def test_failover_on_5xx(self):
|
||||
"""主档案 503 → 转移成功"""
|
||||
fc = self._fc()
|
||||
with mock.patch("llm_client.requests.post",
|
||||
side_effect=[_resp(503, "unavailable"), _resp(200, content="ok5")]):
|
||||
assert fc.chat_completion([{"role": "user", "content": "hi"}]) == "ok5"
|
||||
|
||||
def test_failover_on_connection_error(self):
|
||||
"""主档案连接失败 → 转移成功"""
|
||||
fc = self._fc()
|
||||
with mock.patch("llm_client.requests.post",
|
||||
side_effect=[requests.exceptions.ConnectionError("boom"),
|
||||
_resp(200, content="ok-conn")]):
|
||||
assert fc.chat_completion([{"role": "user", "content": "hi"}]) == "ok-conn"
|
||||
|
||||
def test_no_failover_on_auth_failure(self):
|
||||
"""主档案 401(不可转移失败)→ 直接 None,不调 fallback"""
|
||||
fc = self._fc()
|
||||
with mock.patch("llm_client.requests.post",
|
||||
return_value=_resp(401, "unauthorized")) as mp:
|
||||
assert fc.chat_completion([{"role": "user", "content": "hi"}]) is None
|
||||
assert len(mp.call_args_list) == 1 # 只打了主档案
|
||||
|
||||
def test_chain_exhausted_returns_none(self):
|
||||
"""全链瞬时失败 → None(与 chat_completion 失败契约一致)"""
|
||||
fc = self._fc()
|
||||
with mock.patch("llm_client.requests.post",
|
||||
side_effect=[_resp(429, "x"), _resp(500, "y")]) as mp:
|
||||
assert fc.chat_completion([{"role": "user", "content": "hi"}]) is None
|
||||
assert len(mp.call_args_list) == 2
|
||||
|
||||
def test_cooldown_skips_recently_failed(self):
|
||||
"""主档案失败后进入冷却:下次调用直接走 fallback;冷却过期后恢复主档案"""
|
||||
import time as _time
|
||||
fc = self._fc()
|
||||
with mock.patch("llm_client.requests.post",
|
||||
side_effect=[_resp(429, "x"), _resp(200, content="r1")]):
|
||||
assert fc.chat_completion([{"role": "user", "content": "q1"}]) == "r1"
|
||||
# 冷却中:第一次请求即打 kimi(deepseek 被跳过)
|
||||
with mock.patch("llm_client.requests.post",
|
||||
return_value=_resp(200, content="r2")) as mp:
|
||||
assert fc.chat_completion([{"role": "user", "content": "q2"}]) == "r2"
|
||||
assert mp.call_args_list[0].args[0].startswith("https://api.moonshot.cn")
|
||||
# 冷却过期(手动回拨失败时间戳):恢复走主档案
|
||||
fc._fail_at["deepseek"] = _time.monotonic() - fc.cooldown_seconds - 1
|
||||
with mock.patch("llm_client.requests.post",
|
||||
return_value=_resp(200, content="r3")) as mp:
|
||||
assert fc.chat_completion([{"role": "user", "content": "q3"}]) == "r3"
|
||||
assert mp.call_args_list[0].args[0].startswith("https://api.deepseek.com")
|
||||
|
||||
def test_missing_key_fallback_skipped(self):
|
||||
"""fallback 档案缺 API Key(环境问题)→ 跳过 + warning,主档案仍可用"""
|
||||
logger = _RecordingLogger()
|
||||
fc = self._fc(env={"DEEPSEEK_API_KEY": "sk-ds"}, logger=logger) # 未设 MOONSHOT_API_KEY
|
||||
from llm_client import FailoverClient
|
||||
assert isinstance(fc, FailoverClient)
|
||||
assert len(fc._clients) == 1 # kimi 被跳过
|
||||
assert any("kimi" in m for lvl, m in logger.records if lvl == "warning")
|
||||
with mock.patch("llm_client.requests.post",
|
||||
return_value=_resp(200, content="solo")):
|
||||
assert fc.chat_completion([{"role": "user", "content": "hi"}]) == "solo"
|
||||
|
||||
def test_transient_429_5xx_raise(self):
|
||||
"""裸 DeepSeekClient:429/502 → TransientLLMError 上抛(不被自身 catch 吞掉)"""
|
||||
from llm_client import DeepSeekClient, TransientLLMError
|
||||
c = DeepSeekClient(api_key="k", model="m", base_url="https://x")
|
||||
for status in (429, 502):
|
||||
with mock.patch("llm_client.requests.post", return_value=_resp(status, "err")):
|
||||
with pytest.raises(TransientLLMError):
|
||||
c.chat_completion([{"role": "user", "content": "hi"}])
|
||||
|
||||
def test_auth_4xx_returns_none_no_raise(self):
|
||||
"""裸 DeepSeekClient:401/400 → 返回 None 不抛(回归,响亮暴露由消费方处理)"""
|
||||
from llm_client import DeepSeekClient, TransientLLMError
|
||||
c = DeepSeekClient(api_key="k", model="m", base_url="https://x")
|
||||
for status in (401, 400):
|
||||
with mock.patch("llm_client.requests.post", return_value=_resp(status, "err")):
|
||||
assert c.chat_completion([{"role": "user", "content": "hi"}]) is None
|
||||
|
||||
def test_stream_chat_delegates_to_primary(self):
|
||||
"""L4a:stream_chat 直接委托主 client(转移属 L4b)"""
|
||||
from llm_client import FailoverClient
|
||||
primary = mock.MagicMock()
|
||||
primary.name = "deepseek"
|
||||
primary.stream_chat = mock.MagicMock(
|
||||
return_value=iter([{"type": "content", "text": "x"}, {"type": "done"}]))
|
||||
fc = FailoverClient([primary])
|
||||
events = list(fc.stream_chat([{"role": "user", "content": "hi"}],
|
||||
temperature=0.5, max_tokens=99))
|
||||
assert events == [{"type": "content", "text": "x"}, {"type": "done"}]
|
||||
kwargs = primary.stream_chat.call_args.kwargs
|
||||
assert kwargs["temperature"] == 0.5 and kwargs["max_tokens"] == 99
|
||||
|
||||
|
||||
# ==================== DeepSeekChat 档案级 reasoning 读取 ====================
|
||||
|
||||
class TestChatReasoningFlag:
|
||||
|
||||
Reference in new issue
Block a user