Skip to content
Draft
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
6 changes: 5 additions & 1 deletion src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,11 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool:
if tool == "opencode":
return bool(state.get("opencode_models"))
if tool == "codex":
return bool(state.get("codex_models"))
# A non-empty list isn't enough: codex pins the model id verbatim and
# `default_model` drops any id that doesn't parse as `gpt-<version>`.
# Testing the list alone reports codex available and then fails at
# launch with "No models available for codex".
return codex.default_model(state) is not None
if tool == "gemini":
return bool(state.get("gemini_models"))
if tool == "copilot":
Expand Down
12 changes: 9 additions & 3 deletions src/ucode/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
build_auth_shell_command,
build_tool_base_url,
get_databricks_token,
is_responses_model_id,
)
from ucode.launcher import exec_or_spawn
from ucode.state import mark_tool_managed, save_state
Expand Down Expand Up @@ -51,15 +52,20 @@ def is_update_available() -> tuple[str, str] | None:
def _resolve_web_search_model(state: dict) -> str | None:
"""Pick the model the web_search MCP server should call. Prefers an
explicit override in state, otherwise the first endpoint discovered as
Responses-API-capable. Returns None if no GPT endpoint is available —
callers should skip the MCP wiring in that case."""
Responses-API-capable. Returns None if no such endpoint is available —
callers should skip the MCP wiring in that case.

The MCP server POSTs to `/ai-gateway/codex/v1/responses`, which only
forwards models that speak the Responses dialect. `codex_models` holds
exactly those, so the guard below is a backstop against a discovery
regression silently repointing the MCP at a chat-completions model."""
override = state.get("web_search_model")
if isinstance(override, str) and override.strip():
return override.strip()
codex_models = state.get("codex_models") or []
if isinstance(codex_models, list) and codex_models:
first = codex_models[0]
if isinstance(first, str) and first.strip():
if isinstance(first, str) and first.strip() and is_responses_model_id(first.strip()):
return first.strip()
return None

Expand Down
80 changes: 64 additions & 16 deletions src/ucode/agents/pi.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,15 @@ def is_update_available() -> tuple[str, str] | None:

def _resolve_model_selector(
model: str,
claude_models: dict[str, str],
claude_ids: list[str],
codex_models: list[str],
gemini_models: list[str],
) -> str:
"""Return a Pi model selector in `<provider>/<model>` form when possible."""
for name in PROVIDER_NAMES:
if model.startswith(f"{name}/"):
return model
if model in claude_models.values():
if model in claude_ids:
return f"databricks-claude/{model}"
if model in codex_models:
return f"databricks-openai/{model}"
Expand All @@ -104,18 +104,23 @@ def render_overlay(
model: str,
token: str,
pi_base_urls: dict[str, str],
claude_models: dict[str, str],
claude_ids: list[str],
codex_models: list[str],
gemini_models: list[str],
) -> tuple[dict, list[list[str]]]:
"""Return (overlay, managed_key_paths) for ~/.pi/agent/models.json."""
"""Return (overlay, managed_key_paths) for ~/.pi/agent/models.json.

``claude_ids`` is every Anthropic-dialect model the workspace serves, not
just the newest per family — Pi has its own model picker, so it should see
the full list.
"""
providers: dict = {}
keys: list[list[str]] = [["model"]]
# Pi expands header values that match an env var name. Our UA contains
# `/` and a space so it can never collide — safe to pass as a literal.
ua_headers = {"User-Agent": f"ucode/{ucode_version()} pi/{agent_version('pi')}"}

claude_ids = sorted(set(claude_models.values()))
claude_ids = sorted(set(claude_ids))
if claude_ids:
providers["databricks-claude"] = {
"baseUrl": pi_base_urls["claude"],
Expand Down Expand Up @@ -151,7 +156,7 @@ def render_overlay(
}
keys.append(["providers", "databricks-gemini"])
overlay: dict = {
"model": _resolve_model_selector(model, claude_models, codex_models, gemini_models),
"model": _resolve_model_selector(model, claude_ids, codex_models, gemini_models),
}
if providers:
overlay["providers"] = providers
Expand All @@ -164,18 +169,28 @@ def write_tool_config(
token: str | None = None,
*,
force_refresh: bool = False,
update_settings: bool = True,
) -> tuple[dict, str]:
"""Write Pi's models.json, and seed settings.json unless told not to.

``update_settings=False`` is used by the background token refresh: it must
rewrite models.json to rotate the baked-in bearer, but must not touch the
model the user picked in Pi's own model selector.
"""
backup_existing_file(PI_CONFIG_PATH, PI_BACKUP_PATH)
if token is None:
token = get_databricks_token(
state["workspace"], state.get("profile"), force_refresh=force_refresh
)
pi_base_urls = state.get("base_urls", {}).get("pi") or build_pi_base_urls(state["workspace"])
# State written before claude_model_ids existed only carries the family map;
# fall back to its values so a stale state still renders a usable config.
claude_ids = state.get("claude_model_ids") or list((state.get("claude_models") or {}).values())
overlay, managed_keys = render_overlay(
model,
token,
pi_base_urls,
state.get("claude_models") or {},
claude_ids,
state.get("codex_models") or [],
state.get("gemini_models") or [],
)
Expand All @@ -186,21 +201,47 @@ def write_tool_config(
providers.pop(stale, None)
merged = deep_merge_dict(existing, overlay)
write_json_file(PI_CONFIG_PATH, merged)
_write_settings(overlay["model"])
if update_settings:
_write_settings(overlay["model"], overlay.get("providers") or {})
state = mark_tool_managed(state, "pi", managed_keys)
save_state(state)
return state, token


def _write_settings(model_selector: str) -> None:
# Pin defaultProvider/defaultModel in settings.json so Pi doesn't fall
# through to an env-key-backed provider (e.g. HF_TOKEN exposing
# huggingface) in `findInitialModel` when no --model is passed.
def _settings_need_repair(existing: dict, providers: dict) -> bool:
"""True when ucode should (re)write Pi's defaultProvider/defaultModel.

Pi persists the user's model-selector choice into settings.json via
`setDefaultModelAndProvider`, so an existing pin is a user preference and
must survive. We only write when there is nothing to preserve, or when what
is there is ucode-authored residue that no longer resolves:

- nothing pinned yet — seed it, so Pi's `findInitialModel` can't fall
through to an env-key-backed provider (e.g. HF_TOKEN exposing huggingface)
- pinned to a ucode provider we no longer register (e.g. `databricks-openai`
on a workspace with no Responses models, or a `LEGACY_PROVIDER_NAMES` entry)
- pinned to a ucode provider we do register, but to a model it no longer offers

A pin naming a provider ucode does not manage is the user's own; leave it.
"""
provider = existing.get("defaultProvider")
model_id = existing.get("defaultModel")
if not isinstance(provider, str) or not isinstance(model_id, str) or not model_id:
return True
if provider in providers:
offered = {m["id"] for m in providers[provider].get("models") or []}
return model_id not in offered
return provider in (*PROVIDER_NAMES, *LEGACY_PROVIDER_NAMES)


def _write_settings(model_selector: str, providers: dict) -> None:
provider, _, model_id = model_selector.partition("/")
if not model_id:
return
backup_existing_file(PI_SETTINGS_PATH, PI_SETTINGS_BACKUP_PATH)
existing = read_json_safe(PI_SETTINGS_PATH)
if not _settings_need_repair(existing, providers):
return
backup_existing_file(PI_SETTINGS_PATH, PI_SETTINGS_BACKUP_PATH)
merged = deep_merge_dict(existing, {"defaultProvider": provider, "defaultModel": model_id})
write_json_file(PI_SETTINGS_PATH, merged)

Expand All @@ -218,18 +259,25 @@ def default_model(state: dict) -> str | None:
return gemini_models[0] if gemini_models else None


def _refresh_token_once(state: dict, *, force_refresh: bool = False) -> str:
def _refresh_token_once(
state: dict, *, force_refresh: bool = False, update_settings: bool = True
) -> str:
model = default_model(state)
if not model:
raise RuntimeError("No Pi model is available on this workspace.")
_, token = write_tool_config(state, model, force_refresh=force_refresh)
_, token = write_tool_config(
state, model, force_refresh=force_refresh, update_settings=update_settings
)
return token


def _refresh_forever(state: dict, stop_event: threading.Event) -> None:
while not stop_event.wait(TOKEN_REFRESH_INTERVAL_SECONDS):
try:
_refresh_token_once(state, force_refresh=True)
# Rotate the bearer baked into models.json, but leave settings.json
# alone: mid-session the user may have switched models in Pi's
# selector, and Pi persists that choice there.
_refresh_token_once(state, force_refresh=True, update_settings=False)
except RuntimeError:
continue

Expand Down
27 changes: 18 additions & 9 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ def configure_shared_state(
codex_reason: str | None = None
oss_reason: str | None = None
claude_models = {}
claude_model_ids: list[str] = []
gemini_models = []
codex_models = []
oss_models = []
Expand All @@ -300,25 +301,32 @@ def configure_shared_state(
# empty (workspace without UC model-services, or the listing failed), fall
# back to the per-family AI Gateway listing for that family only.
with spinner("Fetching available models..."):
ms_claude, ms_codex, ms_gemini, ms_oss, ms_reason = discover_model_services(
workspace, token
)
discovered = discover_model_services(workspace, token)
ms_reason = discovered.reason
if want_claude:
claude_models, claude_reason = ms_claude, ms_reason
if not claude_models:
claude_models, claude_reason = discover_claude_models(workspace, token)
claude_models = discovered.claude_models
claude_model_ids = discovered.claude_ids
claude_reason = ms_reason
if not claude_model_ids:
claude_models, claude_model_ids, claude_reason = discover_claude_models(
workspace, token
)
if want_gemini:
gemini_models, gemini_reason = ms_gemini, ms_reason
gemini_models, gemini_reason = discovered.gemini_models, ms_reason
if not gemini_models:
gemini_models, gemini_reason = discover_gemini_models(workspace, token)
if want_codex:
codex_models, codex_reason = ms_codex, ms_reason
codex_models, codex_reason = discovered.codex_models, ms_reason
if not codex_models:
codex_models, codex_reason = discover_codex_models(workspace, token)
if want_oss:
oss_models, oss_reason = ms_oss, ms_reason
oss_models, oss_reason = discovered.oss_models, ms_reason
opencode_models: dict[str, list[str]] = {}
if claude_models:
# Deliberately the family map, not claude_model_ids: opencode picks
# `anthropic[0]` as its default, and the family map is ordered
# opus → sonnet → haiku. Widening this to every Claude id would silently
# move opencode's default onto whichever id sorts first.
opencode_models["anthropic"] = list(claude_models.values())
if gemini_models:
opencode_models["gemini"] = gemini_models
Expand Down Expand Up @@ -350,6 +358,7 @@ def configure_shared_state(
else:
if want_claude:
state["claude_models"] = claude_models
state["claude_model_ids"] = claude_model_ids
if want_gemini:
state["gemini_models"] = gemini_models
if want_codex:
Expand Down
Loading