痛点:很多模型都是 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,配置结构与内容完好。
298 lines
11 KiB
Python
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
|