mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
Merge branch 'main' into refactor-split-ports
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* Console Log Viewer — Real-time application log viewer.
|
||||
*
|
||||
@@ -42,6 +44,7 @@ const LEVEL_BG: Record<string, string> = {
|
||||
const POLL_INTERVAL = 5000; // 5 seconds
|
||||
|
||||
export default function ConsoleLogViewer() {
|
||||
const t = useTranslations("loggers");
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -131,7 +134,7 @@ export default function ConsoleLogViewer() {
|
||||
aria-label="Filter by log level"
|
||||
className="px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
||||
>
|
||||
<option value="all">All Levels</option>
|
||||
<option value="all">{t("allLevels")}</option>
|
||||
<option value="debug">Debug+</option>
|
||||
<option value="info">Info+</option>
|
||||
<option value="warn">Warn+</option>
|
||||
@@ -226,7 +229,7 @@ export default function ConsoleLogViewer() {
|
||||
<span className="material-symbols-outlined text-[40px] block mb-2 opacity-30">
|
||||
terminal
|
||||
</span>
|
||||
<p>No log entries found</p>
|
||||
<p>{t("noLogEntries")}</p>
|
||||
<p className="text-[10px] mt-1 opacity-60">
|
||||
Ensure LOG_TO_FILE=true is set in your .env file
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import Link from "next/link";
|
||||
import { APP_CONFIG } from "@/shared/constants/config";
|
||||
|
||||
@@ -36,6 +38,7 @@ const footerLinks = {
|
||||
};
|
||||
|
||||
export default function Footer() {
|
||||
const t = useTranslations("stats");
|
||||
const renderFooterLink = (link) => {
|
||||
if (link.external) {
|
||||
return (
|
||||
@@ -106,7 +109,7 @@ export default function Footer() {
|
||||
|
||||
{/* Product */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-text-main mb-4">Product</h4>
|
||||
<h4 className="font-semibold text-text-main mb-4">{t("product")}</h4>
|
||||
<ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
|
||||
{footerLinks.product.map((link) => (
|
||||
<li key={link.label}>{renderFooterLink(link)}</li>
|
||||
@@ -116,7 +119,7 @@ export default function Footer() {
|
||||
|
||||
{/* Resources */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-text-main mb-4">Resources</h4>
|
||||
<h4 className="font-semibold text-text-main mb-4">{t("resources")}</h4>
|
||||
<ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
|
||||
{footerLinks.resources.map((link) => (
|
||||
<li key={link.label}>{renderFooterLink(link)}</li>
|
||||
@@ -126,7 +129,7 @@ export default function Footer() {
|
||||
|
||||
{/* Company */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-text-main mb-4">Company</h4>
|
||||
<h4 className="font-semibold text-text-main mb-4">{t("company")}</h4>
|
||||
<ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
|
||||
{footerLinks.company.map((link) => (
|
||||
<li key={link.label}>{renderFooterLink(link)}</li>
|
||||
|
||||
@@ -6,6 +6,8 @@ import Image from "next/image";
|
||||
import PropTypes from "prop-types";
|
||||
import { ThemeToggle } from "@/shared/components";
|
||||
import TokenHealthBadge from "./TokenHealthBadge";
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
OAUTH_PROVIDERS,
|
||||
APIKEY_PROVIDERS,
|
||||
@@ -14,7 +16,9 @@ import {
|
||||
ANTHROPIC_COMPATIBLE_PREFIX,
|
||||
} from "@/shared/constants/providers";
|
||||
|
||||
const getPageInfo = (pathname) => {
|
||||
function usePageInfo(pathname: string | null) {
|
||||
const t = useTranslations("header");
|
||||
|
||||
if (!pathname) return { title: "", description: "", breadcrumbs: [] };
|
||||
|
||||
// Provider detail page: /dashboard/providers/[id]
|
||||
@@ -29,7 +33,7 @@ const getPageInfo = (pathname) => {
|
||||
title: providerInfo.name,
|
||||
description: "",
|
||||
breadcrumbs: [
|
||||
{ label: "Providers", href: "/dashboard/providers" },
|
||||
{ label: t("providers"), href: "/dashboard/providers" },
|
||||
{ label: providerInfo.name, image: `/providers/${providerInfo.id}.png` },
|
||||
],
|
||||
};
|
||||
@@ -37,22 +41,22 @@ const getPageInfo = (pathname) => {
|
||||
|
||||
if (providerId.startsWith(OPENAI_COMPATIBLE_PREFIX)) {
|
||||
return {
|
||||
title: "OpenAI Compatible",
|
||||
title: t("openaiCompatible"),
|
||||
description: "",
|
||||
breadcrumbs: [
|
||||
{ label: "Providers", href: "/dashboard/providers" },
|
||||
{ label: "OpenAI Compatible", image: "/providers/oai-cc.png" },
|
||||
{ label: t("providers"), href: "/dashboard/providers" },
|
||||
{ label: t("openaiCompatible"), image: "/providers/oai-cc.png" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX)) {
|
||||
return {
|
||||
title: "Anthropic Compatible",
|
||||
title: t("anthropicCompatible"),
|
||||
description: "",
|
||||
breadcrumbs: [
|
||||
{ label: "Providers", href: "/dashboard/providers" },
|
||||
{ label: "Anthropic Compatible", image: "/providers/anthropic-m.png" },
|
||||
{ label: t("providers"), href: "/dashboard/providers" },
|
||||
{ label: t("anthropicCompatible"), image: "/providers/anthropic-m.png" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -60,40 +64,41 @@ const getPageInfo = (pathname) => {
|
||||
|
||||
if (pathname.includes("/providers"))
|
||||
return {
|
||||
title: "Providers",
|
||||
description: "Manage your AI provider connections",
|
||||
title: t("providers"),
|
||||
description: t("providerDescription"),
|
||||
breadcrumbs: [],
|
||||
};
|
||||
if (pathname.includes("/combos"))
|
||||
return { title: "Combos", description: "Model combos with fallback", breadcrumbs: [] };
|
||||
return { title: t("combos"), description: t("comboDescription"), breadcrumbs: [] };
|
||||
if (pathname.includes("/usage"))
|
||||
return {
|
||||
title: "Usage & Analytics",
|
||||
description: "Monitor your API usage, token consumption, and request logs",
|
||||
title: t("usage"),
|
||||
description: t("usageDescription"),
|
||||
breadcrumbs: [],
|
||||
};
|
||||
if (pathname.includes("/analytics"))
|
||||
return {
|
||||
title: "Analytics",
|
||||
description: "Charts, trends, and evaluation insights",
|
||||
title: t("analytics"),
|
||||
description: t("analyticsDescription"),
|
||||
breadcrumbs: [],
|
||||
};
|
||||
if (pathname.includes("/cli-tools"))
|
||||
return { title: "CLI Tools", description: "Configure CLI tools", breadcrumbs: [] };
|
||||
return { title: t("cliTools"), description: t("cliToolsDescription"), breadcrumbs: [] };
|
||||
if (pathname === "/dashboard")
|
||||
return { title: "Home", description: "Welcome to OmniRoute", breadcrumbs: [] };
|
||||
return { title: t("home"), description: t("homeDescription"), breadcrumbs: [] };
|
||||
if (pathname.includes("/endpoint"))
|
||||
return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] };
|
||||
return { title: t("endpoint"), description: t("endpointDescription"), breadcrumbs: [] };
|
||||
if (pathname.includes("/profile"))
|
||||
return { title: "Settings", description: "Manage your preferences", breadcrumbs: [] };
|
||||
return { title: t("settings"), description: t("settingsDescription"), breadcrumbs: [] };
|
||||
|
||||
return { title: "", description: "", breadcrumbs: [] };
|
||||
};
|
||||
}
|
||||
|
||||
export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { title, description, breadcrumbs } = getPageInfo(pathname);
|
||||
const t = useTranslations("header");
|
||||
const { title, description, breadcrumbs } = usePageInfo(pathname);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
@@ -175,6 +180,9 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
|
||||
{/* Right actions */}
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
{/* Language selector */}
|
||||
<LanguageSelector />
|
||||
|
||||
{/* Theme toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
@@ -185,7 +193,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center justify-center p-2 rounded-lg text-text-muted hover:text-red-500 hover:bg-red-500/10 transition-all"
|
||||
title="Logout"
|
||||
title={t("logout")}
|
||||
>
|
||||
<span className="material-symbols-outlined">logout</span>
|
||||
</button>
|
||||
|
||||
90
src/shared/components/LanguageSelector.tsx
Normal file
90
src/shared/components/LanguageSelector.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LANGUAGES, LOCALE_COOKIE } from "@/i18n/config";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
import { useLocale } from "next-intl";
|
||||
|
||||
/** Persist locale preference in cookie + localStorage (outside component scope for ESLint) */
|
||||
function persistLocale(code: Locale) {
|
||||
document.cookie = `${LOCALE_COOKIE}=${code};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`;
|
||||
try {
|
||||
localStorage.setItem(LOCALE_COOKIE, code);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
export default function LanguageSelector() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentLang = LANGUAGES.find((l) => l.code === locale) || LANGUAGES[0];
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (code: Locale) => {
|
||||
if (code === locale) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
persistLocale(code);
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm font-medium text-text-main hover:bg-surface-hover transition-all border border-transparent hover:border-border"
|
||||
title={currentLang.name}
|
||||
>
|
||||
<span className="text-base leading-none">{currentLang.flag}</span>
|
||||
<span className="text-xs font-semibold tracking-wide">{currentLang.label}</span>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px] text-text-muted transition-transform ${open ? "rotate-180" : ""}`}
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 w-40 rounded-xl border border-border bg-bg shadow-xl z-50 overflow-hidden animate-in fade-in slide-in-from-top-1 duration-150">
|
||||
{LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => handleSelect(lang.code)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2.5 text-sm transition-colors ${
|
||||
lang.code === locale
|
||||
? "bg-primary/10 text-primary font-semibold"
|
||||
: "text-text-main hover:bg-surface-hover"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base leading-none">{lang.flag}</span>
|
||||
<span className="flex-1 text-left">{lang.name}</span>
|
||||
{lang.code === locale && (
|
||||
<span className="material-symbols-outlined text-[16px] text-primary">check</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -149,13 +149,14 @@ export default function ModelSelectModal({
|
||||
} else if (isCustomProvider) {
|
||||
const matchedNode = providerNodes.find((node) => node.id === providerId);
|
||||
const displayName = matchedNode?.name || providerInfo.name;
|
||||
const nodePrefix = matchedNode?.prefix || providerId; // Consider a more user-friendly fallback if providerId is a UUID
|
||||
|
||||
const nodeModels = Object.entries(modelAliases as Record<string, string>)
|
||||
.filter(([, fullModel]: [string, string]) => fullModel.startsWith(`${providerId}/`))
|
||||
.map(([aliasName, fullModel]: [string, string]) => ({
|
||||
id: fullModel.replace(`${providerId}/`, ""),
|
||||
name: aliasName,
|
||||
value: fullModel,
|
||||
value: `${nodePrefix}/${fullModel.replace(`${providerId}/`, "")}`,
|
||||
}));
|
||||
|
||||
// Merge custom models for custom providers
|
||||
@@ -164,7 +165,7 @@ export default function ModelSelectModal({
|
||||
.map((cm) => ({
|
||||
id: cm.id,
|
||||
name: cm.name || cm.id,
|
||||
value: `${providerId}/${cm.id}`,
|
||||
value: `${nodePrefix}/${cm.id}`,
|
||||
isCustom: true,
|
||||
}));
|
||||
|
||||
@@ -173,7 +174,7 @@ export default function ModelSelectModal({
|
||||
if (allModels.length > 0) {
|
||||
groups[providerId] = {
|
||||
name: displayName,
|
||||
alias: matchedNode?.prefix || providerId,
|
||||
alias: nodePrefix,
|
||||
color: providerInfo.color,
|
||||
models: allModels,
|
||||
isCustom: true,
|
||||
|
||||
@@ -34,9 +34,11 @@ export default function OAuthModal({
|
||||
const callbackProcessedRef = useRef(false);
|
||||
const flowStartedRef = useRef(false);
|
||||
|
||||
// Detect if running on localhost or private/LAN IP (client-side only)
|
||||
// Google OAuth rejects private IPs (192.168.x.x, 10.x.x.x, etc.) the same as localhost,
|
||||
// requiring device_id/device_name. Treat them identically for redirect URI construction.
|
||||
// Detect if running on true localhost vs LAN IP (client-side only)
|
||||
// - True localhost (127.0.0.1/localhost): popup auto-callback works
|
||||
// - LAN IPs (192.168.x, 10.x, 172.x): redirect URI uses localhost but callback
|
||||
// won't resolve back to the VPS, so use manual paste mode
|
||||
const [isTrueLocalhost, setIsTrueLocalhost] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const hostname = window.location.hostname;
|
||||
@@ -46,7 +48,9 @@ export default function OAuthModal({
|
||||
hostname.startsWith("192.168.") ||
|
||||
hostname.startsWith("10.") ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname);
|
||||
const isTrulyLocal = hostname === "localhost" || hostname === "127.0.0.1";
|
||||
setIsLocalhost(isLocal);
|
||||
setIsTrueLocalhost(isTrulyLocal);
|
||||
setPlaceholderUrl(`${window.location.origin}/callback?code=...`);
|
||||
}
|
||||
}, []);
|
||||
@@ -272,8 +276,8 @@ export default function OAuthModal({
|
||||
|
||||
setAuthData({ ...data, redirectUri });
|
||||
|
||||
// For non-localhost: use manual input mode (user pastes callback URL)
|
||||
if (!isLocalhost) {
|
||||
// For non-true-localhost (LAN IPs, remote): use manual input mode (user pastes callback URL)
|
||||
if (!isTrueLocalhost) {
|
||||
setStep("input");
|
||||
window.open(data.authUrl, "oauth_auth");
|
||||
} else {
|
||||
@@ -290,7 +294,7 @@ export default function OAuthModal({
|
||||
setError(err.message);
|
||||
setStep("error");
|
||||
}
|
||||
}, [provider, isLocalhost, startPolling, onSuccess]);
|
||||
}, [provider, isLocalhost, isTrueLocalhost, startPolling, onSuccess]);
|
||||
|
||||
// Reset guard when modal closes
|
||||
useEffect(() => {
|
||||
@@ -493,8 +497,8 @@ export default function OAuthModal({
|
||||
{step === "input" && !isDeviceCode && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{/* Remote server info for Google OAuth providers */}
|
||||
{!isLocalhost && GOOGLE_OAUTH_PROVIDERS.includes(provider) && (
|
||||
{/* Remote/LAN server info for Google OAuth providers */}
|
||||
{!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.includes(provider) && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">
|
||||
warning
|
||||
@@ -515,7 +519,7 @@ export default function OAuthModal({
|
||||
</div>
|
||||
)}
|
||||
{/* Generic remote info for other providers */}
|
||||
{!isLocalhost && !GOOGLE_OAUTH_PROVIDERS.includes(provider) && (
|
||||
{!isTrueLocalhost && !GOOGLE_OAUTH_PROVIDERS.includes(provider) && (
|
||||
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200">
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">info</span>
|
||||
<strong>Remote access:</strong> Since you're accessing OmniRoute remotely,
|
||||
|
||||
@@ -40,6 +40,36 @@ const COLUMNS = [
|
||||
|
||||
const DEFAULT_VISIBLE = Object.fromEntries(COLUMNS.map((c) => [c.key, true]));
|
||||
|
||||
/**
|
||||
* Get a friendly display label for compatible providers.
|
||||
* Converts long IDs like "openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441"
|
||||
* to readable labels like "OAI-Compat".
|
||||
*/
|
||||
function getProviderDisplayLabel(provider: string): string {
|
||||
if (!provider) return "-";
|
||||
if (provider.startsWith("openai-compatible-")) {
|
||||
// Extract the "chat" or custom-name part after the prefix
|
||||
const suffix = provider.replace("openai-compatible-", "");
|
||||
// If it's just "chat-<uuid>", show "OAI-Compat"
|
||||
// If it has a meaningful name, include it
|
||||
const parts = suffix.split("-");
|
||||
if (parts.length > 1 && parts[1]?.length >= 8) {
|
||||
// Looks like chat-<uuid>, just show category
|
||||
return `OAI-COMPAT`;
|
||||
}
|
||||
return `OAI: ${suffix.slice(0, 16).toUpperCase()}`;
|
||||
}
|
||||
if (provider.startsWith("anthropic-compatible-")) {
|
||||
const suffix = provider.replace("anthropic-compatible-", "");
|
||||
const parts = suffix.split("-");
|
||||
if (parts.length > 1 && parts[1]?.length >= 8) {
|
||||
return `ANT-COMPAT`;
|
||||
}
|
||||
return `ANT: ${suffix.slice(0, 16).toUpperCase()}`;
|
||||
}
|
||||
return null; // Not a compatible provider, use default PROVIDER_COLORS
|
||||
}
|
||||
|
||||
function getLogTotalTokens(log) {
|
||||
return (log?.tokens?.in || 0) + (log?.tokens?.out || 0);
|
||||
}
|
||||
@@ -269,10 +299,11 @@ export default function RequestLoggerV2() {
|
||||
>
|
||||
<option value="">All Providers</option>
|
||||
{uniqueProviders.map((p) => {
|
||||
const compatLabel = getProviderDisplayLabel(p);
|
||||
const pc = PROVIDER_COLORS[p];
|
||||
return (
|
||||
<option key={p} value={p}>
|
||||
{pc?.label || p.toUpperCase()}
|
||||
{compatLabel || pc?.label || p.toUpperCase()}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
@@ -410,7 +441,13 @@ export default function RequestLoggerV2() {
|
||||
|
||||
{/* Dynamic Provider Quick Filters (from data) */}
|
||||
{uniqueProviders.map((p) => {
|
||||
const pc = PROVIDER_COLORS[p] || { bg: "#374151", text: "#fff", label: p.toUpperCase() };
|
||||
const compatLabel = getProviderDisplayLabel(p);
|
||||
const pc = PROVIDER_COLORS[p] || {
|
||||
bg: "#374151",
|
||||
text: "#fff",
|
||||
label: compatLabel || p.toUpperCase(),
|
||||
};
|
||||
const displayLabel = compatLabel || pc.label;
|
||||
const isActive = selectedProvider === p;
|
||||
return (
|
||||
<button
|
||||
@@ -426,7 +463,7 @@ export default function RequestLoggerV2() {
|
||||
color: isActive ? pc.text : pc.bg,
|
||||
}}
|
||||
>
|
||||
{pc.label}
|
||||
{displayLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -538,11 +575,13 @@ export default function RequestLoggerV2() {
|
||||
text: "#fff",
|
||||
label: (protocolKey || log.provider || "-").toUpperCase(),
|
||||
};
|
||||
const compatLabel = getProviderDisplayLabel(log.provider);
|
||||
const providerColor = PROVIDER_COLORS[log.provider] || {
|
||||
bg: "#374151",
|
||||
text: "#fff",
|
||||
label: (log.provider || "-").toUpperCase(),
|
||||
label: compatLabel || (log.provider || "-").toUpperCase(),
|
||||
};
|
||||
const providerLabel = compatLabel || providerColor.label;
|
||||
const isError = log.status >= 400;
|
||||
|
||||
return (
|
||||
@@ -572,7 +611,7 @@ export default function RequestLoggerV2() {
|
||||
className="inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase"
|
||||
style={{ backgroundColor: providerColor.bg, color: providerColor.text }}
|
||||
>
|
||||
{providerColor.label}
|
||||
{providerLabel}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
|
||||
@@ -10,30 +10,32 @@ import OmniRouteLogo from "./OmniRouteLogo";
|
||||
import Button from "./Button";
|
||||
import { ConfirmModal } from "./Modal";
|
||||
import CloudSyncStatus from "./CloudSyncStatus";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Home", icon: "home", exact: true },
|
||||
{ href: "/dashboard/endpoint", label: "Endpoint", icon: "api" },
|
||||
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
|
||||
{ href: "/dashboard/combos", label: "Combos", icon: "layers" },
|
||||
{ href: "/dashboard/logs", label: "Logs", icon: "description" },
|
||||
{ href: "/dashboard/costs", label: "Costs", icon: "account_balance_wallet" },
|
||||
{ href: "/dashboard/analytics", label: "Analytics", icon: "analytics" },
|
||||
{ href: "/dashboard/limits", label: "Limits & Quotas", icon: "tune" },
|
||||
{ href: "/dashboard/health", label: "Health", icon: "health_and_safety" },
|
||||
{ href: "/dashboard/cli-tools", label: "CLI Tools", icon: "terminal" },
|
||||
// Nav items use i18n keys resolved inside the component
|
||||
const navItemDefs = [
|
||||
{ href: "/dashboard", i18nKey: "home", icon: "home", exact: true },
|
||||
{ href: "/dashboard/endpoint", i18nKey: "endpoint", icon: "api" },
|
||||
{ href: "/dashboard/api-manager", i18nKey: "apiManager", icon: "vpn_key" },
|
||||
{ href: "/dashboard/providers", i18nKey: "providers", icon: "dns" },
|
||||
{ href: "/dashboard/combos", i18nKey: "combos", icon: "layers" },
|
||||
{ href: "/dashboard/logs", i18nKey: "logs", icon: "description" },
|
||||
{ href: "/dashboard/costs", i18nKey: "costs", icon: "account_balance_wallet" },
|
||||
{ href: "/dashboard/analytics", i18nKey: "analytics", icon: "analytics" },
|
||||
{ href: "/dashboard/limits", i18nKey: "limits", icon: "tune" },
|
||||
{ href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" },
|
||||
{ href: "/dashboard/cli-tools", i18nKey: "cliTools", icon: "terminal" },
|
||||
];
|
||||
|
||||
// Debug items (only show when ENABLE_REQUEST_LOGS=true)
|
||||
const debugItems = [{ href: "/dashboard/translator", label: "Translator", icon: "translate" }];
|
||||
const debugItemDefs = [{ href: "/dashboard/translator", i18nKey: "translator", icon: "translate" }];
|
||||
|
||||
const systemItems = [{ href: "/dashboard/settings", label: "Settings", icon: "settings" }];
|
||||
const systemItemDefs = [{ href: "/dashboard/settings", i18nKey: "settings", icon: "settings" }];
|
||||
|
||||
const helpItems = [
|
||||
{ href: "/docs", label: "Docs", icon: "menu_book" },
|
||||
const helpItemDefs = [
|
||||
{ href: "/docs", i18nKey: "docs", icon: "menu_book" },
|
||||
{
|
||||
href: "https://github.com/diegosouzapw/OmniRoute/issues",
|
||||
label: "Issues",
|
||||
i18nKey: "issues",
|
||||
icon: "bug_report",
|
||||
external: true,
|
||||
},
|
||||
@@ -49,6 +51,8 @@ export default function Sidebar({
|
||||
onToggleCollapse?: any;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations("sidebar");
|
||||
const tc = useTranslations("common");
|
||||
const [showShutdownModal, setShowShutdownModal] = useState(false);
|
||||
const [showRestartModal, setShowRestartModal] = useState(false);
|
||||
const [isShuttingDown, setIsShuttingDown] = useState(false);
|
||||
@@ -99,6 +103,13 @@ export default function Sidebar({
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// Resolve i18n keys → labels
|
||||
const resolveItems = (defs) => defs.map((d) => ({ ...d, label: t(d.i18nKey) }));
|
||||
const navItems = resolveItems(navItemDefs);
|
||||
const debugItems = resolveItems(debugItemDefs);
|
||||
const systemItems = resolveItems(systemItemDefs);
|
||||
const helpItems = resolveItems(helpItemDefs);
|
||||
|
||||
const renderNavLink = (item) => {
|
||||
const active = !item.external && isActive(item.href, item.exact);
|
||||
const className = cn(
|
||||
@@ -270,7 +281,7 @@ export default function Sidebar({
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowRestartModal(true)}
|
||||
title="Restart server"
|
||||
title={t("restart")}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 rounded-lg font-medium transition-all",
|
||||
"text-amber-500 hover:bg-amber-500/10 border border-amber-500/20 hover:border-amber-500/40",
|
||||
@@ -278,11 +289,11 @@ export default function Sidebar({
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">restart_alt</span>
|
||||
{!collapsed && "Restart"}
|
||||
{!collapsed && t("restart")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowShutdownModal(true)}
|
||||
title="Shutdown server"
|
||||
title={t("shutdown")}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 rounded-lg font-medium transition-all",
|
||||
"text-red-500 hover:bg-red-500/10 border border-red-500/20 hover:border-red-500/40",
|
||||
@@ -290,7 +301,7 @@ export default function Sidebar({
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">power_settings_new</span>
|
||||
{!collapsed && "Shutdown"}
|
||||
{!collapsed && t("shutdown")}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -300,10 +311,10 @@ export default function Sidebar({
|
||||
isOpen={showShutdownModal}
|
||||
onClose={() => setShowShutdownModal(false)}
|
||||
onConfirm={handleShutdown}
|
||||
title="Close Proxy"
|
||||
message="Are you sure you want to close the proxy server?"
|
||||
confirmText="Close"
|
||||
cancelText="Cancel"
|
||||
title={t("shutdown")}
|
||||
message={t("shutdownConfirm")}
|
||||
confirmText={t("shutdown")}
|
||||
cancelText={tc("cancel")}
|
||||
variant="danger"
|
||||
loading={isShuttingDown}
|
||||
/>
|
||||
@@ -313,10 +324,10 @@ export default function Sidebar({
|
||||
isOpen={showRestartModal}
|
||||
onClose={() => setShowRestartModal(false)}
|
||||
onConfirm={handleRestart}
|
||||
title="Restart Proxy"
|
||||
message="Are you sure you want to restart the proxy server? It will be back online in a few seconds."
|
||||
confirmText="Restart"
|
||||
cancelText="Cancel"
|
||||
title={t("restart")}
|
||||
message={t("restartConfirm")}
|
||||
confirmText={t("restart")}
|
||||
cancelText={tc("cancel")}
|
||||
variant="warning"
|
||||
loading={isRestarting}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* SystemMonitor — Real-time system metrics widget
|
||||
*
|
||||
@@ -51,6 +53,7 @@ function MetricRow({ icon, label, value, color = "text-text-main" }) {
|
||||
}
|
||||
|
||||
export default function SystemMonitor({ compact = false }) {
|
||||
const t = useTranslations("stats");
|
||||
const [metrics, setMetrics] = useState(null);
|
||||
const [error, setError] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
@@ -85,7 +88,7 @@ export default function SystemMonitor({ compact = false }) {
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 text-text-muted text-sm">
|
||||
<span className="material-symbols-outlined text-[18px] text-red-400">error</span>
|
||||
<span>Unable to load system metrics</span>
|
||||
<span>{t("unableToLoad")}</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* TokenHealthBadge — Batch G
|
||||
*
|
||||
@@ -17,6 +19,7 @@ const STATUS_MAP = {
|
||||
};
|
||||
|
||||
export default function TokenHealthBadge() {
|
||||
const t = useTranslations("stats");
|
||||
const [health, setHealth] = useState(null);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
@@ -71,31 +74,31 @@ export default function TokenHealthBadge() {
|
||||
backdropFilter: "blur(12px)",
|
||||
}}
|
||||
>
|
||||
<p className="text-xs font-medium text-text-main mb-2">Token Health</p>
|
||||
<p className="text-xs font-medium text-text-main mb-2">{t("tokenHealth")}</p>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">Total OAuth</span>
|
||||
<span className="text-text-muted">{t("totalOAuth")}</span>
|
||||
<span className="text-text-main">{health.total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-emerald-400">Healthy</span>
|
||||
<span className="text-emerald-400">{t("healthy")}</span>
|
||||
<span className="text-text-main">{health.healthy}</span>
|
||||
</div>
|
||||
{health.errored > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-red-400">Errored</span>
|
||||
<span className="text-red-400">{t("errored")}</span>
|
||||
<span className="text-text-main">{health.errored}</span>
|
||||
</div>
|
||||
)}
|
||||
{health.warning > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-amber-400">Warning</span>
|
||||
<span className="text-amber-400">{t("warning")}</span>
|
||||
<span className="text-text-main">{health.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
{health.lastCheckAt && (
|
||||
<div className="flex justify-between mt-1 pt-1 border-t border-white/5">
|
||||
<span className="text-text-muted">Last check</span>
|
||||
<span className="text-text-muted">{t("lastCheck")}</span>
|
||||
<span className="text-text-muted">
|
||||
{new Date(health.lastCheckAt).toLocaleTimeString()}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
@@ -8,7 +10,15 @@ import Badge from "./Badge";
|
||||
import { CardSkeleton } from "./Loading";
|
||||
import { fmtFull, fmtCost } from "@/shared/utils/formatting";
|
||||
|
||||
function SortIcon({ field, currentSort, currentOrder }: { field: string; currentSort: string; currentOrder: string }) {
|
||||
function SortIcon({
|
||||
field,
|
||||
currentSort,
|
||||
currentOrder,
|
||||
}: {
|
||||
field: string;
|
||||
currentSort: string;
|
||||
currentOrder: string;
|
||||
}) {
|
||||
if (currentSort !== field) return <span className="ml-1 opacity-20">↕</span>;
|
||||
return <span className="ml-1">{currentOrder === "asc" ? "↑" : "↓"}</span>;
|
||||
}
|
||||
@@ -19,7 +29,13 @@ SortIcon.propTypes = {
|
||||
currentOrder: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function MiniBarGraph({ data, colorClass = "bg-primary" }: { data: number[]; colorClass?: string }) {
|
||||
function MiniBarGraph({
|
||||
data,
|
||||
colorClass = "bg-primary",
|
||||
}: {
|
||||
data: number[];
|
||||
colorClass?: string;
|
||||
}) {
|
||||
const max = Math.max(...data, 1);
|
||||
return (
|
||||
<div className="flex items-end gap-1 h-8 w-24">
|
||||
@@ -41,6 +57,7 @@ MiniBarGraph.propTypes = {
|
||||
};
|
||||
|
||||
export default function UsageStats() {
|
||||
const t = useTranslations("stats");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -188,7 +205,7 @@ export default function UsageStats() {
|
||||
|
||||
if (loading) return <CardSkeleton />;
|
||||
|
||||
if (!stats) return <div className="text-text-muted">Failed to load usage statistics.</div>;
|
||||
if (!stats) return <div className="text-text-muted">{t("failedToLoad")}</div>;
|
||||
|
||||
// Format number with commas — delegated to shared module
|
||||
const fmt = (n: number) => fmtFull(n);
|
||||
@@ -213,7 +230,7 @@ export default function UsageStats() {
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header with Auto Refresh Toggle and View Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Usage Overview</h2>
|
||||
<h2 className="text-xl font-semibold">{t("usageOverview")}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View Toggle */}
|
||||
<div className="flex items-center gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
|
||||
@@ -331,21 +348,25 @@ export default function UsageStats() {
|
||||
<Card className="px-4 py-2 flex flex-col gap-1">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Output Tokens</span>
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">
|
||||
{t("outputTokens")}
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-success">
|
||||
{fmt(stats.totalCompletionTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-px bg-border self-stretch mx-2" />
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Total Cost</span>
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">
|
||||
{t("totalCost")}
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-warning">{fmtCost(stats.totalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Usage by Model Table */}
|
||||
{/* {t("usageByModel")} Table */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-4 border-b border-border bg-bg-subtle/50">
|
||||
<h3 className="font-semibold">Usage by Model</h3>
|
||||
@@ -504,7 +525,7 @@ export default function UsageStats() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Usage by Account Table */}
|
||||
{/* {t("usageByAccount")} Table */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-4 border-b border-border bg-bg-subtle/50">
|
||||
<h3 className="font-semibold">Usage by Account</h3>
|
||||
|
||||
@@ -140,25 +140,28 @@ export const CLI_TOOLS = {
|
||||
description: "Google Antigravity IDE with MITM",
|
||||
configType: "mitm",
|
||||
modelAliases: [
|
||||
"claude-opus-4-5-thinking",
|
||||
"claude-sonnet-4-5-thinking",
|
||||
"claude-sonnet-4-5",
|
||||
"gemini-3-pro-high",
|
||||
"claude-opus-4-6-thinking",
|
||||
"claude-sonnet-4-6",
|
||||
"gemini-3-flash",
|
||||
"gpt-oss-120b-medium",
|
||||
"gemini-3.1-pro-high",
|
||||
"gemini-3.1-pro-low",
|
||||
],
|
||||
defaultModels: [
|
||||
{
|
||||
id: "claude-opus-4-5-thinking",
|
||||
name: "Claude Opus 4.5 Thinking",
|
||||
alias: "claude-opus-4-5-thinking",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-5-thinking",
|
||||
name: "Claude Sonnet 4.5 Thinking",
|
||||
alias: "claude-sonnet-4-5-thinking",
|
||||
},
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4-5" },
|
||||
{ id: "gemini-3-pro-high", name: "Gemini 3 Pro High", alias: "gemini-3-pro-high" },
|
||||
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", alias: "gemini-3.1-pro-high" },
|
||||
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low", alias: "gemini-3.1-pro-low" },
|
||||
{ id: "gemini-3-flash", name: "Gemini 3 Flash", alias: "gemini-3-flash" },
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
alias: "claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-6-thinking",
|
||||
name: "Claude Opus 4.6 Thinking",
|
||||
alias: "claude-opus-4-6-thinking",
|
||||
},
|
||||
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium", alias: "gpt-oss-120b-medium" },
|
||||
],
|
||||
},
|
||||
// HIDDEN: gemini-cli
|
||||
|
||||
@@ -223,14 +223,14 @@ export const DEFAULT_PRICING = {
|
||||
|
||||
// Antigravity (ag) - User-provided pricing
|
||||
ag: {
|
||||
"gemini-3-pro-low": {
|
||||
"gemini-3.1-pro-low": {
|
||||
input: 2.0,
|
||||
output: 12.0,
|
||||
cached: 0.25,
|
||||
reasoning: 18.0,
|
||||
cache_creation: 2.0,
|
||||
},
|
||||
"gemini-3-pro-high": {
|
||||
"gemini-3.1-pro-high": {
|
||||
input: 4.0,
|
||||
output: 18.0,
|
||||
cached: 0.5,
|
||||
@@ -244,34 +244,13 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.5,
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
input: 0.3,
|
||||
output: 2.5,
|
||||
cached: 0.03,
|
||||
reasoning: 3.75,
|
||||
cache_creation: 0.3,
|
||||
},
|
||||
"claude-sonnet-4-5": {
|
||||
"claude-sonnet-4-6": {
|
||||
input: 3.0,
|
||||
output: 15.0,
|
||||
cached: 0.3,
|
||||
reasoning: 22.5,
|
||||
cache_creation: 3.0,
|
||||
},
|
||||
"claude-sonnet-4-5-thinking": {
|
||||
input: 3.0,
|
||||
output: 15.0,
|
||||
cached: 0.3,
|
||||
reasoning: 22.5,
|
||||
cache_creation: 3.0,
|
||||
},
|
||||
"claude-opus-4-5-thinking": {
|
||||
input: 5.0,
|
||||
output: 25.0,
|
||||
cached: 0.5,
|
||||
reasoning: 37.5,
|
||||
cache_creation: 5.0,
|
||||
},
|
||||
"claude-opus-4-6-thinking": {
|
||||
input: 5.0,
|
||||
output: 25.0,
|
||||
@@ -279,6 +258,13 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 37.5,
|
||||
cache_creation: 5.0,
|
||||
},
|
||||
"gpt-oss-120b-medium": {
|
||||
input: 0.5,
|
||||
output: 2.0,
|
||||
cached: 0.25,
|
||||
reasoning: 3.0,
|
||||
cache_creation: 0.5,
|
||||
},
|
||||
},
|
||||
|
||||
// GitHub Copilot (gh)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { jwtVerify } from "jose";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
|
||||
// ──────────────── Public Routes (No Auth Required) ────────────────
|
||||
@@ -78,6 +79,46 @@ export async function verifyAuth(request: any): Promise<string | null> {
|
||||
return "Authentication required";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is authenticated — boolean convenience wrapper for route handlers.
|
||||
*
|
||||
* Uses `cookies()` from next/headers (App Router compatible) and Bearer API key.
|
||||
* Returns true if authenticated, false otherwise.
|
||||
*
|
||||
* Unlike `verifyAuth`, this does NOT check `isAuthRequired()` — callers that
|
||||
* need to conditionally skip auth should check that separately.
|
||||
*/
|
||||
export async function isAuthenticated(request: Request): Promise<boolean> {
|
||||
// 1. Check API key (for external clients)
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const apiKey = authHeader.slice(7);
|
||||
try {
|
||||
const { validateApiKey } = await import("@/lib/db/apiKeys");
|
||||
if (await validateApiKey(apiKey)) return true;
|
||||
} catch {
|
||||
// DB not ready or import error
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check JWT cookie (for dashboard session)
|
||||
if (process.env.JWT_SECRET) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("auth_token")?.value;
|
||||
if (token) {
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
await jwtVerify(token, secret);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid/expired token or cookies not available
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route is in the public (no-auth) allowlist.
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ import crypto from "crypto";
|
||||
if (!process.env.API_KEY_SECRET) {
|
||||
console.error("[SECURITY] API_KEY_SECRET is not set. API key CRC will be insecure.");
|
||||
}
|
||||
const API_KEY_SECRET = process.env.API_KEY_SECRET;
|
||||
const API_KEY_SECRET = process.env.API_KEY_SECRET || "omniroute-insecure-default-key";
|
||||
|
||||
/**
|
||||
* Generate 6-char random keyId
|
||||
|
||||
117
src/shared/utils/apiKeyPolicy.ts
Normal file
117
src/shared/utils/apiKeyPolicy.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* API Key Policy Enforcement — Shared middleware for all /v1/* endpoints.
|
||||
*
|
||||
* Enforces API key policies: model restrictions and budget limits.
|
||||
* Should be called after API key authentication in every endpoint that
|
||||
* accepts a model parameter.
|
||||
*
|
||||
* @module shared/utils/apiKeyPolicy
|
||||
*/
|
||||
|
||||
import { extractApiKey } from "@/sse/services/auth";
|
||||
import { getApiKeyMetadata, isModelAllowedForKey } from "@/lib/localDb";
|
||||
import { checkBudget } from "@/domain/costRules";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
|
||||
/** Metadata stored for an API key in the local database. */
|
||||
export interface ApiKeyMetadata {
|
||||
id: string;
|
||||
name?: string;
|
||||
allowedModels?: string[];
|
||||
budget?: number;
|
||||
usedBudget?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ApiKeyPolicyResult {
|
||||
/** API key string (null if no key provided) */
|
||||
apiKey: string | null;
|
||||
/** Metadata from DB (null if no key or key not found) */
|
||||
apiKeyInfo: ApiKeyMetadata | null;
|
||||
/** If set, the request should be rejected with this Response */
|
||||
rejection: Response | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce API key policies for a request.
|
||||
*
|
||||
* Checks:
|
||||
* 1. Model restriction — if the key has `allowedModels`, verify the requested model is permitted
|
||||
* 2. Budget limit — if the key has a budget configured, verify it hasn't been exceeded
|
||||
*
|
||||
* @param request - The incoming HTTP request
|
||||
* @param modelStr - The model ID from the request body
|
||||
* @returns ApiKeyPolicyResult with apiKey, metadata, and optional rejection response
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const policy = await enforceApiKeyPolicy(request, body.model);
|
||||
* if (policy.rejection) return policy.rejection;
|
||||
* // proceed with request, optionally use policy.apiKeyInfo
|
||||
* ```
|
||||
*/
|
||||
export async function enforceApiKeyPolicy(
|
||||
request: Request,
|
||||
modelStr: string | null
|
||||
): Promise<ApiKeyPolicyResult> {
|
||||
const apiKey = extractApiKey(request);
|
||||
|
||||
// No API key = local mode, skip policy checks
|
||||
if (!apiKey) {
|
||||
return { apiKey: null, apiKeyInfo: null, rejection: null };
|
||||
}
|
||||
|
||||
// Fetch key metadata (includes allowedModels)
|
||||
let apiKeyInfo: ApiKeyMetadata | null = null;
|
||||
try {
|
||||
apiKeyInfo = await getApiKeyMetadata(apiKey);
|
||||
} catch (error) {
|
||||
// If metadata fetch fails, don't block — degrade gracefully, but log for debugging
|
||||
log.warn("API_POLICY", "Failed to fetch API key metadata. Request will be allowed.", { error });
|
||||
return { apiKey, apiKeyInfo: null, rejection: null };
|
||||
}
|
||||
|
||||
// Key not found in DB — skip policy (auth layer handles validation)
|
||||
if (!apiKeyInfo) {
|
||||
return { apiKey, apiKeyInfo: null, rejection: null };
|
||||
}
|
||||
|
||||
// ── Check 1: Model restriction ──
|
||||
if (modelStr && apiKeyInfo.allowedModels && apiKeyInfo.allowedModels.length > 0) {
|
||||
const allowed = await isModelAllowedForKey(apiKey, modelStr);
|
||||
if (!allowed) {
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(
|
||||
HTTP_STATUS.FORBIDDEN,
|
||||
`Model "${modelStr}" is not allowed for this API key`
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 2: Budget limit ──
|
||||
if (apiKeyInfo.id) {
|
||||
try {
|
||||
const budgetOk = checkBudget(apiKeyInfo.id);
|
||||
if (!budgetOk.allowed) {
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(
|
||||
HTTP_STATUS.RATE_LIMITED,
|
||||
budgetOk.reason || "Budget limit exceeded"
|
||||
),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Budget check is best-effort — don't block on errors, but log them
|
||||
log.warn("API_POLICY", "Budget check failed. Request will be allowed.", { error });
|
||||
}
|
||||
}
|
||||
|
||||
return { apiKey, apiKeyInfo, rejection: null };
|
||||
}
|
||||
@@ -72,6 +72,13 @@ export const updateSettingsSchema = z.object({
|
||||
setupComplete: z.boolean().optional(),
|
||||
requireAuthForModels: z.boolean().optional(),
|
||||
blockedProviders: z.array(z.string().max(100)).optional(),
|
||||
hideHealthCheckLogs: z.boolean().optional(),
|
||||
// Routing settings (#134)
|
||||
fallbackStrategy: z
|
||||
.enum(["fill-first", "round-robin", "p2c", "random", "least-used", "cost-optimized"])
|
||||
.optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
|
||||
});
|
||||
|
||||
// ──── Auth Schemas ────
|
||||
|
||||
Reference in New Issue
Block a user