Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 3 additions & 90 deletions src/ucode/smart_routing/claude_routing.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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
40 changes: 1 addition & 39 deletions src/ucode/smart_routing/codex_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,6 @@

_GPT_RE = re.compile(r"gpt-(\d+)(?:[.-](\d+))?(?:[.-](\d+))?(-.+|[a-z].*)?")

_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,
Expand All @@ -63,8 +49,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()
Expand All @@ -83,18 +68,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],
*,
Expand Down Expand Up @@ -148,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.

Expand Down
52 changes: 17 additions & 35 deletions src/ucode/smart_routing/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -93,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 = ""
Expand Down Expand Up @@ -183,20 +181,6 @@ def _plaintext_string(value: Any) -> str | None:
return text


def normalize_model(model: str) -> str:
"""Strip provider prefixes so router arms and workspace ids compare equal.

``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.
"""
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):
Expand All @@ -209,23 +193,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 = {
Expand Down Expand Up @@ -260,19 +247,17 @@ 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,
rationale=rationale,
route_options=offered_routes,
router_name=router_name,
Expand Down Expand Up @@ -328,7 +313,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."""
Expand All @@ -341,8 +325,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.
Expand Down Expand Up @@ -425,7 +407,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
Expand Down
29 changes: 17 additions & 12 deletions src/ucode/smart_routing/v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -307,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]
Expand Down Expand Up @@ -433,18 +442,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,
)
Expand Down
2 changes: 1 addition & 1 deletion tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading