Skip to content

Commit 51cf452

Browse files
committed
Add ucode run: recommend-model-driven session startup
New top-level `ucode run` command that queries the AI Gateway's recommendModel endpoint for the caller's usage tier, then launches the matching harness pinned to the chosen model: - Discover all workspace models, POST them to /api/ai-gateway/v2/coding-agent-configs:recommendModel. - 1 model -> auto-launch (no prompt); 2+ -> interactive picker; empty -> guidance to use `ucode <harness>`; error -> exit non-zero. - Map model family -> harness: claude->claude, gpt->codex, kimi/glm->codex, gemini->gemini. - Extend codex to route OSS models (kimi/glm) via the MLflow chat-completions gateway (wire_api="chat") instead of the Responses route; GPT models unchanged. Adds recommend_coding_agent_models, is_oss_model, build_oss_base_url (databricks.py) and harness_for_model (agents/__init__.py), with unit tests for the endpoint, the harness map, codex OSS routing, and CLI routing. Co-authored-by: Isaac
1 parent a4ec6ec commit 51cf452

8 files changed

Lines changed: 509 additions & 5 deletions

File tree

src/ucode/agents/__init__.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
BEDROCK_PROVIDER_TYPES,
2222
get_databricks_token,
2323
install_databricks_cli,
24+
is_oss_model,
2425
map_bedrock_claude_models,
2526
resolve_provider_service,
2627
)
@@ -75,6 +76,34 @@ def normalize_tool(tool: str) -> str:
7576
return normalized
7677

7778

79+
def harness_for_model(model_id: str) -> str | None:
80+
"""Map a model id to the coding-agent harness that can serve it.
81+
82+
Used by `ucode run` to pick a harness from the model the user selected:
83+
84+
- claude models -> claude (Claude Code)
85+
- gpt models -> codex
86+
- OSS (kimi/glm) -> codex
87+
- gemini models -> gemini
88+
89+
Returns None when no harness maps to the model so the caller can surface a
90+
clear error instead of launching the wrong tool. OSS is checked before the
91+
generic families via the shared `is_oss_model` substrings so the map stays in
92+
sync with discovery.
93+
"""
94+
if not model_id:
95+
return None
96+
if is_oss_model(model_id):
97+
return "codex"
98+
if "claude" in model_id:
99+
return "claude"
100+
if "gpt-" in model_id:
101+
return "codex"
102+
if "gemini-" in model_id:
103+
return "gemini"
104+
return None
105+
106+
78107
def _update_installed_tool_binary(tool: str, version: str | None = None) -> bool:
79108
spec = TOOL_SPECS[tool]
80109
binary = spec["binary"]

src/ucode/agents/codex.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
)
1818
from ucode.databricks import (
1919
build_auth_token_argv,
20+
build_oss_base_url,
2021
build_tool_base_url,
2122
get_databricks_token,
23+
is_oss_model,
2224
)
2325
from ucode.launcher import exec_or_spawn
2426
from ucode.state import mark_tool_managed, save_state
@@ -107,9 +109,14 @@ def _provider_block(
107109
databricks_profile: str | None,
108110
use_pat: bool = False,
109111
provider: str | None = None,
112+
model: str | None = None,
110113
) -> dict:
111114
auth_argv = build_auth_token_argv(workspace, databricks_profile, use_pat=use_pat)
112-
base_url = build_tool_base_url("codex", workspace)
115+
# OSS chat models (kimi/glm) don't speak the Responses API — they're served
116+
# by the OpenAI-compatible MLflow chat-completions gateway. Route them there
117+
# with `wire_api = "chat"`; everything else uses the codex Responses route.
118+
oss = is_oss_model(model)
119+
base_url = build_oss_base_url(workspace) if oss else build_tool_base_url("codex", workspace)
113120
http_headers = {
114121
"User-Agent": f"ucode/{ucode_version()} codex/{agent_version('codex')}",
115122
}
@@ -120,7 +127,7 @@ def _provider_block(
120127
return {
121128
"name": "Databricks AI Gateway",
122129
"base_url": base_url,
123-
"wire_api": "responses",
130+
"wire_api": "chat" if oss else "responses",
124131
"http_headers": http_headers,
125132
# Run the `ucode auth-token` executable directly (not via `sh -c`) so the
126133
# helper works on Windows, where there is no POSIX shell (issue #116).
@@ -145,7 +152,7 @@ def render_overlay(
145152
overlay["model"] = model
146153
overlay["model_providers"] = {
147154
CODEX_MODEL_PROVIDER_NAME: _provider_block(
148-
workspace, databricks_profile, use_pat, provider
155+
workspace, databricks_profile, use_pat, provider, model
149156
),
150157
}
151158
return overlay
@@ -171,7 +178,7 @@ def render_legacy_overlay(
171178
"profiles": {CODEX_PROFILE_NAME: profile_block},
172179
"model_providers": {
173180
CODEX_MODEL_PROVIDER_NAME: _provider_block(
174-
workspace, databricks_profile, use_pat, provider
181+
workspace, databricks_profile, use_pat, provider, model
175182
),
176183
},
177184
}

src/ucode/cli.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
configure_tool,
1717
ensure_bootstrap_dependencies,
1818
ensure_provider_state,
19+
harness_for_model,
1920
install_tool_binary,
2021
normalize_tool,
2122
provider_permission_error,
@@ -48,6 +49,7 @@
4849
list_profile_entries,
4950
list_tool_provider_services,
5051
normalize_workspace_url,
52+
recommend_coding_agent_models,
5153
resolve_pat_token,
5254
run_databricks_login,
5355
)
@@ -1034,6 +1036,124 @@ def pi_cmd(ctx: typer.Context, skip_preflight: SkipPreflightOption = False) -> N
10341036
_launch_tool("pi", ctx, skip_preflight=skip_preflight)
10351037

10361038

1039+
def _available_models(state: dict) -> list[str]:
1040+
"""Collect every Databricks model id discovered for this workspace.
1041+
1042+
Flattens the per-family state buckets (claude by family alias, plus the flat
1043+
codex/gemini/oss lists) into a single de-duplicated, order-preserving list to
1044+
send to the recommendModel endpoint.
1045+
"""
1046+
models: list[str] = []
1047+
models.extend((state.get("claude_models") or {}).values())
1048+
for key in ("codex_models", "gemini_models", "oss_models"):
1049+
models.extend(state.get(key) or [])
1050+
seen: set[str] = set()
1051+
unique: list[str] = []
1052+
for model in models:
1053+
if model and model not in seen:
1054+
seen.add(model)
1055+
unique.append(model)
1056+
return unique
1057+
1058+
1059+
def _run_session(ctx: typer.Context, skip_preflight: bool = False) -> None:
1060+
"""Recommend a model for the user's tier, then launch the matching harness.
1061+
1062+
Discovers the workspace's models, asks the AI Gateway's recommendModel
1063+
endpoint which ones the caller's tier allows, lets the user pick one, maps it
1064+
to a harness (claude/codex/gemini) and launches that tool pinned to the model.
1065+
"""
1066+
try:
1067+
existing = load_state()
1068+
apply_pat_environment(existing)
1069+
# Ensure a workspace is configured. Without one, fall back to the same
1070+
# first-run configuration prompt the per-tool launchers use, defaulting to
1071+
# the codex harness for the bootstrap install.
1072+
if not existing.get("workspace"):
1073+
ensure_bootstrap_dependencies("codex", update_existing=True)
1074+
_auto_configure_tool("codex")
1075+
# Refresh model discovery for every family so recommendModel sees the full
1076+
# set the workspace exposes (tools=None => fetch_all).
1077+
state = configure_shared_state(
1078+
load_state()["workspace"],
1079+
profile=load_state().get("profile"),
1080+
skip_preflight=skip_preflight,
1081+
)
1082+
available = _available_models(state)
1083+
if not available:
1084+
raise RuntimeError(
1085+
"No models available for this workspace. Run `ucode configure` to set it up."
1086+
)
1087+
with spinner("Requesting recommended models..."):
1088+
token = get_databricks_token(state["workspace"], state.get("profile"))
1089+
recommended, reason = recommend_coding_agent_models(
1090+
state["workspace"], token, available
1091+
)
1092+
if reason is not None:
1093+
raise RuntimeError(f"Could not fetch recommended models: {reason}")
1094+
if not recommended:
1095+
print_warning(
1096+
"No recommended models — use `ucode <harness>` "
1097+
"(e.g. `ucode claude` or `ucode codex`) to boot up your preferred harness."
1098+
)
1099+
raise typer.Exit(0)
1100+
1101+
print_section("ucode")
1102+
if len(recommended) == 1:
1103+
# Only one model recommended — no point prompting; launch it directly.
1104+
chosen = recommended[0]
1105+
print_note(f"Only one recommended model — launching {chosen}.")
1106+
else:
1107+
chosen = prompt_for_selection(
1108+
"Select a model", [(model, model) for model in recommended]
1109+
)
1110+
if not chosen:
1111+
print_err("No model selected.")
1112+
raise typer.Exit(130)
1113+
1114+
tool = harness_for_model(chosen)
1115+
if not tool:
1116+
raise RuntimeError(
1117+
f"No coding-agent harness maps to model '{chosen}'. "
1118+
"Supported families: claude, gpt, kimi/glm, gemini."
1119+
)
1120+
1121+
# A newly-picked harness may not have been configured/installed yet.
1122+
ensure_bootstrap_dependencies(tool, update_existing=True)
1123+
if tool not in (state.get("available_tools") or []):
1124+
_auto_configure_tool(tool)
1125+
state = load_state()
1126+
1127+
state, resolved_model = resolve_launch_model(tool, state, chosen)
1128+
state = configure_tool(tool, state, resolved_model)
1129+
print_kv("Model", resolved_model or chosen)
1130+
print_kv("Harness", TOOL_SPECS[tool]["display"])
1131+
if tool in ("gemini", "opencode", "copilot", "pi"):
1132+
print_note(
1133+
f"{TOOL_SPECS[tool]['display']} token refresh is managed automatically "
1134+
f"every 30 minutes while the session is running."
1135+
)
1136+
print_success(f"Starting {TOOL_SPECS[tool]['display']}")
1137+
launch_agent(tool, state, ctx.args)
1138+
except typer.Exit:
1139+
# Intentional exits (empty recommendation, cancelled selection) subclass
1140+
# RuntimeError, so re-raise them before the error handler below rewrites
1141+
# them to a generic exit code 1.
1142+
raise
1143+
except RuntimeError as exc:
1144+
print_err(str(exc))
1145+
raise typer.Exit(1) from None
1146+
except KeyboardInterrupt:
1147+
print_err("Interrupted.")
1148+
raise typer.Exit(130) from None
1149+
1150+
1151+
@app.command("run", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
1152+
def run_cmd(ctx: typer.Context, skip_preflight: SkipPreflightOption = False) -> None:
1153+
"""Recommend a model for your usage tier, then launch the matching harness."""
1154+
_run_session(ctx, skip_preflight=skip_preflight)
1155+
1156+
10371157
@configure_app.callback(invoke_without_command=True)
10381158
def configure(
10391159
ctx: typer.Context,

src/ucode/databricks.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1131,6 +1131,17 @@ def model_token_limits(model_id: str) -> dict[str, int] | None:
11311131
return None
11321132

11331133

1134+
def is_oss_model(model_id: str | None) -> bool:
1135+
"""Return True when ``model_id`` belongs to a supported OSS chat family.
1136+
1137+
Matches the same ``kimi-``/``glm-`` substrings used by discovery so callers
1138+
(e.g. codex OSS routing, the harness map) stay in sync with what
1139+
``discover_model_services`` buckets as OSS."""
1140+
if not model_id:
1141+
return False
1142+
return any(family in model_id for family in _OSS_MODEL_FAMILIES)
1143+
1144+
11341145
def _model_service_id(service: dict) -> str | None:
11351146
"""Extract the `system.ai.<model-name>` id from one model-service entry.
11361147
@@ -1276,6 +1287,56 @@ def discover_model_services(
12761287
return claude_models, codex_models, gemini_models, oss_models, None
12771288

12781289

1290+
def recommend_coding_agent_models(
1291+
workspace: str, token: str, available_models: list[str]
1292+
) -> tuple[list[str], str | None]:
1293+
"""Ask the AI Gateway which of ``available_models`` this user may use.
1294+
1295+
A workspace admin defines usage-based tiers via a "coding agent config"; the
1296+
``recommendModel`` endpoint filters/orders the caller's ``available_models``
1297+
down to what their tier allows. Returns ``(models, reason)``:
1298+
1299+
- ``(models, None)`` on success — ``models`` may be empty, meaning the tier
1300+
recommends nothing (the caller should guide the user to pick a harness
1301+
manually rather than fall back to the full list).
1302+
- ``([], reason)`` on transport/parse failure — distinct from an intentional
1303+
empty recommendation so the caller can surface the error.
1304+
"""
1305+
url = (
1306+
f"https://{workspace_hostname(workspace)}"
1307+
"/api/ai-gateway/v2/coding-agent-configs:recommendModel"
1308+
)
1309+
payload, reason = _http_post_json(url, token, {"available_models": available_models})
1310+
if reason is not None:
1311+
return [], reason
1312+
models = _extract_recommended_models(payload)
1313+
if models is None:
1314+
return [], "recommendModel response did not contain a model list"
1315+
return models, None
1316+
1317+
1318+
def _extract_recommended_models(payload: dict | list | None) -> list[str] | None:
1319+
"""Pull the recommended-model list out of a recommendModel response.
1320+
1321+
Tolerates either a bare JSON array or an object wrapping the list under a
1322+
common key. Returns None when the shape is unrecognized so the caller can
1323+
distinguish a malformed response from an empty recommendation."""
1324+
if isinstance(payload, list):
1325+
candidate: list | None = payload
1326+
elif isinstance(payload, dict):
1327+
candidate = None
1328+
for key in ("models", "recommended_models", "available_models"):
1329+
value = payload.get(key)
1330+
if isinstance(value, list):
1331+
candidate = value
1332+
break
1333+
else:
1334+
candidate = None
1335+
if candidate is None:
1336+
return None
1337+
return [m for m in candidate if isinstance(m, str) and m]
1338+
1339+
12791340
# --- MCP services (parallel to model services) -----------------------------
12801341

12811342

@@ -2117,11 +2178,18 @@ def build_tool_base_url(tool: str, workspace: str) -> str:
21172178
raise RuntimeError(f"Unsupported tool '{tool}'.")
21182179

21192180

2181+
def build_oss_base_url(workspace: str) -> str:
2182+
# OSS chat models (kimi/glm) are served by the OpenAI-compatible MLflow
2183+
# chat-completions gateway, not the family-specific routes. Shared by every
2184+
# agent that speaks chat-completions to OSS models.
2185+
return f"{workspace}/ai-gateway/mlflow/v1"
2186+
2187+
21202188
def build_opencode_base_urls(workspace: str) -> dict[str, str]:
21212189
return {
21222190
"anthropic": build_tool_base_url("claude", workspace) + "/v1",
21232191
"gemini": build_tool_base_url("gemini", workspace) + "/v1beta",
2124-
"oss": f"{workspace}/ai-gateway/mlflow/v1",
2192+
"oss": build_oss_base_url(workspace),
21252193
}
21262194

21272195

tests/test_agent_codex.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,28 @@ def test_provider_wire_api(self):
4545
provider = overlay["model_providers"]["ucode-databricks"]
4646
assert provider["wire_api"] == "responses"
4747

48+
def test_oss_model_routes_to_mlflow_chat(self):
49+
overlay = codex.render_overlay(WS, "system.ai.kimi-k2")
50+
provider = overlay["model_providers"]["ucode-databricks"]
51+
assert provider["base_url"] == f"{WS}/ai-gateway/mlflow/v1"
52+
assert provider["wire_api"] == "chat"
53+
54+
def test_oss_glm_model_routes_to_mlflow_chat(self):
55+
overlay = codex.render_overlay(WS, "system.ai.glm-4-6")
56+
provider = overlay["model_providers"]["ucode-databricks"]
57+
assert provider["base_url"] == f"{WS}/ai-gateway/mlflow/v1"
58+
assert provider["wire_api"] == "chat"
59+
60+
def test_oss_model_pinned_verbatim(self):
61+
overlay = codex.render_overlay(WS, "system.ai.kimi-k2")
62+
assert overlay["model"] == "system.ai.kimi-k2"
63+
64+
def test_gpt_model_still_uses_responses_route(self):
65+
overlay = codex.render_overlay(WS, "system.ai.gpt-5")
66+
provider = overlay["model_providers"]["ucode-databricks"]
67+
assert provider["base_url"] == f"{WS}/ai-gateway/codex/v1"
68+
assert provider["wire_api"] == "responses"
69+
4870
def test_auth_runs_ucode_auth_token(self):
4971
# The auth command runs the `ucode auth-token` executable directly
5072
# (not `sh -c`), so it works on Windows where there is no POSIX shell.

tests/test_agents_init.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,37 @@
1414
configure_selected_tools,
1515
default_model_for_tool,
1616
ensure_tool_binary_available,
17+
harness_for_model,
1718
install_tool_binary,
1819
normalize_tool,
1920
provider_permission_error,
2021
resolve_launch_model,
2122
)
2223

2324

25+
class TestHarnessForModel:
26+
def test_claude_maps_to_claude(self):
27+
assert harness_for_model("system.ai.claude-opus-4-8") == "claude"
28+
29+
def test_gpt_maps_to_codex(self):
30+
assert harness_for_model("system.ai.gpt-5-5") == "codex"
31+
32+
def test_kimi_maps_to_codex(self):
33+
assert harness_for_model("system.ai.kimi-k2") == "codex"
34+
35+
def test_glm_maps_to_codex(self):
36+
assert harness_for_model("system.ai.glm-4-6") == "codex"
37+
38+
def test_gemini_maps_to_gemini(self):
39+
assert harness_for_model("system.ai.gemini-2-5-pro") == "gemini"
40+
41+
def test_unknown_returns_none(self):
42+
assert harness_for_model("system.ai.mystery-model") is None
43+
44+
def test_empty_returns_none(self):
45+
assert harness_for_model("") is None
46+
47+
2448
class TestProviderPermissionError:
2549
_CONN_ERR = (
2650
"User does not have USE CONNECTION on SCHEMA_CONNECTION "

0 commit comments

Comments
 (0)