mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
fix(models): apply blocked providers filter to non-chat catalog models (#1752)
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(models):** apply blocked providers filter to non-chat catalog models (image, embedding, audio, etc.) (#1752)
|
||||
- **fix(antigravity):** stabilize streaming payload parsing and deduplicate usage/model metadata refreshes (#1748)
|
||||
|
||||
---
|
||||
|
||||
246
package-lock.json
generated
246
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.7.4",
|
||||
"version": "3.7.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "3.7.4",
|
||||
"version": "3.7.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -16,6 +16,7 @@
|
||||
"@lobehub/icons": "^5.5.4",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@ngrok/ngrok": "^1.7.0",
|
||||
"@swc/helpers": "0.5.21",
|
||||
"axios": "^1.15.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
@@ -2322,6 +2323,247 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrok/ngrok": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngrok/ngrok/-/ngrok-1.7.0.tgz",
|
||||
"integrity": "sha512-P06o9TpxrJbiRbHQkiwy/rUrlXRupc+Z8KT4MiJfmcdWxvIdzjCaJOdnNkcOTs6DMyzIOefG5tvk/HLdtjqr0g==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ngrok/ngrok-android-arm64": "1.7.0",
|
||||
"@ngrok/ngrok-darwin-arm64": "1.7.0",
|
||||
"@ngrok/ngrok-darwin-universal": "1.7.0",
|
||||
"@ngrok/ngrok-darwin-x64": "1.7.0",
|
||||
"@ngrok/ngrok-freebsd-x64": "1.7.0",
|
||||
"@ngrok/ngrok-linux-arm-gnueabihf": "1.7.0",
|
||||
"@ngrok/ngrok-linux-arm64-gnu": "1.7.0",
|
||||
"@ngrok/ngrok-linux-arm64-musl": "1.7.0",
|
||||
"@ngrok/ngrok-linux-x64-gnu": "1.7.0",
|
||||
"@ngrok/ngrok-linux-x64-musl": "1.7.0",
|
||||
"@ngrok/ngrok-win32-arm64-msvc": "1.7.0",
|
||||
"@ngrok/ngrok-win32-ia32-msvc": "1.7.0",
|
||||
"@ngrok/ngrok-win32-x64-msvc": "1.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrok/ngrok-android-arm64": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-android-arm64/-/ngrok-android-arm64-1.7.0.tgz",
|
||||
"integrity": "sha512-8tco3ID6noSaNy+CMS7ewqPoIkIM6XO5COCzsUp3Wv3XEbMSyn65RN6cflX2JdqLfUCHcMyD0ahr9IEiHwqmbQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrok/ngrok-darwin-arm64": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-arm64/-/ngrok-darwin-arm64-1.7.0.tgz",
|
||||
"integrity": "sha512-+dmJSOzSO+MNDVrPOca2yYDP1W3KfP4qOlAkarIeFRIfqonQwq3QCBmcR7HAlZocLsSqEwyG6KP4RRvAuT0WGQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrok/ngrok-darwin-universal": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-universal/-/ngrok-darwin-universal-1.7.0.tgz",
|
||||
"integrity": "sha512-fDEfewyE2pWGFBhOSwQZObeHUkc65U1l+3HIgSOe094TMHsqmyJD0KTCgW9KSn0VP4OvDZbAISi1T3nvqgZYhQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrok/ngrok-darwin-x64": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-x64/-/ngrok-darwin-x64-1.7.0.tgz",
|
||||
"integrity": "sha512-+fwMi5uHd9G8BS42MMa9ye6exI5lwTcjUO6Ut497Vu0qgLONdVRenRqnEePV+Q3KtQR7NjqkMnomVfkr9MBjtw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrok/ngrok-freebsd-x64": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-freebsd-x64/-/ngrok-freebsd-x64-1.7.0.tgz",
|
||||
"integrity": "sha512-2OGgbrjy3yLRrqAz5N6hlUKIWIXSpR5RjQa2chtZMsSbszQ6c9dI+uVQfOKAeo05tHMUgrYAZ7FocC+ig0dzdQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrok/ngrok-linux-arm-gnueabihf": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm-gnueabihf/-/ngrok-linux-arm-gnueabihf-1.7.0.tgz",
|
||||
"integrity": "sha512-SN9YIfEQiR9xN90QVNvdgvAemqMLoFVSeTWZs779145hQMhvF9Qd9rnWi6J+2uNNK10OczdV1oc/nq1es7u/3g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrok/ngrok-linux-arm64-gnu": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-gnu/-/ngrok-linux-arm64-gnu-1.7.0.tgz",
|
||||
"integrity": "sha512-KDMgzPKFU2kbpVSaA2RZBBia5IPdJEe063YlyVFnSMJmPYWCUnMwdybBsucXfV9u1Lw/ZjKTKotIlbTWGn3HGw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrok/ngrok-linux-arm64-musl": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-musl/-/ngrok-linux-arm64-musl-1.7.0.tgz",
|
||||
"integrity": "sha512-e66vUdVrBlQ0lT9ZdamB4U604zt5Gualt8/WVcUGzbu8s5LajWd6g/mzZCUjK4UepjvMpfgmCp1/+rX7Rk8d5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrok/ngrok-linux-x64-gnu": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-gnu/-/ngrok-linux-x64-gnu-1.7.0.tgz",
|
||||
"integrity": "sha512-M6gF0DyOEFqXLfWxObfL3bxYZ4+PnKBHuyLVaqNfFN9Y5utY2mdPOn5422Ppbk4XoIK5/YkuhRqPJl/9FivKEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrok/ngrok-linux-x64-musl": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-musl/-/ngrok-linux-x64-musl-1.7.0.tgz",
|
||||
"integrity": "sha512-4Ijm0dKeoyzZTMaYxR2EiNjtlK81ebflg/WYIO1XtleFrVy4UJEGnxtxEidYoT4BfCqi4uvXiK2Mx216xXKvog==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrok/ngrok-win32-arm64-msvc": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-arm64-msvc/-/ngrok-win32-arm64-msvc-1.7.0.tgz",
|
||||
"integrity": "sha512-u7qyWIJI2/YG1HTBnHwUR1+Z2tyGfAsUAItJK/+N1G0FeWJhIWQvSIFJHlaPy4oW1Dc8mSDBX9qvVsiQgLaRFg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrok/ngrok-win32-ia32-msvc": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-ia32-msvc/-/ngrok-win32-ia32-msvc-1.7.0.tgz",
|
||||
"integrity": "sha512-/UdYUsLNv/Q8j9YJsyIfq/jLCoD8WP+NidouucTUzSoDtmOsXBBT3itLrmPiZTEdEgKiFYLuC1Zon8XQQvbVLA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrok/ngrok-win32-x64-msvc": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-x64-msvc/-/ngrok-win32-x64-msvc-1.7.0.tgz",
|
||||
"integrity": "sha512-UFJg/duEWzZlLkEs61Gz6/5nYhGaKI62I8dvUGdBR3NCtIMagehnFaFxmnXZldyHmCM8U0aCIFNpWRaKcrQkoA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.7.4",
|
||||
"version": "3.7.5",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -110,6 +110,7 @@
|
||||
"@lobehub/icons": "^5.5.4",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@ngrok/ngrok": "^1.7.0",
|
||||
"@swc/helpers": "0.5.21",
|
||||
"axios": "^1.15.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
|
||||
@@ -63,6 +63,26 @@ type TailscaleTunnelStatus = {
|
||||
pid: number | null;
|
||||
};
|
||||
|
||||
type NgrokTunnelPhase =
|
||||
| "unsupported"
|
||||
| "not_installed"
|
||||
| "stopped"
|
||||
| "needs_auth"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "error";
|
||||
|
||||
type NgrokTunnelStatus = {
|
||||
supported: boolean;
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
publicUrl: string | null;
|
||||
apiUrl: string | null;
|
||||
targetUrl: string;
|
||||
phase: NgrokTunnelPhase;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
type TunnelNotice = {
|
||||
type: "success" | "error" | "info";
|
||||
message: string;
|
||||
@@ -105,6 +125,11 @@ export default function APIPageClient({ machineId }) {
|
||||
const [tailscalePassword, setTailscalePassword] = useState("");
|
||||
const [showCloudflaredTunnel, setShowCloudflaredTunnel] = useState(true);
|
||||
const [showTailscaleFunnel, setShowTailscaleFunnel] = useState(true);
|
||||
const [ngrokStatus, setNgrokStatus] = useState<NgrokTunnelStatus | null>(null);
|
||||
const [ngrokBusy, setNgrokBusy] = useState(false);
|
||||
const [ngrokNotice, setNgrokNotice] = useState<TunnelNotice | null>(null);
|
||||
const [ngrokToken, setNgrokToken] = useState("");
|
||||
const [showNgrokTunnel, setShowNgrokTunnel] = useState(true);
|
||||
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
@@ -201,6 +226,35 @@ export default function APIPageClient({ machineId }) {
|
||||
[translateOrFallback]
|
||||
);
|
||||
|
||||
const fetchNgrokStatus = useCallback(
|
||||
async (silent = false) => {
|
||||
try {
|
||||
const res = await fetch("/api/tunnels/ngrok", { cache: "no-store" });
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
data?.error || translateOrFallback("ngrokRequestFailed", "Failed to load ngrok status")
|
||||
);
|
||||
}
|
||||
|
||||
setNgrokStatus(data);
|
||||
return data as NgrokTunnelStatus;
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
setNgrokNotice({
|
||||
type: "error",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translateOrFallback("ngrokRequestFailed", "Failed to load ngrok status"),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[translateOrFallback]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([
|
||||
loadCloudSettings(),
|
||||
@@ -209,10 +263,11 @@ export default function APIPageClient({ machineId }) {
|
||||
fetchSearchProviders(),
|
||||
fetchCloudflaredStatus(true),
|
||||
fetchTailscaleStatus(true),
|
||||
fetchNgrokStatus(true),
|
||||
]).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [fetchCloudflaredStatus, fetchTailscaleStatus]);
|
||||
}, [fetchCloudflaredStatus, fetchTailscaleStatus, fetchNgrokStatus]);
|
||||
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
@@ -502,6 +557,52 @@ export default function APIPageClient({ machineId }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleNgrokAction = async (action: "enable" | "disable") => {
|
||||
setNgrokBusy(true);
|
||||
setNgrokNotice(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/tunnels/ngrok", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, authToken: action === "enable" ? ngrokToken : undefined }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
data?.error || translateOrFallback("ngrokRequestFailed", "Failed to update ngrok tunnel")
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.status) {
|
||||
setNgrokStatus(data.status);
|
||||
}
|
||||
|
||||
setNgrokNotice({
|
||||
type: "success",
|
||||
message:
|
||||
action === "enable"
|
||||
? translateOrFallback("ngrokStarted", "ngrok tunnel started")
|
||||
: translateOrFallback("ngrokStopped", "ngrok tunnel stopped"),
|
||||
});
|
||||
if (action === "enable") {
|
||||
setNgrokToken("");
|
||||
}
|
||||
} catch (error) {
|
||||
setNgrokNotice({
|
||||
type: "error",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translateOrFallback("ngrokRequestFailed", "Failed to update ngrok tunnel"),
|
||||
});
|
||||
} finally {
|
||||
setNgrokBusy(false);
|
||||
await fetchNgrokStatus(true);
|
||||
}
|
||||
};
|
||||
|
||||
const waitForTailscale = useCallback(
|
||||
async (
|
||||
predicate: (status: TailscaleTunnelStatus) => boolean,
|
||||
@@ -891,6 +992,42 @@ export default function APIPageClient({ machineId }) {
|
||||
"Uses your Tailscale .ts.net address. Login and Funnel approval may be required on first use."
|
||||
);
|
||||
|
||||
const ngrokPhase = ngrokStatus?.phase || "not_installed";
|
||||
const ngrokPhaseMeta: Record<NgrokTunnelPhase, { label: string; className: string }> = {
|
||||
running: {
|
||||
label: translateOrFallback("ngrokRunning", "Running"),
|
||||
className: "bg-green-500/10 border-green-500/30 text-green-400",
|
||||
},
|
||||
starting: {
|
||||
label: translateOrFallback("ngrokStarting", "Starting"),
|
||||
className: "bg-blue-500/10 border-blue-500/30 text-blue-400",
|
||||
},
|
||||
stopped: {
|
||||
label: translateOrFallback("ngrokStoppedState", "Stopped"),
|
||||
className: "bg-surface border-border/70 text-text-muted",
|
||||
},
|
||||
needs_auth: {
|
||||
label: translateOrFallback("ngrokNeedsAuth", "Needs Auth"),
|
||||
className: "bg-amber-500/10 border-amber-500/30 text-amber-400",
|
||||
},
|
||||
not_installed: {
|
||||
label: translateOrFallback("ngrokNotInstalled", "Not installed"),
|
||||
className: "bg-surface border-border/70 text-text-muted",
|
||||
},
|
||||
unsupported: {
|
||||
label: translateOrFallback("ngrokUnsupported", "Unsupported"),
|
||||
className: "bg-amber-500/10 border-amber-500/30 text-amber-400",
|
||||
},
|
||||
error: {
|
||||
label: translateOrFallback("ngrokError", "Error"),
|
||||
className: "bg-red-500/10 border-red-500/30 text-red-400",
|
||||
},
|
||||
};
|
||||
const ngrokActionLabel = ngrokStatus?.running
|
||||
? translateOrFallback("ngrokDisable", "Stop Tunnel")
|
||||
: translateOrFallback("ngrokEnable", "Enable Tunnel");
|
||||
const ngrokUrlNotice = translateOrFallback("ngrokUrlNotice", "Creates a public ngrok tunnel.");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Endpoint Card */}
|
||||
@@ -1229,6 +1366,120 @@ export default function APIPageClient({ machineId }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNgrokTunnel && (
|
||||
<div
|
||||
className={`${showCloudflaredTunnel || showTailscaleFunnel ? "mt-4 " : ""}rounded-xl border border-border/70 bg-surface/40 p-4`}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{translateOrFallback("ngrokTitle", "ngrok Tunnel")}
|
||||
</h3>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-1 text-xs font-medium ${ngrokPhaseMeta[ngrokPhase].className}`}
|
||||
>
|
||||
{ngrokPhaseMeta[ngrokPhase].label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ngrokStatus?.supported !== false && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={ngrokStatus?.running ? "secondary" : "primary"}
|
||||
icon={ngrokStatus?.running ? "public_off" : "public"}
|
||||
onClick={() => handleNgrokAction(ngrokStatus?.running ? "disable" : "enable")}
|
||||
loading={ngrokBusy}
|
||||
className={
|
||||
ngrokStatus?.running
|
||||
? "border-border/70! text-text-muted! hover:text-text!"
|
||||
: "bg-linear-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600"
|
||||
}
|
||||
>
|
||||
{ngrokActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ngrokNotice && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
|
||||
ngrokNotice.type === "success"
|
||||
? "border-green-500/30 bg-green-500/10 text-green-400"
|
||||
: ngrokNotice.type === "info"
|
||||
? "border-blue-500/30 bg-blue-500/10 text-blue-400"
|
||||
: "border-red-500/30 bg-red-500/10 text-red-400"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{ngrokNotice.type === "success"
|
||||
? "check_circle"
|
||||
: ngrokNotice.type === "info"
|
||||
? "info"
|
||||
: "error"}
|
||||
</span>
|
||||
<span className="flex-1">{ngrokNotice.message}</span>
|
||||
<button
|
||||
onClick={() => setNgrokNotice(null)}
|
||||
className="rounded p-0.5 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-text-muted">{ngrokUrlNotice}</p>
|
||||
{ngrokStatus?.phase === "needs_auth" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-text-muted">
|
||||
{translateOrFallback(
|
||||
"ngrokAuthTokenLabel",
|
||||
"Authtoken (Required if NGROK_AUTHTOKEN not set)"
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={ngrokToken}
|
||||
onChange={(event) => setNgrokToken(event.target.value)}
|
||||
placeholder={translateOrFallback(
|
||||
"ngrokAuthTokenPlaceholder",
|
||||
"Enter your ngrok authtoken"
|
||||
)}
|
||||
disabled={ngrokBusy}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={ngrokStatus?.apiUrl || ""}
|
||||
readOnly
|
||||
placeholder="https://your-tunnel.ngrok-free.app/v1"
|
||||
className="flex-1 min-w-0 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "ngrok_url" ? "check" : "content_copy"}
|
||||
onClick={() => ngrokStatus?.apiUrl && copy(ngrokStatus.apiUrl, "ngrok_url")}
|
||||
disabled={!ngrokStatus?.apiUrl}
|
||||
className="shrink-0 self-start sm:self-auto"
|
||||
>
|
||||
{copied === "ngrok_url" ? tc("copied") : tc("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
{ngrokStatus?.lastError && (
|
||||
<p className="text-xs text-red-400">
|
||||
{translateOrFallback("ngrokLastError", "Last error: {error}", {
|
||||
error: ngrokStatus.lastError,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
|
||||
74
src/app/api/tunnels/ngrok/route.ts
Normal file
74
src/app/api/tunnels/ngrok/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { getNgrokTunnelStatus, startNgrokTunnel, stopNgrokTunnel } from "@/lib/ngrokTunnel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const actionSchema = z.object({
|
||||
action: z.enum(["enable", "disable"]),
|
||||
authToken: z.string().optional(),
|
||||
});
|
||||
|
||||
function unauthorized() {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await getNgrokTunnelStatus();
|
||||
return NextResponse.json(status);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Failed to load ngrok tunnel status",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validation = validateBody(actionSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return validation.response;
|
||||
}
|
||||
|
||||
const parsed = validation.data;
|
||||
|
||||
try {
|
||||
const status =
|
||||
parsed.action === "enable"
|
||||
? await startNgrokTunnel(parsed.authToken)
|
||||
: await stopNgrokTunnel();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action: parsed.action,
|
||||
status,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Failed to update ngrok tunnel",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -478,6 +478,17 @@ export async function getUnifiedModelsResponse(
|
||||
const isProviderActive = (provider: string) => {
|
||||
if (activeAliases.size === 0) return false; // No active connections = show nothing
|
||||
const alias = providerIdToAlias[provider] || provider;
|
||||
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || provider;
|
||||
|
||||
// FIX #1752: Ensure blocked providers are not returned for non-chat models
|
||||
if (
|
||||
blockedProviders.has(alias) ||
|
||||
blockedProviders.has(canonicalProviderId) ||
|
||||
blockedProviders.has(provider)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return activeAliases.has(alias) || activeAliases.has(provider);
|
||||
};
|
||||
|
||||
|
||||
@@ -1980,7 +1980,24 @@
|
||||
"tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.",
|
||||
"tailscaleSudoPlaceholder": "Optional sudo password",
|
||||
"tailscaleInstalling": "Installing",
|
||||
"tailscaleSudoLabel": "Sudo Password (required on macOS/Linux)"
|
||||
"tailscaleSudoLabel": "Sudo Password (required on macOS/Linux)",
|
||||
"ngrokTitle": "ngrok Tunnel",
|
||||
"ngrokRunning": "Running",
|
||||
"ngrokStarting": "Starting",
|
||||
"ngrokStoppedState": "Stopped",
|
||||
"ngrokNeedsAuth": "Needs Auth",
|
||||
"ngrokNotInstalled": "Not installed",
|
||||
"ngrokUnsupported": "Unsupported",
|
||||
"ngrokError": "Error",
|
||||
"ngrokEnable": "Enable Tunnel",
|
||||
"ngrokDisable": "Stop Tunnel",
|
||||
"ngrokUrlNotice": "Creates a public ngrok tunnel.",
|
||||
"ngrokAuthTokenLabel": "Authtoken (Required if NGROK_AUTHTOKEN not set)",
|
||||
"ngrokAuthTokenPlaceholder": "Enter your ngrok authtoken",
|
||||
"ngrokLastError": "Last error: {error}",
|
||||
"ngrokStarted": "ngrok tunnel started",
|
||||
"ngrokStopped": "ngrok tunnel stopped",
|
||||
"ngrokRequestFailed": "Failed to update ngrok tunnel"
|
||||
},
|
||||
"endpoints": {
|
||||
"tabProxy": "Endpoint Proxy",
|
||||
|
||||
@@ -1902,7 +1902,24 @@
|
||||
"tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.",
|
||||
"tailscaleSudoPlaceholder": "Optional sudo password",
|
||||
"tailscaleInstalling": "Installing",
|
||||
"tailscaleSudoLabel": "Senha Sudo (obrigatória em macOS/Linux)"
|
||||
"tailscaleSudoLabel": "Senha Sudo (obrigatória em macOS/Linux)",
|
||||
"ngrokTitle": "Túnel ngrok",
|
||||
"ngrokRunning": "Rodando",
|
||||
"ngrokStarting": "Iniciando",
|
||||
"ngrokStoppedState": "Parado",
|
||||
"ngrokNeedsAuth": "Requer Autenticação",
|
||||
"ngrokNotInstalled": "Não instalado",
|
||||
"ngrokUnsupported": "Não suportado",
|
||||
"ngrokError": "Erro",
|
||||
"ngrokEnable": "Habilitar Túnel",
|
||||
"ngrokDisable": "Parar Túnel",
|
||||
"ngrokUrlNotice": "Cria um túnel público ngrok.",
|
||||
"ngrokAuthTokenLabel": "Authtoken (Necessário caso NGROK_AUTHTOKEN não esteja definido)",
|
||||
"ngrokAuthTokenPlaceholder": "Digite seu authtoken do ngrok",
|
||||
"ngrokLastError": "Último erro: {error}",
|
||||
"ngrokStarted": "Túnel ngrok iniciado",
|
||||
"ngrokStopped": "Túnel ngrok parado",
|
||||
"ngrokRequestFailed": "Falha ao atualizar túnel ngrok"
|
||||
},
|
||||
"endpoints": {
|
||||
"tabProxy": "Proxy de terminal",
|
||||
|
||||
@@ -1874,7 +1874,24 @@
|
||||
"tailscaleInstallIntro": "Installs Tailscale on this machine and prepares OmniRoute to enable Funnel.",
|
||||
"tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.",
|
||||
"tailscaleSudoPlaceholder": "Optional sudo password",
|
||||
"tailscaleInstalling": "Installing"
|
||||
"tailscaleInstalling": "Installing",
|
||||
"ngrokTitle": "Túnel ngrok",
|
||||
"ngrokRunning": "Rodando",
|
||||
"ngrokStarting": "Iniciando",
|
||||
"ngrokStoppedState": "Parado",
|
||||
"ngrokNeedsAuth": "Requer Autenticação",
|
||||
"ngrokNotInstalled": "Não instalado",
|
||||
"ngrokUnsupported": "Não suportado",
|
||||
"ngrokError": "Erro",
|
||||
"ngrokEnable": "Habilitar Túnel",
|
||||
"ngrokDisable": "Parar Túnel",
|
||||
"ngrokUrlNotice": "Cria um túnel público ngrok.",
|
||||
"ngrokAuthTokenLabel": "Authtoken (Necessário caso NGROK_AUTHTOKEN não esteja definido)",
|
||||
"ngrokAuthTokenPlaceholder": "Digite seu authtoken do ngrok",
|
||||
"ngrokLastError": "Último erro: {error}",
|
||||
"ngrokStarted": "Túnel ngrok iniciado",
|
||||
"ngrokStopped": "Túnel ngrok parado",
|
||||
"ngrokRequestFailed": "Falha ao atualizar túnel ngrok"
|
||||
},
|
||||
"endpoints": {
|
||||
"tabProxy": "Endpoint Proxy",
|
||||
|
||||
142
src/lib/ngrokTunnel.ts
Normal file
142
src/lib/ngrokTunnel.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
|
||||
export type TunnelPhase =
|
||||
| "unsupported"
|
||||
| "not_installed"
|
||||
| "stopped"
|
||||
| "needs_auth"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "error";
|
||||
|
||||
export type NgrokTunnelStatus = {
|
||||
supported: boolean;
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
publicUrl: string | null;
|
||||
apiUrl: string | null;
|
||||
targetUrl: string;
|
||||
phase: TunnelPhase;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
// Next.js hot-reloading safe global storage for the listener
|
||||
const globalForNgrok = globalThis as unknown as {
|
||||
__ngrokListener: any;
|
||||
};
|
||||
|
||||
let startPromise: Promise<NgrokTunnelStatus> | null = null;
|
||||
|
||||
function getLocalTargetUrl() {
|
||||
const { apiPort } = getRuntimePorts();
|
||||
return `http://127.0.0.1:${apiPort}`;
|
||||
}
|
||||
|
||||
function getTunnelApiUrl(publicUrl: string | null) {
|
||||
return publicUrl ? `${publicUrl.replace(/\/$/, "")}/v1` : null;
|
||||
}
|
||||
|
||||
export async function getNgrokTunnelStatus(): Promise<NgrokTunnelStatus> {
|
||||
const targetUrl = getLocalTargetUrl();
|
||||
const tokenAvailable = !!(
|
||||
process.env.NGROK_AUTHTOKEN && process.env.NGROK_AUTHTOKEN.trim() !== ""
|
||||
);
|
||||
const listener = globalForNgrok.__ngrokListener;
|
||||
let currentUrl = null;
|
||||
|
||||
if (listener) {
|
||||
try {
|
||||
currentUrl = typeof listener.url === "function" ? listener.url() : listener.url;
|
||||
} catch {
|
||||
// Ignored
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
installed: true,
|
||||
running: currentUrl !== null,
|
||||
publicUrl: currentUrl || null,
|
||||
apiUrl: currentUrl ? getTunnelApiUrl(currentUrl) : null,
|
||||
targetUrl,
|
||||
phase: currentUrl === null ? (tokenAvailable ? "stopped" : "needs_auth") : "running",
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startNgrokTunnel(inputAuthToken?: string): Promise<NgrokTunnelStatus> {
|
||||
const current = await getNgrokTunnelStatus();
|
||||
if (current.running) return current;
|
||||
if (startPromise) return startPromise;
|
||||
|
||||
startPromise = (async () => {
|
||||
try {
|
||||
const authToken =
|
||||
inputAuthToken && inputAuthToken.trim() !== ""
|
||||
? inputAuthToken.trim()
|
||||
: process.env.NGROK_AUTHTOKEN;
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
...(await getNgrokTunnelStatus()),
|
||||
phase: "needs_auth",
|
||||
lastError: "An ngrok authtoken is required.",
|
||||
};
|
||||
}
|
||||
|
||||
// Dynamically import ngrok so it doesn't break environments where native build fails if not used
|
||||
const ngrok = await import("@ngrok/ngrok");
|
||||
|
||||
const targetUrl = getLocalTargetUrl();
|
||||
const listenerOptions: any = { addr: targetUrl };
|
||||
|
||||
if (!inputAuthToken && process.env.NGROK_AUTHTOKEN) {
|
||||
listenerOptions.authtoken_from_env = true;
|
||||
} else {
|
||||
listenerOptions.authtoken = authToken;
|
||||
}
|
||||
|
||||
const listener = await ngrok.forward(listenerOptions);
|
||||
globalForNgrok.__ngrokListener = listener;
|
||||
|
||||
let url = null;
|
||||
try {
|
||||
url = typeof listener.url === "function" ? listener.url() : listener.url;
|
||||
} catch {
|
||||
// Ignored
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
await stopNgrokTunnel();
|
||||
throw new Error("ngrok did not return a public URL.");
|
||||
}
|
||||
|
||||
return await getNgrokTunnelStatus();
|
||||
} catch (error) {
|
||||
return {
|
||||
...(await getNgrokTunnelStatus()),
|
||||
phase: "error",
|
||||
lastError: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
startPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return startPromise;
|
||||
}
|
||||
|
||||
export async function stopNgrokTunnel(): Promise<NgrokTunnelStatus> {
|
||||
const listener = globalForNgrok.__ngrokListener;
|
||||
if (listener) {
|
||||
try {
|
||||
if (typeof listener.close === "function") {
|
||||
await listener.close();
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore close errors
|
||||
}
|
||||
globalForNgrok.__ngrokListener = undefined;
|
||||
}
|
||||
return await getNgrokTunnelStatus();
|
||||
}
|
||||
Reference in New Issue
Block a user