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:
Tejas Chopra
2026-08-18 15:21:22 -07:00
committed by GitHub
parent 139c7cbdde
commit b77d612913
9 changed files with 417 additions and 30 deletions
+2 -2
View File
@@ -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
View File
@@ -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"
+1 -2
View File
@@ -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",
+94 -5
View File
@@ -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():
+10 -2
View File
@@ -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}"
)
+9 -6
View File
@@ -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
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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")