feat(proxy): T-X6 模型池能力位过滤——vision/tools/context 硬过滤(采纳 cortiq capabilities)

- model_pool:条目新增 capabilities{vision,tools,context_window},
  归一化缺省全兼容(老条目行为零变化;非法形态回落缺省);
  filter_by_capabilities 硬过滤(context_window=0 视为不限)
- proxy routes:_request_needs 从请求体推断需求(多模态 image_url → vision、
  tools 非空 → tools、上下文需求 = 字符/3 + min(max_tokens,4096) 与预扣同口径);
  条目不满足时在池内重定向到首个合格条目
- 与 T-X1/T-X2 组合语义:能力重定向 → 预算降档 → 降级链,逐级独立不互扰

pytest 461 passed(T-X3 后 455 + 6)
This commit is contained in:
tzt
2026-09-18 22:45:50 +08:00
parent 27a092a3f8
commit 5078721df4
4 changed files with 189 additions and 1 deletions
+44 -1
View File
@@ -13,7 +13,7 @@ import json
import re import re
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
_POOL_PATH = Path(__file__).resolve().parent.parent / "config" / "model_pool.json" _POOL_PATH = Path(__file__).resolve().parent.parent / "config" / "model_pool.json"
@@ -28,6 +28,7 @@ ENTRY_FIELDS = {
"id", "name", "tier", "backend", "base_url", "model", "api_key", "id", "name", "tier", "backend", "base_url", "model", "api_key",
"price_in", "price_out", "temperature", "max_tokens", "enabled", "price_in", "price_out", "temperature", "max_tokens", "enabled",
"provider", "in_hit_price", # 代理层扩展(D-P3):usage 归一化 / 按命中价选上游 "provider", "in_hit_price", # 代理层扩展(D-P3):usage 归一化 / 按命中价选上游
"capabilities", # 能力位(T-X6):vision/tools/context_window
} }
# 单价默认值($/1M tokens);local 档为 0 # 单价默认值($/1M tokens);local 档为 0
@@ -41,6 +42,45 @@ def _empty_pool() -> Dict[str, Any]:
} }
def _normalize_capabilities(raw: Any) -> Dict[str, Any]:
"""能力位归一(T-X6):缺省全兼容。
- vision/tools:缺省 True(未声明 = 不设限,老条目行为不变);
- context_window:缺省 0 = 不按上下文长度过滤。
"""
if not isinstance(raw, dict):
return {"vision": True, "tools": True, "context_window": 0}
try:
ctx = int(raw.get("context_window", 0) or 0)
except (TypeError, ValueError):
ctx = 0
return {"vision": bool(raw.get("vision", True)),
"tools": bool(raw.get("tools", True)),
"context_window": max(0, ctx)}
def filter_by_capabilities(entries: List[Dict[str, Any]], *,
need_vision: bool = False, need_tools: bool = False,
min_context_tokens: int = 0) -> List[Dict[str, Any]]:
"""能力位硬过滤(T-X6,采纳 cortiq capabilities 过滤)。
依次校验 vision/tools 声明位与 context_window >= 提示+输出预估
context_window=0 视为不限)。返回保持原顺序的合格条目子集。
"""
out: List[Dict[str, Any]] = []
for e in entries:
cap = e.get("capabilities") or _normalize_capabilities(None)
if need_vision and not cap.get("vision", True):
continue
if need_tools and not cap.get("tools", True):
continue
ctx = int(cap.get("context_window") or 0)
if min_context_tokens > 0 and 0 < ctx < min_context_tokens:
continue
out.append(e)
return out
class PoolError(ValueError): class PoolError(ValueError):
"""池条目/角色配置非法。""" """池条目/角色配置非法。"""
@@ -203,6 +243,7 @@ class PoolStore:
max_tokens = int(entry.get("max_tokens", 4096)) max_tokens = int(entry.get("max_tokens", 4096))
except (TypeError, ValueError): except (TypeError, ValueError):
max_tokens = 4096 max_tokens = 4096
capabilities = _normalize_capabilities(entry.get("capabilities"))
return { return {
"id": eid, "id": eid,
"name": str(entry.get("name") or entry.get("model") or eid), "name": str(entry.get("name") or entry.get("model") or eid),
@@ -220,6 +261,8 @@ class PoolStore:
"provider": (str(entry["provider"]) if entry.get("provider") else "openai"), "provider": (str(entry["provider"]) if entry.get("provider") else "openai"),
"in_hit_price": (float(entry["in_hit_price"]) if entry.get("in_hit_price") is not None "in_hit_price": (float(entry["in_hit_price"]) if entry.get("in_hit_price") is not None
else round(price_in / 30.0, 6)), else round(price_in / 30.0, 6)),
# 能力位(T-X6):缺省全兼容(不破坏既有条目/选型行为)
"capabilities": capabilities,
} }
@staticmethod @staticmethod
+50
View File
@@ -274,6 +274,41 @@ def _budget_headers(budget_mode: str) -> Dict[str, str]:
return {"X-Budget-Mode": budget_mode} if budget_mode and budget_mode != "normal" else {} return {"X-Budget-Mode": budget_mode} if budget_mode and budget_mode != "normal" else {}
def _request_needs(body: dict) -> Dict[str, Any]:
"""从请求体推断能力需求(T-X6):多模态图片 -> vision;带 tools -> tools
上下文需求 = 提示字符/3 + min(max_tokens, 4096)(与预扣估算同口径)。"""
vision = False
for m in body.get("messages") or []:
content = m.get("content")
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and ("image_url" in part
or part.get("type") == "image_url"):
vision = True
prompt_chars = sum(len(str(m.get("content") or ""))
if not isinstance(m.get("content"), list)
else sum(len(str(p.get("text") or "")) for p in m["content"]
if isinstance(p, dict))
for m in (body.get("messages") or []))
est_out = min(int(body.get("max_tokens") or 1024), 4096)
return {"vision": vision,
"tools": bool(body.get("tools")),
"min_context_tokens": prompt_chars // 3 + est_out}
def _entry_meets(entry: Dict[str, Any], needs: Dict[str, Any]) -> bool:
"""条目能力位是否满足请求需求(capabilities 缺省全兼容)。"""
cap = entry.get("capabilities") or {}
if needs["vision"] and not cap.get("vision", True):
return False
if needs["tools"] and not cap.get("tools", True):
return False
ctx = int(cap.get("context_window") or 0)
if needs["min_context_tokens"] > 0 and 0 < ctx < needs["min_context_tokens"]:
return False
return True
def _fallback_chain(pool, entry: Dict[str, Any], max_total: int = 3) -> List[Dict[str, Any]]: def _fallback_chain(pool, entry: Dict[str, Any], max_total: int = 3) -> List[Dict[str, Any]]:
"""上游有序降级链(T-X2,采纳 cortiq tier 链思路)。 """上游有序降级链(T-X2,采纳 cortiq tier 链思路)。
@@ -367,6 +402,21 @@ async def _run_chat(body: dict, headers: Dict[str, str], ctx: Dict[str, Any],
if isinstance(body.get("stream_options"), dict) else False if isinstance(body.get("stream_options"), dict) else False
is_stream = bool(body.get("stream")) is_stream = bool(body.get("stream"))
# ---- 能力位硬过滤(T-X6):请求需要 vision/tools/上下文而条目不满足时重定向 ----
needs = _request_needs(body)
if not _entry_meets(entry, needs):
from gateway.model_pool import filter_by_capabilities
cap_entries = filter_by_capabilities(
[e for e in pool.list().get("entries", [])
if e.get("enabled") and e.get("backend") not in ("mock",)
and e.get("base_url")],
need_vision=needs["vision"], need_tools=needs["tools"],
min_context_tokens=needs["min_context_tokens"])
if cap_entries:
entry = cap_entries[0]
model = str(entry.get("model") or model)
body = {**body, "model": model}
# ---- 预算四档(T-X1):接近日上限渐进降档;>100% 仍由 try_hold 硬拒 ---- # ---- 预算四档(T-X1):接近日上限渐进降档;>100% 仍由 try_hold 硬拒 ----
budget_mode = "normal" budget_mode = "normal"
try: try:
+94
View File
@@ -0,0 +1,94 @@
"""模型池能力位过滤测试(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
+1
View File
@@ -168,3 +168,4 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
| T-X1 | 预算四档渐进干预(外部采纳 ai-model-router):budget_mode 整数基点判定(80/95/100%+ optimize/cheap 自动降档 + X-Budget-Mode 上报;黄金用例锁边界 | ✅ 完成 | T-X1 | | T-X1 | 预算四档渐进干预(外部采纳 ai-model-router):budget_mode 整数基点判定(80/95/100%+ optimize/cheap 自动降档 + X-Budget-Mode 上报;黄金用例锁边界 | ✅ 完成 | T-X1 |
| T-X2 | 上游有序降级链(外部采纳 cortiq tier 链):池内候选按档位/单价排序、首 token 前 failover、X-Upstream-Fallback 三元组响应头 + admin/stats failover 聚合 | ✅ 完成 | T-X2 | | T-X2 | 上游有序降级链(外部采纳 cortiq tier 链):池内候选按档位/单价排序、首 token 前 failover、X-Upstream-Fallback 三元组响应头 + admin/stats failover 聚合 | ✅ 完成 | T-X2 |
| T-X3 | 路由决策缓存(外部采纳 cortiq 决策哈希缓存):DecisionCachesha256+LRU+TTL60s4096 条)前置 grader live 模式,embed+线性头去重;观察/审计不跳过、invalidate 同步清空、collect/shadow 不缓存 | ✅ 完成 | T-X3 | | T-X3 | 路由决策缓存(外部采纳 cortiq 决策哈希缓存):DecisionCachesha256+LRU+TTL60s4096 条)前置 grader live 模式,embed+线性头去重;观察/审计不跳过、invalidate 同步清空、collect/shadow 不缓存 | ✅ 完成 | T-X3 |
| T-X6 | 模型池能力位过滤(外部采纳 cortiq capabilities):条目 capabilities{vision,tools,context_window}(缺省全兼容)+ filter_by_capabilities 硬过滤 + 代理请求需求推断(图片→vision/tools/上下文)与重定向 | ✅ 完成 | T-X6 |