fix(copilot): send VS Code inline completions to the host that serves them (#3112)
## Description #3077 stopped Copilot's inline completions being forwarded to `api.openai.com` (the corporate-blocked host in the original report) — but sent them to the **CAPI host**, which does not serve that endpoint. Copilot has two surfaces on two different hosts, and GitHub's own client library keeps them apart: ```js _getCAPIUrl(t) -> t?.endpoints.api || "https://api.githubcopilot.com" _getProxyUrl(t) -> t?.endpoints.proxy || DEFAULT_PROXY_BASE_URL DEFAULT_PROXY_BASE_URL = "https://copilot-proxy.githubusercontent.com" ``` building completions as `${proxyBaseURL}/v1/engines/<engine>/completions` (`@vscode/copilot-api` 0.5.2). Probed unauthenticated against the live hosts: | host | `POST /v1/engines/<e>/completions` | |---|---| | `copilot-proxy.githubusercontent.com` | **401** — exists, needs auth | | `proxy.individual.githubcopilot.com` | **401** — CNAME to the above | | `api.githubcopilot.com` | **404** — does not serve this path | So the destination #3077 chose could not have worked. Three separate defects were in the way, each sufficient on its own to keep completions broken. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `copilot_auth.py`: added `DEFAULT_COMPLETIONS_PROXY_URL` and made it the default in `copilot_completions_base_url()`, replacing the CAPI host. - `copilot_auth.py`: the "custom deployment keeps its own host" rule now excludes public Copilot hosts. Without this, `headroom wrap vscode` — the common setup, and the one that exports `GITHUB_COPILOT_API_URL=<resolved subscription URL>` — resolved straight back to the 404 host. **This was a bug in my own first cut of the fix, found by testing the real `wrap vscode` environment rather than just the routing table.** - `copilot_auth.py`: added `is_copilot_completions_host()` and `is_copilot_upstream_url()` (chat ∪ completions). The completions host was recognised as Copilot **nowhere**, so `apply_copilot_api_auth` attached no credentials (401 — routing correctly to a host we then failed to authenticate against) and `build_copilot_upstream_url` skipped `mark_request_routed_to_copilot()`, mislabelling the provider in telemetry. - The union is applied at exactly those two call sites. `is_copilot_api_url` is left alone, so validation of a token payload's `endpoints.api` and the Responses-API preference check keep their strict chat-only meaning. All six call sites were read before choosing this. - `proxy_targets.py`: the "already a Copilot host" guard now keys on the *completions* host. A CAPI host is not a completions host, so it must still be redirected; a genuine per-SKU completions host or operator override is still left untouched. - `providers/copilot/vscode.py`, `cli/wrap.py`, `docs/…/vscode-copilot.mdx`: stop writing/printing `github.copilot.advanced.debug.overrideAuthType`. No such setting exists in the modern Copilot Chat extension — the only one left after `GitHub.copilot` was deprecated in early 2026. Its full `advanced.*` surface is `authPermissions`, `authProvider`, `debug.overrideCapiUrl`, `debug.overrideProxyUrl`, `debug.use*Fetcher`. It is still *recognised* so a stale hand-written copy is detected, just never emitted. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text tests/test_copilot_vscode_completions_routing.py 59 passed Copilot-related suites 293 passed, 8 skipped Full suite: 3 failed, 11250 passed, 581 skipped in 342.50s ``` The 3 failures are pre-existing and environmental, identical to a plain-`main` baseline on this machine: no `cargo` (`test_no_native_tls_in_wheel_build_tree`), no `codex` CLI (`test_learn/test_integration.py`), and `test_run_server_installs_cancelled_error_filter`, which fails under full-suite ordering on `main` too. ## Real Behavior Proof - Environment: macOS (darwin 25.4.0), Python 3.12.13, worktree off `main` @ `139c7cbd`, `HEADROOM_SKIP_UPSTREAM_CHECK=1` - Exact command / steps: (1) composed the real request path — `select_passthrough_base_url(proxy, headers, path)` → `build_copilot_upstream_url` → `apply_copilot_api_auth` — across 7 deployment shapes (no config, `wrap vscode`, advertised `endpoints.proxy`, operator override, GHE `.ghe.com`, GHE custom domain, target already a completions host); (2) probed the three candidate hosts unauthenticated with `curl -X POST /v1/engines/gpt-4o-copilot/completions`; (3) round-tripped `settings.json` through empty / one-setting / comments+array / CRLF shapes asserting valid JSON, idempotency and clean removal. - Observed result: before — `api.githubcopilot.com/...` (404 host), and with `GITHUB_COPILOT_API_URL` set as `wrap vscode` sets it, `api.business.githubcopilot.com` (also 404); no `Authorization` header on the completions host. After — `copilot-proxy.githubusercontent.com/v1/engines/gpt-41-copilot/completions` with credentials attached in every public-Copilot shape, `endpoints.proxy` and the operator override still winning, and a GHE tenant staying on its own host. `settings.json` stays valid JSON in all four shapes with the dead key gone; the two `restored=False` cases are pre-existing whitespace/CRLF normalisation, identical on `main`. Reverting the source fails 14 of the new tests, including the credential test on the completions host. - Not tested: no live VS Code session and no authenticated completion — the 401 proves the endpoint exists, not that GitHub accepts our forwarded request, which needs a real Copilot token. Confirmation from @rganesh-msys is still wanted. **Enterprise remains unresolved by default**: a GHE tenant stays on its own CAPI host, which is likely still the wrong surface for completions, but staying in-tenant beats forwarding keystrokes to a public GitHub host — `GITHUB_COPILOT_PROXY_URL` is the exact fix and now takes precedence over everything. ## Runtime Rollout Safety - Rollout-managed feature(s): None — no rollout channel gates this. - Minimum rollout channel: n/a - Stable/default behavior changed: Yes, and deliberately — the completions destination moves from a host that answers 404 to the one GitHub's own client defaults to. Only `/v1/engines/<engine>/completions` is affected; every other path keeps its upstream, pinned by tests. Copilot credentials now also reach the completions host, which is the point. - Kill switch / disable path: `GITHUB_COPILOT_PROXY_URL` pins the destination explicitly and beats all inference. - Unsafe override required: No. - Qualification impact: None. - Rollback path: Revert this commit; completions return to the CAPI host (404) and the settings block regains the inert `overrideAuthType`. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes Two things found while reading the extension source, **not changed here**: 1. `advanced.debug.overrideProxyUrl` is **not** deprecated — the report that Copilot 0.60.0 stopped honouring it does not hold. The current canonical key is `github.copilot.internal.completionsUrl`, and `advanced.debug.overrideProxyUrl` is checked as its explicit legacy fallback (`getEndpointOverrideUrl` in `completions-core/lib/src/networkConfiguration.ts`), so what we write still works. Worth migrating to the `internal.*` keys eventually, since they take precedence. 2. `endpoints.proxy` is still only recorded during a token exchange, which is opt-in via `GITHUB_COPILOT_USE_TOKEN_EXCHANGE`, and the base URL is chosen before auth runs. With the default now correct this is a refinement for per-SKU hosts rather than a correctness requirement, so it is left as-is. Closes #3076 --------- Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,8 +45,8 @@ The command:
|
||||
1. validates Copilot subscription access and resolves the account API endpoint;
|
||||
2. starts Headroom on `127.0.0.1:8787` with the short-lived upstream token;
|
||||
3. adds a marker-owned block to VS Code user settings containing
|
||||
`github.copilot.advanced.debug.overrideProxyUrl` and
|
||||
`github.copilot.advanced.debug.overrideAuthType`;
|
||||
`github.copilot.advanced.debug.overrideProxyUrl` (inline completions) and
|
||||
`github.copilot.advanced.debug.overrideCapiUrl` (chat);
|
||||
4. keeps running until `Ctrl+C` so the local proxy is available to VS Code.
|
||||
|
||||
Continue using Copilot's normal model picker. The request body—and therefore the
|
||||
|
||||
+3
-2
@@ -796,8 +796,9 @@ def verify_vscode_wrap(base_env: dict[str, str], project_dir: Path) -> None:
|
||||
"VS Code wrap should route Copilot Chat generation through Headroom",
|
||||
)
|
||||
assert_true(
|
||||
'"github.copilot.advanced.debug.overrideAuthType": "token"' in configured,
|
||||
"VS Code wrap should configure token auth",
|
||||
"overrideAuthType" not in configured,
|
||||
"VS Code wrap must not write overrideAuthType: no such setting exists in "
|
||||
"the modern Copilot Chat extension, so VS Code flags it as unknown (#3076)",
|
||||
)
|
||||
assert_true(
|
||||
"synthetic-e2e-token" not in configured, "Settings must not contain credentials"
|
||||
|
||||
@@ -5543,9 +5543,8 @@ def vscode_copilot(
|
||||
f' "github.copilot.advanced.debug.overrideProxyUrl": "{vscode_proxy_url(actual_port, _project_name_from_cwd())}",'
|
||||
)
|
||||
click.echo(
|
||||
f' "github.copilot.advanced.debug.overrideCapiUrl": "{vscode_proxy_url(actual_port, _project_name_from_cwd())}",'
|
||||
f' "github.copilot.advanced.debug.overrideCapiUrl": "{vscode_proxy_url(actual_port, _project_name_from_cwd())}"'
|
||||
)
|
||||
click.echo(' "github.copilot.advanced.debug.overrideAuthType": "token"')
|
||||
|
||||
_run_proxy_only_watcher(
|
||||
agent_label="VS CODE COPILOT",
|
||||
|
||||
@@ -28,6 +28,17 @@ from headroom.copilot_macos_keychain import read_copilot_oauth_token as read_mac
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_API_URL = "https://api.githubcopilot.com"
|
||||
# Copilot serves *chat* from the CAPI host above and *inline completions* from a
|
||||
# separate proxy host. GitHub's own client library keeps them apart:
|
||||
#
|
||||
# _getCAPIUrl(t) -> t?.endpoints.api || "https://api.githubcopilot.com"
|
||||
# _getProxyUrl(t) -> t?.endpoints.proxy || DEFAULT_PROXY_BASE_URL
|
||||
# DEFAULT_PROXY_BASE_URL = "https://copilot-proxy.githubusercontent.com"
|
||||
#
|
||||
# and builds completions as `${proxyBaseURL}/v1/engines/<engine>/completions`
|
||||
# (@vscode/copilot-api 0.5.2). Sending that path to the CAPI host is the wrong
|
||||
# surface, so the completions default has to be its own constant (#3076).
|
||||
DEFAULT_COMPLETIONS_PROXY_URL = "https://copilot-proxy.githubusercontent.com"
|
||||
DEFAULT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token"
|
||||
DEFAULT_USER_INFO_URL = "https://api.github.com/copilot_internal/user"
|
||||
DEFAULT_GITHUB_HOST = "github.com"
|
||||
@@ -280,6 +291,47 @@ def reset_observed_completions_endpoint() -> None:
|
||||
_observed_completions_base_url = None
|
||||
|
||||
|
||||
def _url_host(value: str) -> str:
|
||||
"""Hostname for a URL, tolerating a scheme-less value.
|
||||
|
||||
Mirrors the normalization :func:`is_copilot_api_url` performs, so a host
|
||||
configured without "https://" is not silently treated as a different host.
|
||||
"""
|
||||
|
||||
parsed = urlparse(value)
|
||||
netloc_or_path = parsed.netloc.lower() or parsed.path.lower()
|
||||
return (parsed.hostname or netloc_or_path.split("/", 1)[0]).lower()
|
||||
|
||||
|
||||
def is_copilot_completions_host(url: str | None) -> bool:
|
||||
"""Return True when *url* already points at a Copilot inline-completions host.
|
||||
|
||||
Distinct from :func:`is_copilot_api_url`, which matches the CAPI (chat)
|
||||
surface. A CAPI host is *not* a completions host, so the two must not be
|
||||
conflated when deciding whether a completions request is already addressed
|
||||
correctly.
|
||||
"""
|
||||
|
||||
if not url:
|
||||
return False
|
||||
# Compare hosts, never whole strings: this is asked both about a bare base
|
||||
# URL (routing) and about a fully-built URL with the path appended (auth).
|
||||
# A string compare answers True for the first and False for the second, so
|
||||
# an operator override would route correctly and then be forwarded with no
|
||||
# credentials at all.
|
||||
host = _url_host(url)
|
||||
if not host:
|
||||
return False
|
||||
override = os.environ.get("GITHUB_COPILOT_PROXY_URL", "").strip()
|
||||
if override and host == _url_host(override):
|
||||
return True
|
||||
if host == "copilot-proxy.githubusercontent.com":
|
||||
return True
|
||||
# Per-SKU hosts GitHub hands out via `endpoints.proxy`, e.g.
|
||||
# proxy.individual.githubcopilot.com / proxy.business… / proxy.enterprise….
|
||||
return host.startswith("proxy.") and host.endswith(".githubcopilot.com")
|
||||
|
||||
|
||||
def copilot_completions_base_url() -> str:
|
||||
"""Return the base URL serving Copilot's inline-completions endpoint.
|
||||
|
||||
@@ -290,8 +342,19 @@ def copilot_completions_base_url() -> str:
|
||||
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.
|
||||
3. ``copilot-proxy.githubusercontent.com`` — GitHub's own documented
|
||||
default for this endpoint (see ``DEFAULT_COMPLETIONS_PROXY_URL``).
|
||||
4. For an enterprise or otherwise custom Copilot deployment, that
|
||||
deployment's own host. Falling back to the public GitHub host there would
|
||||
send an enterprise tenant's keystrokes outside their deployment, which is
|
||||
worse than failing to resolve.
|
||||
|
||||
Note what step 4 must *not* capture: a configured API URL that is itself a
|
||||
public Copilot host. ``headroom wrap vscode`` sets ``GITHUB_COPILOT_API_URL``
|
||||
to the resolved subscription URL (e.g. ``api.business.githubcopilot.com``),
|
||||
which is the chat surface — returning it here would put the completions path
|
||||
straight back on the host that answers it with 404. Only a host outside
|
||||
``*.githubcopilot.com`` indicates a deployment whose traffic has to stay put.
|
||||
|
||||
Never performs I/O; step 2 only reads what a previous exchange recorded.
|
||||
"""
|
||||
@@ -301,7 +364,10 @@ def copilot_completions_base_url() -> str:
|
||||
return override.rstrip("/")
|
||||
if _observed_completions_base_url:
|
||||
return _observed_completions_base_url
|
||||
return copilot_api_url()
|
||||
configured = _configured_api_url_override()
|
||||
if configured and not _is_public_copilot_api_host(_url_host(configured)):
|
||||
return configured
|
||||
return DEFAULT_COMPLETIONS_PROXY_URL
|
||||
|
||||
|
||||
def _github_oauth_domain(domain: str | None = None) -> str:
|
||||
@@ -1052,6 +1118,25 @@ def is_copilot_api_url(url: str | None) -> bool:
|
||||
return _is_public_copilot_api_host(hostname) or _is_ghe_copilot_api_host(hostname)
|
||||
|
||||
|
||||
def is_copilot_upstream_url(url: str | None) -> bool:
|
||||
"""Return True for any Copilot-served upstream: chat (CAPI) or completions.
|
||||
|
||||
Copilot has two surfaces on two different hosts, and code that asks "is this
|
||||
request going to Copilot?" means the union. :func:`is_copilot_api_url` alone
|
||||
answers only for chat, so the completions host looked like a stranger:
|
||||
``apply_copilot_api_auth`` attached no credentials to it (401) and
|
||||
``build_copilot_upstream_url`` skipped ``mark_request_routed_to_copilot``,
|
||||
which mislabels the provider in telemetry.
|
||||
|
||||
Deliberately *not* folded into :func:`is_copilot_api_url`, which also gates
|
||||
validation of the ``endpoints.api`` value from a token exchange and the
|
||||
Responses-API preference check — neither of which should treat a completions
|
||||
host as a chat host (#3076).
|
||||
"""
|
||||
|
||||
return is_copilot_api_url(url) or is_copilot_completions_host(url)
|
||||
|
||||
|
||||
def _is_public_copilot_api_host(host: str) -> bool:
|
||||
"""Return True for GitHub-hosted Copilot API domains."""
|
||||
|
||||
@@ -1143,7 +1228,7 @@ def build_copilot_upstream_url(base_url: str, path: str) -> str:
|
||||
|
||||
normalized_base = base_url.rstrip("/")
|
||||
normalized_path = path if path.startswith("/") else f"/{path}"
|
||||
if is_copilot_api_url(normalized_base):
|
||||
if is_copilot_upstream_url(normalized_base):
|
||||
# Single routing chokepoint for every Copilot surface (OpenAI
|
||||
# chat/responses and Anthropic messages all build their upstream URL
|
||||
# here), so mark the request for provider relabeling downstream.
|
||||
@@ -1411,7 +1496,11 @@ def _is_managed_copilot_seeded_bearer(token: str) -> bool:
|
||||
async def apply_copilot_api_auth(headers: dict[str, str], *, url: str) -> dict[str, str]:
|
||||
"""Apply Copilot auth headers for GitHub Copilot API requests."""
|
||||
resolved = dict(headers)
|
||||
if not is_copilot_api_url(url):
|
||||
# Both Copilot surfaces need credentials. Gating on the chat host alone left
|
||||
# inline completions unauthenticated: the request reached
|
||||
# copilot-proxy.githubusercontent.com with no Authorization header, and that
|
||||
# host answers 401 (#3076).
|
||||
if not is_copilot_upstream_url(url):
|
||||
return resolved
|
||||
|
||||
for name, value in _copilot_chat_header_defaults().items():
|
||||
|
||||
@@ -18,6 +18,15 @@ _MARKER_START = "// --- Headroom Copilot proxy ---"
|
||||
_MARKER_END = "// --- end Headroom Copilot proxy ---"
|
||||
_PROXY_KEY = "github.copilot.advanced.debug.overrideProxyUrl"
|
||||
_CAPI_KEY = "github.copilot.advanced.debug.overrideCapiUrl"
|
||||
# Written by Headroom until #3076: it no longer exists. The modern Copilot Chat
|
||||
# extension — the only one left after `GitHub.copilot` was deprecated in early
|
||||
# 2026 — defines no `authType` setting in either its own configuration
|
||||
# (`advanced.authPermissions`, `advanced.authProvider`,
|
||||
# `advanced.debug.overrideCapiUrl`, `advanced.debug.overrideProxyUrl`,
|
||||
# `advanced.debug.use*Fetcher`) or in the completions code merged into it. Still
|
||||
# recognised below so a stale hand-written copy is detected, but never emitted:
|
||||
# VS Code flags unknown keys, and shipping one that does nothing invited the
|
||||
# conclusion that the override mechanism had stopped working.
|
||||
_AUTH_KEY = "github.copilot.advanced.debug.overrideAuthType"
|
||||
|
||||
|
||||
@@ -119,8 +128,7 @@ def _managed_block(proxy_url: str, *, owns_preceding_comma: bool, line_sep: str)
|
||||
return (
|
||||
f"\t{marker}{line_sep}"
|
||||
f"\t{json.dumps(_PROXY_KEY)}: {json.dumps(proxy_url)},{line_sep}"
|
||||
f"\t{json.dumps(_CAPI_KEY)}: {json.dumps(proxy_url)},{line_sep}"
|
||||
f'\t{json.dumps(_AUTH_KEY)}: "token"{line_sep}'
|
||||
f"\t{json.dumps(_CAPI_KEY)}: {json.dumps(proxy_url)}{line_sep}"
|
||||
f"\t{_MARKER_END}"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any, cast
|
||||
|
||||
from headroom.copilot_auth import (
|
||||
copilot_completions_base_url,
|
||||
is_copilot_api_url,
|
||||
is_copilot_completions_host,
|
||||
is_copilot_completions_path,
|
||||
)
|
||||
from headroom.providers.codex import resolve_codex_routing
|
||||
@@ -53,7 +53,7 @@ def select_passthrough_base_url(
|
||||
path is not None
|
||||
and provider_name == "openai"
|
||||
and is_copilot_completions_path(path)
|
||||
and not is_copilot_api_url(target)
|
||||
and not is_copilot_completions_host(target)
|
||||
):
|
||||
# Copilot's inline completions arrive here because
|
||||
# `/v1/engines/<engine>/completions` matches no built-in route. Nothing
|
||||
@@ -69,10 +69,13 @@ def select_passthrough_base_url(
|
||||
# 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.
|
||||
# The guard is on the *completions* host, not "any Copilot host". A CAPI
|
||||
# host is not a completions host: `headroom wrap vscode` points the
|
||||
# OpenAI target at the resolved subscription URL, which is the chat
|
||||
# surface (`GITHUB_COPILOT_API_URL`), and leaving that alone sent
|
||||
# `/v1/engines/.../completions` to a host that does not serve it. An
|
||||
# already-correct completions host — an operator override or a per-SKU
|
||||
# `endpoints.proxy` value — is still left untouched.
|
||||
#
|
||||
# Scoped to the OpenAI fall-through, which is the branch that is wrong
|
||||
# for this path. Every other branch above reflects a deliberate choice
|
||||
|
||||
@@ -67,7 +67,10 @@ def test_wrap_vscode_no_configure_prints_transparent_settings(tmp_path: Path) ->
|
||||
assert not path.exists()
|
||||
assert "overrideProxyUrl" in result.output
|
||||
assert "overrideCapiUrl" in result.output
|
||||
assert "overrideAuthType" in result.output
|
||||
# No `overrideAuthType`: the setting does not exist in the modern Copilot
|
||||
# Chat extension, so printing it told users to add a key VS Code flags as
|
||||
# unknown and which does nothing (#3076).
|
||||
assert "overrideAuthType" not in result.output
|
||||
|
||||
|
||||
def test_unwrap_vscode_removes_only_managed_settings(tmp_path: Path) -> None:
|
||||
|
||||
@@ -15,6 +15,8 @@ extension already built the exact path Copilot serves.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from headroom import copilot_auth
|
||||
@@ -27,6 +29,13 @@ from headroom.copilot_auth import (
|
||||
from headroom.providers.proxy_targets import select_passthrough_base_url
|
||||
|
||||
COPILOT_API = "https://api.githubcopilot.com"
|
||||
# GitHub serves inline completions from a *different* host than chat. Verified
|
||||
# unauthenticated against the live endpoints:
|
||||
# POST copilot-proxy.githubusercontent.com/v1/engines/<e>/completions -> 401
|
||||
# POST api.githubcopilot.com/v1/engines/<e>/completions -> 404
|
||||
# and proxy.<sku>.githubcopilot.com is a CNAME to the former. 401 means "exists,
|
||||
# needs auth"; 404 means the CAPI host does not serve this path at all (#3076).
|
||||
COMPLETIONS_PROXY = "https://copilot-proxy.githubusercontent.com"
|
||||
COMPLETIONS = "/v1/engines/gpt-41-copilot/completions"
|
||||
|
||||
|
||||
@@ -100,20 +109,36 @@ 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
|
||||
# ...and they must land on the completions host, not the chat host, which
|
||||
# answers this path with 404.
|
||||
assert select_passthrough_base_url(proxy, {}, COMPLETIONS) == COMPLETIONS_PROXY
|
||||
|
||||
|
||||
def test_an_account_specific_copilot_host_is_left_alone() -> None:
|
||||
"""`headroom wrap vscode` points the OpenAI target at the resolved host.
|
||||
def test_a_chat_host_is_not_treated_as_a_completions_host() -> None:
|
||||
"""A CAPI host must still be redirected, because it does not serve this path.
|
||||
|
||||
That URL is account-specific (individual/business/enterprise), so replacing
|
||||
it with the generic one would route a subscriber to the wrong tenant.
|
||||
`headroom wrap vscode` points the OpenAI target at the resolved subscription
|
||||
URL, which is the *chat* surface (it is what `GITHUB_COPILOT_API_URL` is set
|
||||
to). Leaving it alone — as an "it's already a Copilot host" guard did — sent
|
||||
`/v1/engines/.../completions` to a host that answers 404.
|
||||
"""
|
||||
proxy = _proxy(OPENAI_API_URL="https://api.business.githubcopilot.com")
|
||||
|
||||
assert select_passthrough_base_url(proxy, {}, COMPLETIONS) == COMPLETIONS_PROXY
|
||||
|
||||
|
||||
def test_an_account_specific_completions_host_is_left_alone() -> None:
|
||||
"""A host that already serves completions is never rewritten.
|
||||
|
||||
These are the per-SKU hosts GitHub hands out through `endpoints.proxy`, so
|
||||
replacing one with the generic default would move a subscriber off the host
|
||||
their own token named.
|
||||
"""
|
||||
proxy = _proxy(OPENAI_API_URL="https://proxy.business.githubcopilot.com")
|
||||
|
||||
assert (
|
||||
select_passthrough_base_url(proxy, {}, COMPLETIONS)
|
||||
== "https://api.business.githubcopilot.com"
|
||||
== "https://proxy.business.githubcopilot.com"
|
||||
)
|
||||
|
||||
|
||||
@@ -173,8 +198,14 @@ def test_explicit_provider_auth_is_never_hijacked() -> None:
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Where completions are sent
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_completions_host_defaults_to_the_copilot_api() -> None:
|
||||
assert copilot_completions_base_url() == COPILOT_API
|
||||
def test_completions_host_defaults_to_githubs_completions_proxy() -> None:
|
||||
"""The default is GitHub's own default for this endpoint, not the CAPI host.
|
||||
|
||||
`@vscode/copilot-api` resolves it as
|
||||
``token?.endpoints.proxy || DEFAULT_PROXY_BASE_URL`` where
|
||||
``DEFAULT_PROXY_BASE_URL = "https://copilot-proxy.githubusercontent.com"``.
|
||||
"""
|
||||
assert copilot_completions_base_url() == COMPLETIONS_PROXY
|
||||
|
||||
|
||||
def test_github_advertised_completions_host_wins_over_the_default() -> None:
|
||||
@@ -223,7 +254,7 @@ def test_an_operator_override_beats_everything() -> None:
|
||||
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
|
||||
assert copilot_completions_base_url() == COMPLETIONS_PROXY
|
||||
|
||||
|
||||
def test_the_advertised_host_is_used_for_routing() -> None:
|
||||
@@ -265,3 +296,253 @@ def test_a_non_copilot_upstream_is_never_rewritten() -> None:
|
||||
build_copilot_upstream_url("https://api.openai.com", COMPLETIONS)
|
||||
== f"https://api.openai.com{COMPLETIONS}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Telling the two Copilot surfaces apart
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://copilot-proxy.githubusercontent.com",
|
||||
"https://copilot-proxy.githubusercontent.com/",
|
||||
# Scheme-less, as a hand-written config value can be. Failing to
|
||||
# recognise it means forwarding with no credentials.
|
||||
"copilot-proxy.githubusercontent.com",
|
||||
"proxy.individual.githubcopilot.com/v1/engines/x/completions",
|
||||
"https://proxy.individual.githubcopilot.com",
|
||||
"https://proxy.business.githubcopilot.com",
|
||||
"https://proxy.enterprise.githubcopilot.com",
|
||||
],
|
||||
)
|
||||
def test_completions_hosts_are_recognised(url: str) -> None:
|
||||
assert copilot_auth.is_copilot_completions_host(url) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
None,
|
||||
"",
|
||||
# The chat surface. Recognising it as a completions host is the bug this
|
||||
# function exists to prevent: it answers this path with 404.
|
||||
"https://api.githubcopilot.com",
|
||||
"https://api.business.githubcopilot.com",
|
||||
"https://copilot-api.acme.ghe.com",
|
||||
"https://api.openai.com",
|
||||
# Not a Copilot host merely because "proxy" appears somewhere.
|
||||
"https://proxy.example.com",
|
||||
"https://notproxy.githubcopilot.com",
|
||||
],
|
||||
)
|
||||
def test_non_completions_hosts_are_rejected(url) -> None: # type: ignore[no-untyped-def]
|
||||
assert copilot_auth.is_copilot_completions_host(url) is False
|
||||
|
||||
|
||||
def test_an_operator_override_counts_as_a_completions_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Otherwise the redirect would fight the operator's own configuration."""
|
||||
monkeypatch.setenv("GITHUB_COPILOT_PROXY_URL", "https://copilot.internal.acme/")
|
||||
|
||||
assert copilot_auth.is_copilot_completions_host("https://copilot.internal.acme") is True
|
||||
proxy = _proxy(OPENAI_API_URL="https://copilot.internal.acme")
|
||||
assert select_passthrough_base_url(proxy, {}, COMPLETIONS) == "https://copilot.internal.acme"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# An enterprise tenant's keystrokes must not leave their deployment
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_an_enterprise_deployment_is_never_sent_to_the_public_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The public default applies only when no custom deployment is configured.
|
||||
|
||||
For a GHE tenant, defaulting to ``copilot-proxy.githubusercontent.com``
|
||||
would forward editor keystrokes to a host outside their deployment. Staying
|
||||
on their own host may still be the wrong surface, but it keeps the traffic
|
||||
inside the tenant; ``GITHUB_COPILOT_PROXY_URL`` is the exact fix.
|
||||
"""
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_URL", "https://copilot-api.github.acme.com")
|
||||
|
||||
resolved = copilot_completions_base_url()
|
||||
|
||||
assert resolved == "https://copilot-api.github.acme.com"
|
||||
assert "githubusercontent.com" not in resolved
|
||||
assert "githubcopilot.com" not in resolved
|
||||
|
||||
|
||||
def test_the_advertised_host_still_wins_for_an_enterprise_deployment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""GitHub naming the host beats any inference from the configured API URL."""
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_URL", "https://copilot-api.github.acme.com")
|
||||
copilot_auth._remember_completions_endpoint(
|
||||
{"endpoints": {"proxy": "https://copilot-proxy.github.acme.com"}}
|
||||
)
|
||||
|
||||
assert copilot_completions_base_url() == "https://copilot-proxy.github.acme.com"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Routing to the right host is only half of it: it needs credentials
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://copilot-proxy.githubusercontent.com/v1/engines/gpt-41-copilot/completions",
|
||||
"https://proxy.business.githubcopilot.com/v1/engines/gpt-41-copilot/completions",
|
||||
# The chat surface must keep working exactly as before.
|
||||
"https://api.githubcopilot.com/chat/completions",
|
||||
],
|
||||
)
|
||||
def test_a_copilot_upstream_is_authenticated(monkeypatch: pytest.MonkeyPatch, url: str) -> None:
|
||||
"""Both Copilot surfaces get credentials.
|
||||
|
||||
Gating auth on the chat host alone routed completions to the correct host
|
||||
with no Authorization header at all, which that host answers 401 — the fix
|
||||
for the destination would have been inert without this.
|
||||
"""
|
||||
|
||||
class _Token:
|
||||
token = "test-copilot-token"
|
||||
|
||||
class _Provider:
|
||||
async def get_api_token(self): # noqa: ANN202
|
||||
return _Token()
|
||||
|
||||
monkeypatch.setattr(copilot_auth, "get_copilot_token_provider", lambda: _Provider())
|
||||
|
||||
resolved = asyncio.run(copilot_auth.apply_copilot_api_auth({}, url=url))
|
||||
|
||||
assert resolved.get("Authorization") == "Bearer test-copilot-token"
|
||||
|
||||
|
||||
def test_a_non_copilot_upstream_is_never_given_copilot_credentials(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The widened gate must not start handing Copilot tokens to other hosts."""
|
||||
|
||||
class _Provider:
|
||||
async def get_api_token(self): # noqa: ANN202
|
||||
raise AssertionError("must not mint a Copilot token for a non-Copilot host")
|
||||
|
||||
monkeypatch.setattr(copilot_auth, "get_copilot_token_provider", lambda: _Provider())
|
||||
|
||||
for url in (
|
||||
"https://api.openai.com/v1/engines/x/completions",
|
||||
"https://proxy.example.com/v1/engines/x/completions",
|
||||
"https://evil.githubcopilot.com.attacker.test/v1/engines/x/completions",
|
||||
):
|
||||
assert asyncio.run(copilot_auth.apply_copilot_api_auth({}, url=url)) == {}
|
||||
|
||||
|
||||
def test_a_completions_host_is_marked_as_copilot_routed() -> None:
|
||||
"""`build_copilot_upstream_url` is the chokepoint that labels the provider."""
|
||||
url = copilot_auth.build_copilot_upstream_url(
|
||||
"https://copilot-proxy.githubusercontent.com", COMPLETIONS
|
||||
)
|
||||
|
||||
assert url == f"https://copilot-proxy.githubusercontent.com{COMPLETIONS}"
|
||||
assert copilot_auth.is_copilot_upstream_url(url) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configured_api_url",
|
||||
[
|
||||
# What `headroom wrap vscode` actually exports (wrap.py sets
|
||||
# GITHUB_COPILOT_API_URL to the resolved subscription URL).
|
||||
"https://api.business.githubcopilot.com",
|
||||
"https://api.individual.githubcopilot.com",
|
||||
"https://api.githubcopilot.com",
|
||||
],
|
||||
)
|
||||
def test_a_public_capi_url_does_not_become_the_completions_host(
|
||||
monkeypatch: pytest.MonkeyPatch, configured_api_url: str
|
||||
) -> None:
|
||||
"""A configured *chat* URL must not drag completions back onto the 404 host.
|
||||
|
||||
The in-tenant rule for a custom deployment has to exclude public Copilot
|
||||
hosts, or the single most common setup — `headroom wrap vscode`, which
|
||||
exports GITHUB_COPILOT_API_URL — lands right back where it started.
|
||||
"""
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_URL", configured_api_url)
|
||||
|
||||
assert copilot_completions_base_url() == COMPLETIONS_PROXY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configured_api_url",
|
||||
[
|
||||
"https://copilot-api.acme.ghe.com",
|
||||
"https://copilot-api.github.acme.com",
|
||||
],
|
||||
)
|
||||
def test_a_custom_deployment_still_keeps_its_own_host(
|
||||
monkeypatch: pytest.MonkeyPatch, configured_api_url: str
|
||||
) -> None:
|
||||
"""Only a host outside *.githubcopilot.com marks a deployment to stay put."""
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_URL", configured_api_url)
|
||||
|
||||
assert copilot_completions_base_url() == configured_api_url
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The two callers pass different shapes of URL
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_an_operator_override_is_matched_on_the_full_request_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Routing sees a base URL; auth sees the base URL *plus the path*.
|
||||
|
||||
Matching the override by whole-string equality answered True for the first
|
||||
and False for the second, so an operator gateway was routed to correctly and
|
||||
then forwarded with no credentials — a 401 on the one configuration that is
|
||||
the documented remedy for a custom deployment.
|
||||
"""
|
||||
monkeypatch.setenv("GITHUB_COPILOT_PROXY_URL", "https://gw.corp.internal")
|
||||
|
||||
base = "https://gw.corp.internal"
|
||||
full = f"https://gw.corp.internal{COMPLETIONS}"
|
||||
|
||||
assert copilot_auth.is_copilot_completions_host(base) is True
|
||||
assert copilot_auth.is_copilot_completions_host(full) is True
|
||||
assert copilot_auth.is_copilot_completions_host("https://gw.corp.internal/") is True
|
||||
assert copilot_auth.is_copilot_upstream_url(full) is True
|
||||
# A different host is still not the override.
|
||||
assert copilot_auth.is_copilot_completions_host(f"https://elsewhere.test{COMPLETIONS}") is False
|
||||
|
||||
|
||||
def test_an_operator_override_gateway_receives_credentials(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""End of the same chain: the gateway must actually be authenticated."""
|
||||
|
||||
class _Token:
|
||||
token = "test-copilot-token"
|
||||
|
||||
class _Provider:
|
||||
async def get_api_token(self): # noqa: ANN202
|
||||
return _Token()
|
||||
|
||||
monkeypatch.setenv("GITHUB_COPILOT_PROXY_URL", "https://gw.corp.internal")
|
||||
monkeypatch.setattr(copilot_auth, "get_copilot_token_provider", lambda: _Provider())
|
||||
|
||||
resolved = asyncio.run(
|
||||
copilot_auth.apply_copilot_api_auth({}, url=f"https://gw.corp.internal{COMPLETIONS}")
|
||||
)
|
||||
|
||||
assert resolved.get("Authorization") == "Bearer test-copilot-token"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configured",
|
||||
["api.githubcopilot.com", "api.business.githubcopilot.com"],
|
||||
)
|
||||
def test_a_scheme_less_public_capi_url_is_still_recognised(
|
||||
monkeypatch: pytest.MonkeyPatch, configured: str
|
||||
) -> None:
|
||||
"""A hand-written value without "https://" must not read as a custom host."""
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_URL", configured)
|
||||
|
||||
assert copilot_completions_base_url() == COMPLETIONS_PROXY
|
||||
|
||||
@@ -51,7 +51,10 @@ def test_configure_update_and_remove_preserve_jsonc_verbatim(tmp_path: Path) ->
|
||||
assert "user comment" in configured
|
||||
assert '"github.copilot.advanced.debug.overrideProxyUrl"' in configured
|
||||
assert '"github.copilot.advanced.debug.overrideCapiUrl"' in configured
|
||||
assert '"github.copilot.advanced.debug.overrideAuthType": "token"' in configured
|
||||
# `overrideAuthType` is deliberately absent: no such setting exists in the
|
||||
# modern Copilot Chat extension, and writing one made VS Code flag an
|
||||
# unknown key while doing nothing (#3076).
|
||||
assert "overrideAuthType" not in configured
|
||||
|
||||
assert configure_vscode_proxy_settings(path, "http://127.0.0.1:9999") == "updated"
|
||||
assert "9999" in path.read_text(encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user