Merge remote-tracking branch 'upstream/master'

# Conflicts:
#	.gitignore
#	open-sse/handlers/chatCore.js
This commit is contained in:
decolua
2026-07-16 11:59:46 +07:00
162 changed files with 9368 additions and 1287 deletions

View File

@@ -41,6 +41,10 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [saving, setSaving] = useState(false);
const bulkPlaceholder = isCloudflareAi
? `name1|sk-key1|acc123456\nname2|sk-key2|def789012\nsk-key-only-auto-named`
: BULK_PLACEHOLDER;
const [mode, setMode] = useState("single"); // "single" | "bulk"
const [bulkText, setBulkText] = useState("");
const [bulkResult, setBulkResult] = useState(null); // { success, failed }
@@ -135,14 +139,31 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
let failed = 0;
for (let i = 0; i < lines.length; i++) {
const parts = lines[i].split("|");
const apiKey = parts.length >= 2 ? parts.slice(1).join("|").trim() : parts[0].trim();
const baseName = parts.length >= 2 ? parts[0].trim() : "Key";
const name = `${baseName} ${i + 1}`;
let apiKey;
let providerSpecificData;
if (isCloudflareAi && parts.length >= 3) {
// Format: name|apiKey|accountId
apiKey = parts.slice(1, -1).join("|").trim();
providerSpecificData = { accountId: parts[parts.length - 1].trim() };
} else {
apiKey = parts.length >= 2 ? parts.slice(1).join("|").trim() : parts[0].trim();
}
try {
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, apiKey, name, priority: 1, testStatus: "unknown" }),
body: JSON.stringify({
provider,
apiKey,
name,
priority: 1,
testStatus: "unknown",
...(providerSpecificData ? { providerSpecificData } : {}),
}),
});
if (res.ok) success++;
else failed++;
@@ -168,10 +189,15 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
{mode === "bulk" && (
<div className="flex flex-col gap-3">
<p className="text-xs text-text-muted">One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</p>
<p className="text-xs text-text-muted">
{isCloudflareAi
? <>One key per line. Format: <code>name|apiKey|accountId</code> or just <code>apiKey</code> (auto-named by index).</>
: <>One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</>
}
</p>
<textarea
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-sm font-mono resize-y min-h-[140px] focus:outline-none focus:ring-1 focus:ring-primary"
placeholder={BULK_PLACEHOLDER}
placeholder={bulkPlaceholder}
value={bulkText}
onChange={(e) => setBulkText(e.target.value)}
/>

View File

@@ -1,7 +1,8 @@
import PropTypes from "prop-types";
import { CapacityBadges } from "@/shared/components";
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable, caps }) {
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable, caps, thinkingSuffix }) {
const displayModel = thinkingSuffix ? `${fullModel}(${thinkingSuffix})` : fullModel;
const borderColor = testStatus === "ok"
? "border-green-500/40"
: testStatus === "error"
@@ -24,7 +25,7 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<code className="max-w-[72vw] truncate rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted sm:max-w-[360px]">{fullModel}</code>
<code className="max-w-[72vw] truncate rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted sm:max-w-[360px]">{displayModel}</code>
<span className="flex min-w-0 items-center text-[9px] gap-1 pl-1">
{model.name && <span className="truncate text-[9px] italic text-text-muted/70">{model.name}</span>}
<CapacityBadges caps={caps} colorOverride="text-text-muted/70" size={12} />
@@ -48,7 +49,7 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
)}
<div className="relative shrink-0 group/btn">
<button
onClick={() => onCopy(fullModel, `model-${model.id}`)}
onClick={() => onCopy(displayModel, `model-${model.id}`)}
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary"
>
<span className="material-symbols-outlined text-sm">
@@ -97,4 +98,5 @@ ModelRow.propTypes = {
isTesting: PropTypes.bool,
onDisable: PropTypes.func,
caps: PropTypes.object,
thinkingSuffix: PropTypes.string,
};

View File

@@ -5,8 +5,9 @@ import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS, THINKING_CONFIG } from "@/shared/constants/providers";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers";
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useModelCaps } from "@/shared/hooks/useModelCaps";
import { translate } from "@/i18n/runtime";
@@ -147,11 +148,38 @@ export default function ProviderDetailPage() {
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId);
const isCompatible = isOpenAICompatible || isAnthropicCompatible;
const hasDualAuthModes = !isCompatible && isOAuth && supportsApiKeyAuth;
const oauthConnectionLabel = providerId === "xai" ? "Grok Build OAuth" : "OAuth";
const oauthConnectionLabel =
providerId === "xai" ? "Grok Build OAuth"
: providerId === "grok-cli" ? "Grok CLI Device Login"
: "OAuth";
const apiKeyConnectionLabel = providerId === "xai" ? "xAI API Key" : "API Key";
const thinkingConfig = AI_PROVIDERS[providerId]?.thinkingConfig || THINKING_CONFIG.extended;
// Resolve suffix "(level)" for a model when a thinking level is picked and the model supports it.
const resolveThinkingSuffix = (modelId) => {
if (!thinkingMode || thinkingMode === "auto") return null;
const levels = getThinkingLevels(providerId, modelId);
return levels && levels.includes(thinkingMode) ? thinkingMode : null;
};
const providerStorageAlias = isCompatible ? providerId : providerAlias;
// Union of levels across this provider's reasoning models — drives the level picker options.
// Include custom models too (e.g. manually added gpt-5.6-sol → max).
const providerThinkingLevels = (() => {
const set = new Set();
const seen = new Set();
const addLevels = (modelId) => {
if (!modelId || seen.has(modelId)) return;
seen.add(modelId);
const lv = getThinkingLevels(providerId, modelId);
if (lv) lv.forEach((l) => { if (l !== "none") set.add(l); });
};
for (const m of models) addLevels(m.id);
for (const m of kiloFreeModels) addLevels(m.id);
for (const entry of customModels) {
if (entry.providerAlias !== providerStorageAlias) continue;
if ((entry.kind || entry.type || "llm") !== "llm") continue;
addLevels(entry.id);
}
return set.size ? ["auto", ...[...set]] : null;
})();
const providerDisplayAlias = isCompatible
? (providerNode?.prefix || providerId)
: providerAlias;
@@ -1065,6 +1093,7 @@ export default function ProviderDetailPage() {
isCustom
isFree={false}
caps={getCaps(`${providerId}/${model.id}`)}
thinkingSuffix={resolveThinkingSuffix(model.id)}
/>
))}
@@ -1090,6 +1119,7 @@ export default function ProviderDetailPage() {
isFree={model.isFree}
onDisable={() => handleDisableModel(model.id)}
caps={getCaps(`${providerId}/${model.id}`)}
thinkingSuffix={resolveThinkingSuffix(model.id)}
/>
);
})}
@@ -1395,21 +1425,6 @@ export default function ProviderDetailPage() {
)}
</>
)}
{/* Thinking config */}
{/* {thinkingConfig && (
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted font-medium">Thinking</span>
<select
value={thinkingMode}
onChange={(e) => handleThinkingModeChange(e.target.value)}
className="text-xs px-2 py-1 border border-border rounded-md bg-background focus:outline-none focus:border-primary"
>
{thinkingConfig.options.map((opt) => (
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
))}
</select>
</div>
)} */}
{/* Round Robin toggle */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-text-muted font-medium">Round Robin</span>
@@ -1580,9 +1595,23 @@ export default function ProviderDetailPage() {
{/* Models */}
<Card>
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">
{"Available Models"}
</h2>
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">
{"Available Models"}
</h2>
{providerThinkingLevels && (
<select
value={thinkingMode}
onChange={(e) => handleThinkingModeChange(e.target.value)}
title="Appends (level) suffix to copied model names"
className="rounded-md border border-border bg-background px-2 py-1 text-xs focus:border-primary focus:outline-none"
>
{providerThinkingLevels.map((opt) => (
<option key={opt} value={opt}>{`Thinking: ${opt.charAt(0).toUpperCase() + opt.slice(1)}`}</option>
))}
</select>
)}
</div>
{!isCompatible && (() => {
const allIds = [
...models,

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import PxpipeClient from "./PxpipeClient";
export default function PxpipePage() {
return <PxpipeClient />;
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Input, Modal, Toggle } from "@/shared/components";
import { useState, useEffect, useCallback, useRef } from "react";
import { Card, Button, Input, Modal, Toggle, ConfirmModal } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { getCurrentLocale, onLocaleChange } from "@/i18n/runtime";
import {
@@ -24,10 +24,39 @@ export default function TokenSaverClient() {
useState(false);
const [headroomActionLoading, setHeadroomActionLoading] = useState(false);
const [headroomActionError, setHeadroomActionError] = useState("");
const [headroomExtras, setHeadroomExtras] = useState({
version: null,
extras: { code: false, ml: false },
available: ["code", "ml"],
loading: false,
});
const [pendingExtras, setPendingExtras] = useState([]);
const [extrasActionLoading, setExtrasActionLoading] = useState(false);
const [extrasActionError, setExtrasActionError] = useState("");
const [removingExtra, setRemovingExtra] = useState(null);
const [installLog, setInstallLog] = useState("");
const [extrasConfirm, setExtrasConfirm] = useState(null);
const [codeAware, setCodeAware] = useState(false);
const [kompress, setKompress] = useState(true);
const [restartingProxy, setRestartingProxy] = useState(false);
const logPollRef = useRef(null);
const [cavemanEnabled, setCavemanEnabled] = useState(false);
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();
@@ -102,6 +131,39 @@ export default function TokenSaverClient() {
});
const data = await res.json();
setHeadroomStatus({ ...data, loading: false });
if (!data?.installed) {
setHeadroomExtras({
version: null,
extras: { code: false, ml: false },
available: ["code", "ml"],
loading: false,
});
setPendingExtras([]);
return;
}
try {
const er = await fetch("/api/headroom/extras", {
headers: { "Cache-Control": "no-store" },
});
if (!er.ok) throw new Error("extras status failed");
const ed = await er.json();
setHeadroomExtras((s) => ({
...s,
version: ed.version ?? null,
extras: ed.extras || { code: false, ml: false },
available: ed.available || ["code", "ml"],
loading: false,
}));
setPendingExtras([]);
} catch {
setHeadroomExtras({
version: null,
extras: { code: false, ml: false },
available: ["code", "ml"],
loading: false,
});
setPendingExtras([]);
}
} catch {
setHeadroomStatus({
installed: false,
@@ -109,6 +171,13 @@ export default function TokenSaverClient() {
python: null,
loading: false,
});
setHeadroomExtras({
version: null,
extras: { code: false, ml: false },
available: ["code", "ml"],
loading: false,
});
setPendingExtras([]);
}
}, []);
@@ -137,6 +206,138 @@ export default function TokenSaverClient() {
}
}, [refreshHeadroomStatus]);
const togglePendingExtra = (extra) => {
setPendingExtras((cur) =>
cur.includes(extra) ? cur.filter((e) => e !== extra) : [...cur, extra]
);
};
// Poll the install log tail while a pip install/uninstall is running.
const startLogPolling = useCallback(() => {
setInstallLog("");
if (logPollRef.current) clearInterval(logPollRef.current);
const tick = async () => {
try {
const r = await fetch("/api/headroom/extras?log=1", {
headers: { "Cache-Control": "no-store" },
});
const d = await r.json().catch(() => ({}));
if (typeof d.log === "string") setInstallLog(d.log);
} catch { /* ignore transient poll errors */ }
};
tick();
logPollRef.current = setInterval(tick, 1500);
}, []);
const stopLogPolling = useCallback(() => {
if (logPollRef.current) {
clearInterval(logPollRef.current);
logPollRef.current = null;
}
}, []);
useEffect(() => () => stopLogPolling(), [stopLogPolling]);
const installExtrasConfirmed = useCallback(async () => {
if (pendingExtras.length === 0) return;
setExtrasActionLoading(true);
setExtrasActionError("");
startLogPolling();
try {
const res = await fetch("/api/headroom/extras", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ extras: pendingExtras }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Install failed");
setHeadroomExtras((s) => ({
...s,
version: data.version ?? s.version,
extras: data.extras || s.extras,
}));
setPendingExtras([]);
} catch (e) {
setExtrasActionError(e.message);
} finally {
stopLogPolling();
setExtrasActionLoading(false);
}
}, [pendingExtras, startLogPolling, stopLogPolling]);
const removeExtraConfirmed = useCallback(async (extra) => {
setRemovingExtra(extra);
setExtrasActionError("");
startLogPolling();
try {
const res = await fetch("/api/headroom/extras", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ extras: [extra] }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Remove failed");
setHeadroomExtras((s) => ({
...s,
version: data.version ?? s.version,
extras: data.extras || s.extras,
}));
} catch (e) {
setExtrasActionError(e.message);
} finally {
stopLogPolling();
setRemovingExtra(null);
}
}, [startLogPolling, stopLogPolling]);
const handleInstallExtras = useCallback(() => {
if (pendingExtras.length === 0) return;
// Warn about the heavy ~1GB torch download before installing [ml].
if (pendingExtras.includes("ml")) {
setExtrasConfirm({
title: "Install [ml]",
message: "[ml] downloads ~1 GB (torch + huggingface-hub). Continue?",
confirmText: "Install",
variant: "primary",
onConfirm: installExtrasConfirmed,
});
return;
}
installExtrasConfirmed();
}, [pendingExtras, installExtrasConfirmed]);
const handleRemoveExtra = useCallback((extra) => {
setExtrasConfirm({
title: `Remove [${extra}]`,
message: `Remove [${extra}] and its packages?`,
confirmText: "Remove",
variant: "danger",
onConfirm: () => removeExtraConfirmed(extra),
});
}, [removeExtraConfirmed]);
// Toggle an extra's active state (persist setting), then restart the proxy so
// the new --code-aware / --disable-kompress flags take effect.
const toggleExtraActive = useCallback(async (extra, value) => {
setExtrasActionError("");
if (extra === "code") setCodeAware(value);
if (extra === "ml") setKompress(value);
const key = extra === "code" ? "headroomCodeAware" : "headroomKompress";
await patchSetting({ [key]: value });
if (!headroomStatus.running) return;
setRestartingProxy(true);
try {
const res = await fetch("/api/headroom/restart", { method: "POST" });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Restart failed");
await refreshHeadroomStatus();
} catch (e) {
setExtrasActionError(e.message);
} finally {
setRestartingProxy(false);
}
}, [headroomStatus.running, refreshHeadroomStatus]);
const handleCavemanLevel = (level) => {
setCavemanLevel(level);
patchSetting({ cavemanLevel: level });
@@ -152,6 +353,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 {
@@ -161,16 +415,22 @@ export default function TokenSaverClient() {
setRtkEnabledState(data.rtkEnabled !== false);
setHeadroomEnabled(!!data.headroomEnabled);
setHeadroomUrl(data.headroomUrl || "http://localhost:8787");
setCodeAware(data.headroomCodeAware === true);
setKompress(data.headroomKompress !== false);
setCavemanEnabled(!!data.cavemanEnabled);
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
@@ -187,6 +447,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">
@@ -220,7 +497,7 @@ export default function TokenSaverClient() {
onChange={() => handleRtkEnabled(!rtkEnabled)}
/>
</div>
<div className="flex items-center justify-between py-4 border-b border-border gap-4 flex-wrap">
<div className="flex items-center justify-between py-4 gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3 flex-wrap">
<p className="font-medium">
@@ -257,7 +534,105 @@ export default function TokenSaverClient() {
onChange={() => handleHeadroomEnabled(!headroomEnabled)}
/>
</div>
<div className="flex items-center justify-between pt-4 gap-4 flex-wrap">
{headroomStatus.installed && (
<div className="mb-3 ml-1 pl-3 pb-4 border-l-2 border-border">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-text-muted">
Compression extras
{headroomExtras.version ? ` · v${headroomExtras.version}` : ""}:
</span>
{headroomExtras.available.map((extra) => {
const installed = !!headroomExtras.extras[extra];
const pending = pendingExtras.includes(extra);
const extraTitle =
extra === "code"
? "tree-sitter AST compression for code responses"
: "Kompress-v2 HF model for prose/agentic traces (~+1GB)";
if (installed) {
const active = extra === "code" ? codeAware : kompress;
return (
<div
key={extra}
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded border border-success/40 bg-success/5 text-text"
title={extraTitle}
>
<Toggle
size="sm"
checked={active}
disabled={restartingProxy}
onChange={() => toggleExtraActive(extra, !active)}
/>
<span className="font-medium">[{extra}]</span>
<button
type="button"
onClick={() => handleRemoveExtra(extra)}
disabled={removingExtra === extra}
className="ml-1 text-error underline hover:opacity-80 disabled:opacity-50"
title={`Uninstall [${extra}]`}
>
{removingExtra === extra ? "Uninstalling…" : "Uninstall"}
</button>
</div>
);
}
return (
<label
key={extra}
className={`flex items-center gap-1.5 text-xs px-2 py-1 rounded border cursor-pointer transition-colors ${
pending
? "border-primary bg-primary/10 text-primary"
: "border-border text-text-muted hover:bg-surface-2"
}`}
title={extraTitle}
>
<input
type="checkbox"
className="w-3 h-3"
checked={pending}
onChange={() => togglePendingExtra(extra)}
/>
<span className="font-medium">[{extra}]</span>
<span className="opacity-70">not installed</span>
</label>
);
})}
{pendingExtras.length > 0 && (
<button
onClick={handleInstallExtras}
disabled={extrasActionLoading}
className="text-xs px-2.5 py-1 rounded bg-primary text-white hover:opacity-90 disabled:opacity-50"
>
{extrasActionLoading
? "Installing…"
: `Install [proxy,${pendingExtras.join(",")}]`}
</button>
)}
</div>
{extrasActionError && (
<p className="text-xs text-error mt-1">{extrasActionError}</p>
)}
{restartingProxy && (
<p className="text-xs text-text-muted mt-1">Restarting proxy…</p>
)}
{(extrasActionLoading || removingExtra) && installLog && (
<pre className="mt-2 max-h-32 overflow-auto rounded bg-surface-2 p-2 text-[10px] leading-tight text-text-muted whitespace-pre-wrap">
{installLog}
</pre>
)}
<p className="text-xs text-text-muted mt-1">
Installing adds the package; use <code>on</code>/<code>off</code>{" "}
to activate it (restarts the proxy). Default install is{" "}
<code>[proxy]</code> only (SmartCrusher for JSON). Adding{" "}
<code>[code]</code> enables AST compression
(Python/JS/TS/Go/Rust/Java/C/C++/Perl). Adding <code>[ml]</code>{" "}
enables the Kompress-v2 HF model for prose/agentic traces but
adds ~1 GB (torch + huggingface-hub).
</p>
</div>
)}
<div className="flex items-center justify-between pt-4 border-t border-border gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="font-medium">
Compress LLM output{" "}
@@ -358,6 +733,52 @@ export default function TokenSaverClient() {
/>
</div>
</div>
{/* PXPIPE hidden from UI — experimental, not exposed to users yet */}
{false && (
<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
@@ -374,6 +795,16 @@ export default function TokenSaverClient() {
{headroomStatusLabel}
</span>
</div>
{headroomRunning && (
<a
href="/api/headroom/proxy/dashboard"
target="_blank"
rel="noreferrer"
className="w-full rounded border border-border px-4 py-2 text-center text-sm hover:bg-surface-2"
>
Open Headroom Dashboard
</a>
)}
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Proxy URL</p>
<Input
@@ -457,6 +888,128 @@ export default function TokenSaverClient() {
</div>
</div>
</Modal>
<Modal
isOpen={false}
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>
<ConfirmModal
isOpen={!!extrasConfirm}
onClose={() => setExtrasConfirm(null)}
onConfirm={() => {
const fn = extrasConfirm?.onConfirm;
setExtrasConfirm(null);
fn?.();
}}
title={extrasConfirm?.title}
message={extrasConfirm?.message}
confirmText={extrasConfirm?.confirmText}
variant={extrasConfirm?.variant}
/>
</div>
);
}

View File

@@ -8,7 +8,7 @@ const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
export default function OverviewCards({ stats }) {
return (
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-4 sm:gap-4">
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 sm:gap-4">
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
<span className="text-text-muted text-sm uppercase font-semibold">Total Requests</span>
<span className="truncate text-2xl font-bold">{fmt(stats.totalRequests)}</span>
@@ -17,12 +17,10 @@ export default function OverviewCards({ stats }) {
<span className="text-text-muted text-sm uppercase font-semibold">Total Input Tokens</span>
<span className="truncate text-2xl font-bold text-primary">{fmt(stats.totalPromptTokens)}</span>
</Card>
{/* Temporarily hidden: Cached Tokens card
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
<span className="text-text-muted text-sm uppercase font-semibold">Cached Tokens</span>
<span className="truncate text-2xl font-bold text-info">{fmt(stats.totalCachedTokens)}</span>
</Card>
*/}
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
<span className="text-text-muted text-sm uppercase font-semibold">Output Tokens</span>
<span className="truncate text-2xl font-bold text-success">{fmt(stats.totalCompletionTokens)}</span>

View File

@@ -475,6 +475,23 @@ export function parseQuotaData(provider, data) {
}
break;
case "grok-cli":
// Grok Build credits (on-demand window + prepaid balance).
// Do NOT forward absolute `remaining` — getRemainingPercentage treats
// it as a 0–100 percentage (same as Qoder). Use remainingPercentage.
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]) => {
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
});
});
}
break;
default:
// Generic fallback for unknown providers
if (data.quotas) {

View File

@@ -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">

View File

@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { findPython310, getInstalledHeadroomExtras, HEADROOM_COMPRESSION_EXTRAS } from "@/lib/headroom/detect";
import { installHeadroomExtras, uninstallHeadroomExtras, getInstallLogTail } from "@/lib/headroom/process";
export const dynamic = "force-dynamic";
export async function GET(req) {
try {
// `?log=1` returns the live install/uninstall log tail for progress polling.
if (new URL(req.url).searchParams.get("log") === "1") {
return NextResponse.json({ log: getInstallLogTail() });
}
const python = findPython310();
const status = getInstalledHeadroomExtras(python);
return NextResponse.json({
available: HEADROOM_COMPRESSION_EXTRAS,
...status,
});
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(req) {
try {
const body = await req.json().catch(() => ({}));
const requested = Array.isArray(body?.extras) ? body.extras : [];
const result = await installHeadroomExtras(requested);
return NextResponse.json(result);
} catch (error) {
const status = error.code === "NOT_INSTALLED" || error.code === "NO_PYTHON" ? 400 : 500;
return NextResponse.json({ error: error.message, code: error.code || null }, { status });
}
}
export async function DELETE(req) {
try {
const body = await req.json().catch(() => ({}));
const requested = Array.isArray(body?.extras) ? body.extras : [];
const result = await uninstallHeadroomExtras(requested);
return NextResponse.json(result);
} catch (error) {
const status = error.code === "NO_PYTHON" || error.code === "INVALID_EXTRAS" ? 400 : 500;
return NextResponse.json({ error: error.message, code: error.code || null }, { status });
}
}

View File

@@ -0,0 +1,104 @@
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/localDb";
import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect";
export const dynamic = "force-dynamic";
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);
const DASHBOARD_PREFIX = "/api/headroom/proxy";
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
async function getTargetBase() {
const settings = await getSettings();
const url = settings.headroomUrl || DEFAULT_HEADROOM_URL;
const target = new URL(url);
if (!["http:", "https:"].includes(target.protocol)) {
throw new Error("Headroom URL must use http or https");
}
return target;
}
function buildTargetUrl(base, path, search) {
const target = new URL(base);
target.pathname = `/${path.join("/")}`;
target.search = search;
return target;
}
function forwardedHeaders(request, target) {
const headers = new Headers(request.headers);
for (const header of headers.keys()) {
if (HOP_BY_HOP_HEADERS.has(header.toLowerCase())) headers.delete(header);
}
headers.delete("host");
// Never leak viewer credentials to a non-loopback Headroom host
if (!LOOPBACK_HOSTS.has(target.hostname.replace(/^\[|\]$/g, "").toLowerCase())) {
headers.delete("cookie");
headers.delete("authorization");
}
return headers;
}
function rewriteDashboardHtml(html) {
return html.replace(
/fetch\('(?=\/(?:stats|health|stats-history|transformations\/feed))/g,
`fetch('${DASHBOARD_PREFIX}`,
);
}
async function proxy(request, { params }) {
try {
const base = await getTargetBase();
const { search } = new URL(request.url);
const path = (await params).path || [];
const target = buildTargetUrl(base, path, search);
const method = request.method;
const hasBody = !["GET", "HEAD"].includes(method);
const response = await fetch(target, {
method,
headers: forwardedHeaders(request, target),
body: hasBody ? request.body : undefined,
duplex: hasBody ? "half" : undefined,
redirect: "manual",
});
const headers = new Headers(response.headers);
for (const header of headers.keys()) {
if (HOP_BY_HOP_HEADERS.has(header.toLowerCase())) headers.delete(header);
}
if (path.join("/") === "dashboard") {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("text/html")) {
headers.delete("content-length");
return new NextResponse(rewriteDashboardHtml(await response.text()), {
status: response.status,
headers,
});
}
}
return new NextResponse(response.body, { status: response.status, headers });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export const GET = proxy;
export const POST = proxy;
export const PUT = proxy;
export const PATCH = proxy;
export const DELETE = proxy;
export const HEAD = proxy;
export const OPTIONS = proxy;

View File

@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/localDb";
import { restartHeadroomProxy } from "@/lib/headroom/process";
import { DEFAULT_HEADROOM_URL, isLoopbackHeadroomUrl } from "@/lib/headroom/detect";
export const dynamic = "force-dynamic";
function parsePortFromUrl(url) {
try {
const u = new URL(url);
const p = parseInt(u.port, 10);
if (p > 0 && p < 65536) return p;
} catch { /* ignore, fall through to default */ }
return null;
}
export async function POST() {
try {
const settings = await getSettings();
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 restartHeadroomProxy({
port,
codeAware: settings.headroomCodeAware === true,
kompress: settings.headroomKompress !== false,
});
return NextResponse.json({ success: true, ...result });
} catch (error) {
const status = error.code === "NOT_INSTALLED" ? 400 : 500;
return NextResponse.json({ error: error.message, code: error.code || null }, { status });
}
}

View File

@@ -22,7 +22,11 @@ export async function POST() {
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 });
const result = await startHeadroomProxy({
port,
codeAware: settings.headroomCodeAware === true,
kompress: settings.headroomKompress !== false,
});
return NextResponse.json({ success: true, ...result });
} catch (error) {
const status = error.code === "NOT_INSTALLED" ? 400 : 500;

View File

@@ -150,8 +150,16 @@ export async function GET(request, { params }) {
}
: undefined;
// Providers that don't use PKCE for device code
const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode", "codebuddy-cn", "qoder"];
// Providers that don't use PKCE for device code (Grok CLI HAR: plain device_code, no challenge)
const noPkceDeviceProviders = [
"github",
"kiro",
"kimi-coding",
"kilocode",
"codebuddy-cn",
"qoder",
"grok-cli",
];
let deviceData;
if (noPkceDeviceProviders.includes(provider)) {
deviceData = await requestDeviceCode(provider, undefined, deviceOptions);

View File

@@ -236,6 +236,7 @@ const PROVIDER_MODELS_CONFIG = {
xai: createOpenAIModelsConfig("https://api.x.ai/v1/models"),
mistral: createOpenAIModelsConfig("https://api.mistral.ai/v1/models"),
perplexity: createOpenAIModelsConfig("https://api.perplexity.ai/v1/models"),
"perplexity-agent": createOpenAIModelsConfig("https://api.perplexity.ai/v1/models"),
together: createOpenAIModelsConfig("https://api.together.xyz/v1/models"),
fireworks: createOpenAIModelsConfig("https://api.fireworks.ai/inference/v1/models"),
cerebras: createOpenAIModelsConfig("https://api.cerebras.ai/v1/models"),

View File

@@ -103,8 +103,61 @@ const OAUTH_TEST_CONFIG = {
},
refreshable: false,
},
// Grok CLI / Grok Build — probe /v1/user (no inference quota). Headers mirror official CLI.
"grok-cli": {
url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: {
Accept: "application/json",
...(PROVIDERS["grok-cli"]?.headers || {
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
"x-xai-token-auth": "xai-grok-cli",
"x-grok-client-identifier": "grok-pager",
"x-grok-client-version": "0.2.93",
}),
},
refreshable: true,
// Subscription spending-limit is not an auth failure — token is fine, credits aren't.
// Accept 402 so the connection stays "active" with a warning (same idea as Codex 400).
acceptStatuses: [402],
softFailMessage: {
402: "Connected, but Grok Build credits are exhausted (spending limit). Add credits or upgrade SuperGrok.",
},
},
};
/**
* Classify an OAuth probe response as success / soft-success / hard-fail.
* Soft success (e.g. 402 spending-limit on Grok CLI) means auth works but the
* account cannot spend — keep connection active and surface a warning.
* Exported for unit tests.
*/
export function classifyOAuthProbeResult(res, config, bodyText = "") {
if (!res) return { valid: false, error: "No response", soft: false };
const status = res.status;
const accepted = res.ok || (config?.acceptStatuses && config.acceptStatuses.includes(status));
if (!accepted) {
if (status === 401) return { valid: false, error: "Token invalid or revoked", soft: false };
if (status === 403) return { valid: false, error: "Access denied", soft: false };
return { valid: false, error: `API returned ${status}`, soft: false };
}
// Soft success only when the provider configured an explicit message for this
// status (e.g. Grok CLI 402 spending-limit). Codex-style acceptStatuses:[400]
// stays silent success — 400 there only proves auth, not a user-facing warning.
if (!res.ok && config?.acceptStatuses?.includes(status)) {
const softMap = config.softFailMessage || {};
if (softMap[status]) {
return { valid: true, error: softMap[status], soft: true };
}
return { valid: true, error: null, soft: false };
}
return { valid: true, error: null, soft: false };
}
async function probeClineAccessToken(accessToken) {
const res = await fetch("https://api.cline.bot/api/v1/users/me", {
method: "GET",
@@ -186,7 +239,7 @@ async function refreshOAuthToken(connection) {
return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken };
}
if (provider === "codex") {
if (provider === "codex" || provider === "grok-cli" || provider === "xai") {
return await refreshProviderCredentials(provider, connection, console);
}
@@ -362,9 +415,19 @@ async function testOAuthConnection(connection, effectiveProxy = null) {
const fetchOpts = { method: config.method, headers };
if (config.body) fetchOpts.body = config.body;
const res = await fetchWithConnectionProxy(testUrl, fetchOpts, effectiveProxy);
const bodyText = !res.ok ? await res.text().catch(() => "") : "";
const accepted = res.ok || (config.acceptStatuses && config.acceptStatuses.includes(res.status));
if (accepted) return { valid: true, error: null, refreshed, newTokens };
const classified = classifyOAuthProbeResult(res, config, bodyText);
if (classified.valid) {
return {
valid: true,
// soft success surfaces warning text without marking connection error
error: classified.soft ? classified.error : null,
warning: classified.soft ? classified.error : null,
refreshed,
newTokens,
};
}
if (res.status === 401 && config.refreshable && !refreshed && connection.refreshToken) {
const tokens = await refreshOAuthToken(connection);
@@ -376,15 +439,22 @@ async function testOAuthConnection(connection, effectiveProxy = null) {
const retryOpts = { method: config.method, headers: retryHeaders };
if (config.body) retryOpts.body = config.body;
const retryRes = await fetchWithConnectionProxy(retryUrl, retryOpts, effectiveProxy);
const retryAccepted = retryRes.ok || (config.acceptStatuses && config.acceptStatuses.includes(retryRes.status));
if (retryAccepted) return { valid: true, error: null, refreshed: true, newTokens: tokens };
const retryBody = !retryRes.ok ? await retryRes.text().catch(() => "") : "";
const retryClassified = classifyOAuthProbeResult(retryRes, config, retryBody);
if (retryClassified.valid) {
return {
valid: true,
error: retryClassified.soft ? retryClassified.error : null,
warning: retryClassified.soft ? retryClassified.error : null,
refreshed: true,
newTokens: tokens,
};
}
}
return { valid: false, error: "Token invalid or revoked", refreshed: false };
}
if (res.status === 401) return { valid: false, error: "Token invalid or revoked", refreshed };
if (res.status === 403) return { valid: false, error: "Access denied", refreshed };
return { valid: false, error: `API returned ${res.status}`, refreshed };
return { valid: false, error: classified.error, refreshed };
} catch (err) {
return { valid: false, error: err.message, refreshed };
}
@@ -752,10 +822,18 @@ export async function testSingleConnection(id) {
const latencyMs = Date.now() - start;
// Soft success (e.g. Grok CLI 402 spending-limit): credentials are good, account is
// out of credits. Keep testStatus active; surface the message as lastError so the
// dashboard can show a warning without marking the connection broken.
const softWarning = result.valid && (result.warning || result.error);
const updateData = {
testStatus: result.valid ? "active" : "error",
lastError: result.valid ? null : result.error,
lastErrorAt: result.valid ? null : new Date().toISOString(),
lastError: result.valid ? (softWarning || null) : result.error,
lastErrorAt: result.valid
? softWarning
? new Date().toISOString()
: null
: new Date().toISOString(),
};
if (result.refreshed && result.newTokens) {

View 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;

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -1,11 +1,23 @@
import { NextResponse } from "next/server";
import { getTunnelStatus, getTailscaleStatus, getDownloadStatus } from "@/lib/tunnel";
const STATUS_CACHE_TTL_MS = 3000; // coalesce rapid polls; underlying probes already cache 10s
// Survive hot reload; one cache per process. Only tunnel/tailscale probes are cached —
// download progress stays live so the enable/download UI updates smoothly.
const statusCache = (global.__tunnelStatusCache ??= { value: null, fetchedAt: 0 });
export async function GET() {
try {
const [tunnel, tailscale] = await Promise.all([getTunnelStatus(), getTailscaleStatus()]);
let probes = statusCache.value;
if (!probes || Date.now() - statusCache.fetchedAt >= STATUS_CACHE_TTL_MS) {
const [tunnel, tailscale] = await Promise.all([getTunnelStatus(), getTailscaleStatus()]);
probes = { tunnel, tailscale };
statusCache.value = probes;
statusCache.fetchedAt = Date.now();
}
const download = getDownloadStatus();
return NextResponse.json({ tunnel, tailscale, download });
return NextResponse.json({ ...probes, download });
} catch (error) {
console.error("Tunnel status error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });

View File

@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { getRequestDetails } from "@/lib/requestDetailsDb";
import { getDistinctProviders } from "@/lib/requestDetailsDb";
import { getProviderNodes } from "@/lib/localDb";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
@@ -9,10 +9,9 @@ import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
*/
export async function GET() {
try {
const { details } = await getRequestDetails({ pageSize: 9999 });
// Extract unique providers
const providerIds = [...new Set(details.map(r => r.provider).filter(Boolean))].sort();
// Query DISTINCT provider column directly — avoids parsing every row's
// full JSON blob (can be hundreds of MB), which previously caused OOM.
const providerIds = await getDistinctProviders();
const providerNodes = await getProviderNodes();
const nodeMap = {};

View File

@@ -9,8 +9,10 @@ export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page")) || 1;
const pageSize = parseInt(searchParams.get("pageSize")) || 20;
const pageRaw = parseInt(searchParams.get("page"));
const page = Number.isNaN(pageRaw) ? 1 : pageRaw;
const pageSizeRaw = parseInt(searchParams.get("pageSize"));
const pageSize = Number.isNaN(pageSizeRaw) ? 20 : pageSizeRaw;
const provider = searchParams.get("provider");
const model = searchParams.get("model");
const connectionId = searchParams.get("connectionId");

View File

@@ -11,6 +11,64 @@ export async function OPTIONS() {
return new Response(null, { headers: CORS_HEADERS });
}
function countValueChars(value) {
if (value == null) return 0;
if (typeof value === "string") return value.length;
if (typeof value === "number" || typeof value === "boolean") {
return String(value).length;
}
if (Array.isArray(value)) {
return value.reduce((total, item) => total + countValueChars(item), 0);
}
if (typeof value === "object") {
return Object.entries(value).reduce((total, [key, item]) => {
return total + key.length + countValueChars(item);
}, 0);
}
return 0;
}
function countContentBlockChars(block) {
if (block == null) return 0;
if (typeof block === "string") return block.length;
if (typeof block !== "object") return countValueChars(block);
switch (block.type) {
case "text":
return countValueChars(block.text);
case "tool_use":
return countValueChars(block.name) + countValueChars(block.input);
case "tool_result":
return countValueChars(block.content);
case "thinking":
return countValueChars(block.thinking);
default:
return countValueChars(block);
}
}
function countMessageChars(message) {
if (!message || typeof message !== "object") return 0;
const content = message.content;
if (typeof content === "string") return content.length;
if (Array.isArray(content)) {
return content.reduce((total, block) => total + countContentBlockChars(block), 0);
}
return countValueChars(content);
}
export function estimateAnthropicInputTokens(body = {}) {
const messages = Array.isArray(body.messages) ? body.messages : [];
let totalChars = countValueChars(body.system) + countValueChars(body.tools);
for (const msg of messages) {
totalChars += countMessageChars(msg);
}
return Math.ceil(totalChars / 4);
}
/**
* POST /v1/messages/count_tokens - Mock token count response
*/
@@ -25,23 +83,7 @@ export async function POST(request) {
});
}
// Estimate token count based on content length
const messages = body.messages || [];
let totalChars = 0;
for (const msg of messages) {
if (typeof msg.content === "string") {
totalChars += msg.content.length;
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "text" && part.text) {
totalChars += part.text.length;
}
}
}
}
// Rough estimate: ~4 chars per token
const inputTokens = Math.ceil(totalChars / 4);
const inputTokens = estimateAnthropicInputTokens(body);
return new Response(JSON.stringify({
input_tokens: inputTokens

View File

@@ -2,6 +2,10 @@ import https from "https";
import pkg from "../../../../package.json" with { type: "json" };
const NPM_PACKAGE_NAME = "9router";
const VERSION_CACHE_TTL_MS = 3600000; // cache npm latest lookup for 1h
// Survive hot reload; one cache per process
const versionCache = (global.__npmVersionCache ??= { value: null, fetchedAt: 0 });
// Fetch latest version from npm registry
function fetchLatestVersion() {
@@ -36,8 +40,20 @@ function compareVersions(a, b) {
return 0;
}
async function getLatestVersionCached() {
if (versionCache.value && Date.now() - versionCache.fetchedAt < VERSION_CACHE_TTL_MS) {
return versionCache.value;
}
const latest = await fetchLatestVersion();
if (latest) {
versionCache.value = latest;
versionCache.fetchedAt = Date.now();
}
return latest;
}
export async function GET() {
const latestVersion = await fetchLatestVersion();
const latestVersion = await getLatestVersionCached();
const currentVersion = pkg.version;
const hasUpdate = latestVersion ? compareVersions(latestVersion, currentVersion) > 0 : false;

View File

@@ -1,4 +1,5 @@
@import "tailwindcss";
/* source() sets scan base to src/ for both webpack + Turbopack; auto-detection still skips binaries + gitignore */
@import "tailwindcss" source("../../");
@custom-variant dark (&:where(.dark, .dark *));

View File

@@ -81,6 +81,7 @@ const LOCAL_ONLY_PATHS = [
"/api/auth/reset-password",
"/api/headroom/start",
"/api/headroom/stop",
"/api/headroom/proxy",
];
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);

View File

@@ -1,41 +1,77 @@
export const LOCALES = ["en", "vi", "zh-CN", "zh-TW", "ja", "pt-BR", "pt-PT", "ko", "es", "de", "fr", "he", "ar", "ru", "pl", "cs", "nl", "tr", "uk", "tl", "id", "th", "hi", "bn", "ur", "ro", "sv", "it", "el", "hu", "fi", "da", "no"];
export const LOCALES = [
"en",
"vi",
"zh-CN",
"zh-TW",
"ja",
"pt-BR",
"pt-PT",
"ko",
"es",
"de",
"fr",
"he",
"ar",
"ru",
"pl",
"cs",
"nl",
"tr",
"uk",
"tl",
"id",
"th",
"hi",
"bn",
"ur",
"ro",
"sv",
"it",
"el",
"hu",
"fi",
"da",
"no",
"fa",
];
export const DEFAULT_LOCALE = "en";
export const LOCALE_COOKIE = "locale";
export const LOCALE_NAMES = {
"en": "English",
"vi": "Tiếng Việt",
en: "English",
vi: "Tiếng Việt",
"zh-CN": "简体中文",
"zh-TW": "繁體中文",
"ja": "日本語",
ja: "日本語",
"pt-BR": "Português (Brasil)",
"pt-PT": "Português (Portugal)",
"ko": "한국어",
"es": "Español",
"de": "Deutsch",
"fr": "Français",
"he": "עברית",
"ar": "العربية",
"ru": "Русский",
"pl": "Polski",
"cs": "Čeština",
"nl": "Nederlands",
"tr": "Türkçe",
"uk": "Українська",
"tl": "Tagalog",
"id": "Indonesia",
"th": "ไทย",
"hi": "हिन्दी",
"bn": "বাংলা",
"ur": "اردو",
"ro": "Română",
"sv": "Svenska",
"it": "Italiano",
"el": "Ελληνικά",
"hu": "Magyar",
"fi": "Suomi",
"da": "Dansk",
"no": "Norsk"
ko: "한국어",
es: "Español",
de: "Deutsch",
fr: "Français",
he: "עברית",
ar: "العربية",
ru: "Русский",
pl: "Polski",
cs: "Čeština",
nl: "Nederlands",
tr: "Türkçe",
uk: "Українська",
tl: "Tagalog",
id: "Indonesia",
th: "ไทย",
hi: "हिन्दी",
bn: "বাংলা",
ur: "اردو",
ro: "Română",
sv: "Svenska",
it: "Italiano",
el: "Ελληνικά",
hu: "Magyar",
fi: "Suomi",
da: "Dansk",
no: "Norsk",
fa: "فارسی",
};
export function normalizeLocale(locale) {
@@ -138,6 +174,9 @@ export function normalizeLocale(locale) {
if (locale === "no") {
return "no";
}
if (locale === "fa") {
return "fa";
}
return DEFAULT_LOCALE;
}

View File

@@ -1,9 +1,20 @@
// DB safety backups — taken ONLY before a schema change (see migrate.js).
//
// ⚠️ AGENT/DEV NOTES:
// - Backups are a best-effort safety net before schema migrations. There is NO
// automated restore path; recovery is manual (copy a backup file back).
// - Backups intentionally EXCLUDE the `requestDetails` table (observability log,
// auto-pruned, non-critical) so a multi-hundred-MB DB backs up as a few MB.
// - Only the newest KEEP_BACKUPS are kept; older ones are pruned automatically.
import fs from "node:fs";
import path from "node:path";
import { BACKUPS_DIR, ensureDirs } from "./paths.js";
import { timestampSlug, getAppVersion } from "./version.js";
const KEEP_BACKUPS = 5;
const KEEP_BACKUPS = 3;
// Tables excluded from safety backups (large, non-critical, reproducible).
const BACKUP_EXCLUDE_TABLES = ["requestDetails"];
export function makeBackupDir(label) {
ensureDirs();
@@ -22,6 +33,35 @@ export function backupFile(srcPath, destDir, destName = null) {
return dest;
}
// Lightweight DB backup via ATTACH: create an empty sqlite file, copy every
// table EXCEPT the excluded ones into it. Avoids duplicating the huge
// observability log, so the backup stays small regardless of DB size.
export function backupDbLite(adapter, destDir, destName = "data.sqlite") {
const dest = path.join(destDir, destName);
try { fs.rmSync(dest, { force: true }); } catch {}
const escaped = dest.replace(/'/g, "''");
adapter.exec(`ATTACH DATABASE '${escaped}' AS bak`);
try {
const excluded = new Set(BACKUP_EXCLUDE_TABLES);
const tables = adapter
.all(`SELECT name, sql FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
.filter((t) => !excluded.has(t.name));
adapter.transaction(() => {
for (const t of tables) {
// Recreate table structure in backup DB, then copy rows.
const createSql = t.sql.replace(/CREATE TABLE\s+/i, "CREATE TABLE bak.");
adapter.exec(createSql);
adapter.exec(`INSERT INTO bak.${t.name} SELECT * FROM main.${t.name}`);
}
});
} finally {
try { adapter.exec("DETACH DATABASE bak"); } catch {}
}
return dest;
}
export function pruneOldBackups() {
if (!fs.existsSync(BACKUPS_DIR)) return;
const entries = fs.readdirSync(BACKUPS_DIR, { withFileTypes: true })

View File

@@ -64,7 +64,7 @@ export {
// Request details
export {
saveRequestDetail, getRequestDetails, getRequestDetailById,
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
} from "./repos/requestDetailsRepo.js";
// Export/import full DB

View File

@@ -1,10 +1,10 @@
import fs from "node:fs";
import path from "node:path";
import { LEGACY_FILES, DB_DIR, DATA_FILE } from "./paths.js";
import { TABLES, buildCreateTableSql } from "./schema.js";
import { LEGACY_FILES, DB_DIR } from "./paths.js";
import { TABLES, buildCreateTableSql, SCHEMA_VERSION } from "./schema.js";
import { MIGRATIONS, latestVersion } from "./migrations/index.js";
import { getMetaSync, setMetaSync } from "./helpers/metaStore.js";
import { makeBackupDir, backupFile, pruneOldBackups } from "./backup.js";
import { makeBackupDir, backupFile, backupDbLite, pruneOldBackups } from "./backup.js";
import { getAppVersion } from "./version.js";
import { stringifyJson } from "./helpers/jsonCol.js";
@@ -221,12 +221,37 @@ export async function runMigrationOnce(adapter) {
// a brand-new DB as non-fresh once schemaVersion is written).
const fresh = isFreshDb(adapter);
// Prune stale backups every boot so old oversized backups shrink to KEEP.
pruneOldBackups();
// Bootstrap _meta so we can read the stored backup schema version below
// (runVersionedMigrations also ensures this, but we need it earlier here).
adapter.exec(buildCreateTableSql("_meta", TABLES._meta));
// Detect a pending schema change via the central SCHEMA_VERSION const.
// A lightweight backup is taken BEFORE any schema mutation below.
const storedSchemaVer = parseInt(getMetaSync(adapter, "backupSchemaVersion", "0"), 10) || 0;
const schemaChanging = !fresh && storedSchemaVer < SCHEMA_VERSION;
if (schemaChanging) {
try {
const backupDir = makeBackupDir(`schema-${storedSchemaVer}-to-${SCHEMA_VERSION}`);
backupDbLite(adapter, backupDir);
pruneOldBackups();
console.log(`[DB][migrate] pre-schema backup ${storedSchemaVer} → ${SCHEMA_VERSION}: ${backupDir}`);
} catch (e) {
console.warn(`[DB][migrate] pre-schema backup failed (continuing): ${e.message}`);
}
}
// 1. Always run versioned migrations chain (skip-version safe)
const migInfo = runVersionedMigrations(adapter);
// 2. Additive sync (auto add missing columns/indexes declared in TABLES)
syncSchemaFromTables(adapter);
// Stamp the schema version we just reached so future boots skip re-backup.
setMetaSync(adapter, "backupSchemaVersion", SCHEMA_VERSION);
// 3. One-time legacy JSON import (only if DB was fresh on entry)
const alreadyImported = fs.existsSync(MIGRATED_MARKER);
const legacyMain = readJsonSafe(LEGACY_FILES.main);
@@ -247,6 +272,7 @@ export async function runMigrationOnce(adapter) {
importLegacyDisabled(adapter, legacyDisabled);
importLegacyDetails(adapter, legacyDetails);
setMetaSync(adapter, "appVersion", getAppVersion());
setMetaSync(adapter, "backupSchemaVersion", SCHEMA_VERSION);
setMetaSync(adapter, "migratedAt", new Date().toISOString());
});
} catch (err) {
@@ -263,24 +289,9 @@ export async function runMigrationOnce(adapter) {
return;
}
if (fresh) {
setMetaSync(adapter, "appVersion", getAppVersion());
return;
}
// 4. App version bump → backup data.sqlite (safety net before user-side upgrade)
const oldVer = getMetaSync(adapter, "appVersion", null);
// Track app version for informational purposes only. App version bumps no
// longer trigger a DB backup — only real schema changes (SCHEMA_VERSION) do.
const newVer = getAppVersion();
if (oldVer && oldVer !== newVer) {
const backupDir = makeBackupDir(`upgrade-${oldVer}-to-${newVer}`);
try { backupFile(DATA_FILE, backupDir); } catch {}
setMetaSync(adapter, "appVersion", newVer);
pruneOldBackups();
console.log(`[DB][migrate] App ${oldVer} → ${newVer} | schema ${migInfo.from} → ${migInfo.to} | backup: ${backupDir}`);
} else if (migInfo.applied > 0) {
// Schema upgrade without app version bump — still backup
const backupDir = makeBackupDir(`schema-${migInfo.from}-to-${migInfo.to}`);
try { backupFile(DATA_FILE, backupDir); } catch {}
pruneOldBackups();
}
const oldVer = getMetaSync(adapter, "appVersion", null);
if (oldVer !== newVer) setMetaSync(adapter, "appVersion", newVer);
}

View File

@@ -56,6 +56,17 @@ function upsert(db, c) {
);
}
function deriveConnectionName(data, fallbackName) {
if (data.provider === "github") {
return data.providerSpecificData?.githubLogin
|| data.providerSpecificData?.githubEmail
|| data.email
|| data.providerSpecificData?.githubName
|| fallbackName;
}
return fallbackName;
}
export async function getProviderConnections(filter = {}) {
const db = await getAdapter();
const where = [];
@@ -102,7 +113,18 @@ export async function createProviderConnection(data) {
const incomingWs = data.providerSpecificData?.chatgptAccountId;
existing = all.find(c => {
if (c.authType !== "oauth" || c.email !== data.email) return false;
// Workspace providers (Codex) use workspace ID when both sides have it
// Codex/OpenAI can issue multiple OAuth grants for the same email.
// Refresh tokens are rotated single-use; collapsing a new login onto an
// existing bare-email row overwrites the first account's token pair and
// makes it look "invalid" after adding a second account. Only update an
// existing Codex row when both rows expose the same ChatGPT account ID.
if (data.provider === "codex") {
const existingWs = c.providerSpecificData?.chatgptAccountId;
return !!incomingWs && !!existingWs && incomingWs === existingWs;
}
// Workspace providers use workspace ID when both sides have it
const existingWs = c.providerSpecificData?.chatgptAccountId;
if (incomingWs && existingWs) return incomingWs === existingWs;
if (incomingWs && !existingWs) return false;
@@ -133,7 +155,7 @@ export async function createProviderConnection(data) {
let connectionName = data.name || null;
if (!connectionName && (data.authType === "oauth" || data.authType === "access_token")) {
connectionName = data.email || `Account ${all.length + 1}`;
connectionName = deriveConnectionName(data, data.email || `Account ${all.length + 1}`);
}
let connectionPriority = data.priority;
if (!connectionPriority) {

View File

@@ -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(
@@ -174,6 +175,12 @@ export async function getRequestDetails(filter = {}) {
};
}
export async function getDistinctProviders() {
const db = await getAdapter();
const rows = db.all(`SELECT DISTINCT provider FROM requestDetails WHERE provider IS NOT NULL ORDER BY provider ASC`);
return rows.map((r) => r.provider);
}
export async function getRequestDetailById(id) {
const db = await getAdapter();
const row = db.get(`SELECT data FROM requestDetails WHERE id = ?`, [id]);

View File

@@ -43,6 +43,10 @@ const DEFAULT_SETTINGS = {
cavemanLevel: "full",
ponytailEnabled: false,
ponytailLevel: "full",
pxpipeEnabled: false,
pxpipeAutoInstall: true,
pxpipeMinChars: 25000,
pxpipeTimeoutMs: 15000,
};
async function readRaw() {

View File

@@ -189,8 +189,7 @@ export function trackPendingRequest(model, provider, connectionId, started, erro
lastErrorProvider.ts = Date.now();
}
const t = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
console.log(`[${t}] [PENDING] ${started ? "START" : "END"}${error ? " (ERROR)" : ""} | provider=${provider} | model=${model}`);
// [PENDING] console line removed; lifecycle is visible via "▶" and "📊 done" lines
scheduleStatsEvent("pending");
}

View File

@@ -1,4 +1,8 @@
// Latest schema version — bumped when a migration is added in ./migrations/
// ⚠️ AGENT/DEV: Bump this by +1 EVERY TIME you change the schema below
// (add/remove/alter a table, column, or index in TABLES). It drives the
// pre-change safety backup in migrate.js: when the stored version is lower,
// one lightweight DB backup is taken before applying schema changes. Forgetting
// to bump only skips that backup — it does NOT break the additive auto-sync.
export const SCHEMA_VERSION = 1;
export const PRAGMA_SQL = `

View File

@@ -1,6 +1,21 @@
import { execSync } from "child_process";
import { execFileSync, execSync } from "child_process";
import path from "path";
// Extras that improve headroom compression quality. `proxy` is the base;
// `code` adds tree-sitter AST compression; `ml` adds Kompress-v2 HF model.
// Other `[all]` extras (image, voice, otel, reports, evals, ...) are not
// useful for the 9router proxy use case, so we don't track them here.
export const HEADROOM_COMPRESSION_EXTRAS = ["code", "ml"];
// Marker packages that each extra pulls in. Detected from `pip list --format=json`
// so one call can answer both the installed version and active extras.
export const EXTRA_MARKERS = {
code: ["tree-sitter", "tree-sitter-language-pack"],
ml: ["torch", "huggingface-hub"],
};
const HEADROOM_PIP_TIMEOUT_MS = 8000;
const IS_WIN = process.platform === "win32";
const WHICH_CMD = IS_WIN ? "where" : "which";
@@ -49,8 +64,33 @@ export function findHeadroomBinary() {
}
// Find a Python interpreter >= 3.10 (headroom-ai requires it). Returns null if none.
// `python3`, `python3.13`, `python` can point at different envs on any OS. Prefer
// the interpreter that can also see the installed `headroom-ai` package so the
// dashboard probes and install action operate on the same interpreter as the CLI.
// Falls back to the first version-eligible candidate when headroom-ai is not yet
// installed anywhere (needed for the initial install).
// Interpreters to probe, most specific first: the python next to the headroom
// binary (guaranteed to have headroom-ai), then full paths from EXTRA_BINS, then
// bare names resolved via PATH.
function pythonCandidates() {
const list = [];
const bin = findHeadroomBinary();
if (bin) {
const dir = path.dirname(bin);
const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3", "python3.13", "python"];
for (const n of names) list.push(path.join(dir, n));
}
for (const dir of EXTRA_BINS) {
if (!dir) continue;
for (const n of PYTHON_CANDIDATES) list.push(path.join(dir, IS_WIN ? `${n}.exe` : n));
}
list.push(...PYTHON_CANDIDATES);
return list;
}
export function findPython310() {
for (const candidate of PYTHON_CANDIDATES) {
let fallback = null;
for (const candidate of pythonCandidates()) {
try {
const ver = execSync(`${candidate} --version`, {
stdio: ["ignore", "pipe", "ignore"],
@@ -60,14 +100,24 @@ export function findPython310() {
const match = ver.match(/(\d+)\.(\d+)/);
if (!match) continue;
const [major, minor] = [parseInt(match[1], 10), parseInt(match[2], 10)];
if (major > MIN_VERSION[0] || (major === MIN_VERSION[0] && minor >= MIN_VERSION[1])) {
if (!(major > MIN_VERSION[0] || (major === MIN_VERSION[0] && minor >= MIN_VERSION[1]))) continue;
if (!fallback) fallback = candidate;
try {
execFileSync(candidate, ["-m", "pip", "show", "headroom-ai"], {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
timeout: HEADROOM_PIP_TIMEOUT_MS,
env: { ...process.env, PATH: EXTENDED_PATH },
});
return candidate;
} catch {
// Keep scanning until an interpreter that sees headroom-ai is found.
}
} catch {
// candidate not present, try next
}
}
return null;
return fallback;
}
// Probe whether a Headroom proxy is reachable at the given URL by hitting /health.
@@ -98,5 +148,45 @@ export async function getHeadroomStatus(url) {
const installed = Boolean(path);
const running = await probeProxyRunning(url);
const localUrl = isLoopbackHeadroomUrl(url);
return { installed, path, running, python, localUrl, canStart: installed && localUrl };
const extrasStatus = installed ? getInstalledHeadroomExtras(python) : { installed: false, version: null, extras: { code: false, ml: false } };
return {
installed,
path,
running,
python,
localUrl,
canStart: installed && localUrl,
version: extrasStatus.version,
extras: extrasStatus.extras,
};
}
// Parse installed headroom-ai version + which compression extras are
// actually installed (detected via marker package presence). One `pip list`
// call is enough to answer both questions.
//
// Returns: { installed: bool, version: string|null, extras: { code, ml } }
export function getInstalledHeadroomExtras(python) {
const py = python || findPython310();
if (!py) return { installed: false, version: null, extras: { code: false, ml: false } };
try {
const out = execFileSync(py, ["-m", "pip", "list", "--format=json", "--disable-pip-version-check"], {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
timeout: HEADROOM_PIP_TIMEOUT_MS,
env: { ...process.env, PATH: EXTENDED_PATH },
}).toString();
const packages = JSON.parse(out);
const names = new Set(packages.map((p) => String(p.name || "").toLowerCase()));
const installed = names.has("headroom-ai");
if (!installed) return { installed: false, version: null, extras: { code: false, ml: false } };
const version = packages.find((p) => p.name?.toLowerCase() === "headroom-ai")?.version || null;
const extras = {};
for (const extra of HEADROOM_COMPRESSION_EXTRAS) {
extras[extra] = EXTRA_MARKERS[extra].some((m) => names.has(m));
}
return { installed: true, version, extras };
} catch {
return { installed: false, version: null, extras: { code: false, ml: false } };
}
}

View File

@@ -2,11 +2,12 @@ import fs from "fs";
import path from "path";
import { spawn } from "child_process";
import { DATA_DIR } from "@/lib/dataDir.js";
import { findHeadroomBinary } from "./detect.js";
import { findHeadroomBinary, findPython310, HEADROOM_COMPRESSION_EXTRAS, EXTRA_MARKERS, getInstalledHeadroomExtras } from "./detect.js";
const HEADROOM_DIR = path.join(DATA_DIR, "headroom");
const PID_FILE = path.join(HEADROOM_DIR, "proxy.pid");
const LOG_FILE = path.join(HEADROOM_DIR, "proxy.log");
const INSTALL_LOG_FILE = path.join(HEADROOM_DIR, "install.log");
const DEFAULT_PORT = 8787;
const STARTUP_TIMEOUT_MS = 8000;
@@ -41,7 +42,17 @@ export function getManagedPid() {
return pid && isPidAlive(pid) ? pid : null;
}
export async function startHeadroomProxy({ port = DEFAULT_PORT } = {}) {
// Build proxy CLI flags for the active compression extras. `[code]` (AST
// compression) is off by default in headroom → pass --code-aware to turn it on;
// `[ml]` (Kompress) is on by default → pass --disable-kompress to turn it off.
function extrasProxyArgs({ codeAware, kompress } = {}) {
const args = [];
if (codeAware) args.push("--code-aware");
if (kompress === false) args.push("--disable-kompress");
return args;
}
export async function startHeadroomProxy({ port = DEFAULT_PORT, codeAware = false, kompress = true } = {}) {
const safePort = Number(port) > 0 && Number(port) < 65536 ? Number(port) : DEFAULT_PORT;
const binary = findHeadroomBinary();
if (!binary) {
@@ -57,7 +68,8 @@ export async function startHeadroomProxy({ port = DEFAULT_PORT } = {}) {
// spawn stdio requires fd numbers, not WriteStream objects.
const outFd = fs.openSync(LOG_FILE, "a");
const child = spawn(binary, ["proxy", "--port", String(safePort)], {
const args = ["proxy", "--port", String(safePort), ...extrasProxyArgs({ codeAware, kompress })];
const child = spawn(binary, args, {
stdio: ["ignore", outFd, outFd],
detached: true,
windowsHide: true,
@@ -118,6 +130,25 @@ export function stopHeadroomProxy() {
}
}
// Stop the managed proxy (if any), wait for the pid to die, then start again
// with the given flags. Used when toggling active extras that require a restart.
export async function restartHeadroomProxy(opts = {}) {
const pid = getManagedPid();
if (pid) {
try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ }
// Wait up to ~3s for graceful exit, force-kill if still alive.
for (let i = 0; i < 30 && isPidAlive(pid); i++) {
await new Promise((r) => setTimeout(r, 100));
}
if (isPidAlive(pid)) {
try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ }
await new Promise((r) => setTimeout(r, 300));
}
clearPid();
}
return startHeadroomProxy(opts);
}
export function getHeadroomLogTail(maxLines = 200) {
try {
if (!fs.existsSync(LOG_FILE)) return "";
@@ -126,3 +157,104 @@ export function getHeadroomLogTail(maxLines = 200) {
return lines.slice(-maxLines).join("\n");
} catch { return ""; }
}
// Install (or upgrade) headroom-ai with the requested compression extras.
// `extras` is a whitelist from HEADROOM_COMPRESSION_EXTRAS — anything else
// is rejected to keep the install surface predictable. Always installs the
// `proxy` base + whatever extras the user picked, regardless of what is
// already present.
export async function installHeadroomExtras(extras = []) {
const requested = Array.isArray(extras) ? extras.filter((e) => HEADROOM_COMPRESSION_EXTRAS.includes(e)) : [];
const py = findPython310();
if (!py) {
const err = new Error("Python >= 3.10 not found");
err.code = "NO_PYTHON";
throw err;
}
if (!findHeadroomBinary()) {
const err = new Error("headroom-ai not installed (run `pip install headroom-ai[proxy]` first)");
err.code = "NOT_INSTALLED";
throw err;
}
// pip install string is built from a closed set (HEADROOM_COMPRESSION_EXTRAS),
// so it cannot be poisoned by caller input — the comma-list is a fixed
// ['proxy', ...requested]. No shell interpolation.
const extrasList = ["proxy", ...requested].join(",");
const spec = `headroom-ai[${extrasList}]`;
const args = ["-m", "pip", "install", "--upgrade", spec];
ensureDir();
// Truncate ("w") so the log reflects only the current install for live progress.
const outFd = fs.openSync(INSTALL_LOG_FILE, "w");
const child = spawn(py, args, {
stdio: ["ignore", outFd, outFd],
windowsHide: true,
env: { ...process.env },
});
return new Promise((resolve, reject) => {
child.once("error", (e) => { fs.closeSync(outFd); reject(e); });
child.once("exit", (code) => {
fs.closeSync(outFd);
if (code === 0) {
const status = getInstalledHeadroomExtras(py);
resolve({ success: true, code, spec, extras: requested, ...status });
} else {
const err = new Error(`pip install exited with code=${code} — see headroom/install.log`);
err.code = "INSTALL_FAILED";
reject(err);
}
});
});
}
// Uninstall the marker packages that back a single extra (e.g. `ml` → torch,
// huggingface-hub). `headroom-ai` base and the `proxy` extra are never removed.
export async function uninstallHeadroomExtras(extras = []) {
const requested = Array.isArray(extras) ? extras.filter((e) => HEADROOM_COMPRESSION_EXTRAS.includes(e)) : [];
const py = findPython310();
if (!py) {
const err = new Error("Python >= 3.10 not found");
err.code = "NO_PYTHON";
throw err;
}
const pkgs = [...new Set(requested.flatMap((e) => EXTRA_MARKERS[e] || []))];
if (pkgs.length === 0) {
const err = new Error("No valid extras to remove");
err.code = "INVALID_EXTRAS";
throw err;
}
const args = ["-m", "pip", "uninstall", "-y", ...pkgs];
ensureDir();
const outFd = fs.openSync(INSTALL_LOG_FILE, "w");
const child = spawn(py, args, {
stdio: ["ignore", outFd, outFd],
windowsHide: true,
env: { ...process.env },
});
return new Promise((resolve, reject) => {
child.once("error", (e) => { fs.closeSync(outFd); reject(e); });
child.once("exit", (code) => {
fs.closeSync(outFd);
if (code === 0) {
const status = getInstalledHeadroomExtras(py);
resolve({ success: true, code, removed: pkgs, extras: requested, ...status });
} else {
const err = new Error(`pip uninstall exited with code=${code} — see headroom/install.log`);
err.code = "UNINSTALL_FAILED";
reject(err);
}
});
});
}
// Read the tail of the install/uninstall log for live progress in the UI.
export function getInstallLogTail(maxLines = 15) {
try {
if (!fs.existsSync(INSTALL_LOG_FILE)) return "";
const lines = fs.readFileSync(INSTALL_LOG_FILE, "utf8").split(/\r?\n/).filter(Boolean);
return lines.slice(-maxLines).join("\n");
} catch { return ""; }
}

View File

@@ -153,6 +153,20 @@ function unregisterSession(name, sid) {
const entry = getStore().get(name);
if (!entry) return;
entry.sessions.delete(sid);
// No sessions left → kill child to avoid idle orphan process leak.
if (entry.sessions.size === 0) {
try { entry.proc.kill(); } catch { /* ignore */ }
getStore().delete(name);
}
}
// Kill all spawned MCP children — called on app shutdown to prevent orphans.
function killAllBridges() {
const store = getStore();
for (const [name, entry] of store) {
try { entry.proc.kill(); } catch { /* ignore */ }
store.delete(name);
}
}
function sendToChild(name, jsonRpc) {
@@ -166,4 +180,4 @@ function isRunning(name) {
return !!(entry?.proc && !entry.proc.killed && entry.proc.exitCode === null);
}
module.exports = { getOrSpawn, registerSession, unregisterSession, sendToChild, isRunning, findPlugin };
module.exports = { getOrSpawn, registerSession, unregisterSession, sendToChild, isRunning, findPlugin, killAllBridges };

View File

@@ -6,6 +6,33 @@ function normalizeString(value) {
return String(value).trim();
}
// ─── Proxy pool rotation state (in-memory) ─────────────────────────
const rotateState = new Map(); // providerId → { index }
/**
* Pick one proxy pool ID from a list based on strategy.
* round-robin: cycle sequentially (in-memory, resets on restart)
* random: uniform random pick
* none/single: return first entry
*/
export function pickProxyPoolId(poolIds, strategy, providerId) {
if (!poolIds || poolIds.length === 0) return null;
if (poolIds.length === 1) return poolIds[0];
if (strategy === "round-robin") {
const state = rotateState.get(providerId) || { index: -1 };
state.index = (state.index + 1) % poolIds.length;
rotateState.set(providerId, state);
return poolIds[state.index];
}
if (strategy === "random") {
return poolIds[Math.floor(Math.random() * poolIds.length)];
}
return poolIds[0]; // "none" or unknown
}
/**
* Normalize legacy proxy configuration.
*/

View File

@@ -114,6 +114,10 @@ export const CODEBUDDY_CONFIG = { ...PROVIDER_OAUTH["codebuddy-cn"] };
// Kimchi OAuth Configuration (Browser token callback flow)
export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] };
// Grok CLI / Grok Build OAuth Configuration (Device Code Flow)
// Endpoint: cli-chat-proxy.grok.com — same client_id as xai, different flow + scopes
export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] };
// OAuth timeout (5 minutes)
export const OAUTH_TIMEOUT = 300000;
@@ -137,4 +141,5 @@ export const PROVIDERS = {
GITLAB: "gitlab",
CODEBUDDY: "codebuddy-cn",
KIMCHI: "kimchi",
GROK_CLI: "grok-cli",
};

View File

@@ -27,6 +27,7 @@ import {
GITLAB_CONFIG,
CODEBUDDY_CONFIG,
KIMCHI_CONFIG,
GROK_CLI_CONFIG,
getOAuthClientMetadata,
} from "./constants/oauth";
import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai";
@@ -255,6 +256,122 @@ const PROVIDERS = {
},
},
// Grok CLI / Grok Build — device code flow to auth.x.ai, inference on cli-chat-proxy.grok.com
"grok-cli": {
config: GROK_CLI_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const body = new URLSearchParams({
client_id: config.clientId,
scope: config.scope,
});
// Official CLI sends referrer=grok-build
if (config.referrer) body.set("referrer", config.referrer);
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
},
body,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Grok CLI device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: deviceCode,
client_id: config.clientId,
}),
});
let data;
try {
data = await response.json();
} catch {
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
// Device flow: 400 + authorization_pending is expected while user authorizes
const pending =
data?.error === "authorization_pending" ||
data?.error === "slow_down";
return {
ok: response.ok || pending,
data,
};
},
postExchange: async (tokens) => {
// Best-effort user profile from cli-chat-proxy (non-fatal)
try {
const res = await fetch("https://cli-chat-proxy.grok.com/v1/user", {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: "application/json",
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
"x-xai-token-auth": "xai-grok-cli",
"x-grok-client-version": "0.2.93",
},
});
if (res.ok) return { user: await res.json() };
} catch {
/* ignore */
}
return { user: null };
},
mapTokens: (tokens, extra) => {
const email =
decodeXaiIdTokenEmail(tokens.id_token) ||
extractEmailFromAccessToken(tokens.access_token) ||
extra?.user?.email ||
null;
const userId =
extra?.user?.userId ||
extra?.user?.principalId ||
null;
const displayName = [extra?.user?.firstName, extra?.user?.lastName]
.filter(Boolean)
.join(" ")
.trim() || null;
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || null,
expiresIn: tokens.expires_in,
scope: tokens.scope,
// Top-level for dashboard connection cards
email: email || undefined,
displayName: displayName || undefined,
// Mirror identity into providerSpecificData so GrokCliExecutor can set
// x-email / x-userid without depending on top-level credential shape.
providerSpecificData: {
authMethod: "device_code",
idToken: tokens.id_token || null,
email: email || null,
userId,
hasGrokCodeAccess: extra?.user?.hasGrokCodeAccess ?? null,
subscriptionTier: extra?.user?.subscriptionTier ?? null,
},
};
},
},
"gemini-cli": {
config: GEMINI_CONFIG,
flowType: "authorization_code",
@@ -777,6 +894,9 @@ const PROVIDERS = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
name: extra?.userInfo?.login || extra?.userInfo?.name,
displayName: extra?.userInfo?.name || extra?.userInfo?.login,
email: extra?.userInfo?.email || null,
providerSpecificData: {
copilotToken: extra?.copilotToken?.token,
copilotTokenExpiresAt: extra?.copilotToken?.expires_at,

125
src/lib/pxpipe/events.js Normal file
View 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
View 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
View 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
View 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 };
}

View File

@@ -1,4 +1,4 @@
// Shim → re-export from new SQLite-based DB layer (src/lib/db/)
export {
saveRequestDetail, getRequestDetails, getRequestDetailById,
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
} from "@/lib/db/index.js";

View File

@@ -496,9 +496,15 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) {
fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" });
} catch (e) {
if (e.code === "EEXIST") {
throw new Error("MITM server is already starting (lock contention)");
}
throw e;
let stale = false;
try {
const pid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8").trim(), 10);
stale = !pid || !isProcessAlive(pid);
} catch { stale = true; } // unreadable lock → treat as stale
if (!stale) throw new Error("MITM server is already starting (lock contention)");
try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ }
fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" });
} else throw e;
}
try {

View File

@@ -49,7 +49,8 @@ const getLocaleInfo = (locale) => {
"hu": { name: "Magyar", flag: "🇭🇺" },
"fi": { name: "Suomi", flag: "🇫🇮" },
"da": { name: "Dansk", flag: "🇩🇰" },
"no": { name: "Norsk", flag: "🇳🇴" }
"no": { name: "Norsk", flag: "🇳🇴" },
"fa": { name: "فارسی", flag: "🇮🇷" }
};
return locales[locale] || { name: locale, flag: "🌐" };
};

View File

@@ -1,16 +1,22 @@
"use client";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import PropTypes from "prop-types";
import Card from "./Card";
import Select from "./Select";
import Badge from "./Badge";
const NONE_PROXY_POOL_VALUE = "__none__";
const STRATEGIES = [
{ value: "none", label: "None (single pool)" },
{ value: "round-robin", label: "Round-robin" },
{ value: "random", label: "Random" },
];
export default function NoAuthProxyCard({ providerId }) {
const [proxyPools, setProxyPools] = useState([]);
const [proxyPoolId, setProxyPoolId] = useState(NONE_PROXY_POOL_VALUE);
const [rotateStrategy, setRotateStrategy] = useState("none");
const [saving, setSaving] = useState(false);
const [savedFlash, setSavedFlash] = useState(false);
@@ -24,20 +30,22 @@ export default function NoAuthProxyCard({ providerId }) {
setProxyPools(poolData.proxyPools || []);
const override = (settingsData.providerStrategies || {})[providerId] || {};
setProxyPoolId(override.proxyPoolId || NONE_PROXY_POOL_VALUE);
setRotateStrategy(override.rotateStrategy || "none");
}).catch(() => {});
return () => { cancelled = true; };
}, [providerId]);
const handleChange = async (newValue) => {
setProxyPoolId(newValue);
const save = useCallback(async (poolId, strategy) => {
setSaving(true);
try {
const res = await fetch("/api/settings", { cache: "no-store" });
const data = res.ok ? await res.json() : {};
const current = data.providerStrategies || {};
const override = { ...(current[providerId] || {}) };
if (newValue === NONE_PROXY_POOL_VALUE) delete override.proxyPoolId;
else override.proxyPoolId = newValue;
if (poolId === NONE_PROXY_POOL_VALUE) delete override.proxyPoolId;
else override.proxyPoolId = poolId;
if (strategy === "none") delete override.rotateStrategy;
else override.rotateStrategy = strategy;
const updated = { ...current };
if (Object.keys(override).length === 0) delete updated[providerId];
else updated[providerId] = override;
@@ -49,12 +57,25 @@ export default function NoAuthProxyCard({ providerId }) {
setSavedFlash(true);
setTimeout(() => setSavedFlash(false), 1500);
} catch (e) {
console.log("Save proxyPoolId error:", e);
console.log("Save proxy config error:", e);
} finally {
setSaving(false);
}
}, [providerId]);
const handlePoolChange = (newPoolId) => {
setProxyPoolId(newPoolId);
save(newPoolId, rotateStrategy);
};
const handleStrategyChange = (newStrategy) => {
setRotateStrategy(newStrategy);
save(proxyPoolId, newStrategy);
};
const canRotate = proxyPools.length >= 2;
const isRotation = rotateStrategy !== "none";
return (
<Card>
<div className="flex items-center gap-3 mb-4">
@@ -67,16 +88,43 @@ export default function NoAuthProxyCard({ providerId }) {
</div>
{savedFlash && <Badge variant="success" size="sm">Saved</Badge>}
</div>
<Select
label="Proxy Pool"
value={proxyPoolId}
onChange={(e) => handleChange(e.target.value)}
disabled={saving}
onChange={(e) => handlePoolChange(e.target.value)}
disabled={saving || isRotation}
options={[
{ value: NONE_PROXY_POOL_VALUE, label: "None (direct)" },
...proxyPools.map((pool) => ({ value: pool.id, label: pool.name })),
]}
hint={isRotation ? "Pool selector is ignored when rotation is active — all active pools are used." : undefined}
/>
<div className="flex flex-col gap-2 mt-4">
<label className="text-sm font-medium text-text-main">Rotation Strategy</label>
<select
value={rotateStrategy}
onChange={(e) => handleStrategyChange(e.target.value)}
disabled={saving}
className="py-2 px-3 text-sm text-text-main bg-white dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-md focus:ring-1 focus:ring-primary/30 focus:border-primary/50 focus:outline-none transition-all disabled:opacity-50"
>
{STRATEGIES.map((s) => (
<option key={s.value} value={s.value} disabled={s.value !== "none" && !canRotate}>
{s.label}
</option>
))}
</select>
<p className="text-xs text-text-muted">
{!canRotate
? `Need at least 2 active proxy pools for rotation.`
: isRotation
? rotateStrategy === "round-robin"
? `Rotating through all ${proxyPools.length} active pools in order. State is in-memory (resets on restart).`
: `Picking a random pool from ${proxyPools.length} active pools each request.`
: `Uses the selected pool above. Set to Round-robin or Random to rotate across all active pools.`}
</p>
</div>
</Card>
);
}

View File

@@ -156,8 +156,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
try {
setError(null);
// Device code flow providers
const deviceCodeProviders = ["github", "qwen", "kiro", "kimi-coding", "kilocode", "codebuddy-cn", "qoder"];
// Device code flow providers (must match oauth providers with flowType: "device_code")
const deviceCodeProviders = [
"github",
"qwen",
"kiro",
"kimi-coding",
"kilocode",
"codebuddy-cn",
"qoder",
"grok-cli",
];
if (deviceCodeProviders.includes(provider)) {
setIsDeviceCode(true);
setStep("waiting");
@@ -277,6 +286,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setAuthData({ ...data, redirectUri, codexServerSide, xaiServerSide });
// Guard: device_code providers return authUrl:null from /authorize. Never window.open(null)
// (browsers coerce it to the relative path ".../null").
if (!data.authUrl) {
if (data.flowType === "device_code") {
throw new Error(
`Provider ${provider} uses device-code login but is not wired in the OAuth modal device-code list`
);
}
throw new Error("No authorization URL returned from OAuth provider");
}
if (provider === "codex" && codexProxyActive) {
// Proxy active: callback will be handled server-side (auto-exchange) or via channels (fallback)
setStep("waiting");

View File

@@ -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" },
];

View File

@@ -14,7 +14,9 @@ import Badge from "./Badge";
import Card from "./Card";
import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards";
import UsageTable, { fmt, fmtTime } from "@/app/(dashboard)/dashboard/usage/components/UsageTable";
import ProviderTopology from "@/app/(dashboard)/dashboard/usage/components/ProviderTopology";
import dynamic from "next/dynamic";
// Lazy-load: keeps @xyflow/react out of the shared bundle until topology renders
const ProviderTopology = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/ProviderTopology"), { ssr: false });
import UsageChart from "@/app/(dashboard)/dashboard/usage/components/UsageChart";
function timeAgo(timestamp) {

View File

@@ -95,13 +95,15 @@ export const CLI_TOOLS = {
model: "ANTHROPIC_MODEL",
opusModel: "ANTHROPIC_DEFAULT_OPUS_MODEL",
sonnetModel: "ANTHROPIC_DEFAULT_SONNET_MODEL",
fableModel: "ANTHROPIC_DEFAULT_FABLE_MODEL",
haikuModel: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
},
modelAliases: ["default", "sonnet", "opus", "haiku", "opusplan"],
modelAliases: ["default", "sonnet", "opus", "fable", "haiku", "opusplan"],
settingsFile: "~/.claude/settings.json",
defaultModels: [
{ id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-6" },
{ id: "sonnet", name: "Claude Sonnet", alias: "sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-6" },
{ id: "fable", name: "Claude Fable", alias: "fable", envKey: "ANTHROPIC_DEFAULT_FABLE_MODEL", defaultValue: "cc/claude-fable-5" },
{ id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-8" },
{ id: "sonnet", name: "Claude Sonnet", alias: "sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-5" },
{ id: "haiku", name: "Claude Haiku", alias: "haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" },
],
},

View File

@@ -33,4 +33,5 @@ export const LOCALE_FLAGS = {
"fi": "🇫🇮",
"da": "🇩🇰",
"no": "🇳🇴",
"fa": "🇮🇷",
};

View File

@@ -16,6 +16,7 @@ import {
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
import { startQuotaAutoPing } from "@/shared/services/quotaAutoPing";
import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
import { killAllBridges } from "@/lib/mcp/stdioSseBridge";
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context
(function bootstrapMitm() {
@@ -32,6 +33,10 @@ import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
process.setMaxListeners(20);
// Defer heavy startup work so the first HTTP request (login → dashboard) isn't
// starved by DB cleanup, cloudflared download, lsof/DNS probes and OAuth pings.
const STARTUP_DEFER_MS = 3000;
// Survive Next.js hot reload
const g = global.__appSingleton ??= {
signalHandlersRegistered: false,
@@ -47,26 +52,12 @@ const g = global.__appSingleton ??= {
export async function initializeApp() {
try {
await cleanupProviderConnections();
const settings = await getSettings();
// Auto-resume tunnel (once per process)
if (settings.tunnelEnabled && !g.tunnelAutoResumed) {
g.tunnelAutoResumed = true;
console.log("[InitApp] Tunnel was enabled, auto-resuming...");
safeRestartTunnel("startup").catch((e) => console.log("[InitApp] Tunnel resume failed:", e.message));
}
// Auto-resume tailscale (once per process)
if (settings.tailscaleEnabled && !g.tailscaleAutoResumed) {
g.tailscaleAutoResumed = true;
console.log("[InitApp] Tailscale was enabled, auto-resuming...");
safeRestartTailscale("startup").catch((e) => console.log("[InitApp] Tailscale resume failed:", e.message));
}
// Register cleanup + exit-respawn callback immediately so signals and
// unexpected cloudflared exits are handled even during the deferred window.
if (!g.signalHandlersRegistered) {
const cleanup = () => {
try { removeAllDNSEntriesSync(); } catch { /* best effort */ }
try { killAllBridges(); } catch { /* best effort */ }
killCloudflared();
process.exit();
};
@@ -76,25 +67,48 @@ export async function initializeApp() {
g.signalHandlersRegistered = true;
}
ensureCloudflared().catch(() => {});
// Sync mitmAlias DB → JSON cache so standalone MITM server can read it
syncMitmAliasCache().catch(() => {});
// Auto-respawn tunnel when cloudflared exits unexpectedly (e.g. network change drop)
setTunnelUnexpectedExitCallback(() => {
safeRestartTunnel("unexpected-exit").catch(() => {});
});
startWatchdog();
startNetworkMonitor();
autoStartMitm();
startQuotaAutoPing();
// Defer the heavy work — nothing here blocks incoming requests.
setTimeout(() => {
runHeavyStartup().catch((e) => console.error("[InitApp] deferred startup failed:", e.message));
}, STARTUP_DEFER_MS);
} catch (error) {
console.error("[InitApp] Error:", error);
}
}
async function runHeavyStartup() {
await cleanupProviderConnections();
const settings = await getSettings();
// Auto-resume tunnel (once per process)
if (settings.tunnelEnabled && !g.tunnelAutoResumed) {
g.tunnelAutoResumed = true;
console.log("[InitApp] Tunnel was enabled, auto-resuming...");
safeRestartTunnel("startup").catch((e) => console.log("[InitApp] Tunnel resume failed:", e.message));
}
// Auto-resume tailscale (once per process)
if (settings.tailscaleEnabled && !g.tailscaleAutoResumed) {
g.tailscaleAutoResumed = true;
console.log("[InitApp] Tailscale was enabled, auto-resuming...");
safeRestartTailscale("startup").catch((e) => console.log("[InitApp] Tailscale resume failed:", e.message));
}
ensureCloudflared().catch(() => {});
// Sync mitmAlias DB → JSON cache so standalone MITM server can read it
syncMitmAliasCache().catch(() => {});
startWatchdog();
startNetworkMonitor();
autoStartMitm();
startQuotaAutoPing();
}
async function autoStartMitm() {
if (g.mitmStartInProgress) return;
g.mitmStartInProgress = true;

View File

@@ -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";
@@ -46,15 +48,9 @@ export async function handleChat(request, clientRawRequest = null) {
}
cacheClaudeHeaders(clientRawRequest.headers);
// Log request endpoint and model
const url = new URL(request.url);
const modelStr = body.model;
// Count messages (support both messages[] and input[] formats)
const msgCount = body.messages?.length || body.input?.length || 0;
const toolCount = body.tools?.length || 0;
const effort = body.reasoning_effort || body.reasoning?.effort || null;
log.request("POST", `${url.pathname} | ${modelStr} | ${msgCount} msgs${toolCount ? ` | ${toolCount} tools` : ""}${effort ? ` | effort=${effort}` : ""}`);
// Request summary is emitted as the unified "▶" line in chatCore (has fmt/thinking/account)
// Log API key (masked)
const authHeader = request.headers.get("Authorization");
@@ -189,12 +185,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
const { provider, model } = modelInfo;
// Log model routing (alias → actual model)
if (modelStr !== `${provider}/${model}`) {
log.info("ROUTING", `${modelStr} → ${provider}/${model}`);
} else {
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
}
// Routing shown in the unified "▶" line (client model → provider/model)
// Extract userAgent from request
const userAgent = request?.headers?.get("user-agent") || "";
@@ -223,9 +214,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
}
// Log account selection
log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`);
// Account selection shown in the unified "▶" line (acc:...)
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
// Ensure real project ID is available for providers that need it (P0 fix: cold miss)
@@ -259,6 +248,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,
@@ -280,7 +275,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model, result.resetsAtMs);
if (shouldFallback) {
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
log.warn("FALLBACK", `⇄ ACC:${credentials.connectionName} UNAVAILABLE (${result.status}) → NEXT ACCOUNT`);
excludeConnectionIds.add(credentials.connectionId);
lastError = result.error;
lastStatus = result.status;

View File

@@ -1,5 +1,5 @@
import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings } from "@/lib/localDb";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb";
import { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/connectionProxy";
import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js";
import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js";
import { resolveProviderId, FREE_PROVIDERS } from "@/shared/constants/providers.js";
@@ -36,7 +36,14 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
if (FREE_PROVIDERS[providerId]?.noAuth) {
const settings = await getSettings();
const override = (settings.providerStrategies || {})[providerId] || {};
const resolvedProxy = await resolveConnectionProxyConfig({ proxyPoolId: override.proxyPoolId || "" });
const strategy = override.rotateStrategy || "none";
let pickedId = override.proxyPoolId || null;
if (strategy !== "none") {
const allPools = await getProxyPools({ isActive: true });
const poolIds = allPools.filter(p => p.proxyUrl).map(p => p.id);
pickedId = pickProxyPoolId(poolIds, strategy, providerId);
}
const resolvedProxy = await resolveConnectionProxyConfig({ proxyPoolId: pickedId || "" });
return {
id: "noauth",
connectionName: "Public",

View File

@@ -13,6 +13,49 @@ function formatTime() {
return new Date().toLocaleTimeString("en-US", { hour12: false });
}
// Colored-dot tags to correlate request lines by session (same session → same color)
const REQ_TAGS = ["🟢", "🔵", "🟣", "🟡", "🟠", "🔴", "⚪", "🟤"];
let tagCursor = 0;
// Allocate next rotating tag (fallback when no session seed available)
export function nextTag() {
const tag = REQ_TAGS[tagCursor % REQ_TAGS.length];
tagCursor++;
return tag;
}
// Stable tag derived from a session/connection seed: same seed always maps to the same color
export function tagForSession(seed) {
if (!seed) return nextTag();
let h = 0;
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
return REQ_TAGS[Math.abs(h) % REQ_TAGS.length];
}
// Print one correlated line: [time] tag symbol message
export function line(tag, symbol, message) {
if (LEVEL > LOG_LEVELS.INFO) return;
console.log(`[${formatTime()}] ${tag} ${symbol} ${message}`);
}
// Like line() but always printed regardless of LOG_LEVEL (errors must never be hidden)
export function errorLine(tag, symbol, message) {
console.log(`[${formatTime()}] ${tag} ${symbol} ${message}`);
}
// Format thinking intent for the request line ("high(10k)" / "off" / "auto")
export function fmtThink(intent) {
if (!intent || !intent.mode) return null;
if (intent.mode === "none") return "off";
if (intent.mode === "auto") return "auto";
if (intent.mode === "budget") {
const k = intent.budget >= 1000 ? `${Math.round(intent.budget / 1000)}k` : `${intent.budget}`;
return k;
}
if (intent.mode === "level") return intent.level;
return null;
}
function formatData(data) {
if (!data) return "";
if (typeof data === "string") return data;
@@ -40,7 +83,7 @@ export function info(tag, message, data) {
export function warn(tag, message, data) {
if (LEVEL <= LOG_LEVELS.WARN) {
const dataStr = data ? ` ${formatData(data)}` : "";
// console.warn(`[${formatTime()}] ⚠️ [${tag}] ${message}${dataStr}`);
console.warn(`[${formatTime()}] ⚠️ [${tag}] ${message}${dataStr}`);
}
}