Explicit gateway capability now splits known/unknown: known-missing explicit models fail-fast under active (same shape as the gateway default gate), unknown explicit models stay audit-only, shadow never blocks; explicit priority now protects which model the user chose, not that the chosen model must run. Shadow report reads worker_capability_gateway_explicit rows as explicit_capability_failures with a field note distinguishing the three explicit counts. N2: coordinator requeue CAS rowcount=0 branch covered via a proxy connection test (no refactor needed). N3: shadow endpoint test on the create_server + real HTTP skeleton (capability, truncated, and explicit-failure fields). N4: creation-layer rejection tests for tools/instructions/tool_approvals on goal/coordinator tasks. N5: model writeback failure logs a warning (best-effort kept). N1: writeback capability-recheck trade-off documented in comment only; recheck itself is a later batch.
760 lines
31 KiB
Python
760 lines
31 KiB
Python
"""Model fallback routing and pre-dispatch rule routing (v0).
|
||
|
||
Zero third-party dependencies; standard library only.
|
||
|
||
This module decides *whether* a failed model attempt may fall back to
|
||
the next model in a priority list, and *which* model to try next. It
|
||
never runs a model turn itself and never talks to any provider.
|
||
|
||
Rule routing (v0) is deliberately narrower than that: it only fills
|
||
in the model for tasks that did not specify one, using structural
|
||
signals already stored on the task (``plan``, ``goal_mode`` /
|
||
``verify_command``). It never changes ``effort``, ``approval``,
|
||
``plan``, ``executor`` or any quota, never switches provider/gateway,
|
||
and never invents a quality ranking between models. A policy that
|
||
cannot be parsed, or a tier with no model, falls back to the existing
|
||
worker-default chain instead of failing the task.
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
|
||
_logger = logging.getLogger("cloud.models")
|
||
|
||
FALLBACKABLE = {404, 429, 500, 502, 503, 504, "timeout"}
|
||
|
||
|
||
def _is_fallbackable_code(value):
|
||
"""Return True for a single status code / error token."""
|
||
if isinstance(value, bool):
|
||
return False
|
||
if isinstance(value, int):
|
||
if value in FALLBACKABLE:
|
||
return True
|
||
# Any other 5xx is a server-side failure of the same kind.
|
||
return 500 <= value <= 599
|
||
if isinstance(value, str):
|
||
text = value.strip().lower()
|
||
if text in FALLBACKABLE:
|
||
return True
|
||
if text.isdigit():
|
||
return _is_fallbackable_code(int(text))
|
||
return False
|
||
return False
|
||
|
||
|
||
def is_fallbackable(error) -> bool:
|
||
"""Return True if ``error`` may trigger a model fallback.
|
||
|
||
``error`` may be:
|
||
|
||
* an int HTTP status code (404, 429, 5xx -> True; 401, 400 -> False);
|
||
* the string ``"timeout"``;
|
||
* an object / exception carrying a ``status`` or ``code`` attribute
|
||
holding one of the above.
|
||
|
||
Authentication failures (401) and bad-request errors (400) are
|
||
deliberately *not* fallbackable: retrying them on another model
|
||
cannot fix bad credentials or a bad request.
|
||
"""
|
||
if error is None:
|
||
return False
|
||
if isinstance(error, (int, str)) and not isinstance(error, bool):
|
||
return _is_fallbackable_code(error)
|
||
for attr in ("status", "code"):
|
||
value = getattr(error, attr, None)
|
||
if value is not None:
|
||
return _is_fallbackable_code(value)
|
||
return False
|
||
|
||
|
||
class ModelRouter:
|
||
"""Walk a priority list of models, one fallback at a time."""
|
||
|
||
def __init__(self, priority_list):
|
||
if not priority_list:
|
||
raise ValueError("priority_list must not be empty")
|
||
self.priority_list = list(priority_list)
|
||
|
||
def first(self):
|
||
"""Return the first (highest-priority) model."""
|
||
return self.priority_list[0]
|
||
|
||
def next_after(self, current_model, error):
|
||
"""Return the model after ``current_model``, or None.
|
||
|
||
Returns None when ``error`` is not fallbackable, when
|
||
``current_model`` is not in the priority list, or when there
|
||
is no model left after ``current_model``.
|
||
"""
|
||
if not is_fallbackable(error):
|
||
return None
|
||
try:
|
||
index = self.priority_list.index(current_model)
|
||
except ValueError:
|
||
return None
|
||
if index + 1 < len(self.priority_list):
|
||
return self.priority_list[index + 1]
|
||
return None
|
||
|
||
def route(self, attempts):
|
||
"""Return the next model to try given ``attempts``, or None.
|
||
|
||
``attempts`` is a list of ``(model, error)`` pairs in the order
|
||
they were tried. With no attempts yet, the next model to try
|
||
is the first in the priority list. A non-fallbackable error
|
||
anywhere in the chain stops routing (returns None), as does
|
||
exhausting the priority list.
|
||
"""
|
||
if not attempts:
|
||
return self.first()
|
||
last_model = None
|
||
for model, error in attempts:
|
||
if not is_fallbackable(error):
|
||
return None
|
||
last_model = model
|
||
return self.next_after(last_model, attempts[-1][1])
|
||
|
||
|
||
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 split_model_capability_missing(capabilities, model, requires):
|
||
"""把 check_model_capabilities 的缺项拆成已知 / 未知两档。
|
||
|
||
Audit fix 4(第三遍审计分档建议):显式模型的网关检查不能
|
||
只用 (missing, unknown) 的总口径——unknown 为真时可能是
|
||
“目录里根本没有该模型”,也可能是“条目里某项能力没填”,
|
||
两者决策不同。返回 (known_missing, unknown_missing):
|
||
|
||
- 目录里没有该模型:全部需求都算 unknown,known 为空;
|
||
- 布尔能力明确为 False、或 context_tokens 明确低于需求:
|
||
known(目录明确写了不满足);
|
||
- 布尔能力未填(None)、或 context_tokens 未填:unknown。
|
||
|
||
与 check_model_capabilities 的口径一致:requires 里非布尔/
|
||
非整数键被忽略,不计入任何一档。
|
||
"""
|
||
requires = requires or {}
|
||
model = _clean_model_name(model)
|
||
entry = (capabilities or {}).get(model) if model else None
|
||
if entry is None:
|
||
return [], sorted(
|
||
k for k in requires
|
||
if k in REQUIREMENT_BOOL_KEYS or k in REQUIREMENT_INT_KEYS)
|
||
known = []
|
||
unknown = []
|
||
for key, value in requires.items():
|
||
if key in REQUIREMENT_BOOL_KEYS:
|
||
flag = entry.get(key)
|
||
if flag is True:
|
||
continue
|
||
if flag is None:
|
||
unknown.append(key)
|
||
else:
|
||
known.append(key)
|
||
elif key in REQUIREMENT_INT_KEYS:
|
||
context = entry.get("context_tokens")
|
||
if context is None:
|
||
unknown.append(key)
|
||
elif context < value:
|
||
known.append(key)
|
||
return sorted(known), sorted(unknown)
|
||
|
||
|
||
def prompt_len_bucket(prompt) -> str:
|
||
"""Bucket a prompt by length for logging only, never for routing.
|
||
|
||
Length is recorded so shadow reports can test whether it correlates
|
||
with outcomes; v0 deliberately does not route on it (a long prompt
|
||
may just be a pasted log, not a hard task).
|
||
"""
|
||
length = len(prompt) if isinstance(prompt, str) else 0
|
||
if length < 500:
|
||
return "<500"
|
||
if length <= 3000:
|
||
return "500-3000"
|
||
return ">3000"
|
||
|
||
|
||
def classify_task(task) -> str:
|
||
"""Classify a task into one routing tier, first match wins.
|
||
|
||
Only structural signals are used, in this order: ``plan`` (read-only,
|
||
never mutates files) before ``goal`` (multi-round goal loop, possibly
|
||
unbounded rounds), everything else is ``default``. Keyword, prompt
|
||
length, priority, ``depends_on`` count and executor are intentionally
|
||
excluded: they are weak or unrelated proxies for difficulty.
|
||
"""
|
||
if not isinstance(task, dict):
|
||
return "default"
|
||
if task.get("plan"):
|
||
return "plan"
|
||
if task.get("goal_mode") or task.get("verify_command"):
|
||
return "goal"
|
||
return "default"
|
||
|
||
|
||
def _clean_model_name(value):
|
||
# Audit fix 3(M7,文档级):只 strip、不校验字符集。
|
||
# 含空白的模型名仍会原样进审计 detail,而影子审计
|
||
# 格式是空白分隔,整行解析失败即无声丢样本(不报错、
|
||
# 不计数)。这里不加字符集校验——那会改变模型名接受
|
||
# 语义、可能拦住合法模型名;风险以文档记录,修复
|
||
# 需另起一轨(审计格式改分隔或模型名白名单)。
|
||
if isinstance(value, str) and value.strip():
|
||
return value.strip()
|
||
return None
|
||
|
||
|
||
def _clean_tiers(raw):
|
||
"""Return {tier: model} keeping only valid non-empty entries."""
|
||
tiers = {}
|
||
if not isinstance(raw, dict):
|
||
return tiers
|
||
for tier in ROUTING_TIERS:
|
||
model = _clean_model_name(raw.get(tier))
|
||
if isinstance(model, str):
|
||
# A tier entry may itself be a dict {"model": ...}; accept
|
||
# that shape too so policy authors are not punished for it.
|
||
tiers[tier] = model
|
||
elif isinstance(raw.get(tier), dict):
|
||
model = _clean_model_name(raw[tier].get("model"))
|
||
if model:
|
||
tiers[tier] = model
|
||
return tiers
|
||
|
||
|
||
class RoutingPolicy:
|
||
"""A parsed, versioned routing policy (or an empty one).
|
||
|
||
``mode`` is one of ``off`` / ``shadow`` / ``active`` and is the
|
||
default for every tier. ``tiers`` maps a tier name to a model
|
||
name; a tier absent from the map falls back to the worker
|
||
default. ``tier_modes`` (v0c, per-tier activation) maps a tier
|
||
name to an explicit mode that overrides the global ``mode`` in
|
||
both directions: a tier can be ``active`` while the global mode
|
||
is ``shadow``/``off`` (activate plan first), and a tier can be
|
||
``off`` while the global mode is ``active`` (roll back one tier
|
||
without touching the others). A tier entry whose mode is not a
|
||
valid mode is dropped entirely by the parser (fail-closed: a
|
||
typo must never accidentally activate a tier).
|
||
``fallback_models`` is parsed and validated but deliberately
|
||
NOT applied by v0: the worker's explicit ``--fallback-models``
|
||
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,
|
||
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"
|
||
self.tiers = dict(tiers or {})
|
||
self.tier_modes = {}
|
||
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
|
||
# Audit fix: fallback_models used to live after the
|
||
# return in tier_mode (unreachable dead code), so a
|
||
# RoutingPolicy never actually had the attribute and
|
||
# any future reader would get AttributeError. It is
|
||
# stored here, cleaned and deduplicated, and still
|
||
# deliberately NOT applied by the router (see class
|
||
# docstring): only worker CLI --fallback-models enables
|
||
# a real fallback retry.
|
||
self.fallback_models = []
|
||
for item in fallback_models or []:
|
||
model = _clean_model_name(item)
|
||
if model and model not in self.fallback_models:
|
||
self.fallback_models.append(model)
|
||
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."""
|
||
return self.tier_modes.get(tier, self.mode)
|
||
|
||
|
||
def parse_routing_policy(data):
|
||
"""Parse one policy document, or return None when it is unusable.
|
||
|
||
Unusable means: not a dict, no usable ``tiers`` map, or a ``mode``
|
||
outside ``off``/``shadow``/``active``. Returning None (instead of
|
||
raising) is the fail-closed path: the caller falls back to the
|
||
worker-default chain and the task is never rejected for a bad
|
||
policy file.
|
||
"""
|
||
if not isinstance(data, dict):
|
||
return None
|
||
mode = data.get("mode", "shadow")
|
||
if mode not in ROUTING_MODES:
|
||
return None
|
||
tiers = _clean_tiers(data.get("tiers"))
|
||
if not tiers:
|
||
return None
|
||
# Per-tier modes (v0c): a tier entry may be a string (model,
|
||
# mode inherits global) or a dict {"model": ..., "mode": ...}.
|
||
# An invalid tier mode drops the whole tier entry (fail-closed),
|
||
# it never falls back to the global mode where a typo could
|
||
# accidentally activate, and never kills the other tiers.
|
||
raw_tiers = data.get("tiers") if isinstance(data.get("tiers"),
|
||
dict) else {}
|
||
tier_modes = {}
|
||
for tier, entry in raw_tiers.items():
|
||
if not isinstance(entry, dict) or "mode" not in entry:
|
||
continue
|
||
tier_mode = entry.get("mode")
|
||
if tier_mode in ROUTING_MODES:
|
||
tier_modes[tier] = tier_mode
|
||
else:
|
||
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,
|
||
model_capabilities=data.get(
|
||
"model_capabilities"),
|
||
tier_requirements=tier_requirements)
|
||
|
||
|
||
def load_routing_policy(env=None):
|
||
"""Load a policy from env, following the cost-table convention.
|
||
|
||
``CLOUD_ROUTING_POLICY_FILE`` is read first, then
|
||
``CLOUD_ROUTING_POLICY_JSON`` overrides its fields per tier;
|
||
``CLOUD_ROUTING_MODE`` overrides the mode when valid. Any source
|
||
that fails to parse is ignored, never fatal, but the failure is
|
||
logged as a warning (Audit fix 3, I3): an unconfigured policy
|
||
is silent, a configured-but-broken one is not. Returns None
|
||
when no usable policy was found, so the caller keeps the old
|
||
behaviour.
|
||
"""
|
||
env = env if env is not None else os.environ
|
||
merged = None
|
||
# Audit fix 3(I3):区分“未配置策略”(无 env,正常,
|
||
# 不记警告)与“配置了但解析失败”(异常,必须可见)。
|
||
# 旧版任一来源失败都静默吞掉,运营方无法区分
|
||
# “策略生效”与“策略已死”。行为不变:失败仍返回
|
||
# None、派发回落旧链,只是失败现在记警告日志;
|
||
# worker 侧另有 routing_policy_load_failed 标志与
|
||
# 审计(见 worker.py),让失败在运行期也可查。
|
||
configured = bool(env.get("CLOUD_ROUTING_POLICY_FILE")
|
||
or env.get("CLOUD_ROUTING_POLICY_JSON"))
|
||
failures = []
|
||
path = env.get("CLOUD_ROUTING_POLICY_FILE") or ""
|
||
if path:
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
merged = json.load(f)
|
||
except (OSError, ValueError) as exc:
|
||
failures.append("policy file %r: %s" % (path, exc))
|
||
merged = None
|
||
raw = env.get("CLOUD_ROUTING_POLICY_JSON") or ""
|
||
if raw:
|
||
try:
|
||
override = json.loads(raw)
|
||
except ValueError as exc:
|
||
failures.append("CLOUD_ROUTING_POLICY_JSON: %s" % exc)
|
||
override = None
|
||
if isinstance(override, dict):
|
||
if not isinstance(merged, dict):
|
||
merged = override
|
||
else:
|
||
merged = dict(merged)
|
||
merged.update(override)
|
||
if isinstance(merged.get("tiers"), dict) and \
|
||
isinstance(override.get("tiers"), dict):
|
||
tiers = dict(merged["tiers"])
|
||
tiers.update(override["tiers"])
|
||
merged["tiers"] = tiers
|
||
policy = parse_routing_policy(merged)
|
||
if policy is None:
|
||
if configured:
|
||
failures.append("merged policy document is unusable")
|
||
if failures:
|
||
_logger.warning(
|
||
"routing policy load failed (%s); falling back to"
|
||
" worker default", "; ".join(failures))
|
||
return None
|
||
if failures:
|
||
_logger.warning(
|
||
"routing policy partially failed (%s); continuing with"
|
||
" the usable source", "; ".join(failures))
|
||
mode_override = env.get("CLOUD_ROUTING_MODE") or ""
|
||
if mode_override in ROUTING_MODES:
|
||
policy.mode = mode_override
|
||
return policy
|
||
|
||
|
||
class RoutingDecision:
|
||
"""One pre-dispatch model decision, kept auditable by construction."""
|
||
|
||
def __init__(self, task_id, tier, chosen_model, suggested_model,
|
||
source, policy_version, applied, reason, signals,
|
||
capability_fallback=False):
|
||
self.task_id = task_id
|
||
self.tier = tier
|
||
self.chosen_model = chosen_model
|
||
self.suggested_model = suggested_model
|
||
self.source = source
|
||
self.policy_version = policy_version
|
||
self.applied = applied
|
||
self.reason = reason
|
||
self.signals = dict(signals or {})
|
||
# Audit fix: True only when an active tier model failed
|
||
# its capability check and the worker default was used
|
||
# instead. The source stays "worker_default" (existing
|
||
# callers rely on it), so the worker must check this
|
||
# flag to audit the fallback -- otherwise the fallback
|
||
# is invisible in the audit trail.
|
||
self.capability_fallback = bool(capability_fallback)
|
||
|
||
def to_event(self) -> dict:
|
||
return {
|
||
"type": "model_routing",
|
||
"task_id": self.task_id,
|
||
"tier": self.tier,
|
||
"chosen_model": self.chosen_model,
|
||
"suggested_model": self.suggested_model,
|
||
"source": self.source,
|
||
"policy_version": self.policy_version,
|
||
"applied": self.applied,
|
||
"reason": self.reason,
|
||
"capability_fallback": self.capability_fallback,
|
||
}
|
||
|
||
|
||
def decide_routing(task, worker_model, policy=None,
|
||
worker_model_explicit=False):
|
||
"""Decide the model for one task, in the documented priority order.
|
||
|
||
1. task-level ``model`` (explicit) -- never overridden;
|
||
2. worker CLI ``--model`` (explicit) -- a pinned deployment is never
|
||
silently changed by a policy file;
|
||
3. policy tier model in the tier's effective mode (v0c: explicit
|
||
tier mode, else the global mode) -- only when the task named
|
||
no model; ``active`` applies the tier model, ``shadow``
|
||
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
|
||
never reads env, files, or a provider, so it is unit-testable with
|
||
plain dicts.
|
||
"""
|
||
task = task if isinstance(task, dict) else {}
|
||
tier = classify_task(task)
|
||
signals = {
|
||
"plan": bool(task.get("plan")),
|
||
"goal_mode": bool(task.get("goal_mode")),
|
||
"prompt_len_bucket": prompt_len_bucket(task.get("prompt")),
|
||
"effort": task.get("effort"),
|
||
}
|
||
task_model = _clean_model_name(task.get("model"))
|
||
if task_model:
|
||
return RoutingDecision(
|
||
task_id=task.get("id"), tier=tier, chosen_model=task_model,
|
||
suggested_model=None, source="explicit", policy_version=None,
|
||
applied=True, reason="task model explicit", signals=signals)
|
||
worker_model = _clean_model_name(worker_model)
|
||
if worker_model_explicit and worker_model:
|
||
return RoutingDecision(
|
||
task_id=task.get("id"), tier=tier, chosen_model=worker_model,
|
||
suggested_model=None, source="worker_explicit",
|
||
policy_version=None, applied=True,
|
||
reason="worker CLI model explicit", signals=signals)
|
||
if policy is None:
|
||
return RoutingDecision(
|
||
task_id=task.get("id"), tier=tier, chosen_model=worker_model,
|
||
suggested_model=None, source="worker_default",
|
||
policy_version=None,
|
||
applied=True, reason="routing off or no policy",
|
||
signals=signals)
|
||
tier_model = policy.tiers.get(tier)
|
||
if not tier_model:
|
||
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 has no policy model" % tier, signals=signals)
|
||
# Per-tier effective mode (v0c): explicit tier mode overrides
|
||
# the global mode in both directions. A tier in "off" is not
|
||
# routed at all (no policy/shadow audit downstream, so it never
|
||
# inflates a shadow sample count).
|
||
effective_mode = policy.tier_mode(tier)
|
||
if effective_mode == "off":
|
||
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 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)%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, capability_fallback=True)
|
||
return RoutingDecision(
|
||
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:
|
||
"""Append a ``model_routing`` event to a task record, like fallback.
|
||
|
||
Mirrors ``record_fallback``: the event is appended to
|
||
``task_record["events"]`` (created if absent) so a routed model is
|
||
never silent. Returns the event.
|
||
"""
|
||
event = decision.to_event() if isinstance(decision, RoutingDecision) \
|
||
else dict(decision)
|
||
events = task_record.get("events")
|
||
if not isinstance(events, list):
|
||
events = []
|
||
task_record["events"] = events
|
||
events.append(event)
|
||
return event
|
||
|
||
|
||
def record_fallback(task_record, from_model, to_model, reason) -> dict:
|
||
"""Record a model fallback on ``task_record`` and return the event.
|
||
|
||
Fallback must be recorded as an event, never silently downgraded:
|
||
the returned event is appended to ``task_record["events"]``
|
||
(created if absent) so the downgrade is always visible in the
|
||
task's history.
|
||
"""
|
||
event = {
|
||
"type": "model_fallback",
|
||
"task_id": task_record.get("id"),
|
||
"from_model": from_model,
|
||
"to_model": to_model,
|
||
"reason": reason,
|
||
}
|
||
events = task_record.get("events")
|
||
if not isinstance(events, list):
|
||
events = []
|
||
task_record["events"] = events
|
||
events.append(event)
|
||
return event
|