Merge remote-tracking branch 'upstream/master'
# Conflicts: # .gitignore # open-sse/handlers/chatCore.js
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user