feat(pxpipe): PXPIPE token saver — multimodal prompt compression (#2465)
Add pxpipe as an experimental fifth Token Saver: Claude-format request bodies above a configurable size threshold are rendered as dense PNGs via the pxpipe-proxy library API (transformAnthropicMessages) before dispatch, cutting estimated input tokens by ~35-60% on token-dense contexts. Integration follows the Headroom pattern: applied to the final body in chatCore just before dispatch, fail-open on any error/timeout. Managed npm install into DATA_DIR/pxpipe, dynamic loader with per-version cache-bust, JSONL event log with rotation, /api/pxpipe/* endpoints, Token Saver card (marked experimental) + /dashboard/pxpipe page, and per-request Activated/Skipped annotation in Request Details. Disabled by default.
This commit is contained in:
committed by
decolua
parent
e1f3399b73
commit
dcf1927f22
@@ -24,6 +24,7 @@ import { injectCaveman } from "../rtk/caveman.js";
|
||||
import { injectPonytail } from "../rtk/ponytail.js";
|
||||
import { compressMessages, formatRtkLog } from "../rtk/index.js";
|
||||
import { compressWithHeadroom, formatHeadroomLog, formatHeadroomSizeLog, isHeadroomPhantomSavings } from "../rtk/headroom.js";
|
||||
import { compressWithPxpipe, formatPxpipeLog } from "../rtk/pxpipe.js";
|
||||
import { getCapabilitiesForModel } from "../providers/capabilities.js";
|
||||
import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
|
||||
import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
|
||||
@@ -35,7 +36,7 @@ import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
|
||||
* @param {object} options.credentials - Provider credentials
|
||||
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses")
|
||||
*/
|
||||
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking }) {
|
||||
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 }) {
|
||||
const { provider, model } = modelInfo;
|
||||
const requestStartTime = Date.now();
|
||||
|
||||
@@ -186,6 +187,21 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
log?.debug?.("PONYTAIL", `${ponytailLevel} | ${finalFormat}`);
|
||||
}
|
||||
|
||||
// PXPIPE: image bulky context (Claude-format bodies only), last saver before dispatch
|
||||
let pxpipeSummary = null;
|
||||
if (pxpipeEnabled) {
|
||||
const pxpipeResult = await compressWithPxpipe(translatedBody, {
|
||||
enabled: true, format: finalFormat, model: upstreamModel,
|
||||
minChars: pxpipeMinChars, timeoutMs: pxpipeTimeoutMs, transform: pxpipeTransform,
|
||||
});
|
||||
pxpipeSummary = pxpipeResult.summary;
|
||||
if (pxpipeResult.body) translatedBody = pxpipeResult.body;
|
||||
const pxpipeLine = formatPxpipeLog(pxpipeSummary);
|
||||
if (pxpipeLine) log?.info?.("PXPIPE", pxpipeLine);
|
||||
else log?.debug?.("PXPIPE", `skipped: ${pxpipeSummary.reason}${pxpipeSummary.detail ? ` (${pxpipeSummary.detail})` : ""}`);
|
||||
try { onPxpipeEvent?.({ provider, model, ...pxpipeSummary }); } catch { /* stats must not break requests */ }
|
||||
}
|
||||
|
||||
const executor = getExecutor(provider);
|
||||
trackPendingRequest(model, provider, connectionId, true);
|
||||
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { });
|
||||
@@ -254,6 +270,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: translatedBody || null,
|
||||
response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null },
|
||||
pxpipe: pxpipeSummary,
|
||||
status: "error"
|
||||
})).catch(() => { });
|
||||
|
||||
@@ -300,6 +317,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null,
|
||||
response: { error: message, status: statusCode, thinking: null },
|
||||
pxpipe: pxpipeSummary,
|
||||
status: "error"
|
||||
})).catch(() => { });
|
||||
|
||||
@@ -309,7 +327,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
return createErrorResult(statusCode, errMsg, resetsAtMs);
|
||||
}
|
||||
|
||||
const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess };
|
||||
const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, pxpipe: pxpipeSummary };
|
||||
const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => { });
|
||||
const trackDone = () => trackPendingRequest(model, provider, connectionId, false);
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
|
||||
/**
|
||||
* Handle non-streaming response from provider.
|
||||
*/
|
||||
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog }) {
|
||||
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog, pxpipe }) {
|
||||
trackDone();
|
||||
const contentType = providerResponse.headers.get("content-type") || "";
|
||||
let responseBody;
|
||||
@@ -296,6 +296,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
|
||||
thinking: translatedResponse?.choices?.[0]?.message?.reasoning_content || translatedResponse?.reasoning_content || null,
|
||||
finish_reason: translatedResponse?.choices?.[0]?.finish_reason || "unknown"
|
||||
},
|
||||
pxpipe,
|
||||
status: "success"
|
||||
}, { endpoint: clientRawRequest?.endpoint || null })).catch(err => {
|
||||
console.error("[RequestDetail] Failed to save:", err.message);
|
||||
|
||||
@@ -69,6 +69,7 @@ export function buildRequestDetail(base, overrides = {}) {
|
||||
providerRequest: base.providerRequest || null,
|
||||
providerResponse: base.providerResponse || null,
|
||||
response: base.response || {},
|
||||
pxpipe: base.pxpipe || undefined,
|
||||
status: base.status || "success",
|
||||
...overrides
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
|
||||
/**
|
||||
* Handle streaming response — pipe provider SSE through transform stream to client.
|
||||
*/
|
||||
export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId }) {
|
||||
export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId, pxpipe }) {
|
||||
if (onRequestSuccess) {
|
||||
Promise.resolve()
|
||||
.then(onRequestSuccess)
|
||||
@@ -94,6 +94,7 @@ export async function handleStreamingResponse({ providerResponse, provider, mode
|
||||
providerRequest: finalBody || translatedBody || null,
|
||||
providerResponse: "[Streaming - raw response not captured]",
|
||||
response: { content: "[Streaming in progress...]", thinking: null, type: "streaming" },
|
||||
pxpipe,
|
||||
status: "success"
|
||||
}, { id: streamDetailId })).catch(err => {
|
||||
console.error("[RequestDetail] Failed to save streaming request:", err.message);
|
||||
@@ -108,7 +109,7 @@ export async function handleStreamingResponse({ providerResponse, provider, mode
|
||||
/**
|
||||
* Build onStreamComplete callback for streaming usage tracking.
|
||||
*/
|
||||
export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest }) {
|
||||
export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest, pxpipe }) {
|
||||
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
const onStreamComplete = (contentObj, usage, ttftAt) => {
|
||||
@@ -127,6 +128,7 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r
|
||||
providerRequest: finalBody || translatedBody || null,
|
||||
providerResponse: safeContent,
|
||||
response: { content: safeContent, thinking: safeThinking, type: "streaming" },
|
||||
pxpipe,
|
||||
status: "success"
|
||||
}, { id: streamDetailId })).catch(err => {
|
||||
console.error("[RequestDetail] Failed to update streaming content:", err.message);
|
||||
|
||||
104
open-sse/rtk/pxpipe.js
Normal file
104
open-sse/rtk/pxpipe.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// PXPIPE: render bulky Claude-format context as dense PNGs via pxpipe-proxy's
|
||||
// library API (transformAnthropicMessages). Fail-open like every token saver:
|
||||
// any error/timeout returns { body: null, summary } and leaves the request untouched.
|
||||
import { FORMATS } from "../translator/formats.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15000;
|
||||
const DEFAULT_MIN_CHARS = 25000;
|
||||
// pxpipe's own profitability gate assumes ~4 chars/token; reuse it for the
|
||||
// estimated before/after numbers surfaced in stats (marked "estimated" in UI).
|
||||
const EST_CHARS_PER_TOKEN = 4;
|
||||
|
||||
function bodyChars(body) {
|
||||
try {
|
||||
return JSON.stringify(body)?.length || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function estTokens(chars) {
|
||||
return Math.round(chars / EST_CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
function skipped(reason, extra = {}) {
|
||||
return { body: null, summary: { applied: false, reason, ...extra } };
|
||||
}
|
||||
|
||||
// Transform a Claude-format request body through pxpipe. Returns
|
||||
// { body: <new body object> | null, summary } — body is null when nothing changed.
|
||||
// opts.transform is injected by the host (src side) so open-sse stays free of
|
||||
// filesystem/install concerns and remains usable standalone.
|
||||
export async function compressWithPxpipe(body, { enabled, format, model, minChars, timeoutMs, transform } = {}) {
|
||||
if (!enabled) return skipped("disabled");
|
||||
if (typeof transform !== "function") return skipped("not_installed");
|
||||
if (!body) return skipped("missing_body");
|
||||
if (format !== FORMATS.CLAUDE) return skipped("unsupported_format", { detail: format });
|
||||
|
||||
const startedAt = Date.now();
|
||||
const originalChars = bodyChars(body);
|
||||
const threshold = Number(minChars) > 0 ? Number(minChars) : DEFAULT_MIN_CHARS;
|
||||
if (originalChars < threshold) {
|
||||
return skipped("below_threshold", { originalChars, threshold });
|
||||
}
|
||||
|
||||
try {
|
||||
const encoded = new TextEncoder().encode(JSON.stringify(body));
|
||||
const budget = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_TIMEOUT_MS;
|
||||
// transformAnthropicMessages is local CPU work and can't be aborted; race a
|
||||
// timer and discard the result if it loses (input body is never mutated).
|
||||
const result = await Promise.race([
|
||||
transform({
|
||||
body: encoded,
|
||||
model,
|
||||
options: { minCompressChars: threshold },
|
||||
}),
|
||||
new Promise((resolve) => setTimeout(() => resolve(null), budget)),
|
||||
]);
|
||||
if (!result) return skipped("timeout", { originalChars, durationMs: Date.now() - startedAt });
|
||||
if (!result.applied) {
|
||||
return skipped(result.reason || "passthrough", {
|
||||
detail: result.detail,
|
||||
originalChars,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const newBody = JSON.parse(new TextDecoder().decode(result.body));
|
||||
const compressedBodyChars = bodyChars(newBody);
|
||||
const info = result.info || {};
|
||||
const imagedChars = info.compressedChars || 0;
|
||||
// The transformed body is BIGGER in bytes (base64 PNGs) but cheaper in tokens:
|
||||
// images bill by pixels (Anthropic: pixels/750), not by encoded length. So the
|
||||
// after-estimate is remaining-text tokens + image tokens — never chars/4 of the
|
||||
// new body. Provider-billed usage recorded per request stays the ground truth.
|
||||
const imageTokensEst = info.imageTokens
|
||||
|| (info.imagePixels ? Math.round(info.imagePixels / 750) : (info.imageCount || 0) * 4761);
|
||||
const summary = {
|
||||
applied: true,
|
||||
reason: "applied",
|
||||
originalChars,
|
||||
compressedBodyChars,
|
||||
imagedChars,
|
||||
imageCount: info.imageCount || 0,
|
||||
imageBytes: info.imageBytes || 0,
|
||||
tokensBeforeEst: info.baselineTokens || estTokens(originalChars),
|
||||
tokensAfterEst: estTokens(Math.max(0, originalChars - imagedChars)) + imageTokensEst,
|
||||
durationMs: Date.now() - startedAt,
|
||||
cacheOwnsControl: result.cache?.ownsCacheControl === true,
|
||||
};
|
||||
summary.tokensSavedEst = Math.max(0, summary.tokensBeforeEst - summary.tokensAfterEst);
|
||||
summary.savedPct = summary.tokensBeforeEst > 0
|
||||
? +((summary.tokensSavedEst / summary.tokensBeforeEst) * 100).toFixed(2)
|
||||
: 0;
|
||||
return { body: newBody, summary };
|
||||
} catch (e) {
|
||||
return skipped("transform_error", { detail: e?.message || String(e), originalChars, durationMs: Date.now() - startedAt });
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPxpipeLog(summary) {
|
||||
if (!summary) return null;
|
||||
if (!summary.applied) return null;
|
||||
return `imaged ${summary.imagedChars}ch → ${summary.imageCount} image(s) | est ${summary.tokensBeforeEst}→${summary.tokensAfterEst} tokens (-${summary.savedPct}%) | ${summary.durationMs}ms`;
|
||||
}
|
||||
283
src/app/(dashboard)/dashboard/pxpipe/PxpipeClient.js
Normal file
283
src/app/(dashboard)/dashboard/pxpipe/PxpipeClient.js
Normal file
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
|
||||
const fmtTokens = (n) => {
|
||||
if (n >= 1000000) return `${(n / 1000000).toFixed(2)}M`;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
||||
return String(n || 0);
|
||||
};
|
||||
|
||||
const fmtUptime = (ms) => {
|
||||
if (!ms || ms <= 0) return "—";
|
||||
const m = Math.floor(ms / 60000);
|
||||
const h = Math.floor(m / 60);
|
||||
return h > 0 ? `${h}h${String(m % 60).padStart(2, "0")}m` : `${m}m`;
|
||||
};
|
||||
|
||||
const WINDOW_TABS = [
|
||||
{ id: "today", label: "Today" },
|
||||
{ id: "yesterday", label: "Yesterday" },
|
||||
{ id: "last7d", label: "7 days" },
|
||||
{ id: "last30d", label: "30 days" },
|
||||
{ id: "all", label: "All time" },
|
||||
];
|
||||
|
||||
const REASON_LABELS = {
|
||||
applied: "Prompt exceeded threshold",
|
||||
below_threshold: "Below size threshold",
|
||||
not_profitable: "Compression not profitable",
|
||||
below_min_chars: "Below minimum chars",
|
||||
below_min_tokens: "Below minimum tokens",
|
||||
unsupported_model: "Model not in allowlist",
|
||||
unsupported_format: "Non-Claude request format",
|
||||
timeout: "Compression timed out",
|
||||
transform_error: "Transform error",
|
||||
passthrough: "Passthrough",
|
||||
disabled: "Disabled",
|
||||
not_installed: "Not installed",
|
||||
};
|
||||
|
||||
function SummaryCard({ label, value, sub, tone }) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<p className="text-xs text-text-muted uppercase tracking-wide">{label}</p>
|
||||
<p className={`text-xl font-semibold mt-1 ${tone || ""}`}>{value}</p>
|
||||
{sub && <p className="text-xs text-text-muted mt-0.5">{sub}</p>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PxpipeClient() {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [health, setHealth] = useState(null);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [logs, setLogs] = useState(null);
|
||||
const [windowId, setWindowId] = useState("last7d");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statusRes, statsRes, logsRes] = await Promise.all([
|
||||
fetch("/api/pxpipe/status", { headers: { "Cache-Control": "no-store" } }),
|
||||
fetch("/api/pxpipe/stats"),
|
||||
fetch("/api/pxpipe/logs?limit=50"),
|
||||
]);
|
||||
setStatus(await statusRes.json());
|
||||
setStats(await statsRes.json());
|
||||
setLogs(await logsRes.json());
|
||||
const healthRes = await fetch("/api/pxpipe/health", { method: "POST" });
|
||||
setHealth(await healthRes.json());
|
||||
} catch {
|
||||
/* sections render placeholders */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const w = stats?.windows?.[windowId];
|
||||
const statusLabel = !status
|
||||
? "—"
|
||||
: !status.installed
|
||||
? "Not installed"
|
||||
: health?.healthy
|
||||
? "Healthy"
|
||||
: status.running
|
||||
? "Running"
|
||||
: "Stopped";
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary">image</span>
|
||||
PXPIPE Dashboard
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<a href="/dashboard/token-saver" className="text-xs text-primary underline hover:opacity-80">
|
||||
Token Saver settings
|
||||
</a>
|
||||
<Button size="sm" variant="ghost" onClick={refresh} disabled={loading}>
|
||||
{loading ? "Refreshing…" : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<SummaryCard
|
||||
label="Status"
|
||||
value={statusLabel}
|
||||
tone={health?.healthy ? "text-success" : status?.installed ? "text-warning" : "text-text-muted"}
|
||||
sub={status?.enabled ? "Enabled in pipeline" : "Disabled in pipeline"}
|
||||
/>
|
||||
<SummaryCard label="Version" value={status?.version ? `v${status.version}` : "—"} sub="pxpipe-proxy" />
|
||||
<SummaryCard label="Uptime" value={fmtUptime(status?.uptimeMs)} sub="module loaded" />
|
||||
<SummaryCard label="Requests" value={w ? w.requests.toLocaleString() : "—"} />
|
||||
<SummaryCard label="Compressed" value={w ? w.compressed.toLocaleString() : "—"} tone="text-success" />
|
||||
<SummaryCard label="Bypassed" value={w ? w.bypassed.toLocaleString() : "—"} />
|
||||
</div>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3 mb-4">
|
||||
<h3 className="font-medium">Token savings (estimated)</h3>
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1">
|
||||
{WINDOW_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setWindowId(tab.id)}
|
||||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||
windowId === tab.id
|
||||
? "bg-primary text-white shadow-sm"
|
||||
: "text-text-muted hover:text-text hover:bg-bg-hover"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-xs text-text-muted">Original tokens</p>
|
||||
<p className="text-lg font-semibold">{w ? fmtTokens(w.tokensBeforeEst) : "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-muted">After PXPIPE</p>
|
||||
<p className="text-lg font-semibold">{w ? fmtTokens(w.tokensAfterEst) : "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-muted">Saved</p>
|
||||
<p className="text-lg font-semibold text-success">{w ? fmtTokens(w.tokensSavedEst) : "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-muted">Reduction</p>
|
||||
<p className="text-lg font-semibold text-success">{w ? `${w.savedPct}%` : "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-3">
|
||||
Estimates from body size before/after imaging; billed usage per request
|
||||
(recorded on the Usage page) remains the ground truth. Images generated:{" "}
|
||||
{w ? w.imagesGenerated.toLocaleString() : "—"} · avg compression time:{" "}
|
||||
{w ? `${w.avgCompressionMs}ms` : "—"} · errors: {w ? w.errors : "—"}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<h3 className="font-medium mb-3">Tokens saved — last 30 days</h3>
|
||||
{stats?.timeline?.some((d) => d.tokensSavedEst > 0) ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={stats.timeline} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradPxpipe" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.25} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.2} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} tickFormatter={(d) => d.slice(5)} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={fmtTokens} width={48} />
|
||||
<Tooltip formatter={(v) => [fmtTokens(v), "Tokens saved"]} labelFormatter={(d) => d} />
|
||||
<Area type="monotone" dataKey="tokensSavedEst" stroke="#10b981" fill="url(#gradPxpipe)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-32 flex items-center justify-center text-text-muted text-sm">
|
||||
No savings recorded yet — enable PXPIPE in the Token Saver and route a large Claude-format request.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<h3 className="font-medium mb-3">History</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-text-muted border-b border-border">
|
||||
<th className="py-2 pr-3">Time</th>
|
||||
<th className="py-2 pr-3">Model</th>
|
||||
<th className="py-2 pr-3 text-right">Original</th>
|
||||
<th className="py-2 pr-3 text-right">Compressed</th>
|
||||
<th className="py-2 pr-3 text-right">Saved</th>
|
||||
<th className="py-2 pr-3 text-right">%</th>
|
||||
<th className="py-2 pr-3 text-right">Duration</th>
|
||||
<th className="py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(stats?.recent || []).slice(0, 50).map((ev, i) => (
|
||||
<tr key={`${ev.ts}-${i}`} className="border-b border-border/50">
|
||||
<td className="py-1.5 pr-3 whitespace-nowrap text-text-muted">
|
||||
{new Date(ev.ts).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 font-mono text-xs">{ev.provider ? `${ev.provider}/${ev.model}` : ev.model || "—"}</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-xs">
|
||||
{ev.applied ? fmtTokens(ev.tokensBeforeEst) : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-xs">
|
||||
{ev.applied ? fmtTokens(ev.tokensAfterEst) : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-xs text-success">
|
||||
{ev.applied ? fmtTokens(ev.tokensSavedEst) : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-xs">
|
||||
{ev.applied ? `${ev.savedPct}%` : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-xs">
|
||||
{ev.durationMs != null ? `${ev.durationMs}ms` : "—"}
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded ${
|
||||
ev.applied
|
||||
? "bg-success/15 text-success"
|
||||
: ev.reason === "transform_error" || ev.reason === "timeout"
|
||||
? "bg-danger/15 text-danger"
|
||||
: "bg-warning/15 text-warning"
|
||||
}`}
|
||||
title={ev.detail || ""}
|
||||
>
|
||||
{ev.applied ? "Compressed" : REASON_LABELS[ev.reason] || ev.reason}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!stats?.recent || stats.recent.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-6 text-center text-text-muted text-sm">
|
||||
No PXPIPE activity yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4" id="logs">
|
||||
<h3 className="font-medium mb-3">PXPIPE Logs</h3>
|
||||
{logs?.installLog ? (
|
||||
<pre className="rounded bg-black/5 dark:bg-white/5 p-3 text-xs font-mono overflow-x-auto max-h-64 overflow-y-auto whitespace-pre-wrap">
|
||||
{logs.installLog}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No install log yet.</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/app/(dashboard)/dashboard/pxpipe/page.js
Normal file
5
src/app/(dashboard)/dashboard/pxpipe/page.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import PxpipeClient from "./PxpipeClient";
|
||||
|
||||
export default function PxpipePage() {
|
||||
return <PxpipeClient />;
|
||||
}
|
||||
@@ -37,6 +37,19 @@ export default function TokenSaverClient() {
|
||||
const [cavemanLevel, setCavemanLevel] = useState("full");
|
||||
const [ponytailEnabled, setPonytailEnabled] = useState(false);
|
||||
const [ponytailLevel, setPonytailLevel] = useState("full");
|
||||
const [pxpipeEnabled, setPxpipeEnabled] = useState(false);
|
||||
const [pxpipeMinChars, setPxpipeMinChars] = useState(25000);
|
||||
const [pxpipeStatus, setPxpipeStatus] = useState({
|
||||
installed: false,
|
||||
installing: false,
|
||||
running: false,
|
||||
version: null,
|
||||
loading: true,
|
||||
});
|
||||
const [pxpipeHealth, setPxpipeHealth] = useState(null);
|
||||
const [showPxpipeModal, setShowPxpipeModal] = useState(false);
|
||||
const [pxpipeActionLoading, setPxpipeActionLoading] = useState(false);
|
||||
const [pxpipeActionError, setPxpipeActionError] = useState("");
|
||||
const [locale, setLocale] = useState("en");
|
||||
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
@@ -232,6 +245,59 @@ export default function TokenSaverClient() {
|
||||
patchSetting({ ponytailLevel: level });
|
||||
};
|
||||
|
||||
const refreshPxpipeStatus = useCallback(async () => {
|
||||
setPxpipeStatus((s) => ({ ...s, loading: true }));
|
||||
try {
|
||||
const res = await fetch("/api/pxpipe/status", {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
const data = await res.json();
|
||||
setPxpipeStatus({ ...data, loading: false });
|
||||
if (typeof data.minChars === "number") setPxpipeMinChars(data.minChars);
|
||||
} catch {
|
||||
setPxpipeStatus({ installed: false, installing: false, running: false, version: null, loading: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runPxpipeHealth = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/pxpipe/health", { method: "POST" });
|
||||
setPxpipeHealth(await res.json());
|
||||
} catch (e) {
|
||||
setPxpipeHealth({ healthy: false, checks: [], error: e.message });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pxpipeAction = useCallback(
|
||||
async (endpoint) => {
|
||||
setPxpipeActionError("");
|
||||
setPxpipeActionLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/pxpipe/${endpoint}`, { method: "POST" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `PXPIPE ${endpoint} failed`);
|
||||
await refreshPxpipeStatus();
|
||||
await runPxpipeHealth();
|
||||
} catch (e) {
|
||||
setPxpipeActionError(e.message);
|
||||
} finally {
|
||||
setPxpipeActionLoading(false);
|
||||
}
|
||||
},
|
||||
[refreshPxpipeStatus, runPxpipeHealth]
|
||||
);
|
||||
|
||||
const handlePxpipeEnabled = (value) => {
|
||||
setPxpipeEnabled(value);
|
||||
patchSetting({ pxpipeEnabled: value });
|
||||
};
|
||||
|
||||
const handlePxpipeMinCharsBlur = () => {
|
||||
const next = Math.max(0, Number(pxpipeMinChars) || 25000);
|
||||
setPxpipeMinChars(next);
|
||||
patchSetting({ pxpipeMinChars: next });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
@@ -245,12 +311,16 @@ export default function TokenSaverClient() {
|
||||
setCavemanLevel(data.cavemanLevel || "full");
|
||||
setPonytailEnabled(!!data.ponytailEnabled);
|
||||
setPonytailLevel(data.ponytailLevel || "full");
|
||||
setPxpipeEnabled(!!data.pxpipeEnabled);
|
||||
if (typeof data.pxpipeMinChars === "number") setPxpipeMinChars(data.pxpipeMinChars);
|
||||
refreshHeadroomStatus();
|
||||
// PRD: run the PXPIPE health check automatically when the page opens
|
||||
refreshPxpipeStatus().then(runPxpipeHealth);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
loadSettings();
|
||||
}, [refreshHeadroomStatus]);
|
||||
}, [refreshHeadroomStatus, refreshPxpipeStatus, runPxpipeHealth]);
|
||||
|
||||
const headroomRunning = !!headroomStatus.running;
|
||||
const headroomStatusLabel = headroomStatus.loading
|
||||
@@ -267,6 +337,23 @@ export default function TokenSaverClient() {
|
||||
const headroomManaged =
|
||||
headroomLocalUrl && !!headroomStatus.managedPid;
|
||||
|
||||
const pxpipeHealthy = pxpipeHealth?.healthy === true;
|
||||
const pxpipeStatusLabel = pxpipeStatus.loading
|
||||
? "Checking…"
|
||||
: pxpipeStatus.installing
|
||||
? "Installing…"
|
||||
: !pxpipeStatus.installed
|
||||
? "Not installed"
|
||||
: pxpipeHealthy
|
||||
? "Healthy"
|
||||
: pxpipeStatus.running
|
||||
? "Running"
|
||||
: "Stopped";
|
||||
const pxpipeChipClass =
|
||||
pxpipeHealthy || pxpipeStatus.running
|
||||
? "bg-success/15 text-success"
|
||||
: "bg-warning/15 text-warning";
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Card id="rtk">
|
||||
@@ -502,6 +589,49 @@ export default function TokenSaverClient() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4 mt-4 border-t border-border gap-4 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<p className="font-medium">
|
||||
Compress prompts as images{" "}
|
||||
<a
|
||||
href="https://github.com/teamchong/pxpipe"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs font-normal text-primary underline hover:opacity-80"
|
||||
>
|
||||
(PXPIPE)
|
||||
</a>
|
||||
</p>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${pxpipeChipClass}`}>
|
||||
{pxpipeStatusLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPxpipeModal(true)}
|
||||
className="text-xs text-primary underline hover:opacity-80"
|
||||
>
|
||||
{pxpipeStatus.installed ? "Manage" : "Setup"}
|
||||
</button>
|
||||
<a
|
||||
href="/dashboard/pxpipe"
|
||||
className="text-xs text-primary underline hover:opacity-80"
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Transforms large textual context into optimized images before
|
||||
sending to the LLM. Ideal for huge prompts, tool outputs and long
|
||||
conversations.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={pxpipeEnabled}
|
||||
disabled={!pxpipeStatus.installed}
|
||||
onChange={() => handlePxpipeEnabled(!pxpipeEnabled)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
@@ -611,6 +741,114 @@ export default function TokenSaverClient() {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showPxpipeModal}
|
||||
title={pxpipeStatus.installed ? "PXPIPE" : "Setup PXPIPE"}
|
||||
onClose={() => setShowPxpipeModal(false)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
Compress prompts using multimodal encoding. Runs in-process — no
|
||||
extra server or environment variables required.
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>Status</span>
|
||||
<span className={pxpipeHealthy || pxpipeStatus.running ? "text-success" : "text-warning"}>
|
||||
{pxpipeStatusLabel}
|
||||
{pxpipeStatus.version ? ` · v${pxpipeStatus.version}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{pxpipeHealth?.checks?.length > 0 && (
|
||||
<div className="flex flex-col gap-1 rounded border border-border p-3">
|
||||
<p className="text-sm font-medium mb-1">Health check</p>
|
||||
{pxpipeHealth.checks.map((check) => (
|
||||
<div key={check.id} className="flex items-center justify-between text-xs">
|
||||
<span className={check.ok ? "text-success" : "text-warning"}>
|
||||
{check.ok ? "●" : "○"} {check.label}
|
||||
</span>
|
||||
{check.detail && (
|
||||
<span className="text-text-muted font-mono truncate max-w-[50%]">{check.detail}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{pxpipeHealth.error && (
|
||||
<p className="text-xs text-warning mt-1">{pxpipeHealth.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!pxpipeStatus.installed ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-warning">PXPIPE is not installed.</p>
|
||||
<Button
|
||||
onClick={() => pxpipeAction("install")}
|
||||
fullWidth
|
||||
disabled={pxpipeActionLoading || pxpipeStatus.installing}
|
||||
>
|
||||
{pxpipeActionLoading || pxpipeStatus.installing ? "Installing…" : "Install"}
|
||||
</Button>
|
||||
<p className="text-xs text-text-muted">
|
||||
Installs the npm package <code className="font-mono">pxpipe-proxy</code> into
|
||||
the 9Router data directory. May take a few minutes.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{pxpipeStatus.running ? (
|
||||
<>
|
||||
<Button onClick={() => pxpipeAction("restart")} variant="ghost" disabled={pxpipeActionLoading}>
|
||||
Restart
|
||||
</Button>
|
||||
<Button onClick={() => pxpipeAction("stop")} variant="ghost" disabled={pxpipeActionLoading}>
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => pxpipeAction("start")} disabled={pxpipeActionLoading}>
|
||||
{pxpipeActionLoading ? "Starting…" : "Start"}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => pxpipeAction("install")} variant="ghost" disabled={pxpipeActionLoading}>
|
||||
Repair
|
||||
</Button>
|
||||
<a
|
||||
href="/dashboard/pxpipe#logs"
|
||||
className="col-span-2 rounded border border-border px-4 py-2 text-center text-sm hover:bg-surface-2"
|
||||
>
|
||||
Open Logs
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">Minimum prompt size (chars)</p>
|
||||
<Input
|
||||
value={String(pxpipeMinChars)}
|
||||
onChange={(e) => setPxpipeMinChars(e.target.value)}
|
||||
onBlur={handlePxpipeMinCharsBlur}
|
||||
placeholder="25000"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Requests smaller than this bypass PXPIPE and are sent as-is.
|
||||
</p>
|
||||
</div>
|
||||
{pxpipeActionError && (
|
||||
<p className="text-sm text-warning">{pxpipeActionError}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => refreshPxpipeStatus().then(runPxpipeHealth)}
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
>
|
||||
Recheck
|
||||
</Button>
|
||||
<Button onClick={() => setShowPxpipeModal(false)} fullWidth>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -412,7 +412,49 @@ export default function RequestDetailsTab() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{selectedDetail.pxpipe && (
|
||||
<div className="rounded-lg border border-black/5 dark:border-white/5 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">image</span>
|
||||
<span className="font-semibold text-sm text-text-main">PXPIPE</span>
|
||||
<span className={cn(
|
||||
"text-xs px-2 py-0.5 rounded",
|
||||
selectedDetail.pxpipe.applied
|
||||
? "bg-green-500/15 text-green-600"
|
||||
: "bg-amber-500/15 text-amber-600"
|
||||
)}>
|
||||
{selectedDetail.pxpipe.applied ? "Activated" : "Skipped"}
|
||||
</span>
|
||||
</div>
|
||||
{selectedDetail.pxpipe.applied ? (
|
||||
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||
<div>
|
||||
<span className="text-text-muted block text-xs">Original (est.)</span>
|
||||
<span className="font-mono">{(selectedDetail.pxpipe.tokensBeforeEst || 0).toLocaleString()} tokens</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted block text-xs">Compressed (est.)</span>
|
||||
<span className="font-mono">{(selectedDetail.pxpipe.tokensAfterEst || 0).toLocaleString()} tokens</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted block text-xs">Saved</span>
|
||||
<span className="font-mono text-green-600">{selectedDetail.pxpipe.savedPct || 0}%</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted block text-xs">Images</span>
|
||||
<span className="font-mono">{selectedDetail.pxpipe.imageCount || 0} ({selectedDetail.pxpipe.durationMs || 0}ms)</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">
|
||||
Reason: <span className="font-mono">{selectedDetail.pxpipe.reason}</span>
|
||||
{selectedDetail.pxpipe.detail ? ` — ${selectedDetail.pxpipe.detail}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="1. Client Request (Input)" defaultOpen={true} icon="input">
|
||||
<pre className="max-h-[300px] max-w-full overflow-auto rounded-lg border border-black/5 bg-black/5 p-3 font-mono text-xs text-text-main dark:border-white/5 dark:bg-white/5 sm:p-4">
|
||||
|
||||
16
src/app/api/pxpipe/health/route.js
Normal file
16
src/app/api/pxpipe/health/route.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runHealthCheck } from "@/lib/pxpipe/service.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const result = await runHealthCheck();
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ healthy: false, checks: [], error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// GET mirrors POST so the card can probe on page load without a mutation call.
|
||||
export const GET = POST;
|
||||
20
src/app/api/pxpipe/install/route.js
Normal file
20
src/app/api/pxpipe/install/route.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { installPxpipe } from "@/lib/pxpipe/install.js";
|
||||
import { unloadPxpipe } from "@/lib/pxpipe/loader.js";
|
||||
import { runHealthCheck } from "@/lib/pxpipe/service.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
// npm install can legitimately take minutes on a cold cache.
|
||||
export const maxDuration = 300;
|
||||
|
||||
// Install (or repair — same operation, reinstalls @latest) then re-run the health check.
|
||||
export async function POST() {
|
||||
try {
|
||||
const info = await installPxpipe();
|
||||
unloadPxpipe(); // drop any previously-loaded version so health loads the fresh one
|
||||
const health = await runHealthCheck();
|
||||
return NextResponse.json({ ...info, health });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message, code: error.code || null }, { status: 500 });
|
||||
}
|
||||
}
|
||||
18
src/app/api/pxpipe/logs/route.js
Normal file
18
src/app/api/pxpipe/logs/route.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getInstallLogTail } from "@/lib/pxpipe/install.js";
|
||||
import { readPxpipeEvents } from "@/lib/pxpipe/events.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(Number(searchParams.get("limit")) || 100, 500);
|
||||
return NextResponse.json({
|
||||
installLog: getInstallLogTail(),
|
||||
events: readPxpipeEvents({ limit }).reverse(),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
src/app/api/pxpipe/restart/route.js
Normal file
16
src/app/api/pxpipe/restart/route.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { unloadPxpipe, loadPxpipe } from "@/lib/pxpipe/loader.js";
|
||||
import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Reload the in-process module (picks up an upgraded install without a server restart).
|
||||
export async function POST() {
|
||||
try {
|
||||
unloadPxpipe();
|
||||
await loadPxpipe();
|
||||
return NextResponse.json(getPxpipeStatus());
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message, code: error.code || null }, { status: 500 });
|
||||
}
|
||||
}
|
||||
26
src/app/api/pxpipe/start/route.js
Normal file
26
src/app/api/pxpipe/start/route.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { getInstallInfo, installPxpipe } from "@/lib/pxpipe/install.js";
|
||||
import { loadPxpipe } from "@/lib/pxpipe/loader.js";
|
||||
import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 300;
|
||||
|
||||
// "Start" in library mode = warm the in-process transform module.
|
||||
// Auto-installs first when the package is missing and pxpipeAutoInstall is on.
|
||||
export async function POST() {
|
||||
try {
|
||||
if (!getInstallInfo().installed) {
|
||||
const settings = await getSettings();
|
||||
if (!settings.pxpipeAutoInstall) {
|
||||
return NextResponse.json({ error: "PXPIPE is not installed", code: "NOT_INSTALLED" }, { status: 409 });
|
||||
}
|
||||
await installPxpipe();
|
||||
}
|
||||
await loadPxpipe();
|
||||
return NextResponse.json(getPxpipeStatus());
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message, code: error.code || null }, { status: 500 });
|
||||
}
|
||||
}
|
||||
14
src/app/api/pxpipe/stats/route.js
Normal file
14
src/app/api/pxpipe/stats/route.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPxpipeStats } from "@/lib/pxpipe/events.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const recentLimit = Math.min(Number(searchParams.get("limit")) || 100, 500);
|
||||
return NextResponse.json(getPxpipeStats({ recentLimit }));
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
21
src/app/api/pxpipe/status/route.js
Normal file
21
src/app/api/pxpipe/status/route.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const status = getPxpipeStatus();
|
||||
return NextResponse.json({
|
||||
...status,
|
||||
enabled: !!settings.pxpipeEnabled,
|
||||
autoInstall: !!settings.pxpipeAutoInstall,
|
||||
minChars: settings.pxpipeMinChars,
|
||||
timeoutMs: settings.pxpipeTimeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
src/app/api/pxpipe/stop/route.js
Normal file
16
src/app/api/pxpipe/stop/route.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { unloadPxpipe } from "@/lib/pxpipe/loader.js";
|
||||
import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// "Stop" in library mode = drop the in-process module; requests fail open to
|
||||
// uncompressed passthrough until it is started again.
|
||||
export async function POST() {
|
||||
try {
|
||||
const wasLoaded = unloadPxpipe();
|
||||
return NextResponse.json({ stopped: wasLoaded, ...getPxpipeStatus() });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ async function flushToDatabase() {
|
||||
providerRequest: truncateField(item.providerRequest, config.maxJsonSize),
|
||||
providerResponse: truncateField(item.providerResponse, config.maxJsonSize),
|
||||
response: truncateField(item.response, config.maxJsonSize),
|
||||
pxpipe: item.pxpipe || undefined,
|
||||
};
|
||||
|
||||
db.run(
|
||||
|
||||
@@ -42,6 +42,10 @@ const DEFAULT_SETTINGS = {
|
||||
cavemanLevel: "full",
|
||||
ponytailEnabled: false,
|
||||
ponytailLevel: "full",
|
||||
pxpipeEnabled: false,
|
||||
pxpipeAutoInstall: true,
|
||||
pxpipeMinChars: 25000,
|
||||
pxpipeTimeoutMs: 15000,
|
||||
};
|
||||
|
||||
async function readRaw() {
|
||||
|
||||
125
src/lib/pxpipe/events.js
Normal file
125
src/lib/pxpipe/events.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { PXPIPE_DIR } from "./install.js";
|
||||
|
||||
const EVENTS_FILE = path.join(PXPIPE_DIR, "events.jsonl");
|
||||
const ROTATED_FILE = path.join(PXPIPE_DIR, "events.jsonl.1");
|
||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function ensureDir() {
|
||||
if (!fs.existsSync(PXPIPE_DIR)) fs.mkdirSync(PXPIPE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Fire-and-forget: stats must never break the request path.
|
||||
export function appendPxpipeEvent(event) {
|
||||
try {
|
||||
ensureDir();
|
||||
try {
|
||||
const stat = fs.statSync(EVENTS_FILE);
|
||||
if (stat.size > MAX_FILE_BYTES) fs.renameSync(EVENTS_FILE, ROTATED_FILE);
|
||||
} catch { /* no file yet */ }
|
||||
fs.appendFile(EVENTS_FILE, JSON.stringify({ ts: Date.now(), ...event }) + "\n", () => {});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function readPxpipeEvents({ sinceMs = null, limit = null } = {}) {
|
||||
const events = [];
|
||||
for (const file of [ROTATED_FILE, EVENTS_FILE]) {
|
||||
try {
|
||||
if (!fs.existsSync(file)) continue;
|
||||
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const ev = JSON.parse(line);
|
||||
if (sinceMs && ev.ts < sinceMs) continue;
|
||||
events.push(ev);
|
||||
} catch { /* skip corrupt line */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
events.sort((a, b) => a.ts - b.ts);
|
||||
return limit ? events.slice(-limit) : events;
|
||||
}
|
||||
|
||||
function emptyTotals() {
|
||||
return {
|
||||
requests: 0, compressed: 0, bypassed: 0, errors: 0,
|
||||
tokensBeforeEst: 0, tokensAfterEst: 0, tokensSavedEst: 0, savedPct: 0,
|
||||
imagesGenerated: 0, compressionTimeMs: 0, avgCompressionMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function accumulate(totals, ev) {
|
||||
totals.requests++;
|
||||
if (ev.applied) {
|
||||
totals.compressed++;
|
||||
totals.tokensBeforeEst += ev.tokensBeforeEst || 0;
|
||||
totals.tokensAfterEst += ev.tokensAfterEst || 0;
|
||||
totals.tokensSavedEst += ev.tokensSavedEst || 0;
|
||||
totals.imagesGenerated += ev.imageCount || 0;
|
||||
totals.compressionTimeMs += ev.durationMs || 0;
|
||||
} else if (ev.reason === "transform_error" || ev.reason === "timeout") {
|
||||
totals.errors++;
|
||||
} else {
|
||||
totals.bypassed++;
|
||||
}
|
||||
}
|
||||
|
||||
function finalize(totals) {
|
||||
totals.savedPct = totals.tokensBeforeEst > 0
|
||||
? +((totals.tokensSavedEst / totals.tokensBeforeEst) * 100).toFixed(2)
|
||||
: 0;
|
||||
totals.avgCompressionMs = totals.compressed > 0
|
||||
? Math.round(totals.compressionTimeMs / totals.compressed)
|
||||
: 0;
|
||||
return totals;
|
||||
}
|
||||
|
||||
// Aggregated stats for the dashboard: all-time + windowed totals, a daily
|
||||
// tokens-saved timeline (last `timelineDays`), and the most recent events.
|
||||
export function getPxpipeStats({ timelineDays = 30, recentLimit = 100 } = {}) {
|
||||
const events = readPxpipeEvents();
|
||||
const now = Date.now();
|
||||
const startOfToday = new Date(new Date(now).setHours(0, 0, 0, 0)).getTime();
|
||||
|
||||
const windows = {
|
||||
all: emptyTotals(),
|
||||
today: emptyTotals(),
|
||||
yesterday: emptyTotals(),
|
||||
last7d: emptyTotals(),
|
||||
last30d: emptyTotals(),
|
||||
};
|
||||
|
||||
const timeline = new Map();
|
||||
for (let i = timelineDays - 1; i >= 0; i--) {
|
||||
const day = new Date(startOfToday - i * DAY_MS);
|
||||
timeline.set(day.toISOString().slice(0, 10), { date: day.toISOString().slice(0, 10), tokensSavedEst: 0, compressed: 0, requests: 0 });
|
||||
}
|
||||
|
||||
for (const ev of events) {
|
||||
accumulate(windows.all, ev);
|
||||
if (ev.ts >= startOfToday) accumulate(windows.today, ev);
|
||||
else if (ev.ts >= startOfToday - DAY_MS) accumulate(windows.yesterday, ev);
|
||||
if (ev.ts >= now - 7 * DAY_MS) accumulate(windows.last7d, ev);
|
||||
if (ev.ts >= now - 30 * DAY_MS) accumulate(windows.last30d, ev);
|
||||
|
||||
const key = new Date(ev.ts).toISOString().slice(0, 10);
|
||||
const bucket = timeline.get(key);
|
||||
if (bucket) {
|
||||
bucket.requests++;
|
||||
if (ev.applied) {
|
||||
bucket.compressed++;
|
||||
bucket.tokensSavedEst += ev.tokensSavedEst || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const w of Object.values(windows)) finalize(w);
|
||||
|
||||
return {
|
||||
windows,
|
||||
timeline: [...timeline.values()],
|
||||
recent: events.slice(-recentLimit).reverse(),
|
||||
};
|
||||
}
|
||||
123
src/lib/pxpipe/install.js
Normal file
123
src/lib/pxpipe/install.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { spawn, execSync } from "child_process";
|
||||
import { DATA_DIR } from "@/lib/dataDir.js";
|
||||
|
||||
export const PXPIPE_DIR = path.join(DATA_DIR, "pxpipe");
|
||||
export const PXPIPE_PACKAGE = "pxpipe-proxy";
|
||||
const INSTALL_LOG = path.join(PXPIPE_DIR, "install.log");
|
||||
const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
const IS_WIN = process.platform === "win32";
|
||||
const NPM_CMD = IS_WIN ? "npm.cmd" : "npm";
|
||||
|
||||
// Same PATH extension trick as headroom/detect.js: packaged/launchd environments
|
||||
// often miss the Node bin dirs.
|
||||
const EXTRA_BINS = IS_WIN
|
||||
? [`${process.env.ProgramFiles || ""}\\nodejs`, `${process.env.APPDATA || ""}\\npm`]
|
||||
: ["/usr/local/bin", "/opt/homebrew/bin", `${process.env.HOME || ""}/.local/bin`, "/usr/bin", "/bin"];
|
||||
const EXTENDED_PATH = [...EXTRA_BINS, process.env.PATH || ""].filter(Boolean).join(path.delimiter);
|
||||
|
||||
let installInFlight = null;
|
||||
|
||||
function ensureDir() {
|
||||
if (!fs.existsSync(PXPIPE_DIR)) fs.mkdirSync(PXPIPE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export function packageRoot() {
|
||||
return path.join(PXPIPE_DIR, "node_modules", PXPIPE_PACKAGE);
|
||||
}
|
||||
|
||||
export function libraryEntry() {
|
||||
return path.join(packageRoot(), "dist", "core", "library.js");
|
||||
}
|
||||
|
||||
export function findNpm() {
|
||||
try {
|
||||
const out = execSync(`${IS_WIN ? "where" : "which"} npm`, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
}).toString().trim();
|
||||
return out ? out.split(/\r?\n/)[0].trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// { installed, version, path } — installed means the library entry exists on disk.
|
||||
export function getInstallInfo() {
|
||||
try {
|
||||
const pkgJson = path.join(packageRoot(), "package.json");
|
||||
if (!fs.existsSync(pkgJson) || !fs.existsSync(libraryEntry())) {
|
||||
return { installed: false, version: null, path: null };
|
||||
}
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf8"));
|
||||
return { installed: true, version: pkg.version || null, path: packageRoot() };
|
||||
} catch {
|
||||
return { installed: false, version: null, path: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function isInstalling() {
|
||||
return installInFlight !== null;
|
||||
}
|
||||
|
||||
// Install (or repair by reinstalling) pxpipe-proxy into DATA_DIR/pxpipe.
|
||||
// Serialized: concurrent calls await the same run.
|
||||
export function installPxpipe() {
|
||||
if (installInFlight) return installInFlight;
|
||||
installInFlight = runInstall().finally(() => { installInFlight = null; });
|
||||
return installInFlight;
|
||||
}
|
||||
|
||||
async function runInstall() {
|
||||
const npm = findNpm();
|
||||
if (!npm) {
|
||||
const err = new Error("npm not found on PATH — Node.js/npm is required to install PXPIPE");
|
||||
err.code = "NPM_NOT_FOUND";
|
||||
throw err;
|
||||
}
|
||||
|
||||
ensureDir();
|
||||
const pkgJson = path.join(PXPIPE_DIR, "package.json");
|
||||
if (!fs.existsSync(pkgJson)) {
|
||||
fs.writeFileSync(pkgJson, JSON.stringify({ name: "9router-pxpipe-host", private: true }, null, 2));
|
||||
}
|
||||
|
||||
const outFd = fs.openSync(INSTALL_LOG, "a");
|
||||
fs.writeSync(outFd, `\n[${new Date().toISOString()}] npm install ${PXPIPE_PACKAGE}@latest\n`);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(npm, ["install", `${PXPIPE_PACKAGE}@latest`, "--no-audit", "--no-fund", "--omit=dev"], {
|
||||
cwd: PXPIPE_DIR,
|
||||
stdio: ["ignore", outFd, outFd],
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("npm install timed out after 5 minutes — see install.log"));
|
||||
}, INSTALL_TIMEOUT_MS);
|
||||
child.once("error", (e) => { clearTimeout(timer); reject(e); });
|
||||
child.once("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`npm install exited with code ${code} — see install.log`));
|
||||
});
|
||||
}).finally(() => fs.closeSync(outFd));
|
||||
|
||||
const info = getInstallInfo();
|
||||
if (!info.installed) throw new Error("install finished but package is missing — see install.log");
|
||||
return info;
|
||||
}
|
||||
|
||||
export function getInstallLogTail(maxLines = 200) {
|
||||
try {
|
||||
if (!fs.existsSync(INSTALL_LOG)) return "";
|
||||
const lines = fs.readFileSync(INSTALL_LOG, "utf8").split(/\r?\n/).filter(Boolean);
|
||||
return lines.slice(-maxLines).join("\n");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
70
src/lib/pxpipe/loader.js
Normal file
70
src/lib/pxpipe/loader.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { pathToFileURL } from "url";
|
||||
import { getInstallInfo, libraryEntry } from "./install.js";
|
||||
|
||||
// Module cache: pxpipe is loaded once per process ("started") and dropped on
|
||||
// "stop". In library mode start/stop govern the in-process module, not a daemon.
|
||||
let cached = null; // { module, version, loadedAt }
|
||||
let loadPromise = null;
|
||||
|
||||
export function getLoadedInfo() {
|
||||
return cached ? { loaded: true, version: cached.version, loadedAt: cached.loadedAt } : { loaded: false };
|
||||
}
|
||||
|
||||
export async function loadPxpipe() {
|
||||
if (cached) return cached;
|
||||
if (loadPromise) return loadPromise;
|
||||
loadPromise = doLoad().finally(() => { loadPromise = null; });
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
async function doLoad() {
|
||||
const info = getInstallInfo();
|
||||
if (!info.installed) {
|
||||
const err = new Error("PXPIPE is not installed");
|
||||
err.code = "NOT_INSTALLED";
|
||||
throw err;
|
||||
}
|
||||
// Cache-bust per version so Repair/upgrade takes effect without a server restart.
|
||||
const url = `${pathToFileURL(libraryEntry()).href}?v=${encodeURIComponent(info.version || "0")}`;
|
||||
const mod = await import(/* webpackIgnore: true */ url);
|
||||
if (typeof mod.transformAnthropicMessages !== "function") {
|
||||
throw new Error("installed pxpipe package does not export transformAnthropicMessages");
|
||||
}
|
||||
cached = { module: mod, version: info.version, loadedAt: Date.now() };
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function unloadPxpipe() {
|
||||
const wasLoaded = !!cached;
|
||||
cached = null;
|
||||
return wasLoaded;
|
||||
}
|
||||
|
||||
// Transform function for the request pipeline; null when unavailable (fail-open).
|
||||
// autoLoad controls whether a cold cache triggers a load (first request warms it).
|
||||
export async function getTransform({ autoLoad = true } = {}) {
|
||||
try {
|
||||
if (!cached && !autoLoad) return null;
|
||||
const { module: mod } = await loadPxpipe();
|
||||
return mod.transformAnthropicMessages;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Health self-test: run a tiny synthetic Claude request through the transformer.
|
||||
// A healthy module parses it and answers with a machine-readable reason.
|
||||
export async function selfTest() {
|
||||
const startedAt = Date.now();
|
||||
const { module: mod } = await loadPxpipe();
|
||||
const body = new TextEncoder().encode(JSON.stringify({
|
||||
model: "claude-fable-5",
|
||||
max_tokens: 16,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
}));
|
||||
const result = await mod.transformAnthropicMessages({ body, model: "claude-fable-5" });
|
||||
if (!result || typeof result.applied !== "boolean" || !(result.body instanceof Uint8Array)) {
|
||||
throw new Error("transform returned an unexpected shape");
|
||||
}
|
||||
return { ok: true, reason: result.reason, durationMs: Date.now() - startedAt };
|
||||
}
|
||||
49
src/lib/pxpipe/service.js
Normal file
49
src/lib/pxpipe/service.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getInstallInfo, isInstalling, findNpm } from "./install.js";
|
||||
import { getLoadedInfo, loadPxpipe, selfTest } from "./loader.js";
|
||||
|
||||
// Aggregate status for the Token Saver card and /api/pxpipe/status.
|
||||
// "running" in library mode = module loaded into this process.
|
||||
export function getPxpipeStatus() {
|
||||
const install = getInstallInfo();
|
||||
const loaded = getLoadedInfo();
|
||||
return {
|
||||
installed: install.installed,
|
||||
installing: isInstalling(),
|
||||
version: install.version,
|
||||
path: install.path,
|
||||
running: loaded.loaded,
|
||||
loadedAt: loaded.loadedAt || null,
|
||||
uptimeMs: loaded.loaded ? Date.now() - loaded.loadedAt : 0,
|
||||
npmAvailable: !!findNpm(),
|
||||
mode: "library", // in-process transform, not an external proxy
|
||||
};
|
||||
}
|
||||
|
||||
// PRD health checklist, adapted to library mode: installed? → module loads
|
||||
// (the "executable found / port listening" equivalent) → test request transforms.
|
||||
export async function runHealthCheck() {
|
||||
const checks = [];
|
||||
const fail = (error) => ({ healthy: false, checks, error });
|
||||
|
||||
const install = getInstallInfo();
|
||||
checks.push({ id: "installed", label: "PXPIPE installed", ok: install.installed, detail: install.version ? `v${install.version}` : null });
|
||||
if (!install.installed) return fail("pxpipe not installed");
|
||||
|
||||
try {
|
||||
await loadPxpipe();
|
||||
checks.push({ id: "module", label: "Transform module loads", ok: true, detail: `v${install.version}` });
|
||||
} catch (e) {
|
||||
checks.push({ id: "module", label: "Transform module loads", ok: false, detail: e.message });
|
||||
return fail(`Cannot load module: ${e.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const test = await selfTest();
|
||||
checks.push({ id: "transform", label: "Test request transforms", ok: true, detail: `${test.durationMs}ms (${test.reason})` });
|
||||
} catch (e) {
|
||||
checks.push({ id: "transform", label: "Test request transforms", ok: false, detail: e.message });
|
||||
return fail(`Self-test failed: ${e.message}`);
|
||||
}
|
||||
|
||||
return { healthy: true, checks, error: null };
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const navItems = [
|
||||
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
|
||||
{ href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },
|
||||
{ href: "/dashboard/token-saver", label: "Token Saver", icon: "savings" },
|
||||
{ href: "/dashboard/pxpipe", label: "PXPIPE", icon: "image" },
|
||||
{ href: "/dashboard/cli-tools", label: "CLI Tools", icon: "terminal" },
|
||||
];
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ 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 { getTransform as getPxpipeTransform } from "@/lib/pxpipe/loader.js";
|
||||
import { appendPxpipeEvent } from "@/lib/pxpipe/events.js";
|
||||
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";
|
||||
@@ -259,6 +261,12 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||
cavemanLevel: chatSettings.cavemanLevel || "full",
|
||||
ponytailEnabled: !!chatSettings.ponytailEnabled,
|
||||
ponytailLevel: chatSettings.ponytailLevel || "full",
|
||||
pxpipeEnabled: !!chatSettings.pxpipeEnabled,
|
||||
pxpipeMinChars: chatSettings.pxpipeMinChars,
|
||||
pxpipeTimeoutMs: chatSettings.pxpipeTimeoutMs,
|
||||
// Lazily warms the in-process module on first use; null when not installed (fail-open)
|
||||
pxpipeTransform: chatSettings.pxpipeEnabled ? await getPxpipeTransform() : null,
|
||||
onPxpipeEvent: appendPxpipeEvent,
|
||||
providerThinking,
|
||||
// Detect source format by endpoint + body
|
||||
sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null,
|
||||
|
||||
95
tests/unit/pxpipe.test.js
Normal file
95
tests/unit/pxpipe.test.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { compressWithPxpipe, formatPxpipeLog } from "../../open-sse/rtk/pxpipe.js";
|
||||
|
||||
const bigText = "x".repeat(30000);
|
||||
const claudeBody = () => ({
|
||||
model: "claude-fable-5",
|
||||
max_tokens: 100,
|
||||
messages: [{ role: "user", content: bigText }],
|
||||
});
|
||||
|
||||
// A transform double mimicking pxpipe-proxy/transform's contract.
|
||||
const appliedTransform = (outBody) => async () => ({
|
||||
applied: true,
|
||||
reason: "applied",
|
||||
body: new TextEncoder().encode(JSON.stringify(outBody)),
|
||||
info: { compressedChars: 25000, imageCount: 2, imageBytes: 5000, imagePixels: 1500000 },
|
||||
cache: { ownsCacheControl: true, markerCount: 1 },
|
||||
});
|
||||
|
||||
describe("compressWithPxpipe gates", () => {
|
||||
it("skips when disabled", async () => {
|
||||
const { body, summary } = await compressWithPxpipe(claudeBody(), { enabled: false });
|
||||
expect(body).toBeNull();
|
||||
expect(summary.reason).toBe("disabled");
|
||||
});
|
||||
|
||||
it("skips when transform is unavailable (not installed)", async () => {
|
||||
const { body, summary } = await compressWithPxpipe(claudeBody(), { enabled: true, format: "claude", transform: null });
|
||||
expect(body).toBeNull();
|
||||
expect(summary.reason).toBe("not_installed");
|
||||
});
|
||||
|
||||
it("skips non-Claude formats", async () => {
|
||||
const transform = vi.fn();
|
||||
const { body, summary } = await compressWithPxpipe(claudeBody(), { enabled: true, format: "openai", transform });
|
||||
expect(body).toBeNull();
|
||||
expect(summary.reason).toBe("unsupported_format");
|
||||
expect(transform).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bypasses small prompts below minChars", async () => {
|
||||
const transform = vi.fn();
|
||||
const small = { model: "claude-fable-5", messages: [{ role: "user", content: "hi" }] };
|
||||
const { body, summary } = await compressWithPxpipe(small, { enabled: true, format: "claude", minChars: 25000, transform });
|
||||
expect(body).toBeNull();
|
||||
expect(summary.reason).toBe("below_threshold");
|
||||
expect(transform).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies the transform and reports savings", async () => {
|
||||
const compressed = { model: "claude-fable-5", messages: [{ role: "user", content: "imaged" }] };
|
||||
const { body, summary } = await compressWithPxpipe(claudeBody(), {
|
||||
enabled: true, format: "claude", minChars: 1000, transform: appliedTransform(compressed),
|
||||
});
|
||||
expect(body).toEqual(compressed);
|
||||
expect(summary.applied).toBe(true);
|
||||
expect(summary.imageCount).toBe(2);
|
||||
expect(summary.tokensBeforeEst).toBeGreaterThan(summary.tokensAfterEst);
|
||||
expect(summary.savedPct).toBeGreaterThan(0);
|
||||
expect(formatPxpipeLog(summary)).toContain("2 image(s)");
|
||||
});
|
||||
|
||||
it("passes through when the transform declines (not_profitable)", async () => {
|
||||
const transform = async () => ({ applied: false, reason: "not_profitable", body: new Uint8Array(), info: {} });
|
||||
const { body, summary } = await compressWithPxpipe(claudeBody(), {
|
||||
enabled: true, format: "claude", minChars: 1000, transform,
|
||||
});
|
||||
expect(body).toBeNull();
|
||||
expect(summary.reason).toBe("not_profitable");
|
||||
});
|
||||
|
||||
it("fails open when the transform throws", async () => {
|
||||
const transform = async () => { throw new Error("boom"); };
|
||||
const { body, summary } = await compressWithPxpipe(claudeBody(), {
|
||||
enabled: true, format: "claude", minChars: 1000, transform,
|
||||
});
|
||||
expect(body).toBeNull();
|
||||
expect(summary.reason).toBe("transform_error");
|
||||
expect(summary.detail).toBe("boom");
|
||||
});
|
||||
|
||||
it("fails open on timeout", async () => {
|
||||
const transform = () => new Promise(() => {}); // never resolves
|
||||
const { body, summary } = await compressWithPxpipe(claudeBody(), {
|
||||
enabled: true, format: "claude", minChars: 1000, timeoutMs: 50, transform,
|
||||
});
|
||||
expect(body).toBeNull();
|
||||
expect(summary.reason).toBe("timeout");
|
||||
});
|
||||
|
||||
it("does not log skipped requests as savings", () => {
|
||||
expect(formatPxpipeLog({ applied: false, reason: "below_threshold" })).toBeNull();
|
||||
expect(formatPxpipeLog(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user