"""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) class CapabilityVerdict: """Structured result of one capability check (audit batch 3, I-3). One call replaces the previous check+split pair at the five capability sites (decide_routing, worker gateway check, worker persisted-model recheck, dry-run tier/gateway checks, server shadow endpoint). The fields are exactly what those sites used before, so no site changes behaviour by adopting it: - ``ok`` / ``missing`` / ``unknown`` are the total-check fields from check_model_capabilities (decide/dry-run/shadow wording); - ``known_missing`` / ``unknown_missing`` are the split fields from split_model_capability_missing (gateway explicit split, persisted recheck); - ``status`` is "ok", or "unknown" when not ok and any part is unverifiable, else "fail" (the dry-run/shadow status rule); - ``verb`` is "cannot verify" when unknown else "lacks" (the dry-run/gateway note wording); it is only meaningful when not ok. Edge preserved, not fixed: for a model absent from the directory, ``missing`` lists every requires key (the total check's rule) while ``unknown_missing`` lists only recognised requirement keys (the split's rule). Callers that previously used one function keep getting that function's values; the blocking rules themselves stay at the call sites (gateway has no worker-default fallback, recheck blocks only on known, dry-run/shadow never block), they are not folded into this verdict. """ def __init__(self, ok, missing, unknown, known_missing, unknown_missing): self.ok = bool(ok) self.missing = list(missing) self.unknown = bool(unknown) self.known_missing = list(known_missing) self.unknown_missing = list(unknown_missing) @property def status(self): if self.ok: return "ok" return "unknown" if self.unknown else "fail" @property def verb(self): return "cannot verify" if self.unknown else "lacks" def evaluate_capability(capabilities, model, requires): """Check one model against requires, returning a CapabilityVerdict. Pure: it only calls check_model_capabilities and split_model_capability_missing, so its fields are definitionally identical to what the five call sites computed before with two calls. ``requires`` is expected to be already cleaned by parse_tier_requires (all five sites pass tier_requirements); uncleaned keys behave exactly as the two underlying functions behave (see CapabilityVerdict). """ ok, missing, unknown = check_model_capabilities( capabilities, model, requires) known, unknown_missing = split_model_capability_missing( capabilities, model, requires) return CapabilityVerdict(ok, missing, unknown, known, unknown_missing) def merge_policy_documents(base, override): """Merge a file policy document with a JSON override document. This is the merge load_routing_policy and dry_run_from_env always shared line-for-line, now in one place (audit batch 3, I-4). ``base`` is the file document (or None when unread/absent), ``override`` is the JSON document (or None when absent/unparsed). Rules, exactly as before: - override not a dict: base unchanged; - base not a dict: override; - otherwise: a shallow copy of base updated with override, so JSON replaces whole keys — including the whole "tiers" map. Per-tier merging does NOT happen: the old tiers-update block after update() copied override's tiers back onto themselves (merged["tiers"] already WAS override's tiers), i.e. it was a no-op, and it is kept verbatim below so this function stays a line-for-line move, not a semantic "fix". File tiers absent from the JSON document are therefore dropped, not preserved; changing that would be a behaviour change for another batch. Source-level failure records (dropped_tiers/partial_failures from batch 2) are NOT a product of this merge: they are filled in by parse/load afterwards, and this function neither reads nor writes them. """ if not isinstance(override, dict): return base if not isinstance(base, dict): return override merged = dict(base) 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 return merged 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, dropped_tiers=None, partial_failures=None): # Audit batch 2 (I-2): tiers the parser dropped for an # invalid mode/requires, and source-level failures that # still left a usable policy. Both default to [] so # directly-constructed policies never raise AttributeError # on these fields; parse/load fill them in. They are # visibility only: they never change routing decisions. self.dropped_tiers = list(dropped_tiers or []) self.partial_failures = list(partial_failures or []) 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 content_fingerprint(self): """Content fingerprint of the parsed policy (9.5-code). sha256 of the canonical JSON (sorted keys, tight separators) over the full normalized policy payload (not only the fields that decide routing and capability checks: fallback_models is included even though the router deliberately does not apply it, so the fingerprint is fail-safe — it reports more changes, never fewer): mode, tiers, tier_modes, tier_requirements, model_capabilities, fallback_models. The operator-declared ``version`` string is deliberately NOT part of the fingerprint: it is hand-filled and may stay unchanged while requires change, so it cannot be trusted to detect a change. The fingerprint is only for dry-run output and debugging; it is never written into worker_routing audit rows, so the shadow parser (which splits audit detail on whitespace) is untouched. Two policies that parse to the same effective fields share a fingerprint even if their raw documents differ in key order or in dropped/invalid tiers. """ import hashlib payload = { "mode": self.mode, "tiers": self.tiers, "tier_modes": self.tier_modes, "tier_requirements": self.tier_requirements, "model_capabilities": self.model_capabilities, "fallback_models": self.fallback_models, } canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() 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 {} # Audit batch 2 (I-2): record every tier the fail-closed pops # actually remove, with a warning naming the tier. A pop that # finds the tier already absent (e.g. _clean_tiers dropped it # for an empty model) is not a second drop and is not logged: # the warning must name a tier this pop really removed. dropped_tiers = [] 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 elif tier in tiers: tiers.pop(tier, None) dropped_tiers.append(tier) _logger.warning( "routing policy tier %r dropped: invalid mode %r", tier, tier_mode) 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: if tier in tiers: tiers.pop(tier, None) dropped_tiers.append(tier) _logger.warning( "routing policy tier %r dropped: invalid requires", tier) 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, dropped_tiers=dropped_tiers) 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): merged = merge_policy_documents(merged, override) 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: # Audit batch 2 (I-2): 部分失败仍是 fail-open—— # 可用来源继续生效、失败来源被丢弃,派发不炸。 # 但可见性不再只有这条警告日志:失败清单挂在返回 # 的 policy 上(partial_failures),worker 派发时 # 据此记任务级审计(与全失败同款 action),影子 # 报告里也附指纹与丢弃档;返回类型本身不变, # 仍是 RoutingPolicy 或 None。 policy.partial_failures = list(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 = evaluate_capability( 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_cap = evaluate_capability( policy.model_capabilities, worker_model, requires) if worker_cap.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