diff --git a/README.md b/README.md index 444ba129..9fd3bda9 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,14 @@ ucode configure --agents claude,codex Available agent names are `codex`, `claude`, `gemini`, `opencode`, `copilot`, and `pi`. `cursor` is also accepted (MCP-only — it registers Databricks MCP servers but configures no models). +Naming agents explicitly is treated as a request for all of them: if any one isn't available on the workspace, the run fails without configuring the others. Add `--skip-unavailable` to configure the available subset instead and skip the rest with a warning: + +```bash +ucode configure --agents claude,codex,pi --skip-unavailable +``` + +This is useful in CI against a mix of workspaces — on a workspace whose AI Gateway exposes no OpenAI models, the command above still configures `claude` and `pi`, and reports Codex as skipped. It exits non-zero only when none of the requested agents are available. + To configure without the workspace picker, pass a comma-separated list of workspaces: ```bash @@ -235,6 +243,7 @@ pick the new config up on their next ucode run. | `ucode claude --enable-smart-routing` | Enable AI Gateway routing for Claude Code sessions and subagents | | `ucode claude --disable-smart-routing` | Disable routing and remove ucode's Claude Code routing hooks | | `ucode configure --skip-validate` | Write configs without sending a test message through each agent | +| `ucode configure --agents claude,codex,pi --skip-unavailable` | Configure the requested agents that are available; skip the rest with a warning | | `ucode configure --agents claude --mcp system.ai.slack` | Configure an agent and register its Databricks MCP server(s) in one command | | `ucode configure skills` | Register the skills MCP connection (utility tools only); no skills download | | `ucode configure skills --location main.default [--path ]` | Download a schema's skills to disk (under ``, or your home dir) and register a schema-less skills MCP connection | diff --git a/src/ucode/agents/codex.py b/src/ucode/agents/codex.py index b8f37cc3..0ce96da5 100644 --- a/src/ucode/agents/codex.py +++ b/src/ucode/agents/codex.py @@ -355,17 +355,23 @@ def write_tool_config(state: dict, model: str | None = None, provider: str | Non return state -def default_model(state: dict) -> str | None: - """Pick the newest GPT model when multiple are available. +def _is_gpt_family(model: str) -> bool: + """Return True if this id is in the GPT family (versioned or OSS variants).""" + tail = model.split("/")[-1] + if tail.startswith("system.ai."): + tail = tail[len("system.ai.") :] + return tail.startswith("gpt-") - A managed config's ``codex_default_model`` takes priority. The discovery list - is alphabetically sorted, which can put "databricks-gpt-5" ahead of - "databricks-gpt-5-5". Prefer the highest semantic version instead. - Only GPT-parseable ids are considered. Codex routes the chosen ``model`` - through the gateway as-is, so a non-GPT entry (e.g. ``moonshotai/kimi-k2.5``) - would be rejected with a Unity Catalog endpoint-name error. When no - candidate parses as GPT we return None rather than pinning an unroutable id. +def default_model(state: dict) -> str | None: + """Pick the best available codex model. + + A managed config's ``codex_default_model`` takes priority. Among versioned + GPT ids (e.g. ``system.ai.gpt-5``, ``system.ai.gpt-5-6-luna``) the highest + semantic version wins. When no versioned GPT is present but other codex-family + ids are available (e.g. ``system.ai.gpt-oss-120b``), the first of those is + used — UC model-services only places ids in the codex bucket when they expose + the responses API, so any id there is routable. """ if isinstance(state.get("codex_default_model"), str): return state.get("codex_default_model") @@ -373,15 +379,21 @@ def default_model(state: dict) -> str | None: parsed: list[tuple[str, tuple[int, int | None, int | None, str]]] = [ (mid, gpt) for mid in codex_models if (gpt := _parse_gpt(mid)) is not None ] - if not parsed: - return None + if parsed: + + def _gpt_version_key(entry: tuple[str, tuple[int, int | None, int | None, str]]): + major, minor, patch, suffix = entry[1] + base_bonus = 1 if not suffix else 0 + return (major, minor or 0, patch or 0, base_bonus) - def _gpt_version_key(entry: tuple[str, tuple[int, int | None, int | None, str]]): - major, minor, patch, suffix = entry[1] - base_bonus = 1 if not suffix else 0 - return (major, minor or 0, patch or 0, base_bonus) + return max(parsed, key=_gpt_version_key)[0] - return max(parsed, key=_gpt_version_key)[0] + # No versioned GPT found. Fall back to the first GPT-family id (gpt-* + # after stripping the system.ai. prefix). gpt-oss-* models are confirmed + # routable through the responses API; non-GPT ids (e.g. moonshotai/kimi-k2.5) + # would be rejected by the gateway, so they stay excluded. + gpt_family = [m for m in codex_models if _is_gpt_family(m)] + return gpt_family[0] if gpt_family else None def launch(state: dict, tool_args: list[str]) -> None: diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 7c982c7c..44599b80 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -672,6 +672,7 @@ def configure_workspace_command( prompt_optional_updates: bool = True, use_pat: bool = False, skip_validate: bool = False, + skip_unavailable: bool = False, fable_enabled: bool | None = None, databricks_ai_tools_enabled: bool | None = None, ) -> int: @@ -758,8 +759,13 @@ def configure_workspace_command( displays = ", ".join( TOOL_SPECS[tool_name]["display"] for tool_name in unavailable_tools ) - raise RuntimeError(f"Requested agent(s) not available on this workspace: {displays}.") - picked = selected_tools + if not skip_unavailable: + raise RuntimeError( + f"Requested agent(s) not available on this workspace: {displays}. " + "Pass --skip-unavailable to configure the available ones instead." + ) + print_warning(f"Skipping agent(s) not available on this workspace: {displays}.") + picked = [tool_name for tool_name in selected_tools if tool_name in available_on_workspace] if not picked: print_note("No coding agents selected — nothing to configure.") @@ -1978,6 +1984,17 @@ def configure( "freshly discovered models.", ), ] = False, + skip_unavailable: Annotated[ + bool, + typer.Option( + "--skip-unavailable", + help="With --agents, configure the agents that are available on the workspace " + "and skip (with a warning) any that aren't, instead of failing the whole run. " + "Useful in CI against heterogeneous workspaces — e.g. requesting " + "claude,codex,pi where the workspace exposes no OpenAI models still " + "configures claude and pi. Exits non-zero only if none are available.", + ), + ] = False, enable_fable: Annotated[ bool | None, typer.Option( @@ -2056,6 +2073,15 @@ def configure( "--use-pat requires --profiles. Pass the PAT-backed Databricks CLI " "profile(s) explicitly, e.g. `ucode configure --profiles DEFAULT --use-pat`." ) + # Skipping only has meaning against an explicit agent list: the interactive + # picker already offers just the available agents, and --agent names a + # single agent whose absence is the whole answer. + if skip_unavailable and agents is None: + raise RuntimeError( + "--skip-unavailable requires --agents. It selects the available subset " + "of an explicit agent list, e.g. `ucode configure --agents claude,codex,pi " + "--skip-unavailable`." + ) workspace_entries = _parse_workspaces_option(workspaces) if workspaces is not None else None if profiles is not None: workspace_entries = _parse_profiles_option(profiles) @@ -2110,18 +2136,21 @@ def configure( model_agent_names = ",".join(a for a in requested if a != "cursor") if model_agent_names: selected_tools = _parse_agents_option(model_agent_names) + agents_kwargs = dict(skip_kwargs) + if skip_unavailable: + agents_kwargs["skip_unavailable"] = True if workspace_entries is None: configure_workspace_command( selected_tools=selected_tools, prompt_optional_updates=prompt_optional_updates, - **skip_kwargs, + **agents_kwargs, ) else: configure_workspace_command( selected_tools=selected_tools, workspaces=workspace_entries, prompt_optional_updates=prompt_optional_updates, - **skip_kwargs, + **agents_kwargs, ) elif wants_cursor: # Cursor-only: establish workspace state without the model picker. diff --git a/tests/test_agent_codex.py b/tests/test_agent_codex.py index 10d0f9e1..20c3510d 100644 --- a/tests/test_agent_codex.py +++ b/tests/test_agent_codex.py @@ -527,6 +527,18 @@ def test_default_model_selects_model_services_gpt(self): assert codex.default_model({"codex_models": models}) == "system.ai.gpt-5-5" + def test_default_model_falls_back_to_first_when_no_versioned_gpt(self): + # gpt-oss-* models are in the codex bucket from UC model-services and + # expose the responses API, so they're routable even though _parse_gpt + # returns None for them (no semantic version to rank). + models = ["system.ai.gpt-oss-120b", "system.ai.gpt-oss-20b"] + assert codex.default_model({"codex_models": models}) == "system.ai.gpt-oss-120b" + + def test_default_model_prefers_versioned_gpt_over_oss(self): + # When both versioned and OSS models are present, the versioned one wins. + models = ["system.ai.gpt-oss-120b", "system.ai.gpt-5"] + assert codex.default_model({"codex_models": models}) == "system.ai.gpt-5" + class TestCodexValidateCmd: def test_starts_with_binary(self): diff --git a/tests/test_cli.py b/tests/test_cli.py index a0109ff8..4601f9de 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1447,6 +1447,79 @@ def test_unavailable_selected_tool_errors_before_configure(self, monkeypatch): with pytest.raises(RuntimeError, match="Codex"): cli_mod.configure_workspace_command(selected_tools=["claude", "codex"]) + def test_strict_error_mentions_skip_unavailable(self, monkeypatch): + import ucode.cli as cli_mod + + state = {**MINIMAL_STATE, "available_tools": []} + monkeypatch.setattr(cli_mod, "configure_shared_state", lambda *a, **k: state) + monkeypatch.setattr(cli_mod, "check_gateway_endpoint", lambda state, tool: tool == "claude") + monkeypatch.setattr(cli_mod, "install_tool_binary", lambda *a, **k: None) + + with pytest.raises(RuntimeError, match="--skip-unavailable"): + cli_mod.configure_workspace_command( + selected_tools=["claude", "codex"], + workspaces=[("https://example.com", None)], + ) + + def test_skip_unavailable_configures_available_subset(self, monkeypatch): + """A workspace with no OpenAI models still configures claude and pi.""" + import ucode.cli as cli_mod + + state = {**MINIMAL_STATE, "available_tools": []} + monkeypatch.setattr(cli_mod, "configure_shared_state", lambda *a, **k: state) + monkeypatch.setattr( + cli_mod, "check_gateway_endpoint", lambda state, tool: tool in {"claude", "pi"} + ) + installed: list[str] = [] + monkeypatch.setattr( + cli_mod, + "install_tool_binary", + lambda tool, **kwargs: installed.append(tool) or True, + ) + configured: list[list[str]] = [] + monkeypatch.setattr( + cli_mod, + "configure_selected_tools", + lambda state, tools: configured.append(tools) or {**state, "available_tools": tools}, + ) + monkeypatch.setattr(cli_mod, "validate_all_tools", lambda state: None) + warnings: list[str] = [] + monkeypatch.setattr(cli_mod, "print_warning", lambda msg: warnings.append(msg)) + + assert ( + cli_mod.configure_workspace_command( + selected_tools=["claude", "codex", "pi"], + workspaces=[("https://example.com", None)], + skip_unavailable=True, + ) + == 0 + ) + # Order of the original --agents list is preserved, minus codex. + assert configured == [["claude", "pi"]] + assert installed == ["claude", "pi"] + assert any("Codex" in msg for msg in warnings) + + def test_skip_unavailable_still_fails_when_none_available(self, monkeypatch): + import ucode.cli as cli_mod + + state = {**MINIMAL_STATE, "available_tools": []} + monkeypatch.setattr(cli_mod, "configure_shared_state", lambda *a, **k: state) + monkeypatch.setattr(cli_mod, "check_gateway_endpoint", lambda state, tool: False) + monkeypatch.setattr( + cli_mod, + "configure_selected_tools", + lambda state, tools: pytest.fail("configure_selected_tools should not be called"), + ) + + assert ( + cli_mod.configure_workspace_command( + selected_tools=["codex"], + workspaces=[("https://example.com", None)], + skip_unavailable=True, + ) + == 1 + ) + def test_multiple_workspaces_configure_all_and_use_first(self, monkeypatch): import ucode.cli as cli_mod @@ -1639,6 +1712,45 @@ def test_use_pat_requires_profiles(self): assert "--use-pat requires --profiles" in _strip_ansi(result.output) mock_cfg.assert_not_called() + def test_skip_unavailable_requires_agents(self): + with ( + patch("ucode.cli.install_databricks_cli"), + patch("ucode.cli.configure_workspace_command") as mock_cfg, + ): + result = runner.invoke(app, ["configure", "--skip-unavailable"]) + assert result.exit_code == 1 + assert "--skip-unavailable requires --agents" in _strip_ansi(result.output) + mock_cfg.assert_not_called() + + def test_skip_unavailable_forwarded_with_agents(self): + with ( + patch("ucode.cli.install_databricks_cli"), + patch("ucode.cli.configure_workspace_command") as mock_cfg, + ): + result = runner.invoke( + app, + [ + "configure", + "--workspaces", + "https://example.azuredatabricks.net", + "--agents", + "claude,codex,pi", + "--skip-unavailable", + ], + ) + assert result.exit_code == 0, result.output + assert mock_cfg.call_args.kwargs["skip_unavailable"] is True + assert mock_cfg.call_args.kwargs["selected_tools"] == ["claude", "codex", "pi"] + + def test_skip_unavailable_absent_by_default(self): + with ( + patch("ucode.cli.install_databricks_cli"), + patch("ucode.cli.configure_workspace_command") as mock_cfg, + ): + result = runner.invoke(app, ["configure", "--agents", "claude,codex"]) + assert result.exit_code == 0, result.output + assert "skip_unavailable" not in mock_cfg.call_args.kwargs + def test_profiles_and_workspaces_are_mutually_exclusive(self): with ( patch("ucode.cli.install_databricks_cli"),