Files
codex-app-server/cloud/models.py
T
host e364970b46 feat: rule routing v0 for model selection (shadow/active, fail-closed)
Pre-dispatch routing only fills in tasks with no explicit model,
classified by structural signals only (plan > goal > default).
Explicit task model and worker CLI --model win over policy;
policy via CLOUD_ROUTING_POLICY_JSON/FILE + CLOUD_ROUTING_MODE.
Shadow mode records a suggestion without changing dispatch.
Only policy/shadow decisions emit a model_routing event, so the
existing events contract (fallback tests) is unchanged.
Fallback rules, effort/approval/sandbox/quota and provider are
untouched. Tests: test_models_routing.py (20 tests).
2026-11-04 18:01:44 +00:00

405 lines
15 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 os
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")
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):
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _clean_tiers(raw):
"""Return {tier: model} keeping only valid non-empty entries."""
tiers = {}
if not isinstance(raw, dict):
return tiers
for tier in ROUTING_TIERS:
model = _clean_model_name(raw.get(tier))
if isinstance(model, str):
# A tier entry may itself be a dict {"model": ...}; accept
# that shape too so policy authors are not punished for it.
tiers[tier] = model
elif isinstance(raw.get(tier), dict):
model = _clean_model_name(raw[tier].get("model"))
if model:
tiers[tier] = model
return tiers
class RoutingPolicy:
"""A parsed, versioned routing policy (or an empty one).
``mode`` is one of ``off`` / ``shadow`` / ``active``. ``tiers`` maps
a tier name to a model name; a tier absent from the map falls back
to the worker default. ``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.
"""
def __init__(self, version=None, mode="shadow", tiers=None,
fallback_models=None):
self.version = version if isinstance(version, str) \
and version.strip() else None
self.mode = mode if mode in ROUTING_MODES else "off"
self.tiers = dict(tiers or {})
self.fallback_models = []
for item in fallback_models or []:
model = _clean_model_name(item)
if model and model not in self.fallback_models:
self.fallback_models.append(model)
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
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)
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. 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
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):
merged = None
raw = env.get("CLOUD_ROUTING_POLICY_JSON") or ""
if raw:
try:
override = json.loads(raw)
except ValueError:
override = None
if isinstance(override, dict):
if not isinstance(merged, dict):
merged = override
else:
merged = dict(merged)
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
policy = parse_routing_policy(merged)
if policy is None:
return None
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):
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 {})
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,
}
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. active policy tier model -- only when the task named no model;
4. worker default chain (config file / ``DEFAULT_MODEL``).
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 or policy.mode == "off":
return RoutingDecision(
task_id=task.get("id"), tier=tier, chosen_model=worker_model,
suggested_model=None, source="worker_default",
policy_version=getattr(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)
if policy.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)"
% (tier, tier_model), signals=signals)
# mode == "active"
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)
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