"""模型池能力位过滤测试(T-X6):归一化缺省 / 硬过滤 / 请求需求推断。""" import pytest from gateway.model_pool import PoolStore, filter_by_capabilities from gateway.proxy.routes import _entry_meets, _request_needs pytest.importorskip("fastapi") def _make_pool(tmp_path) -> PoolStore: store = PoolStore(path=tmp_path / "pool.json") store.upsert({"id": "text-only", "name": "纯文本", "tier": "budget", "backend": "openai", "base_url": "http://a", "model": "text-m", "capabilities": {"vision": False, "tools": True, "context_window": 8192}, "enabled": True}) store.upsert({"id": "vision", "name": "多模态", "tier": "premium", "backend": "openai", "base_url": "http://b", "model": "vision-m", "capabilities": {"vision": True, "tools": True, "context_window": 32768}, "enabled": True}) store.upsert({"id": "legacy", "name": "老条目", "tier": "local", "backend": "llama_server", "base_url": "http://c", "model": "legacy-m", "enabled": True}) # 无 capabilities 字段 return store def test_capabilities_default_full_compat(tmp_path): """未声明能力位的老条目:缺省全兼容(vision/tools True、context 0 不限)。""" pool = _make_pool(tmp_path) e = pool.find_by_model("legacy-m") assert e["capabilities"] == {"vision": True, "tools": True, "context_window": 0} def test_capabilities_invalid_shapes_fall_back(tmp_path): pool = _make_pool(tmp_path) pool.upsert({"id": "weird", "name": "怪", "tier": "budget", "backend": "openai", "base_url": "http://d", "model": "weird-m", "capabilities": {"vision": "是", "context_window": "abc"}, "enabled": True}) cap = pool.find_by_model("weird-m")["capabilities"] assert cap["vision"] is True # 非布尔按缺省 assert cap["context_window"] == 0 # 非整数回落不限 def test_filter_by_capabilities_hard_rules(): entries = [{"capabilities": {"vision": False, "tools": True, "context_window": 8192}}, {"capabilities": {"vision": True, "tools": True, "context_window": 32768}}, {"capabilities": {"vision": True, "tools": False, "context_window": 0}}] out = filter_by_capabilities(entries, need_vision=True) assert len(out) == 2 out = filter_by_capabilities(entries, need_vision=True, need_tools=True) assert len(out) == 1 out = filter_by_capabilities(entries, need_vision=True, need_tools=True, min_context_tokens=16384) assert len(out) == 1 and out[0]["capabilities"]["context_window"] == 32768 # context_window=0(不限)不受 min_context 约束 out = filter_by_capabilities(entries, min_context_tokens=999999) assert len(out) == 1 and out[0]["capabilities"]["context_window"] == 0 def test_request_needs_detection(): body = {"messages": [ {"role": "user", "content": "普通文本问题"}, {"role": "user", "content": [{"type": "text", "text": "看这张图"}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,x"}}]}, ], "tools": [{"type": "function", "function": {"name": "f"}}], "max_tokens": 3000} needs = _request_needs(body) assert needs["vision"] is True and needs["tools"] is True assert needs["min_context_tokens"] > 3000 plain = _request_needs({"messages": [{"role": "user", "content": "hi"}]}) assert plain["vision"] is False and plain["tools"] is False assert plain["min_context_tokens"] > 0 def test_entry_meets_defaults_and_limits(): legacy = {"capabilities": {"vision": True, "tools": True, "context_window": 0}} assert _entry_meets(legacy, {"vision": True, "tools": True, "min_context_tokens": 999999}) limited = {"capabilities": {"vision": True, "tools": True, "context_window": 4096}} assert _entry_meets(limited, {"vision": False, "tools": False, "min_context_tokens": 4096}) assert not _entry_meets(limited, {"vision": False, "tools": False, "min_context_tokens": 4097}) def test_pool_list_returns_capabilities(tmp_path): """list() 打码输出保留 capabilities 字段(前端可见)。""" pool = _make_pool(tmp_path) e = next(x for x in pool.list()["entries"] if x["id"] == "vision") assert e["capabilities"]["vision"] is True