feat: capability-based routing (v8)

Policy carries an operator-declared model_capabilities directory
and per-tier requires (tool_calling/reasoning/vision booleans,
min_context_tokens). decide_routing checks the policy tier model
in active mode: capable applies, incapable falls back to a capable
worker default, and if both fail or are unknown it returns
capability_blocked, which the worker turns into a failed task
(v3 tools-gate shape, runner never called). Shadow mode only
annotates the reason, never blocks. Explicit task and worker CLI
models bypass the check by design. Invalid requires drops only
that tier (fail-closed, like invalid tier mode). Dry-run reports
per-tier capability status and blocks on active tiers that fail
or cannot be verified.
This commit is contained in:
host committed 2026-11-04 18:02:20 +00:00
1 parent 362dffba4f
commit cd33a929ca
4 files changed
+616 -11

No files matched your search

+219 -8
View File
@@ -117,6 +117,131 @@ class ModelRouter:
ROUTING_MODES = ("off", "shadow", "active")
ROUTING_TIERS = ("plan", "goal", "default")
# 能力路由(v8):能力目录与档需求的字段名。布尔能力只认
# True 为“具备”,False 为“已知不具备”,缺失为“未知”——
# 缺失绝不按 False 算(那会把“没声明”说成“没能力”),
# 也绝不按 True 算(那会把“没声明”说成“有能力”)。
CAPABILITY_BOOL_KEYS = ("tool_calling", "reasoning", "vision")
CAPABILITY_INT_KEYS = ("context_tokens",)
REQUIREMENT_BOOL_KEYS = CAPABILITY_BOOL_KEYS
REQUIREMENT_INT_KEYS = ("min_context_tokens",)
def _is_bool(value):
return isinstance(value, bool)
def _is_int(value):
return isinstance(value, int) and not isinstance(value, bool)
def parse_model_capabilities(raw):
"""清洗能力目录:{model: {context_tokens, tool_calling,
reasoning, vision}},只保留字段全合法的条目。
目录是运营方手工维护的声明,不是中转 /models 的实测
(中转只回 id,不回能力与价格,见路由 v0 的价格口径):
运营方写错一个条目时,整条丢掉比只丢错字段更安全——
只丢错字段会让“要求工具调用”悄悄变成“不查工具调用”。
非 dict、模型名为空、context_tokens 非正整数、布尔
能力非布尔,一律整条丢掉,调用方据此把该模型的能力
判为未知。
"""
out = {}
if not isinstance(raw, dict):
return out
for model, entry in raw.items():
model = _clean_model_name(model)
if not model or not isinstance(entry, dict):
continue
cleaned = {}
valid = True
for key in CAPABILITY_INT_KEYS:
if key in entry:
if not _is_int(entry[key]) or entry[key] <= 0:
valid = False
break
cleaned[key] = entry[key]
if not valid:
continue
for key in CAPABILITY_BOOL_KEYS:
if key in entry:
if not _is_bool(entry[key]):
valid = False
break
cleaned[key] = entry[key]
if not valid:
continue
out[model] = cleaned
return out
def parse_tier_requires(raw):
"""校验一档的需求:返回清洗后的 dict,或 None 表示非法。
合法需求:布尔能力键只接受布尔值(False 等于“无此
要求”,不是“要求不具备”);min_context_tokens 只接受
正整数;未知键一律非法——运营方拼错需求名时,若静默
忽略,他会以为该要求已生效,实际没生效。非法需求的
处理在 parse_routing_policy:整档丢掉(与无效档模式
同款 fail-closed),不连累其他档。
"""
if raw is None:
return {}
if not isinstance(raw, dict):
return None
out = {}
for key, value in raw.items():
if key in REQUIREMENT_BOOL_KEYS:
if not _is_bool(value):
return None
if value:
out[key] = True
elif key in REQUIREMENT_INT_KEYS:
if not _is_int(value) or value <= 0:
return None
out[key] = value
else:
return None
return out
def check_model_capabilities(capabilities, model, requires):
"""查一个模型是否满足需求:返回 (ok, missing, unknown)。
requires 为空时恒为 ok。模型不在目录里时全部需求都
无法验证:ok=False,missing 列全部需求名,unknown=True。
目录里有模型但某项能力缺失时,该项计 unknown(不是
已知不具备),missing 里同样列它——active 模式下
fail 与 unknown 都拦任务,区别只在报错文案里写清是
“已知缺乏”还是“无法验证”。
"""
requires = requires or {}
if not requires:
return True, [], False
model = _clean_model_name(model)
entry = (capabilities or {}).get(model) if model else None
if entry is None:
return False, sorted(requires.keys()), True
missing = []
unknown = False
for key, value in requires.items():
if key in REQUIREMENT_BOOL_KEYS:
flag = entry.get(key)
if flag is True:
continue
missing.append(key)
if flag is None:
unknown = True
elif key in REQUIREMENT_INT_KEYS:
context = entry.get("context_tokens")
if context is None:
missing.append(key)
unknown = True
elif context < value:
missing.append(key)
return (not missing), sorted(missing), unknown
def prompt_len_bucket(prompt) -> str:
"""Bucket a prompt by length for logging only, never for routing.
@@ -194,10 +319,15 @@ class RoutingPolicy:
remains the only source that can enable a real fallback retry,
so a policy file alone can never start burning a second
attempt's quota.
``model_capabilities`` (v8) is the operator-declared
capability directory, and ``tier_requirements`` maps a tier
to its requires dict; both are filtered to tiers/models
that actually exist, exactly like ``tier_modes``.
"""
def __init__(self, version=None, mode="shadow", tiers=None,
fallback_models=None, tier_modes=None):
fallback_models=None, tier_modes=None,
model_capabilities=None, tier_requirements=None):
self.version = version if isinstance(version, str) \
and version.strip() else None
self.mode = mode if mode in ROUTING_MODES else "off"
@@ -206,6 +336,14 @@ class RoutingPolicy:
for tier, tier_mode in (tier_modes or {}).items():
if tier in self.tiers and tier_mode in ROUTING_MODES:
self.tier_modes[tier] = tier_mode
self.model_capabilities = parse_model_capabilities(
model_capabilities)
self.tier_requirements = {}
for tier, requires in (tier_requirements or {}).items():
if tier in self.tiers and requires:
cleaned = parse_tier_requires(requires)
if cleaned is not None:
self.tier_requirements[tier] = cleaned
def tier_mode(self, tier):
"""Effective mode for one tier: explicit tier mode, else global."""
@@ -252,13 +390,32 @@ def parse_routing_policy(data):
tiers.pop(tier, None)
if not tiers:
return None
# Per-tier requirements (v8): a tier entry dict may carry
# "requires". An invalid requires (unknown key, bad value)
# drops the whole tier entry, same fail-closed rule as an
# invalid mode: a typo'd requirement must never be silently
# ignored while the operator believes it is enforced.
tier_requirements = {}
for tier, entry in raw_tiers.items():
if not isinstance(entry, dict) or "requires" not in entry:
continue
cleaned = parse_tier_requires(entry.get("requires"))
if cleaned is None:
tiers.pop(tier, None)
elif cleaned:
tier_requirements[tier] = cleaned
if not tiers:
return None
fallback_models = data.get("fallback_models")
if fallback_models is not None and not isinstance(fallback_models,
list):
return None
return RoutingPolicy(version=data.get("version"), mode=mode,
tiers=tiers, fallback_models=fallback_models,
tier_modes=tier_modes)
tier_modes=tier_modes,
model_capabilities=data.get(
"model_capabilities"),
tier_requirements=tier_requirements)
def load_routing_policy(env=None):
@@ -347,6 +504,28 @@ def decide_routing(task, worker_model, policy=None,
suggests it, ``off`` falls back to the worker default;
4. worker default chain (config file / ``DEFAULT_MODEL``).
Capability filter (v8): when the tier carries ``requires``,
the policy tier model is checked against the policy's
``model_capabilities`` directory. Explicit task models and
worker CLI models are never checked -- an explicit choice
is never overridden by a policy. In ``shadow`` mode a
mismatch only annotates the reason (shadow never changes
behaviour, never blocks). In ``active`` mode a mismatch
first tries the worker default: if the worker default
satisfies the same requirements it is used instead (source
worker_default, reason names the tier model's shortfall);
if the worker default also fails or is unknown, the
decision is ``source="capability_blocked"`` with
``chosen_model=None`` -- the worker turns that into a
failed task with a clear error (same fail-fast shape as
the v3 required-tools gate), never silently dispatching
an incapable or unverifiable model. A tier with no
requirements, and a policy with no directory, behave
exactly as before. The directory is operator-declared,
never fetched from a provider, so a stale directory is
the operator's responsibility to fix; the dry-run tool
reports capability mismatches before activation.
Shadow mode returns the worker default as ``chosen_model`` and the
policy tier model as ``suggested_model`` with ``applied=False``:
it observes without changing behaviour. This function is pure: it
@@ -399,19 +578,51 @@ def decide_routing(task, worker_model, policy=None,
suggested_model=None, source="worker_default",
policy_version=policy.version, applied=True,
reason="%s tier mode is off" % tier, signals=signals)
requires = policy.tier_requirements.get(tier) or {}
cap_ok, cap_missing, cap_unknown = check_model_capabilities(
policy.model_capabilities, tier_model, requires)
cap_note = ""
if requires and not cap_ok:
cap_note = " (capability %s: %s)" % (
"unknown" if cap_unknown else "lacks",
", ".join(cap_missing))
if effective_mode == "shadow":
return RoutingDecision(
task_id=task.get("id"), tier=tier, chosen_model=worker_model,
suggested_model=tier_model, source="shadow",
policy_version=policy.version, applied=False,
reason="%s tier suggests %s (shadow, not applied)"
% (tier, tier_model), signals=signals)
reason="%s tier suggests %s (shadow, not applied)%s"
% (tier, tier_model, cap_note), signals=signals)
# effective_mode == "active"
if cap_ok:
return RoutingDecision(
task_id=task.get("id"), tier=tier, chosen_model=tier_model,
suggested_model=None, source="policy",
policy_version=policy.version, applied=True,
reason="%s tier -> %s" % (tier, tier_model),
signals=signals)
# Tier model cannot be verified as capable. Try the worker
# default against the same requirements before blocking:
# a capable worker default keeps the task moving without
# violating the requirement.
worker_ok, _w_missing, _w_unknown = check_model_capabilities(
policy.model_capabilities, worker_model, requires)
if worker_ok:
return RoutingDecision(
task_id=task.get("id"), tier=tier, chosen_model=worker_model,
suggested_model=None, source="worker_default",
policy_version=policy.version, applied=True,
reason="%s tier model %s%s, worker default satisfies"
" requirements, falling back" % (tier, tier_model, cap_note),
signals=signals)
return RoutingDecision(
task_id=task.get("id"), tier=tier, chosen_model=tier_model,
suggested_model=None, source="policy",
policy_version=policy.version, applied=True,
reason="%s tier -> %s" % (tier, tier_model), signals=signals)
task_id=task.get("id"), tier=tier, chosen_model=None,
suggested_model=tier_model, source="capability_blocked",
policy_version=policy.version, applied=False,
reason="%s tier model %s%s and worker default also cannot"
" satisfy requirements; refusing to dispatch an incapable"
" or unverifiable model" % (tier, tier_model, cap_note),
signals=signals)
def record_routing(task_record, decision) -> dict: