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

@@ -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",