Skip to content
Merged
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
5 changes: 4 additions & 1 deletion src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,14 +313,16 @@ def configure_tool(
provider_models: dict[str, str] | None = None,
relayed: bool = False,
route_root_model: str | None = None,
custom_model: str | None = None,
) -> dict:
result: dict | tuple[dict, str]
if tool == "codex":
result = codex.write_tool_config(state, model, provider=provider)
elif tool == "claude":
# A Model Provider Service routes by header and pins no Databricks
# model, so the usual "model required" guard doesn't apply to claude.
if not model and not provider:
# `custom_model` (from `ucode claude --model`) likewise supplies the model.
if not model and not provider and not custom_model:
raise RuntimeError(f"A {tool} model must be selected before configuration.")
result = claude.write_tool_config(
state,
Expand All @@ -329,6 +331,7 @@ def configure_tool(
provider_models=provider_models,
relayed=relayed,
route_root_model=route_root_model,
custom_model=custom_model,
)
else:
# provider routing is claude/codex-only; every other tool needs a model.
Expand Down
18 changes: 17 additions & 1 deletion src/ucode/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ def render_overlay(
relayed: bool = False,
relayed_base_url: str | None = None,
route_root_model: str | None = None,
custom_model: str | None = None,
) -> tuple[dict, list[list[str]]]:
"""Return (overlay, managed_key_paths) for Claude settings.json.

Expand Down Expand Up @@ -314,10 +315,23 @@ def render_overlay(
_ = model # API stability; no longer pinned via env.
if route_root_model:
env["ANTHROPIC_MODEL"] = route_root_model
# `ucode claude --model <id>` pins an arbitrary Databricks model id for this launch. It CANNOT
# go in ANTHROPIC_MODEL: Claude Code validates that value client-side against the models it knows
# (via the apiKeyHelper auth path ucode uses) and rejects a raw id with "may not exist ... run
# /model". The family-alias vars (ANTHROPIC_DEFAULT_*_MODEL) are passed through unchecked, so pin
# the id into all of them — a raw id carries no signal of its family (opus/sonnet/haiku), and
# overriding every slot makes the model take effect no matter which one Claude Code resolves
# (root session, a tier switch, or a subagent). Wins over the discovered-model aliases below.
if custom_model and not provider:
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = custom_model
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = custom_model
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = custom_model
if fable_enabled:
env["ANTHROPIC_DEFAULT_FABLE_MODEL"] = custom_model
# A Bedrock-backed provider needs its provider-side ids pinned verbatim
# (Claude Code's canonical names aren't routable there). These come from the
# service's targets, already de-duped to one id per family upstream.
if provider and provider_models:
elif provider and provider_models:
if provider_models.get("opus"):
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = provider_models["opus"]
if provider_models.get("sonnet"):
Expand Down Expand Up @@ -466,6 +480,7 @@ def write_tool_config(
provider_models: dict[str, str] | None = None,
relayed: bool = False,
route_root_model: str | None = None,
custom_model: str | None = None,
) -> dict:
backup_existing_file(CLAUDE_SETTINGS_PATH, CLAUDE_BACKUP_PATH)
web_search_model = _resolve_web_search_model(state)
Expand All @@ -485,6 +500,7 @@ def write_tool_config(
relayed=relayed,
relayed_base_url=relayed_base_url,
route_root_model=route_root_model,
custom_model=custom_model,
)
tracing_env_vars = tracing_env(state, "claude")
stop_hook_command = claude_tracing_stop_hook_command() if tracing_env_vars else None
Expand Down
39 changes: 39 additions & 0 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1372,9 +1372,14 @@ def _launch_tool(
enable_smart_routing_flag: bool = False,
managed: dict | None = None,
recommendation: dict | None = None,
model: str | None = None,
) -> None:
try:
tool = normalize_tool(tool_name)
# A provider service routes by header and pins no model id, so pairing it with an explicit
# model is contradictory — reject rather than silently ignore one.
if model and provider:
raise RuntimeError("Use either --model or --provider, not both.")
# An explicit --workspace targets that workspace for this launch (and
# auto-configures it if unseen), so `ucode claude --provider ... --workspace ...`
# works without a prior `ucode configure`.
Expand Down Expand Up @@ -1530,6 +1535,25 @@ def _launch_tool(
route_root_model = managed_model
else:
resolved_model = managed_model
# An explicit `--model` is the user's own choice and outranks everything above (managed
# default, smart-routing pick). Non-claude agents take it as the resolved model, which
# their CLIs pass to the gateway verbatim. Claude is special (see custom_model below):
# Claude Code validates ANTHROPIC_MODEL client-side and rejects a raw Databricks id, so
# the id can't ride `resolved_model` — it is threaded separately as `custom_model`.
if model and tool != "claude":
resolved_model = model
# Claude Code's enterprise managed-settings scope (e.g. an Isaac/dbexec install)
# outranks the --settings file ucode writes AND can't be excluded with --setting-sources,
# so a model pinned there silently wins over `--model`. Warn so a launch that ignores the
# requested model looks like the misconfiguration it is, not a ucode bug.
if model and tool == "claude":
enterprise = claude_agent.managed_settings_model_overrides()
if enterprise is not None:
print_warning(
f"Your enterprise managed settings at {enterprise} pin the Claude model, "
f"which overrides `--model {model}` — Claude Code will launch on the pinned "
"model instead. Edit or remove that file to use --model."
)
state = configure_tool(
tool,
state,
Expand All @@ -1538,12 +1562,16 @@ def _launch_tool(
provider_models=provider_models,
relayed=relayed,
route_root_model=route_root_model,
custom_model=model if tool == "claude" else None,
)
print_section(f"ucode with {TOOL_SPECS[tool]['display']}")
if managed is not None:
print_kv("Config", "workspace-managed")
if provider:
print_kv("Provider", provider)
elif model and tool == "claude":
# Claude's --model is pinned via the family aliases, not resolved_model/route_root_model.
print_kv("Model", model)
elif route_root_model:
print_kv("Model", route_root_model)
elif resolved_model:
Expand Down Expand Up @@ -1789,6 +1817,16 @@ def claude_cmd(
"before any `--` separator.",
),
] = None,
model: Annotated[
str | None,
typer.Option(
"--model",
help="Launch on a specific Databricks model id (e.g. a UC "
"`<catalog>.<schema>.<name>`). Pinned via ANTHROPIC_MODEL so the gateway "
"resolves it — unlike Claude Code's own --model, which rejects non-catalog ids. "
"Pass before any `--` separator; not usable with --provider.",
),
] = None,
skip_preflight: SkipPreflightOption = False,
workspace: WorkspaceOption = None,
enable_smart_routing_flag: Annotated[
Expand Down Expand Up @@ -1818,6 +1856,7 @@ def claude_cmd(
"claude",
ctx,
provider=provider,
model=model,
skip_preflight=skip_preflight,
workspace=workspace,
enable_smart_routing_flag=enable_smart_routing_flag,
Expand Down
28 changes: 28 additions & 0 deletions tests/test_agent_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,34 @@ def test_no_1m_suffix_for_model_services_haiku(self):
)
assert overlay["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"] == "system.ai.claude-haiku-4-6"

def test_custom_model_pins_all_family_aliases(self):
# `ucode claude --model` pins the id into every family alias so it takes effect whichever
# slot Claude Code resolves — and NOT into ANTHROPIC_MODEL, which Claude Code validates and
# rejects for a raw Databricks id. It overrides the discovered-model aliases.
overlay, _ = claude.render_overlay(
WS,
"s4",
claude_models={"opus": "system.ai.claude-opus-4-8", "sonnet": "system.ai.sonnet"},
custom_model="main.aarushi.claude-opus-5",
)
env = overlay["env"]
assert env["ANTHROPIC_DEFAULT_OPUS_MODEL"] == "main.aarushi.claude-opus-5"
assert env["ANTHROPIC_DEFAULT_SONNET_MODEL"] == "main.aarushi.claude-opus-5"
assert env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] == "main.aarushi.claude-opus-5"
assert "ANTHROPIC_MODEL" not in env
# No [1m] suffix is appended to the custom id — it's passed through verbatim.
assert "[1m]" not in env["ANTHROPIC_DEFAULT_OPUS_MODEL"]

def test_custom_model_pins_fable_alias_only_when_fable_enabled(self):
without = claude.render_overlay(WS, "s4", claude_models={}, custom_model="main.x.m")[0][
"env"
]
assert "ANTHROPIC_DEFAULT_FABLE_MODEL" not in without
with_fable = claude.render_overlay(
WS, "s4", claude_models={}, custom_model="main.x.m", fable_enabled=True
)[0]["env"]
assert with_fable["ANTHROPIC_DEFAULT_FABLE_MODEL"] == "main.x.m"

def test_sets_anthropic_base_url(self):
overlay, _ = claude.render_overlay(WS, "s4")
assert overlay["env"]["ANTHROPIC_BASE_URL"] == f"{WS}/ai-gateway/anthropic"
Expand Down
76 changes: 76 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,82 @@ def test_enabled_codex_launch_uses_routed_root_model(self):
)


class TestClaudeModelFlag:
"""`ucode claude --model <id>` pins the id into the family aliases so the gateway resolves any
Databricks model id, instead of Claude Code's own --model flag rejecting non-catalog ids."""

def test_model_threads_through_to_launch(self):
with patch("ucode.cli._launch_tool") as mock_launch:
result = runner.invoke(app, ["claude", "--model", "cat.schema.claude-opus-5"])
assert result.exit_code == 0, result.output
assert mock_launch.call_args.kwargs["model"] == "cat.schema.claude-opus-5"

def test_model_threads_to_claude_as_custom_model(self):
with (
patch("ucode.cli.ensure_bootstrap_dependencies"),
patch("ucode.cli.load_state", return_value=MINIMAL_STATE),
patch("ucode.cli.ensure_provider_state", return_value=MINIMAL_STATE),
patch("ucode.cli.configure_shared_state", return_value=MINIMAL_STATE),
patch("ucode.cli.resolve_launch_model", return_value=(MINIMAL_STATE, "system.ai.opus")),
patch("ucode.cli.configure_tool", return_value=MINIMAL_STATE) as mock_configure,
patch("ucode.cli._fetch_managed_config", return_value=None),
patch("ucode.cli.launch_agent"),
):
result = runner.invoke(app, ["claude", "--model", "cat.schema.claude-opus-5"])
assert result.exit_code == 0, result.output
# Claude routes --model as custom_model (pinned into the family aliases by render_overlay),
# NOT as ANTHROPIC_MODEL — Claude Code validates that value and rejects a raw id.
assert mock_configure.call_args.kwargs["custom_model"] == "cat.schema.claude-opus-5"
assert mock_configure.call_args.kwargs["route_root_model"] is None

def test_model_and_provider_are_mutually_exclusive(self):
result = runner.invoke(
app, ["claude", "--model", "cat.schema.m", "--provider", "cat.schema.svc"]
)
assert result.exit_code == 1
assert "Use either --model or --provider" in result.output

def test_warns_when_enterprise_settings_pin_the_model(self):
# Claude Code's enterprise managed-settings scope outranks the --settings file ucode writes,
# so --model is silently ignored; warn instead of launching on the "wrong" model unexplained.
from pathlib import Path

with (
patch("ucode.cli.ensure_bootstrap_dependencies"),
patch("ucode.cli.load_state", return_value=MINIMAL_STATE),
patch("ucode.cli.ensure_provider_state", return_value=MINIMAL_STATE),
patch("ucode.cli.configure_shared_state", return_value=MINIMAL_STATE),
patch("ucode.cli.resolve_launch_model", return_value=(MINIMAL_STATE, "system.ai.opus")),
patch("ucode.cli.configure_tool", return_value=MINIMAL_STATE),
patch("ucode.cli._fetch_managed_config", return_value=None),
patch(
"ucode.cli.claude_agent.managed_settings_model_overrides",
return_value=Path("/etc/claude-code/managed-settings.json"),
),
patch("ucode.cli.launch_agent"),
):
result = runner.invoke(app, ["claude", "--model", "main.aarushi.claude-opus-5"])
assert result.exit_code == 0, result.output
assert "enterprise managed settings" in _strip_ansi(result.output)
assert "overrides `--model main.aarushi.claude-opus-5`" in _strip_ansi(result.output)

def test_no_enterprise_warning_when_no_managed_settings(self):
with (
patch("ucode.cli.ensure_bootstrap_dependencies"),
patch("ucode.cli.load_state", return_value=MINIMAL_STATE),
patch("ucode.cli.ensure_provider_state", return_value=MINIMAL_STATE),
patch("ucode.cli.configure_shared_state", return_value=MINIMAL_STATE),
patch("ucode.cli.resolve_launch_model", return_value=(MINIMAL_STATE, "system.ai.opus")),
patch("ucode.cli.configure_tool", return_value=MINIMAL_STATE),
patch("ucode.cli._fetch_managed_config", return_value=None),
patch("ucode.cli.claude_agent.managed_settings_model_overrides", return_value=None),
patch("ucode.cli.launch_agent"),
):
result = runner.invoke(app, ["claude", "--model", "main.aarushi.claude-opus-5"])
assert result.exit_code == 0, result.output
assert "enterprise managed settings" not in _strip_ansi(result.output)


class TestMcpSubcommands:
def test_web_search_subcommand_help(self):
result = runner.invoke(app, ["mcp", "web-search", "--help"])
Expand Down
Loading