Skip to content

Commit 9cad465

Browse files
committed
Extend intelligent routing to Claude Code
Generalize the Codex intelligent-routing engine into a shared core and add Claude Code support. `ucode claude --enable-intelligent-routing` routes the root session (via ANTHROPIC_MODEL) and subagents (via a PreToolUse hook that rewrites the Agent tool's model) through the workspace task_v1 router. - routing.py / hooks.py: harness-agnostic routes:select call, decision shape, canary/audit bookkeeping, and hook add/remove machinery. codex_routing / codex_hooks now delegate to these (behavior unchanged; 62 Codex tests green). - claude_routing.py / claude_hooks.py: Claude cc-scenario arms (claude-opus-4-8, claude-sonnet-5, harness "claude"), both offered unconditionally per the frozen task_v1 menu; settings.json hook shape. - claude agent + CLI: enable/disable flags, hidden claude-router-hook command, claude_intelligent_routing_enabled state key. Co-authored-by: Isaac
1 parent e26479b commit 9cad465

12 files changed

Lines changed: 1227 additions & 303 deletions

File tree

src/ucode/agents/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ def configure_tool(
312312
provider: str | None = None,
313313
provider_models: dict[str, str] | None = None,
314314
relayed: bool = False,
315+
route_root_model: str | None = None,
315316
) -> dict:
316317
result: dict | tuple[dict, str]
317318
if tool == "codex":
@@ -322,7 +323,12 @@ def configure_tool(
322323
if not model and not provider:
323324
raise RuntimeError(f"A {tool} model must be selected before configuration.")
324325
result = claude.write_tool_config(
325-
state, model, provider=provider, provider_models=provider_models, relayed=relayed
326+
state,
327+
model,
328+
provider=provider,
329+
provider_models=provider_models,
330+
relayed=relayed,
331+
route_root_model=route_root_model,
326332
)
327333
else:
328334
# provider routing is claude/codex-only; every other tool needs a model.

src/ucode/agents/claude.py

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
build_tool_base_url,
2929
get_databricks_token,
3030
)
31+
from ucode.intelligent_routing.claude_hooks import (
32+
remove_intelligent_routing_hooks,
33+
sync_intelligent_routing_hooks,
34+
)
3135
from ucode.launcher import exec_or_spawn
3236
from ucode.state import mark_tool_managed, save_state
3337
from ucode.telemetry import agent_version, ucode_version
@@ -46,6 +50,12 @@
4650
"backup_path": CLAUDE_BACKUP_PATH,
4751
}
4852

53+
# Per-workspace opt-in flag for Claude Code intelligent routing (state key).
54+
INTELLIGENT_ROUTING_STATE_KEY = "claude_intelligent_routing_enabled"
55+
# Claude Code settings.json hook events ucode manages when routing is enabled;
56+
# marked managed so they're tracked/reverted with the rest of ucode's config.
57+
CLAUDE_ROUTING_HOOK_EVENTS = ("PreToolUse", "SessionStart", "SubagentStart")
58+
4959

5060
def is_update_available() -> tuple[str, str] | None:
5161
return available_npm_package_update(SPEC["package"])
@@ -161,6 +171,7 @@ def render_overlay(
161171
fable_enabled: bool = False,
162172
relayed: bool = False,
163173
relayed_base_url: str | None = None,
174+
route_root_model: str | None = None,
164175
) -> tuple[dict, list[list[str]]]:
165176
"""Return (overlay, managed_key_paths) for Claude settings.json.
166177
@@ -212,13 +223,20 @@ def render_overlay(
212223
"ENABLE_TOOL_SEARCH": "1",
213224
"CLAUDE_CODE_USE_GATEWAY": "1",
214225
}
215-
# Intentionally NOT setting ANTHROPIC_MODEL. Setting it produces a duplicate
216-
# catalog row in Claude Code's /model picker (e.g. "Opus 4.8 (1M context) ✓")
217-
# on top of the family-alias row from ANTHROPIC_DEFAULT_OPUS_MODEL. Without
218-
# it, Default resolves through the pinned family alias and the picker shows
219-
# only one row per model. `ucode claude -- --model X` still overrides for a
220-
# single session via Claude Code's own --model flag.
226+
# Intentionally NOT setting ANTHROPIC_MODEL by default. Setting it produces a
227+
# duplicate catalog row in Claude Code's /model picker (e.g. "Opus 4.8 (1M
228+
# context) ✓") on top of the family-alias row from ANTHROPIC_DEFAULT_OPUS_MODEL.
229+
# Without it, Default resolves through the pinned family alias and the picker
230+
# shows only one row per model. `ucode claude -- --model X` still overrides for
231+
# a single session via Claude Code's own --model flag.
232+
#
233+
# The one exception is intelligent routing: `route_root_model` pins the
234+
# router's per-launch pick for the root session as ANTHROPIC_MODEL. The
235+
# duplicate-picker-row cost is acceptable because the whole point is to launch
236+
# on the routed model rather than the family default.
221237
_ = model # API stability; no longer pinned via env.
238+
if route_root_model:
239+
env["ANTHROPIC_MODEL"] = route_root_model
222240
# A Bedrock-backed provider needs its provider-side ids pinned verbatim
223241
# (Claude Code's canonical names aren't routable there). These come from the
224242
# service's targets, already de-duped to one id per family upstream.
@@ -336,12 +354,41 @@ def _unregister_web_search_mcp() -> None:
336354
pass
337355

338356

357+
def intelligent_routing_enabled(state: dict) -> bool:
358+
"""Return whether the current workspace opted into Claude Code routing."""
359+
return state.get(INTELLIGENT_ROUTING_STATE_KEY) is True
360+
361+
362+
def enable_intelligent_routing(state: dict) -> dict:
363+
"""Persist the current workspace's Claude Code intelligent-routing opt-in."""
364+
state[INTELLIGENT_ROUTING_STATE_KEY] = True
365+
return state
366+
367+
368+
def disable_intelligent_routing(state: dict) -> bool:
369+
"""Disable routing and remove only ucode's Claude Code routing hooks."""
370+
state.pop(INTELLIGENT_ROUTING_STATE_KEY, None)
371+
if state.get("workspace"):
372+
save_state(state)
373+
changed = False
374+
if CLAUDE_SETTINGS_PATH.exists():
375+
doc = read_json_safe(CLAUDE_SETTINGS_PATH)
376+
if remove_intelligent_routing_hooks(doc):
377+
write_json_file(CLAUDE_SETTINGS_PATH, doc)
378+
changed = True
379+
from ucode.intelligent_routing.claude_routing import clear_routing_artifacts
380+
381+
clear_routing_artifacts()
382+
return changed
383+
384+
339385
def write_tool_config(
340386
state: dict,
341387
model: str | None,
342388
provider: str | None = None,
343389
provider_models: dict[str, str] | None = None,
344390
relayed: bool = False,
391+
route_root_model: str | None = None,
345392
) -> dict:
346393
backup_existing_file(CLAUDE_SETTINGS_PATH, CLAUDE_BACKUP_PATH)
347394
web_search_model = _resolve_web_search_model(state)
@@ -360,6 +407,7 @@ def write_tool_config(
360407
fable_enabled=bool(state.get("fable_enabled")),
361408
relayed=relayed,
362409
relayed_base_url=relayed_base_url,
410+
route_root_model=route_root_model,
363411
)
364412
tracing_env_vars = tracing_env(state, "claude")
365413
stop_hook_command = claude_tracing_stop_hook_command() if tracing_env_vars else None
@@ -404,6 +452,13 @@ def write_tool_config(
404452
if isinstance(merged_env, dict):
405453
for key in CLAUDE_REMOVED_ENV_KEYS:
406454
merged_env.pop(key, None)
455+
# Intelligent-routing hooks: install ucode's PreToolUse/SessionStart/
456+
# SubagentStart hooks when routing is enabled (and not under a provider,
457+
# which pins no Databricks model), else surgically strip only ucode's own.
458+
routing_enabled = intelligent_routing_enabled(state) and provider is None
459+
sync_intelligent_routing_hooks(merged, state, enabled=routing_enabled)
460+
if routing_enabled:
461+
managed_keys = managed_keys + [["hooks", event] for event in CLAUDE_ROUTING_HOOK_EVENTS]
407462
write_json_file(CLAUDE_SETTINGS_PATH, merged)
408463

409464
if web_search_model:

0 commit comments

Comments
 (0)