"""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 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 { "valid": False, "blocked": True, "version": None, "content_fingerprint": None, "global_mode": None, "global_mode_source": None, "tiers": tiers_report, "dropped_tiers": dropped, "unknown_tiers": unknown, "warnings": ["policy document is not an object; " "the loader would ignore it entirely"], "would_activate": [], "would_shadow": [], "would_off": [], "gateway": _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 { "valid": False, "blocked": True, "version": None, "content_fingerprint": None, "global_mode": None, "global_mode_source": None, "tiers": tiers_report, "dropped_tiers": dropped, "unknown_tiers": unknown, "warnings": warnings, "would_activate": [], "would_shadow": [], "would_off": [], "gateway": _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())