Skip to content
Open
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
39 changes: 33 additions & 6 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
list_tool_provider_services,
normalize_workspace_url,
resolve_pat_token,
resolve_provider_launch_model,
run_databricks_login,
)
from ucode.managed_budget import (
Expand Down Expand Up @@ -1714,10 +1715,9 @@ def _launch_tool(
) -> 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.")
# `--model` is claude-only (no other launch command exposes it). Under a provider it selects
# which tier the service offers to launch on, rather than being rejected — see the provider
# branch below.
# 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 @@ -1853,6 +1853,26 @@ def _launch_tool(
# provider). Skip model resolution, which would otherwise fail when
# the workspace has no matching Databricks models.
resolved_model = None
# Claude Code starts on its built-in "family default" (opus), which the gateway 403s when
# the service declares no opus target. Pick the launch model explicitly and pin it via
# ANTHROPIC_MODEL (route_root_model): the user's --model when given, else the most capable
# tier the service actually offers. A relayed service selects the model server-side, so
# there's nothing to pin — and --model can't be honored, so say so rather than ignore it.
#
# KNOWN GAP (deferred): ANTHROPIC_MODEL is checked client-side, so this covers services
# whose targets are canonical Anthropic names (an API-key Anthropic service); a Bedrock
# service's region-prefixed slug can be rejected there. Only an opus-less Bedrock service
# hits this — an opus-having one returns None above and is unaffected — and that case was
# already broken (bare launch 403s on opus). The follow-up fix is to pin the servable
# target into the opus family slot instead (that channel is passed through unchecked).
if tool == "claude" and relayed:
if model:
print_warning(
"This is a subscription-relay Model Provider Service; the gateway selects "
"the model, so --model is ignored."
)
elif tool == "claude" and (model or provider_models):
route_root_model = resolve_provider_launch_model(model, provider_models or {})
else:
# A managed default_model is the model the admin wants sessions to start on, so it goes
# in as the explicit model rather than being applied afterwards: for codex the proto has
Expand Down Expand Up @@ -1918,13 +1938,19 @@ def _launch_tool(
provider_models=provider_models,
relayed=relayed,
route_root_model=route_root_model,
custom_model=model if tool == "claude" else None,
# Under a provider, --model is honored via route_root_model (above), not custom_model —
# the latter pins a raw id into every family alias, which would clobber the service's
# per-family target pins.
custom_model=model if (tool == "claude" and not provider) 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)
# The tier the session will start on when it isn't Claude Code's own opus default.
if route_root_model:
print_kv("Model", route_root_model)
elif model and tool == "claude":
# Claude's --model is pinned via the family aliases, not resolved_model/route_root_model.
print_kv("Model", model)
Expand Down Expand Up @@ -2203,7 +2229,8 @@ def claude_cmd(
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.",
"With --provider, pass a family (opus/sonnet/haiku) or a target the service allows to "
"start on that tier instead of Claude Code's opus default. Pass before any `--` separator.",
),
] = None,
skip_preflight: SkipPreflightOption = False,
Expand Down
40 changes: 40 additions & 0 deletions src/ucode/databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2345,6 +2345,46 @@ def map_claude_family_models(targets: list[str]) -> dict[str, str]:
return result


# Claude Code starts every session on its opus tier, which the gateway 403s when a Model Provider
# Service declares no opus target. When opus is missing, fall back to the most capable tier the
# service does offer. opus > sonnet > haiku.
_CLAUDE_LAUNCH_TIER_PREFERENCE = ("opus", "sonnet", "haiku")


def resolve_provider_launch_model(model: str | None, provider_models: dict[str, str]) -> str | None:
"""Pick the model a provider-routed Claude session starts on, or None to keep Claude Code's default.

``provider_models`` maps the Claude families a service declares to their target ids (see
``map_claude_family_models``). With an explicit ``model`` (``ucode claude --model``) the user's
choice wins: a family alias resolves to that tier's declared target (erroring when the service
doesn't offer it), any other value is trusted as a raw target id the service allows. Without one,
return None when the service offers opus — Claude Code's own default already works, so we avoid
setting ANTHROPIC_MODEL and the duplicate ``/model`` picker row it produces — else the most
capable tier the service does offer, so the launch doesn't dead-end on an unservable opus.
"""
if model:
if model in ANTHROPIC_FAMILIES:
target = provider_models.get(model)
if not target:
available = ", ".join(sorted(provider_models)) or "none"
raise RuntimeError(
f"This Model Provider Service does not offer a '{model}' model "
f"(available families: {available})."
)
return target
return model
if provider_models.get("opus"):
return None
return next(
(
provider_models[fam]
for fam in _CLAUDE_LAUNCH_TIER_PREFERENCE
if provider_models.get(fam)
),
None,
)


# `list_vector_search_catalog_schemas` walks Vector Search endpoints+indexes.
# `list_uc_functions_catalog_schemas` walks UC catalogs+schemas in parallel and
# keeps only schemas with at least one user function.
Expand Down
77 changes: 73 additions & 4 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,12 +309,81 @@ def test_model_threads_to_claude_as_custom_model(self):
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"]
@staticmethod
def _provider_launch(monkeypatch, argv, provider_models, relayed=False):
"""Invoke a provider launch with model discovery/config stubbed, returning the
configure_tool mock so tests can assert what was threaded to it."""
import ucode.cli as cli_mod

monkeypatch.setattr(cli_mod, "ensure_bootstrap_dependencies", lambda *a, **k: None)
monkeypatch.setattr(cli_mod, "load_state", lambda: MINIMAL_STATE)
monkeypatch.setattr(cli_mod, "ensure_provider_state", lambda t: MINIMAL_STATE)
monkeypatch.setattr(cli_mod, "configure_shared_state", lambda *a, **k: MINIMAL_STATE)
monkeypatch.setattr(cli_mod, "_fetch_managed_config", lambda s: (None, False))
monkeypatch.setattr(cli_mod, "_fetch_budget_recommendation", lambda s, m: None)
monkeypatch.setattr(cli_mod, "launch_agent", lambda *a, **k: None)
monkeypatch.setattr(
cli_mod, "resolve_provider_models", lambda t, s, p: (provider_models, None, relayed)
)
mock_configure = MagicMock(return_value=MINIMAL_STATE)
monkeypatch.setattr(cli_mod, "configure_tool", mock_configure)
result = runner.invoke(app, argv)
return result, mock_configure

def test_model_and_provider_now_pin_the_launch_tier(self, monkeypatch):
# --model under a provider is no longer rejected: a family alias resolves to that tier's
# declared target and is threaded as route_root_model (ANTHROPIC_MODEL), not custom_model.
result, mock_configure = self._provider_launch(
monkeypatch,
["claude", "--model", "haiku", "--provider", "cat.schema.svc"],
{"sonnet": "claude-sonnet-5", "haiku": "claude-haiku-4-5"},
)
assert result.exit_code == 0, result.output
assert mock_configure.call_args.kwargs["route_root_model"] == "claude-haiku-4-5"
assert mock_configure.call_args.kwargs["custom_model"] is None

def test_provider_without_opus_auto_picks_best_servable_tier(self, monkeypatch):
# No --model, and the service declares no opus target: launch on the most capable tier it
# does offer (sonnet) instead of dead-ending on Claude Code's opus default.
result, mock_configure = self._provider_launch(
monkeypatch,
["claude", "--provider", "cat.schema.svc"],
{"sonnet": "claude-sonnet-5", "haiku": "claude-haiku-4-5"},
)
assert result.exit_code == 0, result.output
assert mock_configure.call_args.kwargs["route_root_model"] == "claude-sonnet-5"

def test_provider_with_opus_keeps_claude_default(self, monkeypatch):
# Opus is offered, so Claude Code's own default already works — pin nothing (no ANTHROPIC_MODEL
# and no duplicate /model picker row).
result, mock_configure = self._provider_launch(
monkeypatch,
["claude", "--provider", "cat.schema.svc"],
{"opus": "claude-opus-4-8", "sonnet": "claude-sonnet-5"},
)
assert result.exit_code == 0, result.output
assert mock_configure.call_args.kwargs["route_root_model"] is None

def test_model_family_not_offered_by_provider_errors(self, monkeypatch):
result, _ = self._provider_launch(
monkeypatch,
["claude", "--model", "opus", "--provider", "cat.schema.svc"],
{"sonnet": "claude-sonnet-5", "haiku": "claude-haiku-4-5"},
)
assert result.exit_code == 1
assert "Use either --model or --provider" in result.output
assert "does not offer a 'opus' model" in result.output

def test_model_ignored_for_relayed_provider(self, monkeypatch):
# A relayed (subscription) service selects the model server-side; --model can't be honored.
result, mock_configure = self._provider_launch(
monkeypatch,
["claude", "--model", "haiku", "--provider", "cat.schema.svc"],
None,
relayed=True,
)
assert result.exit_code == 0, result.output
assert mock_configure.call_args.kwargs["route_root_model"] is None
assert "--model is ignored" in _strip_ansi(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,
Expand Down
41 changes: 41 additions & 0 deletions tests/test_databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,47 @@ def test_empty_when_no_claude(self):
assert db_mod.map_claude_family_models(["amazon.titan-text-express-v1"]) == {}


class TestResolveProviderLaunchModel:
def test_none_when_service_offers_opus(self):
# Claude Code's own opus default already works, so we pin nothing (and avoid the duplicate
# /model picker row that setting ANTHROPIC_MODEL causes).
models = {
"opus": "claude-opus-4-8",
"sonnet": "claude-sonnet-5",
"haiku": "claude-haiku-4-5",
}
assert db_mod.resolve_provider_launch_model(None, models) is None

def test_falls_back_to_best_tier_when_no_opus(self):
# No opus target: launch on the most capable tier the service does offer (sonnet > haiku).
models = {"sonnet": "claude-sonnet-5", "haiku": "claude-haiku-4-5"}
assert db_mod.resolve_provider_launch_model(None, models) == "claude-sonnet-5"

def test_falls_back_to_haiku_when_only_haiku(self):
assert db_mod.resolve_provider_launch_model(None, {"haiku": "claude-haiku-4-5"}) == (
"claude-haiku-4-5"
)

def test_family_alias_resolves_to_declared_target(self):
models = {"sonnet": "claude-sonnet-5", "haiku": "claude-haiku-4-5"}
assert db_mod.resolve_provider_launch_model("haiku", models) == "claude-haiku-4-5"

def test_family_alias_not_offered_raises_with_available_list(self):
models = {"sonnet": "claude-sonnet-5", "haiku": "claude-haiku-4-5"}
with pytest.raises(RuntimeError, match="does not offer a 'opus' model.*haiku, sonnet"):
db_mod.resolve_provider_launch_model("opus", models)

def test_raw_target_id_is_trusted(self):
# A non-family value is a raw target the user knows the service allows; pass it through.
models = {"sonnet": "claude-sonnet-5"}
assert db_mod.resolve_provider_launch_model("claude-3-7-sonnet", models) == (
"claude-3-7-sonnet"
)

def test_no_models_and_no_override_is_none(self):
assert db_mod.resolve_provider_launch_model(None, {}) is None


class TestProviderServicePagination:
"""The listing is paginated; ignoring next_page_token hid services on later pages entirely."""

Expand Down
Loading