fix(headroom): support Docker sidecar proxy

Treat configured Headroom proxy as running when its /health endpoint
responds, even if local headroom CLI is not installed. Dashboard
Start/Stop stays limited to local loopback proxies while external
Docker sidecars can be enabled via HEADROOM_URL.

Closes #1948

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Carmelo Campos
2026-06-21 16:03:26 +07:00
committed by decolua
parent 096491d8d7
commit 50ed79fe9e
8 changed files with 140 additions and 40 deletions

View File

@@ -64,6 +64,34 @@ docker run -d \
decolua/9router:latest
```
## Optional Headroom sidecar
The 9Router image does not bundle Python or Headroom. To use Headroom in Docker, run it as a separate service and point 9Router at that proxy:
```yaml
services:
9router:
image: decolua/9router:latest
ports:
- "20128:20128"
volumes:
- "$HOME/.9router:/app/data"
environment:
DATA_DIR: /app/data
HEADROOM_URL: http://headroom:8787
depends_on:
- headroom
headroom:
image: ghcr.io/chopratejas/headroom:latest
ports:
- "8787:8787"
```
In the dashboard, open `Endpoint``Token Saver``Headroom`, confirm the URL is `http://headroom:8787`, recheck status, then enable Headroom.
If Headroom runs on the Docker host instead of as a sidecar, use `http://host.docker.internal:8787` on macOS/Windows. On Linux, add `--add-host=host.docker.internal:host-gateway` or the equivalent compose `extra_hosts` entry.
## Update to latest
```bash

View File

@@ -336,10 +336,11 @@ export default function APIPageClient({ machineId }) {
patchSetting({ headroomEnabled: value, headroomUrl: nextUrl });
};
const handleHeadroomUrlBlur = () => {
const handleHeadroomUrlBlur = async () => {
const next = headroomUrl.trim() || "http://localhost:8787";
setHeadroomUrl(next);
patchSetting({ headroomUrl: next });
await patchSetting({ headroomUrl: next });
refreshHeadroomStatus();
};
const handleHeadroomCompressUserMessages = (value) => {
@@ -845,6 +846,19 @@ export default function APIPageClient({ machineId }) {
}
const currentEndpoint = baseUrl;
const headroomRunning = !!headroomStatus.running;
const headroomLocalUrl = headroomStatus.localUrl !== false;
const headroomCanStart = !!headroomStatus.canStart;
const headroomManaged = headroomLocalUrl && !!headroomStatus.managedPid;
const headroomStatusLabel = headroomStatus.loading
? "Checking…"
: headroomRunning
? "Running"
: headroomLocalUrl && !headroomStatus.installed
? "Not installed"
: headroomLocalUrl
? "Proxy off"
: "Unreachable";
return (
<div className="flex flex-col gap-8">
@@ -1250,21 +1264,15 @@ export default function APIPageClient({ machineId }) {
(Headroom)
</a>
</p>
<span className={`text-xs px-2 py-0.5 rounded ${headroomStatus.installed && headroomStatus.running ? "bg-success/15 text-success" : "bg-warning/15 text-warning"}`}>
{headroomStatus.loading
? "Checking…"
: !headroomStatus.installed
? "Not installed"
: !headroomStatus.running
? "Proxy off"
: "Running"}
<span className={`text-xs px-2 py-0.5 rounded ${headroomRunning ? "bg-success/15 text-success" : "bg-warning/15 text-warning"}`}>
{headroomStatusLabel}
</span>
<button
type="button"
onClick={() => setShowHeadroomInstallModal(true)}
className="text-xs text-primary underline hover:opacity-80"
>
{headroomStatus.installed && headroomStatus.running ? "Manage" : "Setup"}
{headroomRunning ? "Manage" : "Setup"}
</button>
</div>
<p className="text-sm text-text-muted mt-1">
@@ -1272,8 +1280,8 @@ export default function APIPageClient({ machineId }) {
</p>
</div>
<Toggle
checked={headroomEnabled && headroomStatus.installed && headroomStatus.running}
disabled={!headroomStatus.installed || !headroomStatus.running}
checked={headroomEnabled && headroomRunning}
disabled={!headroomRunning}
onChange={() => handleHeadroomEnabled(!headroomEnabled)}
/>
</div>
@@ -1591,34 +1599,43 @@ export default function APIPageClient({ machineId }) {
{/* Headroom Install Guide Modal */}
<Modal
isOpen={showHeadroomInstallModal}
title={headroomStatus.installed ? "Headroom" : "Install Headroom"}
title={headroomRunning ? "Headroom" : "Setup Headroom"}
onClose={() => setShowHeadroomInstallModal(false)}
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between text-sm">
<span>Status</span>
<span className={headroomStatus.installed && headroomStatus.running ? "text-success" : "text-warning"}>
{headroomStatus.loading
? "Checking…"
: !headroomStatus.installed
? "Not installed"
: !headroomStatus.running
? "Proxy off"
: "Running"}
<span className={headroomRunning ? "text-success" : "text-warning"}>
{headroomStatusLabel}
</span>
</div>
{headroomStatus.installed ? (
headroomStatus.running ? (
<Button onClick={handleHeadroomStop} variant="ghost" fullWidth disabled={headroomActionLoading}>
{headroomActionLoading ? "Stopping…" : "Stop Headroom"}
</Button>
) : (
<Button onClick={handleHeadroomStart} fullWidth disabled={headroomActionLoading}>
{headroomActionLoading ? "Starting…" : "Start Headroom"}
</Button>
)
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Proxy URL</p>
<Input
value={headroomUrl}
onChange={(e) => setHeadroomUrl(e.target.value)}
onBlur={handleHeadroomUrlBlur}
placeholder="http://localhost:8787"
className="font-mono text-sm"
/>
<p className="text-xs text-text-muted">
Use a local proxy for Start/Stop, or an external Docker sidecar like http://headroom:8787.
</p>
</div>
{headroomManaged ? (
<Button onClick={handleHeadroomStop} variant="ghost" fullWidth disabled={headroomActionLoading}>
{headroomActionLoading ? "Stopping…" : "Stop Headroom"}
</Button>
) : headroomRunning ? (
<p className="text-sm text-success">Headroom proxy is reachable. You can enable the token saver.</p>
) : headroomCanStart ? (
<Button onClick={handleHeadroomStart} fullWidth disabled={headroomActionLoading}>
{headroomActionLoading ? "Starting…" : "Start Headroom"}
</Button>
) : !headroomLocalUrl ? (
<p className="text-sm text-warning">Start Headroom separately at the configured URL, then recheck.</p>
) : !headroomStatus.python ? (
<p className="text-sm text-warning">Python 3.10 required. Install Python first.</p>
<p className="text-sm text-warning">Python 3.10 required for local managed mode. Install Python first, or use an external proxy URL.</p>
) : (
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Install then click Start:</p>

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/localDb";
import { startHeadroomProxy } from "@/lib/headroom/process";
import { DEFAULT_HEADROOM_URL, isLoopbackHeadroomUrl } from "@/lib/headroom/detect";
export const dynamic = "force-dynamic";
@@ -16,7 +17,10 @@ function parsePortFromUrl(url) {
export async function POST() {
try {
const settings = await getSettings();
const url = settings.headroomUrl || "http://localhost:8787";
const url = settings.headroomUrl || DEFAULT_HEADROOM_URL;
if (!isLoopbackHeadroomUrl(url)) {
return NextResponse.json({ error: "External Headroom proxies must be started outside 9Router", code: "EXTERNAL_PROXY" }, { status: 400 });
}
const port = parsePortFromUrl(url) || 8787;
const result = await startHeadroomProxy({ port });
return NextResponse.json({ success: true, ...result });

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/localDb";
import { getHeadroomStatus } from "@/lib/headroom/detect";
import { DEFAULT_HEADROOM_URL, getHeadroomStatus } from "@/lib/headroom/detect";
import { getManagedPid } from "@/lib/headroom/process";
export const dynamic = "force-dynamic";
@@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";
export async function GET() {
try {
const settings = await getSettings();
const url = settings.headroomUrl || "http://localhost:8787";
const url = settings.headroomUrl || DEFAULT_HEADROOM_URL;
const status = await getHeadroomStatus(url);
const managedPid = getManagedPid();
return NextResponse.json({ ...status, url, managedPid });

View File

@@ -2,6 +2,7 @@ import { getAdapter } from "../driver.js";
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
const DEFAULT_HEADROOM_URL = process.env.HEADROOM_URL || "http://localhost:8787";
const DEFAULT_SETTINGS = {
cloudEnabled: false,
@@ -35,7 +36,7 @@ const DEFAULT_SETTINGS = {
dnsToolEnabled: {},
rtkEnabled: true,
headroomEnabled: false,
headroomUrl: "http://localhost:8787",
headroomUrl: DEFAULT_HEADROOM_URL,
headroomCompressUserMessages: false,
cavemanEnabled: false,
cavemanLevel: "full",

View File

@@ -29,6 +29,9 @@ const EXTENDED_PATH = [...EXTRA_BINS, process.env.PATH || ""].filter(Boolean).jo
const PYTHON_CANDIDATES = ["python3.13", "python3.12", "python3.11", "python3.10", "python3", "python"];
const MIN_VERSION = [3, 10];
const HEADROOM_HEALTH_TIMEOUT_MS = 1500;
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]);
export const DEFAULT_HEADROOM_URL = process.env.HEADROOM_URL || "http://localhost:8787";
// Detect whether the headroom CLI is installed and where its binary lives.
export function findHeadroomBinary() {
@@ -79,11 +82,21 @@ export async function probeProxyRunning(url) {
}
}
export function isLoopbackHeadroomUrl(url) {
try {
const parsed = new URL(url);
return LOOPBACK_HOSTS.has(parsed.hostname);
} catch {
return false;
}
}
// Aggregate status for the dashboard: installed, running, python interpreter.
export async function getHeadroomStatus(url) {
const path = findHeadroomBinary();
const python = findPython310();
const installed = Boolean(path);
const running = installed ? await probeProxyRunning(url) : false;
return { installed, path, running, python };
const running = await probeProxyRunning(url);
const localUrl = isLoopbackHeadroomUrl(url);
return { installed, path, running, python, localUrl, canStart: installed && localUrl };
}

View File

@@ -11,6 +11,7 @@ import { cacheClaudeHeaders } from "open-sse/utils/claudeHeaderCache.js";
import { getSettings } from "@/lib/localDb";
import { getModelInfo, getComboModels } from "../services/model.js";
import { handleChatCore } from "open-sse/handlers/chatCore.js";
import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect";
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
import { handleComboChat, handleFusionChat } from "open-sse/services/combo.js";
import { handleBypassRequest } from "open-sse/utils/bypassHandler.js";
@@ -252,7 +253,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
ccFilterNaming: !!chatSettings.ccFilterNaming,
rtkEnabled: !!chatSettings.rtkEnabled,
headroomEnabled: !!chatSettings.headroomEnabled,
headroomUrl: chatSettings.headroomUrl || "http://localhost:8787",
headroomUrl: chatSettings.headroomUrl || DEFAULT_HEADROOM_URL,
headroomCompressUserMessages: !!chatSettings.headroomCompressUserMessages,
cavemanEnabled: !!chatSettings.cavemanEnabled,
cavemanLevel: chatSettings.cavemanLevel || "full",

View File

@@ -0,0 +1,36 @@
import { describe, it, expect, vi, afterEach } from "vitest";
const mocks = vi.hoisted(() => ({
execSync: vi.fn(() => { throw new Error("not found"); }),
}));
vi.mock("child_process", () => ({
execSync: mocks.execSync,
}));
import { getHeadroomStatus, isLoopbackHeadroomUrl } from "../../src/lib/headroom/detect.js";
afterEach(() => {
vi.clearAllMocks();
});
describe("headroom detect", () => {
it("treats a reachable external proxy as running without local CLI", async () => {
global.fetch = vi.fn(async () => new Response("ok", { status: 200 }));
const status = await getHeadroomStatus("http://headroom:8787");
expect(status.installed).toBe(false);
expect(status.running).toBe(true);
expect(status.localUrl).toBe(false);
expect(status.canStart).toBe(false);
expect(global.fetch).toHaveBeenCalledWith("http://headroom:8787/health", expect.any(Object));
});
it("recognizes loopback URLs for managed local mode", () => {
expect(isLoopbackHeadroomUrl("http://localhost:8787")).toBe(true);
expect(isLoopbackHeadroomUrl("http://127.0.0.1:8787")).toBe(true);
expect(isLoopbackHeadroomUrl("http://headroom:8787")).toBe(false);
expect(isLoopbackHeadroomUrl("not-a-url")).toBe(false);
});
});