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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <dir>]` | Download a schema's skills to disk (under `<dir>`, or your home dir) and register a schema-less skills MCP connection |
Expand Down
44 changes: 28 additions & 16 deletions src/ucode/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,33 +355,45 @@ 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")
codex_models = state.get("codex_models") or []
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:
Expand Down
37 changes: 33 additions & 4 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions tests/test_agent_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
112 changes: 112 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"),
Expand Down
Loading