fix: audit findings (fallback dead code, silent fallback, registry re-parse, shadow sort, gateway capability)
- RoutingPolicy.fallback_models: move the cleaning loop into __init__ (it was unreachable after tier_mode's return, so the attribute never existed); still parsed but not applied. - Capability fallback: RoutingDecision gains capability_fallback; the worker audits worker_capability_fallback when it fires, so a tier-model rejection followed by a worker-default fallback is no longer invisible (source stays worker_default, no model_routing event, so shadow samples are unaffected). - Registry: resolve_registry_agent checks skills directly against the loaded definition instead of re-parsing a hand-built dict, so future fields cannot be silently dropped the way tool_approvals was in v9. - Shadow: evaluate_shadow sorts rows earliest-first by created_at (id tiebreak) when timestamps are present, so dedup no longer depends on caller row order; the server path (which strips created_at) is unchanged. - Gateway: when sub2api fills the default model, the worker now checks it against the tier's capability requirements with the same mode semantics as v8 -- shadow only audits, active fails fast with worker_capability_gate_rejected and never calls the runner. No worker-default fallback here: the gateway model is the task's gateway model, falling back to a worker default would run against the wrong gateway.
This commit is contained in:
1 parent
a51919f24b
commit
f9a77577c5
5 files changed
+392
-34
No files matched your search
+14
-19
@@ -460,25 +460,20 @@ def resolve_registry_agent(name, version=None, agents_dir=None,
|
|||||||
"registry file %s says version %r, not requested %r" %
|
"registry file %s says version %r, not requested %r" %
|
||||||
(filename, definition.version, version))
|
(filename, definition.version, version))
|
||||||
if known_skills is not None:
|
if known_skills is not None:
|
||||||
# Re-parse for skill existence only (load already validated
|
# Audit fix: skill existence is now checked directly
|
||||||
# format); the re-parsed definition is the one returned, so
|
# against the loaded definition, instead of re-parsing
|
||||||
# callers never see a definition that failed skill checks.
|
# a hand-built dict. The old re-parse silently dropped
|
||||||
definition = parse_agent_definition(
|
# any field not copied into that dict (tool_approvals
|
||||||
{
|
# was lost exactly this way in v9, and the next new
|
||||||
"name": definition.name,
|
# field would have been lost the same way). The
|
||||||
"version": definition.version,
|
# original definition is returned unchanged, so future
|
||||||
"description": definition.description,
|
# fields survive automatically. The error message is
|
||||||
"instructions": definition.instructions,
|
# kept identical to parse_agent_definition's.
|
||||||
"skills": definition.skills,
|
known = set(known_skills)
|
||||||
"tools": definition.tools,
|
missing = [s for s in definition.skills if s not in known]
|
||||||
# v9: the re-parse must carry tool_approvals, or
|
if missing:
|
||||||
# a registry definition's declaration would be
|
raise ValueError("agent.skills unknown skill(s): %s"
|
||||||
# silently dropped on the server path (inline
|
% ", ".join(missing))
|
||||||
# definitions never re-parse, so they were fine).
|
|
||||||
"tool_approvals": definition.tool_approvals,
|
|
||||||
"defaults": definition.defaults,
|
|
||||||
},
|
|
||||||
known_skills=known_skills)
|
|
||||||
return definition
|
return definition
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+24
-7
@@ -336,6 +336,19 @@ class RoutingPolicy:
|
|||||||
for tier, tier_mode in (tier_modes or {}).items():
|
for tier, tier_mode in (tier_modes or {}).items():
|
||||||
if tier in self.tiers and tier_mode in ROUTING_MODES:
|
if tier in self.tiers and tier_mode in ROUTING_MODES:
|
||||||
self.tier_modes[tier] = tier_mode
|
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)
|
||||||
|
if model and model not in self.fallback_models:
|
||||||
|
self.fallback_models.append(model)
|
||||||
self.model_capabilities = parse_model_capabilities(
|
self.model_capabilities = parse_model_capabilities(
|
||||||
model_capabilities)
|
model_capabilities)
|
||||||
self.tier_requirements = {}
|
self.tier_requirements = {}
|
||||||
@@ -348,11 +361,6 @@ class RoutingPolicy:
|
|||||||
def tier_mode(self, tier):
|
def tier_mode(self, tier):
|
||||||
"""Effective mode for one tier: explicit tier mode, else global."""
|
"""Effective mode for one tier: explicit tier mode, else global."""
|
||||||
return self.tier_modes.get(tier, self.mode)
|
return self.tier_modes.get(tier, self.mode)
|
||||||
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):
|
def parse_routing_policy(data):
|
||||||
@@ -466,7 +474,8 @@ class RoutingDecision:
|
|||||||
"""One pre-dispatch model decision, kept auditable by construction."""
|
"""One pre-dispatch model decision, kept auditable by construction."""
|
||||||
|
|
||||||
def __init__(self, task_id, tier, chosen_model, suggested_model,
|
def __init__(self, task_id, tier, chosen_model, suggested_model,
|
||||||
source, policy_version, applied, reason, signals):
|
source, policy_version, applied, reason, signals,
|
||||||
|
capability_fallback=False):
|
||||||
self.task_id = task_id
|
self.task_id = task_id
|
||||||
self.tier = tier
|
self.tier = tier
|
||||||
self.chosen_model = chosen_model
|
self.chosen_model = chosen_model
|
||||||
@@ -476,6 +485,13 @@ class RoutingDecision:
|
|||||||
self.applied = applied
|
self.applied = applied
|
||||||
self.reason = reason
|
self.reason = reason
|
||||||
self.signals = dict(signals or {})
|
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:
|
def to_event(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -488,6 +504,7 @@ class RoutingDecision:
|
|||||||
"policy_version": self.policy_version,
|
"policy_version": self.policy_version,
|
||||||
"applied": self.applied,
|
"applied": self.applied,
|
||||||
"reason": self.reason,
|
"reason": self.reason,
|
||||||
|
"capability_fallback": self.capability_fallback,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -614,7 +631,7 @@ def decide_routing(task, worker_model, policy=None,
|
|||||||
policy_version=policy.version, applied=True,
|
policy_version=policy.version, applied=True,
|
||||||
reason="%s tier model %s%s, worker default satisfies"
|
reason="%s tier model %s%s, worker default satisfies"
|
||||||
" requirements, falling back" % (tier, tier_model, cap_note),
|
" requirements, falling back" % (tier, tier_model, cap_note),
|
||||||
signals=signals)
|
signals=signals, capability_fallback=True)
|
||||||
return RoutingDecision(
|
return RoutingDecision(
|
||||||
task_id=task.get("id"), tier=tier, chosen_model=None,
|
task_id=task.get("id"), tier=tier, chosen_model=None,
|
||||||
suggested_model=tier_model, source="capability_blocked",
|
suggested_model=tier_model, source="capability_blocked",
|
||||||
|
|||||||
+33
-8
@@ -111,18 +111,43 @@ def evaluate_shadow(audit_rows, tasks_by_id, costs_by_task,
|
|||||||
explicit_n=0, min_samples=MIN_SAMPLES):
|
explicit_n=0, min_samples=MIN_SAMPLES):
|
||||||
"""Evaluate shadow evidence from already-fetched rows.
|
"""Evaluate shadow evidence from already-fetched rows.
|
||||||
|
|
||||||
``audit_rows``: dicts with at least ``detail`` (and optionally
|
``audit_rows``: dicts with at least ``detail``. When rows
|
||||||
``created_at`` for earliest-first dedup; without it, first-seen
|
carry ``created_at`` (and optionally ``id`` as a tiebreak),
|
||||||
wins). ``tasks_by_id``: task_id -> {"status": str}.
|
they are sorted earliest-first inside this function before
|
||||||
``costs_by_task``: task_id -> {"total_tokens": int,
|
dedup, so the first-seen sample for a task is its earliest
|
||||||
"usd_micros": int|None, "unpriced_tokens": int}.
|
routing audit regardless of the caller's row order. Rows
|
||||||
``explicit_n``: number of tasks with a creation-time model in
|
without ``created_at`` keep their given order (stable sort).
|
||||||
the same project (counted separately, never as routed samples).
|
The server endpoint passes rows already ordered, and it
|
||||||
|
strips ``created_at`` before calling -- that path is
|
||||||
|
unaffected, but a future caller passing raw rows no longer
|
||||||
|
silently changes dedup semantics. ``tasks_by_id``: task_id
|
||||||
|
-> {"status": str}. ``costs_by_task``: task_id ->
|
||||||
|
{"total_tokens": int, "usd_micros": int|None,
|
||||||
|
"unpriced_tokens": int}. ``explicit_n``: number of tasks
|
||||||
|
with a creation-time model in the same project (counted
|
||||||
|
separately, never as routed samples).
|
||||||
"""
|
"""
|
||||||
tiers = {tier: _empty_tier() for tier in TIERS}
|
tiers = {tier: _empty_tier() for tier in TIERS}
|
||||||
seen = set()
|
seen = set()
|
||||||
routed_task_ids = set()
|
routed_task_ids = set()
|
||||||
for row in audit_rows or []:
|
rows = list(audit_rows or [])
|
||||||
|
if any(isinstance(r, dict) and r.get("created_at") is not None
|
||||||
|
for r in rows):
|
||||||
|
def _sort_key(row):
|
||||||
|
created = row.get("created_at") if isinstance(row, dict) \
|
||||||
|
else None
|
||||||
|
row_id = row.get("id") if isinstance(row, dict) else None
|
||||||
|
# Rows without a timestamp sort first only if they
|
||||||
|
# were first already: use a sentinel that keeps
|
||||||
|
# stable order among themselves, but never jumps
|
||||||
|
# ahead of a timestamped row -- so put them last.
|
||||||
|
return (created is None,
|
||||||
|
created if isinstance(created, (int, float))
|
||||||
|
else 0,
|
||||||
|
row_id if isinstance(row_id, (int, float))
|
||||||
|
else 0)
|
||||||
|
rows = sorted(rows, key=_sort_key)
|
||||||
|
for row in rows:
|
||||||
parsed = parse_worker_routing_detail(
|
parsed = parse_worker_routing_detail(
|
||||||
(row or {}).get("detail"))
|
(row or {}).get("detail"))
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""Audit fixes: fallback_models storage, capability fallback audit,
|
||||||
|
registry skill check without re-parse, shadow dedup sorting, and
|
||||||
|
gateway-model capability check.
|
||||||
|
|
||||||
|
Run: python3 -m unittest discover -s cloud/tests -p "test_audit_fixes.py" -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from cloud.agent_def import default_agents_dir, resolve_registry_agent
|
||||||
|
from cloud.models import decide_routing, parse_routing_policy
|
||||||
|
from cloud.routing_shadow import evaluate_shadow
|
||||||
|
from cloud.server import init_db
|
||||||
|
from cloud.worker import Worker
|
||||||
|
|
||||||
|
|
||||||
|
class FallbackModelsTestCase(unittest.TestCase):
|
||||||
|
def test_policy_stores_fallback_models(self):
|
||||||
|
policy = parse_routing_policy({
|
||||||
|
"mode": "shadow",
|
||||||
|
"tiers": {"default": "m1"},
|
||||||
|
"fallback_models": ["m2", "m2", " ", "m3"],
|
||||||
|
})
|
||||||
|
# Previously unreachable dead code: the attribute did
|
||||||
|
# not exist at all on a RoutingPolicy.
|
||||||
|
self.assertEqual(policy.fallback_models, ["m2", "m3"])
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityFallbackAuditTestCase(unittest.TestCase):
|
||||||
|
def test_decision_flags_capability_fallback(self):
|
||||||
|
policy = parse_routing_policy({
|
||||||
|
"mode": "active",
|
||||||
|
"tiers": {"plan": {"model": "no-tools",
|
||||||
|
"requires": {"tool_calling": True}}},
|
||||||
|
"model_capabilities": {
|
||||||
|
"no-tools": {"tool_calling": False},
|
||||||
|
"worker-model": {"tool_calling": True},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
decision = decide_routing(
|
||||||
|
{"id": "t1", "plan": True}, "worker-model", policy=policy)
|
||||||
|
self.assertEqual(decision.source, "worker_default")
|
||||||
|
self.assertTrue(decision.capability_fallback)
|
||||||
|
self.assertEqual(decision.chosen_model, "worker-model")
|
||||||
|
|
||||||
|
def test_normal_worker_default_is_not_flagged(self):
|
||||||
|
policy = parse_routing_policy({
|
||||||
|
"mode": "active", "tiers": {"plan": "plan-model"}})
|
||||||
|
decision = decide_routing(
|
||||||
|
{"id": "t1", "plan": True}, "worker-model", policy=policy)
|
||||||
|
self.assertFalse(decision.capability_fallback)
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityFallbackWorkerAuditTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||||
|
init_db(self.db_path)
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_fallback_is_audited(self):
|
||||||
|
_insert_task(self.db_path, "F1")
|
||||||
|
|
||||||
|
def recording(cmd):
|
||||||
|
self.calls.append(list(cmd))
|
||||||
|
return _completed_runner(cmd)
|
||||||
|
|
||||||
|
worker = Worker(
|
||||||
|
self.db_path, "/fake/host.py", runner=recording,
|
||||||
|
env={"XCM_API_KEY": "test-placeholder"},
|
||||||
|
model="worker-model", model_explicit=False,
|
||||||
|
routing_policy={
|
||||||
|
"mode": "active",
|
||||||
|
"tiers": {"default": {"model": "no-tools",
|
||||||
|
"requires": {"tool_calling": True}}},
|
||||||
|
"model_capabilities": {
|
||||||
|
"no-tools": {"tool_calling": False},
|
||||||
|
"worker-model": {"tool_calling": True},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
result = worker.run_one()
|
||||||
|
self.assertEqual(result["status"], "completed")
|
||||||
|
from cloud.auth import AuthStore
|
||||||
|
store = AuthStore(self.db_path)
|
||||||
|
try:
|
||||||
|
entries = [e for e in store.list_audit("p1")
|
||||||
|
if e["action"] == "worker_capability_fallback"]
|
||||||
|
finally:
|
||||||
|
store.conn.close()
|
||||||
|
self.assertTrue(entries)
|
||||||
|
self.assertIn("no-tools", entries[0]["detail"])
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrySkillCheckTestCase(unittest.TestCase):
|
||||||
|
def test_registry_definition_keeps_all_fields(self):
|
||||||
|
# The old re-parse dropped any field not copied into
|
||||||
|
# its hand-built dict; the direct check must return
|
||||||
|
# the loaded definition unchanged, including
|
||||||
|
# tool_approvals, and still reject unknown skills.
|
||||||
|
definition = resolve_registry_agent(
|
||||||
|
"research-brief-approval", agents_dir=default_agents_dir(),
|
||||||
|
known_skills=["live-info"])
|
||||||
|
self.assertEqual(definition.tool_approvals,
|
||||||
|
{"websearch": "ask"})
|
||||||
|
self.assertEqual(definition.tools, ["websearch"])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
resolve_registry_agent(
|
||||||
|
"research-brief-approval",
|
||||||
|
agents_dir=default_agents_dir(),
|
||||||
|
known_skills=["other-skill"])
|
||||||
|
|
||||||
|
|
||||||
|
class ShadowSortTestCase(unittest.TestCase):
|
||||||
|
def test_dedup_uses_earliest_row_regardless_of_input_order(self):
|
||||||
|
rows = [
|
||||||
|
{"detail": "t1 tier=default source=shadow applied=False"
|
||||||
|
" suggested=new-model chosen=worker-model",
|
||||||
|
"created_at": 200, "id": 2},
|
||||||
|
{"detail": "t1 tier=default source=shadow applied=False"
|
||||||
|
" suggested=old-model chosen=worker-model",
|
||||||
|
"created_at": 100, "id": 1},
|
||||||
|
]
|
||||||
|
report = evaluate_shadow(
|
||||||
|
rows, {"t1": {"status": "completed"}}, {})
|
||||||
|
self.assertEqual(
|
||||||
|
report["tiers"]["default"]["suggested_models"],
|
||||||
|
{"old-model": 1})
|
||||||
|
|
||||||
|
|
||||||
|
def _completed_runner(cmd):
|
||||||
|
if "dispatch" in cmd:
|
||||||
|
name = cmd[cmd.index("--name") + 1]
|
||||||
|
else:
|
||||||
|
name = cmd[cmd.index("--task") + 1]
|
||||||
|
return SimpleNamespace(
|
||||||
|
returncode=0,
|
||||||
|
stdout='{"task": "%s", "status": "completed", "text": "done"}' % name,
|
||||||
|
stderr="")
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_task(db_path, task_id):
|
||||||
|
now = time.time()
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||||||
|
" idempotency_key, depends_on, priority, created_by,"
|
||||||
|
" created_at, updated_at, last_heartbeat)"
|
||||||
|
" VALUES(?, 'p1', 'do work', 'queued', NULL, '[]', 0,"
|
||||||
|
" 'tester', ?, ?, NULL)", (task_id, now, now))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayCapabilityTestCase(unittest.TestCase):
|
||||||
|
"""Gateway check is exercised through the worker's routing
|
||||||
|
policy path by stubbing _ensure_gateway: the worker's
|
||||||
|
run_one gateway step is the only place the sub2api
|
||||||
|
default model is filled, so the check must live there."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||||
|
init_db(self.db_path)
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _worker(self, policy_doc, gateway_model):
|
||||||
|
def recording(cmd):
|
||||||
|
self.calls.append(list(cmd))
|
||||||
|
return _completed_runner(cmd)
|
||||||
|
|
||||||
|
worker = Worker(self.db_path, "/fake/host.py",
|
||||||
|
runner=recording,
|
||||||
|
env={"XCM_API_KEY": "test-placeholder"},
|
||||||
|
routing_policy=policy_doc)
|
||||||
|
# Stub the gateway to behave like sub2api filling a
|
||||||
|
# default model, without touching real gateway code.
|
||||||
|
worker._ensure_gateway = lambda task: ("sub2api", {})
|
||||||
|
worker._sub2api_default_model = lambda env: gateway_model
|
||||||
|
return worker
|
||||||
|
|
||||||
|
def test_active_gateway_incapable_blocks_before_runner(self):
|
||||||
|
_insert_task(self.db_path, "G1")
|
||||||
|
worker = self._worker({
|
||||||
|
"mode": "active",
|
||||||
|
"tiers": {"default": {"model": "policy-model",
|
||||||
|
"requires": {"tool_calling": True}}},
|
||||||
|
"model_capabilities": {
|
||||||
|
"gateway-model": {"tool_calling": False},
|
||||||
|
},
|
||||||
|
}, gateway_model="gateway-model")
|
||||||
|
result = worker.run_one()
|
||||||
|
self.assertTrue(result["dispatched"])
|
||||||
|
self.assertEqual(result["status"], "failed")
|
||||||
|
self.assertIn("tool_calling", result["error"])
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
|
||||||
|
def test_shadow_gateway_incapable_only_audits(self):
|
||||||
|
_insert_task(self.db_path, "G2")
|
||||||
|
worker = self._worker({
|
||||||
|
"mode": "shadow",
|
||||||
|
"tiers": {"default": {"model": "policy-model",
|
||||||
|
"requires": {"tool_calling": True}}},
|
||||||
|
"model_capabilities": {
|
||||||
|
"gateway-model": {"tool_calling": False},
|
||||||
|
},
|
||||||
|
}, gateway_model="gateway-model")
|
||||||
|
result = worker.run_one()
|
||||||
|
self.assertEqual(result["status"], "completed")
|
||||||
|
from cloud.auth import AuthStore
|
||||||
|
store = AuthStore(self.db_path)
|
||||||
|
try:
|
||||||
|
entries = [e for e in store.list_audit("p1")
|
||||||
|
if e["action"]
|
||||||
|
== "worker_capability_gateway_shadow"]
|
||||||
|
finally:
|
||||||
|
store.conn.close()
|
||||||
|
self.assertTrue(entries)
|
||||||
@@ -156,6 +156,7 @@ from cloud.db import connect as _db_connect
|
|||||||
from cloud.goal_command import parse_goal_command as _parse_goal_command
|
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.logging_config import get_logger, log_event, setup_logging
|
||||||
from cloud.models import ModelRouter, RoutingPolicy, decide_routing, \
|
from cloud.models import ModelRouter, RoutingPolicy, decide_routing, \
|
||||||
|
check_model_capabilities, classify_task, \
|
||||||
is_fallbackable, load_routing_policy, parse_routing_policy, \
|
is_fallbackable, load_routing_policy, parse_routing_policy, \
|
||||||
record_fallback, record_routing
|
record_fallback, record_routing
|
||||||
from cloud.auto_memory import AutoMemoryStore
|
from cloud.auto_memory import AutoMemoryStore
|
||||||
@@ -6019,10 +6020,12 @@ class Worker:
|
|||||||
# xcmapi 模型名去撞 sub2api)。xcmapi 下 _ensure_gateway
|
# xcmapi 模型名去撞 sub2api)。xcmapi 下 _ensure_gateway
|
||||||
# 直接返回 (None, None),不做任何事。注意:这里改的是
|
# 直接返回 (None, None),不做任何事。注意:这里改的是
|
||||||
# 原 task dict,随后 task = dict(task) 会原样拷走。
|
# 原 task dict,随后 task = dict(task) 会原样拷走。
|
||||||
|
_gateway_filled = False
|
||||||
try:
|
try:
|
||||||
gateway_provider, gateway_env = self._ensure_gateway(task)
|
gateway_provider, gateway_env = self._ensure_gateway(task)
|
||||||
if not task.get("model") and gateway_provider == "sub2api":
|
if not task.get("model") and gateway_provider == "sub2api":
|
||||||
task["model"] = self._sub2api_default_model(gateway_env)
|
task["model"] = self._sub2api_default_model(gateway_env)
|
||||||
|
_gateway_filled = True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error = "网关准备失败: %s" % exc
|
error = "网关准备失败: %s" % exc
|
||||||
self._set_status(task_id, "failed")
|
self._set_status(task_id, "failed")
|
||||||
@@ -6044,6 +6047,78 @@ class Worker:
|
|||||||
"stdout": "", "error": error, "events": [],
|
"stdout": "", "error": error, "events": [],
|
||||||
"cost_record_id": None}
|
"cost_record_id": None}
|
||||||
|
|
||||||
|
# Audit fix:网关能力检查——此前路由决策只在模型
|
||||||
|
# 为空时跑,sub2api 网关先填默认模型后决策直接跳
|
||||||
|
# 过,能力门在生产路径上完全不生效。补齐:网关填
|
||||||
|
# 的模型同样按档需求检查,档模式与 v8 一致:shadow
|
||||||
|
# 只审计不拦,active 不满足则 fail-fast(同款工具
|
||||||
|
# 门形状,清占位、不调 runner)。没有策略、没有档
|
||||||
|
# 需求、档模式 off 时一律不查,旧行为不变。注意:
|
||||||
|
# 这里没有 worker 默认可回落(网关模型就是该任务
|
||||||
|
# 的网关内模型,换成 worker 默认会跑错网关),所以
|
||||||
|
# active 下不满足即拦,不存在“先试 worker 默认”
|
||||||
|
# 的中间态;运营方须先把网关模型写进能力目录,
|
||||||
|
# 否则 unknown 同样拦(fail-closed,与 v8 一致)。
|
||||||
|
if _gateway_filled and self.routing_policy is not None:
|
||||||
|
_gw_tier = classify_task(task)
|
||||||
|
_gw_requires = self.routing_policy.tier_requirements.get(
|
||||||
|
_gw_tier) or {}
|
||||||
|
if _gw_requires:
|
||||||
|
_gw_mode = self.routing_policy.tier_mode(_gw_tier)
|
||||||
|
if _gw_mode in ("active", "shadow"):
|
||||||
|
_gw_ok, _gw_missing, _gw_unknown = \
|
||||||
|
check_model_capabilities(
|
||||||
|
self.routing_policy.model_capabilities,
|
||||||
|
task.get("model"), _gw_requires)
|
||||||
|
if not _gw_ok:
|
||||||
|
_gw_note = ("gateway model %s %s capability:"
|
||||||
|
" %s" % (
|
||||||
|
task.get("model"),
|
||||||
|
"cannot verify"
|
||||||
|
if _gw_unknown else "lacks",
|
||||||
|
", ".join(_gw_missing)))
|
||||||
|
if _gw_mode == "shadow":
|
||||||
|
self._audit(
|
||||||
|
task["project_id"],
|
||||||
|
"worker_capability_gateway_shadow",
|
||||||
|
"%s tier=%s %s (shadow, not blocked)"
|
||||||
|
% (task_id, _gw_tier, _gw_note))
|
||||||
|
else:
|
||||||
|
_gw_error = (
|
||||||
|
"%s tier gateway model %s; refusing"
|
||||||
|
" to dispatch an incapable or"
|
||||||
|
" unverifiable model"
|
||||||
|
% (_gw_tier, _gw_note))
|
||||||
|
self._set_status(task_id, "failed")
|
||||||
|
self._write_execution_result(
|
||||||
|
task_id, error_text=_gw_error,
|
||||||
|
actual_model=self.model,
|
||||||
|
started_at=started_at,
|
||||||
|
finished_at=time.time(),
|
||||||
|
only_if_status="failed")
|
||||||
|
self._audit(
|
||||||
|
task["project_id"],
|
||||||
|
"worker_capability_gate_rejected",
|
||||||
|
"%s: %s" % (task_id, _gw_error))
|
||||||
|
log_event(
|
||||||
|
self.logger,
|
||||||
|
"worker_capability_gate_rejected",
|
||||||
|
task_id=task_id,
|
||||||
|
project_id=task["project_id"],
|
||||||
|
status="failed")
|
||||||
|
self._running_clear(task_id)
|
||||||
|
return {"dispatched": True,
|
||||||
|
"task_id": task_id,
|
||||||
|
"host_name":
|
||||||
|
self.executor._host_name(task)
|
||||||
|
if hasattr(self.executor,
|
||||||
|
"_host_name")
|
||||||
|
else task_id,
|
||||||
|
"status": "failed",
|
||||||
|
"executor": self._executor_name(),
|
||||||
|
"stdout": "", "error": _gw_error,
|
||||||
|
"events": [], "cost_record_id": None}
|
||||||
|
|
||||||
task = dict(task)
|
task = dict(task)
|
||||||
if not task.get("model"):
|
if not task.get("model"):
|
||||||
# 规则路由 v0(只在模型仍为空时跑一次,整个派发只算一次,
|
# 规则路由 v0(只在模型仍为空时跑一次,整个派发只算一次,
|
||||||
@@ -6103,6 +6178,19 @@ class Worker:
|
|||||||
decision.applied,
|
decision.applied,
|
||||||
decision.suggested_model or "-",
|
decision.suggested_model or "-",
|
||||||
decision.chosen_model or "-"))
|
decision.chosen_model or "-"))
|
||||||
|
# Audit fix:能力回落以前不记任何审计(source 是
|
||||||
|
# worker_default,不在 policy/shadow 之列),运营
|
||||||
|
# 方看不到档模型被能力门否掉过。现在用 decision
|
||||||
|
# 上的 capability_fallback 标志单独记一条审计,
|
||||||
|
# 不改 source、不记 model_routing 事件(影子样本
|
||||||
|
# 只算 policy/shadow 决策,回落不是一次路由决策)。
|
||||||
|
if getattr(decision, "capability_fallback", False):
|
||||||
|
self._audit(task["project_id"],
|
||||||
|
"worker_capability_fallback",
|
||||||
|
"%s tier=%s chosen=%s reason=%s" % (
|
||||||
|
task_id, decision.tier,
|
||||||
|
decision.chosen_model or "-",
|
||||||
|
decision.reason))
|
||||||
task["model"] = decision.chosen_model or self.model
|
task["model"] = decision.chosen_model or self.model
|
||||||
# agent 指令通道(v2):local/docker 由 sandbox 的
|
# agent 指令通道(v2):local/docker 由 sandbox 的
|
||||||
# --developer-instructions 派发,prompt 保持干净;通用
|
# --developer-instructions 派发,prompt 保持干净;通用
|
||||||
|
|||||||
Reference in new issue
Block a user