971 lines
41 KiB
Python
971 lines
41 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)
|
||
# 审计批 4(I-6):能力目录键含空白时整条丢弃——
|
||
# 含空白的键在空白分隔的审计 detail 里无法被消费点
|
||
# 稳健识别,保留它只会让能力检查静默走 unknown。
|
||
if not model or _model_name_has_whitespace(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):
|
||
"""把模型名规范成可比较的字符串:仅 strip。
|
||
|
||
注意:这里刻意不做空白白名单。decide_routing 里清洗后
|
||
的 None 会被当成“无显式模型”而改走策略/worker 默认路由,
|
||
全局白名单会把“显式模型含空白(目前原样派发、provider
|
||
侧 loud fail)”悄悄改成“静默替换用户选型”,比现状更差。
|
||
空白模型名的处理因此分两层(审计批 4,I-6):
|
||
(a) 策略写入路径白名单——能力目录键、档模型、
|
||
fallback_models 含空白的一律丢弃;(b) 运行时消费点
|
||
稳健解析——影子 chosen 贪到行尾、_is_persisted_model
|
||
按键提取 model 值,不再依赖空白分隔的位置。
|
||
"""
|
||
if isinstance(value, str) and value.strip():
|
||
return value.strip()
|
||
return None
|
||
|
||
|
||
def _model_name_has_whitespace(name):
|
||
"""清洗后的模型名是否含空白(审计批 4,I-6 白名单判定)。"""
|
||
return isinstance(name, str) and any(ch.isspace() for ch in name)
|
||
|
||
|
||
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))
|
||
# 审计批 4(I-6):档模型含空白时整档丢弃(parse 层
|
||
# 另行记入 dropped_tiers + 警告),不让含空白的模型
|
||
# 名进空白分隔的审计 detail。
|
||
if isinstance(model, str) \
|
||
and not _model_name_has_whitespace(model):
|
||
# 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 and not _model_name_has_whitespace(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"
|
||
# 审计批 4(I-6):直接构造的策略同样过白名单——含
|
||
# 空白的档模型进不了空白分隔的审计 detail,整档丢弃
|
||
# (与 parse 层口径一致;parse 层另行记 dropped_tiers)。
|
||
self.tiers = {tier: model for tier, model in (tiers or {}).items()
|
||
if not (isinstance(model, str)
|
||
and _model_name_has_whitespace(model))}
|
||
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)
|
||
# 审计批 4(I-6):fallback 模型名同款白名单。
|
||
if model and not _model_name_has_whitespace(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 = []
|
||
# 审计批 4(I-6):_clean_tiers 已把含空白模型名的档
|
||
# 丢掉,这里只记名——与无效模式/无效 requires 的 pop
|
||
# 同款可见性。只认 ROUTING_TIERS 内的档(未知档名本
|
||
# 来就被 _clean_tiers 忽略,不是“被丢弃”);档已被
|
||
# _clean_tiers 滤掉时,后面的 mode/requires pop 因
|
||
# `tier in tiers` 为假不会重复记,不会双计。
|
||
for tier, entry in raw_tiers.items():
|
||
if tier not in ROUTING_TIERS or tier in tiers:
|
||
continue
|
||
raw_model = entry if isinstance(entry, str) else \
|
||
entry.get("model") if isinstance(entry, dict) else None
|
||
cleaned = _clean_model_name(raw_model)
|
||
if cleaned and _model_name_has_whitespace(cleaned):
|
||
dropped_tiers.append(tier)
|
||
_logger.warning(
|
||
"routing policy tier %r dropped: model name contains"
|
||
" whitespace %r", tier, cleaned)
|
||
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
|