diff --git a/DOCKER.md b/DOCKER.md index 91f1156d..1f280d97 100644 --- a/DOCKER.md +++ b/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 diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 0aadd536..88f5e81f 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -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 (
@@ -1250,21 +1264,15 @@ export default function APIPageClient({ machineId }) { (Headroom)

- - {headroomStatus.loading - ? "Checking…" - : !headroomStatus.installed - ? "Not installed" - : !headroomStatus.running - ? "Proxy off" - : "Running"} + + {headroomStatusLabel}

@@ -1272,8 +1280,8 @@ export default function APIPageClient({ machineId }) {

handleHeadroomEnabled(!headroomEnabled)} /> @@ -1591,34 +1599,43 @@ export default function APIPageClient({ machineId }) { {/* Headroom Install Guide Modal */} setShowHeadroomInstallModal(false)} >
Status - - {headroomStatus.loading - ? "Checking…" - : !headroomStatus.installed - ? "Not installed" - : !headroomStatus.running - ? "Proxy off" - : "Running"} + + {headroomStatusLabel}
- {headroomStatus.installed ? ( - headroomStatus.running ? ( - - ) : ( - - ) +
+

Proxy URL

+ setHeadroomUrl(e.target.value)} + onBlur={handleHeadroomUrlBlur} + placeholder="http://localhost:8787" + className="font-mono text-sm" + /> +

+ Use a local proxy for Start/Stop, or an external Docker sidecar like http://headroom:8787. +

+
+ {headroomManaged ? ( + + ) : headroomRunning ? ( +

Headroom proxy is reachable. You can enable the token saver.

+ ) : headroomCanStart ? ( + + ) : !headroomLocalUrl ? ( +

Start Headroom separately at the configured URL, then recheck.

) : !headroomStatus.python ? ( -

Python ≥ 3.10 required. Install Python first.

+

Python ≥ 3.10 required for local managed mode. Install Python first, or use an external proxy URL.

) : (

Install then click Start:

diff --git a/src/app/api/headroom/start/route.js b/src/app/api/headroom/start/route.js index 793cf566..56af456c 100644 --- a/src/app/api/headroom/start/route.js +++ b/src/app/api/headroom/start/route.js @@ -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 }); diff --git a/src/app/api/headroom/status/route.js b/src/app/api/headroom/status/route.js index 1ae54435..582147cb 100644 --- a/src/app/api/headroom/status/route.js +++ b/src/app/api/headroom/status/route.js @@ -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 }); diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index 02d07f6b..0057cc1c 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -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", diff --git a/src/lib/headroom/detect.js b/src/lib/headroom/detect.js index 5f3855b2..ac64dc87 100644 --- a/src/lib/headroom/detect.js +++ b/src/lib/headroom/detect.js @@ -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 }; } diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 311f33d9..571930e0 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -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", diff --git a/tests/unit/headroom-detect.test.js b/tests/unit/headroom-detect.test.js new file mode 100644 index 00000000..cbe16b25 --- /dev/null +++ b/tests/unit/headroom-detect.test.js @@ -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); + }); +});