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>
This commit is contained in:
Tejas Chopra 2026-08-17 08:31:01 -07:00 committed by GitHub
parent 6d2254dfb5
commit 204e751d2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 410 additions and 5 deletions

View file

@ -245,6 +245,65 @@ def _configured_api_url() -> str:
return DEFAULT_API_URL
def copilot_api_url() -> str:
"""Return the configured Copilot API base URL without any network calls.
Resolves ``GITHUB_COPILOT_API_URL``, then the configured enterprise domain,
then ``api.githubcopilot.com``. Unlike :func:`resolve_copilot_api_url` this
performs no token exchange, so it is safe to call while routing a request.
"""
return _configured_api_url()
# GitHub's token exchange advertises the host that serves inline completions
# under ``endpoints.proxy``, alongside the ``endpoints.api`` chat host. It is
# recorded here when observed so completions routing uses GitHub's own answer
# instead of an assumption about which host serves that endpoint (#3076).
_observed_completions_base_url: str | None = None
def _remember_completions_endpoint(payload: Any) -> None:
"""Record the completions host advertised by a token-exchange payload."""
global _observed_completions_base_url
endpoints = payload.get("endpoints") if isinstance(payload, dict) else None
proxy_url = endpoints.get("proxy") if isinstance(endpoints, dict) else None
if isinstance(proxy_url, str) and proxy_url.strip():
_observed_completions_base_url = proxy_url.strip().rstrip("/")
def reset_observed_completions_endpoint() -> None:
"""Forget the advertised completions host (test isolation)."""
global _observed_completions_base_url
_observed_completions_base_url = None
def copilot_completions_base_url() -> str:
"""Return the base URL serving Copilot's inline-completions endpoint.
Resolution order, most authoritative first:
1. ``GITHUB_COPILOT_PROXY_URL`` an explicit operator override, so a
network that fronts Copilot behind its own gateway (or a GitHub change
to this endpoint) is a config edit rather than a code change.
2. ``endpoints.proxy`` from the last Copilot token exchange GitHub
telling us directly where completions go.
3. The Copilot API URL, which is where GitHub's consolidated surface
serves them.
Never performs I/O; step 2 only reads what a previous exchange recorded.
"""
override = os.environ.get("GITHUB_COPILOT_PROXY_URL", "").strip()
if override:
return override.rstrip("/")
if _observed_completions_base_url:
return _observed_completions_base_url
return copilot_api_url()
def _github_oauth_domain(domain: str | None = None) -> str:
raw = (domain or DEFAULT_GITHUB_HOST).strip()
if not raw:
@ -1056,6 +1115,29 @@ def reset_request_routed_to_copilot() -> None:
_request_routed_to_copilot.set(False)
def is_copilot_completions_path(path: str) -> bool:
"""Return True for Copilot's inline-completions ("ghost text") endpoint.
The Copilot editor extensions send code completions to
``/v1/engines/<engine>/completions`` on whatever host
``github.copilot.advanced.debug.overrideProxyUrl`` names so when that
setting points at Headroom, this is the path that arrives.
The shape identifies GitHub Copilot on its own. OpenAI's Engines API was
removed years ago and no other provider Headroom fronts serves it, so a
request on this path is Copilot's and can never be answered by the default
OpenAI target (#3076).
"""
normalized = (path if path.startswith("/") else f"/{path}").rstrip("/")
prefix = "/v1/engines/"
suffix = "/completions"
if not normalized.startswith(prefix) or not normalized.endswith(suffix):
return False
engine = normalized[len(prefix) : -len(suffix)]
return bool(engine) and "/" not in engine
def build_copilot_upstream_url(base_url: str, path: str) -> str:
"""Build an upstream URL, normalizing GitHub Copilot's non-/v1 path layout."""
@ -1071,7 +1153,17 @@ def build_copilot_upstream_url(base_url: str, path: str) -> str:
# Anthropic surface for Claude models IS ``/v1/messages`` (with the
# ``/v1``); stripping it forwarded ``/messages`` and Copilot returned 404
# for claude-* models (#2409). Keep ``/v1`` for the messages endpoint.
if normalized_path.startswith("/v1/") and not normalized_path.startswith("/v1/messages"):
#
# Inline completions are the same story: the Copilot extension itself
# builds ``/v1/engines/<engine>/completions``, so the path that reaches
# us is already the exact path Copilot serves. Stripping ``/v1`` there
# rewrites a Copilot-native path into one that 404s (#3076). The rule
# this encodes: strip only for clients speaking generic-OpenAI at
# Copilot, never for Copilot's own paths.
keep_v1 = normalized_path.startswith("/v1/messages") or is_copilot_completions_path(
normalized_path
)
if normalized_path.startswith("/v1/") and not keep_v1:
normalized_path = normalized_path[3:]
else:
reset_request_routed_to_copilot()
@ -1207,7 +1299,12 @@ class CopilotTokenProvider:
try:
with urllib_request.urlopen(request, timeout=10.0) as response:
payload = json.loads(response.read().decode("utf-8"))
return payload if isinstance(payload, dict) else {}
if not isinstance(payload, dict):
return {}
# Every exchange funnels through here, so this is the one place
# that sees GitHub's advertised completions host (#3076).
_remember_completions_endpoint(payload)
return payload
except urllib_error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(

View file

@ -530,5 +530,7 @@ def register_provider_routes(app: FastAPI, proxy: Any) -> None:
return await proxy.handle_passthrough(
request,
_select_passthrough_base_url(proxy, dict(request.headers)),
# The path matters here: this is where unrouted paths land, and
# Copilot's inline completions are one of them (#3076).
_select_passthrough_base_url(proxy, dict(request.headers), request.url.path),
)

View file

@ -5,6 +5,11 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import Any, cast
from headroom.copilot_auth import (
copilot_completions_base_url,
is_copilot_api_url,
is_copilot_completions_path,
)
from headroom.providers.codex import resolve_codex_routing
from headroom.providers.codex.endpoints import CHATGPT_BACKEND_API_URL
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:
return _vertex_target_for_location(api_target(proxy, "vertex"), location)
def select_passthrough_base_url(proxy: Any, headers: Mapping[str, str]) -> str:
def select_passthrough_base_url(
proxy: Any, headers: Mapping[str, str], path: str | None = None
) -> str:
"""Resolve the upstream base URL for catch-all proxy passthrough requests."""
routing = resolve_codex_routing(headers)
if routing.is_chatgpt_auth:
@ -41,4 +48,36 @@ def select_passthrough_base_url(proxy: Any, headers: Mapping[str, str]) -> str:
if azure_base:
return azure_base.rstrip("/")
provider_name = proxy.provider_runtime.model_metadata_provider(headers)
return api_target(proxy, provider_name)
target = api_target(proxy, provider_name)
if (
path is not None
and provider_name == "openai"
and is_copilot_completions_path(path)
and not is_copilot_api_url(target)
):
# Copilot's inline completions arrive here because
# `/v1/engines/<engine>/completions` matches no built-in route. Nothing
# above this line looks at the path, so the request fell through to the
# OpenAI target and Headroom forwarded editor keystrokes to
# api.openai.com — a host that has not served the Engines API for years,
# and one many corporate networks block outright (#3076).
#
# Only Copilot emits this path, so sending it to Copilot is unambiguous.
# `copilot_completions_base_url()` does no I/O: it prefers an operator
# override, then the completions host GitHub advertised in the last
# token exchange, then the Copilot API URL — so the destination is
# GitHub's own answer where we have it rather than a hardcoded guess,
# and GHE deployments keep their host.
#
# When the target is already a Copilot host — `headroom wrap vscode`
# points the OpenAI target at the resolved subscription URL — it is left
# alone, so an account-specific host is never overwritten with the
# generic one.
#
# Scoped to the OpenAI fall-through, which is the branch that is wrong
# for this path. Every other branch above reflects a deliberate choice
# of upstream by the caller's own auth headers, and the Copilot editor
# extension sends none of them — so a request that took one of those
# branches is not Copilot's and keeps the upstream it asked for.
return copilot_completions_base_url()
return target

View file

@ -0,0 +1,267 @@
"""VS Code Copilot inline completions must reach Copilot, not OpenAI (#3076).
When `github.copilot.advanced.debug.overrideProxyUrl` points at Headroom, the
Copilot extension sends its "ghost text" completions to
``/v1/engines/<engine>/completions``. Headroom registers no route for that path,
so it lands in the catch-all passthrough which resolves an upstream from the
auth headers alone and therefore fell through to the OpenAI target. Editor
keystrokes were forwarded to ``api.openai.com``, a host that has not served the
Engines API for years and that corporate networks routinely block.
Two things have to hold for the round trip: the path has to select the Copilot
API, and it has to survive Copilot's ``/v1``-stripping intact, because the
extension already built the exact path Copilot serves.
"""
from __future__ import annotations
import pytest
from headroom import copilot_auth
from headroom.copilot_auth import (
build_copilot_upstream_url,
copilot_completions_base_url,
is_copilot_completions_path,
reset_observed_completions_endpoint,
)
from headroom.providers.proxy_targets import select_passthrough_base_url
COPILOT_API = "https://api.githubcopilot.com"
COMPLETIONS = "/v1/engines/gpt-41-copilot/completions"
def _proxy(**legacy_targets: str):
class Runtime:
@staticmethod
def api_target(provider: str) -> str:
return f"https://runtime.{provider}.test"
@staticmethod
def model_metadata_provider(headers) -> str: # type: ignore[no-untyped-def]
return "anthropic" if headers.get("x-api-key") else "openai"
return type("Proxy", (), {**legacy_targets, "provider_runtime": Runtime()})()
@pytest.fixture(autouse=True)
def _no_ambient_copilot_config(monkeypatch: pytest.MonkeyPatch):
"""Resolve the Copilot URL from a clean environment, not the dev's own."""
for var in (
"GITHUB_COPILOT_API_URL",
"GITHUB_COPILOT_ENTERPRISE_URL",
"GITHUB_COPILOT_PROXY_URL",
):
monkeypatch.delenv(var, raising=False)
reset_observed_completions_endpoint()
yield
reset_observed_completions_endpoint()
# --------------------------------------------------------------------------- #
# Path recognition
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"path",
[
COMPLETIONS,
"/v1/engines/copilot-codex/completions",
# A trailing slash is still the same endpoint.
"/v1/engines/gpt-41-copilot/completions/",
],
)
def test_copilot_completions_paths_are_recognised(path: str) -> None:
assert is_copilot_completions_path(path) is True
@pytest.mark.parametrize(
"path",
[
# The OpenAI-compatible surface, which must keep its existing routing.
"/v1/chat/completions",
"/chat/completions",
"/v1/messages",
"/models",
# Shape-alike paths that are not the completions endpoint. Matching
# these would divert unrelated traffic to Copilot.
"/v1/engines/gpt-41-copilot",
"/v1/engines//completions",
"/v1/engines/a/b/completions",
"/v2/engines/gpt-41-copilot/completions",
],
)
def test_other_paths_are_not_mistaken_for_completions(path: str) -> None:
assert is_copilot_completions_path(path) is False
# --------------------------------------------------------------------------- #
# Upstream selection
# --------------------------------------------------------------------------- #
def test_completions_do_not_fall_through_to_the_openai_target() -> None:
"""The reported bug: keystrokes forwarded to api.openai.com."""
proxy = _proxy(OPENAI_API_URL="https://api.openai.com")
assert select_passthrough_base_url(proxy, {}, COMPLETIONS) == COPILOT_API
def test_an_account_specific_copilot_host_is_left_alone() -> None:
"""`headroom wrap vscode` points the OpenAI target at the resolved host.
That URL is account-specific (individual/business/enterprise), so replacing
it with the generic one would route a subscriber to the wrong tenant.
"""
proxy = _proxy(OPENAI_API_URL="https://api.business.githubcopilot.com")
assert (
select_passthrough_base_url(proxy, {}, COMPLETIONS)
== "https://api.business.githubcopilot.com"
)
def test_enterprise_deployments_keep_their_own_copilot_host(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The redirect is env-resolved, so a GHE tenant is not sent to github.com."""
monkeypatch.setenv("GITHUB_COPILOT_API_URL", "https://copilot-api.acme.ghe.com")
proxy = _proxy(OPENAI_API_URL="https://api.openai.com")
assert select_passthrough_base_url(proxy, {}, COMPLETIONS) == (
"https://copilot-api.acme.ghe.com"
)
def test_non_copilot_paths_keep_their_existing_upstream() -> None:
"""The redirect is scoped to the one path; nothing else may move."""
proxy = _proxy(
OPENAI_API_URL="https://legacy.openai.test",
ANTHROPIC_API_URL="https://legacy.anthropic.test",
GEMINI_API_URL="https://legacy.gemini.test",
)
assert select_passthrough_base_url(proxy, {}, "/v1/chat/completions") == (
"https://legacy.openai.test"
)
assert select_passthrough_base_url(proxy, {}, "/v1/embeddings") == "https://legacy.openai.test"
# Callers that pass no path at all behave exactly as before.
assert select_passthrough_base_url(proxy, {}) == "https://legacy.openai.test"
def test_explicit_provider_auth_is_never_hijacked() -> None:
"""Only the OpenAI fall-through is redirected.
The Copilot extension sends none of these headers, so a request that
selected an upstream through one of them is not Copilot's — and silently
diverting a caller who authenticated to a named provider would be worse
than the bug being fixed.
"""
proxy = _proxy(
OPENAI_API_URL="https://api.openai.com",
ANTHROPIC_API_URL="https://legacy.anthropic.test",
GEMINI_API_URL="https://legacy.gemini.test",
)
assert select_passthrough_base_url(proxy, {"x-api-key": "k"}, COMPLETIONS) == (
"https://legacy.anthropic.test"
)
assert select_passthrough_base_url(proxy, {"x-goog-api-key": "k"}, COMPLETIONS) == (
"https://legacy.gemini.test"
)
assert select_passthrough_base_url(proxy, {"chatgpt-account-id": "acct"}, COMPLETIONS) == (
"https://chatgpt.com"
)
# --------------------------------------------------------------------------- #
# Where completions are sent
# --------------------------------------------------------------------------- #
def test_completions_host_defaults_to_the_copilot_api() -> None:
assert copilot_completions_base_url() == COPILOT_API
def test_github_advertised_completions_host_wins_over_the_default() -> None:
"""GitHub names the completions host in the token exchange; believe it.
This is what keeps the destination from being an assumption about which
host serves inline completions if GitHub says they live elsewhere, that
is where they go.
"""
copilot_auth._remember_completions_endpoint(
{
"token": "tid=x",
"endpoints": {
"api": COPILOT_API,
"proxy": "https://copilot-proxy.githubusercontent.com",
},
}
)
assert copilot_completions_base_url() == "https://copilot-proxy.githubusercontent.com"
def test_an_operator_override_beats_everything() -> None:
"""A network fronting Copilot through its own gateway needs no code change."""
copilot_auth._remember_completions_endpoint(
{"endpoints": {"proxy": "https://copilot-proxy.githubusercontent.com"}}
)
with pytest.MonkeyPatch.context() as patch:
patch.setenv("GITHUB_COPILOT_PROXY_URL", "https://copilot.internal.acme/")
assert copilot_completions_base_url() == "https://copilot.internal.acme"
@pytest.mark.parametrize(
"payload",
[
None,
{},
{"endpoints": {}},
{"endpoints": {"proxy": " "}},
{"endpoints": {"proxy": 7}},
{"endpoints": "not-a-dict"},
"not-a-dict",
],
)
def test_a_payload_without_a_usable_proxy_host_changes_nothing(payload) -> None: # type: ignore[no-untyped-def]
copilot_auth._remember_completions_endpoint(payload)
assert copilot_completions_base_url() == COPILOT_API
def test_the_advertised_host_is_used_for_routing() -> None:
proxy = _proxy(OPENAI_API_URL="https://api.openai.com")
copilot_auth._remember_completions_endpoint(
{"endpoints": {"proxy": "https://copilot-proxy.githubusercontent.com"}}
)
assert select_passthrough_base_url(proxy, {}, COMPLETIONS) == (
"https://copilot-proxy.githubusercontent.com"
)
# --------------------------------------------------------------------------- #
# URL construction
# --------------------------------------------------------------------------- #
def test_completions_keep_their_v1_prefix() -> None:
"""Copilot built this path itself, so rewriting it can only break it.
``/v1`` is stripped for clients speaking generic-OpenAI at Copilot's
unprefixed surface. Applying that to a Copilot-native path turns a working
request into a 404.
"""
assert build_copilot_upstream_url(COPILOT_API, COMPLETIONS) == f"{COPILOT_API}{COMPLETIONS}"
def test_the_v1_strip_still_applies_to_the_openai_surface() -> None:
"""Guard the behaviour the carve-out sits next to."""
assert (
build_copilot_upstream_url(COPILOT_API, "/v1/chat/completions")
== f"{COPILOT_API}/chat/completions"
)
assert build_copilot_upstream_url(COPILOT_API, "/v1/messages") == f"{COPILOT_API}/v1/messages"
assert build_copilot_upstream_url(COPILOT_API, "/models") == f"{COPILOT_API}/models"
def test_a_non_copilot_upstream_is_never_rewritten() -> None:
assert (
build_copilot_upstream_url("https://api.openai.com", COMPLETIONS)
== f"https://api.openai.com{COMPLETIONS}"
)