Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 44 additions & 20 deletions src/ucode/agents/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ def classify_model(
wire = _wire_from_name(tail)
if provider_type in ANTHROPIC_API_PROVIDER_TYPES:
if wire != WIRE_ANTHROPIC:
return None, "ug copilot uses only the Claude models of this service"
return None, "only the Claude models of this service can be used"
return wire, None
if provider_type is not None:
# Foundry serves its Claude targets on the Anthropic route but not on Chat Completions,
Expand Down Expand Up @@ -245,16 +245,26 @@ def uses_responses_api(model: str) -> bool:
return _wire_from_name(bare_model_name(model)) == WIRE_RESPONSES


def claude_model_name(model: str) -> str:
"""A Claude model's own name, without its catalog/schema and release suffix:
``us.anthropic.claude-sonnet-4-5-20250929-v1:0`` -> ``claude-sonnet-4-5``.

Other models keep their bare name."""
name = bare_model_name(model)
if not name.startswith("claude-"):
return name
return _CLAUDE_RELEASE_SUFFIX_PATTERN.sub("", name, count=1)
Comment thread
xi-db marked this conversation as resolved.
Resolved


def copilot_catalog_model_id(model: str) -> str | None:
"""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 Claude.
"""
tail = bare_model_name(model)
tail = claude_model_name(model)
if not tail.startswith("claude-"):
return None
tail = _CLAUDE_RELEASE_SUFFIX_PATTERN.sub("", tail, count=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.
Expand All @@ -279,6 +289,17 @@ def copilot_model_limits(model: str) -> tuple[int, int] | None:
return _CLAUDE_1M_LIMITS if one_million else _CLAUDE_200K_LIMITS


def catalog_model_limits(model: CopilotModel, *, external: bool) -> tuple[int, int] | None:
""":func:`copilot_model_limits` for a Claude ``model`` of the catalog, or None for others.

``external`` (a Model Provider Service, or a location's model service) caps them at 200K, which
such a service may serve instead of the 1M window."""
limits = copilot_model_limits(model.id) if model.wire == WIRE_ANTHROPIC else None
if limits is not None and external and sum(limits) > _CLAUDE_200K_WINDOW:
return _CLAUDE_200K_LIMITS
return limits


@dataclass(frozen=True)
class CopilotModel:
"""A Databricks model in Copilot's registry and the wire API it is served on."""
Expand Down Expand Up @@ -347,30 +368,36 @@ def _without_registry_prefix(model: str) -> str:


def provider_service_catalog(
state: dict, service_name: str, requested: str | None
state: dict, service_name: str, requested: str | None, *, tool: str = "copilot"
) -> list[CopilotModel]:
"""The models of Model Provider Service ``service_name`` that this launch registers.

A service that allows every model registers what the gateway lists for it, plus ``requested``
(the launch's ``--model``) or the managed default, so a model the listing omits can be named."""
(the launch's ``--model``) or the managed default, so a model the listing omits can be named.
``tool`` is ``vscode`` for Copilot Chat in VS Code, which has neither."""
workspace = state["workspace"]
token = launch_token(state, workspace)
service, error = resolve_provider_service("copilot", service_name, workspace, token)
service, error = resolve_provider_service(tool, service_name, workspace, token)
if error or service is None:
raise RuntimeError(error or f"Model provider service '{service_name}' was not found.")
agent = "Copilot" if tool == "copilot" else "VS Code"
provider_type = service["provider_type"]
named = list(service.get("targets") or [])
listed: list[str] = []
allow_all = bool(service.get("allow_all_targets"))
if allow_all:
start = _without_registry_prefix(requested) if requested else None
managed_default = state.get("copilot_default_model")
managed_default = state.get("copilot_default_model") if tool == "copilot" else None
start = start or (managed_default if isinstance(managed_default, str) else None)
listed, reason = list_provider_service_models(workspace, token, service_name, provider_type)
if reason and not start:
raise RuntimeError(
f"Could not list the models of '{service_name}': {reason}. Name the one to start "
"on with `ug copilot --model <id>`."
f"Could not list the models of '{service_name}': {reason}."
+ (
" Name the one to start on with `ug copilot --model <id>`."
if tool == "copilot"
else ""
)
)
if reason:
print_warning(f"Could not list the models of {service_name}: {reason}.")
Expand All @@ -386,12 +413,16 @@ def provider_service_catalog(
if not catalog:
detail = "".join(f" {model} ({reason})." for model, reason in skipped)
raise RuntimeError(
f"Model provider service '{service_name}' has no models ug copilot can use.{detail}"
+ (" Name one it serves with `ug copilot --model <id>`." if allow_all else "")
f"Model provider service '{service_name}' has no models {agent} can use.{detail}"
+ (
" Name one it serves with `ug copilot --model <id>`."
if allow_all and tool == "copilot"
else ""
)
)
if skipped:
print_warning(
f"Skipping models of {service_name} that ug copilot can't use: "
f"Skipping models of {service_name} that {agent} can't use: "
+ "; ".join(f"{model} ({reason})" for model, reason in skipped)
+ "."
)
Expand Down Expand Up @@ -649,14 +680,7 @@ def render_providers_config(
catalog_id = copilot_catalog_model_id(model.id) if model.wire == WIRE_ANTHROPIC else None
if catalog_id is not None:
entry["modelId"] = catalog_id
limits = copilot_model_limits(model.id) if model.wire == WIRE_ANTHROPIC else None
if (
limits is not None
and (provider_service or model_location)
and sum(limits) > _CLAUDE_200K_WINDOW
):
# A Model Provider Service, or a location's model service, may not serve the 1M window.
limits = _CLAUDE_200K_LIMITS
limits = catalog_model_limits(model, external=bool(provider_service or model_location))
if limits is not None:
entry["maxPromptTokens"], entry["maxOutputTokens"] = limits
models.append(entry)
Expand Down
101 changes: 67 additions & 34 deletions src/ucode/agents/vscode.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
holds the workspace, its models and the auth command, and installs it with VS Code's `code` command.
The extension reads that file from its own folder, so ug never edits the user's settings.json.
`ug revert` uninstalls the extension.

The models come from one source: the workspace's own (`system.ai`), a Model Provider Service
(`--provider`), or the model services of a Unity Catalog schema (`--model-location`).
"""

from __future__ import annotations
Expand All @@ -21,9 +24,12 @@
from xml.sax.saxutils import escape

from ucode.agents.copilot import (
copilot_exclusion_reason,
copilot_model_limits,
uses_responses_api,
CopilotModel,
build_copilot_catalog,
catalog_model_limits,
claude_model_name,
location_catalog,
provider_service_catalog,
)
from ucode.custom_oauth import CustomOAuthConfig, build_custom_auth_shell_command, launch_token
from ucode.databricks import build_auth_shell_command, discover_model_services
Expand Down Expand Up @@ -161,36 +167,57 @@ def install_extension(code: Path, config: dict[str, object]) -> str:
return f"installed {EXTENSION_ID} {version}"


def _model_entry(model: str, name: str) -> dict[str, object]:
def _model_entry(model: CopilotModel, name: str, *, external: bool) -> dict[str, object]:
entry: dict[str, object] = {
"id": model,
"id": model.id,
"name": name,
"api": "responses" if uses_responses_api(model) else "chat_completions",
"family": claude_model_name(model.id),
# The wire names of Copilot's catalog; the extension dispatches on them (see APIS in
# vscode-extension/src/extension.ts).
"api": model.wire,
}
# Claude's limits hold on Chat Completions too. Models without known limits get the
# extension's 128K-in, 32K-out default.
if limits := copilot_model_limits(model):
# Models without known limits get the extension's 128K-in, 32K-out default.
if limits := catalog_model_limits(model, external=external):
entry["maxInputTokens"], entry["maxOutputTokens"] = limits
return entry


def model_entries(workspace: str, token: str) -> tuple[list[dict[str, object]], str | None]:
"""The workspace's Claude and GPT models, each with the API the extension calls it on."""
# The models Copilot CLI offers: Gemini and open models, gpt-oss among them, fail on Copilot's
# requests or aren't served on every workspace.
claude, gpt, _gemini, _oss, reason = discover_model_services(workspace, token)
models = [
model
for model in dict.fromkeys([*claude.values(), *gpt])
if copilot_exclusion_reason(model) is None
]
labels = {model: model_picker_label(model.rsplit(".", 1)[-1]) for model in models}
def _catalog(state: dict, provider: str | None, location: str | None) -> list[CopilotModel]:
"""The models Copilot CLI would offer from the same source; raises when there are none."""
if provider:
return provider_service_catalog(state, provider, None, tool="vscode")
if location:
return location_catalog(state, location)
workspace = state["workspace"]
# Gemini and open models, gpt-oss among them, fail on Copilot's requests or aren't served on
# every workspace.
claude, gpt, _gemini, _oss, reason = discover_model_services(
workspace, launch_token(state, workspace)
)
catalog = build_copilot_catalog([*claude.values(), *gpt])
if not catalog:
raise RuntimeError(
f"No Claude or GPT models for VS Code ({reason or 'the workspace has none'})."
)
return catalog


def model_entries(
state: dict, *, provider: str | None = None, location: str | None = None
) -> list[dict[str, object]]:
"""The models of the chosen source, each with the API the extension calls it on."""
catalog = _catalog(state, provider, location)
if provider:
# A service's targets are the provider's own ids, which is what its users know it by, and
# several of them differ only by a region the label would drop.
return [_model_entry(model, model.id, external=True) for model in catalog]
labels = {model: model_picker_label(claude_model_name(model.id)) for model in catalog}
counts = Counter(labels.values())
# As in Codex's picker: two models with the same label show their full ids instead.
return [
_model_entry(model, model if counts[label] > 1 else label)
_model_entry(model, model.id if counts[label] > 1 else label, external=bool(location))
for model, label in labels.items()
], reason
]


def auth_command(state: dict) -> str:
Expand Down Expand Up @@ -218,29 +245,35 @@ def next_steps() -> list[str]:
]


def configure(state: dict) -> bool:
"""Install the extension with this workspace's setup. Returns False, after a warning, if any
step fails."""
def configure(state: dict, *, provider: str | None = None, location: str | None = None) -> bool:
"""Install the extension with this workspace's setup and the models of ``provider`` (a Model
Provider Service), ``location`` (a ``<catalog>.<schema>``) or else ``system.ai``. Returns False,
after a warning, if any step fails."""
code = find_code()
if code is None:
print_warning(
"VS Code's `code` command wasn't found. Install VS Code (on macOS, also run "
"\"Shell Command: Install 'code' command in PATH\"), then rerun `ug configure --agents vscode`."
)
return False
workspace = state["workspace"]
models, reason = model_entries(workspace, launch_token(state, workspace))
if not models:
try:
models = model_entries(state, provider=provider, location=location)
except RuntimeError as exc:
# Keep what an earlier configure installed: a failed listing shouldn't wipe a working setup.
print_warning(
f"No Claude or GPT models for VS Code ({reason or 'the workspace has none'}). "
"VS Code was left unchanged."
)
print_warning(f"{exc} VS Code was left unchanged.")
return False
config = {"workspace": workspace, "authCommand": auth_command(state), "models": models}
config: dict[str, object] = {
"workspace": state["workspace"],
"authCommand": auth_command(state),
"models": models,
}
if provider:
config["provider"] = provider
source = f" from {provider or location}" if provider or location else ""
try:
print_kv(
"VS Code extension", f"{install_extension(code, config)} with {len(models)} models"
"VS Code extension",
f"{install_extension(code, config)} with {len(models)} models{source}",
)
except RuntimeError as exc:
print_warning(f"{exc} Rerun `ug configure --agents vscode` to try again.")
Expand Down
33 changes: 32 additions & 1 deletion src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4561,6 +4561,22 @@ def configure(
"workspace has an admin-managed config.",
),
] = None,
provider: Annotated[
str | None,
typer.Option(
"--provider",
help="With --agents vscode: give VS Code the models of this Model Provider Service "
"(`<catalog>.<schema>.<name>`) instead of the Databricks-hosted ones.",
),
] = None,
model_location: Annotated[
str | None,
typer.Option(
"--model-location",
help="With --agents vscode: give VS Code the model services in this Unity Catalog "
"schema (`<catalog>.<schema>`) instead of the Databricks-hosted ones.",
),
] = None,
mcp: Annotated[
str | None,
typer.Option(
Expand Down Expand Up @@ -4627,6 +4643,21 @@ def configure(
install_databricks_cli()
if agent is not None and agents is not None:
raise RuntimeError("Use either --agent or --agents, not both.")
if provider is not None or model_location is not None:
requested_agents = [a.strip().lower() for a in (agents or "").split(",") if a.strip()]
# Both flags choose VS Code's models alone, so another model agent in the same run
# would silently keep the workspace's own. Cursor has no models to choose.
if "vscode" not in requested_agents or [
a for a in requested_agents if a not in ("vscode", "cursor")
]:
raise RuntimeError(
"--provider and --model-location work only with `--agents vscode`. "
"Configure the other agents in a separate run."
)
if provider is not None and model_location is not None:
raise RuntimeError("--provider and --model-location cannot be used together.")
if model_location is not None and not is_valid_catalog_schema(model_location):
raise RuntimeError("--model-location must be `<catalog>.<schema>`.")
# --workspaces / --profiles are deprecated aliases of the singular flags.
if workspace is not None and workspaces is not None:
raise RuntimeError("Use either --workspace or --workspaces, not both.")
Expand Down Expand Up @@ -4722,7 +4753,7 @@ def configure(
state = load_state()
managed, _ = _read_policy_for_other_agent(state)
refuse_user_scope_over_managed_config(state.get("workspace"), managed)
vscode.configure(state)
vscode.configure(state, provider=provider, location=model_location)
elif mcp is not None:
# MCP-only: `--mcp` without --agent(s) (e.g. Cursor, which isn't a
# model agent, or adding MCP servers to an already-configured setup).
Expand Down
7 changes: 5 additions & 2 deletions src/ucode/databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2803,9 +2803,11 @@ def build_skills_mcp_url(workspace: str, locations: list[str]) -> str:
"microsoft_foundry",
),
}
# Copilot Chat in VS Code can use the same provider types as Copilot CLI.
_TOOL_PROVIDER_TYPES["vscode"] = _TOOL_PROVIDER_TYPES["copilot"]

# Agents that can only use a Bedrock service's Claude targets.
_CLAUDE_ONLY_BEDROCK_TOOLS = ("claude", "copilot")
_CLAUDE_ONLY_BEDROCK_TOOLS = ("claude", "copilot", "vscode")

# Provider types that expose Bedrock-style model ids (e.g.
# `us.anthropic.claude-sonnet-4-6`, or region-less `anthropic.claude-*` on
Expand Down Expand Up @@ -3605,8 +3607,9 @@ def fetch_ai_gateway_claude_models(workspace: str, token: str) -> dict[str, str]
# system.ai.claude-opus-4-7[1m] -> claude-opus-4-7
# main.ucode.claude_haiku -> claude_haiku
# gpt-5.5 -> gpt-5.5 (one dot is a version, not a schema)
# A region prefix is dotted on Bedrock (`us.anthropic.`) and slashed on Azure (`eu/gpt-5.6-luna`).
_BARE_MODEL_NAME_PATTERN = re.compile(
r"^(?:[^.\[]+\.[^.\[]+\.)?(?:anthropic\.)?(?:databricks-)?(?P<name>[^\[]*)"
r"^(?:[^.\[]+\.[^.\[]+\.)?(?:[^/.\[]+/)?(?:anthropic\.)?(?:databricks-)?(?P<name>[^\[]*)"
)


Expand Down
Loading
Loading