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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:03:20 +08:00
commit 0ef5fcb1c5
1951 changed files with 606278 additions and 0 deletions
@@ -0,0 +1,17 @@
{
"name": "headroom",
"version": "0.31.0",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"author": {
"name": "Headroom Contributors",
"url": "https://github.com/chopratejas/headroom"
},
"homepage": "https://github.com/chopratejas/headroom",
"repository": "https://github.com/chopratejas/headroom",
"keywords": [
"headroom",
"hooks",
"claude-code",
"copilot-cli"
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "headroom",
"version": "0.31.0",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"author": {
"name": "Headroom Contributors",
"url": "https://github.com/chopratejas/headroom"
},
"homepage": "https://github.com/chopratejas/headroom",
"repository": "https://github.com/chopratejas/headroom",
"keywords": [
"headroom",
"hooks",
"claude-code",
"copilot-cli"
],
"hooks": "./hooks"
}
+11
View File
@@ -0,0 +1,11 @@
# Headroom agent hooks
This plugin exposes lightweight startup hooks for Claude Code and GitHub Copilot CLI.
The hooks call:
```bash
headroom init hook ensure
```
That hidden helper checks for a matching durable `headroom init` deployment and starts it if needed.
@@ -0,0 +1,29 @@
{
"description": "Headroom plugin hooks — ensure the local Headroom runtime is available for initialized agents.",
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "headroom init hook ensure",
"timeout": 15
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash|PowerShell",
"hooks": [
{
"type": "command",
"command": "headroom init hook ensure",
"timeout": 15
}
]
}
]
}
}
+16
View File
@@ -0,0 +1,16 @@
# Changelog
## 0.1.0
Initial release — generic OAuth2 client-credentials upstream-auth extension for the Headroom proxy.
- Mints an OAuth2 client-credentials (RFC 6749 §4.4) bearer from a configurable token endpoint and
injects it as the upstream `Authorization` on each proxied request, via Headroom's opt-in
`headroom.proxy_extension` seam (`--proxy-extension oauth2`). No core changes; vendor-neutral.
- `post` and `basic` client-auth styles; scopes, `audience`, RFC 8707 `resource`, static upstream
headers, configurable timeout/skew — all env-driven.
- Token caching with single-flight refresh and pre-expiry skew; `expires_in` clamped to a positive
TTL.
- Fails closed on misconfiguration; returns `502 upstream_auth_error` on mint failure without
leaking the IdP error body. `token_url` https-enforced (loopback exempt). Std-lib only (system
cert store -> works behind corporate SSL inspection).
+3
View File
@@ -0,0 +1,3 @@
SPDX-License-Identifier: Apache-2.0
Apache License 2.0 — full text: https://www.apache.org/licenses/LICENSE-2.0
(Matches the upstream Headroom license; full text bundled at publish time.)
+43
View File
@@ -0,0 +1,43 @@
# headroom-oauth2
Generic **OAuth2 client-credentials** upstream-auth extension for the
[Headroom](https://github.com/chopratejas/headroom) proxy.
When Headroom routes to an OpenAI-compatible backend that is protected by an
OAuth2 client-credentials flow (enterprise AI gateways, Azure AD / Entra, Okta,
Auth0, Keycloak, Cognito, …), this extension mints a bearer token from a
configurable token endpoint, caches + refreshes it (single-flight), and injects
`Authorization: Bearer <token>` on each upstream request. Optional static upstream
headers are sent via litellm. **Fully vendor-neutral — no provider is hard-coded.**
It plugs into Headroom's public `headroom.proxy_extension` entry-point seam, so it
is fully out-of-tree and opt-in.
## Install & enable
```bash
pip install headroom-oauth2
headroom proxy --backend litellm-openai --proxy-extension oauth2
```
## Configure (env; no-op unless HEADROOM_OAUTH2_TOKEN_URL is set)
| Env | Meaning |
|-----|---------|
| `HEADROOM_OAUTH2_TOKEN_URL` | token endpoint (client_credentials grant) |
| `HEADROOM_OAUTH2_CLIENT_ID` / `_CLIENT_SECRET` | credentials (secrets) |
| `HEADROOM_OAUTH2_SCOPES` | space/comma-separated scopes |
| `HEADROOM_OAUTH2_AUDIENCE` | optional audience |
| `HEADROOM_OAUTH2_GRANT_TYPE` | default `client_credentials` |
| `HEADROOM_OAUTH2_AUTH_STYLE` | `post` (form creds) or `basic` (HTTP Basic) |
| `HEADROOM_OAUTH2_HEADERS` | static upstream headers, `K=V,K2=V2` |
Tokens are minted with the standard library (`urllib`, system cert store), which
works behind corporate SSL-inspection where bundled-root TLS stacks fail.
**Effective backends:** the injected bearer reaches the upstream only for OpenAI-compatible /
passthrough litellm providers. `bedrock` / `vertex` / `sagemaker` authenticate from env and
ignore it, so this extension is a no-op there (it logs a warning at startup).
**Transport:** `token_url` must be `https` (loopback `http` is allowed for tests; set
`HEADROOM_OAUTH2_ALLOW_INSECURE=1` to override). Tokens are minted with the standard library
(`urllib`, system cert store), so a corporate-injected CA is trusted without bundling roots.
+112
View File
@@ -0,0 +1,112 @@
# Spec — `headroom-oauth2` (generic OAuth2 client-credentials upstream auth)
## Summary
A vendor-neutral proxy extension (registers on Headroom's `headroom.proxy_extension` seam) that
mints an OAuth2 **client-credentials** (RFC 6749 §4.4) bearer from a configured token endpoint and
injects it as the upstream `Authorization` on every proxied request. Lets Headroom front any
gateway that requires a minted machine token (not a static API key) — with **zero core changes**
and **no vendor specifics** (the gateway is entirely config/env).
It complements `#510` (env-var auth), which assumes a long-lived static key; this covers the
"mint-then-refresh a short-lived token" case.
## API surface (config / CLI / env)
Opt-in only, via Headroom's existing flags — **no new CLI flags**:
headroom proxy --backend litellm-openai --proxy-extension oauth2
# or HEADROOM_PROXY_EXTENSIONS=oauth2
All configuration is env (12-factor; nothing baked in):
| Env var | Required | Meaning |
|---|---|---|
| `HEADROOM_OAUTH2_TOKEN_URL` | yes (else no-op) | OAuth2 token endpoint; must be `https` (loopback `http` allowed for tests) |
| `HEADROOM_OAUTH2_CLIENT_ID` / `_CLIENT_SECRET` | yes | client credentials |
| `HEADROOM_OAUTH2_SCOPES` | no | space/comma-separated scopes |
| `HEADROOM_OAUTH2_AUDIENCE` | no | `audience` form param |
| `HEADROOM_OAUTH2_RESOURCE` | no | RFC 8707 target `resource` form param |
| `HEADROOM_OAUTH2_GRANT_TYPE` | no | default `client_credentials` |
| `HEADROOM_OAUTH2_AUTH_STYLE` | no | `post` (form creds) or `basic` (HTTP Basic) |
| `HEADROOM_OAUTH2_HEADERS` | no | static upstream headers, `K=V,K2=V2` (control chars rejected) |
| `HEADROOM_OAUTH2_TIMEOUT` / `_SKEW` | no | token request timeout / pre-expiry refresh skew (s) |
| `HEADROOM_OAUTH2_ALLOW_INSECURE` | no | `1` to allow a non-loopback `http` token_url (discouraged) |
Public Python API: `OAuth2ClientCredentials`, `OAuth2Middleware`, `OAuth2Error`, `install`,
`provider_from_env`, `parse_headers`.
## Changes to existing behavior / defaults / compatibility
- **None unless explicitly enabled.** The entry point is dormant until `--proxy-extension oauth2`
is passed, and even then a **no-op** unless `HEADROOM_OAUTH2_TOKEN_URL` is set.
- When active, it **overwrites the request `Authorization` header** with the minted bearer before
the backend runs. The client's own `Authorization`/`x-api-key` is intentionally replaced (the
proxy authenticates to the gateway on the client's behalf). *Compatibility note:* because the
request then carries a bearer, Headroom classifies it as OAuth-mode auth — same as supplying a
bearer yourself; no new classification path.
- No change to defaults, the request/response body, model routing, or compression.
## User stories (Given / When / Then)
- **Golden path** — *Given* a proxy started with `--proxy-extension oauth2` and valid
`TOKEN_URL`/`CLIENT_ID`/`CLIENT_SECRET`, *When* a client sends `/v1/messages`, *Then* the
extension mints (or reuses a cached) bearer and the upstream receives `Authorization: Bearer
<minted>` plus any static headers; the client never sees the secret.
- **Edge: token endpoint down** — *Given* an unreachable/erroring `TOKEN_URL`, *When* a request
arrives, *Then* the proxy returns `502 upstream_auth_error` (no upstream call, no secret/body
leak) and stays up; the next request retries.
- **Edge: wrong backend** — *Given* `--backend bedrock` (env-auth), *When* the extension installs,
*Then* it logs a loud warning that the injected bearer will have no effect and to use an
OpenAI-compatible/passthrough backend.
## Failure modes & recovery
| Failure | Behavior |
|---|---|
| Missing/invalid config at startup | `install()` raises -> proxy **fails closed** (won't start mis-auth'd) |
| Token endpoint unreachable / non-2xx / non-JSON / no `access_token` | `OAuth2Error` -> `502`, per-request, proxy stays up, retried next request |
| `expires_in` = 0/negative/absent | clamped to a positive TTL (never stale, never per-request mint) |
| Concurrent first requests | single-flight lock -> exactly one mint per refresh |
| Malformed `HEADROOM_OAUTH2_HEADERS` (CR/LF) | offending pair dropped with a warning (no header injection) |
## Resilience (Docker / native / wrappers / providers / multi-process)
- **Native & Docker:** identical; pure env-driven, std-lib only. Token minted via `urllib` against
the **system cert store**, so a corporate-injected CA is trusted with no bundled roots (works in
SSL-inspection networks).
- **Wrappers (`headroom wrap`, agent hooks):** the extension lives at the proxy layer, so anything
routed through the proxy inherits it transparently.
- **Providers:** effective for OpenAI-compatible / passthrough litellm backends (those that forward
the request bearer upstream). `bedrock`/`vertex`/`sagemaker` authenticate from env and ignore the
bearer -> the extension warns and is a no-op there.
- **Multi-process (multiple workers):** the token cache is per-process; each worker mints/refreshes
independently. Acceptable for client-credentials (idempotent, low rate); no shared state, no
cross-process lock needed. Documented so operators can size token-endpoint rate limits.
## Security & privacy
- Secrets are env-only; **never logged** and **never returned** to the client.
- The IdP error body is **drained, not surfaced** (may echo sensitive context).
- `token_url` is **https-enforced** (loopback exception for tests; explicit opt-out env).
- The minted bearer is sent only to the configured upstream; the client's inbound credential is
replaced, not forwarded onward.
## Observability / logging / telemetry
- `INFO` on install (token_url + auth style, no secrets) and on each mint (`ttl`, scopes).
- `WARNING` on mint failure, env-auth-backend no-op, and dropped malformed static headers.
- No metrics/telemetry emitted; piggybacks on Headroom's existing request logging. (A future
counter for mint/refresh/failure could be added if maintainers want it.)
## Rollback / migration
- **No migration** — additive and opt-in; existing deployments are unaffected.
- **Instant rollback:** drop `--proxy-extension oauth2` (or unset `HEADROOM_PROXY_EXTENSIONS`), or
uninstall the package. No state to clean up, no config format changes.
## Dependencies
- **Runtime:** standard library only (no new core dependency). `litellm` is touched **only** if
`HEADROOM_OAUTH2_HEADERS` is set, and it is already a Headroom backend dependency — declared here
as the optional `[litellm]` extra, not a hard requirement.
+48
View File
@@ -0,0 +1,48 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "headroom-oauth2"
version = "0.1.0"
description = "Generic OAuth2 client-credentials upstream-auth extension for the Headroom proxy"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "Apache-2.0" }
authors = [{ name = "Khalid Shaikh", email = "43288811+mkhalid-s@users.noreply.github.com" }]
keywords = ["headroom", "oauth2", "client-credentials", "proxy", "llm-gateway"]
dependencies = []
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Internet :: Proxy Servers",
"Topic :: Security",
]
[project.optional-dependencies]
# Only needed if you set HEADROOM_OAUTH2_HEADERS (static upstream headers via litellm.headers).
# The token-minting + injection path is standard-library only and needs none of this.
litellm = ["litellm>=1.40"]
dev = ["pytest>=7", "ruff"]
test = ["pytest>=7"]
[project.urls]
Homepage = "https://github.com/chopratejas/headroom"
Repository = "https://github.com/chopratejas/headroom"
Issues = "https://github.com/chopratejas/headroom/issues"
# Registers with Headroom's opt-in proxy-extension seam (headroom/proxy/extensions.py).
# Enable at runtime with `--proxy-extension oauth2` or HEADROOM_PROXY_EXTENSIONS=oauth2.
[project.entry-points."headroom.proxy_extension"]
oauth2 = "headroom_oauth2:install"
[tool.setuptools.packages.find]
where = ["src"]
[tool.ruff]
line-length = 100
@@ -0,0 +1,136 @@
"""Generic OAuth2 client-credentials upstream-auth extension for the Headroom proxy.
Enable: `--proxy-extension oauth2` (or HEADROOM_PROXY_EXTENSIONS=oauth2).
No-op unless HEADROOM_OAUTH2_TOKEN_URL is set. See README for env config.
"""
from __future__ import annotations
import logging
import os
from typing import Any
from .middleware import OAuth2Middleware
from .provider import OAuth2ClientCredentials, OAuth2Error
__all__ = ["install", "OAuth2ClientCredentials", "OAuth2Error", "OAuth2Middleware", "parse_headers"]
__version__ = "0.1.0"
log = logging.getLogger("headroom_oauth2")
def _split(s):
return [x.strip() for x in (s or "").replace(",", " ").split() if x.strip()]
def _ctrl(s):
return any(ord(c) < 32 or ord(c) == 127 for c in s)
def parse_headers(s: str | None) -> dict[str, str]:
"""Parse ``K=V,K2=V2`` into a dict. Drops pairs whose key/value contain control
characters, or whose key contains a space or colon -- prevents HTTP header injection
from a malformed env value."""
out: dict[str, str] = {}
for pair in (s or "").split(","):
if "=" not in pair:
continue
k, v = (x.strip() for x in pair.split("=", 1))
if not k:
continue
if _ctrl(k) or _ctrl(v) or " " in k or ":" in k:
log.warning("headroom-oauth2: dropping malformed static header: %r", k)
continue
out[k] = v
return out
def _int(env, key):
raw = env.get(key)
if raw is None or not str(raw).strip():
return None
try:
return int(raw)
except ValueError:
raise ValueError(f"{key}={raw!r} is not an integer") from None
def provider_from_env(env: dict | None = None) -> OAuth2ClientCredentials | None:
"""Build a provider from ``HEADROOM_OAUTH2_*`` env vars, or None if TOKEN_URL is unset.
Raises ValueError on malformed config so callers can fail closed.
"""
env = os.environ if env is None else env
token_url = env.get("HEADROOM_OAUTH2_TOKEN_URL")
if not token_url:
return None
allow_insecure = env.get("HEADROOM_OAUTH2_ALLOW_INSECURE", "").strip().lower() in (
"1",
"true",
"yes",
)
if allow_insecure:
log.warning("headroom-oauth2: ALLOW_INSECURE set -- token endpoint TLS check disabled")
resource = env.get("HEADROOM_OAUTH2_RESOURCE") # RFC 8707 target service
timeout = _int(env, "HEADROOM_OAUTH2_TIMEOUT")
skew = _int(env, "HEADROOM_OAUTH2_SKEW")
kwargs = {}
if timeout is not None:
kwargs["timeout_seconds"] = float(timeout)
if skew is not None:
kwargs["skew_seconds"] = skew
return OAuth2ClientCredentials(
token_url=token_url,
client_id=env.get("HEADROOM_OAUTH2_CLIENT_ID", ""),
client_secret=env.get("HEADROOM_OAUTH2_CLIENT_SECRET", ""),
scopes=_split(env.get("HEADROOM_OAUTH2_SCOPES")),
audience=env.get("HEADROOM_OAUTH2_AUDIENCE") or None,
grant_type=env.get("HEADROOM_OAUTH2_GRANT_TYPE", "client_credentials"),
auth_style=env.get("HEADROOM_OAUTH2_AUTH_STYLE", "post"),
extra_params={"resource": resource} if resource else None,
allow_insecure=allow_insecure,
**kwargs,
)
def install(app: Any, config: Any) -> None:
"""Headroom proxy-extension entry point: install(app, config) -> None."""
try:
provider = provider_from_env()
except ValueError as e:
raise RuntimeError(f"headroom-oauth2 misconfigured: {e}") from None # fail-closed
if provider is None:
log.info("headroom-oauth2 loaded but HEADROOM_OAUTH2_TOKEN_URL unset; no-op")
return
static = parse_headers(os.environ.get("HEADROOM_OAUTH2_HEADERS"))
if static:
try:
# litellm's import runs load_dotenv and can inject .env values into os.environ;
# snapshot and restore so we never leak unrelated keys into the process env.
_before = dict(os.environ)
import litellm
# drop keys litellm/load_dotenv added, restore any it changed (no empty-env window)
for k in list(os.environ):
if k not in _before:
del os.environ[k]
os.environ.update(_before)
litellm.headers = {**(getattr(litellm, "headers", None) or {}), **static}
log.info("headroom-oauth2: static upstream headers: %s", list(static))
except Exception as e: # pragma: no cover
log.warning("headroom-oauth2: could not set litellm.headers: %s", e)
# The litellm backend auths bedrock/vertex/sagemaker from env and ignores a forwarded
# bearer, so this extension is a no-op there -- warn loudly rather than silently do nothing.
backend = str(getattr(config, "backend", "") or "").lower()
if any(p in backend for p in ("bedrock", "vertex", "sagemaker")):
log.warning(
"headroom-oauth2: backend %r authenticates from env (bedrock/vertex/sagemaker) and "
"ignores the injected bearer -- this extension will have NO effect. Use an "
"OpenAI-compatible / passthrough backend (e.g. --backend litellm-openai).",
backend or "<default>",
)
app.add_middleware(OAuth2Middleware, provider=provider)
log.info(
"headroom-oauth2: client-credentials auth installed (token_url=%s, style=%s)",
provider.token_url,
provider.auth_style,
)
@@ -0,0 +1,61 @@
"""ASGI middleware that injects a refreshed OAuth2 bearer on each upstream request.
Headroom's litellm backend forwards the request's `Authorization` bearer to the
upstream as the API key, so setting it here makes the minted token reach the
backend with no core changes.
"""
from __future__ import annotations
import asyncio
import json
import logging
from .provider import OAuth2Error
log = logging.getLogger("headroom_oauth2")
class OAuth2Middleware:
"""ASGI middleware that replaces the request Authorization with a minted bearer."""
def __init__(self, app, provider):
self.app = app
self.provider = provider
async def __call__(self, scope, receive, send):
if scope.get("type") != "http":
await self.app(scope, receive, send)
return
# Hot path: a cached, still-valid token needs no thread hop. Only mint (blocking
# urllib) off the event loop when the cache is empty/expired.
token = self.provider.cached()
if token is None:
try:
loop = asyncio.get_running_loop()
token = await loop.run_in_executor(None, self.provider.token)
except OAuth2Error as e:
log.warning("oauth2: token mint failed: %s", e)
await self._error(
send, 502, "upstream_auth_error", "could not obtain upstream credentials"
)
return
headers = [(k, v) for (k, v) in scope.get("headers", []) if k.lower() != b"authorization"]
headers.append((b"authorization", b"Bearer " + token.encode()))
await self.app(dict(scope, headers=headers), receive, send)
@staticmethod
async def _error(send, status, etype, message):
body = json.dumps({"type": "error", "error": {"type": etype, "message": message}}).encode()
await send(
{
"type": "http.response.start",
"status": status,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode()),
(b"cache-control", b"no-store"),
],
}
)
await send({"type": "http.response.body", "body": body})
@@ -0,0 +1,150 @@
"""Generic OAuth2 client-credentials token provider (RFC 6749 section 4.4).
Mints a bearer from a configurable token endpoint, caches it, and refreshes
single-flight before expiry. Standard library only; `urllib` uses the system
cert store (works behind corporate SSL inspection). No vendor specifics.
"""
from __future__ import annotations
import base64
import json
import logging
import threading
import time
import urllib.parse
import urllib.request
from urllib.error import HTTPError, URLError
log = logging.getLogger("headroom_oauth2")
def _https_or_local(url: str) -> bool:
parts = urllib.parse.urlsplit(url)
if parts.scheme == "https":
return True
# only numeric loopback -- "localhost" can be repointed via /etc/hosts or DNS rebinding
return parts.scheme == "http" and (parts.hostname or "") in ("127.0.0.1", "::1")
class OAuth2Error(RuntimeError):
"""Raised when a token cannot be minted."""
class OAuth2ClientCredentials:
"""Mints and caches an OAuth2 client-credentials bearer (RFC 6749 section 4.4).
Thread-safe: ``token()`` refreshes single-flight before expiry; ``cached()`` is a
lock-free read for the request hot path.
"""
def __init__(
self,
*,
token_url: str,
client_id: str,
client_secret: str,
scopes=None,
audience: str | None = None,
grant_type: str = "client_credentials",
auth_style: str = "post",
extra_params=None,
skew_seconds: int = 60,
timeout_seconds: float = 30.0,
allow_insecure: bool = False,
):
if not token_url:
raise ValueError("token_url is required")
if not client_id or not client_secret:
raise ValueError("client_id and client_secret are required")
if auth_style not in ("post", "basic"):
raise ValueError("auth_style must be 'post' or 'basic'")
if not allow_insecure and not _https_or_local(token_url):
raise ValueError(
"token_url must be https (loopback http allowed for tests; set "
"allow_insecure=True / HEADROOM_OAUTH2_ALLOW_INSECURE=1 to override)"
)
self.token_url = token_url
self.client_id = client_id
self.client_secret = client_secret
self.scopes = list(scopes or [])
self.audience = audience
self.grant_type = grant_type
self.auth_style = auth_style
self.extra_params = dict(extra_params or {})
self.skew = max(0, int(skew_seconds))
self.timeout = timeout_seconds
self._lock = threading.Lock()
self._token: str | None = None
self._exp = 0.0
self._eff_skew = self.skew
def _valid(self) -> bool:
return self._token is not None and time.monotonic() < self._exp - self._eff_skew
def cached(self) -> str | None:
"""Return the cached token if still valid, else None. No minting -- hot-path read."""
return self._token if self._valid() else None
def token(self) -> str:
"""Return a valid bearer, minting/refreshing single-flight if needed."""
if self._valid():
return self._token # type: ignore[return-value]
with self._lock: # single-flight: one mint per burst
if self._valid():
return self._token # type: ignore[return-value]
token, ttl = self._mint()
# Publish _exp/_eff_skew BEFORE _token so a concurrent cached() reader never
# sees a fresh token paired with a stale expiry.
self._eff_skew = min(self.skew, max(0, ttl // 2))
self._exp = time.monotonic() + ttl
self._token = token
return self._token
def _mint(self):
form = dict(self.extra_params) # caller extras first; canonical fields below always win
form["grant_type"] = self.grant_type
if self.scopes:
form["scope"] = " ".join(self.scopes)
if self.audience:
form["audience"] = self.audience
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
if self.auth_style == "basic":
creds = base64.b64encode(f"{self.client_id}:{self.client_secret}".encode()).decode()
headers["Authorization"] = "Basic " + creds
else:
form["client_id"] = self.client_id
form["client_secret"] = self.client_secret
req = urllib.request.Request(
self.token_url,
data=urllib.parse.urlencode(form).encode(),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
payload = json.load(resp)
except HTTPError as e:
try:
e.read() # drain; do NOT surface the IdP body (may echo sensitive context)
except Exception:
pass
raise OAuth2Error(f"token endpoint returned HTTP {e.code}") from None
except (URLError, OSError) as e:
raise OAuth2Error(f"token endpoint unreachable: {e}") from None
except json.JSONDecodeError:
raise OAuth2Error("token endpoint returned non-JSON") from None
token = payload.get("access_token")
if not token:
raise OAuth2Error("token endpoint response had no access_token")
raw = payload.get("expires_in")
try:
ttl = int(float(raw)) # tolerate "3600", "3600.0", 3600, or a JSON float
except (TypeError, ValueError):
ttl = 300
ttl = max(1, ttl) # 0/negative would cause a stale token or per-request minting
log.info("oauth2: minted token (ttl=%ss, scopes=%s)", ttl, self.scopes or "-")
return token, ttl
@@ -0,0 +1,494 @@
import asyncio
import base64
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from headroom_oauth2 import (
OAuth2ClientCredentials,
OAuth2Error,
OAuth2Middleware,
_split,
install,
parse_headers,
provider_from_env,
)
class _IdP(BaseHTTPRequestHandler):
last_form = None
last_auth = None
status = 200
tok = "TOK-1"
expires_in = 3600
mint_count = 0
slow = False
non_json = False
omit_expires = False
def do_POST(self):
n = int(self.headers.get("content-length", 0) or 0)
_IdP.last_form = self.rfile.read(n).decode()
_IdP.last_auth = self.headers.get("authorization")
if _IdP.status != 200:
self.send_response(_IdP.status)
self.end_headers()
self.wfile.write(b'{"error":"bad","error_description":"SENSITIVE"}')
return
if _IdP.slow:
time.sleep(0.05) # widen the window so concurrent callers contend on the lock
_IdP.mint_count += 1
if _IdP.non_json:
body = b"<html>not json SENSITIVE</html>"
else:
payload = {"access_token": _IdP.tok, "token_type": "Bearer"}
if not _IdP.omit_expires:
payload["expires_in"] = _IdP.expires_in
body = json.dumps(payload).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
@pytest.fixture
def idp():
_IdP.last_form = _IdP.last_auth = None
_IdP.status = 200
_IdP.tok = "TOK-1"
_IdP.expires_in = 3600
_IdP.mint_count = 0
_IdP.slow = False
_IdP.non_json = False
_IdP.omit_expires = False
srv = HTTPServer(("127.0.0.1", 0), _IdP)
threading.Thread(target=srv.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{srv.server_address[1]}/token"
srv.shutdown()
# --- minimal ASGI test doubles -------------------------------------------------
class _RecordingApp:
def __init__(self):
self.called = False
self.scope = None
async def __call__(self, scope, receive, send):
self.called = True
self.scope = scope
async def _recv():
return {"type": "http.request"}
async def _ignore(_msg):
pass
def _cfg(backend):
return type("Cfg", (), {"backend": backend})()
# --- provider: token minting ---------------------------------------------------
def test_post_style_mint(idp):
p = OAuth2ClientCredentials(
token_url=idp, client_id="cid", client_secret="csec", scopes=["a", "b"], audience="aud"
)
assert p.token() == "TOK-1"
assert "grant_type=client_credentials" in _IdP.last_form
assert "scope=a+b" in _IdP.last_form
assert "client_id=cid" in _IdP.last_form
assert "audience=aud" in _IdP.last_form
assert _IdP.last_auth is None
def test_basic_style_mint(idp):
p = OAuth2ClientCredentials(
token_url=idp, client_id="cid", client_secret="csec", auth_style="basic"
)
p.token()
assert _IdP.last_auth == "Basic " + base64.b64encode(b"cid:csec").decode()
assert "client_secret" not in _IdP.last_form
def test_cache_and_refresh(idp):
p = OAuth2ClientCredentials(token_url=idp, client_id="c", client_secret="s")
assert p.token() == "TOK-1"
_IdP.tok = "TOK-2"
_IdP.last_form = None
assert p.token() == "TOK-1" # cached -> no re-mint
assert _IdP.last_form is None
p._exp = time.monotonic() - 1 # force expiry
assert p.token() == "TOK-2" # re-minted
def test_cached_fast_path(idp):
p = OAuth2ClientCredentials(token_url=idp, client_id="c", client_secret="s")
assert p.cached() is None # nothing minted yet -> middleware will mint off-loop
p.token()
assert p.cached() == "TOK-1" # now served without a token endpoint round-trip
p._exp = time.monotonic() - 1
assert p.cached() is None # expired -> forces a refresh
def test_concurrent_single_flight(idp):
p = OAuth2ClientCredentials(token_url=idp, client_id="c", client_secret="s")
_IdP.slow = True
out = []
threads = [threading.Thread(target=lambda: out.append(p.token())) for _ in range(12)]
for t in threads:
t.start()
for t in threads:
t.join()
assert out == ["TOK-1"] * 12
assert _IdP.mint_count == 1 # 12 concurrent callers -> exactly one mint
# --- provider: failure modes ---------------------------------------------------
def test_error_on_bad_status_hides_body(idp):
_IdP.status = 401
p = OAuth2ClientCredentials(token_url=idp, client_id="c", client_secret="s")
with pytest.raises(OAuth2Error) as ei:
p.token()
assert "SENSITIVE" not in str(ei.value) # IdP error body must not leak into the exception
def test_malformed_200_no_token(idp):
_IdP.tok = None # HTTP 200 but no access_token field
p = OAuth2ClientCredentials(token_url=idp, client_id="c", client_secret="s")
with pytest.raises(OAuth2Error):
p.token()
def test_unreachable_token_url():
p = OAuth2ClientCredentials(
token_url="http://127.0.0.1:1/token", client_id="c", client_secret="s", timeout_seconds=1
)
with pytest.raises(OAuth2Error):
p.token()
def test_validation():
with pytest.raises(ValueError):
OAuth2ClientCredentials(token_url="", client_id="c", client_secret="s")
with pytest.raises(ValueError):
OAuth2ClientCredentials(token_url="u", client_id="", client_secret="s")
with pytest.raises(ValueError):
OAuth2ClientCredentials(token_url="u", client_id="c", client_secret="s", auth_style="x")
def test_https_enforced():
with pytest.raises(ValueError):
OAuth2ClientCredentials(
token_url="http://example.com/token", client_id="c", client_secret="s"
)
# loopback http allowed for local testing
OAuth2ClientCredentials(token_url="http://127.0.0.1:1/token", client_id="c", client_secret="s")
# explicit opt-out
OAuth2ClientCredentials(
token_url="http://example.com/token", client_id="c", client_secret="s", allow_insecure=True
)
def test_expires_in_clamp(idp):
_IdP.expires_in = 0 # immediate-expiry -> must clamp to a positive ttl (not stale)
p = OAuth2ClientCredentials(token_url=idp, client_id="c", client_secret="s")
assert p.token() == "TOK-1"
assert p._exp > time.monotonic()
_IdP.expires_in = -10 # negative -> must clamp (not perpetual re-mint)
p2 = OAuth2ClientCredentials(token_url=idp, client_id="c", client_secret="s")
assert p2.token() == "TOK-1"
assert p2._exp > time.monotonic()
# --- helpers / config ----------------------------------------------------------
def test_helpers():
assert _split("a, b c") == ["a", "b", "c"]
assert parse_headers("X=1,Y=2") == {"X": "1", "Y": "2"}
def test_parse_headers_rejects_control_chars():
assert parse_headers("Good=ok,Bad=line\r\ninject") == {"Good": "ok"} # CRLF value dropped
assert parse_headers("=novalue,K=v") == {"K": "v"} # empty key dropped
assert parse_headers("") == {}
def test_provider_from_env_wires_knobs(idp):
env = {
"HEADROOM_OAUTH2_TOKEN_URL": idp,
"HEADROOM_OAUTH2_CLIENT_ID": "c",
"HEADROOM_OAUTH2_CLIENT_SECRET": "s",
"HEADROOM_OAUTH2_RESOURCE": "https://api.example",
"HEADROOM_OAUTH2_TIMEOUT": "5",
"HEADROOM_OAUTH2_SKEW": "10",
}
p = provider_from_env(env)
assert p.extra_params == {"resource": "https://api.example"}
assert p.timeout == 5.0
assert p.skew == 10
p.token()
assert "resource=https" in _IdP.last_form
def test_provider_from_env_none_when_unset():
assert provider_from_env({}) is None
# --- middleware (ASGI behavior) ------------------------------------------------
def test_middleware_injects_bearer(idp):
p = OAuth2ClientCredentials(token_url=idp, client_id="c", client_secret="s")
app = _RecordingApp()
mw = OAuth2Middleware(app, p)
scope = {"type": "http", "headers": [(b"authorization", b"Bearer CLIENT"), (b"x-keep", b"1")]}
asyncio.run(mw(scope, _recv, _ignore))
hdrs = dict(app.scope["headers"])
assert hdrs[b"authorization"] == b"Bearer TOK-1" # client creds replaced by minted token
assert hdrs[b"x-keep"] == b"1" # other headers preserved
def test_middleware_non_http_passthrough():
app = _RecordingApp()
mw = OAuth2Middleware(app, provider=object()) # provider must never be touched
scope = {"type": "lifespan"}
asyncio.run(mw(scope, _recv, _ignore))
assert app.called and app.scope is scope
def test_middleware_502_on_mint_failure():
class _Bad:
def cached(self):
return None
def token(self):
raise OAuth2Error("nope")
app = _RecordingApp()
mw = OAuth2Middleware(app, _Bad())
sent = []
async def send(msg):
sent.append(msg)
asyncio.run(mw({"type": "http", "headers": []}, _recv, send))
assert not app.called # request must not reach upstream without credentials
assert sent[0]["status"] == 502
assert json.loads(sent[1]["body"])["error"]["type"] == "upstream_auth_error"
# --- install() (entry point) ---------------------------------------------------
def test_install_noop_when_unset(monkeypatch):
monkeypatch.delenv("HEADROOM_OAUTH2_TOKEN_URL", raising=False)
class App:
def add_middleware(self, *a, **k):
raise AssertionError("must not install middleware when unconfigured")
install(App(), _cfg("litellm-openai")) # no raise, no add_middleware
def test_install_fail_closed_on_bad_config(monkeypatch):
monkeypatch.setenv("HEADROOM_OAUTH2_TOKEN_URL", "https://idp.example.com/token")
monkeypatch.setenv("HEADROOM_OAUTH2_CLIENT_ID", "") # missing -> ValueError -> RuntimeError
with pytest.raises(RuntimeError):
install(object(), _cfg("litellm-openai"))
def test_install_warns_for_envauth_backend(monkeypatch, caplog):
monkeypatch.setenv("HEADROOM_OAUTH2_TOKEN_URL", "https://idp.example.com/token")
monkeypatch.setenv("HEADROOM_OAUTH2_CLIENT_ID", "c")
monkeypatch.setenv("HEADROOM_OAUTH2_CLIENT_SECRET", "s")
monkeypatch.delenv("HEADROOM_OAUTH2_HEADERS", raising=False)
installed = []
class App:
def add_middleware(self, *a, **k):
installed.append(True)
with caplog.at_level("WARNING"):
install(App(), _cfg("bedrock"))
assert installed # still installs
assert "NO effect" in caplog.text # but warns the bearer is ignored by env-auth backends
def test_install_fail_closed_on_bad_timeout(monkeypatch):
monkeypatch.setenv("HEADROOM_OAUTH2_TOKEN_URL", "https://idp.example.com/token")
monkeypatch.setenv("HEADROOM_OAUTH2_CLIENT_ID", "c")
monkeypatch.setenv("HEADROOM_OAUTH2_CLIENT_SECRET", "s")
monkeypatch.setenv("HEADROOM_OAUTH2_TIMEOUT", "not-a-number") # invalid -> fail closed
with pytest.raises(RuntimeError):
install(object(), _cfg("litellm-openai"))
def test_parse_headers_rejects_bad_keys():
assert parse_headers("Bad Key=v,Ok=1") == {"Ok": "1"} # space in key dropped
assert parse_headers("X:Y=v,Ok=1") == {"Ok": "1"} # colon in key dropped
def test_expires_in_float(idp):
_IdP.expires_in = 3599.9 # some IdPs return a JSON float -> must not fall back to 300
p = OAuth2ClientCredentials(token_url=idp, client_id="c", client_secret="s")
assert p.token() == "TOK-1"
assert p._exp - time.monotonic() > 1000 # ~3599, not the 300 fallback
def test_middleware_handles_missing_headers_key(idp):
p = OAuth2ClientCredentials(token_url=idp, client_id="c", client_secret="s")
app = _RecordingApp()
mw = OAuth2Middleware(app, p)
asyncio.run(mw({"type": "http"}, _recv, _ignore)) # scope without a "headers" key
assert dict(app.scope["headers"])[b"authorization"] == b"Bearer TOK-1"
def test_middleware_502_sets_no_store():
class _Bad:
def cached(self):
return None
def token(self):
raise OAuth2Error("nope")
app = _RecordingApp()
mw = OAuth2Middleware(app, _Bad())
sent = []
async def send(msg):
sent.append(msg)
asyncio.run(mw({"type": "http", "headers": []}, _recv, send))
hdrs = dict(sent[0]["headers"])
assert hdrs[b"cache-control"] == b"no-store" # a fronting cache must not pin the 502
# --- TLS / loopback edge cases -------------------------------------------------
def test_localhost_rejected():
# "localhost" is a name (DNS-rebinding / /etc/hosts risk) -> not a loopback exception
with pytest.raises(ValueError):
OAuth2ClientCredentials(
token_url="http://localhost/token", client_id="c", client_secret="s"
)
def test_ipv6_loopback_allowed():
OAuth2ClientCredentials(token_url="http://[::1]:1/token", client_id="c", client_secret="s")
def test_allow_insecure_env_permits_nonloopback_http():
p = provider_from_env(
{
"HEADROOM_OAUTH2_TOKEN_URL": "http://example.com/token",
"HEADROOM_OAUTH2_CLIENT_ID": "c",
"HEADROOM_OAUTH2_CLIENT_SECRET": "s",
"HEADROOM_OAUTH2_ALLOW_INSECURE": "1",
}
)
assert p.token_url == "http://example.com/token"
# --- token-request form edge cases ---------------------------------------------
def test_extra_params_cannot_override_canonical(idp):
p = OAuth2ClientCredentials(
token_url=idp,
client_id="cid",
client_secret="csec",
scopes=["a", "b"],
extra_params={"grant_type": "evil", "client_id": "evil", "scope": "evil", "resource": "r"},
)
p.token()
assert "grant_type=client_credentials" in _IdP.last_form
assert "client_id=cid" in _IdP.last_form
assert "scope=a+b" in _IdP.last_form
assert "resource=r" in _IdP.last_form # a benign extra still passes through
assert "evil" not in _IdP.last_form # caller extras never clobber canonical fields
def test_auth_style_basic_via_env(idp):
p = provider_from_env(
{
"HEADROOM_OAUTH2_TOKEN_URL": idp,
"HEADROOM_OAUTH2_CLIENT_ID": "cid",
"HEADROOM_OAUTH2_CLIENT_SECRET": "csec",
"HEADROOM_OAUTH2_AUTH_STYLE": "basic",
}
)
assert p.auth_style == "basic"
p.token()
assert _IdP.last_auth == "Basic " + base64.b64encode(b"cid:csec").decode()
def test_scopes_comma_separated_via_env(idp):
p = provider_from_env(
{
"HEADROOM_OAUTH2_TOKEN_URL": idp,
"HEADROOM_OAUTH2_CLIENT_ID": "c",
"HEADROOM_OAUTH2_CLIENT_SECRET": "s",
"HEADROOM_OAUTH2_SCOPES": "a, b ,c",
}
)
assert p.scopes == ["a", "b", "c"]
# --- expires_in edge cases -----------------------------------------------------
def test_expires_in_missing_falls_back(idp):
_IdP.omit_expires = True # no expires_in field -> default ttl, not stale
p = OAuth2ClientCredentials(token_url=idp, client_id="c", client_secret="s")
assert p.token() == "TOK-1"
assert 100 < p._exp - time.monotonic() <= 300
def test_expires_in_non_numeric_falls_back(idp):
_IdP.expires_in = "not-a-number" # garbage -> default ttl, no crash
p = OAuth2ClientCredentials(token_url=idp, client_id="c", client_secret="s")
assert p.token() == "TOK-1"
assert 100 < p._exp - time.monotonic() <= 300
# --- response-shape failure modes ----------------------------------------------
def test_non_json_200_raises_without_leak(idp):
_IdP.non_json = True # HTTP 200 but body is not JSON
p = OAuth2ClientCredentials(token_url=idp, client_id="c", client_secret="s")
with pytest.raises(OAuth2Error) as ei:
p.token()
assert "SENSITIVE" not in str(ei.value) # body must not leak into the exception
def test_single_flight_on_refresh(idp):
p = OAuth2ClientCredentials(token_url=idp, client_id="c", client_secret="s")
p.token() # cold mint #1
assert _IdP.mint_count == 1
p._exp = time.monotonic() - 1 # force expiry
_IdP.slow = True
threads = [threading.Thread(target=p.token) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert _IdP.mint_count == 2 # exactly one refresh despite 8 concurrent expired callers
def test_install_sets_static_headers(monkeypatch):
import sys
import types
fake = types.ModuleType("litellm") # avoid importing the real (heavy) litellm
fake.headers = {}
monkeypatch.setitem(sys.modules, "litellm", fake)
monkeypatch.setenv("HEADROOM_OAUTH2_TOKEN_URL", "https://idp.example.com/token")
monkeypatch.setenv("HEADROOM_OAUTH2_CLIENT_ID", "c")
monkeypatch.setenv("HEADROOM_OAUTH2_CLIENT_SECRET", "s")
monkeypatch.setenv("HEADROOM_OAUTH2_HEADERS", "X-App=demo,Bad Key=x")
class App:
def add_middleware(self, *a, **k):
pass
install(App(), _cfg("litellm-openai"))
assert fake.headers == {"X-App": "demo"} # valid header set on litellm; malformed key dropped
+66
View File
@@ -0,0 +1,66 @@
# Hermes Agent Integration
CCR retrieval plugin for [Hermes Agent](https://hermes-agent.nousresearch.com/) (Nous Research). Gives Hermes a native `headroom_retrieve` tool so compression markers produced by the headroom proxy are no longer a black box — the agent can fetch the original content back on demand instead of guessing or re-running commands.
## Why this is needed
When Hermes routes its LLM traffic through `headroom proxy`, large tool outputs get compressed into markers like:
```
[1500 items compressed to 50. Retrieve more: hash=abc123] # Kompress path
<<ccr:abc123>> / <<ccr:abc123,base64,4.5KB>> # SmartCrusher opaque-blob path
```
Claude Code users get the `headroom_retrieve` MCP tool injected automatically. Hermes registers its own tools, so without this plugin the markers are irreversible from the agent's point of view — in practice the model either re-runs the original command (wasting tokens/time) or, worse, treats `ccr:abc123` as a file path and tries to `cat` it.
This plugin closes the loop by calling the proxy's `POST /v1/retrieve` HTTP endpoint directly. It complements (does not overlap with) `headroom wrap hermes` proxy-side support.
## Install
1. Copy the plugin into Hermes's user plugin directory:
```bash
mkdir -p ~/.hermes/plugins
cp -r headroom_retrieve ~/.hermes/plugins/
```
2. Enable it in `~/.hermes/config.yaml`:
```yaml
toolsets:
- hermes-cli
- web
- headroom # add this
plugins:
enabled:
- headroom_retrieve
```
> Note: once the `plugins.enabled` key exists it acts as an explicit allowlist — list any other user plugins you already rely on.
3. Restart the Hermes gateway / TUI (plugin discovery is cached per process).
## Recommended proxy configuration
Hermes tool names don't match headroom's built-in `DEFAULT_EXCLUDE_TOOLS` (which protects Claude Code's `Read`/`Grep`/`Edit`/...), so two exclusions are strongly recommended on the proxy side:
```bash
HEADROOM_EXCLUDE_TOOLS=read_file,headroom_retrieve
```
- `read_file` — Hermes's file reads are reference data the agent needs verbatim, same rationale as Claude Code's `Read`.
- `headroom_retrieve` — without this, retrieved originals get re-compressed on the next request, producing an endless marker→retrieve→marker loop.
## Behavior
- Accepts the bare hash or the whole marker — `<<ccr:abc123,base64,4.5KB>>`, `ccr:abc123`, and `hash=abc123` are all normalized to `abc123`.
- Retrieval is by hash and always returns the full original content.
- Clear, actionable errors: expired hash (TTL) and proxy-unreachable cases both tell the model to re-run the original command instead of retrying blindly.
## Requirements
- headroom proxy running on `127.0.0.1:8787` (edit `_PROXY_URL` in `__init__.py` otherwise)
- `httpx` (already a Hermes dependency)
Tested against headroom 0.22.4 and 0.23.0 with Hermes Agent on macOS and Linux.
@@ -0,0 +1,94 @@
"""Headroom CCR retrieve plugin.
The headroom proxy (127.0.0.1:8787) compresses large tool outputs in LLM
requests, replacing them with markers like ``[N items compressed ...
hash=abc123]`` or ``<<ccr:abc123>>``. This plugin gives Hermes a tool to fetch the original
uncompressed content back from the proxy's compression store, so compressed
markers are no longer a black box.
Storage is in-memory on the proxy side with a TTL — expired or
post-proxy-restart hashes return 404 and the tool reports that clearly.
"""
from __future__ import annotations
import httpx
from tools.registry import tool_error, tool_result
_PROXY_URL = "http://127.0.0.1:8787"
HEADROOM_RETRIEVE_SCHEMA = {
"name": "headroom_retrieve",
"description": (
"Retrieve the original uncompressed content behind a headroom "
"compression marker. Markers look like "
"'[N items compressed ... hash=abc123]' OR '<<ccr:abc123>>' OR "
"'<<ccr:abc123,base64,4.5KB>>'. They are NOT file paths — never try "
"to cat/read them. When you see one in a tool result or in "
"conversation history, call this tool with the hash (the hex string "
"after 'hash=' or 'ccr:') to read the full original content instead "
"of guessing or re-running the command. Retrieval is by hash and "
"always returns the complete original content. Content expires after "
"a TTL — if expired, re-run the original command instead."
),
"parameters": {
"type": "object",
"properties": {
"hash": {
"type": "string",
"description": "Hash from the compression marker, e.g. 'abc123' from '[... hash=abc123]' or '<<ccr:abc123>>'",
},
},
"required": ["hash"],
},
}
def _handle_headroom_retrieve(args: dict, **kw) -> str:
hash_key = str(args.get("hash") or "").strip()
# Tolerate the model passing the whole marker instead of the bare hash:
# '<<ccr:abc123,base64,4.5KB>>' / 'ccr:abc123' / 'hash=abc123' -> 'abc123'
hash_key = hash_key.strip("<>").removeprefix("ccr:").removeprefix("hash=")
hash_key = hash_key.split(",")[0].strip()
if not hash_key:
return tool_error(
"hash is required (from a '[... hash=abc123]' or '<<ccr:abc123>>' marker)"
)
payload: dict = {"hash": hash_key}
try:
resp = httpx.post(f"{_PROXY_URL}/v1/retrieve", json=payload, timeout=15)
except httpx.HTTPError as exc:
return tool_error(
f"headroom proxy unreachable at {_PROXY_URL} ({type(exc).__name__}). "
"The proxy may be down; re-run the original command to get the data."
)
if resp.status_code == 404:
return tool_error(
"Content not found: expired (TTL passed) or proxy restarted. "
"Re-run the original command to regenerate the data."
)
if resp.status_code != 200:
return tool_error(f"headroom proxy returned HTTP {resp.status_code}: {resp.text[:200]}")
data = resp.json()
return tool_result(
{
"original_content": data.get("original_content", ""),
"original_tokens": data.get("original_tokens"),
"tool_name": data.get("tool_name"),
}
)
def register(ctx) -> None:
"""Register the headroom_retrieve tool. Called by the plugin loader."""
ctx.register_tool(
name="headroom_retrieve",
toolset="headroom",
schema=HEADROOM_RETRIEVE_SCHEMA,
handler=_handle_headroom_retrieve,
emoji="🗜️",
)
@@ -0,0 +1,6 @@
name: headroom_retrieve
version: 1.0.0
description: "Retrieve original content compressed by the headroom proxy (CCR markers)"
author: akb4q
provides_tools:
- headroom_retrieve
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.env
+2
View File
@@ -0,0 +1,2 @@
node_modules/
.env
+222
View File
@@ -0,0 +1,222 @@
# @headroom-ai/openclaw
Context compression plugin for [OpenClaw](https://github.com/openclaw/openclaw). Compresses tool outputs, code, logs, and structured data — 70-90% token savings with zero LLM calls.
## Install
Recommended one-command setup:
```bash
headroom wrap openclaw
```
Manual install:
```bash
pip install "headroom-ai[proxy]"
openclaw plugins install --dangerously-force-unsafe-install headroom-ai/openclaw
```
This plugin can auto-start a local `headroom proxy` when needed. OpenClaw treats process-launching plugins as unsafe by default, so `--dangerously-force-unsafe-install` is required even if you plan to use a remote proxy (the capability is declared at install time).
## Local Development Install (Detection-Friendly)
If you are testing from this repo, run npm install/build from the plugin directory so local launcher detection aligns with runtime paths. These linked installs are supported:
```bash
cd plugins/openclaw
npm install
npm run build
openclaw plugins install --dangerously-force-unsafe-install --link .
openclaw plugins install --dangerously-force-unsafe-install --link dist
```
From the repo root, install the plugin directory explicitly:
```bash
openclaw plugins install --dangerously-force-unsafe-install --link ./plugins/openclaw
```
Or, from inside `dist/`:
```bash
cd plugins/openclaw/dist
openclaw plugins install --dangerously-force-unsafe-install --link .
```
Why this matters:
- The plugin checks launchers in this order: PATH -> local npm bin -> global npm -> python.
- "local npm bin" means `plugins/openclaw/node_modules/.bin/headroom` relative to the source checkout.
- Using `--link dist` (or `--link .` from `dist/`) still keeps runtime code adjacent to the checkout, and launcher detection falls back to PATH/global/python if a local npm bin is not present under the installed root.
- `plugins/openclaw` also carries a no-op hook shim so OpenClaw's hook-pack fallback treats the path as valid instead of emitting a misleading `package.json missing openclaw.hooks` warning.
- If you install from a `.tgz`, local npm bin may not exist in the installed extension and detection will fall back to PATH/global/python.
## Configure
Install automatically selects the `contextEngine` slot for `headroom` on current OpenClaw releases. If you need to switch back manually, set `plugins.slots.contextEngine` to `"legacy"` or another engine id.
```json
{
"plugins": {
"entries": {
"headroom": {
"enabled": true,
"config": {
"proxyUrl": "http://127.0.0.1:8787"
}
}
},
"slots": {
"contextEngine": "headroom"
}
}
}
```
`proxyUrl` is optional. If omitted, the plugin auto-detects on localhost:
- `http://127.0.0.1:<proxyPort>`
- `http://localhost:<proxyPort>`
Default `proxyPort` is `8787`. Auto-start is opt-in; in production, prefer an externally
managed proxy such as systemd with `proxyUrl` set and `autoStart: false`.
### Upstream gateway routing
By default, the plugin also rewrites the built-in `openai-codex` provider base URL to a verified active Headroom proxy at runtime. That means Codex provider traffic flows through Headroom, so `/stats` can observe real upstream request and cache activity instead of only local context compression.
This does not replace Headroom's existing Codex routing rules. The proxy already decides between `api.openai.com` and `chatgpt.com/backend-api/codex/responses` based on ChatGPT auth. The plugin change only points OpenClaw's provider config at the active proxy in memory and preserves the rest of the provider config.
You can also route additional provider ids such as `anthropic`, `github-copilot`, `google`, or `openrouter` through the same proxy:
```json
{
"plugins": {
"entries": {
"headroom": {
"enabled": true,
"config": {
"gatewayProviderIds": ["openai-codex", "anthropic", "github-copilot", "google", "openrouter"]
}
}
}
}
}
```
When `gatewayProviderIds` is set, it becomes the exact list the plugin rewrites in memory for the current gateway process.
For convenience, the plugin also accepts family aliases:
- `codex` -> `openai-codex`
- `claude` -> `anthropic`
- `copilot` -> `github-copilot`
- `gemini` -> `google`
When OpenClaw has already resolved a provider's upstream `baseUrl`, the plugin preserves protocol-specific path segments while swapping only the origin. That keeps provider families on the right proxy route:
- Codex / ChatGPT backend: `/backend-api`
- OpenAI-compatible providers: `/v1` or `/api/v1`
- GitHub Copilot Claude-family models: `/anthropic`
- Gemini: `/v1beta`
GitHub Copilot is a special case because OpenClaw can route it through either OpenAI Responses or Anthropic Messages depending on the selected model. The plugin only rewrites Copilot when OpenClaw has already resolved the upstream `baseUrl`, so it can preserve the correct `/v1` or `/anthropic` path instead of guessing.
The routing is intentionally lightweight and reversible:
- the plugin does not persist provider `baseUrl` changes back to `openclaw.json`
- disabling the plugin, clearing `gatewayProviderIds`, or restarting without Headroom restores OpenClaw's normal provider resolution
- if you want durable provider rewrites, use `headroom wrap openclaw` instead of relying on plugin install side effects
If you need to disable that behavior:
```json
{
"plugins": {
"entries": {
"headroom": {
"enabled": true,
"config": {
"routeCodexViaProxy": false
}
}
}
}
}
```
### Local proxy (auto-start)
When `proxyUrl` points to localhost (or is omitted), the plugin will auto-start `headroom proxy` if no running proxy is detected. Launch order:
1. `headroom` from `PATH`
2. local npm bin (`node_modules/.bin/headroom`)
3. global npm bin
4. Python module (`python -m headroom.cli proxy ...`)
If `pythonPath` is set, it is tried first in the Python fallback step.
Docker-native Headroom installs intentionally leave `pythonPath` unset so this launcher order prefers the installed host `headroom` wrapper on `PATH`, which then runs Headroom in Docker.
### Remote proxy (connect-only)
Point `proxyUrl` to any reachable Headroom instance:
```json
{
"config": {
"proxyUrl": "https://headroom.example.com:8787"
}
}
```
Remote URLs are **connect-only** — the plugin probes the URL at startup and fails fast if the proxy is not reachable. No subprocess is spawned for remote addresses.
## Manual Proxy Setup
If you prefer to manage the proxy yourself (or are running a remote instance), start it before launching OpenClaw:
Python install:
```bash
pip install "headroom-ai[proxy]"
headroom proxy --host 127.0.0.1 --port 8787
```
NPM install:
```bash
npm install -g headroom-ai
headroom proxy --host 127.0.0.1 --port 8787
```
## How It Works
Every time OpenClaw assembles context for the model, the plugin compresses tool outputs and large messages:
- **JSON arrays** (tool outputs, search results) — statistical selection keeps anomalies, errors, boundaries
- **Code** — AST-aware compression via tree-sitter
- **Logs** — pattern deduplication, keeps errors and boundaries
- **Text** — ML-based token compression
Compression is lossless via CCR (Compress-Cache-Retrieve): originals are stored and the agent gets a `headroom_retrieve` tool to access full details when needed.
## Configuration Options
| Option | Default | Description |
|--------|---------|-------------|
| `proxyUrl` | auto-detected | Optional URL of a Headroom proxy. Configured URLs are probe-gated before provider routing. Remote URLs (`https://headroom.example.com`) are connect-only. |
| `proxyPort` | `8787` | Port used for default auto-detect and optional local auto-start when `proxyUrl` is not set. |
| `pythonPath` | auto-detected | Optional Python executable override for Python fallback launcher. |
| `autoStart` | `false` | Opt-in auto-start for a local `headroom proxy` if not already running (local URLs only; ignored for remote proxies). Keep `false` when systemd owns the proxy. |
| `startupTimeoutMs` | `20000` | Time to wait for auto-started proxy to become healthy |
| `routeCodexViaProxy` | `true` | Rewrite OpenClaw's built-in `openai-codex` provider to use the active Headroom proxy in memory so upstream Codex requests pass through Headroom. |
| `gatewayProviderIds` | `[]` | Optional explicit list of OpenClaw provider ids to route through the active Headroom proxy in memory. Friendly aliases `codex`, `claude`, `copilot`, and `gemini` are also accepted. When set, this overrides the default `openai-codex` routing list. |
## Comparison with lossless-claw
| | lossless-claw | headroom |
|---|---|---|
| Compaction method | LLM summarization (DAG) | Content-aware compression (zero LLM) |
| Cost of compaction | Tokens (LLM calls) | Zero |
| Best for | Long conversations | Tool-heavy agents with large outputs |
| Retrieval | `lcm_grep`, `lcm_expand` | `headroom_retrieve` (instant) |
## License
Apache-2.0
+21
View File
@@ -0,0 +1,21 @@
---
name: headroom-link-shim
description: "No-op hook shim so local plugin source paths are also valid OpenClaw hook-pack paths."
metadata:
{
"openclaw":
{
"emoji": "🪝",
"events": ["command"],
},
}
---
# Headroom Link Shim
This hook intentionally does nothing.
OpenClaw currently falls back to validating local plugin paths as hook packs when a
plugin install cannot proceed, such as when the plugin is already installed.
Including this no-op hook keeps `--link` installs from reporting a misleading
`package.json missing openclaw.hooks` error for valid Headroom plugin paths.
+3
View File
@@ -0,0 +1,3 @@
export default async function headroomLinkShim() {
return undefined;
}
+98
View File
@@ -0,0 +1,98 @@
{
"id": "headroom",
"kind": "context-engine",
"uiHints": {
"proxyUrl": {
"label": "Proxy URL",
"help": "Optional. URL Headroom proxy (example: http://127.0.0.1:8787 or https://headroom.example.com). Configured URLs probe-gated before provider routing. Auto-start opt-in only works local addresses."
},
"proxyPort": {
"label": "Proxy Port",
"help": "Default port used for auto-detect/auto-start when proxyUrl is not set (default: 8787)."
},
"pythonPath": {
"label": "Python Path",
"help": "Optional explicit python executable for python fallback launcher (for example: python, python3, py, or full path)."
},
"retryMaxAttempts": {
"label": "Retry Max Attempts",
"help": "Optional maximum number of upstream retry attempts for connection/read/5xx failures when the plugin auto-starts a local Headroom proxy. Lower values fail faster for interactive chat."
},
"connectTimeoutSeconds": {
"label": "Connect Timeout Seconds",
"help": "Optional upstream connection timeout for the auto-started local Headroom proxy. Lower values surface network failures sooner."
},
"routeCodexViaProxy": {
"label": "Route OpenAI Codex Via Headroom",
"help": "When enabled, OpenClaw will use the active Headroom proxy as the in-memory upstream base URL for the built-in openai-codex provider so provider traffic flows through Headroom."
},
"gatewayProviderIds": {
"label": "Gateway Provider IDs",
"help": "Optional list of OpenClaw provider ids to route through the active Headroom proxy in memory. Friendly aliases codex, claude, copilot, and gemini are also accepted. When set, this overrides the default openai-codex-only routing."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"proxyUrl": {
"type": "string",
"pattern": "^https?:\\/\\/.+(:\\d+)?\\/?$"
},
"proxyPort": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 8787
},
"pythonPath": {
"type": "string"
},
"autoStart": {
"type": "boolean",
"default": false
},
"startupTimeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 120000,
"default": 20000
},
"retryMaxAttempts": {
"type": "integer",
"minimum": 1
},
"connectTimeoutSeconds": {
"type": "integer",
"minimum": 1
},
"routeCodexViaProxy": {
"type": "boolean",
"default": true
},
"gatewayProviderIds": {
"type": "array",
"items": {
"type": "string"
},
"default": []
}
}
},
"capabilities": {
"network": {
"allow": [
"http://*:*",
"https://*:*"
]
}
},
"contracts": {
"tools": [
"headroom_retrieve"
]
}
}
+2707
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
{
"name": "headroom-openclaw",
"version": "0.31.0",
"description": "Headroom context compression plugin for OpenClaw — 70-90% token savings with zero LLM calls",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist",
"hook-shim",
"openclaw.plugin.json",
"README.md"
],
"scripts": {
"build": "tsup && node prepare-dist.mjs",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"headroom-ai": "^0.31.0"
},
"peerDependencies": {
"openclaw": "*"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"devDependencies": {
"@types/node": "^26.1.1",
"tsup": "^8.0.0",
"typescript": "^5.5.0",
"vitest": "^4.1.5"
},
"overrides": {
"esbuild": "^0.28.1"
},
"openclaw": {
"hooks": [
"./hook-shim"
],
"extensions": [
"./dist/index.js"
],
"capabilities": {
"network": {
"allow": [
"http://*:*",
"https://*:*"
]
}
}
},
"license": "Apache-2.0"
}
+56
View File
@@ -0,0 +1,56 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = __dirname;
const distDir = path.join(rootDir, "dist");
const rootPackage = JSON.parse(
await fs.readFile(path.join(rootDir, "package.json"), "utf8"),
);
const distPackage = {
name: rootPackage.name,
version: rootPackage.version,
description: rootPackage.description,
type: rootPackage.type,
main: "./index.js",
types: "./index.d.ts",
license: rootPackage.license,
dependencies: rootPackage.dependencies,
peerDependencies: rootPackage.peerDependencies,
peerDependenciesMeta: rootPackage.peerDependenciesMeta,
openclaw: {
hooks: ["./hook-shim"],
extensions: ["./index.js"],
capabilities: rootPackage.openclaw?.capabilities ?? {},
},
};
await fs.mkdir(distDir, { recursive: true });
await fs.writeFile(
path.join(distDir, "package.json"),
`${JSON.stringify(distPackage, null, 2)}\n`,
"utf8",
);
await Promise.all([
fs.copyFile(
path.join(rootDir, "openclaw.plugin.json"),
path.join(distDir, "openclaw.plugin.json"),
),
fs.copyFile(path.join(rootDir, "README.md"), path.join(distDir, "README.md")),
fs.mkdir(path.join(distDir, "hook-shim"), { recursive: true }),
]);
await Promise.all([
fs.copyFile(
path.join(rootDir, "hook-shim", "HOOK.md"),
path.join(distDir, "hook-shim", "HOOK.md"),
),
fs.copyFile(
path.join(rootDir, "hook-shim", "handler.js"),
path.join(distDir, "hook-shim", "handler.js"),
),
]);
+418
View File
@@ -0,0 +1,418 @@
/**
* Convert between OpenClaw's AgentMessage format and OpenAI message format.
*
* AgentMessage uses:
* role: "user" | "assistant" | "toolResult"
* content: string | ContentBlock[]
*
* OpenAI uses:
* role: "user" | "assistant" | "system" | "tool"
* content: string
* tool_calls?: ToolCall[]
* tool_call_id?: string
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface OpenAIMessage {
role: string;
content: string | null;
tool_calls?: any[];
tool_call_id?: string;
name?: string;
_headroomMeta?: Record<string, unknown>;
}
/**
* Convert AgentMessage[] to OpenAI message format for compression.
*/
export function agentToOpenAI(messages: any[]): OpenAIMessage[] {
const result: OpenAIMessage[] = [];
for (const msg of messages) {
const normalized = normalizeAgentMessage(msg);
const role = normalized.role;
const buildMeta = (): Record<string, unknown> => {
const meta = { ...normalized } as Record<string, unknown>;
delete meta.role;
delete meta.content;
return meta;
};
if (role === "system") {
result.push({
role: "system",
content:
typeof normalized.content === "string"
? normalized.content
: extractText(normalized.content),
_headroomMeta: buildMeta(),
});
continue;
}
if (role === "user") {
result.push({
role: "user",
content:
typeof normalized.content === "string"
? normalized.content
: extractText(normalized.content),
_headroomMeta: buildMeta(),
});
continue;
}
if (role === "assistant") {
const content = normalized.content;
if (typeof content === "string") {
result.push({ role: "assistant", content, _headroomMeta: buildMeta() });
continue;
}
// Content blocks: extract text and tool call blocks.
// OpenClaw uses `toolCall`; some adapters still emit legacy `tool_use`.
if (Array.isArray(content)) {
const textParts: string[] = [];
const toolCalls: any[] = [];
for (const block of content) {
if (typeof block === "string") {
textParts.push(block);
} else if (block.type === "text") {
textParts.push(block.text);
} else if (block.type === "tool_use" || block.type === "toolCall") {
const args =
block.type === "toolCall"
? block.arguments
: block.input;
toolCalls.push({
id: block.id,
type: "function",
function: {
name: block.name,
arguments:
typeof args === "string"
? args
: JSON.stringify(args ?? {}),
},
});
}
}
const openaiMsg: OpenAIMessage = {
role: "assistant",
content: textParts.length > 0 ? textParts.join("") : null,
_headroomMeta: buildMeta(),
};
if (toolCalls.length > 0) {
openaiMsg.tool_calls = toolCalls;
}
result.push(openaiMsg);
}
continue;
}
if (role === "toolResult" || role === "tool_result") {
const content =
typeof normalized.content === "string"
? normalized.content
: Array.isArray(normalized.content)
? extractText(normalized.content)
: JSON.stringify(normalized.content);
result.push({
role: "tool",
content,
tool_call_id:
normalized.toolCallId ??
normalized.tool_use_id ??
normalized.id ??
"unknown",
_headroomMeta: buildMeta(),
});
continue;
}
// Fallback: pass through as user message
result.push({
role: "user",
content:
typeof normalized.content === "string"
? normalized.content
: JSON.stringify(normalized.content),
_headroomMeta: buildMeta(),
});
}
return result;
}
/**
* Convert compressed OpenAI messages back to AgentMessage format.
*/
export function openAIToAgent(messages: OpenAIMessage[]): any[] {
const result: any[] = [];
for (const msg of messages) {
const meta = (msg._headroomMeta ?? {}) as Record<string, unknown>;
const timestamp =
typeof meta.timestamp === "number" ? meta.timestamp : Date.now();
if (msg.role === "system") {
result.push({
role: "system",
content: msg.content ?? "",
timestamp,
});
continue;
}
if (msg.role === "user") {
result.push({
role: "user",
content: msg.content ?? "",
timestamp,
});
continue;
}
if (msg.role === "assistant") {
const blocks: any[] = [];
if (msg.content) {
blocks.push({ type: "text", text: msg.content });
}
if (msg.tool_calls) {
for (const tc of msg.tool_calls) {
let input: any;
try {
input = JSON.parse(tc.function.arguments);
} catch {
input = tc.function.arguments ?? {};
}
// Emit OpenClaw-native block shape so downstream transports keep call linkage.
blocks.push({
type: "toolCall",
id: tc.id,
name: tc.function.name,
arguments: input,
});
}
}
// OpenClaw's Pi agent expects content to always be an array for assistant messages
// (it calls .flatMap() on it). Never flatten to a string.
result.push({
...(meta as object),
role: "assistant",
content: blocks,
api: typeof meta.api === "string" ? meta.api : "headroom",
provider: typeof meta.provider === "string" ? meta.provider : "headroom",
model: typeof meta.model === "string" ? meta.model : "headroom",
usage:
isRecord(meta.usage)
? meta.usage
: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason:
typeof meta.stopReason === "string" ? meta.stopReason : "stop",
timestamp,
});
continue;
}
if (msg.role === "tool") {
const textContent =
typeof msg.content === "string"
? msg.content
: msg.content == null
? ""
: JSON.stringify(msg.content);
const toolCallId = msg.tool_call_id ?? "unknown";
result.push({
...(meta as object),
role: "toolResult",
// OpenClaw transport layers expect toolResult content blocks, not a raw string.
content: [{ type: "text", text: textContent }],
toolCallId:
typeof meta.toolCallId === "string" ? meta.toolCallId : toolCallId,
tool_use_id:
typeof meta.tool_use_id === "string" ? meta.tool_use_id : toolCallId,
toolName:
typeof meta.toolName === "string" ? meta.toolName : "headroom",
isError: typeof meta.isError === "boolean" ? meta.isError : false,
timestamp,
});
continue;
}
}
return result;
}
export function normalizeAgentMessages(messages: any[]): any[] {
return messages.map((message) => normalizeAgentMessage(message));
}
/**
* Extract text from content blocks.
*/
function extractText(content: any): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return JSON.stringify(content);
return content
.map((block: any) => {
if (typeof block === "string") return block;
if (block.type === "text") return block.text;
if (block.type === "tool_result") {
return typeof block.content === "string" ? block.content : JSON.stringify(block.content);
}
return "";
})
.filter(Boolean)
.join("\n");
}
function normalizeAgentMessage(message: any): any {
if (!isRecord(message)) return message;
if (message.role === "assistant") {
return normalizeAssistantMessage(message);
}
if (message.role === "toolResult" || message.role === "tool_result") {
return normalizeToolResultMessage(message);
}
return message;
}
function normalizeAssistantMessage(message: Record<string, any>): Record<string, any> {
const normalizedContent = normalizeAssistantContent(message.content);
return {
...message,
content: normalizedContent,
api: typeof message.api === "string" ? message.api : "headroom",
provider: typeof message.provider === "string" ? message.provider : "headroom",
model: typeof message.model === "string" ? message.model : "headroom",
usage: isRecord(message.usage)
? message.usage
: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: typeof message.stopReason === "string" ? message.stopReason : "stop",
timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now(),
};
}
function normalizeToolResultMessage(message: Record<string, any>): Record<string, any> {
const normalizedContent = normalizeToolResultContent(message.content);
const toolCallId =
typeof message.toolCallId === "string"
? message.toolCallId
: typeof message.tool_use_id === "string"
? message.tool_use_id
: typeof message.id === "string"
? message.id
: "unknown";
return {
...message,
role: "toolResult",
content: normalizedContent,
toolCallId,
tool_use_id:
typeof message.tool_use_id === "string" ? message.tool_use_id : toolCallId,
toolName: typeof message.toolName === "string" ? message.toolName : "headroom",
isError: typeof message.isError === "boolean" ? message.isError : false,
timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now(),
};
}
function normalizeAssistantContent(content: unknown): any[] {
if (Array.isArray(content)) {
return content.flatMap((block) => {
if (typeof block === "string") return [{ type: "text", text: block }];
if (!isRecord(block) || typeof block.type !== "string") return [];
if (block.type === "text" && typeof block.text === "string") return [block];
if (block.type === "thinking" && typeof block.thinking === "string") return [block];
if (
(block.type === "toolCall" || block.type === "tool_use") &&
typeof block.name === "string"
) {
return [
{
type: "toolCall",
id: typeof block.id === "string" ? block.id : "unknown",
name: block.name,
arguments:
"arguments" in block
? block.arguments
: "input" in block
? block.input
: {},
},
];
}
return [];
});
}
if (typeof content === "string" && content.length > 0) {
return [{ type: "text", text: content }];
}
if (content == null) {
return [];
}
return [{ type: "text", text: JSON.stringify(content) }];
}
function normalizeToolResultContent(content: unknown): any[] {
if (Array.isArray(content)) {
return content.flatMap((block) => {
if (typeof block === "string") return [{ type: "text", text: block }];
if (!isRecord(block) || typeof block.type !== "string") return [];
if (block.type === "text" && typeof block.text === "string") return [block];
if (
block.type === "image" &&
typeof block.data === "string" &&
typeof block.mimeType === "string"
) {
return [block];
}
if (block.type === "tool_result" && "content" in block) {
return normalizeToolResultContent(block.content);
}
return [];
});
}
if (typeof content === "string" && content.length > 0) {
return [{ type: "text", text: content }];
}
if (content == null) {
return [];
}
return [{ type: "text", text: JSON.stringify(content) }];
}
function isRecord(value: unknown): value is Record<string, any> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+301
View File
@@ -0,0 +1,301 @@
/**
* HeadroomContextEngine — ContextEngine implementation for OpenClaw.
*
* Compresses tool outputs and conversation context using the Headroom proxy.
* Zero LLM calls — all compression is algorithmic (SmartCrusher, ContentRouter, etc.)
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { compress } from "headroom-ai";
import { ProxyManager, defaultLogger, type ProxyManagerConfig, type ProxyManagerLogger } from "./proxy-manager.js";
import { agentToOpenAI, normalizeAgentMessages, openAIToAgent } from "./convert.js";
export interface HeadroomEngineConfig extends ProxyManagerConfig {
enabled?: boolean;
}
export class HeadroomContextEngine {
readonly info = {
id: "headroom",
name: "Headroom Context Compression",
version: "0.1.0",
ownsCompaction: true,
};
private proxyManager: ProxyManager;
private proxyUrl: string | null = null;
private config: HeadroomEngineConfig;
private logger: ProxyManagerLogger;
private proxyReadyListeners = new Set<(proxyUrl: string) => void | Promise<void>>();
private proxyStartupPromise: Promise<string> | null = null;
private proxyStartupError: unknown = null;
private stats = {
totalCompressions: 0,
totalTokensSaved: 0,
totalTokensBefore: 0,
compactions: 0,
};
constructor(config: HeadroomEngineConfig = {}, logger?: ProxyManagerLogger) {
this.config = config;
this.logger = logger ?? defaultLogger;
this.proxyManager = new ProxyManager(config, this.logger);
}
// === ContextEngine Lifecycle ===
async bootstrap(params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
}): Promise<{ bootstrapped: boolean; reason?: string }> {
if (this.config.enabled === false) {
return { bootstrapped: false, reason: "disabled" };
}
this.ensureProxyStarted();
return { bootstrapped: true, reason: "proxy startup scheduled" };
}
async ingest(params: {
sessionId: string;
message: any;
isHeartbeat?: boolean;
}): Promise<{ ingested: boolean }> {
// No-op: OpenClaw's runtime stores messages. We don't need a separate store.
return { ingested: true };
}
async ingestBatch?(params: {
sessionId: string;
messages: any[];
isHeartbeat?: boolean;
}): Promise<{ ingestedCount: number }> {
return { ingestedCount: params.messages.length };
}
/**
* Assemble context for the model — THE CORE HOOK.
*
* Converts AgentMessage[] → OpenAI format → compress() → AgentMessage[]
*/
async assemble(params: {
sessionId: string;
messages: any[];
tokenBudget?: number;
model?: string;
prompt?: string;
}): Promise<{
messages: any[];
estimatedTokens: number;
systemPromptAddition?: string;
}> {
if (!this.proxyUrl || this.config.enabled === false) {
this.ensureProxyStarted();
// Fallback: return messages unchanged
return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
}
try {
// Convert AgentMessage → OpenAI format
const openaiMessages = agentToOpenAI(params.messages);
// Compress via proxy — pass tokenBudget so RollingWindow enforces it
const result = await compress(openaiMessages, {
model: params.model ?? "claude-sonnet-4-5",
baseUrl: this.proxyUrl,
fallback: true,
tokenBudget: params.tokenBudget,
} as any);
if (!result.compressed || result.tokensSaved === 0) {
return {
messages: normalizeAgentMessages(params.messages),
estimatedTokens: result.tokensBefore,
};
}
// Convert back to AgentMessage format
const compressedAgentMessages = openAIToAgent(result.messages);
// Track stats
this.stats.totalCompressions++;
this.stats.totalTokensSaved += result.tokensSaved;
this.stats.totalTokensBefore += result.tokensBefore;
this.logger.debug(
`Assembled: ${result.tokensBefore}${result.tokensAfter} tokens (saved ${result.tokensSaved})`,
);
return {
messages: compressedAgentMessages,
estimatedTokens: result.tokensAfter,
systemPromptAddition:
result.tokensSaved > 100
? `[Context compressed by Headroom: ${result.tokensSaved} tokens saved. Use headroom_retrieve with the hash to get full details.]`
: undefined,
};
} catch (error) {
this.logger.error(`Assemble failed: ${error}`);
// Graceful fallback: return original messages
return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
}
}
/**
* Compact context — zero-cost alternative to LLM summarization.
*
* Calls compress() with the token budget, which triggers:
* - SmartCrusher: aggressive JSON compression (70-90% on tool outputs)
* - Kompress: ModernBERT text compression (40-60% on assistant text)
* - RollingWindow: drops oldest messages if still over budget
* - CCR: stores originals for retrieval via headroom_retrieve tool
*
* Zero LLM calls. All algorithmic.
*/
async compact(params: {
sessionId: string;
sessionFile: string;
tokenBudget?: number;
force?: boolean;
runtimeContext?: any;
}): Promise<{
ok: boolean;
compacted: boolean;
reason?: string;
result?: {
tokensBefore: number;
tokensAfter?: number;
};
}> {
if (!this.proxyUrl) {
return { ok: false, compacted: false, reason: "Proxy not available" };
}
// Read current messages from session file if available
// For now, compact() works in tandem with assemble() — the next assemble()
// call will compress with the token budget. When compact() is called
// independently, we report success since our pipeline handles it.
//
// TODO: Read session file, extract messages, call compress() with tokenBudget,
// write back compacted messages.
this.stats.compactions++;
this.logger.info(
`Compact called (budget: ${params.tokenBudget ?? "none"}, force: ${params.force ?? false})`,
);
return {
ok: true,
compacted: true,
reason: "Headroom applies SmartCrusher + Kompress + RollingWindow on next assemble()",
};
}
async afterTurn?(params: {
sessionId: string;
messages: any[];
prePromptMessageCount: number;
isHeartbeat?: boolean;
}): Promise<void> {
// Optional: could log stats or trigger learning
}
async prepareSubagentSpawn?(params: {
parentSessionKey: string;
childSessionKey: string;
ttlMs?: number;
}): Promise<{ rollback: () => Promise<void> } | undefined> {
// Subagent context is compressed naturally via assemble()
return undefined;
}
async onSubagentEnded?(params: {
childSessionKey: string;
reason: string;
}): Promise<void> {
// No-op
}
async dispose(): Promise<void> {
await this.proxyManager.stop();
this.logger.info(
`Engine disposed. Stats: ${this.stats.totalCompressions} compressions, ` +
`${this.stats.totalTokensSaved} tokens saved`,
);
}
// --- Public API ---
getStats() {
return { ...this.stats };
}
getProxyUrl(): string | null {
return this.proxyUrl;
}
getProxyStartupError(): unknown {
return this.proxyStartupError;
}
ensureProxyStarted(): void {
if (this.config.enabled === false || this.proxyUrl || this.proxyStartupPromise) {
return;
}
this.proxyStartupError = null;
this.proxyStartupPromise = this.proxyManager
.start()
.then(async (proxyUrl) => {
this.proxyUrl = proxyUrl;
this.proxyStartupError = null;
await this.notifyProxyReady(proxyUrl);
this.logger.info(`Headroom proxy ready at ${proxyUrl}`);
return proxyUrl;
})
.catch((error) => {
this.proxyStartupError = error;
this.logger.warn(`Headroom proxy unavailable: ${error}`);
throw error;
})
.finally(() => {
this.proxyStartupPromise = null;
});
// Fire-and-forget lifecycle callers intentionally do not await this promise.
// Keep the promise rejectable for ensureProxyUrl(), but mark it observed so
// a missing proxy cannot become a process-level unhandled rejection.
void this.proxyStartupPromise.catch(() => {});
}
onProxyReady(listener: (proxyUrl: string) => void | Promise<void>): () => void {
this.proxyReadyListeners.add(listener);
return () => {
this.proxyReadyListeners.delete(listener);
};
}
async ensureProxyUrl(): Promise<string> {
if (this.proxyUrl) {
return this.proxyUrl;
}
this.ensureProxyStarted();
if (!this.proxyStartupPromise) {
throw new Error("Headroom proxy startup is disabled");
}
return this.proxyStartupPromise;
}
private async notifyProxyReady(proxyUrl: string): Promise<void> {
for (const listener of this.proxyReadyListeners) {
try {
await listener(proxyUrl);
} catch (error) {
this.logger.warn(`Headroom proxy ready listener failed: ${error}`);
}
}
}
}
+143
View File
@@ -0,0 +1,143 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export const DEFAULT_GATEWAY_PROVIDER_IDS = ["openai-codex"] as const;
const DEFAULT_PROVIDER_BASE_URLS: Readonly<Record<string, string>> = {
"openai-codex": "https://chatgpt.com/backend-api",
};
const GATEWAY_PROVIDER_ID_ALIASES: Readonly<Record<string, string>> = {
codex: "openai-codex",
claude: "anthropic",
copilot: "github-copilot",
gemini: "google",
};
const EXPLICIT_BASE_URL_REQUIRED_PROVIDER_IDS = new Set<string>(["github-copilot"]);
export function resolveGatewayProviderIds(config: Record<string, unknown> | undefined): string[] {
const configuredProviderIds = normalizeGatewayProviderIds(config?.gatewayProviderIds);
if (configuredProviderIds.length > 0) {
return configuredProviderIds;
}
if (config?.routeCodexViaProxy === false) {
return [];
}
return [...DEFAULT_GATEWAY_PROVIDER_IDS];
}
function normalizeGatewayProviderIds(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const normalized: string[] = [];
for (const entry of value) {
if (typeof entry !== "string") {
continue;
}
const rawProviderId = entry.trim();
const providerId = GATEWAY_PROVIDER_ID_ALIASES[rawProviderId.toLowerCase()] ?? rawProviderId;
if (!providerId || seen.has(providerId)) {
continue;
}
seen.add(providerId);
normalized.push(providerId);
}
return normalized;
}
export function applyGatewayProviderBaseUrls<T>(
cfg: T,
proxyUrl: string,
providerIds: readonly string[],
): { changed: boolean; config: T } {
const next = structuredClone((cfg ?? {}) as any);
const changed = applyGatewayProviderBaseUrlsInPlace(next, proxyUrl, providerIds);
return { changed, config: next as T };
}
export function applyGatewayProviderBaseUrlsInPlace(
cfg: any,
proxyUrl: string,
providerIds: readonly string[],
): boolean {
if (!cfg || typeof cfg !== "object" || providerIds.length === 0) {
return false;
}
const models = (cfg.models ??= {});
const providers = (models.providers ??= {});
let changed = false;
for (const providerId of providerIds) {
const currentValue = providers[providerId];
const currentConfig =
currentValue && typeof currentValue === "object" && !Array.isArray(currentValue)
? currentValue
: {};
const nextConfig = { ...currentConfig };
const currentBaseUrl =
typeof nextConfig.baseUrl === "string" && nextConfig.baseUrl.trim().length > 0
? nextConfig.baseUrl
: undefined;
const defaultBaseUrl = DEFAULT_PROVIDER_BASE_URLS[providerId];
if (
!currentBaseUrl &&
!defaultBaseUrl &&
EXPLICIT_BASE_URL_REQUIRED_PROVIDER_IDS.has(providerId)
) {
continue;
}
const nextBaseUrl = routeBaseUrlThroughProxy({
providerId,
proxyUrl,
currentBaseUrl,
});
if (!Array.isArray(nextConfig.models)) {
nextConfig.models = [];
changed = true;
}
if (nextConfig.baseUrl === nextBaseUrl) {
providers[providerId] = nextConfig;
continue;
}
nextConfig.baseUrl = nextBaseUrl;
providers[providerId] = nextConfig;
changed = true;
}
return changed;
}
function routeBaseUrlThroughProxy(params: {
providerId: string;
proxyUrl: string;
currentBaseUrl?: string;
}): string {
const upstreamBaseUrl = params.currentBaseUrl ?? DEFAULT_PROVIDER_BASE_URLS[params.providerId];
if (!upstreamBaseUrl) {
return params.proxyUrl;
}
try {
const proxy = new URL(params.proxyUrl);
const upstream = new URL(upstreamBaseUrl);
proxy.pathname = upstream.pathname;
proxy.search = upstream.search;
proxy.hash = "";
return proxy.toString().replace(/\/$/, "");
} catch {
return params.proxyUrl;
}
}
+11
View File
@@ -0,0 +1,11 @@
export { default } from "./plugin/index.js";
export { HeadroomContextEngine } from "./engine.js";
export { ProxyManager, normalizeAndValidateProxyUrl, isLocalProxyUrl, defaultLogger, probeHeadroomProxy } from "./proxy-manager.js";
export { agentToOpenAI, normalizeAgentMessages, openAIToAgent } from "./convert.js";
export { createHeadroomRetrieveTool } from "./tools/headroom-retrieve.js";
export {
DEFAULT_GATEWAY_PROVIDER_IDS,
applyGatewayProviderBaseUrls,
applyGatewayProviderBaseUrlsInPlace,
resolveGatewayProviderIds,
} from "./gateway-config.js";
+142
View File
@@ -0,0 +1,142 @@
/**
* Headroom OpenClaw Plugin — register ContextEngine + CCR retrieval tool.
*
* Usage:
* openclaw plugins install headroom-ai/openclaw
*
* Configuration (in ~/.openclaw/config.json or ~/.clawdbot/clawdbot.json):
* {
* "plugins": {
* "slots": { "contextEngine": "headroom" },
* "entries": { "headroom": { "enabled": true } }
* }
* }
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { HeadroomContextEngine } from "../engine.js";
import {
applyGatewayProviderBaseUrlsInPlace,
resolveGatewayProviderIds,
} from "../gateway-config.js";
import { normalizeAndValidateProxyUrl, probeHeadroomProxy } from "../proxy-manager.js";
import { createHeadroomRetrieveTool } from "../tools/headroom-retrieve.js";
/**
* OpenClaw 2026.x plugin API requires a `{ register(api) }` object export.
* The previous bare-function default export was silently skipped by the loader.
* See: https://github.com/chopratejas/headroom/issues/XXX
*/
export default {
register: headroomPlugin,
};
function headroomPlugin(api: any) {
const config = api.config?.plugins?.entries?.headroom?.config ?? {};
const logger = api.logger ?? console;
const rawProxyUrl = config.proxyUrl;
const proxyUrl =
typeof rawProxyUrl === "string" && rawProxyUrl.trim().length > 0
? normalizeAndValidateProxyUrl(rawProxyUrl)
: undefined;
const engine = new HeadroomContextEngine({ ...config, proxyUrl }, {
info: (m: string) => logger.info(m),
warn: (m: string) => logger.warn(m),
error: (m: string) => logger.error(m),
debug: (m: string) => logger.debug?.(m),
});
const gatewayProviderIds = resolveGatewayProviderIds(config);
let validatedConfiguredProxyUrl: string | null = null;
let configuredProxyProbePromise: Promise<string | null> | null = null;
const applyGatewayRouting = async (activeProxyUrl: string) => {
if (gatewayProviderIds.length === 0) {
return;
}
try {
const changed = applyGatewayProviderBaseUrlsInPlace(api.config, activeProxyUrl, gatewayProviderIds);
if (changed) {
logger.info(
`[headroom] Routed ${gatewayProviderIds.join(", ")} through Headroom proxy in memory at ${activeProxyUrl}`,
);
} else {
logger.info(
`[headroom] Upstream gateway already routed in memory for ${gatewayProviderIds.join(", ")} at ${activeProxyUrl}`,
);
}
} catch (error) {
logger.warn(`[headroom] Failed to configure upstream gateway routing: ${error}`);
}
};
const getConfiguredRoutingProxyUrl = async (): Promise<string | null> => {
if (!proxyUrl) {
return null;
}
if (validatedConfiguredProxyUrl === proxyUrl) {
return validatedConfiguredProxyUrl;
}
if (!configuredProxyProbePromise) {
configuredProxyProbePromise = probeHeadroomProxy(proxyUrl)
.then((probe) => {
if (probe.reachable && probe.isHeadroom) {
validatedConfiguredProxyUrl = proxyUrl;
return proxyUrl;
}
logger.warn(
`[headroom] Skipping upstream gateway routing: configured proxyUrl is not a ready Headroom proxy at ${proxyUrl}` +
(probe.reason ? ` (${probe.reason})` : ""),
);
return null;
})
.catch((error) => {
logger.warn(
`[headroom] Skipping upstream gateway routing: failed to probe configured proxyUrl ${proxyUrl}: ${error}`,
);
return null;
})
.finally(() => {
configuredProxyProbePromise = null;
});
}
return configuredProxyProbePromise;
};
const ensureGatewayRouting = async () => {
if (gatewayProviderIds.length === 0) {
return;
}
const activeProxyUrl = engine.getProxyUrl() ?? (await getConfiguredRoutingProxyUrl());
if (!activeProxyUrl) {
logger.debug?.("[headroom] Deferring upstream gateway routing until proxy is available");
return;
}
await applyGatewayRouting(activeProxyUrl);
};
engine.onProxyReady(async (activeProxyUrl) => {
await applyGatewayRouting(activeProxyUrl);
});
// Register as context engine
api.registerContextEngine("headroom", () => engine);
// Register CCR retrieval tool (active once proxy is running)
api.registerTool((ctx: any) => {
const activeProxyUrl = engine.getProxyUrl() ?? proxyUrl;
if (!activeProxyUrl) return null;
return createHeadroomRetrieveTool({ proxyUrl: activeProxyUrl });
}, { names: ["headroom_retrieve"] });
api.on("gateway_start", async () => {
await ensureGatewayRouting();
});
void ensureGatewayRouting();
logger.info("[headroom] Plugin registered");
}
+528
View File
@@ -0,0 +1,528 @@
/**
* Manages connectivity to a Headroom proxy (local or remote).
*
* Security model:
* - Local proxies (127.0.0.1 / localhost) can be auto-started via subprocess
* - Remote proxies are connect-only: probe and use, never launch
* - No environment variable access
*/
import { spawn } from "node:child_process";
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import os from "os";
export interface ProxyManagerConfig {
proxyUrl?: string;
proxyPort?: number;
pythonPath?: string;
autoStart?: boolean;
startupTimeoutMs?: number;
retryMaxAttempts?: number;
connectTimeoutSeconds?: number;
}
export interface ProxyManagerLogger {
info(message: string): void;
warn(message: string): void;
error(message: string): void;
debug(message: string): void;
}
/** Default logger that prefixes all messages with `[headroom]`. */
export const defaultLogger: ProxyManagerLogger = {
info: (m) => console.log(`[headroom] ${m}`),
warn: (m) => console.warn(`[headroom] ${m}`),
error: (m) => console.error(`[headroom] ${m}`),
debug: () => {},
};
export interface ProxyProbeResult {
reachable: boolean;
isHeadroom: boolean;
reason?: string;
}
interface LaunchSpec {
label: string;
command: string;
args: string[];
checkCommand: string;
checkArgs: string[];
useShell?: boolean;
checkUseShell?: boolean;
}
const HEADROOM_MODULE_DISCOVERY_SNIPPET =
"import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('headroom') else 1)";
export class ProxyManager {
private config: ProxyManagerConfig;
private logger: ProxyManagerLogger;
private proxyUrl: string | null = null;
constructor(config: ProxyManagerConfig = {}, logger?: ProxyManagerLogger) {
this.config = config;
this.logger = logger ?? defaultLogger;
}
/**
* Ensure a proxy is available. Returns the normalized URL origin.
*/
async start(): Promise<string> {
const port = this.getProxyPort();
const rawExplicitUrl =
typeof this.config.proxyUrl === "string" && this.config.proxyUrl.trim().length > 0
? normalizeAndValidateProxyUrl(this.config.proxyUrl)
: null;
// Only apply proxyPort default to local URLs — remote URLs use their protocol default
const explicitUrl = rawExplicitUrl
? isLocalProxyUrl(rawExplicitUrl) ? withDefaultPort(rawExplicitUrl, port) : rawExplicitUrl
: null;
const defaultCandidates = this.getDefaultProxyCandidates(port);
const candidateUrls = explicitUrl ? [explicitUrl] : [...defaultCandidates];
const probeByUrl = new Map<string, ProxyProbeResult>();
for (const url of candidateUrls) {
const probe = await probeHeadroomProxy(url);
probeByUrl.set(url, probe);
if (probe.reachable && probe.isHeadroom) {
this.proxyUrl = url;
this.logger.info(`Headroom proxy already running at ${url}`);
return url;
}
}
if (explicitUrl) {
const explicitProbe = probeByUrl.get(explicitUrl);
if (explicitProbe?.reachable && !explicitProbe.isHeadroom) {
throw new Error(
`Service reachable at ${explicitUrl}, but it does not appear to be a Headroom proxy (${explicitProbe.reason ?? "unknown service"}).`,
);
}
}
// Remote URLs are connect-only — never auto-start a subprocess for them
if (explicitUrl && !isLocalProxyUrl(explicitUrl)) {
throw new Error(
`Remote Headroom proxy not reachable at ${explicitUrl}. Ensure the proxy is running at that address.`,
);
}
// Auto-start is only available for local proxies
if (this.config.autoStart === true) {
const startupUrl = explicitUrl ?? defaultCandidates[0];
const startupProbe = probeByUrl.get(startupUrl);
if (startupProbe?.reachable && !startupProbe.isHeadroom) {
throw new Error(
`Cannot auto-start Headroom at ${startupUrl}: port is in use by a non-Headroom service (${startupProbe.reason ?? "unknown service"}).`,
);
}
this.logger.info(
`No Headroom proxy detected${explicitUrl ? ` at ${startupUrl}` : " on default local endpoints"}; attempting to auto-start...`,
);
await this.startHeadroomProxy(startupUrl, port);
const startedProbe = await waitForHeadroomProxy(
startupUrl,
this.config.startupTimeoutMs ?? 20_000,
);
if (startedProbe.reachable && startedProbe.isHeadroom) {
this.proxyUrl = startupUrl;
this.logger.info(`Headroom proxy started and reachable at ${startupUrl}`);
return startupUrl;
}
throw new Error(
`Attempted to start Headroom proxy, but it was not reachable at ${startupUrl} (${startedProbe.reason ?? "unknown"}).`,
);
}
if (explicitUrl) {
throw new Error(
`Headroom proxy not reachable at ${explicitUrl}. Ensure the proxy is running first.`,
);
}
throw new Error(
`Headroom proxy not detected on default endpoints (${defaultCandidates.join(", ")}). ` +
"Set proxyUrl explicitly or enable autoStart.",
);
}
private getProxyPort(): number {
const rawPort = this.config.proxyPort;
if (!Number.isInteger(rawPort) || rawPort === undefined) return 8787;
if (rawPort < 1 || rawPort > 65535) {
throw new Error("proxyPort must be an integer between 1 and 65535");
}
return rawPort;
}
private getDefaultProxyCandidates(port: number): string[] {
return [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
}
/**
* Stop manager state. Spawned proxy processes are detached and externally managed.
*/
async stop(): Promise<void> {
this.proxyUrl = null;
}
getUrl(): string | null {
return this.proxyUrl;
}
// --- Internal ---
private async startHeadroomProxy(proxyUrl: string, defaultPort: number): Promise<void> {
const parsed = new URL(proxyUrl);
const host = parsed.hostname;
const port = parsed.port || String(defaultPort);
const specs = this.buildLaunchSpecs(host, port);
const errors: string[] = [];
for (const spec of specs) {
if (!this.canExecute(spec.checkCommand, spec.checkArgs, spec.checkUseShell ?? spec.useShell)) {
this.logger.debug(`Launcher unavailable: ${spec.label}`);
continue;
}
try {
const child = spawn(spec.command, spec.args, {
detached: true,
shell: spec.useShell === true,
stdio: "ignore",
});
child.unref();
this.logger.info(`Auto-start launcher selected: ${spec.label}`);
return;
} catch (error) {
errors.push(`${spec.label}: ${String(error)}`);
}
}
throw new Error(
"No usable Headroom launcher found. Tried PATH, local npm, global npm, and Python. " +
"Install headroom-ai (npm or pip) and ensure one launcher is available.\n" +
(errors.length > 0 ? `Launch errors: ${errors.join("; ")}` : ""),
);
}
private buildLaunchSpecs(host: string, port: string): LaunchSpec[] {
const commonArgs = ["proxy", "--host", host, "--port", port];
const retryMaxAttempts = this.config.retryMaxAttempts;
if (Number.isInteger(retryMaxAttempts)) {
commonArgs.push("--retry-max-attempts", String(retryMaxAttempts));
}
const connectTimeoutSeconds = this.config.connectTimeoutSeconds;
if (Number.isInteger(connectTimeoutSeconds)) {
commonArgs.push("--connect-timeout-seconds", String(connectTimeoutSeconds));
}
const specs: LaunchSpec[] = [];
const configuredPython = this.getConfiguredPythonCommand();
if (configuredPython) {
specs.push({
label: `Configured Python: ${configuredPython} -m headroom.cli`,
command: configuredPython,
args: ["-m", "headroom.cli", ...commonArgs],
checkCommand: configuredPython,
checkArgs: ["-c", HEADROOM_MODULE_DISCOVERY_SNIPPET],
});
}
// 2) Windows pyenv: resolve the real executable so we avoid shim .bat wrappers.
if (process.platform === "win32") {
const pyenvHeadroom = this.getPyenvResolvedHeadroom();
if (pyenvHeadroom) {
specs.push({
label: `pyenv: ${pyenvHeadroom}`,
command: pyenvHeadroom,
args: commonArgs,
checkCommand: pyenvHeadroom,
checkArgs: ["--version"],
useShell: false,
});
}
}
// 3) PATH
specs.push({
label: "PATH: headroom",
command: "headroom",
args: commonArgs,
checkCommand: process.platform === "win32" ? "where.exe" : "sh",
checkArgs: process.platform === "win32"
? ["headroom"]
: ["-c", "command -v headroom >/dev/null 2>&1"],
useShell: process.platform === "win32",
checkUseShell: false,
});
// 4) uv tool install path (~/.local/bin/headroom)
const uvBin = join(
os.homedir(),
".local", "bin", "headroom"
);
if (existsSync(uvBin)) {
specs.push({
label: `uv tool: ${uvBin}`,
command: uvBin,
args: commonArgs,
checkCommand: uvBin,
checkArgs: ["--version"],
});
}
// 5) Local npm install (inside plugin install path)
const moduleDir = dirname(fileURLToPath(import.meta.url)); // .../dist
const packageRoot = dirname(moduleDir);
const localBinDir = join(packageRoot, "node_modules", ".bin");
const localBins = process.platform === "win32"
? [join(localBinDir, "headroom.cmd"), join(localBinDir, "headroom")]
: [join(localBinDir, "headroom")];
for (const localBin of localBins) {
if (!existsSync(localBin)) continue;
specs.push({
label: `Local npm: ${localBin}`,
command: localBin,
args: commonArgs,
checkCommand: localBin,
checkArgs: ["--version"],
useShell: process.platform === "win32",
});
}
// 5) Global npm install
const npmPrefix = this.getNpmGlobalPrefix();
if (npmPrefix) {
const globalBins = process.platform === "win32"
? [join(npmPrefix, "headroom.cmd"), join(npmPrefix, "headroom")]
: [join(npmPrefix, "bin", "headroom"), join(npmPrefix, "headroom")];
for (const globalBin of globalBins) {
if (!existsSync(globalBin)) continue;
specs.push({
label: `Global npm: ${globalBin}`,
command: globalBin,
args: commonArgs,
checkCommand: globalBin,
checkArgs: ["--version"],
useShell: process.platform === "win32",
});
}
}
// 6) Python module fallback
const pythonCommands = this.getPythonCommands();
for (const pyCmd of pythonCommands) {
if (configuredPython && pyCmd === configuredPython) continue;
specs.push({
label: `Python: ${pyCmd} -m headroom.cli`,
command: pyCmd,
args: ["-m", "headroom.cli", ...commonArgs],
checkCommand: pyCmd,
checkArgs: ["-c", HEADROOM_MODULE_DISCOVERY_SNIPPET],
});
}
return specs;
}
private getConfiguredPythonCommand(): string | null {
const configured = typeof this.config.pythonPath === "string"
? this.config.pythonPath.trim()
: "";
return configured.length > 0 ? configured : null;
}
private getPyenvResolvedHeadroom(): string | null {
if (process.platform !== "win32") return null;
try {
const result = spawnSync("pyenv", ["which", "headroom"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5000,
});
if (result.error || result.status !== 0) return null;
const resolved = (result.stdout ?? "").trim().split(/\r?\n/, 1)[0];
if (!resolved || !existsSync(resolved)) return null;
return resolved;
} catch {
return null;
}
}
private getPythonCommands(): string[] {
const commands: string[] = [];
const configured = this.getConfiguredPythonCommand() ?? "";
if (configured.length > 0) {
commands.push(configured);
}
for (const fallback of ["python", "python3", "py"]) {
if (!commands.includes(fallback)) commands.push(fallback);
}
return commands;
}
private canExecute(command: string, args: string[], useShell = false): boolean {
try {
const result = spawnSync(command, args, {
shell: useShell,
stdio: "ignore",
timeout: 5000,
});
if (result.error) return false;
return result.status === 0;
} catch {
return false;
}
}
private getNpmGlobalPrefix(): string | null {
try {
const result = spawnSync("npm", ["prefix", "-g"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5000,
});
if (result.error || result.status !== 0) return null;
const prefix = (result.stdout ?? "").trim();
return prefix.length > 0 ? prefix : null;
} catch {
return null;
}
}
}
/** Parse a URL, returning the parsed object or throwing a descriptive error. */
function parseProxyUrl(proxyUrl: string): URL {
try {
return new URL(proxyUrl);
} catch {
throw new Error(`Invalid proxyUrl: "${proxyUrl}"`);
}
}
export function normalizeAndValidateProxyUrl(proxyUrl: string): string {
const parsed = parseProxyUrl(proxyUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("proxyUrl must use http:// or https://");
}
if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
throw new Error("proxyUrl must not include a path, query, or hash");
}
return parsed.origin;
}
/** Returns true if the URL points to a local address (localhost or 127.0.0.1). */
export function isLocalProxyUrl(proxyUrl: string): boolean {
try {
const parsed = new URL(proxyUrl);
return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost";
} catch {
return false;
}
}
function withDefaultPort(proxyUrl: string, defaultPort: number): string {
const parsed = parseProxyUrl(proxyUrl);
if (!parsed.port) {
parsed.port = String(defaultPort);
}
return parsed.origin;
}
/**
* Probe a configured URL and verify whether it is a running Headroom proxy.
*/
export async function probeHeadroomProxy(proxyUrl: string): Promise<ProxyProbeResult> {
const origin = normalizeAndValidateProxyUrl(proxyUrl);
const probeEndpoint = async (
path: string,
options: { readBody?: boolean } = {},
): Promise<{ reachable: boolean; ok: boolean; status?: number; body?: string }> => {
try {
const response = await fetch(`${origin}${path}`, {
signal: AbortSignal.timeout(3_000),
});
const body =
response.ok && options.readBody
? await response.text().catch(() => undefined)
: undefined;
return { reachable: true, ok: response.ok, status: response.status, body };
} catch {
return { reachable: false, ok: false };
}
};
const ready = await probeEndpoint("/readyz");
const retrieveStats = await probeEndpoint("/v1/retrieve/stats", { readBody: true });
if (retrieveStats.ok && hasHeadroomStatsShape(retrieveStats.body)) {
return { reachable: true, isHeadroom: true };
}
const stats = await probeEndpoint("/stats", { readBody: true });
if (stats.ok && hasHeadroomStatsShape(stats.body)) {
return { reachable: true, isHeadroom: true };
}
const health = await probeEndpoint("/health");
const anyReachable = ready.reachable || retrieveStats.reachable || stats.reachable || health.reachable;
if (!anyReachable) {
return { reachable: false, isHeadroom: false, reason: "proxy probe failed" };
}
const reasons = [
ready.reachable ? `readyz HTTP ${ready.status}` : "readyz unavailable",
retrieveStats.reachable
? `retrieve stats HTTP ${retrieveStats.status}`
: "retrieve stats endpoint unavailable",
stats.reachable ? `stats HTTP ${stats.status}` : "stats endpoint unavailable",
health.reachable ? `health HTTP ${health.status}` : "health check failed",
];
return { reachable: true, isHeadroom: false, reason: reasons.join("; ") };
}
function hasHeadroomStatsShape(body: string | undefined): boolean {
if (!body) {
return false;
}
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
return (
parsed !== null &&
typeof parsed === "object" &&
(Object.hasOwn(parsed, "proxy_inbound") ||
Object.hasOwn(parsed, "api_requests") ||
Object.hasOwn(parsed, "provider_tokens") ||
Object.hasOwn(parsed, "proxy_compression_saved") ||
Object.hasOwn(parsed, "store") ||
Object.hasOwn(parsed, "recent_retrievals"))
);
} catch {
return false;
}
}
async function waitForHeadroomProxy(proxyUrl: string, timeoutMs: number): Promise<ProxyProbeResult> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const result = await probeHeadroomProxy(proxyUrl);
if (result.reachable && result.isHeadroom) {
return result;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
return probeHeadroomProxy(proxyUrl);
}
@@ -0,0 +1,70 @@
/**
* CCR (Compress-Cache-Retrieve) tool for OpenClaw.
*
* Allows the agent to retrieve original uncompressed content
* from the Headroom proxy's compression store.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { normalizeAndValidateProxyUrl } from "../proxy-manager.js";
export interface RetrieveToolConfig {
proxyUrl: string;
}
export function createHeadroomRetrieveTool(config: RetrieveToolConfig) {
const proxyOrigin = normalizeAndValidateProxyUrl(config.proxyUrl);
return {
name: "headroom_retrieve",
description:
"Retrieve original uncompressed content from Headroom's compression store. " +
"Use when compressed context mentions a hash and you need the full details. " +
"Pass the hash from the compression marker (24 hex characters). " +
"Retrieval is by hash and always returns the full original content.",
parameters: {
type: "object" as const,
properties: {
hash: {
type: "string",
description: "The 24-character hex hash from the compression marker",
},
},
required: ["hash"],
},
execute: async (args: { hash: string }): Promise<string> => {
const { hash } = args;
// Validate hash format
if (!/^[a-f0-9]{24}$/i.test(hash)) {
return JSON.stringify({
error: "Invalid hash format. Expected 24 hex characters.",
});
}
try {
const url = `${proxyOrigin}/v1/retrieve/${hash}`;
const resp = await fetch(url, {
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
const body = await resp.text().catch(() => "");
return JSON.stringify({
error: `Retrieval failed: HTTP ${resp.status}`,
details: body,
});
}
const data = await resp.json();
return typeof data === "string" ? data : JSON.stringify(data);
} catch (error) {
return JSON.stringify({
error: `Retrieval failed: ${error}`,
hint: "The compressed content may have expired (default TTL: 5 minutes)",
});
}
},
};
}
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { agentToOpenAI, normalizeAgentMessages, openAIToAgent, type OpenAIMessage } from "../src/convert";
describe("openAIToAgent", () => {
it("emits toolResult content as blocks so transports can safely filter", () => {
const messages: OpenAIMessage[] = [
{
role: "tool",
content: "tool output",
tool_call_id: "call_123",
},
];
const result = openAIToAgent(messages);
const toolResult = result[0] as {
role: string;
content: Array<{ type: string; text?: string }>;
toolCallId: string;
tool_use_id: string;
};
expect(toolResult.role).toBe("toolResult");
expect(Array.isArray(toolResult.content)).toBe(true);
expect(toolResult.content).toEqual([{ type: "text", text: "tool output" }]);
expect(toolResult.toolCallId).toBe("call_123");
expect(toolResult.tool_use_id).toBe("call_123");
});
});
describe("normalizeAgentMessages", () => {
it("normalizes assistant string content into OpenClaw blocks", () => {
const result = normalizeAgentMessages([
{
role: "assistant",
content: "hello from headroom",
},
]);
expect(result[0]).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "hello from headroom" }],
api: "headroom",
provider: "headroom",
model: "headroom",
stopReason: "stop",
});
});
it("normalizes tool result string content into OpenClaw blocks", () => {
const result = normalizeAgentMessages([
{
role: "toolResult",
content: "tool output",
},
]);
expect(result[0]).toMatchObject({
role: "toolResult",
content: [{ type: "text", text: "tool output" }],
toolCallId: "unknown",
tool_use_id: "unknown",
toolName: "headroom",
isError: false,
});
});
});
describe("agentToOpenAI", () => {
it("captures assistant metadata needed for OpenClaw round-trips", () => {
const result = agentToOpenAI([
{
role: "assistant",
content: "hello",
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
},
]);
expect(result[0]._headroomMeta).toMatchObject({
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "stop",
});
});
});
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { HeadroomContextEngine } from "../src/engine.js";
describe("HeadroomContextEngine", () => {
it("normalizes pass-through assistant messages when no proxy is available", async () => {
const engine = new HeadroomContextEngine({ enabled: false });
const result = await engine.assemble({
sessionId: "test-session",
messages: [
{ role: "user", content: "hi", timestamp: Date.now() },
{ role: "assistant", content: "hello there", timestamp: Date.now() },
],
});
expect(result.messages[1]).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "hello there" }],
});
});
});
+199
View File
@@ -0,0 +1,199 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const mocked = vi.hoisted(() => ({
start: vi.fn(async () => "http://127.0.0.1:8787"),
stop: vi.fn(async () => undefined),
logger: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("headroom-ai", () => ({
compress: vi.fn(),
}));
vi.mock("../src/proxy-manager.js", () => ({
ProxyManager: class {
start = mocked.start;
stop = mocked.stop;
},
defaultLogger: mocked.logger,
}));
import { HeadroomContextEngine } from "../src/engine.js";
afterEach(() => {
mocked.start.mockReset();
mocked.start.mockResolvedValue("http://127.0.0.1:8787");
mocked.stop.mockClear();
mocked.logger.debug.mockClear();
mocked.logger.error.mockClear();
mocked.logger.info.mockClear();
mocked.logger.warn.mockClear();
});
describe("HeadroomContextEngine proxy startup helpers", () => {
it("bootstraps by scheduling proxy startup when enabled", async () => {
const engine = new HeadroomContextEngine();
await expect(
engine.bootstrap({
sessionId: "session-1",
sessionFile: "session.jsonl",
}),
).resolves.toEqual({
bootstrapped: true,
reason: "proxy startup scheduled",
});
expect(mocked.start).toHaveBeenCalledTimes(1);
});
it("removes unsubscribed proxy listeners before notifying readiness", async () => {
const engine = new HeadroomContextEngine();
const first = vi.fn();
const second = vi.fn();
const unsubscribeFirst = engine.onProxyReady(first);
engine.onProxyReady(second);
unsubscribeFirst();
engine.ensureProxyStarted();
await engine.ensureProxyUrl();
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledWith("http://127.0.0.1:8787");
});
it("returns the existing proxy URL without starting again", async () => {
const engine = new HeadroomContextEngine();
(engine as { proxyUrl: string | null }).proxyUrl = "http://127.0.0.1:8787";
await expect(engine.ensureProxyUrl()).resolves.toBe("http://127.0.0.1:8787");
expect(mocked.start).not.toHaveBeenCalled();
});
it("throws when proxy startup is disabled", async () => {
const engine = new HeadroomContextEngine({ enabled: false });
await expect(engine.ensureProxyUrl()).rejects.toThrow("Headroom proxy startup is disabled");
expect(mocked.start).not.toHaveBeenCalled();
});
it("does not emit an unhandledRejection when fire-and-forget startup fails", async () => {
mocked.start.mockReset();
mocked.start.mockRejectedValue(new Error("proxy boom"));
const engine = new HeadroomContextEngine();
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandled);
try {
// Fire-and-forget: caller intentionally does not await.
engine.ensureProxyStarted();
// Let the startup promise settle and any microtasks/macrotasks flush.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(unhandled).toEqual([]);
expect(mocked.logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Headroom proxy unavailable"),
);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
it("stores the startup failure in getProxyStartupError()", async () => {
const failure = new Error("proxy boom");
mocked.start.mockReset();
mocked.start.mockRejectedValue(failure);
const engine = new HeadroomContextEngine();
expect(engine.getProxyStartupError()).toBeNull();
engine.ensureProxyStarted();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(engine.getProxyStartupError()).toBe(failure);
});
it("allows retrying startup after a failure", async () => {
mocked.start.mockReset();
mocked.start
.mockRejectedValueOnce(new Error("proxy boom"))
.mockResolvedValueOnce("http://127.0.0.1:8787");
const engine = new HeadroomContextEngine();
engine.ensureProxyStarted();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(engine.getProxyStartupError()).toBeInstanceOf(Error);
// A second attempt is possible once the failed promise has cleared.
const url = await engine.ensureProxyUrl();
expect(url).toBe("http://127.0.0.1:8787");
expect(engine.getProxyStartupError()).toBeNull();
expect(mocked.start).toHaveBeenCalledTimes(2);
});
it("ensureProxyUrl rejects cleanly on startup failure without unhandledRejection", async () => {
const failure = new Error("proxy boom");
mocked.start.mockReset();
mocked.start.mockRejectedValue(failure);
const engine = new HeadroomContextEngine();
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandled);
try {
await expect(engine.ensureProxyUrl()).rejects.toBe(failure);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(unhandled).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
it("isolates and logs proxy-ready listener rejections", async () => {
const engine = new HeadroomContextEngine();
const failing = vi.fn(async () => {
throw new Error("listener boom");
});
const healthy = vi.fn();
engine.onProxyReady(failing);
engine.onProxyReady(healthy);
engine.ensureProxyStarted();
// ensureProxyUrl must still resolve despite the listener throwing.
await expect(engine.ensureProxyUrl()).resolves.toBe("http://127.0.0.1:8787");
expect(failing).toHaveBeenCalled();
expect(healthy).toHaveBeenCalledWith("http://127.0.0.1:8787");
expect(mocked.logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Headroom proxy ready listener failed"),
);
expect(engine.getProxyStartupError()).toBeNull();
});
it("schedules startup and returns original messages when assembling before proxy readiness", async () => {
const engine = new HeadroomContextEngine();
const messages = [{ role: "user", content: "hello" }];
await expect(
engine.assemble({
sessionId: "session-1",
messages,
}),
).resolves.toEqual({
messages,
estimatedTokens: 0,
});
expect(mocked.start).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,337 @@
import { describe, expect, it } from "vitest";
import {
applyGatewayProviderBaseUrls,
applyGatewayProviderBaseUrlsInPlace,
resolveGatewayProviderIds,
} from "../src/gateway-config.js";
describe("resolveGatewayProviderIds", () => {
it("routes openai-codex by default", () => {
expect(resolveGatewayProviderIds(undefined)).toEqual(["openai-codex"]);
});
it("allows an explicit provider list to override the default", () => {
expect(
resolveGatewayProviderIds({
gatewayProviderIds: ["anthropic", "github-copilot", "minimax-portal"],
}),
).toEqual(["anthropic", "github-copilot", "minimax-portal"]);
});
it("normalizes explicit provider ids and friendly aliases", () => {
expect(
resolveGatewayProviderIds({
gatewayProviderIds: [" claude ", "", "copilot", "codex", "gemini", "anthropic"],
}),
).toEqual(["anthropic", "github-copilot", "openai-codex", "google"]);
});
it("allows routing to be disabled", () => {
expect(resolveGatewayProviderIds({ routeCodexViaProxy: false })).toEqual([]);
});
});
describe("applyGatewayProviderBaseUrls", () => {
it("creates an openai-codex provider config when missing", () => {
const result = applyGatewayProviderBaseUrls({}, "http://127.0.0.1:8787", ["openai-codex"]);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers["openai-codex"]).toEqual({
baseUrl: "http://127.0.0.1:8787/backend-api",
models: [],
});
});
it("creates provider configs for multiple configured provider ids", () => {
const result = applyGatewayProviderBaseUrls(
{},
"http://127.0.0.1:8787",
["anthropic", "openrouter", "google", "minimax-portal"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers).toEqual({
anthropic: {
baseUrl: "http://127.0.0.1:8787",
models: [],
},
openrouter: {
baseUrl: "http://127.0.0.1:8787",
models: [],
},
google: {
baseUrl: "http://127.0.0.1:8787",
models: [],
},
"minimax-portal": {
baseUrl: "http://127.0.0.1:8787",
models: [],
},
});
});
it("preserves existing provider config fields", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
"openai-codex": {
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
},
},
},
},
"http://127.0.0.1:8787",
["openai-codex"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers["openai-codex"]).toEqual({
api: "openai-codex-responses",
baseUrl: "http://127.0.0.1:8787/backend-api",
models: [],
});
});
it("is a no-op when the provider already points at headroom", () => {
const cfg = {
models: {
providers: {
"openai-codex": {
baseUrl: "http://127.0.0.1:8787/backend-api",
models: [],
},
},
},
};
const result = applyGatewayProviderBaseUrls(cfg, "http://127.0.0.1:8787", ["openai-codex"]);
expect(result.changed).toBe(false);
expect(result.config).toEqual(cfg);
});
it("preserves upstream path segments when routing through the proxy", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
anthropic: {
baseUrl: "https://api.anthropic.com/v1",
},
},
},
},
"http://127.0.0.1:8787",
["anthropic"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers.anthropic).toEqual({
baseUrl: "http://127.0.0.1:8787/v1",
models: [],
});
});
it("preserves protocol-specific GitHub Copilot OpenAI-family paths", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
"github-copilot": {
baseUrl: "https://api.githubcopilot.com/v1",
},
},
},
},
"http://127.0.0.1:8787",
["github-copilot"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers["github-copilot"]).toEqual({
baseUrl: "http://127.0.0.1:8787/v1",
models: [],
});
});
it("preserves protocol-specific GitHub Copilot Claude-family paths", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
"github-copilot": {
baseUrl: "https://api.githubcopilot.com/anthropic",
},
},
},
},
"http://127.0.0.1:8787",
["github-copilot"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers["github-copilot"]).toEqual({
baseUrl: "http://127.0.0.1:8787/anthropic",
models: [],
});
});
it("preserves OpenAI-compatible /api/v1 paths", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
openrouter: {
baseUrl: "https://openrouter.ai/api/v1",
},
},
},
},
"http://127.0.0.1:8787",
["openrouter"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers.openrouter).toEqual({
baseUrl: "http://127.0.0.1:8787/api/v1",
models: [],
});
});
it("preserves Gemini /v1beta paths", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
google: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
},
},
},
},
"http://127.0.0.1:8787",
["google"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers.google).toEqual({
baseUrl: "http://127.0.0.1:8787/v1beta",
models: [],
});
});
it("does not invent a GitHub Copilot proxy baseUrl without an upstream baseUrl", () => {
const result = applyGatewayProviderBaseUrls({}, "http://127.0.0.1:8787", ["github-copilot"]);
expect(result.changed).toBe(false);
expect((result.config as any).models?.providers?.["github-copilot"]).toBeUndefined();
});
it("documents the Gate-D risk: anthropic without an explicit baseUrl routes to the bare proxy origin", () => {
const result = applyGatewayProviderBaseUrls({}, "http://127.0.0.1:8787", ["anthropic"]);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers.anthropic).toEqual({
baseUrl: "http://127.0.0.1:8787",
models: [],
});
});
it("documents the multi-provider risk: providers sharing /v1 collapse to the same proxy path", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
},
"github-copilot": {
baseUrl: "https://api.githubcopilot.com/v1",
},
},
},
},
"http://127.0.0.1:8787",
["openai", "github-copilot"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers.openai.baseUrl).toBe(
"http://127.0.0.1:8787/v1",
);
expect((result.config as any).models.providers["github-copilot"].baseUrl).toBe(
"http://127.0.0.1:8787/v1",
);
});
it("re-points an already routed provider to a new proxy origin without duplicating paths", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
"openai-codex": {
baseUrl: "http://127.0.0.1:8787/backend-api",
},
},
},
},
"http://localhost:8787",
["openai-codex"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers["openai-codex"]).toEqual({
baseUrl: "http://localhost:8787/backend-api",
models: [],
});
});
});
describe("applyGatewayProviderBaseUrlsInPlace", () => {
it("updates the live config object in place", () => {
const cfg: any = { models: { providers: {} } };
const changed = applyGatewayProviderBaseUrlsInPlace(
cfg,
"http://127.0.0.1:8787",
["openai-codex"],
);
expect(changed).toBe(true);
expect(cfg.models.providers["openai-codex"]).toEqual({
baseUrl: "http://127.0.0.1:8787/backend-api",
models: [],
});
});
it("does not clobber existing provider logic when changing only the base URL", () => {
const cfg: any = {
models: {
providers: {
"openai-codex": {
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
envKey: "OPENAI_API_KEY",
models: ["gpt-5.3-codex"],
},
},
},
};
const changed = applyGatewayProviderBaseUrlsInPlace(
cfg,
"http://127.0.0.1:8787",
["openai-codex"],
);
expect(changed).toBe(true);
expect(cfg.models.providers["openai-codex"]).toEqual({
api: "openai-codex-responses",
envKey: "OPENAI_API_KEY",
baseUrl: "http://127.0.0.1:8787/backend-api",
models: ["gpt-5.3-codex"],
});
});
});
@@ -0,0 +1,385 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const mocked = vi.hoisted(() => ({
ensureProxyUrl: vi.fn(async () => "http://127.0.0.1:8787"),
ensureProxyStarted: vi.fn(),
getProxyUrl: vi.fn(() => null as string | null),
createHeadroomRetrieveTool: vi.fn(({ proxyUrl }: { proxyUrl: string }) => ({ proxyUrl })),
}));
const proxyReadyListeners: Array<(proxyUrl: string) => void | Promise<void>> = [];
vi.mock("../src/engine.js", () => ({
HeadroomContextEngine: class {
ensureProxyUrl = mocked.ensureProxyUrl;
ensureProxyStarted = mocked.ensureProxyStarted;
getProxyUrl = mocked.getProxyUrl;
onProxyReady(listener: (proxyUrl: string) => void | Promise<void>) {
proxyReadyListeners.push(listener);
return () => {};
}
},
}));
vi.mock("../src/tools/headroom-retrieve.js", () => ({
createHeadroomRetrieveTool: mocked.createHeadroomRetrieveTool,
}));
import headroomPlugin from "../src/plugin/index.js";
afterEach(() => {
vi.restoreAllMocks();
mocked.ensureProxyUrl.mockClear();
mocked.ensureProxyStarted.mockClear();
mocked.getProxyUrl.mockReset();
mocked.getProxyUrl.mockReturnValue(null);
mocked.createHeadroomRetrieveTool.mockClear();
proxyReadyListeners.length = 0;
});
describe("headroomPlugin runtime routing", () => {
function stubConfiguredProxyProbe(response: "headroom" | "non-headroom" | "down") {
if (response === "down") {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
return;
}
vi.stubGlobal(
"fetch",
vi.fn((url: string) => {
if (url.endsWith("/readyz")) {
return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve("") });
}
if (url.endsWith("/v1/retrieve/stats")) {
return Promise.resolve({
ok: response === "headroom",
status: response === "headroom" ? 200 : 404,
text: () => Promise.resolve(""),
});
}
if (url.endsWith("/stats")) {
return Promise.resolve({
ok: response === "headroom",
status: response === "headroom" ? 200 : 200,
text: () =>
Promise.resolve(response === "headroom" ? JSON.stringify({ proxy_inbound: { total: 1 } }) : "{}"),
});
}
return Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve("") });
}),
);
}
it("routes configured providers in memory once the proxy becomes available", async () => {
const gatewayHandlers = new Map<string, () => Promise<void>>();
const writeConfigFile = vi.fn();
const loadConfig = vi.fn(() => ({
models: {
providers: {
anthropic: {
api: "anthropic-messages",
},
},
},
}));
const api: any = {
config: {
plugins: {
entries: {
headroom: {
config: {
gatewayProviderIds: ["codex", "claude", "copilot", "gemini", "openrouter"],
},
},
},
},
models: {
providers: {
anthropic: {
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
},
"github-copilot": {
baseUrl: "https://api.githubcopilot.com/v1",
},
google: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
},
openrouter: {
baseUrl: "https://openrouter.ai/api/v1",
},
},
},
},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
registerContextEngine: vi.fn(),
registerTool: vi.fn(),
on: vi.fn((event: string, handler: () => Promise<void>) => {
gatewayHandlers.set(event, handler);
}),
runtime: {
config: {
loadConfig,
writeConfigFile,
},
},
};
headroomPlugin(api);
await Promise.resolve();
// With no active or configured proxy URL, initial routing defers without
// auto-starting the proxy or mutating providers.
expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
expect(mocked.ensureProxyStarted).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
expect(loadConfig).not.toHaveBeenCalled();
expect(api.config.models.providers["openai-codex"]).toBeUndefined();
await proxyReadyListeners[0]?.("http://127.0.0.1:8787");
expect(api.config.models.providers["openai-codex"]).toEqual({
baseUrl: "http://127.0.0.1:8787/backend-api",
models: [],
});
expect(api.config.models.providers.anthropic).toEqual({
api: "anthropic-messages",
baseUrl: "http://127.0.0.1:8787",
models: [],
});
expect(api.config.models.providers["github-copilot"]).toEqual({
baseUrl: "http://127.0.0.1:8787/v1",
models: [],
});
expect(api.config.models.providers.google).toEqual({
baseUrl: "http://127.0.0.1:8787/v1beta",
models: [],
});
expect(api.config.models.providers.openrouter).toEqual({
baseUrl: "http://127.0.0.1:8787/api/v1",
models: [],
});
const gatewayStart = gatewayHandlers.get("gateway_start");
expect(gatewayStart).toBeTypeOf("function");
// getProxyUrl now reports the active proxy so gateway_start re-routes in
// memory without ever auto-starting or awaiting the proxy.
mocked.getProxyUrl.mockReturnValue("http://127.0.0.1:8787");
await gatewayStart?.();
expect(mocked.ensureProxyStarted).not.toHaveBeenCalled();
expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
expect(loadConfig).not.toHaveBeenCalled();
});
it("does not auto-start on gateway_start when no proxy URL is available", async () => {
const gatewayHandlers = new Map<string, () => Promise<void>>();
const api: any = {
config: {
plugins: {
entries: {
headroom: {
config: { gatewayProviderIds: ["claude"] },
},
},
},
models: {
providers: {
anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" },
},
},
},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
registerContextEngine: vi.fn(),
registerTool: vi.fn(),
on: vi.fn((event: string, handler: () => Promise<void>) => {
gatewayHandlers.set(event, handler);
}),
};
headroomPlugin(api);
await Promise.resolve();
await gatewayHandlers.get("gateway_start")?.();
expect(mocked.ensureProxyStarted).not.toHaveBeenCalled();
expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
expect(api.config.models.providers.anthropic).toEqual({
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
});
});
it("routes configured proxyUrl only after it probes as Headroom", async () => {
const gatewayHandlers = new Map<string, () => Promise<void>>();
stubConfiguredProxyProbe("headroom");
const api: any = {
config: {
plugins: {
entries: {
headroom: {
config: {
proxyUrl: "http://127.0.0.1:8787",
gatewayProviderIds: ["claude"],
},
},
},
},
models: {
providers: {
anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" },
},
},
},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
registerContextEngine: vi.fn(),
registerTool: vi.fn(),
on: vi.fn((event: string, handler: () => Promise<void>) => {
gatewayHandlers.set(event, handler);
}),
};
headroomPlugin(api);
await gatewayHandlers.get("gateway_start")?.();
// Configured proxyUrl is probe-gated before provider mutation.
expect(mocked.ensureProxyStarted).not.toHaveBeenCalled();
expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
expect(api.config.models.providers.anthropic).toEqual({
api: "anthropic-messages",
baseUrl: "http://127.0.0.1:8787",
models: [],
});
});
it("does not route configured proxyUrl when the proxy is unavailable", async () => {
const gatewayHandlers = new Map<string, () => Promise<void>>();
stubConfiguredProxyProbe("down");
const api: any = {
config: {
plugins: {
entries: {
headroom: {
config: {
proxyUrl: "http://127.0.0.1:8787",
gatewayProviderIds: ["claude"],
},
},
},
},
models: {
providers: {
anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" },
},
},
},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
registerContextEngine: vi.fn(),
registerTool: vi.fn(),
on: vi.fn((event: string, handler: () => Promise<void>) => {
gatewayHandlers.set(event, handler);
}),
};
headroomPlugin(api);
await Promise.resolve();
await Promise.resolve();
await gatewayHandlers.get("gateway_start")?.();
expect(mocked.ensureProxyStarted).not.toHaveBeenCalled();
expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
expect(api.config.models.providers.anthropic).toEqual({
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
});
expect(api.logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Skipping upstream gateway routing"),
);
});
it("does not route configured proxyUrl when only generic liveness endpoints respond", async () => {
const gatewayHandlers = new Map<string, () => Promise<void>>();
stubConfiguredProxyProbe("non-headroom");
const api: any = {
config: {
plugins: {
entries: {
headroom: {
config: {
proxyUrl: "http://127.0.0.1:8787",
gatewayProviderIds: ["claude"],
},
},
},
},
models: {
providers: {
anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" },
},
},
},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
registerContextEngine: vi.fn(),
registerTool: vi.fn(),
on: vi.fn((event: string, handler: () => Promise<void>) => {
gatewayHandlers.set(event, handler);
}),
};
headroomPlugin(api);
await gatewayHandlers.get("gateway_start")?.();
expect(api.config.models.providers.anthropic).toEqual({
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
});
expect(api.logger.warn).toHaveBeenCalledWith(
expect.stringContaining("configured proxyUrl is not a ready Headroom proxy"),
);
});
it("documents that the retrieve tool can be created from configured proxyUrl before routing is validated", () => {
stubConfiguredProxyProbe("down");
const api: any = {
config: {
plugins: {
entries: {
headroom: {
config: {
proxyUrl: "http://127.0.0.1:8787",
gatewayProviderIds: ["codex"],
},
},
},
},
models: {
providers: {},
},
},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
registerContextEngine: vi.fn(),
registerTool: vi.fn(),
on: vi.fn(),
};
headroomPlugin(api);
const [toolFactory] = api.registerTool.mock.calls[0];
const tool = toolFactory({});
expect(tool).toEqual({ proxyUrl: "http://127.0.0.1:8787" });
expect(mocked.createHeadroomRetrieveTool).toHaveBeenCalledWith({
proxyUrl: "http://127.0.0.1:8787",
});
});
});
+478
View File
@@ -0,0 +1,478 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import {
ProxyManager,
normalizeAndValidateProxyUrl,
isLocalProxyUrl,
probeHeadroomProxy,
} from "../src/proxy-manager.js";
const retrieveStatsBody = JSON.stringify({ store: { entry_count: 0 }, recent_retrievals: [] });
const proxyStatsBody = JSON.stringify({ proxy_inbound: { total: 1 } });
afterEach(() => {
vi.restoreAllMocks();
});
function stubProbeSuccess() {
const mock = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 404 }) // /readyz
.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(retrieveStatsBody),
}); // /v1/retrieve/stats
vi.stubGlobal("fetch", mock);
return mock;
}
function stubProbeNonHeadroom() {
// Every endpoint reachable but non-OK => reachable, non-Headroom (occupied port).
const mock = vi.fn().mockResolvedValue({ ok: false, status: 404 });
vi.stubGlobal("fetch", mock);
return mock;
}
function stubProbeUnreachable() {
const mock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
vi.stubGlobal("fetch", mock);
return mock;
}
describe("normalizeAndValidateProxyUrl", () => {
it("accepts localhost origins", () => {
expect(normalizeAndValidateProxyUrl("http://127.0.0.1:8787")).toBe("http://127.0.0.1:8787");
expect(normalizeAndValidateProxyUrl("http://localhost:8787")).toBe("http://localhost:8787");
});
it("accepts remote URLs", () => {
expect(normalizeAndValidateProxyUrl("http://example.com:8787")).toBe("http://example.com:8787");
expect(normalizeAndValidateProxyUrl("https://headroom.example.com")).toBe("https://headroom.example.com");
expect(normalizeAndValidateProxyUrl("https://headroom.example.com:9090")).toBe("https://headroom.example.com:9090");
});
it("rejects malformed URLs", () => {
expect(() => normalizeAndValidateProxyUrl("ftp://localhost:8787")).toThrow(
/must use http/,
);
expect(() => normalizeAndValidateProxyUrl("http://localhost:8787/path")).toThrow(
/must not include a path/,
);
});
});
describe("isLocalProxyUrl", () => {
it("returns true for localhost addresses", () => {
expect(isLocalProxyUrl("http://127.0.0.1:8787")).toBe(true);
expect(isLocalProxyUrl("http://localhost:8787")).toBe(true);
});
it("returns false for remote addresses", () => {
expect(isLocalProxyUrl("http://example.com:8787")).toBe(false);
expect(isLocalProxyUrl("https://headroom.example.com")).toBe(false);
});
it("returns false for invalid URLs", () => {
expect(isLocalProxyUrl("not-a-url")).toBe(false);
});
});
describe("probeHeadroomProxy", () => {
/**
* Resolve fetch outcomes by request path so tests express the new probe order
* (/readyz, /v1/retrieve/stats, /stats, /health) without depending on call
* sequencing. Unlisted paths reject (treated as unreachable).
*/
function stubByPath(byPath: Record<string, { ok: boolean; status: number; body?: string }>) {
const mock = vi.fn((url: string) => {
for (const [path, response] of Object.entries(byPath)) {
if (url.endsWith(path)) {
return Promise.resolve({
ok: response.ok,
status: response.status,
text: () => Promise.resolve(response.body ?? ""),
});
}
}
return Promise.reject(new Error("ECONNREFUSED"));
});
vi.stubGlobal("fetch", mock);
return mock;
}
it("does not treat /readyz success alone as Headroom identity", async () => {
stubByPath({
"/readyz": { ok: true, status: 200 },
"/v1/retrieve/stats": { ok: false, status: 404 },
"/stats": { ok: false, status: 404 },
"/health": { ok: false, status: 404 },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result.reachable).toBe(true);
expect(result.isHeadroom).toBe(false);
});
it("treats Headroom-shaped /v1/retrieve/stats 200 as Headroom even when /readyz is OK and /health is 503", async () => {
stubByPath({
"/health": { ok: false, status: 503 },
"/readyz": { ok: true, status: 200 },
"/v1/retrieve/stats": { ok: true, status: 200, body: retrieveStatsBody },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result).toEqual({ reachable: true, isHeadroom: true });
});
it("treats Headroom-shaped /v1/retrieve/stats 200 as Headroom even when /health is 503", async () => {
stubByPath({
"/health": { ok: false, status: 503 },
"/readyz": { ok: false, status: 404 },
"/v1/retrieve/stats": { ok: true, status: 200, body: retrieveStatsBody },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result).toEqual({ reachable: true, isHeadroom: true });
});
it("does not treat generic /v1/retrieve/stats 200 as Headroom identity", async () => {
stubByPath({
"/readyz": { ok: true, status: 200 },
"/v1/retrieve/stats": { ok: true, status: 200, body: JSON.stringify({ ok: true }) },
"/stats": { ok: false, status: 404 },
"/health": { ok: true, status: 200 },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result.reachable).toBe(true);
expect(result.isHeadroom).toBe(false);
});
it("falls through from auth-gated /v1/retrieve/stats to Headroom-shaped /stats", async () => {
stubByPath({
"/readyz": { ok: true, status: 200 },
"/v1/retrieve/stats": { ok: false, status: 403 },
"/stats": { ok: true, status: 200, body: proxyStatsBody },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result).toEqual({ reachable: true, isHeadroom: true });
});
it("continues probing when one endpoint is unreachable", async () => {
stubByPath({
"/readyz": { ok: true, status: 200 },
// /v1/retrieve/stats rejects because it is not listed.
"/stats": { ok: true, status: 200, body: proxyStatsBody },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result).toEqual({ reachable: true, isHeadroom: true });
});
it("falls back to /stats only when the response has a Headroom stats shape", async () => {
stubByPath({
// /readyz and /v1/retrieve/stats unavailable (reject), /stats answers.
"/stats": { ok: true, status: 200, body: proxyStatsBody },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result).toEqual({ reachable: true, isHeadroom: true });
});
it("does not treat generic /stats 200 as Headroom identity", async () => {
stubByPath({
"/readyz": { ok: false, status: 404 },
"/v1/retrieve/stats": { ok: false, status: 404 },
"/stats": { ok: true, status: 200, body: JSON.stringify({ uptime: 123 }) },
"/health": { ok: true, status: 200 },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result.reachable).toBe(true);
expect(result.isHeadroom).toBe(false);
});
it("returns reachable but non-headroom when identity endpoints are non-OK", async () => {
stubProbeNonHeadroom();
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result.reachable).toBe(true);
expect(result.isHeadroom).toBe(false);
expect(result.reason).toMatch(/retrieve stats HTTP 404/);
});
it("returns unreachable when no endpoint responds", async () => {
stubProbeUnreachable();
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result.reachable).toBe(false);
expect(result.isHeadroom).toBe(false);
});
});
describe("ProxyManager.start", () => {
it("auto-detects running proxy on default candidates", async () => {
const manager = new ProxyManager({});
// Candidate 1 (127.0.0.1): all four probes fail.
// Candidate 2 (localhost): /v1/retrieve/stats succeeds.
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /readyz
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /v1/retrieve/stats
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /stats
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /health
.mockResolvedValueOnce({ ok: true, status: 200 }) // localhost /readyz
.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(retrieveStatsBody),
}); // localhost /v1/retrieve/stats
vi.stubGlobal("fetch", fetchMock);
const startSpy = vi.spyOn(manager as any, "startHeadroomProxy");
const url = await manager.start();
expect(url).toBe("http://localhost:8787");
expect(startSpy).not.toHaveBeenCalled();
});
it("uses proxyPort for auto-detect candidates", async () => {
const manager = new ProxyManager({ proxyPort: 9797, autoStart: false });
const fetchMock = vi.fn().mockRejectedValue(new Error("down"));
vi.stubGlobal("fetch", fetchMock);
await expect(manager.start()).rejects.toThrow(/127\.0\.0\.1:9797.*localhost:9797/);
});
it("rejects invalid proxyPort", async () => {
const manager = new ProxyManager({ proxyPort: 0 });
await expect(manager.start()).rejects.toThrow(/proxyPort must be an integer between 1 and 65535/);
});
it("fails when explicit URL is reachable but not a headroom proxy", async () => {
const manager = new ProxyManager({ proxyUrl: "http://127.0.0.1:8787" });
stubProbeNonHeadroom();
await expect(manager.start()).rejects.toThrow(/does not appear to be a Headroom proxy/);
});
it("applies default proxyPort when explicit proxyUrl omits port", async () => {
const manager = new ProxyManager({ proxyUrl: "http://127.0.0.1", autoStart: true });
const startSpy = vi.spyOn(manager as any, "startHeadroomProxy").mockResolvedValue(undefined);
// Initial probe of the single candidate fails on all four endpoints, then
// after auto-start the identity probe succeeds on /v1/retrieve/stats.
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new Error("down")) // /readyz
.mockRejectedValueOnce(new Error("down")) // /v1/retrieve/stats
.mockRejectedValueOnce(new Error("down")) // /stats
.mockRejectedValueOnce(new Error("down")) // /health
.mockResolvedValueOnce({ ok: true, status: 200 }) // post-start /readyz
.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(retrieveStatsBody),
}); // post-start /v1/retrieve/stats
vi.stubGlobal("fetch", fetchMock);
const url = await manager.start();
expect(url).toBe("http://127.0.0.1:8787");
expect(startSpy).toHaveBeenCalledWith("http://127.0.0.1:8787", 8787);
});
it("connects to remote proxy without auto-start", async () => {
const manager = new ProxyManager({ proxyUrl: "http://headroom.remote.example:8787", autoStart: true });
const startSpy = vi.spyOn(manager as any, "startHeadroomProxy").mockResolvedValue(undefined);
stubProbeSuccess();
const url = await manager.start();
expect(url).toBe("http://headroom.remote.example:8787");
expect(startSpy).not.toHaveBeenCalled();
});
it("does not apply proxyPort default to remote URLs", async () => {
const manager = new ProxyManager({ proxyUrl: "https://headroom.remote.example", proxyPort: 9999 });
stubProbeSuccess();
const url = await manager.start();
expect(url).toBe("https://headroom.remote.example");
});
it("fails fast for unreachable remote proxy without attempting auto-start", async () => {
const manager = new ProxyManager({ proxyUrl: "https://headroom.remote.example:8787", autoStart: true });
const startSpy = vi.spyOn(manager as any, "startHeadroomProxy").mockResolvedValue(undefined);
stubProbeUnreachable();
await expect(manager.start()).rejects.toThrow(/Remote Headroom proxy not reachable/);
expect(startSpy).not.toHaveBeenCalled();
});
it("auto-starts when nothing is detected", async () => {
const manager = new ProxyManager({ autoStart: true });
const startSpy = vi.spyOn(manager as any, "startHeadroomProxy").mockResolvedValue(undefined);
// Both candidates fail all four probes, then the post-start identity probe
// succeeds on /v1/retrieve/stats.
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /readyz
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /v1/retrieve/stats
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /stats
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /health
.mockRejectedValueOnce(new Error("down")) // localhost /readyz
.mockRejectedValueOnce(new Error("down")) // localhost /v1/retrieve/stats
.mockRejectedValueOnce(new Error("down")) // localhost /stats
.mockRejectedValueOnce(new Error("down")) // localhost /health
.mockResolvedValueOnce({ ok: true, status: 200 }) // post-start /readyz
.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(retrieveStatsBody),
}); // post-start /v1/retrieve/stats
vi.stubGlobal("fetch", fetchMock);
const url = await manager.start();
expect(url).toBe("http://127.0.0.1:8787");
expect(startSpy).toHaveBeenCalledWith("http://127.0.0.1:8787", 8787);
});
});
describe("ProxyManager launch internals", () => {
it("prefers configured pythonPath in fallback order", () => {
const manager = new ProxyManager({ pythonPath: "C:\\Python311\\python.exe" });
const commands = (manager as any).getPythonCommands() as string[];
expect(commands[0]).toBe("C:\\Python311\\python.exe");
expect(commands).toContain("python");
expect(commands).toContain("python3");
expect(commands).toContain("py");
});
it("prefers configured pythonPath ahead of PATH launchers", () => {
const manager = new ProxyManager({ pythonPath: "C:\\Python311\\python.exe" });
vi.spyOn(manager as any, "getPyenvResolvedHeadroom").mockReturnValue(null);
const specs = (manager as any).buildLaunchSpecs("127.0.0.1", "8787") as Array<Record<string, unknown>>;
expect(specs[0]?.label).toContain("Configured Python:");
expect(specs[0]?.command).toBe("C:\\Python311\\python.exe");
expect(specs[0]?.args).toEqual(["-m", "headroom.cli", "proxy", "--host", "127.0.0.1", "--port", "8787"]);
});
it("uses lightweight PATH checks instead of booting the headroom CLI", () => {
const manager = new ProxyManager({});
const specs = (manager as any).buildLaunchSpecs("127.0.0.1", "8787") as Array<Record<string, unknown>>;
const pathSpec = specs.find((spec) => spec.command === "headroom");
expect(pathSpec).toBeDefined();
expect(pathSpec.command).toBe("headroom");
expect(pathSpec.args).toEqual(["proxy", "--host", "127.0.0.1", "--port", "8787"]);
if (process.platform === "win32") {
expect(pathSpec.checkCommand).toBe("where.exe");
expect(pathSpec.checkArgs).toEqual(["headroom"]);
expect(pathSpec.checkUseShell).toBe(false);
} else {
expect(pathSpec.checkCommand).toBe("sh");
expect(pathSpec.checkArgs).toEqual(["-lc", "command -v headroom >/dev/null 2>&1"]);
}
});
it("prefers a resolved pyenv executable on Windows before PATH shims", () => {
if (process.platform !== "win32") return;
const manager = new ProxyManager({});
vi.spyOn(manager as any, "getPyenvResolvedHeadroom").mockReturnValue("C:\\Python312\\Scripts\\headroom.exe");
const specs = (manager as any).buildLaunchSpecs("127.0.0.1", "8787") as Array<Record<string, unknown>>;
expect(specs[0]?.label).toContain("pyenv:");
expect(specs[0]?.command).toBe("C:\\Python312\\Scripts\\headroom.exe");
expect(specs[0]?.useShell).toBe(false);
expect(specs[1]?.command).toBe("headroom");
});
it("passes through fast-fail launch flags when configured", () => {
const manager = new ProxyManager({ retryMaxAttempts: 1, connectTimeoutSeconds: 3 });
const specs = (manager as any).buildLaunchSpecs("127.0.0.1", "8787") as Array<Record<string, unknown>>;
const pathSpec = specs[0];
expect(pathSpec.args).toEqual([
"proxy",
"--host",
"127.0.0.1",
"--port",
"8787",
"--retry-max-attempts",
"1",
"--connect-timeout-seconds",
"3",
]);
});
it("uses lightweight module discovery for python fallback checks", () => {
const manager = new ProxyManager({ pythonPath: "C:\\Python311\\python.exe" });
const specs = (manager as any).buildLaunchSpecs("127.0.0.1", "8787") as Array<Record<string, unknown>>;
const pythonSpec = specs.find((spec) => spec.command === "C:\\Python311\\python.exe");
expect(pythonSpec).toBeDefined();
expect(pythonSpec?.checkArgs).toEqual([
"-c",
"import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('headroom') else 1)",
]);
});
it("uses first available launcher from provided specs", async () => {
const manager = new ProxyManager({});
(manager as any).buildLaunchSpecs = () => [
{
label: "first",
command: "first-missing-command",
args: ["proxy"],
checkCommand: "first-missing-command",
checkArgs: ["--version"],
},
{
label: "second-node",
command: "node",
args: ["-e", ""],
checkCommand: "node",
checkArgs: ["--version"],
},
];
const infoSpy = vi.spyOn((manager as any).logger, "info");
await (manager as any).startHeadroomProxy("http://127.0.0.1:8787");
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("Auto-start launcher selected"));
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("second-node"));
});
it("supports shell-backed launch specs for PATH shims and script wrappers", async () => {
const manager = new ProxyManager({});
const shellBuiltin = process.platform === "win32" ? "dir" : ":";
(manager as any).buildLaunchSpecs = () => [
{
label: "shell-backed",
command: shellBuiltin,
args: [],
checkCommand: shellBuiltin,
checkArgs: [],
useShell: true,
},
];
const infoSpy = vi.spyOn((manager as any).logger, "info");
await (manager as any).startHeadroomProxy("http://127.0.0.1:8787", 8787);
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("Auto-start launcher selected"));
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("shell-backed"));
});
it("throws when no launcher is executable", async () => {
const manager = new ProxyManager({});
(manager as any).buildLaunchSpecs = () => [
{
label: "none",
command: "none",
args: ["proxy"],
checkCommand: "none",
checkArgs: ["--version"],
},
];
(manager as any).canExecute = () => false;
await expect((manager as any).startHeadroomProxy("http://127.0.0.1:8787")).rejects.toThrow(
/No usable Headroom launcher found/,
);
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"isolatedModules": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "test"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: { index: "src/index.ts" },
format: ["esm"],
dts: true,
sourcemap: true,
clean: true,
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
},
});
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
*.log
+107
View File
@@ -0,0 +1,107 @@
# headroom-opencode
OpenCode integration helpers for Headroom. The package supports two integration paths:
1. Provider config helpers used by `headroom wrap opencode` and persistent installs.
2. A native OpenCode plugin that installs Headroom transport interception and exposes the retrieve tool.
## Install
```bash
npm install headroom-opencode
```
## Provider Config Helpers
Use these helpers when you need to generate OpenCode config that routes a `headroom` provider through a running Headroom proxy.
```ts
import {
buildOpencodeConfigContent,
createHeadroomProvider,
} from "headroom-opencode";
const provider = createHeadroomProvider({ proxyPort: 8787 });
const config = buildOpencodeConfigContent({
proxyPort: 8787,
defaultModel: "claude-sonnet-4-6",
});
console.log(provider.provider.headroom.npm);
console.log(config.model);
```
The generated provider uses `@ai-sdk/openai-compatible` and points model requests at `http://127.0.0.1:<port>/v1`.
## Native OpenCode Plugin
Use `HeadroomPlugin` when OpenCode should intercept provider traffic in-process and expose Headroom tooling from a plugin.
```ts
import { HeadroomPlugin } from "headroom-opencode";
export default async function plugin(input) {
return HeadroomPlugin(input, {
proxyUrl: process.env.HEADROOM_PROXY_URL ?? "http://127.0.0.1:8787",
});
}
```
`HeadroomPlugin`:
- installs Headroom transport interception for OpenCode provider traffic.
- exposes the `headroom_retrieve` tool.
- publishes `HEADROOM_PROXY_URL` in the plugin output env.
- defaults to `http://127.0.0.1:8787` when no proxy URL is supplied.
## Retrieve Tool
```ts
import { createHeadroomRetrieveTool } from "headroom-opencode";
const retrieve = createHeadroomRetrieveTool({
proxyBaseUrl: "http://127.0.0.1:8787",
});
const result = await retrieve.execute({
hash: "0123456789abcdef01234567",
});
```
The tool calls `/v1/retrieve/<hash>` on the Headroom proxy.
## Compression Helper
```ts
import { compressWithHeadroom } from "headroom-opencode";
const result = await compressWithHeadroom(
[{ role: "user", content: "Summarize this file" }],
{ model: "gpt-4o", proxyUrl: "http://127.0.0.1:8787" },
);
console.log(`Saved ${result.tokensSaved} tokens`);
```
## Models
| Model | Context | Output |
|---|---:|---:|
| `claude-sonnet-4-6` | 200K | 16K |
| `claude-opus-4-6` | 200K | 16K |
| `claude-haiku-4-5-20251001` | 200K | 8K |
| `gpt-4o` | 128K | 16K |
| `gpt-4.1` | 1M | 32K |
The provider config exposes these as `headroom/<model>` and defaults to `headroom/claude-sonnet-4-6`.
## Environment
| Variable | Used by | Description |
|---|---|---|
| `HEADROOM_PROXY_URL` | Native plugin | Proxy URL used by `HeadroomPlugin` |
| `OPENCODE_CONFIG_CONTENT` | OpenCode wrapper | Generated OpenCode provider, model, and MCP config |
## License
Apache-2.0
+8
View File
@@ -0,0 +1,8 @@
import { installHeadroomTransport } from "../dist/index.js";
const proxyUrl = process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
if (!proxyUrl) {
throw new Error("Headroom OpenCode transport shim loaded without HEADROOM_OPENCODE_TRANSPORT_PROXY_URL");
}
installHeadroomTransport({ proxyUrl });
+3091
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "headroom-opencode",
"version": "0.1.0",
"description": "Headroom proxy integration plugin for OpenCode - routes LLM traffic through the Headroom proxy for token compression",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist",
"hook-shim",
"README.md"
],
"scripts": {
"build": "tsup",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@opencode-ai/plugin": "^1.17.16",
"headroom-ai": "^0.22.3"
},
"peerDependencies": {
"@ai-sdk/openai-compatible": "*",
"@ai-sdk/provider": "*",
"ai": "*"
},
"peerDependenciesMeta": {
"@ai-sdk/openai-compatible": {
"optional": true
},
"@ai-sdk/provider": {
"optional": true
},
"ai": {
"optional": true
}
},
"devDependencies": {
"@types/node": "^26.1.1",
"tsup": "^8.0.0",
"typescript": "^5.5.0",
"vitest": "^4.1.10"
},
"license": "Apache-2.0"
}
+7
View File
@@ -0,0 +1,7 @@
// Dedicated entry for OpenCode's plugin loader.
//
// OpenCode loads a plugin module and treats its exports as plugin factories —
// it rejects the module if a non-function export is present ("Plugin export is
// not a function"). The library barrel (index.ts) re-exports helpers/constants,
// so it cannot be loaded directly. This entry exports ONLY the plugin function.
export { HeadroomPlugin as default } from "./plugin.js";
+23
View File
@@ -0,0 +1,23 @@
export {
DEFAULT_MODEL,
DEFAULT_MODELS,
buildOpencodeConfigContent,
buildOpencodeConfigContentJson,
createHeadroomProvider,
} from "./provider.js";
export type {
HeadroomModelMapping,
HeadroomProvider,
HeadroomProviderOptions,
} from "./provider.js";
export {
compressWithHeadroom,
createHeadroomRetrieveTool,
getDefaultProxyUrl,
setDefaultProxyUrl,
} from "./retrieve.js";
export type { RetrieveToolConfig } from "./retrieve.js";
export { HeadroomPlugin, default } from "./plugin.js";
export type { HeadroomOpenCodePluginOptions } from "./plugin.js";
export { installHeadroomTransport } from "./transport.js";
+68
View File
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { HeadroomPlugin } from "./plugin.js";
function pluginInput() {
return {
client: {},
project: { id: "project-1" },
directory: "/repo",
worktree: "/repo",
experimental_workspace: {
register: vi.fn(),
},
$: {},
} as never;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("HeadroomPlugin", () => {
it("adds only Headroom metadata to shell env", async () => {
const plugin = await HeadroomPlugin(pluginInput(), {
proxyUrl: "http://127.0.0.1:8787/",
backend: "litellm",
});
const output = {
env: {
OPENAI_BASE_URL: "https://deepseek.example/v1",
ANTHROPIC_BASE_URL: "https://anthropic.example",
},
};
await plugin["shell.env"]?.({ cwd: "/repo" }, output);
expect(output.env).toMatchObject({
HEADROOM_ACTIVE: "1",
HEADROOM_PROXY_URL: "http://127.0.0.1:8787",
HEADROOM_PROJECT: "project-1",
HEADROOM_BACKEND: "litellm",
OPENAI_BASE_URL: "https://deepseek.example/v1",
ANTHROPIC_BASE_URL: "https://anthropic.example",
});
});
it("exposes a headroom_retrieve tool backed by the proxy", async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => "original content",
}));
vi.stubGlobal("fetch", fetchMock);
const plugin = await HeadroomPlugin(pluginInput(), {
proxyUrl: "http://127.0.0.1:8787",
});
const result = await plugin.tool?.headroom_retrieve.execute(
{ hash: "0123456789abcdef01234567" },
{} as never,
);
expect(result).toBe("original content");
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:8787/v1/retrieve/0123456789abcdef01234567",
expect.any(Object),
);
});
});
+68
View File
@@ -0,0 +1,68 @@
import type { Plugin } from "@opencode-ai/plugin";
import { tool } from "@opencode-ai/plugin";
import { z } from "zod";
import { createHeadroomRetrieveTool, getDefaultProxyUrl } from "./retrieve.js";
import { installHeadroomTransport } from "./transport.js";
export interface HeadroomOpenCodePluginOptions {
proxyUrl?: string;
project?: string;
backend?: string;
debug?: boolean;
}
function normalizeProxyUrl(url: string): string {
return url.replace(/\/+$/, "");
}
function resolveProxyUrl(options?: HeadroomOpenCodePluginOptions): string {
return normalizeProxyUrl(
options?.proxyUrl ??
process.env.HEADROOM_PROXY_URL ??
process.env.HEADROOM_BASE_URL ??
getDefaultProxyUrl(),
);
}
export const HeadroomPlugin: Plugin = async (input, options = {}) => {
const pluginOptions = options as HeadroomOpenCodePluginOptions;
const proxyUrl = resolveProxyUrl(pluginOptions);
const retrieveTool = createHeadroomRetrieveTool({ proxyBaseUrl: proxyUrl });
const uninstallTransport = installHeadroomTransport({
proxyUrl,
debug: pluginOptions.debug,
});
return {
dispose: async () => {
uninstallTransport();
},
tool: {
headroom_retrieve: tool({
description: retrieveTool.description,
args: {
hash: z
.string()
.regex(/^[a-f0-9]{24}$/i, "Expected 24-character hex hash"),
},
async execute(args) {
return retrieveTool.execute(args);
},
}),
},
"shell.env": async (_input, output) => {
output.env.HEADROOM_ACTIVE = "1";
output.env.HEADROOM_PROXY_URL = proxyUrl;
output.env.HEADROOM_PROJECT =
pluginOptions.project ??
(input.project as { id?: string }).id ??
input.directory;
if (pluginOptions.backend) {
output.env.HEADROOM_BACKEND = pluginOptions.backend;
}
},
};
};
export default HeadroomPlugin;
+89
View File
@@ -0,0 +1,89 @@
export interface HeadroomModelMapping {
name: string;
limit: {
context: number;
output: number;
};
}
export interface HeadroomProviderOptions {
proxyBaseUrl?: string;
proxyPort?: number;
defaultModel?: string;
models?: Record<string, HeadroomModelMapping>;
}
export const DEFAULT_MODELS: Record<string, HeadroomModelMapping> = {
"claude-sonnet-4-6": {
name: "Claude Sonnet 4.6",
limit: { context: 200000, output: 16384 },
},
"claude-opus-4-6": {
name: "Claude Opus 4.6",
limit: { context: 200000, output: 16384 },
},
"claude-haiku-4-5-20251001": {
name: "Claude Haiku 4.5",
limit: { context: 200000, output: 8192 },
},
"gpt-4o": {
name: "GPT-4o",
limit: { context: 128000, output: 16384 },
},
"gpt-4.1": {
name: "GPT-4.1",
limit: { context: 1048576, output: 32768 },
},
};
export const DEFAULT_MODEL = "claude-sonnet-4-6";
function resolveBaseUrl(options: HeadroomProviderOptions): string {
if (options.proxyBaseUrl) return options.proxyBaseUrl.replace(/\/+$/, "");
const port = options.proxyPort ?? 8787;
return `http://127.0.0.1:${port}`;
}
export interface HeadroomProvider {
npm: string;
name: string;
options: {
baseURL: string;
apiKey?: string;
};
models: Record<string, HeadroomModelMapping>;
}
export function createHeadroomProvider(
options: HeadroomProviderOptions = {},
): HeadroomProvider {
const baseUrl = resolveBaseUrl(options);
const models = options.models ?? DEFAULT_MODELS;
return {
npm: "@ai-sdk/openai-compatible",
name: "Headroom Proxy",
options: { baseURL: `${baseUrl}/v1` },
// OpenCode namespaces model ids by provider key, so entries must be bare
// ids ("claude-sonnet-4-6"), referenced as "headroom/<id>".
models: { ...models },
};
}
export function buildOpencodeConfigContent(
options: HeadroomProviderOptions = {},
): Record<string, unknown> {
const defaultModel = options.defaultModel ?? DEFAULT_MODEL;
const provider = createHeadroomProvider(options);
return {
provider: { headroom: provider },
model: `headroom/${defaultModel}`,
};
}
export function buildOpencodeConfigContentJson(
options: HeadroomProviderOptions = {},
): string {
return JSON.stringify(buildOpencodeConfigContent(options));
}
+88
View File
@@ -0,0 +1,88 @@
import type { CompressResult } from "headroom-ai";
import { compress } from "headroom-ai";
let _proxyUrlCache: string | null = null;
export function setDefaultProxyUrl(url: string): void {
_proxyUrlCache = url;
}
export function getDefaultProxyUrl(): string {
return _proxyUrlCache ?? process.env.HEADROOM_BASE_URL ?? "http://localhost:8787";
}
export interface RetrieveToolConfig {
proxyBaseUrl: string;
}
export function createHeadroomRetrieveTool(config: RetrieveToolConfig) {
const origin = config.proxyBaseUrl.replace(/\/+$/, "");
return {
name: "headroom_retrieve",
description:
"Retrieve original uncompressed content from Headroom's compression store. " +
"Use when compressed context mentions a hash and you need the full details. " +
"Pass the hash from the compression marker (24 hex characters). " +
"Retrieval is by hash and always returns the full original content.",
parameters: {
type: "object" as const,
properties: {
hash: {
type: "string",
description: "The 24-character hex hash from the compression marker",
},
},
required: ["hash"],
},
execute: async (args: { hash: string }): Promise<string> => {
const { hash } = args;
if (!/^[a-f0-9]{24}$/i.test(hash)) {
return JSON.stringify({
error: "Invalid hash format. Expected 24 hex characters.",
});
}
try {
const url = `${origin}/v1/retrieve/${hash}`;
const resp = await fetch(url, {
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
const body = await resp.text().catch(() => "");
return JSON.stringify({
error: `Retrieval failed: HTTP ${resp.status}`,
details: body,
});
}
const data = await resp.json();
return typeof data === "string" ? data : JSON.stringify(data);
} catch (error) {
return JSON.stringify({
error: `Retrieval failed: ${error}`,
hint: "The compressed content may have expired (default TTL: 5 minutes)",
});
}
},
};
}
export async function compressWithHeadroom(
messages: unknown[],
options: {
model?: string;
tokenBudget?: number;
proxyUrl?: string;
} = {},
): Promise<CompressResult> {
return compress(messages, {
baseUrl: options.proxyUrl ?? getDefaultProxyUrl(),
model: options.model ?? "gpt-4o",
tokenBudget: options.tokenBudget,
stack: "opencode",
});
}
+379
View File
@@ -0,0 +1,379 @@
import childProcess from "node:child_process";
import http from "node:http";
import http2 from "node:http2";
import https from "node:https";
import { afterEach, describe, expect, it, vi } from "vitest";
import { installHeadroomTransport, uninstallHeadroomTransport } from "./transport.js";
afterEach(() => {
uninstallHeadroomTransport();
vi.restoreAllMocks();
});
type FetchCall = [RequestInfo | URL, RequestInit?];
type SeenRequest = {
method: string | undefined;
url: string | undefined;
headers: http.IncomingHttpHeaders;
body: string;
};
function proxyServer(pathPrefix: string = "/v1"): Promise<{
url: string;
seen: SeenRequest[];
close: () => Promise<void>;
}> {
const seen: SeenRequest[] = [];
const server = http.createServer((req, res) => {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
seen.push({ method: req.method, url: req.url, headers: req.headers, body });
res.writeHead(200, { "content-type": "application/json" });
res.end("{\"ok\":true}");
});
});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("Expected TCP server address"));
return;
}
resolve({
url: `http://127.0.0.1:${address.port}${pathPrefix}`,
seen,
close: () => new Promise((done) => server.close(() => done())),
});
});
});
}
describe("Headroom OpenCode transport", () => {
it("routes fetch chat paths through /v1/chat/completions with proxy base and normalized-path header", async () => {
const proxyTargets = ["http://127.0.0.1:8787", "http://127.0.0.1:8787/v1"];
const upstreamPath = "/api/coding/paas/v4/chat/completions";
for (const proxyUrl of proxyTargets) {
const proxyOrigin = new URL(proxyUrl).origin;
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
globalThis.fetch = fetchMock as unknown as typeof fetch;
installHeadroomTransport({ proxyUrl });
await fetch(`https://open.bigmodel.cn${upstreamPath}`, { method: "POST", headers: { "content-type": "application/json" } });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toEqual(new URL(`${proxyOrigin}/v1/chat/completions`));
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
expect(headers.get("x-headroom-base-url")).toBe("https://open.bigmodel.cn");
expect(headers.get("x-headroom-original-path")).toBe(upstreamPath);
globalThis.fetch = originalFetch;
uninstallHeadroomTransport();
}
});
it("routes fetch responses paths through /v1/responses with proxy base and normalized-path header", async () => {
const proxyTargets = ["http://127.0.0.1:8787", "http://127.0.0.1:8787/v1"];
const upstreamPath = "/api/coding/paas/v4/responses";
for (const proxyUrl of proxyTargets) {
const proxyOrigin = new URL(proxyUrl).origin;
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
globalThis.fetch = fetchMock as unknown as typeof fetch;
installHeadroomTransport({ proxyUrl });
await fetch(`https://open.bigmodel.cn${upstreamPath}`, { method: "POST", headers: { "content-type": "application/json" } });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toEqual(new URL(`${proxyOrigin}/v1/responses`));
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
expect(headers.get("x-headroom-base-url")).toBe("https://open.bigmodel.cn");
expect(headers.get("x-headroom-original-path")).toBe(upstreamPath);
globalThis.fetch = originalFetch;
uninstallHeadroomTransport();
}
});
it("routes external fetch calls through the proxy without pre-registering providers", async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
globalThis.fetch = fetchMock as unknown as typeof fetch;
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
await fetch("https://api.deepseek.com/v1/chat/completions?x=1", {
method: "POST",
headers: { authorization: "Bearer test" },
});
await fetch("https://new-provider.example/base/v1/messages", { method: "POST" });
expect(fetchMock).toHaveBeenNthCalledWith(
1,
new URL("http://127.0.0.1:8787/v1/chat/completions?x=1"),
expect.objectContaining({ method: "POST" }),
);
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get("x-headroom-base-url")).toBe(
"https://api.deepseek.com",
);
expect(fetchMock.mock.calls[1][0]).toEqual(new URL("http://127.0.0.1:8787/base/v1/messages"));
expect(new Headers(fetchMock.mock.calls[1][1]?.headers).get("x-headroom-base-url")).toBe(
"https://new-provider.example",
);
globalThis.fetch = originalFetch;
});
it("preserves non-prefix paths like /base/v1/messages", async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
globalThis.fetch = fetchMock as unknown as typeof fetch;
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
await fetch("https://example.test/base/v1/messages", { method: "POST" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toEqual(new URL("http://127.0.0.1:8787/base/v1/messages"));
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get("x-headroom-original-path")).toBeNull();
globalThis.fetch = originalFetch;
});
it("bypasses local, OpenCode, and Headroom proxy fetch URLs", async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
globalThis.fetch = fetchMock as unknown as typeof fetch;
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
await fetch("http://127.0.0.1:8787/v1/retrieve");
await fetch("http://localhost:4096/config");
expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:8787/v1/retrieve");
expect(fetchMock.mock.calls[1][0]).toBe("http://localhost:4096/config");
globalThis.fetch = originalFetch;
});
it("routes external https.request calls through the proxy", async () => {
const proxy = await proxyServer();
installHeadroomTransport({ proxyUrl: proxy.url });
await new Promise<void>((resolve, reject) => {
const req = https.request(
"https://api.anthropic.com/v1/messages?beta=1",
{ method: "POST", headers: { authorization: "Bearer test" } },
(res) => {
res.resume();
res.on("end", resolve);
},
);
req.on("error", reject);
req.end("{\"model\":\"claude\"}");
});
expect(proxy.seen).toHaveLength(1);
expect(proxy.seen[0]).toMatchObject({ method: "POST", url: "/v1/messages?beta=1" });
expect(proxy.seen[0].headers["x-headroom-base-url"]).toBe("https://api.anthropic.com");
expect(proxy.seen[0].headers.host).toMatch(/^127\.0\.0\.1:/);
expect(proxy.seen[0].body).toBe("{\"model\":\"claude\"}");
await proxy.close();
});
it("normalizes Node HTTP(S) requests for /chat/completions and /responses", async () => {
const proxy = await proxyServer("");
installHeadroomTransport({ proxyUrl: proxy.url });
const httpChatPath = "/api/coding/paas/v4/chat/completions";
const httpResponsesPath = "/api/coding/paas/v4/responses";
const httpsChatPath = "/v4/openai/chat/completions";
const httpsResponsesPath = "/v4/openai/responses";
await new Promise<void>((resolve, reject) => {
const req = http.request(
`http://open.bigmodel.cn${httpChatPath}`,
{ method: "POST", headers: { authorization: "Bearer test" } },
(res) => {
res.resume();
res.on("end", resolve);
},
);
req.on("error", reject);
req.end("{\"model\":\"gpt-4\"}");
});
await new Promise<void>((resolve, reject) => {
const req = http.request(
`http://open.bigmodel.cn${httpResponsesPath}`,
{ method: "POST", headers: { authorization: "Bearer test" } },
(res) => {
res.resume();
res.on("end", resolve);
},
);
req.on("error", reject);
req.end("{\"model\":\"gpt-4\"}");
});
await new Promise<void>((resolve, reject) => {
const req = https.request(
`https://api.deepseek.com${httpsChatPath}`,
{ method: "POST", headers: { authorization: "Bearer test" } },
(res) => {
res.resume();
res.on("end", resolve);
},
);
req.on("error", reject);
req.end("{\"model\":\"gpt-4\"}");
});
await new Promise<void>((resolve, reject) => {
const req = https.request(
`https://api.deepseek.com${httpsResponsesPath}`,
{ method: "POST", headers: { authorization: "Bearer test" } },
(res) => {
res.resume();
res.on("end", resolve);
},
);
req.on("error", reject);
req.end("{\"model\":\"gpt-4\"}");
});
expect(proxy.seen[0]).toMatchObject({
method: "POST",
url: "/v1/chat/completions",
headers: expect.objectContaining({
"x-headroom-base-url": "http://open.bigmodel.cn",
"x-headroom-original-path": httpChatPath,
}),
});
expect(proxy.seen[1]).toMatchObject({
method: "POST",
url: "/v1/responses",
headers: expect.objectContaining({
"x-headroom-base-url": "http://open.bigmodel.cn",
"x-headroom-original-path": httpResponsesPath,
}),
});
expect(proxy.seen[2]).toMatchObject({
method: "POST",
url: "/v1/chat/completions",
headers: expect.objectContaining({
"x-headroom-base-url": "https://api.deepseek.com",
"x-headroom-original-path": httpsChatPath,
}),
});
expect(proxy.seen[3]).toMatchObject({
method: "POST",
url: "/v1/responses",
headers: expect.objectContaining({
"x-headroom-base-url": "https://api.deepseek.com",
"x-headroom-original-path": httpsResponsesPath,
}),
});
await proxy.close();
});
it("blocks external http2 connections instead of leaking them", () => {
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
expect(() => http2.connect("https://api.openai.com")).toThrow(
/blocked direct HTTP\/2 connection to https:\/\/api\.openai\.com/,
);
});
it("preloads the Headroom shim into child Node processes", () => {
const originalNodeOptions = process.env.NODE_OPTIONS;
const originalProxyUrl = process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
try {
process.env.NODE_OPTIONS = "--trace-warnings";
delete process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
expect(process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL).toBe("http://127.0.0.1:8787/v1");
expect(process.env.NODE_OPTIONS).toContain("--trace-warnings");
expect(process.env.NODE_OPTIONS).toContain("--import=file:");
expect(process.env.NODE_OPTIONS).toContain("/hook-shim/handler.js");
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
expect(process.env.NODE_OPTIONS?.match(/hook-shim\/handler\.js/g)).toHaveLength(1);
} finally {
if (originalNodeOptions === undefined) {
delete process.env.NODE_OPTIONS;
} else {
process.env.NODE_OPTIONS = originalNodeOptions;
}
if (originalProxyUrl === undefined) {
delete process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
} else {
process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL = originalProxyUrl;
}
uninstallHeadroomTransport();
}
});
it("injects the Headroom shim into child processes with custom env", () => {
const originalSpawn = childProcess.spawn;
const spawnMock = vi.fn(() => ({
on: vi.fn(),
once: vi.fn(),
emit: vi.fn(),
kill: vi.fn(),
killed: false,
pid: 123,
}));
childProcess.spawn = spawnMock as unknown as typeof childProcess.spawn;
try {
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
childProcess.spawn("node", ["agent.js"], { env: { PATH: "/bin", NODE_OPTIONS: "--trace-warnings" } });
const options = (spawnMock.mock.calls[0] as unknown[])[2] as { env: NodeJS.ProcessEnv };
expect(options.env.PATH).toBe("/bin");
expect(options.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL).toBe("http://127.0.0.1:8787/v1");
expect(options.env.NODE_OPTIONS).toContain("--trace-warnings");
expect(options.env.NODE_OPTIONS).toContain("--import=file:");
expect(options.env.NODE_OPTIONS).toContain("/hook-shim/handler.js");
} finally {
uninstallHeadroomTransport();
childProcess.spawn = originalSpawn;
}
});
it("restores patched transports only after the final disposer", () => {
const originalFetch = globalThis.fetch;
const originalHttpRequest = http.request;
const originalHttpsRequest = https.request;
const firstDispose = installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
const secondDispose = installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8788/v1" });
expect(globalThis.fetch).not.toBe(originalFetch);
expect(http.request).not.toBe(originalHttpRequest);
expect(https.request).not.toBe(originalHttpsRequest);
firstDispose();
expect(globalThis.fetch).not.toBe(originalFetch);
expect(http.request).not.toBe(originalHttpRequest);
secondDispose();
expect(globalThis.fetch).toBe(originalFetch);
expect(http.request).toBe(originalHttpRequest);
expect(https.request).toBe(originalHttpsRequest);
});
});
+479
View File
@@ -0,0 +1,479 @@
import { createRequire, syncBuiltinESMExports } from "node:module";
const nodeRequire = createRequire(import.meta.url);
const http = nodeRequire("node:http") as typeof import("node:http");
const https = nodeRequire("node:https") as typeof import("node:https");
const http2 = nodeRequire("node:http2") as typeof import("node:http2");
const childProcess = nodeRequire("node:child_process") as typeof import("node:child_process");
const BASE_URL_HEADER = "x-headroom-base-url";
const ORIGINAL_PATH_HEADER = "x-headroom-original-path";
const PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
const STATE_KEY = Symbol.for("headroom.opencode.transport");
type FetchArgs = Parameters<typeof fetch>;
type HttpRequest = typeof http.request;
type HttpGet = typeof http.get;
type HttpsRequest = typeof https.request;
type HttpsGet = typeof https.get;
type Http2Connect = typeof http2.connect;
type ChildSpawn = typeof childProcess.spawn;
type ChildExec = typeof childProcess.exec;
type ChildExecFile = typeof childProcess.execFile;
type ChildFork = typeof childProcess.fork;
interface InstallOptions {
proxyUrl: string;
debug?: boolean;
}
interface TransportState {
refs: number;
proxyUrl: string;
debug: boolean;
originalFetch: typeof fetch;
originalHttpRequest: HttpRequest;
originalHttpGet: HttpGet;
originalHttpsRequest: HttpsRequest;
originalHttpsGet: HttpsGet;
originalHttp2Connect: Http2Connect;
originalChildSpawn: ChildSpawn;
originalChildExec: ChildExec;
originalChildExecFile: ChildExecFile;
originalChildFork: ChildFork;
}
interface GlobalWithHeadroomTransport {
[STATE_KEY]?: TransportState;
}
interface NodeRequestParts {
url?: URL;
options: Record<string, unknown>;
callback?: (...args: unknown[]) => unknown;
}
function getState(): TransportState | undefined {
return (globalThis as GlobalWithHeadroomTransport)[STATE_KEY];
}
function setState(state: TransportState | undefined): void {
(globalThis as GlobalWithHeadroomTransport)[STATE_KEY] = state;
}
function shimImportSpecifier(): string {
return new URL("../hook-shim/handler.js", import.meta.url).href;
}
function withNodeImportOption(existing: string | undefined, shim: string): string {
const parts = existing?.trim() ? existing.trim().split(/\s+/) : [];
const alreadyPresent = parts.some((part, index) => {
return part === `--import=${shim}` || (part === "--import" && parts[index + 1] === shim);
});
if (!alreadyPresent) {
parts.push(`--import=${shim}`);
}
return parts.join(" ");
}
function withShimEnv(env: NodeJS.ProcessEnv | Record<string, unknown> | undefined, proxyUrl: string): NodeJS.ProcessEnv {
const nextEnv = { ...(env ?? process.env) } as NodeJS.ProcessEnv;
nextEnv[PROXY_ENV] = proxyUrl;
nextEnv.NODE_OPTIONS = withNodeImportOption(nextEnv.NODE_OPTIONS, shimImportSpecifier());
return nextEnv;
}
function installProcessEnv(proxyUrl: string): void {
process.env[PROXY_ENV] = proxyUrl;
process.env.NODE_OPTIONS = withNodeImportOption(process.env.NODE_OPTIONS, shimImportSpecifier());
}
function isOptions(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value) && !(value instanceof URL);
}
function injectOptionsEnv(args: unknown[], optionIndex: number, proxyUrl: string): unknown[] {
const nextArgs = [...args];
const callback = typeof nextArgs.at(-1) === "function" ? nextArgs.pop() : undefined;
const existing = isOptions(nextArgs[optionIndex]) ? { ...(nextArgs[optionIndex] as Record<string, unknown>) } : {};
existing.env = withShimEnv(existing.env as NodeJS.ProcessEnv | undefined, proxyUrl);
if (isOptions(nextArgs[optionIndex])) {
nextArgs[optionIndex] = existing;
} else {
nextArgs.splice(optionIndex, 0, existing);
}
if (callback) {
nextArgs.push(callback);
}
return nextArgs;
}
function wrapSpawn(originalSpawn: ChildSpawn): ChildSpawn {
return function headroomSpawn(this: unknown, ...args: unknown[]) {
const state = getState();
if (!state) {
return Reflect.apply(originalSpawn, this, args);
}
const optionIndex = Array.isArray(args[1]) ? 2 : 1;
return Reflect.apply(originalSpawn, this, injectOptionsEnv(args, optionIndex, state.proxyUrl));
} as ChildSpawn;
}
function wrapExec(originalExec: ChildExec): ChildExec {
return function headroomExec(this: unknown, ...args: unknown[]) {
const state = getState();
if (!state) {
return Reflect.apply(originalExec, this, args);
}
return Reflect.apply(originalExec, this, injectOptionsEnv(args, 1, state.proxyUrl));
} as ChildExec;
}
function wrapExecFile(originalExecFile: ChildExecFile): ChildExecFile {
return function headroomExecFile(this: unknown, ...args: unknown[]) {
const state = getState();
if (!state) {
return Reflect.apply(originalExecFile, this, args);
}
const optionIndex = Array.isArray(args[1]) ? 2 : 1;
return Reflect.apply(originalExecFile, this, injectOptionsEnv(args, optionIndex, state.proxyUrl));
} as ChildExecFile;
}
function wrapFork(originalFork: ChildFork): ChildFork {
return function headroomFork(this: unknown, ...args: unknown[]) {
const state = getState();
if (!state) {
return Reflect.apply(originalFork, this, args);
}
const optionIndex = Array.isArray(args[1]) ? 2 : 1;
return Reflect.apply(originalFork, this, injectOptionsEnv(args, optionIndex, state.proxyUrl));
} as ChildFork;
}
function normalizeProxyUrl(proxyUrl: string): URL {
return new URL(proxyUrl);
}
function isLoopback(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
}
function shouldRoute(url: URL, proxy: URL): boolean {
if (url.protocol !== "http:" && url.protocol !== "https:") {
return false;
}
if (isLoopback(url.hostname)) {
return false;
}
if (url.origin === proxy.origin) {
return false;
}
return true;
}
function routedUrl(upstream: URL, proxy: URL): URL {
return new URL(`${upstream.pathname}${upstream.search}`, proxy.origin);
}
function normalizedOpenAiProxyPath(pathname: string): string | undefined {
if (pathname.endsWith("/chat/completions")) {
return "/v1/chat/completions";
}
if (pathname.endsWith("/responses")) {
return "/v1/responses";
}
return undefined;
}
function routedUrlForOpenCode(upstream: URL, proxy: URL): { url: URL; originalPath: string | undefined } {
const normalizedPath = normalizedOpenAiProxyPath(upstream.pathname);
if (!normalizedPath) {
return {
url: routedUrl(upstream, proxy),
originalPath: undefined,
};
}
return {
url: new URL(`${normalizedPath}${upstream.search}`, proxy.origin),
originalPath: upstream.pathname,
};
}
function requestUrl(input: RequestInfo | URL): URL {
if (input instanceof Request) {
return new URL(input.url);
}
if (input instanceof URL) {
return input;
}
return new URL(String(input));
}
function mergeFetchHeaders(
input: RequestInfo | URL,
init: RequestInit | undefined,
upstream: URL | undefined,
originalPath: string | undefined = undefined,
): Headers {
const headers = new Headers(input instanceof Request ? input.headers : undefined);
if (init?.headers) {
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
}
if (upstream) {
headers.set(BASE_URL_HEADER, upstream.origin);
headers.delete("host");
}
if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath);
}
return headers;
}
function withRoutedFetchInput(input: RequestInfo | URL, init: RequestInit | undefined, proxy: URL): FetchArgs {
const upstream = requestUrl(input);
if (!shouldRoute(upstream, proxy)) {
return [input, init];
}
const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy);
const nextInit = {
...init,
headers: mergeFetchHeaders(input, init, upstream, originalPath),
};
if (input instanceof Request) {
return [new Request(nextUrl, input), nextInit];
}
return [nextUrl, nextInit];
}
function splitNodeArgs(args: unknown[]): NodeRequestParts {
const callback = typeof args.at(-1) === "function" ? (args.at(-1) as (...args: unknown[]) => unknown) : undefined;
const withoutCallback = callback ? args.slice(0, -1) : args;
const [first, second] = withoutCallback;
const options = typeof second === "object" && second !== null ? { ...(second as Record<string, unknown>) } : {};
if (first instanceof URL) {
return { url: first, options, callback };
}
if (typeof first === "string") {
try {
return { url: new URL(first), options, callback };
} catch {
return { options, callback };
}
}
if (typeof first === "object" && first !== null) {
const requestOptions = { ...(first as Record<string, unknown>), ...options };
return { url: urlFromRequestOptions(requestOptions), options: requestOptions, callback };
}
return { options, callback };
}
function urlFromRequestOptions(options: Record<string, unknown>): URL | undefined {
const protocol = String(options.protocol ?? "http:");
if (protocol !== "http:" && protocol !== "https:") {
return undefined;
}
const hostValue = options.hostname ?? options.host;
if (!hostValue) {
return undefined;
}
const hostname = String(hostValue).replace(/:\d+$/, "");
const port = options.port ? `:${String(options.port)}` : "";
const path = String(options.path ?? "/");
try {
return new URL(`${protocol}//${hostname}${port}${path}`);
} catch {
return undefined;
}
}
function headersForNodeRequest(
options: Record<string, unknown>,
upstream: URL,
originalPath: string | undefined,
): Record<string, string> {
const headers = new Headers(options.headers as HeadersInit | undefined);
headers.set(BASE_URL_HEADER, upstream.origin);
if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath);
}
headers.delete("host");
const result: Record<string, string> = {};
headers.forEach((value, key) => {
result[key] = value;
});
return result;
}
function routedNodeOptions(parts: NodeRequestParts, proxy: URL): Record<string, unknown> | undefined {
if (!parts.url || !shouldRoute(parts.url, proxy)) {
return undefined;
}
const { url: nextUrl, originalPath } = routedUrlForOpenCode(parts.url, proxy);
const {
agent: _agent,
auth: _auth,
createConnection: _createConnection,
defaultPort: _defaultPort,
family: _family,
headers: _headers,
host: _host,
hostname: _hostname,
href: _href,
lookup: _lookup,
path: _path,
pathname: _pathname,
port: _port,
protocol: _protocol,
search: _search,
servername: _servername,
setHost: _setHost,
...rest
} = parts.options;
return {
...rest,
protocol: nextUrl.protocol,
hostname: nextUrl.hostname,
port: nextUrl.port || undefined,
path: `${nextUrl.pathname}${nextUrl.search}`,
headers: headersForNodeRequest(parts.options, parts.url, originalPath),
};
}
function wrapRequest(
originalHttpRequest: HttpRequest,
originalHttpsRequest: HttpsRequest,
originalRequest: HttpRequest | HttpsRequest,
): HttpRequest | HttpsRequest {
return function headroomRequest(this: unknown, ...args: unknown[]) {
const state = getState();
if (!state) {
return Reflect.apply(originalRequest, this, args);
}
const proxy = normalizeProxyUrl(state.proxyUrl);
const parts = splitNodeArgs(args);
const nextOptions = routedNodeOptions(parts, proxy);
if (!nextOptions) {
return Reflect.apply(originalRequest, this, args);
}
const targetRequest = proxy.protocol === "https:" ? originalHttpsRequest : originalHttpRequest;
const nextArgs = parts.callback ? [nextOptions, parts.callback] : [nextOptions];
return Reflect.apply(targetRequest, this, nextArgs);
} as HttpRequest | HttpsRequest;
}
function wrapGet(request: HttpRequest | HttpsRequest): HttpGet | HttpsGet {
return function headroomGet(this: unknown, ...args: unknown[]) {
const req = Reflect.apply(request, this, args);
req.end();
return req;
} as HttpGet | HttpsGet;
}
function wrapHttp2Connect(originalConnect: Http2Connect): Http2Connect {
return function headroomHttp2Connect(this: unknown, authority: string | URL, ...args: unknown[]) {
const state = getState();
if (state) {
const proxy = normalizeProxyUrl(state.proxyUrl);
const upstream = authority instanceof URL ? authority : new URL(String(authority));
if (shouldRoute(upstream, proxy)) {
throw new Error(
`Headroom OpenCode wrap blocked direct HTTP/2 connection to ${upstream.origin}. ` +
"Use fetch, http, or https so traffic can be routed through Headroom.",
);
}
}
return Reflect.apply(originalConnect, this, [authority, ...args]);
} as Http2Connect;
}
export function installHeadroomTransport(options: InstallOptions): () => void {
const existing = getState();
if (existing) {
existing.refs += 1;
existing.proxyUrl = options.proxyUrl;
existing.debug = Boolean(options.debug);
installProcessEnv(options.proxyUrl);
return () => uninstallHeadroomTransport();
}
const state: TransportState = {
refs: 1,
proxyUrl: options.proxyUrl,
debug: Boolean(options.debug),
originalFetch: globalThis.fetch,
originalHttpRequest: http.request,
originalHttpGet: http.get,
originalHttpsRequest: https.request,
originalHttpsGet: https.get,
originalHttp2Connect: http2.connect,
originalChildSpawn: childProcess.spawn,
originalChildExec: childProcess.exec,
originalChildExecFile: childProcess.execFile,
originalChildFork: childProcess.fork,
};
setState(state);
installProcessEnv(options.proxyUrl);
globalThis.fetch = async (...args: FetchArgs) => {
const current = getState();
if (!current) {
return state.originalFetch(...args);
}
const proxy = normalizeProxyUrl(current.proxyUrl);
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy);
return state.originalFetch(nextInput, nextInit);
};
http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest) as HttpRequest;
https.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpsRequest) as HttpsRequest;
http.get = wrapGet(http.request) as HttpGet;
https.get = wrapGet(https.request) as HttpsGet;
http2.connect = wrapHttp2Connect(state.originalHttp2Connect);
childProcess.spawn = wrapSpawn(state.originalChildSpawn);
childProcess.exec = wrapExec(state.originalChildExec);
childProcess.execFile = wrapExecFile(state.originalChildExecFile);
childProcess.fork = wrapFork(state.originalChildFork);
syncBuiltinESMExports();
return () => uninstallHeadroomTransport();
}
export function uninstallHeadroomTransport(): void {
const state = getState();
if (!state) {
return;
}
state.refs -= 1;
if (state.refs > 0) {
return;
}
globalThis.fetch = state.originalFetch;
http.request = state.originalHttpRequest;
http.get = state.originalHttpGet;
https.request = state.originalHttpsRequest;
https.get = state.originalHttpsGet;
http2.connect = state.originalHttp2Connect;
childProcess.spawn = state.originalChildSpawn;
childProcess.exec = state.originalChildExec;
childProcess.execFile = state.originalChildExecFile;
childProcess.fork = state.originalChildFork;
syncBuiltinESMExports();
setState(undefined);
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"isolatedModules": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "test"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: { index: "src/index.ts", "entry.opencode": "src/entry.opencode.ts" },
format: ["esm"],
dts: true,
sourcemap: true,
clean: true,
external: ["headroom-ai"],
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
globals: true,
},
});