Merge pull request #141 from JerrettDavis/feat/copilot-wrap

feat: add GitHub Copilot CLI wrap support
This commit is contained in:
Tejas Chopra 2026-04-11 09:04:43 -07:00 committed by GitHub
commit fb953ebfe8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 419 additions and 6 deletions

View file

@ -131,6 +131,8 @@ Works with any language, any tool, any framework. **[Proxy docs](docs/proxy.md)*
```bash
headroom wrap claude # Starts proxy + launches Claude Code
headroom wrap copilot -- --model claude-sonnet-4-20250514
# Starts proxy + launches GitHub Copilot CLI
headroom wrap codex # Starts proxy + launches OpenAI Codex CLI
headroom wrap aider # Starts proxy + launches Aider
headroom wrap cursor # Starts proxy + prints Cursor config
@ -178,6 +180,7 @@ Gives your AI tool three MCP tools: `headroom_compress`, `headroom_retrieve`, `h
| **LangChain** | Wrap model | `HeadroomChatModel(your_llm)` |
| **OpenClaw** | One-command wrap/unwrap | `headroom wrap openclaw` / `headroom unwrap openclaw` |
| **Claude Code** | Wrap | `headroom wrap claude` |
| **GitHub Copilot CLI** | Wrap | `headroom wrap copilot -- --model claude-sonnet-4-20250514` |
| **Codex / Aider** | Wrap | `headroom wrap codex` or `headroom wrap aider` |
**[Full Integration Guide](docs/integration-guide.md)** | **[TypeScript SDK](docs/typescript-sdk.md)**
@ -316,7 +319,7 @@ Plugin-based: reads conversation history from Claude Code, Codex, or Gemini CLI.
| **Compression Hooks** | Customize compression with pre/post hooks |
| **Read Lifecycle** | Detects stale/superseded Read outputs, replaces with CCR markers |
| **`headroom learn`** | Plugin-based failure learning for Claude Code, Codex, Gemini CLI (extensible via entry points) |
| **`headroom wrap`** | One-command setup for Claude Code, Codex, Aider, Cursor |
| **`headroom wrap`** | One-command setup for Claude Code, GitHub Copilot CLI, Codex, Aider, Cursor |
| **SharedContext** | Compressed inter-agent context sharing for multi-agent workflows |
| **MCP Tools** | headroom_compress, headroom_retrieve, headroom_stats for Claude Code/Cursor |
@ -374,7 +377,7 @@ Context compression is a new space. Here's how the approaches differ:
| Integration | Status | Docs |
|-------------|--------|------|
| `headroom wrap claude/codex/aider/cursor` | **Stable** | [Proxy Docs](docs/proxy.md) |
| `headroom wrap claude/copilot/codex/aider/cursor` | **Stable** | [Proxy Docs](docs/proxy.md) |
| `compress()` — one function | **Stable** | [Integration Guide](docs/integration-guide.md) |
| `SharedContext` — multi-agent | **Stable** | [SharedContext Guide](docs/shared-context.md) |
| LiteLLM callback | **Stable** | [Integration Guide](docs/integration-guide.md#litellm) |

View file

@ -41,6 +41,9 @@ Then point your LLM client at it:
# Claude Code
ANTHROPIC_BASE_URL=http://localhost:8787 claude
# GitHub Copilot CLI (default Anthropic-style proxy route)
headroom wrap copilot -- --model claude-sonnet-4-20250514
# OpenAI-compatible clients
OPENAI_BASE_URL=http://localhost:8787/v1 your-app
```

View file

@ -95,6 +95,7 @@ Headroom works as a **transparent proxy** (zero code changes), a **Python functi
```bash
headroom wrap claude # Claude Code
headroom wrap copilot -- --model claude-sonnet-4-20250514
headroom wrap codex # OpenAI Codex CLI
headroom wrap aider # Aider
headroom wrap cursor # Cursor

View file

@ -9,7 +9,7 @@ You don't need to run the Headroom proxy. Headroom is a compression library that
| Any Python app | [`compress()`](#compress-function) | 2 lines |
| LiteLLM | [LiteLLM callback](#litellm) | 1 line |
| A Python proxy (FastAPI, custom) | [ASGI middleware](#asgi-middleware) | 1 line |
| Claude Code / Cursor | [Headroom proxy](#proxy) | 1 env var |
| Claude Code / Cursor / Copilot CLI | [Headroom proxy](#proxy) | 1 command or env var |
| Agno agents | [Agno integration](#agno) | Wrap model |
| LangChain | [LangChain integration](#langchain) | Wrap model |
| Non-Python app | [Headroom proxy](#proxy) | HTTP |
@ -182,7 +182,7 @@ Response headers include:
## Proxy
The Headroom proxy is a standalone HTTP server. Best for non-Python apps or tools that only support base URL configuration (Claude Code, Cursor).
The Headroom proxy is a standalone HTTP server. Best for non-Python apps or tools that only support base URL configuration (Claude Code, Cursor, GitHub Copilot CLI).
```bash
pip install "headroom-ai[all]"
@ -193,10 +193,21 @@ headroom proxy --port 8787
# Claude Code
ANTHROPIC_BASE_URL=http://localhost:8787 claude
# GitHub Copilot CLI
headroom wrap copilot -- --model claude-sonnet-4-20250514
# Cursor / Any OpenAI client
OPENAI_BASE_URL=http://localhost:8787/v1 cursor
```
For translated backends, the Copilot wrapper can switch to Headroom's OpenAI-compatible route:
```bash
headroom wrap copilot --backend anyllm --anyllm-provider groq -- --model gpt-4o
```
By default, `headroom wrap copilot` installs `rtk` and appends token-optimized shell guidance to `.github/copilot-instructions.md` so Copilot sessions reuse the same command-saving conventions as other wrapped agent CLIs. Use `--no-rtk` to skip that step.
### With Cloud Providers
```bash

View file

@ -21,6 +21,21 @@ headroom proxy \
--budget 100.0
```
### Common agent CLI entrypoints
```bash
# Claude Code
ANTHROPIC_BASE_URL=http://localhost:8787 claude
# GitHub Copilot CLI
headroom wrap copilot -- --model claude-sonnet-4-20250514
# OpenAI-compatible clients
OPENAI_BASE_URL=http://localhost:8787/v1 your-app
```
`headroom wrap copilot` uses Copilot CLI's BYOK provider settings under the hood. In `provider-type=auto`, it chooses Headroom's Anthropic route for the default proxy backend and the OpenAI-compatible `/v1` route for translated backends such as `anyllm` and LiteLLM.
Anonymous aggregate telemetry is enabled by default. Opt out with `HEADROOM_TELEMETRY=off` or `headroom proxy --no-telemetry`. Downstream apps can set `HEADROOM_SDK=headroom-app` to override the anonymous telemetry `sdk` label; the default remains `proxy`.
Operational OTEL metrics are configured separately and are **off by default**. Install `headroom-ai[proxy,otel]` and set:
@ -183,6 +198,7 @@ Response:
"ready": true,
"version": "0.5.21",
"config": {
"backend": "anthropic",
"optimize": true,
"cache": true,
"rate_limit": true

View file

@ -41,7 +41,7 @@ headroom proxy --port 8787
```bash
curl http://localhost:8787/health
# Expected: {"status": "healthy", "mode": "optimize", ...}
# Expected: {"status":"healthy","ready":true,"config":{"backend":"anthropic",...},...}
```
### Step 3: Point Your Client
@ -50,6 +50,9 @@ curl http://localhost:8787/health
# Claude Code
ANTHROPIC_BASE_URL=http://localhost:8787 claude
# GitHub Copilot CLI (default Anthropic-style proxy route)
headroom wrap copilot -- --model claude-sonnet-4-20250514
# Cursor / Continue / any OpenAI client
OPENAI_BASE_URL=http://localhost:8787/v1 your-app

View file

@ -2,6 +2,7 @@
Usage:
headroom wrap claude # Start proxy + rtk + claude
headroom wrap copilot -- --model ... # Start proxy + launch GitHub Copilot CLI
headroom wrap codex # Start proxy + OpenAI Codex CLI
headroom wrap aider # Start proxy + aider
headroom wrap cursor # Start proxy + print Cursor config instructions
@ -22,6 +23,8 @@ import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
@ -327,6 +330,46 @@ def _inject_rtk_instructions(file_path: Path, verbose: bool = False) -> bool:
return True
def _resolve_copilot_provider_type(backend: str | None, provider_type: str) -> str:
"""Resolve Copilot BYOK provider type for the current proxy backend."""
if provider_type != "auto":
return provider_type
effective_backend = backend or os.environ.get("HEADROOM_BACKEND") or "anthropic"
return "anthropic" if effective_backend == "anthropic" else "openai"
def _detect_running_proxy_backend(port: int) -> str | None:
"""Read the backend of an already-running proxy from its health endpoint."""
url = f"http://127.0.0.1:{port}/health"
try:
with urllib.request.urlopen(url, timeout=1) as response:
payload = json.loads(response.read().decode("utf-8"))
except (OSError, urllib.error.URLError, ValueError, json.JSONDecodeError):
return None
config = payload.get("config")
if not isinstance(config, dict):
return None
backend = config.get("backend")
return backend if isinstance(backend, str) else None
def _copilot_model_configured(copilot_args: tuple[str, ...], env: dict[str, str]) -> bool:
"""Return True when Copilot BYOK model selection is configured."""
if env.get("COPILOT_MODEL") or env.get("COPILOT_PROVIDER_MODEL_ID"):
return True
for idx, arg in enumerate(copilot_args):
if arg == "--model" and idx + 1 < len(copilot_args):
return True
if arg.startswith("--model="):
return True
return False
def _ensure_proxy(
port: int,
no_proxy: bool,
@ -675,6 +718,7 @@ def wrap() -> None:
\b
Supported tools:
headroom wrap claude # Claude Code (Anthropic)
headroom wrap copilot -- --model claude-sonnet-4-20250514
headroom wrap codex # OpenAI Codex CLI
headroom wrap aider # Aider
headroom wrap cursor # Cursor (prints config instructions)
@ -768,6 +812,153 @@ def claude(
cleanup()
# =============================================================================
# GitHub Copilot CLI
# =============================================================================
@wrap.command(context_settings={"ignore_unknown_options": True})
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--no-rtk",
is_flag=True,
help="Skip rtk installation and Copilot instructions injection",
)
@click.option("--no-proxy", is_flag=True, help="Skip proxy startup (use existing proxy)")
@click.option(
"--backend",
default=None,
help="API backend for the proxy: 'anthropic', 'anyllm', 'litellm-vertex', etc. (env: HEADROOM_BACKEND)",
)
@click.option(
"--anyllm-provider",
default=None,
help="Provider for any-llm backend: openai, mistral, groq, etc. (env: HEADROOM_ANYLLM_PROVIDER)",
)
@click.option(
"--region", default=None, help="Cloud region for Bedrock/Vertex (env: HEADROOM_REGION)"
)
@click.option(
"--provider-type",
type=click.Choice(["auto", "anthropic", "openai"]),
default="auto",
show_default=True,
help="Copilot BYOK provider mode. 'auto' uses anthropic for the default proxy backend and openai for translated backends.",
)
@click.option(
"--wire-api",
type=click.Choice(["completions", "responses"]),
default=None,
help="OpenAI-compatible Copilot wire API. Defaults to 'completions' when provider-type resolves to openai.",
)
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
@click.argument("copilot_args", nargs=-1, type=click.UNPROCESSED)
def copilot(
port: int,
no_rtk: bool,
no_proxy: bool,
backend: str | None,
anyllm_provider: str | None,
region: str | None,
provider_type: str,
wire_api: str | None,
verbose: bool,
copilot_args: tuple[str, ...],
) -> None:
"""Launch GitHub Copilot CLI through Headroom proxy.
\b
Configures Copilot CLI BYOK provider variables so Copilot routes through
the local Headroom proxy. In auto mode, the wrapper uses Anthropic-style
routing for the stock proxy backend and OpenAI-compatible routing for
translated backends such as any-llm and LiteLLM.
\b
Examples:
headroom wrap copilot -- --model claude-sonnet-4-20250514
headroom wrap copilot --backend anyllm --anyllm-provider groq -- --model gpt-4o
headroom wrap copilot --provider-type openai --wire-api responses -- --model gpt-5.4
headroom wrap copilot --no-rtk -- --prompt "explain this file"
"""
copilot_bin = shutil.which("copilot")
if not copilot_bin:
click.echo("Error: 'copilot' not found in PATH.")
click.echo(
"Install GitHub Copilot CLI: "
"https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli"
)
raise SystemExit(1)
effective_backend = backend or os.environ.get("HEADROOM_BACKEND")
if _check_proxy(port):
running_backend = _detect_running_proxy_backend(port)
if effective_backend and running_backend and effective_backend != running_backend:
raise click.ClickException(
f"Proxy already running on port {port} with backend '{running_backend}'. "
f"Stop it or rerun with --backend {running_backend}."
)
effective_backend = running_backend or effective_backend
effective_provider_type = _resolve_copilot_provider_type(effective_backend, provider_type)
if effective_provider_type == "anthropic" and wire_api is not None:
raise click.ClickException(
"--wire-api is only valid when Copilot is using the openai provider type."
)
if wire_api == "responses" and effective_backend not in (None, "anthropic"):
raise click.ClickException(
"--wire-api responses is not supported with translated backends; use completions."
)
if not no_rtk:
click.echo(" Setting up rtk for Copilot...")
rtk_path = _ensure_rtk_binary(verbose=verbose)
if rtk_path:
copilot_instructions = Path.cwd() / ".github" / "copilot-instructions.md"
_inject_rtk_instructions(copilot_instructions, verbose=verbose)
env = os.environ.copy()
env["COPILOT_PROVIDER_TYPE"] = effective_provider_type
env.pop("COPILOT_PROVIDER_WIRE_API", None)
env_vars_display: list[str]
if effective_provider_type == "anthropic":
env["COPILOT_PROVIDER_BASE_URL"] = f"http://127.0.0.1:{port}"
env_vars_display = [
"COPILOT_PROVIDER_TYPE=anthropic",
f"COPILOT_PROVIDER_BASE_URL=http://127.0.0.1:{port}",
]
else:
effective_wire_api = wire_api or "completions"
env["COPILOT_PROVIDER_BASE_URL"] = f"http://127.0.0.1:{port}/v1"
env["COPILOT_PROVIDER_WIRE_API"] = effective_wire_api
env_vars_display = [
"COPILOT_PROVIDER_TYPE=openai",
f"COPILOT_PROVIDER_BASE_URL=http://127.0.0.1:{port}/v1",
f"COPILOT_PROVIDER_WIRE_API={effective_wire_api}",
]
if not _copilot_model_configured(copilot_args, env):
click.echo(
" Note: Copilot BYOK requires a model. Pass `--model <name>` "
"or set `COPILOT_MODEL` / `COPILOT_PROVIDER_MODEL_ID`."
)
_launch_tool(
binary=copilot_bin,
args=copilot_args,
env=env,
port=port,
no_proxy=no_proxy,
tool_label="COPILOT",
env_vars_display=env_vars_display,
learn=False,
agent_type="copilot",
backend=backend,
anyllm_provider=anyllm_provider,
region=region,
)
# =============================================================================
# OpenAI Codex CLI
# =============================================================================

View file

@ -998,7 +998,7 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
def _uptime_seconds() -> float:
started_at = getattr(app.state, "started_at", None)
if not isinstance(started_at, (int, float)):
if not isinstance(started_at, int | float):
return 0.0
return round(max(0.0, time.time() - float(started_at)), 3)
@ -1072,6 +1072,7 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
}
if include_config:
payload["config"] = {
"backend": config.backend,
"optimize": config.optimize,
"cache": config.cache_enabled,
"rate_limit": config.rate_limit_enabled,

View file

@ -0,0 +1,183 @@
"""Tests for `headroom wrap copilot` command."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from headroom.cli import wrap as wrap_cli
from headroom.cli.main import main
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
def test_wrap_copilot_auto_anthropic_injects_instructions(
runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch("headroom.cli.wrap.shutil.which", return_value="copilot"):
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=Path("/tmp/rtk")):
with patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool):
result = runner.invoke(
main,
["wrap", "copilot", "--", "--model", "claude-sonnet-4-20250514"],
)
assert result.exit_code == 0, result.output
instructions = tmp_path / ".github" / "copilot-instructions.md"
assert instructions.exists()
content = instructions.read_text()
assert wrap_cli._RTK_MARKER in content
assert "RTK (Rust Token Killer)" in content
env = captured["env"]
assert isinstance(env, dict)
assert env["COPILOT_PROVIDER_TYPE"] == "anthropic"
assert env["COPILOT_PROVIDER_BASE_URL"] == "http://127.0.0.1:8787"
assert "COPILOT_PROVIDER_WIRE_API" not in env
assert captured["agent_type"] == "copilot"
assert captured["tool_label"] == "COPILOT"
assert captured["args"] == ("--model", "claude-sonnet-4-20250514")
def test_wrap_copilot_openai_backend_sets_completions_env(runner: CliRunner) -> None:
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch("headroom.cli.wrap.shutil.which", return_value="copilot"):
with patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool):
result = runner.invoke(
main,
[
"wrap",
"copilot",
"--no-rtk",
"--backend",
"anyllm",
"--anyllm-provider",
"groq",
"--region",
"us-central1",
"--",
"--model",
"gpt-4o",
],
)
assert result.exit_code == 0, result.output
env = captured["env"]
assert isinstance(env, dict)
assert env["COPILOT_PROVIDER_TYPE"] == "openai"
assert env["COPILOT_PROVIDER_BASE_URL"] == "http://127.0.0.1:8787/v1"
assert env["COPILOT_PROVIDER_WIRE_API"] == "completions"
assert captured["backend"] == "anyllm"
assert captured["anyllm_provider"] == "groq"
assert captured["region"] == "us-central1"
assert captured["args"] == ("--model", "gpt-4o")
def test_wrap_copilot_auto_detects_running_proxy_backend(runner: CliRunner) -> None:
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch("headroom.cli.wrap.shutil.which", return_value="copilot"):
with patch("headroom.cli.wrap._check_proxy", return_value=True):
with patch("headroom.cli.wrap._detect_running_proxy_backend", return_value="anyllm"):
with patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool):
result = runner.invoke(
main,
["wrap", "copilot", "--no-rtk", "--", "--model", "gpt-4o"],
)
assert result.exit_code == 0, result.output
env = captured["env"]
assert isinstance(env, dict)
assert env["COPILOT_PROVIDER_TYPE"] == "openai"
assert env["COPILOT_PROVIDER_BASE_URL"] == "http://127.0.0.1:8787/v1"
assert env["COPILOT_PROVIDER_WIRE_API"] == "completions"
def test_wrap_copilot_rejects_wire_api_for_anthropic_provider(runner: CliRunner) -> None:
with patch("headroom.cli.wrap.shutil.which", return_value="copilot"):
result = runner.invoke(
main,
[
"wrap",
"copilot",
"--wire-api",
"responses",
"--",
"--model",
"claude-sonnet-4-20250514",
],
)
assert result.exit_code != 0
assert "--wire-api is only valid" in result.output
def test_wrap_copilot_rejects_responses_for_translated_backends(runner: CliRunner) -> None:
with patch("headroom.cli.wrap.shutil.which", return_value="copilot"):
result = runner.invoke(
main,
[
"wrap",
"copilot",
"--backend",
"anyllm",
"--wire-api",
"responses",
"--",
"--model",
"gpt-4o",
],
)
assert result.exit_code != 0
assert "not supported with translated backends" in result.output
def test_wrap_copilot_clears_stale_wire_api_in_anthropic_mode(runner: CliRunner) -> None:
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch("headroom.cli.wrap.shutil.which", return_value="copilot"):
with patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool):
result = runner.invoke(
main,
["wrap", "copilot", "--no-rtk", "--", "--model", "claude-sonnet-4-20250514"],
env={"COPILOT_PROVIDER_WIRE_API": "responses"},
)
assert result.exit_code == 0, result.output
env = captured["env"]
assert isinstance(env, dict)
assert env["COPILOT_PROVIDER_TYPE"] == "anthropic"
assert "COPILOT_PROVIDER_WIRE_API" not in env
def test_wrap_copilot_fails_when_binary_missing(runner: CliRunner) -> None:
with patch("headroom.cli.wrap.shutil.which", return_value=None):
result = runner.invoke(main, ["wrap", "copilot", "--", "--model", "gpt-4o"])
assert result.exit_code == 1
assert "'copilot' not found in PATH" in result.output
assert "Install GitHub Copilot CLI" in result.output

View file

@ -57,6 +57,7 @@ def test_health_preserves_backwards_compatible_config_payload(client):
assert data["status"] == "healthy"
assert data["ready"] is True
assert data["config"] == {
"backend": "anthropic",
"optimize": False,
"cache": False,
"rate_limit": False,