mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(copilot): route VS Code inline completions to Copilot, not OpenAI (#3077)
## Description Fixes #3076. When `github.copilot.advanced.debug.overrideProxyUrl` points at Headroom, the VS Code Copilot extension sends its inline ("ghost text") completions to `/v1/engines/<engine>/completions`. No route matches that path, so it falls into the catch-all passthrough — and `select_passthrough_base_url()` resolves an upstream from the **auth headers alone**, never looking at the path. Copilot sends none of the headers the earlier branches key on, so the request reached the final line (default to OpenAI) and Headroom forwarded editor keystrokes to: ``` https://api.openai.com/v1/engines/gpt-41-copilot/completions ``` Wrong under every configuration — OpenAI removed the Engines API years ago — and blocked outright on corporate networks that permit GitHub Copilot but not OpenAI, which is how it was reported. Inline completions stopped working for every user behind such a policy. The Copilot **CLI** was unaffected: it speaks the CAPI shape (`/chat/completions`), which already resolved correctly. That is the exact asymmetry in the report. ## Type of Change - [x] Bug fix ## Changes Made **Routing.** `select_passthrough_base_url()` now takes the request path and sends this one path to Copilot. The shape identifies Copilot on its own, so the redirect is unambiguous. It is scoped to the OpenAI fall-through — the branch that is wrong here — because every other branch reflects an upstream the caller chose with its own auth headers. **The destination is not hardcoded.** GitHub's token exchange advertises the completions host in `endpoints.proxy`, alongside the `endpoints.api` chat host Headroom already reads. It is now recorded at the single chokepoint every exchange passes through, and preferred. Resolution order: 1. `GITHUB_COPILOT_PROXY_URL` — operator override 2. `endpoints.proxy` from the last token exchange — GitHub's own answer 3. The Copilot API URL No I/O on the request path, and GHE deployments keep their host. This matters: it means the destination is not an assumption about which host serves completions, and if it is wrong for a given network it is an env var rather than a release. **Path preservation.** `build_copilot_upstream_url()` strips `/v1` when the upstream is a Copilot host, because Copilot serves its OpenAI-compatible surface unprefixed (`/chat/completions`, `/models`). But the extension built `/v1/engines/<engine>/completions` itself, so that path is already exactly what Copilot serves — stripping the prefix rewrites a working request into a 404. Preserved, the same carve-out `/v1/messages` needed in #2409. The rule: strip only for clients speaking generic-OpenAI at Copilot, never for Copilot's own paths. ## Testing - [x] New suite: `tests/test_copilot_vscode_completions_routing.py` (30 tests) — path recognition and its near-misses, upstream selection, the `endpoints.proxy` resolution order, and URL construction in both directions - [x] 286 passed across the Copilot, provider-routing and passthrough suites - [x] Ruff check and format pass ### Real Behavior Proof Environment: this branch, a `POST /v1/engines/gpt-41-copilot/completions` driven through the real app with `OPENAI_API_URL=https://api.openai.com` and the outbound HTTP client captured. ``` BEFORE (main): https://api.openai.com/v1/engines/gpt-41-copilot/completions AFTER (this): https://api.githubcopilot.com/v1/engines/gpt-41-copilot/completions ``` The "before" line reproduces the reported URL exactly. **Not tested:** a live VS Code Copilot session confirming GitHub accepts the forwarded request. That needs a real Copilot account and editor. If the completions host turns out to differ, the `endpoints.proxy` lookup or `GITHUB_COPILOT_PROXY_URL` covers it without a code change. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6d2254dfb5
commit
204e751d2f
4 changed files with 410 additions and 5 deletions
|
|
@ -245,6 +245,65 @@ def _configured_api_url() -> str:
|
|||
return DEFAULT_API_URL
|
||||
|
||||
|
||||
def copilot_api_url() -> str:
|
||||
"""Return the configured Copilot API base URL without any network calls.
|
||||
|
||||
Resolves ``GITHUB_COPILOT_API_URL``, then the configured enterprise domain,
|
||||
then ``api.githubcopilot.com``. Unlike :func:`resolve_copilot_api_url` this
|
||||
performs no token exchange, so it is safe to call while routing a request.
|
||||
"""
|
||||
|
||||
return _configured_api_url()
|
||||
|
||||
|
||||
# GitHub's token exchange advertises the host that serves inline completions
|
||||
# under ``endpoints.proxy``, alongside the ``endpoints.api`` chat host. It is
|
||||
# recorded here when observed so completions routing uses GitHub's own answer
|
||||
# instead of an assumption about which host serves that endpoint (#3076).
|
||||
_observed_completions_base_url: str | None = None
|
||||
|
||||
|
||||
def _remember_completions_endpoint(payload: Any) -> None:
|
||||
"""Record the completions host advertised by a token-exchange payload."""
|
||||
|
||||
global _observed_completions_base_url
|
||||
endpoints = payload.get("endpoints") if isinstance(payload, dict) else None
|
||||
proxy_url = endpoints.get("proxy") if isinstance(endpoints, dict) else None
|
||||
if isinstance(proxy_url, str) and proxy_url.strip():
|
||||
_observed_completions_base_url = proxy_url.strip().rstrip("/")
|
||||
|
||||
|
||||
def reset_observed_completions_endpoint() -> None:
|
||||
"""Forget the advertised completions host (test isolation)."""
|
||||
|
||||
global _observed_completions_base_url
|
||||
_observed_completions_base_url = None
|
||||
|
||||
|
||||
def copilot_completions_base_url() -> str:
|
||||
"""Return the base URL serving Copilot's inline-completions endpoint.
|
||||
|
||||
Resolution order, most authoritative first:
|
||||
|
||||
1. ``GITHUB_COPILOT_PROXY_URL`` — an explicit operator override, so a
|
||||
network that fronts Copilot behind its own gateway (or a GitHub change
|
||||
to this endpoint) is a config edit rather than a code change.
|
||||
2. ``endpoints.proxy`` from the last Copilot token exchange — GitHub
|
||||
telling us directly where completions go.
|
||||
3. The Copilot API URL, which is where GitHub's consolidated surface
|
||||
serves them.
|
||||
|
||||
Never performs I/O; step 2 only reads what a previous exchange recorded.
|
||||
"""
|
||||
|
||||
override = os.environ.get("GITHUB_COPILOT_PROXY_URL", "").strip()
|
||||
if override:
|
||||
return override.rstrip("/")
|
||||
if _observed_completions_base_url:
|
||||
return _observed_completions_base_url
|
||||
return copilot_api_url()
|
||||
|
||||
|
||||
def _github_oauth_domain(domain: str | None = None) -> str:
|
||||
raw = (domain or DEFAULT_GITHUB_HOST).strip()
|
||||
if not raw:
|
||||
|
|
@ -1056,6 +1115,29 @@ def reset_request_routed_to_copilot() -> None:
|
|||
_request_routed_to_copilot.set(False)
|
||||
|
||||
|
||||
def is_copilot_completions_path(path: str) -> bool:
|
||||
"""Return True for Copilot's inline-completions ("ghost text") endpoint.
|
||||
|
||||
The Copilot editor extensions send code completions to
|
||||
``/v1/engines/<engine>/completions`` on whatever host
|
||||
``github.copilot.advanced.debug.overrideProxyUrl`` names — so when that
|
||||
setting points at Headroom, this is the path that arrives.
|
||||
|
||||
The shape identifies GitHub Copilot on its own. OpenAI's Engines API was
|
||||
removed years ago and no other provider Headroom fronts serves it, so a
|
||||
request on this path is Copilot's and can never be answered by the default
|
||||
OpenAI target (#3076).
|
||||
"""
|
||||
|
||||
normalized = (path if path.startswith("/") else f"/{path}").rstrip("/")
|
||||
prefix = "/v1/engines/"
|
||||
suffix = "/completions"
|
||||
if not normalized.startswith(prefix) or not normalized.endswith(suffix):
|
||||
return False
|
||||
engine = normalized[len(prefix) : -len(suffix)]
|
||||
return bool(engine) and "/" not in engine
|
||||
|
||||
|
||||
def build_copilot_upstream_url(base_url: str, path: str) -> str:
|
||||
"""Build an upstream URL, normalizing GitHub Copilot's non-/v1 path layout."""
|
||||
|
||||
|
|
@ -1071,7 +1153,17 @@ def build_copilot_upstream_url(base_url: str, path: str) -> str:
|
|||
# Anthropic surface for Claude models IS ``/v1/messages`` (with the
|
||||
# ``/v1``); stripping it forwarded ``/messages`` and Copilot returned 404
|
||||
# for claude-* models (#2409). Keep ``/v1`` for the messages endpoint.
|
||||
if normalized_path.startswith("/v1/") and not normalized_path.startswith("/v1/messages"):
|
||||
#
|
||||
# Inline completions are the same story: the Copilot extension itself
|
||||
# builds ``/v1/engines/<engine>/completions``, so the path that reaches
|
||||
# us is already the exact path Copilot serves. Stripping ``/v1`` there
|
||||
# rewrites a Copilot-native path into one that 404s (#3076). The rule
|
||||
# this encodes: strip only for clients speaking generic-OpenAI at
|
||||
# Copilot, never for Copilot's own paths.
|
||||
keep_v1 = normalized_path.startswith("/v1/messages") or is_copilot_completions_path(
|
||||
normalized_path
|
||||
)
|
||||
if normalized_path.startswith("/v1/") and not keep_v1:
|
||||
normalized_path = normalized_path[3:]
|
||||
else:
|
||||
reset_request_routed_to_copilot()
|
||||
|
|
@ -1207,7 +1299,12 @@ class CopilotTokenProvider:
|
|||
try:
|
||||
with urllib_request.urlopen(request, timeout=10.0) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
# Every exchange funnels through here, so this is the one place
|
||||
# that sees GitHub's advertised completions host (#3076).
|
||||
_remember_completions_endpoint(payload)
|
||||
return payload
|
||||
except urllib_error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(
|
||||
|
|
|
|||
|
|
@ -530,5 +530,7 @@ def register_provider_routes(app: FastAPI, proxy: Any) -> None:
|
|||
|
||||
return await proxy.handle_passthrough(
|
||||
request,
|
||||
_select_passthrough_base_url(proxy, dict(request.headers)),
|
||||
# The path matters here: this is where unrouted paths land, and
|
||||
# Copilot's inline completions are one of them (#3076).
|
||||
_select_passthrough_base_url(proxy, dict(request.headers), request.url.path),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ from __future__ import annotations
|
|||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
from headroom.copilot_auth import (
|
||||
copilot_completions_base_url,
|
||||
is_copilot_api_url,
|
||||
is_copilot_completions_path,
|
||||
)
|
||||
from headroom.providers.codex import resolve_codex_routing
|
||||
from headroom.providers.codex.endpoints import CHATGPT_BACKEND_API_URL
|
||||
from headroom.providers.vertex import vertex_target_for_location as _vertex_target_for_location
|
||||
|
|
@ -29,7 +34,9 @@ def vertex_target_for_location(proxy: Any, location: str) -> str:
|
|||
return _vertex_target_for_location(api_target(proxy, "vertex"), location)
|
||||
|
||||
|
||||
def select_passthrough_base_url(proxy: Any, headers: Mapping[str, str]) -> str:
|
||||
def select_passthrough_base_url(
|
||||
proxy: Any, headers: Mapping[str, str], path: str | None = None
|
||||
) -> str:
|
||||
"""Resolve the upstream base URL for catch-all proxy passthrough requests."""
|
||||
routing = resolve_codex_routing(headers)
|
||||
if routing.is_chatgpt_auth:
|
||||
|
|
@ -41,4 +48,36 @@ def select_passthrough_base_url(proxy: Any, headers: Mapping[str, str]) -> str:
|
|||
if azure_base:
|
||||
return azure_base.rstrip("/")
|
||||
provider_name = proxy.provider_runtime.model_metadata_provider(headers)
|
||||
return api_target(proxy, provider_name)
|
||||
target = api_target(proxy, provider_name)
|
||||
if (
|
||||
path is not None
|
||||
and provider_name == "openai"
|
||||
and is_copilot_completions_path(path)
|
||||
and not is_copilot_api_url(target)
|
||||
):
|
||||
# Copilot's inline completions arrive here because
|
||||
# `/v1/engines/<engine>/completions` matches no built-in route. Nothing
|
||||
# above this line looks at the path, so the request fell through to the
|
||||
# OpenAI target and Headroom forwarded editor keystrokes to
|
||||
# api.openai.com — a host that has not served the Engines API for years,
|
||||
# and one many corporate networks block outright (#3076).
|
||||
#
|
||||
# Only Copilot emits this path, so sending it to Copilot is unambiguous.
|
||||
# `copilot_completions_base_url()` does no I/O: it prefers an operator
|
||||
# override, then the completions host GitHub advertised in the last
|
||||
# token exchange, then the Copilot API URL — so the destination is
|
||||
# GitHub's own answer where we have it rather than a hardcoded guess,
|
||||
# and GHE deployments keep their host.
|
||||
#
|
||||
# When the target is already a Copilot host — `headroom wrap vscode`
|
||||
# points the OpenAI target at the resolved subscription URL — it is left
|
||||
# alone, so an account-specific host is never overwritten with the
|
||||
# generic one.
|
||||
#
|
||||
# Scoped to the OpenAI fall-through, which is the branch that is wrong
|
||||
# for this path. Every other branch above reflects a deliberate choice
|
||||
# of upstream by the caller's own auth headers, and the Copilot editor
|
||||
# extension sends none of them — so a request that took one of those
|
||||
# branches is not Copilot's and keeps the upstream it asked for.
|
||||
return copilot_completions_base_url()
|
||||
return target
|
||||
|
|
|
|||
267
tests/test_copilot_vscode_completions_routing.py
Normal file
267
tests/test_copilot_vscode_completions_routing.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"""VS Code Copilot inline completions must reach Copilot, not OpenAI (#3076).
|
||||
|
||||
When `github.copilot.advanced.debug.overrideProxyUrl` points at Headroom, the
|
||||
Copilot extension sends its "ghost text" completions to
|
||||
``/v1/engines/<engine>/completions``. Headroom registers no route for that path,
|
||||
so it lands in the catch-all passthrough — which resolves an upstream from the
|
||||
auth headers alone and therefore fell through to the OpenAI target. Editor
|
||||
keystrokes were forwarded to ``api.openai.com``, a host that has not served the
|
||||
Engines API for years and that corporate networks routinely block.
|
||||
|
||||
Two things have to hold for the round trip: the path has to select the Copilot
|
||||
API, and it has to survive Copilot's ``/v1``-stripping intact, because the
|
||||
extension already built the exact path Copilot serves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from headroom import copilot_auth
|
||||
from headroom.copilot_auth import (
|
||||
build_copilot_upstream_url,
|
||||
copilot_completions_base_url,
|
||||
is_copilot_completions_path,
|
||||
reset_observed_completions_endpoint,
|
||||
)
|
||||
from headroom.providers.proxy_targets import select_passthrough_base_url
|
||||
|
||||
COPILOT_API = "https://api.githubcopilot.com"
|
||||
COMPLETIONS = "/v1/engines/gpt-41-copilot/completions"
|
||||
|
||||
|
||||
def _proxy(**legacy_targets: str):
|
||||
class Runtime:
|
||||
@staticmethod
|
||||
def api_target(provider: str) -> str:
|
||||
return f"https://runtime.{provider}.test"
|
||||
|
||||
@staticmethod
|
||||
def model_metadata_provider(headers) -> str: # type: ignore[no-untyped-def]
|
||||
return "anthropic" if headers.get("x-api-key") else "openai"
|
||||
|
||||
return type("Proxy", (), {**legacy_targets, "provider_runtime": Runtime()})()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_ambient_copilot_config(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Resolve the Copilot URL from a clean environment, not the dev's own."""
|
||||
for var in (
|
||||
"GITHUB_COPILOT_API_URL",
|
||||
"GITHUB_COPILOT_ENTERPRISE_URL",
|
||||
"GITHUB_COPILOT_PROXY_URL",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
reset_observed_completions_endpoint()
|
||||
yield
|
||||
reset_observed_completions_endpoint()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Path recognition
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
COMPLETIONS,
|
||||
"/v1/engines/copilot-codex/completions",
|
||||
# A trailing slash is still the same endpoint.
|
||||
"/v1/engines/gpt-41-copilot/completions/",
|
||||
],
|
||||
)
|
||||
def test_copilot_completions_paths_are_recognised(path: str) -> None:
|
||||
assert is_copilot_completions_path(path) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
# The OpenAI-compatible surface, which must keep its existing routing.
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
"/v1/messages",
|
||||
"/models",
|
||||
# Shape-alike paths that are not the completions endpoint. Matching
|
||||
# these would divert unrelated traffic to Copilot.
|
||||
"/v1/engines/gpt-41-copilot",
|
||||
"/v1/engines//completions",
|
||||
"/v1/engines/a/b/completions",
|
||||
"/v2/engines/gpt-41-copilot/completions",
|
||||
],
|
||||
)
|
||||
def test_other_paths_are_not_mistaken_for_completions(path: str) -> None:
|
||||
assert is_copilot_completions_path(path) is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Upstream selection
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_completions_do_not_fall_through_to_the_openai_target() -> None:
|
||||
"""The reported bug: keystrokes forwarded to api.openai.com."""
|
||||
proxy = _proxy(OPENAI_API_URL="https://api.openai.com")
|
||||
|
||||
assert select_passthrough_base_url(proxy, {}, COMPLETIONS) == COPILOT_API
|
||||
|
||||
|
||||
def test_an_account_specific_copilot_host_is_left_alone() -> None:
|
||||
"""`headroom wrap vscode` points the OpenAI target at the resolved host.
|
||||
|
||||
That URL is account-specific (individual/business/enterprise), so replacing
|
||||
it with the generic one would route a subscriber to the wrong tenant.
|
||||
"""
|
||||
proxy = _proxy(OPENAI_API_URL="https://api.business.githubcopilot.com")
|
||||
|
||||
assert (
|
||||
select_passthrough_base_url(proxy, {}, COMPLETIONS)
|
||||
== "https://api.business.githubcopilot.com"
|
||||
)
|
||||
|
||||
|
||||
def test_enterprise_deployments_keep_their_own_copilot_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The redirect is env-resolved, so a GHE tenant is not sent to github.com."""
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_URL", "https://copilot-api.acme.ghe.com")
|
||||
proxy = _proxy(OPENAI_API_URL="https://api.openai.com")
|
||||
|
||||
assert select_passthrough_base_url(proxy, {}, COMPLETIONS) == (
|
||||
"https://copilot-api.acme.ghe.com"
|
||||
)
|
||||
|
||||
|
||||
def test_non_copilot_paths_keep_their_existing_upstream() -> None:
|
||||
"""The redirect is scoped to the one path; nothing else may move."""
|
||||
proxy = _proxy(
|
||||
OPENAI_API_URL="https://legacy.openai.test",
|
||||
ANTHROPIC_API_URL="https://legacy.anthropic.test",
|
||||
GEMINI_API_URL="https://legacy.gemini.test",
|
||||
)
|
||||
|
||||
assert select_passthrough_base_url(proxy, {}, "/v1/chat/completions") == (
|
||||
"https://legacy.openai.test"
|
||||
)
|
||||
assert select_passthrough_base_url(proxy, {}, "/v1/embeddings") == "https://legacy.openai.test"
|
||||
# Callers that pass no path at all behave exactly as before.
|
||||
assert select_passthrough_base_url(proxy, {}) == "https://legacy.openai.test"
|
||||
|
||||
|
||||
def test_explicit_provider_auth_is_never_hijacked() -> None:
|
||||
"""Only the OpenAI fall-through is redirected.
|
||||
|
||||
The Copilot extension sends none of these headers, so a request that
|
||||
selected an upstream through one of them is not Copilot's — and silently
|
||||
diverting a caller who authenticated to a named provider would be worse
|
||||
than the bug being fixed.
|
||||
"""
|
||||
proxy = _proxy(
|
||||
OPENAI_API_URL="https://api.openai.com",
|
||||
ANTHROPIC_API_URL="https://legacy.anthropic.test",
|
||||
GEMINI_API_URL="https://legacy.gemini.test",
|
||||
)
|
||||
|
||||
assert select_passthrough_base_url(proxy, {"x-api-key": "k"}, COMPLETIONS) == (
|
||||
"https://legacy.anthropic.test"
|
||||
)
|
||||
assert select_passthrough_base_url(proxy, {"x-goog-api-key": "k"}, COMPLETIONS) == (
|
||||
"https://legacy.gemini.test"
|
||||
)
|
||||
assert select_passthrough_base_url(proxy, {"chatgpt-account-id": "acct"}, COMPLETIONS) == (
|
||||
"https://chatgpt.com"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Where completions are sent
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_completions_host_defaults_to_the_copilot_api() -> None:
|
||||
assert copilot_completions_base_url() == COPILOT_API
|
||||
|
||||
|
||||
def test_github_advertised_completions_host_wins_over_the_default() -> None:
|
||||
"""GitHub names the completions host in the token exchange; believe it.
|
||||
|
||||
This is what keeps the destination from being an assumption about which
|
||||
host serves inline completions — if GitHub says they live elsewhere, that
|
||||
is where they go.
|
||||
"""
|
||||
copilot_auth._remember_completions_endpoint(
|
||||
{
|
||||
"token": "tid=x",
|
||||
"endpoints": {
|
||||
"api": COPILOT_API,
|
||||
"proxy": "https://copilot-proxy.githubusercontent.com",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert copilot_completions_base_url() == "https://copilot-proxy.githubusercontent.com"
|
||||
|
||||
|
||||
def test_an_operator_override_beats_everything() -> None:
|
||||
"""A network fronting Copilot through its own gateway needs no code change."""
|
||||
copilot_auth._remember_completions_endpoint(
|
||||
{"endpoints": {"proxy": "https://copilot-proxy.githubusercontent.com"}}
|
||||
)
|
||||
with pytest.MonkeyPatch.context() as patch:
|
||||
patch.setenv("GITHUB_COPILOT_PROXY_URL", "https://copilot.internal.acme/")
|
||||
|
||||
assert copilot_completions_base_url() == "https://copilot.internal.acme"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
None,
|
||||
{},
|
||||
{"endpoints": {}},
|
||||
{"endpoints": {"proxy": " "}},
|
||||
{"endpoints": {"proxy": 7}},
|
||||
{"endpoints": "not-a-dict"},
|
||||
"not-a-dict",
|
||||
],
|
||||
)
|
||||
def test_a_payload_without_a_usable_proxy_host_changes_nothing(payload) -> None: # type: ignore[no-untyped-def]
|
||||
copilot_auth._remember_completions_endpoint(payload)
|
||||
|
||||
assert copilot_completions_base_url() == COPILOT_API
|
||||
|
||||
|
||||
def test_the_advertised_host_is_used_for_routing() -> None:
|
||||
proxy = _proxy(OPENAI_API_URL="https://api.openai.com")
|
||||
copilot_auth._remember_completions_endpoint(
|
||||
{"endpoints": {"proxy": "https://copilot-proxy.githubusercontent.com"}}
|
||||
)
|
||||
|
||||
assert select_passthrough_base_url(proxy, {}, COMPLETIONS) == (
|
||||
"https://copilot-proxy.githubusercontent.com"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# URL construction
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_completions_keep_their_v1_prefix() -> None:
|
||||
"""Copilot built this path itself, so rewriting it can only break it.
|
||||
|
||||
``/v1`` is stripped for clients speaking generic-OpenAI at Copilot's
|
||||
unprefixed surface. Applying that to a Copilot-native path turns a working
|
||||
request into a 404.
|
||||
"""
|
||||
assert build_copilot_upstream_url(COPILOT_API, COMPLETIONS) == f"{COPILOT_API}{COMPLETIONS}"
|
||||
|
||||
|
||||
def test_the_v1_strip_still_applies_to_the_openai_surface() -> None:
|
||||
"""Guard the behaviour the carve-out sits next to."""
|
||||
assert (
|
||||
build_copilot_upstream_url(COPILOT_API, "/v1/chat/completions")
|
||||
== f"{COPILOT_API}/chat/completions"
|
||||
)
|
||||
assert build_copilot_upstream_url(COPILOT_API, "/v1/messages") == f"{COPILOT_API}/v1/messages"
|
||||
assert build_copilot_upstream_url(COPILOT_API, "/models") == f"{COPILOT_API}/models"
|
||||
|
||||
|
||||
def test_a_non_copilot_upstream_is_never_rewritten() -> None:
|
||||
assert (
|
||||
build_copilot_upstream_url("https://api.openai.com", COMPLETIONS)
|
||||
== f"https://api.openai.com{COMPLETIONS}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue