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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions src/ucode/agents/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@
to clean up; the values are also injected directly into the child process's
environment at launch.

We point Copilot CLI's `openai` provider at the Databricks MLflow chat-completions
gateway, which serves Claude and codex (gpt-5) models. Gemini is intentionally
excluded — Databricks' Gemini translation layer rejects the `stream_options`
field that Copilot CLI sends, so Gemini models 400 on every request.
Copilot CLI supports two BYOK provider dialects. Claude models get its native
`anthropic` provider type, pointed at the same Messages-API gateway path
claude.py uses — Copilot's own runtime inserts `cache_control` breakpoints on
that path, so the (typically huge, shared) system/tool prefix actually caches.
Codex (gpt-5) has no native-dialect provider on Copilot's side, so it stays on
the `openai` provider against the Databricks MLflow chat-completions gateway.
Gemini is intentionally excluded from both — Databricks' Gemini translation
layer rejects the `stream_options` field that Copilot CLI sends, so Gemini
models 400 on every request.
"""

from __future__ import annotations
Expand All @@ -32,7 +37,7 @@
)
from ucode.databricks import (
TOKEN_REFRESH_INTERVAL_SECONDS,
build_copilot_base_url,
build_copilot_base_urls,
get_databricks_token,
)
from ucode.state import mark_tool_managed, save_state
Expand All @@ -56,14 +61,20 @@
"COPILOT_PROVIDER_BASE_URL",
"COPILOT_MODEL",
"COPILOT_PROVIDER_BEARER_TOKEN",
"COPILOT_PROVIDER_API_KEY",
"COPILOT_OFFLINE",
"OAUTH_TOKEN",
]
LEGACY_ENV_KEYS = [
"OPENAI_BASE_URL",
"OPENAI_API_KEY",
"COPILOT_PROVIDER_API_KEY",
]
# COPILOT_PROVIDER_API_KEY and COPILOT_PROVIDER_BEARER_TOKEN are mutually
# exclusive — only one applies at a time, depending on the selected model's
# family (see render_env_overlay). Both are cleared before applying a fresh
# overlay so switching families (Claude <-> codex) doesn't leave the other
# one stale in ~/.copilot/ucode.env.
_PROVIDER_AUTH_KEYS = ("COPILOT_PROVIDER_API_KEY", "COPILOT_PROVIDER_BEARER_TOKEN")


def is_update_available() -> tuple[str, str] | None:
Expand Down Expand Up @@ -91,10 +102,28 @@ def default_model(state: dict) -> str | None:
return None


def _is_claude_model(model: str) -> bool:
# Every Claude family/model id ucode discovers or pins contains "claude"
# (canonical Anthropic names like "claude-sonnet-5", or Bedrock-style
# slugs like "us.anthropic.claude-opus-4-8") — same substring check
# `databricks.py` already uses elsewhere to special-case the family.
return "claude" in model.lower()


def render_env_overlay(workspace: str, model: str, token: str) -> dict[str, str]:
base_urls = build_copilot_base_urls(workspace)
if _is_claude_model(model):
return {
"COPILOT_PROVIDER_TYPE": "anthropic",
"COPILOT_PROVIDER_BASE_URL": base_urls["anthropic"],
"COPILOT_MODEL": model,
"COPILOT_PROVIDER_API_KEY": token,
"COPILOT_OFFLINE": "true",
"OAUTH_TOKEN": token,
}
return {
"COPILOT_PROVIDER_TYPE": "openai",
"COPILOT_PROVIDER_BASE_URL": build_copilot_base_url(workspace),
"COPILOT_PROVIDER_BASE_URL": base_urls["openai"],
"COPILOT_MODEL": model,
"COPILOT_PROVIDER_BEARER_TOKEN": token,
"COPILOT_OFFLINE": "true",
Expand Down Expand Up @@ -161,6 +190,8 @@ def write_tool_config(
existing = parse_dotenv(COPILOT_ENV_PATH)
for key in LEGACY_ENV_KEYS:
existing.pop(key, None)
for key in _PROVIDER_AUTH_KEYS:
existing.pop(key, None)
existing.update(overlay)
write_dotenv(COPILOT_ENV_PATH, existing)
state = mark_tool_managed(state, "copilot", MANAGED_KEYS)
Expand Down
25 changes: 18 additions & 7 deletions src/ucode/databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3206,12 +3206,23 @@ def build_pi_base_urls(workspace: str) -> dict[str, str]:
}


def build_copilot_base_url(workspace: str) -> str:
# Copilot CLI's `openai` provider appends `/chat/completions` to the
# configured base URL. The Databricks MLflow chat-completions gateway is
# OpenAI-compatible and serves Claude, codex (gpt-5), and gemini models
# behind one URL.
return f"{workspace}/ai-gateway/mlflow/v1"
def build_copilot_base_urls(workspace: str) -> dict[str, str]:
# Copilot CLI's `anthropic` provider type speaks the native Anthropic
# Messages API directly, against the same gateway path claude.py uses.
# That's what lets Copilot's own cache_control-insertion logic run and
# cache the (typically huge, shared) system/tool prefix — the `openai`
# provider's wire format has no field to carry a cache breakpoint, so a
# Claude model proxied that way never gets a cache hit.
#
# Codex (gpt-5) has no native-dialect provider type on Copilot's side, so
# it stays on `openai` against the OpenAI-compatible MLflow
# chat-completions gateway, which serves Claude, codex, and gemini behind
# one URL. Copilot CLI's `openai` provider appends `/chat/completions` to
# this base URL.
return {
"anthropic": build_tool_base_url("claude", workspace),
"openai": f"{workspace}/ai-gateway/mlflow/v1",
}


def build_shared_base_urls(workspace: str) -> dict[str, str | dict[str, str]]:
Expand All @@ -3220,7 +3231,7 @@ def build_shared_base_urls(workspace: str) -> dict[str, str | dict[str, str]]:
"claude": build_tool_base_url("claude", workspace),
"gemini": build_tool_base_url("gemini", workspace),
"opencode": build_opencode_base_urls(workspace),
"copilot": build_copilot_base_url(workspace),
"copilot": build_copilot_base_urls(workspace),
"pi": build_pi_base_urls(workspace),
}
return urls
82 changes: 73 additions & 9 deletions tests/test_agent_copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,66 @@ def test_config_path_is_ucode_env_file(self):


class TestRenderEnvOverlay:
def test_sets_provider_base_url(self):
"""Claude models get Copilot's native `anthropic` provider (so Copilot's own
cache_control logic runs); everything else (codex/gpt-5) stays on `openai`
against the MLflow gateway."""

def test_claude_model_uses_anthropic_provider_type(self):
env = copilot.render_env_overlay(WS, "claude-sonnet-4-6", "tok")
assert env["COPILOT_PROVIDER_BASE_URL"] == f"{WS}/ai-gateway/mlflow/v1"
assert env["COPILOT_PROVIDER_TYPE"] == "anthropic"

def test_claude_model_uses_native_anthropic_base_url(self):
env = copilot.render_env_overlay(WS, "claude-sonnet-4-6", "tok")
assert env["COPILOT_PROVIDER_BASE_URL"] == f"{WS}/ai-gateway/anthropic"

def test_sets_provider_type(self):
env = copilot.render_env_overlay(WS, "m", "t")
def test_claude_model_sets_api_key_not_bearer_token(self):
env = copilot.render_env_overlay(WS, "claude-sonnet-4-6", "tok123")
assert env["COPILOT_PROVIDER_API_KEY"] == "tok123"
assert "COPILOT_PROVIDER_BEARER_TOKEN" not in env

def test_claude_model_matches_case_insensitively(self):
env = copilot.render_env_overlay(WS, "us.anthropic.Claude-Opus-4-8", "tok")
assert env["COPILOT_PROVIDER_TYPE"] == "anthropic"

def test_non_claude_model_uses_openai_provider_type(self):
env = copilot.render_env_overlay(WS, "gpt-5", "t")
assert env["COPILOT_PROVIDER_TYPE"] == "openai"

def test_non_claude_model_uses_mlflow_base_url(self):
env = copilot.render_env_overlay(WS, "gpt-5", "t")
assert env["COPILOT_PROVIDER_BASE_URL"] == f"{WS}/ai-gateway/mlflow/v1"

def test_non_claude_model_sets_bearer_token_not_api_key(self):
env = copilot.render_env_overlay(WS, "gpt-5", "tok123")
assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "tok123"
assert "COPILOT_PROVIDER_API_KEY" not in env

def test_sets_model(self):
env = copilot.render_env_overlay(WS, "claude-sonnet-4-6", "tok")
assert env["COPILOT_MODEL"] == "claude-sonnet-4-6"

def test_sets_bearer_token(self):
env = copilot.render_env_overlay(WS, "m", "tok123")
assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "tok123"

def test_sets_offline_true(self):
env = copilot.render_env_overlay(WS, "m", "t")
env = copilot.render_env_overlay(WS, "gpt-5", "t")
assert env["COPILOT_OFFLINE"] == "true"

def test_sets_oauth_token_for_both_families(self):
assert copilot.render_env_overlay(WS, "claude-sonnet-4-6", "tok")["OAUTH_TOKEN"] == "tok"
assert copilot.render_env_overlay(WS, "gpt-5", "tok")["OAUTH_TOKEN"] == "tok"


class TestIsClaudeModel:
def test_true_for_canonical_name(self):
assert copilot._is_claude_model("claude-sonnet-5") is True

def test_true_for_bedrock_style_slug(self):
assert copilot._is_claude_model("us.anthropic.claude-opus-4-8") is True

def test_false_for_codex(self):
assert copilot._is_claude_model("gpt-5") is False

def test_false_for_catalog_qualified_gpt(self):
assert copilot._is_claude_model("system.ai.gpt-5") is False


class TestBuildRuntimeEnv:
def test_inherits_path(self):
Expand All @@ -60,6 +100,29 @@ def test_sets_oauth_token_for_mcp(self):
assert env["OAUTH_TOKEN"] == "tok"


class TestWriteToolConfig:
def test_switching_families_clears_the_stale_auth_key(self, tmp_path, monkeypatch):
import ucode.agents.copilot as cp_mod
import ucode.config_io as config_io_mod

monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path)
env_path = tmp_path / "ucode.env"
monkeypatch.setattr(cp_mod, "COPILOT_ENV_PATH", env_path)
monkeypatch.setattr(cp_mod, "COPILOT_BACKUP_PATH", tmp_path / "backup")
monkeypatch.setattr(cp_mod, "save_state", lambda state: None)

state = {"workspace": WS}
cp_mod.write_tool_config(state, "claude-sonnet-4-6", token="tok-a")
written = config_io_mod.parse_dotenv(env_path)
assert written["COPILOT_PROVIDER_API_KEY"] == "tok-a"
assert "COPILOT_PROVIDER_BEARER_TOKEN" not in written

cp_mod.write_tool_config(state, "gpt-5", token="tok-b")
written = config_io_mod.parse_dotenv(env_path)
assert written["COPILOT_PROVIDER_BEARER_TOKEN"] == "tok-b"
assert "COPILOT_PROVIDER_API_KEY" not in written


class TestMcpServerConfig:
# ucode registers the `ucode mcp-proxy ...` bridge as a `local` (stdio) MCP
# server; the proxy refreshes the token, so no URL/bearer header here.
Expand Down Expand Up @@ -192,6 +255,7 @@ def test_includes_required_vars(self):
"COPILOT_PROVIDER_BASE_URL",
"COPILOT_MODEL",
"COPILOT_PROVIDER_BEARER_TOKEN",
"COPILOT_PROVIDER_API_KEY",
"COPILOT_OFFLINE",
"OAUTH_TOKEN",
):
Expand Down
20 changes: 20 additions & 0 deletions tests/test_databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
all_users_can_use_schema,
build_auth_shell_command,
build_auth_token_argv,
build_copilot_base_urls,
build_databricks_cli_env,
build_opencode_base_urls,
build_shared_base_urls,
Expand Down Expand Up @@ -115,6 +116,10 @@ def test_opencode_raises(self):
with pytest.raises(RuntimeError, match="multiple base URLs"):
build_tool_base_url("opencode", WS)

def test_copilot_raises(self):
with pytest.raises(RuntimeError, match="multiple base URLs"):
build_tool_base_url("copilot", WS)

def test_unsupported_tool_raises(self):
with pytest.raises(RuntimeError, match="Unsupported"):
build_tool_base_url("unknown", WS)
Expand All @@ -128,18 +133,33 @@ def test_returns_anthropic_gemini_and_oss(self):
assert urls["oss"] == f"{WS}/ai-gateway/mlflow/v1"


class TestBuildCopilotBaseUrls:
def test_anthropic_points_at_the_native_claude_gateway(self):
urls = build_copilot_base_urls(WS)
assert urls["anthropic"] == f"{WS}/ai-gateway/anthropic"

def test_openai_points_at_the_mlflow_gateway(self):
urls = build_copilot_base_urls(WS)
assert urls["openai"] == f"{WS}/ai-gateway/mlflow/v1"


class TestBuildSharedBaseUrls:
def test_contains_all_tools(self):
urls = build_shared_base_urls(WS)
assert "codex" in urls
assert "claude" in urls
assert "gemini" in urls
assert "opencode" in urls
assert "copilot" in urls

def test_opencode_is_dict(self):
urls = build_shared_base_urls(WS)
assert isinstance(urls["opencode"], dict)

def test_copilot_is_dict(self):
urls = build_shared_base_urls(WS)
assert isinstance(urls["copilot"], dict)

def test_codex_url_format(self):
urls = build_shared_base_urls(WS)
assert urls["codex"] == f"{WS}/ai-gateway/codex/v1"
Expand Down