Skip to content

Commit 204e751

Browse files
chopratejasTejas Chopraclaude
authored
fix(copilot): route VS Code inline completions to Copilot, not OpenAI (#3077)
## Description Fixes #3076. When `github.copilot.advanced.debug.overrideProxyUrl` points at Headroom, the VS Code Copilot extension sends its inline ("ghost text") completions to `/v1/engines/<engine>/completions`. No route matches that path, so it falls into the catch-all passthrough — and `select_passthrough_base_url()` resolves an upstream from the **auth headers alone**, never looking at the path. Copilot sends none of the headers the earlier branches key on, so the request reached the final line (default to OpenAI) and Headroom forwarded editor keystrokes to: ``` https://api.openai.com/v1/engines/gpt-41-copilot/completions ``` Wrong under every configuration — OpenAI removed the Engines API years ago — and blocked outright on corporate networks that permit GitHub Copilot but not OpenAI, which is how it was reported. Inline completions stopped working for every user behind such a policy. The Copilot **CLI** was unaffected: it speaks the CAPI shape (`/chat/completions`), which already resolved correctly. That is the exact asymmetry in the report. ## Type of Change - [x] Bug fix ## Changes Made **Routing.** `select_passthrough_base_url()` now takes the request path and sends this one path to Copilot. The shape identifies Copilot on its own, so the redirect is unambiguous. It is scoped to the OpenAI fall-through — the branch that is wrong here — because every other branch reflects an upstream the caller chose with its own auth headers. **The destination is not hardcoded.** GitHub's token exchange advertises the completions host in `endpoints.proxy`, alongside the `endpoints.api` chat host Headroom already reads. It is now recorded at the single chokepoint every exchange passes through, and preferred. Resolution order: 1. `GITHUB_COPILOT_PROXY_URL` — operator override 2. `endpoints.proxy` from the last token exchange — GitHub's own answer 3. The Copilot API URL No I/O on the request path, and GHE deployments keep their host. This matters: it means the destination is not an assumption about which host serves completions, and if it is wrong for a given network it is an env var rather than a release. **Path preservation.** `build_copilot_upstream_url()` strips `/v1` when the upstream is a Copilot host, because Copilot serves its OpenAI-compatible surface unprefixed (`/chat/completions`, `/models`). But the extension built `/v1/engines/<engine>/completions` itself, so that path is already exactly what Copilot serves — stripping the prefix rewrites a working request into a 404. Preserved, the same carve-out `/v1/messages` needed in #2409. The rule: strip only for clients speaking generic-OpenAI at Copilot, never for Copilot's own paths. ## Testing - [x] New suite: `tests/test_copilot_vscode_completions_routing.py` (30 tests) — path recognition and its near-misses, upstream selection, the `endpoints.proxy` resolution order, and URL construction in both directions - [x] 286 passed across the Copilot, provider-routing and passthrough suites - [x] Ruff check and format pass ### Real Behavior Proof Environment: this branch, a `POST /v1/engines/gpt-41-copilot/completions` driven through the real app with `OPENAI_API_URL=https://api.openai.com` and the outbound HTTP client captured. ``` BEFORE (main): https://api.openai.com/v1/engines/gpt-41-copilot/completions AFTER (this): https://api.githubcopilot.com/v1/engines/gpt-41-copilot/completions ``` The "before" line reproduces the reported URL exactly. **Not tested:** a live VS Code Copilot session confirming GitHub accepts the forwarded request. That needs a real Copilot account and editor. If the completions host turns out to differ, the `endpoints.proxy` lookup or `GITHUB_COPILOT_PROXY_URL` covers it without a code change. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 6d2254d commit 204e751

4 files changed

Lines changed: 410 additions & 5 deletions

File tree

headroom/copilot_auth.py

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,65 @@ def _configured_api_url() -> str:
245245
return DEFAULT_API_URL
246246

247247

248+
def copilot_api_url() -> str:
249+
"""Return the configured Copilot API base URL without any network calls.
250+
251+
Resolves ``GITHUB_COPILOT_API_URL``, then the configured enterprise domain,
252+
then ``api.githubcopilot.com``. Unlike :func:`resolve_copilot_api_url` this
253+
performs no token exchange, so it is safe to call while routing a request.
254+
"""
255+
256+
return _configured_api_url()
257+
258+
259+
# GitHub's token exchange advertises the host that serves inline completions
260+
# under ``endpoints.proxy``, alongside the ``endpoints.api`` chat host. It is
261+
# recorded here when observed so completions routing uses GitHub's own answer
262+
# instead of an assumption about which host serves that endpoint (#3076).
263+
_observed_completions_base_url: str | None = None
264+
265+
266+
def _remember_completions_endpoint(payload: Any) -> None:
267+
"""Record the completions host advertised by a token-exchange payload."""
268+
269+
global _observed_completions_base_url
270+
endpoints = payload.get("endpoints") if isinstance(payload, dict) else None
271+
proxy_url = endpoints.get("proxy") if isinstance(endpoints, dict) else None
272+
if isinstance(proxy_url, str) and proxy_url.strip():
273+
_observed_completions_base_url = proxy_url.strip().rstrip("/")
274+
275+
276+
def reset_observed_completions_endpoint() -> None:
277+
"""Forget the advertised completions host (test isolation)."""
278+
279+
global _observed_completions_base_url
280+
_observed_completions_base_url = None
281+
282+
283+
def copilot_completions_base_url() -> str:
284+
"""Return the base URL serving Copilot's inline-completions endpoint.
285+
286+
Resolution order, most authoritative first:
287+
288+
1. ``GITHUB_COPILOT_PROXY_URL`` — an explicit operator override, so a
289+
network that fronts Copilot behind its own gateway (or a GitHub change
290+
to this endpoint) is a config edit rather than a code change.
291+
2. ``endpoints.proxy`` from the last Copilot token exchange — GitHub
292+
telling us directly where completions go.
293+
3. The Copilot API URL, which is where GitHub's consolidated surface
294+
serves them.
295+
296+
Never performs I/O; step 2 only reads what a previous exchange recorded.
297+
"""
298+
299+
override = os.environ.get("GITHUB_COPILOT_PROXY_URL", "").strip()
300+
if override:
301+
return override.rstrip("/")
302+
if _observed_completions_base_url:
303+
return _observed_completions_base_url
304+
return copilot_api_url()
305+
306+
248307
def _github_oauth_domain(domain: str | None = None) -> str:
249308
raw = (domain or DEFAULT_GITHUB_HOST).strip()
250309
if not raw:
@@ -1056,6 +1115,29 @@ def reset_request_routed_to_copilot() -> None:
10561115
_request_routed_to_copilot.set(False)
10571116

10581117

1118+
def is_copilot_completions_path(path: str) -> bool:
1119+
"""Return True for Copilot's inline-completions ("ghost text") endpoint.
1120+
1121+
The Copilot editor extensions send code completions to
1122+
``/v1/engines/<engine>/completions`` on whatever host
1123+
``github.copilot.advanced.debug.overrideProxyUrl`` names — so when that
1124+
setting points at Headroom, this is the path that arrives.
1125+
1126+
The shape identifies GitHub Copilot on its own. OpenAI's Engines API was
1127+
removed years ago and no other provider Headroom fronts serves it, so a
1128+
request on this path is Copilot's and can never be answered by the default
1129+
OpenAI target (#3076).
1130+
"""
1131+
1132+
normalized = (path if path.startswith("/") else f"/{path}").rstrip("/")
1133+
prefix = "/v1/engines/"
1134+
suffix = "/completions"
1135+
if not normalized.startswith(prefix) or not normalized.endswith(suffix):
1136+
return False
1137+
engine = normalized[len(prefix) : -len(suffix)]
1138+
return bool(engine) and "/" not in engine
1139+
1140+
10591141
def build_copilot_upstream_url(base_url: str, path: str) -> str:
10601142
"""Build an upstream URL, normalizing GitHub Copilot's non-/v1 path layout."""
10611143

@@ -1071,7 +1153,17 @@ def build_copilot_upstream_url(base_url: str, path: str) -> str:
10711153
# Anthropic surface for Claude models IS ``/v1/messages`` (with the
10721154
# ``/v1``); stripping it forwarded ``/messages`` and Copilot returned 404
10731155
# for claude-* models (#2409). Keep ``/v1`` for the messages endpoint.
1074-
if normalized_path.startswith("/v1/") and not normalized_path.startswith("/v1/messages"):
1156+
#
1157+
# Inline completions are the same story: the Copilot extension itself
1158+
# builds ``/v1/engines/<engine>/completions``, so the path that reaches
1159+
# us is already the exact path Copilot serves. Stripping ``/v1`` there
1160+
# rewrites a Copilot-native path into one that 404s (#3076). The rule
1161+
# this encodes: strip only for clients speaking generic-OpenAI at
1162+
# Copilot, never for Copilot's own paths.
1163+
keep_v1 = normalized_path.startswith("/v1/messages") or is_copilot_completions_path(
1164+
normalized_path
1165+
)
1166+
if normalized_path.startswith("/v1/") and not keep_v1:
10751167
normalized_path = normalized_path[3:]
10761168
else:
10771169
reset_request_routed_to_copilot()
@@ -1207,7 +1299,12 @@ def _exchange_token_sync(headers: dict[str, str]) -> dict[str, Any]:
12071299
try:
12081300
with urllib_request.urlopen(request, timeout=10.0) as response:
12091301
payload = json.loads(response.read().decode("utf-8"))
1210-
return payload if isinstance(payload, dict) else {}
1302+
if not isinstance(payload, dict):
1303+
return {}
1304+
# Every exchange funnels through here, so this is the one place
1305+
# that sees GitHub's advertised completions host (#3076).
1306+
_remember_completions_endpoint(payload)
1307+
return payload
12111308
except urllib_error.HTTPError as exc:
12121309
body = exc.read().decode("utf-8", errors="replace")
12131310
raise RuntimeError(

headroom/providers/proxy_routes.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,5 +530,7 @@ async def passthrough(request: Request, path: str):
530530

531531
return await proxy.handle_passthrough(
532532
request,
533-
_select_passthrough_base_url(proxy, dict(request.headers)),
533+
# The path matters here: this is where unrouted paths land, and
534+
# Copilot's inline completions are one of them (#3076).
535+
_select_passthrough_base_url(proxy, dict(request.headers), request.url.path),
534536
)

headroom/providers/proxy_targets.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
from collections.abc import Mapping
66
from typing import Any, cast
77

8+
from headroom.copilot_auth import (
9+
copilot_completions_base_url,
10+
is_copilot_api_url,
11+
is_copilot_completions_path,
12+
)
813
from headroom.providers.codex import resolve_codex_routing
914
from headroom.providers.codex.endpoints import CHATGPT_BACKEND_API_URL
1015
from headroom.providers.vertex import vertex_target_for_location as _vertex_target_for_location
@@ -29,7 +34,9 @@ def vertex_target_for_location(proxy: Any, location: str) -> str:
2934
return _vertex_target_for_location(api_target(proxy, "vertex"), location)
3035

3136

32-
def select_passthrough_base_url(proxy: Any, headers: Mapping[str, str]) -> str:
37+
def select_passthrough_base_url(
38+
proxy: Any, headers: Mapping[str, str], path: str | None = None
39+
) -> str:
3340
"""Resolve the upstream base URL for catch-all proxy passthrough requests."""
3441
routing = resolve_codex_routing(headers)
3542
if routing.is_chatgpt_auth:
@@ -41,4 +48,36 @@ def select_passthrough_base_url(proxy: Any, headers: Mapping[str, str]) -> str:
4148
if azure_base:
4249
return azure_base.rstrip("/")
4350
provider_name = proxy.provider_runtime.model_metadata_provider(headers)
44-
return api_target(proxy, provider_name)
51+
target = api_target(proxy, provider_name)
52+
if (
53+
path is not None
54+
and provider_name == "openai"
55+
and is_copilot_completions_path(path)
56+
and not is_copilot_api_url(target)
57+
):
58+
# Copilot's inline completions arrive here because
59+
# `/v1/engines/<engine>/completions` matches no built-in route. Nothing
60+
# above this line looks at the path, so the request fell through to the
61+
# OpenAI target and Headroom forwarded editor keystrokes to
62+
# api.openai.com — a host that has not served the Engines API for years,
63+
# and one many corporate networks block outright (#3076).
64+
#
65+
# Only Copilot emits this path, so sending it to Copilot is unambiguous.
66+
# `copilot_completions_base_url()` does no I/O: it prefers an operator
67+
# override, then the completions host GitHub advertised in the last
68+
# token exchange, then the Copilot API URL — so the destination is
69+
# GitHub's own answer where we have it rather than a hardcoded guess,
70+
# and GHE deployments keep their host.
71+
#
72+
# When the target is already a Copilot host — `headroom wrap vscode`
73+
# points the OpenAI target at the resolved subscription URL — it is left
74+
# alone, so an account-specific host is never overwritten with the
75+
# generic one.
76+
#
77+
# Scoped to the OpenAI fall-through, which is the branch that is wrong
78+
# for this path. Every other branch above reflects a deliberate choice
79+
# of upstream by the caller's own auth headers, and the Copilot editor
80+
# extension sends none of them — so a request that took one of those
81+
# branches is not Copilot's and keeps the upstream it asked for.
82+
return copilot_completions_base_url()
83+
return target

0 commit comments

Comments
 (0)