diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index f44b91b..df73b2d 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -287,17 +287,23 @@ def configure_tool( model: str | None = None, provider: str | None = None, provider_models: dict[str, 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) + result = codex.write_tool_config(state, model, provider=provider, custom_model=custom_model) 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: + # A custom UC model-service (`--model`) likewise satisfies the guard. + 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, model, provider=provider, provider_models=provider_models + state, + model, + provider=provider, + provider_models=provider_models, + custom_model=custom_model, ) else: # provider routing is claude/codex-only; every other tool needs a model. diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index f1e62e5..fe9bd3f 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -137,6 +137,7 @@ def render_overlay( provider: str | None = None, provider_models: dict[str, str] | None = None, fable_enabled: bool = False, + custom_model: str | None = None, ) -> tuple[dict, list[list[str]]]: """Return (overlay, managed_key_paths) for Claude settings.json. @@ -150,7 +151,13 @@ def render_overlay( understands Claude Code's own canonical model names, so no model id is pinned. A Bedrock-backed provider exposes different model ids (e.g. `us.anthropic.claude-sonnet-4-6`), passed in `provider_models` by family — - those get pinned via the `ANTHROPIC_DEFAULT_*_MODEL` env vars.""" + those get pinned via the `ANTHROPIC_DEFAULT_*_MODEL` env vars. + + When `custom_model` is set (from `ucode claude --model`), it pins a custom + UC model-service id (outside `system.ai`) as `ANTHROPIC_MODEL` — the active + default — *alongside* the discovered `system.ai` family aliases, so the + system.ai rows still populate the /model picker while the custom model + becomes the default. Mutually exclusive with `provider`.""" base_url = build_tool_base_url("claude", workspace) # ANTHROPIC_CUSTOM_HEADERS is parsed as `key: value` pairs separated by # newlines (Anthropic SDK convention). Setting User-Agent here overrides @@ -176,6 +183,14 @@ def render_overlay( # only one row per model. `ucode claude -- --model X` still overrides for a # single session via Claude Code's own --model flag. _ = model # API stability; no longer pinned via env. + # A custom UC model-service (`--model`) pins ANTHROPIC_MODEL as the active + # default. This is the one case we DO set ANTHROPIC_MODEL: the family aliases + # below still populate the picker's system.ai rows, and the custom id becomes + # the resolved default on top of them. ANTHROPIC_MODEL is in + # CLAUDE_MANAGED_MODEL_ENV_KEYS, so it's pruned again when no `--model` is + # passed. Mutually exclusive with `provider` (enforced upstream in cli.py). + if custom_model and not provider: + env["ANTHROPIC_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. @@ -295,6 +310,7 @@ def write_tool_config( model: str | None, provider: str | None = None, provider_models: dict[str, 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) @@ -308,6 +324,7 @@ def write_tool_config( provider=provider, provider_models=provider_models, fable_enabled=bool(state.get("fable_enabled")), + 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 diff --git a/src/ucode/agents/codex.py b/src/ucode/agents/codex.py index 2a5c657..b53879d 100644 --- a/src/ucode/agents/codex.py +++ b/src/ucode/agents/codex.py @@ -309,12 +309,25 @@ def _parse_gpt(model: str | None) -> tuple[int, int | None, int | None, str] | N ) -def write_tool_config(state: dict, model: str | None = None, provider: str | None = None) -> dict: +def write_tool_config( + state: dict, + model: str | None = None, + provider: str | None = None, + custom_model: str | None = None, +) -> dict: workspace = state["workspace"] # With a Model Provider Service the gateway routes by header and Codex sends # its own canonical model name (e.g. `gpt-5`) — leave `model` unset so no - # Databricks endpoint id is pinned. - chosen_model = None if provider else _codex_model_id(model or default_model(state)) + # Databricks endpoint id is pinned. A custom UC model-service (`--model`) is + # pinned VERBATIM, bypassing the `_codex_model_id` OpenAI-id rewrite — the + # gateway routes it by name like a `system.ai.*` id. Mutually exclusive with + # `provider` (enforced upstream in cli.py). + if provider: + chosen_model = None + elif custom_model: + chosen_model = custom_model + else: + chosen_model = _codex_model_id(model or default_model(state)) databricks_profile = state.get("profile") if _use_legacy_layout(): diff --git a/src/ucode/cli.py b/src/ucode/cli.py index a0ef7c4..7803ffe 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -43,6 +43,7 @@ find_profile_name_for_host, get_databricks_profiles, get_databricks_token, + get_model_service, install_databricks_cli, is_model_provider_feature_unavailable, list_profile_entries, @@ -886,10 +887,20 @@ def _launch_tool( tool_name: str, ctx: typer.Context, provider: str | None = None, + model: str | None = None, skip_preflight: bool = False, ) -> None: try: tool = normalize_tool(tool_name) + # `--model` (a custom UC model-service pinned as the default) and + # `--provider` (routing through a Model Provider Service via a header) + # are two different routing modes; picking both is ambiguous. + if model and provider: + raise RuntimeError( + "Pass only one of --model or --provider. --model pins a custom UC " + "model-service as the default; --provider routes through a Model " + "Provider Service. Choose one routing mode." + ) existing = load_state() # Workspaces configured with --use-pat export the profile's PAT as # DATABRICKS_BEARER up front so every auth check below (and the @@ -915,6 +926,17 @@ def _launch_tool( provider_models, error = resolve_provider_models(tool, state, provider) if error: raise RuntimeError(error) + # A custom UC model-service (`--model`) pins that id as the default while + # KEEPING normal system.ai discovery so the family shortcuts still + # populate the picker/codex list. Validate existence only (compatibility + # is assumed per the user's explicit choice); abort with the reason on + # failure. Mutually exclusive with --provider (checked at the top). + custom_model = None + if model: + token = get_databricks_token(state["workspace"], state.get("profile")) + custom_model, error = get_model_service(state["workspace"], token, model) + if error: + raise RuntimeError(error) # Re-fetch model lists on every launch so newly-added Databricks # endpoints show up without a manual `ucode configure` (and so that # tools like pi which read multiple model bundles never run on @@ -927,20 +949,29 @@ def _launch_tool( skip_model_discovery=bool(provider), skip_preflight=skip_preflight, ) - if provider: - # Routing through a Model Provider Service pins no Databricks model; - # the agent uses its own canonical model names (header selects the - # provider). Skip model resolution, which would otherwise fail when - # the workspace has no matching Databricks models. + if provider or custom_model: + # A Model Provider Service pins no Databricks model (the header + # selects the provider). A custom `--model` pins its own id directly + # (ANTHROPIC_MODEL for claude / the `model` key for codex), so the + # discovery-default resolution below isn't needed — skipping it also + # avoids a spurious failure when the workspace has no matching + # Databricks models to fall back on. resolved_model = None else: state, resolved_model = resolve_launch_model(tool, state, None) state = configure_tool( - tool, state, resolved_model, provider=provider, provider_models=provider_models + tool, + state, + resolved_model, + provider=provider, + provider_models=provider_models, + custom_model=custom_model, ) print_section(f"ucode with {TOOL_SPECS[tool]['display']}") if provider: print_kv("Provider", provider) + elif custom_model: + print_kv("Model", custom_model) elif resolved_model: print_kv("Model", resolved_model) if tool in ("gemini", "opencode", "copilot", "pi"): @@ -984,10 +1015,19 @@ def codex_cmd( "before any `--` separator.", ), ] = None, + model: Annotated[ + str | None, + typer.Option( + "--model", + help="Pin a custom UC model-service as the default model (outside " + "system.ai). Assumes the service is compatible with this agent. Pass " + "before any `--` separator.", + ), + ] = None, skip_preflight: SkipPreflightOption = False, ) -> None: """Launch Codex via Databricks.""" - _launch_tool("codex", ctx, provider=provider, skip_preflight=skip_preflight) + _launch_tool("codex", ctx, provider=provider, model=model, skip_preflight=skip_preflight) @app.command("claude", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) @@ -1002,10 +1042,19 @@ def claude_cmd( "before any `--` separator.", ), ] = None, + model: Annotated[ + str | None, + typer.Option( + "--model", + help="Pin a custom UC model-service as the default model (outside " + "system.ai). Assumes the service is compatible with this agent. Pass " + "before any `--` separator.", + ), + ] = None, skip_preflight: SkipPreflightOption = False, ) -> None: """Launch Claude Code via Databricks.""" - _launch_tool("claude", ctx, provider=provider, skip_preflight=skip_preflight) + _launch_tool("claude", ctx, provider=provider, model=model, skip_preflight=skip_preflight) @app.command("gemini", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index b38bca9..ceb5c32 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -26,7 +26,7 @@ from typing import Literal, cast, overload from urllib import error as urllib_error from urllib import request as urllib_request -from urllib.parse import urlencode, urlparse +from urllib.parse import quote, urlencode, urlparse from databricks.sql.exc import ServerOperationError @@ -1183,11 +1183,14 @@ def list_model_services( ) -> tuple[list[str], str | None]: """List all `system.ai.*` model ids via the UC model-services API. - Pages through ``/api/2.1/unity-catalog/model-services`` (metastore scope) - with a bounded ``page_size`` (the endpoint 499s without one) and returns the - de-duplicated, sorted list of ``system.ai.`` ids. Returns - (ids, reason); reason is None on success, otherwise it describes why the - list is empty (HTTP/network error or no services). + Pages through ``/api/2.1/unity-catalog/model-services`` scoped to the + ``system.ai`` schema (``parent=schemas/system.ai``) with a bounded + ``page_size`` (the endpoint 499s without one) and returns the + de-duplicated, sorted list of ``system.ai.`` ids. The + ``system.ai.`` client-side filter is kept as a safety net in case the + ``parent`` scope isn't honored on some workspaces. Returns (ids, reason); + reason is None on success, otherwise it describes why the list is empty + (HTTP/network error or no services). """ hostname = workspace_hostname(workspace) ids: list[str] = [] @@ -1195,7 +1198,14 @@ def list_model_services( seen_tokens: set[str] = set() last_reason: str | None = None for _ in range(max_pages): - params: dict[str, str] = {"page_size": str(page_size)} + # Scope the listing to the system.ai schema so the server returns only + # foundation models instead of every schema's services — much less to + # page through. The client-side `system.ai.` filter below stays as a + # safety net. + params: dict[str, str] = { + "page_size": str(page_size), + "parent": "schemas/system.ai", + } if page_token: params["page_token"] = page_token url = f"https://{hostname}/api/2.1/unity-catalog/model-services?{urlencode(params)}" @@ -1225,6 +1235,85 @@ def list_model_services( return [], last_reason or "model-services listing returned no models" +def get_model_service(workspace: str, token: str, full_name: str) -> tuple[str | None, str | None]: + """Look up a single UC model-service by its ``..``. + + Returns ``(routable_id, None)`` when the service exists, where + ``routable_id`` is the ``name`` with the ``model-services/`` prefix stripped + (i.e. the ``..`` the gateway routes by). Returns + ``(None, reason)`` with an actionable message when the service can't be + found or the API call fails. + + Validates existence only — it does NOT check the provider type or model + family, since a custom ``--model`` service is assumed compatible with the + agent per the caller's explicit choice. + + The single-resource endpoint (``GET .../model-services/``) is + tried first; if that path isn't served (404-style) we fall back to a + schema-scoped listing filtered to the exact name, so the lookup works + regardless of which shape the workspace's API exposes. + """ + full_name = (full_name or "").strip() + if not full_name: + return None, "No model-service name was provided." + hostname = workspace_hostname(workspace) + + # 1. Try the singular GET first. + encoded = quote(full_name, safe="") + url = f"https://{hostname}/api/2.1/unity-catalog/model-services/{encoded}" + payload, reason = _http_get_json(url, token, timeout=30) + if isinstance(payload, dict): + routable = _model_service_routable_id(payload) + if routable: + return routable, None + # A 200 without a usable name is unexpected; fall through to listing. + not_found = bool(reason) and "HTTP 404" in reason + + # 2. Fall back to a schema-scoped listing filtered to the exact name. + parts = full_name.split(".") + if len(parts) >= 2: + schema_parent = ".".join(parts[:2]) # . + params = { + "page_size": str(_MODEL_SERVICES_PAGE_SIZE), + "parent": f"schemas/{schema_parent}", + } + list_url = f"https://{hostname}/api/2.1/unity-catalog/model-services?{urlencode(params)}" + list_payload, list_reason = _get_model_services_page(list_url, token) + if isinstance(list_payload, dict): + for service in list_payload.get("model_services", []): + if not isinstance(service, dict): + continue + routable = _model_service_routable_id(service) + if routable == full_name: + return routable, None + return None, ( + f"Model-service '{full_name}' was not found in schema " + f"'{schema_parent}'. Pass a .. that exists " + f"on this workspace." + ) + # Listing itself failed; prefer the more specific failure to report. + reason = list_reason or reason + + if not_found or (reason and "HTTP 404" in reason): + return None, ( + f"Model-service '{full_name}' was not found. Pass a " + f".. that exists on this workspace." + ) + return None, f"Could not look up model-service '{full_name}': {reason}" + + +def _model_service_routable_id(service: dict) -> str | None: + """Return the ``..`` routable id from a model-service + entry, stripping the ``model-services/`` prefix. None if there's no name.""" + name = service.get("name") + if not isinstance(name, str): + return None + name = name.strip() + if name.startswith(_MODEL_SERVICE_NAME_PREFIX): + name = name[len(_MODEL_SERVICE_NAME_PREFIX) :] + return name or None + + def discover_model_services( workspace: str, token: str ) -> tuple[dict[str, str], list[str], list[str], list[str], str | None]: diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index c7b5b32..230e4a9 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -113,6 +113,31 @@ def test_model_overrides_not_set_when_no_models(self): env = overlay["env"] assert "ANTHROPIC_DEFAULT_SONNET_MODEL" not in env + def test_custom_model_sets_anthropic_model_and_keeps_family_aliases(self): + # `--model` pins the custom UC id as ANTHROPIC_MODEL (the active default) + # AND retains the discovered system.ai family aliases so those picker + # rows still populate. + models = { + "opus": "system.ai.claude-opus-4-8", + "sonnet": "system.ai.claude-sonnet-4-6", + } + overlay, _ = claude.render_overlay( + WS, "s4", claude_models=models, custom_model="cat.schema.my-model" + ) + env = overlay["env"] + assert env["ANTHROPIC_MODEL"] == "cat.schema.my-model" + assert env["ANTHROPIC_DEFAULT_OPUS_MODEL"] == "system.ai.claude-opus-4-8[1m]" + assert env["ANTHROPIC_DEFAULT_SONNET_MODEL"] == "system.ai.claude-sonnet-4-6[1m]" + + def test_custom_model_ignored_under_provider(self): + # `--model` and `--provider` are mutually exclusive upstream; if both + # somehow reach the overlay, the provider header wins and ANTHROPIC_MODEL + # is not pinned (the custom id isn't routable through the header path). + overlay, _ = claude.render_overlay( + WS, "s4", provider="cat.schema.prov", custom_model="cat.schema.my-model" + ) + assert "ANTHROPIC_MODEL" not in overlay["env"] + def test_fable_not_pinned_by_default(self): # Fable is opt-in: even when the workspace advertises it, the env var is # absent unless fable_enabled is passed. diff --git a/tests/test_agent_codex.py b/tests/test_agent_codex.py index 9a68e03..254f7ec 100644 --- a/tests/test_agent_codex.py +++ b/tests/test_agent_codex.py @@ -141,6 +141,45 @@ def test_preserves_databricks_model_id_when_openai_id_is_incompatible( doc = read_toml_safe(config_path) assert doc["model"] == "databricks-gpt-5-2-codex" + def test_custom_model_pinned_verbatim_not_rewritten(self, tmp_path, monkeypatch): + # A custom UC model-service (`--model`) is pinned verbatim, bypassing the + # `_openai_model_id` rewrite that would otherwise turn a gpt-shaped id + # into an OpenAI id. The gateway routes it by name. + config_path = tmp_path / ".codex" / "ucode.config.toml" + backup_path = tmp_path / "codex-ucode-config.backup.toml" + monkeypatch.setattr(codex, "CODEX_CONFIG_PATH", config_path) + monkeypatch.setattr(codex, "CODEX_BACKUP_PATH", backup_path) + monkeypatch.setattr(codex, "agent_version", lambda binary: "0.134.0") + monkeypatch.setattr(codex, "save_state", lambda state: None) + + codex.write_tool_config( + {"workspace": WS, "codex_models": ["databricks-gpt-5"]}, + custom_model="cat.schema.my-gpt-5", + ) + + doc = read_toml_safe(config_path) + # Verbatim — NOT rewritten to `gpt-5`. + assert doc["model"] == "cat.schema.my-gpt-5" + + def test_provider_wins_over_custom_model(self, tmp_path, monkeypatch): + # `--model` and `--provider` are mutually exclusive upstream; if both + # reach write_tool_config, the provider path wins and no model is pinned. + config_path = tmp_path / ".codex" / "ucode.config.toml" + backup_path = tmp_path / "codex-ucode-config.backup.toml" + monkeypatch.setattr(codex, "CODEX_CONFIG_PATH", config_path) + monkeypatch.setattr(codex, "CODEX_BACKUP_PATH", backup_path) + monkeypatch.setattr(codex, "agent_version", lambda binary: "0.134.0") + monkeypatch.setattr(codex, "save_state", lambda state: None) + + codex.write_tool_config( + {"workspace": WS, "codex_models": ["gpt-5"]}, + provider="main.aarushi.aarushi-openai", + custom_model="cat.schema.my-gpt-5", + ) + + doc = read_toml_safe(config_path) + assert "model" not in doc + def test_provider_writes_header_and_drops_stale_model(self, tmp_path, monkeypatch): config_path = tmp_path / ".codex" / "ucode.config.toml" backup_path = tmp_path / "codex-ucode-config.backup.toml" diff --git a/tests/test_cli.py b/tests/test_cli.py index 76a2ec5..f305b57 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1530,3 +1530,72 @@ def test_absent_flag_defaults_false(self, tool): result = runner.invoke(app, [tool]) assert result.exit_code == 0, result.output assert cfg.call_args.kwargs["skip_preflight"] is False + + +class TestModelFlag: + """`--model` on claude/codex validates a custom UC model-service via + get_model_service and threads the resolved id into configure_tool as + custom_model, while keeping normal system.ai discovery running.""" + + @staticmethod + def _patches(configure_tool, get_model_service): + return [ + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli._auto_configure_tool"), + 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.get_databricks_token", return_value="tok"), + patch("ucode.cli.get_model_service", get_model_service), + # If reached, discovery-default resolution would run — but --model + # skips it. Make it explode so the test fails loudly if it's hit. + patch( + "ucode.cli.resolve_launch_model", + side_effect=AssertionError("resolve_launch_model must be skipped for --model"), + ), + patch("ucode.cli.configure_tool", configure_tool), + patch("ucode.cli.launch_agent"), + ] + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_validates_and_threads_custom_model(self, tool): + configure_tool = MagicMock(return_value=MINIMAL_STATE) + # get_model_service echoes back the routable id on success. + get_model_service = MagicMock(return_value=("cat.schema.my-model", None)) + with contextlib.ExitStack() as stack: + for p in self._patches(configure_tool, get_model_service): + stack.enter_context(p) + result = runner.invoke(app, [tool, "--model", "cat.schema.my-model"]) + assert result.exit_code == 0, result.output + # Existence validated against the passed full name. + assert get_model_service.call_args.args[-1] == "cat.schema.my-model" + # Resolved id threaded as custom_model; provider stays None. + assert configure_tool.call_args.kwargs["custom_model"] == "cat.schema.my-model" + assert configure_tool.call_args.kwargs["provider"] is None + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_lookup_failure_aborts_launch(self, tool): + configure_tool = MagicMock(return_value=MINIMAL_STATE) + get_model_service = MagicMock(return_value=(None, "Model-service 'x' was not found.")) + launch_agent = MagicMock() + with contextlib.ExitStack() as stack: + for p in self._patches(configure_tool, get_model_service): + stack.enter_context(p) + stack.enter_context(patch("ucode.cli.launch_agent", launch_agent)) + result = runner.invoke(app, [tool, "--model", "x"]) + assert result.exit_code == 1 + assert "was not found" in _strip_ansi(result.output) + configure_tool.assert_not_called() + launch_agent.assert_not_called() + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_model_and_provider_are_mutually_exclusive(self, tool): + # Mutual exclusion is checked before any state/auth work, so no patches + # beyond load_state are needed — the error must surface first. + with patch("ucode.cli.load_state", return_value=MINIMAL_STATE): + result = runner.invoke( + app, [tool, "--model", "cat.schema.m", "--provider", "cat.schema.p"] + ) + assert result.exit_code == 1 + out = _strip_ansi(result.output) + assert "--model" in out and "--provider" in out diff --git a/tests/test_databricks.py b/tests/test_databricks.py index 40af048..b9d486b 100644 --- a/tests/test_databricks.py +++ b/tests/test_databricks.py @@ -315,6 +315,24 @@ def fake_get(url, token, timeout=10): assert reason is None assert all("page_size=" in u for u in urls) + def test_scopes_listing_to_system_ai_schema(self, monkeypatch): + # The listing is scoped to the system.ai schema so the server returns + # only foundation models instead of every schema's services. + urls: list[str] = [] + + def fake_get(url, token, timeout=10): + urls.append(url) + return {"model_services": [_model_service("system.ai.gpt-5")]}, None + + monkeypatch.setattr(db_mod, "_http_get_json", fake_get) + + ids, reason = db_mod.list_model_services(WS, "token") + + assert ids == ["system.ai.gpt-5"] + assert reason is None + # parent=schemas/system.ai, url-encoded, present on every request. + assert all("parent=schemas%2Fsystem.ai" in u for u in urls) + def test_retries_page_before_giving_up(self, monkeypatch): payload = {"model_services": [_model_service("system.ai.gpt-5")]} calls = {"n": 0} @@ -334,6 +352,69 @@ def flaky_get(url, token, timeout=10): assert calls["n"] == 3 # two failures, third succeeds +class TestGetModelService: + FULL = "cat.schema.my-model" + + def test_singular_get_returns_routable_id(self, monkeypatch): + # The singular GET is tried first; a 200 with a name resolves it. + seen: list[str] = [] + + def fake_get(url, token, timeout=10): + seen.append(url) + return {"name": f"model-services/{self.FULL}"}, None + + monkeypatch.setattr(db_mod, "_http_get_json", fake_get) + + routable, reason = db_mod.get_model_service(WS, "token", self.FULL) + + assert reason is None + assert routable == self.FULL + # Only the singular endpoint was hit — no fallback listing. + assert len(seen) == 1 + assert seen[0].endswith(f"/model-services/{self.FULL}") + + def test_falls_back_to_scoped_listing_when_no_singular_get(self, monkeypatch): + # Singular GET 404s (endpoint not served); the schema-scoped listing + # fallback finds the service by exact name. + def fake_get(url, token, timeout=10): + if "?" not in url: # singular GET + return None, "HTTP 404 Not Found" + # listing + return { + "model_services": [ + _model_service("cat.schema.other"), + _model_service(self.FULL), + ] + }, None + + monkeypatch.setattr(db_mod, "_http_get_json", fake_get) + + routable, reason = db_mod.get_model_service(WS, "token", self.FULL) + + assert reason is None + assert routable == self.FULL + + def test_not_found_returns_actionable_reason(self, monkeypatch): + # Singular GET 404s and the scoped listing doesn't contain the name. + def fake_get(url, token, timeout=10): + if "?" not in url: + return None, "HTTP 404 Not Found" + return {"model_services": [_model_service("cat.schema.other")]}, None + + monkeypatch.setattr(db_mod, "_http_get_json", fake_get) + + routable, reason = db_mod.get_model_service(WS, "token", self.FULL) + + assert routable is None + assert reason is not None + assert self.FULL in reason and "not found" in reason.lower() + + def test_empty_name_is_rejected(self): + routable, reason = db_mod.get_model_service(WS, "token", " ") + assert routable is None + assert reason is not None + + class TestListModelProviderServices: _PAYLOAD = { "model_provider_services": [