Files

410 lines
18 KiB
Python
Raw Permalink 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.
"""Routing policy dry-run (v0d): validate before applying, never apply.
A routing policy takes effect the moment a worker loads it from the
host env -- there is no CI gate for an env change. A typo in a tier
mode silently drops that tier, a bad model name is never checked
against a whitelist (models are not whitelisted by design), and a
policy file that fails to parse is silently ignored by the loader
(fail-closed to the worker default, but also silently). This module
surfaces all of that BEFORE the policy is applied, and it never
applies anything itself: it only reports what WOULD happen if the
given policy document were loaded.
What it reports, per tier (plan / goal / default):
* the effective model and mode, and whether the mode came from an
explicit tier mode or from the global mode,
* tiers that were dropped, and why (invalid tier mode, no model,
unrecognised entry) -- a dropped tier is a BLOCKER, not a warning
to skim past, because applying the policy would silently not route
that tier,
* tiers absent from the document (worker default, not routed),
* unknown tier names in the document (ignored by the parser),
* whether any tier would actually activate on next dispatch,
* capability checks (v8): per tier, whether its model meets
its requires against the policy's model_capabilities
directory -- an active tier that fails or cannot be verified
is a BLOCKER (it would block dispatch at the worker
capability gate); a shadow/off tier with the same problem
is a warning only.
* gateway model check (Audit fix 3, I1): the same requires,
checked against the sub2api gateway model -- the model
production actually dispatches, which is NOT the tier
model. The gateway model comes only from env
(CLOUD_SUB2API_DEFAULT_MODEL); this module never calls
the relay to list models. An active tier whose requires
the gateway model fails or cannot verify is a BLOCKER;
a shadow tier is a warning only. "运营方须先把网关模型
写进能力目录" is now a checkable rule, not a comment.
What it does NOT do, honestly:
* It does NOT validate model names against a provider or relay
catalog. Task models are deliberately not whitelisted in this
codebase (the relay catalog changes), so a model typo here is
reported as "unverified, not whitelisted" only if the caller
passes a known-model set; without one, model names are reported
as-is and the caller must check them against the relay catalog
themselves. This module never invents a catalog.
* It does NOT check shadow sample counts. That is the shadow
report's job (GET /routing-shadow); a dry-run that said "ready
to activate" without sample data would be fabrication.
* It does NOT change host env, worker state, or any file. The CLI
exits 1 when the policy is invalid or any tier was dropped or
unknown, so a deploy script can gate on it; warnings alone
(no version, fallback_models present) do not block, but are
always printed.
Loading order, mirrored from load_routing_policy in models.py:
file (CLOUD_ROUTING_POLICY_FILE) first, then JSON
(CLOUD_ROUTING_POLICY_JSON) overrides whole tier entries (not
per-field: a JSON tier entry replaces the file's tier entry
entirely), then CLOUD_ROUTING_MODE overrides the GLOBAL mode
only -- it never overrides an explicit tier mode. Failed file/JSON
sources, which the loader silently ignores, are reported here as
warnings instead of being swallowed.
"""
import argparse
import json
import os
import sys
try:
from cloud.models import ROUTING_MODES, ROUTING_TIERS
from cloud.models import parse_routing_policy
from cloud.models import parse_tier_requires
from cloud.models import evaluate_capability
from cloud.models import merge_policy_documents
except ImportError: # run as a script from the repo root
sys.path.insert(0, os.path.join(
os.path.dirname(os.path.abspath(__file__)), ".."))
from cloud.models import ROUTING_MODES, ROUTING_TIERS
from cloud.models import parse_routing_policy
from cloud.models import parse_tier_requires
from cloud.models import evaluate_capability
from cloud.models import merge_policy_documents
def _tier_dropped_reason(raw_entry):
if isinstance(raw_entry, dict):
if "mode" in raw_entry and \
raw_entry.get("mode") not in ROUTING_MODES:
return "invalid tier mode %r" % (raw_entry.get("mode"),)
if "requires" in raw_entry and \
parse_tier_requires(raw_entry.get("requires")) is None:
return "invalid tier requires %r" % (raw_entry.get("requires"),)
return "no usable model"
if isinstance(raw_entry, str):
return "empty model" if not raw_entry.strip() else "unknown"
return "unrecognised tier entry (expected string or dict)"
def _empty_report(warnings, tiers, dropped, unknown, gateway):
"""无效策略的空报告(审计批 5,N-10:纯结构整理,行为不变)。
dry_run_policy_document 的两处早期返回(非对象文档、解析
出空策略)原来各写一遍同样的 dict 骨架,只有 warnings 不同;
抽成助手后各处只传不同项。传入的列表按引用进报告(与原来
直接写字面量时同一语义),不拷贝。
"""
return {
"valid": False, "blocked": True, "version": None,
"content_fingerprint": None,
"global_mode": None, "global_mode_source": None,
"tiers": tiers, "dropped_tiers": dropped,
"unknown_tiers": unknown, "warnings": warnings,
"would_activate": [], "would_shadow": [], "would_off": [],
"gateway": gateway,
}
def dry_run_policy_document(data, env_mode_override=None,
gateway_model=None):
"""Dry-run one merged policy document (pure, no IO).
``data`` is the merged document as parse_routing_policy would
receive it. ``env_mode_override`` is the raw CLOUD_ROUTING_MODE
value (or None): a valid value overrides the global mode only,
an invalid value is ignored with a warning. ``gateway_model``
is the sub2api gateway model from env only (or None when
unset); it is never fetched from the relay here.
"""
warnings = []
tiers_report = {}
dropped = []
unknown = []
_gw = {"model": gateway_model,
"source": ("CLOUD_SUB2API_DEFAULT_MODEL"
if gateway_model else "env_unset"),
"tiers": {}}
if not isinstance(data, dict):
return _empty_report(
["policy document is not an object; "
"the loader would ignore it entirely"],
tiers_report, dropped, unknown, _gw)
raw_tiers = data.get("tiers")
if isinstance(raw_tiers, dict):
for name in sorted(raw_tiers):
if name not in ROUTING_TIERS:
unknown.append(name)
warnings.append(
"unknown tier %r is ignored by the parser" % name)
policy = parse_routing_policy(data)
if policy is None:
warnings.append(
"policy is unusable (no usable tiers, or global mode "
"outside off/shadow/active); the loader would ignore it "
"entirely and workers would keep the worker default")
return _empty_report(
warnings, tiers_report, dropped, unknown, _gw)
global_mode = policy.mode
global_source = "policy"
if env_mode_override:
if env_mode_override in ROUTING_MODES:
global_mode = env_mode_override
global_source = "env_override"
warnings.append(
"CLOUD_ROUTING_MODE overrides the global mode to %r, "
"but does NOT override explicit tier modes" %
env_mode_override)
else:
warnings.append(
"CLOUD_ROUTING_MODE %r is invalid and ignored; "
"the global mode from the policy stands" %
env_mode_override)
# Apply the env global override to the effective per-tier modes
# without mutating the parsed policy: a tier with an explicit
# mode keeps it, a tier without one follows the overridden global.
effective_policy_mode = global_mode
cap_blocked = False
for tier in ROUTING_TIERS:
raw_entry = raw_tiers.get(tier) if isinstance(raw_tiers,
dict) else None
if tier not in policy.tiers:
if raw_entry is not None:
reason = _tier_dropped_reason(raw_entry)
dropped.append(tier)
warnings.append(
"tier %r was dropped: %s; applying this policy "
"would silently not route %r tasks" %
(tier, reason, tier))
tiers_report[tier] = {
"model": None, "mode": None,
"mode_source": None, "status": "dropped",
"dropped_reason": reason,
}
else:
tiers_report[tier] = {
"model": None, "mode": None,
"mode_source": None, "status": "absent",
"dropped_reason": None,
}
continue
if tier in policy.tier_modes:
mode = policy.tier_modes[tier]
mode_source = "tier_mode"
else:
mode = effective_policy_mode
mode_source = "global_mode"
status = "active" if mode == "active" else (
"shadow" if mode == "shadow" else "off")
# Capability check (v8): the same check decide_routing
# runs, reported before activation. "not_applicable"
# when the tier carries no requirements; "unknown" when
# requirements exist but the model (or a needed field)
# is absent from the directory.
requires = policy.tier_requirements.get(tier) or {}
if not requires:
cap = {"required": None, "missing": [], "unknown": False,
"status": "not_applicable"}
else:
cap_verdict = evaluate_capability(
policy.model_capabilities, policy.tiers[tier], requires)
cap = {"required": requires, "missing": cap_verdict.missing,
"unknown": cap_verdict.unknown,
"status": cap_verdict.status}
tiers_report[tier] = {
"model": policy.tiers[tier], "mode": mode,
"mode_source": mode_source, "status": status,
"dropped_reason": None, "capabilities": cap,
}
if cap["status"] in ("fail", "unknown"):
note = ("tier %r model %r %s capability: %s" % (
tier, policy.tiers[tier],
"cannot verify" if cap["status"] == "unknown"
else "lacks",
", ".join(cap["missing"])))
if status == "active":
# An active tier that fails/unknowns its capability
# check would block dispatch (worker capability
# gate), so it blocks the dry-run too.
cap_blocked = True
warnings.append(note + "; applying this policy "
"would block %r tasks at dispatch" % tier)
else:
warnings.append(note + "; %s mode does not block "
"dispatch" % status)
# Audit fix 3(I1):网关模型检查——生产实际派发的
# 是网关模型,不是档模型;档模型干跑通过不代表
# 网关模型能过。只查 active/shadow 档(worker 的
# 网关检查只在这两种模式下跑,off 档不查);网关
# 模型未在 env 里设定时记 unknown(live 回落会去
# 继电器查首个模型,干跑绝不联网,所以只能是
# unknown,不能假装 ok)。
_gw_blocked = False
for tier in ROUTING_TIERS:
_tstatus = tiers_report[tier]["status"]
if _tstatus not in ("active", "shadow"):
_gw["tiers"][tier] = {
"status": "not_checked", "missing": [],
"unknown": False, "tier_mode": _tstatus}
continue
_req = policy.tier_requirements.get(tier) or {}
if not _req:
_gw["tiers"][tier] = {
"status": "not_applicable", "missing": [],
"unknown": False, "tier_mode": _tstatus}
continue
if gateway_model is None:
_gw["tiers"][tier] = {
"status": "unknown", "missing": [],
"unknown": True, "tier_mode": _tstatus}
_note = ("tier %r requires %s but the gateway model"
" is not set in CLOUD_SUB2API_DEFAULT_MODEL"
" (and this dry-run never queries the relay)"
" -- cannot verify it against the capability"
" directory" % (tier, sorted(_req)))
else:
_gw_verdict = evaluate_capability(
policy.model_capabilities, gateway_model, _req)
_gw["tiers"][tier] = {
"status": _gw_verdict.status,
"missing": _gw_verdict.missing,
"unknown": _gw_verdict.unknown,
"tier_mode": _tstatus}
_note = ("tier %r gateway model %r %s capability:"
" %s" % (tier, gateway_model,
"cannot verify"
if _gw_verdict.unknown else "lacks",
", ".join(_gw_verdict.missing)))
if _gw["tiers"][tier]["status"] in ("fail", "unknown"):
if _tstatus == "active":
_gw_blocked = True
warnings.append(_note + "; applying this policy "
"would block %r tasks at the gateway"
" gate" % tier)
else:
warnings.append(_note + "; shadow mode does not"
" block dispatch")
would_activate = [t for t in ROUTING_TIERS
if tiers_report[t]["status"] == "active"]
would_shadow = [t for t in ROUTING_TIERS
if tiers_report[t]["status"] == "shadow"]
would_off = [t for t in ROUTING_TIERS
if tiers_report[t]["status"] == "off"]
if policy.version is None:
warnings.append(
"no policy version: policy changes will be hard to "
"attribute in shadow reports (decisions record "
"policy_version)")
if data.get("fallback_models") is not None:
warnings.append(
"fallback_models in the policy are parsed but NOT "
"applied; only worker CLI --fallback-models enables a "
"real fallback retry")
if would_activate:
warnings.append(
"activation warning: tier(s) %s would actually route "
"tasks on next worker dispatch if this policy were "
"applied; this dry-run does not activate anything" %
", ".join(would_activate))
blocked = bool(dropped or unknown or cap_blocked or _gw_blocked)
return {
"valid": True, "blocked": blocked,
"version": policy.version,
"content_fingerprint": policy.content_fingerprint(),
"global_mode": global_mode,
"global_mode_source": global_source,
"tiers": tiers_report, "dropped_tiers": dropped,
"unknown_tiers": unknown, "warnings": warnings,
"would_activate": would_activate,
"would_shadow": would_shadow, "would_off": would_off,
"gateway": _gw,
}
def dry_run_from_env(env=None):
"""Dry-run from an env mapping, sharing merge_policy_documents
with load_routing_policy (audit batch 3, I-4) — the merge is
one function now, not a mirrored copy.
Returns the report dict. File/JSON failures that the loader
silently ignores are reported as warnings here.
"""
env = env if env is not None else os.environ
warnings = []
merged = None
path = env.get("CLOUD_ROUTING_POLICY_FILE") or ""
if path:
try:
with open(path, encoding="utf-8") as fh:
merged = json.load(fh)
except (OSError, ValueError) as exc:
warnings.append(
"policy file %r could not be read/parsed (%s); "
"the loader would ignore it" % (path, exc))
merged = None
raw = env.get("CLOUD_ROUTING_POLICY_JSON") or ""
if raw:
try:
override = json.loads(raw)
except ValueError as exc:
warnings.append(
"CLOUD_ROUTING_POLICY_JSON could not be parsed "
"(%s); the loader would ignore it" % exc)
override = None
if isinstance(override, dict):
merged = merge_policy_documents(merged, override)
_gw_model = (env.get("CLOUD_SUB2API_DEFAULT_MODEL") or "").strip()
report = dry_run_policy_document(
merged, env_mode_override=env.get("CLOUD_ROUTING_MODE") or None,
gateway_model=_gw_model or None)
# Source-failure warnings belong before the document warnings,
# and they do not change blocked (a failed source alone is not
# a dropped tier; the document report already covers validity).
report["warnings"] = warnings + report["warnings"]
return report
def main(argv=None):
parser = argparse.ArgumentParser(
description="Dry-run a routing policy (never applies it).")
parser.add_argument("--policy-file", default=None,
help="Policy JSON file (same as "
"CLOUD_ROUTING_POLICY_FILE)")
parser.add_argument("--policy-json", default=None,
help="Policy JSON string (same as "
"CLOUD_ROUTING_POLICY_JSON)")
parser.add_argument("--mode", default=None,
help="Global mode override (same as "
"CLOUD_ROUTING_MODE)")
parser.add_argument("--gateway-model", default=None,
help="Gateway model (same as "
"CLOUD_SUB2API_DEFAULT_MODEL)")
args = parser.parse_args(argv)
env = {}
if args.policy_file:
env["CLOUD_ROUTING_POLICY_FILE"] = args.policy_file
if args.policy_json:
env["CLOUD_ROUTING_POLICY_JSON"] = args.policy_json
if args.mode:
env["CLOUD_ROUTING_MODE"] = args.mode
if args.gateway_model:
env["CLOUD_SUB2API_DEFAULT_MODEL"] = args.gateway_model
report = dry_run_from_env(env)
print(json.dumps(report, indent=2, sort_keys=True))
return 1 if (not report["valid"] or report["blocked"]) else 0
if __name__ == "__main__":
raise SystemExit(main())