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
|
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
|
## Update to latest
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -336,10 +336,11 @@ export default function APIPageClient({ machineId }) {
|
|||||||
patchSetting({ headroomEnabled: value, headroomUrl: nextUrl });
|
patchSetting({ headroomEnabled: value, headroomUrl: nextUrl });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHeadroomUrlBlur = () => {
|
const handleHeadroomUrlBlur = async () => {
|
||||||
const next = headroomUrl.trim() || "http://localhost:8787";
|
const next = headroomUrl.trim() || "http://localhost:8787";
|
||||||
setHeadroomUrl(next);
|
setHeadroomUrl(next);
|
||||||
patchSetting({ headroomUrl: next });
|
await patchSetting({ headroomUrl: next });
|
||||||
|
refreshHeadroomStatus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHeadroomCompressUserMessages = (value) => {
|
const handleHeadroomCompressUserMessages = (value) => {
|
||||||
@@ -845,6 +846,19 @@ export default function APIPageClient({ machineId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentEndpoint = baseUrl;
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
@@ -1250,21 +1264,15 @@ export default function APIPageClient({ machineId }) {
|
|||||||
(Headroom)
|
(Headroom)
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</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"}`}>
|
<span className={`text-xs px-2 py-0.5 rounded ${headroomRunning ? "bg-success/15 text-success" : "bg-warning/15 text-warning"}`}>
|
||||||
{headroomStatus.loading
|
{headroomStatusLabel}
|
||||||
? "Checking…"
|
|
||||||
: !headroomStatus.installed
|
|
||||||
? "Not installed"
|
|
||||||
: !headroomStatus.running
|
|
||||||
? "Proxy off"
|
|
||||||
: "Running"}
|
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowHeadroomInstallModal(true)}
|
onClick={() => setShowHeadroomInstallModal(true)}
|
||||||
className="text-xs text-primary underline hover:opacity-80"
|
className="text-xs text-primary underline hover:opacity-80"
|
||||||
>
|
>
|
||||||
{headroomStatus.installed && headroomStatus.running ? "Manage" : "Setup"}
|
{headroomRunning ? "Manage" : "Setup"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-muted mt-1">
|
<p className="text-sm text-text-muted mt-1">
|
||||||
@@ -1272,8 +1280,8 @@ export default function APIPageClient({ machineId }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={headroomEnabled && headroomStatus.installed && headroomStatus.running}
|
checked={headroomEnabled && headroomRunning}
|
||||||
disabled={!headroomStatus.installed || !headroomStatus.running}
|
disabled={!headroomRunning}
|
||||||
onChange={() => handleHeadroomEnabled(!headroomEnabled)}
|
onChange={() => handleHeadroomEnabled(!headroomEnabled)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1591,34 +1599,43 @@ export default function APIPageClient({ machineId }) {
|
|||||||
{/* Headroom Install Guide Modal */}
|
{/* Headroom Install Guide Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showHeadroomInstallModal}
|
isOpen={showHeadroomInstallModal}
|
||||||
title={headroomStatus.installed ? "Headroom" : "Install Headroom"}
|
title={headroomRunning ? "Headroom" : "Setup Headroom"}
|
||||||
onClose={() => setShowHeadroomInstallModal(false)}
|
onClose={() => setShowHeadroomInstallModal(false)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span>Status</span>
|
<span>Status</span>
|
||||||
<span className={headroomStatus.installed && headroomStatus.running ? "text-success" : "text-warning"}>
|
<span className={headroomRunning ? "text-success" : "text-warning"}>
|
||||||
{headroomStatus.loading
|
{headroomStatusLabel}
|
||||||
? "Checking…"
|
|
||||||
: !headroomStatus.installed
|
|
||||||
? "Not installed"
|
|
||||||
: !headroomStatus.running
|
|
||||||
? "Proxy off"
|
|
||||||
: "Running"}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{headroomStatus.installed ? (
|
<div className="flex flex-col gap-1">
|
||||||
headroomStatus.running ? (
|
<p className="text-sm font-medium">Proxy URL</p>
|
||||||
<Button onClick={handleHeadroomStop} variant="ghost" fullWidth disabled={headroomActionLoading}>
|
<Input
|
||||||
{headroomActionLoading ? "Stopping…" : "Stop Headroom"}
|
value={headroomUrl}
|
||||||
</Button>
|
onChange={(e) => setHeadroomUrl(e.target.value)}
|
||||||
) : (
|
onBlur={handleHeadroomUrlBlur}
|
||||||
<Button onClick={handleHeadroomStart} fullWidth disabled={headroomActionLoading}>
|
placeholder="http://localhost:8787"
|
||||||
{headroomActionLoading ? "Starting…" : "Start Headroom"}
|
className="font-mono text-sm"
|
||||||
</Button>
|
/>
|
||||||
)
|
<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 ? (
|
) : !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">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-sm font-medium">Install then click Start:</p>
|
<p className="text-sm font-medium">Install then click Start:</p>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getSettings } from "@/lib/localDb";
|
import { getSettings } from "@/lib/localDb";
|
||||||
import { startHeadroomProxy } from "@/lib/headroom/process";
|
import { startHeadroomProxy } from "@/lib/headroom/process";
|
||||||
|
import { DEFAULT_HEADROOM_URL, isLoopbackHeadroomUrl } from "@/lib/headroom/detect";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -16,7 +17,10 @@ function parsePortFromUrl(url) {
|
|||||||
export async function POST() {
|
export async function POST() {
|
||||||
try {
|
try {
|
||||||
const settings = await getSettings();
|
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 port = parsePortFromUrl(url) || 8787;
|
||||||
const result = await startHeadroomProxy({ port });
|
const result = await startHeadroomProxy({ port });
|
||||||
return NextResponse.json({ success: true, ...result });
|
return NextResponse.json({ success: true, ...result });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getSettings } from "@/lib/localDb";
|
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";
|
import { getManagedPid } from "@/lib/headroom/process";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
const url = settings.headroomUrl || "http://localhost:8787";
|
const url = settings.headroomUrl || DEFAULT_HEADROOM_URL;
|
||||||
const status = await getHeadroomStatus(url);
|
const status = await getHeadroomStatus(url);
|
||||||
const managedPid = getManagedPid();
|
const managedPid = getManagedPid();
|
||||||
return NextResponse.json({ ...status, url, managedPid });
|
return NextResponse.json({ ...status, url, managedPid });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getAdapter } from "../driver.js";
|
|||||||
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
||||||
|
|
||||||
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
|
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
|
||||||
|
const DEFAULT_HEADROOM_URL = process.env.HEADROOM_URL || "http://localhost:8787";
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = {
|
const DEFAULT_SETTINGS = {
|
||||||
cloudEnabled: false,
|
cloudEnabled: false,
|
||||||
@@ -35,7 +36,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
dnsToolEnabled: {},
|
dnsToolEnabled: {},
|
||||||
rtkEnabled: true,
|
rtkEnabled: true,
|
||||||
headroomEnabled: false,
|
headroomEnabled: false,
|
||||||
headroomUrl: "http://localhost:8787",
|
headroomUrl: DEFAULT_HEADROOM_URL,
|
||||||
headroomCompressUserMessages: false,
|
headroomCompressUserMessages: false,
|
||||||
cavemanEnabled: false,
|
cavemanEnabled: false,
|
||||||
cavemanLevel: "full",
|
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 PYTHON_CANDIDATES = ["python3.13", "python3.12", "python3.11", "python3.10", "python3", "python"];
|
||||||
const MIN_VERSION = [3, 10];
|
const MIN_VERSION = [3, 10];
|
||||||
const HEADROOM_HEALTH_TIMEOUT_MS = 1500;
|
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.
|
// Detect whether the headroom CLI is installed and where its binary lives.
|
||||||
export function findHeadroomBinary() {
|
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.
|
// Aggregate status for the dashboard: installed, running, python interpreter.
|
||||||
export async function getHeadroomStatus(url) {
|
export async function getHeadroomStatus(url) {
|
||||||
const path = findHeadroomBinary();
|
const path = findHeadroomBinary();
|
||||||
const python = findPython310();
|
const python = findPython310();
|
||||||
const installed = Boolean(path);
|
const installed = Boolean(path);
|
||||||
const running = installed ? await probeProxyRunning(url) : false;
|
const running = await probeProxyRunning(url);
|
||||||
return { installed, path, running, python };
|
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 { getSettings } from "@/lib/localDb";
|
||||||
import { getModelInfo, getComboModels } from "../services/model.js";
|
import { getModelInfo, getComboModels } from "../services/model.js";
|
||||||
import { handleChatCore } from "open-sse/handlers/chatCore.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 { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
||||||
import { handleComboChat, handleFusionChat } from "open-sse/services/combo.js";
|
import { handleComboChat, handleFusionChat } from "open-sse/services/combo.js";
|
||||||
import { handleBypassRequest } from "open-sse/utils/bypassHandler.js";
|
import { handleBypassRequest } from "open-sse/utils/bypassHandler.js";
|
||||||
@@ -252,7 +253,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
ccFilterNaming: !!chatSettings.ccFilterNaming,
|
ccFilterNaming: !!chatSettings.ccFilterNaming,
|
||||||
rtkEnabled: !!chatSettings.rtkEnabled,
|
rtkEnabled: !!chatSettings.rtkEnabled,
|
||||||
headroomEnabled: !!chatSettings.headroomEnabled,
|
headroomEnabled: !!chatSettings.headroomEnabled,
|
||||||
headroomUrl: chatSettings.headroomUrl || "http://localhost:8787",
|
headroomUrl: chatSettings.headroomUrl || DEFAULT_HEADROOM_URL,
|
||||||
headroomCompressUserMessages: !!chatSettings.headroomCompressUserMessages,
|
headroomCompressUserMessages: !!chatSettings.headroomCompressUserMessages,
|
||||||
cavemanEnabled: !!chatSettings.cavemanEnabled,
|
cavemanEnabled: !!chatSettings.cavemanEnabled,
|
||||||
cavemanLevel: chatSettings.cavemanLevel || "full",
|
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