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:
28
DOCKER.md
28
DOCKER.md
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
36
tests/unit/headroom-detect.test.js
Normal file
36
tests/unit/headroom-detect.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user