mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
139c7cbdde
commit
b77d612913
9 changed files with 417 additions and 30 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue