mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
feat(api): Phase 7 — API & Code Quality
7.1 — Consolidated rate-limit routes - Merged rate-limit/ and rate-limits/ into rate-limits/route.js - GET returns connections + overview + lockouts + cacheStats (unified) - POST handles toggle protection - Old rate-limit/ now redirects 308 → rate-limits/ - Updated frontend fetch URL in providers/[id]/page.js 7.2 — Zod schema for provider constants - New: src/shared/validation/providerSchema.js - Validates FREE_PROVIDERS, OAUTH_PROVIDERS, APIKEY_PROVIDERS at module load - Catches config drift (invalid colors, missing fields) at startup 7.3 — TailwindCSS error pages - Converted not-found.js inline styles → Tailwind classes - Converted global-error.js inline styles → Tailwind classes - Replaced JS hover handlers with Tailwind hover: utilities 7.4 — Fixed GitHub link in privacy page - decolua/omniroute → diegosouzapw/OmniRoute 7.5 — Already completed in Phase 5 Tests: 295 pass | Build: success
This commit is contained in:
@@ -276,7 +276,7 @@ export default function ProviderDetailPage() {
|
||||
|
||||
const handleToggleRateLimit = async (connectionId, enabled) => {
|
||||
try {
|
||||
const res = await fetch("/api/rate-limit", {
|
||||
const res = await fetch("/api/rate-limits", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ connectionId, enabled }),
|
||||
|
||||
@@ -1,63 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnections, updateProviderConnection } from "@/lib/localDb";
|
||||
import {
|
||||
enableRateLimitProtection,
|
||||
disableRateLimitProtection,
|
||||
getRateLimitStatus,
|
||||
getAllRateLimitStatus,
|
||||
} from "@omniroute/open-sse/services/rateLimitManager.js";
|
||||
|
||||
/**
|
||||
* GET /api/rate-limit — Get rate limit status for all connections
|
||||
* @deprecated Use /api/rate-limits instead.
|
||||
* This route redirects to the consolidated rate-limits endpoint.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const connections = await getProviderConnections();
|
||||
const statuses = connections.map((conn) => ({
|
||||
connectionId: conn.id,
|
||||
provider: conn.provider,
|
||||
name: conn.name || conn.email || conn.id.slice(0, 8),
|
||||
rateLimitProtection: !!conn.rateLimitProtection,
|
||||
...getRateLimitStatus(conn.provider, conn.id),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
connections: statuses,
|
||||
overview: getAllRateLimitStatus(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/rate-limit GET:", error);
|
||||
return NextResponse.json({ error: "Failed to get rate limit status" }, { status: 500 });
|
||||
}
|
||||
export async function GET(request) {
|
||||
const url = new URL(request.url);
|
||||
url.pathname = "/api/rate-limits";
|
||||
return NextResponse.redirect(url, 308);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/rate-limit — Toggle rate limit protection for a connection
|
||||
* Body: { connectionId: string, enabled: boolean }
|
||||
*/
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { connectionId, enabled } = await request.json();
|
||||
|
||||
if (!connectionId) {
|
||||
return NextResponse.json({ error: "Missing connectionId" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Update in-memory state
|
||||
if (enabled) {
|
||||
enableRateLimitProtection(connectionId);
|
||||
} else {
|
||||
disableRateLimitProtection(connectionId);
|
||||
}
|
||||
|
||||
// Persist to database
|
||||
await updateProviderConnection(connectionId, {
|
||||
rateLimitProtection: !!enabled,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, connectionId, enabled: !!enabled });
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/rate-limit POST:", error);
|
||||
return NextResponse.json({ error: "Failed to toggle rate limit" }, { status: 500 });
|
||||
}
|
||||
const url = new URL(request.url);
|
||||
url.pathname = "/api/rate-limits";
|
||||
return NextResponse.redirect(url, 308);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getAllModelLockouts,
|
||||
} from "@omniroute/open-sse/services/accountFallback.js";
|
||||
import { getAllModelLockouts } from "@omniroute/open-sse/services/accountFallback.js";
|
||||
import { getCacheStats } from "@omniroute/open-sse/services/signatureCache.js";
|
||||
import { getProviderConnections, updateProviderConnection } from "@/lib/localDb";
|
||||
import {
|
||||
enableRateLimitProtection,
|
||||
disableRateLimitProtection,
|
||||
getRateLimitStatus,
|
||||
getAllRateLimitStatus,
|
||||
} from "@omniroute/open-sse/services/rateLimitManager.js";
|
||||
|
||||
/**
|
||||
* GET /api/rate-limits — Consolidated rate-limit status
|
||||
*
|
||||
* Returns:
|
||||
* - Per-connection rate-limit status (protection toggle, current state)
|
||||
* - Global overview (all providers)
|
||||
* - Model lockouts
|
||||
* - Signature cache stats
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const connections = await getProviderConnections();
|
||||
const statuses = connections.map((conn) => ({
|
||||
connectionId: conn.id,
|
||||
provider: conn.provider,
|
||||
name: conn.name || conn.email || conn.id.slice(0, 8),
|
||||
rateLimitProtection: !!conn.rateLimitProtection,
|
||||
...getRateLimitStatus(conn.provider, conn.id),
|
||||
}));
|
||||
|
||||
const lockouts = getAllModelLockouts();
|
||||
const cacheStats = getCacheStats();
|
||||
return NextResponse.json({ lockouts, cacheStats });
|
||||
|
||||
return NextResponse.json({
|
||||
connections: statuses,
|
||||
overview: getAllRateLimitStatus(),
|
||||
lockouts,
|
||||
cacheStats,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
console.error("[API ERROR] /api/rate-limits GET:", error);
|
||||
return NextResponse.json({ error: "Failed to get rate limit status" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/rate-limits — Toggle rate limit protection for a connection
|
||||
* Body: { connectionId: string, enabled: boolean }
|
||||
*/
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { connectionId, enabled } = await request.json();
|
||||
|
||||
if (!connectionId) {
|
||||
return NextResponse.json({ error: "Missing connectionId" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Update in-memory state
|
||||
if (enabled) {
|
||||
enableRateLimitProtection(connectionId);
|
||||
} else {
|
||||
disableRateLimitProtection(connectionId);
|
||||
}
|
||||
|
||||
// Persist to database
|
||||
await updateProviderConnection(connectionId, {
|
||||
rateLimitProtection: !!enabled,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, connectionId, enabled: !!enabled });
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/rate-limits POST:", error);
|
||||
return NextResponse.json({ error: "Failed to toggle rate limit" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,88 +6,29 @@
|
||||
* Root-level error boundary for unrecoverable errors.
|
||||
* This is the last resort — catches errors that the per-page
|
||||
* error.js boundaries don't handle.
|
||||
* Styled with TailwindCSS 4 (Phase 7.3).
|
||||
*/
|
||||
|
||||
export default function GlobalError({ error, reset }) {
|
||||
return (
|
||||
<html>
|
||||
<body
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
padding: "24px",
|
||||
background: "#0a0a0f",
|
||||
color: "#e0e0e0",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
textAlign: "center",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "64px",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "28px",
|
||||
fontWeight: 700,
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "15px",
|
||||
color: "#888",
|
||||
maxWidth: "400px",
|
||||
lineHeight: 1.5,
|
||||
marginBottom: "24px",
|
||||
}}
|
||||
>
|
||||
<body className="flex flex-col items-center justify-center min-h-screen p-6 bg-[#0a0a0f] text-[#e0e0e0] font-[system-ui,-apple-system,sans-serif] text-center m-0">
|
||||
<div className="text-[64px] mb-4">⚠️</div>
|
||||
<h1 className="text-[28px] font-bold mb-2">Something went wrong</h1>
|
||||
<p className="text-[15px] text-[#888] max-w-[400px] leading-relaxed mb-6">
|
||||
An unexpected error occurred. This has been logged and our team will investigate.
|
||||
</p>
|
||||
{process.env.NODE_ENV === "development" && error?.message && (
|
||||
<pre
|
||||
style={{
|
||||
padding: "16px",
|
||||
borderRadius: "8px",
|
||||
background: "rgba(239, 68, 68, 0.1)",
|
||||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
||||
color: "#ef4444",
|
||||
fontSize: "12px",
|
||||
maxWidth: "600px",
|
||||
overflow: "auto",
|
||||
textAlign: "left",
|
||||
marginBottom: "24px",
|
||||
}}
|
||||
>
|
||||
<pre className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-xs max-w-[600px] overflow-auto text-left mb-6">
|
||||
{error.message}
|
||||
</pre>
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
className="px-8 py-3 rounded-[10px] text-white border-none text-sm font-semibold cursor-pointer transition-transform duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5"
|
||||
style={{
|
||||
padding: "12px 32px",
|
||||
borderRadius: "10px",
|
||||
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
transition: "transform 0.2s",
|
||||
boxShadow: "0 4px 16px rgba(99, 102, 241, 0.3)",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.target.style.transform = "translateY(-2px)")}
|
||||
onMouseLeave={(e) => (e.target.style.transform = "translateY(0)")}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
|
||||
@@ -4,70 +4,33 @@
|
||||
* Custom Not Found Page — FASE-04 Error Handling
|
||||
*
|
||||
* Displayed when a user navigates to a non-existent route.
|
||||
* Styled with TailwindCSS 4 (Phase 7.3).
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
padding: "24px",
|
||||
background: "var(--bg-primary, #0a0a0f)",
|
||||
color: "var(--text-primary, #e0e0e0)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-6 bg-[var(--bg-primary,#0a0a0f)] text-[var(--text-primary,#e0e0e0)] text-center">
|
||||
<div
|
||||
className="text-[96px] font-extrabold leading-none mb-2"
|
||||
style={{
|
||||
fontSize: "96px",
|
||||
fontWeight: 800,
|
||||
background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
lineHeight: 1,
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
404
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
fontWeight: 600,
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Page not found
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "15px",
|
||||
color: "var(--text-secondary, #888)",
|
||||
maxWidth: "400px",
|
||||
lineHeight: 1.5,
|
||||
marginBottom: "32px",
|
||||
}}
|
||||
>
|
||||
<h1 className="text-2xl font-semibold mb-2">Page not found</h1>
|
||||
<p className="text-[15px] text-[var(--text-secondary,#888)] max-w-[400px] leading-relaxed mb-8">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="px-8 py-3 rounded-[10px] text-white text-sm font-semibold no-underline transition-all duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5"
|
||||
style={{
|
||||
padding: "12px 32px",
|
||||
borderRadius: "10px",
|
||||
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
color: "#fff",
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
transition: "all 0.2s",
|
||||
boxShadow: "0 4px 16px rgba(99, 102, 241, 0.3)",
|
||||
}}
|
||||
>
|
||||
Go to Dashboard
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function PrivacyPage() {
|
||||
<p>
|
||||
Questions? Visit our{" "}
|
||||
<a
|
||||
href="https://github.com/decolua/omniroute"
|
||||
href="https://github.com/diegosouzapw/OmniRoute"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
@@ -329,3 +329,10 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => {
|
||||
|
||||
// Providers that support usage/quota API
|
||||
export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github", "codex", "claude"];
|
||||
|
||||
// ── Zod validation at module load (Phase 7.2) ──
|
||||
import { validateProviders } from "../validation/providerSchema.js";
|
||||
|
||||
validateProviders(FREE_PROVIDERS, "FREE_PROVIDERS");
|
||||
validateProviders(OAUTH_PROVIDERS, "OAUTH_PROVIDERS");
|
||||
validateProviders(APIKEY_PROVIDERS, "APIKEY_PROVIDERS");
|
||||
|
||||
38
src/shared/validation/providerSchema.js
Normal file
38
src/shared/validation/providerSchema.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Provider Schema Validation — Phase 7.2
|
||||
*
|
||||
* Zod schemas for provider constant validation.
|
||||
* Validates FREE_PROVIDERS, OAUTH_PROVIDERS, and APIKEY_PROVIDERS
|
||||
* at module load time to catch configuration drift early.
|
||||
*
|
||||
* @module shared/validation/providerSchema
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const ProviderSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
alias: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
icon: z.string().min(1),
|
||||
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "Must be a valid hex color (#RRGGBB)"),
|
||||
textIcon: z.string().optional(),
|
||||
website: z.string().url().optional(),
|
||||
passthroughModels: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ProvidersMapSchema = z.record(z.string(), ProviderSchema);
|
||||
|
||||
/**
|
||||
* Validate a providers map, throwing a descriptive error on failure.
|
||||
* @param {Record<string, object>} map - The providers map to validate
|
||||
* @param {string} name - Name of the map for error messages
|
||||
*/
|
||||
export function validateProviders(map, name) {
|
||||
const result = ProvidersMapSchema.safeParse(map);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
|
||||
console.error(`[PROVIDER VALIDATION] ${name} has invalid entries:\n${issues}`);
|
||||
throw new Error(`Provider validation failed for ${name}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user