Spaces:
Build error
Build error
Merge upstream/main into feat/canonical-pipeline
Browse filesCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- .claude-plugin/marketplace.json +2 -2
- .github/plugin/marketplace.json +2 -2
- CHANGELOG.md +19 -0
- codecov.yml +19 -0
- headroom/cli/wrap.py +0 -0
- headroom/copilot_auth.py +138 -0
- headroom/dashboard/templates/dashboard.html +54 -12
- headroom/learn/writer.py +72 -12
- headroom/proxy/handlers/openai.py +0 -0
- headroom/proxy/handlers/streaming.py +35 -0
- headroom/proxy/helpers.py +53 -12
- headroom/proxy/server.py +45 -4
- plugins/headroom-agent-hooks/.claude-plugin/plugin.json +1 -1
- plugins/headroom-agent-hooks/.github/plugin/plugin.json +1 -1
- tests/test_cli/test_wrap_copilot.py +335 -212
- tests/test_cli/test_wrap_persistent.py +33 -17
- tests/test_copilot_auth.py +261 -11
- tests/test_learn/test_writer.py +135 -9
- tests/test_proxy_copilot_auth_hooks.py +47 -23
- tests/test_proxy_dashboard_stats_cache.py +144 -0
- tests/test_proxy_streaming_request_logger.py +174 -0
.claude-plugin/marketplace.json
CHANGED
|
@@ -5,14 +5,14 @@
|
|
| 5 |
},
|
| 6 |
"metadata": {
|
| 7 |
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
|
| 8 |
-
"version": "0.
|
| 9 |
},
|
| 10 |
"plugins": [
|
| 11 |
{
|
| 12 |
"name": "headroom",
|
| 13 |
"source": "./plugins/headroom-agent-hooks",
|
| 14 |
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
|
| 15 |
-
"version": "0.
|
| 16 |
"author": {
|
| 17 |
"name": "Headroom Contributors",
|
| 18 |
"url": "https://github.com/chopratejas/headroom"
|
|
|
|
| 5 |
},
|
| 6 |
"metadata": {
|
| 7 |
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
|
| 8 |
+
"version": "0.11.0"
|
| 9 |
},
|
| 10 |
"plugins": [
|
| 11 |
{
|
| 12 |
"name": "headroom",
|
| 13 |
"source": "./plugins/headroom-agent-hooks",
|
| 14 |
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
|
| 15 |
+
"version": "0.11.0",
|
| 16 |
"author": {
|
| 17 |
"name": "Headroom Contributors",
|
| 18 |
"url": "https://github.com/chopratejas/headroom"
|
.github/plugin/marketplace.json
CHANGED
|
@@ -5,14 +5,14 @@
|
|
| 5 |
},
|
| 6 |
"metadata": {
|
| 7 |
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
|
| 8 |
-
"version": "0.
|
| 9 |
},
|
| 10 |
"plugins": [
|
| 11 |
{
|
| 12 |
"name": "headroom",
|
| 13 |
"source": "./plugins/headroom-agent-hooks",
|
| 14 |
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
|
| 15 |
-
"version": "0.
|
| 16 |
"author": {
|
| 17 |
"name": "Headroom Contributors",
|
| 18 |
"url": "https://github.com/chopratejas/headroom"
|
|
|
|
| 5 |
},
|
| 6 |
"metadata": {
|
| 7 |
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
|
| 8 |
+
"version": "0.11.0"
|
| 9 |
},
|
| 10 |
"plugins": [
|
| 11 |
{
|
| 12 |
"name": "headroom",
|
| 13 |
"source": "./plugins/headroom-agent-hooks",
|
| 14 |
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
|
| 15 |
+
"version": "0.11.0",
|
| 16 |
"author": {
|
| 17 |
"name": "Headroom Contributors",
|
| 18 |
"url": "https://github.com/chopratejas/headroom"
|
CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
| 7 |
|
| 8 |
## [Unreleased]
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
### Added
|
| 11 |
- **Telemetry stack & install-mode identity fields** — anonymous beacon now
|
| 12 |
reports `headroom_stack` (how Headroom is invoked: `proxy`, `wrap_claude`,
|
|
@@ -39,6 +47,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
| 39 |
hourly/daily/weekly/monthly rollups. Responses now include a
|
| 40 |
`history_summary` block describing stored versus returned points.
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
## [0.5.22] - 2026-04-11
|
| 43 |
|
| 44 |
### Added
|
|
|
|
| 7 |
|
| 8 |
## [Unreleased]
|
| 9 |
|
| 10 |
+
### Fixed
|
| 11 |
+
- **`headroom learn` no longer clobbers prior recommendations on re-run** —
|
| 12 |
+
the marker block in `CLAUDE.md` / `MEMORY.md` is now merged with the
|
| 13 |
+
prior block instead of wholesale-replaced. Sections re-surfaced by the
|
| 14 |
+
new run win; sections not re-surfaced are carried forward so learnings
|
| 15 |
+
accumulate across runs instead of disappearing. To fully rebuild the
|
| 16 |
+
block, delete it manually and re-run. (#231)
|
| 17 |
+
|
| 18 |
### Added
|
| 19 |
- **Telemetry stack & install-mode identity fields** — anonymous beacon now
|
| 20 |
reports `headroom_stack` (how Headroom is invoked: `proxy`, `wrap_claude`,
|
|
|
|
| 47 |
hourly/daily/weekly/monthly rollups. Responses now include a
|
| 48 |
`history_summary` block describing stored versus returned points.
|
| 49 |
|
| 50 |
+
### Fixed
|
| 51 |
+
- **Streaming Anthropic requests are now visible to `/stats.recent_requests`
|
| 52 |
+
and `/transformations/feed`** — `_finalize_stream_response` did not call
|
| 53 |
+
`self.logger.log(...)`, so the entire streaming Anthropic code path (the
|
| 54 |
+
one Claude Code uses) silently bypassed the request logger. Only the
|
| 55 |
+
non-streaming Anthropic path and the Bedrock streaming path were logged.
|
| 56 |
+
As a consequence, `--log-messages` had no observable effect on the live
|
| 57 |
+
transformations feed for typical traffic. The streaming finalizer now
|
| 58 |
+
emits the same `RequestLog` shape the other paths do, including
|
| 59 |
+
`request_messages` when `log_full_messages` is enabled.
|
| 60 |
+
|
| 61 |
## [0.5.22] - 2026-04-11
|
| 62 |
|
| 63 |
### Added
|
codecov.yml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
codecov:
|
| 2 |
+
require_ci_to_pass: true
|
| 3 |
+
|
| 4 |
+
coverage:
|
| 5 |
+
status:
|
| 6 |
+
project:
|
| 7 |
+
default:
|
| 8 |
+
target: auto
|
| 9 |
+
patch:
|
| 10 |
+
default:
|
| 11 |
+
target: auto
|
| 12 |
+
|
| 13 |
+
ignore:
|
| 14 |
+
- "tests/**"
|
| 15 |
+
- "scripts/tests/**"
|
| 16 |
+
- ".github/**"
|
| 17 |
+
- ".claude-plugin/**"
|
| 18 |
+
- "plugins/headroom-agent-hooks/.claude-plugin/**"
|
| 19 |
+
- "plugins/headroom-agent-hooks/.github/**"
|
headroom/cli/wrap.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
headroom/copilot_auth.py
CHANGED
|
@@ -3,10 +3,13 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import asyncio
|
|
|
|
| 6 |
import json
|
| 7 |
import logging
|
| 8 |
import os
|
|
|
|
| 9 |
import time
|
|
|
|
| 10 |
from dataclasses import dataclass
|
| 11 |
from datetime import datetime
|
| 12 |
from pathlib import Path
|
|
@@ -67,6 +70,11 @@ def _token_exchange_url() -> str:
|
|
| 67 |
return os.environ.get("GITHUB_COPILOT_TOKEN_EXCHANGE_URL", DEFAULT_TOKEN_EXCHANGE_URL).strip()
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
def _resolve_token_file_paths() -> list[Path]:
|
| 71 |
override = os.environ.get("GITHUB_COPILOT_TOKEN_FILE", "").strip()
|
| 72 |
if override:
|
|
@@ -83,6 +91,108 @@ def _resolve_token_file_paths() -> list[Path]:
|
|
| 83 |
return paths
|
| 84 |
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
def _parse_expiry(value: Any) -> float | None:
|
| 87 |
if value in (None, ""):
|
| 88 |
return None
|
|
@@ -157,6 +267,14 @@ def read_cached_oauth_token() -> str | None:
|
|
| 157 |
if token:
|
| 158 |
return token
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
host = _github_host()
|
| 161 |
for path in _resolve_token_file_paths():
|
| 162 |
try:
|
|
@@ -203,6 +321,16 @@ def is_copilot_api_url(url: str | None) -> bool:
|
|
| 203 |
return "githubcopilot.com" in host
|
| 204 |
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
class CopilotTokenProvider:
|
| 207 |
"""Resolve and cache short-lived Copilot API tokens."""
|
| 208 |
|
|
@@ -233,6 +361,16 @@ class CopilotTokenProvider:
|
|
| 233 |
if not oauth_token:
|
| 234 |
raise RuntimeError("No GitHub Copilot OAuth token is available.")
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
exchanged = await self._exchange_token(oauth_token)
|
| 237 |
self._cached = exchanged
|
| 238 |
return exchanged
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import asyncio
|
| 6 |
+
import ctypes
|
| 7 |
import json
|
| 8 |
import logging
|
| 9 |
import os
|
| 10 |
+
import subprocess
|
| 11 |
import time
|
| 12 |
+
from ctypes import wintypes
|
| 13 |
from dataclasses import dataclass
|
| 14 |
from datetime import datetime
|
| 15 |
from pathlib import Path
|
|
|
|
| 70 |
return os.environ.get("GITHUB_COPILOT_TOKEN_EXCHANGE_URL", DEFAULT_TOKEN_EXCHANGE_URL).strip()
|
| 71 |
|
| 72 |
|
| 73 |
+
def _should_exchange_oauth_token() -> bool:
|
| 74 |
+
raw = os.environ.get("GITHUB_COPILOT_USE_TOKEN_EXCHANGE", "").strip().lower()
|
| 75 |
+
return raw in {"1", "true", "yes", "on"}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
def _resolve_token_file_paths() -> list[Path]:
|
| 79 |
override = os.environ.get("GITHUB_COPILOT_TOKEN_FILE", "").strip()
|
| 80 |
if override:
|
|
|
|
| 91 |
return paths
|
| 92 |
|
| 93 |
|
| 94 |
+
def _read_gh_cli_oauth_token() -> str | None:
|
| 95 |
+
gh_bin = os.environ.get("GH_PATH", "").strip() or "gh"
|
| 96 |
+
command = [gh_bin, "auth", "token"]
|
| 97 |
+
host = _github_host()
|
| 98 |
+
if host and host != DEFAULT_GITHUB_HOST:
|
| 99 |
+
command.extend(["--hostname", host])
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
result = subprocess.run(
|
| 103 |
+
command,
|
| 104 |
+
capture_output=True,
|
| 105 |
+
text=True,
|
| 106 |
+
encoding="utf-8",
|
| 107 |
+
errors="replace",
|
| 108 |
+
check=False,
|
| 109 |
+
)
|
| 110 |
+
except OSError as exc:
|
| 111 |
+
logger.debug("Unable to invoke GitHub CLI for Copilot auth discovery: %s", exc)
|
| 112 |
+
return None
|
| 113 |
+
|
| 114 |
+
if result.returncode != 0:
|
| 115 |
+
logger.debug("GitHub CLI auth token lookup failed with exit code %s", result.returncode)
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
token = result.stdout.strip()
|
| 119 |
+
return token or None
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _read_windows_copilot_cli_oauth_token() -> str | None:
|
| 123 |
+
if os.name != "nt":
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
class FILETIME(ctypes.Structure):
|
| 127 |
+
_fields_ = [
|
| 128 |
+
("dwLowDateTime", wintypes.DWORD),
|
| 129 |
+
("dwHighDateTime", wintypes.DWORD),
|
| 130 |
+
]
|
| 131 |
+
|
| 132 |
+
class CREDENTIAL(ctypes.Structure):
|
| 133 |
+
_fields_ = [
|
| 134 |
+
("Flags", wintypes.DWORD),
|
| 135 |
+
("Type", wintypes.DWORD),
|
| 136 |
+
("TargetName", wintypes.LPWSTR),
|
| 137 |
+
("Comment", wintypes.LPWSTR),
|
| 138 |
+
("LastWritten", FILETIME),
|
| 139 |
+
("CredentialBlobSize", wintypes.DWORD),
|
| 140 |
+
("CredentialBlob", ctypes.POINTER(ctypes.c_ubyte)),
|
| 141 |
+
("Persist", wintypes.DWORD),
|
| 142 |
+
("AttributeCount", wintypes.DWORD),
|
| 143 |
+
("Attributes", wintypes.LPVOID),
|
| 144 |
+
("TargetAlias", wintypes.LPWSTR),
|
| 145 |
+
("UserName", wintypes.LPWSTR),
|
| 146 |
+
]
|
| 147 |
+
|
| 148 |
+
cred_ptr = ctypes.POINTER(CREDENTIAL)
|
| 149 |
+
credentials = ctypes.POINTER(cred_ptr)()
|
| 150 |
+
count = wintypes.DWORD()
|
| 151 |
+
win_dll = getattr(ctypes, "WinDLL", None)
|
| 152 |
+
if win_dll is None:
|
| 153 |
+
return None
|
| 154 |
+
|
| 155 |
+
advapi32 = win_dll("Advapi32.dll")
|
| 156 |
+
advapi32.CredEnumerateW.argtypes = [
|
| 157 |
+
wintypes.LPCWSTR,
|
| 158 |
+
wintypes.DWORD,
|
| 159 |
+
ctypes.POINTER(wintypes.DWORD),
|
| 160 |
+
ctypes.POINTER(ctypes.POINTER(cred_ptr)),
|
| 161 |
+
]
|
| 162 |
+
advapi32.CredEnumerateW.restype = wintypes.BOOL
|
| 163 |
+
advapi32.CredFree.argtypes = [wintypes.LPVOID]
|
| 164 |
+
|
| 165 |
+
try:
|
| 166 |
+
if not advapi32.CredEnumerateW(None, 0, ctypes.byref(count), ctypes.byref(credentials)):
|
| 167 |
+
return None
|
| 168 |
+
except OSError as exc:
|
| 169 |
+
logger.debug("Unable to enumerate Windows credentials for Copilot auth discovery: %s", exc)
|
| 170 |
+
return None
|
| 171 |
+
|
| 172 |
+
host = _github_host().lower()
|
| 173 |
+
service_prefixes = [f"copilot-cli/{host}:"]
|
| 174 |
+
if "://" not in host:
|
| 175 |
+
service_prefixes.append(f"copilot-cli/https://{host}:")
|
| 176 |
+
|
| 177 |
+
try:
|
| 178 |
+
for idx in range(count.value):
|
| 179 |
+
credential = credentials[idx].contents
|
| 180 |
+
target = (credential.TargetName or "").strip().lower()
|
| 181 |
+
if not any(target.startswith(prefix) for prefix in service_prefixes):
|
| 182 |
+
continue
|
| 183 |
+
if credential.CredentialBlobSize <= 0 or not credential.CredentialBlob:
|
| 184 |
+
continue
|
| 185 |
+
blob = ctypes.string_at(credential.CredentialBlob, credential.CredentialBlobSize)
|
| 186 |
+
token = blob.decode("utf-8", errors="replace").strip()
|
| 187 |
+
if token:
|
| 188 |
+
return token
|
| 189 |
+
finally:
|
| 190 |
+
if credentials:
|
| 191 |
+
advapi32.CredFree(credentials)
|
| 192 |
+
|
| 193 |
+
return None
|
| 194 |
+
|
| 195 |
+
|
| 196 |
def _parse_expiry(value: Any) -> float | None:
|
| 197 |
if value in (None, ""):
|
| 198 |
return None
|
|
|
|
| 267 |
if token:
|
| 268 |
return token
|
| 269 |
|
| 270 |
+
windows_copilot_token = _read_windows_copilot_cli_oauth_token()
|
| 271 |
+
if windows_copilot_token:
|
| 272 |
+
return windows_copilot_token
|
| 273 |
+
|
| 274 |
+
gh_token = _read_gh_cli_oauth_token()
|
| 275 |
+
if gh_token:
|
| 276 |
+
return gh_token
|
| 277 |
+
|
| 278 |
host = _github_host()
|
| 279 |
for path in _resolve_token_file_paths():
|
| 280 |
try:
|
|
|
|
| 321 |
return "githubcopilot.com" in host
|
| 322 |
|
| 323 |
|
| 324 |
+
def build_copilot_upstream_url(base_url: str, path: str) -> str:
|
| 325 |
+
"""Build an upstream URL, normalizing GitHub Copilot's non-/v1 path layout."""
|
| 326 |
+
|
| 327 |
+
normalized_base = base_url.rstrip("/")
|
| 328 |
+
normalized_path = path if path.startswith("/") else f"/{path}"
|
| 329 |
+
if is_copilot_api_url(normalized_base) and normalized_path.startswith("/v1/"):
|
| 330 |
+
normalized_path = normalized_path[3:]
|
| 331 |
+
return f"{normalized_base}{normalized_path}"
|
| 332 |
+
|
| 333 |
+
|
| 334 |
class CopilotTokenProvider:
|
| 335 |
"""Resolve and cache short-lived Copilot API tokens."""
|
| 336 |
|
|
|
|
| 361 |
if not oauth_token:
|
| 362 |
raise RuntimeError("No GitHub Copilot OAuth token is available.")
|
| 363 |
|
| 364 |
+
if not _should_exchange_oauth_token():
|
| 365 |
+
direct_token = CopilotAPIToken(
|
| 366 |
+
token=oauth_token,
|
| 367 |
+
expires_at=time.time() + 3600,
|
| 368 |
+
api_url=os.environ.get("GITHUB_COPILOT_API_URL", DEFAULT_API_URL).strip()
|
| 369 |
+
or DEFAULT_API_URL,
|
| 370 |
+
)
|
| 371 |
+
self._cached = direct_token
|
| 372 |
+
return direct_token
|
| 373 |
+
|
| 374 |
exchanged = await self._exchange_token(oauth_token)
|
| 375 |
self._cached = exchanged
|
| 376 |
return exchanged
|
headroom/dashboard/templates/dashboard.html
CHANGED
|
@@ -55,12 +55,12 @@
|
|
| 55 |
<div class="inline-flex rounded-lg border border-border bg-surface p-1">
|
| 56 |
<button class="px-3 py-1.5 text-sm rounded-md transition-colors"
|
| 57 |
:class="viewMode === 'session' ? 'bg-accent text-black' : 'text-gray-400 hover:text-gray-200'"
|
| 58 |
-
@click="
|
| 59 |
Session
|
| 60 |
</button>
|
| 61 |
<button class="px-3 py-1.5 text-sm rounded-md transition-colors"
|
| 62 |
:class="viewMode === 'history' ? 'bg-accent text-black' : 'text-gray-400 hover:text-gray-200'"
|
| 63 |
-
@click="
|
| 64 |
Historical
|
| 65 |
</button>
|
| 66 |
</div>
|
|
@@ -89,7 +89,7 @@
|
|
| 89 |
</div>
|
| 90 |
<button x-show="log_full_messages" id="feed-toggle"
|
| 91 |
class="px-3 py-1.5 text-sm rounded-md border border-border bg-surface text-gray-300 hover:text-white transition-colors"
|
| 92 |
-
@click="
|
| 93 |
:class="feedOpen ? 'bg-accent text-black' : ''">
|
| 94 |
Live Feed
|
| 95 |
</button>
|
|
@@ -1256,6 +1256,11 @@
|
|
| 1256 |
savingsHistory: [],
|
| 1257 |
expandedRows: {},
|
| 1258 |
pollInterval: null,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1259 |
feedOpen: false,
|
| 1260 |
transformations: [],
|
| 1261 |
feedScrolled_: false,
|
|
@@ -1267,31 +1272,55 @@
|
|
| 1267 |
|
| 1268 |
async init() {
|
| 1269 |
await this.fetchStats();
|
| 1270 |
-
await this.fetchTransformations();
|
| 1271 |
|
| 1272 |
this.pollInterval = setInterval(() => {
|
| 1273 |
-
this.
|
| 1274 |
-
|
| 1275 |
-
}, 3000);
|
| 1276 |
|
| 1277 |
// Keyboard shortcuts
|
| 1278 |
document.addEventListener('keydown', (e) => {
|
| 1279 |
if (e.key === 'r' || e.key === 'R') {
|
| 1280 |
-
this.
|
| 1281 |
}
|
| 1282 |
});
|
| 1283 |
},
|
| 1284 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1285 |
async fetchStats() {
|
| 1286 |
try {
|
| 1287 |
-
const [statsRes,
|
| 1288 |
-
fetch('/stats'),
|
| 1289 |
-
fetch('/stats-history'),
|
| 1290 |
fetch('/health')
|
| 1291 |
]);
|
| 1292 |
|
| 1293 |
this.stats = await statsRes.json();
|
| 1294 |
-
this.historyStats = await historyRes.json();
|
| 1295 |
const health = await healthRes.json();
|
| 1296 |
this.healthy = health.status === 'healthy';
|
| 1297 |
this.version = health.version || '0.3.0';
|
|
@@ -1312,6 +1341,18 @@
|
|
| 1312 |
}
|
| 1313 |
},
|
| 1314 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1315 |
async fetchTransformations() {
|
| 1316 |
try {
|
| 1317 |
const prevLen = this.transformations.length;
|
|
@@ -1324,6 +1365,7 @@
|
|
| 1324 |
}
|
| 1325 |
this.transformations = data.transformations || [];
|
| 1326 |
this.log_full_messages = data.log_full_messages ?? this.log_full_messages;
|
|
|
|
| 1327 |
this.renderTransformations();
|
| 1328 |
}
|
| 1329 |
} catch (e) {
|
|
|
|
| 55 |
<div class="inline-flex rounded-lg border border-border bg-surface p-1">
|
| 56 |
<button class="px-3 py-1.5 text-sm rounded-md transition-colors"
|
| 57 |
:class="viewMode === 'session' ? 'bg-accent text-black' : 'text-gray-400 hover:text-gray-200'"
|
| 58 |
+
@click="setViewMode('session')">
|
| 59 |
Session
|
| 60 |
</button>
|
| 61 |
<button class="px-3 py-1.5 text-sm rounded-md transition-colors"
|
| 62 |
:class="viewMode === 'history' ? 'bg-accent text-black' : 'text-gray-400 hover:text-gray-200'"
|
| 63 |
+
@click="setViewMode('history')">
|
| 64 |
Historical
|
| 65 |
</button>
|
| 66 |
</div>
|
|
|
|
| 89 |
</div>
|
| 90 |
<button x-show="log_full_messages" id="feed-toggle"
|
| 91 |
class="px-3 py-1.5 text-sm rounded-md border border-border bg-surface text-gray-300 hover:text-white transition-colors"
|
| 92 |
+
@click="toggleFeed()"
|
| 93 |
:class="feedOpen ? 'bg-accent text-black' : ''">
|
| 94 |
Live Feed
|
| 95 |
</button>
|
|
|
|
| 1256 |
savingsHistory: [],
|
| 1257 |
expandedRows: {},
|
| 1258 |
pollInterval: null,
|
| 1259 |
+
statsPollMs: 5000,
|
| 1260 |
+
historyPollMs: 30000,
|
| 1261 |
+
feedPollMs: 5000,
|
| 1262 |
+
lastHistoryFetchMs: 0,
|
| 1263 |
+
lastFeedFetchMs: 0,
|
| 1264 |
feedOpen: false,
|
| 1265 |
transformations: [],
|
| 1266 |
feedScrolled_: false,
|
|
|
|
| 1272 |
|
| 1273 |
async init() {
|
| 1274 |
await this.fetchStats();
|
|
|
|
| 1275 |
|
| 1276 |
this.pollInterval = setInterval(() => {
|
| 1277 |
+
this.pollDashboard();
|
| 1278 |
+
}, this.statsPollMs);
|
|
|
|
| 1279 |
|
| 1280 |
// Keyboard shortcuts
|
| 1281 |
document.addEventListener('keydown', (e) => {
|
| 1282 |
if (e.key === 'r' || e.key === 'R') {
|
| 1283 |
+
this.pollDashboard(true);
|
| 1284 |
}
|
| 1285 |
});
|
| 1286 |
},
|
| 1287 |
|
| 1288 |
+
async pollDashboard(force = false) {
|
| 1289 |
+
if (!force && document.hidden) return;
|
| 1290 |
+
|
| 1291 |
+
await this.fetchStats();
|
| 1292 |
+
|
| 1293 |
+
const now = Date.now();
|
| 1294 |
+
if (this.viewMode === 'history' && (force || now - this.lastHistoryFetchMs >= this.historyPollMs)) {
|
| 1295 |
+
await this.fetchHistoryStats();
|
| 1296 |
+
}
|
| 1297 |
+
if (this.feedOpen && (force || now - this.lastFeedFetchMs >= this.feedPollMs)) {
|
| 1298 |
+
await this.fetchTransformations();
|
| 1299 |
+
}
|
| 1300 |
+
},
|
| 1301 |
+
|
| 1302 |
+
async setViewMode(mode) {
|
| 1303 |
+
this.viewMode = mode;
|
| 1304 |
+
if (mode === 'history') {
|
| 1305 |
+
await this.fetchHistoryStats();
|
| 1306 |
+
}
|
| 1307 |
+
},
|
| 1308 |
+
|
| 1309 |
+
async toggleFeed() {
|
| 1310 |
+
this.feedOpen = !this.feedOpen;
|
| 1311 |
+
if (this.feedOpen) {
|
| 1312 |
+
await this.fetchTransformations();
|
| 1313 |
+
}
|
| 1314 |
+
},
|
| 1315 |
+
|
| 1316 |
async fetchStats() {
|
| 1317 |
try {
|
| 1318 |
+
const [statsRes, healthRes] = await Promise.all([
|
| 1319 |
+
fetch('/stats?cached=1'),
|
|
|
|
| 1320 |
fetch('/health')
|
| 1321 |
]);
|
| 1322 |
|
| 1323 |
this.stats = await statsRes.json();
|
|
|
|
| 1324 |
const health = await healthRes.json();
|
| 1325 |
this.healthy = health.status === 'healthy';
|
| 1326 |
this.version = health.version || '0.3.0';
|
|
|
|
| 1341 |
}
|
| 1342 |
},
|
| 1343 |
|
| 1344 |
+
async fetchHistoryStats() {
|
| 1345 |
+
try {
|
| 1346 |
+
const response = await fetch('/stats-history');
|
| 1347 |
+
if (response.ok) {
|
| 1348 |
+
this.historyStats = await response.json();
|
| 1349 |
+
this.lastHistoryFetchMs = Date.now();
|
| 1350 |
+
}
|
| 1351 |
+
} catch (e) {
|
| 1352 |
+
console.error('Failed to fetch history stats:', e);
|
| 1353 |
+
}
|
| 1354 |
+
},
|
| 1355 |
+
|
| 1356 |
async fetchTransformations() {
|
| 1357 |
try {
|
| 1358 |
const prevLen = this.transformations.length;
|
|
|
|
| 1365 |
}
|
| 1366 |
this.transformations = data.transformations || [];
|
| 1367 |
this.log_full_messages = data.log_full_messages ?? this.log_full_messages;
|
| 1368 |
+
this.lastFeedFetchMs = Date.now();
|
| 1369 |
this.renderTransformations();
|
| 1370 |
}
|
| 1371 |
} catch (e) {
|
headroom/learn/writer.py
CHANGED
|
@@ -87,8 +87,73 @@ def _build_section(recommendations: list[Recommendation]) -> str:
|
|
| 87 |
return "\n".join(lines)
|
| 88 |
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
if file_path.exists():
|
| 93 |
existing = file_path.read_text()
|
| 94 |
if _MARKER_START in existing:
|
|
@@ -119,8 +184,7 @@ class ClaudeCodeWriter(ContextWriter):
|
|
| 119 |
|
| 120 |
if context_recs:
|
| 121 |
claude_md_path = self._resolve_context_path(project)
|
| 122 |
-
|
| 123 |
-
full_content = _merge_into_file(claude_md_path, section_content)
|
| 124 |
result.add(claude_md_path, full_content)
|
| 125 |
if not dry_run:
|
| 126 |
claude_md_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -128,8 +192,7 @@ class ClaudeCodeWriter(ContextWriter):
|
|
| 128 |
|
| 129 |
if memory_recs:
|
| 130 |
memory_path = self._resolve_memory_path(project)
|
| 131 |
-
|
| 132 |
-
full_content = _merge_into_file(memory_path, section_content)
|
| 133 |
result.add(memory_path, full_content)
|
| 134 |
if not dry_run:
|
| 135 |
memory_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -174,8 +237,7 @@ class CodexWriter(ContextWriter):
|
|
| 174 |
|
| 175 |
if context_recs:
|
| 176 |
agents_md = project.context_file or (project.project_path / "AGENTS.md")
|
| 177 |
-
|
| 178 |
-
full_content = _merge_into_file(agents_md, section_content)
|
| 179 |
result.add(agents_md, full_content)
|
| 180 |
if not dry_run:
|
| 181 |
agents_md.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -183,8 +245,7 @@ class CodexWriter(ContextWriter):
|
|
| 183 |
|
| 184 |
if memory_recs:
|
| 185 |
instructions_md = project.memory_file or (project.data_path.parent / "instructions.md")
|
| 186 |
-
|
| 187 |
-
full_content = _merge_into_file(instructions_md, section_content)
|
| 188 |
result.add(instructions_md, full_content)
|
| 189 |
if not dry_run:
|
| 190 |
instructions_md.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -214,8 +275,7 @@ class GeminiWriter(ContextWriter):
|
|
| 214 |
return result
|
| 215 |
|
| 216 |
gemini_md = project.context_file or (project.project_path / "GEMINI.md")
|
| 217 |
-
|
| 218 |
-
full_content = _merge_into_file(gemini_md, section_content)
|
| 219 |
result.add(gemini_md, full_content)
|
| 220 |
if not dry_run:
|
| 221 |
gemini_md.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 87 |
return "\n".join(lines)
|
| 88 |
|
| 89 |
|
| 90 |
+
# Matches the "*~N tokens/session saved*" annotation emitted by _build_section.
|
| 91 |
+
_TOKENS_ANNOTATION_PATTERN = re.compile(r"\*~([\d,]+) tokens/session saved\*\n?")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _parse_prior_recommendations(existing: str) -> list[Recommendation]:
|
| 95 |
+
"""Parse recommendations out of a prior marker block.
|
| 96 |
+
|
| 97 |
+
Returns [] if no marker block is present or it contains no sections.
|
| 98 |
+
The returned Recommendation objects are round-trip compatible with
|
| 99 |
+
_build_section — target is set to a placeholder since the marker block
|
| 100 |
+
itself doesn't record it (blocks are always per-file and per-target).
|
| 101 |
+
"""
|
| 102 |
+
match = _MARKER_PATTERN.search(existing)
|
| 103 |
+
if not match:
|
| 104 |
+
return []
|
| 105 |
+
inner = match.group(0)[len(_MARKER_START) : -len(_MARKER_END)]
|
| 106 |
+
|
| 107 |
+
recs: list[Recommendation] = []
|
| 108 |
+
for part in re.split(r"\n### ", "\n" + inner)[1:]:
|
| 109 |
+
heading_line, _, body = part.partition("\n")
|
| 110 |
+
heading = heading_line.strip()
|
| 111 |
+
if not heading:
|
| 112 |
+
continue
|
| 113 |
+
|
| 114 |
+
tokens_saved = 0
|
| 115 |
+
tokens_match = _TOKENS_ANNOTATION_PATTERN.match(body)
|
| 116 |
+
if tokens_match:
|
| 117 |
+
tokens_saved = int(tokens_match.group(1).replace(",", ""))
|
| 118 |
+
body = body[tokens_match.end() :]
|
| 119 |
+
|
| 120 |
+
recs.append(
|
| 121 |
+
Recommendation(
|
| 122 |
+
target=RecommendationTarget.CONTEXT_FILE,
|
| 123 |
+
section=heading,
|
| 124 |
+
content=body.rstrip(),
|
| 125 |
+
estimated_tokens_saved=tokens_saved,
|
| 126 |
+
)
|
| 127 |
+
)
|
| 128 |
+
return recs
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _merge_recommendations(
|
| 132 |
+
file_path: Path,
|
| 133 |
+
new_recommendations: list[Recommendation],
|
| 134 |
+
) -> list[Recommendation]:
|
| 135 |
+
"""Union new recommendations with prior ones whose section is not re-surfaced.
|
| 136 |
+
|
| 137 |
+
Sections produced by the current run take precedence over same-named
|
| 138 |
+
prior sections — the latest analysis is authoritative. Prior sections
|
| 139 |
+
whose headings do not reappear in the new run are carried forward so
|
| 140 |
+
a re-run doesn't silently drop accumulated learnings. To fully rebuild
|
| 141 |
+
the block, delete it manually and re-run.
|
| 142 |
+
"""
|
| 143 |
+
if not file_path.exists():
|
| 144 |
+
return new_recommendations
|
| 145 |
+
prior = _parse_prior_recommendations(file_path.read_text())
|
| 146 |
+
if not prior:
|
| 147 |
+
return new_recommendations
|
| 148 |
+
new_sections = {r.section for r in new_recommendations}
|
| 149 |
+
carried = [p for p in prior if p.section not in new_sections]
|
| 150 |
+
return list(new_recommendations) + carried
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _merge_into_file(file_path: Path, new_recommendations: list[Recommendation]) -> str:
|
| 154 |
+
"""Merge new recommendations with any existing marker block and rebuild the file."""
|
| 155 |
+
merged = _merge_recommendations(file_path, new_recommendations)
|
| 156 |
+
section = _build_section(merged)
|
| 157 |
if file_path.exists():
|
| 158 |
existing = file_path.read_text()
|
| 159 |
if _MARKER_START in existing:
|
|
|
|
| 184 |
|
| 185 |
if context_recs:
|
| 186 |
claude_md_path = self._resolve_context_path(project)
|
| 187 |
+
full_content = _merge_into_file(claude_md_path, context_recs)
|
|
|
|
| 188 |
result.add(claude_md_path, full_content)
|
| 189 |
if not dry_run:
|
| 190 |
claude_md_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 192 |
|
| 193 |
if memory_recs:
|
| 194 |
memory_path = self._resolve_memory_path(project)
|
| 195 |
+
full_content = _merge_into_file(memory_path, memory_recs)
|
|
|
|
| 196 |
result.add(memory_path, full_content)
|
| 197 |
if not dry_run:
|
| 198 |
memory_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 237 |
|
| 238 |
if context_recs:
|
| 239 |
agents_md = project.context_file or (project.project_path / "AGENTS.md")
|
| 240 |
+
full_content = _merge_into_file(agents_md, context_recs)
|
|
|
|
| 241 |
result.add(agents_md, full_content)
|
| 242 |
if not dry_run:
|
| 243 |
agents_md.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 245 |
|
| 246 |
if memory_recs:
|
| 247 |
instructions_md = project.memory_file or (project.data_path.parent / "instructions.md")
|
| 248 |
+
full_content = _merge_into_file(instructions_md, memory_recs)
|
|
|
|
| 249 |
result.add(instructions_md, full_content)
|
| 250 |
if not dry_run:
|
| 251 |
instructions_md.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 275 |
return result
|
| 276 |
|
| 277 |
gemini_md = project.context_file or (project.project_path / "GEMINI.md")
|
| 278 |
+
full_content = _merge_into_file(gemini_md, recommendations)
|
|
|
|
| 279 |
result.add(gemini_md, full_content)
|
| 280 |
if not dry_run:
|
| 281 |
gemini_md.parent.mkdir(parents=True, exist_ok=True)
|
headroom/proxy/handlers/openai.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
headroom/proxy/handlers/streaming.py
CHANGED
|
@@ -451,6 +451,7 @@ class StreamingMixin:
|
|
| 451 |
optimization_latency: float,
|
| 452 |
stream_state: dict[str, Any],
|
| 453 |
start_time: float,
|
|
|
|
| 454 |
pipeline_timing: dict[str, float] | None = None,
|
| 455 |
prefix_tracker: Any | None = None,
|
| 456 |
original_messages: list[dict] | None = None,
|
|
@@ -547,6 +548,38 @@ class StreamingMixin:
|
|
| 547 |
uncached_input_tokens=uncached_input_tokens,
|
| 548 |
)
|
| 549 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 550 |
async def _stream_response(
|
| 551 |
self,
|
| 552 |
url: str,
|
|
@@ -702,6 +735,7 @@ class StreamingMixin:
|
|
| 702 |
optimization_latency=optimization_latency,
|
| 703 |
stream_state=stream_state,
|
| 704 |
start_time=start_time,
|
|
|
|
| 705 |
pipeline_timing=pipeline_timing,
|
| 706 |
prefix_tracker=prefix_tracker,
|
| 707 |
original_messages=original_messages,
|
|
@@ -875,6 +909,7 @@ class StreamingMixin:
|
|
| 875 |
optimization_latency=optimization_latency,
|
| 876 |
stream_state=stream_state,
|
| 877 |
start_time=start_time,
|
|
|
|
| 878 |
pipeline_timing=pipeline_timing,
|
| 879 |
prefix_tracker=prefix_tracker,
|
| 880 |
original_messages=original_messages,
|
|
|
|
| 451 |
optimization_latency: float,
|
| 452 |
stream_state: dict[str, Any],
|
| 453 |
start_time: float,
|
| 454 |
+
tags: dict[str, str] | None = None,
|
| 455 |
pipeline_timing: dict[str, float] | None = None,
|
| 456 |
prefix_tracker: Any | None = None,
|
| 457 |
original_messages: list[dict] | None = None,
|
|
|
|
| 548 |
uncached_input_tokens=uncached_input_tokens,
|
| 549 |
)
|
| 550 |
|
| 551 |
+
# Log the request to the in-memory request logger so it shows up in
|
| 552 |
+
# /stats `recent_requests` and `/transformations/feed`. Without this
|
| 553 |
+
# the streaming Anthropic path (which is what Claude Code uses) is
|
| 554 |
+
# invisible to both surfaces — only Bedrock streaming and the
|
| 555 |
+
# non-streaming Anthropic path were logged previously.
|
| 556 |
+
if getattr(self, "logger", None) is not None:
|
| 557 |
+
from headroom.proxy.models import RequestLog
|
| 558 |
+
|
| 559 |
+
self.logger.log(
|
| 560 |
+
RequestLog(
|
| 561 |
+
request_id=request_id,
|
| 562 |
+
timestamp=datetime.now().isoformat(),
|
| 563 |
+
provider=provider,
|
| 564 |
+
model=model,
|
| 565 |
+
input_tokens_original=original_tokens,
|
| 566 |
+
input_tokens_optimized=optimized_tokens,
|
| 567 |
+
output_tokens=output_tokens,
|
| 568 |
+
tokens_saved=tokens_saved,
|
| 569 |
+
savings_percent=(tokens_saved / original_tokens * 100)
|
| 570 |
+
if original_tokens > 0
|
| 571 |
+
else 0,
|
| 572 |
+
optimization_latency_ms=optimization_latency,
|
| 573 |
+
total_latency_ms=total_latency,
|
| 574 |
+
tags=tags or {},
|
| 575 |
+
cache_hit=False,
|
| 576 |
+
transforms_applied=transforms_applied,
|
| 577 |
+
request_messages=body.get("messages")
|
| 578 |
+
if getattr(self.config, "log_full_messages", False)
|
| 579 |
+
else None,
|
| 580 |
+
)
|
| 581 |
+
)
|
| 582 |
+
|
| 583 |
async def _stream_response(
|
| 584 |
self,
|
| 585 |
url: str,
|
|
|
|
| 735 |
optimization_latency=optimization_latency,
|
| 736 |
stream_state=stream_state,
|
| 737 |
start_time=start_time,
|
| 738 |
+
tags=tags,
|
| 739 |
pipeline_timing=pipeline_timing,
|
| 740 |
prefix_tracker=prefix_tracker,
|
| 741 |
original_messages=original_messages,
|
|
|
|
| 909 |
optimization_latency=optimization_latency,
|
| 910 |
stream_state=stream_state,
|
| 911 |
start_time=start_time,
|
| 912 |
+
tags=tags,
|
| 913 |
pipeline_timing=pipeline_timing,
|
| 914 |
prefix_tracker=prefix_tracker,
|
| 915 |
original_messages=original_messages,
|
headroom/proxy/helpers.py
CHANGED
|
@@ -11,8 +11,10 @@ from __future__ import annotations
|
|
| 11 |
import json
|
| 12 |
import logging
|
| 13 |
import random
|
|
|
|
|
|
|
| 14 |
from pathlib import Path
|
| 15 |
-
from typing import TYPE_CHECKING, Any
|
| 16 |
|
| 17 |
from headroom import paths as _paths
|
| 18 |
|
|
@@ -21,6 +23,14 @@ if TYPE_CHECKING:
|
|
| 21 |
|
| 22 |
logger = logging.getLogger("headroom.proxy")
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
# Maximum request body size (100MB - increased to support image-heavy requests)
|
| 25 |
MAX_REQUEST_BODY_SIZE = 100 * 1024 * 1024
|
| 26 |
|
|
@@ -114,11 +124,18 @@ def _get_rtk_stats() -> dict[str, Any] | None:
|
|
| 114 |
"""Get rtk (Rust Token Killer) savings stats if rtk is installed.
|
| 115 |
|
| 116 |
Reads from rtk's tracking database via `rtk gain --format json`.
|
| 117 |
-
|
|
|
|
| 118 |
"""
|
| 119 |
import shutil
|
| 120 |
import subprocess as _sp
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
rtk_bin = shutil.which("rtk")
|
| 123 |
if not rtk_bin:
|
| 124 |
# Check headroom-managed install. Preserve the historical Unix-name
|
|
@@ -128,7 +145,16 @@ def _get_rtk_stats() -> dict[str, Any] | None:
|
|
| 128 |
if rtk_managed.exists():
|
| 129 |
rtk_bin = str(rtk_managed)
|
| 130 |
else:
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
try:
|
| 134 |
result = _sp.run(
|
|
@@ -140,21 +166,36 @@ def _get_rtk_stats() -> dict[str, Any] | None:
|
|
| 140 |
if result.returncode == 0 and result.stdout.strip():
|
| 141 |
data = json.loads(result.stdout)
|
| 142 |
summary = data.get("summary", {})
|
| 143 |
-
|
| 144 |
"installed": True,
|
| 145 |
"total_commands": summary.get("total_commands", 0),
|
| 146 |
"tokens_saved": summary.get("total_saved", 0),
|
| 147 |
"avg_savings_pct": summary.get("avg_savings_pct", 0.0),
|
| 148 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
except Exception:
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
|
| 160 |
def is_anthropic_auth(headers: dict[str, str]) -> bool:
|
|
|
|
| 11 |
import json
|
| 12 |
import logging
|
| 13 |
import random
|
| 14 |
+
import threading
|
| 15 |
+
import time
|
| 16 |
from pathlib import Path
|
| 17 |
+
from typing import TYPE_CHECKING, Any, cast
|
| 18 |
|
| 19 |
from headroom import paths as _paths
|
| 20 |
|
|
|
|
| 23 |
|
| 24 |
logger = logging.getLogger("headroom.proxy")
|
| 25 |
|
| 26 |
+
RTK_STATS_CACHE_TTL_SECONDS = 5.0
|
| 27 |
+
_rtk_stats_cache_lock = threading.Lock()
|
| 28 |
+
_rtk_stats_cache: dict[str, Any] = {
|
| 29 |
+
"expires_at": 0.0,
|
| 30 |
+
"has_value": False,
|
| 31 |
+
"value": None,
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
# Maximum request body size (100MB - increased to support image-heavy requests)
|
| 35 |
MAX_REQUEST_BODY_SIZE = 100 * 1024 * 1024
|
| 36 |
|
|
|
|
| 124 |
"""Get rtk (Rust Token Killer) savings stats if rtk is installed.
|
| 125 |
|
| 126 |
Reads from rtk's tracking database via `rtk gain --format json`.
|
| 127 |
+
Results are memoized briefly so dashboard polling does not spawn a new
|
| 128 |
+
subprocess on every refresh.
|
| 129 |
"""
|
| 130 |
import shutil
|
| 131 |
import subprocess as _sp
|
| 132 |
|
| 133 |
+
now = time.monotonic()
|
| 134 |
+
with _rtk_stats_cache_lock:
|
| 135 |
+
if _rtk_stats_cache["has_value"] and now < float(_rtk_stats_cache["expires_at"]):
|
| 136 |
+
return cast(dict[str, Any] | None, _rtk_stats_cache["value"])
|
| 137 |
+
|
| 138 |
+
payload: dict[str, Any] | None
|
| 139 |
rtk_bin = shutil.which("rtk")
|
| 140 |
if not rtk_bin:
|
| 141 |
# Check headroom-managed install. Preserve the historical Unix-name
|
|
|
|
| 145 |
if rtk_managed.exists():
|
| 146 |
rtk_bin = str(rtk_managed)
|
| 147 |
else:
|
| 148 |
+
payload = None
|
| 149 |
+
with _rtk_stats_cache_lock:
|
| 150 |
+
_rtk_stats_cache.update(
|
| 151 |
+
{
|
| 152 |
+
"expires_at": time.monotonic() + RTK_STATS_CACHE_TTL_SECONDS,
|
| 153 |
+
"has_value": True,
|
| 154 |
+
"value": payload,
|
| 155 |
+
}
|
| 156 |
+
)
|
| 157 |
+
return payload
|
| 158 |
|
| 159 |
try:
|
| 160 |
result = _sp.run(
|
|
|
|
| 166 |
if result.returncode == 0 and result.stdout.strip():
|
| 167 |
data = json.loads(result.stdout)
|
| 168 |
summary = data.get("summary", {})
|
| 169 |
+
payload = {
|
| 170 |
"installed": True,
|
| 171 |
"total_commands": summary.get("total_commands", 0),
|
| 172 |
"tokens_saved": summary.get("total_saved", 0),
|
| 173 |
"avg_savings_pct": summary.get("avg_savings_pct", 0.0),
|
| 174 |
}
|
| 175 |
+
else:
|
| 176 |
+
payload = {
|
| 177 |
+
"installed": True,
|
| 178 |
+
"total_commands": 0,
|
| 179 |
+
"tokens_saved": 0,
|
| 180 |
+
"avg_savings_pct": 0.0,
|
| 181 |
+
}
|
| 182 |
except Exception:
|
| 183 |
+
payload = {
|
| 184 |
+
"installed": True,
|
| 185 |
+
"total_commands": 0,
|
| 186 |
+
"tokens_saved": 0,
|
| 187 |
+
"avg_savings_pct": 0.0,
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
with _rtk_stats_cache_lock:
|
| 191 |
+
_rtk_stats_cache.update(
|
| 192 |
+
{
|
| 193 |
+
"expires_at": time.monotonic() + RTK_STATS_CACHE_TTL_SECONDS,
|
| 194 |
+
"has_value": True,
|
| 195 |
+
"value": payload,
|
| 196 |
+
}
|
| 197 |
+
)
|
| 198 |
+
return payload
|
| 199 |
|
| 200 |
|
| 201 |
def is_anthropic_auth(headers: dict[str, str]) -> bool:
|
headroom/proxy/server.py
CHANGED
|
@@ -32,7 +32,7 @@ import sys
|
|
| 32 |
import time
|
| 33 |
from datetime import datetime, timezone
|
| 34 |
from pathlib import Path
|
| 35 |
-
from typing import TYPE_CHECKING, Any, Literal
|
| 36 |
|
| 37 |
if TYPE_CHECKING:
|
| 38 |
from ..backends.base import Backend
|
|
@@ -1403,9 +1403,12 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
|
|
| 1403 |
"""Serve the Headroom dashboard UI."""
|
| 1404 |
return get_dashboard_html()
|
| 1405 |
|
| 1406 |
-
|
| 1407 |
-
|
| 1408 |
-
|
|
|
|
|
|
|
|
|
|
| 1409 |
|
| 1410 |
This is the main stats endpoint - it aggregates data from all subsystems:
|
| 1411 |
- Request metrics (total, cached, failed, by model/provider)
|
|
@@ -1634,6 +1637,44 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
|
|
| 1634 |
**get_quota_registry().get_all_stats(),
|
| 1635 |
}
|
| 1636 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1637 |
@app.get("/stats-history")
|
| 1638 |
async def stats_history(
|
| 1639 |
format: Literal["json", "csv"] = "json",
|
|
|
|
| 32 |
import time
|
| 33 |
from datetime import datetime, timezone
|
| 34 |
from pathlib import Path
|
| 35 |
+
from typing import TYPE_CHECKING, Any, Literal, cast
|
| 36 |
|
| 37 |
if TYPE_CHECKING:
|
| 38 |
from ..backends.base import Backend
|
|
|
|
| 1403 |
"""Serve the Headroom dashboard UI."""
|
| 1404 |
return get_dashboard_html()
|
| 1405 |
|
| 1406 |
+
DASHBOARD_STATS_CACHE_TTL_SECONDS = 5.0
|
| 1407 |
+
_stats_snapshot_lock = asyncio.Lock()
|
| 1408 |
+
_stats_snapshot: dict[str, Any] = {"expires_at": 0.0, "value": None}
|
| 1409 |
+
|
| 1410 |
+
async def _build_stats_payload() -> dict[str, Any]:
|
| 1411 |
+
"""Build the full `/stats` response payload.
|
| 1412 |
|
| 1413 |
This is the main stats endpoint - it aggregates data from all subsystems:
|
| 1414 |
- Request metrics (total, cached, failed, by model/provider)
|
|
|
|
| 1637 |
**get_quota_registry().get_all_stats(),
|
| 1638 |
}
|
| 1639 |
|
| 1640 |
+
async def _get_cached_stats_payload() -> dict[str, Any]:
|
| 1641 |
+
"""Return a short-TTL cached `/stats` snapshot for dashboard polling."""
|
| 1642 |
+
now = time.monotonic()
|
| 1643 |
+
cached_payload = cast(dict[str, Any] | None, _stats_snapshot.get("value"))
|
| 1644 |
+
if cached_payload is not None and now < float(_stats_snapshot["expires_at"]):
|
| 1645 |
+
return cached_payload
|
| 1646 |
+
|
| 1647 |
+
async with _stats_snapshot_lock:
|
| 1648 |
+
now = time.monotonic()
|
| 1649 |
+
cached_payload = cast(dict[str, Any] | None, _stats_snapshot.get("value"))
|
| 1650 |
+
if cached_payload is not None and now < float(_stats_snapshot["expires_at"]):
|
| 1651 |
+
return cached_payload
|
| 1652 |
+
|
| 1653 |
+
payload = await _build_stats_payload()
|
| 1654 |
+
_stats_snapshot["value"] = payload
|
| 1655 |
+
_stats_snapshot["expires_at"] = time.monotonic() + DASHBOARD_STATS_CACHE_TTL_SECONDS
|
| 1656 |
+
return payload
|
| 1657 |
+
|
| 1658 |
+
@app.get("/stats")
|
| 1659 |
+
async def stats(cached: bool = False):
|
| 1660 |
+
"""Get comprehensive proxy statistics.
|
| 1661 |
+
|
| 1662 |
+
This is the main stats endpoint - it aggregates data from all subsystems:
|
| 1663 |
+
- Request metrics (total, cached, failed, by model/provider)
|
| 1664 |
+
- Token usage and savings
|
| 1665 |
+
- Cost tracking
|
| 1666 |
+
- Canonical persisted display_session metrics for downstream dashboards
|
| 1667 |
+
- Compression (CCR) statistics
|
| 1668 |
+
- Telemetry/TOIN (data flywheel) statistics
|
| 1669 |
+
- Cache and rate limiter stats
|
| 1670 |
+
|
| 1671 |
+
Use ``?cached=1`` for the dashboard fast path. That returns a short-TTL
|
| 1672 |
+
snapshot to avoid rebuilding the full payload on every UI poll.
|
| 1673 |
+
"""
|
| 1674 |
+
if cached:
|
| 1675 |
+
return await _get_cached_stats_payload()
|
| 1676 |
+
return await _build_stats_payload()
|
| 1677 |
+
|
| 1678 |
@app.get("/stats-history")
|
| 1679 |
async def stats_history(
|
| 1680 |
format: Literal["json", "csv"] = "json",
|
plugins/headroom-agent-hooks/.claude-plugin/plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"name": "headroom",
|
| 3 |
-
"version": "0.
|
| 4 |
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
|
| 5 |
"author": {
|
| 6 |
"name": "Headroom Contributors",
|
|
|
|
| 1 |
{
|
| 2 |
"name": "headroom",
|
| 3 |
+
"version": "0.11.0",
|
| 4 |
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
|
| 5 |
"author": {
|
| 6 |
"name": "Headroom Contributors",
|
plugins/headroom-agent-hooks/.github/plugin/plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"name": "headroom",
|
| 3 |
-
"version": "0.
|
| 4 |
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
|
| 5 |
"author": {
|
| 6 |
"name": "Headroom Contributors",
|
|
|
|
| 1 |
{
|
| 2 |
"name": "headroom",
|
| 3 |
+
"version": "0.11.0",
|
| 4 |
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
|
| 5 |
"author": {
|
| 6 |
"name": "Headroom Contributors",
|
tests/test_cli/test_wrap_copilot.py
CHANGED
|
@@ -1,212 +1,335 @@
|
|
| 1 |
-
"""Tests for `headroom wrap copilot` command."""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
import
|
| 6 |
-
import
|
| 7 |
-
|
| 8 |
-
from
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
monkeypatch.
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
assert
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
assert
|
| 103 |
-
assert
|
| 104 |
-
assert
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
assert
|
| 211 |
-
assert "
|
| 212 |
-
assert "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for `headroom wrap copilot` command."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import importlib
|
| 6 |
+
import sys
|
| 7 |
+
import types
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from unittest.mock import patch
|
| 10 |
+
|
| 11 |
+
import click
|
| 12 |
+
import pytest
|
| 13 |
+
from click.testing import CliRunner
|
| 14 |
+
|
| 15 |
+
from headroom.copilot_auth import DEFAULT_API_URL
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@pytest.fixture
|
| 19 |
+
def runner() -> CliRunner:
|
| 20 |
+
return CliRunner()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@pytest.fixture
|
| 24 |
+
def wrap_modules(monkeypatch: pytest.MonkeyPatch) -> tuple[types.ModuleType, click.Group]:
|
| 25 |
+
headroom_pkg = sys.modules.get("headroom")
|
| 26 |
+
saved_headroom_cli_attr = (
|
| 27 |
+
headroom_pkg.cli if headroom_pkg is not None and hasattr(headroom_pkg, "cli") else None
|
| 28 |
+
)
|
| 29 |
+
saved_modules = {
|
| 30 |
+
name: sys.modules.get(name)
|
| 31 |
+
for name in ("headroom.cli", "headroom.cli.main", "headroom.cli.wrap")
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
fake_main_module = types.ModuleType("headroom.cli.main")
|
| 35 |
+
fake_main_module.main = click.Group()
|
| 36 |
+
sys.modules["headroom.cli.main"] = fake_main_module
|
| 37 |
+
sys.modules.pop("headroom.cli", None)
|
| 38 |
+
sys.modules.pop("headroom.cli.wrap", None)
|
| 39 |
+
|
| 40 |
+
wrap_cli = importlib.import_module("headroom.cli.wrap")
|
| 41 |
+
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda _port: False)
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
yield wrap_cli, fake_main_module.main
|
| 45 |
+
finally:
|
| 46 |
+
for name in ("headroom.cli.wrap", "headroom.cli.main", "headroom.cli"):
|
| 47 |
+
sys.modules.pop(name, None)
|
| 48 |
+
for name, module in saved_modules.items():
|
| 49 |
+
if module is not None:
|
| 50 |
+
sys.modules[name] = module
|
| 51 |
+
if saved_modules["headroom.cli"] is not None:
|
| 52 |
+
cli_pkg = saved_modules["headroom.cli"]
|
| 53 |
+
if saved_modules["headroom.cli.main"] is not None:
|
| 54 |
+
cli_pkg.main = saved_modules["headroom.cli.main"]
|
| 55 |
+
if saved_modules["headroom.cli.wrap"] is not None:
|
| 56 |
+
cli_pkg.wrap = saved_modules["headroom.cli.wrap"]
|
| 57 |
+
if headroom_pkg is not None:
|
| 58 |
+
if saved_headroom_cli_attr is None:
|
| 59 |
+
if hasattr(headroom_pkg, "cli"):
|
| 60 |
+
delattr(headroom_pkg, "cli")
|
| 61 |
+
else:
|
| 62 |
+
headroom_pkg.cli = saved_headroom_cli_attr
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_wrap_copilot_auto_anthropic_injects_instructions(
|
| 66 |
+
runner: CliRunner,
|
| 67 |
+
wrap_modules: tuple[types.ModuleType, click.Group],
|
| 68 |
+
tmp_path: Path,
|
| 69 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 70 |
+
) -> None:
|
| 71 |
+
wrap_cli, main = wrap_modules
|
| 72 |
+
monkeypatch.chdir(tmp_path)
|
| 73 |
+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-dummy")
|
| 74 |
+
captured: dict[str, object] = {}
|
| 75 |
+
|
| 76 |
+
def fake_launch_tool(**kwargs): # noqa: ANN003
|
| 77 |
+
captured.update(kwargs)
|
| 78 |
+
|
| 79 |
+
with (
|
| 80 |
+
patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
|
| 81 |
+
patch("headroom.cli.wrap.has_oauth_auth", return_value=False),
|
| 82 |
+
patch("headroom.cli.wrap._ensure_rtk_binary", return_value=Path("/tmp/rtk")),
|
| 83 |
+
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
|
| 84 |
+
):
|
| 85 |
+
result = runner.invoke(
|
| 86 |
+
main,
|
| 87 |
+
["wrap", "copilot", "--", "--model", "claude-sonnet-4-20250514"],
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
assert result.exit_code == 0, result.output
|
| 91 |
+
instructions = tmp_path / ".github" / "copilot-instructions.md"
|
| 92 |
+
assert instructions.exists()
|
| 93 |
+
content = instructions.read_text()
|
| 94 |
+
assert wrap_cli._RTK_MARKER in content
|
| 95 |
+
assert "RTK (Rust Token Killer)" in content
|
| 96 |
+
|
| 97 |
+
env = captured["env"]
|
| 98 |
+
assert isinstance(env, dict)
|
| 99 |
+
assert env["COPILOT_PROVIDER_TYPE"] == "anthropic"
|
| 100 |
+
assert env["COPILOT_PROVIDER_BASE_URL"] == "http://127.0.0.1:8787"
|
| 101 |
+
assert "COPILOT_PROVIDER_WIRE_API" not in env
|
| 102 |
+
assert captured["agent_type"] == "copilot"
|
| 103 |
+
assert captured["tool_label"] == "COPILOT"
|
| 104 |
+
assert captured["args"] == ("--model", "claude-sonnet-4-20250514")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_wrap_copilot_openai_backend_sets_completions_env(
|
| 108 |
+
runner: CliRunner,
|
| 109 |
+
wrap_modules: tuple[types.ModuleType, click.Group],
|
| 110 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 111 |
+
) -> None:
|
| 112 |
+
_wrap_cli, main = wrap_modules
|
| 113 |
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-dummy")
|
| 114 |
+
captured: dict[str, object] = {}
|
| 115 |
+
|
| 116 |
+
def fake_launch_tool(**kwargs): # noqa: ANN003
|
| 117 |
+
captured.update(kwargs)
|
| 118 |
+
|
| 119 |
+
with (
|
| 120 |
+
patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
|
| 121 |
+
patch("headroom.cli.wrap.has_oauth_auth", return_value=False),
|
| 122 |
+
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
|
| 123 |
+
):
|
| 124 |
+
result = runner.invoke(
|
| 125 |
+
main,
|
| 126 |
+
[
|
| 127 |
+
"wrap",
|
| 128 |
+
"copilot",
|
| 129 |
+
"--no-rtk",
|
| 130 |
+
"--backend",
|
| 131 |
+
"anyllm",
|
| 132 |
+
"--anyllm-provider",
|
| 133 |
+
"groq",
|
| 134 |
+
"--region",
|
| 135 |
+
"us-central1",
|
| 136 |
+
"--",
|
| 137 |
+
"--model",
|
| 138 |
+
"gpt-4o",
|
| 139 |
+
],
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
assert result.exit_code == 0, result.output
|
| 143 |
+
|
| 144 |
+
env = captured["env"]
|
| 145 |
+
assert isinstance(env, dict)
|
| 146 |
+
assert env["COPILOT_PROVIDER_TYPE"] == "openai"
|
| 147 |
+
assert env["COPILOT_PROVIDER_BASE_URL"] == "http://127.0.0.1:8787/v1"
|
| 148 |
+
assert env["COPILOT_PROVIDER_WIRE_API"] == "completions"
|
| 149 |
+
assert captured["backend"] == "anyllm"
|
| 150 |
+
assert captured["anyllm_provider"] == "groq"
|
| 151 |
+
assert captured["region"] == "us-central1"
|
| 152 |
+
assert captured["args"] == ("--model", "gpt-4o")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def test_wrap_copilot_auto_detects_running_proxy_backend(
|
| 156 |
+
runner: CliRunner,
|
| 157 |
+
wrap_modules: tuple[types.ModuleType, click.Group],
|
| 158 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 159 |
+
) -> None:
|
| 160 |
+
_wrap_cli, main = wrap_modules
|
| 161 |
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-dummy")
|
| 162 |
+
captured: dict[str, object] = {}
|
| 163 |
+
|
| 164 |
+
def fake_launch_tool(**kwargs): # noqa: ANN003
|
| 165 |
+
captured.update(kwargs)
|
| 166 |
+
|
| 167 |
+
with (
|
| 168 |
+
patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
|
| 169 |
+
patch("headroom.cli.wrap.has_oauth_auth", return_value=False),
|
| 170 |
+
patch("headroom.cli.wrap._check_proxy", return_value=True),
|
| 171 |
+
patch("headroom.cli.wrap._detect_running_proxy_backend", return_value="anyllm"),
|
| 172 |
+
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
|
| 173 |
+
):
|
| 174 |
+
result = runner.invoke(
|
| 175 |
+
main,
|
| 176 |
+
["wrap", "copilot", "--no-rtk", "--", "--model", "gpt-4o"],
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
assert result.exit_code == 0, result.output
|
| 180 |
+
env = captured["env"]
|
| 181 |
+
assert isinstance(env, dict)
|
| 182 |
+
assert env["COPILOT_PROVIDER_TYPE"] == "openai"
|
| 183 |
+
assert env["COPILOT_PROVIDER_BASE_URL"] == "http://127.0.0.1:8787/v1"
|
| 184 |
+
assert env["COPILOT_PROVIDER_WIRE_API"] == "completions"
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def test_wrap_copilot_prefers_existing_oauth_session(
|
| 188 |
+
runner: CliRunner,
|
| 189 |
+
wrap_modules: tuple[types.ModuleType, click.Group],
|
| 190 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 191 |
+
) -> None:
|
| 192 |
+
_wrap_cli, main = wrap_modules
|
| 193 |
+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-dummy")
|
| 194 |
+
captured: dict[str, object] = {}
|
| 195 |
+
|
| 196 |
+
def fake_launch_tool(**kwargs): # noqa: ANN003
|
| 197 |
+
captured.update(kwargs)
|
| 198 |
+
|
| 199 |
+
with patch("headroom.cli.wrap.shutil.which", return_value="copilot"):
|
| 200 |
+
with patch("headroom.cli.wrap.resolve_client_bearer_token", return_value="gho-existing"):
|
| 201 |
+
with patch("headroom.cli.wrap.has_oauth_auth", return_value=True):
|
| 202 |
+
with patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool):
|
| 203 |
+
result = runner.invoke(
|
| 204 |
+
main,
|
| 205 |
+
["wrap", "copilot", "--no-rtk", "--", "--model", "claude-sonnet-4.6"],
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
assert result.exit_code == 0, result.output
|
| 209 |
+
env = captured["env"]
|
| 210 |
+
assert isinstance(env, dict)
|
| 211 |
+
assert env["COPILOT_PROVIDER_TYPE"] == "openai"
|
| 212 |
+
assert env["COPILOT_PROVIDER_BASE_URL"] == "http://127.0.0.1:8787/v1"
|
| 213 |
+
assert env["COPILOT_PROVIDER_WIRE_API"] == "completions"
|
| 214 |
+
assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "gho-existing"
|
| 215 |
+
assert "COPILOT_PROVIDER_API_KEY" not in env
|
| 216 |
+
assert captured["openai_api_url"] == DEFAULT_API_URL
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def test_wrap_copilot_translated_backend_still_requires_byok(
|
| 220 |
+
runner: CliRunner,
|
| 221 |
+
wrap_modules: tuple[types.ModuleType, click.Group],
|
| 222 |
+
) -> None:
|
| 223 |
+
_wrap_cli, main = wrap_modules
|
| 224 |
+
with patch("headroom.cli.wrap.shutil.which", return_value="copilot"):
|
| 225 |
+
with patch("headroom.cli.wrap.has_oauth_auth", return_value=True):
|
| 226 |
+
result = runner.invoke(
|
| 227 |
+
main,
|
| 228 |
+
[
|
| 229 |
+
"wrap",
|
| 230 |
+
"copilot",
|
| 231 |
+
"--no-rtk",
|
| 232 |
+
"--backend",
|
| 233 |
+
"anyllm",
|
| 234 |
+
"--",
|
| 235 |
+
"--model",
|
| 236 |
+
"gpt-4o",
|
| 237 |
+
],
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
assert result.exit_code == 1
|
| 241 |
+
assert "Copilot BYOK mode requires a provider API key" in result.output
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def test_wrap_copilot_rejects_wire_api_for_anthropic_provider(
|
| 245 |
+
runner: CliRunner,
|
| 246 |
+
wrap_modules: tuple[types.ModuleType, click.Group],
|
| 247 |
+
) -> None:
|
| 248 |
+
_wrap_cli, main = wrap_modules
|
| 249 |
+
with patch("headroom.cli.wrap.shutil.which", return_value="copilot"):
|
| 250 |
+
result = runner.invoke(
|
| 251 |
+
main,
|
| 252 |
+
[
|
| 253 |
+
"wrap",
|
| 254 |
+
"copilot",
|
| 255 |
+
"--wire-api",
|
| 256 |
+
"responses",
|
| 257 |
+
"--",
|
| 258 |
+
"--model",
|
| 259 |
+
"claude-sonnet-4-20250514",
|
| 260 |
+
],
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
assert result.exit_code != 0
|
| 264 |
+
assert "--wire-api is only valid" in result.output
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def test_wrap_copilot_rejects_responses_for_translated_backends(
|
| 268 |
+
runner: CliRunner,
|
| 269 |
+
wrap_modules: tuple[types.ModuleType, click.Group],
|
| 270 |
+
) -> None:
|
| 271 |
+
_wrap_cli, main = wrap_modules
|
| 272 |
+
with patch("headroom.cli.wrap.shutil.which", return_value="copilot"):
|
| 273 |
+
result = runner.invoke(
|
| 274 |
+
main,
|
| 275 |
+
[
|
| 276 |
+
"wrap",
|
| 277 |
+
"copilot",
|
| 278 |
+
"--backend",
|
| 279 |
+
"anyllm",
|
| 280 |
+
"--wire-api",
|
| 281 |
+
"responses",
|
| 282 |
+
"--",
|
| 283 |
+
"--model",
|
| 284 |
+
"gpt-4o",
|
| 285 |
+
],
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
assert result.exit_code != 0
|
| 289 |
+
assert "not supported with translated backends" in result.output
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def test_wrap_copilot_clears_stale_wire_api_in_anthropic_mode(
|
| 293 |
+
runner: CliRunner,
|
| 294 |
+
wrap_modules: tuple[types.ModuleType, click.Group],
|
| 295 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 296 |
+
) -> None:
|
| 297 |
+
_wrap_cli, main = wrap_modules
|
| 298 |
+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-dummy")
|
| 299 |
+
captured: dict[str, object] = {}
|
| 300 |
+
|
| 301 |
+
def fake_launch_tool(**kwargs): # noqa: ANN003
|
| 302 |
+
captured.update(kwargs)
|
| 303 |
+
|
| 304 |
+
with (
|
| 305 |
+
patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
|
| 306 |
+
patch("headroom.cli.wrap.has_oauth_auth", return_value=False),
|
| 307 |
+
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
|
| 308 |
+
):
|
| 309 |
+
result = runner.invoke(
|
| 310 |
+
main,
|
| 311 |
+
["wrap", "copilot", "--no-rtk", "--", "--model", "claude-sonnet-4-20250514"],
|
| 312 |
+
env={
|
| 313 |
+
"COPILOT_PROVIDER_WIRE_API": "responses",
|
| 314 |
+
"ANTHROPIC_API_KEY": "sk-test-dummy",
|
| 315 |
+
},
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
assert result.exit_code == 0, result.output
|
| 319 |
+
env = captured["env"]
|
| 320 |
+
assert isinstance(env, dict)
|
| 321 |
+
assert env["COPILOT_PROVIDER_TYPE"] == "anthropic"
|
| 322 |
+
assert "COPILOT_PROVIDER_WIRE_API" not in env
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def test_wrap_copilot_fails_when_binary_missing(
|
| 326 |
+
runner: CliRunner,
|
| 327 |
+
wrap_modules: tuple[types.ModuleType, click.Group],
|
| 328 |
+
) -> None:
|
| 329 |
+
_wrap_cli, main = wrap_modules
|
| 330 |
+
with patch("headroom.cli.wrap.shutil.which", return_value=None):
|
| 331 |
+
result = runner.invoke(main, ["wrap", "copilot", "--", "--model", "gpt-4o"])
|
| 332 |
+
|
| 333 |
+
assert result.exit_code == 1
|
| 334 |
+
assert "'copilot' not found in PATH" in result.output
|
| 335 |
+
assert "Install GitHub Copilot CLI" in result.output
|
tests/test_cli/test_wrap_persistent.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import click
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
|
| 8 |
class _Manifest:
|
|
@@ -15,8 +15,8 @@ class _Manifest:
|
|
| 15 |
def test_ensure_proxy_recovers_matching_persistent_deployment(monkeypatch) -> None:
|
| 16 |
calls: list[str] = []
|
| 17 |
|
| 18 |
-
monkeypatch.setattr("
|
| 19 |
-
monkeypatch.setattr("
|
| 20 |
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
| 21 |
monkeypatch.setattr(
|
| 22 |
"headroom.install.supervisors.start_supervisor",
|
|
@@ -26,13 +26,14 @@ def test_ensure_proxy_recovers_matching_persistent_deployment(monkeypatch) -> No
|
|
| 26 |
"headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True
|
| 27 |
)
|
| 28 |
monkeypatch.setattr(
|
| 29 |
-
|
|
|
|
| 30 |
lambda *args, **kwargs: (_ for _ in ()).throw(
|
| 31 |
AssertionError("ephemeral proxy should not start")
|
| 32 |
),
|
| 33 |
)
|
| 34 |
|
| 35 |
-
result = _ensure_proxy(8787, False)
|
| 36 |
|
| 37 |
assert result is None
|
| 38 |
assert calls == ["start:default"]
|
|
@@ -41,8 +42,8 @@ def test_ensure_proxy_recovers_matching_persistent_deployment(monkeypatch) -> No
|
|
| 41 |
def test_ensure_proxy_recovers_persistent_deployment_when_socket_is_bound(monkeypatch) -> None:
|
| 42 |
calls: list[str] = []
|
| 43 |
|
| 44 |
-
monkeypatch.setattr("
|
| 45 |
-
monkeypatch.setattr("
|
| 46 |
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
| 47 |
monkeypatch.setattr(
|
| 48 |
"headroom.install.supervisors.start_supervisor",
|
|
@@ -52,26 +53,41 @@ def test_ensure_proxy_recovers_persistent_deployment_when_socket_is_bound(monkey
|
|
| 52 |
"headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True
|
| 53 |
)
|
| 54 |
|
| 55 |
-
result = _ensure_proxy(8787, False)
|
| 56 |
|
| 57 |
assert result is None
|
| 58 |
assert calls == ["start:default"]
|
| 59 |
|
| 60 |
|
| 61 |
def test_ensure_proxy_rejects_unhealthy_persistent_deployment(monkeypatch) -> None:
|
| 62 |
-
monkeypatch.setattr("
|
| 63 |
-
monkeypatch.setattr("
|
| 64 |
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
| 65 |
-
monkeypatch.setattr("
|
| 66 |
|
| 67 |
try:
|
| 68 |
-
_ensure_proxy(8787, False)
|
| 69 |
except click.ClickException as exc:
|
| 70 |
assert "is not healthy" in str(exc)
|
| 71 |
else:
|
| 72 |
raise AssertionError("expected unhealthy persistent deployment to raise")
|
| 73 |
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
def test_find_persistent_manifest_prefers_default_profile(monkeypatch) -> None:
|
| 76 |
class DefaultManifest:
|
| 77 |
profile = "default"
|
|
@@ -86,23 +102,23 @@ def test_find_persistent_manifest_prefers_default_profile(monkeypatch) -> None:
|
|
| 86 |
lambda: [OtherManifest(), DefaultManifest()],
|
| 87 |
)
|
| 88 |
|
| 89 |
-
manifest = _find_persistent_manifest(8787)
|
| 90 |
|
| 91 |
assert manifest.profile == "default"
|
| 92 |
|
| 93 |
|
| 94 |
def test_recover_persistent_proxy_reuses_healthy_deployment(monkeypatch) -> None:
|
| 95 |
-
monkeypatch.setattr("
|
| 96 |
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
| 97 |
|
| 98 |
-
assert _recover_persistent_proxy(8787) is True
|
| 99 |
|
| 100 |
|
| 101 |
def test_recover_persistent_proxy_warns_for_task_deployment(monkeypatch) -> None:
|
| 102 |
class TaskManifest(_Manifest):
|
| 103 |
supervisor_kind = "task"
|
| 104 |
|
| 105 |
-
monkeypatch.setattr("
|
| 106 |
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
| 107 |
|
| 108 |
-
assert _recover_persistent_proxy(8787) is False
|
|
|
|
| 2 |
|
| 3 |
import click
|
| 4 |
|
| 5 |
+
import headroom.cli.wrap as wrap_cli
|
| 6 |
|
| 7 |
|
| 8 |
class _Manifest:
|
|
|
|
| 15 |
def test_ensure_proxy_recovers_matching_persistent_deployment(monkeypatch) -> None:
|
| 16 |
calls: list[str] = []
|
| 17 |
|
| 18 |
+
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: False)
|
| 19 |
+
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
| 20 |
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
| 21 |
monkeypatch.setattr(
|
| 22 |
"headroom.install.supervisors.start_supervisor",
|
|
|
|
| 26 |
"headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True
|
| 27 |
)
|
| 28 |
monkeypatch.setattr(
|
| 29 |
+
wrap_cli,
|
| 30 |
+
"_start_proxy",
|
| 31 |
lambda *args, **kwargs: (_ for _ in ()).throw(
|
| 32 |
AssertionError("ephemeral proxy should not start")
|
| 33 |
),
|
| 34 |
)
|
| 35 |
|
| 36 |
+
result = wrap_cli._ensure_proxy(8787, False)
|
| 37 |
|
| 38 |
assert result is None
|
| 39 |
assert calls == ["start:default"]
|
|
|
|
| 42 |
def test_ensure_proxy_recovers_persistent_deployment_when_socket_is_bound(monkeypatch) -> None:
|
| 43 |
calls: list[str] = []
|
| 44 |
|
| 45 |
+
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
| 46 |
+
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
| 47 |
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
| 48 |
monkeypatch.setattr(
|
| 49 |
"headroom.install.supervisors.start_supervisor",
|
|
|
|
| 53 |
"headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True
|
| 54 |
)
|
| 55 |
|
| 56 |
+
result = wrap_cli._ensure_proxy(8787, False)
|
| 57 |
|
| 58 |
assert result is None
|
| 59 |
assert calls == ["start:default"]
|
| 60 |
|
| 61 |
|
| 62 |
def test_ensure_proxy_rejects_unhealthy_persistent_deployment(monkeypatch) -> None:
|
| 63 |
+
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
| 64 |
+
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
| 65 |
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
| 66 |
+
monkeypatch.setattr(wrap_cli, "_recover_persistent_proxy", lambda port: False)
|
| 67 |
|
| 68 |
try:
|
| 69 |
+
wrap_cli._ensure_proxy(8787, False)
|
| 70 |
except click.ClickException as exc:
|
| 71 |
assert "is not healthy" in str(exc)
|
| 72 |
else:
|
| 73 |
raise AssertionError("expected unhealthy persistent deployment to raise")
|
| 74 |
|
| 75 |
|
| 76 |
+
def test_ensure_proxy_falls_back_when_persistent_manifest_is_stale(monkeypatch) -> None:
|
| 77 |
+
calls: list[str] = []
|
| 78 |
+
|
| 79 |
+
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: False)
|
| 80 |
+
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
| 81 |
+
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
| 82 |
+
monkeypatch.setattr(wrap_cli, "_recover_persistent_proxy", lambda port: False)
|
| 83 |
+
monkeypatch.setattr(wrap_cli, "_start_proxy", lambda *args, **kwargs: calls.append("start"))
|
| 84 |
+
|
| 85 |
+
result = wrap_cli._ensure_proxy(8787, False)
|
| 86 |
+
|
| 87 |
+
assert result is None
|
| 88 |
+
assert calls == ["start"]
|
| 89 |
+
|
| 90 |
+
|
| 91 |
def test_find_persistent_manifest_prefers_default_profile(monkeypatch) -> None:
|
| 92 |
class DefaultManifest:
|
| 93 |
profile = "default"
|
|
|
|
| 102 |
lambda: [OtherManifest(), DefaultManifest()],
|
| 103 |
)
|
| 104 |
|
| 105 |
+
manifest = wrap_cli._find_persistent_manifest(8787)
|
| 106 |
|
| 107 |
assert manifest.profile == "default"
|
| 108 |
|
| 109 |
|
| 110 |
def test_recover_persistent_proxy_reuses_healthy_deployment(monkeypatch) -> None:
|
| 111 |
+
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
| 112 |
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
| 113 |
|
| 114 |
+
assert wrap_cli._recover_persistent_proxy(8787) is True
|
| 115 |
|
| 116 |
|
| 117 |
def test_recover_persistent_proxy_warns_for_task_deployment(monkeypatch) -> None:
|
| 118 |
class TaskManifest(_Manifest):
|
| 119 |
supervisor_kind = "task"
|
| 120 |
|
| 121 |
+
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: TaskManifest())
|
| 122 |
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
| 123 |
|
| 124 |
+
assert wrap_cli._recover_persistent_proxy(8787) is False
|
tests/test_copilot_auth.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import json
|
| 4 |
import time
|
| 5 |
from pathlib import Path
|
|
|
|
|
|
|
| 6 |
|
| 7 |
import pytest
|
| 8 |
|
|
@@ -14,6 +17,68 @@ def test_read_cached_oauth_token_prefers_env(monkeypatch: pytest.MonkeyPatch) ->
|
|
| 14 |
assert copilot_auth.read_cached_oauth_token() == "gho-env"
|
| 15 |
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
def test_read_cached_oauth_token_reads_hosts_file(
|
| 18 |
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
| 19 |
) -> None:
|
|
@@ -31,6 +96,8 @@ def test_read_cached_oauth_token_reads_hosts_file(
|
|
| 31 |
)
|
| 32 |
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
| 33 |
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_FILE", str(hosts))
|
|
|
|
|
|
|
| 34 |
|
| 35 |
assert copilot_auth.read_cached_oauth_token() == "gho-file"
|
| 36 |
|
|
@@ -44,10 +111,68 @@ def test_read_cached_oauth_token_skips_expired_entries(
|
|
| 44 |
encoding="utf-8",
|
| 45 |
)
|
| 46 |
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_FILE", str(hosts))
|
|
|
|
|
|
|
| 47 |
|
| 48 |
assert copilot_auth.read_cached_oauth_token() is None
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
def test_resolve_client_bearer_token_prefers_api_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 52 |
monkeypatch.setenv("GITHUB_COPILOT_API_TOKEN", "copilot-api")
|
| 53 |
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-oauth")
|
|
@@ -55,16 +180,36 @@ def test_resolve_client_bearer_token_prefers_api_token(monkeypatch: pytest.Monke
|
|
| 55 |
assert copilot_auth.resolve_client_bearer_token() == "copilot-api"
|
| 56 |
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
def test_is_copilot_api_url_matches_expected_hosts() -> None:
|
| 59 |
assert copilot_auth.is_copilot_api_url("https://api.githubcopilot.com/v1/chat/completions")
|
| 60 |
assert copilot_auth.is_copilot_api_url("wss://api.githubcopilot.com/v1/responses")
|
| 61 |
assert not copilot_auth.is_copilot_api_url("https://api.openai.com/v1/chat/completions")
|
| 62 |
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
|
| 69 |
return copilot_auth.CopilotAPIToken(
|
| 70 |
token="copilot-session",
|
|
@@ -78,18 +223,48 @@ async def test_apply_copilot_api_auth_replaces_authorization(
|
|
| 78 |
fake_get_api_token,
|
| 79 |
)
|
| 80 |
|
| 81 |
-
headers =
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
| 84 |
)
|
| 85 |
|
| 86 |
assert headers["Authorization"] == "Bearer copilot-session"
|
| 87 |
assert "authorization" not in headers
|
| 88 |
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-oauth")
|
|
|
|
| 93 |
|
| 94 |
provider = copilot_auth.CopilotTokenProvider()
|
| 95 |
calls = {"count": 0}
|
|
@@ -106,9 +281,84 @@ async def test_token_provider_exchanges_and_caches(monkeypatch: pytest.MonkeyPat
|
|
| 106 |
|
| 107 |
monkeypatch.setattr(provider, "_exchange_token_sync", staticmethod(fake_exchange))
|
| 108 |
|
| 109 |
-
first =
|
| 110 |
-
second =
|
| 111 |
|
| 112 |
assert first.token == "copilot-api"
|
| 113 |
assert second.token == "copilot-api"
|
| 114 |
assert calls["count"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
import json
|
| 5 |
import time
|
| 6 |
from pathlib import Path
|
| 7 |
+
from types import SimpleNamespace
|
| 8 |
+
from urllib import error as urllib_error
|
| 9 |
|
| 10 |
import pytest
|
| 11 |
|
|
|
|
| 17 |
assert copilot_auth.read_cached_oauth_token() == "gho-env"
|
| 18 |
|
| 19 |
|
| 20 |
+
def test_should_exchange_oauth_token_supports_truthy_values(
|
| 21 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 22 |
+
) -> None:
|
| 23 |
+
for raw in ("1", "true", "YES", "On"):
|
| 24 |
+
monkeypatch.setenv("GITHUB_COPILOT_USE_TOKEN_EXCHANGE", raw)
|
| 25 |
+
assert copilot_auth._should_exchange_oauth_token() is True
|
| 26 |
+
|
| 27 |
+
monkeypatch.setenv("GITHUB_COPILOT_USE_TOKEN_EXCHANGE", "off")
|
| 28 |
+
assert copilot_auth._should_exchange_oauth_token() is False
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_resolve_token_file_paths_prefers_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 32 |
+
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_FILE", "~/custom-token.json")
|
| 33 |
+
|
| 34 |
+
paths = copilot_auth._resolve_token_file_paths()
|
| 35 |
+
|
| 36 |
+
assert paths == [Path("~/custom-token.json").expanduser()]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_resolve_token_file_paths_includes_localappdata_and_config(
|
| 40 |
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
| 41 |
+
) -> None:
|
| 42 |
+
monkeypatch.delenv("GITHUB_COPILOT_TOKEN_FILE", raising=False)
|
| 43 |
+
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "local"))
|
| 44 |
+
monkeypatch.setattr(copilot_auth.Path, "home", staticmethod(lambda: tmp_path / "home"))
|
| 45 |
+
|
| 46 |
+
paths = copilot_auth._resolve_token_file_paths()
|
| 47 |
+
|
| 48 |
+
assert paths == [
|
| 49 |
+
tmp_path / "local" / "github-copilot" / "apps.json",
|
| 50 |
+
tmp_path / "local" / "github-copilot" / "hosts.json",
|
| 51 |
+
tmp_path / "home" / ".config" / "github-copilot" / "apps.json",
|
| 52 |
+
tmp_path / "home" / ".config" / "github-copilot" / "hosts.json",
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_read_cached_oauth_token_falls_back_to_gh_cli(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 57 |
+
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
|
| 58 |
+
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
| 59 |
+
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
| 60 |
+
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
| 61 |
+
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
| 62 |
+
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: "gho-gh-cli")
|
| 63 |
+
|
| 64 |
+
assert copilot_auth.read_cached_oauth_token() == "gho-gh-cli"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_read_cached_oauth_token_prefers_copilot_cli_windows_token(
|
| 68 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 69 |
+
) -> None:
|
| 70 |
+
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
|
| 71 |
+
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
| 72 |
+
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
| 73 |
+
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
| 74 |
+
monkeypatch.setattr(
|
| 75 |
+
copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: "gho-copilot"
|
| 76 |
+
)
|
| 77 |
+
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: "gho-gh-cli")
|
| 78 |
+
|
| 79 |
+
assert copilot_auth.read_cached_oauth_token() == "gho-copilot"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
def test_read_cached_oauth_token_reads_hosts_file(
|
| 83 |
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
| 84 |
) -> None:
|
|
|
|
| 96 |
)
|
| 97 |
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
| 98 |
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_FILE", str(hosts))
|
| 99 |
+
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
| 100 |
+
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
|
| 101 |
|
| 102 |
assert copilot_auth.read_cached_oauth_token() == "gho-file"
|
| 103 |
|
|
|
|
| 111 |
encoding="utf-8",
|
| 112 |
)
|
| 113 |
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_FILE", str(hosts))
|
| 114 |
+
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
| 115 |
+
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
|
| 116 |
|
| 117 |
assert copilot_auth.read_cached_oauth_token() is None
|
| 118 |
|
| 119 |
|
| 120 |
+
def test_read_gh_cli_oauth_token_uses_hostname(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 121 |
+
calls: list[list[str]] = []
|
| 122 |
+
|
| 123 |
+
class CompletedProcess:
|
| 124 |
+
def __init__(self) -> None:
|
| 125 |
+
self.returncode = 0
|
| 126 |
+
self.stdout = "gho-gh-cli\n"
|
| 127 |
+
|
| 128 |
+
def fake_run(*args: object, **kwargs: object) -> CompletedProcess:
|
| 129 |
+
calls.append(list(args[0]))
|
| 130 |
+
assert kwargs["capture_output"] is True
|
| 131 |
+
assert kwargs["check"] is False
|
| 132 |
+
return CompletedProcess()
|
| 133 |
+
|
| 134 |
+
monkeypatch.setenv("GITHUB_COPILOT_HOST", "example.ghe.com")
|
| 135 |
+
monkeypatch.setattr(copilot_auth.subprocess, "run", fake_run)
|
| 136 |
+
|
| 137 |
+
assert copilot_auth._read_gh_cli_oauth_token() == "gho-gh-cli"
|
| 138 |
+
assert calls == [["gh", "auth", "token", "--hostname", "example.ghe.com"]]
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_read_gh_cli_oauth_token_returns_none_when_invocation_fails(
|
| 142 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 143 |
+
) -> None:
|
| 144 |
+
def fake_run(*args: object, **kwargs: object) -> None: # noqa: ANN002, ANN003
|
| 145 |
+
raise OSError("gh missing")
|
| 146 |
+
|
| 147 |
+
monkeypatch.setattr(copilot_auth.subprocess, "run", fake_run)
|
| 148 |
+
|
| 149 |
+
assert copilot_auth._read_gh_cli_oauth_token() is None
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def test_read_gh_cli_oauth_token_returns_none_for_nonzero_exit(
|
| 153 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 154 |
+
) -> None:
|
| 155 |
+
monkeypatch.setattr(
|
| 156 |
+
copilot_auth.subprocess,
|
| 157 |
+
"run",
|
| 158 |
+
lambda *args, **kwargs: SimpleNamespace(returncode=1, stdout="ignored"),
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
assert copilot_auth._read_gh_cli_oauth_token() is None
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_read_gh_cli_oauth_token_returns_none_for_blank_stdout(
|
| 165 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 166 |
+
) -> None:
|
| 167 |
+
monkeypatch.setattr(
|
| 168 |
+
copilot_auth.subprocess,
|
| 169 |
+
"run",
|
| 170 |
+
lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout=" \n"),
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
assert copilot_auth._read_gh_cli_oauth_token() is None
|
| 174 |
+
|
| 175 |
+
|
| 176 |
def test_resolve_client_bearer_token_prefers_api_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 177 |
monkeypatch.setenv("GITHUB_COPILOT_API_TOKEN", "copilot-api")
|
| 178 |
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-oauth")
|
|
|
|
| 180 |
assert copilot_auth.resolve_client_bearer_token() == "copilot-api"
|
| 181 |
|
| 182 |
|
| 183 |
+
def test_has_oauth_auth_false_when_no_tokens(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 184 |
+
monkeypatch.setattr(copilot_auth, "resolve_client_bearer_token", lambda: None)
|
| 185 |
+
|
| 186 |
+
assert copilot_auth.has_oauth_auth() is False
|
| 187 |
+
|
| 188 |
+
|
| 189 |
def test_is_copilot_api_url_matches_expected_hosts() -> None:
|
| 190 |
assert copilot_auth.is_copilot_api_url("https://api.githubcopilot.com/v1/chat/completions")
|
| 191 |
assert copilot_auth.is_copilot_api_url("wss://api.githubcopilot.com/v1/responses")
|
| 192 |
assert not copilot_auth.is_copilot_api_url("https://api.openai.com/v1/chat/completions")
|
| 193 |
|
| 194 |
|
| 195 |
+
def test_build_copilot_upstream_url_strips_v1_only_for_copilot_hosts() -> None:
|
| 196 |
+
assert (
|
| 197 |
+
copilot_auth.build_copilot_upstream_url(
|
| 198 |
+
"https://api.githubcopilot.com",
|
| 199 |
+
"/v1/chat/completions",
|
| 200 |
+
)
|
| 201 |
+
== "https://api.githubcopilot.com/chat/completions"
|
| 202 |
+
)
|
| 203 |
+
assert (
|
| 204 |
+
copilot_auth.build_copilot_upstream_url(
|
| 205 |
+
"https://api.openai.com",
|
| 206 |
+
"/v1/chat/completions",
|
| 207 |
+
)
|
| 208 |
+
== "https://api.openai.com/v1/chat/completions"
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def test_apply_copilot_api_auth_replaces_authorization(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 213 |
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
|
| 214 |
return copilot_auth.CopilotAPIToken(
|
| 215 |
token="copilot-session",
|
|
|
|
| 223 |
fake_get_api_token,
|
| 224 |
)
|
| 225 |
|
| 226 |
+
headers = asyncio.run(
|
| 227 |
+
copilot_auth.apply_copilot_api_auth(
|
| 228 |
+
{"authorization": "Bearer downstream-token"},
|
| 229 |
+
url="https://api.githubcopilot.com/v1/chat/completions",
|
| 230 |
+
)
|
| 231 |
)
|
| 232 |
|
| 233 |
assert headers["Authorization"] == "Bearer copilot-session"
|
| 234 |
assert "authorization" not in headers
|
| 235 |
|
| 236 |
|
| 237 |
+
def test_token_provider_reuses_oauth_token_without_exchange(
|
| 238 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 239 |
+
) -> None:
|
| 240 |
+
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-oauth")
|
| 241 |
+
|
| 242 |
+
provider = copilot_auth.CopilotTokenProvider()
|
| 243 |
+
calls = {"count": 0}
|
| 244 |
+
|
| 245 |
+
def fake_exchange(headers: dict[str, str]) -> dict[str, object]:
|
| 246 |
+
calls["count"] += 1
|
| 247 |
+
return {
|
| 248 |
+
"token": "copilot-api",
|
| 249 |
+
"expires_at": int(time.time()) + 3600,
|
| 250 |
+
"refresh_in": 1200,
|
| 251 |
+
"endpoints": {"api": "https://api.githubcopilot.com"},
|
| 252 |
+
"sku": "copilot_individual",
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
monkeypatch.setattr(provider, "_exchange_token_sync", staticmethod(fake_exchange))
|
| 256 |
+
|
| 257 |
+
first = asyncio.run(provider.get_api_token())
|
| 258 |
+
second = asyncio.run(provider.get_api_token())
|
| 259 |
+
|
| 260 |
+
assert first.token == "gho-oauth"
|
| 261 |
+
assert second.token == "gho-oauth"
|
| 262 |
+
assert calls["count"] == 0
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def test_token_provider_can_exchange_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 266 |
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-oauth")
|
| 267 |
+
monkeypatch.setenv("GITHUB_COPILOT_USE_TOKEN_EXCHANGE", "true")
|
| 268 |
|
| 269 |
provider = copilot_auth.CopilotTokenProvider()
|
| 270 |
calls = {"count": 0}
|
|
|
|
| 281 |
|
| 282 |
monkeypatch.setattr(provider, "_exchange_token_sync", staticmethod(fake_exchange))
|
| 283 |
|
| 284 |
+
first = asyncio.run(provider.get_api_token())
|
| 285 |
+
second = asyncio.run(provider.get_api_token())
|
| 286 |
|
| 287 |
assert first.token == "copilot-api"
|
| 288 |
assert second.token == "copilot-api"
|
| 289 |
assert calls["count"] == 1
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def test_token_provider_prefers_explicit_api_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 293 |
+
monkeypatch.setenv("GITHUB_COPILOT_API_TOKEN", "copilot-api")
|
| 294 |
+
monkeypatch.setenv("GITHUB_COPILOT_API_URL", "https://api.githubcopilot.com")
|
| 295 |
+
|
| 296 |
+
token = asyncio.run(copilot_auth.CopilotTokenProvider().get_api_token())
|
| 297 |
+
|
| 298 |
+
assert token.token == "copilot-api"
|
| 299 |
+
assert token.api_url == "https://api.githubcopilot.com"
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def test_token_provider_raises_without_oauth_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 303 |
+
monkeypatch.delenv("GITHUB_COPILOT_API_TOKEN", raising=False)
|
| 304 |
+
monkeypatch.setattr(copilot_auth, "read_cached_oauth_token", lambda: None)
|
| 305 |
+
|
| 306 |
+
with pytest.raises(RuntimeError, match="No GitHub Copilot OAuth token"):
|
| 307 |
+
asyncio.run(copilot_auth.CopilotTokenProvider().get_api_token())
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def test_exchange_token_raises_when_exchange_returns_empty_token(
|
| 311 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 312 |
+
) -> None:
|
| 313 |
+
provider = copilot_auth.CopilotTokenProvider()
|
| 314 |
+
monkeypatch.setattr(
|
| 315 |
+
provider,
|
| 316 |
+
"_exchange_token_sync",
|
| 317 |
+
staticmethod(lambda headers: {"token": "", "expires_at": int(time.time()) + 1}),
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
with pytest.raises(RuntimeError, match="empty token"):
|
| 321 |
+
asyncio.run(provider._exchange_token("gho-oauth"))
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def test_exchange_token_sync_raises_for_http_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 325 |
+
class DummyResponse:
|
| 326 |
+
def read(self) -> bytes:
|
| 327 |
+
return b'{"message":"Not Found"}'
|
| 328 |
+
|
| 329 |
+
def close(self) -> None:
|
| 330 |
+
return None
|
| 331 |
+
|
| 332 |
+
def fake_urlopen(request, timeout: float): # noqa: ANN001, ANN202
|
| 333 |
+
raise urllib_error.HTTPError(
|
| 334 |
+
url=request.full_url,
|
| 335 |
+
code=404,
|
| 336 |
+
msg="Not Found",
|
| 337 |
+
hdrs=None,
|
| 338 |
+
fp=DummyResponse(),
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
monkeypatch.setattr(copilot_auth.urllib_request, "urlopen", fake_urlopen)
|
| 342 |
+
|
| 343 |
+
with pytest.raises(RuntimeError, match="HTTP 404"):
|
| 344 |
+
copilot_auth.CopilotTokenProvider._exchange_token_sync({"Authorization": "token test"})
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def test_apply_copilot_api_auth_returns_original_headers_for_non_copilot_url() -> None:
|
| 348 |
+
headers = asyncio.run(
|
| 349 |
+
copilot_auth.apply_copilot_api_auth(
|
| 350 |
+
{"authorization": "Bearer downstream-token"},
|
| 351 |
+
url="https://api.openai.com/v1/chat/completions",
|
| 352 |
+
)
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
assert headers == {"authorization": "Bearer downstream-token"}
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def test_read_windows_copilot_cli_oauth_token_returns_none_without_windll(
|
| 359 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 360 |
+
) -> None:
|
| 361 |
+
monkeypatch.setattr(copilot_auth.os, "name", "nt")
|
| 362 |
+
monkeypatch.delattr(copilot_auth.ctypes, "WinDLL", raising=False)
|
| 363 |
+
|
| 364 |
+
assert copilot_auth._read_windows_copilot_cli_oauth_token() is None
|
tests/test_learn/test_writer.py
CHANGED
|
@@ -3,7 +3,12 @@
|
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
from headroom.learn.models import ProjectInfo, Recommendation, RecommendationTarget
|
| 6 |
-
from headroom.learn.writer import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
def _project(tmp_path: Path) -> ProjectInfo:
|
|
@@ -80,26 +85,114 @@ class TestClaudeCodeWriter:
|
|
| 80 |
assert "Existing instructions here" in content
|
| 81 |
assert "Use uv" in content
|
| 82 |
|
| 83 |
-
def
|
|
|
|
| 84 |
proj = _project(tmp_path)
|
| 85 |
claude_md = proj.project_path / "CLAUDE.md"
|
| 86 |
-
|
| 87 |
-
f"# My Project\n\n{_MARKER_START}\n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
)
|
| 89 |
-
claude_md.write_text(
|
| 90 |
|
| 91 |
writer = ClaudeCodeWriter()
|
| 92 |
-
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "-
|
| 93 |
writer.write(recs, proj, dry_run=False)
|
| 94 |
|
| 95 |
content = claude_md.read_text()
|
| 96 |
-
assert "old stuff" not in content
|
| 97 |
-
assert "New stuff" in content
|
| 98 |
assert "My Project" in content
|
| 99 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
assert content.count(_MARKER_START) == 1
|
| 101 |
assert content.count(_MARKER_END) == 1
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
def test_appends_to_existing_memory_md(self, tmp_path):
|
| 104 |
proj = _project(tmp_path)
|
| 105 |
memory_md = proj.data_path / "memory" / "MEMORY.md"
|
|
@@ -113,3 +206,36 @@ class TestClaudeCodeWriter:
|
|
| 113 |
assert "Existing Memory" in content
|
| 114 |
assert "Some facts" in content
|
| 115 |
assert "New pattern" in content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
from headroom.learn.models import ProjectInfo, Recommendation, RecommendationTarget
|
| 6 |
+
from headroom.learn.writer import (
|
| 7 |
+
_MARKER_END,
|
| 8 |
+
_MARKER_START,
|
| 9 |
+
ClaudeCodeWriter,
|
| 10 |
+
_parse_prior_recommendations,
|
| 11 |
+
)
|
| 12 |
|
| 13 |
|
| 14 |
def _project(tmp_path: Path) -> ProjectInfo:
|
|
|
|
| 85 |
assert "Existing instructions here" in content
|
| 86 |
assert "Use uv" in content
|
| 87 |
|
| 88 |
+
def test_carries_forward_prior_sections_not_resurfaced(self, tmp_path):
|
| 89 |
+
"""Re-running learn must not drop prior sections that the new run didn't re-surface."""
|
| 90 |
proj = _project(tmp_path)
|
| 91 |
claude_md = proj.project_path / "CLAUDE.md"
|
| 92 |
+
prior_block = (
|
| 93 |
+
f"# My Project\n\n{_MARKER_START}\n"
|
| 94 |
+
"## Headroom Learned Patterns\n"
|
| 95 |
+
"*Auto-generated by `headroom learn` on 2026-01-01 — do not edit manually*\n\n"
|
| 96 |
+
"### Large Files\n"
|
| 97 |
+
"*~15,000 tokens/session saved*\n"
|
| 98 |
+
"- src/App.tsx is huge\n\n"
|
| 99 |
+
"### Build Commands\n"
|
| 100 |
+
"- cargo check from src-tauri/\n\n"
|
| 101 |
+
f"{_MARKER_END}\n"
|
| 102 |
)
|
| 103 |
+
claude_md.write_text(prior_block)
|
| 104 |
|
| 105 |
writer = ClaudeCodeWriter()
|
| 106 |
+
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
|
| 107 |
writer.write(recs, proj, dry_run=False)
|
| 108 |
|
| 109 |
content = claude_md.read_text()
|
|
|
|
|
|
|
| 110 |
assert "My Project" in content
|
| 111 |
+
# New section present
|
| 112 |
+
assert "Use uv" in content
|
| 113 |
+
# Prior sections preserved (neither heading re-surfaced by the new run)
|
| 114 |
+
assert "### Large Files" in content
|
| 115 |
+
assert "src/App.tsx is huge" in content
|
| 116 |
+
assert "### Build Commands" in content
|
| 117 |
+
assert "cargo check from src-tauri/" in content
|
| 118 |
+
# Tokens annotation round-tripped
|
| 119 |
+
assert "*~15,000 tokens/session saved*" in content
|
| 120 |
+
# Still exactly one marker pair
|
| 121 |
assert content.count(_MARKER_START) == 1
|
| 122 |
assert content.count(_MARKER_END) == 1
|
| 123 |
|
| 124 |
+
def test_new_run_overrides_same_named_prior_section(self, tmp_path):
|
| 125 |
+
"""When a section appears in both prior and new, the new run wins."""
|
| 126 |
+
proj = _project(tmp_path)
|
| 127 |
+
claude_md = proj.project_path / "CLAUDE.md"
|
| 128 |
+
prior_block = (
|
| 129 |
+
f"{_MARKER_START}\n"
|
| 130 |
+
"## Headroom Learned Patterns\n"
|
| 131 |
+
"*Auto-generated by `headroom learn` on 2026-01-01 — do not edit manually*\n\n"
|
| 132 |
+
"### Environment\n"
|
| 133 |
+
"- old stale environment note\n\n"
|
| 134 |
+
f"{_MARKER_END}\n"
|
| 135 |
+
)
|
| 136 |
+
claude_md.write_text(prior_block)
|
| 137 |
+
|
| 138 |
+
writer = ClaudeCodeWriter()
|
| 139 |
+
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- fresh environment note")]
|
| 140 |
+
writer.write(recs, proj, dry_run=False)
|
| 141 |
+
|
| 142 |
+
content = claude_md.read_text()
|
| 143 |
+
assert "fresh environment note" in content
|
| 144 |
+
assert "old stale environment note" not in content
|
| 145 |
+
# Only one Environment section in the final block
|
| 146 |
+
assert content.count("### Environment") == 1
|
| 147 |
+
|
| 148 |
+
def test_memory_md_carry_forward(self, tmp_path):
|
| 149 |
+
"""Carry-forward also works for MEMORY.md."""
|
| 150 |
+
proj = _project(tmp_path)
|
| 151 |
+
memory_md = proj.data_path / "memory" / "MEMORY.md"
|
| 152 |
+
memory_md.write_text(
|
| 153 |
+
f"{_MARKER_START}\n"
|
| 154 |
+
"## Headroom Learned Patterns\n"
|
| 155 |
+
"*Auto-generated by `headroom learn` on 2026-01-01 — do not edit manually*\n\n"
|
| 156 |
+
"### User Workflow Preferences\n"
|
| 157 |
+
"- User rejects sleep-based polling\n\n"
|
| 158 |
+
f"{_MARKER_END}\n"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
writer = ClaudeCodeWriter()
|
| 162 |
+
recs = [
|
| 163 |
+
_rec(RecommendationTarget.MEMORY_FILE, "Related Codebases", "- web app at ~/Code/web")
|
| 164 |
+
]
|
| 165 |
+
writer.write(recs, proj, dry_run=False)
|
| 166 |
+
|
| 167 |
+
content = memory_md.read_text()
|
| 168 |
+
assert "User rejects sleep-based polling" in content
|
| 169 |
+
assert "web app at ~/Code/web" in content
|
| 170 |
+
|
| 171 |
+
def test_section_without_tokens_annotation_round_trips(self, tmp_path):
|
| 172 |
+
"""Prior sections emitted without a tokens annotation must still carry forward cleanly."""
|
| 173 |
+
proj = _project(tmp_path)
|
| 174 |
+
claude_md = proj.project_path / "CLAUDE.md"
|
| 175 |
+
claude_md.write_text(
|
| 176 |
+
f"{_MARKER_START}\n"
|
| 177 |
+
"## Headroom Learned Patterns\n"
|
| 178 |
+
"*Auto-generated by `headroom learn` on 2026-01-01 — do not edit manually*\n\n"
|
| 179 |
+
"### Misc\n"
|
| 180 |
+
"- one-liner pattern\n\n"
|
| 181 |
+
f"{_MARKER_END}\n"
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
writer = ClaudeCodeWriter()
|
| 185 |
+
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Other", "- new one")]
|
| 186 |
+
writer.write(recs, proj, dry_run=False)
|
| 187 |
+
|
| 188 |
+
content = claude_md.read_text()
|
| 189 |
+
assert "### Misc" in content
|
| 190 |
+
assert "one-liner pattern" in content
|
| 191 |
+
# No spurious tokens annotation injected for a prior that didn't have one
|
| 192 |
+
misc_idx = content.index("### Misc")
|
| 193 |
+
after_misc = content[misc_idx : misc_idx + 200]
|
| 194 |
+
assert "tokens/session saved" not in after_misc
|
| 195 |
+
|
| 196 |
def test_appends_to_existing_memory_md(self, tmp_path):
|
| 197 |
proj = _project(tmp_path)
|
| 198 |
memory_md = proj.data_path / "memory" / "MEMORY.md"
|
|
|
|
| 206 |
assert "Existing Memory" in content
|
| 207 |
assert "Some facts" in content
|
| 208 |
assert "New pattern" in content
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
class TestParsePriorRecommendations:
|
| 212 |
+
"""Direct coverage for _parse_prior_recommendations edge cases."""
|
| 213 |
+
|
| 214 |
+
def test_no_marker_block_returns_empty(self):
|
| 215 |
+
"""A file without any marker block yields no prior recommendations."""
|
| 216 |
+
assert _parse_prior_recommendations("# Project\n\nJust a regular README.\n") == []
|
| 217 |
+
|
| 218 |
+
def test_empty_marker_block_yields_no_recs(self):
|
| 219 |
+
"""A marker block with nothing between the markers yields no recs."""
|
| 220 |
+
content = f"prefix\n{_MARKER_START}\n{_MARKER_END}\nsuffix\n"
|
| 221 |
+
assert _parse_prior_recommendations(content) == []
|
| 222 |
+
|
| 223 |
+
def test_marker_block_with_empty_heading_is_skipped(self):
|
| 224 |
+
"""A stray `### ` (empty heading) inside the block is skipped, not raised."""
|
| 225 |
+
# Leading `### ` with no heading text, followed by a real section.
|
| 226 |
+
content = (
|
| 227 |
+
f"{_MARKER_START}\n"
|
| 228 |
+
"## Headroom Learned Patterns\n"
|
| 229 |
+
"### \n"
|
| 230 |
+
"some orphan content\n"
|
| 231 |
+
"\n"
|
| 232 |
+
"### Real Section\n"
|
| 233 |
+
"- real bullet\n"
|
| 234 |
+
"\n"
|
| 235 |
+
f"{_MARKER_END}\n"
|
| 236 |
+
)
|
| 237 |
+
recs = _parse_prior_recommendations(content)
|
| 238 |
+
# Only the real section is parsed; the empty-heading entry is dropped.
|
| 239 |
+
assert len(recs) == 1
|
| 240 |
+
assert recs[0].section == "Real Section"
|
| 241 |
+
assert "real bullet" in recs[0].content
|
tests/test_proxy_copilot_auth_hooks.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import importlib.util
|
| 4 |
import sys
|
| 5 |
import types
|
|
@@ -9,6 +10,27 @@ from types import SimpleNamespace
|
|
| 9 |
import pytest
|
| 10 |
|
| 11 |
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
def _load_handler_module(monkeypatch: pytest.MonkeyPatch, module_name: str, relative_path: str):
|
|
@@ -54,8 +76,7 @@ def _load_handler_module(monkeypatch: pytest.MonkeyPatch, module_name: str, rela
|
|
| 54 |
return module
|
| 55 |
|
| 56 |
|
| 57 |
-
|
| 58 |
-
async def test_openai_passthrough_applies_copilot_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 59 |
openai_mod = _load_handler_module(
|
| 60 |
monkeypatch,
|
| 61 |
"tests.headroom_proxy_handlers_openai",
|
|
@@ -100,20 +121,21 @@ async def test_openai_passthrough_applies_copilot_auth(monkeypatch: pytest.Monke
|
|
| 100 |
request.body = body
|
| 101 |
|
| 102 |
handler = Dummy()
|
| 103 |
-
response =
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
| 108 |
)
|
| 109 |
|
| 110 |
-
assert seen["url"] == "https://api.githubcopilot.com/
|
| 111 |
assert seen["request_kwargs"]["headers"] == {"Authorization": "Bearer upstream-token"}
|
| 112 |
assert response.status_code == 200
|
| 113 |
|
| 114 |
|
| 115 |
-
|
| 116 |
-
async def test_streaming_response_applies_copilot_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 117 |
streaming_mod = _load_handler_module(
|
| 118 |
monkeypatch,
|
| 119 |
"tests.headroom_proxy_handlers_streaming",
|
|
@@ -155,19 +177,21 @@ async def test_streaming_response_applies_copilot_auth(monkeypatch: pytest.Monke
|
|
| 155 |
return SimpleNamespace(headers={}, status_code=200)
|
| 156 |
|
| 157 |
handler = Dummy()
|
| 158 |
-
response =
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
| 171 |
)
|
| 172 |
|
| 173 |
assert seen["url"] == "https://api.githubcopilot.com/v1/responses"
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
import importlib.util
|
| 5 |
import sys
|
| 6 |
import types
|
|
|
|
| 10 |
import pytest
|
| 11 |
|
| 12 |
ROOT = Path(__file__).resolve().parents[1]
|
| 13 |
+
_ISOLATED_MODULE_NAMES = (
|
| 14 |
+
"headroom.proxy",
|
| 15 |
+
"headroom.proxy.handlers",
|
| 16 |
+
"httpx",
|
| 17 |
+
"fastapi.responses",
|
| 18 |
+
"tests.headroom_proxy_handlers_openai",
|
| 19 |
+
"tests.headroom_proxy_handlers_streaming",
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@pytest.fixture(autouse=True)
|
| 24 |
+
def restore_isolated_modules() -> None:
|
| 25 |
+
saved_modules = {name: sys.modules.get(name) for name in _ISOLATED_MODULE_NAMES}
|
| 26 |
+
try:
|
| 27 |
+
yield
|
| 28 |
+
finally:
|
| 29 |
+
for name in _ISOLATED_MODULE_NAMES:
|
| 30 |
+
sys.modules.pop(name, None)
|
| 31 |
+
for name, module in saved_modules.items():
|
| 32 |
+
if module is not None:
|
| 33 |
+
sys.modules[name] = module
|
| 34 |
|
| 35 |
|
| 36 |
def _load_handler_module(monkeypatch: pytest.MonkeyPatch, module_name: str, relative_path: str):
|
|
|
|
| 76 |
return module
|
| 77 |
|
| 78 |
|
| 79 |
+
def test_openai_passthrough_applies_copilot_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
| 80 |
openai_mod = _load_handler_module(
|
| 81 |
monkeypatch,
|
| 82 |
"tests.headroom_proxy_handlers_openai",
|
|
|
|
| 121 |
request.body = body
|
| 122 |
|
| 123 |
handler = Dummy()
|
| 124 |
+
response = asyncio.run(
|
| 125 |
+
handler.handle_passthrough(
|
| 126 |
+
request,
|
| 127 |
+
"https://api.githubcopilot.com",
|
| 128 |
+
"models",
|
| 129 |
+
"openai",
|
| 130 |
+
)
|
| 131 |
)
|
| 132 |
|
| 133 |
+
assert seen["url"] == "https://api.githubcopilot.com/models"
|
| 134 |
assert seen["request_kwargs"]["headers"] == {"Authorization": "Bearer upstream-token"}
|
| 135 |
assert response.status_code == 200
|
| 136 |
|
| 137 |
|
| 138 |
+
def test_streaming_response_applies_copilot_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
| 139 |
streaming_mod = _load_handler_module(
|
| 140 |
monkeypatch,
|
| 141 |
"tests.headroom_proxy_handlers_streaming",
|
|
|
|
| 177 |
return SimpleNamespace(headers={}, status_code=200)
|
| 178 |
|
| 179 |
handler = Dummy()
|
| 180 |
+
response = asyncio.run(
|
| 181 |
+
handler._stream_response(
|
| 182 |
+
url="https://api.githubcopilot.com/v1/responses",
|
| 183 |
+
headers={"authorization": "Bearer downstream"},
|
| 184 |
+
body={"model": "gpt-4o"},
|
| 185 |
+
provider="openai",
|
| 186 |
+
model="gpt-4o",
|
| 187 |
+
request_id="req-test",
|
| 188 |
+
original_tokens=0,
|
| 189 |
+
optimized_tokens=0,
|
| 190 |
+
tokens_saved=0,
|
| 191 |
+
transforms_applied=[],
|
| 192 |
+
tags={},
|
| 193 |
+
optimization_latency=0.0,
|
| 194 |
+
)
|
| 195 |
)
|
| 196 |
|
| 197 |
assert seen["url"] == "https://api.githubcopilot.com/v1/responses"
|
tests/test_proxy_dashboard_stats_cache.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import shutil
|
| 5 |
+
import subprocess
|
| 6 |
+
from types import SimpleNamespace
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
from headroom.dashboard import get_dashboard_html
|
| 11 |
+
from headroom.proxy import helpers as proxy_helpers
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class _StatsStub:
|
| 15 |
+
def __init__(self, calls: dict[str, int], key: str, payload: dict):
|
| 16 |
+
self._calls = calls
|
| 17 |
+
self._key = key
|
| 18 |
+
self._payload = payload
|
| 19 |
+
|
| 20 |
+
def get_stats(self) -> dict:
|
| 21 |
+
self._calls[self._key] += 1
|
| 22 |
+
return dict(self._payload)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class _ToinStub:
|
| 26 |
+
def get_stats(self) -> dict:
|
| 27 |
+
return {"patterns": 0}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@pytest.fixture(autouse=True)
|
| 31 |
+
def _reset_rtk_stats_cache() -> None:
|
| 32 |
+
proxy_helpers._rtk_stats_cache.update({"expires_at": 0.0, "has_value": False, "value": None})
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_get_rtk_stats_memoizes_subprocess_calls(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 36 |
+
now = {"value": 100.0}
|
| 37 |
+
calls = {"run": 0}
|
| 38 |
+
|
| 39 |
+
def _fake_run(*args, **kwargs):
|
| 40 |
+
calls["run"] += 1
|
| 41 |
+
return SimpleNamespace(
|
| 42 |
+
returncode=0,
|
| 43 |
+
stdout=json.dumps({"summary": {"total_commands": 7, "total_saved": 1234}}),
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
monkeypatch.setattr(proxy_helpers.time, "monotonic", lambda: now["value"])
|
| 47 |
+
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/rtk")
|
| 48 |
+
monkeypatch.setattr(subprocess, "run", _fake_run)
|
| 49 |
+
|
| 50 |
+
first = proxy_helpers._get_rtk_stats()
|
| 51 |
+
second = proxy_helpers._get_rtk_stats()
|
| 52 |
+
|
| 53 |
+
assert first == second
|
| 54 |
+
assert first == {
|
| 55 |
+
"installed": True,
|
| 56 |
+
"total_commands": 7,
|
| 57 |
+
"tokens_saved": 1234,
|
| 58 |
+
"avg_savings_pct": 0.0,
|
| 59 |
+
}
|
| 60 |
+
assert calls["run"] == 1
|
| 61 |
+
|
| 62 |
+
now["value"] += proxy_helpers.RTK_STATS_CACHE_TTL_SECONDS + 0.1
|
| 63 |
+
third = proxy_helpers._get_rtk_stats()
|
| 64 |
+
|
| 65 |
+
assert third == first
|
| 66 |
+
assert calls["run"] == 2
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_stats_cached_query_reuses_short_ttl_snapshot(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 70 |
+
pytest.importorskip("fastapi")
|
| 71 |
+
from fastapi.testclient import TestClient
|
| 72 |
+
|
| 73 |
+
import headroom.proxy.server as server
|
| 74 |
+
from headroom.proxy.server import ProxyConfig, create_app
|
| 75 |
+
|
| 76 |
+
calls = {"store": 0, "telemetry": 0, "feedback": 0, "rtk": 0}
|
| 77 |
+
now = {"value": 100.0}
|
| 78 |
+
|
| 79 |
+
monkeypatch.setattr(server.time, "monotonic", lambda: now["value"])
|
| 80 |
+
monkeypatch.setattr(
|
| 81 |
+
server,
|
| 82 |
+
"get_compression_store",
|
| 83 |
+
lambda: _StatsStub(calls, "store", {"entry_count": 1, "max_entries": 100}),
|
| 84 |
+
)
|
| 85 |
+
monkeypatch.setattr(
|
| 86 |
+
server,
|
| 87 |
+
"get_telemetry_collector",
|
| 88 |
+
lambda: _StatsStub(calls, "telemetry", {"enabled": True}),
|
| 89 |
+
)
|
| 90 |
+
monkeypatch.setattr(
|
| 91 |
+
server,
|
| 92 |
+
"get_compression_feedback",
|
| 93 |
+
lambda: _StatsStub(calls, "feedback", {}),
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
def _fake_rtk_stats() -> dict[str, int | bool | float]:
|
| 97 |
+
calls["rtk"] += 1
|
| 98 |
+
return {
|
| 99 |
+
"installed": True,
|
| 100 |
+
"total_commands": 1,
|
| 101 |
+
"tokens_saved": 5,
|
| 102 |
+
"avg_savings_pct": 10.0,
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
monkeypatch.setattr(server, "_get_rtk_stats", _fake_rtk_stats)
|
| 106 |
+
monkeypatch.setattr(server, "get_toin", lambda: _ToinStub())
|
| 107 |
+
|
| 108 |
+
app = create_app(
|
| 109 |
+
ProxyConfig(
|
| 110 |
+
optimize=False,
|
| 111 |
+
cache_enabled=False,
|
| 112 |
+
rate_limit_enabled=False,
|
| 113 |
+
cost_tracking_enabled=False,
|
| 114 |
+
log_requests=False,
|
| 115 |
+
ccr_inject_tool=False,
|
| 116 |
+
ccr_handle_responses=False,
|
| 117 |
+
ccr_context_tracking=False,
|
| 118 |
+
)
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
with TestClient(app) as client:
|
| 122 |
+
first = client.get("/stats?cached=1")
|
| 123 |
+
second = client.get("/stats?cached=1")
|
| 124 |
+
now["value"] += 5.1
|
| 125 |
+
third = client.get("/stats?cached=1")
|
| 126 |
+
uncached = client.get("/stats")
|
| 127 |
+
|
| 128 |
+
assert first.status_code == 200
|
| 129 |
+
assert second.status_code == 200
|
| 130 |
+
assert third.status_code == 200
|
| 131 |
+
assert uncached.status_code == 200
|
| 132 |
+
|
| 133 |
+
assert calls == {"store": 3, "telemetry": 3, "feedback": 3, "rtk": 3}
|
| 134 |
+
assert first.json()["cli_filtering"]["tokens_saved"] == 5
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_dashboard_uses_cached_stats_and_lazy_history_feed_polling() -> None:
|
| 138 |
+
html = get_dashboard_html()
|
| 139 |
+
|
| 140 |
+
assert "fetch('/stats?cached=1')" in html
|
| 141 |
+
assert "@click=\"setViewMode('history')\"" in html
|
| 142 |
+
assert '@click="toggleFeed()"' in html
|
| 143 |
+
assert "this.viewMode === 'history'" in html
|
| 144 |
+
assert "this.feedOpen" in html
|
tests/test_proxy_streaming_request_logger.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests that the Anthropic streaming finalizer logs requests for the feed.
|
| 2 |
+
|
| 3 |
+
Without this, the streaming Anthropic path (which is what Claude Code uses)
|
| 4 |
+
silently bypassed the request logger, leaving `/stats.recent_requests` and
|
| 5 |
+
`/transformations/feed` permanently empty even when `--log-messages` was set.
|
| 6 |
+
The non-streaming Anthropic path and the Bedrock streaming path were the
|
| 7 |
+
only ones that called `self.logger.log(...)`.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from unittest.mock import AsyncMock, MagicMock
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
import pytest
|
| 14 |
+
|
| 15 |
+
from headroom.proxy.request_logger import RequestLogger
|
| 16 |
+
from headroom.proxy.server import HeadroomProxy
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _build_proxy_with_real_logger(*, log_full_messages: bool) -> HeadroomProxy:
|
| 20 |
+
"""Build a HeadroomProxy with mocks for everything except the request logger,
|
| 21 |
+
so we can assert what actually gets recorded."""
|
| 22 |
+
proxy = object.__new__(HeadroomProxy)
|
| 23 |
+
proxy.http_client = MagicMock(spec=httpx.AsyncClient)
|
| 24 |
+
proxy.metrics = MagicMock()
|
| 25 |
+
proxy.metrics.record_request = AsyncMock(return_value=None)
|
| 26 |
+
proxy.cost_tracker = MagicMock()
|
| 27 |
+
proxy.cost_tracker.record_tokens.return_value = None
|
| 28 |
+
proxy.memory_manager = None
|
| 29 |
+
proxy.memory_handler = None
|
| 30 |
+
proxy._config = MagicMock()
|
| 31 |
+
proxy._config.log_full_messages = log_full_messages
|
| 32 |
+
proxy._config.ccr_inject_tool = False
|
| 33 |
+
proxy.config = proxy._config
|
| 34 |
+
proxy.logger = RequestLogger(log_file=None, log_full_messages=log_full_messages)
|
| 35 |
+
return proxy
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _stream_state(output_tokens: int = 42) -> dict:
|
| 39 |
+
return {
|
| 40 |
+
"output_tokens": output_tokens,
|
| 41 |
+
"total_bytes": 200,
|
| 42 |
+
"ttfb_ms": 35.0,
|
| 43 |
+
"input_tokens": 1000,
|
| 44 |
+
"cache_read_input_tokens": 0,
|
| 45 |
+
"cache_creation_input_tokens": 0,
|
| 46 |
+
"cache_creation_ephemeral_5m_input_tokens": 0,
|
| 47 |
+
"cache_creation_ephemeral_1h_input_tokens": 0,
|
| 48 |
+
"sse_buffer": "",
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@pytest.mark.asyncio
|
| 53 |
+
async def test_finalize_stream_response_logs_request_for_feed():
|
| 54 |
+
proxy = _build_proxy_with_real_logger(log_full_messages=False)
|
| 55 |
+
|
| 56 |
+
await proxy._finalize_stream_response(
|
| 57 |
+
body={"messages": [{"role": "user", "content": "hi"}]},
|
| 58 |
+
provider="anthropic",
|
| 59 |
+
model="claude-sonnet-4-6",
|
| 60 |
+
request_id="req-stream-1",
|
| 61 |
+
original_tokens=1000,
|
| 62 |
+
optimized_tokens=600,
|
| 63 |
+
tokens_saved=400,
|
| 64 |
+
transforms_applied=["smart_crusher"],
|
| 65 |
+
optimization_latency=12.0,
|
| 66 |
+
stream_state=_stream_state(),
|
| 67 |
+
start_time=0.0,
|
| 68 |
+
tags={"stack": "wrap_claude"},
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
entries = proxy.logger.get_recent(10)
|
| 72 |
+
assert len(entries) == 1, "streaming finalizer must log exactly one entry per request"
|
| 73 |
+
entry = entries[0]
|
| 74 |
+
assert entry["request_id"] == "req-stream-1"
|
| 75 |
+
assert entry["provider"] == "anthropic"
|
| 76 |
+
assert entry["model"] == "claude-sonnet-4-6"
|
| 77 |
+
assert entry["input_tokens_original"] == 1000
|
| 78 |
+
assert entry["input_tokens_optimized"] == 600
|
| 79 |
+
assert entry["tokens_saved"] == 400
|
| 80 |
+
assert entry["savings_percent"] == pytest.approx(40.0)
|
| 81 |
+
assert entry["transforms_applied"] == ["smart_crusher"]
|
| 82 |
+
assert entry["tags"] == {"stack": "wrap_claude"}
|
| 83 |
+
assert entry["cache_hit"] is False
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@pytest.mark.asyncio
|
| 87 |
+
async def test_finalize_stream_response_includes_messages_when_log_full_messages_enabled():
|
| 88 |
+
proxy = _build_proxy_with_real_logger(log_full_messages=True)
|
| 89 |
+
body = {"messages": [{"role": "user", "content": "hello"}]}
|
| 90 |
+
|
| 91 |
+
await proxy._finalize_stream_response(
|
| 92 |
+
body=body,
|
| 93 |
+
provider="anthropic",
|
| 94 |
+
model="claude-sonnet-4-6",
|
| 95 |
+
request_id="req-stream-2",
|
| 96 |
+
original_tokens=10,
|
| 97 |
+
optimized_tokens=8,
|
| 98 |
+
tokens_saved=2,
|
| 99 |
+
transforms_applied=[],
|
| 100 |
+
optimization_latency=1.0,
|
| 101 |
+
stream_state=_stream_state(output_tokens=5),
|
| 102 |
+
start_time=0.0,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
entries = proxy.logger.get_recent_with_messages(10)
|
| 106 |
+
assert len(entries) == 1
|
| 107 |
+
assert entries[0]["request_messages"] == body["messages"]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@pytest.mark.asyncio
|
| 111 |
+
async def test_finalize_stream_response_omits_messages_when_log_full_messages_disabled():
|
| 112 |
+
proxy = _build_proxy_with_real_logger(log_full_messages=False)
|
| 113 |
+
|
| 114 |
+
await proxy._finalize_stream_response(
|
| 115 |
+
body={"messages": [{"role": "user", "content": "hello"}]},
|
| 116 |
+
provider="anthropic",
|
| 117 |
+
model="claude-sonnet-4-6",
|
| 118 |
+
request_id="req-stream-3",
|
| 119 |
+
original_tokens=10,
|
| 120 |
+
optimized_tokens=8,
|
| 121 |
+
tokens_saved=2,
|
| 122 |
+
transforms_applied=[],
|
| 123 |
+
optimization_latency=1.0,
|
| 124 |
+
stream_state=_stream_state(output_tokens=5),
|
| 125 |
+
start_time=0.0,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
entries = proxy.logger.get_recent_with_messages(10)
|
| 129 |
+
assert len(entries) == 1
|
| 130 |
+
assert entries[0]["request_messages"] is None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@pytest.mark.asyncio
|
| 134 |
+
async def test_finalize_stream_response_handles_zero_original_tokens():
|
| 135 |
+
proxy = _build_proxy_with_real_logger(log_full_messages=False)
|
| 136 |
+
|
| 137 |
+
await proxy._finalize_stream_response(
|
| 138 |
+
body={"messages": []},
|
| 139 |
+
provider="anthropic",
|
| 140 |
+
model="claude-sonnet-4-6",
|
| 141 |
+
request_id="req-stream-4",
|
| 142 |
+
original_tokens=0,
|
| 143 |
+
optimized_tokens=0,
|
| 144 |
+
tokens_saved=0,
|
| 145 |
+
transforms_applied=[],
|
| 146 |
+
optimization_latency=0.0,
|
| 147 |
+
stream_state=_stream_state(output_tokens=0),
|
| 148 |
+
start_time=0.0,
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
entries = proxy.logger.get_recent(10)
|
| 152 |
+
assert len(entries) == 1
|
| 153 |
+
assert entries[0]["savings_percent"] == 0
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@pytest.mark.asyncio
|
| 157 |
+
async def test_finalize_stream_response_no_op_when_logger_disabled():
|
| 158 |
+
proxy = _build_proxy_with_real_logger(log_full_messages=False)
|
| 159 |
+
proxy.logger = None # `--no-log-requests` would put us here
|
| 160 |
+
|
| 161 |
+
# Should not raise.
|
| 162 |
+
await proxy._finalize_stream_response(
|
| 163 |
+
body={"messages": []},
|
| 164 |
+
provider="anthropic",
|
| 165 |
+
model="claude-sonnet-4-6",
|
| 166 |
+
request_id="req-stream-5",
|
| 167 |
+
original_tokens=10,
|
| 168 |
+
optimized_tokens=8,
|
| 169 |
+
tokens_saved=2,
|
| 170 |
+
transforms_applied=[],
|
| 171 |
+
optimization_latency=1.0,
|
| 172 |
+
stream_state=_stream_state(),
|
| 173 |
+
start_time=0.0,
|
| 174 |
+
)
|