From 6a446b4f8aef4e87129199e42276695b8256cb46 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Tue, 20 Oct 2026 11:21:27 +0000 Subject: [PATCH 01/11] copilot: discover and switch between all authorized models Co-authored-by: Isaac --- README.md | 25 +- src/ucode/agents/__init__.py | 4 +- src/ucode/agents/args.py | 1 + src/ucode/agents/copilot.py | 621 ++++++++++++--- src/ucode/cli.py | 45 +- src/ucode/databricks.py | 72 +- src/ucode/doctor.py | 23 +- src/ucode/managed_resolve.py | 9 +- src/ucode/managed_setup.py | 7 +- tests/AGENTS.md | 2 +- tests/README.md | 5 +- tests/integration/README.md | 10 +- tests/integration/test_ug_copilot_headless.py | 28 +- tests/integration/test_ug_copilot_models.py | 165 ++++ tests/integration/utils/agents/copilot.py | 48 ++ tests/integration/utils/constants.py | 2 + tests/integration/utils/evidence.py | 19 +- tests/integration/utils/terminal.py | 13 +- tests/test_agent_copilot.py | 751 +++++++++++------- tests/test_agents_init.py | 9 +- tests/test_cli.py | 81 ++ tests/test_databricks.py | 28 + tests/test_doctor.py | 27 + tests/test_e2e.py | 34 +- tests/test_managed_resolve.py | 16 + tests/test_managed_setup.py | 9 +- 26 files changed, 1575 insertions(+), 479 deletions(-) create mode 100644 tests/integration/test_ug_copilot_models.py create mode 100644 tests/integration/utils/agents/copilot.py diff --git a/README.md b/README.md index c16c942b2..809f13cdf 100644 --- a/README.md +++ b/README.md @@ -68,17 +68,24 @@ model for one launch. OpenCode's `provider/model` form is also accepted. Unknown Databricks models produce an error; this option does not add models to discovery or change ug's saved default. -`ug copilot` uses the Anthropic Messages API for Claude model IDs, the Responses -API for GPT-5 and newer model IDs, and Chat Completions for other models. The -model selected at launch (including `--model`) determines the API. An inherited `COPILOT_PROVIDER_WIRE_MODEL` takes precedence -because it overrides the model sent to the gateway. Restart Copilot through `ug` -to change the wire model or API; in-session model selection does not rebuild its -provider configuration. +`ug copilot` lists every Claude and GPT model you can use on the workspace in +Copilot's `/model` picker, and you can switch between them mid-session. Claude +models use the Anthropic Messages API, GPT-5 and newer use Responses, and older +GPT models use Chat Completions. Gemini and open-source models aren't offered. +`ug copilot --model claude-opus-5-5` (or `system.ai.claude-opus-5-5`, or Copilot's +own `dbx-anthropic/system.ai.claude-opus-5-5`) picks the model a session starts on; +an unknown or unsupported model fails before Copilot starts and lists the +available ones. ug passes Copilot a per-session model registry, so your own +`~/.copilot/providers.json` and `COPILOT_PROVIDER_*` settings apply only to plain +`copilot`. Use plain `/model`, not `/model --global`, inside `ug copilot`: a +global choice is saved for plain `copilot` too, where the Databricks models don't +exist. Requires Copilot CLI 1.0.91 or newer. `ug copilot` runs Copilot against a loopback proxy for the session. Copilot holds only -a random session secret; the proxy adds a fresh Databricks token to each gateway -request, so OAuth sessions keep working past token expiry without a restart, and no -token is written to `~/.copilot/ucode.env`. If the Databricks sign-in itself expires, +a random session secret, kept in the session registry under `~/.ucode/run/` (readable +only by you, deleted when the session ends); the proxy adds a fresh Databricks token to +each gateway request, so OAuth sessions keep working past token expiry without a +restart, and no Databricks token is written to disk. If the Databricks sign-in itself expires, Copilot shows a "Databricks sign-in expired" error; run `databricks auth login` and restart `ug copilot`. Proxy errors are logged to `~/.ucode/logs/copilot-proxy.log`. diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index 82634cf88..e507187fa 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -529,7 +529,7 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool: if tool == "gemini": return bool(state.get("gemini_models")) if tool == "copilot": - return bool(state.get("claude_models")) or bool(state.get("codex_models")) + return bool(copilot.copilot_catalog(state)) if tool == "pi": return ( bool(state.get("claude_models")) @@ -545,7 +545,7 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool: "kilo": ("claude", "gemini", "oss"), "codex": ("codex",), "gemini": ("gemini",), - "copilot": ("claude", "codex"), + "copilot": ("copilot",), "pi": ("claude", "codex", "gemini"), } diff --git a/src/ucode/agents/args.py b/src/ucode/agents/args.py index faca49dfe..2de251eb9 100644 --- a/src/ucode/agents/args.py +++ b/src/ucode/agents/args.py @@ -11,6 +11,7 @@ class LaunchOptions: launch_smart_routing: bool = False user_pinned_model: str | None = None + launch_model: str | None = None def explicit_model_arg_value(tool_args: list[str]) -> str | None: diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index d08f7eba5..d3f443da3 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -1,37 +1,37 @@ -"""GitHub Copilot CLI agent: writes ~/.copilot/ucode.env and injects BYOK env vars at launch. - -Copilot CLI's BYOK config (COPILOT_PROVIDER_*) is documented as env-var only — -the CLI does not auto-load ~/.copilot/ucode.env. We still write the file so users can -inspect what's configured and to give `revert` something to clean up; it never holds -a credential. - -Copilot holds its provider credential for the whole session, so `ug copilot` runs it -against a per-session loopback proxy (`gateway_proxy.start_gateway_auth_proxy`): -Copilot authenticates to the proxy with a random session secret, and the proxy -forwards each request with a fresh Databricks token. - -System Claude models (`system.ai.claude-*`) use Copilot CLI's `anthropic` provider against the gateway's -Anthropic endpoint, with `COPILOT_PROVIDER_MODEL_ID` set to Copilot's catalog id -for the model so Copilot sends Claude request settings. Other models use the -`openai` provider against the Databricks MLflow gateway: GPT models with major -version 5 or newer use Responses, because Copilot sends `temperature: 0` on Chat -Completions and those models accept only the default; the rest use Chat Completions. Copilot fixes its wire API and model when it builds the native -client, so changing models in the picker cannot change either mid-session. -Relaunch Copilot after changing model families. Gemini is intentionally excluded -— Databricks' Gemini translation layer rejects the `stream_options` field that -Copilot CLI sends, so Gemini models 400 on every request. +"""GitHub Copilot CLI agent: runs Copilot against a per-session Databricks model registry. + +Copilot reads its BYOK models from a providers.json registry (`COPILOT_PROVIDERS_CONFIG`). Each +`ug copilot` launch writes one under ~/.ucode/run/ listing every Databricks model Copilot can use, +so `/model` switches between them mid-session, across wire APIs. Each family keeps its native +wire: Claude on the gateway's Anthropic endpoint (with Copilot's catalog id as `modelId`, so +Copilot sends Claude request settings), GPT 5+ on Responses (Copilot sends `temperature: 0` on Chat +Completions, which those models reject), older GPT on Chat Completions. Gemini and open-source +models are excluded: Databricks' Gemini translation rejects the `stream_options` field Copilot +sends, and open-source models are not validated with Copilot yet. + +Copilot holds its provider credential for the whole session, so every provider points at a +per-session loopback proxy (`gateway_proxy.start_gateway_auth_proxy`): Copilot authenticates to +the proxy with a random session secret, written only into the session registry, and the proxy +forwards each request with a fresh Databricks token. ~/.copilot/ucode.env is written for +inspection and `revert`; Copilot never reads it and it never holds a credential. """ from __future__ import annotations +import json import os import re import secrets +import shutil +import tempfile import threading import time -from collections.abc import Callable +from collections.abc import Callable, Iterable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import UTC, datetime from pathlib import Path -from typing import Protocol +from typing import IO, Any, Protocol from ucode.config_io import ( APP_DIR, @@ -46,14 +46,22 @@ from ucode.constants import AI_GATEWAY_REQUEST_TAGS_HEADER, LOOPBACK_HOST from ucode.custom_oauth import launch_token from ucode.databricks import ( + ANTHROPIC_FAMILIES, + ModelServiceEntry, build_copilot_base_url, build_tool_base_url, + model_version_sort_key, ) from ucode.os_compatibility import subprocess_cross_os +from ucode.os_compatibility.file_lock_cross_os import ( + acquire_exclusive_file_lock, + release_file_lock, +) from ucode.state import is_tool_managed, mark_tool_managed, save_state from ucode.telemetry import agent_version, ug_version +from ucode.ui import print_warning -from .args import LaunchOptions, explicit_model_arg_value +from .args import LaunchOptions from .request_tags import request_tags_header_value COPILOT_CONFIG_DIR = Path.home() / ".copilot" @@ -62,6 +70,13 @@ COPILOT_BACKUP_PATH = APP_DIR / "copilot-ucode-env.backup" COPILOT_MCP_BACKUP_PATH = APP_DIR / "copilot-ucode-mcp-config.backup.json" COPILOT_PROXY_LOG_PATH = APP_DIR / "logs" / "copilot-proxy.log" +COPILOT_RUN_DIR = APP_DIR / "run" +_SESSION_DIR_PREFIX = "copilot-" +_SESSION_LOCK_NAME = "session.lock" +_RUN_DIR_LOCK_NAME = ".copilot.lock" +_REGISTRY_NAME = "providers.json" + +MINIMUM_COPILOT_VERSION = (1, 0, 91) SPEC: ToolSpec = { "binary": "copilot", @@ -71,45 +86,110 @@ "backup_path": COPILOT_BACKUP_PATH, } -MANAGED_KEYS: list[str] = [ - "COPILOT_PROVIDER_TYPE", - "COPILOT_PROVIDER_BASE_URL", - "COPILOT_PROVIDER_WIRE_API", - "COPILOT_PROVIDER_MODEL_ID", - "COPILOT_MODEL", - "COPILOT_OFFLINE", -] +MANAGED_KEYS: list[str] = ["COPILOT_MODEL", "COPILOT_OFFLINE"] LEGACY_ENV_KEYS = [ "OPENAI_BASE_URL", "OPENAI_API_KEY", "COPILOT_PROVIDER_API_KEY", + # Single-provider settings earlier versions wrote; the session registry replaces them. + "COPILOT_PROVIDER_TYPE", + "COPILOT_PROVIDER_BASE_URL", + "COPILOT_PROVIDER_WIRE_API", + "COPILOT_PROVIDER_MODEL_ID", ] # Earlier versions wrote the Databricks token (or PAT) under these keys. CREDENTIAL_ENV_KEYS = ("COPILOT_PROVIDER_BEARER_TOKEN", "OAUTH_TOKEN") -# Inherited settings that would bypass the proxy's credential or its HTTP transport. -_INHERITED_PROVIDER_OVERRIDES = ( - "COPILOT_PROVIDER_API_KEY", - "COPILOT_PROVIDER_API_KEY_COMMAND", - "COPILOT_PROVIDER_TRANSPORT", -) -_GPT_MODEL_MAJOR_PATTERN = re.compile(r"^(?:system\.ai\.)?(?:databricks-)?gpt-(\d+)(?=$|[.-])") +CATALOG_STATE_KEY = "copilot_catalog" - -_SYSTEM_CLAUDE_MODEL_PATTERN = re.compile(r"^system\.ai\.(claude-[^\[]+)(?:\[[^\]]*\])?$") +PROVIDER_NAMES: dict[str, str] = { + "anthropic": "dbx-anthropic", + "responses": "dbx-responses", + "completions": "dbx-completions", +} +_WIRE_ROUTES: dict[str, tuple[str, ...]] = { + "anthropic": ("anthropic/v1/messages",), + "responses": ("mlflow/v1/responses", "openai/v1/responses", "codex/v1/responses"), + "completions": ("mlflow/v1/chat/completions", "openai/v1/chat/completions"), +} +_UNSUPPORTED_MODELS = { + "claude-opus-4-1": "the gateway rejects the thinking settings Copilot sends for it", +} +_GATEWAY_ALIAS_PREFIX = "anthropic-aigw-" +_GPT_MODEL_MAJOR_PATTERN = re.compile(r"^gpt-(\d+)(?=$|[.-])") _CLAUDE_DASHED_VERSION_PATTERN = re.compile(r"-(\d+)-(\d+)$") _CLAUDE_HAIKU_MAJOR_PATTERN = re.compile(r"^claude-haiku-(\d+)") +_CLAUDE_CATALOG_VERSION_PATTERN = re.compile(r"^claude-([a-z]+)-(\d+)(?:\.(\d+))?$") + +# (maxPromptTokens, maxOutputTokens) by Copilot catalog id. Copilot assumes a 128K prompt for +# registry models without limits, and its own values differ between versions. +_CLAUDE_LIMITS: dict[str, tuple[int, int]] = { + "claude-fable-5.1": (936_000, 32_000), + "claude-fable-5": (936_000, 32_000), + "claude-opus-5.5": (936_000, 32_000), + "claude-opus-5": (936_000, 32_000), + "claude-opus-4.8": (936_000, 32_000), + "claude-opus-4.7": (936_000, 32_000), + "claude-opus-4.6": (936_000, 32_000), + "claude-opus-4.5": (168_000, 32_000), + "claude-sonnet-5.5": (872_000, 128_000), + "claude-sonnet-5": (936_000, 32_000), + "claude-sonnet-4.6": (936_000, 32_000), + "claude-sonnet-4.5": (168_000, 32_000), + "claude-haiku-4.5": (136_000, 64_000), +} +_CLAUDE_1M_LIMITS = (936_000, 32_000) +_CLAUDE_200K_LIMITS = (168_000, 32_000) + + +def _model_tail(model: str) -> str: + """The model name without its ``..`` and ``databricks-`` prefixes or ``[...]`` suffix.""" + tail = model.split("[", 1)[0].rsplit(".", 1)[-1].lower() + return tail.removeprefix("databricks-") + + +def copilot_exclusion_reason(model: str, api_types: Iterable[str] = ()) -> str | None: + """Why ``model`` can't be offered in Copilot, or None when it can.""" + return _classify(model, tuple(api_types))[1] + + +def classify_copilot_model(model: str, api_types: Iterable[str] = ()) -> str | None: + """The wire API (``anthropic``, ``responses`` or ``completions``) Copilot uses for ``model``. + + Names decide the wire; ``api_types`` (the model service's advertised routes) only excludes, + because the gateway serves some models on routes they don't list.""" + return _classify(model, tuple(api_types))[0] + + +def _classify(model: str, api_types: tuple[str, ...]) -> tuple[str | None, str | None]: + if model.lower().startswith(_GATEWAY_ALIAS_PREFIX): + return None, "it is a gateway alias, not a model" + tail = _model_tail(model) + if tail in _UNSUPPORTED_MODELS: + return None, _UNSUPPORTED_MODELS[tail] + gpt = _GPT_MODEL_MAJOR_PATTERN.match(tail) + if tail.startswith("claude-"): + wire = "anthropic" + elif gpt: + wire = "responses" if int(gpt.group(1)) >= 5 else "completions" + elif "gemini-" in tail: + return None, "the gateway rejects the requests Copilot sends to Gemini models" + else: + return None, "ug copilot supports Claude and GPT models" + if api_types and not set(api_types) & set(_WIRE_ROUTES[wire]): + return None, f"it isn't served on {_WIRE_ROUTES[wire][0]}" + return wire, None def copilot_catalog_model_id(model: str) -> str | None: - """Copilot's catalog id for a system Claude model, e.g. ``system.ai.claude-sonnet-5-5`` -> ``claude-sonnet-5.5``. + """Copilot's catalog id for a Claude model, e.g. ``system.ai.claude-sonnet-5-5`` -> ``claude-sonnet-5.5``. Copilot applies Claude request settings to any ``claude-*`` id, so ids missing from its catalog still work. - Returns None for anything other than ``system.ai.claude-*``. + Returns None for anything other than Claude. """ - match = _SYSTEM_CLAUDE_MODEL_PATTERN.match(model) - if match is None: + tail = _model_tail(model) + if not tail.startswith("claude-"): return None - catalog_id = _CLAUDE_DASHED_VERSION_PATTERN.sub(r"-\1.\2", match.group(1)) + catalog_id = _CLAUDE_DASHED_VERSION_PATTERN.sub(r"-\1.\2", tail) # Copilot sends budget thinking for any Haiku it doesn't know, which Haiku 5+ rejects; Sonnet # ids get the adaptive thinking those models require. haiku = _CLAUDE_HAIKU_MAJOR_PATTERN.match(catalog_id) @@ -118,74 +198,368 @@ def copilot_catalog_model_id(model: str) -> str | None: return catalog_id -def model_uses_responses_api(model: str) -> bool: - """Whether a supported GPT model id uses the Responses API.""" - match = _GPT_MODEL_MAJOR_PATTERN.match(model) - return match is not None and int(match.group(1)) >= 5 +def copilot_model_limits(model: str) -> tuple[int, int] | None: + """``(maxPromptTokens, maxOutputTokens)`` for a Claude model, or None for other models.""" + catalog_id = copilot_catalog_model_id(model) + if catalog_id is None: + return None + if catalog_id in _CLAUDE_LIMITS: + return _CLAUDE_LIMITS[catalog_id] + version = _CLAUDE_CATALOG_VERSION_PATTERN.match(catalog_id) + if version is None: + return _CLAUDE_200K_LIMITS + family, major, minor = version.group(1), int(version.group(2)), int(version.group(3) or 0) + one_million = major >= 5 or (family in ("opus", "sonnet") and (major, minor) >= (4, 6)) + return _CLAUDE_1M_LIMITS if one_million else _CLAUDE_200K_LIMITS + + +@dataclass(frozen=True) +class CopilotModel: + """A Databricks model in Copilot's registry and the wire API it is served on.""" + + id: str + wire: str + + @property + def provider(self) -> str: + return PROVIDER_NAMES[self.wire] + + @property + def ref(self) -> str: + """Copilot's selection id; a bare model id isn't matched against the registry.""" + return f"{self.provider}/{self.id}" + + +def _catalog_sort_key(model: CopilotModel) -> tuple: + family = next( + ( + i + for i, f in enumerate(ANTHROPIC_FAMILIES) + if _model_tail(model.id).startswith(f"claude-{f}-") + ), + len(ANTHROPIC_FAMILIES), + ) + return (tuple(PROVIDER_NAMES).index(model.wire), family, model_version_sort_key(model.id)) + + +def build_copilot_catalog(entries: Iterable[ModelServiceEntry | str]) -> list[CopilotModel]: + """The models Copilot can use, Claude first (by family, newest first), then GPT.""" + models: dict[str, CopilotModel] = {} + for entry in entries: + model, api_types = (entry, ()) if isinstance(entry, str) else (entry.id, entry.api_types) + wire = classify_copilot_model(model, api_types) + if wire is not None and model not in models: + models[model] = CopilotModel(model, wire) + return sorted(models.values(), key=_catalog_sort_key) -def default_model(state: dict) -> str | None: - """Prefer Claude sonnet, then opus/haiku, then codex. +def catalog_from_listings( + entries: Iterable[ModelServiceEntry] | None, fallback: Iterable[str] +) -> list[CopilotModel] | None: + """The Copilot catalog from this run's model listings. - A managed config's ``copilot_default_model`` and ``copilot_models`` both win outright: the former is - the admin's chosen session start, the latter their allowlist. Workspace-wide discovery falls back. - """ - if isinstance(state.get("copilot_default_model"), str): - return state.get("copilot_default_model") - copilot_models = state.get("copilot_models") or [] - if isinstance(copilot_models, list) and copilot_models: - return copilot_models[0] - claude_models = state.get("claude_models") or {} - for family in ("sonnet", "opus", "haiku"): - if claude_models.get(family): - return claude_models[family] - codex_models = state.get("codex_models") or [] - if codex_models: - return codex_models[0] - return next(iter(claude_models.values()), None) - - -def render_env_overlay( - base_url: str, - selected_model: str, - *, - override_model: str | None = None, -) -> dict[str, str]: - """Copilot's provider settings for ``selected_model``, rooted at ``base_url``. - - ``base_url`` is the workspace for the inspectable env file and the loopback proxy - at launch. Credentials are added only to the launch environment.""" - request_model = override_model or selected_model - catalog_model_id = copilot_catalog_model_id(request_model) - if catalog_model_id is not None: - return { - "COPILOT_PROVIDER_TYPE": "anthropic", - "COPILOT_PROVIDER_BASE_URL": build_tool_base_url("claude", base_url), - "COPILOT_PROVIDER_MODEL_ID": catalog_model_id, - "COPILOT_MODEL": selected_model, - "COPILOT_OFFLINE": "true", - } - wire_api = "responses" if model_uses_responses_api(request_model) else "completions" + ``entries`` is the UC model-services listing (None when it failed); ``fallback`` holds the ids + the per-family gateway listings found instead. None when neither answered, so the caller keeps + the saved catalog; an empty catalog when they answered with nothing Copilot can use.""" + listed = list(entries or ()) + if listed: + return build_copilot_catalog(listed) + fallback_ids = list(fallback) + return build_copilot_catalog(fallback_ids) if fallback_ids else None + + +def catalog_state(models: list[CopilotModel]) -> dict: return { - "COPILOT_PROVIDER_TYPE": "openai", - "COPILOT_PROVIDER_BASE_URL": build_copilot_base_url(base_url), - "COPILOT_PROVIDER_WIRE_API": wire_api, - "COPILOT_MODEL": selected_model, - "COPILOT_OFFLINE": "true", + "models": [model.id for model in models], + "discovered_at": datetime.now(UTC).isoformat(timespec="seconds"), } -def build_runtime_env(proxy_base_url: str, model: str, session_secret: str) -> dict[str, str]: - """Copilot's launch environment: provider settings pointed at the session proxy.""" - env = os.environ.copy() - for key in _INHERITED_PROVIDER_OVERRIDES: - env.pop(key, None) - override_model = env.get("COPILOT_PROVIDER_WIRE_MODEL") - env.update(render_env_overlay(proxy_base_url, model, override_model=override_model)) - env["COPILOT_PROVIDER_BEARER_TOKEN"] = session_secret +def _string_list(value: object) -> list[str]: + return ( + [item for item in value if isinstance(item, str) and item] + if isinstance(value, list) + else [] + ) + + +def copilot_catalog(state: dict) -> list[CopilotModel]: + """The models this launch registers: the managed list, else the discovered catalog. + + State saved before the catalog existed falls back to the Claude and GPT discovery lists.""" + static = build_copilot_catalog(_string_list(state.get("copilot_static_models"))) + if static: + return static + saved = state.get(CATALOG_STATE_KEY) + if isinstance(saved, dict): + return build_copilot_catalog(_string_list(saved.get("models"))) + claude_models = state.get("claude_models") + legacy = list(claude_models.values()) if isinstance(claude_models, dict) else [] + return build_copilot_catalog([*_string_list(legacy), *_string_list(state.get("codex_models"))]) + + +def unservable_static_models(state: dict) -> list[tuple[str, str]]: + """Managed models Copilot can't use, with the reason, so the launch can say why they're missing. + + Empty when none of the list is usable: the launch then reports that the whole list was + ignored instead.""" + static = _string_list(state.get("copilot_static_models")) + if not build_copilot_catalog(static): + return [] + skipped = [] + for model in static: + reason = copilot_exclusion_reason(model) + if reason is not None: + skipped.append((model, reason)) + return skipped + + +def _normalized(model: str) -> str: + return model.split("[", 1)[0].lower() + + +def resolve_copilot_model(requested: str, catalog: list[CopilotModel]) -> CopilotModel: + """Match ``requested`` (an id, a short name or ``provider/id``) to a catalog model. + + Raises RuntimeError listing the available models when nothing matches.""" + for matches in ( + lambda m: m.id == requested, + lambda m: m.ref == requested, + lambda m: _normalized(m.id) == _normalized(requested), + lambda m: _normalized(m.id) == f"system.ai.{_normalized(requested)}", + lambda m: _normalized(m.id) == f"databricks-{_normalized(requested)}", + ): + found = [model for model in catalog if matches(model)] + if found: + return next((m for m in found if m.id.startswith("system.ai.")), found[0]) + reason = copilot_exclusion_reason(requested) + detail = f" ({reason})" if reason else "" + available = "\n".join( + f" {provider}: {', '.join(m.id for m in catalog if m.provider == provider)}" + for provider in PROVIDER_NAMES.values() + if any(m.provider == provider for m in catalog) + ) + raise RuntimeError( + f"Model '{requested}' isn't available in ug copilot{detail}. Available models:\n{available}" + ) + + +def _first_available( + candidates: Iterable[str | None], catalog: list[CopilotModel], *, warn: bool +) -> CopilotModel | None: + for candidate in candidates: + if not candidate: + continue + try: + return resolve_copilot_model(candidate, catalog) + except RuntimeError: + if warn: + print_warning( + f"{candidate} isn't available in ug copilot; starting on another model." + ) + return None + + +def launch_model( + state: dict, catalog: list[CopilotModel], *, requested: str | None = None, warn: bool = True +) -> CopilotModel: + """The model a session starts on: ``requested`` (budget recommendation or managed default), + then the managed default and list, then the newest Sonnet, Opus or Haiku, then GPT.""" + static = _string_list(state.get("copilot_static_models")) + chosen = _first_available( + [requested, state.get("copilot_default_model"), static[0] if static else None], + catalog, + warn=warn, + ) + if chosen is not None: + return chosen + for prefix in ("claude-sonnet-", "claude-opus-", "claude-haiku-"): + family = next((m for m in catalog if _model_tail(m.id).startswith(prefix)), None) + if family is not None: + return family + return next((m for m in catalog if m.wire != "anthropic"), catalog[0]) + + +def default_model(state: dict) -> str | None: + catalog = copilot_catalog(state) + return launch_model(state, catalog, warn=False).id if catalog else None + + +def rewrite_model_args( + tool_args: list[str], catalog: list[CopilotModel] +) -> tuple[list[str], CopilotModel | None]: + """Resolve every ``--model``/``-m`` before ``--`` to Copilot's ``provider/id`` form. + + Copilot has no ``-m`` alias, so it becomes ``--model``. Returns the rewritten args and the + last model selected, which is the one Copilot uses.""" + rewritten: list[str] = [] + selected: CopilotModel | None = None + index = 0 + while index < len(tool_args): + arg = tool_args[index] + if arg == "--": + rewritten.extend(tool_args[index:]) + break + value: str | None = None + if ( + arg in {"--model", "-m"} + and index + 1 < len(tool_args) + and not tool_args[index + 1].startswith("-") + ): + value = tool_args[index + 1] + index += 1 + elif arg.startswith("--model=") and arg.partition("=")[2]: + value = arg.partition("=")[2] + if value is None: + rewritten.append(arg) + else: + selected = resolve_copilot_model(value, catalog) + rewritten.extend(["--model", selected.ref]) + index += 1 + return rewritten, selected + + +def render_providers_config( + catalog: list[CopilotModel], proxy_base_url: str, session_secret: str +) -> dict: + """Copilot's providers.json for ``catalog``: one provider per wire, all on the session proxy. + + A provider with no models makes Copilot probe ``GET /models``, so only used wires are + listed.""" + providers = [] + for wire, name in PROVIDER_NAMES.items(): + if not any(model.wire == wire for model in catalog): + continue + if wire == "anthropic": + provider = { + "name": name, + "type": "anthropic", + "baseUrl": build_tool_base_url("claude", proxy_base_url), + } + else: + provider = { + "name": name, + "type": "openai", + "wireApi": wire, + "baseUrl": build_copilot_base_url(proxy_base_url), + } + # `bearerToken` reaches the proxy as `Authorization` on every provider type. + provider["bearerToken"] = session_secret + providers.append(provider) + models = [] + for model in catalog: + entry: dict[str, Any] = { + "id": model.id, + "provider": model.provider, + "wireModel": model.id, + "name": model.id, + } + catalog_id = copilot_catalog_model_id(model.id) if model.wire == "anthropic" else None + if catalog_id is not None: + entry["modelId"] = catalog_id + limits = copilot_model_limits(model.id) if model.wire == "anthropic" else None + if limits is not None: + entry["maxPromptTokens"], entry["maxOutputTokens"] = limits + models.append(entry) + return {"providers": providers, "models": models} + + +def build_runtime_env(registry_path: Path, model: CopilotModel) -> dict[str, str]: + """Copilot's launch environment: the session registry and the model to start on. + + Inherited ``COPILOT_PROVIDER*`` settings are dropped so a user's own BYOK setup can't take + over a ug session.""" + env = { + key: value for key, value in os.environ.items() if not key.startswith("COPILOT_PROVIDER") + } + env["COPILOT_PROVIDERS_CONFIG"] = str(registry_path) + env["COPILOT_MODEL"] = model.ref + # Fail closed without the registry instead of falling back to GitHub sign-in. + env["COPILOT_OFFLINE"] = "true" return env +@contextmanager +def _run_dir_lock() -> Iterator[None]: + COPILOT_RUN_DIR.mkdir(parents=True, exist_ok=True) + with (COPILOT_RUN_DIR / _RUN_DIR_LOCK_NAME).open("a+b") as lock_file: + acquire_exclusive_file_lock(lock_file) + try: + yield + finally: + release_file_lock(lock_file) + + +def _sweep_dead_sessions() -> None: + for session_dir in COPILOT_RUN_DIR.glob(f"{_SESSION_DIR_PREFIX}*"): + lock_path = session_dir / _SESSION_LOCK_NAME + if lock_path.exists(): + with lock_path.open("a+b") as lock_file: + try: + acquire_exclusive_file_lock(lock_file, timeout=0) + except (TimeoutError, OSError): + continue + release_file_lock(lock_file) + shutil.rmtree(session_dir, ignore_errors=True) + + +def sweep_session_dirs() -> None: + """Delete registries left by sessions that ended without cleaning up (e.g. a killed terminal).""" + if not COPILOT_RUN_DIR.exists(): + return + with _run_dir_lock(): + _sweep_dead_sessions() + + +@contextmanager +def session_registry(config: dict) -> Iterator[Path]: + """Write ``config`` to a private per-session providers.json, removed when the session ends. + + The session holds a lock on its directory so a concurrent sweep leaves it alone.""" + lock_file: IO[Any] | None = None + with _run_dir_lock(): + _sweep_dead_sessions() + session_dir = Path(tempfile.mkdtemp(prefix=_SESSION_DIR_PREFIX, dir=COPILOT_RUN_DIR)) + try: + lock_file = (session_dir / _SESSION_LOCK_NAME).open("a+b") + acquire_exclusive_file_lock(lock_file, timeout=0) + path = session_dir / _REGISTRY_NAME + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as registry: + json.dump(config, registry, indent=2) + except BaseException: + if lock_file is not None: + lock_file.close() + shutil.rmtree(session_dir, ignore_errors=True) + raise + try: + yield path + finally: + release_file_lock(lock_file) + lock_file.close() + shutil.rmtree(session_dir, ignore_errors=True) + + +def _parse_version(value: str) -> tuple[int, int, int] | None: + match = re.search(r"(\d+)\.(\d+)\.(\d+)", value) + if match is None: + return None + major, minor, patch = match.groups() + return int(major), int(minor), int(patch) + + +def minimum_version_error() -> str | None: + installed = agent_version(SPEC["binary"]) + parsed = _parse_version(installed) + if parsed is None or parsed >= MINIMUM_COPILOT_VERSION: + return None + required = ".".join(str(part) for part in MINIMUM_COPILOT_VERSION) + return ( + f"GitHub Copilot CLI {installed} is too old. ucode requires {required} or newer for " + f"Databricks model switching. Update it with `npm install -g {SPEC['package']}`." + ) + + def build_mcp_server_entry(argv: list[str]) -> dict: # A `local` MCP server runs a stdio command; `command`/`args` split the # argv. ug registers the `ug mcp-proxy ...` bridge here so Copilot @@ -250,16 +624,13 @@ def write_tool_config(state: dict, model: str) -> dict: if not is_tool_managed(state, "copilot"): backup_existing_file(COPILOT_ENV_PATH, COPILOT_BACKUP_PATH) existing = parse_dotenv(COPILOT_ENV_PATH) - # Keep the inspectable file self-consistent without treating it as launch input. - override_model = existing.get("COPILOT_PROVIDER_WIRE_MODEL") - overlay = render_env_overlay(state["workspace"], model, override_model=override_model) for key in LEGACY_ENV_KEYS: existing.pop(key, None) - # Wire API is openai-only and the catalog id is anthropic-only; drop whichever this model doesn't use. - for key in ("COPILOT_PROVIDER_WIRE_API", "COPILOT_PROVIDER_MODEL_ID"): - if key not in overlay: - existing.pop(key, None) - existing.update(overlay) + try: + existing["COPILOT_MODEL"] = resolve_copilot_model(model, copilot_catalog(state)).ref + except RuntimeError: + existing["COPILOT_MODEL"] = model + existing["COPILOT_OFFLINE"] = "true" write_dotenv(COPILOT_ENV_PATH, existing) state = mark_tool_managed(state, "copilot", MANAGED_KEYS) save_state(state) @@ -313,9 +684,17 @@ def _wait_for_exit(proc: _ChildProcess) -> int: def launch(state: dict, tool_args: list[str], *, options: LaunchOptions) -> None: - model = explicit_model_arg_value(tool_args) or options.user_pinned_model or default_model(state) - if not model: - raise RuntimeError("No Copilot model is available on this workspace.") + catalog = copilot_catalog(state) + if not catalog: + raise RuntimeError( + "No Copilot model is available on this workspace: ug copilot supports its Claude and " + "GPT models. Run `ug configure --agents copilot` to rediscover them." + ) + for model, reason in unservable_static_models(state): + print_warning(f"Skipping {model} from your workspace's managed config: {reason}.") + # Resolve before the proxy starts so a bad model fails without starting Copilot. + tool_args, selected = rewrite_model_args(tool_args, catalog) + start_model = selected or launch_model(state, catalog, requested=options.launch_model) scrub_stored_credentials() # Only Copilot launches need the proxy (and its `httpx` import). @@ -334,9 +713,13 @@ def launch(state: dict, tool_args: list[str], *, options: LaunchOptions) -> None threading.Thread(target=server.serve_forever, daemon=True).start() try: proxy_base_url = f"http://{LOOPBACK_HOST}:{server.server_address[1]}" - env = build_runtime_env(proxy_base_url, model, session_secret) - proc = subprocess_cross_os.popen([SPEC["binary"], *mcp_config_args(), *tool_args], env=env) - returncode = _wait_for_exit(proc) + config = render_providers_config(catalog, proxy_base_url, session_secret) + with session_registry(config) as registry_path: + env = build_runtime_env(registry_path, start_model) + proc = subprocess_cross_os.popen( + [SPEC["binary"], *mcp_config_args(), *tool_args], env=env + ) + returncode = _wait_for_exit(proc) finally: cache.stop() server.shutdown() diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 576b4b0e6..0489dda06 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -63,6 +63,7 @@ SKILLS_MCP_MIN_DATABRICKS_CLI_VERSION, apply_pat_environment, build_shared_base_urls, + cached_model_service_entries, databricks_cli_installed, discover_claude_models, discover_codex_models, @@ -654,11 +655,14 @@ def configure_shared_state( # Codex smart routing can select OSS models such as GLM, so a Codex-only # configure must persist that discovered family too. want_oss = fetch_all or "opencode" in tools or "kilo" in tools or "codex" in tools + want_copilot = fetch_all or "copilot" in tools claude_reason: str | None = None gemini_reason: str | None = None codex_reason: str | None = None oss_reason: str | None = None + copilot_reason: str | None = None + copilot_models: list[copilot_agent.CopilotModel] | None = None claude_models = {} gemini_models = [] codex_models = [] @@ -699,6 +703,12 @@ def configure_shared_state( codex_models, codex_reason = discover_codex_models(workspace, token) if want_oss: oss_models, oss_reason = ms_oss, ms_reason + if want_copilot: + copilot_models = copilot_agent.catalog_from_listings( + cached_model_service_entries(workspace), + [*claude_models.values(), *codex_models], + ) + copilot_reason = ms_reason or claude_reason or codex_reason if claude_models: opencode_models["anthropic"] = list(claude_models.values()) if gemini_models: @@ -721,6 +731,13 @@ def configure_shared_state( state["codex_models"] = codex_models if want_oss: state["oss_models"] = oss_models + if want_copilot and copilot_models is not None: + state[copilot_agent.CATALOG_STATE_KEY] = copilot_agent.catalog_state(copilot_models) + elif want_copilot and state.get(copilot_agent.CATALOG_STATE_KEY): + print_warning( + f"Copilot model discovery failed ({copilot_reason}); " + "using the models found last time." + ) if fetch_all or "opencode" in tools or "kilo" in tools: # Kilo is an OpenCode fork and consumes the same discovered view. state["opencode_models"] = opencode_models @@ -736,6 +753,7 @@ def configure_shared_state( "gemini": gemini_reason, "codex": codex_reason, "oss": oss_reason, + "copilot": copilot_reason, } return state @@ -1204,17 +1222,15 @@ def _model_values(value: object) -> list[str]: def _status_models(tool: str, state: dict) -> list[str]: """Return the effective model allow-list for one configured agent.""" static_models = _model_values(state.get(f"{tool}_static_models")) - if static_models: + if tool == "copilot": + models = [model.id for model in copilot_agent.copilot_catalog(state)] + elif static_models: models = static_models elif tool in ("claude", "codex", "gemini", "opencode"): models = _model_values(state.get(f"{tool}_models")) elif tool == "kilo": # Kilo shares OpenCode's discovery; its models live under opencode_models. models = _model_values(state.get("opencode_models")) - elif tool == "copilot": - models = _model_values(state.get("copilot_models")) or ( - _model_values(state.get("claude_models")) + _model_values(state.get("codex_models")) - ) elif tool == "pi": models = _model_values(state.get("pi_models")) or ( _model_values(state.get("claude_models")) @@ -1227,6 +1243,8 @@ def _status_models(tool: str, state: dict) -> list[str]: def _status_default_model(tool: str, state: dict, models: list[str]) -> str | None: + if tool == "copilot": + return copilot_agent.default_model(state) explicit = state.get(f"{tool}_default_model") if isinstance(explicit, str) and explicit: return explicit @@ -1277,6 +1295,13 @@ def _live_status_model_state(state: dict, tools: set[str]) -> tuple[dict, str]: if oss_models: opencode_models["oss"] = oss_models live["opencode_models"] = opencode_models + if "copilot" in tools: + copilot_models = copilot_agent.catalog_from_listings( + cached_model_service_entries(workspace), + [*claude_models.values(), *codex_models], + ) + if copilot_models is not None: + live[copilot_agent.CATALOG_STATE_KEY] = copilot_agent.catalog_state(copilot_models) live["_status_model_reasons"] = { family: reason or shared_reason for family, reason in reasons.items() } @@ -1422,6 +1447,7 @@ def revert() -> int: mcp_results = revert_mcp_configs(state) # Before restoring backups: one written by an earlier ug may hold a token. copilot_agent.scrub_stored_credentials() + copilot_agent.sweep_session_dirs() claude_managed_result = claude_agent.revert_managed_settings() codex_managed_result = codex_agent.revert_managed_config() @@ -3036,6 +3062,8 @@ def _child_owns_stdout(tool: str, tool_args: list[str]) -> bool: tool_args = tool_args[: tool_args.index("--")] if tool == "claude": return any(arg in {"-p", "--print"} for arg in tool_args) + if tool == "copilot": + return any(arg in {"-p", "--prompt"} or arg.startswith("--prompt=") for arg in tool_args) return tool == "codex" and any(arg in {"exec", "e", "app-server"} for arg in tool_args) @@ -3066,8 +3094,10 @@ def _launch_options( explicit_prompt: bool, user_pinned_model: str | None, provider: str | None, + launch_model: str | None = None, ) -> LaunchOptions: return LaunchOptions( + launch_model=launch_model, # Pinned models for providers are resolved above through the provider-specific launch path. user_pinned_model=user_pinned_model if provider is None else None, launch_smart_routing=( @@ -3110,6 +3140,9 @@ def _skip_model_discovery( (``family_defaults``). In the last case it is also harmful: discovered family defaults would overwrite that writer's on every launch, and each rewrite of the managed file costs a sudo prompt. """ + if tool == "copilot": + # Copilot registers its discovered catalog, so a managed default alone still needs it. + return False return ( bool(provider) or bool(managed_parent_schema) @@ -3590,6 +3623,8 @@ def _launch_tool( # initial/fallback model and still participates in a routed session. user_pinned_model=model or forwarded_model, provider=provider, + # Copilot picks its start model from its own catalog; the admin's or budget pick leads. + launch_model=managed_model if tool == "copilot" else None, ) print_success(f"Starting {TOOL_SPECS[tool]['display']}") with _smart_routing_v2_flag( diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index 2f678c0e9..f32b2c52a 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -2097,6 +2097,24 @@ def model_token_limits(model_id: str) -> dict[str, int] | None: return None +@dataclass(frozen=True) +class ModelServiceEntry: + """One ``system.ai.*`` model service and the API types it advertises. + + ``api_types`` is advisory: the gateway serves some models on routes they don't list, so callers + use it to exclude non-chat models, never to pick a route.""" + + id: str + api_types: tuple[str, ...] = () + + +def _model_service_api_types(service: dict) -> tuple[str, ...]: + api_types = service.get("supported_api_types") + if not isinstance(api_types, list): + return () + return tuple(t for t in api_types if isinstance(t, str)) + + def _model_service_id(service: dict) -> str | None: """Extract the `system.ai.` id from one model-service entry. @@ -2150,7 +2168,7 @@ def _get_model_services_page( # Claude ids), so a single `ucode setup` run would otherwise page it twice. Cached per process, not # persisted: a long-lived process is not a thing here, and a new model appearing mid-command is not # worth a second walk. Failures are never cached, so a transient error still retries. -_MODEL_SERVICES_CACHE: dict[str, list[str]] = {} +_MODEL_SERVICES_CACHE: dict[str, list[ModelServiceEntry]] = {} # Same idea for the Model Provider Service listing (a different endpoint). It is workspace-wide and # filtered per agent afterwards, so `ucode setup` would otherwise re-list it once per MPS-capable @@ -2159,6 +2177,12 @@ def _get_model_services_page( _MODEL_PROVIDER_SERVICES_CACHE: dict[tuple[str, str], list[dict]] = {} +def cached_model_service_entries(workspace: str) -> list[ModelServiceEntry] | None: + """The model services this process already listed for ``workspace``, without a request.""" + cached = _MODEL_SERVICES_CACHE.get(workspace) + return list(cached) if cached is not None else None + + def clear_model_services_cache() -> None: """Forget cached model-service listings (used by tests, and after a workspace switch).""" _MODEL_SERVICES_CACHE.clear() @@ -2183,16 +2207,32 @@ def list_model_services( max_pages: int = 100, use_cache: bool = True, ) -> tuple[list[str], str | None]: - """List all `system.ai.*` model ids via the UC model-services API. + """List all `system.ai.*` model ids via the UC model-services API (see + :func:`list_model_service_entries`).""" + entries, reason = list_model_service_entries( + workspace, token, page_size=page_size, max_pages=max_pages, use_cache=use_cache + ) + return [entry.id for entry in entries], reason + + +def list_model_service_entries( + workspace: str, + token: str, + *, + page_size: int = _MODEL_SERVICES_PAGE_SIZE, + max_pages: int = 100, + use_cache: bool = True, +) -> tuple[list[ModelServiceEntry], str | None]: + """List all `system.ai.*` model services via the UC model-services API. Pages through ``/api/2.1/unity-catalog/model-services`` scoped to the ``system.ai`` schema (``parent=schemas/system.ai``) with a bounded - ``page_size`` (the endpoint 499s without one) and returns the de-duplicated, - sorted list of ``system.ai.`` ids. Returns (ids, reason); reason - is None on success, otherwise it describes why the list is empty (HTTP/network - error or no services). Scoping matters: the unscoped metastore listing walks - every schema across dozens of ~2s pages (~50s on a busy workspace) only to - keep the same ``system.ai.*`` subset — see ``_MODEL_SERVICE_PARENT_SCHEMA``. + ``page_size`` (the endpoint 499s without one) and returns the de-duplicated + entries sorted by id. Returns (entries, reason); reason is None on success, + otherwise it describes why the list is empty (HTTP/network error or no + services). Scoping matters: the unscoped metastore listing walks every schema + across dozens of ~2s pages (~50s on a busy workspace) only to keep the same + ``system.ai.*`` subset — see ``_MODEL_SERVICE_PARENT_SCHEMA``. A successful result is memoized per workspace for the life of the process; pass ``use_cache=False`` to force a fresh walk. @@ -2203,7 +2243,7 @@ def list_model_services( return list(cached), None origin = workspace_origin(workspace) - ids: list[str] = [] + entries: dict[str, ModelServiceEntry] = {} page_token: str | None = None seen_tokens: set[str] = set() last_reason: str | None = None @@ -2225,8 +2265,10 @@ def list_model_services( for service in data.get("model_services", []): if isinstance(service, dict): model_id = _model_service_id(service) - if model_id: - ids.append(model_id) + if model_id and model_id not in entries: + entries[model_id] = ModelServiceEntry( + model_id, _model_service_api_types(service) + ) page_token = data.get("next_page_token") or None if not page_token: last_reason = None @@ -2235,11 +2277,11 @@ def list_model_services( break seen_tokens.add(page_token) - deduped = sorted(set(ids)) - if deduped: + listed = [entries[model_id] for model_id in sorted(entries)] + if listed: if use_cache: - _MODEL_SERVICES_CACHE[workspace] = list(deduped) - return deduped, None + _MODEL_SERVICES_CACHE[workspace] = list(listed) + return listed, None return [], last_reason or "model-services listing returned no models" diff --git a/src/ucode/doctor.py b/src/ucode/doctor.py index 85ec1513a..c56566379 100644 --- a/src/ucode/doctor.py +++ b/src/ucode/doctor.py @@ -16,6 +16,7 @@ from ucode.agents import ( TOOL_SPECS, + copilot, tool_binary_installed, tool_version_error, update_tool_binary, @@ -184,6 +185,26 @@ def _check_agent_clis() -> list[Check]: return checks +def _check_copilot_models() -> Check | None: + """The models `ug copilot` registers for the current workspace, when Copilot is configured.""" + state = load_state() + if "copilot" not in (state.get("available_tools") or []): + return None + catalog = copilot.copilot_catalog(state) + if not catalog: + return Check( + "Copilot models", + "warn", + "none Copilot can use — run `ug configure --agents copilot` to rediscover them", + ) + counts = ", ".join( + f"{sum(m.provider == provider for m in catalog)} {provider}" + for provider in copilot.PROVIDER_NAMES.values() + if any(m.provider == provider for m in catalog) + ) + return Check("Copilot models", "ok", f"{len(catalog)} models ({counts})") + + def _check_databricks_auth() -> Check | None: """Validate the configured workspace's Databricks credentials. @@ -256,7 +277,7 @@ def _gather_checks() -> list[Check]: checks: list[Check] = [_check_uv(), _check_npm(), _check_databricks_cli(), _check_workspace()] # These return None when they don't apply (no workspace, no env collision), # so drop the Nones before display. - optional = [_check_databricks_auth(), _check_anthropic_env_collision()] + optional = [_check_databricks_auth(), _check_anthropic_env_collision(), _check_copilot_models()] checks.extend(c for c in optional if c is not None) checks.extend(_check_agent_clis()) checks.append(_check_ug()) diff --git a/src/ucode/managed_resolve.py b/src/ucode/managed_resolve.py index f4959787b..96e758f0f 100644 --- a/src/ucode/managed_resolve.py +++ b/src/ucode/managed_resolve.py @@ -99,7 +99,7 @@ def managed_state_overrides(managed: dict, tool: str) -> dict[str, object]: overrides["opencode_models"] = buckets else: overrides[f"{tool}_models"] = models - if tool in ("claude", "codex"): + if tool in ("claude", "codex", "copilot"): static_models = managed_static_models(managed, tool) if static_models: overrides[f"{tool}_static_models"] = static_models @@ -123,8 +123,13 @@ def managed_unservable_models(managed: dict, tool: str) -> list[str]: Only non-empty when *every* named model is unservable, which is when the translation yields nothing and the developer's own models stand — so the caller can say why the admin's list had no effect. opencode has no OpenAI provider and pi has no OSS provider, so each can be handed a - valid model FQN it cannot route. + valid model FQN it cannot route, and copilot serves only Claude and GPT. """ + if tool == "copilot": + from ucode.agents.copilot import classify_copilot_model + + listed = managed_static_models(managed, tool) or [] + return [] if any(classify_copilot_model(m) for m in listed) else listed if tool not in ("opencode", "pi"): return [] models = _manifest_models(managed, tool) diff --git a/src/ucode/managed_setup.py b/src/ucode/managed_setup.py index 8d130a057..a7feab5cb 100644 --- a/src/ucode/managed_setup.py +++ b/src/ucode/managed_setup.py @@ -58,7 +58,7 @@ "codex": ("codex", "oss"), "opencode": ("claude", "codex", "gemini", "oss"), "pi": ("claude", "codex", "gemini", "oss"), - "copilot": ("claude", "codex", "gemini", "oss"), + "copilot": ("claude", "codex"), } @@ -102,6 +102,11 @@ def model_options_for_agent(tool: str, state: dict) -> list[str]: models = state.get(key) if isinstance(models, list): options.extend(m for m in models if isinstance(m, str) and m) + if tool == "copilot": + from ucode.agents.copilot import classify_copilot_model + + # The codex listing also carries models Copilot can't serve, such as gpt-oss. + options = [m for m in options if classify_copilot_model(m)] # dict.fromkeys de-duplicates while preserving first-seen order (a model can match more than # one family bucket, e.g. an OSS id that also appears in the codex listing). return list(dict.fromkeys(options)) diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 8ff486fbd..e6788b2e7 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -93,7 +93,7 @@ exercise. and run documentation at the suite root. - Current scope is basic Claude/Codex configuration, routing, script usage, command forwarding, and configure/revert CUJs, plus Copilot CLI headless - model tasks (non-blocking lane). Do not add MCP/skills functionality, + model tasks and one TUI model-switch journey (non-blocking lane). Do not add MCP/skills functionality, tracing, or the broad configure-option matrix without a new scope request. Existing `mcp --help` checks cover dispatch only. diff --git a/tests/README.md b/tests/README.md index 9e277430c..847e32142 100644 --- a/tests/README.md +++ b/tests/README.md @@ -502,7 +502,10 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi | `test_case_14_*` | Launch configured and fresh Codex with a model location | The app-server list exactly matches the independent API-compatible parent catalog, includes the dedicated Codex service, and contains no out-of-schema models | | `test_ug_claude_headless_prompt_argument`, `test_ug_claude_headless_prompt_stdin`, `test_ug_claude_headless_prompt_after_separator` | Run Claude from a script using each prompt form | Structured final answer contains the file value; exit zero; no routing | | `test_ug_codex_headless_prompt_argument`, `test_ug_codex_headless_prompt_stdin`, `test_ug_codex_headless_prompt_after_separator` | Run Codex from a script using each prompt form | Completed turn and final answer contain the file value; exit zero; no routing | -| `test_ug_copilot_headless_completes_file_task`, `test_ug_copilot_headless_gpt_responses_task` | Run Copilot CLI from a script (`-p ... --output-format json`) on a Claude model (Anthropic Messages) and a GPT model (Responses) | Completed turn whose assistant message contains the file value; no file under the session home holds the Databricks credential (non-blocking CI lane) | +| `test_ug_copilot_headless_completes_file_task`, `test_ug_copilot_headless_gpt_responses_task` | Run Copilot CLI from a script (`-p ... --output-format json`) on a Claude model (Anthropic Messages) and a GPT model (Responses) | Completed turn whose assistant message contains the file value and was sent as the requested model; the session started on the matching registry provider; no file under the session home holds the Databricks credential; no session registry left (non-blocking CI lane) | +| `test_ug_copilot_one_setup_runs_claude_then_gpt_by_provider_ref`, `test_ug_copilot_runs_an_older_claude_from_the_full_catalog` | One configure, then headless tasks on a Claude model, a GPT model chosen as `dbx-responses/`, and an older Claude from the full catalog | Each answer contains its file value and was sent as the requested model; sessions started on `dbx-anthropic` and `dbx-responses` (non-blocking CI lane) | +| `test_ug_copilot_unknown_model_lists_available_models` | Interactive launch with a model the workspace doesn't have | Exit 1 before Copilot starts; stderr names the model and lists the available ones, without `claude-opus-4-1`; no session registry left (non-blocking CI lane) | +| `test_ug_copilot_tui_switches_from_claude_to_gpt_with_model_command` | TUI: answer folder trust, Claude task, `/model dbx-responses/`, GPT task, `/exit` | Both answers sent as the Claude then GPT model; the session records the switch; normal exit; no session registry left (non-blocking CI lane) | | `test_ug_copilot_refreshes_a_revoked_token_mid_session`, `test_ug_copilot_reports_expired_sign_in` | Sign in through `DATABRICKS_BEARER_COMMAND` minting short-lived PATs; mid-task the agent revokes them (or also signs out) | Revoked: a token is minted after the revocation and the task completes without restarting Copilot. Signed out: output shows "Databricks sign-in expired" with `databricks auth login`; the failure is in `~/.ucode/logs/copilot-proxy.log`, not the terminal (non-blocking CI lane) | | `test_ug_configure_copilot_repeat_and_revert` | Configure Copilot twice over a user-owned `~/.copilot/mcp-config.json`, run a GPT task, revert twice | Task completes; no file holds the bearer; revert removes `~/.copilot/ucode.env`, leaves the user file byte-identical and reports Not Configured. (non-blocking CI lane) | | `test_ug_copilot_upgrade_scrubs_token_saved_by_older_ug` | Configure Copilot with ug v0.2.0 (pinned commit; saves the bearer in `~/.copilot/ucode.env`), then configure with this ug, run a GPT task, revert | v0.2.0 left the bearer; after this ug's configure no file holds it; task completes; revert leaves no token and no `ucode.env` (non-blocking CI lane) | diff --git a/tests/integration/README.md b/tests/integration/README.md index 08c98adc8..ccb6aed20 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -256,6 +256,11 @@ Databricks credential. `test_ug_copilot_lifecycle.py` signs in through revoke them mid-task: once to require a refresh, once with sign-out to require the sign-in-expired message. It also covers configure/revert, including upgrading from ug v0.2.0, which saved the bearer. Refresh near natural expiry is covered by the hermetic proxy tests in `tests/test_gateway_proxy_integration.py`. +`test_ug_copilot_models.py` covers the per-session model registry: one setup runs a +Claude task and then a GPT task chosen with Copilot's `dbx-responses/` form, an +older Claude runs from the full catalog, an unknown model fails before Copilot starts, +and the TUI switches from Claude to GPT with `/model` mid-session. Model evidence comes +from Copilot's own `~/.copilot/session-state/*/events.jsonl` (`utils/agents/copilot.py`). ## Shared agent helpers @@ -481,6 +486,7 @@ test_ug_claude_tracing.py # Claude OTLP export reaches the configu test_ug_codex_headless.py # script prompts and model arguments test_ug_copilot_headless.py # Copilot via the session proxy: Claude + GPT tasks, no token on disk test_ug_copilot_lifecycle.py # Copilot refresh after revocation, sign-in expired, configure/revert, upgrade +test_ug_copilot_models.py # Copilot model registry: Claude then GPT, older Claude, unknown model, TUI /model switch test_ug_codex_tracing.py # Codex OTLP export reaches the configured trace table test_ug_claude_commands.py # command help forwarding test_ug_codex_commands.py # command help and parser error forwarding @@ -675,7 +681,7 @@ cases instead complete real file tasks through `--workspace` and `--model-locati journeys check setup and launch with existing services, without inference. There are **85 live cases** (including 11 marked TUI journeys) and **7 installation -checks** with Claude and Codex; selecting OpenCode adds one live headless case and two `managed_fixture` cases; selecting Copilot adds six live cases. One +checks** with Claude and Codex; selecting OpenCode adds one live headless case and two `managed_fixture` cases; selecting Copilot adds ten live cases. One **`workspace_switch` case** uses two real workspaces and checks skills MCP cleanup and a completed Claude task. A further **43 `managed_fixture` cases** (ten of them also `live`) run on the managed workspace with a checked-in JSON CodingAgentConfig from `tests/fixtures/managed_config/` @@ -901,7 +907,7 @@ A second non-blocking job, **Managed OpenCode self-managed** (`managed_fixture a in `test_ug_agents_self_managed.py`), installs Claude, Codex and OpenCode and runs against the managed e2e workspace (`E2E_ADMIN_WORKSPACE`) with the injected `managed_workspace_default` config, which enables Claude/Codex but not OpenCode. It is likewise outside `cujs`. -A non-blocking **Copilot** job (`live and copilot`, six cases) runs on Linux and Windows the +A non-blocking **Copilot** job (`live and copilot`, ten cases) runs on Linux and Windows the same way and is likewise outside `cujs` until it is stable. Each lane installs only the agent CLIs it needs (one, except the managed OpenCode lane's diff --git a/tests/integration/test_ug_copilot_headless.py b/tests/integration/test_ug_copilot_headless.py index cae6f08a7..9fb0de7be 100644 --- a/tests/integration/test_ug_copilot_headless.py +++ b/tests/integration/test_ug_copilot_headless.py @@ -2,7 +2,13 @@ import pytest from utils.constants import CLAUDE_TEST_MODEL, CODEX_TEST_MODEL, COPILOT_HEADLESS_FLAGS -from utils.evidence import FileTask, assert_no_bearer_on_disk +from utils.evidence import ( + FileTask, + assert_completed_task_model, + assert_no_bearer_on_disk, + assert_no_session_registry_left, + copilot_selected_models, +) pytestmark = [pytest.mark.live, pytest.mark.copilot] @@ -10,9 +16,10 @@ def test_ug_copilot_headless_completes_file_task(live_session, workspace): """Scenario: configure Copilot, then run a headless Claude prompt through `ug copilot`. - Expected: Copilot reads the fixture through the session's loopback proxy (Anthropic - provider) and its completed answer contains the unknown value; no file under the - session home holds the Databricks credential afterwards. + Expected: Copilot starts on the registry's Anthropic provider, reads the fixture through + the session's loopback proxy, and its completed answer (sent as the requested model) + contains the unknown value; no file under the session home holds the Databricks + credential, and the session registry is gone afterwards. """ session = live_session task = FileTask(session) @@ -39,15 +46,19 @@ def test_ug_copilot_headless_completes_file_task(live_session, workspace): ) task.assert_headless_answer("copilot", result) + assert_completed_task_model(session, "copilot", task.value, CLAUDE_TEST_MODEL) + assert copilot_selected_models(session)[0] == f"dbx-anthropic/{CLAUDE_TEST_MODEL}" assert_no_bearer_on_disk(session) + assert_no_session_registry_left(session) def test_ug_copilot_headless_gpt_responses_task(live_session, workspace): """Scenario: configure Copilot, then run a headless GPT prompt through `ug copilot`. - Expected: Copilot reaches the gateway's Responses API through the session proxy and - its completed answer contains the fixture's unknown value; no file under the session - home holds the Databricks credential afterwards. + Expected: Copilot starts on the registry's Responses provider, reaches the gateway's + Responses API through the session proxy, and its completed answer (sent as the requested + model) contains the fixture's unknown value; no file under the session home holds the + Databricks credential, and the session registry is gone afterwards. """ session = live_session task = FileTask(session) @@ -74,4 +85,7 @@ def test_ug_copilot_headless_gpt_responses_task(live_session, workspace): ) task.assert_headless_answer("copilot", result) + assert_completed_task_model(session, "copilot", task.value, CODEX_TEST_MODEL) + assert copilot_selected_models(session)[0] == f"dbx-responses/{CODEX_TEST_MODEL}" assert_no_bearer_on_disk(session) + assert_no_session_registry_left(session) diff --git a/tests/integration/test_ug_copilot_models.py b/tests/integration/test_ug_copilot_models.py new file mode 100644 index 000000000..ef0145b74 --- /dev/null +++ b/tests/integration/test_ug_copilot_models.py @@ -0,0 +1,165 @@ +"""CUJs for discovering and switching between Databricks models in GitHub Copilot CLI through ug.""" + +import pytest +from utils.constants import ( + CLAUDE_TEST_MODEL, + CODEX_TEST_MODEL, + COPILOT_HEADLESS_FLAGS, + COPILOT_OLDER_CLAUDE_MODEL, +) +from utils.evidence import ( + FileTask, + assert_completed_task_model, + assert_no_session_registry_left, + copilot_selected_models, + fresh_id, +) +from utils.terminal import AgentTerminal + +pytestmark = [pytest.mark.live, pytest.mark.copilot] + + +def _configure(session, workspace): + session.run( + "configure", + "--agents", + "copilot", + "--workspace", + workspace, + "--skip-validate", + "--skip-upgrade", + "--disable-databricks-ai-tools", + ) + + +def test_ug_copilot_one_setup_runs_claude_then_gpt_by_provider_ref(live_session, workspace): + """Scenario: configure Copilot once, run a headless Claude task, then a headless GPT task + selected with Copilot's own `dbx-responses/` form, without reconfiguring. + + Expected: both completed answers contain their fixture values and were sent as the requested + models, the sessions started on `dbx-anthropic` then `dbx-responses`, and no session registry + is left afterwards. + """ + session = live_session + claude_task = FileTask(session) + gpt_task = FileTask(session) + _configure(session, workspace) + session.record("copilot-models.json", {"models": [CLAUDE_TEST_MODEL, CODEX_TEST_MODEL]}) + + claude = session.run( + "copilot", + "--model", + CLAUDE_TEST_MODEL, + "-p", + claude_task.prompt, + *COPILOT_HEADLESS_FLAGS, + timeout=180, + ) + gpt = session.run( + "copilot", + "--model", + f"dbx-responses/{CODEX_TEST_MODEL}", + "-p", + gpt_task.prompt, + *COPILOT_HEADLESS_FLAGS, + timeout=180, + ) + + claude_task.assert_headless_answer("copilot", claude) + gpt_task.assert_headless_answer("copilot", gpt) + assert_completed_task_model(session, "copilot", claude_task.value, CLAUDE_TEST_MODEL) + assert_completed_task_model(session, "copilot", gpt_task.value, CODEX_TEST_MODEL) + selected = copilot_selected_models(session) + assert f"dbx-anthropic/{CLAUDE_TEST_MODEL}" in selected + assert f"dbx-responses/{CODEX_TEST_MODEL}" in selected + assert_no_session_registry_left(session) + + +def test_ug_copilot_runs_an_older_claude_from_the_full_catalog(live_session, workspace): + """Scenario: configure Copilot, then run a headless task on an older Claude than its family's + newest model. + + Expected: the completed answer contains the fixture value and was sent as that older model, + which only reaches Copilot through the full discovered catalog. Limit: needs the model on the + e2e workspace. + """ + session = live_session + task = FileTask(session) + _configure(session, workspace) + session.record("copilot-model.json", {"model": COPILOT_OLDER_CLAUDE_MODEL}) + + result = session.run( + "copilot", + "--model", + COPILOT_OLDER_CLAUDE_MODEL, + "-p", + task.prompt, + *COPILOT_HEADLESS_FLAGS, + timeout=180, + ) + + task.assert_headless_answer("copilot", result) + assert_completed_task_model(session, "copilot", task.value, COPILOT_OLDER_CLAUDE_MODEL) + + +def test_ug_copilot_unknown_model_lists_available_models(live_session, workspace): + """Scenario: configure Copilot, then launch it interactively with a model the workspace + doesn't have. + + Expected: ug exits 1 before starting Copilot, and its error names the model and lists the + available ones, including the Claude and GPT test models but not `claude-opus-4-1`, which + Copilot can't use. No session registry is left. + """ + session = live_session + _configure(session, workspace) + missing = f"system.ai.no-such-model-{fresh_id()}" + + result = session.run("copilot", "--model", missing, ok=False, timeout=120) + + assert result.returncode == 1, result.stdout + result.stderr + assert f"Model '{missing}' isn't available in ug copilot" in result.stderr + assert CLAUDE_TEST_MODEL in result.stderr + assert CODEX_TEST_MODEL in result.stderr + assert "claude-opus-4-1" not in result.stderr + assert not list(session.home.glob(".copilot/session-state/*")), "Copilot started" + assert_no_session_registry_left(session) + + +def test_ug_copilot_tui_switches_from_claude_to_gpt_with_model_command(live_session, workspace): + """Scenario: configure Copilot, start its TUI on a Claude model, complete a task, switch to a + GPT model with `/model dbx-responses/`, and complete a second task in the same session. + + Expected: the folder-trust prompt is answered through its visible choice, both answers carry + their fixture values and were sent as the Claude then the GPT model, the session records the + switch to the GPT ref, Copilot exits normally, and no session registry is left. + """ + session = live_session + claude_task = FileTask(session) + gpt_task = FileTask(session) + _configure(session, workspace) + session.record("copilot-models.json", {"models": [CLAUDE_TEST_MODEL, CODEX_TEST_MODEL]}) + gpt_ref = f"dbx-responses/{CODEX_TEST_MODEL}" + command = [ + str(session.binary), + "copilot", + "--model", + CLAUDE_TEST_MODEL, + "--allow-all-tools", + "--no-auto-update", + "--disable-builtin-mcps", + ] + + with AgentTerminal(session, "copilot", command, "copilot-model-switch") as tui: + tui.boot() + tui.submit(claude_task.prompt) + tui.wait_for_task(claude_task) + tui.submit(f"/model {gpt_ref}") + tui.wait_for(lambda screen: "Model changed" in screen, "the model switch notice") + tui.submit(gpt_task.prompt) + tui.wait_for_task(gpt_task) + tui.exit_normally() + + assert_completed_task_model(session, "copilot", claude_task.value, CLAUDE_TEST_MODEL) + assert_completed_task_model(session, "copilot", gpt_task.value, CODEX_TEST_MODEL) + assert copilot_selected_models(session)[-1] == gpt_ref + assert_no_session_registry_left(session) diff --git a/tests/integration/utils/agents/copilot.py b/tests/integration/utils/agents/copilot.py new file mode 100644 index 000000000..2ea73799d --- /dev/null +++ b/tests/integration/utils/agents/copilot.py @@ -0,0 +1,48 @@ +"""Copilot-specific integration evidence, read from Copilot's own session events.""" + +from . import _evidence_id + +__all__ = ["EVIDENCE_KIND", "assistant_answers", "completed_task_models", "selected_models"] + +SESSION_DIRECTORY = ".copilot/session-state" +EVIDENCE_KIND = "assistant message model" + + +def is_child_session(path: str, records: list[dict]) -> bool: + del path, records + return False + + +def _messages(records: list[dict]) -> list[dict]: + return [ + record.get("data") or {} for record in records if record.get("type") == "assistant.message" + ] + + +def assistant_answers(records: list[dict]) -> list[str]: + return [ + message["content"] + for message in _messages(records) + if isinstance(message.get("content"), str) + ] + + +def completed_task_models(records: list[dict], answer_value: str) -> set[str]: + """The model ids Copilot sent for the assistant messages that carry ``answer_value``.""" + return { + _evidence_id(message.get("model"), "model") + for message in _messages(records) + if answer_value in (message.get("content") or "") + } + + +def selected_models(records: list[dict]) -> list[str]: + """Registry refs the session started on or switched to, in order.""" + refs = [] + for record in records: + data = record.get("data") or {} + if record.get("type") == "session.start" and data.get("selectedModel"): + refs.append(data["selectedModel"]) + elif record.get("type") == "session.model_change" and data.get("newModel"): + refs.append(data["newModel"]) + return refs diff --git a/tests/integration/utils/constants.py b/tests/integration/utils/constants.py index a3adbd04c..2acc51a9f 100644 --- a/tests/integration/utils/constants.py +++ b/tests/integration/utils/constants.py @@ -6,6 +6,8 @@ # nano sometimes nests powershell inside Codex's pwsh, breaking `$` expansion in its file reads. CODEX_TEST_MODEL = "system.ai.gpt-5-6-luna" if os.name == "nt" else "system.ai.gpt-5-4-nano" +# An older Claude than the family default: only the full catalog registers it for Copilot. +COPILOT_OLDER_CLAUDE_MODEL = "system.ai.claude-sonnet-4-5" COPILOT_HEADLESS_FLAGS = ( "--allow-all-tools", "--output-format", diff --git a/tests/integration/utils/evidence.py b/tests/integration/utils/evidence.py index ab5ad1f0a..771324b6c 100644 --- a/tests/integration/utils/evidence.py +++ b/tests/integration/utils/evidence.py @@ -5,9 +5,9 @@ import uuid from pathlib import Path -from .agents import _evidence_id, claude, codex +from .agents import _evidence_id, claude, codex, copilot -_AGENT_HELPERS = {"claude": claude, "codex": codex} +_AGENT_HELPERS = {"claude": claude, "codex": codex, "copilot": copilot} def assert_no_terminal_api_error(screen: str) -> None: @@ -147,6 +147,21 @@ def assert_no_bearer_on_disk(session) -> None: assert "OAUTH_TOKEN" not in text +def copilot_selected_models(session) -> list[str]: + """Every registry ref Copilot sessions under this home started on or switched to.""" + return [ + ref + for records in agent_sessions(session, "copilot").values() + for ref in copilot.selected_models(records) + ] + + +def assert_no_session_registry_left(session) -> None: + """`ug copilot` removes its per-session registry, which holds the proxy secret, on exit.""" + leftovers = sorted(session.home.glob(".ucode/run/copilot-*")) + assert not leftovers, f"Session registries left behind: {leftovers}" + + def fresh_id(): """Return a 12-hex-char id; models drop characters when copying longer ones.""" return uuid.uuid4().hex[:12] diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 9a35c9e29..64da87814 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -229,7 +229,7 @@ def __exit__(self, exc_type, exc, traceback): "normal_exit_observed": self.ended and self.child.exitstatus == 0, "routing_log": routing_log.read_text() if routing_log.is_file() else None, "agent_sessions": agent_sessions(self.session, self.agent) - if self.agent in ("claude", "codex") + if self.agent in ("claude", "codex", "copilot") else {}, }, ) @@ -403,6 +403,14 @@ def boot(self, timeout=120): and bool(re.search(r"1[.)]\s+Yes, I trust (?:this|the) folder", text)), "\r", ), + ( + "copilot-folder-trust", + self.agent == "copilot" + and "Confirm folder trust" in text + and self.session.cwd.name in text + and bool(re.search(r"❯\s*1[.)]\s+Yes\b", text)), + "\r", + ), ( "trust-directory", self.agent == "codex" @@ -444,9 +452,10 @@ def boot(self, timeout=120): "Configured ug launched Codex's account-login flow instead of its gateway session:\n" + text ) - title = "Claude Code" if self.agent == "claude" else "Codex" + title = {"claude": "Claude Code", "copilot": "Copilot v"}.get(self.agent, "Codex") if ( title in text + and "Confirm folder trust" not in text and "loading" not in text.lower() and not re.search(r"(?m)^\s*>\s+You are in\b", text) and re.search(r"(?m)^\s*[❯›>]\s*(?!\d+[.)])", text) diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index 71e995ec4..3739d1308 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -3,14 +3,23 @@ from __future__ import annotations import json +import os import httpx import pytest from ucode import custom_oauth from ucode.agents import copilot +from ucode.databricks import ModelServiceEntry WS = "https://example.databricks.com" +PROXY = "http://127.0.0.1:4321" +SONNET = "system.ai.claude-sonnet-5-5" +GPT = "system.ai.gpt-5-4" + + +def catalog(*ids: str) -> list[copilot.CopilotModel]: + return copilot.build_copilot_catalog(ids) class TestCopilotSpec: @@ -27,111 +36,382 @@ def test_config_path_is_ucode_env_file(self): assert copilot.SPEC["config_path"].name == "ucode.env" -class TestRenderEnvOverlay: - def test_sets_provider_base_url(self): - env = copilot.render_env_overlay(WS, "gpt-5") - assert env["COPILOT_PROVIDER_BASE_URL"] == f"{WS}/ai-gateway/mlflow/v1" +class TestClassifyCopilotModel: + @pytest.mark.parametrize( + ("model", "wire"), + [ + ("system.ai.claude-sonnet-5-5", "anthropic"), + ("system.ai.claude-sonnet-5-5[1m]", "anthropic"), + ("databricks-claude-sonnet-4-6", "anthropic"), + ("main.models.claude-opus-4-7", "anthropic"), + ("system.ai.gpt-5-4", "responses"), + ("system.ai.gpt-6-1-sol", "responses"), + ("databricks-gpt-5-3-codex", "responses"), + ("system.ai.gpt-4-1", "completions"), + ], + ) + def test_names_decide_the_wire(self, model, wire): + assert copilot.classify_copilot_model(model) == wire - def test_claude_uses_anthropic_provider_with_catalog_model_id(self): - env = copilot.render_env_overlay(WS, "system.ai.claude-sonnet-5-5") + @pytest.mark.parametrize( + ("model", "reason"), + [ + ("system.ai.gemini-3-flash", "Gemini"), + ("system.ai.gpt-oss-120b", "Claude and GPT"), + ("system.ai.meta-llama-3-3-70b-instruct", "Claude and GPT"), + ("system.ai.gte-large-en", "Claude and GPT"), + ("system.ai.claude-opus-4-1", "thinking"), + ("anthropic-aigw-12e8d1ee-system.ai.gpt-5-6-sol", "alias"), + ], + ) + def test_excluded_models_say_why(self, model, reason): + assert copilot.classify_copilot_model(model) is None + assert reason in (copilot.copilot_exclusion_reason(model) or "") + + def test_advertised_routes_only_exclude(self): + assert copilot.classify_copilot_model(SONNET, ["mlflow/v1/embeddings"]) is None + # gpt-5-3-codex lists only openai/codex Responses, which the gateway also serves on mlflow. + codex_routes = ["openai/v1/responses", "codex/v1/responses"] + assert ( + copilot.classify_copilot_model("system.ai.gpt-5-3-codex", codex_routes) == "responses" + ) - assert env["COPILOT_PROVIDER_TYPE"] == "anthropic" - assert env["COPILOT_PROVIDER_BASE_URL"] == f"{WS}/ai-gateway/anthropic" - assert env["COPILOT_PROVIDER_MODEL_ID"] == "claude-sonnet-5.5" - assert env["COPILOT_MODEL"] == "system.ai.claude-sonnet-5-5" - assert "COPILOT_PROVIDER_WIRE_API" not in env - def test_claude_wire_model_override_selects_anthropic_provider(self): - env = copilot.render_env_overlay(WS, "gpt-5", override_model="system.ai.claude-opus-4-7") +class TestBuildCopilotCatalog: + def test_orders_claude_by_family_newest_first_then_gpt(self): + models = catalog( + "system.ai.gpt-5-4", + "system.ai.claude-haiku-4-5", + "system.ai.claude-sonnet-4-6", + "system.ai.gpt-6-1-sol", + "system.ai.claude-sonnet-5-5", + "system.ai.claude-opus-5-5", + "system.ai.claude-fable-5-1", + "system.ai.gemini-3-flash", + ) - assert env["COPILOT_PROVIDER_TYPE"] == "anthropic" - assert env["COPILOT_PROVIDER_MODEL_ID"] == "claude-opus-4.7" + assert [m.id for m in models] == [ + "system.ai.claude-fable-5-1", + "system.ai.claude-opus-5-5", + "system.ai.claude-sonnet-5-5", + "system.ai.claude-sonnet-4-6", + "system.ai.claude-haiku-4-5", + "system.ai.gpt-6-1-sol", + "system.ai.gpt-5-4", + ] + + def test_uses_the_listing_routes_and_drops_duplicates(self): + models = copilot.build_copilot_catalog( + [ + ModelServiceEntry(SONNET, ("anthropic/v1/messages",)), + ModelServiceEntry("system.ai.gte-large-en", ("mlflow/v1/embeddings",)), + SONNET, + ] + ) - def test_sets_provider_type(self): - env = copilot.render_env_overlay(WS, "m") - assert env["COPILOT_PROVIDER_TYPE"] == "openai" + assert models == [copilot.CopilotModel(SONNET, "anthropic")] + assert models[0].ref == f"dbx-anthropic/{SONNET}" - def test_sets_model(self): - env = copilot.render_env_overlay(WS, "claude-sonnet-4-6") - assert env["COPILOT_MODEL"] == "claude-sonnet-4-6" - @pytest.mark.parametrize("model", ["m", "system.ai.claude-sonnet-5-5"]) - def test_carries_no_credential(self, model): - env = copilot.render_env_overlay(WS, model) - for key in copilot.CREDENTIAL_ENV_KEYS: - assert key not in env +class TestCatalogFromListings: + def test_prefers_the_model_services_listing(self): + entries = [ModelServiceEntry(GPT), ModelServiceEntry("system.ai.gemini-3-flash")] + assert copilot.catalog_from_listings(entries, [SONNET]) == catalog(GPT) - def test_sets_offline_true(self): - env = copilot.render_env_overlay(WS, "m") - assert env["COPILOT_OFFLINE"] == "true" + def test_falls_back_to_the_gateway_listings(self): + assert copilot.catalog_from_listings(None, ["databricks-claude-sonnet-4-6"]) == catalog( + "databricks-claude-sonnet-4-6" + ) + + def test_no_answer_keeps_the_saved_catalog(self): + assert copilot.catalog_from_listings(None, []) is None + + def test_an_answer_with_nothing_usable_is_an_empty_catalog(self): + assert ( + copilot.catalog_from_listings([ModelServiceEntry("system.ai.gemini-3-flash")], []) == [] + ) + + +class TestCopilotCatalog: + def test_managed_list_wins_and_skips_what_copilot_cannot_use(self): + state = { + "copilot_static_models": [GPT, "system.ai.gemini-3-flash"], + "copilot_catalog": copilot.catalog_state(catalog(SONNET)), + } + + assert copilot.copilot_catalog(state) == catalog(GPT) + assert copilot.unservable_static_models(state) == [ + ( + "system.ai.gemini-3-flash", + copilot.copilot_exclusion_reason("system.ai.gemini-3-flash"), + ) + ] + + def test_an_unusable_managed_list_falls_back_to_discovery(self): + state = { + "copilot_static_models": ["system.ai.gemini-3-flash"], + "copilot_catalog": copilot.catalog_state(catalog(SONNET)), + } + + assert copilot.copilot_catalog(state) == catalog(SONNET) + assert copilot.unservable_static_models(state) == [] + + def test_saved_catalog(self): + saved = copilot.catalog_state(catalog(SONNET, GPT)) + + assert saved["models"] == [SONNET, GPT] + assert saved["discovered_at"].endswith("+00:00") + assert copilot.copilot_catalog({"copilot_catalog": saved}) == catalog(SONNET, GPT) + + def test_an_empty_saved_catalog_does_not_revive_older_lists(self): + state = {"copilot_catalog": copilot.catalog_state([]), "codex_models": [GPT]} + assert copilot.copilot_catalog(state) == [] + + def test_state_from_before_the_catalog_uses_the_claude_and_gpt_lists(self): + state = { + "claude_models": {"sonnet": SONNET}, + "codex_models": [GPT, "system.ai.gpt-oss-120b"], + } + assert copilot.copilot_catalog(state) == catalog(SONNET, GPT) + + +class TestResolveCopilotModel: + MODELS = catalog(SONNET, GPT, "databricks-claude-opus-4-7") @pytest.mark.parametrize( - ("selected_model", "override_model", "expected_api"), + "requested", [ - ("gpt-6.1-sol", None, "responses"), - ("system.ai.gpt-6-astra", "gpt-4.1", "completions"), - ("system.ai.gpt-5-6-sol", "databricks-gpt-6-1-sol", "responses"), - ("system.ai.gpt-6-astra", "", "responses"), - ("gpt-5", "", "responses"), - ("gpt-4.1", "", "completions"), + SONNET, + "claude-sonnet-5-5", + "CLAUDE-Sonnet-5-5", + f"{SONNET}[1m]", + f"dbx-anthropic/{SONNET}", ], ) - def test_selects_wire_api_from_override_and_keeps_selected_model( - self, selected_model, override_model, expected_api - ): - env = copilot.render_env_overlay(WS, selected_model, override_model=override_model) + def test_accepts_ids_short_names_and_refs(self, requested): + assert copilot.resolve_copilot_model(requested, self.MODELS).id == SONNET - assert env["COPILOT_PROVIDER_WIRE_API"] == expected_api - assert env["COPILOT_MODEL"] == selected_model + def test_accepts_the_databricks_prefix(self): + assert copilot.resolve_copilot_model("claude-opus-4-7", self.MODELS).id == ( + "databricks-claude-opus-4-7" + ) + def test_prefers_the_system_ai_id(self): + models = catalog("databricks-claude-sonnet-5-5", SONNET) + assert copilot.resolve_copilot_model("claude-sonnet-5-5", models).id == SONNET -PROXY = "http://127.0.0.1:4321" + def test_unknown_model_lists_what_is_available(self): + with pytest.raises(RuntimeError) as error: + copilot.resolve_copilot_model("system.ai.gemini-3-flash", self.MODELS) + message = str(error.value) + assert "Gemini" in message + assert f"dbx-anthropic: databricks-claude-opus-4-7, {SONNET}" in message + assert f"dbx-responses: {GPT}" in message -class TestBuildRuntimeEnv: - def test_inherits_path(self): - env = copilot.build_runtime_env(PROXY, "m", "secret") - assert "PATH" in env - def test_points_copilot_at_the_session_proxy(self): - env = copilot.build_runtime_env(PROXY, "m", "secret") - assert env["COPILOT_PROVIDER_BASE_URL"] == f"{PROXY}/ai-gateway/mlflow/v1" - assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "secret" +class TestLaunchModel: + MODELS = catalog(SONNET, "system.ai.claude-opus-5-5", GPT) + + def test_requested_model_wins(self): + state = {"copilot_default_model": SONNET} + assert copilot.launch_model(state, self.MODELS, requested=GPT).id == GPT + + def test_managed_default_then_list(self): + assert copilot.launch_model({"copilot_default_model": GPT}, self.MODELS).id == GPT + assert copilot.launch_model({"copilot_static_models": [GPT]}, catalog(GPT)).id == GPT + + @pytest.mark.parametrize( + ("ids", "expected"), + [ + ((GPT, "system.ai.claude-opus-5-5", SONNET), SONNET), + ((GPT, "system.ai.claude-opus-5-5"), "system.ai.claude-opus-5-5"), + (("system.ai.claude-fable-5", GPT), GPT), + ], + ) + def test_defaults_to_sonnet_then_opus_then_gpt(self, ids, expected): + assert copilot.launch_model({}, catalog(*ids)).id == expected + + def test_an_unavailable_requested_model_warns_and_falls_through(self, capsys): + model = copilot.launch_model({}, self.MODELS, requested="system.ai.gemini-3-flash") + + assert model.id == SONNET + assert "gemini-3-flash isn't available in ug copilot" in capsys.readouterr().out - def test_claude_goes_through_the_proxy_anthropic_route(self): - env = copilot.build_runtime_env(PROXY, "system.ai.claude-sonnet-5-5", "secret") - assert env["COPILOT_PROVIDER_BASE_URL"] == f"{PROXY}/ai-gateway/anthropic" + def test_default_model_is_quiet(self, capsys): + assert copilot.default_model({"copilot_default_model": "missing"}) is None + state = {"copilot_default_model": "missing", "codex_models": [GPT]} + assert copilot.default_model(state) == GPT + assert capsys.readouterr().out == "" - def test_sets_no_oauth_token(self, monkeypatch): - monkeypatch.delenv("OAUTH_TOKEN", raising=False) - env = copilot.build_runtime_env(PROXY, "m", "secret") - assert "OAUTH_TOKEN" not in env + +class TestRewriteModelArgs: + MODELS = catalog(SONNET, GPT) + + @pytest.mark.parametrize( + "args", + [["--model", GPT], [f"--model={GPT}"], ["-m", "gpt-5-4"], ["--model", SONNET, "-m", GPT]], + ) + def test_rewrites_to_the_registry_ref(self, args): + rewritten, selected = copilot.rewrite_model_args(["-p", "hi", *args], self.MODELS) + + assert selected == copilot.CopilotModel(GPT, "responses") + assert rewritten[:2] == ["-p", "hi"] + assert rewritten[-2:] == ["--model", f"dbx-responses/{GPT}"] + + def test_leaves_arguments_after_the_separator(self): + rewritten, selected = copilot.rewrite_model_args(["--", "--model", "x"], self.MODELS) + assert (rewritten, selected) == (["--", "--model", "x"], None) + + def test_unknown_model_raises(self): + with pytest.raises(RuntimeError, match="isn't available"): + copilot.rewrite_model_args(["--model", "system.ai.claude-opus-4-1"], self.MODELS) + + +class TestCopilotCatalogModelId: + @pytest.mark.parametrize( + ("model", "expected"), + [ + ("system.ai.claude-sonnet-5-5", "claude-sonnet-5.5"), + ("system.ai.claude-opus-4-7[1m]", "claude-opus-4.7"), + ("system.ai.claude-sonnet-5", "claude-sonnet-5"), + ("system.ai.claude-haiku-4-5", "claude-haiku-4.5"), + ("system.ai.claude-haiku-5-5", "claude-sonnet-5.5"), + ("databricks-claude-sonnet-4-6", "claude-sonnet-4.6"), + ], + ) + def test_maps_claude_ids(self, model, expected): + assert copilot.copilot_catalog_model_id(model) == expected + + def test_other_models_have_no_catalog_id(self): + assert copilot.copilot_catalog_model_id(GPT) is None + + +class TestCopilotModelLimits: + @pytest.mark.parametrize( + ("model", "limits"), + [ + ("system.ai.claude-sonnet-5-5", (872_000, 128_000)), + ("system.ai.claude-haiku-5-5", (872_000, 128_000)), + ("system.ai.claude-haiku-4-5", (136_000, 64_000)), + ("system.ai.claude-sonnet-4-5", (168_000, 32_000)), + ("system.ai.claude-opus-4-8", (936_000, 32_000)), + ("system.ai.claude-opus-6", (936_000, 32_000)), + ("system.ai.claude-sonnet-4-7", (936_000, 32_000)), + ("system.ai.claude-opus-4-0", (168_000, 32_000)), + ], + ) + def test_claude_limits(self, model, limits): + assert copilot.copilot_model_limits(model) == limits + + def test_gpt_keeps_copilots_defaults(self): + assert copilot.copilot_model_limits(GPT) is None + + +class TestRenderProvidersConfig: + def test_one_provider_per_wire_in_use_on_the_proxy(self): + config = copilot.render_providers_config(catalog(SONNET, GPT), PROXY, "secret") + + assert config["providers"] == [ + { + "name": "dbx-anthropic", + "type": "anthropic", + "baseUrl": f"{PROXY}/ai-gateway/anthropic", + "bearerToken": "secret", + }, + { + "name": "dbx-responses", + "type": "openai", + "wireApi": "responses", + "baseUrl": f"{PROXY}/ai-gateway/mlflow/v1", + "bearerToken": "secret", + }, + ] + + def test_completions_provider(self): + config = copilot.render_providers_config(catalog("system.ai.gpt-4-1"), PROXY, "secret") + assert [(p["name"], p.get("wireApi")) for p in config["providers"]] == [ + ("dbx-completions", "completions") + ] + + def test_models_carry_claude_settings_and_limits(self): + config = copilot.render_providers_config(catalog(SONNET, GPT), PROXY, "secret") + + assert config["models"] == [ + { + "id": SONNET, + "provider": "dbx-anthropic", + "wireModel": SONNET, + "name": SONNET, + "modelId": "claude-sonnet-5.5", + "maxPromptTokens": 872_000, + "maxOutputTokens": 128_000, + }, + {"id": GPT, "provider": "dbx-responses", "wireModel": GPT, "name": GPT}, + ] + + +class TestBuildRuntimeEnv: + def test_points_copilot_at_the_registry(self, tmp_path): + env = copilot.build_runtime_env(tmp_path / "providers.json", catalog(GPT)[0]) + + assert env["COPILOT_PROVIDERS_CONFIG"] == str(tmp_path / "providers.json") + assert env["COPILOT_MODEL"] == f"dbx-responses/{GPT}" + assert env["COPILOT_OFFLINE"] == "true" + assert env["PATH"] == os.environ["PATH"] @pytest.mark.parametrize( "key", [ - "COPILOT_PROVIDER_API_KEY", + "COPILOT_PROVIDERS_CONFIG", + "COPILOT_PROVIDER_BASE_URL", + "COPILOT_PROVIDER_BEARER_TOKEN", "COPILOT_PROVIDER_API_KEY_COMMAND", - "COPILOT_PROVIDER_TRANSPORT", + "COPILOT_PROVIDER_WIRE_MODEL", ], ) - def test_drops_inherited_settings_that_bypass_the_proxy(self, monkeypatch, key): + def test_drops_the_users_own_provider_settings(self, monkeypatch, tmp_path, key): monkeypatch.setenv(key, "user-value") - env = copilot.build_runtime_env(PROXY, "m", "secret") - assert key not in env + env = copilot.build_runtime_env(tmp_path / "providers.json", catalog(GPT)[0]) + assert env.get(key) != "user-value" - def test_inherited_wire_model_overrides_route_without_being_cleared(self, monkeypatch): - monkeypatch.setenv("COPILOT_PROVIDER_WIRE_MODEL", "gpt-4.1") - monkeypatch.setenv("COPILOT_PROVIDER_WIRE_API", "responses") - monkeypatch.setenv("COPILOT_PROVIDER_MODEL_ID", "user-model-id") - monkeypatch.setenv("COPILOT_PROVIDER_MODEL_LIMITS_ID", "user-model-limits") - env = copilot.build_runtime_env(PROXY, "system.ai.gpt-6-astra", "secret") +class TestSessionRegistry: + def test_writes_a_private_registry_removed_at_exit(self, tmp_path, monkeypatch): + isolate_copilot_config_paths(monkeypatch, tmp_path) + + with copilot.session_registry({"models": ["x"]}) as path: + assert json.loads(path.read_text(encoding="utf-8")) == {"models": ["x"]} + assert path.parent.parent == copilot.COPILOT_RUN_DIR + if os.name != "nt": + assert path.stat().st_mode & 0o777 == 0o600 + assert path.parent.stat().st_mode & 0o777 == 0o700 + + assert not path.parent.exists() - assert env["COPILOT_PROVIDER_WIRE_MODEL"] == "gpt-4.1" - assert env["COPILOT_PROVIDER_WIRE_API"] == "completions" - assert env["COPILOT_PROVIDER_MODEL_ID"] == "user-model-id" - assert env["COPILOT_PROVIDER_MODEL_LIMITS_ID"] == "user-model-limits" + def test_removed_when_the_session_fails(self, tmp_path, monkeypatch): + isolate_copilot_config_paths(monkeypatch, tmp_path) + + with pytest.raises(OSError), copilot.session_registry({}) as path: + raise OSError("copilot failed") + + assert not path.parent.exists() + + def test_sweep_keeps_live_sessions_and_removes_dead_ones(self, tmp_path, monkeypatch): + isolate_copilot_config_paths(monkeypatch, tmp_path) + dead = copilot.COPILOT_RUN_DIR / "copilot-dead" + dead.mkdir(parents=True) + (dead / "session.lock").touch() + (dead / "providers.json").write_text("{}", encoding="utf-8") + unlocked = copilot.COPILOT_RUN_DIR / "copilot-crashed-before-lock" + unlocked.mkdir() + + with copilot.session_registry({}) as live: + copilot.sweep_session_dirs() + assert live.exists() + + assert not dead.exists() + assert not unlocked.exists() class TestMcpServerConfig: @@ -230,95 +510,6 @@ def test_removes_mcp_server_without_clobbering_others(self, tmp_path, monkeypatc assert written["other"] is True -class TestDefaultModel: - def test_prefers_claude_sonnet(self): - state = { - "claude_models": {"sonnet": "s4", "opus": "o4", "haiku": "h4"}, - "codex_models": ["gpt-5"], - } - assert copilot.default_model(state) == "s4" - - def test_falls_back_to_opus(self): - state = {"claude_models": {"opus": "o4", "haiku": "h4"}} - assert copilot.default_model(state) == "o4" - - def test_falls_back_to_haiku(self): - state = {"claude_models": {"haiku": "h4"}} - assert copilot.default_model(state) == "h4" - - def test_falls_back_to_codex_when_no_claude(self): - state = {"codex_models": ["gpt-5", "gpt-5-mini"]} - assert copilot.default_model(state) == "gpt-5" - - def test_returns_none_when_no_models(self): - assert copilot.default_model({}) is None - - def test_ignores_gemini_models(self): - # Gemini is excluded — Databricks' Gemini translator rejects copilot's request shape. - state = {"gemini_models": ["gemini-2-5-pro"]} - assert copilot.default_model(state) is None - - -class TestCopilotCatalogModelId: - @pytest.mark.parametrize( - ("model", "expected"), - [ - ("system.ai.claude-sonnet-5-5", "claude-sonnet-5.5"), - ("system.ai.claude-opus-5-5[1m]", "claude-opus-5.5"), - ("system.ai.claude-opus-4-7", "claude-opus-4.7"), - ("system.ai.claude-sonnet-5", "claude-sonnet-5"), - ("system.ai.claude-haiku-4-5", "claude-haiku-4.5"), - ("system.ai.claude-haiku-5-5", "claude-sonnet-5.5"), - ("system.ai.claude-haiku-6", "claude-sonnet-6"), - ], - ) - def test_maps_claude_ids(self, model, expected): - assert copilot.copilot_catalog_model_id(model) == expected - - @pytest.mark.parametrize( - "model", - [ - "gpt-5", - "system.ai.gpt-6-astra", - "claude-sonnet-4-6", - "databricks-claude-opus-4-7", - "global.anthropic.claude-opus-4-8", - "main.schema.my-claude-finetune", - "system.ai.myclaude-model", - ], - ) - def test_non_system_claude_ids_return_none(self, model): - assert copilot.copilot_catalog_model_id(model) is None - - -class TestModelUsesResponsesApi: - def test_numeric_gpt_majors_and_gateway_aliases_use_responses(self): - for model in ( - "gpt-5", - "databricks-gpt-5-mini", - "system.ai.gpt-5-4", - "gpt-6", - "gpt-6.1-sol", - "system.ai.gpt-6-astra", - "databricks-gpt-6-1-sol", - "gpt-7", - "gpt-10", - ): - assert copilot.model_uses_responses_api(model), model - - def test_pre_gpt5_non_gpt_and_unsupported_alias_shapes_use_completions(self): - for model in ( - "gpt-4.1", - "gpt-4o", - "claude-sonnet-4-6", - "my-gpt-6-model", - "gpt6", - "gpt.6", - "gpt-60x", - ): - assert not copilot.model_uses_responses_api(model), model - - def isolate_copilot_config_paths(monkeypatch, tmp_path): import ucode.config_io as config_io import ucode.state as state_mod @@ -331,61 +522,32 @@ def isolate_copilot_config_paths(monkeypatch, tmp_path): monkeypatch.setattr(copilot, "COPILOT_ENV_PATH", env_path) monkeypatch.setattr(copilot, "COPILOT_BACKUP_PATH", app_dir / "copilot-ucode-env.backup") monkeypatch.setattr(copilot, "COPILOT_MCP_CONFIG_PATH", tmp_path / "missing-mcp.json") + monkeypatch.setattr(copilot, "COPILOT_RUN_DIR", app_dir / "run") return env_path class TestWriteToolConfig: - def test_manages_wire_api_and_preserves_wire_model_and_user_values(self, tmp_path, monkeypatch): + def test_records_the_default_model_and_drops_stale_provider_settings( + self, tmp_path, monkeypatch + ): env_path = isolate_copilot_config_paths(monkeypatch, tmp_path) env_path.parent.mkdir(parents=True) env_path.write_text( - "COPILOT_PROVIDER_WIRE_MODEL=system.ai.gpt-6-1-sol\n" - "COPILOT_PROVIDER_WIRE_API=completions\n" - "COPILOT_PROVIDER_MODEL_ID=user-model-id\n" - "COPILOT_PROVIDER_MODEL_LIMITS_ID=user-model-limits\n" - "USER_SETTING=keep\n", + 'COPILOT_PROVIDER_TYPE="anthropic"\nCOPILOT_PROVIDER_WIRE_API="responses"\n' + 'USER_SETTING="kept"\n', encoding="utf-8", ) - monkeypatch.setenv("COPILOT_PROVIDER_WIRE_MODEL", "gpt-5") - - state = copilot.write_tool_config({"workspace": WS}, "system.ai.gpt-5-6-sol") - - written = copilot.parse_dotenv(env_path) - assert written["COPILOT_MODEL"] == "system.ai.gpt-5-6-sol" - assert written["COPILOT_PROVIDER_WIRE_API"] == "responses" - assert written["COPILOT_PROVIDER_WIRE_MODEL"] == "system.ai.gpt-6-1-sol" - assert "COPILOT_PROVIDER_MODEL_ID" not in written - assert written["COPILOT_PROVIDER_MODEL_LIMITS_ID"] == "user-model-limits" - assert written["USER_SETTING"] == "keep" - assert "COPILOT_PROVIDER_WIRE_API" in state["managed_configs"]["copilot"]["keys"] - assert "COPILOT_PROVIDER_WIRE_MODEL" not in state["managed_configs"]["copilot"]["keys"] - - def test_claude_drops_stale_wire_api_from_env_file(self, tmp_path, monkeypatch): - env_path = isolate_copilot_config_paths(monkeypatch, tmp_path) - env_path.parent.mkdir(parents=True) - env_path.write_text("COPILOT_PROVIDER_WIRE_API=completions\n", encoding="utf-8") - - copilot.write_tool_config({"workspace": WS}, "system.ai.claude-sonnet-5-5") + state = {"workspace": WS, "codex_models": [GPT]} - written = copilot.parse_dotenv(env_path) - assert "COPILOT_PROVIDER_WIRE_API" not in written - assert written["COPILOT_PROVIDER_TYPE"] == "anthropic" - assert written["COPILOT_PROVIDER_MODEL_ID"] == "claude-sonnet-5.5" - - def test_needs_no_token_and_writes_none(self, tmp_path, monkeypatch): - env_path = isolate_copilot_config_paths(monkeypatch, tmp_path) + state = copilot.write_tool_config(state, GPT) - def no_token(*args, **kwargs): - raise AssertionError("configure must not mint a token") - - monkeypatch.setattr(custom_oauth, "get_databricks_token", no_token) - - copilot.write_tool_config({"workspace": WS}, "gpt-5") - - written = copilot.parse_dotenv(env_path) - assert written["COPILOT_PROVIDER_BASE_URL"] == f"{WS}/ai-gateway/mlflow/v1" - for key in copilot.CREDENTIAL_ENV_KEYS: - assert key not in written + env = copilot.parse_dotenv(env_path) + assert env == { + "USER_SETTING": "kept", + "COPILOT_MODEL": f"dbx-responses/{GPT}", + "COPILOT_OFFLINE": "true", + } + assert state["managed_configs"]["copilot"]["keys"] == copilot.MANAGED_KEYS def test_scrubs_a_token_an_earlier_version_wrote(self, tmp_path, monkeypatch): env_path = isolate_copilot_config_paths(monkeypatch, tmp_path) @@ -531,65 +693,91 @@ def _stub_launch(monkeypatch, process=None, token="dbx-token"): def popen(argv, *, env): seen["argv"] = argv seen["env"] = env - base = env["COPILOT_PROVIDER_BASE_URL"] - seen["accepted"] = httpx.post( - f"{base}/chat/completions", - headers={"Authorization": "Bearer wrong-secret"}, - content=b"{}", - timeout=5, - ).status_code + registry_path = env["COPILOT_PROVIDERS_CONFIG"] + seen["registry_path"] = registry_path + seen["registry"] = json.loads(open(registry_path, encoding="utf-8").read()) + provider = seen["registry"]["providers"][0] + seen["accepted"] = { + secret: httpx.post( + f"{provider['baseUrl']}/v1/messages", + headers={"Authorization": f"Bearer {secret}"}, + content=b"{}", + timeout=5, + ).status_code + for secret in ("wrong-secret",) + } return process or _ExitedProcess() monkeypatch.setattr(copilot.subprocess_cross_os, "popen", popen) return seen +STATE = {"workspace": WS, "copilot_catalog": {"models": [SONNET, GPT]}} + + class TestLaunch: - @pytest.mark.parametrize( - ("tool_args", "pinned_model", "default", "expected_model", "expected_api"), - [ - (["--model", "gpt-6.1-sol"], "gpt-5", "gpt-5", "gpt-6.1-sol", "responses"), - ([], "system.ai.gpt-4-1", "gpt-6", "system.ai.gpt-4-1", "completions"), - ([], None, "system.ai.gpt-10", "system.ai.gpt-10", "responses"), - ], - ) - def test_launch_model_precedence_preserves_argv( - self, tool_args, pinned_model, default, expected_model, expected_api, tmp_path, monkeypatch - ): + def test_copilot_gets_the_registry_and_never_the_token(self, tmp_path, monkeypatch): isolate_copilot_config_paths(monkeypatch, tmp_path) - monkeypatch.delenv("COPILOT_PROVIDER_WIRE_MODEL", raising=False) - seen = _stub_launch(monkeypatch) - state = {"workspace": WS, "copilot_default_model": default} + seen = _stub_launch(monkeypatch, token="dbx-token") with pytest.raises(SystemExit) as exit_info: - copilot.launch( - state, - tool_args, - options=copilot.LaunchOptions(user_pinned_model=pinned_model), - ) + copilot.launch(dict(STATE), [], options=copilot.LaunchOptions()) assert exit_info.value.code == 0 - assert seen["argv"] == ["copilot", *tool_args] - assert seen["env"]["COPILOT_MODEL"] == expected_model - assert seen["env"]["COPILOT_PROVIDER_WIRE_API"] == expected_api - assert state["copilot_default_model"] == default + assert seen["env"]["COPILOT_MODEL"] == f"dbx-anthropic/{SONNET}" + assert "dbx-token" not in seen["env"].values() + registry = seen["registry"] + assert [m["id"] for m in registry["models"]] == [SONNET, GPT] + secret = registry["providers"][0]["bearerToken"] + assert secret not in ("", "dbx-token") + assert secret not in seen["env"].values() + assert "dbx-token" not in json.dumps(registry) + # The proxy is live while Copilot runs and refuses other credentials. + assert seen["accepted"] == {"wrong-secret": 401} + assert not os.path.exists(seen["registry_path"]) - def test_copilot_gets_the_proxy_and_a_session_secret_not_the_token(self, tmp_path, monkeypatch): + def test_explicit_model_is_rewritten_to_the_registry_ref(self, tmp_path, monkeypatch): isolate_copilot_config_paths(monkeypatch, tmp_path) - monkeypatch.delenv("COPILOT_PROVIDER_WIRE_MODEL", raising=False) - seen = _stub_launch(monkeypatch, token="dbx-token") + seen = _stub_launch(monkeypatch) with pytest.raises(SystemExit): copilot.launch( - {"workspace": WS}, [], options=copilot.LaunchOptions(user_pinned_model="gpt-5") + dict(STATE), + ["-p", "hi", "-m", "gpt-5-4"], + options=copilot.LaunchOptions(launch_model=SONNET), ) - env = seen["env"] - assert env["COPILOT_PROVIDER_BASE_URL"].startswith("http://127.0.0.1:") - assert env["COPILOT_PROVIDER_BEARER_TOKEN"] not in ("", "dbx-token") - assert "dbx-token" not in env.values() - # The proxy is live while Copilot runs and refuses other credentials. - assert seen["accepted"] == 401 + assert seen["argv"] == ["copilot", "-p", "hi", "--model", f"dbx-responses/{GPT}"] + assert seen["env"]["COPILOT_MODEL"] == f"dbx-responses/{GPT}" + + def test_starts_on_the_requested_launch_model(self, tmp_path, monkeypatch): + isolate_copilot_config_paths(monkeypatch, tmp_path) + seen = _stub_launch(monkeypatch) + + with pytest.raises(SystemExit): + copilot.launch(dict(STATE), [], options=copilot.LaunchOptions(launch_model=GPT)) + + assert seen["env"]["COPILOT_MODEL"] == f"dbx-responses/{GPT}" + + def test_unknown_model_fails_before_starting_copilot(self, tmp_path, monkeypatch): + isolate_copilot_config_paths(monkeypatch, tmp_path) + seen = _stub_launch(monkeypatch) + + with pytest.raises(RuntimeError, match="isn't available in ug copilot"): + copilot.launch(dict(STATE), ["--model", "nope"], options=copilot.LaunchOptions()) + + assert seen == {} + assert not copilot.COPILOT_RUN_DIR.exists() + + def test_warns_about_managed_models_copilot_cannot_use(self, tmp_path, monkeypatch, capsys): + isolate_copilot_config_paths(monkeypatch, tmp_path) + _stub_launch(monkeypatch) + state = {"workspace": WS, "copilot_static_models": [GPT, "system.ai.gemini-3-flash"]} + + with pytest.raises(SystemExit): + copilot.launch(state, [], options=copilot.LaunchOptions()) + + assert "Skipping system.ai.gemini-3-flash" in capsys.readouterr().out def test_launch_persists_no_state_and_no_token(self, tmp_path, monkeypatch): env_path = isolate_copilot_config_paths(monkeypatch, tmp_path) @@ -598,9 +786,7 @@ def test_launch_persists_no_state_and_no_token(self, tmp_path, monkeypatch): monkeypatch.setattr(copilot, "save_state", saved.append) with pytest.raises(SystemExit): - copilot.launch( - {"workspace": WS}, [], options=copilot.LaunchOptions(user_pinned_model="gpt-5") - ) + copilot.launch(dict(STATE), [], options=copilot.LaunchOptions()) assert saved == [] assert not env_path.exists() @@ -610,31 +796,30 @@ def test_ctrl_c_keeps_waiting_for_copilot(self, tmp_path, monkeypatch): _stub_launch(monkeypatch, process=_ExitedProcess(returncode=130, interrupts=2)) with pytest.raises(SystemExit) as exit_info: - copilot.launch( - {"workspace": WS}, [], options=copilot.LaunchOptions(user_pinned_model="gpt-5") - ) + copilot.launch(dict(STATE), [], options=copilot.LaunchOptions()) assert exit_info.value.code == 130 - def test_proxy_stops_when_copilot_cannot_start(self, tmp_path, monkeypatch): + def test_proxy_and_registry_stop_when_copilot_cannot_start(self, tmp_path, monkeypatch): isolate_copilot_config_paths(monkeypatch, tmp_path) monkeypatch.setattr(custom_oauth, "get_databricks_token", lambda *args, **kwargs: "tok") monkeypatch.setattr(copilot, "agent_version", lambda binary: "1") seen = {} def popen(argv, *, env): - seen["base"] = env["COPILOT_PROVIDER_BASE_URL"] + seen["registry"] = env["COPILOT_PROVIDERS_CONFIG"] + registry = json.loads(open(seen["registry"], encoding="utf-8").read()) + seen["base"] = registry["providers"][0]["baseUrl"] raise FileNotFoundError("copilot") monkeypatch.setattr(copilot.subprocess_cross_os, "popen", popen) with pytest.raises(FileNotFoundError): - copilot.launch( - {"workspace": WS}, [], options=copilot.LaunchOptions(user_pinned_model="gpt-5") - ) + copilot.launch(dict(STATE), [], options=copilot.LaunchOptions()) + assert not os.path.exists(seen["registry"]) with pytest.raises(httpx.ConnectError): - httpx.post(f"{seen['base']}/responses", content=b"{}", timeout=10) + httpx.post(f"{seen['base']}/v1/messages", content=b"{}", timeout=10) def test_no_model_is_a_launch_error(self, tmp_path, monkeypatch): isolate_copilot_config_paths(monkeypatch, tmp_path) @@ -644,21 +829,34 @@ def test_no_model_is_a_launch_error(self, tmp_path, monkeypatch): class TestManagedKeys: - def test_includes_required_vars(self): - for key in ( - "COPILOT_PROVIDER_TYPE", - "COPILOT_PROVIDER_BASE_URL", - "COPILOT_PROVIDER_WIRE_API", - "COPILOT_MODEL", - "COPILOT_OFFLINE", - ): - assert key in copilot.MANAGED_KEYS + def test_only_the_model_and_offline_mode(self): + assert copilot.MANAGED_KEYS == ["COPILOT_MODEL", "COPILOT_OFFLINE"] def test_excludes_credentials(self): for key in copilot.CREDENTIAL_ENV_KEYS: assert key not in copilot.MANAGED_KEYS +class TestMinimumVersion: + @pytest.mark.parametrize( + ("version", "blocked"), + [ + ("GitHub Copilot CLI 1.0.90.", True), + ("1.0.91", False), + ("1.1.0", False), + ("unknown", False), + ], + ) + def test_requires_1_0_91(self, monkeypatch, version, blocked): + monkeypatch.setattr(copilot, "agent_version", lambda binary: version) + + error = copilot.minimum_version_error() + + assert (error is not None) == blocked + if blocked: + assert "npm install -g @github/copilot" in error + + class TestValidateCmd: def test_starts_with_binary(self): cmd = copilot.validate_cmd("copilot") @@ -678,25 +876,6 @@ def test_adds_ucode_mcp_config_when_present(self, tmp_path, monkeypatch): assert cmd[:3] == ["copilot", "--additional-mcp-config", f"@{mcp_path}"] -class TestManagedModels: - def test_managed_models_win_over_the_shared_discovery_lists(self): - state = { - "copilot_models": ["system.ai.gpt-5"], - "claude_models": {"sonnet": "shared-should-not-win"}, - } - assert copilot.default_model(state) == "system.ai.gpt-5" - - def test_falls_back_to_the_shared_lists_without_a_managed_config(self): - assert copilot.default_model({"claude_models": {"sonnet": "discovered"}}) == "discovered" - - def test_copilot_default_model_wins_over_allowlist(self): - state = { - "copilot_default_model": "admin-chosen-default", - "copilot_models": ["system.ai.gpt-5"], - } - assert copilot.default_model(state) == "admin-chosen-default" - - class TestWriteUserMcpServers: def test_batched_add_remove_preserves_other_keys(self, tmp_path, monkeypatch): path = tmp_path / "ucode-mcp-config.json" diff --git a/tests/test_agents_init.py b/tests/test_agents_init.py index c90fa4148..9e2866237 100644 --- a/tests/test_agents_init.py +++ b/tests/test_agents_init.py @@ -288,10 +288,11 @@ def test_opencode_available(self): assert check_gateway_endpoint(state, "opencode") is True def test_copilot_available_with_claude(self): - assert check_gateway_endpoint({"claude_models": {"sonnet": "s4"}}, "copilot") is True + state = {"claude_models": {"sonnet": "system.ai.claude-sonnet-4-6"}} + assert check_gateway_endpoint(state, "copilot") is True def test_copilot_available_with_codex(self): - assert check_gateway_endpoint({"codex_models": ["m"]}, "copilot") is True + assert check_gateway_endpoint({"codex_models": ["system.ai.gpt-5-4"]}, "copilot") is True def test_copilot_unavailable_with_only_gemini(self): # Gemini is intentionally excluded from Copilot. @@ -1091,9 +1092,9 @@ def test_fable_only_workspace_has_a_default(tool): def test_fable_does_not_displace_existing_default(tool): state = { "claude_models": {"fable": "system.ai.claude-fable-5"}, - "codex_models": ["existing-gpt"], + "codex_models": ["system.ai.gpt-5-4"], } - assert default_model_for_tool(tool, state) == "existing-gpt" + assert default_model_for_tool(tool, state) == "system.ai.gpt-5-4" class TestConfigureSelectedTools: diff --git a/tests/test_cli.py b/tests/test_cli.py index 46353c2da..b24eec176 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3509,6 +3509,24 @@ def test_reports_runtime_error(self): assert "boom" in _strip_ansi(result.output) +class TestCopilotLaunchModel: + MANAGED = { + "enabled_agents": {"copilot": {"model_config": {"default_model": "system.ai.gpt-5-4"}}} + } + + def test_managed_default_is_the_launch_model(self): + with _launch_policy_patches(self.MANAGED) as calls: + result = runner.invoke(app, ["copilot"]) + + assert result.exit_code == 0, result.output + assert calls["launch"].call_args.kwargs["options"].launch_model == "system.ai.gpt-5-4" + + def test_a_managed_default_still_discovers_the_catalog(self): + assert not cli_mod._skip_model_discovery( + self.MANAGED, "copilot", provider=None, managed_parent_schema=None, family_defaults=None + ) + + class TestOpenCodeModelFlag: @pytest.mark.parametrize("flag", ["--model", "-m"]) def test_model_option_threads_to_config_and_launch(self, flag): @@ -5935,6 +5953,56 @@ def _boom(*a, **k): assert state["claude_models"] == {"opus": "databricks-claude-opus-4-8"} +class TestConfigureSharedStateCopilotCatalog: + """Copilot's catalog comes from the same listings as the other agents' models.""" + + WS = "https://copilot.databricks.com" + + def _configure(self, monkeypatch, *, listed, saved=None): + import ucode.cli as cli_mod + import ucode.databricks as db_mod + + TestConfigureSharedStateSkipDiscovery._stub(monkeypatch) + previous = {"workspace": self.WS} + if saved is not None: + previous["copilot_catalog"] = saved + monkeypatch.setattr(cli_mod, "load_state", lambda: dict(previous)) + if listed is not None: + db_mod._MODEL_SERVICES_CACHE[self.WS] = [db_mod.ModelServiceEntry(m) for m in listed] + reason = None if listed else "HTTP 503" + sonnet = "system.ai.claude-sonnet-4-6" + monkeypatch.setattr( + cli_mod, + "discover_model_services", + lambda w, t: ( + ({"sonnet": sonnet}, [], [], [], None) if listed else ({}, [], [], [], reason) + ), + ) + monkeypatch.setattr(cli_mod, "discover_claude_models", lambda w, t: ({}, reason)) + monkeypatch.setattr(cli_mod, "discover_codex_models", lambda w, t: ([], reason)) + return cli_mod.configure_shared_state(self.WS, tools=["copilot"]) + + def test_saves_the_classified_catalog(self, monkeypatch): + state = self._configure( + monkeypatch, + listed=["system.ai.claude-sonnet-4-6", "system.ai.gpt-5-4", "system.ai.gemini-3-flash"], + ) + + assert state["copilot_catalog"]["models"] == [ + "system.ai.claude-sonnet-4-6", + "system.ai.gpt-5-4", + ] + + def test_failed_listing_keeps_the_saved_catalog(self, monkeypatch, capsys): + saved = {"models": ["system.ai.gpt-5-4"], "discovered_at": "2026-10-01T00:00:00+00:00"} + + state = self._configure(monkeypatch, listed=None, saved=saved) + + assert state["copilot_catalog"] == saved + assert state["_discovery_reasons"]["copilot"] == "HTTP 503" + assert "Copilot model discovery failed (HTTP 503)" in capsys.readouterr().out + + class TestConfigureSharedStateSkipPreflight: """With skip_preflight (--skip-preflight), a prior configure is trusted: no auth login, token fetch, gateway probe, or model discovery runs — but the @@ -6752,6 +6820,19 @@ def test_other_agent_commands_keep_stdout(self): assert cli_mod._child_owns_stdout("claude", ["app-server"]) is False assert cli_mod._child_owns_stdout("gemini", []) is False + @pytest.mark.parametrize( + ("tool_args", "expected"), + [ + (["-p", "hi"], True), + (["--prompt", "hi"], True), + (["--prompt=hi"], True), + ([], False), + (["--", "-p", "hi"], False), + ], + ) + def test_copilot_prompt_mode_owns_stdout(self, tool_args, expected): + assert cli_mod._child_owns_stdout("copilot", tool_args) is expected + @pytest.mark.parametrize( "option", [ diff --git a/tests/test_databricks.py b/tests/test_databricks.py index d2b569ea6..9910d1a1f 100644 --- a/tests/test_databricks.py +++ b/tests/test_databricks.py @@ -707,6 +707,34 @@ def flaky_get(url, token, timeout=10): assert ids == ["system.ai.gpt-5"] assert calls["n"] == 3 # two failures, third succeeds + def test_entries_keep_the_advertised_api_types(self, monkeypatch): + service = { + **_model_service("system.ai.gte-large-en"), + "supported_api_types": ["mlflow/v1/embeddings", 3], + } + + monkeypatch.setattr( + db_mod, + "_get_model_services_page", + lambda url, token: ({"model_services": [service]}, None), + ) + + entries, reason = db_mod.list_model_service_entries(WS, "token") + + assert reason is None + assert entries == [ + db_mod.ModelServiceEntry("system.ai.gte-large-en", ("mlflow/v1/embeddings",)) + ] + assert db_mod.cached_model_service_entries(WS) == entries + + def test_failed_listing_is_not_cached(self, monkeypatch): + monkeypatch.setattr( + db_mod, "_get_model_services_page", lambda url, token: (None, "HTTP 503") + ) + + assert db_mod.list_model_service_entries(WS, "token") == ([], "HTTP 503") + assert db_mod.cached_model_service_entries(WS) is None + class TestModelServiceExists: def test_true_when_listed_in_its_schema(self, monkeypatch): diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 45ea62c40..31e68fc39 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -143,6 +143,33 @@ def test_unknown_tool_is_skipped(self): assert _check_agent_clis() == [] +class TestCopilotModelsCheck: + def test_skipped_when_copilot_is_not_configured(self): + with patch.object(doctor_mod, "load_state", return_value={"available_tools": ["claude"]}): + assert doctor_mod._check_copilot_models() is None + + def test_counts_models_per_provider(self): + state = { + "available_tools": ["copilot"], + "copilot_catalog": {"models": ["system.ai.claude-sonnet-4-6", "system.ai.gpt-5-4"]}, + } + with patch.object(doctor_mod, "load_state", return_value=state): + check = doctor_mod._check_copilot_models() + + assert check is not None + assert check.status == "ok" + assert check.detail == "2 models (1 dbx-anthropic, 1 dbx-responses)" + + def test_warns_when_nothing_is_usable(self): + state = {"available_tools": ["copilot"], "copilot_catalog": {"models": []}} + with patch.object(doctor_mod, "load_state", return_value=state): + check = doctor_mod._check_copilot_models() + + assert check is not None + assert check.status == "warn" + assert "ug configure --agents copilot" in check.detail + + class TestDatabricksAuthCheck: def test_none_when_no_workspace(self): with patch.object(doctor_mod, "load_state", return_value={}): diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 92d86a216..09f20aa81 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1041,7 +1041,7 @@ def test_recovers_from_rejected_initial_token( class TestCopilotLaunch: - """Run Copilot through MLflow using Responses for GPT-6+ and Chat Completions otherwise. + """Run Copilot on every catalog model through one session registry. Gemini is excluded by design — Databricks' Gemini translator rejects the `stream_options` field Copilot CLI sends. Other incompatible models are @@ -1095,10 +1095,8 @@ def test_launch_copilot_per_model( pytest.skip("No Copilot-compatible models available on this workspace") monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path) - env_path = tmp_path / ".copilot-env" - backup_path = tmp_path / "copilot-env.backup" - monkeypatch.setattr(copilot, "COPILOT_ENV_PATH", env_path) - monkeypatch.setattr(copilot, "COPILOT_BACKUP_PATH", backup_path) + monkeypatch.setattr(copilot, "COPILOT_RUN_DIR", tmp_path / "run") + catalog = copilot.build_copilot_catalog([model for _, model in models]) from ucode import gateway_proxy @@ -1116,21 +1114,19 @@ def test_launch_copilot_per_model( proxy_base_url = f"http://127.0.0.1:{server.server_address[1]}" failures = [] + config = copilot.render_providers_config(catalog, proxy_base_url, session_secret) try: - for family, model in models: - with pytest.MonkeyPatch().context() as mp: - mp.setattr("ucode.agents.copilot.save_state", lambda s: None) - copilot.write_tool_config({**e2e_state, "workspace": e2e_workspace}, model) - - env = copilot.build_runtime_env(proxy_base_url, model, session_secret) - cmd = copilot.validate_cmd("copilot") - result = _run_agent(cmd, env=env, timeout=120) - combined = (result.stdout + result.stderr).strip() - if result.returncode != 0 or not combined: - failures.append( - f"family={family} model={model} rc={result.returncode} " - f"stdout={result.stdout[:300]!r} stderr={result.stderr[:300]!r}" - ) + with copilot.session_registry(config) as registry_path: + for model in catalog: + env = copilot.build_runtime_env(registry_path, model) + cmd = [*copilot.validate_cmd("copilot"), "--model", model.ref] + result = _run_agent(cmd, env=env, timeout=120) + combined = (result.stdout + result.stderr).strip() + if result.returncode != 0 or not combined: + failures.append( + f"model={model.ref} rc={result.returncode} " + f"stdout={result.stdout[:300]!r} stderr={result.stderr[:300]!r}" + ) finally: cache.stop() server.shutdown() diff --git a/tests/test_managed_resolve.py b/tests/test_managed_resolve.py index 4bf2b5ab5..f3125411b 100644 --- a/tests/test_managed_resolve.py +++ b/tests/test_managed_resolve.py @@ -683,6 +683,22 @@ def test_no_warning_when_anything_is_servable(self, tool, models): def test_agents_that_pass_models_through_never_warn(self): assert managed_unservable_models(self._managed("codex", ["anything"]), "codex") == [] + @staticmethod + def _copilot(models): + return {"enabled_agents": {"copilot": {"model_config": {"model_services": models}}}} + + def test_copilot_list_with_only_gemini_and_oss_is_unservable(self): + models = ["system.ai.gemini-3-flash", "system.ai.kimi-k2-6"] + assert managed_unservable_models(self._copilot(models), "copilot") == models + + def test_copilot_list_with_a_claude_model_is_servable(self): + models = ["system.ai.gemini-3-flash", "system.ai.claude-opus-4-8"] + assert managed_unservable_models(self._copilot(models), "copilot") == [] + + def test_copilot_model_list_reaches_state_as_its_static_models(self): + overrides = managed_state_overrides(self._copilot(["system.ai.gpt-5-4"]), "copilot") + assert overrides["copilot_static_models"] == ["system.ai.gpt-5-4"] + class TestRecommendedAgent: """A tier can move the org to a cheaper agent; with none named, default_agent stands.""" diff --git a/tests/test_managed_setup.py b/tests/test_managed_setup.py index e06260915..e4c9be432 100644 --- a/tests/test_managed_setup.py +++ b/tests/test_managed_setup.py @@ -333,13 +333,20 @@ def test_codex_sees_gpt_and_oss(self): ] def test_multi_provider_agents_see_everything(self): - for tool in ("opencode", "pi", "copilot"): + for tool in ("opencode", "pi"): options = model_options_for_agent(tool, STATE) assert "system.ai.claude-opus-4-8" in options, tool assert "system.ai.gpt-5-6" in options, tool assert "system.ai.gemini-3-flash" in options, tool assert "system.ai.kimi-k2-6" in options, tool + def test_copilot_sees_only_claude_and_gpt(self): + options = model_options_for_agent("copilot", STATE) + assert "system.ai.claude-opus-4-8" in options + assert "system.ai.gpt-5-6" in options + assert "system.ai.gemini-3-flash" not in options + assert "system.ai.kimi-k2-6" not in options + def test_empty_state_yields_no_options(self): assert model_options_for_agent("claude", {}) == [] From 028eeb87656dc5f621d13d5315093505734b5fa6 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Tue, 20 Oct 2026 12:18:15 +0000 Subject: [PATCH 02/11] copilot: keep the session registry in ~/.copilot/ucode-providers-.json Co-authored-by: Isaac --- README.md | 4 +- src/ucode/agents/copilot.py | 60 ++++++++++++++--------------- src/ucode/cli.py | 2 +- tests/integration/utils/evidence.py | 2 +- tests/test_agent_copilot.py | 32 ++++++++------- tests/test_e2e.py | 2 +- 6 files changed, 53 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 809f13cdf..1c0bfb57d 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,8 @@ global choice is saved for plain `copilot` too, where the Databricks models don' exist. Requires Copilot CLI 1.0.91 or newer. `ug copilot` runs Copilot against a loopback proxy for the session. Copilot holds only -a random session secret, kept in the session registry under `~/.ucode/run/` (readable -only by you, deleted when the session ends); the proxy adds a fresh Databricks token to +a random session secret, kept in the session's `~/.copilot/ucode-providers-.json` +registry (readable only by you, deleted when the session ends); the proxy adds a fresh Databricks token to each gateway request, so OAuth sessions keep working past token expiry without a restart, and no Databricks token is written to disk. If the Databricks sign-in itself expires, Copilot shows a "Databricks sign-in expired" error; run `databricks auth login` and diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index d3f443da3..51138fcb0 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -1,7 +1,8 @@ """GitHub Copilot CLI agent: runs Copilot against a per-session Databricks model registry. Copilot reads its BYOK models from a providers.json registry (`COPILOT_PROVIDERS_CONFIG`). Each -`ug copilot` launch writes one under ~/.ucode/run/ listing every Databricks model Copilot can use, +`ug copilot` launch writes one (~/.copilot/ucode-providers-.json) listing every Databricks +model Copilot can use, so `/model` switches between them mid-session, across wire APIs. Each family keeps its native wire: Claude on the gateway's Anthropic endpoint (with Copilot's catalog id as `modelId`, so Copilot sends Claude request settings), GPT 5+ on Responses (Copilot sends `temperature: 0` on Chat @@ -22,8 +23,6 @@ import os import re import secrets -import shutil -import tempfile import threading import time from collections.abc import Callable, Iterable, Iterator @@ -31,7 +30,7 @@ from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import IO, Any, Protocol +from typing import Any, Protocol from ucode.config_io import ( APP_DIR, @@ -70,11 +69,8 @@ COPILOT_BACKUP_PATH = APP_DIR / "copilot-ucode-env.backup" COPILOT_MCP_BACKUP_PATH = APP_DIR / "copilot-ucode-mcp-config.backup.json" COPILOT_PROXY_LOG_PATH = APP_DIR / "logs" / "copilot-proxy.log" -COPILOT_RUN_DIR = APP_DIR / "run" -_SESSION_DIR_PREFIX = "copilot-" -_SESSION_LOCK_NAME = "session.lock" -_RUN_DIR_LOCK_NAME = ".copilot.lock" -_REGISTRY_NAME = "providers.json" +_REGISTRY_PREFIX = "ucode-providers-" +_REGISTRY_LOCK_NAME = "ucode-providers.lock" MINIMUM_COPILOT_VERSION = (1, 0, 91) @@ -480,9 +476,9 @@ def build_runtime_env(registry_path: Path, model: CopilotModel) -> dict[str, str @contextmanager -def _run_dir_lock() -> Iterator[None]: - COPILOT_RUN_DIR.mkdir(parents=True, exist_ok=True) - with (COPILOT_RUN_DIR / _RUN_DIR_LOCK_NAME).open("a+b") as lock_file: +def _registries_lock() -> Iterator[None]: + COPILOT_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with (COPILOT_CONFIG_DIR / _REGISTRY_LOCK_NAME).open("a+b") as lock_file: acquire_exclusive_file_lock(lock_file) try: yield @@ -490,9 +486,9 @@ def _run_dir_lock() -> Iterator[None]: release_file_lock(lock_file) -def _sweep_dead_sessions() -> None: - for session_dir in COPILOT_RUN_DIR.glob(f"{_SESSION_DIR_PREFIX}*"): - lock_path = session_dir / _SESSION_LOCK_NAME +def _sweep_dead_registries() -> None: + for registry in COPILOT_CONFIG_DIR.glob(f"{_REGISTRY_PREFIX}*.json"): + lock_path = registry.with_suffix(".lock") if lock_path.exists(): with lock_path.open("a+b") as lock_file: try: @@ -500,44 +496,46 @@ def _sweep_dead_sessions() -> None: except (TimeoutError, OSError): continue release_file_lock(lock_file) - shutil.rmtree(session_dir, ignore_errors=True) + registry.unlink(missing_ok=True) + lock_path.unlink(missing_ok=True) -def sweep_session_dirs() -> None: +def sweep_session_registries() -> None: """Delete registries left by sessions that ended without cleaning up (e.g. a killed terminal).""" - if not COPILOT_RUN_DIR.exists(): + if not COPILOT_CONFIG_DIR.exists(): return - with _run_dir_lock(): - _sweep_dead_sessions() + with _registries_lock(): + _sweep_dead_registries() @contextmanager def session_registry(config: dict) -> Iterator[Path]: """Write ``config`` to a private per-session providers.json, removed when the session ends. - The session holds a lock on its directory so a concurrent sweep leaves it alone.""" - lock_file: IO[Any] | None = None - with _run_dir_lock(): - _sweep_dead_sessions() - session_dir = Path(tempfile.mkdtemp(prefix=_SESSION_DIR_PREFIX, dir=COPILOT_RUN_DIR)) + The session holds a lock on a sibling ``.lock`` file so a concurrent sweep leaves it alone; + locking the registry itself would block Copilot from reading it on Windows.""" + with _registries_lock(): + _sweep_dead_registries() + path = COPILOT_CONFIG_DIR / f"{_REGISTRY_PREFIX}{secrets.token_hex(8)}.json" + lock_path = path.with_suffix(".lock") + lock_file = lock_path.open("a+b") try: - lock_file = (session_dir / _SESSION_LOCK_NAME).open("a+b") acquire_exclusive_file_lock(lock_file, timeout=0) - path = session_dir / _REGISTRY_NAME descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(descriptor, "w", encoding="utf-8") as registry: json.dump(config, registry, indent=2) except BaseException: - if lock_file is not None: - lock_file.close() - shutil.rmtree(session_dir, ignore_errors=True) + lock_file.close() + path.unlink(missing_ok=True) + lock_path.unlink(missing_ok=True) raise try: yield path finally: release_file_lock(lock_file) lock_file.close() - shutil.rmtree(session_dir, ignore_errors=True) + path.unlink(missing_ok=True) + lock_path.unlink(missing_ok=True) def _parse_version(value: str) -> tuple[int, int, int] | None: diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 0489dda06..ffdbe487d 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -1447,7 +1447,7 @@ def revert() -> int: mcp_results = revert_mcp_configs(state) # Before restoring backups: one written by an earlier ug may hold a token. copilot_agent.scrub_stored_credentials() - copilot_agent.sweep_session_dirs() + copilot_agent.sweep_session_registries() claude_managed_result = claude_agent.revert_managed_settings() codex_managed_result = codex_agent.revert_managed_config() diff --git a/tests/integration/utils/evidence.py b/tests/integration/utils/evidence.py index 771324b6c..5881349d9 100644 --- a/tests/integration/utils/evidence.py +++ b/tests/integration/utils/evidence.py @@ -158,7 +158,7 @@ def copilot_selected_models(session) -> list[str]: def assert_no_session_registry_left(session) -> None: """`ug copilot` removes its per-session registry, which holds the proxy secret, on exit.""" - leftovers = sorted(session.home.glob(".ucode/run/copilot-*")) + leftovers = sorted(session.home.glob(".copilot/ucode-providers-*")) assert not leftovers, f"Session registries left behind: {leftovers}" diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index 3739d1308..c101fa121 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -382,12 +382,13 @@ def test_writes_a_private_registry_removed_at_exit(self, tmp_path, monkeypatch): with copilot.session_registry({"models": ["x"]}) as path: assert json.loads(path.read_text(encoding="utf-8")) == {"models": ["x"]} - assert path.parent.parent == copilot.COPILOT_RUN_DIR + assert path.parent == copilot.COPILOT_CONFIG_DIR + assert path.name.startswith("ucode-providers-") if os.name != "nt": assert path.stat().st_mode & 0o777 == 0o600 - assert path.parent.stat().st_mode & 0o777 == 0o700 - assert not path.parent.exists() + assert not path.exists() + assert not path.with_suffix(".lock").exists() def test_removed_when_the_session_fails(self, tmp_path, monkeypatch): isolate_copilot_config_paths(monkeypatch, tmp_path) @@ -395,23 +396,28 @@ def test_removed_when_the_session_fails(self, tmp_path, monkeypatch): with pytest.raises(OSError), copilot.session_registry({}) as path: raise OSError("copilot failed") - assert not path.parent.exists() + assert not path.exists() def test_sweep_keeps_live_sessions_and_removes_dead_ones(self, tmp_path, monkeypatch): isolate_copilot_config_paths(monkeypatch, tmp_path) - dead = copilot.COPILOT_RUN_DIR / "copilot-dead" - dead.mkdir(parents=True) - (dead / "session.lock").touch() - (dead / "providers.json").write_text("{}", encoding="utf-8") - unlocked = copilot.COPILOT_RUN_DIR / "copilot-crashed-before-lock" - unlocked.mkdir() + config_dir = copilot.COPILOT_CONFIG_DIR + config_dir.mkdir(parents=True) + dead = config_dir / "ucode-providers-dead.json" + dead.write_text("{}", encoding="utf-8") + dead.with_suffix(".lock").touch() + unlocked = config_dir / "ucode-providers-crashed-before-lock.json" + unlocked.write_text("{}", encoding="utf-8") + users_own = config_dir / "providers.json" + users_own.write_text("{}", encoding="utf-8") with copilot.session_registry({}) as live: - copilot.sweep_session_dirs() + copilot.sweep_session_registries() assert live.exists() assert not dead.exists() + assert not dead.with_suffix(".lock").exists() assert not unlocked.exists() + assert users_own.exists() class TestMcpServerConfig: @@ -522,7 +528,7 @@ def isolate_copilot_config_paths(monkeypatch, tmp_path): monkeypatch.setattr(copilot, "COPILOT_ENV_PATH", env_path) monkeypatch.setattr(copilot, "COPILOT_BACKUP_PATH", app_dir / "copilot-ucode-env.backup") monkeypatch.setattr(copilot, "COPILOT_MCP_CONFIG_PATH", tmp_path / "missing-mcp.json") - monkeypatch.setattr(copilot, "COPILOT_RUN_DIR", app_dir / "run") + monkeypatch.setattr(copilot, "COPILOT_CONFIG_DIR", env_path.parent) return env_path @@ -767,7 +773,7 @@ def test_unknown_model_fails_before_starting_copilot(self, tmp_path, monkeypatch copilot.launch(dict(STATE), ["--model", "nope"], options=copilot.LaunchOptions()) assert seen == {} - assert not copilot.COPILOT_RUN_DIR.exists() + assert not list(copilot.COPILOT_CONFIG_DIR.glob("ucode-providers-*")) def test_warns_about_managed_models_copilot_cannot_use(self, tmp_path, monkeypatch, capsys): isolate_copilot_config_paths(monkeypatch, tmp_path) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 09f20aa81..03353873f 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1095,7 +1095,7 @@ def test_launch_copilot_per_model( pytest.skip("No Copilot-compatible models available on this workspace") monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path) - monkeypatch.setattr(copilot, "COPILOT_RUN_DIR", tmp_path / "run") + monkeypatch.setattr(copilot, "COPILOT_CONFIG_DIR", tmp_path / ".copilot") catalog = copilot.build_copilot_catalog([model for _, model in models]) from ucode import gateway_proxy From 20908a80d7c24a054d0b06d218d556a016518010 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Tue, 20 Oct 2026 13:39:48 +0000 Subject: [PATCH 03/11] copilot: readable model names in the session registry Co-authored-by: Isaac --- src/ucode/agents/claude.py | 29 +++++------------------------ src/ucode/agents/copilot.py | 3 ++- src/ucode/databricks.py | 23 +++++++++++++++++++++++ tests/test_agent_copilot.py | 4 ++-- 4 files changed, 32 insertions(+), 27 deletions(-) diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index e288a6867..d5cbf4673 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -48,6 +48,7 @@ build_tool_base_url, get_databricks_token, maybe_add_gpt_1m_suffix, + model_display_label, ug_binary, ) from ucode.launcher import exec_or_spawn @@ -608,7 +609,7 @@ def render_overlay( overlay["modelPicker"] = { "replaceBuiltInOptions": True, "options": [ - {"model": maybe_add_gpt_1m_suffix(m), "label": _picker_label(m)} + {"model": maybe_add_gpt_1m_suffix(m), "label": model_display_label(m)} for m in static_models ], } @@ -619,7 +620,8 @@ def render_overlay( "options": [ _picker_option( maybe_add_gpt_1m_suffix(model), - picker_catalog.model_id_to_display_name.get(model) or _picker_label(model), + picker_catalog.model_id_to_display_name.get(model) + or model_display_label(model), picker_catalog.model_id_to_description.get(model), ) for model in picker_catalog.model_ids @@ -638,32 +640,11 @@ def render_overlay( return overlay, keys -_MODEL_LABEL_ACRONYMS = frozenset({"glm", "gpt"}) _CLAUDE_PICKER_LABEL_RE = re.compile( r"^Claude (Fable|Opus|Sonnet|Haiku) (\d+(?:\.\d+)*)$", re.IGNORECASE ) -def _picker_label(model: str) -> str: - """A human-friendly picker label for a model id (e.g. ``system.ai.claude-haiku-4-5`` -> - ``Claude Haiku 4.5``): keep the vendor and name words title-cased, uppercase known acronyms, - and join a run of numeric segments into a dotted version.""" - stem = model.removeprefix("system.ai.") - parts: list[str] = [] - version: list[str] = [] - for token in stem.split("-"): - if token.isdigit(): - version.append(token) - continue - if version: - parts.append(".".join(version)) - version = [] - parts.append(token.upper() if token in _MODEL_LABEL_ACRONYMS else token.title()) - if version: - parts.append(".".join(version)) - return " ".join(parts) if parts else stem - - def _picker_option(model: str, label: str, description: str | None = None) -> dict[str, str]: option = {"model": model, "label": label} if description: @@ -706,7 +687,7 @@ def default_model_picker_catalog( descriptions: dict[str, str] = {} for family, raw_model in defaults.items(): model = raw_model - label = _picker_label(model.removesuffix("[1m]")) + label = model_display_label(model.removesuffix("[1m]")) if provider is not None: # Family shortcuts stay distinct from catalog rows for the same target. model = family diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index 51138fcb0..236505e4e 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -49,6 +49,7 @@ ModelServiceEntry, build_copilot_base_url, build_tool_base_url, + model_display_label, model_version_sort_key, ) from ucode.os_compatibility import subprocess_cross_os @@ -448,7 +449,7 @@ def render_providers_config( "id": model.id, "provider": model.provider, "wireModel": model.id, - "name": model.id, + "name": model_display_label(model.id), } catalog_id = copilot_catalog_model_id(model.id) if model.wire == "anthropic" else None if catalog_id is not None: diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index f32b2c52a..44d446211 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -3531,6 +3531,29 @@ def fetch_ai_gateway_claude_models(workspace: str, token: str) -> dict[str, str] return models +_MODEL_LABEL_ACRONYMS = frozenset({"glm", "gpt"}) + + +def model_display_label(model: str) -> str: + """A human-friendly picker label for a model id (e.g. ``system.ai.claude-haiku-4-5`` -> + ``Claude Haiku 4.5``): keep the vendor and name words title-cased, uppercase known acronyms, + and join a run of numeric segments into a dotted version.""" + stem = model.removeprefix("system.ai.") + parts: list[str] = [] + version: list[str] = [] + for token in stem.split("-"): + if token.isdigit(): + version.append(token) + continue + if version: + parts.append(".".join(version)) + version = [] + parts.append(token.upper() if token in _MODEL_LABEL_ACRONYMS else token.title()) + if version: + parts.append(".".join(version)) + return " ".join(parts) if parts else stem + + def model_version_sort_key(name: str) -> tuple: """Sort endpoint names so newer model versions come first. diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index c101fa121..248f5135c 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -342,12 +342,12 @@ def test_models_carry_claude_settings_and_limits(self): "id": SONNET, "provider": "dbx-anthropic", "wireModel": SONNET, - "name": SONNET, + "name": "Claude Sonnet 5.5", "modelId": "claude-sonnet-5.5", "maxPromptTokens": 872_000, "maxOutputTokens": 128_000, }, - {"id": GPT, "provider": "dbx-responses", "wireModel": GPT, "name": GPT}, + {"id": GPT, "provider": "dbx-responses", "wireModel": GPT, "name": "GPT 5.4"}, ] From 75b76b64361de8ee2109fdc4e89bed26a1830a65 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Wed, 21 Oct 2026 11:33:44 +0000 Subject: [PATCH 04/11] copilot: move readable model names to a child PR Co-authored-by: Isaac --- src/ucode/agents/claude.py | 29 ++++++++++++++++++++++++----- src/ucode/agents/copilot.py | 3 +-- src/ucode/databricks.py | 23 ----------------------- tests/test_agent_copilot.py | 4 ++-- 4 files changed, 27 insertions(+), 32 deletions(-) diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index d5cbf4673..e288a6867 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -48,7 +48,6 @@ build_tool_base_url, get_databricks_token, maybe_add_gpt_1m_suffix, - model_display_label, ug_binary, ) from ucode.launcher import exec_or_spawn @@ -609,7 +608,7 @@ def render_overlay( overlay["modelPicker"] = { "replaceBuiltInOptions": True, "options": [ - {"model": maybe_add_gpt_1m_suffix(m), "label": model_display_label(m)} + {"model": maybe_add_gpt_1m_suffix(m), "label": _picker_label(m)} for m in static_models ], } @@ -620,8 +619,7 @@ def render_overlay( "options": [ _picker_option( maybe_add_gpt_1m_suffix(model), - picker_catalog.model_id_to_display_name.get(model) - or model_display_label(model), + picker_catalog.model_id_to_display_name.get(model) or _picker_label(model), picker_catalog.model_id_to_description.get(model), ) for model in picker_catalog.model_ids @@ -640,11 +638,32 @@ def render_overlay( return overlay, keys +_MODEL_LABEL_ACRONYMS = frozenset({"glm", "gpt"}) _CLAUDE_PICKER_LABEL_RE = re.compile( r"^Claude (Fable|Opus|Sonnet|Haiku) (\d+(?:\.\d+)*)$", re.IGNORECASE ) +def _picker_label(model: str) -> str: + """A human-friendly picker label for a model id (e.g. ``system.ai.claude-haiku-4-5`` -> + ``Claude Haiku 4.5``): keep the vendor and name words title-cased, uppercase known acronyms, + and join a run of numeric segments into a dotted version.""" + stem = model.removeprefix("system.ai.") + parts: list[str] = [] + version: list[str] = [] + for token in stem.split("-"): + if token.isdigit(): + version.append(token) + continue + if version: + parts.append(".".join(version)) + version = [] + parts.append(token.upper() if token in _MODEL_LABEL_ACRONYMS else token.title()) + if version: + parts.append(".".join(version)) + return " ".join(parts) if parts else stem + + def _picker_option(model: str, label: str, description: str | None = None) -> dict[str, str]: option = {"model": model, "label": label} if description: @@ -687,7 +706,7 @@ def default_model_picker_catalog( descriptions: dict[str, str] = {} for family, raw_model in defaults.items(): model = raw_model - label = model_display_label(model.removesuffix("[1m]")) + label = _picker_label(model.removesuffix("[1m]")) if provider is not None: # Family shortcuts stay distinct from catalog rows for the same target. model = family diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index 236505e4e..51138fcb0 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -49,7 +49,6 @@ ModelServiceEntry, build_copilot_base_url, build_tool_base_url, - model_display_label, model_version_sort_key, ) from ucode.os_compatibility import subprocess_cross_os @@ -449,7 +448,7 @@ def render_providers_config( "id": model.id, "provider": model.provider, "wireModel": model.id, - "name": model_display_label(model.id), + "name": model.id, } catalog_id = copilot_catalog_model_id(model.id) if model.wire == "anthropic" else None if catalog_id is not None: diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index 44d446211..f32b2c52a 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -3531,29 +3531,6 @@ def fetch_ai_gateway_claude_models(workspace: str, token: str) -> dict[str, str] return models -_MODEL_LABEL_ACRONYMS = frozenset({"glm", "gpt"}) - - -def model_display_label(model: str) -> str: - """A human-friendly picker label for a model id (e.g. ``system.ai.claude-haiku-4-5`` -> - ``Claude Haiku 4.5``): keep the vendor and name words title-cased, uppercase known acronyms, - and join a run of numeric segments into a dotted version.""" - stem = model.removeprefix("system.ai.") - parts: list[str] = [] - version: list[str] = [] - for token in stem.split("-"): - if token.isdigit(): - version.append(token) - continue - if version: - parts.append(".".join(version)) - version = [] - parts.append(token.upper() if token in _MODEL_LABEL_ACRONYMS else token.title()) - if version: - parts.append(".".join(version)) - return " ".join(parts) if parts else stem - - def model_version_sort_key(name: str) -> tuple: """Sort endpoint names so newer model versions come first. diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index 248f5135c..c101fa121 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -342,12 +342,12 @@ def test_models_carry_claude_settings_and_limits(self): "id": SONNET, "provider": "dbx-anthropic", "wireModel": SONNET, - "name": "Claude Sonnet 5.5", + "name": SONNET, "modelId": "claude-sonnet-5.5", "maxPromptTokens": 872_000, "maxOutputTokens": 128_000, }, - {"id": GPT, "provider": "dbx-responses", "wireModel": GPT, "name": "GPT 5.4"}, + {"id": GPT, "provider": "dbx-responses", "wireModel": GPT, "name": GPT}, ] From 518f364b5100babd6151303675fb1d6a776822a3 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Wed, 21 Oct 2026 13:28:07 +0000 Subject: [PATCH 05/11] copilot: fold the older-Claude journey into the Claude-then-GPT journey The e2e workspace lists claude-sonnet-4-5 but the CI principal gets 403 on it; claude-haiku-4-5 is already older than its family's newest there. Co-authored-by: Isaac --- tests/README.md | 2 +- tests/integration/README.md | 9 +++--- tests/integration/test_ug_copilot_models.py | 31 ++------------------- tests/integration/utils/constants.py | 2 -- 4 files changed, 8 insertions(+), 36 deletions(-) diff --git a/tests/README.md b/tests/README.md index 847e32142..cee5d4140 100644 --- a/tests/README.md +++ b/tests/README.md @@ -503,7 +503,7 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi | `test_ug_claude_headless_prompt_argument`, `test_ug_claude_headless_prompt_stdin`, `test_ug_claude_headless_prompt_after_separator` | Run Claude from a script using each prompt form | Structured final answer contains the file value; exit zero; no routing | | `test_ug_codex_headless_prompt_argument`, `test_ug_codex_headless_prompt_stdin`, `test_ug_codex_headless_prompt_after_separator` | Run Codex from a script using each prompt form | Completed turn and final answer contain the file value; exit zero; no routing | | `test_ug_copilot_headless_completes_file_task`, `test_ug_copilot_headless_gpt_responses_task` | Run Copilot CLI from a script (`-p ... --output-format json`) on a Claude model (Anthropic Messages) and a GPT model (Responses) | Completed turn whose assistant message contains the file value and was sent as the requested model; the session started on the matching registry provider; no file under the session home holds the Databricks credential; no session registry left (non-blocking CI lane) | -| `test_ug_copilot_one_setup_runs_claude_then_gpt_by_provider_ref`, `test_ug_copilot_runs_an_older_claude_from_the_full_catalog` | One configure, then headless tasks on a Claude model, a GPT model chosen as `dbx-responses/`, and an older Claude from the full catalog | Each answer contains its file value and was sent as the requested model; sessions started on `dbx-anthropic` and `dbx-responses` (non-blocking CI lane) | +| `test_ug_copilot_one_setup_runs_claude_then_gpt_by_provider_ref` | One configure, then headless tasks on a Claude model older than its family's newest (only the full catalog registers it) and a GPT model chosen as `dbx-responses/` | Each answer contains its file value and was sent as the requested model; sessions started on `dbx-anthropic` and `dbx-responses` (non-blocking CI lane) | | `test_ug_copilot_unknown_model_lists_available_models` | Interactive launch with a model the workspace doesn't have | Exit 1 before Copilot starts; stderr names the model and lists the available ones, without `claude-opus-4-1`; no session registry left (non-blocking CI lane) | | `test_ug_copilot_tui_switches_from_claude_to_gpt_with_model_command` | TUI: answer folder trust, Claude task, `/model dbx-responses/`, GPT task, `/exit` | Both answers sent as the Claude then GPT model; the session records the switch; normal exit; no session registry left (non-blocking CI lane) | | `test_ug_copilot_refreshes_a_revoked_token_mid_session`, `test_ug_copilot_reports_expired_sign_in` | Sign in through `DATABRICKS_BEARER_COMMAND` minting short-lived PATs; mid-task the agent revokes them (or also signs out) | Revoked: a token is minted after the revocation and the task completes without restarting Copilot. Signed out: output shows "Databricks sign-in expired" with `databricks auth login`; the failure is in `~/.ucode/logs/copilot-proxy.log`, not the terminal (non-blocking CI lane) | diff --git a/tests/integration/README.md b/tests/integration/README.md index ccb6aed20..980ff57a7 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -257,8 +257,9 @@ revoke them mid-task: once to require a refresh, once with sign-out to require t sign-in-expired message. It also covers configure/revert, including upgrading from ug v0.2.0, which saved the bearer. Refresh near natural expiry is covered by the hermetic proxy tests in `tests/test_gateway_proxy_integration.py`. `test_ug_copilot_models.py` covers the per-session model registry: one setup runs a -Claude task and then a GPT task chosen with Copilot's `dbx-responses/` form, an -older Claude runs from the full catalog, an unknown model fails before Copilot starts, +Claude task on a model older than its family's newest (only the full catalog registers it) +and then a GPT task chosen with Copilot's `dbx-responses/` form, an unknown model +fails before Copilot starts, and the TUI switches from Claude to GPT with `/model` mid-session. Model evidence comes from Copilot's own `~/.copilot/session-state/*/events.jsonl` (`utils/agents/copilot.py`). @@ -486,7 +487,7 @@ test_ug_claude_tracing.py # Claude OTLP export reaches the configu test_ug_codex_headless.py # script prompts and model arguments test_ug_copilot_headless.py # Copilot via the session proxy: Claude + GPT tasks, no token on disk test_ug_copilot_lifecycle.py # Copilot refresh after revocation, sign-in expired, configure/revert, upgrade -test_ug_copilot_models.py # Copilot model registry: Claude then GPT, older Claude, unknown model, TUI /model switch +test_ug_copilot_models.py # Copilot model registry: Claude then GPT, unknown model, TUI /model switch test_ug_codex_tracing.py # Codex OTLP export reaches the configured trace table test_ug_claude_commands.py # command help forwarding test_ug_codex_commands.py # command help and parser error forwarding @@ -907,7 +908,7 @@ A second non-blocking job, **Managed OpenCode self-managed** (`managed_fixture a in `test_ug_agents_self_managed.py`), installs Claude, Codex and OpenCode and runs against the managed e2e workspace (`E2E_ADMIN_WORKSPACE`) with the injected `managed_workspace_default` config, which enables Claude/Codex but not OpenCode. It is likewise outside `cujs`. -A non-blocking **Copilot** job (`live and copilot`, ten cases) runs on Linux and Windows the +A non-blocking **Copilot** job (`live and copilot`, nine cases) runs on Linux and Windows the same way and is likewise outside `cujs` until it is stable. Each lane installs only the agent CLIs it needs (one, except the managed OpenCode lane's diff --git a/tests/integration/test_ug_copilot_models.py b/tests/integration/test_ug_copilot_models.py index ef0145b74..0f5d52e8f 100644 --- a/tests/integration/test_ug_copilot_models.py +++ b/tests/integration/test_ug_copilot_models.py @@ -5,7 +5,6 @@ CLAUDE_TEST_MODEL, CODEX_TEST_MODEL, COPILOT_HEADLESS_FLAGS, - COPILOT_OLDER_CLAUDE_MODEL, ) from utils.evidence import ( FileTask, @@ -38,7 +37,8 @@ def test_ug_copilot_one_setup_runs_claude_then_gpt_by_provider_ref(live_session, Expected: both completed answers contain their fixture values and were sent as the requested models, the sessions started on `dbx-anthropic` then `dbx-responses`, and no session registry - is left afterwards. + is left afterwards. The Claude model is older than its family's newest, so only the full + discovered catalog registers it for Copilot. """ session = live_session claude_task = FileTask(session) @@ -75,33 +75,6 @@ def test_ug_copilot_one_setup_runs_claude_then_gpt_by_provider_ref(live_session, assert_no_session_registry_left(session) -def test_ug_copilot_runs_an_older_claude_from_the_full_catalog(live_session, workspace): - """Scenario: configure Copilot, then run a headless task on an older Claude than its family's - newest model. - - Expected: the completed answer contains the fixture value and was sent as that older model, - which only reaches Copilot through the full discovered catalog. Limit: needs the model on the - e2e workspace. - """ - session = live_session - task = FileTask(session) - _configure(session, workspace) - session.record("copilot-model.json", {"model": COPILOT_OLDER_CLAUDE_MODEL}) - - result = session.run( - "copilot", - "--model", - COPILOT_OLDER_CLAUDE_MODEL, - "-p", - task.prompt, - *COPILOT_HEADLESS_FLAGS, - timeout=180, - ) - - task.assert_headless_answer("copilot", result) - assert_completed_task_model(session, "copilot", task.value, COPILOT_OLDER_CLAUDE_MODEL) - - def test_ug_copilot_unknown_model_lists_available_models(live_session, workspace): """Scenario: configure Copilot, then launch it interactively with a model the workspace doesn't have. diff --git a/tests/integration/utils/constants.py b/tests/integration/utils/constants.py index 2acc51a9f..a3adbd04c 100644 --- a/tests/integration/utils/constants.py +++ b/tests/integration/utils/constants.py @@ -6,8 +6,6 @@ # nano sometimes nests powershell inside Codex's pwsh, breaking `$` expansion in its file reads. CODEX_TEST_MODEL = "system.ai.gpt-5-6-luna" if os.name == "nt" else "system.ai.gpt-5-4-nano" -# An older Claude than the family default: only the full catalog registers it for Copilot. -COPILOT_OLDER_CLAUDE_MODEL = "system.ai.claude-sonnet-4-5" COPILOT_HEADLESS_FLAGS = ( "--allow-all-tools", "--output-format", From 16f893eb53d23b3ec00852637b5df38b4c721e60 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Wed, 21 Oct 2026 14:45:26 +0000 Subject: [PATCH 06/11] copilot: keep each workspace's own model catalog; review fixes - A workspace switch no longer carries the source workspace's Copilot catalog when the destination's listing fails. - ug doctor counts the managed model list that launch registers. - Dotted model names keep their version punctuation when classified. - Copilot evidence parsing and -p stdout routing get unit coverage. Co-authored-by: Isaac --- src/ucode/agents/copilot.py | 3 ++- src/ucode/cli.py | 7 ++++++ src/ucode/doctor.py | 5 ++++ tests/test_agent_copilot.py | 24 ++++++++---------- tests/test_cli.py | 40 ++++++++++++++++++++++++++++-- tests/test_doctor.py | 20 +++++++++++++++ tests/test_integration_evidence.py | 38 +++++++++++++++++++++++++++- 7 files changed, 119 insertions(+), 18 deletions(-) diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index 51138fcb0..bf614ed29 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -139,7 +139,8 @@ def _model_tail(model: str) -> str: """The model name without its ``..`` and ``databricks-`` prefixes or ``[...]`` suffix.""" - tail = model.split("[", 1)[0].rsplit(".", 1)[-1].lower() + name = model.split("[", 1)[0].lower() + tail = name.split(".", 2)[-1] if name.count(".") >= 2 else name return tail.removeprefix("databricks-") diff --git a/src/ucode/cli.py b/src/ucode/cli.py index ffdbe487d..92e178915 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -552,6 +552,13 @@ def configure_shared_state( state[SELF_MANAGED_AGENTS_KEY] = dest_self_managed else: state.pop(SELF_MANAGED_AGENTS_KEY, None) + if previous_workspace != workspace: + dest_block = load_full_state().get("workspaces", {}).get(workspace, {}) + dest_catalog = dest_block.get(copilot_agent.CATALOG_STATE_KEY) + if dest_catalog: + state[copilot_agent.CATALOG_STATE_KEY] = dest_catalog + else: + state.pop(copilot_agent.CATALOG_STATE_KEY, None) if profile: state["profile"] = profile else: diff --git a/src/ucode/doctor.py b/src/ucode/doctor.py index c56566379..7867cf7f8 100644 --- a/src/ucode/doctor.py +++ b/src/ucode/doctor.py @@ -31,6 +31,8 @@ run_databricks_login, upgrade_databricks_cli, ) +from ucode.managed_config import load_managed_state +from ucode.managed_resolve import resolve_state from ucode.state import load_state from ucode.telemetry import ug_version from ucode.ui import ( @@ -190,6 +192,9 @@ def _check_copilot_models() -> Check | None: state = load_state() if "copilot" not in (state.get("available_tools") or []): return None + managed = load_managed_state(state.get("workspace")) + if managed: + state = resolve_state(managed, state, "copilot") catalog = copilot.copilot_catalog(state) if not catalog: return Check( diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index c101fa121..3504a4347 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -48,6 +48,9 @@ class TestClassifyCopilotModel: ("system.ai.gpt-6-1-sol", "responses"), ("databricks-gpt-5-3-codex", "responses"), ("system.ai.gpt-4-1", "completions"), + ("system.ai.gpt-6.1-sol", "responses"), + ("gpt-5.4", "responses"), + ("claude-sonnet-4.5", "anthropic"), ], ) def test_names_decide_the_wire(self, model, wire): @@ -703,15 +706,12 @@ def popen(argv, *, env): seen["registry_path"] = registry_path seen["registry"] = json.loads(open(registry_path, encoding="utf-8").read()) provider = seen["registry"]["providers"][0] - seen["accepted"] = { - secret: httpx.post( - f"{provider['baseUrl']}/v1/messages", - headers={"Authorization": f"Bearer {secret}"}, - content=b"{}", - timeout=5, - ).status_code - for secret in ("wrong-secret",) - } + seen["wrong_secret_status"] = httpx.post( + f"{provider['baseUrl']}/v1/messages", + headers={"Authorization": "Bearer wrong-secret"}, + content=b"{}", + timeout=5, + ).status_code return process or _ExitedProcess() monkeypatch.setattr(copilot.subprocess_cross_os, "popen", popen) @@ -739,7 +739,7 @@ def test_copilot_gets_the_registry_and_never_the_token(self, tmp_path, monkeypat assert secret not in seen["env"].values() assert "dbx-token" not in json.dumps(registry) # The proxy is live while Copilot runs and refuses other credentials. - assert seen["accepted"] == {"wrong-secret": 401} + assert seen["wrong_secret_status"] == 401 assert not os.path.exists(seen["registry_path"]) def test_explicit_model_is_rewritten_to_the_registry_ref(self, tmp_path, monkeypatch): @@ -838,10 +838,6 @@ class TestManagedKeys: def test_only_the_model_and_offline_mode(self): assert copilot.MANAGED_KEYS == ["COPILOT_MODEL", "COPILOT_OFFLINE"] - def test_excludes_credentials(self): - for key in copilot.CREDENTIAL_ENV_KEYS: - assert key not in copilot.MANAGED_KEYS - class TestMinimumVersion: @pytest.mark.parametrize( diff --git a/tests/test_cli.py b/tests/test_cli.py index b24eec176..95cbda27d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5958,15 +5958,19 @@ class TestConfigureSharedStateCopilotCatalog: WS = "https://copilot.databricks.com" - def _configure(self, monkeypatch, *, listed, saved=None): + def _configure(self, monkeypatch, *, listed, saved=None, previous_ws=None, dest_saved=None): import ucode.cli as cli_mod import ucode.databricks as db_mod TestConfigureSharedStateSkipDiscovery._stub(monkeypatch) - previous = {"workspace": self.WS} + previous = {"workspace": previous_ws or self.WS} if saved is not None: previous["copilot_catalog"] = saved monkeypatch.setattr(cli_mod, "load_state", lambda: dict(previous)) + dest_block = {} if dest_saved is None else {"copilot_catalog": dest_saved} + monkeypatch.setattr( + cli_mod, "load_full_state", lambda: {"workspaces": {self.WS: dest_block}} + ) if listed is not None: db_mod._MODEL_SERVICES_CACHE[self.WS] = [db_mod.ModelServiceEntry(m) for m in listed] reason = None if listed else "HTTP 503" @@ -6002,6 +6006,33 @@ def test_failed_listing_keeps_the_saved_catalog(self, monkeypatch, capsys): assert state["_discovery_reasons"]["copilot"] == "HTTP 503" assert "Copilot model discovery failed (HTTP 503)" in capsys.readouterr().out + def test_failed_listing_after_a_workspace_switch_drops_the_other_workspace_catalog( + self, monkeypatch + ): + saved = {"models": ["system.ai.gpt-5-4"], "discovered_at": "2026-10-01T00:00:00+00:00"} + + state = self._configure( + monkeypatch, listed=None, saved=saved, previous_ws="https://other.databricks.com" + ) + + assert "copilot_catalog" not in state + + def test_failed_listing_after_a_workspace_switch_keeps_that_workspace_catalog( + self, monkeypatch + ): + other = {"models": ["system.ai.gpt-5-4"], "discovered_at": "2026-10-01T00:00:00+00:00"} + own = {"models": ["system.ai.gpt-5-5"], "discovered_at": "2026-10-02T00:00:00+00:00"} + + state = self._configure( + monkeypatch, + listed=None, + saved=other, + previous_ws="https://other.databricks.com", + dest_saved=own, + ) + + assert state["copilot_catalog"] == own + class TestConfigureSharedStateSkipPreflight: """With skip_preflight (--skip-preflight), a prior configure is trusted: @@ -6674,6 +6705,8 @@ def isolation(*args, **kwargs): ("tool", "tool_args", "display"), [ ("claude", ["-p", "say hello", "--output-format", "json"], "Claude Code"), + ("copilot", ["-p", "say hello", "--output-format", "json"], "GitHub Copilot CLI"), + ("copilot", ["--prompt=say hello"], "GitHub Copilot CLI"), ( "claude", ["--output-format", "stream-json", "--verbose", "--print", "say hello"], @@ -6753,6 +6786,7 @@ def bootstrap_status(*_args, **_kwargs): [ ("claude", ["-p", "hello"]), ("claude", ["--print", "hello"]), + ("copilot", ["-p", "hello"]), ("codex", ["app-server"]), ("codex", ["exec", "hello"]), ("codex", ["exec", "--json", "hello"]), @@ -6786,6 +6820,8 @@ def fail_bootstrap(*_args, **_kwargs): [ ("claude", [], "Claude Code"), ("claude", ["explain --print and -p"], "Claude Code"), + ("copilot", [], "GitHub Copilot CLI"), + ("copilot", ["--", "-p"], "GitHub Copilot CLI"), ("claude", ["--", "-p"], "Claude Code"), ("codex", [], "Codex"), ("codex", ["explain exec and e"], "Codex"), diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 31e68fc39..23388c6aa 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -160,6 +160,26 @@ def test_counts_models_per_provider(self): assert check.status == "ok" assert check.detail == "2 models (1 dbx-anthropic, 1 dbx-responses)" + def test_counts_the_managed_list_launch_registers(self): + state = { + "workspace": "https://ws.databricks.com", + "available_tools": ["copilot"], + "copilot_catalog": {"models": ["system.ai.claude-sonnet-4-6", "system.ai.gpt-5-4"]}, + } + managed = { + "enabled_agents": { + "copilot": {"model_config": {"model_services": ["system.ai.gpt-5-4-mini"]}} + } + } + with ( + patch.object(doctor_mod, "load_state", return_value=state), + patch.object(doctor_mod, "load_managed_state", return_value=managed), + ): + check = doctor_mod._check_copilot_models() + + assert check is not None + assert check.detail == "1 models (1 dbx-responses)" + def test_warns_when_nothing_is_usable(self): state = {"available_tools": ["copilot"], "copilot_catalog": {"models": []}} with patch.object(doctor_mod, "load_state", return_value=state): diff --git a/tests/test_integration_evidence.py b/tests/test_integration_evidence.py index 95e9d2def..58db49f42 100644 --- a/tests/test_integration_evidence.py +++ b/tests/test_integration_evidence.py @@ -5,7 +5,7 @@ import pytest from tests.integration.utils import evidence -from tests.integration.utils.agents import claude, codex +from tests.integration.utils.agents import claude, codex, copilot from tests.integration.utils.evidence import ( FileTask, SubagentCalculation, @@ -155,6 +155,42 @@ def test_codex_model_identity_rejects_prompt_only_evidence(): assert codex.completed_task_models(records, "withheld-file-value") == set() +def test_copilot_model_identity_uses_only_answering_assistant_messages(): + records = [ + {"type": "user.message", "data": {"content": "withheld-file-value"}}, + { + "type": "assistant.message", + "data": {"content": "Reading the file.", "model": "system.ai.claude-haiku-4-5"}, + }, + { + "type": "assistant.message", + "data": {"content": "withheld-file-value", "model": "system.ai.gpt-5-4-nano"}, + }, + {"type": "assistant.message"}, + {"type": "assistant.message", "data": {"content": None, "model": "system.ai.gpt-5"}}, + ] + assert copilot.completed_task_models(records, "withheld-file-value") == { + "system.ai.gpt-5-4-nano" + } + assert copilot.assistant_answers(records) == ["Reading the file.", "withheld-file-value"] + + +def test_copilot_model_identity_rejects_an_answer_without_a_model(): + records = [{"type": "assistant.message", "data": {"content": "withheld-file-value"}}] + with pytest.raises(AssertionError, match="completed-task model"): + copilot.completed_task_models(records, "withheld-file-value") + + +def test_copilot_selected_models_follow_start_then_switches(): + records = [ + {"type": "session.start", "data": {"selectedModel": "dbx-anthropic/claude"}}, + {"type": "session.model_change", "data": {"newModel": "dbx-responses/gpt"}}, + {"type": "session.model_change", "data": {}}, + {"type": "assistant.message", "data": {"model": "system.ai.gpt"}}, + ] + assert copilot.selected_models(records) == ["dbx-anthropic/claude", "dbx-responses/gpt"] + + def test_claude_model_identity_uses_assistant_answer_not_tool_output(): records = [ { From bfe89e487b96145a85e088fba0f03da2560422cd Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Wed, 21 Oct 2026 21:04:44 +0000 Subject: [PATCH 07/11] copilot: name the session registry files ug-providers-* Co-authored-by: Isaac --- README.md | 2 +- src/ucode/agents/copilot.py | 8 ++++---- tests/integration/utils/evidence.py | 2 +- tests/test_agent_copilot.py | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1c0bfb57d..bfcb16f0b 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ global choice is saved for plain `copilot` too, where the Databricks models don' exist. Requires Copilot CLI 1.0.91 or newer. `ug copilot` runs Copilot against a loopback proxy for the session. Copilot holds only -a random session secret, kept in the session's `~/.copilot/ucode-providers-.json` +a random session secret, kept in the session's `~/.copilot/ug-providers-.json` registry (readable only by you, deleted when the session ends); the proxy adds a fresh Databricks token to each gateway request, so OAuth sessions keep working past token expiry without a restart, and no Databricks token is written to disk. If the Databricks sign-in itself expires, diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index bf614ed29..ac566ce8b 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -1,7 +1,7 @@ """GitHub Copilot CLI agent: runs Copilot against a per-session Databricks model registry. Copilot reads its BYOK models from a providers.json registry (`COPILOT_PROVIDERS_CONFIG`). Each -`ug copilot` launch writes one (~/.copilot/ucode-providers-.json) listing every Databricks +`ug copilot` launch writes one (~/.copilot/ug-providers-.json) listing every Databricks model Copilot can use, so `/model` switches between them mid-session, across wire APIs. Each family keeps its native wire: Claude on the gateway's Anthropic endpoint (with Copilot's catalog id as `modelId`, so @@ -69,8 +69,8 @@ COPILOT_BACKUP_PATH = APP_DIR / "copilot-ucode-env.backup" COPILOT_MCP_BACKUP_PATH = APP_DIR / "copilot-ucode-mcp-config.backup.json" COPILOT_PROXY_LOG_PATH = APP_DIR / "logs" / "copilot-proxy.log" -_REGISTRY_PREFIX = "ucode-providers-" -_REGISTRY_LOCK_NAME = "ucode-providers.lock" +_REGISTRY_PREFIX = "ug-providers-" +_REGISTRY_LOCK_NAME = "ug-providers.lock" MINIMUM_COPILOT_VERSION = (1, 0, 91) @@ -554,7 +554,7 @@ def minimum_version_error() -> str | None: return None required = ".".join(str(part) for part in MINIMUM_COPILOT_VERSION) return ( - f"GitHub Copilot CLI {installed} is too old. ucode requires {required} or newer for " + f"GitHub Copilot CLI {installed} is too old. ug requires {required} or newer for " f"Databricks model switching. Update it with `npm install -g {SPEC['package']}`." ) diff --git a/tests/integration/utils/evidence.py b/tests/integration/utils/evidence.py index 5881349d9..b0d807f04 100644 --- a/tests/integration/utils/evidence.py +++ b/tests/integration/utils/evidence.py @@ -158,7 +158,7 @@ def copilot_selected_models(session) -> list[str]: def assert_no_session_registry_left(session) -> None: """`ug copilot` removes its per-session registry, which holds the proxy secret, on exit.""" - leftovers = sorted(session.home.glob(".copilot/ucode-providers-*")) + leftovers = sorted(session.home.glob(".copilot/ug-providers-*")) assert not leftovers, f"Session registries left behind: {leftovers}" diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index 3504a4347..86763f141 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -386,7 +386,7 @@ def test_writes_a_private_registry_removed_at_exit(self, tmp_path, monkeypatch): with copilot.session_registry({"models": ["x"]}) as path: assert json.loads(path.read_text(encoding="utf-8")) == {"models": ["x"]} assert path.parent == copilot.COPILOT_CONFIG_DIR - assert path.name.startswith("ucode-providers-") + assert path.name.startswith("ug-providers-") if os.name != "nt": assert path.stat().st_mode & 0o777 == 0o600 @@ -405,10 +405,10 @@ def test_sweep_keeps_live_sessions_and_removes_dead_ones(self, tmp_path, monkeyp isolate_copilot_config_paths(monkeypatch, tmp_path) config_dir = copilot.COPILOT_CONFIG_DIR config_dir.mkdir(parents=True) - dead = config_dir / "ucode-providers-dead.json" + dead = config_dir / "ug-providers-dead.json" dead.write_text("{}", encoding="utf-8") dead.with_suffix(".lock").touch() - unlocked = config_dir / "ucode-providers-crashed-before-lock.json" + unlocked = config_dir / "ug-providers-crashed-before-lock.json" unlocked.write_text("{}", encoding="utf-8") users_own = config_dir / "providers.json" users_own.write_text("{}", encoding="utf-8") @@ -773,7 +773,7 @@ def test_unknown_model_fails_before_starting_copilot(self, tmp_path, monkeypatch copilot.launch(dict(STATE), ["--model", "nope"], options=copilot.LaunchOptions()) assert seen == {} - assert not list(copilot.COPILOT_CONFIG_DIR.glob("ucode-providers-*")) + assert not list(copilot.COPILOT_CONFIG_DIR.glob("ug-providers-*")) def test_warns_about_managed_models_copilot_cannot_use(self, tmp_path, monkeypatch, capsys): isolate_copilot_config_paths(monkeypatch, tmp_path) From 61475aa117b6cd825253e8fda98c9cec7ca1d725 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Wed, 21 Oct 2026 21:40:06 +0000 Subject: [PATCH 08/11] copilot: deflake the Windows shell step and GPT file reads in integration journeys Copilot's shell tool tries pwsh first; with only Windows PowerShell on the test PATH its 40s startup probe can time out, leaving Copilot without a shell, so the revoke/sign-out step never runs. Expose pwsh to Copilot lanes on Windows. gpt-5-4-nano sometimes drops the path segment after the space in 'project with spaces' when it rebuilds the file's absolute path. Give the GPT journeys the full path. Co-authored-by: Isaac --- scripts/run_integration.py | 5 +++++ tests/integration/test_ug_copilot_headless.py | 2 +- tests/integration/test_ug_copilot_lifecycle.py | 4 ++-- tests/integration/test_ug_copilot_models.py | 4 ++-- tests/integration/utils/evidence.py | 5 +++++ 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/scripts/run_integration.py b/scripts/run_integration.py index b41a33cb8..20b7cd2f9 100644 --- a/scripts/run_integration.py +++ b/scripts/run_integration.py @@ -759,6 +759,11 @@ def run(command, *, cwd=output, env=base_env, timeout=600) -> str: runtime_paths = [runtime_bin, agent_bin, tool_bin] if os.name == "nt": system_root = Path(base_env["SYSTEMROOT"]) + # Copilot's shell tool tries pwsh first; Windows PowerShell alone can miss its 40s + # startup probe on a cold runner and leave Copilot without a shell. + pwsh = shutil.which("pwsh", path=base_env.get("PATH")) + if "copilot" in agents and pwsh: + runtime_paths.append(Path(pwsh).parent) runtime_paths.extend( [ system_root / "System32", diff --git a/tests/integration/test_ug_copilot_headless.py b/tests/integration/test_ug_copilot_headless.py index 9fb0de7be..64d0f2c13 100644 --- a/tests/integration/test_ug_copilot_headless.py +++ b/tests/integration/test_ug_copilot_headless.py @@ -79,7 +79,7 @@ def test_ug_copilot_headless_gpt_responses_task(live_session, workspace): "--model", CODEX_TEST_MODEL, "-p", - task.prompt, + task.prompt_with_path, *COPILOT_HEADLESS_FLAGS, timeout=180, ) diff --git a/tests/integration/test_ug_copilot_lifecycle.py b/tests/integration/test_ug_copilot_lifecycle.py index 5781578b8..5d30cdff3 100644 --- a/tests/integration/test_ug_copilot_lifecycle.py +++ b/tests/integration/test_ug_copilot_lifecycle.py @@ -205,7 +205,7 @@ def test_ug_configure_copilot_repeat_and_revert(live_session, workspace): "--model", CODEX_TEST_MODEL, "-p", - task.prompt, + task.prompt_with_path, *COPILOT_HEADLESS_FLAGS, timeout=180, ) @@ -261,7 +261,7 @@ def test_ug_copilot_upgrade_scrubs_token_saved_by_older_ug(live_session, workspa "--model", CODEX_TEST_MODEL, "-p", - task.prompt, + task.prompt_with_path, *COPILOT_HEADLESS_FLAGS, timeout=180, ) diff --git a/tests/integration/test_ug_copilot_models.py b/tests/integration/test_ug_copilot_models.py index 0f5d52e8f..e17fe9c83 100644 --- a/tests/integration/test_ug_copilot_models.py +++ b/tests/integration/test_ug_copilot_models.py @@ -60,7 +60,7 @@ def test_ug_copilot_one_setup_runs_claude_then_gpt_by_provider_ref(live_session, "--model", f"dbx-responses/{CODEX_TEST_MODEL}", "-p", - gpt_task.prompt, + gpt_task.prompt_with_path, *COPILOT_HEADLESS_FLAGS, timeout=180, ) @@ -128,7 +128,7 @@ def test_ug_copilot_tui_switches_from_claude_to_gpt_with_model_command(live_sess tui.wait_for_task(claude_task) tui.submit(f"/model {gpt_ref}") tui.wait_for(lambda screen: "Model changed" in screen, "the model switch notice") - tui.submit(gpt_task.prompt) + tui.submit(gpt_task.prompt_with_path) tui.wait_for_task(gpt_task) tui.exit_normally() diff --git a/tests/integration/utils/evidence.py b/tests/integration/utils/evidence.py index b0d807f04..ab7adb3ff 100644 --- a/tests/integration/utils/evidence.py +++ b/tests/integration/utils/evidence.py @@ -184,6 +184,11 @@ def __init__(self, session): f"Read the local file {self.filename} in the current directory using a tool. " "Reply with only its contents." ) + # Small models can drop path segments after a space when rebuilding the absolute path. + self.prompt_with_path = ( + f"Read the local file {session.cwd / self.filename} using a tool. " + "Reply with only its contents." + ) self.delegate_prompt = ( f"Delegate this task to one subagent: read the local file {self.filename} in the " "current directory using a tool and return its contents. Do not read the file " From 2812a5443a2274d132070d0ea0741f1b8abc3d7b Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Wed, 21 Oct 2026 21:41:00 +0000 Subject: [PATCH 09/11] copilot: only expose a dedicated pwsh install directory on the Windows test PATH Co-authored-by: Isaac --- scripts/run_integration.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/run_integration.py b/scripts/run_integration.py index 20b7cd2f9..9bcea210c 100644 --- a/scripts/run_integration.py +++ b/scripts/run_integration.py @@ -760,10 +760,12 @@ def run(command, *, cwd=output, env=base_env, timeout=600) -> str: if os.name == "nt": system_root = Path(base_env["SYSTEMROOT"]) # Copilot's shell tool tries pwsh first; Windows PowerShell alone can miss its 40s - # startup probe on a cold runner and leave Copilot without a shell. + # startup probe. pwsh loads pwsh.dll beside it, so it can't go in tool_bin; expose + # only a dedicated install dir, never a shared alias dir like WindowsApps. pwsh = shutil.which("pwsh", path=base_env.get("PATH")) - if "copilot" in agents and pwsh: - runtime_paths.append(Path(pwsh).parent) + pwsh_dir = Path(pwsh).resolve().parent if pwsh else None + if "copilot" in agents and pwsh_dir and (pwsh_dir / "pwsh.dll").is_file(): + runtime_paths.append(pwsh_dir) runtime_paths.extend( [ system_root / "System32", From 04c98e2aa55d72deb268f3069331afc1ea1f189c Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Wed, 21 Oct 2026 21:55:53 +0000 Subject: [PATCH 10/11] copilot: review fixes for managed models, wire-model override, revert and discovery - A managed model_config.models list picks the start model again, as before. - An unservable managed entry or a repeated candidate no longer warns twice. - COPILOT_PROVIDER_WIRE_MODEL still sets the start model's served name. - A failed Copilot session-file sweep warns instead of stopping ug revert. - Copilot skips discovery when a provider, parent schema or managed list names its models. - Drop the unreachable copilot status fallback. Co-authored-by: Isaac --- src/ucode/agents/copilot.py | 20 ++++++++++++----- src/ucode/cli.py | 12 +++++++--- tests/test_agent_copilot.py | 29 ++++++++++++++++++++++++ tests/test_cli.py | 45 +++++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 8 deletions(-) diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index ac566ce8b..fe6152e42 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -362,12 +362,17 @@ def launch_model( ) -> CopilotModel: """The model a session starts on: ``requested`` (budget recommendation or managed default), then the managed default and list, then the newest Sonnet, Opus or Haiku, then GPT.""" - static = _string_list(state.get("copilot_static_models")) - chosen = _first_available( - [requested, state.get("copilot_default_model"), static[0] if static else None], - catalog, - warn=warn, + servable_static = ( + m for m in _string_list(state.get("copilot_static_models")) if classify_copilot_model(m) ) + managed_models = _string_list(state.get("copilot_models")) + candidates = [ + requested, + state.get("copilot_default_model"), + next(servable_static, None), + managed_models[0] if managed_models else None, + ] + chosen = _first_available(dict.fromkeys(c for c in candidates if c), catalog, warn=warn) if chosen is not None: return chosen for prefix in ("claude-sonnet-", "claude-opus-", "claude-haiku-"): @@ -713,6 +718,11 @@ def launch(state: dict, tool_args: list[str], *, options: LaunchOptions) -> None try: proxy_base_url = f"http://{LOOPBACK_HOST}:{server.server_address[1]}" config = render_providers_config(catalog, proxy_base_url, session_secret) + wire_override = os.environ.get("COPILOT_PROVIDER_WIRE_MODEL") + if wire_override: + for entry in config["models"]: + if entry["id"] == start_model.id: + entry["wireModel"] = wire_override with session_registry(config) as registry_path: env = build_runtime_env(registry_path, start_model) proc = subprocess_cross_os.popen( diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 92e178915..493192967 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -110,6 +110,7 @@ managed_launch_model, managed_provider_family_models, managed_provider_service, + managed_static_models, managed_supplies_models, managed_unity_catalog_location, managed_unservable_models, @@ -1257,7 +1258,7 @@ def _status_default_model(tool: str, state: dict, models: list[str]) -> str | No return explicit # Claude and Codex deliberately leave the starting model to the agent unless a managed # config pins one. The other clients write the first resolved model into their ug config. - return models[0] if models and tool in ("gemini", "opencode", "kilo", "copilot", "pi") else None + return models[0] if models and tool in ("gemini", "opencode", "kilo", "pi") else None def _live_status_model_state(state: dict, tools: set[str]) -> tuple[dict, str]: @@ -1454,7 +1455,10 @@ def revert() -> int: mcp_results = revert_mcp_configs(state) # Before restoring backups: one written by an earlier ug may hold a token. copilot_agent.scrub_stored_credentials() - copilot_agent.sweep_session_registries() + try: + copilot_agent.sweep_session_registries() + except OSError as exc: + print_warning(f"Could not remove leftover Copilot session files: {exc}") claude_managed_result = claude_agent.revert_managed_settings() codex_managed_result = codex_agent.revert_managed_config() @@ -3149,7 +3153,9 @@ def _skip_model_discovery( """ if tool == "copilot": # Copilot registers its discovered catalog, so a managed default alone still needs it. - return False + return bool( + provider or managed_parent_schema or (managed and managed_static_models(managed, tool)) + ) return ( bool(provider) or bool(managed_parent_schema) diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index 86763f141..db51a98ed 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -243,6 +243,22 @@ def test_an_unavailable_requested_model_warns_and_falls_through(self, capsys): def test_default_model_is_quiet(self, capsys): assert copilot.default_model({"copilot_default_model": "missing"}) is None + + def test_a_managed_models_list_picks_the_start_model(self): + assert copilot.launch_model({"copilot_models": [GPT]}, self.MODELS).id == GPT + + def test_an_unservable_managed_entry_is_skipped_without_a_second_warning(self, capsys): + state = {"copilot_static_models": ["system.ai.gemini-3-flash", GPT]} + + assert copilot.launch_model(state, catalog(GPT)).id == GPT + assert "isn't available" not in capsys.readouterr().out + + def test_a_repeated_candidate_warns_once(self, capsys): + state = {"copilot_default_model": "system.ai.missing"} + + copilot.launch_model(state, self.MODELS, requested="system.ai.missing") + + assert capsys.readouterr().out.count("isn't available") == 1 state = {"copilot_default_model": "missing", "codex_models": [GPT]} assert copilot.default_model(state) == GPT assert capsys.readouterr().out == "" @@ -765,6 +781,19 @@ def test_starts_on_the_requested_launch_model(self, tmp_path, monkeypatch): assert seen["env"]["COPILOT_MODEL"] == f"dbx-responses/{GPT}" + def test_a_wire_model_override_applies_to_the_start_model(self, tmp_path, monkeypatch): + isolate_copilot_config_paths(monkeypatch, tmp_path) + monkeypatch.setenv("COPILOT_PROVIDER_WIRE_MODEL", "main.models.my-gpt") + seen = _stub_launch(monkeypatch) + + with pytest.raises(SystemExit): + copilot.launch(dict(STATE), [], options=copilot.LaunchOptions(launch_model=GPT)) + + wire_models = {m["id"]: m["wireModel"] for m in seen["registry"]["models"]} + assert wire_models[GPT] == "main.models.my-gpt" + assert wire_models[SONNET] == SONNET + assert "COPILOT_PROVIDER_WIRE_MODEL" not in seen["env"] + def test_unknown_model_fails_before_starting_copilot(self, tmp_path, monkeypatch): isolate_copilot_config_paths(monkeypatch, tmp_path) seen = _stub_launch(monkeypatch) diff --git a/tests/test_cli.py b/tests/test_cli.py index 95cbda27d..85b8d47b0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3407,6 +3407,26 @@ def test_invalid_routing_config_does_not_block_revert(self, monkeypatch): revert.assert_called_once_with() assert dict(os.environ) == previous + def test_a_failed_copilot_session_sweep_does_not_stop_revert(self): + cleared: list[bool] = [] + + with ( + patch("ucode.cli.load_state", return_value=dict(MINIMAL_STATE)), + patch("ucode.cli.restore_file", return_value=False), + patch("ucode.cli.revert_mcp_configs", return_value={}), + patch.object( + cli_mod.copilot_agent, + "sweep_session_registries", + side_effect=PermissionError("read-only"), + ), + patch("ucode.cli.clear_state", side_effect=lambda: cleared.append(True)), + ): + result = runner.invoke(app, ["revert"]) + + assert result.exit_code == 0, result.output + assert "Could not remove leftover Copilot session files" in _strip_ansi(result.output) + assert cleared == [True] + def test_reverts_mcp_configs_before_clearing_state(self): state = { **MINIMAL_STATE, @@ -3526,6 +3546,31 @@ def test_a_managed_default_still_discovers_the_catalog(self): self.MANAGED, "copilot", provider=None, managed_parent_schema=None, family_defaults=None ) + @pytest.mark.parametrize( + ("managed", "provider", "parent_schema"), + [ + (None, "main.default.mps", None), + (None, None, "main.models"), + ( + { + "enabled_agents": { + "copilot": {"model_config": {"model_services": ["system.ai.gpt-5-4"]}} + } + }, + None, + None, + ), + ], + ) + def test_a_named_model_source_skips_discovery(self, managed, provider, parent_schema): + assert cli_mod._skip_model_discovery( + managed, + "copilot", + provider=provider, + managed_parent_schema=parent_schema, + family_defaults=None, + ) + class TestOpenCodeModelFlag: @pytest.mark.parametrize("flag", ["--model", "-m"]) From fe24b4f7810a1f7a800100d494218f611316ee3b Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Thu, 22 Oct 2026 06:46:48 +0000 Subject: [PATCH 11/11] copilot: keep gateway-listed families, accept gpt-4o, clean up sessions under the mutex - A UC listing without Claude or GPT models no longer discards the models the gateway listings found for that family, so a Gemini-only listing doesn't leave Copilot with an empty catalog. - gpt-4o and gpt-4o-mini classify as Completions models again. - Session cleanup holds the registries mutex, so a concurrent sweep can't reopen the lock file before it is deleted (a PermissionError on Windows). Co-authored-by: Isaac --- src/ucode/agents/copilot.py | 25 +++++++++++++-------- tests/test_agent_copilot.py | 43 +++++++++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index fe6152e42..3bf3c254b 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -111,7 +111,7 @@ "claude-opus-4-1": "the gateway rejects the thinking settings Copilot sends for it", } _GATEWAY_ALIAS_PREFIX = "anthropic-aigw-" -_GPT_MODEL_MAJOR_PATTERN = re.compile(r"^gpt-(\d+)(?=$|[.-])") +_GPT_MODEL_MAJOR_PATTERN = re.compile(r"^gpt-(\d+)o?(?=$|[.-])") _CLAUDE_DASHED_VERSION_PATTERN = re.compile(r"-(\d+)-(\d+)$") _CLAUDE_HAIKU_MAJOR_PATTERN = re.compile(r"^claude-haiku-(\d+)") _CLAUDE_CATALOG_VERSION_PATTERN = re.compile(r"^claude-([a-z]+)-(\d+)(?:\.(\d+))?$") @@ -259,10 +259,14 @@ def catalog_from_listings( the per-family gateway listings found instead. None when neither answered, so the caller keeps the saved catalog; an empty catalog when they answered with nothing Copilot can use.""" listed = list(entries or ()) - if listed: - return build_copilot_catalog(listed) - fallback_ids = list(fallback) - return build_copilot_catalog(fallback_ids) if fallback_ids else None + fallback_catalog = build_copilot_catalog(list(fallback)) + if not listed: + return fallback_catalog or None + catalog = build_copilot_catalog(listed) + # A family the UC listing lacks keeps the models its gateway listing found. + families = {model.wire == "anthropic" for model in catalog} + missing = [model for model in fallback_catalog if (model.wire == "anthropic") not in families] + return sorted([*catalog, *missing], key=_catalog_sort_key) def catalog_state(models: list[CopilotModel]) -> dict: @@ -538,10 +542,13 @@ def session_registry(config: dict) -> Iterator[Path]: try: yield path finally: - release_file_lock(lock_file) - lock_file.close() - path.unlink(missing_ok=True) - lock_path.unlink(missing_ok=True) + # Under the mutex, so a concurrent sweep can't reopen the lock file before it's deleted + # (Windows refuses to delete an open file). + with _registries_lock(): + release_file_lock(lock_file) + lock_file.close() + path.unlink(missing_ok=True) + lock_path.unlink(missing_ok=True) def _parse_version(value: str) -> tuple[int, int, int] | None: diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index db51a98ed..f7da66ed9 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -2,8 +2,10 @@ from __future__ import annotations +import contextlib import json import os +from pathlib import Path import httpx import pytest @@ -49,6 +51,8 @@ class TestClassifyCopilotModel: ("databricks-gpt-5-3-codex", "responses"), ("system.ai.gpt-4-1", "completions"), ("system.ai.gpt-6.1-sol", "responses"), + ("databricks-gpt-4o", "completions"), + ("system.ai.gpt-4o-mini", "completions"), ("gpt-5.4", "responses"), ("claude-sonnet-4.5", "anthropic"), ], @@ -117,9 +121,16 @@ def test_uses_the_listing_routes_and_drops_duplicates(self): class TestCatalogFromListings: - def test_prefers_the_model_services_listing(self): + def test_prefers_the_model_services_listing_per_family(self): entries = [ModelServiceEntry(GPT), ModelServiceEntry("system.ai.gemini-3-flash")] - assert copilot.catalog_from_listings(entries, [SONNET]) == catalog(GPT) + fallback = [SONNET, "databricks-gpt-5-5"] + + assert copilot.catalog_from_listings(entries, fallback) == catalog(SONNET, GPT) + + def test_a_listing_with_only_unusable_models_keeps_the_gateway_families(self): + entries = [ModelServiceEntry("system.ai.gemini-3-flash")] + + assert copilot.catalog_from_listings(entries, [SONNET, GPT]) == catalog(SONNET, GPT) def test_falls_back_to_the_gateway_listings(self): assert copilot.catalog_from_listings(None, ["databricks-claude-sonnet-4-6"]) == catalog( @@ -409,6 +420,34 @@ def test_writes_a_private_registry_removed_at_exit(self, tmp_path, monkeypatch): assert not path.exists() assert not path.with_suffix(".lock").exists() + def test_cleanup_holds_the_registries_mutex(self, tmp_path, monkeypatch): + isolate_copilot_config_paths(monkeypatch, tmp_path) + real_lock, real_release = copilot._registries_lock, copilot.release_file_lock + held: list[bool] = [] + released_under_mutex: list[bool] = [] + + @contextlib.contextmanager + def tracking_lock(): + with real_lock(): + held.append(True) + try: + yield + finally: + held.pop() + + def tracking_release(lock_file): + if Path(lock_file.name).name.startswith(copilot._REGISTRY_PREFIX): + released_under_mutex.append(bool(held)) + real_release(lock_file) + + monkeypatch.setattr(copilot, "_registries_lock", tracking_lock) + monkeypatch.setattr(copilot, "release_file_lock", tracking_release) + + with copilot.session_registry({}): + pass + + assert released_under_mutex == [True] + def test_removed_when_the_session_fails(self, tmp_path, monkeypatch): isolate_copilot_config_paths(monkeypatch, tmp_path)