merge origin/master into gitea/new_feature
Bring local branch up to v0.5.35 while keeping xAI image/edit, SuperGrok quota tracking, per-provider timeouts, and pinned model-test actions.
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
||||
ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard,
|
||||
HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard,
|
||||
CopilotToolCard, ClineToolCard, KiloToolCard, DeepSeekTuiToolCard,
|
||||
JcodeToolCard,
|
||||
JcodeToolCard, GrokBuildToolCard,
|
||||
} from "../components";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
@@ -139,6 +139,8 @@ export default function ToolDetailClient({ toolId, machineId }) {
|
||||
return <DeepSeekTuiToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
||||
case "jcode":
|
||||
return <JcodeToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
||||
case "grok-build":
|
||||
return <GrokBuildToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
||||
default:
|
||||
return <DefaultToolCard toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} tunnelEnabled={tunnelEnabled} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import BaseUrlSelect from "./BaseUrlSelect";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||
|
||||
const ENDPOINT = "/api/cli-tools/grok-build-settings";
|
||||
const MODEL_SLOT = "9router";
|
||||
|
||||
export default function GrokBuildToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
baseUrl,
|
||||
hasActiveProviders,
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
cloudEnabled,
|
||||
initialStatus,
|
||||
tunnelEnabled,
|
||||
tunnelPublicUrl,
|
||||
tailscaleEnabled,
|
||||
tailscaleUrl,
|
||||
}) {
|
||||
const [grokStatus, setGrokStatus] = useState(initialStatus || null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
const hasInitializedModel = useRef(false);
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!grokStatus?.installed) return null;
|
||||
const cfg = grokStatus.settings?.model;
|
||||
if (!cfg?.base_url) return "not_configured";
|
||||
if (matchKnownEndpoint(cfg.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStatus) setGrokStatus(initialStatus);
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !grokStatus) {
|
||||
checkStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
if (res.ok) setModelAliases(data.aliases || {});
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (grokStatus?.installed && !hasInitializedModel.current) {
|
||||
hasInitializedModel.current = true;
|
||||
const cfg = grokStatus.settings?.model;
|
||||
if (cfg?.model) setSelectedModel(cfg.model);
|
||||
}
|
||||
}, [grokStatus]);
|
||||
|
||||
const checkStatus = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await fetch(ENDPOINT);
|
||||
const data = await res.json();
|
||||
setGrokStatus(data);
|
||||
} catch (error) {
|
||||
setGrokStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1");
|
||||
|
||||
const getLocalBaseUrl = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
return normalizeLocalhost(window.location.origin);
|
||||
}
|
||||
return "http://127.0.0.1:20128";
|
||||
};
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || getLocalBaseUrl();
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const keyToUse = selectedApiKey?.trim()
|
||||
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|
||||
|| (!cloudEnabled ? "sk_9router" : null);
|
||||
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: getEffectiveBaseUrl(),
|
||||
apiKey: keyToUse,
|
||||
model: selectedModel,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings applied successfully!" });
|
||||
checkStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setRestoring(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch(ENDPOINT, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset successfully!" });
|
||||
setSelectedModel("");
|
||||
checkStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelSelect = (model) => {
|
||||
setSelectedModel(model.value);
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const getManualConfigs = () => {
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
||||
|
||||
const modelId = selectedModel || "provider/model-id";
|
||||
const tomlContent = `[models]
|
||||
default = "${MODEL_SLOT}"
|
||||
|
||||
[model.${MODEL_SLOT}]
|
||||
model = "${modelId}"
|
||||
base_url = "${getEffectiveBaseUrl()}"
|
||||
name = "9Router"
|
||||
description = "Routed via 9Router gateway"
|
||||
api_backend = "chat_completions"
|
||||
api_key = "${keyToUse}"
|
||||
`;
|
||||
|
||||
return [
|
||||
{ filename: "~/.grok/config.toml", content: tomlContent },
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="xs" className="overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image
|
||||
src={tool.image || "/providers/grok-cli.png"}
|
||||
alt={tool.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
|
||||
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
|
||||
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
|
||||
{checking && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
<span>Checking Grok Build...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && grokStatus && !grokStatus.installed && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-yellow-500">warning</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-yellow-600 dark:text-yellow-400">Grok Build not detected locally</p>
|
||||
<p className="text-sm text-text-muted mt-1">Install:</p>
|
||||
<code className="block mt-2 p-2 bg-black/20 rounded text-xs font-mono">curl -fsSL https://x.ai/cli/install.sh | bash</code>
|
||||
<p className="text-sm text-text-muted mt-2">Manual configuration is still available if 9router is deployed on a remote server.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 pl-0 sm:pl-9">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowManualConfigModal(true)}
|
||||
className="w-full sm:w-auto !bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
|
||||
Manual Config
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && grokStatus?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{tool.notes && tool.notes.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
{tool.notes.map((note, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex items-start gap-2 p-2 rounded text-xs ${
|
||||
note.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
|
||||
note.type === "error" ? "bg-red-500/10 text-red-600 dark:text-red-400" :
|
||||
"bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mt-0.5">
|
||||
{note.type === "warning" ? "warning" : note.type === "error" ? "error" : "info"}
|
||||
</span>
|
||||
<span>{note.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<BaseUrlSelect
|
||||
value={customBaseUrl || getEffectiveBaseUrl()}
|
||||
onChange={setCustomBaseUrl}
|
||||
requiresExternalUrl={tool.requiresExternalUrl}
|
||||
tunnelEnabled={tunnelEnabled}
|
||||
tunnelPublicUrl={tunnelPublicUrl}
|
||||
tailscaleEnabled={tailscaleEnabled}
|
||||
tailscaleUrl={tailscaleUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{grokStatus?.settings?.model?.base_url && (
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
|
||||
{grokStatus.settings.model.base_url}
|
||||
{grokStatus.settings.model.model ? ` · ${grokStatus.settings.model.model}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Default Model</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<div className="relative w-full min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
placeholder="provider/model-id"
|
||||
className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
|
||||
/>
|
||||
{selectedModel && (
|
||||
<button
|
||||
onClick={() => setSelectedModel("")}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors"
|
||||
title="Clear"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModalOpen(true)}
|
||||
disabled={!hasActiveProviders}
|
||||
className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${
|
||||
hasActiveProviders
|
||||
? "bg-surface border-border text-text-main hover:border-primary cursor-pointer"
|
||||
: "opacity-50 cursor-not-allowed border-border"
|
||||
}`}
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
|
||||
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
|
||||
<span>{message.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<Button variant="primary" size="sm" onClick={handleApply} disabled={!selectedModel} loading={applying} className="w-full sm:w-auto">
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={!grokStatus?.has9Router} loading={restoring} className="w-full sm:w-auto">
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)} className="w-full sm:w-auto">
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Grok Build"
|
||||
/>
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="Grok Build - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export { default as ClineToolCard } from "./ClineToolCard";
|
||||
export { default as KiloToolCard } from "./KiloToolCard";
|
||||
export { default as DeepSeekTuiToolCard } from "./DeepSeekTuiToolCard";
|
||||
export { default as JcodeToolCard } from "./JcodeToolCard";
|
||||
export { default as GrokBuildToolCard } from "./GrokBuildToolCard";
|
||||
export { default as MitmServerCard } from "./MitmServerCard";
|
||||
export { default as MitmToolCard } from "./MitmToolCard";
|
||||
export { default as MitmLinkCard } from "./MitmLinkCard";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Card, Button, Input, Modal, CardSkeleton, Toggle, ConfirmModal } from "@/shared/components";
|
||||
import { Card, Button, Input, Modal, CardSkeleton, Toggle, ConfirmModal, ApiExplorerModal } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import {
|
||||
TUNNEL_BENEFITS,
|
||||
@@ -21,6 +21,7 @@ export default function APIPageClient({ machineId }) {
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showApiExplorer, setShowApiExplorer] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [importKeyValue, setImportKeyValue] = useState("");
|
||||
const [importKeyName, setImportKeyName] = useState("");
|
||||
@@ -710,10 +711,20 @@ export default function APIPageClient({ machineId }) {
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Endpoint Card */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary">api</span>
|
||||
API Endpoint
|
||||
</h2>
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary">api</span>
|
||||
API Endpoint
|
||||
</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="science"
|
||||
onClick={() => setShowApiExplorer(true)}
|
||||
>
|
||||
API Explorer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Endpoint rows */}
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -1389,6 +1400,12 @@ export default function APIPageClient({ machineId }) {
|
||||
message={confirmState?.message}
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{/* API Explorer — list + test all public AI endpoints */}
|
||||
<ApiExplorerModal
|
||||
isOpen={showApiExplorer}
|
||||
onClose={() => setShowApiExplorer(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { planBulkAdd } from "@/shared/utils/bulkAdd";
|
||||
|
||||
const BULK_PLACEHOLDER = `name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named`;
|
||||
|
||||
export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, error, onSave, onBulkDone, onClose }) {
|
||||
export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, error, existingNames, onSave, onBulkDone, onClose }) {
|
||||
const NONE_PROXY_POOL_VALUE = "__none__";
|
||||
const isOllamaLocal = provider === "ollama-local";
|
||||
const isCookie = authType === "cookie";
|
||||
@@ -41,6 +42,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 }
|
||||
@@ -127,22 +132,30 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
||||
};
|
||||
|
||||
const handleBulkSubmit = async () => {
|
||||
const lines = bulkText.split("\n").map(l => l.trim()).filter(Boolean);
|
||||
const lines = bulkText.split("\n");
|
||||
if (!lines.length) return;
|
||||
// Plan collision-free names against existing connections so a generated
|
||||
// "Key N" never matches a saved name (which the backend would upsert /
|
||||
// overwrite instead of inserting). See bulkAdd.js for the full rationale.
|
||||
const plan = planBulkAdd(lines, existingNames, { isCloudflareAi });
|
||||
if (!plan.length) return;
|
||||
setSaving(true);
|
||||
setBulkResult(null);
|
||||
let success = 0;
|
||||
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}`;
|
||||
for (const entry of plan) {
|
||||
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: entry.apiKey,
|
||||
name: entry.name,
|
||||
priority: 1,
|
||||
testStatus: "unknown",
|
||||
...(entry.providerSpecificData ? { providerSpecificData: entry.providerSpecificData } : {}),
|
||||
}),
|
||||
});
|
||||
if (res.ok) success++;
|
||||
else failed++;
|
||||
@@ -168,10 +181,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)}
|
||||
/>
|
||||
@@ -383,6 +401,7 @@ AddApiKeyModal.propTypes = {
|
||||
name: PropTypes.string,
|
||||
})),
|
||||
error: PropTypes.string,
|
||||
existingNames: PropTypes.arrayOf(PropTypes.string),
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onBulkDone: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
@@ -153,11 +154,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;
|
||||
@@ -1307,6 +1335,7 @@ export default function ProviderDetailPage() {
|
||||
isCustom
|
||||
isFree={false}
|
||||
caps={getCaps(`${providerId}/${model.id}`)}
|
||||
thinkingSuffix={resolveThinkingSuffix(model.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1332,6 +1361,7 @@ export default function ProviderDetailPage() {
|
||||
isFree={model.isFree}
|
||||
onDisable={() => handleDisableModel(model.id)}
|
||||
caps={getCaps(`${providerId}/${model.id}`)}
|
||||
thinkingSuffix={resolveThinkingSuffix(model.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -1651,21 +1681,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>
|
||||
)} */}
|
||||
{/* Connect Timeout */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-text-muted font-medium">Connect Timeout</span>
|
||||
@@ -1851,9 +1866,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,
|
||||
@@ -1932,6 +1961,7 @@ export default function ProviderDetailPage() {
|
||||
website={providerInfo?.website}
|
||||
proxyPools={proxyPools}
|
||||
error={addConnectionError}
|
||||
existingNames={connections.map((c) => c.name).filter(Boolean)}
|
||||
onSave={handleSaveApiKey}
|
||||
onBulkDone={fetchConnections}
|
||||
onClose={() => {
|
||||
|
||||
283
src/app/(dashboard)/dashboard/pxpipe/PxpipeClient.js
Normal file
283
src/app/(dashboard)/dashboard/pxpipe/PxpipeClient.js
Normal file
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
|
||||
const fmtTokens = (n) => {
|
||||
if (n >= 1000000) return `${(n / 1000000).toFixed(2)}M`;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
||||
return String(n || 0);
|
||||
};
|
||||
|
||||
const fmtUptime = (ms) => {
|
||||
if (!ms || ms <= 0) return "—";
|
||||
const m = Math.floor(ms / 60000);
|
||||
const h = Math.floor(m / 60);
|
||||
return h > 0 ? `${h}h${String(m % 60).padStart(2, "0")}m` : `${m}m`;
|
||||
};
|
||||
|
||||
const WINDOW_TABS = [
|
||||
{ id: "today", label: "Today" },
|
||||
{ id: "yesterday", label: "Yesterday" },
|
||||
{ id: "last7d", label: "7 days" },
|
||||
{ id: "last30d", label: "30 days" },
|
||||
{ id: "all", label: "All time" },
|
||||
];
|
||||
|
||||
const REASON_LABELS = {
|
||||
applied: "Prompt exceeded threshold",
|
||||
below_threshold: "Below size threshold",
|
||||
not_profitable: "Compression not profitable",
|
||||
below_min_chars: "Below minimum chars",
|
||||
below_min_tokens: "Below minimum tokens",
|
||||
unsupported_model: "Model not in allowlist",
|
||||
unsupported_format: "Non-Claude request format",
|
||||
timeout: "Compression timed out",
|
||||
transform_error: "Transform error",
|
||||
passthrough: "Passthrough",
|
||||
disabled: "Disabled",
|
||||
not_installed: "Not installed",
|
||||
};
|
||||
|
||||
function SummaryCard({ label, value, sub, tone }) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<p className="text-xs text-text-muted uppercase tracking-wide">{label}</p>
|
||||
<p className={`text-xl font-semibold mt-1 ${tone || ""}`}>{value}</p>
|
||||
{sub && <p className="text-xs text-text-muted mt-0.5">{sub}</p>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PxpipeClient() {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [health, setHealth] = useState(null);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [logs, setLogs] = useState(null);
|
||||
const [windowId, setWindowId] = useState("last7d");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statusRes, statsRes, logsRes] = await Promise.all([
|
||||
fetch("/api/pxpipe/status", { headers: { "Cache-Control": "no-store" } }),
|
||||
fetch("/api/pxpipe/stats"),
|
||||
fetch("/api/pxpipe/logs?limit=50"),
|
||||
]);
|
||||
setStatus(await statusRes.json());
|
||||
setStats(await statsRes.json());
|
||||
setLogs(await logsRes.json());
|
||||
const healthRes = await fetch("/api/pxpipe/health", { method: "POST" });
|
||||
setHealth(await healthRes.json());
|
||||
} catch {
|
||||
/* sections render placeholders */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const w = stats?.windows?.[windowId];
|
||||
const statusLabel = !status
|
||||
? "—"
|
||||
: !status.installed
|
||||
? "Not installed"
|
||||
: health?.healthy
|
||||
? "Healthy"
|
||||
: status.running
|
||||
? "Running"
|
||||
: "Stopped";
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary">image</span>
|
||||
PXPIPE Dashboard
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<a href="/dashboard/token-saver" className="text-xs text-primary underline hover:opacity-80">
|
||||
Token Saver settings
|
||||
</a>
|
||||
<Button size="sm" variant="ghost" onClick={refresh} disabled={loading}>
|
||||
{loading ? "Refreshing…" : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<SummaryCard
|
||||
label="Status"
|
||||
value={statusLabel}
|
||||
tone={health?.healthy ? "text-success" : status?.installed ? "text-warning" : "text-text-muted"}
|
||||
sub={status?.enabled ? "Enabled in pipeline" : "Disabled in pipeline"}
|
||||
/>
|
||||
<SummaryCard label="Version" value={status?.version ? `v${status.version}` : "—"} sub="pxpipe-proxy" />
|
||||
<SummaryCard label="Uptime" value={fmtUptime(status?.uptimeMs)} sub="module loaded" />
|
||||
<SummaryCard label="Requests" value={w ? w.requests.toLocaleString() : "—"} />
|
||||
<SummaryCard label="Compressed" value={w ? w.compressed.toLocaleString() : "—"} tone="text-success" />
|
||||
<SummaryCard label="Bypassed" value={w ? w.bypassed.toLocaleString() : "—"} />
|
||||
</div>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3 mb-4">
|
||||
<h3 className="font-medium">Token savings (estimated)</h3>
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1">
|
||||
{WINDOW_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setWindowId(tab.id)}
|
||||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||
windowId === tab.id
|
||||
? "bg-primary text-white shadow-sm"
|
||||
: "text-text-muted hover:text-text hover:bg-bg-hover"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-xs text-text-muted">Original tokens</p>
|
||||
<p className="text-lg font-semibold">{w ? fmtTokens(w.tokensBeforeEst) : "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-muted">After PXPIPE</p>
|
||||
<p className="text-lg font-semibold">{w ? fmtTokens(w.tokensAfterEst) : "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-muted">Saved</p>
|
||||
<p className="text-lg font-semibold text-success">{w ? fmtTokens(w.tokensSavedEst) : "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-muted">Reduction</p>
|
||||
<p className="text-lg font-semibold text-success">{w ? `${w.savedPct}%` : "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-3">
|
||||
Estimates from body size before/after imaging; billed usage per request
|
||||
(recorded on the Usage page) remains the ground truth. Images generated:{" "}
|
||||
{w ? w.imagesGenerated.toLocaleString() : "—"} · avg compression time:{" "}
|
||||
{w ? `${w.avgCompressionMs}ms` : "—"} · errors: {w ? w.errors : "—"}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<h3 className="font-medium mb-3">Tokens saved — last 30 days</h3>
|
||||
{stats?.timeline?.some((d) => d.tokensSavedEst > 0) ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={stats.timeline} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradPxpipe" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.25} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.2} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} tickFormatter={(d) => d.slice(5)} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={fmtTokens} width={48} />
|
||||
<Tooltip formatter={(v) => [fmtTokens(v), "Tokens saved"]} labelFormatter={(d) => d} />
|
||||
<Area type="monotone" dataKey="tokensSavedEst" stroke="#10b981" fill="url(#gradPxpipe)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-32 flex items-center justify-center text-text-muted text-sm">
|
||||
No savings recorded yet — enable PXPIPE in the Token Saver and route a large Claude-format request.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<h3 className="font-medium mb-3">History</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-text-muted border-b border-border">
|
||||
<th className="py-2 pr-3">Time</th>
|
||||
<th className="py-2 pr-3">Model</th>
|
||||
<th className="py-2 pr-3 text-right">Original</th>
|
||||
<th className="py-2 pr-3 text-right">Compressed</th>
|
||||
<th className="py-2 pr-3 text-right">Saved</th>
|
||||
<th className="py-2 pr-3 text-right">%</th>
|
||||
<th className="py-2 pr-3 text-right">Duration</th>
|
||||
<th className="py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(stats?.recent || []).slice(0, 50).map((ev, i) => (
|
||||
<tr key={`${ev.ts}-${i}`} className="border-b border-border/50">
|
||||
<td className="py-1.5 pr-3 whitespace-nowrap text-text-muted">
|
||||
{new Date(ev.ts).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 font-mono text-xs">{ev.provider ? `${ev.provider}/${ev.model}` : ev.model || "—"}</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-xs">
|
||||
{ev.applied ? fmtTokens(ev.tokensBeforeEst) : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-xs">
|
||||
{ev.applied ? fmtTokens(ev.tokensAfterEst) : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-xs text-success">
|
||||
{ev.applied ? fmtTokens(ev.tokensSavedEst) : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-xs">
|
||||
{ev.applied ? `${ev.savedPct}%` : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-xs">
|
||||
{ev.durationMs != null ? `${ev.durationMs}ms` : "—"}
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded ${
|
||||
ev.applied
|
||||
? "bg-success/15 text-success"
|
||||
: ev.reason === "transform_error" || ev.reason === "timeout"
|
||||
? "bg-danger/15 text-danger"
|
||||
: "bg-warning/15 text-warning"
|
||||
}`}
|
||||
title={ev.detail || ""}
|
||||
>
|
||||
{ev.applied ? "Compressed" : REASON_LABELS[ev.reason] || ev.reason}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!stats?.recent || stats.recent.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-6 text-center text-text-muted text-sm">
|
||||
No PXPIPE activity yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4" id="logs">
|
||||
<h3 className="font-medium mb-3">PXPIPE Logs</h3>
|
||||
{logs?.installLog ? (
|
||||
<pre className="rounded bg-black/5 dark:bg-white/5 p-3 text-xs font-mono overflow-x-auto max-h-64 overflow-y-auto whitespace-pre-wrap">
|
||||
{logs.installLog}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No install log yet.</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/app/(dashboard)/dashboard/pxpipe/page.js
Normal file
5
src/app/(dashboard)/dashboard/pxpipe/page.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import PxpipeClient from "./PxpipeClient";
|
||||
|
||||
export default function PxpipePage() {
|
||||
return <PxpipeClient />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -89,6 +89,7 @@ export default function QuotaTable({
|
||||
compact = false,
|
||||
sortMode = "default",
|
||||
showSortLabel = false,
|
||||
onHideQuota = null,
|
||||
}) {
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
@@ -132,6 +133,7 @@ export default function QuotaTable({
|
||||
const resetPrimary = compact ? "text-[11px]" : "text-sm";
|
||||
const resetSecondary = compact ? "text-[10px] leading-tight" : "text-xs";
|
||||
const sortLabel = "Sorted by account remaining";
|
||||
const hasHideAction = typeof onHideQuota === "function";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -195,7 +197,7 @@ export default function QuotaTable({
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className={`${cellPad} w-[25%]`}>
|
||||
<td className={`${cellPad} ${hasHideAction ? "w-[20%]" : "w-[25%]"}`}>
|
||||
{countdown !== "-" || resetDisplay ? (
|
||||
compact ? (
|
||||
<div
|
||||
@@ -222,6 +224,22 @@ export default function QuotaTable({
|
||||
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{hasHideAction && (
|
||||
<td className={`${cellPad} w-[5%] text-right`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onHideQuota(quota)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-text-muted transition-colors hover:bg-black/5 hover:text-text-primary dark:hover:bg-white/5"
|
||||
title="Hide this quota row"
|
||||
aria-label={`Hide quota ${quota.name}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[15px]">
|
||||
visibility_off
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -8,6 +8,9 @@ import Tooltip from "@/shared/components/Tooltip";
|
||||
import {
|
||||
parseQuotaData,
|
||||
calculatePercentage,
|
||||
filterQuotasByVisibility,
|
||||
getHiddenQuotaRows,
|
||||
getQuotaVisibilityKey,
|
||||
getConnectionLabel,
|
||||
getConnectionQuotaRemaining,
|
||||
sortVisibleConnections,
|
||||
@@ -146,6 +149,7 @@ export default function ProviderLimits() {
|
||||
const [providerOptions, setProviderOptions] = useState([]);
|
||||
const [accountFilter, setAccountFilter] = useState("all");
|
||||
const [quotaSortMode, setQuotaSortMode] = useState("default");
|
||||
const [quotaVisibility, setQuotaVisibility] = useState({});
|
||||
const [expiringFirst, setExpiringFirst] = useState(false);
|
||||
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
|
||||
const [bulkToggling, setBulkToggling] = useState(false);
|
||||
@@ -536,10 +540,13 @@ export default function ProviderLimits() {
|
||||
useEffect(() => {
|
||||
fetch("/api/settings", { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : {}))
|
||||
.then((s) => setAutoPingMaps({
|
||||
claude: s?.claudeAutoPing?.connections || {},
|
||||
codex: s?.codexAutoPing?.connections || {},
|
||||
}))
|
||||
.then((s) => {
|
||||
setAutoPingMaps({
|
||||
claude: s?.claudeAutoPing?.connections || {},
|
||||
codex: s?.codexAutoPing?.connections || {},
|
||||
});
|
||||
setQuotaVisibility(s?.quotaVisibility || {});
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -565,6 +572,57 @@ export default function ProviderLimits() {
|
||||
}
|
||||
}, [autoPingMaps]);
|
||||
|
||||
const updateQuotaVisibility = useCallback(async (nextVisibility, previousVisibility) => {
|
||||
setQuotaVisibility(nextVisibility);
|
||||
try {
|
||||
const response = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ quotaVisibility: nextVisibility }),
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to update quota visibility");
|
||||
} catch (error) {
|
||||
console.error("Error updating quota visibility:", error);
|
||||
setQuotaVisibility(previousVisibility);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleHideQuota = useCallback((provider, quota) => {
|
||||
const key = getQuotaVisibilityKey(quota);
|
||||
if (!provider || !key) return;
|
||||
|
||||
const previous = quotaVisibility;
|
||||
const providerVisibility = previous[provider] || {};
|
||||
const hidden = new Set(providerVisibility.hidden || []);
|
||||
hidden.add(key);
|
||||
const next = {
|
||||
...previous,
|
||||
[provider]: {
|
||||
...providerVisibility,
|
||||
hidden: [...hidden],
|
||||
},
|
||||
};
|
||||
updateQuotaVisibility(next, previous);
|
||||
}, [quotaVisibility, updateQuotaVisibility]);
|
||||
|
||||
const handleShowQuota = useCallback((provider, quota) => {
|
||||
const key = getQuotaVisibilityKey(quota);
|
||||
if (!provider || !key) return;
|
||||
|
||||
const previous = quotaVisibility;
|
||||
const providerVisibility = previous[provider] || {};
|
||||
const hidden = new Set(providerVisibility.hidden || []);
|
||||
hidden.delete(key);
|
||||
const next = {
|
||||
...previous,
|
||||
[provider]: {
|
||||
...providerVisibility,
|
||||
hidden: [...hidden],
|
||||
},
|
||||
};
|
||||
updateQuotaVisibility(next, previous);
|
||||
}, [quotaVisibility, updateQuotaVisibility]);
|
||||
|
||||
// Auto-refresh interval
|
||||
useEffect(() => {
|
||||
if (!hasHydratedAutoRefresh || !autoRefresh) {
|
||||
@@ -973,6 +1031,9 @@ export default function ProviderLimits() {
|
||||
const resetCreditCount = getCodexResetCreditCount(quota);
|
||||
const isResettingLimit = resettingLimitId === conn.id;
|
||||
const rowBusy = deletingId === conn.id || togglingId === conn.id || isResettingLimit;
|
||||
const rawQuotas = quota?.quotas || [];
|
||||
const visibleQuotas = filterQuotasByVisibility(conn.provider, rawQuotas, quotaVisibility);
|
||||
const hiddenQuotaRows = getHiddenQuotaRows(conn.provider, rawQuotas, quotaVisibility);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -1194,14 +1255,34 @@ export default function ProviderLimits() {
|
||||
</div>
|
||||
) : (
|
||||
<QuotaTable
|
||||
quotas={quota?.quotas}
|
||||
quotas={visibleQuotas}
|
||||
compact
|
||||
sortMode="default"
|
||||
showSortLabel={
|
||||
conn.provider === "codex" && quotaSortMode !== "default"
|
||||
}
|
||||
onHideQuota={(quotaRow) => handleHideQuota(conn.provider, quotaRow)}
|
||||
/>
|
||||
)}
|
||||
{hiddenQuotaRows.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-black/5 pt-2 text-[10px] text-text-muted dark:border-white/5">
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
visibility_off
|
||||
</span>
|
||||
<span>Hidden:</span>
|
||||
{hiddenQuotaRows.map((quotaRow) => (
|
||||
<button
|
||||
key={getQuotaVisibilityKey(quotaRow)}
|
||||
type="button"
|
||||
onClick={() => handleShowQuota(conn.provider, quotaRow)}
|
||||
className="rounded-md border border-black/10 px-1.5 py-0.5 transition-colors hover:bg-black/5 hover:text-text-primary dark:border-white/10 dark:hover:bg-white/5"
|
||||
title="Show this quota row"
|
||||
>
|
||||
{quotaRow.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -300,6 +300,30 @@ export function getRemainingPercentage(quota) {
|
||||
return calculatePercentage(quota?.used, quota?.total);
|
||||
}
|
||||
|
||||
export function getQuotaVisibilityKey(quota) {
|
||||
if (!quota || typeof quota !== "object") return "";
|
||||
return String(quota.modelKey || quota.name || "").trim();
|
||||
}
|
||||
|
||||
function getProviderHiddenQuotaSet(provider, quotaVisibility) {
|
||||
const hidden = quotaVisibility?.[provider]?.hidden;
|
||||
return new Set(Array.isArray(hidden) ? hidden.map(String) : []);
|
||||
}
|
||||
|
||||
export function filterQuotasByVisibility(provider, quotas = [], quotaVisibility = {}) {
|
||||
if (!Array.isArray(quotas) || quotas.length === 0) return [];
|
||||
const hidden = getProviderHiddenQuotaSet(provider, quotaVisibility);
|
||||
if (hidden.size === 0) return quotas;
|
||||
return quotas.filter((quota) => !hidden.has(getQuotaVisibilityKey(quota)));
|
||||
}
|
||||
|
||||
export function getHiddenQuotaRows(provider, quotas = [], quotaVisibility = {}) {
|
||||
if (!Array.isArray(quotas) || quotas.length === 0) return [];
|
||||
const hidden = getProviderHiddenQuotaSet(provider, quotaVisibility);
|
||||
if (hidden.size === 0) return [];
|
||||
return quotas.filter((quota) => hidden.has(getQuotaVisibilityKey(quota)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse provider-specific quota structures into normalized array
|
||||
* @param {string} provider - Provider name (github, antigravity, codex, kiro, claude)
|
||||
@@ -493,6 +517,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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -13,6 +13,7 @@ import { GET as clineGet } from "../cline-settings/route";
|
||||
import { GET as kiloGet } from "../kilo-settings/route";
|
||||
import { GET as deepseekTuiGet } from "../deepseek-tui-settings/route";
|
||||
import { GET as jcodeGet } from "../jcode-settings/route";
|
||||
import { GET as grokBuildGet } from "../grok-build-settings/route";
|
||||
|
||||
const STATUS_GETTERS = {
|
||||
claude: claudeGet,
|
||||
@@ -27,6 +28,7 @@ const STATUS_GETTERS = {
|
||||
kilo: kiloGet,
|
||||
"deepseek-tui": deepseekTuiGet,
|
||||
jcode: jcodeGet,
|
||||
"grok-build": grokBuildGet,
|
||||
};
|
||||
|
||||
// Batch endpoint: gather all CLI tool statuses in one round-trip
|
||||
|
||||
242
src/app/api/cli-tools/grok-build-settings/route.js
Normal file
242
src/app/api/cli-tools/grok-build-settings/route.js
Normal file
@@ -0,0 +1,242 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const PROVIDER_NAME = "9router";
|
||||
const MODEL_SLOT = "9router";
|
||||
const BUILTIN_DEFAULT = "grok-build";
|
||||
|
||||
// [model.9router] ... until next [section] header or EOF
|
||||
const MODEL_SECTION_RE = new RegExp(
|
||||
`^\\[model\\.${MODEL_SLOT}\\][ \\t]*\\r?\\n(?:(?!\\[)[^\\r\\n]*\\r?\\n?)*`,
|
||||
"m"
|
||||
);
|
||||
|
||||
const MODELS_SECTION_RE = /^\[models\][ \t]*\r?\n((?:(?!\[)[^\r\n]*\r?\n?)*)/m;
|
||||
|
||||
// Marker written on Apply so Reset can restore the previous [models].default
|
||||
const PREV_DEFAULT_RE = /^# 9router-prev-default = "([^"]*)"[ \t]*\r?\n?/m;
|
||||
|
||||
const getGrokDir = () => path.join(os.homedir(), ".grok");
|
||||
const getGrokConfigPath = () => path.join(getGrokDir(), "config.toml");
|
||||
const getGrokBinPath = () => path.join(getGrokDir(), "bin", "grok");
|
||||
|
||||
const checkGrokInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where grok" : "which grok";
|
||||
await execAsync(command, { windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
await fs.access(getGrokBinPath());
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
await fs.access(getGrokConfigPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const readConfigToml = async () => {
|
||||
try {
|
||||
return await fs.readFile(getGrokConfigPath(), "utf-8");
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return "";
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getTomlField = (body, key) => {
|
||||
const m = body.match(new RegExp(`^[ \\t]*${key}[ \\t]*=[ \\t]*"([^"]*)"`, "m"));
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
|
||||
const parseModelSection = (toml) => {
|
||||
const match = toml.match(MODEL_SECTION_RE);
|
||||
if (!match) return null;
|
||||
const body = match[0].replace(/^\[model\.[^\]]+\][ \t]*\r?\n/, "");
|
||||
return {
|
||||
model: getTomlField(body, "model"),
|
||||
base_url: getTomlField(body, "base_url"),
|
||||
name: getTomlField(body, "name"),
|
||||
api_key: getTomlField(body, "api_key"),
|
||||
api_backend: getTomlField(body, "api_backend"),
|
||||
};
|
||||
};
|
||||
|
||||
const parseModelsDefault = (toml) => {
|
||||
const match = toml.match(MODELS_SECTION_RE);
|
||||
if (!match) return null;
|
||||
return getTomlField(match[1] || "", "default");
|
||||
};
|
||||
|
||||
const buildModelSection = (model, baseUrl, apiKey) => {
|
||||
const lines = [
|
||||
`[model.${MODEL_SLOT}]`,
|
||||
`model = "${model}"`,
|
||||
`base_url = "${baseUrl}"`,
|
||||
`name = "9Router"`,
|
||||
`description = "Routed via 9Router gateway"`,
|
||||
`api_backend = "chat_completions"`,
|
||||
];
|
||||
if (apiKey) lines.push(`api_key = "${apiKey}"`);
|
||||
return `${lines.join("\n")}\n`;
|
||||
};
|
||||
|
||||
const upsertModelSection = (toml, section) => {
|
||||
if (MODEL_SECTION_RE.test(toml)) return toml.replace(MODEL_SECTION_RE, section);
|
||||
const needsNl = toml.length > 0 && !toml.endsWith("\n");
|
||||
return `${toml}${needsNl ? "\n" : ""}\n${section}`;
|
||||
};
|
||||
|
||||
const removeModelSection = (toml) =>
|
||||
toml.replace(MODEL_SECTION_RE, "").replace(/\n{3,}/g, "\n\n");
|
||||
|
||||
// Set or insert default = "..." inside existing [models], or create the section
|
||||
const setModelsDefault = (toml, value) => {
|
||||
const match = toml.match(MODELS_SECTION_RE);
|
||||
if (match) {
|
||||
const body = match[1] || "";
|
||||
let newBody;
|
||||
if (/^[ \t]*default[ \t]*=/m.test(body)) {
|
||||
newBody = body.replace(/^[ \t]*default[ \t]*=[ \t]*"[^"]*"/m, `default = "${value}"`);
|
||||
} else {
|
||||
newBody = `default = "${value}"\n${body}`;
|
||||
}
|
||||
return toml.replace(match[0], `[models]\n${newBody}`);
|
||||
}
|
||||
const block = `[models]\ndefault = "${value}"\n\n`;
|
||||
return toml.length > 0 ? block + toml : block;
|
||||
};
|
||||
|
||||
// Remember the previous default once (so re-Apply does not overwrite it with "9router")
|
||||
const rememberPrevDefault = (toml) => {
|
||||
if (PREV_DEFAULT_RE.test(toml)) return toml;
|
||||
const current = parseModelsDefault(toml);
|
||||
if (!current || current === MODEL_SLOT) return toml;
|
||||
const marker = `# 9router-prev-default = "${current}"\n`;
|
||||
// Prefer placing the marker just above [model.9router] if present, else at EOF
|
||||
if (MODEL_SECTION_RE.test(toml)) {
|
||||
return toml.replace(MODEL_SECTION_RE, (section) => marker + section);
|
||||
}
|
||||
const needsNl = toml.length > 0 && !toml.endsWith("\n");
|
||||
return `${toml}${needsNl ? "\n" : ""}${marker}`;
|
||||
};
|
||||
|
||||
// If default points at our slot, restore previous (or built-in) default and drop marker
|
||||
const clearModelsDefaultIfOurs = (toml) => {
|
||||
const prevMatch = toml.match(PREV_DEFAULT_RE);
|
||||
const restoreTo = prevMatch?.[1] || BUILTIN_DEFAULT;
|
||||
let next = toml.replace(PREV_DEFAULT_RE, "");
|
||||
const current = parseModelsDefault(next);
|
||||
if (current === MODEL_SLOT) {
|
||||
next = setModelsDefault(next, restoreTo);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const has9RouterConfig = (modelCfg) => {
|
||||
if (!modelCfg?.base_url) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const installed = await checkGrokInstalled();
|
||||
if (!installed) {
|
||||
return NextResponse.json({
|
||||
installed: false,
|
||||
settings: null,
|
||||
message: "Grok Build is not installed",
|
||||
});
|
||||
}
|
||||
|
||||
const toml = await readConfigToml();
|
||||
const model = parseModelSection(toml);
|
||||
const defaultModel = parseModelsDefault(toml);
|
||||
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
settings: {
|
||||
model,
|
||||
default: defaultModel,
|
||||
},
|
||||
has9Router: has9RouterConfig(model),
|
||||
configPath: getGrokConfigPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking grok-build settings:", error);
|
||||
return NextResponse.json({ error: "Failed to check grok-build settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, model } = await request.json();
|
||||
if (!baseUrl || !model) {
|
||||
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const dir = getGrokDir();
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
const keyToWrite = apiKey || "sk_9router";
|
||||
|
||||
let toml = await readConfigToml();
|
||||
toml = rememberPrevDefault(toml);
|
||||
toml = upsertModelSection(toml, buildModelSection(model, normalizedBaseUrl, keyToWrite));
|
||||
toml = setModelsDefault(toml, MODEL_SLOT);
|
||||
|
||||
await fs.writeFile(getGrokConfigPath(), toml);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Grok Build settings applied successfully!",
|
||||
configPath: getGrokConfigPath(),
|
||||
modelSlot: MODEL_SLOT,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating grok-build settings:", error);
|
||||
return NextResponse.json({ error: "Failed to update grok-build settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const configPath = getGrokConfigPath();
|
||||
let toml = "";
|
||||
try {
|
||||
toml = await fs.readFile(configPath, "utf-8");
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return NextResponse.json({ success: true, message: "No config file to reset" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
toml = removeModelSection(toml);
|
||||
toml = clearModelsDefaultIfOurs(toml);
|
||||
await fs.writeFile(configPath, toml);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `${PROVIDER_NAME} model slot removed from Grok Build`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error resetting grok-build settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset grok-build settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
46
src/app/api/headroom/extras/route.js
Normal file
46
src/app/api/headroom/extras/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
104
src/app/api/headroom/proxy/[...path]/route.js
Normal file
104
src/app/api/headroom/proxy/[...path]/route.js
Normal 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;
|
||||
35
src/app/api/headroom/restart/route.js
Normal file
35
src/app/api/headroom/restart/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -8,6 +8,8 @@ import { getModelsByProviderId } from "open-sse/config/providerModels.js";
|
||||
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
|
||||
import { resolveKimchiModels } from "open-sse/services/kimchiModels.js";
|
||||
import { resolveQoderModels } from "open-sse/services/qoderModels.js";
|
||||
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
|
||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||
|
||||
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
|
||||
|
||||
@@ -236,6 +238,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"),
|
||||
@@ -368,6 +371,35 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
errorLabel: "Failed to fetch Gemini CLI models"
|
||||
})
|
||||
},
|
||||
"grok-cli": {
|
||||
customResolver: async (connection) => {
|
||||
const proxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
|
||||
const result = await resolveGrokCliModels({
|
||||
...connection,
|
||||
connectionId: connection.id,
|
||||
}, {
|
||||
log: console,
|
||||
proxyOptions: {
|
||||
connectionProxyEnabled: proxy.connectionProxyEnabled === true,
|
||||
connectionProxyUrl: proxy.connectionProxyUrl || "",
|
||||
connectionNoProxy: proxy.connectionNoProxy || "",
|
||||
vercelRelayUrl: proxy.vercelRelayUrl || "",
|
||||
strictProxy: proxy.strictProxy === true,
|
||||
},
|
||||
onCredentialsRefreshed: async (refreshed) => {
|
||||
await updateProviderCredentials(connection.id, {
|
||||
...refreshed,
|
||||
existingProviderSpecificData: connection.providerSpecificData || {},
|
||||
});
|
||||
},
|
||||
});
|
||||
if (result.models.length) return result;
|
||||
return {
|
||||
models: getStaticProviderModels("grok-cli"),
|
||||
warning: result.warning || "Grok CLI returned no live models; using static catalog.",
|
||||
};
|
||||
},
|
||||
},
|
||||
"ollama-local": {
|
||||
customResolver: async (connection) => {
|
||||
const url = `${resolveOllamaLocalHost(connection)}/api/tags`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
16
src/app/api/pxpipe/health/route.js
Normal file
16
src/app/api/pxpipe/health/route.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runHealthCheck } from "@/lib/pxpipe/service.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const result = await runHealthCheck();
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ healthy: false, checks: [], error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// GET mirrors POST so the card can probe on page load without a mutation call.
|
||||
export const GET = POST;
|
||||
20
src/app/api/pxpipe/install/route.js
Normal file
20
src/app/api/pxpipe/install/route.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { installPxpipe } from "@/lib/pxpipe/install.js";
|
||||
import { unloadPxpipe } from "@/lib/pxpipe/loader.js";
|
||||
import { runHealthCheck } from "@/lib/pxpipe/service.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
// npm install can legitimately take minutes on a cold cache.
|
||||
export const maxDuration = 300;
|
||||
|
||||
// Install (or repair — same operation, reinstalls @latest) then re-run the health check.
|
||||
export async function POST() {
|
||||
try {
|
||||
const info = await installPxpipe();
|
||||
unloadPxpipe(); // drop any previously-loaded version so health loads the fresh one
|
||||
const health = await runHealthCheck();
|
||||
return NextResponse.json({ ...info, health });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message, code: error.code || null }, { status: 500 });
|
||||
}
|
||||
}
|
||||
18
src/app/api/pxpipe/logs/route.js
Normal file
18
src/app/api/pxpipe/logs/route.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getInstallLogTail } from "@/lib/pxpipe/install.js";
|
||||
import { readPxpipeEvents } from "@/lib/pxpipe/events.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(Number(searchParams.get("limit")) || 100, 500);
|
||||
return NextResponse.json({
|
||||
installLog: getInstallLogTail(),
|
||||
events: readPxpipeEvents({ limit }).reverse(),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
src/app/api/pxpipe/restart/route.js
Normal file
16
src/app/api/pxpipe/restart/route.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { unloadPxpipe, loadPxpipe } from "@/lib/pxpipe/loader.js";
|
||||
import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Reload the in-process module (picks up an upgraded install without a server restart).
|
||||
export async function POST() {
|
||||
try {
|
||||
unloadPxpipe();
|
||||
await loadPxpipe();
|
||||
return NextResponse.json(getPxpipeStatus());
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message, code: error.code || null }, { status: 500 });
|
||||
}
|
||||
}
|
||||
26
src/app/api/pxpipe/start/route.js
Normal file
26
src/app/api/pxpipe/start/route.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { getInstallInfo, installPxpipe } from "@/lib/pxpipe/install.js";
|
||||
import { loadPxpipe } from "@/lib/pxpipe/loader.js";
|
||||
import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 300;
|
||||
|
||||
// "Start" in library mode = warm the in-process transform module.
|
||||
// Auto-installs first when the package is missing and pxpipeAutoInstall is on.
|
||||
export async function POST() {
|
||||
try {
|
||||
if (!getInstallInfo().installed) {
|
||||
const settings = await getSettings();
|
||||
if (!settings.pxpipeAutoInstall) {
|
||||
return NextResponse.json({ error: "PXPIPE is not installed", code: "NOT_INSTALLED" }, { status: 409 });
|
||||
}
|
||||
await installPxpipe();
|
||||
}
|
||||
await loadPxpipe();
|
||||
return NextResponse.json(getPxpipeStatus());
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message, code: error.code || null }, { status: 500 });
|
||||
}
|
||||
}
|
||||
14
src/app/api/pxpipe/stats/route.js
Normal file
14
src/app/api/pxpipe/stats/route.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPxpipeStats } from "@/lib/pxpipe/events.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const recentLimit = Math.min(Number(searchParams.get("limit")) || 100, 500);
|
||||
return NextResponse.json(getPxpipeStats({ recentLimit }));
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
21
src/app/api/pxpipe/status/route.js
Normal file
21
src/app/api/pxpipe/status/route.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const status = getPxpipeStatus();
|
||||
return NextResponse.json({
|
||||
...status,
|
||||
enabled: !!settings.pxpipeEnabled,
|
||||
autoInstall: !!settings.pxpipeAutoInstall,
|
||||
minChars: settings.pxpipeMinChars,
|
||||
timeoutMs: settings.pxpipeTimeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
src/app/api/pxpipe/stop/route.js
Normal file
16
src/app/api/pxpipe/stop/route.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { unloadPxpipe } from "@/lib/pxpipe/loader.js";
|
||||
import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// "Stop" in library mode = drop the in-process module; requests fail open to
|
||||
// uncompressed passthrough until it is started again.
|
||||
export async function POST() {
|
||||
try {
|
||||
const wasLoaded = unloadPxpipe();
|
||||
return NextResponse.json({ stopped: wasLoaded, ...getPxpipeStatus() });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
|
||||
import { resetComboRotation } from "open-sse/services/combo.js";
|
||||
import { runQuotaAutoPingTick } from "@/shared/services/quotaAutoPing";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -101,10 +100,12 @@ export async function PATCH(request) {
|
||||
Object.prototype.hasOwnProperty.call(body, "claudeAutoPing") ||
|
||||
Object.prototype.hasOwnProperty.call(body, "codexAutoPing")
|
||||
) {
|
||||
// Run once immediately after opt-in changes so users don't wait for the next scheduler tick.
|
||||
runQuotaAutoPingTick().catch((error) => {
|
||||
console.warn("[AutoPing] settings-triggered tick failed:", error.message);
|
||||
});
|
||||
// Keep the scheduler absent when no account opted in; load its provider graph only on demand.
|
||||
import("@/shared/services/quotaAutoPing")
|
||||
.then(({ configureQuotaAutoPing }) => {
|
||||
configureQuotaAutoPing(settings);
|
||||
})
|
||||
.catch((error) => console.warn("[AutoPing] settings update failed:", error.message));
|
||||
}
|
||||
|
||||
const { password, oidcClientSecret, ...safeSettings } = settings;
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { disableTunnel } from "@/lib/tunnel";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { configureTunnelMonitoring } from "@/shared/services/initializeApp";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const result = await disableTunnel();
|
||||
getSettings()
|
||||
.then(configureTunnelMonitoring)
|
||||
.catch((error) => console.warn("Tunnel monitor update failed:", error.message));
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error("Tunnel disable error:", error);
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { enableTunnel } from "@/lib/tunnel";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { configureTunnelMonitoring } from "@/shared/services/initializeApp";
|
||||
|
||||
const DNS_WARMUP_DELAY_MS = 8000;
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const result = await enableTunnel();
|
||||
getSettings()
|
||||
.then(configureTunnelMonitoring)
|
||||
.catch((error) => console.warn("Tunnel monitor start failed:", error.message));
|
||||
// Wait for DNS warmup to propagate at Cloudflare edge after tunnel registered
|
||||
await new Promise((r) => setTimeout(r, DNS_WARMUP_DELAY_MS));
|
||||
return NextResponse.json(result);
|
||||
|
||||
@@ -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,9 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { disableTailscale } from "@/lib/tunnel";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { configureTunnelMonitoring } from "@/shared/services/initializeApp";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const result = await disableTailscale();
|
||||
getSettings()
|
||||
.then(configureTunnelMonitoring)
|
||||
.catch((error) => console.warn("Tailscale monitor update failed:", error.message));
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error("Tailscale disable error:", error);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { enableTailscale } from "@/lib/tunnel";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { configureTunnelMonitoring } from "@/shared/services/initializeApp";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const result = await enableTailscale();
|
||||
getSettings()
|
||||
.then(configureTunnelMonitoring)
|
||||
.catch((error) => console.warn("Tailscale monitor start failed:", error.message));
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error("Tailscale enable error:", error.message);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,8 +12,10 @@ import { resolveKimchiModels } from "open-sse/services/kimchiModels.js";
|
||||
import { resolveQoderModels } from "open-sse/services/qoderModels.js";
|
||||
import { resolveCopilotModels } from "open-sse/services/copilotModels.js";
|
||||
import { resolveClinepassModels } from "open-sse/services/clinepassModels.js";
|
||||
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
|
||||
import { updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
||||
import { capabilitiesFromServiceKind } from "open-sse/providers/capabilities.js";
|
||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||
import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
||||
|
||||
// Per-provider live model resolvers. Each receives a connection record and
|
||||
// returns { models: [{ id, name? }, ...] } | null on failure.
|
||||
@@ -71,7 +73,30 @@ const LIVE_MODEL_RESOLVERS = {
|
||||
apiKey: conn.apiKey,
|
||||
});
|
||||
return result?.models?.length ? { models: result.models } : null;
|
||||
}
|
||||
},
|
||||
"grok-cli": async (conn) => {
|
||||
const proxy = await resolveConnectionProxyConfig(conn.providerSpecificData || {});
|
||||
const result = await resolveGrokCliModels({
|
||||
...conn,
|
||||
connectionId: conn.id,
|
||||
}, {
|
||||
log: console,
|
||||
proxyOptions: {
|
||||
connectionProxyEnabled: proxy.connectionProxyEnabled === true,
|
||||
connectionProxyUrl: proxy.connectionProxyUrl || "",
|
||||
connectionNoProxy: proxy.connectionNoProxy || "",
|
||||
vercelRelayUrl: proxy.vercelRelayUrl || "",
|
||||
strictProxy: proxy.strictProxy === true,
|
||||
},
|
||||
onCredentialsRefreshed: async (refreshed) => {
|
||||
await updateProviderCredentials(conn.id, {
|
||||
...refreshed,
|
||||
existingProviderSpecificData: conn.providerSpecificData || {},
|
||||
});
|
||||
},
|
||||
});
|
||||
return result?.models?.length ? { models: result.models } : null;
|
||||
},
|
||||
};
|
||||
|
||||
const parseOpenAIStyleModels = (data) => {
|
||||
@@ -79,8 +104,9 @@ const parseOpenAIStyleModels = (data) => {
|
||||
return data?.data || data?.models || data?.results || [];
|
||||
};
|
||||
|
||||
// Matches provider IDs that are upstream/cross-instance connections (contain a UUID suffix)
|
||||
const UPSTREAM_CONNECTION_RE = /[-_][0-9a-f]{8,}$/i;
|
||||
// Header sent by fetchCompatibleModelIds to detect cross-instance /models fetches
|
||||
// and break recursive loops between 9router instances connected to each other.
|
||||
const INTERNAL_MODELS_FETCH_HEADER = "x-9r-internal-models-fetch";
|
||||
|
||||
// LLM kind sentinel — combos/models with no explicit kind default to LLM
|
||||
const LLM_KIND = "llm";
|
||||
@@ -93,6 +119,7 @@ const MODEL_TYPE_TO_KIND = {
|
||||
embedding: "embedding",
|
||||
stt: "stt",
|
||||
imageToText: "imageToText",
|
||||
video: "video",
|
||||
};
|
||||
|
||||
function modelKind(model) {
|
||||
@@ -145,7 +172,7 @@ async function fetchCompatibleModelIds(connection) {
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
headers: { ...headers, [INTERNAL_MODELS_FETCH_HEADER]: "1" },
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -189,7 +216,11 @@ function comboMatchesKinds(combo, kindFilter) {
|
||||
* Build OpenAI-format models list filtered by service kinds.
|
||||
* @param {string[]} kindFilter - List of service kinds to include (e.g. ["llm"], ["webSearch","webFetch"]).
|
||||
*/
|
||||
export async function buildModelsList(kindFilter) {
|
||||
export async function buildModelsList(kindFilter, options = {}) {
|
||||
// When this header is present, the /v1/models request came from another
|
||||
// 9router instance's fetchCompatibleModelIds — skip dynamic fetch to break
|
||||
// cross-instance recursive loops.
|
||||
const skipDynamicFetch = options.skipDynamicFetch === true;
|
||||
let connections = [];
|
||||
try {
|
||||
connections = await getProviderConnections();
|
||||
@@ -319,7 +350,7 @@ export async function buildModelsList(kindFilter) {
|
||||
)
|
||||
: providerModels.map((model) => model.id);
|
||||
|
||||
if (isCompatibleProvider && rawModelIds.length === 0 && !UPSTREAM_CONNECTION_RE.test(providerId)) {
|
||||
if (isCompatibleProvider && rawModelIds.length === 0 && !skipDynamicFetch) {
|
||||
rawModelIds = await fetchCompatibleModelIds(conn);
|
||||
}
|
||||
|
||||
@@ -421,7 +452,13 @@ export async function buildModelsList(kindFilter) {
|
||||
object: "model",
|
||||
owned_by: outputAlias,
|
||||
};
|
||||
const caps = liveCapabilitiesById.get(modelId) || capabilitiesFromServiceKind(customKind || liveKind);
|
||||
// Live-catalog resolvers (kiro/qoder/github/clinepass) mostly only return
|
||||
// { id, name } — no per-model capability data. Fall back to the same
|
||||
// pattern-matched capabilities the dashboard uses (useModelCaps.js) so
|
||||
// dynamically-discovered LLM models still surface vision/reasoning/search/tools.
|
||||
const caps = liveCapabilitiesById.get(modelId)
|
||||
|| capabilitiesFromServiceKind(customKind || liveKind)
|
||||
|| (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null);
|
||||
if (caps) model.capabilities = caps;
|
||||
models.push(model);
|
||||
}
|
||||
@@ -475,9 +512,11 @@ export async function OPTIONS() {
|
||||
* GET /v1/models - OpenAI compatible models list (LLM/chat models only by default).
|
||||
* For other capabilities use /v1/models/{kind} (image, tts, stt, embedding, image-to-text, web).
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const data = await buildModelsList([LLM_KIND]);
|
||||
// Detect cross-instance recursive /models fetch (another 9router fetching our /models)
|
||||
const skipDynamicFetch = request?.headers?.get(INTERNAL_MODELS_FETCH_HEADER) === "1";
|
||||
const data = await buildModelsList([LLM_KIND], { skipDynamicFetch });
|
||||
return Response.json({ object: "list", data }, {
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
|
||||
17
src/app/api/v1/videos/[id]/route.js
Normal file
17
src/app/api/v1/videos/[id]/route.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { handleVideoGet } from "@/sse/handlers/videoGeneration.js";
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /v1/videos/{request_id} - poll async video job status (xAI Grok Imagine) */
|
||||
export async function GET(request, { params }) {
|
||||
const { id } = await params;
|
||||
return await handleVideoGet(request, id);
|
||||
}
|
||||
16
src/app/api/v1/videos/edits/route.js
Normal file
16
src/app/api/v1/videos/edits/route.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { handleVideoCreate } from "@/sse/handlers/videoGeneration.js";
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /v1/videos/edits - async video edit (xAI Grok Imagine) */
|
||||
export async function POST(request) {
|
||||
return await handleVideoCreate(request, "edits");
|
||||
}
|
||||
16
src/app/api/v1/videos/extensions/route.js
Normal file
16
src/app/api/v1/videos/extensions/route.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { handleVideoCreate } from "@/sse/handlers/videoGeneration.js";
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /v1/videos/extensions - async video extension (xAI Grok Imagine) */
|
||||
export async function POST(request) {
|
||||
return await handleVideoCreate(request, "extensions");
|
||||
}
|
||||
16
src/app/api/v1/videos/generations/route.js
Normal file
16
src/app/api/v1/videos/generations/route.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { handleVideoCreate } from "@/sse/handlers/videoGeneration.js";
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /v1/videos/generations - async video generation (xAI Grok Imagine) */
|
||||
export async function POST(request) {
|
||||
return await handleVideoCreate(request, "generations");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 *));
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -64,7 +64,7 @@ export {
|
||||
|
||||
// Request details
|
||||
export {
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById,
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
||||
} from "./repos/requestDetailsRepo.js";
|
||||
|
||||
// Export/import full DB
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -15,6 +15,7 @@ const DEFAULT_SETTINGS = {
|
||||
providerStrategies: {},
|
||||
providerTimeouts: {},
|
||||
defaultTimeoutMs: null,
|
||||
quotaVisibility: {},
|
||||
comboStrategy: "fallback",
|
||||
comboStickyRoundRobinLimit: 1,
|
||||
comboStrategies: {},
|
||||
@@ -44,6 +45,10 @@ const DEFAULT_SETTINGS = {
|
||||
cavemanLevel: "full",
|
||||
ponytailEnabled: false,
|
||||
ponytailLevel: "full",
|
||||
pxpipeEnabled: false,
|
||||
pxpipeAutoInstall: true,
|
||||
pxpipeMinChars: 25000,
|
||||
pxpipeTimeoutMs: 15000,
|
||||
};
|
||||
|
||||
async function readRaw() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ""; }
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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,132 @@ 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;
|
||||
|
||||
const expiresAt = tokens.expires_in
|
||||
? new Date(Date.now() + tokens.expires_in * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || null,
|
||||
expiresIn: tokens.expires_in,
|
||||
// Surface an absolute expiry so the proactive refresh path
|
||||
// (shouldRefreshCredentials / checkAndRefreshToken) can refresh the
|
||||
// xAI token before it silently expires ~40-45 min after login.
|
||||
// Without this, only the reactive 401 path in chatCore would refresh,
|
||||
// causing intermittent "token expired" failures for Grok CLI.
|
||||
expiresAt,
|
||||
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 +904,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
125
src/lib/pxpipe/events.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { PXPIPE_DIR } from "./install.js";
|
||||
|
||||
const EVENTS_FILE = path.join(PXPIPE_DIR, "events.jsonl");
|
||||
const ROTATED_FILE = path.join(PXPIPE_DIR, "events.jsonl.1");
|
||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function ensureDir() {
|
||||
if (!fs.existsSync(PXPIPE_DIR)) fs.mkdirSync(PXPIPE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Fire-and-forget: stats must never break the request path.
|
||||
export function appendPxpipeEvent(event) {
|
||||
try {
|
||||
ensureDir();
|
||||
try {
|
||||
const stat = fs.statSync(EVENTS_FILE);
|
||||
if (stat.size > MAX_FILE_BYTES) fs.renameSync(EVENTS_FILE, ROTATED_FILE);
|
||||
} catch { /* no file yet */ }
|
||||
fs.appendFile(EVENTS_FILE, JSON.stringify({ ts: Date.now(), ...event }) + "\n", () => {});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function readPxpipeEvents({ sinceMs = null, limit = null } = {}) {
|
||||
const events = [];
|
||||
for (const file of [ROTATED_FILE, EVENTS_FILE]) {
|
||||
try {
|
||||
if (!fs.existsSync(file)) continue;
|
||||
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const ev = JSON.parse(line);
|
||||
if (sinceMs && ev.ts < sinceMs) continue;
|
||||
events.push(ev);
|
||||
} catch { /* skip corrupt line */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
events.sort((a, b) => a.ts - b.ts);
|
||||
return limit ? events.slice(-limit) : events;
|
||||
}
|
||||
|
||||
function emptyTotals() {
|
||||
return {
|
||||
requests: 0, compressed: 0, bypassed: 0, errors: 0,
|
||||
tokensBeforeEst: 0, tokensAfterEst: 0, tokensSavedEst: 0, savedPct: 0,
|
||||
imagesGenerated: 0, compressionTimeMs: 0, avgCompressionMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function accumulate(totals, ev) {
|
||||
totals.requests++;
|
||||
if (ev.applied) {
|
||||
totals.compressed++;
|
||||
totals.tokensBeforeEst += ev.tokensBeforeEst || 0;
|
||||
totals.tokensAfterEst += ev.tokensAfterEst || 0;
|
||||
totals.tokensSavedEst += ev.tokensSavedEst || 0;
|
||||
totals.imagesGenerated += ev.imageCount || 0;
|
||||
totals.compressionTimeMs += ev.durationMs || 0;
|
||||
} else if (ev.reason === "transform_error" || ev.reason === "timeout") {
|
||||
totals.errors++;
|
||||
} else {
|
||||
totals.bypassed++;
|
||||
}
|
||||
}
|
||||
|
||||
function finalize(totals) {
|
||||
totals.savedPct = totals.tokensBeforeEst > 0
|
||||
? +((totals.tokensSavedEst / totals.tokensBeforeEst) * 100).toFixed(2)
|
||||
: 0;
|
||||
totals.avgCompressionMs = totals.compressed > 0
|
||||
? Math.round(totals.compressionTimeMs / totals.compressed)
|
||||
: 0;
|
||||
return totals;
|
||||
}
|
||||
|
||||
// Aggregated stats for the dashboard: all-time + windowed totals, a daily
|
||||
// tokens-saved timeline (last `timelineDays`), and the most recent events.
|
||||
export function getPxpipeStats({ timelineDays = 30, recentLimit = 100 } = {}) {
|
||||
const events = readPxpipeEvents();
|
||||
const now = Date.now();
|
||||
const startOfToday = new Date(new Date(now).setHours(0, 0, 0, 0)).getTime();
|
||||
|
||||
const windows = {
|
||||
all: emptyTotals(),
|
||||
today: emptyTotals(),
|
||||
yesterday: emptyTotals(),
|
||||
last7d: emptyTotals(),
|
||||
last30d: emptyTotals(),
|
||||
};
|
||||
|
||||
const timeline = new Map();
|
||||
for (let i = timelineDays - 1; i >= 0; i--) {
|
||||
const day = new Date(startOfToday - i * DAY_MS);
|
||||
timeline.set(day.toISOString().slice(0, 10), { date: day.toISOString().slice(0, 10), tokensSavedEst: 0, compressed: 0, requests: 0 });
|
||||
}
|
||||
|
||||
for (const ev of events) {
|
||||
accumulate(windows.all, ev);
|
||||
if (ev.ts >= startOfToday) accumulate(windows.today, ev);
|
||||
else if (ev.ts >= startOfToday - DAY_MS) accumulate(windows.yesterday, ev);
|
||||
if (ev.ts >= now - 7 * DAY_MS) accumulate(windows.last7d, ev);
|
||||
if (ev.ts >= now - 30 * DAY_MS) accumulate(windows.last30d, ev);
|
||||
|
||||
const key = new Date(ev.ts).toISOString().slice(0, 10);
|
||||
const bucket = timeline.get(key);
|
||||
if (bucket) {
|
||||
bucket.requests++;
|
||||
if (ev.applied) {
|
||||
bucket.compressed++;
|
||||
bucket.tokensSavedEst += ev.tokensSavedEst || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const w of Object.values(windows)) finalize(w);
|
||||
|
||||
return {
|
||||
windows,
|
||||
timeline: [...timeline.values()],
|
||||
recent: events.slice(-recentLimit).reverse(),
|
||||
};
|
||||
}
|
||||
123
src/lib/pxpipe/install.js
Normal file
123
src/lib/pxpipe/install.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { spawn, execSync } from "child_process";
|
||||
import { DATA_DIR } from "@/lib/dataDir.js";
|
||||
|
||||
export const PXPIPE_DIR = path.join(DATA_DIR, "pxpipe");
|
||||
export const PXPIPE_PACKAGE = "pxpipe-proxy";
|
||||
const INSTALL_LOG = path.join(PXPIPE_DIR, "install.log");
|
||||
const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
const IS_WIN = process.platform === "win32";
|
||||
const NPM_CMD = IS_WIN ? "npm.cmd" : "npm";
|
||||
|
||||
// Same PATH extension trick as headroom/detect.js: packaged/launchd environments
|
||||
// often miss the Node bin dirs.
|
||||
const EXTRA_BINS = IS_WIN
|
||||
? [`${process.env.ProgramFiles || ""}\\nodejs`, `${process.env.APPDATA || ""}\\npm`]
|
||||
: ["/usr/local/bin", "/opt/homebrew/bin", `${process.env.HOME || ""}/.local/bin`, "/usr/bin", "/bin"];
|
||||
const EXTENDED_PATH = [...EXTRA_BINS, process.env.PATH || ""].filter(Boolean).join(path.delimiter);
|
||||
|
||||
let installInFlight = null;
|
||||
|
||||
function ensureDir() {
|
||||
if (!fs.existsSync(PXPIPE_DIR)) fs.mkdirSync(PXPIPE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export function packageRoot() {
|
||||
return path.join(PXPIPE_DIR, "node_modules", PXPIPE_PACKAGE);
|
||||
}
|
||||
|
||||
export function libraryEntry() {
|
||||
return path.join(packageRoot(), "dist", "core", "library.js");
|
||||
}
|
||||
|
||||
export function findNpm() {
|
||||
try {
|
||||
const out = execSync(`${IS_WIN ? "where" : "which"} npm`, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
}).toString().trim();
|
||||
return out ? out.split(/\r?\n/)[0].trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// { installed, version, path } — installed means the library entry exists on disk.
|
||||
export function getInstallInfo() {
|
||||
try {
|
||||
const pkgJson = path.join(packageRoot(), "package.json");
|
||||
if (!fs.existsSync(pkgJson) || !fs.existsSync(libraryEntry())) {
|
||||
return { installed: false, version: null, path: null };
|
||||
}
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf8"));
|
||||
return { installed: true, version: pkg.version || null, path: packageRoot() };
|
||||
} catch {
|
||||
return { installed: false, version: null, path: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function isInstalling() {
|
||||
return installInFlight !== null;
|
||||
}
|
||||
|
||||
// Install (or repair by reinstalling) pxpipe-proxy into DATA_DIR/pxpipe.
|
||||
// Serialized: concurrent calls await the same run.
|
||||
export function installPxpipe() {
|
||||
if (installInFlight) return installInFlight;
|
||||
installInFlight = runInstall().finally(() => { installInFlight = null; });
|
||||
return installInFlight;
|
||||
}
|
||||
|
||||
async function runInstall() {
|
||||
const npm = findNpm();
|
||||
if (!npm) {
|
||||
const err = new Error("npm not found on PATH — Node.js/npm is required to install PXPIPE");
|
||||
err.code = "NPM_NOT_FOUND";
|
||||
throw err;
|
||||
}
|
||||
|
||||
ensureDir();
|
||||
const pkgJson = path.join(PXPIPE_DIR, "package.json");
|
||||
if (!fs.existsSync(pkgJson)) {
|
||||
fs.writeFileSync(pkgJson, JSON.stringify({ name: "9router-pxpipe-host", private: true }, null, 2));
|
||||
}
|
||||
|
||||
const outFd = fs.openSync(INSTALL_LOG, "a");
|
||||
fs.writeSync(outFd, `\n[${new Date().toISOString()}] npm install ${PXPIPE_PACKAGE}@latest\n`);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(npm, ["install", `${PXPIPE_PACKAGE}@latest`, "--no-audit", "--no-fund", "--omit=dev"], {
|
||||
cwd: PXPIPE_DIR,
|
||||
stdio: ["ignore", outFd, outFd],
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("npm install timed out after 5 minutes — see install.log"));
|
||||
}, INSTALL_TIMEOUT_MS);
|
||||
child.once("error", (e) => { clearTimeout(timer); reject(e); });
|
||||
child.once("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`npm install exited with code ${code} — see install.log`));
|
||||
});
|
||||
}).finally(() => fs.closeSync(outFd));
|
||||
|
||||
const info = getInstallInfo();
|
||||
if (!info.installed) throw new Error("install finished but package is missing — see install.log");
|
||||
return info;
|
||||
}
|
||||
|
||||
export function getInstallLogTail(maxLines = 200) {
|
||||
try {
|
||||
if (!fs.existsSync(INSTALL_LOG)) return "";
|
||||
const lines = fs.readFileSync(INSTALL_LOG, "utf8").split(/\r?\n/).filter(Boolean);
|
||||
return lines.slice(-maxLines).join("\n");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
70
src/lib/pxpipe/loader.js
Normal file
70
src/lib/pxpipe/loader.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { pathToFileURL } from "url";
|
||||
import { getInstallInfo, libraryEntry } from "./install.js";
|
||||
|
||||
// Module cache: pxpipe is loaded once per process ("started") and dropped on
|
||||
// "stop". In library mode start/stop govern the in-process module, not a daemon.
|
||||
let cached = null; // { module, version, loadedAt }
|
||||
let loadPromise = null;
|
||||
|
||||
export function getLoadedInfo() {
|
||||
return cached ? { loaded: true, version: cached.version, loadedAt: cached.loadedAt } : { loaded: false };
|
||||
}
|
||||
|
||||
export async function loadPxpipe() {
|
||||
if (cached) return cached;
|
||||
if (loadPromise) return loadPromise;
|
||||
loadPromise = doLoad().finally(() => { loadPromise = null; });
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
async function doLoad() {
|
||||
const info = getInstallInfo();
|
||||
if (!info.installed) {
|
||||
const err = new Error("PXPIPE is not installed");
|
||||
err.code = "NOT_INSTALLED";
|
||||
throw err;
|
||||
}
|
||||
// Cache-bust per version so Repair/upgrade takes effect without a server restart.
|
||||
const url = `${pathToFileURL(libraryEntry()).href}?v=${encodeURIComponent(info.version || "0")}`;
|
||||
const mod = await import(/* webpackIgnore: true */ url);
|
||||
if (typeof mod.transformAnthropicMessages !== "function") {
|
||||
throw new Error("installed pxpipe package does not export transformAnthropicMessages");
|
||||
}
|
||||
cached = { module: mod, version: info.version, loadedAt: Date.now() };
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function unloadPxpipe() {
|
||||
const wasLoaded = !!cached;
|
||||
cached = null;
|
||||
return wasLoaded;
|
||||
}
|
||||
|
||||
// Transform function for the request pipeline; null when unavailable (fail-open).
|
||||
// autoLoad controls whether a cold cache triggers a load (first request warms it).
|
||||
export async function getTransform({ autoLoad = true } = {}) {
|
||||
try {
|
||||
if (!cached && !autoLoad) return null;
|
||||
const { module: mod } = await loadPxpipe();
|
||||
return mod.transformAnthropicMessages;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Health self-test: run a tiny synthetic Claude request through the transformer.
|
||||
// A healthy module parses it and answers with a machine-readable reason.
|
||||
export async function selfTest() {
|
||||
const startedAt = Date.now();
|
||||
const { module: mod } = await loadPxpipe();
|
||||
const body = new TextEncoder().encode(JSON.stringify({
|
||||
model: "claude-fable-5",
|
||||
max_tokens: 16,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
}));
|
||||
const result = await mod.transformAnthropicMessages({ body, model: "claude-fable-5" });
|
||||
if (!result || typeof result.applied !== "boolean" || !(result.body instanceof Uint8Array)) {
|
||||
throw new Error("transform returned an unexpected shape");
|
||||
}
|
||||
return { ok: true, reason: result.reason, durationMs: Date.now() - startedAt };
|
||||
}
|
||||
49
src/lib/pxpipe/service.js
Normal file
49
src/lib/pxpipe/service.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getInstallInfo, isInstalling, findNpm } from "./install.js";
|
||||
import { getLoadedInfo, loadPxpipe, selfTest } from "./loader.js";
|
||||
|
||||
// Aggregate status for the Token Saver card and /api/pxpipe/status.
|
||||
// "running" in library mode = module loaded into this process.
|
||||
export function getPxpipeStatus() {
|
||||
const install = getInstallInfo();
|
||||
const loaded = getLoadedInfo();
|
||||
return {
|
||||
installed: install.installed,
|
||||
installing: isInstalling(),
|
||||
version: install.version,
|
||||
path: install.path,
|
||||
running: loaded.loaded,
|
||||
loadedAt: loaded.loadedAt || null,
|
||||
uptimeMs: loaded.loaded ? Date.now() - loaded.loadedAt : 0,
|
||||
npmAvailable: !!findNpm(),
|
||||
mode: "library", // in-process transform, not an external proxy
|
||||
};
|
||||
}
|
||||
|
||||
// PRD health checklist, adapted to library mode: installed? → module loads
|
||||
// (the "executable found / port listening" equivalent) → test request transforms.
|
||||
export async function runHealthCheck() {
|
||||
const checks = [];
|
||||
const fail = (error) => ({ healthy: false, checks, error });
|
||||
|
||||
const install = getInstallInfo();
|
||||
checks.push({ id: "installed", label: "PXPIPE installed", ok: install.installed, detail: install.version ? `v${install.version}` : null });
|
||||
if (!install.installed) return fail("pxpipe not installed");
|
||||
|
||||
try {
|
||||
await loadPxpipe();
|
||||
checks.push({ id: "module", label: "Transform module loads", ok: true, detail: `v${install.version}` });
|
||||
} catch (e) {
|
||||
checks.push({ id: "module", label: "Transform module loads", ok: false, detail: e.message });
|
||||
return fail(`Cannot load module: ${e.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const test = await selfTest();
|
||||
checks.push({ id: "transform", label: "Test request transforms", ok: true, detail: `${test.durationMs}ms (${test.reason})` });
|
||||
} catch (e) {
|
||||
checks.push({ id: "transform", label: "Test request transforms", ok: false, detail: e.message });
|
||||
return fail(`Self-test failed: ${e.message}`);
|
||||
}
|
||||
|
||||
return { healthy: true, checks, error: null };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
1215
src/shared/components/ApiExplorerModal.js
Normal file
1215
src/shared/components/ApiExplorerModal.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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: "🌐" };
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function Modal({
|
||||
md: "max-w-md",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
full: "max-w-4xl",
|
||||
full: "max-w-6xl",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,7 +99,10 @@ export default function Modal({
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 max-h-[calc(85vh-100px)] overflow-y-auto custom-scrollbar">{children}</div>
|
||||
<div className={cn(
|
||||
"p-6 overflow-y-auto custom-scrollbar",
|
||||
size === "full" ? "max-h-[calc(92vh-100px)]" : "max-h-[calc(85vh-100px)]"
|
||||
)}>{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ConfirmModal } from "./Modal";
|
||||
import NineRemotePromoModal from "./NineRemotePromoModal";
|
||||
|
||||
// const VISIBLE_MEDIA_KINDS = ["embedding", "image", "imageToText", "tts", "stt", "webSearch", "webFetch", "video", "music"];
|
||||
const VISIBLE_MEDIA_KINDS = ["embedding", "image", "tts", "stt"];
|
||||
const VISIBLE_MEDIA_KINDS = ["embedding", "image", "video", "tts", "stt"];
|
||||
// Combined entry: webSearch + webFetch share one page at /dashboard/media-providers/web
|
||||
const COMBINED_WEB_ITEM = { id: "web", label: "Web Fetch & Search", icon: "travel_explore", href: "/dashboard/media-providers/web" };
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -37,6 +37,7 @@ export { default as SegmentedControl } from "./SegmentedControl";
|
||||
export { default as Tooltip } from "./Tooltip";
|
||||
export { default as ProviderInfoCard } from "./ProviderInfoCard";
|
||||
export { default as CapacityBadges } from "./CapacityBadges";
|
||||
export { default as ApiExplorerModal } from "./ApiExplorerModal";
|
||||
|
||||
// Layouts
|
||||
export * from "./layouts";
|
||||
|
||||
871
src/shared/constants/apiCatalog.js
Normal file
871
src/shared/constants/apiCatalog.js
Normal file
@@ -0,0 +1,871 @@
|
||||
/**
|
||||
* Public AI API catalog for the dashboard API Explorer.
|
||||
* Single source of truth for endpoint docs + form fields used by ApiExplorerModal.
|
||||
*/
|
||||
|
||||
/** @typedef {"string"|"number"|"boolean"|"select"|"textarea"|"json"|"file"|"messages"} FieldType */
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApiField
|
||||
* @property {string} key
|
||||
* @property {string} label
|
||||
* @property {FieldType} type
|
||||
* @property {boolean} [required]
|
||||
* @property {string} [description]
|
||||
* @property {*} [default]
|
||||
* @property {string[]} [options]
|
||||
* @property {number} [min]
|
||||
* @property {number} [max]
|
||||
* @property {number} [step]
|
||||
* @property {string} [placeholder]
|
||||
* @property {string} [accept] - file accept attr
|
||||
* @property {"body"|"query"|"header"} [location] - default body
|
||||
* @property {boolean} [advanced] - hide behind "Advanced"
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApiEndpoint
|
||||
* @property {string} id
|
||||
* @property {string} method
|
||||
* @property {string} path
|
||||
* @property {string} label
|
||||
* @property {string} description
|
||||
* @property {string} icon
|
||||
* @property {string} category
|
||||
* @property {"json"|"multipart"|"none"} contentType
|
||||
* @property {boolean} [needsModel]
|
||||
* @property {string} [modelsKind] - /v1/models/{kind} query
|
||||
* @property {ApiField[]} fields
|
||||
* @property {string} [defaultResponse]
|
||||
* @property {boolean} [supportsStream]
|
||||
*/
|
||||
|
||||
/** @type {ApiEndpoint[]} */
|
||||
export const API_CATALOG = [
|
||||
{
|
||||
id: "models",
|
||||
method: "GET",
|
||||
path: "/v1/models",
|
||||
label: "List Models",
|
||||
description: "List chat/LLM models (default). Combos appear with owned_by: combo.",
|
||||
icon: "list_alt",
|
||||
category: "Discovery",
|
||||
contentType: "none",
|
||||
fields: [],
|
||||
defaultResponse: `{\n "object": "list",\n "data": [\n { "id": "openai/gpt-4o", "object": "model", "owned_by": "openai" }\n ]\n}`,
|
||||
},
|
||||
{
|
||||
id: "models-kind",
|
||||
method: "GET",
|
||||
path: "/v1/models/{kind}",
|
||||
label: "List Models by Kind",
|
||||
description: "List models for a capability kind: image, tts, stt, embedding, web, etc.",
|
||||
icon: "category",
|
||||
category: "Discovery",
|
||||
contentType: "none",
|
||||
fields: [
|
||||
{
|
||||
key: "kind",
|
||||
label: "Kind",
|
||||
type: "select",
|
||||
required: true,
|
||||
location: "path",
|
||||
default: "image",
|
||||
options: ["image", "tts", "stt", "embedding", "web", "image-to-text"],
|
||||
description: "Path segment: /v1/models/{kind}. Chat models use GET /v1/models (no kind).",
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "object": "list",\n "data": [{ "id": "openai/dall-e-3", "object": "model", "owned_by": "openai" }]\n}`,
|
||||
},
|
||||
{
|
||||
id: "models-info",
|
||||
method: "GET",
|
||||
path: "/v1/models/info",
|
||||
label: "Model Info",
|
||||
description: "Per-model metadata: params, options, contextWindow, capabilities.",
|
||||
icon: "info",
|
||||
category: "Discovery",
|
||||
contentType: "none",
|
||||
fields: [
|
||||
{
|
||||
key: "id",
|
||||
label: "Model ID",
|
||||
type: "string",
|
||||
required: true,
|
||||
location: "query",
|
||||
placeholder: "openai/gpt-4o",
|
||||
description: "Query: ?id=provider/model",
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "id": "openai/gpt-4o",\n "kind": "llm",\n "endpoint": "/v1/chat/completions",\n "contextWindow": 128000\n}`,
|
||||
},
|
||||
{
|
||||
id: "chat-completions",
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
label: "Chat Completions",
|
||||
description: "OpenAI-compatible chat/code generation with streaming + auto-fallback.",
|
||||
icon: "chat",
|
||||
category: "Chat",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "llm",
|
||||
supportsStream: true,
|
||||
fields: [
|
||||
{
|
||||
key: "messages",
|
||||
label: "Messages",
|
||||
type: "messages",
|
||||
required: true,
|
||||
default: [{ role: "user", content: "Hello! Say hi in one sentence." }],
|
||||
description: "Array of { role, content }",
|
||||
},
|
||||
{
|
||||
key: "stream",
|
||||
label: "Stream",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "SSE streaming response",
|
||||
},
|
||||
{
|
||||
key: "temperature",
|
||||
label: "Temperature",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "top_p",
|
||||
label: "Top P",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "max_tokens",
|
||||
label: "Max Tokens",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "max_completion_tokens",
|
||||
label: "Max Completion Tokens",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "presence_penalty",
|
||||
label: "Presence Penalty",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: -2,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "frequency_penalty",
|
||||
label: "Frequency Penalty",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: -2,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "seed",
|
||||
label: "Seed",
|
||||
type: "number",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "stop",
|
||||
label: "Stop",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '["\\n"] or "stop"',
|
||||
advanced: true,
|
||||
description: "String or array of stop sequences",
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
label: "Tools",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: "[{ type: \"function\", function: {...} }]",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "tool_choice",
|
||||
label: "Tool Choice",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '"auto" | "none" | { type, function }',
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "response_format",
|
||||
label: "Response Format",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '{ "type": "json_object" }',
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "reasoning_effort",
|
||||
label: "Reasoning Effort",
|
||||
type: "select",
|
||||
default: "",
|
||||
options: ["", "low", "medium", "high"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "thinking",
|
||||
label: "Thinking",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '{ "type": "enabled", "budget_tokens": 1024 }',
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "n",
|
||||
label: "n",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 1,
|
||||
max: 8,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
label: "User",
|
||||
type: "string",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "id": "chatcmpl-...",\n "object": "chat.completion",\n "choices": [{ "message": { "role": "assistant", "content": "..." }, "finish_reason": "stop" }],\n "usage": { "prompt_tokens": 8, "completion_tokens": 2, "total_tokens": 10 }\n}`,
|
||||
},
|
||||
{
|
||||
id: "messages",
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
label: "Messages (Anthropic)",
|
||||
description: "Anthropic Messages API format. Same models, Claude-compatible tools/clients.",
|
||||
icon: "forum",
|
||||
category: "Chat",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "llm",
|
||||
supportsStream: true,
|
||||
fields: [
|
||||
{
|
||||
key: "messages",
|
||||
label: "Messages",
|
||||
type: "messages",
|
||||
required: true,
|
||||
default: [{ role: "user", content: "Hello! Say hi in one sentence." }],
|
||||
},
|
||||
{
|
||||
key: "max_tokens",
|
||||
label: "Max Tokens",
|
||||
type: "number",
|
||||
required: true,
|
||||
default: 1024,
|
||||
min: 1,
|
||||
},
|
||||
{
|
||||
key: "stream",
|
||||
label: "Stream",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: "system",
|
||||
label: "System",
|
||||
type: "textarea",
|
||||
default: "",
|
||||
placeholder: "You are a helpful assistant.",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "temperature",
|
||||
label: "Temperature",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "top_p",
|
||||
label: "Top P",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "top_k",
|
||||
label: "Top K",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "stop_sequences",
|
||||
label: "Stop Sequences",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '["\\n\\nHuman:"]',
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
label: "Tools",
|
||||
type: "json",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "tool_choice",
|
||||
label: "Tool Choice",
|
||||
type: "json",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "thinking",
|
||||
label: "Thinking",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '{ "type": "enabled", "budget_tokens": 1024 }',
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "anthropic-version",
|
||||
label: "anthropic-version",
|
||||
type: "string",
|
||||
location: "header",
|
||||
default: "2023-06-01",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "id": "msg_...",\n "type": "message",\n "role": "assistant",\n "content": [{ "type": "text", "text": "Hello!" }],\n "stop_reason": "end_turn",\n "usage": { "input_tokens": 8, "output_tokens": 2 }\n}`,
|
||||
},
|
||||
{
|
||||
id: "responses",
|
||||
method: "POST",
|
||||
path: "/v1/responses",
|
||||
label: "Responses API",
|
||||
description: "OpenAI Responses format (Codex / OpenClaw). input can be string or items[].",
|
||||
icon: "reply",
|
||||
category: "Chat",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "llm",
|
||||
supportsStream: true,
|
||||
fields: [
|
||||
{
|
||||
key: "input",
|
||||
label: "Input",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
default: "Hello! Say hi in one sentence.",
|
||||
description: "String or JSON array of input items",
|
||||
},
|
||||
{
|
||||
key: "stream",
|
||||
label: "Stream",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: "instructions",
|
||||
label: "Instructions",
|
||||
type: "textarea",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "temperature",
|
||||
label: "Temperature",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "max_output_tokens",
|
||||
label: "Max Output Tokens",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
label: "Tools",
|
||||
type: "json",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "reasoning",
|
||||
label: "Reasoning",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '{ "effort": "medium" }',
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "id": "resp_...",\n "object": "response",\n "status": "completed",\n "output": [{ "type": "message", "content": [{ "type": "output_text", "text": "..." }] }]\n}`,
|
||||
},
|
||||
{
|
||||
id: "count-tokens",
|
||||
method: "POST",
|
||||
path: "/v1/messages/count_tokens",
|
||||
label: "Count Tokens",
|
||||
description: "Estimate token count for Anthropic-style messages (local estimate).",
|
||||
icon: "tag",
|
||||
category: "Chat",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "llm",
|
||||
fields: [
|
||||
{
|
||||
key: "messages",
|
||||
label: "Messages",
|
||||
type: "messages",
|
||||
required: true,
|
||||
default: [{ role: "user", content: "Hello world" }],
|
||||
},
|
||||
{
|
||||
key: "system",
|
||||
label: "System",
|
||||
type: "textarea",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "input_tokens": 12\n}`,
|
||||
},
|
||||
{
|
||||
id: "embeddings",
|
||||
method: "POST",
|
||||
path: "/v1/embeddings",
|
||||
label: "Embeddings",
|
||||
description: "Vector embeddings for RAG / semantic search.",
|
||||
icon: "scatter_plot",
|
||||
category: "Embeddings",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "embedding",
|
||||
fields: [
|
||||
{
|
||||
key: "input",
|
||||
label: "Input",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
default: "Hello world",
|
||||
description: "String or JSON array of strings",
|
||||
},
|
||||
{
|
||||
key: "encoding_format",
|
||||
label: "Encoding",
|
||||
type: "select",
|
||||
default: "float",
|
||||
options: ["float", "base64"],
|
||||
},
|
||||
{
|
||||
key: "dimensions",
|
||||
label: "Dimensions",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 1,
|
||||
advanced: true,
|
||||
description: "OpenAI text-embedding-3-* only",
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "object": "list",\n "data": [{ "object": "embedding", "index": 0, "embedding": [0.01, -0.02, "..."] }],\n "usage": { "prompt_tokens": 2, "total_tokens": 2 }\n}`,
|
||||
},
|
||||
{
|
||||
id: "images-generations",
|
||||
method: "POST",
|
||||
path: "/v1/images/generations",
|
||||
label: "Image Generation",
|
||||
description: "Text-to-image (DALL·E, Imagen, FLUX, Codex, MiniMax…). Supports binary output.",
|
||||
icon: "brush",
|
||||
category: "Media",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "image",
|
||||
fields: [
|
||||
{
|
||||
key: "prompt",
|
||||
label: "Prompt",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
default: "A cute cat wearing a hat, watercolor style",
|
||||
},
|
||||
{
|
||||
key: "n",
|
||||
label: "n",
|
||||
type: "number",
|
||||
default: 1,
|
||||
min: 1,
|
||||
max: 4,
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
label: "Size",
|
||||
type: "select",
|
||||
default: "auto",
|
||||
options: ["auto", "1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"],
|
||||
},
|
||||
{
|
||||
key: "aspect_ratio",
|
||||
label: "Aspect Ratio",
|
||||
type: "select",
|
||||
default: "",
|
||||
options: ["", "auto", "1:1", "16:9", "9:16", "4:3", "3:2", "2:3", "9:19.5", "20:9"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "resolution",
|
||||
label: "Resolution",
|
||||
type: "select",
|
||||
default: "",
|
||||
options: ["", "1k", "2k"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "quality",
|
||||
label: "Quality",
|
||||
type: "select",
|
||||
default: "auto",
|
||||
options: ["auto", "low", "medium", "high", "standard", "hd"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "background",
|
||||
label: "Background",
|
||||
type: "select",
|
||||
default: "auto",
|
||||
options: ["auto", "transparent", "opaque"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "style",
|
||||
label: "Style",
|
||||
type: "select",
|
||||
default: "",
|
||||
options: ["", "vivid", "natural"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "response_format",
|
||||
label: "Response Format",
|
||||
type: "select",
|
||||
default: "url",
|
||||
options: ["url", "b64_json", "binary"],
|
||||
description: "binary uses ?response_format=binary (raw image bytes)",
|
||||
},
|
||||
{
|
||||
key: "output_format",
|
||||
label: "Codec",
|
||||
type: "select",
|
||||
default: "png",
|
||||
options: ["png", "jpeg", "webp"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "image",
|
||||
label: "Reference Image",
|
||||
type: "image",
|
||||
default: "",
|
||||
placeholder: "https://... or upload an image",
|
||||
advanced: true,
|
||||
description: "Edit / img2img when provider supports it. Paste URL or upload file (stored as data URL).",
|
||||
accept: "image/*",
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "created": 1735000000,\n "data": [{ "url": "https://..." }]\n}`,
|
||||
},
|
||||
{
|
||||
id: "audio-speech",
|
||||
method: "POST",
|
||||
path: "/v1/audio/speech",
|
||||
label: "Text to Speech",
|
||||
description: "OpenAI / ElevenLabs / Edge / Google / Deepgram voices → audio bytes.",
|
||||
icon: "record_voice_over",
|
||||
category: "Media",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "tts",
|
||||
fields: [
|
||||
{
|
||||
key: "input",
|
||||
label: "Text",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
default: "Hello from 9Router!",
|
||||
},
|
||||
{
|
||||
key: "response_format",
|
||||
label: "Response Format",
|
||||
type: "select",
|
||||
location: "query",
|
||||
default: "mp3",
|
||||
options: ["mp3", "json"],
|
||||
description: "mp3 = raw audio; json = { audio: base64, format }",
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
label: "Language Hint",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "en, vi, ...",
|
||||
advanced: true,
|
||||
description: "Optional language hint (e.g. Gemini)",
|
||||
},
|
||||
{
|
||||
key: "voice",
|
||||
label: "Voice",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "alloy (provider-dependent)",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "speed",
|
||||
label: "Speed",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0.25,
|
||||
max: 4,
|
||||
step: 0.05,
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: "(binary audio/mp3)",
|
||||
},
|
||||
{
|
||||
id: "audio-voices",
|
||||
method: "GET",
|
||||
path: "/v1/audio/voices",
|
||||
label: "List Voices",
|
||||
description: "List TTS voices for elevenlabs, edge-tts, deepgram, inworld, local-device.",
|
||||
icon: "voice_selection",
|
||||
category: "Media",
|
||||
contentType: "none",
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
type: "select",
|
||||
required: true,
|
||||
location: "query",
|
||||
default: "edge-tts",
|
||||
options: ["edge-tts", "elevenlabs", "deepgram", "inworld", "local-device"],
|
||||
},
|
||||
{
|
||||
key: "lang",
|
||||
label: "Language",
|
||||
type: "string",
|
||||
location: "query",
|
||||
default: "",
|
||||
placeholder: "vi, en, ...",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "object": "list",\n "data": [{ "id": "...", "model": "edge-tts/vi-VN-HoaiMyNeural" }]\n}`,
|
||||
},
|
||||
{
|
||||
id: "audio-transcriptions",
|
||||
method: "POST",
|
||||
path: "/v1/audio/transcriptions",
|
||||
label: "Speech to Text",
|
||||
description: "Whisper-compatible multipart transcription.",
|
||||
icon: "mic",
|
||||
category: "Media",
|
||||
contentType: "multipart",
|
||||
needsModel: true,
|
||||
modelsKind: "stt",
|
||||
fields: [
|
||||
{
|
||||
key: "file",
|
||||
label: "Audio File",
|
||||
type: "file",
|
||||
required: true,
|
||||
accept: "audio/*,.mp3,.wav,.m4a,.webm,.ogg,.flac",
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
label: "Language",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "en, vi, ...",
|
||||
},
|
||||
{
|
||||
key: "prompt",
|
||||
label: "Prompt",
|
||||
type: "textarea",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "response_format",
|
||||
label: "Response Format",
|
||||
type: "select",
|
||||
default: "json",
|
||||
options: ["json", "text", "verbose_json", "srt", "vtt"],
|
||||
},
|
||||
{
|
||||
key: "temperature",
|
||||
label: "Temperature",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "text": "..."\n}`,
|
||||
},
|
||||
{
|
||||
id: "search",
|
||||
method: "POST",
|
||||
path: "/v1/search",
|
||||
label: "Web Search",
|
||||
description: "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com…",
|
||||
icon: "search",
|
||||
category: "Web",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "web",
|
||||
fields: [
|
||||
{
|
||||
key: "query",
|
||||
label: "Query",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
default: "What is the latest news about AI?",
|
||||
},
|
||||
{
|
||||
key: "max_results",
|
||||
label: "Max Results",
|
||||
type: "number",
|
||||
default: 5,
|
||||
min: 1,
|
||||
max: 100,
|
||||
},
|
||||
{
|
||||
key: "search_type",
|
||||
label: "Search Type",
|
||||
type: "select",
|
||||
default: "web",
|
||||
options: ["web", "news"],
|
||||
},
|
||||
{
|
||||
key: "country",
|
||||
label: "Country",
|
||||
type: "string",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
label: "Language",
|
||||
type: "string",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "time_range",
|
||||
label: "Time Range",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "day, week, month, year",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "domain_filter",
|
||||
label: "Domain Filter",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "example.com",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "provider": "tavily",\n "query": "...",\n "results": [{ "title": "...", "url": "...", "snippet": "..." }]\n}`,
|
||||
},
|
||||
{
|
||||
id: "web-fetch",
|
||||
method: "POST",
|
||||
path: "/v1/web/fetch",
|
||||
label: "Web Fetch",
|
||||
description: "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.",
|
||||
icon: "language",
|
||||
category: "Web",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "web",
|
||||
fields: [
|
||||
{
|
||||
key: "url",
|
||||
label: "URL",
|
||||
type: "string",
|
||||
required: true,
|
||||
default: "https://9router.com",
|
||||
placeholder: "https://example.com",
|
||||
},
|
||||
{
|
||||
key: "format",
|
||||
label: "Format",
|
||||
type: "select",
|
||||
default: "markdown",
|
||||
options: ["markdown", "text", "html"],
|
||||
},
|
||||
{
|
||||
key: "max_characters",
|
||||
label: "Max Characters",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
advanced: true,
|
||||
description: "0 = no truncate",
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "provider": "jina-reader",\n "url": "...",\n "title": "...",\n "content": { "format": "markdown", "text": "..." }\n}`,
|
||||
},
|
||||
];
|
||||
|
||||
export const API_CATEGORIES = [...new Set(API_CATALOG.map((e) => e.category))];
|
||||
|
||||
export function getApiEndpointById(id) {
|
||||
return API_CATALOG.find((e) => e.id === id) || null;
|
||||
}
|
||||
|
||||
export function getApiEndpointsByCategory(category) {
|
||||
return API_CATALOG.filter((e) => e.category === category);
|
||||
}
|
||||
@@ -62,6 +62,9 @@ export const MITM_TOOLS = {
|
||||
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", alias: "claude-haiku-4.5" },
|
||||
{ id: "deepseek-3.2", name: "DeepSeek 3.2", alias: "deepseek-3.2" },
|
||||
{ id: "minimax-m2.1", name: "MiniMax M2.1", alias: "minimax-m2.1" },
|
||||
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol", alias: "gpt-5.6-sol", contextLength: 272000, rateMultiplier: 2.4 },
|
||||
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra", alias: "gpt-5.6-terra", contextLength: 272000, rateMultiplier: 1.2 },
|
||||
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna", alias: "gpt-5.6-luna", contextLength: 272000, rateMultiplier: 0.6 },
|
||||
{ id: "simple-task", name: "Qwen3 Coder Next", alias: "simple-task" },
|
||||
],
|
||||
},
|
||||
@@ -95,13 +98,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" },
|
||||
],
|
||||
},
|
||||
@@ -358,6 +363,30 @@ amp --model "{{model}}"
|
||||
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", alias: "gemini", defaultValue: "gemini/gemini-3.1-pro" },
|
||||
],
|
||||
},
|
||||
"grok-build": {
|
||||
id: "grok-build",
|
||||
name: "Grok Build",
|
||||
image: "/providers/grok-cli.png",
|
||||
color: "#1DA1F2",
|
||||
description: "xAI Grok Build TUI coding agent",
|
||||
configType: "custom",
|
||||
docsUrl: "https://x.ai/cli",
|
||||
defaultCommand: "grok",
|
||||
notes: [
|
||||
{
|
||||
type: "info",
|
||||
text: "Grok Build uses ~/.grok/config.toml. 9Router writes a [model.9router] custom model and sets it as the default.",
|
||||
},
|
||||
{
|
||||
type: "info",
|
||||
text: "After Apply, run grok (or /model 9router) to use the routed model. Switch back anytime with /model grok-build.",
|
||||
},
|
||||
{
|
||||
type: "warning",
|
||||
text: "Config path: Linux/macOS ~/.grok/config.toml • Windows %USERPROFILE%\\.grok\\config.toml",
|
||||
},
|
||||
],
|
||||
},
|
||||
// HIDDEN: gemini-cli
|
||||
// "gemini-cli": {
|
||||
// id: "gemini-cli",
|
||||
|
||||
@@ -33,4 +33,5 @@ export const LOCALE_FLAGS = {
|
||||
"fi": "🇫🇮",
|
||||
"da": "🇩🇰",
|
||||
"no": "🇳🇴",
|
||||
"fa": "🇮🇷",
|
||||
};
|
||||
|
||||
@@ -77,7 +77,7 @@ export const MEDIA_PROVIDER_KINDS = [
|
||||
{ id: "stt", label: "Speech To Text", icon: "mic", endpoint: { method: "POST", path: "/v1/audio/transcriptions" } },
|
||||
{ id: "webSearch", label: "Web Search", icon: "travel_explore", endpoint: { method: "POST", path: "/v1/search" } },
|
||||
{ id: "webFetch", label: "Web Fetch", icon: "language", endpoint: { method: "POST", path: "/v1/web/fetch" } },
|
||||
{ id: "video", label: "Video", icon: "movie", endpoint: { method: "POST", path: "/v1/video/generations" } },
|
||||
{ id: "video", label: "Video", icon: "movie", endpoint: { method: "POST", path: "/v1/videos/generations" } },
|
||||
{ id: "music", label: "Music", icon: "music_note", endpoint: { method: "POST", path: "/v1/audio/music" } },
|
||||
];
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS, VIRTUAL_IFACE_REGEX,
|
||||
} from "@/lib/tunnel";
|
||||
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 +32,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 +51,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,30 +66,63 @@ 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 autoStartMitm() {
|
||||
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));
|
||||
}
|
||||
|
||||
if (settings.tunnelEnabled) ensureCloudflared().catch(() => {});
|
||||
|
||||
if (settings.mitmEnabled) {
|
||||
// Sync mitmAlias DB → JSON cache so standalone MITM server can read it.
|
||||
syncMitmAliasCache().catch(() => {});
|
||||
autoStartMitm(settings);
|
||||
}
|
||||
|
||||
configureTunnelMonitoring(settings);
|
||||
|
||||
if (hasQuotaAutoPingEnabled(settings)) {
|
||||
import("@/shared/services/quotaAutoPing")
|
||||
.then(({ startQuotaAutoPing }) => startQuotaAutoPing())
|
||||
.catch((e) => console.log("[AutoPing] scheduler start failed:", e.message));
|
||||
}
|
||||
}
|
||||
|
||||
function hasQuotaAutoPingEnabled(settings) {
|
||||
return [settings?.claudeAutoPing, settings?.codexAutoPing]
|
||||
.some((config) => Object.values(config?.connections || {}).some(Boolean));
|
||||
}
|
||||
|
||||
async function autoStartMitm(settings) {
|
||||
if (g.mitmStartInProgress) return;
|
||||
g.mitmStartInProgress = true;
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (!settings.mitmEnabled) return;
|
||||
const mitmStatus = await getMitmStatus();
|
||||
if (mitmStatus.running) return;
|
||||
@@ -218,6 +241,12 @@ function startWatchdog() {
|
||||
if (g.watchdogInterval.unref) g.watchdogInterval.unref();
|
||||
}
|
||||
|
||||
function stopWatchdog() {
|
||||
if (!g.watchdogInterval) return;
|
||||
clearInterval(g.watchdogInterval);
|
||||
g.watchdogInterval = null;
|
||||
}
|
||||
|
||||
// ─── Network monitor: detect IPv4 fingerprint change + sleep/wake ────────────
|
||||
|
||||
function getNetworkFingerprint() {
|
||||
@@ -279,4 +308,23 @@ function startNetworkMonitor() {
|
||||
if (g.networkMonitorInterval.unref) g.networkMonitorInterval.unref();
|
||||
}
|
||||
|
||||
|
||||
function stopNetworkMonitor() {
|
||||
if (!g.networkMonitorInterval) return;
|
||||
clearInterval(g.networkMonitorInterval);
|
||||
g.networkMonitorInterval = null;
|
||||
g.lastNetworkFingerprint = null;
|
||||
g.lastOnline = null;
|
||||
}
|
||||
|
||||
export function configureTunnelMonitoring(settings) {
|
||||
if (settings?.tunnelEnabled || settings?.tailscaleEnabled) {
|
||||
startWatchdog();
|
||||
startNetworkMonitor();
|
||||
return;
|
||||
}
|
||||
stopWatchdog();
|
||||
stopNetworkMonitor();
|
||||
}
|
||||
|
||||
export default initializeApp;
|
||||
|
||||
@@ -296,3 +296,18 @@ export function startQuotaAutoPing() {
|
||||
g.interval = setInterval(() => { runQuotaAutoPingTick().catch(() => {}); }, C.tickIntervalMs);
|
||||
if (g.interval.unref) g.interval.unref();
|
||||
}
|
||||
|
||||
export function stopQuotaAutoPing() {
|
||||
if (!g.interval) return;
|
||||
clearInterval(g.interval);
|
||||
g.interval = null;
|
||||
console.log("[AutoPing] scheduler stopped");
|
||||
}
|
||||
|
||||
export function configureQuotaAutoPing(settings) {
|
||||
const enabled = Object.values(C.providers).some((providerConfig) =>
|
||||
Object.values(settings?.[providerConfig.settingsKey]?.connections || {}).some(Boolean)
|
||||
);
|
||||
if (enabled) startQuotaAutoPing();
|
||||
else stopQuotaAutoPing();
|
||||
}
|
||||
|
||||
94
src/shared/utils/bulkAdd.js
Normal file
94
src/shared/utils/bulkAdd.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Bulk-add API-key planner.
|
||||
//
|
||||
// Background: the backend upserts apikey connections BY NAME
|
||||
// (src/lib/db/repos/connectionsRepo.js ~L144: existing = all.find(c =>
|
||||
// c.authType === "apikey" && c.name === data.name)). A colliding name
|
||||
// overwrites an existing key instead of inserting a new one. Bulk-add used to
|
||||
// derive "<base> <lineIndex>" from the paste position, blind to existing
|
||||
// names, so re-adding keys often silently replaced earlier ones.
|
||||
//
|
||||
// This planner gap-fills the smallest free "<base> <n>" against both existing
|
||||
// connection names and names already assigned earlier in the same batch, so a
|
||||
// generated name is never reused and the backend always inserts.
|
||||
//
|
||||
// ponytail: only numeric-suffix collision is handled. A user who manually
|
||||
// types an exact existing non-numbered custom name (no index) will still hit
|
||||
// the backend upsert — but bulk auto-naming always appends " <n>", so this
|
||||
// path is unreachable from the bulk modal. Upgrade path: a backend
|
||||
// "skip-if-exists" flag on POST /api/providers if single-add ever needs it.
|
||||
|
||||
/**
|
||||
* Parse one pipe-separated bulk line into { baseName, apiKey, providerSpecificData? }.
|
||||
* @param {string} line
|
||||
* @param {{isCloudflareAi?: boolean}} [opts]
|
||||
* @returns {{baseName: string, apiKey: string, providerSpecificData?: object}|null}
|
||||
*/
|
||||
function parseLine(line, opts = {}) {
|
||||
const { isCloudflareAi = false } = opts;
|
||||
const parts = line.split("|");
|
||||
|
||||
if (isCloudflareAi && parts.length >= 3) {
|
||||
// name|apiKey|accountId (apiKey may itself contain pipes)
|
||||
const baseName = parts[0].trim();
|
||||
const apiKey = parts.slice(1, -1).join("|").trim();
|
||||
const accountId = parts[parts.length - 1].trim();
|
||||
return {
|
||||
baseName: baseName || "Key",
|
||||
apiKey,
|
||||
providerSpecificData: { accountId },
|
||||
};
|
||||
}
|
||||
|
||||
if (parts.length >= 2) {
|
||||
// name|apiKey (apiKey may itself contain pipes)
|
||||
const baseName = parts[0].trim();
|
||||
const apiKey = parts.slice(1).join("|").trim();
|
||||
return { baseName: baseName || "Key", apiKey };
|
||||
}
|
||||
|
||||
// apiKey only — auto-named "Key N"
|
||||
const apiKey = parts[0].trim();
|
||||
return { baseName: "Key", apiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan a bulk add: parse lines, assign collision-free "<base> <n>" names.
|
||||
*
|
||||
* @param {string[]} lines raw paste lines
|
||||
* @param {string[]|null|undefined} existingNames connection names already saved
|
||||
* @param {{isCloudflareAi?: boolean}} [opts]
|
||||
* @returns {{name: string, apiKey: string, skipped: boolean, providerSpecificData?: object}[]}
|
||||
*/
|
||||
export function planBulkAdd(lines, existingNames, opts = {}) {
|
||||
const { isCloudflareAi = false } = opts;
|
||||
|
||||
const safeExisting = Array.isArray(existingNames) ? existingNames : [];
|
||||
const used = new Set(safeExisting.map((n) => (typeof n === "string" ? n.toLowerCase() : "")));
|
||||
|
||||
const out = [];
|
||||
for (const raw of lines) {
|
||||
const line = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!line) continue;
|
||||
|
||||
const parsed = parseLine(line, { isCloudflareAi });
|
||||
if (!parsed || !parsed.apiKey) continue;
|
||||
|
||||
const base = parsed.baseName;
|
||||
|
||||
// Gap-fill from 1: smallest free "<base> <n>" not in `used`.
|
||||
// O(batch * existing) — fine for bulk add (tens to low hundreds of keys).
|
||||
let idx = 1;
|
||||
let name;
|
||||
for (;;) {
|
||||
name = `${base} ${idx}`;
|
||||
if (!used.has(name.toLowerCase())) break;
|
||||
idx += 1;
|
||||
}
|
||||
used.add(name.toLowerCase());
|
||||
|
||||
const entry = { name, apiKey: parsed.apiKey, skipped: false };
|
||||
if (parsed.providerSpecificData) entry.providerSpecificData = parsed.providerSpecificData;
|
||||
out.push(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -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") || "";
|
||||
@@ -225,9 +216,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)
|
||||
@@ -261,6 +250,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,
|
||||
@@ -287,7 +282,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||
log.warn("AUTH", `Pinned account ${credentials.connectionName} unavailable (${result.status}), no fallback`);
|
||||
return result.response;
|
||||
}
|
||||
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;
|
||||
|
||||
223
src/sse/handlers/videoGeneration.js
Normal file
223
src/sse/handlers/videoGeneration.js
Normal file
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
getProviderCredentials,
|
||||
markAccountUnavailable,
|
||||
clearAccountError,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "../services/auth.js";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { getModelInfo } from "../services/model.js";
|
||||
import { handleVideoProxyCore, getVideoConfig, sanitizeSecrets } from "open-sse/handlers/videoCore.js";
|
||||
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
||||
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
||||
import * as log from "../utils/logger.js";
|
||||
|
||||
// Video generation is xAI-only today; requests without a provider prefix
|
||||
// (bare model id, or multipart bodies we deliberately don't parse) land here.
|
||||
const DEFAULT_VIDEO_PROVIDER = "xai";
|
||||
|
||||
// Creation POSTs are billable jobs — only rotate to another account for
|
||||
// errors that upstream rejects BEFORE creating a job (auth/quota). A 5xx may
|
||||
// have created the job, so it is returned to the caller instead of re-sent.
|
||||
const CREATE_ROTATION_STATUSES = new Set([
|
||||
HTTP_STATUS.UNAUTHORIZED,
|
||||
HTTP_STATUS.FORBIDDEN,
|
||||
HTTP_STATUS.RATE_LIMITED,
|
||||
]);
|
||||
|
||||
async function requireValidApiKey(request) {
|
||||
const apiKey = extractApiKey(request);
|
||||
const settings = await getSettings();
|
||||
if (settings.requireApiKey) {
|
||||
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
|
||||
const valid = await isValidApiKey(apiKey);
|
||||
if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the request body once, byte-preserving.
|
||||
* JSON bodies are additionally parsed so the `model` provider prefix can be
|
||||
* resolved (and stripped) — everything else is forwarded exactly as received.
|
||||
*/
|
||||
async function readForwardableBody(request) {
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const raw = await request.text();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body") };
|
||||
}
|
||||
return { raw, parsed, contentType };
|
||||
}
|
||||
// Multipart (or any other content type): forward the exact bytes — parsing
|
||||
// and re-encoding FormData would change the multipart boundary.
|
||||
const buf = Buffer.from(await request.arrayBuffer());
|
||||
return { raw: buf, parsed: null, contentType };
|
||||
}
|
||||
|
||||
async function resolveVideoProvider(parsedBody) {
|
||||
if (!parsedBody?.model) return { provider: DEFAULT_VIDEO_PROVIDER, model: null };
|
||||
|
||||
const modelStr = String(parsedBody.model);
|
||||
const modelInfo = await getModelInfo(modelStr);
|
||||
if (!modelInfo.provider) {
|
||||
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, "Combos are not supported for video generation") };
|
||||
}
|
||||
if (!getVideoConfig(modelInfo.provider)) {
|
||||
// Bare model ids (no explicit "provider/" prefix) fall back to the default
|
||||
// video provider — the prefix-less inference targets chat providers only.
|
||||
if (!modelStr.includes("/")) {
|
||||
return { provider: DEFAULT_VIDEO_PROVIDER, model: modelStr };
|
||||
}
|
||||
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, `Provider '${modelInfo.provider}' does not support video generation`) };
|
||||
}
|
||||
return { provider: modelInfo.provider, model: modelInfo.model };
|
||||
}
|
||||
|
||||
function withConnectionHeader(response, connectionId) {
|
||||
if (!connectionId) return response;
|
||||
const headers = new Headers(response.headers);
|
||||
// Video jobs are account-bound upstream — clients echo this back as
|
||||
// `x-connection-id` on GET polls so the same account is used.
|
||||
headers.set("x-9router-connection-id", String(connectionId));
|
||||
return new Response(response.body, { status: response.status, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/videos/{generations|edits|extensions} — async job creation proxy.
|
||||
*/
|
||||
export async function handleVideoCreate(request, action) {
|
||||
const authError = await requireValidApiKey(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const bodyInfo = await readForwardableBody(request);
|
||||
if (bodyInfo.error) return bodyInfo.error;
|
||||
|
||||
const resolved = await resolveVideoProvider(bodyInfo.parsed);
|
||||
if (resolved.error) return resolved.error;
|
||||
const { provider, model } = resolved;
|
||||
|
||||
// Strip the provider prefix (e.g. "xai/grok-imagine-video") before forwarding;
|
||||
// otherwise forward the original bytes untouched.
|
||||
let forwardBody = bodyInfo.raw;
|
||||
if (bodyInfo.parsed && model && bodyInfo.parsed.model !== model) {
|
||||
forwardBody = JSON.stringify({ ...bodyInfo.parsed, model });
|
||||
}
|
||||
|
||||
const preferredConnectionId = request.headers.get("x-connection-id") || null;
|
||||
const idempotencyKey = request.headers.get("idempotency-key") || null;
|
||||
|
||||
const excludeConnectionIds = new Set();
|
||||
let lastError = null;
|
||||
let lastStatus = null;
|
||||
|
||||
while (true) {
|
||||
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { preferredConnectionId });
|
||||
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
if (credentials?.allRateLimited) {
|
||||
const errorMsg = lastError || credentials.lastError || "Unavailable";
|
||||
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
|
||||
return unavailableResponse(status, `[${provider}/${model || "video"}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
|
||||
}
|
||||
if (excludeConnectionIds.size === 0) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
|
||||
}
|
||||
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
|
||||
}
|
||||
|
||||
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
|
||||
|
||||
const result = await handleVideoProxyCore({
|
||||
provider,
|
||||
action,
|
||||
rawBody: forwardBody,
|
||||
contentType: bodyInfo.contentType || null,
|
||||
idempotencyKey,
|
||||
credentials: refreshedCredentials,
|
||||
signal: request.signal,
|
||||
log,
|
||||
onCredentialsRefreshed: async (newCreds) => {
|
||||
await updateProviderCredentials(credentials.connectionId, {
|
||||
accessToken: newCreds.accessToken,
|
||||
refreshToken: newCreds.refreshToken,
|
||||
providerSpecificData: newCreds.providerSpecificData,
|
||||
testStatus: "active",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await clearAccountError(credentials.connectionId, credentials, model);
|
||||
log.info("VIDEO", `${provider.toUpperCase()} | ${action} accepted (connection ${credentials.connectionId})`);
|
||||
return withConnectionHeader(result.response, credentials.connectionId);
|
||||
}
|
||||
|
||||
// Record the failure (dashboard shows lastError/errorCode → user sees re-auth is needed)
|
||||
const { shouldFallback } = await markAccountUnavailable(
|
||||
credentials.connectionId, result.status, sanitizeSecrets(result.error, refreshedCredentials), provider, model
|
||||
);
|
||||
|
||||
if (shouldFallback && CREATE_ROTATION_STATUSES.has(result.status)) {
|
||||
excludeConnectionIds.add(credentials.connectionId);
|
||||
lastError = result.error;
|
||||
lastStatus = result.status;
|
||||
continue;
|
||||
}
|
||||
|
||||
return result.response;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/videos/{request_id} — poll job status.
|
||||
* Jobs are account-bound upstream, so no cross-account rotation here: the
|
||||
* caller pins the creating account via `x-connection-id` (returned on create).
|
||||
*/
|
||||
export async function handleVideoGet(request, requestId) {
|
||||
const authError = await requireValidApiKey(request);
|
||||
if (authError) return authError;
|
||||
|
||||
if (!requestId) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing video request id");
|
||||
|
||||
const provider = DEFAULT_VIDEO_PROVIDER;
|
||||
const preferredConnectionId = request.headers.get("x-connection-id") || null;
|
||||
|
||||
const credentials = await getProviderCredentials(provider, null, null, { preferredConnectionId });
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
|
||||
}
|
||||
|
||||
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
|
||||
|
||||
const result = await handleVideoProxyCore({
|
||||
provider,
|
||||
requestId,
|
||||
credentials: refreshedCredentials,
|
||||
signal: request.signal,
|
||||
log,
|
||||
onCredentialsRefreshed: async (newCreds) => {
|
||||
await updateProviderCredentials(credentials.connectionId, {
|
||||
accessToken: newCreds.accessToken,
|
||||
refreshToken: newCreds.refreshToken,
|
||||
providerSpecificData: newCreds.providerSpecificData,
|
||||
testStatus: "active",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await clearAccountError(credentials.connectionId, credentials, null);
|
||||
return withConnectionHeader(result.response, credentials.connectionId);
|
||||
}
|
||||
|
||||
await markAccountUnavailable(
|
||||
credentials.connectionId, result.status, sanitizeSecrets(result.error, refreshedCredentials), provider, null
|
||||
);
|
||||
return result.response;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user