diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js
index f5109332..171bea44 100644
--- a/open-sse/handlers/chatCore.js
+++ b/open-sse/handlers/chatCore.js
@@ -457,7 +457,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
} catch (error) {
- trackPendingRequest(model, provider, connectionId, false);
+ trackPendingRequest(model, provider, connectionId, false, true);
appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { });
const errorDetail = {
@@ -532,7 +532,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// Check provider response - return error info for fallback handling
if (!providerResponse.ok) {
- trackPendingRequest(model, provider, connectionId, false);
+ trackPendingRequest(model, provider, connectionId, false, true);
const { statusCode, message, retryAfterMs } = await parseUpstreamError(providerResponse, provider);
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { });
@@ -880,8 +880,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
console.error("[RequestDetail] Failed to update streaming content:", err.message);
});
- // Save usage stats for dashboard
- if (usage && typeof usage === 'object') {
+ // Save usage stats for dashboard (skip if no real token data to avoid duplicates)
+ if (usage && typeof usage === 'object' && (usage.prompt_tokens > 0 || usage.completion_tokens > 0)) {
const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [STREAM USAGE] ${provider.toUpperCase()} | in=${usage?.prompt_tokens || 0} | out=${usage?.completion_tokens || 0}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`;
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js
index 2bf0a1c5..dbda60a4 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js
@@ -14,6 +14,8 @@ export default function CLIToolsPageClient({ machineId }) {
const [expandedTool, setExpandedTool] = useState(null);
const [modelMappings, setModelMappings] = useState({});
const [cloudEnabled, setCloudEnabled] = useState(false);
+ const [tunnelEnabled, setTunnelEnabled] = useState(false);
+ const [tunnelUrl, setTunnelUrl] = useState("");
const [apiKeys, setApiKeys] = useState([]);
useEffect(() => {
@@ -24,13 +26,21 @@ export default function CLIToolsPageClient({ machineId }) {
const loadCloudSettings = async () => {
try {
- const res = await fetch("/api/settings");
- if (res.ok) {
- const data = await res.json();
+ const [settingsRes, tunnelRes] = await Promise.all([
+ fetch("/api/settings"),
+ fetch("/api/tunnel/status"),
+ ]);
+ if (settingsRes.ok) {
+ const data = await settingsRes.json();
setCloudEnabled(data.cloudEnabled || false);
}
+ if (tunnelRes.ok) {
+ const data = await tunnelRes.json();
+ setTunnelEnabled(data.enabled || false);
+ setTunnelUrl(data.tunnelUrl || "");
+ }
} catch (error) {
- console.log("Error loading cloud settings:", error);
+ console.log("Error loading settings:", error);
}
};
@@ -108,6 +118,9 @@ export default function CLIToolsPageClient({ machineId }) {
}, []);
const getBaseUrl = () => {
+ if (tunnelEnabled && tunnelUrl) {
+ return tunnelUrl;
+ }
if (cloudEnabled && CLOUD_URL) {
return CLOUD_URL;
}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js
index b60f2ba7..d373fb6a 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js
@@ -38,7 +38,8 @@ export default function ClaudeToolCard({
if (!currentUrl) return "not_configured";
const localMatch = currentUrl.includes("localhost") || currentUrl.includes("127.0.0.1");
const cloudMatch = cloudEnabled && CLOUD_URL && currentUrl.startsWith(CLOUD_URL);
- if (localMatch || cloudMatch) return "configured";
+ const tunnelMatch = baseUrl && currentUrl.startsWith(baseUrl);
+ if (localMatch || cloudMatch || tunnelMatch) return "configured";
return "other";
};
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
index e2efbc78..0641c913 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
@@ -35,7 +35,8 @@ export default function DroidToolCard({
if (!currentConfig) return "not_configured";
const localMatch = currentConfig.baseUrl?.includes("localhost") || currentConfig.baseUrl?.includes("127.0.0.1");
const cloudMatch = cloudEnabled && CLOUD_URL && currentConfig.baseUrl?.startsWith(CLOUD_URL);
- if (localMatch || cloudMatch) return "configured";
+ const tunnelMatch = baseUrl && currentConfig.baseUrl?.startsWith(baseUrl);
+ if (localMatch || cloudMatch || tunnelMatch) return "configured";
return "other";
};
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
index 473258f9..fc1bf36c 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
@@ -32,7 +32,8 @@ export default function OpenClawToolCard({
const currentProvider = openclawStatus.settings?.models?.providers?.["9router"];
if (!currentProvider) return "not_configured";
const localMatch = currentProvider.baseUrl?.includes("localhost") || currentProvider.baseUrl?.includes("127.0.0.1") || currentProvider.baseUrl?.includes("0.0.0.0");
- if (localMatch) return "configured";
+ const tunnelMatch = baseUrl && currentProvider.baseUrl?.startsWith(baseUrl);
+ if (localMatch || tunnelMatch) return "configured";
return "other";
};
diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js
index 9f725c43..b5049376 100644
--- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js
+++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js
@@ -5,8 +5,19 @@ import PropTypes from "prop-types";
import { Card, Button, Input, Modal, CardSkeleton, Toggle } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
+/* ========== CLOUD CODE — COMMENTED OUT (replaced by Tunnel) ==========
const DEFAULT_CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL || "";
const CLOUD_ACTION_TIMEOUT_MS = 15000;
+========== END CLOUD CODE ========== */
+
+const TUNNEL_BENEFITS = [
+ { icon: "public", title: "Access Anywhere", desc: "Use your API from any network" },
+ { icon: "group", title: "Share Endpoint", desc: "Share URL with team members" },
+ { icon: "code", title: "Use in Cursor/Cline", desc: "Connect AI tools remotely" },
+ { icon: "lock", title: "Encrypted", desc: "End-to-end TLS via Cloudflare" },
+];
+
+const TUNNEL_ACTION_TIMEOUT_MS = 90000;
export default function APIPageClient({ machineId }) {
const [keys, setKeys] = useState([]);
@@ -15,8 +26,7 @@ export default function APIPageClient({ machineId }) {
const [newKeyName, setNewKeyName] = useState("");
const [createdKey, setCreatedKey] = useState(null);
- // Cloud sync state
- const [requireApiKey, setRequireApiKey] = useState(false);
+ /* ========== CLOUD STATE — COMMENTED OUT (replaced by Tunnel) ==========
const [cloudEnabled, setCloudEnabled] = useState(false);
const [cloudUrl, setCloudUrl] = useState(DEFAULT_CLOUD_URL);
const [cloudUrlInput, setCloudUrlInput] = useState(DEFAULT_CLOUD_URL);
@@ -27,15 +37,28 @@ export default function APIPageClient({ machineId }) {
const [setupStatus, setSetupStatus] = useState(null);
const [cloudSyncing, setCloudSyncing] = useState(false);
const [cloudStatus, setCloudStatus] = useState(null);
- const [syncStep, setSyncStep] = useState(""); // "syncing" | "verifying" | "disabling" | ""
+ const [syncStep, setSyncStep] = useState("");
+ ========== END CLOUD STATE ========== */
+
+ // Tunnel state
+ const [requireApiKey, setRequireApiKey] = useState(false);
+ const [tunnelEnabled, setTunnelEnabled] = useState(false);
+ const [tunnelUrl, setTunnelUrl] = useState("");
+ const [tunnelShortId, setTunnelShortId] = useState("");
+ const [tunnelLoading, setTunnelLoading] = useState(false);
+ const [tunnelProgress, setTunnelProgress] = useState("");
+ const [tunnelStatus, setTunnelStatus] = useState(null);
+ const [showDisableModal, setShowDisableModal] = useState(false);
+ const [showEnableModal, setShowEnableModal] = useState(false);
const { copied, copy } = useCopyToClipboard();
useEffect(() => {
fetchData();
- loadCloudSettings();
+ loadSettings();
}, []);
+ /* ========== CLOUD FUNCTIONS — COMMENTED OUT (replaced by Tunnel) ==========
const postCloudAction = async (action, timeoutMs = CLOUD_ACTION_TIMEOUT_MS) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -74,33 +97,6 @@ export default function APIPageClient({ machineId }) {
}
};
- const handleRequireApiKey = async (value) => {
- try {
- const res = await fetch("/api/settings", {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ requireApiKey: value }),
- });
- if (res.ok) setRequireApiKey(value);
- } catch (error) {
- console.log("Error updating requireApiKey:", error);
- }
- };
-
- const fetchData = async () => {
- try {
- const keysRes = await fetch("/api/keys");
- const keysData = await keysRes.json();
- if (keysRes.ok) {
- setKeys(keysData.keys || []);
- }
- } catch (error) {
- console.log("Error fetching data:", error);
- } finally {
- setLoading(false);
- }
- };
-
const handleCloudToggle = (checked) => {
if (checked) {
setShowCloudModal(true);
@@ -116,24 +112,16 @@ export default function APIPageClient({ machineId }) {
const { ok, data } = await postCloudAction("enable");
if (ok) {
setSyncStep("verifying");
-
if (data.verified) {
setCloudEnabled(true);
setCloudStatus({ type: "success", message: "Cloud Proxy connected and verified!" });
setShowCloudModal(false);
} else {
setCloudEnabled(true);
- setCloudStatus({
- type: "warning",
- message: data.verifyError || "Connected but verification failed"
- });
+ setCloudStatus({ type: "warning", message: data.verifyError || "Connected but verification failed" });
setShowCloudModal(false);
}
-
- // Refresh keys list if new key was created
- if (data.createdKey) {
- await fetchData();
- }
+ if (data.createdKey) await fetchData();
} else {
setCloudStatus({ type: "error", message: data.error || "Failed to enable cloud" });
}
@@ -148,16 +136,10 @@ export default function APIPageClient({ machineId }) {
const handleConfirmDisable = async () => {
setCloudSyncing(true);
setSyncStep("syncing");
-
try {
- // Step 1: Sync latest data from cloud
await postCloudAction("sync");
-
setSyncStep("disabling");
-
- // Step 2: Disable cloud
const { ok, data } = await postCloudAction("disable");
-
if (ok) {
setCloudEnabled(false);
setCloudStatus({ type: "success", message: "Cloud disabled" });
@@ -166,7 +148,6 @@ export default function APIPageClient({ machineId }) {
setCloudStatus({ type: "error", message: data.error || "Failed to disable cloud" });
}
} catch (error) {
- console.log("Error disabling cloud:", error);
setCloudStatus({ type: "error", message: "Failed to disable cloud" });
} finally {
setCloudSyncing(false);
@@ -176,15 +157,11 @@ export default function APIPageClient({ machineId }) {
const handleSyncCloud = async () => {
if (!cloudEnabled) return;
-
setCloudSyncing(true);
try {
const { ok, data } = await postCloudAction("sync");
- if (ok) {
- setCloudStatus({ type: "success", message: "Synced successfully" });
- } else {
- setCloudStatus({ type: "error", message: data.error });
- }
+ if (ok) setCloudStatus({ type: "success", message: "Synced successfully" });
+ else setCloudStatus({ type: "error", message: data.error });
} catch (error) {
setCloudStatus({ type: "error", message: error.message });
} finally {
@@ -193,10 +170,8 @@ export default function APIPageClient({ machineId }) {
};
const handleSaveCloudUrl = async () => {
- // Strip trailing /v1 or /v1/ and trailing slashes
const trimmed = cloudUrlInput.trim().replace(/\/v1\/?$/, "").replace(/\/+$/, "");
if (!trimmed) return;
-
setCloudUrlSaving(true);
setSetupStatus(null);
try {
@@ -225,17 +200,128 @@ export default function APIPageClient({ machineId }) {
setSetupStatus(null);
try {
const { ok, data } = await postCloudAction("check", 8000);
- if (ok) {
- setSetupStatus({ type: "success", message: data.message || "Worker is running" });
- } else {
- setSetupStatus({ type: "error", message: data.error || "Check failed" });
- }
+ if (ok) setSetupStatus({ type: "success", message: data.message || "Worker is running" });
+ else setSetupStatus({ type: "error", message: data.error || "Check failed" });
} catch {
setSetupStatus({ type: "error", message: "Cannot reach worker" });
} finally {
setCloudSyncing(false);
}
};
+ ========== END CLOUD FUNCTIONS ========== */
+
+ const loadSettings = async () => {
+ try {
+ const [settingsRes, tunnelRes] = await Promise.all([
+ fetch("/api/settings"),
+ fetch("/api/tunnel/status")
+ ]);
+ if (settingsRes.ok) {
+ const data = await settingsRes.json();
+ setRequireApiKey(data.requireApiKey || false);
+ }
+ if (tunnelRes.ok) {
+ const data = await tunnelRes.json();
+ setTunnelEnabled(data.enabled || false);
+ setTunnelUrl(data.tunnelUrl || "");
+ setTunnelShortId(data.shortId || "");
+ }
+ } catch (error) {
+ console.log("Error loading settings:", error);
+ }
+ };
+
+ const handleRequireApiKey = async (value) => {
+ try {
+ const res = await fetch("/api/settings", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ requireApiKey: value }),
+ });
+ if (res.ok) setRequireApiKey(value);
+ } catch (error) {
+ console.log("Error updating requireApiKey:", error);
+ }
+ };
+
+ const fetchData = async () => {
+ try {
+ const keysRes = await fetch("/api/keys");
+ const keysData = await keysRes.json();
+ if (keysRes.ok) {
+ setKeys(keysData.keys || []);
+ }
+ } catch (error) {
+ console.log("Error fetching data:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleEnableTunnel = async () => {
+ setShowEnableModal(false);
+ setTunnelLoading(true);
+ setTunnelStatus(null);
+ setTunnelProgress("Connecting to server...");
+
+ const progressSteps = [
+ { delay: 2000, msg: "Creating tunnel..." },
+ { delay: 5000, msg: "Starting cloudflared..." },
+ { delay: 15000, msg: "Establishing connections..." },
+ { delay: 30000, msg: "Waiting for tunnel ready..." },
+ ];
+ const timers = progressSteps.map(({ delay, msg }) =>
+ setTimeout(() => setTunnelProgress(msg), delay)
+ );
+
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), TUNNEL_ACTION_TIMEOUT_MS);
+ const res = await fetch("/api/tunnel/enable", {
+ method: "POST",
+ signal: controller.signal,
+ });
+ clearTimeout(timeoutId);
+ timers.forEach(clearTimeout);
+ const data = await res.json();
+ if (res.ok) {
+ setTunnelEnabled(true);
+ setTunnelUrl(data.tunnelUrl || "");
+ setTunnelShortId(data.shortId || "");
+ setTunnelStatus({ type: "success", message: "Tunnel connected!" });
+ } else {
+ setTunnelStatus({ type: "error", message: data.error || "Failed to enable tunnel" });
+ }
+ } catch (error) {
+ timers.forEach(clearTimeout);
+ const msg = error?.name === "AbortError" ? "Tunnel creation timed out" : error.message;
+ setTunnelStatus({ type: "error", message: msg });
+ } finally {
+ setTunnelLoading(false);
+ setTunnelProgress("");
+ }
+ };
+
+ const handleDisableTunnel = async () => {
+ setTunnelLoading(true);
+ setTunnelStatus(null);
+ try {
+ const res = await fetch("/api/tunnel/disable", { method: "POST" });
+ const data = await res.json();
+ if (res.ok) {
+ setTunnelEnabled(false);
+ setTunnelUrl("");
+ setTunnelStatus({ type: "success", message: "Tunnel disabled" });
+ setShowDisableModal(false);
+ } else {
+ setTunnelStatus({ type: "error", message: data.error || "Failed to disable tunnel" });
+ }
+ } catch (error) {
+ setTunnelStatus({ type: "error", message: error.message });
+ } finally {
+ setTunnelLoading(false);
+ }
+ };
const handleCreateKey = async () => {
if (!newKeyName.trim()) return;
@@ -288,7 +374,6 @@ export default function APIPageClient({ machineId }) {
};
const [baseUrl, setBaseUrl] = useState("/v1");
- const cloudEndpointNew = cloudUrl ? `${cloudUrl}/v1` : "";
// Hydration fix: Only access window on client side
useEffect(() => {
@@ -306,8 +391,7 @@ export default function APIPageClient({ machineId }) {
);
}
- // Use new format endpoint (machineId embedded in key)
- const currentEndpoint = cloudEnabled ? cloudEndpointNew : baseUrl;
+ const currentEndpoint = tunnelEnabled && tunnelUrl ? `${tunnelUrl}/v1` : baseUrl;
return (
- {cloudStatus.message}
+ {tunnelStatus.message}
)}
@@ -470,133 +551,9 @@ export default function APIPageClient({ machineId }) {
)}
- {/* Setup Cloud Modal */}
- !cloudSyncing && setShowDisableModal(false)}
+ title="Disable Tunnel"
+ onClose={() => !tunnelLoading && setShowDisableModal(false)}
>
@@ -682,46 +693,33 @@ export default function APIPageClient({ machineId }) {
Warning
- All auth sessions will be deleted from cloud.
+ The tunnel will be disconnected. Remote access will stop working.
- {/* Sync Progress */}
- {cloudSyncing && (
-
-
progress_activity
-
-
- {syncStep === "syncing" && "Syncing latest data..."}
- {syncStep === "disabling" && "Disabling cloud..."}
-
-
-
- )}
-
- Are you sure you want to disable cloud proxy?
+ Are you sure you want to disable the tunnel?
- {cloudSyncing ? (
+ {tunnelLoading ? (
progress_activity
- {syncStep === "syncing" ? "Syncing..." : "Disabling..."}
+ Disabling...
- ) : "Disable Cloud"}
+ ) : "Disable Tunnel"}
setShowDisableModal(false)}
variant="ghost"
fullWidth
- disabled={cloudSyncing}
+ disabled={tunnelLoading}
>
Cancel
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js
index 407e2b5e..eaf810da 100644
--- a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js
@@ -51,7 +51,7 @@ function ProviderNode({ data }) {
{/* Provider name */}
{label}
@@ -99,7 +99,7 @@ RouterNode.propTypes = {
const nodeTypes = { provider: ProviderNode, router: RouterNode };
// Place N nodes evenly along an ellipse around the router center.
-function buildLayout(providers, activeSet, lastSet) {
+function buildLayout(providers, activeSet, lastSet, errorSet) {
const nodeW = 180;
const nodeH = 30;
const routerW = 120;
@@ -130,8 +130,9 @@ function buildLayout(providers, activeSet, lastSet) {
draggable: false,
});
- const edgeStyle = (active, last, color) => {
- if (active) return { stroke: color, strokeWidth: 2, opacity: 0.8 };
+ const edgeStyle = (active, last, error, color) => {
+ if (error) return { stroke: "#ef4444", strokeWidth: 2.5, opacity: 0.9 };
+ if (active) return { stroke: "#22c55e", strokeWidth: 2.5, opacity: 0.9 };
if (last) return { stroke: "#f59e0b", strokeWidth: 2, opacity: 0.7 };
return { stroke: "var(--color-border)", strokeWidth: 1, opacity: 0.3 };
};
@@ -140,6 +141,7 @@ function buildLayout(providers, activeSet, lastSet) {
const config = getProviderConfig(p.provider);
const active = activeSet.has(p.provider?.toLowerCase());
const last = !active && lastSet.has(p.provider?.toLowerCase());
+ const error = !active && errorSet.has(p.provider?.toLowerCase());
const nodeId = `provider-${p.provider}`;
const data = {
label: config.name || p.name || p.provider,
@@ -181,28 +183,32 @@ function buildLayout(providers, activeSet, lastSet) {
target: nodeId,
targetHandle,
animated: active,
- style: edgeStyle(active, last, config.color),
+ style: edgeStyle(active, last, error, config.color),
});
});
return { nodes, edges };
}
-export default function ProviderTopology({ providers = [], activeRequests = [], lastProvider = "" }) {
+export default function ProviderTopology({ providers = [], activeRequests = [], lastProvider = "", errorProvider = "" }) {
const activeSet = useMemo(
() => new Set(activeRequests.map((r) => r.provider?.toLowerCase()).filter(Boolean)),
[activeRequests]
);
- // lastSet: providers that finished most recently (not currently active)
const lastSet = useMemo(
() => new Set(lastProvider ? [lastProvider.toLowerCase()] : []),
[lastProvider]
);
+ const errorSet = useMemo(
+ () => new Set(errorProvider ? [errorProvider.toLowerCase()] : []),
+ [errorProvider]
+ );
+
const { nodes: initialNodes, edges: initialEdges } = useMemo(
- () => buildLayout(providers, activeSet, lastSet),
- [providers, activeSet, lastSet]
+ () => buildLayout(providers, activeSet, lastSet, errorSet),
+ [providers, activeSet, lastSet, errorSet]
);
return (
@@ -246,4 +252,5 @@ ProviderTopology.propTypes = {
account: PropTypes.string,
})),
lastProvider: PropTypes.string,
+ errorProvider: PropTypes.string,
};
diff --git a/src/app/api/cloud/models/alias/route.js b/src/app/api/cloud/models/alias/route.js
index 8ae33dd7..c4ef56ea 100644
--- a/src/app/api/cloud/models/alias/route.js
+++ b/src/app/api/cloud/models/alias/route.js
@@ -1,7 +1,5 @@
import { NextResponse } from "next/server";
-import { validateApiKey, getModelAliases, setModelAlias, isCloudEnabled } from "@/models";
-import { getConsistentMachineId } from "@/shared/utils/machineId";
-import { syncToCloud } from "@/app/api/sync/cloud/route";
+import { validateApiKey, getModelAliases, setModelAlias } from "@/models";
// PUT /api/cloud/models/alias - Set model alias (for cloud/CLI)
export async function PUT(request) {
@@ -37,9 +35,6 @@ export async function PUT(request) {
// Update alias
await setModelAlias(alias, model);
- // Auto sync to Cloud if enabled
- await syncToCloudIfEnabled();
-
return NextResponse.json({
success: true,
model,
@@ -52,21 +47,6 @@ export async function PUT(request) {
}
}
-/**
- * Sync to Cloud if enabled
- */
-async function syncToCloudIfEnabled() {
- try {
- const cloudEnabled = await isCloudEnabled();
- if (!cloudEnabled) return;
-
- const machineId = await getConsistentMachineId();
- await syncToCloud(machineId);
- } catch (error) {
- console.log("Error syncing aliases to cloud:", error);
- }
-}
-
// GET /api/cloud/models/alias - Get all aliases
export async function GET(request) {
try {
diff --git a/src/app/api/combos/[id]/route.js b/src/app/api/combos/[id]/route.js
index af86fa04..6e114454 100644
--- a/src/app/api/combos/[id]/route.js
+++ b/src/app/api/combos/[id]/route.js
@@ -1,7 +1,5 @@
import { NextResponse } from "next/server";
-import { getComboById, updateCombo, deleteCombo, getComboByName, isCloudEnabled } from "@/lib/localDb";
-import { getConsistentMachineId } from "@/shared/utils/machineId";
-import { syncToCloud } from "@/app/api/sync/cloud/route";
+import { getComboById, updateCombo, deleteCombo, getComboByName } from "@/lib/localDb";
// Validate combo name: only a-z, A-Z, 0-9, -, _
const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
@@ -48,9 +46,6 @@ export async function PUT(request, { params }) {
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
}
- // Auto sync to Cloud if enabled
- await syncToCloudIfEnabled();
-
return NextResponse.json(combo);
} catch (error) {
console.log("Error updating combo:", error);
@@ -67,9 +62,6 @@ export async function DELETE(request, { params }) {
if (!success) {
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
}
-
- // Auto sync to Cloud if enabled
- await syncToCloudIfEnabled();
return NextResponse.json({ success: true });
} catch (error) {
@@ -77,18 +69,3 @@ export async function DELETE(request, { params }) {
return NextResponse.json({ error: "Failed to delete combo" }, { status: 500 });
}
}
-
-/**
- * Sync to Cloud if enabled
- */
-async function syncToCloudIfEnabled() {
- try {
- const cloudEnabled = await isCloudEnabled();
- if (!cloudEnabled) return;
-
- const machineId = await getConsistentMachineId();
- await syncToCloud(machineId);
- } catch (error) {
- console.log("Error syncing to cloud:", error);
- }
-}
diff --git a/src/app/api/combos/route.js b/src/app/api/combos/route.js
index fbb89d1e..965610f7 100644
--- a/src/app/api/combos/route.js
+++ b/src/app/api/combos/route.js
@@ -1,7 +1,5 @@
import { NextResponse } from "next/server";
-import { getCombos, createCombo, getComboByName, isCloudEnabled } from "@/lib/localDb";
-import { getConsistentMachineId } from "@/shared/utils/machineId";
-import { syncToCloud } from "@/app/api/sync/cloud/route";
+import { getCombos, createCombo, getComboByName } from "@/lib/localDb";
// Validate combo name: only a-z, A-Z, 0-9, -, _
const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
@@ -40,27 +38,9 @@ export async function POST(request) {
const combo = await createCombo({ name, models: models || [] });
- // Auto sync to Cloud if enabled
- await syncToCloudIfEnabled();
-
return NextResponse.json(combo, { status: 201 });
} catch (error) {
console.log("Error creating combo:", error);
return NextResponse.json({ error: "Failed to create combo" }, { status: 500 });
}
}
-
-/**
- * Sync to Cloud if enabled
- */
-async function syncToCloudIfEnabled() {
- try {
- const cloudEnabled = await isCloudEnabled();
- if (!cloudEnabled) return;
-
- const machineId = await getConsistentMachineId();
- await syncToCloud(machineId);
- } catch (error) {
- console.log("Error syncing to cloud:", error);
- }
-}
diff --git a/src/app/api/keys/[id]/route.js b/src/app/api/keys/[id]/route.js
index 1cfc0ff5..1d22596a 100644
--- a/src/app/api/keys/[id]/route.js
+++ b/src/app/api/keys/[id]/route.js
@@ -1,7 +1,5 @@
import { NextResponse } from "next/server";
-import { deleteApiKey, getApiKeyById, updateApiKey, isCloudEnabled } from "@/lib/localDb";
-import { getConsistentMachineId } from "@/shared/utils/machineId";
-import { syncToCloud } from "@/app/api/sync/cloud/route";
+import { deleteApiKey, getApiKeyById, updateApiKey } from "@/lib/localDb";
// GET /api/keys/[id] - Get single key
export async function GET(request, { params }) {
@@ -34,7 +32,6 @@ export async function PUT(request, { params }) {
if (isActive !== undefined) updateData.isActive = isActive;
const updated = await updateApiKey(id, updateData);
- await syncKeysToCloudIfEnabled();
return NextResponse.json({ key: updated });
} catch (error) {
@@ -53,27 +50,9 @@ export async function DELETE(request, { params }) {
return NextResponse.json({ error: "Key not found" }, { status: 404 });
}
- // Auto sync to Cloud if enabled
- await syncKeysToCloudIfEnabled();
-
return NextResponse.json({ message: "Key deleted successfully" });
} catch (error) {
console.log("Error deleting key:", error);
return NextResponse.json({ error: "Failed to delete key" }, { status: 500 });
}
}
-
-/**
- * Sync API keys to Cloud if enabled
- */
-async function syncKeysToCloudIfEnabled() {
- try {
- const cloudEnabled = await isCloudEnabled();
- if (!cloudEnabled) return;
-
- const machineId = await getConsistentMachineId();
- await syncToCloud(machineId);
- } catch (error) {
- console.log("Error syncing keys to cloud:", error);
- }
-}
diff --git a/src/app/api/keys/route.js b/src/app/api/keys/route.js
index 28bf0a2c..98d25408 100644
--- a/src/app/api/keys/route.js
+++ b/src/app/api/keys/route.js
@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
-import { getApiKeys, createApiKey, isCloudEnabled } from "@/lib/localDb";
+import { getApiKeys, createApiKey } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
-import { syncToCloud } from "@/app/api/sync/cloud/route";
// GET /api/keys - List API keys
export async function GET() {
@@ -28,9 +27,6 @@ export async function POST(request) {
const machineId = await getConsistentMachineId();
const apiKey = await createApiKey(name, machineId);
- // Auto sync to Cloud if enabled
- await syncKeysToCloudIfEnabled();
-
return NextResponse.json({
key: apiKey.key,
name: apiKey.name,
@@ -42,18 +38,3 @@ export async function POST(request) {
return NextResponse.json({ error: "Failed to create key" }, { status: 500 });
}
}
-
-/**
- * Sync API keys to Cloud if enabled
- */
-async function syncKeysToCloudIfEnabled() {
- try {
- const cloudEnabled = await isCloudEnabled();
- if (!cloudEnabled) return;
-
- const machineId = await getConsistentMachineId();
- await syncToCloud(machineId);
- } catch (error) {
- console.log("Error syncing keys to cloud:", error);
- }
-}
diff --git a/src/app/api/models/alias/route.js b/src/app/api/models/alias/route.js
index 30d8ce94..93cf3a66 100644
--- a/src/app/api/models/alias/route.js
+++ b/src/app/api/models/alias/route.js
@@ -1,7 +1,5 @@
import { NextResponse } from "next/server";
-import { getModelAliases, setModelAlias, deleteModelAlias, isCloudEnabled } from "@/models";
-import { getConsistentMachineId } from "@/shared/utils/machineId";
-import { syncToCloud } from "@/app/api/sync/cloud/route";
+import { getModelAliases, setModelAlias, deleteModelAlias } from "@/models";
// GET /api/models/alias - Get all aliases
export async function GET() {
@@ -25,7 +23,6 @@ export async function PUT(request) {
}
await setModelAlias(alias, model);
- await syncToCloudIfEnabled();
return NextResponse.json({ success: true, model, alias });
} catch (error) {
@@ -45,7 +42,6 @@ export async function DELETE(request) {
}
await deleteModelAlias(alias);
- await syncToCloudIfEnabled();
return NextResponse.json({ success: true });
} catch (error) {
@@ -53,15 +49,3 @@ export async function DELETE(request) {
return NextResponse.json({ error: "Failed to delete alias" }, { status: 500 });
}
}
-
-async function syncToCloudIfEnabled() {
- try {
- const cloudEnabled = await isCloudEnabled();
- if (!cloudEnabled) return;
-
- const machineId = await getConsistentMachineId();
- await syncToCloud(machineId);
- } catch (error) {
- console.log("Error syncing aliases to cloud:", error);
- }
-}
diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js
index 8d7a5480..9cf073ca 100644
--- a/src/app/api/oauth/[provider]/[action]/route.js
+++ b/src/app/api/oauth/[provider]/[action]/route.js
@@ -6,9 +6,7 @@ import {
requestDeviceCode,
pollForToken
} from "@/lib/oauth/providers";
-import { createProviderConnection, isCloudEnabled } from "@/models";
-import { getConsistentMachineId } from "@/shared/utils/machineId";
-import { syncToCloud } from "@/app/api/sync/cloud/route";
+import { createProviderConnection } from "@/models";
/**
* Dynamic OAuth API Route
@@ -89,9 +87,6 @@ export async function POST(request, { params }) {
testStatus: "active",
});
- // Auto sync to Cloud if enabled
- await syncToCloudIfEnabled();
-
return NextResponse.json({
success: true,
connection: {
@@ -138,9 +133,6 @@ export async function POST(request, { params }) {
testStatus: "active",
});
- // Auto sync to Cloud if enabled
- await syncToCloudIfEnabled();
-
return NextResponse.json({
success: true,
connection: {
@@ -167,18 +159,3 @@ export async function POST(request, { params }) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
-
-/**
- * Sync to Cloud if enabled
- */
-async function syncToCloudIfEnabled() {
- try {
- const cloudEnabled = await isCloudEnabled();
- if (!cloudEnabled) return;
-
- const machineId = await getConsistentMachineId();
- await syncToCloud(machineId);
- } catch (error) {
- console.log("Error syncing to cloud after OAuth:", error);
- }
-}
diff --git a/src/app/api/oauth/cursor/import/route.js b/src/app/api/oauth/cursor/import/route.js
index 5d6a2282..a1695e92 100644
--- a/src/app/api/oauth/cursor/import/route.js
+++ b/src/app/api/oauth/cursor/import/route.js
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server";
import { CursorService } from "@/lib/oauth/services/cursor";
-import { createProviderConnection, isCloudEnabled } from "@/models";
-import { getConsistentMachineId } from "@/shared/utils/machineId";
-import { syncToCloud } from "@/app/api/sync/cloud/route";
+import { createProviderConnection } from "@/models";
/**
* POST /api/oauth/cursor/import
@@ -58,9 +56,6 @@ export async function POST(request) {
testStatus: "active",
});
- // Auto sync to Cloud if enabled
- await syncToCloudIfEnabled();
-
return NextResponse.json({
success: true,
connection: {
@@ -103,18 +98,3 @@ export async function GET() {
],
});
}
-
-/**
- * Sync to Cloud if enabled
- */
-async function syncToCloudIfEnabled() {
- try {
- const cloudEnabled = await isCloudEnabled();
- if (!cloudEnabled) return;
-
- const machineId = await getConsistentMachineId();
- await syncToCloud(machineId);
- } catch (error) {
- console.log("Error syncing to cloud after Cursor import:", error);
- }
-}
diff --git a/src/app/api/oauth/kiro/import/route.js b/src/app/api/oauth/kiro/import/route.js
index c1db94b9..a278a1e8 100644
--- a/src/app/api/oauth/kiro/import/route.js
+++ b/src/app/api/oauth/kiro/import/route.js
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server";
import { KiroService } from "@/lib/oauth/services/kiro";
-import { createProviderConnection, isCloudEnabled } from "@/models";
-import { getConsistentMachineId } from "@/shared/utils/machineId";
-import { syncToCloud } from "@/app/api/sync/cloud/route";
+import { createProviderConnection } from "@/models";
/**
* POST /api/oauth/kiro/import
@@ -43,9 +41,6 @@ export async function POST(request) {
testStatus: "active",
});
- // Auto sync to Cloud if enabled
- await syncToCloudIfEnabled();
-
return NextResponse.json({
success: true,
connection: {
@@ -59,18 +54,3 @@ export async function POST(request) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
-
-/**
- * Sync to Cloud if enabled
- */
-async function syncToCloudIfEnabled() {
- try {
- const cloudEnabled = await isCloudEnabled();
- if (!cloudEnabled) return;
-
- const machineId = await getConsistentMachineId();
- await syncToCloud(machineId);
- } catch (error) {
- console.log("Error syncing to cloud after Kiro import:", error);
- }
-}
diff --git a/src/app/api/oauth/kiro/social-exchange/route.js b/src/app/api/oauth/kiro/social-exchange/route.js
index 5e916184..a99f276e 100644
--- a/src/app/api/oauth/kiro/social-exchange/route.js
+++ b/src/app/api/oauth/kiro/social-exchange/route.js
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server";
import { KiroService } from "@/lib/oauth/services/kiro";
-import { createProviderConnection, isCloudEnabled } from "@/models";
-import { getConsistentMachineId } from "@/shared/utils/machineId";
-import { syncToCloud } from "@/app/api/sync/cloud/route";
+import { createProviderConnection } from "@/models";
/**
* POST /api/oauth/kiro/social-exchange
@@ -54,9 +52,6 @@ export async function POST(request) {
testStatus: "active",
});
- // Auto sync to Cloud if enabled
- await syncToCloudIfEnabled();
-
return NextResponse.json({
success: true,
connection: {
@@ -70,18 +65,3 @@ export async function POST(request) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
-
-/**
- * Sync to Cloud if enabled
- */
-async function syncToCloudIfEnabled() {
- try {
- const cloudEnabled = await isCloudEnabled();
- if (!cloudEnabled) return;
-
- const machineId = await getConsistentMachineId();
- await syncToCloud(machineId);
- } catch (error) {
- console.log("Error syncing to cloud after Kiro OAuth:", error);
- }
-}
diff --git a/src/app/api/providers/[id]/route.js b/src/app/api/providers/[id]/route.js
index 2951b833..aec181ae 100644
--- a/src/app/api/providers/[id]/route.js
+++ b/src/app/api/providers/[id]/route.js
@@ -1,7 +1,5 @@
import { NextResponse } from "next/server";
-import { getProviderConnectionById, updateProviderConnection, deleteProviderConnection, isCloudEnabled } from "@/models";
-import { getConsistentMachineId } from "@/shared/utils/machineId";
-import { syncToCloud } from "@/app/api/sync/cloud/route";
+import { getProviderConnectionById, updateProviderConnection, deleteProviderConnection } from "@/models";
// GET /api/providers/[id] - Get single connection
export async function GET(request, { params }) {
@@ -59,9 +57,6 @@ export async function PUT(request, { params }) {
delete result.refreshToken;
delete result.idToken;
- // Auto sync to Cloud if enabled
- await syncToCloudIfEnabled();
-
return NextResponse.json({ connection: result });
} catch (error) {
console.log("Error updating connection:", error);
@@ -79,27 +74,9 @@ export async function DELETE(request, { params }) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
- // Auto sync to Cloud if enabled
- await syncToCloudIfEnabled();
-
return NextResponse.json({ message: "Connection deleted successfully" });
} catch (error) {
console.log("Error deleting connection:", error);
return NextResponse.json({ error: "Failed to delete connection" }, { status: 500 });
}
}
-
-/**
- * Sync to Cloud if enabled
- */
-async function syncToCloudIfEnabled() {
- try {
- const cloudEnabled = await isCloudEnabled();
- if (!cloudEnabled) return;
-
- const machineId = await getConsistentMachineId();
- await syncToCloud(machineId);
- } catch (error) {
- console.log("Error syncing providers to cloud:", error);
- }
-}
diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js
index 7f165930..727054c9 100644
--- a/src/app/api/providers/[id]/test/testUtils.js
+++ b/src/app/api/providers/[id]/test/testUtils.js
@@ -1,6 +1,4 @@
-import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb";
-import { getConsistentMachineId } from "@/shared/utils/machineId";
-import { syncToCloud } from "@/app/api/sync/cloud/route";
+import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import {
GEMINI_CONFIG,
@@ -325,17 +323,5 @@ export async function testSingleConnection(id) {
await updateProviderConnection(id, updateData);
- if (result.refreshed) {
- try {
- const cloudEnabled = await isCloudEnabled();
- if (cloudEnabled) {
- const machineId = await getConsistentMachineId();
- await syncToCloud(machineId);
- }
- } catch (err) {
- console.log("Error syncing to cloud after token refresh:", err);
- }
- }
-
return { valid: result.valid, error: result.error, latencyMs, testedAt: new Date().toISOString() };
}
diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js
index bb957195..8ca7a353 100644
--- a/src/app/api/providers/route.js
+++ b/src/app/api/providers/route.js
@@ -1,9 +1,7 @@
import { NextResponse } from "next/server";
-import { getProviderConnections, createProviderConnection, getProviderNodeById, isCloudEnabled } from "@/models";
+import { getProviderConnections, createProviderConnection, getProviderNodeById } from "@/models";
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
-import { getConsistentMachineId } from "@/shared/utils/machineId";
-import { syncToCloud } from "@/app/api/sync/cloud/route";
// GET /api/providers - List all connections
export async function GET() {
@@ -101,27 +99,9 @@ export async function POST(request) {
const result = { ...newConnection };
delete result.apiKey;
- // Auto sync to Cloud if enabled
- await syncToCloudIfEnabled();
-
return NextResponse.json({ connection: result }, { status: 201 });
} catch (error) {
console.log("Error creating provider:", error);
return NextResponse.json({ error: "Failed to create provider" }, { status: 500 });
}
}
-
-/**
- * Sync to Cloud if enabled
- */
-async function syncToCloudIfEnabled() {
- try {
- const cloudEnabled = await isCloudEnabled();
- if (!cloudEnabled) return;
-
- const machineId = await getConsistentMachineId();
- await syncToCloud(machineId);
- } catch (error) {
- console.log("Error syncing providers to cloud:", error);
- }
-}
diff --git a/src/app/api/sync/cloud/route.js b/src/app/api/sync/cloud/route.js
deleted file mode 100644
index 46a972fd..00000000
--- a/src/app/api/sync/cloud/route.js
+++ /dev/null
@@ -1,318 +0,0 @@
-import { NextResponse } from "next/server";
-import { getProviderConnections, getModelAliases, getCombos, getApiKeys, createApiKey, updateProviderConnection, updateSettings, getCloudUrl } from "@/lib/localDb";
-import { getConsistentMachineId } from "@/shared/utils/machineId";
-import fs from "fs/promises";
-import path from "path";
-import os from "os";
-
-const CLOUD_SYNC_TIMEOUT_MS = Number(process.env.CLOUD_SYNC_TIMEOUT_MS || 12000);
-
-async function getResolvedCloudUrl() {
- return await getCloudUrl();
-}
-
-async function fetchWithTimeout(url, options = {}, timeoutMs = CLOUD_SYNC_TIMEOUT_MS) {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
- try {
- return await fetch(url, { ...options, signal: controller.signal });
- } finally {
- clearTimeout(timeoutId);
- }
-}
-
-/**
- * POST /api/sync/cloud
- * Sync data with Cloud
- */
-export async function POST(request) {
- try {
- const body = await request.json();
- const { action } = body;
-
- // Always get machineId from server, don't trust client
- const machineId = await getConsistentMachineId();
-
- switch (action) {
- case "enable":
- await updateSettings({ cloudEnabled: true });
- // Auto create key if none exists
- const keys = await getApiKeys();
- let createdKey = null;
- if (keys.length === 0) {
- createdKey = await createApiKey("Default Key", machineId);
- }
- return syncAndVerify(machineId, createdKey?.key, keys);
- case "sync": {
- const syncResult = await syncToCloud(machineId);
- if (syncResult.error) {
- return NextResponse.json(syncResult, { status: 502 });
- }
- return NextResponse.json(syncResult);
- }
- case "disable":
- await updateSettings({ cloudEnabled: false });
- return handleDisable(machineId, request);
- case "check":
- return handleCheck();
- default:
- return NextResponse.json({ error: "Invalid action" }, { status: 400 });
- }
- } catch (error) {
- console.log("Cloud sync error:", error);
- return NextResponse.json({ error: error.message }, { status: 500 });
- }
-}
-
-/**
- * Sync data to Cloud (exported for reuse)
- * @param {string} machineId
- * @param {string|null} createdKey - Key created during enable
- */
-export async function syncToCloud(machineId, createdKey = null) {
- const cloudUrl = await getResolvedCloudUrl();
- if (!cloudUrl) {
- return { error: "Cloud URL is not configured" };
- }
-
- // Get current data from db
- const providers = await getProviderConnections();
- const modelAliases = await getModelAliases();
- const combos = await getCombos();
- const apiKeys = await getApiKeys();
-
- let response;
- try {
- // Send to Cloud
- response = await fetchWithTimeout(`${cloudUrl}/sync/${machineId}`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- providers,
- modelAliases,
- combos,
- apiKeys
- })
- });
- } catch (error) {
- const isTimeout = error?.name === "AbortError";
- return { error: isTimeout ? "Cloud sync timeout" : "Cloud sync request failed" };
- }
-
- if (!response.ok) {
- const errorText = await response.text();
- console.log("Cloud sync failed:", errorText);
- return { error: "Cloud sync failed" };
- }
-
- const result = await response.json();
-
- // Update local db with tokens from Cloud (providers stored by ID)
- if (result.data && result.data.providers) {
- await updateLocalTokens(result.data.providers);
- }
-
- const responseData = {
- success: true,
- message: "Synced successfully",
- changes: result.changes
- };
-
- if (createdKey) {
- responseData.createdKey = createdKey;
- }
-
- return responseData;
-}
-
-/**
- * Sync and verify connection with ping
- */
-async function syncAndVerify(machineId, createdKey, existingKeys) {
- // Step 1: Sync data to cloud
- const syncResult = await syncToCloud(machineId, createdKey);
- if (syncResult.error) {
- return NextResponse.json(syncResult, { status: 502 });
- }
-
- // Step 2: Verify connection by pinging the cloud
- const apiKey = createdKey || existingKeys[0]?.key;
- if (!apiKey) {
- return NextResponse.json({
- ...syncResult,
- verified: false,
- verifyError: "No API key available"
- });
- }
-
- try {
- const cloudUrl = await getResolvedCloudUrl();
- const pingResponse = await fetchWithTimeout(`${cloudUrl}/${machineId}/v1/verify`, {
- method: "GET",
- headers: {
- "Authorization": `Bearer ${apiKey}`,
- "Content-Type": "application/json"
- }
- });
-
- if (pingResponse.ok) {
- return NextResponse.json({
- ...syncResult,
- verified: true
- });
- } else {
- return NextResponse.json({
- ...syncResult,
- verified: false,
- verifyError: `Ping failed: ${pingResponse.status}`
- });
- }
- } catch (error) {
- return NextResponse.json({
- ...syncResult,
- verified: false,
- verifyError: error.message
- });
- }
-}
-
-/**
- * Disable Cloud - delete cache and update Claude CLI settings
- */
-async function handleDisable(machineId, request) {
- const cloudUrl = await getResolvedCloudUrl();
- if (!cloudUrl) {
- return NextResponse.json({ error: "Cloud URL is not configured" }, { status: 500 });
- }
-
- let response;
- try {
- response = await fetchWithTimeout(`${cloudUrl}/sync/${machineId}`, {
- method: "DELETE"
- });
- } catch (error) {
- const isTimeout = error?.name === "AbortError";
- return NextResponse.json(
- { error: isTimeout ? "Cloud disable timeout" : "Failed to reach cloud service" },
- { status: 502 }
- );
- }
-
- if (!response.ok) {
- const errorText = await response.text();
- console.log("Cloud disable failed:", errorText);
- return NextResponse.json({ error: "Failed to disable cloud" }, { status: 502 });
- }
-
- // Update Claude CLI settings to use local endpoint
- const host = request.headers.get("host") || "localhost:20128";
- await updateClaudeSettingsToLocal(machineId, host, cloudUrl);
-
- return NextResponse.json({
- success: true,
- message: "Cloud disabled"
- });
-}
-
-/**
- * Update Claude CLI settings to use local endpoint (only if currently using cloud)
- */
-async function updateClaudeSettingsToLocal(machineId, host, cloudUrl) {
- try {
- const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
- const cloudEndpoint = `${cloudUrl}/${machineId}`;
- const localUrl = `http://${host}`;
-
- // Read current settings
- let settings;
- try {
- const content = await fs.readFile(settingsPath, "utf-8");
- settings = JSON.parse(content);
- } catch (error) {
- if (error.code === "ENOENT") {
- return; // No settings file, nothing to update
- }
- throw error;
- }
-
- // Check if ANTHROPIC_BASE_URL matches cloud URL
- const currentUrl = settings.env?.ANTHROPIC_BASE_URL;
- if (!currentUrl || currentUrl !== cloudEndpoint) {
- return; // Not using cloud URL, don't modify
- }
-
- // Update to local URL
- settings.env.ANTHROPIC_BASE_URL = localUrl;
- await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
- console.log(`Updated Claude CLI settings: ${cloudEndpoint} → ${localUrl}`);
- } catch (error) {
- console.log("Failed to update Claude CLI settings:", error.message);
- }
-}
-
-/**
- * Check if cloud worker is reachable
- */
-async function handleCheck() {
- const cloudUrl = await getResolvedCloudUrl();
- if (!cloudUrl) {
- return NextResponse.json({ error: "Cloud URL is not configured" }, { status: 400 });
- }
-
- try {
- const res = await fetchWithTimeout(`${cloudUrl}/health`, { method: "GET" }, 5000);
- if (res.ok) {
- return NextResponse.json({ success: true, message: "Worker is running" });
- }
- return NextResponse.json({ error: `Worker responded with ${res.status}` }, { status: 502 });
- } catch (error) {
- const isTimeout = error?.name === "AbortError";
- return NextResponse.json(
- { error: isTimeout ? "Worker request timeout" : "Cannot reach worker" },
- { status: 502 }
- );
- }
-}
-
-/**
- * Update local db with data from Cloud
- * Simple logic: if Cloud is newer, sync entire provider
- * cloudProviders is object keyed by provider ID
- */
-async function updateLocalTokens(cloudProviders) {
- const localProviders = await getProviderConnections();
-
- for (const localProvider of localProviders) {
- const cloudProvider = cloudProviders[localProvider.id];
- if (!cloudProvider) continue;
-
- const cloudUpdatedAt = new Date(cloudProvider.updatedAt || 0).getTime();
- const localUpdatedAt = new Date(localProvider.updatedAt || 0).getTime();
-
- // Simple logic: if Cloud is newer, sync entire provider
- if (cloudUpdatedAt > localUpdatedAt) {
- const updates = {
- // Tokens
- accessToken: cloudProvider.accessToken,
- refreshToken: cloudProvider.refreshToken,
- expiresAt: cloudProvider.expiresAt,
- expiresIn: cloudProvider.expiresIn,
-
- // Provider specific data
- providerSpecificData: cloudProvider.providerSpecificData || localProvider.providerSpecificData,
-
- // Status fields
- testStatus: cloudProvider.status || "active",
- lastError: cloudProvider.lastError,
- lastErrorAt: cloudProvider.lastErrorAt,
- errorCode: cloudProvider.errorCode,
- rateLimitedUntil: cloudProvider.rateLimitedUntil,
-
- // Metadata
- updatedAt: cloudProvider.updatedAt
- };
-
- await updateProviderConnection(localProvider.id, updates);
- }
- }
-}
diff --git a/src/app/api/sync/initialize/route.js b/src/app/api/sync/initialize/route.js
deleted file mode 100644
index 524cc85e..00000000
--- a/src/app/api/sync/initialize/route.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { NextResponse } from "next/server";
-import initializeCloudSync from "@/shared/services/initializeCloudSync";
-
-let syncInitialized = false;
-
-// POST /api/sync/initialize - Initialize cloud sync scheduler
-export async function POST(request) {
- try {
- if (syncInitialized) {
- return NextResponse.json({
- message: "Cloud sync already initialized"
- });
- }
-
- await initializeCloudSync();
- syncInitialized = true;
-
- return NextResponse.json({
- success: true,
- message: "Cloud sync initialized successfully"
- });
- } catch (error) {
- console.log("Error initializing cloud sync:", error);
- return NextResponse.json({
- error: "Failed to initialize cloud sync"
- }, { status: 500 });
- }
-}
-
-// GET /api/sync/status - Check sync initialization status
-export async function GET(request) {
- return NextResponse.json({
- initialized: syncInitialized,
- message: syncInitialized ? "Cloud sync is running" : "Cloud sync not initialized"
- });
-}
diff --git a/src/app/api/tunnel/disable/route.js b/src/app/api/tunnel/disable/route.js
new file mode 100644
index 00000000..e0735f85
--- /dev/null
+++ b/src/app/api/tunnel/disable/route.js
@@ -0,0 +1,12 @@
+import { NextResponse } from "next/server";
+import { disableTunnel } from "@/lib/tunnel/tunnelManager";
+
+export async function POST() {
+ try {
+ const result = await disableTunnel();
+ return NextResponse.json(result);
+ } catch (error) {
+ console.error("Tunnel disable error:", error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/tunnel/enable/route.js b/src/app/api/tunnel/enable/route.js
new file mode 100644
index 00000000..1b2fdc4a
--- /dev/null
+++ b/src/app/api/tunnel/enable/route.js
@@ -0,0 +1,12 @@
+import { NextResponse } from "next/server";
+import { enableTunnel } from "@/lib/tunnel/tunnelManager";
+
+export async function POST() {
+ try {
+ const result = await enableTunnel();
+ return NextResponse.json(result);
+ } catch (error) {
+ console.error("Tunnel enable error:", error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/tunnel/status/route.js b/src/app/api/tunnel/status/route.js
new file mode 100644
index 00000000..6cc4e87b
--- /dev/null
+++ b/src/app/api/tunnel/status/route.js
@@ -0,0 +1,12 @@
+import { NextResponse } from "next/server";
+import { getTunnelStatus } from "@/lib/tunnel/tunnelManager";
+
+export async function GET() {
+ try {
+ const status = await getTunnelStatus();
+ return NextResponse.json(status);
+ } catch (error) {
+ console.error("Tunnel status error:", error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js
index 7256fc32..9439db0e 100644
--- a/src/app/api/usage/[connectionId]/route.js
+++ b/src/app/api/usage/[connectionId]/route.js
@@ -1,22 +1,6 @@
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
-import { getMachineId } from "@/shared/utils/machine";
import { getUsageForProvider } from "open-sse/services/usage.js";
import { getExecutor } from "open-sse/executors/index.js";
-import { syncToCloud } from "@/app/api/sync/cloud/route";
-
-/**
- * Sync to cloud if enabled
- */
-async function syncToCloudIfEnabled() {
- try {
- const machineId = await getMachineId();
- if (!machineId) return;
- await syncToCloud(machineId);
- } catch (error) {
- console.error("[Usage API] Error syncing to cloud:", error);
- }
-}
-
/**
* Refresh credentials using executor and update database
* @returns {{ connection, refreshed: boolean }}
@@ -119,16 +103,9 @@ export async function GET(request, { params }) {
}
// Refresh credentials if needed using executor
- let refreshed = false;
try {
const result = await refreshAndUpdateCredentials(connection);
connection = result.connection;
- refreshed = result.refreshed;
-
- // Sync to cloud only if token was refreshed
- if (refreshed) {
- await syncToCloudIfEnabled();
- }
} catch (refreshError) {
console.error("[Usage API] Credential refresh failed:", refreshError);
return Response.json({
diff --git a/src/app/api/usage/stream/route.js b/src/app/api/usage/stream/route.js
index fc1a513d..acfad388 100644
--- a/src/app/api/usage/stream/route.js
+++ b/src/app/api/usage/stream/route.js
@@ -1,33 +1,53 @@
-import { getUsageStats, statsEmitter } from "@/lib/usageDb";
+import { getUsageStats, statsEmitter, getActiveRequests } from "@/lib/usageDb";
export const dynamic = "force-dynamic";
export async function GET() {
const encoder = new TextEncoder();
- const state = { closed: false, keepalive: null, send: null };
+ const state = { closed: false, keepalive: null, send: null, sendPending: null, cachedStats: null };
const stream = new ReadableStream({
async start(controller) {
+ // Full stats refresh (heavy) + immediate lightweight push
state.send = async () => {
if (state.closed) return;
try {
- const stats = await getUsageStats();
- if (stats.activeRequests?.length > 0) {
- console.log(`[SSE] Push | active=${stats.activeRequests.length} | ${stats.activeRequests.map(r => r.provider).join(",")}`);
+ // Push lightweight update immediately so UI reflects changes fast
+ if (state.cachedStats) {
+ const { activeRequests, recentRequests, errorProvider } = await getActiveRequests();
+ const quickStats = { ...state.cachedStats, activeRequests, recentRequests, errorProvider };
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(quickStats)}\n\n`));
}
+ // Then do full recalc and update cache
+ const stats = await getUsageStats();
+ state.cachedStats = stats;
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
} catch {
- // Controller closed → self-cleanup
state.closed = true;
statsEmitter.off("update", state.send);
+ statsEmitter.off("pending", state.sendPending);
clearInterval(state.keepalive);
}
};
+ // Lightweight push: only refresh activeRequests + recentRequests on pending changes
+ state.sendPending = async () => {
+ if (state.closed || !state.cachedStats) return;
+ try {
+ const { activeRequests, recentRequests, errorProvider } = await getActiveRequests();
+ const stats = { ...state.cachedStats, activeRequests, recentRequests, errorProvider };
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
+ } catch {
+ state.closed = true;
+ statsEmitter.off("pending", state.sendPending);
+ }
+ };
+
await state.send();
console.log(`[SSE] Client connected | listeners=${statsEmitter.listenerCount("update") + 1}`);
statsEmitter.on("update", state.send);
+ statsEmitter.on("pending", state.sendPending);
state.keepalive = setInterval(() => {
if (state.closed) { clearInterval(state.keepalive); return; }
@@ -43,6 +63,7 @@ export async function GET() {
cancel() {
state.closed = true;
statsEmitter.off("update", state.send);
+ statsEmitter.off("pending", state.sendPending);
clearInterval(state.keepalive);
console.log("[SSE] Client disconnected");
},
diff --git a/src/lib/initCloudSync.js b/src/lib/initCloudSync.js
index 7479ee9e..8caca5ea 100644
--- a/src/lib/initCloudSync.js
+++ b/src/lib/initCloudSync.js
@@ -1,22 +1,20 @@
-import initializeCloudSync from "@/shared/services/initializeCloudSync";
+import initializeApp from "@/shared/services/initializeApp";
-// Initialize cloud sync when this module is imported
let initialized = false;
-export async function ensureCloudSyncInitialized() {
+export async function ensureAppInitialized() {
if (!initialized) {
try {
- await initializeCloudSync();
+ await initializeApp();
initialized = true;
} catch (error) {
- console.error("[ServerInit] Error initializing cloud sync:", error);
+ console.error("[ServerInit] Error initializing app:", error);
}
}
return initialized;
}
// Auto-initialize when module loads
-ensureCloudSyncInitialized().catch(console.log);
-
-export default ensureCloudSyncInitialized;
+ensureAppInitialized().catch(console.log);
+export default ensureAppInitialized;
diff --git a/src/lib/localDb.js b/src/lib/localDb.js
index 34227a8c..5a9ab193 100644
--- a/src/lib/localDb.js
+++ b/src/lib/localDb.js
@@ -49,6 +49,8 @@ const defaultData = {
apiKeys: [],
settings: {
cloudEnabled: false,
+ tunnelEnabled: false,
+ tunnelUrl: "",
stickyRoundRobinLimit: 3,
requireLogin: true,
observabilityEnabled: true,
@@ -70,6 +72,8 @@ function cloneDefaultData() {
apiKeys: [],
settings: {
cloudEnabled: false,
+ tunnelEnabled: false,
+ tunnelUrl: "",
stickyRoundRobinLimit: 3,
requireLogin: true,
observabilityEnabled: true,
diff --git a/src/lib/tunnel/cloudflared.js b/src/lib/tunnel/cloudflared.js
new file mode 100644
index 00000000..e9f57dd6
--- /dev/null
+++ b/src/lib/tunnel/cloudflared.js
@@ -0,0 +1,193 @@
+import fs from "fs";
+import path from "path";
+import https from "https";
+import os from "os";
+import { execSync, spawn } from "child_process";
+import { savePid, loadPid, clearPid } from "./state.js";
+
+const BIN_DIR = path.join(os.homedir(), ".9router", "bin");
+const BINARY_NAME = "cloudflared";
+const IS_WINDOWS = os.platform() === "win32";
+const BIN_NAME = IS_WINDOWS ? `${BINARY_NAME}.exe` : BINARY_NAME;
+const BIN_PATH = path.join(BIN_DIR, BIN_NAME);
+
+const GITHUB_BASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download";
+
+const PLATFORM_MAPPINGS = {
+ darwin: {
+ x64: "cloudflared-darwin-amd64.tgz",
+ arm64: "cloudflared-darwin-amd64.tgz"
+ },
+ win32: {
+ x64: "cloudflared-windows-amd64.exe"
+ },
+ linux: {
+ x64: "cloudflared-linux-amd64",
+ arm64: "cloudflared-linux-arm64"
+ }
+};
+
+function getDownloadUrl() {
+ const platform = os.platform();
+ const arch = os.arch();
+
+ const platformMapping = PLATFORM_MAPPINGS[platform];
+ if (!platformMapping) {
+ throw new Error(`Unsupported platform: ${platform}`);
+ }
+
+ const binaryName = platformMapping[arch];
+ if (!binaryName) {
+ throw new Error(`Unsupported architecture: ${arch} for platform ${platform}`);
+ }
+
+ return `${GITHUB_BASE_URL}/${binaryName}`;
+}
+
+function downloadFile(url, dest) {
+ return new Promise((resolve, reject) => {
+ const file = fs.createWriteStream(dest);
+
+ https.get(url, (response) => {
+ if ([301, 302].includes(response.statusCode)) {
+ file.close();
+ fs.unlinkSync(dest);
+ downloadFile(response.headers.location, dest).then(resolve).catch(reject);
+ return;
+ }
+
+ if (response.statusCode !== 200) {
+ file.close();
+ fs.unlinkSync(dest);
+ reject(new Error(`Download failed with status ${response.statusCode}`));
+ return;
+ }
+
+ response.pipe(file);
+
+ file.on("finish", () => {
+ file.close(() => resolve(dest));
+ });
+
+ file.on("error", (err) => {
+ file.close();
+ fs.unlinkSync(dest);
+ reject(err);
+ });
+ }).on("error", (err) => {
+ file.close();
+ if (fs.existsSync(dest)) fs.unlinkSync(dest);
+ reject(err);
+ });
+ });
+}
+
+export async function ensureCloudflared() {
+ if (!fs.existsSync(BIN_DIR)) {
+ fs.mkdirSync(BIN_DIR, { recursive: true });
+ }
+
+ if (fs.existsSync(BIN_PATH)) {
+ if (!IS_WINDOWS) {
+ fs.chmodSync(BIN_PATH, "755");
+ }
+ return BIN_PATH;
+ }
+
+ const url = getDownloadUrl();
+ const isArchive = url.endsWith(".tgz");
+ const downloadDest = isArchive ? path.join(BIN_DIR, "cloudflared.tgz") : BIN_PATH;
+
+ await downloadFile(url, downloadDest);
+
+ if (isArchive) {
+ execSync(`tar -xzf "${downloadDest}" -C "${BIN_DIR}"`, { stdio: "pipe" });
+ fs.unlinkSync(downloadDest);
+ }
+
+ if (!IS_WINDOWS) {
+ fs.chmodSync(BIN_PATH, "755");
+ }
+
+ return BIN_PATH;
+}
+
+let cloudflaredProcess = null;
+
+export async function spawnCloudflared(tunnelToken) {
+ const binaryPath = await ensureCloudflared();
+
+ const child = spawn(binaryPath, ["tunnel", "run", "--dns-resolver-addrs", "1.1.1.1:53", "--token", tunnelToken], {
+ detached: false,
+ stdio: ["ignore", "pipe", "pipe"]
+ });
+
+ cloudflaredProcess = child;
+ savePid(child.pid);
+
+ return new Promise((resolve, reject) => {
+ let connectionCount = 0;
+ const timeout = setTimeout(() => {
+ resolve(child);
+ }, 90000);
+
+ const handleLog = (data) => {
+ const msg = data.toString();
+ if (msg.includes("Registered tunnel connection")) {
+ connectionCount++;
+ if (connectionCount >= 4) {
+ clearTimeout(timeout);
+ resolve(child);
+ }
+ }
+ };
+
+ child.stdout.on("data", handleLog);
+ child.stderr.on("data", handleLog);
+
+ child.on("error", (err) => {
+ clearTimeout(timeout);
+ reject(err);
+ });
+
+ child.on("exit", (code) => {
+ clearTimeout(timeout);
+ if (connectionCount === 0) {
+ reject(new Error(`cloudflared exited with code ${code}`));
+ }
+ });
+ });
+}
+
+export function killCloudflared() {
+ if (cloudflaredProcess) {
+ try {
+ cloudflaredProcess.kill();
+ } catch (e) { /* ignore */ }
+ cloudflaredProcess = null;
+ }
+
+ const pid = loadPid();
+ if (pid) {
+ try {
+ process.kill(pid);
+ } catch (e) { /* ignore */ }
+ clearPid();
+ }
+
+ // Kill any remaining cloudflared processes
+ try {
+ execSync("pkill -f cloudflared 2>/dev/null || true", { stdio: "ignore" });
+ } catch (e) { /* ignore */ }
+}
+
+export function isCloudflaredRunning() {
+ const pid = loadPid();
+ if (!pid) return false;
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
diff --git a/src/lib/tunnel/state.js b/src/lib/tunnel/state.js
new file mode 100644
index 00000000..54c7fca5
--- /dev/null
+++ b/src/lib/tunnel/state.js
@@ -0,0 +1,53 @@
+import fs from "fs";
+import path from "path";
+import os from "os";
+
+const TUNNEL_DIR = path.join(os.homedir(), ".9router", "tunnel");
+const STATE_FILE = path.join(TUNNEL_DIR, "state.json");
+const PID_FILE = path.join(TUNNEL_DIR, "cloudflared.pid");
+
+function ensureDir() {
+ if (!fs.existsSync(TUNNEL_DIR)) {
+ fs.mkdirSync(TUNNEL_DIR, { recursive: true });
+ }
+}
+
+export function loadState() {
+ try {
+ if (fs.existsSync(STATE_FILE)) {
+ return JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
+ }
+ } catch (e) { /* ignore corrupt state */ }
+ return null;
+}
+
+export function saveState(state) {
+ ensureDir();
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
+}
+
+export function clearState() {
+ try {
+ if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);
+ } catch (e) { /* ignore */ }
+}
+
+export function savePid(pid) {
+ ensureDir();
+ fs.writeFileSync(PID_FILE, pid.toString());
+}
+
+export function loadPid() {
+ try {
+ if (fs.existsSync(PID_FILE)) {
+ return parseInt(fs.readFileSync(PID_FILE, "utf8"));
+ }
+ } catch (e) { /* ignore */ }
+ return null;
+}
+
+export function clearPid() {
+ try {
+ if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
+ } catch (e) { /* ignore */ }
+}
diff --git a/src/lib/tunnel/tunnelManager.js b/src/lib/tunnel/tunnelManager.js
new file mode 100644
index 00000000..557f4124
--- /dev/null
+++ b/src/lib/tunnel/tunnelManager.js
@@ -0,0 +1,120 @@
+import crypto from "crypto";
+import { loadState, saveState, clearState } from "./state.js";
+import { spawnCloudflared, killCloudflared, isCloudflaredRunning } from "./cloudflared.js";
+import { getSettings, updateSettings } from "@/lib/localDb";
+
+const TUNNEL_WORKER_URL = process.env.TUNNEL_WORKER_URL || "https://tunnel.9router.com";
+const MACHINE_ID_SALT = "9router-tunnel-salt";
+const API_KEY_SECRET = "9router-tunnel-api-key-secret";
+const SHORT_ID_LENGTH = 6;
+const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
+
+function generateShortId() {
+ let result = "";
+ for (let i = 0; i < SHORT_ID_LENGTH; i++) {
+ result += SHORT_ID_CHARS.charAt(Math.floor(Math.random() * SHORT_ID_CHARS.length));
+ }
+ return result;
+}
+
+function getMachineId() {
+ try {
+ const { machineIdSync } = require("node-machine-id");
+ const raw = machineIdSync();
+ return crypto.createHash("sha256").update(raw + MACHINE_ID_SALT).digest("hex").substring(0, 16);
+ } catch (e) {
+ return crypto.randomUUID().replace(/-/g, "").substring(0, 16);
+ }
+}
+
+function generateApiKey(machineId) {
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
+ let keyId = "";
+ for (let i = 0; i < 6; i++) {
+ keyId += chars.charAt(Math.floor(Math.random() * chars.length));
+ }
+ const crc = crypto.createHmac("sha256", API_KEY_SECRET).update(machineId + keyId).digest("hex").slice(0, 8);
+ return `sk-${machineId}-${keyId}-${crc}`;
+}
+
+async function workerFetch(reqPath, options = {}) {
+ const url = `${TUNNEL_WORKER_URL}${reqPath}`;
+ const res = await fetch(url, {
+ ...options,
+ headers: { "Content-Type": "application/json", ...options.headers }
+ });
+ return res.json();
+}
+
+export async function enableTunnel() {
+ const existing = loadState();
+ if (existing && existing.tunnelUrl && isCloudflaredRunning()) {
+ return { success: true, tunnelUrl: existing.tunnelUrl, shortId: existing.shortId, alreadyRunning: true };
+ }
+
+ killCloudflared();
+
+ const machineId = getMachineId();
+ const shortId = existing?.shortId || generateShortId();
+ const apiKey = existing?.apiKey || generateApiKey(machineId);
+
+ await workerFetch("/api/session/create", {
+ method: "POST",
+ body: JSON.stringify({ apiKey, shortId })
+ });
+
+ const tunnelResult = await workerFetch("/api/tunnel/create", {
+ method: "POST",
+ body: JSON.stringify({ apiKey })
+ });
+
+ if (tunnelResult.error) {
+ throw new Error(tunnelResult.error);
+ }
+
+ const { token, hostname } = tunnelResult;
+
+ await spawnCloudflared(token);
+
+ saveState({ shortId, apiKey, tunnelUrl: hostname, machineId });
+
+ await updateSettings({ tunnelEnabled: true, tunnelUrl: hostname });
+
+ return { success: true, tunnelUrl: hostname, shortId };
+}
+
+export async function disableTunnel() {
+ const state = loadState();
+
+ killCloudflared();
+
+ if (state?.apiKey) {
+ try {
+ await workerFetch("/api/tunnel/delete", {
+ method: "DELETE",
+ body: JSON.stringify({ apiKey: state.apiKey })
+ });
+ } catch (e) { /* ignore worker errors on disable */ }
+ }
+
+ if (state) {
+ saveState({ shortId: state.shortId, apiKey: state.apiKey, machineId: state.machineId, tunnelUrl: null });
+ }
+
+ await updateSettings({ tunnelEnabled: false, tunnelUrl: "" });
+
+ return { success: true };
+}
+
+export async function getTunnelStatus() {
+ const state = loadState();
+ const running = isCloudflaredRunning();
+ const settings = await getSettings();
+
+ return {
+ enabled: settings.tunnelEnabled === true && running,
+ tunnelUrl: state?.tunnelUrl || "",
+ shortId: state?.shortId || "",
+ running
+ };
+}
diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js
index d7c4c0e4..036ba327 100644
--- a/src/lib/usageDb.js
+++ b/src/lib/usageDb.js
@@ -78,6 +78,12 @@ if (!global._pendingRequests) {
}
const pendingRequests = global._pendingRequests;
+// Track last error provider for UI edge coloring (auto-clears after 10s)
+if (!global._lastErrorProvider) {
+ global._lastErrorProvider = { provider: "", ts: 0 };
+}
+const lastErrorProvider = global._lastErrorProvider;
+
// Use global to share singleton across Next.js route modules
if (!global._statsEmitter) {
global._statsEmitter = new EventEmitter();
@@ -91,8 +97,9 @@ export const statsEmitter = global._statsEmitter;
* @param {string} provider
* @param {string} connectionId
* @param {boolean} started - true if started, false if finished
+ * @param {boolean} [error] - true if ended with error
*/
-export function trackPendingRequest(model, provider, connectionId, started) {
+export function trackPendingRequest(model, provider, connectionId, started, error = false) {
const modelKey = provider ? `${model} (${provider})` : model;
// Track by model
@@ -107,8 +114,71 @@ export function trackPendingRequest(model, provider, connectionId, started) {
pendingRequests.byAccount[accountKey][modelKey] = Math.max(0, pendingRequests.byAccount[accountKey][modelKey] + (started ? 1 : -1));
}
- console.log(`[PENDING] ${started ? "START" : "END"} | provider=${provider} | model=${model} | emitter listeners=${statsEmitter.listenerCount("update")}`);
- statsEmitter.emit("update");
+ // Track error provider (auto-clears after 10s)
+ if (!started && error && provider) {
+ lastErrorProvider.provider = provider.toLowerCase();
+ lastErrorProvider.ts = Date.now();
+ }
+
+ console.log(`[PENDING] ${started ? "START" : "END"}${error ? " (ERROR)" : ""} | provider=${provider} | model=${model} | emitter listeners=${statsEmitter.listenerCount("pending")}`);
+ statsEmitter.emit("pending");
+}
+
+/**
+ * Lightweight: get only activeRequests + recentRequests without full stats recalc
+ */
+export async function getActiveRequests() {
+ const activeRequests = [];
+
+ // Build active requests from pending state
+ let connectionMap = {};
+ try {
+ const { getProviderConnections } = await import("@/lib/localDb.js");
+ const allConnections = await getProviderConnections();
+ for (const conn of allConnections) {
+ connectionMap[conn.id] = conn.name || conn.email || conn.id;
+ }
+ } catch {}
+
+ for (const [connectionId, models] of Object.entries(pendingRequests.byAccount)) {
+ for (const [modelKey, count] of Object.entries(models)) {
+ if (count > 0) {
+ const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`;
+ const match = modelKey.match(/^(.*) \((.*)\)$/);
+ const modelName = match ? match[1] : modelKey;
+ const providerName = match ? match[2] : "unknown";
+ activeRequests.push({ model: modelName, provider: providerName, account: accountName, count });
+ }
+ }
+ }
+
+ // Get recent requests from history (re-read to get latest)
+ const db = await getUsageDb();
+ await db.read();
+ const history = db.data.history || [];
+ const seen = new Set();
+ const recentRequests = [...history]
+ .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
+ .map((e) => {
+ const t = e.tokens || {};
+ const promptTokens = t.prompt_tokens || t.input_tokens || 0;
+ const completionTokens = t.completion_tokens || t.output_tokens || 0;
+ return { timestamp: e.timestamp, model: e.model, provider: e.provider || "", promptTokens, completionTokens, status: e.status || "ok" };
+ })
+ .filter((e) => {
+ if (e.promptTokens === 0 && e.completionTokens === 0) return false;
+ const minute = e.timestamp ? e.timestamp.slice(0, 16) : "";
+ const key = `${e.model}|${e.provider}|${e.promptTokens}|${e.completionTokens}|${minute}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ })
+ .slice(0, 20);
+
+ // Error provider (auto-clear after 10s)
+ const errorProvider = (Date.now() - lastErrorProvider.ts < 10000) ? lastErrorProvider.provider : "";
+
+ return { activeRequests, recentRequests, errorProvider };
}
/**
@@ -443,6 +513,7 @@ export async function getUsageStats() {
pending: pendingRequests,
activeRequests: [],
recentRequests,
+ errorProvider: (Date.now() - lastErrorProvider.ts < 10000) ? lastErrorProvider.provider : "",
};
// Build active requests list from pending counts
diff --git a/src/server-init.js b/src/server-init.js
index 4c46a548..b1d7cd47 100644
--- a/src/server-init.js
+++ b/src/server-init.js
@@ -1,21 +1,17 @@
-// Server startup script
-import initializeCloudSync from "./shared/services/initializeCloudSync.js";
+import initializeApp from "./shared/services/initializeApp.js";
async function startServer() {
- console.log("Starting server with cloud sync...");
+ console.log("Starting server...");
try {
- // Initialize cloud sync
- await initializeCloudSync();
- console.log("Server started with cloud sync initialized");
+ await initializeApp();
+ console.log("Server initialized");
} catch (error) {
- console.log("Error initializing cloud sync:", error);
+ console.log("Error initializing server:", error);
process.exit(1);
}
}
-// Start the server initialization
startServer().catch(console.log);
-// Export for use as module if needed
export default startServer;
diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js
index d4a61975..89f918eb 100644
--- a/src/shared/components/UsageStats.js
+++ b/src/shared/components/UsageStats.js
@@ -362,6 +362,7 @@ export default function UsageStats() {
providers={providers}
activeRequests={stats.activeRequests || []}
lastProvider={stats.recentRequests?.[0]?.provider || ""}
+ errorProvider={stats.errorProvider || ""}
/>
diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js
new file mode 100644
index 00000000..91684bfd
--- /dev/null
+++ b/src/shared/services/initializeApp.js
@@ -0,0 +1,42 @@
+import { cleanupProviderConnections, getSettings } from "@/lib/localDb";
+import { enableTunnel } from "@/lib/tunnel/tunnelManager";
+import { killCloudflared, isCloudflaredRunning, ensureCloudflared } from "@/lib/tunnel/cloudflared";
+
+/**
+ * Initialize app on startup
+ * - Cleanup stale data
+ * - Auto-reconnect tunnel if previously enabled
+ * - Register shutdown handler to kill cloudflared
+ */
+export async function initializeApp() {
+ try {
+ await cleanupProviderConnections();
+
+ // Auto-reconnect tunnel if it was enabled before restart
+ const settings = await getSettings();
+ if (settings.tunnelEnabled && !isCloudflaredRunning()) {
+ console.log("[InitApp] Tunnel was enabled, auto-reconnecting...");
+ try {
+ await enableTunnel();
+ console.log("[InitApp] Tunnel reconnected");
+ } catch (error) {
+ console.log("[InitApp] Tunnel reconnect failed:", error.message);
+ }
+ }
+
+ // Kill cloudflared on process exit
+ const cleanup = () => {
+ killCloudflared();
+ process.exit();
+ };
+ process.on("SIGINT", cleanup);
+ process.on("SIGTERM", cleanup);
+
+ // Pre-download cloudflared binary in background
+ ensureCloudflared().catch(() => {});
+ } catch (error) {
+ console.error("[InitApp] Error:", error);
+ }
+}
+
+export default initializeApp;
diff --git a/src/shared/services/initializeCloudSync.js b/src/shared/services/initializeCloudSync.js
index 4191eaaf..b26a7d55 100644
--- a/src/shared/services/initializeCloudSync.js
+++ b/src/shared/services/initializeCloudSync.js
@@ -1,5 +1,7 @@
+/* ========== CLOUD SYNC — COMMENTED OUT (replaced by Tunnel) ==========
import { getCloudSyncScheduler } from "@/shared/services/cloudSyncScheduler";
-import { isCloudEnabled, cleanupProviderConnections } from "@/lib/localDb";
+========== END CLOUD SYNC ========== */
+import { cleanupProviderConnections } from "@/lib/localDb";
/**
* Initialize cloud sync scheduler
@@ -10,6 +12,7 @@ export async function initializeCloudSync() {
// Cleanup null fields from existing data
await cleanupProviderConnections();
+ /* ========== CLOUD SYNC — COMMENTED OUT (replaced by Tunnel) ==========
// Create scheduler instance with default 15-minute interval
const scheduler = await getCloudSyncScheduler(null, 15);
@@ -17,6 +20,8 @@ export async function initializeCloudSync() {
await scheduler.start();
return scheduler;
+ ========== END CLOUD SYNC ========== */
+ return null;
} catch (error) {
console.error("[CloudSync] Error initializing scheduler:", error);
throw error;