"""五维评分选模与拒绝理由留痕(T-X13,采纳 ai-model-router scoring 设计)。""" import gateway.proxy.routes as R from gateway.proxy.routes import (_entry_reject_reason, _rank_candidates, _record_route_score_event, reset_route_score_events) def _entry(model, tier="budget", price_in=1.0, price_out=2.0, vision=True, tools=True, ctx=0): return {"id": model, "model": model, "enabled": True, "backend": "openai", "base_url": "http://127.0.0.1:9/v1", "tier": tier, "price_in": price_in, "price_out": price_out, "capabilities": {"vision": vision, "tools": tools, "context_window": ctx}} _NEEDS = {"vision": True, "tools": False, "min_context_tokens": 1000} def test_reject_reasons_are_human_readable(): """vision/tools/上下文三类不满足均有对应人话理由;满足则 None。""" assert "vision" in (_entry_reject_reason(_entry("a", vision=False), _NEEDS) or "") need_tools = {"vision": False, "tools": True, "min_context_tokens": 1000} assert "tools" in (_entry_reject_reason(_entry("a", tools=False), need_tools) or "") small = _entry("a", ctx=512) assert "512" in (_entry_reject_reason(small, _NEEDS) or "") assert _entry_reject_reason(_entry("a", ctx=8192), _NEEDS) is None def test_rank_prefers_capable_and_cheap(): """不满足需求者 capability=0 沉底;便宜者靠成本效率维胜出。""" good_cheap = _entry("cheap", tier="budget", price_in=0.1, price_out=0.2, ctx=8192) good_pricey = _entry("pricey", tier="premium", price_in=8.0, price_out=8.0, ctx=8192) ranked = _rank_candidates([good_pricey, good_cheap], _NEEDS) assert ranked[0]["model"] == "cheap" assert ranked[0]["dims"]["capability"] > 0 assert ranked[0]["dims"]["cost_efficiency"] > ranked[1]["dims"]["cost_efficiency"] # premium 的质量维更高(启发式) assert ranked[1]["dims"]["quality"] > ranked[0]["dims"]["quality"] def test_rank_tie_break_deterministic(): """同配置同分按模型名字典序。""" a, b = _entry("zeta"), _entry("alpha") ranked = _rank_candidates([a, b], _NEEDS) assert [s["model"] for s in ranked] == ["alpha", "zeta"] def test_score_event_records_rejections(): """评分留痕:入选排序 + 被淘汰候选的拒绝理由;reset 清空。""" reset_route_score_events() ranked = _rank_candidates([_entry("good", ctx=8192)], _NEEDS) rejected = [{"model": "bad", "reason": "模型不支持视觉输入(vision)"}] _record_route_score_event("px-test", ranked, rejected) _record_route_score_event("px-test-2", ranked, rejected) assert len(R._route_score_events) == 2 ev = R._route_score_events[-1] assert ev["request_id"] == "px-test-2" assert ev["ranked"][0]["model"] == "good" assert ev["rejected"][0]["reason"].startswith("模型不支持") reset_route_score_events() assert len(R._route_score_events) == 0