Files
headroomlabs-ai--headroom/tests/test_proxy_copilot_auth_hooks.py
wehub-resource-sync 94b8d5d118
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Waiting to run
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Blocked by required conditions
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Waiting to run
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Blocked by required conditions
Docker / promote-latest (push) Blocked by required conditions
Init Native E2E / init-native (macos-latest, claude) (push) Waiting to run
Init Native E2E / init-native (macos-latest, codex) (push) Waiting to run
Init Native E2E / init-native (macos-latest, copilot) (push) Waiting to run
Install Native E2E / install-native (macos-latest) (push) Waiting to run
Wrap Native E2E / wrap-native (macos-latest) (push) Waiting to run
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / changes (push) Failing after 2s
Deploy Documentation / deploy (push) Failing after 0s
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 1s
Dev Containers / validate-worktree (push) Failing after 1s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Init E2E / docker-init-e2e (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 0s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Merge Conflicts / merge-conflicts (push) Failing after 2s
OpenCode Plugin / typecheck + build + test (push) Failing after 2s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 3s
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:29 +08:00

270 lines
9.3 KiB
Python

from __future__ import annotations
import asyncio
import importlib.util
import sys
import types
from pathlib import Path
from types import SimpleNamespace
import pytest
ROOT = Path(__file__).resolve().parents[1]
_ISOLATED_MODULE_NAMES = (
"headroom.proxy",
"headroom.proxy.handlers",
"httpx",
"fastapi.responses",
"tests.headroom_proxy_handlers_openai",
"tests.headroom_proxy_handlers_streaming",
)
@pytest.fixture(autouse=True)
def restore_isolated_modules() -> None:
saved_modules = {name: sys.modules.get(name) for name in _ISOLATED_MODULE_NAMES}
try:
yield
finally:
for name in _ISOLATED_MODULE_NAMES:
sys.modules.pop(name, None)
for name, module in saved_modules.items():
if module is not None:
sys.modules[name] = module
def _load_handler_module(monkeypatch: pytest.MonkeyPatch, module_name: str, relative_path: str):
proxy_pkg = types.ModuleType("headroom.proxy")
proxy_pkg.__path__ = [str(ROOT / "headroom" / "proxy")]
monkeypatch.setitem(sys.modules, "headroom.proxy", proxy_pkg)
handlers_pkg = types.ModuleType("headroom.proxy.handlers")
handlers_pkg.__path__ = [str(ROOT / "headroom" / "proxy" / "handlers")]
monkeypatch.setitem(sys.modules, "headroom.proxy.handlers", handlers_pkg)
httpx_mod = types.ModuleType("httpx")
httpx_mod.ConnectError = type("ConnectError", (Exception,), {})
httpx_mod.ConnectTimeout = type("ConnectTimeout", (Exception,), {})
httpx_mod.PoolTimeout = type("PoolTimeout", (Exception,), {})
httpx_mod.ReadTimeout = type("ReadTimeout", (Exception,), {})
monkeypatch.setitem(sys.modules, "httpx", httpx_mod)
responses_mod = types.ModuleType("fastapi.responses")
class Response:
def __init__(self, content=None, status_code: int = 200, headers=None, media_type=None):
self.content = content
self.status_code = status_code
self.headers = headers or {}
self.media_type = media_type
class StreamingResponse(Response):
pass
class JSONResponse(Response):
pass
responses_mod.Response = Response
responses_mod.StreamingResponse = StreamingResponse
responses_mod.JSONResponse = JSONResponse
monkeypatch.setitem(sys.modules, "fastapi.responses", responses_mod)
spec = importlib.util.spec_from_file_location(module_name, ROOT / relative_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, module_name, module)
spec.loader.exec_module(module)
return module
def test_openai_passthrough_applies_copilot_auth(monkeypatch: pytest.MonkeyPatch) -> None:
openai_mod = _load_handler_module(
monkeypatch,
"tests.headroom_proxy_handlers_openai",
"headroom/proxy/handlers/openai.py",
)
seen: dict[str, object] = {}
async def fake_apply(headers: dict[str, str], *, url: str) -> dict[str, str]:
seen["headers"] = dict(headers)
seen["url"] = url
return {"Authorization": "Bearer upstream-token"}
monkeypatch.setattr(openai_mod, "apply_copilot_api_auth", fake_apply)
class Dummy(openai_mod.OpenAIHandlerMixin):
def __init__(self) -> None:
self.metrics = SimpleNamespace(record_request=self._record_request)
self.http_client = SimpleNamespace(request=self._request)
self.cost_tracker = None
self._counter = 0
async def _record_request(self, **kwargs) -> None: # noqa: ANN003
return None
async def _next_request_id(self) -> str:
# The passthrough handler now allocates a request_id at end-
# of-call because it records via ``_record_request_outcome``,
# which requires one. Pre-refactor the dummy didn't need
# this method because metrics.record_request was called
# directly without a request_id.
self._counter += 1
return f"req-{self._counter}"
async def _record_request_outcome(self, outcome) -> None: # noqa: ANN001
from headroom.proxy.outcome import emit_request_outcome
await emit_request_outcome(self, outcome)
def _extract_tags(self, headers: dict) -> dict[str, str]:
# Mirror of HeadroomProxy._extract_tags. The passthrough
# handler now extracts tags at entry as part of the
# outcome-tag invariant lock (PR #480).
return {
k.lower().replace("x-headroom-", ""): v
for k, v in headers.items()
if k.lower().startswith("x-headroom-")
}
async def _request(self, **kwargs): # noqa: ANN003
seen["request_kwargs"] = kwargs
return SimpleNamespace(headers={}, content=b"{}", status_code=200)
request = SimpleNamespace(
url=SimpleNamespace(path="/v1/models", query=""),
headers={
"authorization": "Bearer downstream",
"host": "localhost",
"accept-encoding": "gzip",
},
method="GET",
body=lambda: None,
)
async def body() -> bytes:
return b""
request.body = body
handler = Dummy()
response = asyncio.run(
handler.handle_passthrough(
request,
"https://api.githubcopilot.com",
"models",
"openai",
)
)
assert seen["url"] == "https://api.githubcopilot.com/models"
assert seen["request_kwargs"]["headers"] == {"Authorization": "Bearer upstream-token"}
assert response.status_code == 200
def test_streaming_response_applies_copilot_auth(monkeypatch: pytest.MonkeyPatch) -> None:
streaming_mod = _load_handler_module(
monkeypatch,
"tests.headroom_proxy_handlers_streaming",
"headroom/proxy/handlers/streaming.py",
)
seen: dict[str, object] = {}
async def fake_apply(headers: dict[str, str], *, url: str) -> dict[str, str]:
seen["headers"] = dict(headers)
seen["url"] = url
return {"Authorization": "Bearer upstream-token"}
monkeypatch.setattr(streaming_mod, "apply_copilot_api_auth", fake_apply)
class Dummy(streaming_mod.StreamingMixin):
def __init__(self) -> None:
self.memory_handler = None
self.config = SimpleNamespace(
retry_max_attempts=1,
retry_base_delay_ms=1,
retry_max_delay_ms=1,
)
self.http_client = SimpleNamespace(
build_request=self._build_request,
send=self._send,
)
def _build_request(self, method: str, url: str, **kwargs): # noqa: ANN003
# PR-A3: streaming forwarder is byte-faithful; it now passes
# ``content=<bytes>`` instead of ``json=<dict>``.
seen["request"] = {
"method": method,
"url": url,
**kwargs,
}
return SimpleNamespace()
async def _send(self, request, stream: bool): # noqa: ANN001, ANN003
return SimpleNamespace(headers={}, status_code=200)
handler = Dummy()
response = asyncio.run(
handler._stream_response(
url="https://api.githubcopilot.com/v1/responses",
headers={"authorization": "Bearer downstream"},
body={"model": "gpt-4o"},
provider="openai",
model="gpt-4o",
request_id="req-test",
original_tokens=0,
optimized_tokens=0,
tokens_saved=0,
transforms_applied=[],
tags={},
optimization_latency=0.0,
)
)
assert seen["url"] == "https://api.githubcopilot.com/v1/responses"
# PR-A3: byte-faithful forwarder always sets ``content-type`` explicitly.
sent_headers = seen["request"]["headers"]
assert sent_headers["Authorization"] == "Bearer upstream-token"
assert sent_headers["content-type"] == "application/json"
assert response.status_code == 200
def test_openai_chat_routes_copilot_requests_per_model(monkeypatch: pytest.MonkeyPatch) -> None:
openai_mod = _load_handler_module(
monkeypatch,
"tests.headroom_proxy_handlers_openai",
"headroom/proxy/handlers/openai.py",
)
copilot_base = "https://api.githubcopilot.com"
gpt54_mini_url = openai_mod.build_copilot_upstream_url(
copilot_base,
openai_mod._resolve_openai_handler_path(
{},
handler_path=openai_mod._resolve_openai_chat_handler_path(copilot_base, "gpt-5.4-mini"),
),
)
claude_url = openai_mod.build_copilot_upstream_url(
copilot_base,
openai_mod._resolve_openai_handler_path(
{},
handler_path=openai_mod._resolve_openai_chat_handler_path(
copilot_base, "claude-sonnet-5"
),
),
)
openai_url = openai_mod.build_copilot_upstream_url(
"https://api.openai.com",
openai_mod._resolve_openai_handler_path(
{},
handler_path=openai_mod._resolve_openai_chat_handler_path(
"https://api.openai.com", "gpt-5.4-mini"
),
),
)
assert gpt54_mini_url == "https://api.githubcopilot.com/responses"
assert claude_url == "https://api.githubcopilot.com/chat/completions"
assert openai_url == "https://api.openai.com/v1/chat/completions"