diff --git a/src/ucode/cli.py b/src/ucode/cli.py index ca71dd6..9d0a392 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -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 ( @@ -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`. @@ -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 @@ -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) @@ -2203,7 +2229,8 @@ def claude_cmd( help="Launch on a specific Databricks model id (e.g. a UC " "`..`). 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, diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index e98faf9..6b8df66 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -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. diff --git a/tests/test_cli.py b/tests/test_cli.py index 99b99dc..3975322 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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, diff --git a/tests/test_databricks.py b/tests/test_databricks.py index ffd9636..b89636c 100644 --- a/tests/test_databricks.py +++ b/tests/test_databricks.py @@ -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."""