diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index d5dcf2d8a84..520f257f682 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -286,7 +286,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred let pxpipeSummary = null; if (pxpipeEnabled) { const pxpipeResult = await compressWithPxpipe(translatedBody, { - enabled: true, format: finalFormat, model: upstreamModel, + enabled: tokenSaverEnabled, format: finalFormat, model: upstreamModel, minChars: pxpipeMinChars, timeoutMs: pxpipeTimeoutMs, transform: pxpipeTransform, }); pxpipeSummary = pxpipeResult.summary; diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 3d4b2e32f85..22b0e00f015 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -85,6 +85,9 @@ const LOCAL_ONLY_PATHS = [ "/api/headroom/start", "/api/headroom/stop", "/api/headroom/proxy", + "/api/headroom/extras", + "/api/headroom/restart", + "/api/pxpipe", ]; const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); diff --git a/tests/unit/dashboard-guard.test.js b/tests/unit/dashboard-guard.test.js index 931f689ec8d..8e4f9d71402 100644 --- a/tests/unit/dashboard-guard.test.js +++ b/tests/unit/dashboard-guard.test.js @@ -285,6 +285,43 @@ describe("dashboard guard local-only access", () => { expect(response).toBe(mocks.nextResponse); }); + + it.each([ + "/api/pxpipe/install", + "/api/pxpipe/status", + "/api/headroom/extras", + "/api/headroom/restart", + ])("denies management route %s from remote host even when requireLogin=false", async (pathname) => { + mocks.getSettings.mockResolvedValue({ requireLogin: false }); + + const response = await proxy(request(pathname, { host: "router.example.com" })); + + expect(response.status).toBe(403); + expect(response.body.error).toBe("Local only: CLI token required"); + }); + + it.each(["/api/pxpipe/install", "/api/headroom/extras"])( + "allows management route %s on loopback when requireLogin=false", + async (pathname) => { + mocks.getSettings.mockResolvedValue({ requireLogin: false }); + + const response = await proxy(localRequest(pathname, { + host: "localhost:20128", + origin: "http://localhost:20128", + })); + + expect(response).toBe(mocks.nextResponse); + } + ); + + it("allows pxpipe install from remote host with valid CLI token", async () => { + const response = await proxy(request("/api/pxpipe/install", { + host: "router.example.com", + "x-9r-cli-token": "cli-token", + })); + + expect(response).toBe(mocks.nextResponse); + }); }); describe("dashboard guard helpers", () => { diff --git a/tests/unit/headroom-chat-core.test.js b/tests/unit/headroom-chat-core.test.js index e61ab8984de..943b68be2eb 100644 --- a/tests/unit/headroom-chat-core.test.js +++ b/tests/unit/headroom-chat-core.test.js @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { FORMATS } from "../../open-sse/translator/formats.js"; const { executeMock } = vi.hoisted(() => ({ executeMock: vi.fn(), @@ -251,47 +252,115 @@ describe("handleChatCore Headroom diagnostics", () => { ); }); - it("bypasses token savers when requested by the client", async () => { + it.each(["off", "OFF"])("pxpipe honors token-saver header %s on claude body above threshold", async (headerValue) => { const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }; - const pxpipeTransform = vi.fn(); - const messages = [{ role: "user", content: "Write polished prose." }]; - - global.fetch = vi.fn(async (url) => { - throw new Error(`unexpected fetch: ${url}`); - }); + const onPxpipeEvent = vi.fn(); + const transformedBody = { model: "claude-3-5-sonnet", stream: false, max_tokens: 100, system: "base", messages: [{ role: "user", content: "PXPIPE_SENTINEL" }] }; + const pxpipeTransform = vi.fn(async () => ({ + applied: true, + reason: "applied", + body: new TextEncoder().encode(JSON.stringify(transformedBody)), + info: { compressedChars: 25000, imageCount: 1, imageBytes: 1000, imagePixels: 750000 }, + cache: { ownsCacheControl: true }, + })); await handleChatCore({ - body: { model: "gpt-4o", stream: false, messages }, - modelInfo: { provider: "openai", model: "gpt-4o" }, + body: { + model: "claude-3-5-sonnet", + stream: false, + max_tokens: 100, + system: "base", + messages: [{ role: "user", content: [{ type: "text", text: "x".repeat(30000) }] }], + }, + sourceFormatOverride: FORMATS.CLAUDE, + modelInfo: { provider: "anthropic", model: "claude-3-5-sonnet" }, credentials: { apiKey: "test-key", providerSpecificData: {} }, log, connectionId: "test-conn", - headroomEnabled: true, - headroomUrl: "http://localhost:8787", - headroomCompressUserMessages: true, - rtkEnabled: true, - cavemanEnabled: true, - cavemanLevel: "full", - ponytailEnabled: true, - ponytailLevel: "full", + headroomEnabled: false, + rtkEnabled: false, + cavemanEnabled: false, + ponytailEnabled: false, pxpipeEnabled: true, + pxpipeMinChars: 1000, pxpipeTransform, + onPxpipeEvent, clientRawRequest: { - endpoint: "/v1/chat/completions", + endpoint: "/v1/messages", body: {}, headers: { accept: "application/json", - "x-9router-token-saver": "off", + "user-agent": "claude-code", + "x-9router-token-saver": headerValue, }, }, }); - expect(global.fetch).not.toHaveBeenCalled(); expect(pxpipeTransform).not.toHaveBeenCalled(); + expect(onPxpipeEvent).toHaveBeenCalledWith(expect.objectContaining({ + applied: false, + reason: "disabled", + })); expect(executeMock).toHaveBeenCalledWith(expect.objectContaining({ body: expect.objectContaining({ - messages: [{ role: "user", content: "Write polished prose." }], + system: "base", + messages: [expect.objectContaining({ + content: [expect.objectContaining({ text: expect.stringContaining("xxxxx") })], + })], }), })); }); + + it("pxpipe applies transform when no opt-out header is present", async () => { + const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }; + const onPxpipeEvent = vi.fn(); + const transformedBody = { model: "claude-3-5-sonnet", stream: false, max_tokens: 100, system: "base", messages: [{ role: "user", content: [{ type: "text", text: "PXPIPE_SENTINEL" }] }] }; + const pxpipeTransform = vi.fn(async () => ({ + applied: true, + reason: "applied", + body: new TextEncoder().encode(JSON.stringify(transformedBody)), + info: { compressedChars: 25000, imageCount: 1, imageBytes: 1000, imagePixels: 750000 }, + cache: { ownsCacheControl: true }, + })); + + await handleChatCore({ + body: { + model: "claude-3-5-sonnet", + stream: false, + max_tokens: 100, + system: "base", + messages: [{ role: "user", content: [{ type: "text", text: "x".repeat(30000) }] }], + }, + sourceFormatOverride: FORMATS.CLAUDE, + modelInfo: { provider: "anthropic", model: "claude-3-5-sonnet" }, + credentials: { apiKey: "test-key", providerSpecificData: {} }, + log, + connectionId: "test-conn", + headroomEnabled: false, + rtkEnabled: false, + cavemanEnabled: false, + ponytailEnabled: false, + pxpipeEnabled: true, + pxpipeMinChars: 1000, + pxpipeTransform, + onPxpipeEvent, + clientRawRequest: { + endpoint: "/v1/messages", + body: {}, + headers: { + accept: "application/json", + "user-agent": "claude-code", + }, + }, + }); + + expect(pxpipeTransform).toHaveBeenCalledTimes(1); + expect(onPxpipeEvent).toHaveBeenCalledWith(expect.objectContaining({ + applied: true, + reason: "applied", + })); + const sentBody = executeMock.mock.calls[0][0].body; + expect(JSON.stringify(sentBody)).toContain("PXPIPE_SENTINEL"); + expect(JSON.stringify(sentBody)).not.toContain("x".repeat(1000)); + }); });