Files
xiaoli-cli/tests/test_startup_and_fc.py
T
shuiyu67 1383d32f8b feat: OpenAI 多模型注册表 + /model 命令,告别手改配置文件
痛点:很多模型都是 OpenAI 格式,但换模型只能手改 config.json 的单槽位,
且只能存一个。新增模型注册表与 /model 命令族:

- 新增 xcli_core/model_registry.py:在 api.engines.openai 下维护
  models 列表(name + base_url + api_key + model)与 current 指针,全部落盘。
  向后兼容:无 models 时把扁平 api_key/base_url/model 当作单条隐式模型。
- OpenaiAI 接入:__init__ 应用注册表 current 模型;新增
  set_registry_model / list_registry_models / add_model / remove_model /
  current_model_name。注意保留引擎原有的轻量 set_model(只改 model 名)
  与 list_models(查 API /models 端点),新方法与它们不重名、互不干扰。
- 新增 /model 命令族(仅 openai 引擎):
    /model            列出已配置模型(* 标记当前)
    /model <名称>     切换当前模型并持久化
    /model add        交互式录入(名称/base_url/api_key/model),回车即存并切换
    /model rm <名称>  删除(删当前则回退到列表第一个)
- 启动横幅引擎名后显示当前模型;紧凑提示与 /help 速查表均补 /model 入口。
- 测试:新增 tests/test_model_registry.py(13 项,覆盖兼容/增删/切换/落盘/
  引擎接入/CLI 路由,全程临时配置不污染真实 config.json)。
  全量 230 passed(原 217 passed)。

交互实测:/model add → 已添加并切换到 gpt4o;/model gpt4o → 已切换;
/model rm gpt4o → 回退到 xophunyuan7bmt,配置结构与内容完好。
2026-08-05 16:12:35 +08:00

298 lines
11 KiB
Python

"""启动输出瘦身 + FC 能力探测缓存 回归测试
覆盖两件事:
1. 无 FC 能力的模型(如讯飞 xophunyuan7bmt)被 500 打回后会被记住,
之后不再每次白撞一发 500 试探。
2. 启动输出从 117 行折叠到 10 行,详细日志改由 --verbose 召唤。
"""
import contextlib
import io
import json
import os
import sys
import types
import pytest
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
BASE_URL = "https://maas-api.cn-huabei-1.xf-yun.com/v2"
MODEL = "xophunyuan7bmt"
TOOL = {
"type": "function",
"function": {
"name": "read_file",
"description": "读文件",
"parameters": {"type": "object", "properties": {}},
},
}
OK_BODY = {"choices": [{"message": {"content": "好"}}], "usage": {"prompt_tokens": 12}}
ERR_500 = {"error": {"message": "xunfei response error: sid: xxx msg: RequestParamsError:Invalid Params"}}
@pytest.fixture
def fc(tmp_path):
"""把 FC 缓存文件隔离到临时目录,避免污染真实运行时缓存"""
from xcli_core import fc_tools
old_file, old_mem = fc_tools._FC_CACHE_FILE, fc_tools._fc_cache
fc_tools._FC_CACHE_FILE = str(tmp_path / ".fc_support.json")
fc_tools._fc_cache = None
try:
yield fc_tools
finally:
fc_tools._FC_CACHE_FILE, fc_tools._fc_cache = old_file, old_mem
class FakeResp:
def __init__(self, code, payload):
self.status_code = code
self._payload = payload
self.text = json.dumps(payload, ensure_ascii=False)
def json(self):
return self._payload
def make_engine(model=MODEL):
"""不走 __init__(会读配置/连网),直接摆一个可用实例"""
import ai_engines.openai_engine as oe
eng = oe.OpenaiAI.__new__(oe.OpenaiAI)
eng.name = "openai"
eng.cli = None
eng.api_key = "k"
eng.base_url = BASE_URL
eng.model = model
eng.max_history = 10
eng.conversation_history = []
eng.shared_conversation_history = None
eng.last_prompt_tokens = None
eng.thinking_start_marker = "<think>"
eng.thinking_end_marker = "</think>"
return eng
@pytest.fixture
def http(monkeypatch):
"""替换 openai_engine 里的 requests,记录每次请求体"""
import ai_engines.openai_engine as oe
calls = []
def install(responder):
def fake_post(url=None, json=None, headers=None, timeout=None, **kw):
calls.append(dict(json))
return responder(json)
monkeypatch.setattr(
oe, "requests",
types.SimpleNamespace(post=fake_post, exceptions=oe.requests.exceptions),
)
return calls
install.calls = calls
return install
# ══════════════════════════════════════
# 1. FC 能力探测缓存
# ══════════════════════════════════════
class TestFCCache:
def test_default_assume_supported(self, fc):
assert fc.supports_fc(BASE_URL, MODEL) is True
def test_mark_and_remember(self, fc):
assert fc.mark_fc_unsupported(BASE_URL, MODEL) is True
assert fc.supports_fc(BASE_URL, MODEL) is False
def test_repeat_mark_not_reported(self, fc):
fc.mark_fc_unsupported(BASE_URL, MODEL)
# 第二次返回 False,调用方据此不重复刷屏提示
assert fc.mark_fc_unsupported(BASE_URL, MODEL) is False
def test_persist_across_restart(self, fc):
fc.mark_fc_unsupported(BASE_URL, MODEL)
assert os.path.exists(fc._FC_CACHE_FILE)
fc._fc_cache = None # 模拟进程重启
assert fc.supports_fc(BASE_URL, MODEL) is False
def test_other_model_unaffected(self, fc):
fc.mark_fc_unsupported(BASE_URL, MODEL)
assert fc.supports_fc(BASE_URL, "spark-4.0-ultra") is True
def test_trailing_slash_normalized(self, fc):
fc.mark_fc_unsupported(BASE_URL, MODEL)
assert fc.supports_fc(BASE_URL + "/", MODEL) is False
def test_get_cache_returns_copy(self, fc):
fc.mark_fc_unsupported(BASE_URL, MODEL)
snap = fc.get_fc_cache()
assert isinstance(snap, dict) and snap is not fc._fc_cache
def test_reset(self, fc):
fc.mark_fc_unsupported(BASE_URL, MODEL)
assert fc.reset_fc_cache() == 1
assert fc.supports_fc(BASE_URL, MODEL) is True
def test_reset_single_entry(self, fc):
fc.mark_fc_unsupported(BASE_URL, MODEL)
fc.mark_fc_unsupported(BASE_URL, "other")
assert fc.reset_fc_cache(BASE_URL, MODEL) == 1
assert fc.supports_fc(BASE_URL, MODEL) is True
assert fc.supports_fc(BASE_URL, "other") is False
def test_corrupted_file_does_not_crash(self, fc):
with open(fc._FC_CACHE_FILE, "w", encoding="utf-8") as f:
f.write("{ not json")
fc._fc_cache = None
assert fc.supports_fc(BASE_URL, MODEL) is True
# ══════════════════════════════════════
# 2. openai 引擎接线(假 HTTP,不动真实接口)
# ══════════════════════════════════════
class TestOpenAIDegrade:
def test_first_call_degrades_and_caches(self, fc, http):
calls = http(lambda body: FakeResp(500, ERR_500) if "tools" in body else FakeResp(200, OK_BODY))
reply = make_engine().generate_response("说'好'", tools=[TOOL])
assert len(calls) == 2, f"应为 带tools→500 + 降级重试,实际 {len(calls)} 次"
assert "tools" in calls[0]
assert "tools" not in calls[1] and "tool_choice" not in calls[1]
assert reply == "好"
assert fc.supports_fc(BASE_URL, MODEL) is False, "降级成功后应记住该模型不支持 FC"
def test_second_call_skips_probe(self, fc, http):
fc.mark_fc_unsupported(BASE_URL, MODEL)
calls = http(lambda body: FakeResp(500, ERR_500) if "tools" in body else FakeResp(200, OK_BODY))
reply = make_engine().generate_response("再说一次", tools=[TOOL])
assert len(calls) == 1, f"已知不支持 FC 时不该再撞 500,实际 {len(calls)} 次"
assert "tools" not in calls[0]
assert reply == "好"
def test_unmarked_model_still_sends_tools(self, fc, http):
calls = http(lambda body: FakeResp(200, OK_BODY))
make_engine(model="gpt-4o").generate_response("hi", tools=[TOOL])
assert len(calls) == 1 and "tools" in calls[0]
def test_tools_payload_is_pure_official_format(self, fc, http):
calls = http(lambda body: FakeResp(200, OK_BODY))
make_engine(model="gpt-4o").generate_response("hi", tools=[TOOL])
tool = calls[0]["tools"][0]
assert tool.get("type") == "function"
assert set(tool.keys()) == {"type", "function"}
assert calls[0].get("tool_choice") == "auto"
def test_failed_degrade_does_not_cache(self, fc, http):
http(lambda body: FakeResp(500, ERR_500))
reply = make_engine().generate_response("hi", tools=[TOOL])
assert fc.supports_fc(BASE_URL, MODEL) is True, "降级也失败说明不是 FC 的锅,不能误判"
assert "API 调用失败" in str(reply)
# ══════════════════════════════════════
# 3. 启动输出瘦身
# ══════════════════════════════════════
def _read(*parts):
with open(os.path.join(PROJECT_ROOT, *parts), encoding="utf-8") as f:
return f.read()
class TestStartupSlim:
def test_no_21_line_help_dump(self):
src = _read("xcli_core", "cli_core.py")
run_body = src.split(" def run(self):", 1)[1].split(" def _get_code_editor_plugin", 1)[0]
assert run_body.count("输入 '/") == 0, "启动不该再逐条打印命令说明"
assert "/help 全部命令" in run_body
def test_logo_is_opt_in(self):
src = _read("xcli_core", "cli_core.py")
assert "'--logo' in sys.argv" in src
assert "--banner" in src
def test_quick_commands_moved_into_help(self):
from xcli_core import cli_display
names = [n for n in dir(cli_display) if n.endswith("Mixin")]
assert names, "cli_display 里应有 Mixin 类"
cls = getattr(cli_display, names[0])
assert hasattr(cls, "show_quick_commands")
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
cls.show_quick_commands(cls.__new__(cls))
out = buf.getvalue()
assert "/fc" in out and "/diff" in out, "速查表要覆盖新命令"
assert 18 <= len(out.strip().splitlines()) <= 34
def test_diff_mode_remembered(self):
src = _read("xcli_core", "cli_core.py")
assert "set_system_config('diff_popup_mode'" in src
assert "if saved is not None:" in src, "存过就不该再问第二次"
def test_new_commands_registered(self):
src = _read("xcli_core", "cli_core.py")
assert "user_input.startswith('/diff')" in src
assert "user_input.startswith('/fc')" in src
# ══════════════════════════════════════
# 4. 启动噪音折叠到 --verbose
# ══════════════════════════════════════
class TestVerboseGate:
def test_loading_chatter_is_gated(self):
base_src = _read("xcli_core", "cli_base.py")
utm_src = _read("unified_tool_manager.py")
assert 'vprint(f"{Fore.GREEN}已注册插件命令' in base_src
assert 'vprint(f"{Fore.GREEN}已加载AI引擎' in base_src
assert 'vprint(f"{Fore.GREEN}已加载 Skill' in utm_src
assert "已加载 {len(self._tools)} 个工具" in utm_src, "工具加载应汇总成一行"
@pytest.mark.parametrize("argv,env,expected", [
(["ai_cli.py"], None, False),
(["ai_cli.py", "--verbose"], None, True),
(["ai_cli.py", "-v"], None, True),
(["ai_cli.py"], "1", True),
(["ai_cli.py"], "0", False),
])
def test_is_verbose(self, monkeypatch, argv, env, expected):
from xcli_core import verbose as vb
monkeypatch.setattr(sys, "argv", argv)
if env is None:
monkeypatch.delenv("XCLI_VERBOSE", raising=False)
else:
monkeypatch.setenv("XCLI_VERBOSE", env)
assert vb.is_verbose() is expected
def test_vprint_silent_by_default(self, monkeypatch, capsys):
from xcli_core import verbose as vb
monkeypatch.setattr(sys, "argv", ["ai_cli.py"])
monkeypatch.delenv("XCLI_VERBOSE", raising=False)
vb.vprint("should not appear")
assert capsys.readouterr().out == ""
monkeypatch.setattr(sys, "argv", ["ai_cli.py", "-v"])
vb.vprint("should appear")
assert "should appear" in capsys.readouterr().out