From 230639544d60ab9d913fb542bf09fd4b279a98cd Mon Sep 17 00:00:00 2001 From: Xiang Shen Date: Fri, 23 Oct 2026 22:04:28 +0000 Subject: [PATCH 1/4] Remove the unused fixed menu Claude routing path claude_routing.request_routing_decision and route_pre_tool_use offered a fixed two model Claude menu, but no ug code calls them: Claude subagent and first prompt routing go through smart_routing.v2. Keep the spawn tool detector, notice config, and artifact helpers, and move the decision log, task field, Task alias, and legacy notice checks onto the v2 path. Co-authored-by: Isaac --- src/ucode/smart_routing/claude_routing.py | 93 +----- tests/README.md | 2 +- tests/integration/README.md | 2 +- tests/test_claude_routing.py | 342 ++-------------------- tests/test_claude_smart_routing_v2.py | 59 ++++ 5 files changed, 82 insertions(+), 416 deletions(-) diff --git a/src/ucode/smart_routing/claude_routing.py b/src/ucode/smart_routing/claude_routing.py index fa88e0359..db2522911 100644 --- a/src/ucode/smart_routing/claude_routing.py +++ b/src/ucode/smart_routing/claude_routing.py @@ -1,29 +1,17 @@ -"""Databricks AI Gateway routing helpers for Claude Code sessions and subagents. +"""Claude Code smart-routing hook helpers. Claude-specific configuration on top of the shared -:mod:`ucode.smart_routing.routing` core. The ``task_v2`` router infers a -"Claude Code" (``cc``) scenario when it is offered only Claude arms, and that -scenario REQUIRES its full menu — both ``claude-opus-4-8`` and -``claude-sonnet-5`` — or it returns BAD_REQUEST. So both arms are always offered. +:mod:`ucode.smart_routing.routing` core: the spawn-tool detector, the subagent +notice, and the artifact paths. :mod:`ucode.smart_routing.v2` builds the requests. """ from __future__ import annotations -# Re-exported so tests can patch the shared ``urlopen`` seam via -# ``claude_routing.urllib.request`` — the call lives in ``routing``, but Python -# modules are singletons so patching this name patches the one call site. -import urllib.request # noqa: F401 from typing import Any from ucode.config_io import APP_DIR from ucode.smart_routing import routing -from ucode.smart_routing.routing import RoutingDecision -ROUTER_NAME = routing.ROUTER_NAME -REQUEST_TIMEOUT_S = routing.REQUEST_TIMEOUT_S -# Frozen task_v2 "cc" scenario menu (ai-gateway CanonicalModelNames): the router -# rejects the request unless BOTH are offered as route_options. -CLAUDE_ROUTE_ARMS = ("claude-opus-4-8", "claude-sonnet-5") # Claude Code's subagent-spawn tool (renamed Task -> Agent); match both. SPAWN_AGENT_TOOL_NAMES = ("agent", "task") CANARY_PATH = APP_DIR / "claude-smart-routing-canary.json" @@ -36,8 +24,6 @@ leading_newline=True, ) -_normalize_model = routing.normalize_model - # Claude Code CLI options that consume a following value (from `claude --help`); # their values must not be mistaken for the seed prompt. Options whose value is @@ -78,64 +64,6 @@ ) -def request_routing_decision( - workspace: str, - token: str, - task: str, - available_models: list[str], - *, - timeout: float = REQUEST_TIMEOUT_S, -) -> tuple[RoutingDecision | None, str | None]: - """Ask the router for a servable Claude model. - - Offers the full ``cc`` menu; resolves the router's pick back to the - workspace's routable id (e.g. ``system.ai.claude-opus-4-8``). - """ - available = {_normalize_model(m): m for m in available_models if isinstance(m, str) and m} - missing = [arm for arm in CLAUDE_ROUTE_ARMS if arm not in available] - if missing: - return None, f"required Claude routing models are unavailable: {', '.join(missing)}" - - return routing.select_route( - workspace, - token, - task, - [(arm, "claude") for arm in CLAUDE_ROUTE_ARMS], - lambda raw_model: available.get(_normalize_model(raw_model)), - router_name=routing.configured_router_name(), - timeout=timeout, - ) - - -def route_pre_tool_use( - payload: dict[str, Any], - *, - workspace: str, - token: str, - available_models: list[str], - timeout: float = REQUEST_TIMEOUT_S, - audit_decision: bool = False, -) -> dict[str, Any] | None: - """Route one Claude Code ``Agent`` (subagent-spawn) call, rewriting its model.""" - record = None - if audit_decision: - - def record(payload, task, decision, requested): - routing.write_decision_record(DECISIONS_PATH, payload, task, decision, requested) - - return routing.route_spawn_tool( - payload, - is_spawn_agent=is_spawn_agent_tool, - decision_fn=lambda task: request_routing_decision( - workspace, token, task, available_models, timeout=timeout - ), - default_task_label="Claude Code subagent task", - model_id_mapper=_claude_model_id, - notice_config=SUBAGENT_NOTICE_CONFIG, - record_decision=record, - ) - - def is_spawn_agent_tool(tool_name: Any) -> bool: """Return whether a hook payload names Claude Code's subagent spawn tool.""" if not isinstance(tool_name, str): @@ -156,18 +84,3 @@ def record_subagent_start(payload: dict[str, Any]) -> dict[str, Any]: def clear_routing_artifacts() -> None: """Remove ucode-owned routing canary and audit files.""" routing.clear_artifacts((CANARY_PATH, AUDIT_PATH, DECISIONS_PATH)) - - -def _claude_model_id(model: str) -> str: - """The model id Claude Code should launch the subagent with. - - Claude Code's ``Agent`` tool ``model`` field accepts only short family - names (``sonnet``, ``opus``, ``haiku``, ``fable``), not full workspace - ids. Map the router's pick (e.g. ``system.ai.claude-sonnet-5``) back to - its family name. - """ - normalized = _normalize_model(model) - for family in ("fable", "opus", "sonnet", "haiku"): - if f"claude-{family}-" in normalized: - return family - return model diff --git a/tests/README.md b/tests/README.md index 3338f7816..a07d4555d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -417,7 +417,7 @@ orchestration on and off. It mocks the router response, not native Claude render Existing launch tests cover caller argument preservation and file removal after exit. These checks do not cover plugin-write/process-start failures, native plugin refresh, or daemon behavior. -`test_claude_routing.py` checks that the decision log retains the exact candidate +`test_claude_smart_routing_v2.py` checks that the decision log retains the exact candidate models and router name sent in the request. `test_codex_routing.py` checks that Codex decisions carry the same request metadata. These use mocked HTTP responses. diff --git a/tests/integration/README.md b/tests/integration/README.md index 9e1deca02..047541dcc 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -561,7 +561,7 @@ bypass. Plugin generation and launch arguments are covered by component tests in `../test_claude_smart_routing_v2.py`, not by a live registration journey. Exact candidate-model and router-name preservation in decision logs has component -coverage in `../test_claude_routing.py`; this suite does not assert those fields. +coverage in `../test_claude_smart_routing_v2.py`; this suite does not assert those fields. PATH conflicts for the Smart Router skill have subprocess/component coverage in `../test_smart_router.py`: the skill uses the launching interpreter despite a different diff --git a/tests/test_claude_routing.py b/tests/test_claude_routing.py index 5b2b5307d..a886e38f0 100644 --- a/tests/test_claude_routing.py +++ b/tests/test_claude_routing.py @@ -3,290 +3,18 @@ from __future__ import annotations import json -import urllib.error - -from ucode.smart_routing import claude_routing, routing - -WS = "https://example.databricks.com" - - -class _Response: - def __init__(self, payload: dict): - self.payload = payload - - def __enter__(self): - return self - - def __exit__(self, *args): - return None - - def read(self) -> bytes: - return json.dumps(self.payload).encode("utf-8") - - -def test_routes_with_default_claude_menu(monkeypatch, tmp_path): - monkeypatch.delenv("SMART_ROUTER_NAME", raising=False) - captured = {} - - def fake_urlopen(request, timeout): - captured["url"] = request.full_url - captured["headers"] = dict(request.headers) - captured["body"] = json.loads(request.data) - captured["timeout"] = timeout - return _Response( - { - "route_selection": [ - {"route_option": {"model": "claude-opus-4-8", "harness": "claude"}} - ], - "rationale": "Cross-cutting change needs the strongest model.", - } - ) - - monkeypatch.setattr(claude_routing.urllib.request, "urlopen", fake_urlopen) - - decision, error = claude_routing.request_routing_decision( - WS, - "token", - "Refactor the parser", - ["system.ai.claude-opus-4-8", "system.ai.claude-sonnet-5"], - ) - - assert error is None - assert decision == claude_routing.RoutingDecision( - model="system.ai.claude-opus-4-8", - raw_model="claude-opus-4-8", - rationale="Cross-cutting change needs the strongest model.", - route_options=(("claude-opus-4-8", "claude"), ("claude-sonnet-5", "claude")), - router_name=claude_routing.ROUTER_NAME, - ) - assert captured["url"] == f"{WS}/ai-gateway/routing/v1/routes:select" - assert captured["headers"]["Authorization"] == "Bearer token" - # The cc-scenario menu requires BOTH Claude arms be offered. - assert captured["body"] == { - "route_options": [ - {"model": "claude-opus-4-8", "harness": "claude"}, - {"model": "claude-sonnet-5", "harness": "claude"}, - ], - "task": {"prompt": "Refactor the parser"}, - "route_selector": {"router_name": claude_routing.ROUTER_NAME}, - } - decisions_path = tmp_path / "decisions.jsonl" - routing.write_decision_record( - decisions_path, {"session_id": "session"}, "explore", decision, decision.model - ) - recorded = json.loads(decisions_path.read_text()) - assert recorded["route_options"] == captured["body"]["route_options"] - assert recorded["router_name"] == captured["body"]["route_selector"]["router_name"] - - -def test_missing_arm_short_circuits_without_calling_router(monkeypatch): - def fail(*args, **kwargs): - raise AssertionError("router must not be called when an arm is missing") - - monkeypatch.setattr(claude_routing.urllib.request, "urlopen", fail) - - # Only sonnet available; the cc menu also requires opus, so we bail early - # rather than send an under-offered request the router would reject. - decision, error = claude_routing.request_routing_decision( - WS, "token", "task", ["system.ai.claude-sonnet-5"] - ) - - assert decision is None - assert "claude-opus-4-8" in error - - -def test_router_pick_resolves_to_workspace_id(monkeypatch): - monkeypatch.setattr( - claude_routing.urllib.request, - "urlopen", - lambda request, timeout: _Response( - {"route_selection": [{"route_option": {"model": "claude-sonnet-5"}}]} - ), - ) - - decision, error = claude_routing.request_routing_decision( - WS, - "token", - "small fix", - ["databricks-claude-opus-4-8", "databricks-claude-sonnet-5"], - ) - - assert error is None - # The bare router arm maps back to the workspace's routable id. - assert decision.model == "databricks-claude-sonnet-5" - - -def test_router_failure_fails_open(monkeypatch): - monkeypatch.setattr( - claude_routing.urllib.request, - "urlopen", - lambda *args, **kwargs: (_ for _ in ()).throw(urllib.error.URLError("offline")), - ) - - decision, error = claude_routing.request_routing_decision( - WS, - "token", - "task", - ["system.ai.claude-opus-4-8", "system.ai.claude-sonnet-5"], - ) - - assert decision is None - assert "offline" in str(error) +import pytest -def test_spawn_rewrite_injects_routed_model(monkeypatch): - payload = { - "tool_name": "Agent", - "tool_input": { - "subagent_type": "Explore", - "prompt": "map the codebase", - "description": "explore", - }, - } - monkeypatch.setattr( - claude_routing, - "request_routing_decision", - lambda *args, **kwargs: ( - claude_routing.RoutingDecision( - model="system.ai.claude-opus-4-8", - raw_model="claude-opus-4-8", - rationale="Deep exploration needs the strongest model.", - ), - None, - ), - ) - - output = claude_routing.route_pre_tool_use( - payload, - workspace=WS, - token="token", - available_models=["system.ai.claude-opus-4-8", "system.ai.claude-sonnet-5"], - ) - - hook = output["hookSpecificOutput"] - # The rationale is surfaced in the systemMessage (shown to the user), not - # only in permissionDecisionReason. The model field is the short family - # name ("opus") that Claude Code's Agent tool schema accepts. - expected_message = "\n" + claude_routing.routing.format_subagent_message( - "opus", - "Deep exploration needs the strongest model.", - subagent_name="Explore", - ) - assert output["systemMessage"] == expected_message - assert hook["permissionDecision"] == "allow" - assert hook["updatedInput"] == { - "subagent_type": "Explore", - "prompt": "map the codebase", - "description": "explore", - "model": "opus", - } - assert hook["permissionDecisionReason"] == expected_message - - -def test_spawn_oss_notice_unwraps_gateway_model_and_includes_prompt(monkeypatch): - gateway_model = "anthropic-aigw-77df06ea-system.ai.glm-5-3" - monkeypatch.setattr( - claude_routing, - "request_routing_decision", - lambda *args, **kwargs: ( - claude_routing.RoutingDecision( - model=gateway_model, - raw_model="glm-5-3", - rationale="The task fits the OSS route.", - ), - None, - ), - ) - - output = claude_routing.route_pre_tool_use( - { - "tool_name": "Agent", - "tool_input": {"prompt": "Inspect the parser for edge cases"}, - }, - workspace=WS, - token="token", - available_models=[gateway_model], - ) - - expected_message = ( - "\n┌───────────────────────────────────────────────────────────────────────────┐\n" - "│ Using Unity Gateway Smart Router - Subagent │\n" - "│ Prompt : Inspect the parser for edge cases │\n" - "│ Selected Model : system.ai.glm-5-3 │\n" - "│ Reason : The task fits the OSS route. │\n" - "└───────────────────────────────────────────────────────────────────────────┘" - ) - assert output["systemMessage"] == expected_message - assert "anthropic-aigw-77df06ea" not in output["systemMessage"] - assert output["hookSpecificOutput"]["updatedInput"]["model"] == gateway_model - - -def test_task_tool_alias_is_routed(monkeypatch): - monkeypatch.setattr( - claude_routing, - "request_routing_decision", - lambda *args, **kwargs: ( - claude_routing.RoutingDecision( - model="system.ai.claude-sonnet-5", raw_model="claude-sonnet-5" - ), - None, - ), - ) - - output = claude_routing.route_pre_tool_use( - {"tool_name": "Task", "tool_input": {"subagent_type": "general", "prompt": "x"}}, - workspace=WS, - token="token", - available_models=["system.ai.claude-opus-4-8", "system.ai.claude-sonnet-5"], - ) - - assert output["hookSpecificOutput"]["updatedInput"]["model"] == "sonnet" - - -def test_spawn_routes_on_prompt_not_generic_label(monkeypatch): - # Claude Code's Agent tool puts the subagent task in `prompt`/`description`, - # not `message`. The router should receive the real prompt, not the generic - # "Claude Code subagent task" fallback. - captured = {} - - def fake_decision(workspace, token, task, models, **kwargs): - captured["task"] = task - return ( - claude_routing.RoutingDecision( - model="system.ai.claude-sonnet-5", raw_model="claude-sonnet-5" - ), - None, - ) - - monkeypatch.setattr(claude_routing, "request_routing_decision", fake_decision) - - claude_routing.route_pre_tool_use( - { - "tool_name": "Agent", - "tool_input": { - "subagent_type": "Explore", - "prompt": "find all Python entry points", - "description": "explore the repo", - }, - }, - workspace=WS, - token="token", - available_models=["system.ai.claude-opus-4-8", "system.ai.claude-sonnet-5"], - ) - - assert captured["task"] == "find all Python entry points" +from ucode.smart_routing import claude_routing, routing -def test_non_spawn_tool_has_no_opinion(): - assert ( - claude_routing.route_pre_tool_use( - {"tool_name": "Bash", "tool_input": {"command": "true"}}, - workspace=WS, - token="token", - available_models=["system.ai.claude-opus-4-8", "system.ai.claude-sonnet-5"], - ) - is None - ) +@pytest.mark.parametrize( + ("tool_name", "expected"), + [("Agent", True), ("Task", True), (" task ", True), ("Bash", False), (None, False)], +) +def test_spawn_tool_detector_matches_agent_and_legacy_task(tool_name, expected): + assert claude_routing.is_spawn_agent_tool(tool_name) is expected def test_canary_and_audit_are_written(tmp_path, monkeypatch): @@ -314,34 +42,17 @@ def test_decision_is_reconciled_with_actual_subagent_model(tmp_path, monkeypatch audit = tmp_path / "audit.jsonl" monkeypatch.setattr(claude_routing, "DECISIONS_PATH", decisions) monkeypatch.setattr(claude_routing, "AUDIT_PATH", audit) - monkeypatch.setattr( - claude_routing, - "request_routing_decision", - lambda *args, **kwargs: ( - claude_routing.RoutingDecision( - model="system.ai.claude-opus-4-8", raw_model="claude-opus-4-8" - ), - None, - ), + decision = routing.RoutingDecision( + model="system.ai.claude-opus-4-8", raw_model="system.ai.claude-opus-4-8" ) + routing.write_decision_record(decisions, {"session_id": "s1"}, "x", decision, decision.model) - claude_routing.route_pre_tool_use( - { - "session_id": "s1", - "tool_name": "Agent", - "tool_input": {"subagent_type": "Explore", "prompt": "x"}, - }, - workspace=WS, - token="token", - available_models=["system.ai.claude-opus-4-8", "system.ai.claude-sonnet-5"], - audit_decision=True, - ) record = claude_routing.record_subagent_start( - {"session_id": "s1", "agent_id": "a1", "model": "opus"} + {"session_id": "s1", "agent_id": "a1", "model": "system.ai.claude-opus-4-8"} ) - assert record["router_model"] == "claude-opus-4-8" - assert record["requested_model"] == "opus" + assert record["router_model"] == "system.ai.claude-opus-4-8" + assert record["requested_model"] == "system.ai.claude-opus-4-8" assert record["matches_router_decision"] is True @@ -353,31 +64,14 @@ def test_subagent_start_without_model_reports_unknown_not_mismatch(tmp_path, mon audit = tmp_path / "audit.jsonl" monkeypatch.setattr(claude_routing, "DECISIONS_PATH", decisions) monkeypatch.setattr(claude_routing, "AUDIT_PATH", audit) - monkeypatch.setattr( - claude_routing, - "request_routing_decision", - lambda *args, **kwargs: ( - claude_routing.RoutingDecision( - model="system.ai.claude-sonnet-5", raw_model="claude-sonnet-5" - ), - None, - ), + decision = routing.RoutingDecision( + model="system.ai.claude-sonnet-5", raw_model="system.ai.claude-sonnet-5" ) + routing.write_decision_record(decisions, {"session_id": "s2"}, "x", decision, decision.model) - claude_routing.route_pre_tool_use( - { - "session_id": "s2", - "tool_name": "Agent", - "tool_input": {"subagent_type": "Explore", "prompt": "x"}, - }, - workspace=WS, - token="token", - available_models=["system.ai.claude-opus-4-8", "system.ai.claude-sonnet-5"], - audit_decision=True, - ) record = claude_routing.record_subagent_start( {"session_id": "s2", "agent_id": "a2", "model": None} ) - assert record["requested_model"] == "sonnet" + assert record["requested_model"] == "system.ai.claude-sonnet-5" assert record["matches_router_decision"] is None diff --git a/tests/test_claude_smart_routing_v2.py b/tests/test_claude_smart_routing_v2.py index 327e2ccc7..91e1f39a0 100644 --- a/tests/test_claude_smart_routing_v2.py +++ b/tests/test_claude_smart_routing_v2.py @@ -2,6 +2,7 @@ from __future__ import annotations +import io import json import os import sys @@ -718,6 +719,64 @@ def fake_select(workspace, token, task, route_options, resolve, **kwargs): decision_record = json.loads(decisions_path.read_text()) assert decision_record["requested_model"] == "system.ai.claude-opus-4-8" + def test_decision_log_keeps_the_request_menu_and_router(self, tmp_path, monkeypatch): + monkeypatch.delenv("SMART_ROUTER_NAME", raising=False) + decisions_path = tmp_path / "decisions.jsonl" + monkeypatch.setattr(v2.claude_routing, "DECISIONS_PATH", decisions_path) + captured = {} + + def fake_urlopen(request, timeout): + captured["body"] = json.loads(request.data) + first_option = captured["body"]["route_options"][0] + return io.BytesIO( + json.dumps({"route_selection": [{"route_option": first_option}]}).encode() + ) + + monkeypatch.setattr(routing.urllib.request, "urlopen", fake_urlopen) + v2.route_claude_subagent( + { + "session_id": "s1", + "tool_name": "Agent", + "tool_input": { + "subagent_type": "Explore", + "prompt": "find all Python entry points", + "description": "explore the repo", + }, + }, + workspace="https://example.com", + token="token", + available_models=["system.ai.claude-opus-4-8", "system.ai.claude-sonnet-5"], + audit_decision=True, + ) + + body = captured["body"] + assert body["task"] == {"prompt": "find all Python entry points"} + record = json.loads(decisions_path.read_text()) + assert record["route_options"] == body["route_options"] + assert record["router_name"] == body["route_selector"]["router_name"] + assert record["router_name"] == routing.ROUTER_NAME + + def test_legacy_hook_notice_shows_the_unwrapped_gateway_model(self, monkeypatch): + gateway_model = "anthropic-aigw-77df06ea-system.ai.glm-5-3" + monkeypatch.setattr( + v2, + "_request_claude_routing_decision", + lambda *_args: ( + routing.RoutingDecision(model=gateway_model, raw_model="glm-5-3"), + None, + ), + ) + + output = v2.route_claude_subagent( + {"tool_name": "Task", "tool_input": {"prompt": "Inspect the parser"}}, + workspace="https://example.com", + token="token", + available_models=[gateway_model], + hook_output=True, + ) + + assert "Selected Model : system.ai.glm-5-3 " in output["systemMessage"] + def test_returns_routing_details_to_mod_without_writing_files(self, tmp_path, monkeypatch): monkeypatch.setenv(v2.SESSION_ENV_VAR, str(tmp_path / "env.json")) monkeypatch.setattr( From 1448cd4baba56cfbb6fc50a743720cd8c7c1f7c1 Mon Sep 17 00:00:00 2001 From: Xiang Shen Date: Fri, 23 Oct 2026 22:04:29 +0000 Subject: [PATCH 2/4] Send workspace model ids to routes:select unchanged AI Gateway now canonicalizes route option models itself (the system.ai. prefix, Anthropic gateway aliases, the [1m] hint, case, and . vs - spellings), drops models it does not support, collapses duplicate spellings, and returns the option the caller sent. So ug offers each workspace model id as is and accepts only a pick it offered, instead of normalizing names on the way out and mapping the router's canonical name back. This removes the Claude router id normalizer, the Codex GPT respelling (resolving its TODO), and the unused resolve_routed_model. Co-authored-by: Isaac --- src/ucode/smart_routing/codex_routing.py | 22 +---------- src/ucode/smart_routing/routing.py | 37 +++++++++--------- src/ucode/smart_routing/v2.py | 13 +------ tests/test_claude_smart_routing_v2.py | 42 +++++++-------------- tests/test_codex_routing.py | 48 +++++++++++------------- tests/test_codex_smart_routing_v2.py | 33 ++++++++-------- 6 files changed, 74 insertions(+), 121 deletions(-) diff --git a/src/ucode/smart_routing/codex_routing.py b/src/ucode/smart_routing/codex_routing.py index 70c157b35..85ea7a5b0 100644 --- a/src/ucode/smart_routing/codex_routing.py +++ b/src/ucode/smart_routing/codex_routing.py @@ -41,18 +41,6 @@ _normalize_model = routing.normalize_model -# TODO: Remove once server-side smart routing is more robust to duplicate model names. -def _normalize_route_model(model: str) -> str: - """Canonicalize equivalent Codex GPT spellings to one router option.""" - normalized = _normalize_model(model) - match = _GPT_RE.fullmatch(normalized) - if match is None: - return normalized - major, minor, patch, suffix = match.groups() - version = "-".join(part for part in (major, minor, patch) if part is not None) - return f"gpt-{version}{suffix or ''}" - - def request_routing_decision( workspace: str, token: str, @@ -63,8 +51,7 @@ def request_routing_decision( log: Callable[[str], None] | None = None, ) -> tuple[RoutingDecision | None, str | None]: """Ask the router for a servable Codex model.""" - available = {_normalize_route_model(model): model for model in available_models} - route_options = [(model, "codex") for model in available] + route_options = routing.route_options_for(available_models, "codex") if not route_options: return None, "no cached model services are available" router_name = routing.configured_router_name() @@ -83,18 +70,11 @@ def request_routing_decision( token, task, route_options, - lambda raw_model: available.get(_normalize_route_model(raw_model)), router_name=router_name, timeout=timeout, ) -def resolve_routed_model(raw_model: str, available_models: list[str]) -> str | None: - """Map a router arm to a model the configured workspace can serve.""" - normalized = {_normalize_route_model(model): model for model in available_models} - return normalized.get(_normalize_route_model(raw_model)) - - def route_pre_tool_use( payload: dict[str, Any], *, diff --git a/src/ucode/smart_routing/routing.py b/src/ucode/smart_routing/routing.py index 2356fe881..10f44acf9 100644 --- a/src/ucode/smart_routing/routing.py +++ b/src/ucode/smart_routing/routing.py @@ -3,9 +3,8 @@ Both the Codex and Claude Code integrations route through the workspace's configured router at ``/ai-gateway/routing/v1/routes:select``. The harness-agnostic mechanics live here — the gateway call, the decision shape, -model-name normalization, and the canary/audit/decision bookkeeping. Each -harness module (``codex_routing`` / ``claude_routing``) supplies its own route -arms, spawn-tool detector, model-id translation, and artifact paths. +and the canary/audit/decision bookkeeping. Each harness supplies its own route +options, spawn-tool detector, model-id translation, and artifact paths. """ from __future__ import annotations @@ -184,10 +183,10 @@ def _plaintext_string(value: Any) -> str | None: def normalize_model(model: str) -> str: - """Strip provider prefixes so router arms and workspace ids compare equal. + """Strip provider prefixes and lowercase a model id. ``system.ai.claude-opus-4-8`` and ``databricks-claude-opus-4-8`` both - normalize to ``claude-opus-4-8`` — the router's canonical arm vocabulary. + normalize to ``claude-opus-4-8``. """ tail = model.rsplit("/", 1)[-1] for prefix in ("databricks-", "system.ai."): @@ -209,23 +208,26 @@ def configured_router_name() -> str: return os.environ.get(ROUTER_NAME_ENV_VAR, "").strip() or ROUTER_NAME +def route_options_for(models: Iterable[str], harness: str) -> list[tuple[str, str]]: + """Return one ``(model, harness)`` option per distinct model id, unchanged and in order.""" + return [(model, harness) for model in dict.fromkeys(models)] + + def select_route( workspace: str, token: str, task: str, route_options: Iterable[tuple[str, str | None]], - resolve: Callable[[str], str | None], *, router_name: str, timeout: float = REQUEST_TIMEOUT_S, ) -> tuple[RoutingDecision | None, str | None]: - """POST one ``routes:select`` request and resolve the router's pick. + """POST one ``routes:select`` request and return the router's pick. - ``route_options`` are ``(model, harness)`` pairs offered to the router (the - frozen menu the caller's harness scenario requires). ``resolve`` maps the - router's chosen arm back to a model id the workspace can serve, returning - None when the arm is unservable. Returns ``(decision, error)``; a failed - call yields ``(None, reason)`` so callers can fail open. + ``route_options`` are ``(model, harness)`` pairs sent as given. The gateway + canonicalizes model names itself and returns the option it was sent, so a + pick that was not offered is rejected. Returns ``(decision, error)``; a + failed call yields ``(None, reason)`` so callers can fail open. """ offered_routes = tuple(route_options) body = { @@ -260,19 +262,18 @@ def select_route( except (urllib.error.URLError, OSError, TimeoutError, ValueError) as exc: return None, f"router request failed: {exc}" - raw_model = _selected_model(payload) - if raw_model is None: - return None, "router returned no model selection" - model = resolve(raw_model) + model = _selected_model(payload) if model is None: - return None, f"router selected unsupported model {raw_model!r}" + return None, "router returned no model selection" + if model not in {offered for offered, _ in offered_routes}: + return None, f"router selected a model it was not offered: {model!r}" raw_rationale = payload.get("rationale") raw_rationale = raw_rationale if isinstance(raw_rationale, str) else "" rationale = format_rationale(payload["route_selection"][0], raw_rationale) return ( RoutingDecision( model=model, - raw_model=raw_model, + raw_model=model, rationale=rationale, route_options=offered_routes, router_name=router_name, diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py index 3fe6ff66f..c31ce4d31 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -293,11 +293,6 @@ def _unwrapped_claude_model_id(model: str) -> str: return routing.unwrap_anthropic_gateway_model(model) -def _claude_router_model_id(model: str) -> str: - """Unwrap an Anthropic gateway id and drop Claude Code's `[1m]` hint, then normalize.""" - return routing.normalize_model(_unwrapped_claude_model_id(model).removesuffix("[1m]")) - - def _claude_model_overrides(model_ids: list[str]) -> dict[str, str]: overrides: dict[str, str] = {} prefix = "system.ai." @@ -433,18 +428,14 @@ def _request_claude_routing_decision( prompt: str, model_ids: list[str], ) -> tuple[routing.RoutingDecision | None, str | None]: - available: dict[str, str] = {} - for model in _canonical_claude_models(model_ids): - available.setdefault(_claude_router_model_id(model), model) - if not available: + route_options = routing.route_options_for(_canonical_claude_models(model_ids), "claude") + if not route_options: return None, "Anthropic models endpoint returned no Claude models" - route_options = [(model, "claude") for model in available] return routing.select_route( workspace, token, prompt, route_options, - lambda selected: available.get(_claude_router_model_id(selected)), router_name=routing.configured_router_name(), timeout=CLAUDE_ROUTE_SELECTION_TIMEOUT_S, ) diff --git a/tests/test_claude_smart_routing_v2.py b/tests/test_claude_smart_routing_v2.py index 91e1f39a0..38e21319a 100644 --- a/tests/test_claude_smart_routing_v2.py +++ b/tests/test_claude_smart_routing_v2.py @@ -613,34 +613,16 @@ def test_no_model_picker_enables_model_discovery(self, tmp_path, monkeypatch): class TestSubagentRouting: - @pytest.mark.parametrize( - ("model", "expected"), - [ - ("anthropic-aigw-73ea02b2-system.ai.glm-5-2", "glm-5-2"), - ( - "anthropic-aigw-73ea02b-system.ai.glm-5-2", - "anthropic-aigw-73ea02b-system.ai.glm-5-2", - ), - ("system.ai.claude-opus-4-8", "claude-opus-4-8"), - ("system.ai.gpt-5-6-sol[1m]", "gpt-5-6-sol"), - ], - ) - def test_normalizes_router_model_id(self, model, expected): - assert v2._claude_router_model_id(model) == expected - - def test_routes_anthropic_gateway_alias_by_embedded_model_id(self, monkeypatch): + def test_offers_gateway_alias_ids_unchanged(self, monkeypatch): gateway_alias = "anthropic-aigw-73ea02b2-system.ai.glm-5-2" captured = {} monkeypatch.setenv("SMART_ROUTER_NAME", "task_v2") - def fake_select(workspace, token, task, route_options, resolve, **kwargs): + def fake_select(workspace, token, task, route_options, **kwargs): captured["route_options"] = list(route_options) captured["router_name"] = kwargs["router_name"] return ( - routing.RoutingDecision( - model=resolve("glm-5-2"), - raw_model="glm-5-2", - ), + routing.RoutingDecision(model=gateway_alias, raw_model=gateway_alias), None, ) @@ -656,8 +638,8 @@ def fake_select(workspace, token, task, route_options, resolve, **kwargs): assert decision.model == gateway_alias assert captured == { "route_options": [ - ("claude-opus-4-8", "claude"), - ("glm-5-2", "claude"), + ("system.ai.claude-opus-4-8", "claude"), + (gateway_alias, "claude"), ], "router_name": "task_v2", } @@ -668,7 +650,7 @@ def test_routes_agent_prompt_with_initialized_model_menu(self, tmp_path, monkeyp decisions_path = tmp_path / "decisions.jsonl" monkeypatch.setattr(v2.claude_routing, "DECISIONS_PATH", decisions_path) - def fake_select(workspace, token, task, route_options, resolve, **kwargs): + def fake_select(workspace, token, task, route_options, **kwargs): captured.update( workspace=workspace, token=token, @@ -677,8 +659,8 @@ def fake_select(workspace, token, task, route_options, resolve, **kwargs): ) return ( routing.RoutingDecision( - model=resolve("claude-opus-4-8"), - raw_model="claude-opus-4-8", + model="system.ai.claude-opus-4-8", + raw_model="system.ai.claude-opus-4-8", ), None, ) @@ -707,8 +689,8 @@ def fake_select(workspace, token, task, route_options, resolve, **kwargs): "token": "token", "task": "inspect the parser", "route_options": [ - ("claude-opus-4-8", "claude"), - ("claude-sonnet-5", "claude"), + ("system.ai.claude-opus-4-8", "claude"), + ("system.ai.claude-sonnet-5", "claude"), ], } updated_input = output["updatedInput"] @@ -750,6 +732,10 @@ def fake_urlopen(request, timeout): ) body = captured["body"] + assert body["route_options"] == [ + {"model": "system.ai.claude-opus-4-8", "harness": "claude"}, + {"model": "system.ai.claude-sonnet-5", "harness": "claude"}, + ] assert body["task"] == {"prompt": "find all Python entry points"} record = json.loads(decisions_path.read_text()) assert record["route_options"] == body["route_options"] diff --git a/tests/test_codex_routing.py b/tests/test_codex_routing.py index ea3668f96..c04e0c11e 100644 --- a/tests/test_codex_routing.py +++ b/tests/test_codex_routing.py @@ -50,7 +50,9 @@ def fake_urlopen(request, timeout): captured["timeout"] = timeout return _Response( { - "route_selection": [{"route_option": {"model": "gpt-5-6-sol", "harness": "codex"}}], + "route_selection": [ + {"route_option": {"model": "system.ai.gpt-5-6-sol", "harness": "codex"}} + ], "rationale": "Needs the strongest coding model.", } ) @@ -67,17 +69,17 @@ def fake_urlopen(request, timeout): assert error is None assert decision == codex_routing.RoutingDecision( model="system.ai.gpt-5-6-sol", - raw_model="gpt-5-6-sol", + raw_model="system.ai.gpt-5-6-sol", rationale="Needs the strongest coding model.", - route_options=(("gpt-5-6-luna", "codex"), ("gpt-5-6-sol", "codex")), + route_options=(("system.ai.gpt-5-6-luna", "codex"), ("system.ai.gpt-5-6-sol", "codex")), router_name=codex_routing.routing.ROUTER_NAME, ) assert captured["url"] == f"{WS}/ai-gateway/routing/v1/routes:select" assert captured["headers"]["Authorization"] == "Bearer token" assert captured["body"] == { "route_options": [ - {"model": "gpt-5-6-luna", "harness": "codex"}, - {"model": "gpt-5-6-sol", "harness": "codex"}, + {"model": "system.ai.gpt-5-6-luna", "harness": "codex"}, + {"model": "system.ai.gpt-5-6-sol", "harness": "codex"}, ], "task": {"prompt": task}, "route_selector": {"router_name": codex_routing.routing.ROUTER_NAME}, @@ -90,7 +92,9 @@ def test_router_name_can_be_overridden_with_environment_variable(monkeypatch): def fake_urlopen(request, timeout): captured["body"] = json.loads(request.data) - return _Response({"route_selection": [{"route_option": {"model": "gpt-5-6-sol"}}]}) + return _Response( + {"route_selection": [{"route_option": {"model": "system.ai.gpt-5-6-sol"}}]} + ) monkeypatch.setattr(codex_routing.urllib.request, "urlopen", fake_urlopen) @@ -109,31 +113,21 @@ def test_blank_router_name_environment_variable_uses_default(monkeypatch): assert codex_routing.routing.configured_router_name() == codex_routing.routing.ROUTER_NAME -def test_router_model_is_not_substituted_when_exact_model_is_unavailable(): - model = codex_routing.resolve_routed_model( - "gpt-5-6-luna", - ["databricks-gpt-5-4-nano", "databricks-gpt-5", "databricks-gpt-5-5"], - ) - - assert model is None - - -def test_glm_maps_to_databricks_gateway_model(): - model = codex_routing.resolve_routed_model( - "glm-5-2", - ["system.ai.gpt-5-6-luna", "system.ai.gpt-5-6-sol", "system.ai.glm-5-2"], +def test_router_pick_that_was_not_offered_fails_open(monkeypatch): + monkeypatch.setattr( + codex_routing.urllib.request, + "urlopen", + lambda *args, **kwargs: _Response( + {"route_selection": [{"route_option": {"model": "gpt-5-6-sol", "harness": "codex"}}]} + ), ) - assert model == "system.ai.glm-5-2" - - -def test_exact_router_model_is_preserved(): - model = codex_routing.resolve_routed_model( - "gpt-5-6-sol", - ["databricks-gpt-5-5", "databricks-gpt-5-6-sol"], + decision, error = codex_routing.request_routing_decision( + WS, "token", "Refactor the parser", ["system.ai.gpt-5-6-sol"] ) - assert model == "databricks-gpt-5-6-sol" + assert decision is None + assert error == "router selected a model it was not offered: 'gpt-5-6-sol'" def test_router_failure_fails_open(monkeypatch): diff --git a/tests/test_codex_smart_routing_v2.py b/tests/test_codex_smart_routing_v2.py index 6f001aedc..9db74b623 100644 --- a/tests/test_codex_smart_routing_v2.py +++ b/tests/test_codex_smart_routing_v2.py @@ -932,7 +932,7 @@ def test_routing_request_uses_models_prompt_and_same_token(monkeypatch): captured = {} logged = [] - def select_route(workspace, token, task, route_options, resolve, *, router_name, timeout): + def select_route(workspace, token, task, route_options, *, router_name, timeout): captured.update( workspace=workspace, token=token, @@ -943,8 +943,8 @@ def select_route(workspace, token, task, route_options, resolve, *, router_name, ) return ( codex_interposer.routing.RoutingDecision( - model=resolve("gpt-5-6-sol"), - raw_model="gpt-5-6-sol", + model="system.ai.gpt-5-6-sol", + raw_model="system.ai.gpt-5-6-sol", rationale="Bugfix needs deeper reasoning.", ), None, @@ -974,10 +974,10 @@ def select_route(workspace, token, task, route_options, resolve, *, router_name, "router_name": codex_routing.routing.ROUTER_NAME, "timeout": codex_routing.REQUEST_TIMEOUT_S, "route_options": [ - ("kimi-k3-neo", "codex"), - ("gpt-5-6-sol", "codex"), - ("gpt-5-6-luna", "codex"), - ("glm-5-2", "codex"), + ("system.ai.kimi-k3-neo", "codex"), + ("system.ai.gpt-5-6-sol", "codex"), + ("system.ai.gpt-5-6-luna", "codex"), + ("system.ai.glm-5-2", "codex"), ], } assert len(logged) == 1 @@ -985,10 +985,10 @@ def select_route(workspace, token, task, route_options, resolve, *, router_name, request_payload = json.loads(logged[0].split(": ", 1)[1]) assert request_payload == { "route_options": [ - {"model": "kimi-k3-neo", "harness": "codex"}, - {"model": "gpt-5-6-sol", "harness": "codex"}, - {"model": "gpt-5-6-luna", "harness": "codex"}, - {"model": "glm-5-2", "harness": "codex"}, + {"model": "system.ai.kimi-k3-neo", "harness": "codex"}, + {"model": "system.ai.gpt-5-6-sol", "harness": "codex"}, + {"model": "system.ai.gpt-5-6-luna", "harness": "codex"}, + {"model": "system.ai.glm-5-2", "harness": "codex"}, ], "task": {"prompt": "Fix the parser"}, "route_selector": {"router_name": codex_routing.routing.ROUTER_NAME}, @@ -996,10 +996,10 @@ def select_route(workspace, token, task, route_options, resolve, *, router_name, assert "same-oauth-token" not in logged[0] -def test_routing_request_deduplicates_equivalent_gpt_spellings(monkeypatch): +def test_routing_request_sends_each_model_id_once_as_given(monkeypatch): captured = {} - def select_route(workspace, token, task, route_options, resolve, *, router_name, timeout): + def select_route(workspace, token, task, route_options, *, router_name, timeout): captured["route_options"] = list(route_options) return None, "not selected" @@ -1012,12 +1012,13 @@ def select_route(workspace, token, task, route_options, resolve, *, router_name, [ "system.ai.gpt-5-6-sol", "gpt-5.6-sol", + "system.ai.gpt-5-6-sol", "system.ai.gpt-5-6-luna", - "gpt-5.6-luna", ], ) assert captured["route_options"] == [ - ("gpt-5-6-sol", "codex"), - ("gpt-5-6-luna", "codex"), + ("system.ai.gpt-5-6-sol", "codex"), + ("gpt-5.6-sol", "codex"), + ("system.ai.gpt-5-6-luna", "codex"), ] From c2ac6de1876f500f59f09584419ad828e9563b9c Mon Sep 17 00:00:00 2001 From: Xiang Shen Date: Sat, 24 Oct 2026 05:11:38 +0000 Subject: [PATCH 3/4] Drop RoutingDecision.raw_model and the unused skip_arms raw_model held the router's own name for its pick, which differed from the workspace id only because ug sent normalized names. It now always equals model, so the decision log's router_model reads model directly. skip_arms, the only other reader, matched on that router name and has had no caller since #273. Co-authored-by: Isaac --- src/ucode/smart_routing/routing.py | 7 +------ tests/test_claude_routing.py | 8 ++------ tests/test_claude_smart_routing_v2.py | 13 ++++--------- tests/test_codex_routing.py | 16 +++++----------- tests/test_codex_smart_routing_v2.py | 5 ----- 5 files changed, 12 insertions(+), 37 deletions(-) diff --git a/src/ucode/smart_routing/routing.py b/src/ucode/smart_routing/routing.py index 10f44acf9..294ed7ad5 100644 --- a/src/ucode/smart_routing/routing.py +++ b/src/ucode/smart_routing/routing.py @@ -92,7 +92,6 @@ class RoutingDecision: """One model selection returned by the AI Gateway router.""" model: str - raw_model: str rationale: str = "" route_options: tuple[tuple[str, str | None], ...] = () router_name: str = "" @@ -273,7 +272,6 @@ def select_route( return ( RoutingDecision( model=model, - raw_model=model, rationale=rationale, route_options=offered_routes, router_name=router_name, @@ -329,7 +327,6 @@ def route_spawn_tool( default_task_label: str, model_id_mapper: Callable[[str], str], notice_config: SubagentNoticeConfig, - skip_arms: dict[str, str] | None = None, record_decision: Callable[[dict[str, Any], str, RoutingDecision, str], None] | None = None, ) -> dict[str, Any] | None: """Route one subagent-spawn tool call, rewriting its ``model`` input.""" @@ -342,8 +339,6 @@ def route_spawn_tool( ) if route is None: return None - if skip_arms and route.decision.raw_model in skip_arms: - return {"systemMessage": skip_arms[route.decision.raw_model]} if record_decision is not None: record_decision(payload, route.task, route.decision, route.routed_model) # Include the same routing notice in both hook response fields. @@ -426,7 +421,7 @@ def write_decision_record( "decision_id": uuid.uuid4().hex, "session_id": payload.get("session_id"), "task_name": task_name, - "router_model": decision.raw_model, + "router_model": decision.model, "requested_model": requested_model, "route_options": [ {"model": model, "harness": harness} for model, harness in decision.route_options diff --git a/tests/test_claude_routing.py b/tests/test_claude_routing.py index a886e38f0..191bb47b7 100644 --- a/tests/test_claude_routing.py +++ b/tests/test_claude_routing.py @@ -42,9 +42,7 @@ def test_decision_is_reconciled_with_actual_subagent_model(tmp_path, monkeypatch audit = tmp_path / "audit.jsonl" monkeypatch.setattr(claude_routing, "DECISIONS_PATH", decisions) monkeypatch.setattr(claude_routing, "AUDIT_PATH", audit) - decision = routing.RoutingDecision( - model="system.ai.claude-opus-4-8", raw_model="system.ai.claude-opus-4-8" - ) + decision = routing.RoutingDecision(model="system.ai.claude-opus-4-8") routing.write_decision_record(decisions, {"session_id": "s1"}, "x", decision, decision.model) record = claude_routing.record_subagent_start( @@ -64,9 +62,7 @@ def test_subagent_start_without_model_reports_unknown_not_mismatch(tmp_path, mon audit = tmp_path / "audit.jsonl" monkeypatch.setattr(claude_routing, "DECISIONS_PATH", decisions) monkeypatch.setattr(claude_routing, "AUDIT_PATH", audit) - decision = routing.RoutingDecision( - model="system.ai.claude-sonnet-5", raw_model="system.ai.claude-sonnet-5" - ) + decision = routing.RoutingDecision(model="system.ai.claude-sonnet-5") routing.write_decision_record(decisions, {"session_id": "s2"}, "x", decision, decision.model) record = claude_routing.record_subagent_start( diff --git a/tests/test_claude_smart_routing_v2.py b/tests/test_claude_smart_routing_v2.py index 38e21319a..940a7e2fb 100644 --- a/tests/test_claude_smart_routing_v2.py +++ b/tests/test_claude_smart_routing_v2.py @@ -263,7 +263,6 @@ def test_restores_model_captured_immediately_before_switch(self, tmp_path, monke "_route_claude_prompt", lambda *_args: v2.routing.RoutingDecision( model="system.ai.claude-sonnet-5", - raw_model="claude-sonnet-5", rationale="Selected for the parser task.", ), ) @@ -622,7 +621,7 @@ def fake_select(workspace, token, task, route_options, **kwargs): captured["route_options"] = list(route_options) captured["router_name"] = kwargs["router_name"] return ( - routing.RoutingDecision(model=gateway_alias, raw_model=gateway_alias), + routing.RoutingDecision(model=gateway_alias), None, ) @@ -660,7 +659,6 @@ def fake_select(workspace, token, task, route_options, **kwargs): return ( routing.RoutingDecision( model="system.ai.claude-opus-4-8", - raw_model="system.ai.claude-opus-4-8", ), None, ) @@ -748,7 +746,7 @@ def test_legacy_hook_notice_shows_the_unwrapped_gateway_model(self, monkeypatch) v2, "_request_claude_routing_decision", lambda *_args: ( - routing.RoutingDecision(model=gateway_model, raw_model="glm-5-3"), + routing.RoutingDecision(model=gateway_model), None, ), ) @@ -771,7 +769,6 @@ def test_returns_routing_details_to_mod_without_writing_files(self, tmp_path, mo lambda *a, **k: ( routing.RoutingDecision( model="system.ai.claude-opus-4-8", - raw_model="claude-opus-4-8", rationale="A focused task with an explicit target", ), None, @@ -832,7 +829,7 @@ def test_routed_agent_details_match_executed_agent( v2, "_request_claude_routing_decision", lambda *_args: ( - routing.RoutingDecision(model=selected_model, raw_model="claude-haiku-5-5"), + routing.RoutingDecision(model=selected_model), None, ), ) @@ -1062,9 +1059,7 @@ def fake_route(_state, _token, prompt, _model_ids): prompts.append(prompt) if route is not None: return route() - return v2.routing.RoutingDecision( - model="system.ai.claude-sonnet-5", raw_model="claude-sonnet-5", rationale="parser" - ) + return v2.routing.RoutingDecision(model="system.ai.claude-sonnet-5", rationale="parser") monkeypatch.setattr(v2, "_route_claude_prompt", fake_route) if sys.platform != "win32": diff --git a/tests/test_codex_routing.py b/tests/test_codex_routing.py index c04e0c11e..a643562ff 100644 --- a/tests/test_codex_routing.py +++ b/tests/test_codex_routing.py @@ -69,7 +69,6 @@ def fake_urlopen(request, timeout): assert error is None assert decision == codex_routing.RoutingDecision( model="system.ai.gpt-5-6-sol", - raw_model="system.ai.gpt-5-6-sol", rationale="Needs the strongest coding model.", route_options=(("system.ai.gpt-5-6-luna", "codex"), ("system.ai.gpt-5-6-sol", "codex")), router_name=codex_routing.routing.ROUTER_NAME, @@ -165,7 +164,6 @@ def test_spawn_rewrite_preserves_original_input(monkeypatch): lambda *args, **kwargs: ( codex_routing.RoutingDecision( model="databricks-gpt-5-5", - raw_model="gpt-5-6-sol", rationale="Review needs deeper reasoning.", ), None, @@ -215,7 +213,6 @@ def test_spawn_rewrite_uses_codex_model_id_for_uc_endpoint(monkeypatch, tmp_path lambda *args, **kwargs: ( codex_routing.RoutingDecision( model="system.ai.gpt-5-6-luna", - raw_model="gpt-5-6-luna", ), None, ), @@ -267,7 +264,6 @@ def test_spawn_glm_decision_applies_glm_model(monkeypatch): lambda *args, **kwargs: ( codex_routing.RoutingDecision( model="system.ai.glm-5-2", - raw_model="glm-5-2", ), None, ), @@ -312,7 +308,7 @@ def test_spawn_routes_on_plaintext_message_when_present(monkeypatch): def fake_decision(*args, **kwargs): captured["task"] = args[2] if len(args) > 2 else kwargs.get("task") return ( - codex_routing.RoutingDecision(model="databricks-gpt-5-5", raw_model="gpt-5-6-sol"), + codex_routing.RoutingDecision(model="databricks-gpt-5-5"), None, ) @@ -340,7 +336,7 @@ def test_spawn_falls_through_encrypted_message_to_task_name(monkeypatch): def fake_decision(*args, **kwargs): captured["task"] = args[2] if len(args) > 2 else kwargs.get("task") return ( - codex_routing.RoutingDecision(model="databricks-gpt-5-5", raw_model="gpt-5-6-sol"), + codex_routing.RoutingDecision(model="databricks-gpt-5-5"), None, ) @@ -367,7 +363,7 @@ def test_spawn_falls_through_fernet_message_to_task_name(monkeypatch): def fake_decision(*args, **kwargs): captured["task"] = args[2] if len(args) > 2 else kwargs.get("task") return ( - codex_routing.RoutingDecision(model="databricks-gpt-5-5", raw_model="gpt-5-6-sol"), + codex_routing.RoutingDecision(model="databricks-gpt-5-5"), None, ) @@ -394,7 +390,7 @@ def test_spawn_uses_generic_task_instead_of_fernet_message(monkeypatch): def fake_decision(*args, **kwargs): captured["task"] = args[2] if len(args) > 2 else kwargs.get("task") return ( - codex_routing.RoutingDecision(model="databricks-gpt-5-5", raw_model="gpt-5-6-sol"), + codex_routing.RoutingDecision(model="databricks-gpt-5-5"), None, ) @@ -440,7 +436,6 @@ def test_decision_is_reconciled_with_actual_subagent_model(tmp_path, monkeypatch lambda *args, **kwargs: ( codex_routing.RoutingDecision( model="system.ai.gpt-5-6-luna", - raw_model="gpt-5-6-luna", ), None, ), @@ -461,7 +456,7 @@ def test_decision_is_reconciled_with_actual_subagent_model(tmp_path, monkeypatch {"session_id": "s1", "agent_id": "a1", "model": "gpt-5.6-luna"} ) - assert record["router_model"] == "gpt-5-6-luna" + assert record["router_model"] == "system.ai.gpt-5-6-luna" assert record["requested_model"] == "gpt-5.6-luna" assert record["matches_router_decision"] is True @@ -475,7 +470,6 @@ def test_decision_record_persists_rationale(tmp_path, monkeypatch): lambda *args, **kwargs: ( codex_routing.RoutingDecision( model="system.ai.gpt-5-6-sol", - raw_model="gpt-5-6-sol", rationale="Cross-cutting refactor needs the strongest model.", ), None, diff --git a/tests/test_codex_smart_routing_v2.py b/tests/test_codex_smart_routing_v2.py index 9db74b623..311eb16b9 100644 --- a/tests/test_codex_smart_routing_v2.py +++ b/tests/test_codex_smart_routing_v2.py @@ -796,7 +796,6 @@ def select(prompt): return ( codex_interposer.routing.RoutingDecision( model="claude-opus-4-8", - raw_model="claude-opus-4-8", rationale="Task classified as bugfix.", ), None, @@ -822,7 +821,6 @@ def select(_prompt): return ( codex_interposer.routing.RoutingDecision( model="system.ai.gpt-5-6-luna", - raw_model="gpt-5-6-luna", rationale="Trivial task.", ), None, @@ -852,7 +850,6 @@ def select(_prompt): return ( codex_interposer.routing.RoutingDecision( model="system.ai.glm-5-2", - raw_model="glm-5-2", rationale="Short isolated task.", ), None, @@ -895,7 +892,6 @@ def test_rewrites_nested_collaboration_mode_model(self): route_decision=lambda _p: ( codex_interposer.routing.RoutingDecision( model="gpt-5.6-luna", - raw_model="gpt-5-6-luna", rationale="trivial", ), None, @@ -944,7 +940,6 @@ def select_route(workspace, token, task, route_options, *, router_name, timeout) return ( codex_interposer.routing.RoutingDecision( model="system.ai.gpt-5-6-sol", - raw_model="system.ai.gpt-5-6-sol", rationale="Bugfix needs deeper reasoning.", ), None, From 9b56b29ce728a84733303e1bd972825d063419f1 Mon Sep 17 00:00:00 2001 From: Xiang Shen Date: Sat, 24 Oct 2026 05:15:21 +0000 Subject: [PATCH 4/4] Move normalize_model next to its only caller Routing no longer normalizes model names. The one remaining use is the readable part of the routed Claude agent slug, so the helper moves into v2.py. Its Codex use, _parse_gpt, only fed _model_strength, which has had no caller since it was added in #251, so both are removed. Co-authored-by: Isaac --- src/ucode/smart_routing/codex_routing.py | 18 ------------------ src/ucode/smart_routing/routing.py | 14 -------------- src/ucode/smart_routing/v2.py | 16 +++++++++++++++- 3 files changed, 15 insertions(+), 33 deletions(-) diff --git a/src/ucode/smart_routing/codex_routing.py b/src/ucode/smart_routing/codex_routing.py index 85ea7a5b0..fef408529 100644 --- a/src/ucode/smart_routing/codex_routing.py +++ b/src/ucode/smart_routing/codex_routing.py @@ -38,8 +38,6 @@ _GPT_RE = re.compile(r"gpt-(\d+)(?:[.-](\d+))?(?:[.-](\d+))?(-.+|[a-z].*)?") -_normalize_model = routing.normalize_model - def request_routing_decision( workspace: str, @@ -128,22 +126,6 @@ def clear_routing_artifacts() -> None: routing.clear_artifacts((CANARY_PATH, AUDIT_PATH, DECISIONS_PATH)) -def _parse_gpt(model: str) -> tuple[int, int, int, str] | None: - match = _GPT_RE.fullmatch(_normalize_model(model)) - if not match: - return None - major, minor, patch, suffix = match.groups() - return int(major), int(minor or 0), int(patch or 0), suffix or "" - - -def _model_strength(model: str) -> tuple[int, int, int, int]: - parsed = _parse_gpt(model) - if parsed is None: - return (0, 0, 0, 0) - major, minor, patch, suffix = parsed - return major, minor, patch, 1 if not suffix else 0 - - def codex_model_id(model: str) -> str: """Map a UC GPT service ID to Codex's bundled catalog slug. diff --git a/src/ucode/smart_routing/routing.py b/src/ucode/smart_routing/routing.py index 294ed7ad5..115907f47 100644 --- a/src/ucode/smart_routing/routing.py +++ b/src/ucode/smart_routing/routing.py @@ -181,20 +181,6 @@ def _plaintext_string(value: Any) -> str | None: return text -def normalize_model(model: str) -> str: - """Strip provider prefixes and lowercase a model id. - - ``system.ai.claude-opus-4-8`` and ``databricks-claude-opus-4-8`` both - normalize to ``claude-opus-4-8``. - """ - tail = model.rsplit("/", 1)[-1] - for prefix in ("databricks-", "system.ai."): - if tail.startswith(prefix): - tail = tail[len(prefix) :] - break - return tail.lower() - - def unwrap_anthropic_gateway_model(model: str) -> str: """Strip Claude's synthetic Anthropic gateway prefix from a model id.""" if match := _ANTHROPIC_AIGW_MODEL_RE.fullmatch(model): diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py index c31ce4d31..849385820 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -302,9 +302,23 @@ def _claude_model_overrides(model_ids: list[str]) -> dict[str, str]: return overrides +def _normalize_model(model: str) -> str: + """Strip provider prefixes and lowercase a model id. + + ``system.ai.claude-opus-4-8`` and ``databricks-claude-opus-4-8`` both + normalize to ``claude-opus-4-8``. + """ + tail = model.rsplit("/", 1)[-1] + for prefix in ("databricks-", "system.ai."): + if tail.startswith(prefix): + tail = tail[len(prefix) :] + break + return tail.lower() + + def _routed_claude_agent_slug(model: str) -> str: canonical = _canonical_claude_model_id(model) - normalized = routing.normalize_model(canonical) + normalized = _normalize_model(canonical) safe = "".join(character if character.isalnum() else "-" for character in normalized) slug = "-".join(part for part in safe.split("-") if part) digest = hashlib.sha256(canonical.encode()).hexdigest()[:8]