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"),