Files
codex-app-server/cloud/models.py
T

971 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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