Skip to content

Commit 6d238e6

Browse files
authored
configure: add --skip-unavailable so --agents can configure the available subset (#308)
* configure: add --skip-unavailable for --agents `--agents` treats an explicit list as all-or-nothing: if any named agent isn't available on the workspace, the run raises before configuring any of them. On a workspace whose AI Gateway exposes no OpenAI models, `--agents claude,codex,pi` therefore configures nothing, even though claude and pi are both usable there. Keep that strict default — naming agents explicitly is a request for a specific outcome, and a run that silently configures a subset would exit 0 while leaving a caller believing codex works, surfacing the failure later at launch time instead. Add an opt-in `--skip-unavailable` for callers that want the tolerant behavior: available agents are configured and the rest are skipped with a warning, preserving the requested order. The run still exits non-zero when none of the requested agents are available, so the flag can't turn a completely unusable workspace into a silent success. `--skip-unavailable` requires `--agents`: the interactive picker already offers only available agents, and `--agent` names a single agent whose absence is the whole answer. The strict error message now points at the flag. Co-authored-by: Isaac * codex: use gpt-oss-* models when no versioned GPT is available default_model() returned None for workspaces whose codex_models list contains only gpt-oss-* ids (e.g. system.ai.gpt-oss-120b), causing codex to configure with a stale or missing model. The restriction was intended to exclude non-GPT ids (e.g. moonshotai/kimi-k2.5) that the responses gateway would reject, but gpt-oss-* ids come from the codex bucket in UC model-services and expose the responses API — confirmed live against az-dogfood, HTTP 200, tokens consumed. Fall back to the first available model when no versioned GPT parses, instead of returning None. Versioned GPT ids (gpt-5, gpt-5-6-luna, ...) still win when present. Co-authored-by: Isaac * codex: fix gpt-oss fallback and CI test failures Two regressions from the previous commit: 1. default_model() fallback was too broad — it returned any first model when no versioned GPT parsed, including non-GPT ids like moonshotai/kimi-k2.5 that the responses gateway would reject. Tighten the fallback to gpt-* prefixed models only via a new _is_gpt_family() helper. gpt-oss-* stays usable; non-GPT ids stay excluded. 2. test_skip_unavailable_forwarded_with_agents used --profiles DEFAULT --use-pat which reads ~/.databrickscfg — that file doesn't exist on CI runners. Switch to --workspaces https://... which doesn't touch the filesystem. Co-authored-by: Isaac
1 parent eb89248 commit 6d238e6

5 files changed

Lines changed: 195 additions & 21 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ ucode configure --agents claude,codex
7575

7676
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).
7777

78+
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:
79+
80+
```bash
81+
ucode configure --agents claude,codex,pi --skip-unavailable
82+
```
83+
84+
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.
85+
7886
To configure without the workspace picker, pass a comma-separated list of workspaces:
7987

8088
```bash
@@ -233,6 +241,7 @@ pick the new config up on their next ucode run.
233241
| `ucode claude --enable-smart-routing` | Enable AI Gateway routing for Claude Code sessions and subagents |
234242
| `ucode claude --disable-smart-routing` | Disable routing and remove ucode's Claude Code routing hooks |
235243
| `ucode configure --skip-validate` | Write configs without sending a test message through each agent |
244+
| `ucode configure --agents claude,codex,pi --skip-unavailable` | Configure the requested agents that are available; skip the rest with a warning |
236245
| `ucode configure --agents claude --mcp system.ai.slack` | Configure an agent and register its Databricks MCP server(s) in one command |
237246
| `ucode configure skills` | Register the skills MCP connection (utility tools only); no skills download |
238247
| `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 |

src/ucode/agents/codex.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,14 @@ def write_tool_config(state: dict, model: str | None = None, provider: str | Non
371371
return state
372372

373373

374+
def _is_gpt_family(model: str) -> bool:
375+
"""Return True if this id is in the GPT family (versioned or OSS variants)."""
376+
tail = model.split("/")[-1]
377+
if tail.startswith("system.ai."):
378+
tail = tail[len("system.ai.") :]
379+
return tail.startswith("gpt-")
380+
381+
374382
def _managed_config_path() -> Path | None:
375383
"""OS-level Codex managed config file, or None on unsupported platforms.
376384
@@ -414,32 +422,36 @@ def _write_managed_config(
414422

415423

416424
def default_model(state: dict) -> str | None:
417-
"""Pick the newest GPT model when multiple are available.
418-
419-
A managed config's ``codex_default_model`` takes priority. The discovery list
420-
is alphabetically sorted, which can put "databricks-gpt-5" ahead of
421-
"databricks-gpt-5-5". Prefer the highest semantic version instead.
422-
423-
Only GPT-parseable ids are considered. Codex routes the chosen ``model``
424-
through the gateway as-is, so a non-GPT entry (e.g. ``moonshotai/kimi-k2.5``)
425-
would be rejected with a Unity Catalog endpoint-name error. When no
426-
candidate parses as GPT we return None rather than pinning an unroutable id.
425+
"""Pick the best available codex model.
426+
427+
A managed config's ``codex_default_model`` takes priority. Among versioned
428+
GPT ids (e.g. ``system.ai.gpt-5``, ``system.ai.gpt-5-6-luna``) the highest
429+
semantic version wins. When no versioned GPT is present but other codex-family
430+
ids are available (e.g. ``system.ai.gpt-oss-120b``), the first of those is
431+
used — UC model-services only places ids in the codex bucket when they expose
432+
the responses API, so any id there is routable.
427433
"""
428434
if isinstance(state.get("codex_default_model"), str):
429435
return state.get("codex_default_model")
430436
codex_models = state.get("codex_models") or []
431437
parsed: list[tuple[str, tuple[int, int | None, int | None, str]]] = [
432438
(mid, gpt) for mid in codex_models if (gpt := _parse_gpt(mid)) is not None
433439
]
434-
if not parsed:
435-
return None
440+
if parsed:
441+
442+
def _gpt_version_key(entry: tuple[str, tuple[int, int | None, int | None, str]]):
443+
major, minor, patch, suffix = entry[1]
444+
base_bonus = 1 if not suffix else 0
445+
return (major, minor or 0, patch or 0, base_bonus)
436446

437-
def _gpt_version_key(entry: tuple[str, tuple[int, int | None, int | None, str]]):
438-
major, minor, patch, suffix = entry[1]
439-
base_bonus = 1 if not suffix else 0
440-
return (major, minor or 0, patch or 0, base_bonus)
447+
return max(parsed, key=_gpt_version_key)[0]
441448

442-
return max(parsed, key=_gpt_version_key)[0]
449+
# No versioned GPT found. Fall back to the first GPT-family id (gpt-*
450+
# after stripping the system.ai. prefix). gpt-oss-* models are confirmed
451+
# routable through the responses API; non-GPT ids (e.g. moonshotai/kimi-k2.5)
452+
# would be rejected by the gateway, so they stay excluded.
453+
gpt_family = [m for m in codex_models if _is_gpt_family(m)]
454+
return gpt_family[0] if gpt_family else None
443455

444456

445457
# codex rejects the global --profile on subcommands that don't accept it

src/ucode/cli.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,7 @@ def configure_workspace_command(
744744
prompt_optional_updates: bool = True,
745745
use_pat: bool = False,
746746
skip_validate: bool = False,
747+
skip_unavailable: bool = False,
747748
fable_enabled: bool | None = None,
748749
databricks_ai_tools_enabled: bool | None = None,
749750
) -> int:
@@ -830,8 +831,13 @@ def configure_workspace_command(
830831
displays = ", ".join(
831832
TOOL_SPECS[tool_name]["display"] for tool_name in unavailable_tools
832833
)
833-
raise RuntimeError(f"Requested agent(s) not available on this workspace: {displays}.")
834-
picked = selected_tools
834+
if not skip_unavailable:
835+
raise RuntimeError(
836+
f"Requested agent(s) not available on this workspace: {displays}. "
837+
"Pass --skip-unavailable to configure the available ones instead."
838+
)
839+
print_warning(f"Skipping agent(s) not available on this workspace: {displays}.")
840+
picked = [tool_name for tool_name in selected_tools if tool_name in available_on_workspace]
835841

836842
if not picked:
837843
print_note("No coding agents selected — nothing to configure.")
@@ -2199,6 +2205,17 @@ def configure(
21992205
"freshly discovered models.",
22002206
),
22012207
] = False,
2208+
skip_unavailable: Annotated[
2209+
bool,
2210+
typer.Option(
2211+
"--skip-unavailable",
2212+
help="With --agents, configure the agents that are available on the workspace "
2213+
"and skip (with a warning) any that aren't, instead of failing the whole run. "
2214+
"Useful in CI against heterogeneous workspaces — e.g. requesting "
2215+
"claude,codex,pi where the workspace exposes no OpenAI models still "
2216+
"configures claude and pi. Exits non-zero only if none are available.",
2217+
),
2218+
] = False,
22022219
enable_fable: Annotated[
22032220
bool | None,
22042221
typer.Option(
@@ -2278,6 +2295,15 @@ def configure(
22782295
"--use-pat requires --profiles. Pass the PAT-backed Databricks CLI "
22792296
"profile(s) explicitly, e.g. `ucode configure --profiles DEFAULT --use-pat`."
22802297
)
2298+
# Skipping only has meaning against an explicit agent list: the interactive
2299+
# picker already offers just the available agents, and --agent names a
2300+
# single agent whose absence is the whole answer.
2301+
if skip_unavailable and agents is None:
2302+
raise RuntimeError(
2303+
"--skip-unavailable requires --agents. It selects the available subset "
2304+
"of an explicit agent list, e.g. `ucode configure --agents claude,codex,pi "
2305+
"--skip-unavailable`."
2306+
)
22812307
workspace_entries = _parse_workspaces_option(workspaces) if workspaces is not None else None
22822308
if profiles is not None:
22832309
workspace_entries = _parse_profiles_option(profiles)
@@ -2340,18 +2366,21 @@ def configure(
23402366
model_agent_names = ",".join(a for a in requested if a != "cursor")
23412367
if model_agent_names:
23422368
selected_tools = _parse_agents_option(model_agent_names)
2369+
agents_kwargs = dict(skip_kwargs)
2370+
if skip_unavailable:
2371+
agents_kwargs["skip_unavailable"] = True
23432372
if workspace_entries is None:
23442373
configure_workspace_command(
23452374
selected_tools=selected_tools,
23462375
prompt_optional_updates=prompt_optional_updates,
2347-
**skip_kwargs,
2376+
**agents_kwargs,
23482377
)
23492378
else:
23502379
configure_workspace_command(
23512380
selected_tools=selected_tools,
23522381
workspaces=workspace_entries,
23532382
prompt_optional_updates=prompt_optional_updates,
2354-
**skip_kwargs,
2383+
**agents_kwargs,
23552384
)
23562385
elif wants_cursor:
23572386
# Cursor-only: establish workspace state without the model picker.

tests/test_agent_codex.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,18 @@ def test_default_model_selects_model_services_gpt(self):
530530

531531
assert codex.default_model({"codex_models": models}) == "system.ai.gpt-5-5"
532532

533+
def test_default_model_falls_back_to_first_when_no_versioned_gpt(self):
534+
# gpt-oss-* models are in the codex bucket from UC model-services and
535+
# expose the responses API, so they're routable even though _parse_gpt
536+
# returns None for them (no semantic version to rank).
537+
models = ["system.ai.gpt-oss-120b", "system.ai.gpt-oss-20b"]
538+
assert codex.default_model({"codex_models": models}) == "system.ai.gpt-oss-120b"
539+
540+
def test_default_model_prefers_versioned_gpt_over_oss(self):
541+
# When both versioned and OSS models are present, the versioned one wins.
542+
models = ["system.ai.gpt-oss-120b", "system.ai.gpt-5"]
543+
assert codex.default_model({"codex_models": models}) == "system.ai.gpt-5"
544+
533545

534546
class TestCodexValidateCmd:
535547
def test_starts_with_binary(self):

tests/test_cli.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1517,6 +1517,79 @@ def test_unavailable_selected_tool_errors_before_configure(self, monkeypatch):
15171517
with pytest.raises(RuntimeError, match="Codex"):
15181518
cli_mod.configure_workspace_command(selected_tools=["claude", "codex"])
15191519

1520+
def test_strict_error_mentions_skip_unavailable(self, monkeypatch):
1521+
import ucode.cli as cli_mod
1522+
1523+
state = {**MINIMAL_STATE, "available_tools": []}
1524+
monkeypatch.setattr(cli_mod, "configure_shared_state", lambda *a, **k: state)
1525+
monkeypatch.setattr(cli_mod, "check_gateway_endpoint", lambda state, tool: tool == "claude")
1526+
monkeypatch.setattr(cli_mod, "install_tool_binary", lambda *a, **k: None)
1527+
1528+
with pytest.raises(RuntimeError, match="--skip-unavailable"):
1529+
cli_mod.configure_workspace_command(
1530+
selected_tools=["claude", "codex"],
1531+
workspaces=[("https://example.com", None)],
1532+
)
1533+
1534+
def test_skip_unavailable_configures_available_subset(self, monkeypatch):
1535+
"""A workspace with no OpenAI models still configures claude and pi."""
1536+
import ucode.cli as cli_mod
1537+
1538+
state = {**MINIMAL_STATE, "available_tools": []}
1539+
monkeypatch.setattr(cli_mod, "configure_shared_state", lambda *a, **k: state)
1540+
monkeypatch.setattr(
1541+
cli_mod, "check_gateway_endpoint", lambda state, tool: tool in {"claude", "pi"}
1542+
)
1543+
installed: list[str] = []
1544+
monkeypatch.setattr(
1545+
cli_mod,
1546+
"install_tool_binary",
1547+
lambda tool, **kwargs: installed.append(tool) or True,
1548+
)
1549+
configured: list[list[str]] = []
1550+
monkeypatch.setattr(
1551+
cli_mod,
1552+
"configure_selected_tools",
1553+
lambda state, tools: configured.append(tools) or {**state, "available_tools": tools},
1554+
)
1555+
monkeypatch.setattr(cli_mod, "validate_all_tools", lambda state: None)
1556+
warnings: list[str] = []
1557+
monkeypatch.setattr(cli_mod, "print_warning", lambda msg: warnings.append(msg))
1558+
1559+
assert (
1560+
cli_mod.configure_workspace_command(
1561+
selected_tools=["claude", "codex", "pi"],
1562+
workspaces=[("https://example.com", None)],
1563+
skip_unavailable=True,
1564+
)
1565+
== 0
1566+
)
1567+
# Order of the original --agents list is preserved, minus codex.
1568+
assert configured == [["claude", "pi"]]
1569+
assert installed == ["claude", "pi"]
1570+
assert any("Codex" in msg for msg in warnings)
1571+
1572+
def test_skip_unavailable_still_fails_when_none_available(self, monkeypatch):
1573+
import ucode.cli as cli_mod
1574+
1575+
state = {**MINIMAL_STATE, "available_tools": []}
1576+
monkeypatch.setattr(cli_mod, "configure_shared_state", lambda *a, **k: state)
1577+
monkeypatch.setattr(cli_mod, "check_gateway_endpoint", lambda state, tool: False)
1578+
monkeypatch.setattr(
1579+
cli_mod,
1580+
"configure_selected_tools",
1581+
lambda state, tools: pytest.fail("configure_selected_tools should not be called"),
1582+
)
1583+
1584+
assert (
1585+
cli_mod.configure_workspace_command(
1586+
selected_tools=["codex"],
1587+
workspaces=[("https://example.com", None)],
1588+
skip_unavailable=True,
1589+
)
1590+
== 1
1591+
)
1592+
15201593
def test_picker_selected_profile_flows_to_configure_shared_state(self, monkeypatch):
15211594
"""Picker's (host, profile) tuple must reach configure_shared_state's
15221595
`profile` kwarg, otherwise downstream --profile calls fall back to
@@ -1751,6 +1824,45 @@ def test_use_pat_requires_profiles(self):
17511824
assert "--use-pat requires --profiles" in _strip_ansi(result.output)
17521825
mock_cfg.assert_not_called()
17531826

1827+
def test_skip_unavailable_requires_agents(self):
1828+
with (
1829+
patch("ucode.cli.install_databricks_cli"),
1830+
patch("ucode.cli.configure_workspace_command") as mock_cfg,
1831+
):
1832+
result = runner.invoke(app, ["configure", "--skip-unavailable"])
1833+
assert result.exit_code == 1
1834+
assert "--skip-unavailable requires --agents" in _strip_ansi(result.output)
1835+
mock_cfg.assert_not_called()
1836+
1837+
def test_skip_unavailable_forwarded_with_agents(self):
1838+
with (
1839+
patch("ucode.cli.install_databricks_cli"),
1840+
patch("ucode.cli.configure_workspace_command") as mock_cfg,
1841+
):
1842+
result = runner.invoke(
1843+
app,
1844+
[
1845+
"configure",
1846+
"--workspaces",
1847+
"https://example.azuredatabricks.net",
1848+
"--agents",
1849+
"claude,codex,pi",
1850+
"--skip-unavailable",
1851+
],
1852+
)
1853+
assert result.exit_code == 0, result.output
1854+
assert mock_cfg.call_args.kwargs["skip_unavailable"] is True
1855+
assert mock_cfg.call_args.kwargs["selected_tools"] == ["claude", "codex", "pi"]
1856+
1857+
def test_skip_unavailable_absent_by_default(self):
1858+
with (
1859+
patch("ucode.cli.install_databricks_cli"),
1860+
patch("ucode.cli.configure_workspace_command") as mock_cfg,
1861+
):
1862+
result = runner.invoke(app, ["configure", "--agents", "claude,codex"])
1863+
assert result.exit_code == 0, result.output
1864+
assert "skip_unavailable" not in mock_cfg.call_args.kwargs
1865+
17541866
def test_profiles_and_workspaces_are_mutually_exclusive(self):
17551867
with (
17561868
patch("ucode.cli.install_databricks_cli"),

0 commit comments

Comments
 (0)