Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions docs/content/docs/proxy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Telemetry is **local-only and off by default**. `HEADROOM_TELEMETRY=on` (or `--t
|--------|---------|-------------|
| `--host` | `127.0.0.1` | Host to bind to |
| `--port` | `8787` | Port to bind to |
| `--uds` | None | Serve on a Unix domain socket at this path instead of `--host`/`--port`. POSIX only — Windows has no `AF_UNIX` support in Python or asyncio. See [Serving on a Unix socket](#serving-on-a-unix-socket) |
| `--workers` | `1` | Number of Uvicorn worker processes |
| `--limit-concurrency` | `1000` | Maximum concurrent connections before Uvicorn returns 503 |
| `--max-connections` | `500` | Maximum upstream HTTP connections |
Expand Down Expand Up @@ -193,6 +194,65 @@ HEADROOM_SAVINGS_PROFILE=balanced HEADROOM_TARGET_RATIO=0.15 headroom proxy

For permanent custom profiles, see the profile definitions in `headroom/agent_savings.py`. Each profile is an `AgentSavingsProfile` dataclass with fields for compression mode, target ratio, turn protection, and pipeline toggles.

## Serving on a Unix socket

`--uds` binds an `AF_UNIX` socket instead of a TCP port. Request handling is
identical — only the transport changes.

```bash
headroom proxy --uds ~/.headroom/run/proxy.sock
```

Two reasons to prefer it over a loopback port:

- **No port to collide with, and no port exposed.** Access is governed by
filesystem permissions rather than by anything listening on the network.
- **Deployments where a port is awkward.** Containers and systemd units can share a
socket through a bind mount or a runtime directory without publishing a port.

<Callout type="warning">
A socket does **not** re-enable Claude Code's Remote Control. Setting
`ANTHROPIC_UNIX_SOCKET` passes that feature's host check but makes Claude Code
treat the session as API-key auth, which fails a separate subscription-auth
requirement — see [Why the Unix socket route does not
help](/docs/troubleshooting#why-the-unix-socket-route-does-not-help).
</Callout>

Point any Unix-socket-capable client at the path. With curl:

```bash
curl --unix-socket ~/.headroom/run/proxy.sock http://localhost/health
```

### Permissions and lifecycle

A Unix socket carries no credentials of its own, so **the mode of the directory
holding it is the access-control boundary**.

If the parent directory does not exist, Headroom creates it `0700`. If it already
exists, Headroom does not touch its permissions — something else owns that
directory's policy, and silently tightening it could lock out whatever put it
there. Instead it refuses to start when an existing parent is writable by other
users and not sticky, since anyone on the host could then replace the socket.
(A sticky directory such as `/tmp` is accepted: others may create their own
entries there but cannot unlink or rename yours.)

On startup Headroom removes a stale socket left behind by a crashed process, but
it refuses to start when:

- something is still listening on the path (two proxies would silently split traffic),
- the path exists and is not a socket (far more likely a typo'd argument pointing
at real data than a leftover), or
- the path exceeds the platform's `sun_path` limit — 108 bytes on Linux, 104 on
macOS and the BSDs. Keep it short; a long path fails inside `bind()` with an
error that names nothing.

<Callout type="warn">
`--uds` is POSIX-only. On Windows the command exits immediately with an error
telling you to use `--port`; Python exposes no `socket.AF_UNIX` there and
asyncio has no Windows UDS transport.
</Callout>

## Configuration in depth

Proxy behavior is set by three layers, **each overriding the one before**:
Expand Down
43 changes: 40 additions & 3 deletions docs/content/docs/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,48 @@ See [issue #746](https://github.com/headroomlabs-ai/headroom/issues/746) for the

## Remote Control unavailable through custom ANTHROPIC_BASE_URL

**Symptom**: When Claude Code runs with `ANTHROPIC_BASE_URL` set to a custom host (for example, Headroom), the Remote Control menu is absent.
**Symptom**: When Claude Code runs with `ANTHROPIC_BASE_URL` set to a custom host (for example, Headroom), the Remote Control menu is absent. Invoking `/remote-control` reports `Remote Control is only available when using Claude via api.anthropic.com`.

**Cause**: This is a Claude-side gate. Headroom only receives normal API traffic and can still compress it, but Claude evaluates Remote Control availability before proxy traffic reaches the server.
**Cause**: A client-side gate, evaluated before any traffic reaches Headroom. Claude Code v2.1.196+ parses `ANTHROPIC_BASE_URL` and compares the host against `api.anthropic.com` exactly, so a loopback address fails it. The check is satisfied by any one of:

**Fix**: Use Headroom for normal proxied API sessions, and launch Claude directly (without `ANTHROPIC_BASE_URL`) when you need Claude Remote Control.
- `ANTHROPIC_BASE_URL` unset, or naming `api.anthropic.com` as its host, or
- `ANTHROPIC_UNIX_SOCKET` pointing at a Unix domain socket — but see [below](#why-the-unix-socket-route-does-not-help): it passes this check and fails a different one.

Note that Remote Control does **not** honor `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL`, the flag that re-enables the 1M window and on-demand tool loading — it reads the base URL directly. Headroom cannot re-enable Remote Control from the server side the way `--1m` restores the `context-1m` beta header, because the eligibility decision and the bridge connection both happen in the client.

**Fix**: Launch Claude directly, without `ANTHROPIC_BASE_URL`, for sessions that need Remote Control, and use Headroom for the rest.

### Why the Unix socket route does not help

`ANTHROPIC_UNIX_SOCKET` satisfies the host check above, so it looks like a way to keep a first-party base URL while routing through Headroom. It does not work, and the reason is worth recording so nobody re-derives it.

That variable is reserved for `claude ssh`, where the process on the other end of the socket supplies credentials. Claude Code 2.1.198 carries the diagnostic verbatim:

```
ANTHROPIC_UNIX_SOCKET is set (claude ssh remote), and the local proxy is API-key-authed.
```

Setting it therefore makes Claude Code classify the session as API-key auth. Remote Control separately requires *claude.ai subscription* auth — the binary's eligibility messages include `Remote Control requires claude.ai subscription auth.` — so the one variable opens the host gate and closes the subscription gate at the same time.

Confirmed on Linux against Claude Code 2.1.198 with `headroom proxy --uds` and no API key set. The session starts with:

```
⚠ claude.ai connectors are disabled because ANTHROPIC_API_KEY or another auth source is set
and takes precedence over your claude.ai login
```

sends `Not logged in · Please run /login`, and after a successful subscription login still fails inside the SDK, because nothing ever attached a credential:

```
API invalid_api_key: Could not resolve authentication method. Expected one of apiKey,
authToken, credentials, config, or profile to be set.
```

No proxy-side change can repair this: the eligibility decision happens in the client before any request is made.

**What remains**: a transport that leaves `ANTHROPIC_BASE_URL` unset and does not set `ANTHROPIC_UNIX_SOCKET` — an `HTTPS_PROXY` CONNECT/MITM front end with a locally-generated CA trusted through `NODE_EXTRA_CA_CERTS`. That keeps the host genuinely `api.anthropic.com` and leaves subscription auth untouched. It is tracked on [issue #1779](https://github.com/headroomlabs-ai/headroom/issues/1779); `headroom proxy --uds` is still useful as a transport, just not for this.

The upstream request to allow loopback proxies is tracked at [anthropics/claude-code#76653](https://github.com/anthropics/claude-code/issues/76653).

`ENABLE_TOOL_SEARCH` is unaffected and can stay enabled for context-window savings while routing through Headroom.

Expand Down
45 changes: 42 additions & 3 deletions headroom/cli/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,17 @@ def dashboard(port: int, no_open: bool) -> None:
envvar="HEADROOM_HOST",
help="Host to bind to (default: 127.0.0.1, env: HEADROOM_HOST)",
)
@click.option(
"--uds",
default=None,
envvar="HEADROOM_UDS",
metavar="PATH",
help=(
"Serve on a Unix domain socket instead of --host/--port. POSIX only. "
"Lets a client keep a first-party base URL while its traffic still "
"reaches Headroom (env: HEADROOM_UDS)."
),
)
@click.option(
"--port",
"-p",
Expand Down Expand Up @@ -1014,6 +1025,7 @@ def proxy(
mode: str | None,
target_ratio: float | None,
host: str,
uds: str | None,
port: int,
workers: int,
limit_concurrency: int,
Expand Down Expand Up @@ -1116,6 +1128,17 @@ def proxy(
OPENAI_BASE_URL=http://localhost:8787/v1 your-app
"""
_reexec_with_malloc_tuning()

# Fail before any dependency loading or config work: an unusable --uds is a
# typo or an unsupported platform, and both are cheaper to report up front.
if uds:
from headroom.proxy.uds import UdsError, require_uds_support

try:
require_uds_support()
except UdsError as exc:
raise click.ClickException(str(exc)) from exc

ensure_proxy_dependencies()

# Import here to avoid slow startup
Expand Down Expand Up @@ -1296,6 +1319,7 @@ def proxy(
config = ProxyConfig(
host=host,
port=port,
uds=uds,
rollout=rollout_snapshot,
anthropic_api_url=provider_api_overrides.anthropic,
anthropic_extra_headers=resolved_anthropic_extra_headers,
Expand Down Expand Up @@ -1612,6 +1636,22 @@ def proxy(
else:
tuning_section = ""

# A socket has no URL, and no per-agent recipe belongs here — see
# uds.socket_usage_lines() for why the banner stays transport-neutral.
if config.uds:
from headroom.proxy.uds import socket_usage_lines

listen_display = f"unix:{config.uds}"
usage_section = "\n".join(socket_usage_lines(config.uds))
else:
listen_display = f"http://{config.host}:{config.port}"
usage_section = "\n".join(
(
f" Claude Code: ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude",
f" Codex / OpenAI: OPENAI_BASE_URL=http://{config.host}:{config.port}/v1 your-app",
)
)

click.echo(f"""
╔═══════════════════════════════════════════════════════════════════════╗
║ HEADROOM PROXY ║
Expand All @@ -1620,7 +1660,7 @@ def proxy(

Starting proxy server...

URL: http://{config.host}:{config.port}
URL: {listen_display}
Mode: {config.mode}
Optimization: {"ENABLED" if config.optimize else "DISABLED"}
Caching: {"ENABLED" if config.cache_enabled else "DISABLED"}
Expand All @@ -1641,8 +1681,7 @@ def proxy(
/v1/projects/.../publishers/... → {vertex_url}

Usage:
Claude Code: ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude
Codex / OpenAI: OPENAI_BASE_URL=http://{config.host}:{config.port}/v1 your-app
{usage_section}
{memory_section}
Endpoints:
GET /livez Process liveness
Expand Down
3 changes: 3 additions & 0 deletions headroom/proxy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ class ProxyConfig:
# Server
host: str = "127.0.0.1"
port: int = 8787
# Serve on this Unix domain socket instead of host:port. POSIX only; see
# headroom/proxy/uds.py for why a socket transport exists at all (GH #1779).
uds: str | None = None
# Resolved at this configuration boundary and then injected unchanged.
rollout: RolloutSnapshot | None = None
anthropic_api_url: str | None = None # Custom Anthropic API URL override
Expand Down
57 changes: 52 additions & 5 deletions headroom/proxy/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5385,13 +5385,23 @@ def run_server(
# Resolve upstream API targets for display in the banner (#583).
api_targets = resolve_api_targets(config.provider_api_overrides)

if config.uds:
# No per-agent recipe on a socket bind; see uds.socket_usage_lines().
listen_display = f"unix:{config.uds}"
usage_label = "Client: "
usage_display = "must support HTTP over a Unix socket natively"
else:
listen_display = f"http://{config.host}:{config.port}"
usage_label = "Claude Code:"
usage_display = f"ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude"

if print_banner:
print(f"""
╔══════════════════════════════════════════════════════════════════════╗
║ HEADROOM PROXY SERVER ║
╠══════════════════════════════════════════════════════════════════════╣
║ Version: 1.0.0 ║
║ Listening: http://{config.host}:{config.port:<5}
║ Listening: {listen_display:<57}
║ Workers: {workers:<3} Concurrency Limit: {limit_concurrency:<5} ║
║ Backend: {backend_status:<59}║
╠══════════════════════════════════════════════════════════════════════╣
Expand All @@ -5413,7 +5423,7 @@ def run_server(
║ Conn Pool: {pool_info:<52}║
╠══════════════════════════════════════════════════════════════════════╣
║ USAGE: ║
Claude Code: ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude
{usage_label} {usage_display:<51}
║ Cursor: Set base URL in settings ║
╠══════════════════════════════════════════════════════════════════════╣
║ ENDPOINTS: ║
Expand Down Expand Up @@ -5492,11 +5502,48 @@ def run_server(
# and no CLI flag to change it. Overridable now; the default is unchanged.
uvicorn_log_level = _resolve_uvicorn_log_level()

# Bind target: a Unix socket when one is configured, otherwise host:port.
# uvicorn treats `uds` and `host`/`port` as alternatives, so they are built
# here rather than passed together.
bind_kwargs: dict[str, Any]
uds_path: Path | None = None
if config.uds:
from headroom.proxy.uds import prepare_uds_path

uds_path = prepare_uds_path(config.uds)
bind_kwargs = {"uds": str(uds_path)}
else:
bind_kwargs = {"host": config.host, "port": config.port}

try:
_run_uvicorn(
app_target,
bind_kwargs,
workers,
limit_concurrency,
uvicorn_log_level,
uvicorn_kwargs,
)
finally:
if uds_path is not None:
from headroom.proxy.uds import remove_uds_path

remove_uds_path(uds_path)


def _run_uvicorn(
app_target: Any,
bind_kwargs: dict[str, Any],
workers: int,
limit_concurrency: int,
log_level: str,
uvicorn_kwargs: dict[str, Any],
) -> None:
"""Hand off to uvicorn. Split out so the bind target stays testable."""
uvicorn.run(
app_target,
host=config.host,
port=config.port,
log_level=uvicorn_log_level,
**bind_kwargs,
log_level=log_level,
workers=workers if workers > 1 else None, # None = single process (default)
limit_concurrency=limit_concurrency,
# Defense-in-depth: the loopback guard for /debug/* endpoints trusts
Expand Down
Loading
Loading