feat(headroom): make the compression request timeout configurable
The 3000 ms timeout on /v1/compress was fixed, so busy or slow machines timed out often and sent the LLM an inconsistently compressed body, hurting prompt caching. Add a headroomTimeoutMs setting, thread it from the chat handler down to compressWithHeadroom, expose it in the Token Saver dashboard, and normalize invalid values back to the 3000 ms default.
This commit is contained in:
@@ -58,7 +58,7 @@ export function stripContinuityFields(body) {
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking }) {
|
||||
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, headroomTimeoutMs, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking }) {
|
||||
const { provider, model } = modelInfo;
|
||||
const requestStartTime = Date.now();
|
||||
// Stable per-session color so all lines of one CLI conversation share a tag
|
||||
@@ -257,7 +257,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
|
||||
// Headroom: optional external proxy compression; fail open if proxy is absent.
|
||||
const headroomDiagnostics = {};
|
||||
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics });
|
||||
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, timeoutMs: headroomTimeoutMs, diagnostics: headroomDiagnostics });
|
||||
const headroomLine = formatHeadroomLog(headroomStats);
|
||||
const headroomSizeLine = formatHeadroomSizeLog(headroomDiagnostics);
|
||||
if (headroomLine) {
|
||||
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 3000;
|
||||
|
||||
function normalizeTimeout(value) {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
: DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function jsonBytes(value) {
|
||||
try {
|
||||
return new TextEncoder().encode(JSON.stringify(value) || "").length;
|
||||
@@ -240,6 +246,7 @@ async function callCompress(url, messages, model, timeoutMs, compressUserMessage
|
||||
// /v1/compress only understands OpenAI shape, so Claude bodies are translated
|
||||
// to OpenAI, compressed, then translated back using 9Router's own translators.
|
||||
export async function compressWithHeadroom(body, { enabled, url, model, format, compressUserMessages, timeoutMs = DEFAULT_TIMEOUT_MS, diagnostics = null } = {}) {
|
||||
timeoutMs = normalizeTimeout(timeoutMs);
|
||||
if (!enabled) {
|
||||
setDiagnostic(diagnostics, "disabled");
|
||||
return null;
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function TokenSaverClient() {
|
||||
const [rtkEnabled, setRtkEnabledState] = useState(true);
|
||||
const [headroomEnabled, setHeadroomEnabled] = useState(false);
|
||||
const [headroomUrl, setHeadroomUrl] = useState("http://localhost:8787");
|
||||
const [headroomTimeoutMs, setHeadroomTimeoutMs] = useState(3000);
|
||||
const [headroomStatus, setHeadroomStatus] = useState({
|
||||
installed: false,
|
||||
running: false,
|
||||
@@ -406,6 +407,13 @@ export default function TokenSaverClient() {
|
||||
patchSetting({ pxpipeMinChars: next });
|
||||
};
|
||||
|
||||
const handleHeadroomTimeoutBlur = () => {
|
||||
const raw = Math.round(Number(headroomTimeoutMs));
|
||||
const next = Number.isFinite(raw) && raw > 0 ? raw : 3000;
|
||||
setHeadroomTimeoutMs(next);
|
||||
patchSetting({ headroomTimeoutMs: next });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
@@ -415,6 +423,7 @@ export default function TokenSaverClient() {
|
||||
setRtkEnabledState(data.rtkEnabled !== false);
|
||||
setHeadroomEnabled(!!data.headroomEnabled);
|
||||
setHeadroomUrl(data.headroomUrl || "http://localhost:8787");
|
||||
if (typeof data.headroomTimeoutMs === "number") setHeadroomTimeoutMs(data.headroomTimeoutMs);
|
||||
setCodeAware(data.headroomCodeAware === true);
|
||||
setKompress(data.headroomKompress !== false);
|
||||
setCavemanEnabled(!!data.cavemanEnabled);
|
||||
@@ -818,6 +827,19 @@ export default function TokenSaverClient() {
|
||||
like http://headroom:8787.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">Timeout (ms)</p>
|
||||
<Input
|
||||
value={String(headroomTimeoutMs)}
|
||||
onChange={(e) => setHeadroomTimeoutMs(e.target.value)}
|
||||
onBlur={handleHeadroomTimeoutBlur}
|
||||
placeholder="3000"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Request timeout in milliseconds. Defaults to 3000 ms.
|
||||
</p>
|
||||
</div>
|
||||
{headroomManaged ? (
|
||||
<Button
|
||||
onClick={handleHeadroomStop}
|
||||
|
||||
@@ -53,6 +53,7 @@ const DEFAULT_SETTINGS = {
|
||||
headroomEnabled: false,
|
||||
headroomUrl: DEFAULT_HEADROOM_URL,
|
||||
headroomCompressUserMessages: false,
|
||||
headroomTimeoutMs: 3000,
|
||||
cavemanEnabled: false,
|
||||
cavemanLevel: "full",
|
||||
ponytailEnabled: false,
|
||||
|
||||
@@ -274,6 +274,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||
headroomEnabled: !!chatSettings.headroomEnabled,
|
||||
headroomUrl: chatSettings.headroomUrl || DEFAULT_HEADROOM_URL,
|
||||
headroomCompressUserMessages: !!chatSettings.headroomCompressUserMessages,
|
||||
headroomTimeoutMs: chatSettings.headroomTimeoutMs,
|
||||
cavemanEnabled: !!chatSettings.cavemanEnabled,
|
||||
cavemanLevel: chatSettings.cavemanLevel || "full",
|
||||
ponytailEnabled: !!chatSettings.ponytailEnabled,
|
||||
|
||||
@@ -202,6 +202,101 @@ describe("compressWithHeadroom", () => {
|
||||
expect(stats).toBeNull();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("timeout normalization", () => {
|
||||
const mockResponse = JSON.stringify({
|
||||
messages: [{ role: "user", content: "short" }],
|
||||
tokens_before: 100,
|
||||
tokens_after: 20,
|
||||
tokens_saved: 80,
|
||||
});
|
||||
|
||||
function makeSuccessfulFetch() {
|
||||
global.fetch = vi.fn(async () =>
|
||||
new Response(mockResponse, { status: 200 })
|
||||
);
|
||||
}
|
||||
|
||||
function captureTimeoutCalls() {
|
||||
const calls = [];
|
||||
vi.spyOn(AbortSignal, "timeout").mockImplementation((ms) => {
|
||||
calls.push(ms);
|
||||
const controller = new AbortController();
|
||||
return controller.signal;
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
it("passes a valid positive timeout to AbortSignal.timeout", async () => {
|
||||
makeSuccessfulFetch();
|
||||
const calls = captureTimeoutCalls();
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: 5000 });
|
||||
|
||||
expect(calls).toContain(5000);
|
||||
});
|
||||
|
||||
it("falls back to the default timeout when timeoutMs is null", async () => {
|
||||
makeSuccessfulFetch();
|
||||
const calls = captureTimeoutCalls();
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: null });
|
||||
|
||||
expect(calls).toContain(3000);
|
||||
});
|
||||
|
||||
it("falls back to the default timeout when timeoutMs is 0", async () => {
|
||||
makeSuccessfulFetch();
|
||||
const calls = captureTimeoutCalls();
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: 0 });
|
||||
|
||||
expect(calls).toContain(3000);
|
||||
});
|
||||
|
||||
it("falls back to the default timeout when timeoutMs is negative", async () => {
|
||||
makeSuccessfulFetch();
|
||||
const calls = captureTimeoutCalls();
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: -100 });
|
||||
|
||||
expect(calls).toContain(3000);
|
||||
});
|
||||
|
||||
it("falls back to the default timeout when timeoutMs is NaN", async () => {
|
||||
makeSuccessfulFetch();
|
||||
const calls = captureTimeoutCalls();
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: NaN });
|
||||
|
||||
expect(calls).toContain(3000);
|
||||
});
|
||||
|
||||
it("falls back to the default timeout when timeoutMs is Infinity", async () => {
|
||||
makeSuccessfulFetch();
|
||||
const calls = captureTimeoutCalls();
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: Infinity });
|
||||
|
||||
expect(calls).toContain(3000);
|
||||
});
|
||||
|
||||
it("falls back to the default timeout when timeoutMs is a string", async () => {
|
||||
makeSuccessfulFetch();
|
||||
const calls = captureTimeoutCalls();
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: "5000" });
|
||||
|
||||
expect(calls).toContain(3000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatHeadroomLog", () => {
|
||||
|
||||
Reference in New Issue
Block a user