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).
This commit is contained in:
host committed 2026-11-04 18:01:44 +00:00
1 parent e6e1e4b4f3
commit e364970b46
3 files changed
+654 -3

No files matched your search

+48 -2
View File
@@ -146,7 +146,9 @@ from cloud.cost import CostStore, estimate_tokens, price_usd_micros, \
from cloud.db import connect as _db_connect
from cloud.goal_command import parse_goal_command as _parse_goal_command
from cloud.logging_config import get_logger, log_event, setup_logging
from cloud.models import ModelRouter, is_fallbackable, record_fallback
from cloud.models import ModelRouter, RoutingPolicy, decide_routing, \
is_fallbackable, load_routing_policy, parse_routing_policy, \
record_fallback, record_routing
from cloud.auto_memory import AutoMemoryStore
from cloud.auto_memory import build_injection_prefix as _auto_prefix
from cloud.auto_memory import process_due_tasks as _auto_process_due
@@ -424,6 +426,7 @@ class Worker:
def __init__(self, db_path, host_py_path, config_path=None, repo=None,
runner=None, env=None, env_key=None,
codex_config_path=None, model=None, router=None,
model_explicit=None, routing_policy=None,
max_turns=None, max_cost_usd=None, executor=None,
cost_store=None, process_factory=None,
task_timeout=1800, max_retries=1,
@@ -472,9 +475,32 @@ class Worker:
self._env = env if env is not None else os.environ
self.env_key = resolve_env_key(env_key, codex_config_path)
self.model = _resolve_model(model, config_path)
# CLI --model 是否显式:未传参时按“是否传了 model 参数”推断,
# 传了即显式(路由绝不覆盖已钉死的部署模型);调用方也可
# 显式传 model_explicit=False,把 model 当作普通默认值。
if model_explicit is None:
self.model_explicit = model is not None
else:
self.model_explicit = bool(model_explicit)
if router is not None and not isinstance(router, ModelRouter):
raise ValueError("router 必须是 cloud.models.ModelRouter")
self.router = router
# 规则路由策略 v0:显式 RoutingPolicy > dict(解析)> 未传时
# 从环境读(CLOUD_ROUTING_POLICY_*,见 models.py)。
# 解析失败 / 未配时为 None,派发回落旧链,绝不因此拒绝任务。
# 策略里的 fallback_models 在 v0 不启用真实重试(见 models.py),
# 只有 CLI --fallback-models 能启用,策略文件 alone 不能烧钱。
if isinstance(routing_policy, RoutingPolicy):
self.routing_policy = routing_policy
elif isinstance(routing_policy, dict):
self.routing_policy = parse_routing_policy(routing_policy)
elif routing_policy is None:
try:
self.routing_policy = load_routing_policy(self._env)
except Exception:
self.routing_policy = None
else:
raise ValueError("routing_policy 必须是 RoutingPolicy 或 dict")
# 真实 fallback 换模型列表:默认空 = 关闭,只记事件不换模型
# (老行为,生产 worker 未配该参数时绝不突然多花模型钱)。
# 归一化:去空白、去重保序、去掉与主模型相同的项(换成同一
@@ -5817,7 +5843,27 @@ class Worker:
task = dict(task)
if not task.get("model"):
task["model"] = self.model
# 规则路由 v0(只在模型仍为空时跑一次,整个派发只算一次,
# 后续重试/fallback/续跑都复用这里定下的 task["model"],
# 不再重算;网关步已填的 sub2api 默认模型在此之前已生效,
# 路由不覆盖它)。显式任务模型与 worker CLI 显式模型
# 在 decide_routing 内同样优先,见 models.py 的优先级。
decision = decide_routing(
task, self.model, policy=self.routing_policy,
worker_model_explicit=self.model_explicit)
# 只在策略真正介入时记事件与审计:worker_default /
# worker_explicit 就是旧行为,记一条 model_routing 会
# 打破现有调用方对 events 的假设(例如 fallback 单测
# 断言 events[0] 就是 fallback 事件、无事件时为空),
# 而旧行为本身已由 task["model"] / actual_model 体现,
# 不需要再用一条事件重复说明。
if decision.source in ("policy", "shadow"):
record_routing({"id": task_id, "events": events}, decision)
self._audit(task["project_id"], "worker_routing",
"%s tier=%s source=%s applied=%s" % (
task_id, decision.tier, decision.source,
decision.applied))
task["model"] = decision.chosen_model or self.model
actual_model = task["model"]
# 有限重试与真实 fallback 的优先级:取消不重试;一次尝试失败
# 且可 fallback、且显式配了 fallback_models 时,优先真换模型