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.
666 lines
26 KiB
Python
666 lines
26 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 os
|
||
|
||
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 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):
|
||
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
|
||
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)
|
||
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)
|
||
|
||
|
||
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. 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
|
||
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):
|
||
merged = None
|
||
raw = env.get("CLOUD_ROUTING_POLICY_JSON") or ""
|
||
if raw:
|
||
try:
|
||
override = json.loads(raw)
|
||
except ValueError:
|
||
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:
|
||
return None
|
||
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):
|
||
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 {})
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
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)
|
||
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
|