Skip to content
Closed
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
28 changes: 28 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ on:
description: Exact OpenCode version (non-blocking lane)
default: 1.18.31
required: true
copilot_version:
description: Exact GitHub Copilot CLI version (non-blocking lane)
default: 1.0.91
required: true
dependency:
description: Optional exact dependency for reproduction (e.g. tomlkit==0.14.0)
default: ''
Expand All @@ -53,6 +57,7 @@ env:
CLAUDE_VERSION: ${{ inputs.claude_version || '2.1.290' }}
CODEX_VERSION: ${{ inputs.codex_version || '0.154.0' }}
OPENCODE_VERSION: ${{ inputs.opencode_version || '1.18.31' }}
COPILOT_VERSION: ${{ inputs.copilot_version || '1.0.91' }}
PACKAGE_INDEX: ${{ inputs.default_index || 'https://pypi.org/simple' }}

jobs:
Expand Down Expand Up @@ -211,6 +216,7 @@ jobs:
claude) args=(--claude-version "$CLAUDE_VERSION") ;;
codex) args=(--codex-version "$CODEX_VERSION") ;;
opencode) args=(--opencode-version "$OPENCODE_VERSION") ;;
copilot) args=(--copilot-version "$COPILOT_VERSION") ;;
esac
if [[ -n "$DEPENDENCY" ]]; then
args+=(--dependency "$DEPENDENCY")
Expand Down Expand Up @@ -351,6 +357,28 @@ jobs:
ARTIFACT_NAME: integration-full-opencode-${{ matrix.os }}
steps: *live-steps

# Non-blocking pending stabilization, like OpenCode: continue-on-error and absent from the
# cujs `needs`.
copilot:
name: Full journeys · Copilot · ${{ matrix.os == 'windows-latest' && 'Windows' || 'Linux' }} (non-blocking)
if: ${{ !cancelled() && needs.workspace.result == 'success' }}
needs: workspace
continue-on-error: true
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, windows-latest]
timeout-minutes: 30
env:
UCODE_TEST_WORKSPACE: ${{ secrets.UCODE_TEST_WORKSPACE }}
DATABRICKS_BEARER: ${{ secrets.DATABRICKS_BEARER }}
DEPENDENCY: ${{ inputs.dependency }}
AGENT: copilot
TEST_MARKER: live and copilot
ARTIFACT_NAME: integration-full-copilot-${{ matrix.os }}
steps: *live-steps

# Non-blocking pending stabilization: continue-on-error and absent from the cujs `needs`.
# Runs on the managed e2e workspace with an injected config that enables Claude/Codex but not
# OpenCode (a service principal authenticates it, like the `managed` lane).
Expand Down
26 changes: 20 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,26 @@ 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, kept in the session's `~/.copilot/ucode-providers-<id>.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
restart `ug copilot`. Proxy errors are logged to `~/.ucode/logs/copilot-proxy.log`.

Without a managed workspace config, `ug claude` automatically discovers gateway
models for Claude Code's `/model` picker. Discovery defaults to `system.ai` when
Expand Down
36 changes: 29 additions & 7 deletions scripts/run_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"claude": "@anthropic-ai/claude-code",
"codex": "@openai/codex",
"opencode": "opencode-ai",
"copilot": "@github/copilot",
}
WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD"
# Claude exports no spans on Windows, where ug writes no machine-wide Claude settings.
Expand All @@ -44,9 +45,18 @@
"claude": "test_ug_claude_headless.py::test_ug_claude_headless_prompt_argument",
"codex": "test_ug_codex_headless.py::test_ug_codex_headless_prompt_argument",
"opencode": "test_ug_opencode_headless.py::test_ug_opencode_headless_prompt_argument",
"copilot": "test_ug_copilot_headless.py::test_ug_copilot_headless_completes_file_task",
}


def agent_version_output(agent: str, output: str) -> str:
"""The agent's ``--version`` text, normalized for exact-version matching."""
if agent == "copilot":
# "GitHub Copilot CLI 1.0.91." plus an update hint on later lines.
return output.splitlines()[0].removesuffix(".") if output else output
return output


def redact_secrets(value: str, secrets: Iterable[str]) -> str:
for secret in sorted({secret for secret in secrets if secret}, key=len, reverse=True):
value = value.replace(secret, "<redacted>")
Expand Down Expand Up @@ -253,11 +263,13 @@ def arguments(
parser.add_argument("--claude-version", type=exact_npm_version)
parser.add_argument("--codex-version", type=exact_npm_version)
parser.add_argument("--opencode-version", type=exact_npm_version)
parser.add_argument("--copilot-version", type=exact_npm_version)
parser.add_argument("--claude-model", default=environment.get("UG_INTEGRATION_CLAUDE_MODEL"))
parser.add_argument("--codex-model", default=environment.get("UG_INTEGRATION_CODEX_MODEL"))
parser.add_argument(
"--opencode-model", default=environment.get("UG_INTEGRATION_OPENCODE_MODEL")
)
parser.add_argument("--copilot-model", default=environment.get("UG_INTEGRATION_COPILOT_MODEL"))
parser.add_argument(
"--claude-provider",
default="main.ucode.ci_e2e_anthropic_nonrelay_mps",
Expand Down Expand Up @@ -356,9 +368,12 @@ def arguments(
args.pytest_args.extend([flag, str(value)])
if selected.x:
args.pytest_args.append("-x")
if not (args.claude_version or args.codex_version or args.opencode_version):
if not (
args.claude_version or args.codex_version or args.opencode_version or args.copilot_version
):
parser.error(
"Select --claude-version, --codex-version and/or --opencode-version explicitly."
"Select --claude-version, --codex-version, --opencode-version and/or "
"--copilot-version explicitly."
)
if args.ug_version != "checkout" and not re.fullmatch(
r"[0-9][0-9A-Za-z.!+_-]*", args.ug_version
Expand Down Expand Up @@ -516,9 +531,11 @@ def run(command, *, cwd=output, env=base_env, timeout=600) -> str:
"claude": args.claude_version,
"codex": args.codex_version,
"opencode": args.opencode_version,
"copilot": args.copilot_version,
"claude_model": args.claude_model,
"codex_model": args.codex_model,
"opencode_model": args.opencode_model,
"copilot_model": args.copilot_model,
"claude_provider": args.claude_provider,
"claude_relayed_provider": args.claude_relayed_provider,
"claude_provider_model": args.claude_provider_model,
Expand Down Expand Up @@ -731,7 +748,9 @@ def run(command, *, cwd=output, env=base_env, timeout=600) -> str:
report["agents"] = {}
for agent in agents:
agent_command = npm_executable(agent_bin, agent)
version = run([agent_command, "--version"], env=runtime_env, timeout=30)
version = agent_version_output(
agent, run([agent_command, "--version"], env=runtime_env, timeout=30)
)
expected = getattr(args, f"{agent}_version")
if not re.search(rf"(?<![\w.]){re.escape(expected)}(?![\w.])", version):
raise RuntimeError(f"Expected {agent} {expected}, got {version!r}")
Expand Down Expand Up @@ -877,10 +896,13 @@ def run(command, *, cwd=output, env=base_env, timeout=600) -> str:
raise RuntimeError("Pytest returned success without a test report.")
# A bootstrap/update path must not silently alter the selected agent version.
for agent in agents:
after = run(
[npm_executable(agent_bin, agent), "--version"],
env=runtime_env,
timeout=30,
after = agent_version_output(
agent,
run(
[npm_executable(agent_bin, agent), "--version"],
env=runtime_env,
timeout=30,
),
)
if after != report["agents"][agent]:
raise RuntimeError(f"{agent} changed version during the suite: {after}")
Expand Down
6 changes: 3 additions & 3 deletions src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ def configure_tool(
result = kilo.write_tool_config(state, model)
else:
result = opencode.write_tool_config(state, model)
# gemini/opencode/copilot/pi return (state, token); codex/claude return state
# gemini/opencode/kilo/pi return (state, token); codex/claude/copilot return state
if isinstance(result, tuple):
return result[0]
return result
Expand Down Expand Up @@ -511,7 +511,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"))
Expand All @@ -527,7 +527,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"),
}

Expand Down
1 change: 1 addition & 0 deletions src/ucode/agents/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 5 additions & 24 deletions src/ucode/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -602,7 +603,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
],
}
Expand All @@ -613,7 +614,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
Expand All @@ -632,32 +634,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:
Expand Down Expand Up @@ -700,7 +681,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
Expand Down
15 changes: 2 additions & 13 deletions src/ucode/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,14 @@
CUSTOM_OAUTH_TIMEOUT_MS,
CustomOAuthConfig,
build_custom_auth_token_argv,
get_custom_client_token,
launch_token,
)
from ucode.databricks import (
CodexCatalogSource,
CodexMpsModelCatalogUnavailable,
_fetch_codex_model_catalog,
build_auth_token_argv,
build_tool_base_url,
get_databricks_token,
ug_binary,
)
from ucode.launcher import exec_or_spawn
Expand Down Expand Up @@ -1181,17 +1180,7 @@ def _launch_token(state: dict, workspace: str, *, force_refresh: bool = False) -
"""The token Codex authenticates with: a custom-OAuth client token when configured,
else the CLI profile. The single auth-selection point — the OTLP proxy's token
provider (_otel_token_provider) delegates here so both mint the same principal."""
custom_oauth = state.get("custom_oauth")
if isinstance(custom_oauth, dict):
return get_custom_client_token(
workspace,
custom_oauth["client_id"],
custom_oauth["redirect_url"],
scopes=custom_oauth["scopes"],
profile=custom_oauth.get("profile"),
force_refresh=force_refresh,
)
return get_databricks_token(workspace, state.get("profile"), force_refresh=force_refresh)
return launch_token(state, workspace, force_refresh=force_refresh)


def _tool_args_select_model(tool_args: list[str]) -> bool:
Expand Down
Loading
Loading