Skip to content
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ to discovery or change ug's saved default.
`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.
GPT models use Chat Completions. Gemini and open-source models aren't offered, except under
`--model-location` (below).
`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
Expand All @@ -95,15 +96,17 @@ Bedrock service, and the chat models of an OpenAI, Azure OpenAI or Microsoft Fou
allows every model, `/model` lists the models the gateway reports for it, which the provider
account may not serve; name the one to start on with `--model`. Services that relay a Claude subscription work only with Claude Code. Interactive
`ug configure` offers the matching services under **External Models**, and a managed workspace
config can set one for Copilot.
config can set one for Copilot. `ug copilot --model-location <catalog>.<schema>` uses the model
services of one schema instead: services named like Claude or GPT models use their usual API, and
the rest go through the gateway's Anthropic endpoint, as for Claude Code.

Without a managed workspace config, `ug claude` automatically discovers gateway
models for Claude Code's `/model` picker. Discovery defaults to `system.ai` when
no provider or model location is selected. Use `--provider` or `--model-location`
to select another model source; managed workspace configs control their own sources.
Interactive `ug configure` also offers a **Custom Model Location** (`<catalog>.<schema>`) for
Claude Code and Codex alongside Databricks Hosted and External Models. The choice is saved
with the rest of the configuration, so later `ug claude` / `ug codex` launches use it; an
Claude Code, Codex and Copilot alongside Databricks Hosted and External Models. The choice is saved
with the rest of the configuration, so later `ug claude` / `ug codex` / `ug copilot` launches use it; an
explicit `--provider` or `--model-location` still overrides it for that launch.

`ug codex` validates discovered models with the installed Codex binary and publishes
Expand Down
15 changes: 9 additions & 6 deletions src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from pathlib import Path

from ucode.config_io import ToolSpec
from ucode.constants import AGENT_CLAUDE, AGENT_CODEX
from ucode.constants import AGENT_CLAUDE, AGENT_CODEX, AGENT_COPILOT
from ucode.databricks import (
AnthropicModelCatalog,
get_databricks_token,
Expand Down Expand Up @@ -80,6 +80,8 @@
DEFAULT_TOOL = "codex"
BUNDLE_VERSION = 1
_MANAGED_SETTINGS_TOOLS = {"claude", "codex"}
# Agents that can take their models from a Unity Catalog schema (`--model-location`).
MODEL_LOCATION_TOOLS = (AGENT_CLAUDE, AGENT_CODEX, AGENT_COPILOT)
_NATIVE_UPGRADE_COMMANDS = {
"claude": ["claude", "upgrade"],
"codex": ["codex", "update"],
Expand Down Expand Up @@ -483,8 +485,9 @@ def configure_tool(
picker_catalog=picker_catalog,
)
elif tool == "copilot":
# Under a model provider service, Copilot starts on one of the service's models, chosen at launch.
if not model and not provider:
# Under a model provider service or a model location, Copilot starts on one of its models,
# chosen at launch.
if not model and not provider and not parent_schema:
raise RuntimeError(f"A {tool} model must be selected before configuration.")
result = copilot.write_tool_config(state, model)
else:
Expand Down Expand Up @@ -605,10 +608,10 @@ def configure_single_tool(
def _persisted_model_location(tool: str, state: dict, provider: str | None) -> str | None:
"""The model location ``ug configure`` saved for ``tool``, unless a provider is in play.

Only claude and codex discover models by parent schema; an explicit or persisted provider
keeps the source it names.
Only ``MODEL_LOCATION_TOOLS`` discover models by parent schema; an explicit or persisted
provider keeps the source it names.
"""
if tool not in (AGENT_CLAUDE, AGENT_CODEX) or provider or get_provider_service(state, tool):
if tool not in MODEL_LOCATION_TOOLS or provider or get_provider_service(state, tool):
return None
return get_model_location(state, tool)

Expand Down
68 changes: 57 additions & 11 deletions src/ucode/agents/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@

`ug copilot --provider` registers a Model Provider Service's models instead: its Claude models on
the Anthropic endpoint, its other chat models on the gateway's native OpenAI route, with every
registry provider sending the service's routing header.
registry provider sending the service's routing header. `ug copilot --model-location` registers
the model services of one Unity Catalog schema: those named like Claude or GPT models on their
usual wire, the rest on the Anthropic endpoint like Claude Code, which the gateway translates.

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
Expand Down Expand Up @@ -66,6 +68,7 @@
bare_model_name,
build_copilot_base_url,
build_tool_base_url,
list_anthropic_model_catalog,
list_provider_service_models,
model_version_sort_key,
resolve_provider_service,
Expand All @@ -75,6 +78,7 @@
acquire_exclusive_file_lock,
release_file_lock,
)
from ucode.smart_routing.routing import unwrap_anthropic_gateway_model
from ucode.state import is_tool_managed, mark_tool_managed, save_state
from ucode.telemetry import agent_version, ucode_user_agent, ug_version
from ucode.ui import print_warning
Expand Down Expand Up @@ -117,6 +121,8 @@
CATALOG_STATE_KEY = "copilot_catalog"
# The Model Provider Service this launch routes through; set by the launch command, never saved.
LAUNCH_PROVIDER_SERVICE_KEY = "_copilot_launch_provider_service"
# The Unity Catalog schema whose model services this launch uses; never saved.
LAUNCH_MODEL_LOCATION_KEY = "_copilot_launch_model_location"

# The wire APIs Copilot speaks; `responses` and `completions` are Copilot's `wireApi` values.
WIRE_ANTHROPIC = "anthropic"
Expand Down Expand Up @@ -300,14 +306,22 @@ def _catalog_sort_key(model: CopilotModel) -> tuple:


def build_catalog(
models: Iterable[tuple[str, Iterable[str]]], *, provider_type: str | None = None
models: Iterable[tuple[str, Iterable[str]]],
*,
provider_type: str | None = None,
fallback_wire: str | None = None,
) -> tuple[list[CopilotModel], list[tuple[str, str]]]:
"""The ``(model, api_types)`` pairs Copilot can use, sorted Claude first (by family, newest
first) then GPT, and the skipped models with the reason (see :func:`classify_model`)."""
first) then GPT, and the skipped models with the reason (see :func:`classify_model`).

With ``fallback_wire``, a model whose name doesn't decide the wire uses it instead of being
skipped; unsupported models are still skipped."""
usable: dict[str, CopilotModel] = {}
skipped: dict[str, str] = {}
for model, api_types in models:
wire, reason = classify_model(model, api_types, provider_type=provider_type)
if wire is None and fallback_wire and bare_model_name(model) not in _UNSUPPORTED_MODELS:
wire = fallback_wire
if wire is not None:
usable.setdefault(model, CopilotModel(model, wire))
elif reason is not None:
Expand Down Expand Up @@ -379,6 +393,23 @@ def provider_service_catalog(
return catalog


def location_catalog(state: dict, schema: str) -> list[CopilotModel]:
Comment thread
xi-db marked this conversation as resolved.
"""The model services of Unity Catalog schema ``schema`` that this launch registers.

The listing names some services by gateway alias (``anthropic-aigw-<hash>-<name>``); Copilot
sends the full name, which the gateway routes directly."""
workspace = state["workspace"]
listing = list_anthropic_model_catalog(
workspace, launch_token(state, workspace), parent_schema=schema
)
models = (unwrap_anthropic_gateway_model(model) for model in listing.model_ids)
catalog, _ = build_catalog(((model, ()) for model in models), fallback_wire=WIRE_ANTHROPIC)
if not catalog:
reason = f": {listing.error_msg}" if listing.error_msg else ""
raise RuntimeError(f"Found no model services to use in {schema}{reason}.")
return catalog


def catalog_from_listings(
entries: Iterable[ModelServiceEntry] | None, fallback: Iterable[str]
) -> list[CopilotModel] | None:
Expand Down Expand Up @@ -567,6 +598,7 @@ def render_providers_config(
session_secret: str,
*,
provider_service: str | None = None,
model_location: str | None = None,
) -> dict:
"""Copilot's providers.json for ``catalog``: one provider per wire, all on the session proxy.

Expand Down Expand Up @@ -613,8 +645,12 @@ def render_providers_config(
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 and sum(limits) > _CLAUDE_200K_WINDOW:
# A Model Provider Service may not serve the 1M window.
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
if limits is not None:
entry["maxPromptTokens"], entry["maxOutputTokens"] = limits
Expand Down Expand Up @@ -852,14 +888,16 @@ def _wait_for_exit(proc: _ChildProcess) -> int:


def _session_catalog(
state: dict, tool_args: list[str], provider_service: str | None
state: dict, tool_args: list[str], provider_service: str | None, model_location: str | None
) -> list[CopilotModel]:
"""The models this launch registers: the Model Provider Service's, else the Databricks
catalog."""
"""The models this launch registers: the Model Provider Service's, the model location's,
else the Databricks catalog."""
if provider_service:
return provider_service_catalog(
state, provider_service, explicit_model_arg_value(tool_args)
)
if model_location:
return location_catalog(state, model_location)
catalog = copilot_catalog(state)
if not catalog:
raise RuntimeError(
Expand All @@ -873,11 +911,15 @@ def _session_catalog(

def launch(state: dict, tool_args: list[str], *, options: LaunchOptions) -> None:
provider_service = state.pop(LAUNCH_PROVIDER_SERVICE_KEY, None)
catalog = _session_catalog(state, tool_args, provider_service)
model_location = state.pop(LAUNCH_MODEL_LOCATION_KEY, None)
catalog = _session_catalog(state, tool_args, provider_service, model_location)
# 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, managed_list=not provider_service
state,
catalog,
requested=options.launch_model,
managed_list=not (provider_service or model_location),
)
scrub_stored_credentials()

Expand All @@ -901,7 +943,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, provider_service=provider_service
catalog,
proxy_base_url,
session_secret,
provider_service=provider_service,
model_location=model_location,
)
wire_override = os.environ.get("COPILOT_PROVIDER_WIRE_MODEL")
if wire_override:
Expand Down
Loading
Loading