mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(copilot): preserve native enterprise model routing (#2998)
## Description
GitHub Copilot Enterprise/Business users without a BYOK provider key
were routed through Copilot CLI's single-model provider override. Native
model aliases and runtime `/model` switches were therefore forwarded
literally to the override and rejected with `400 model not supported`.
This change routes implicit GitHub OAuth through Copilot's native API
surface while retaining explicit subscription and provider-key behavior.
Closes #1910
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Added explicit `--native` routing and made it automatic for implicit
GitHub OAuth without BYOK.
- Clears every Copilot BYOK variable before native launch.
- Routes both OpenAI and Anthropic protocol targets through the resolved
tenant Copilot host.
- Preserves Enterprise/Business native aliases and runtime model
switching.
- Rejects BYOK-only options when native routing is selected.
- Refuses known Copilot bundles that do not reference `COPILOT_API_URL`,
avoiding silent proxy bypass.
- Preserves explicit `--subscription` and provider-key BYOK semantics.
- Added coverage for unreadable and unverifiable Copilot CLI bundles.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
884 passed, 4 skipped in 103.11s
ruff check .: All checks passed
ruff format --check .: 1412 files already formatted
mypy headroom/providers/copilot/wrap.py headroom/cli/wrap.py:
Success: no issues found in 2 source files
```
Exact-head CI is entirely green on
`0aca48c096`.
## Real Behavior Proof
- Environment: macOS arm64/Python 3.13 locally; GitHub-hosted macOS and
Ubuntu native-wrap jobs.
- Exact command / steps: invoke `headroom wrap copilot` with implicit
OAuth and an Enterprise model alias; inspect the captured child/proxy
environment and resolved target URLs; exercise explicit native conflicts
and bundle-support probes.
- Observed result: native launch uses `COPILOT_API_URL`, clears all BYOK
state, and points both protocol targets at the tenant host. Native-wrap
jobs are green on macOS and Ubuntu for the refreshed head.
- Not tested: live request against a real Enterprise tenant; the
repository has no organization Enterprise credential available to CI.
## Runtime Rollout Safety
- Rollout-managed feature(s): implicit native Copilot routing for GitHub
OAuth sessions without BYOK.
- Minimum rollout channel: normal patch release.
- Stable/default behavior changed: implicit OAuth now uses native
routing; explicit subscription and BYOK paths are unchanged.
- Kill switch / disable path: use an explicit supported provider-key
BYOK configuration; native mode also fails closed when CLI support is
known absent.
- Unsafe override required: none.
- Qualification impact: native-wrap macOS/Ubuntu, Docker wrapper, full
Python matrix, and Copilot focused suites must pass.
- Rollback path: human revert of this PR restores the fixed-wire OAuth
behavior; no configuration migration is persisted.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation — CLI help
and inline routing documentation; no separate guide required
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)
## Screenshots (if applicable)
Not applicable; CLI routing change.
## Additional Notes
Human review only. No merge or auto-merge is configured. Refreshed from
main after #2996; the MCP cap `mcp>=1.28.1,<2.0.0` is preserved.
This commit is contained in:
parent
632cb81dbe
commit
997a47992c
4 changed files with 362 additions and 69 deletions
|
|
@ -32,7 +32,7 @@ import subprocess
|
|||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import contextmanager
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
|
@ -3943,6 +3943,20 @@ def _copilot_default_wire_api_for_model(model: str | None) -> str:
|
|||
return _copilot_default_wire_api_for_model_impl(model)
|
||||
|
||||
|
||||
def _build_copilot_native_launch_env(
|
||||
*, port: int, environ: dict[str, str], project: str | None
|
||||
) -> tuple[dict[str, str], list[str]]:
|
||||
from headroom.providers.copilot.wrap import build_native_launch_env
|
||||
|
||||
return build_native_launch_env(port=port, environ=environ, project=project)
|
||||
|
||||
|
||||
def _native_api_url_supported(*, environ: Mapping[str, str] | None = None) -> bool | None:
|
||||
from headroom.providers.copilot.wrap import native_api_url_supported
|
||||
|
||||
return native_api_url_supported(environ=environ)
|
||||
|
||||
|
||||
def _should_use_copilot_oauth(
|
||||
*,
|
||||
backend: str | None,
|
||||
|
|
@ -4646,6 +4660,7 @@ def _launch_tool(
|
|||
anyllm_provider: str | None = None,
|
||||
region: str | None = None,
|
||||
openai_api_url: str | None = None,
|
||||
anthropic_api_url: str | None = None,
|
||||
copilot_api_token: str | None = None,
|
||||
copilot_refresh_oauth_token: str | None = None,
|
||||
copilot_api_token_expires_at: float | None = None,
|
||||
|
|
@ -4682,6 +4697,7 @@ def _launch_tool(
|
|||
anyllm_provider=anyllm_provider,
|
||||
region=region,
|
||||
openai_api_url=openai_api_url,
|
||||
anthropic_api_url=anthropic_api_url,
|
||||
copilot_api_token=copilot_api_token,
|
||||
copilot_refresh_oauth_token=copilot_refresh_oauth_token,
|
||||
copilot_api_token_expires_at=copilot_api_token_expires_at,
|
||||
|
|
@ -5607,6 +5623,14 @@ def _require_copilot_subscription_resolution() -> CopilotSubscriptionTokenResolu
|
|||
),
|
||||
)
|
||||
@click.option("--memory", is_flag=True, help="Enable persistent cross-session memory")
|
||||
@click.option(
|
||||
"--native",
|
||||
is_flag=True,
|
||||
help=(
|
||||
"Route Copilot's own GitHub-authenticated API through Headroom instead of "
|
||||
"the single-model BYOK override. Keeps native model aliases and /model switching."
|
||||
),
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
|
||||
@click.argument("copilot_args", nargs=-1, type=click.UNPROCESSED)
|
||||
def copilot(
|
||||
|
|
@ -5619,6 +5643,7 @@ def copilot(
|
|||
wire_api: str | None,
|
||||
subscription: bool,
|
||||
memory: bool,
|
||||
native: bool,
|
||||
verbose: bool,
|
||||
copilot_args: tuple[str, ...],
|
||||
) -> None:
|
||||
|
|
@ -5653,6 +5678,7 @@ def copilot(
|
|||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
explicit_subscription = subscription
|
||||
effective_backend = backend or os.environ.get("HEADROOM_BACKEND")
|
||||
if _check_proxy(port):
|
||||
running_backend = _detect_running_proxy_backend(port)
|
||||
|
|
@ -5663,6 +5689,17 @@ def copilot(
|
|||
)
|
||||
effective_backend = running_backend or effective_backend
|
||||
|
||||
if native:
|
||||
subscription = True
|
||||
if provider_type == "anthropic":
|
||||
raise click.ClickException(
|
||||
"--native does not use the BYOK provider override; drop --provider-type anthropic."
|
||||
)
|
||||
if wire_api is not None:
|
||||
raise click.ClickException(
|
||||
"--native selects the wire per request; drop the BYOK-only --wire-api option."
|
||||
)
|
||||
|
||||
effective_provider_type = _resolve_copilot_provider_type(effective_backend, provider_type)
|
||||
if subscription:
|
||||
if effective_backend not in (None, "", "anthropic"):
|
||||
|
|
@ -5690,12 +5727,22 @@ def copilot(
|
|||
copilot_api_token_expires_at: float | None = None
|
||||
client_bearer: str | None = None
|
||||
subscription_resolution: CopilotSubscriptionTokenResolution | None = None
|
||||
if _should_use_copilot_oauth(
|
||||
anthropic_api_url: str | None = None
|
||||
use_copilot_oauth = _should_use_copilot_oauth(
|
||||
backend=effective_backend,
|
||||
provider_type=provider_type,
|
||||
env=env,
|
||||
force_subscription=subscription,
|
||||
):
|
||||
)
|
||||
# Without a provider key, the old implicit OAuth lane still configured
|
||||
# Copilot as a one-model BYOK client. Native aliases (and runtime /model
|
||||
# switches) were then forwarded literally and rejected by GitHub (#1910).
|
||||
# Explicit --subscription remains on its existing fixed-wire behavior;
|
||||
# implicit GitHub OAuth uses Copilot's own routing automatically.
|
||||
if use_copilot_oauth and not explicit_subscription:
|
||||
native = True
|
||||
|
||||
if use_copilot_oauth:
|
||||
if subscription:
|
||||
subscription_resolution = _require_copilot_subscription_resolution()
|
||||
client_bearer = subscription_resolution.token
|
||||
|
|
@ -5708,7 +5755,35 @@ def copilot(
|
|||
"GITHUB_COPILOT_TOKEN / GITHUB_COPILOT_GITHUB_TOKEN."
|
||||
)
|
||||
|
||||
selected_model = _copilot_model_from_args(copilot_args, env)
|
||||
if native:
|
||||
openai_api_url = (
|
||||
subscription_resolution.api_url
|
||||
if subscription_resolution is not None
|
||||
else resolve_copilot_api_url(client_bearer)
|
||||
)
|
||||
env, env_vars_display = _build_copilot_native_launch_env(
|
||||
port=port,
|
||||
environ=env,
|
||||
project=_project_name_from_cwd(),
|
||||
)
|
||||
env["GITHUB_COPILOT_API_URL"] = openai_api_url
|
||||
env["OPENAI_TARGET_API_URL"] = openai_api_url
|
||||
env["ANTHROPIC_TARGET_API_URL"] = openai_api_url
|
||||
anthropic_api_url = openai_api_url
|
||||
copilot_proxy_token = client_bearer
|
||||
if subscription_resolution is not None:
|
||||
copilot_refresh_oauth_token = subscription_resolution.refresh_oauth_token
|
||||
copilot_api_token_expires_at = subscription_resolution.api_token_expires_at
|
||||
support = _native_api_url_supported(environ=os.environ)
|
||||
if support is False:
|
||||
raise click.ClickException(
|
||||
"This Copilot CLI build does not reference COPILOT_API_URL; refusing "
|
||||
"a native launch that could silently bypass Headroom."
|
||||
)
|
||||
if support is None and verbose:
|
||||
click.echo(" Note: could not verify this Copilot CLI's COPILOT_API_URL support.")
|
||||
else:
|
||||
selected_model = _copilot_model_from_args(copilot_args, env)
|
||||
|
||||
# ``--model auto`` is a Copilot-internal routing token that the BYOK
|
||||
# API rejects with ``400 The requested model is not supported``. In
|
||||
|
|
@ -5716,7 +5791,7 @@ def copilot(
|
|||
# Copilot's own native auto-selection works fine — we just need to
|
||||
# strip the ``--model auto`` flag before launch so Copilot doesn't
|
||||
# forward it to the provider endpoint.
|
||||
if _is_auto_model(selected_model):
|
||||
if not native and _is_auto_model(selected_model):
|
||||
copilot_args = _strip_auto_model_args(copilot_args)
|
||||
selected_model = None
|
||||
click.echo(
|
||||
|
|
@ -5725,57 +5800,58 @@ def copilot(
|
|||
"automatic model selection."
|
||||
)
|
||||
|
||||
env_wire_api = env.get("COPILOT_PROVIDER_WIRE_API")
|
||||
effective_wire_api = wire_api or (
|
||||
env_wire_api
|
||||
if env_wire_api in {"completions", "responses"}
|
||||
else _copilot_default_wire_api_for_model(selected_model)
|
||||
)
|
||||
env["COPILOT_PROVIDER_TYPE"] = "openai"
|
||||
# Per-project savings: the Copilot CLI cannot send custom headers, so
|
||||
# the project rides as a /p/<name> base-URL prefix the proxy strips.
|
||||
env["COPILOT_PROVIDER_BASE_URL"] = _with_project_prefix(
|
||||
f"http://127.0.0.1:{port}/v1", _project_name_from_cwd()
|
||||
)
|
||||
env["COPILOT_PROVIDER_WIRE_API"] = effective_wire_api
|
||||
env["COPILOT_PROVIDER_BEARER_TOKEN"] = client_bearer
|
||||
env["GITHUB_COPILOT_USE_TOKEN_EXCHANGE"] = "false"
|
||||
env.pop("COPILOT_PROVIDER_API_KEY", None)
|
||||
# Hand the exact token we resolved (and, for --subscription, validated
|
||||
# against GitHub) to the proxy explicitly via copilot_proxy_token below.
|
||||
# The proxy pins it as GITHUB_COPILOT_API_TOKEN, so upstream auth is
|
||||
# deterministic instead of the proxy re-running unvalidated discovery
|
||||
# (read_cached_oauth_token returns the *first* candidate, which may not
|
||||
# be the one the wrapper approved → environment-dependent 401s). Passing
|
||||
# it as a launch argument — rather than mutating this process's global
|
||||
# os.environ — keeps the token off shared state and out of unrelated
|
||||
# code paths.
|
||||
copilot_proxy_token = client_bearer
|
||||
if subscription_resolution is not None:
|
||||
copilot_refresh_oauth_token = subscription_resolution.refresh_oauth_token
|
||||
copilot_api_token_expires_at = subscription_resolution.api_token_expires_at
|
||||
env_vars_display = [
|
||||
"COPILOT_PROVIDER_TYPE=openai",
|
||||
f"COPILOT_PROVIDER_BASE_URL={env['COPILOT_PROVIDER_BASE_URL']}",
|
||||
f"COPILOT_PROVIDER_WIRE_API={effective_wire_api}",
|
||||
(
|
||||
"COPILOT_AUTH_MODE=github-subscription-experimental"
|
||||
if subscription
|
||||
else "COPILOT_AUTH_MODE=github-oauth"
|
||||
),
|
||||
]
|
||||
# Non-subscription OAuth keeps upstream's generic-host policy from
|
||||
# #610. Subscription mode can use the endpoint returned by the Copilot
|
||||
# token exchange, which is how Business accounts advertise their API
|
||||
# host without requiring users to configure it manually.
|
||||
openai_api_url = (
|
||||
subscription_resolution.api_url
|
||||
if subscription_resolution is not None
|
||||
else resolve_copilot_api_url(client_bearer)
|
||||
)
|
||||
env["GITHUB_COPILOT_API_URL"] = openai_api_url
|
||||
env["OPENAI_TARGET_API_URL"] = openai_api_url
|
||||
env_vars_display.append(f"COPILOT_PROVIDER_API_URL={openai_api_url}")
|
||||
if not native:
|
||||
env_wire_api = env.get("COPILOT_PROVIDER_WIRE_API")
|
||||
effective_wire_api = wire_api or (
|
||||
env_wire_api
|
||||
if env_wire_api in {"completions", "responses"}
|
||||
else _copilot_default_wire_api_for_model(selected_model)
|
||||
)
|
||||
env["COPILOT_PROVIDER_TYPE"] = "openai"
|
||||
# Per-project savings: the Copilot CLI cannot send custom headers, so
|
||||
# the project rides as a /p/<name> base-URL prefix the proxy strips.
|
||||
env["COPILOT_PROVIDER_BASE_URL"] = _with_project_prefix(
|
||||
f"http://127.0.0.1:{port}/v1", _project_name_from_cwd()
|
||||
)
|
||||
env["COPILOT_PROVIDER_WIRE_API"] = effective_wire_api
|
||||
env["COPILOT_PROVIDER_BEARER_TOKEN"] = client_bearer
|
||||
env["GITHUB_COPILOT_USE_TOKEN_EXCHANGE"] = "false"
|
||||
env.pop("COPILOT_PROVIDER_API_KEY", None)
|
||||
# Hand the exact token we resolved (and, for --subscription, validated
|
||||
# against GitHub) to the proxy explicitly via copilot_proxy_token below.
|
||||
# The proxy pins it as GITHUB_COPILOT_API_TOKEN, so upstream auth is
|
||||
# deterministic instead of the proxy re-running unvalidated discovery
|
||||
# (read_cached_oauth_token returns the *first* candidate, which may not
|
||||
# be the one the wrapper approved → environment-dependent 401s). Passing
|
||||
# it as a launch argument — rather than mutating this process's global
|
||||
# os.environ — keeps the token off shared state and out of unrelated
|
||||
# code paths.
|
||||
copilot_proxy_token = client_bearer
|
||||
if subscription_resolution is not None:
|
||||
copilot_refresh_oauth_token = subscription_resolution.refresh_oauth_token
|
||||
copilot_api_token_expires_at = subscription_resolution.api_token_expires_at
|
||||
env_vars_display = [
|
||||
"COPILOT_PROVIDER_TYPE=openai",
|
||||
f"COPILOT_PROVIDER_BASE_URL={env['COPILOT_PROVIDER_BASE_URL']}",
|
||||
f"COPILOT_PROVIDER_WIRE_API={effective_wire_api}",
|
||||
(
|
||||
"COPILOT_AUTH_MODE=github-subscription-experimental"
|
||||
if subscription
|
||||
else "COPILOT_AUTH_MODE=github-oauth"
|
||||
),
|
||||
]
|
||||
# Non-subscription OAuth keeps upstream's generic-host policy from
|
||||
# #610. Subscription mode can use the endpoint returned by the Copilot
|
||||
# token exchange, which is how Business accounts advertise their API
|
||||
# host without requiring users to configure it manually.
|
||||
openai_api_url = (
|
||||
subscription_resolution.api_url
|
||||
if subscription_resolution is not None
|
||||
else resolve_copilot_api_url(client_bearer)
|
||||
)
|
||||
env["GITHUB_COPILOT_API_URL"] = openai_api_url
|
||||
env["OPENAI_TARGET_API_URL"] = openai_api_url
|
||||
env_vars_display.append(f"COPILOT_PROVIDER_API_URL={openai_api_url}")
|
||||
else:
|
||||
env, env_vars_display = _build_copilot_launch_env(
|
||||
port=port,
|
||||
|
|
@ -5798,7 +5874,7 @@ def copilot(
|
|||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
if not subscription and not _copilot_model_configured(copilot_args, env):
|
||||
if not subscription and not native and not _copilot_model_configured(copilot_args, env):
|
||||
# Distinguish between "--model auto" (wrong model for BYOK) and
|
||||
# genuinely missing model (no --model flag at all).
|
||||
raw_model = _copilot_model_from_args(copilot_args, env)
|
||||
|
|
@ -5835,6 +5911,7 @@ def copilot(
|
|||
anyllm_provider=anyllm_provider,
|
||||
region=region,
|
||||
openai_api_url=openai_api_url,
|
||||
anthropic_api_url=anthropic_api_url,
|
||||
copilot_api_token=copilot_proxy_token,
|
||||
copilot_refresh_oauth_token=copilot_refresh_oauth_token,
|
||||
copilot_api_token_expires_at=copilot_api_token_expires_at,
|
||||
|
|
|
|||
|
|
@ -156,6 +156,73 @@ def provider_key_source(provider_type: str) -> str:
|
|||
return "ANTHROPIC_API_KEY" if provider_type == "anthropic" else "OPENAI_API_KEY"
|
||||
|
||||
|
||||
COPILOT_NATIVE_API_URL_ENV = "COPILOT_API_URL"
|
||||
|
||||
# Any survivor keeps Copilot in its single-model BYOK lane, defeating native
|
||||
# model routing while making the launch look superficially successful.
|
||||
COPILOT_BYOK_ENV_VARS: tuple[str, ...] = (
|
||||
"COPILOT_PROVIDER_BASE_URL",
|
||||
"COPILOT_PROVIDER_TYPE",
|
||||
"COPILOT_PROVIDER_API_KEY",
|
||||
"COPILOT_PROVIDER_BEARER_TOKEN",
|
||||
"COPILOT_PROVIDER_WIRE_API",
|
||||
"COPILOT_PROVIDER_TRANSPORT",
|
||||
"COPILOT_PROVIDER_AZURE_API_VERSION",
|
||||
"COPILOT_PROVIDER_MODEL_ID",
|
||||
"COPILOT_PROVIDER_WIRE_MODEL",
|
||||
"COPILOT_PROVIDER_MODEL_LIMITS_ID",
|
||||
"COPILOT_PROVIDER_MAX_PROMPT_TOKENS",
|
||||
"COPILOT_PROVIDER_MAX_OUTPUT_TOKENS",
|
||||
"COPILOT_PROVIDER_HEADERS",
|
||||
)
|
||||
|
||||
|
||||
def build_native_launch_env(
|
||||
*,
|
||||
port: int,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
project: str | None = None,
|
||||
) -> tuple[dict[str, str], list[str]]:
|
||||
"""Redirect Copilot's native API surface through Headroom, not BYOK."""
|
||||
env = dict(environ if environ is not None else os.environ)
|
||||
base_url = with_project_prefix(f"http://127.0.0.1:{port}", project)
|
||||
env[COPILOT_NATIVE_API_URL_ENV] = base_url
|
||||
for variable in COPILOT_BYOK_ENV_VARS:
|
||||
env.pop(variable, None)
|
||||
return env, [
|
||||
f"{COPILOT_NATIVE_API_URL_ENV}={base_url}",
|
||||
"COPILOT_AUTH_MODE=github-native",
|
||||
]
|
||||
|
||||
|
||||
def native_api_url_supported(*, environ: Mapping[str, str] | None = None) -> bool | None:
|
||||
"""Best-effort tri-state probe for the CLI's native API URL override."""
|
||||
env = environ if environ is not None else os.environ
|
||||
local = env.get("LOCALAPPDATA") or env.get("HOME") or os.path.expanduser("~")
|
||||
roots = (
|
||||
os.path.join(local, "copilot", "pkg"),
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share", "copilot", "pkg"),
|
||||
)
|
||||
found_bundle = False
|
||||
for root in roots:
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
for dirpath, _dirnames, filenames in os.walk(root):
|
||||
if "app.js" not in filenames:
|
||||
continue
|
||||
found_bundle = True
|
||||
try:
|
||||
with open(
|
||||
os.path.join(dirpath, "app.js"), encoding="utf-8", errors="replace"
|
||||
) as bundle:
|
||||
while chunk := bundle.read(1 << 20):
|
||||
if COPILOT_NATIVE_API_URL_ENV in chunk:
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False if found_bundle else None
|
||||
|
||||
|
||||
def build_launch_env(
|
||||
*,
|
||||
port: int,
|
||||
|
|
|
|||
|
|
@ -266,17 +266,16 @@ def test_wrap_copilot_prefers_existing_oauth_session(
|
|||
assert result.exit_code == 0, result.output
|
||||
env = captured["env"]
|
||||
assert isinstance(env, dict)
|
||||
assert env["COPILOT_PROVIDER_TYPE"] == "openai"
|
||||
assert env["COPILOT_PROVIDER_BASE_URL"] == (
|
||||
f"http://127.0.0.1:8787{_expected_project_prefix()}/v1"
|
||||
)
|
||||
assert env["COPILOT_PROVIDER_WIRE_API"] == "completions"
|
||||
assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "gho-existing"
|
||||
assert env["COPILOT_API_URL"] == f"http://127.0.0.1:8787{_expected_project_prefix()}"
|
||||
assert "COPILOT_PROVIDER_TYPE" not in env
|
||||
assert "COPILOT_PROVIDER_BASE_URL" not in env
|
||||
assert "COPILOT_PROVIDER_WIRE_API" not in env
|
||||
assert "COPILOT_PROVIDER_BEARER_TOKEN" not in env
|
||||
assert env["GITHUB_COPILOT_API_URL"] == DEFAULT_API_URL
|
||||
assert env["OPENAI_TARGET_API_URL"] == DEFAULT_API_URL
|
||||
assert "COPILOT_PROVIDER_API_KEY" not in env
|
||||
assert captured["openai_api_url"] == DEFAULT_API_URL
|
||||
assert f"COPILOT_PROVIDER_API_URL={DEFAULT_API_URL}" in captured["env_vars_display"]
|
||||
assert "COPILOT_AUTH_MODE=github-native" in captured["env_vars_display"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -293,7 +292,7 @@ def test_wrap_copilot_oauth_defaults_wire_api_for_selected_model(
|
|||
model: str,
|
||||
expected_wire_api: str,
|
||||
) -> None:
|
||||
"""OAuth sessions use the same model-aware wire API default as subscriptions."""
|
||||
"""Implicit OAuth leaves wire selection to Copilot's native router."""
|
||||
_wrap_cli, main = wrap_modules
|
||||
_clear_copilot_env(monkeypatch)
|
||||
captured: dict[str, object] = {}
|
||||
|
|
@ -315,8 +314,8 @@ def test_wrap_copilot_oauth_defaults_wire_api_for_selected_model(
|
|||
assert result.exit_code == 0, result.output
|
||||
env = captured["env"]
|
||||
assert isinstance(env, dict)
|
||||
assert env["COPILOT_PROVIDER_WIRE_API"] == expected_wire_api
|
||||
assert f"COPILOT_PROVIDER_WIRE_API={expected_wire_api}" in captured["env_vars_display"]
|
||||
assert "COPILOT_PROVIDER_WIRE_API" not in env
|
||||
assert env["COPILOT_API_URL"].startswith("http://127.0.0.1:8787")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wire_api", ["completions", "responses"])
|
||||
|
|
@ -348,7 +347,8 @@ def test_wrap_copilot_oauth_honors_existing_wire_api(
|
|||
assert result.exit_code == 0, result.output
|
||||
env = captured["env"]
|
||||
assert isinstance(env, dict)
|
||||
assert env["COPILOT_PROVIDER_WIRE_API"] == wire_api
|
||||
assert "COPILOT_PROVIDER_WIRE_API" not in env
|
||||
assert env["COPILOT_API_URL"].startswith("http://127.0.0.1:8787")
|
||||
|
||||
|
||||
def test_wrap_copilot_subscription_uses_github_auth_without_provider_key(
|
||||
|
|
@ -869,7 +869,8 @@ def test_wrap_copilot_oauth_keeps_generic_endpoint_when_account_advertised(
|
|||
assert result.exit_code == 0, result.output
|
||||
env = captured["env"]
|
||||
assert isinstance(env, dict)
|
||||
assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "gho-oauth"
|
||||
assert "COPILOT_PROVIDER_BEARER_TOKEN" not in env
|
||||
assert env["COPILOT_API_URL"].startswith("http://127.0.0.1:8787")
|
||||
assert captured["openai_api_url"] == DEFAULT_API_URL
|
||||
assert env["OPENAI_TARGET_API_URL"] == DEFAULT_API_URL
|
||||
assert env["GITHUB_COPILOT_API_URL"] == DEFAULT_API_URL
|
||||
|
|
|
|||
148
tests/test_copilot_native_mode.py
Normal file
148
tests/test_copilot_native_mode.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from headroom.providers.copilot.wrap import (
|
||||
COPILOT_BYOK_ENV_VARS,
|
||||
COPILOT_NATIVE_API_URL_ENV,
|
||||
build_launch_env,
|
||||
build_native_launch_env,
|
||||
native_api_url_supported,
|
||||
)
|
||||
|
||||
|
||||
def test_native_env_redirects_api_and_clears_all_byok_state() -> None:
|
||||
seeded = dict.fromkeys(COPILOT_BYOK_ENV_VARS, "stale")
|
||||
seeded["UNRELATED"] = "preserved"
|
||||
env, _ = build_native_launch_env(port=8890, environ=seeded, project="repo name")
|
||||
|
||||
assert env[COPILOT_NATIVE_API_URL_ENV] == "http://127.0.0.1:8890/p/repo%20name"
|
||||
assert env["UNRELATED"] == "preserved"
|
||||
assert not any(variable in env for variable in COPILOT_BYOK_ENV_VARS)
|
||||
|
||||
|
||||
def test_byok_builder_remains_disjoint_from_native_mode() -> None:
|
||||
env, _ = build_launch_env(
|
||||
port=8787,
|
||||
provider_type="openai",
|
||||
wire_api="responses",
|
||||
environ={},
|
||||
)
|
||||
assert env["COPILOT_PROVIDER_BASE_URL"] == "http://127.0.0.1:8787/v1"
|
||||
assert env["COPILOT_PROVIDER_WIRE_API"] == "responses"
|
||||
assert COPILOT_NATIVE_API_URL_ENV not in env
|
||||
|
||||
|
||||
def test_native_support_probe_distinguishes_unknown_and_unsupported(tmp_path) -> None:
|
||||
local = tmp_path / "local"
|
||||
assert native_api_url_supported(environ={"LOCALAPPDATA": str(local)}) is None
|
||||
|
||||
bundle = local / "copilot" / "pkg" / "platform" / "1.0" / "app.js"
|
||||
bundle.parent.mkdir(parents=True)
|
||||
bundle.write_text("no override here", encoding="utf-8")
|
||||
assert native_api_url_supported(environ={"LOCALAPPDATA": str(local)}) is False
|
||||
|
||||
bundle.write_text("process.env.COPILOT_API_URL", encoding="utf-8")
|
||||
assert native_api_url_supported(environ={"LOCALAPPDATA": str(local)}) is True
|
||||
|
||||
|
||||
def test_native_support_probe_skips_unreadable_bundle(monkeypatch, tmp_path) -> None:
|
||||
local = tmp_path / "local"
|
||||
bundle = local / "copilot" / "pkg" / "platform" / "1.0" / "app.js"
|
||||
bundle.parent.mkdir(parents=True)
|
||||
bundle.write_text("process.env.COPILOT_API_URL", encoding="utf-8")
|
||||
|
||||
def _unreadable(*_args, **_kwargs):
|
||||
raise OSError("synthetic unreadable bundle")
|
||||
|
||||
monkeypatch.setattr("builtins.open", _unreadable)
|
||||
assert native_api_url_supported(environ={"LOCALAPPDATA": str(local)}) is False
|
||||
|
||||
|
||||
def _invoke_native(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
extra: list[str] | None = None,
|
||||
*,
|
||||
support: bool | None = True,
|
||||
):
|
||||
from headroom.cli import wrap as wrap_mod
|
||||
from headroom.cli.main import main
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class Resolution:
|
||||
token = "copilot-token"
|
||||
api_url = "https://api.business.githubcopilot.com"
|
||||
refresh_oauth_token = "refresh-token"
|
||||
api_token_expires_at = 123.0
|
||||
|
||||
monkeypatch.setattr(wrap_mod.shutil, "which", lambda _name: "/usr/bin/copilot")
|
||||
monkeypatch.setattr(wrap_mod, "_check_proxy", lambda _port: False)
|
||||
monkeypatch.setattr(wrap_mod, "_require_copilot_subscription_resolution", lambda: Resolution())
|
||||
monkeypatch.setattr(wrap_mod, "_native_api_url_supported", lambda **_kwargs: support)
|
||||
monkeypatch.setattr(wrap_mod, "_launch_tool", lambda **kwargs: captured.update(kwargs))
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
["wrap", "copilot", "--native", "--port", "8890", *(extra or [])],
|
||||
)
|
||||
return result, captured
|
||||
|
||||
|
||||
def test_implicit_oauth_uses_native_routing_without_flag(monkeypatch) -> None:
|
||||
from headroom.cli import wrap as wrap_mod
|
||||
from headroom.cli.main import main
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(wrap_mod.shutil, "which", lambda _name: "/usr/bin/copilot")
|
||||
monkeypatch.setattr(wrap_mod, "_check_proxy", lambda _port: False)
|
||||
monkeypatch.setattr(wrap_mod, "has_oauth_auth", lambda: True)
|
||||
monkeypatch.setattr(wrap_mod, "resolve_client_bearer_token", lambda: "oauth-token")
|
||||
monkeypatch.setattr(
|
||||
wrap_mod, "resolve_copilot_api_url", lambda _token: "https://api.githubcopilot.com"
|
||||
)
|
||||
monkeypatch.setattr(wrap_mod, "_native_api_url_supported", lambda **_kwargs: True)
|
||||
monkeypatch.setattr(wrap_mod, "_launch_tool", lambda **kwargs: captured.update(kwargs))
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
["wrap", "copilot", "--port", "8890", "--", "--model", "claude-sonnet-5"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
env = captured["env"]
|
||||
assert isinstance(env, dict)
|
||||
assert COPILOT_NATIVE_API_URL_ENV in env
|
||||
assert not any(variable in env for variable in COPILOT_BYOK_ENV_VARS)
|
||||
|
||||
|
||||
def test_native_cli_routes_both_protocols_to_tenant_host(monkeypatch) -> None:
|
||||
result, captured = _invoke_native(monkeypatch)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["openai_api_url"] == "https://api.business.githubcopilot.com"
|
||||
assert captured["anthropic_api_url"] == "https://api.business.githubcopilot.com"
|
||||
env = captured["env"]
|
||||
assert isinstance(env, dict)
|
||||
assert COPILOT_NATIVE_API_URL_ENV in env
|
||||
assert not any(variable in env for variable in COPILOT_BYOK_ENV_VARS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extra", [["--wire-api", "responses"], ["--provider-type", "anthropic"]])
|
||||
def test_native_cli_rejects_byok_only_options(monkeypatch, extra) -> None:
|
||||
result, captured = _invoke_native(monkeypatch, extra)
|
||||
assert result.exit_code != 0
|
||||
assert not captured
|
||||
|
||||
|
||||
def test_native_cli_refuses_known_unsupported_bundle(monkeypatch) -> None:
|
||||
result, captured = _invoke_native(monkeypatch, support=False)
|
||||
assert result.exit_code != 0
|
||||
assert "COPILOT_API_URL" in result.output
|
||||
assert not captured
|
||||
|
||||
|
||||
def test_native_cli_reports_unknown_support_in_verbose_mode(monkeypatch) -> None:
|
||||
result, captured = _invoke_native(monkeypatch, ["--verbose"], support=None)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "could not verify" in result.output
|
||||
assert captured
|
||||
Loading…
Add table
Add a link
Reference in a new issue