mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
feat(proxy): serverless relay endpoints with rate limiting (#2675)
Integrated into release/v3.8.3
This commit is contained in:
committed by
GitHub
parent
989994d2e5
commit
ee1ce57065
@@ -138,6 +138,6 @@ self.addEventListener("notificationclick", (event) => {
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(urlToOpen);
|
||||
}
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
245
src/app/(dashboard)/dashboard/relay/RelayProxyClient.tsx
Normal file
245
src/app/(dashboard)/dashboard/relay/RelayProxyClient.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Card from "@/shared/components/Card";
|
||||
import Badge from "@/shared/components/Badge";
|
||||
import Button from "@/shared/components/Button";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
interface RelayToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
description: string;
|
||||
comboId: string | null;
|
||||
allowedModels: string;
|
||||
maxRequestsPerMinute: number;
|
||||
maxRequestsPerDay: number;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
lastUsedAt: number | null;
|
||||
}
|
||||
|
||||
export default function RelayProxyClient() {
|
||||
const [tokens, setTokens] = useState<RelayToken[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newTokenData, setNewTokenData] = useState<{ rawToken: string; name: string } | null>(null);
|
||||
const [form, setForm] = useState({ name: "", description: "", maxRpm: "60", maxRpd: "10000" });
|
||||
const addNotification = useNotificationStore((s) => s.addNotification);
|
||||
|
||||
const fetchTokens = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/relay/tokens");
|
||||
const data = await res.json();
|
||||
setTokens(Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
setTokens([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchTokens(); }, [fetchTokens]);
|
||||
|
||||
const createToken = async () => {
|
||||
if (!form.name.trim()) return;
|
||||
try {
|
||||
const res = await fetch("/api/relay/tokens", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
maxRequestsPerMinute: Number(form.maxRpm),
|
||||
maxRequestsPerDay: Number(form.maxRpd),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setNewTokenData({ rawToken: data.rawToken, name: data.name });
|
||||
setForm({ name: "", description: "", maxRpm: "60", maxRpd: "10000" });
|
||||
setShowCreate(false);
|
||||
addNotification({ type: "success", message: "Relay token created" });
|
||||
fetchTokens();
|
||||
} else {
|
||||
addNotification({ type: "error", message: data.error || "Failed to create token" });
|
||||
}
|
||||
} catch {
|
||||
addNotification({ type: "error", message: "Failed to create token" });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleToken = async (id: string, enabled: boolean) => {
|
||||
try {
|
||||
await fetch(`/api/relay/tokens/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
fetchTokens();
|
||||
} catch {
|
||||
addNotification({ type: "error", message: "Failed to toggle token" });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteToken = async (id: string) => {
|
||||
if (!confirm("Delete this relay token? This cannot be undone.")) return;
|
||||
try {
|
||||
await fetch(`/api/relay/tokens/${id}`, { method: "DELETE" });
|
||||
addNotification({ type: "success", message: "Token deleted" });
|
||||
fetchTokens();
|
||||
} catch {
|
||||
addNotification({ type: "error", message: "Failed to delete token" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Serverless Relay Proxies</h1>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Create public API endpoints that proxy to OmniRoute with rate limiting and access control
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(!showCreate)}>
|
||||
{showCreate ? "Cancel" : "New Relay Token"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreate && (
|
||||
<Card>
|
||||
<div className="p-4 space-y-4">
|
||||
<h2 className="text-sm font-semibold">Create Relay Token</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Name *</label>
|
||||
<input
|
||||
className="w-full border border-border rounded-lg px-3 py-2 bg-surface text-sm"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="my-api-relay"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description</label>
|
||||
<input
|
||||
className="w-full border border-border rounded-lg px-3 py-2 bg-surface text-sm"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="For my serverless functions"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Max Requests/Minute</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full border border-border rounded-lg px-3 py-2 bg-surface text-sm"
|
||||
value={form.maxRpm}
|
||||
onChange={(e) => setForm({ ...form, maxRpm: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Max Requests/Day</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full border border-border rounded-lg px-3 py-2 bg-surface text-sm"
|
||||
value={form.maxRpd}
|
||||
onChange={(e) => setForm({ ...form, maxRpd: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={createToken} disabled={!form.name.trim()}>Create Token</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Token Display (shown once after creation) */}
|
||||
{newTokenData && (
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-green-600 dark:text-green-400">
|
||||
Token Created — Copy it now!
|
||||
</h2>
|
||||
<div className="bg-surface/50 border border-border rounded-lg p-3">
|
||||
<p className="text-xs text-text-muted mb-1">Token for <strong>{newTokenData.name}</strong>:</p>
|
||||
<code className="text-sm font-mono break-all select-all bg-black/10 dark:bg-white/10 px-2 py-1 rounded">
|
||||
{newTokenData.rawToken}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
This token will not be shown again. Store it securely.
|
||||
</p>
|
||||
<Button onClick={() => { setNewTokenData(null); }}>Dismiss</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Usage Guide */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-2">
|
||||
<h2 className="text-sm font-semibold">Usage</h2>
|
||||
<p className="text-xs text-text-muted">
|
||||
Send requests to your relay endpoint:
|
||||
</p>
|
||||
<pre className="text-xs bg-surface/50 border border-border rounded-lg p-3 overflow-x-auto">
|
||||
{`curl http://localhost:20128/v1/relay/chat/completions \\
|
||||
-H "Authorization: Bearer relay_..." \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"model":"claude-sonnet-4","messages":[{"role":"user","content":"Hello"}]}'`}
|
||||
</pre>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tokens List */}
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<h2 className="text-sm font-semibold mb-3">
|
||||
Relay Tokens ({tokens.length})
|
||||
</h2>
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-muted">Loading...</p>
|
||||
) : tokens.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">No relay tokens configured. Create one to get started.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tokens.map((t) => (
|
||||
<div key={t.id} className="flex items-center justify-between border border-border rounded-lg p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${t.enabled ? "bg-green-500" : "bg-red-500"}`} />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{t.name}</div>
|
||||
<div className="text-xs text-text-muted font-mono">{t.tokenPrefix}...</div>
|
||||
{t.description && (
|
||||
<div className="text-xs text-text-muted mt-0.5">{t.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="info" size="sm">{t.maxRequestsPerMinute}/min</Badge>
|
||||
<Badge variant="info" size="sm">{t.maxRequestsPerDay}/day</Badge>
|
||||
<button
|
||||
onClick={() => toggleToken(t.id, !t.enabled)}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{t.enabled ? "Disable" : "Enable"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteToken(t.id)}
|
||||
className="text-xs text-red-500 hover:underline"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/(dashboard)/dashboard/relay/page.tsx
Normal file
11
src/app/(dashboard)/dashboard/relay/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import RelayProxyClient from "./RelayProxyClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OmniRoute — Relay Proxies",
|
||||
description: "Serverless relay proxy endpoints for your AI infrastructure",
|
||||
};
|
||||
|
||||
export default function RelayProxyPage() {
|
||||
return <RelayProxyClient />;
|
||||
}
|
||||
51
src/app/api/relay/tokens/[id]/route.ts
Normal file
51
src/app/api/relay/tokens/[id]/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRelayToken, updateRelayToken, deleteRelayToken, toggleRelayToken, getRelayLogs, getRelayUsage } from "@/lib/db/relayProxies";
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const token = getRelayToken(id);
|
||||
if (!token) return NextResponse.json({ error: "Token not found" }, { status: 404 });
|
||||
|
||||
// Get usage stats
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const lastHour = getRelayUsage(id, now - 3600);
|
||||
const lastDay = getRelayUsage(id, now - 86400);
|
||||
const logs = getRelayLogs(id, 20);
|
||||
|
||||
return NextResponse.json({
|
||||
...token,
|
||||
usage: { lastHour, lastDay },
|
||||
logs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
if (body.enabled !== undefined) {
|
||||
const token = toggleRelayToken(id, body.enabled);
|
||||
if (!token) return NextResponse.json({ error: "Token not found" }, { status: 404 });
|
||||
return NextResponse.json(token);
|
||||
}
|
||||
|
||||
const token = updateRelayToken(id, {
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
comboId: body.comboId,
|
||||
allowedModels: body.allowedModels,
|
||||
maxTokensPerRequest: body.maxTokensPerRequest,
|
||||
maxRequestsPerMinute: body.maxRequestsPerMinute,
|
||||
maxRequestsPerDay: body.maxRequestsPerDay,
|
||||
maxCostPerDay: body.maxCostPerDay,
|
||||
});
|
||||
|
||||
if (!token) return NextResponse.json({ error: "Token not found" }, { status: 404 });
|
||||
return NextResponse.json(token);
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
deleteRelayToken(id);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
53
src/app/api/relay/tokens/route.ts
Normal file
53
src/app/api/relay/tokens/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRelayTokens, createRelayToken } from "@/lib/db/relayProxies";
|
||||
|
||||
export async function GET() {
|
||||
const tokens = getRelayTokens();
|
||||
// Strip hash from response
|
||||
const safe = tokens.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
description: t.description,
|
||||
comboId: t.comboId,
|
||||
allowedModels: t.allowedModels,
|
||||
maxTokensPerRequest: t.maxTokensPerRequest,
|
||||
maxRequestsPerMinute: t.maxRequestsPerMinute,
|
||||
maxRequestsPerDay: t.maxRequestsPerDay,
|
||||
maxCostPerDay: t.maxCostPerDay,
|
||||
enabled: t.enabled,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
expiresAt: t.expiresAt,
|
||||
lastUsedAt: t.lastUsedAt,
|
||||
}));
|
||||
return NextResponse.json(safe);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const token = createRelayToken({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
comboId: body.comboId,
|
||||
allowedModels: body.allowedModels,
|
||||
maxTokensPerRequest: body.maxTokensPerRequest,
|
||||
maxRequestsPerMinute: body.maxRequestsPerMinute,
|
||||
maxRequestsPerDay: body.maxRequestsPerDay,
|
||||
maxCostPerDay: body.maxCostPerDay,
|
||||
expiresAt: body.expiresAt,
|
||||
metadata: body.metadata,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: token.id,
|
||||
name: token.name,
|
||||
rawToken: token.rawToken,
|
||||
tokenPrefix: token.tokenPrefix,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
175
src/app/api/v1/relay/chat/completions/route.ts
Normal file
175
src/app/api/v1/relay/chat/completions/route.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* POST /api/v1/relay/chat/completions
|
||||
*
|
||||
* Serverless Relay Proxy endpoint.
|
||||
* Authenticates via relay token, applies rate limits, then proxies
|
||||
* to the internal OmniRoute chat completions pipeline.
|
||||
*/
|
||||
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { handleChat } from "@/sse/handlers/chat";
|
||||
import { createInjectionGuard } from "@/middleware/promptInjectionGuard";
|
||||
import { getRelayTokenByHash, checkRateLimit, recordRelayUsage } from "@/lib/db/relayProxies";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const injectionGuard = createInjectionGuard();
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleCorsOptions();
|
||||
}
|
||||
|
||||
function extractToken(request: Request): string | null {
|
||||
const auth = request.headers.get("authorization") || "";
|
||||
const match = auth.match(/^Bearer\s+(.+)$/i);
|
||||
if (match) return match[1];
|
||||
|
||||
// Also check X-Relay-Token header
|
||||
return request.headers.get("x-relay-token");
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const startTime = Date.now();
|
||||
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||
|| request.headers.get("x-real-ip")
|
||||
|| "unknown";
|
||||
const userAgent = request.headers.get("user-agent") || "unknown";
|
||||
|
||||
try {
|
||||
// 1. Authenticate
|
||||
const rawToken = extractToken(request);
|
||||
if (!rawToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: "Missing relay token", type: "auth_error", code: "RELAY_AUTH_001" } }),
|
||||
{ status: 401, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const token = getRelayTokenByHash(tokenHash);
|
||||
if (!token) {
|
||||
recordRelayUsage("unknown", {
|
||||
requestId: request.headers.get("x-request-id") || undefined,
|
||||
status: "auth_failed",
|
||||
statusCode: 401,
|
||||
latencyMs: Date.now() - startTime,
|
||||
clientIp,
|
||||
userAgent,
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: "Invalid relay token", type: "auth_error", code: "RELAY_AUTH_002" } }),
|
||||
{ status: 401, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (token.expiresAt && Math.floor(Date.now() / 1000) > token.expiresAt) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: "Relay token expired", type: "auth_error", code: "RELAY_AUTH_003" } }),
|
||||
{ status: 401, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Rate limit check
|
||||
const rateCheck = checkRateLimit(token.id);
|
||||
if (!rateCheck.allowed) {
|
||||
recordRelayUsage(token.id, {
|
||||
requestId: request.headers.get("x-request-id") || undefined,
|
||||
status: "rate_limited",
|
||||
statusCode: 429,
|
||||
latencyMs: Date.now() - startTime,
|
||||
clientIp,
|
||||
userAgent,
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: "Rate limit exceeded", type: "rate_limited", code: "RELAY_RATE_001" } }),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
...CORS_HEADERS,
|
||||
"Content-Type": "application/json",
|
||||
"Retry-After": String(rateCheck.resetIn),
|
||||
"X-RateLimit-Remaining": "0",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Clone request and forward to internal handler
|
||||
const cloned = request.clone();
|
||||
|
||||
// Prompt injection guard (same as main endpoint)
|
||||
try {
|
||||
const body = await cloned.json().catch(() => null);
|
||||
if (body) {
|
||||
const { blocked, result } = injectionGuard(body);
|
||||
if (blocked) {
|
||||
recordRelayUsage(token.id, {
|
||||
requestId: request.headers.get("x-request-id") || undefined,
|
||||
status: "error",
|
||||
statusCode: 400,
|
||||
latencyMs: Date.now() - startTime,
|
||||
clientIp,
|
||||
userAgent,
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: { message: "Request blocked: potential prompt injection detected", type: "injection_detected", code: "SECURITY_001", detections: result.detections.length },
|
||||
}),
|
||||
{ status: 400, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Check allowed models
|
||||
const allowedModels: string[] = JSON.parse(token.allowedModels);
|
||||
if (allowedModels.length > 0 && !allowedModels.includes("*")) {
|
||||
const model = (body as { model?: string }).model || "";
|
||||
const allowed = allowedModels.some(
|
||||
(p) => model === p || (p.endsWith("*") && model.startsWith(p.slice(0, -1))),
|
||||
);
|
||||
if (!allowed) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: `Model "${model}" not allowed by this relay token`, type: "model_not_allowed", code: "RELAY_MODEL_001" } }),
|
||||
{ status: 403, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue even if guard fails
|
||||
}
|
||||
|
||||
// 4. Proxy to internal handler
|
||||
const originalRequest = new Request(request.url.replace("/relay/chat/completions", "/chat/completions"), request);
|
||||
const response = await handleChat(originalRequest);
|
||||
|
||||
// 5. Record usage (async, don't block response)
|
||||
const latencyMs = Date.now() - startTime;
|
||||
recordRelayUsage(token.id, {
|
||||
requestId: request.headers.get("x-request-id") || undefined,
|
||||
status: response.status < 500 ? "success" : "error",
|
||||
statusCode: response.status,
|
||||
latencyMs,
|
||||
clientIp,
|
||||
userAgent,
|
||||
});
|
||||
|
||||
// Add relay headers
|
||||
const newHeaders = new Headers(response.headers);
|
||||
newHeaders.set("X-Relay-Token", token.tokenPrefix + "...");
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: newHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: `Relay error: ${message}`, type: "relay_error", code: "RELAY_ERR_001" } }),
|
||||
{ status: 500, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
56
src/lib/db/migrations/067_relay_proxies.sql
Normal file
56
src/lib/db/migrations/067_relay_proxies.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- Migration 066: Serverless Relay Proxies
|
||||
-- Creates tables for relay tokens, rate limits, and usage tracking.
|
||||
|
||||
-- Relay tokens: map external API consumers to internal OmniRoute configuration
|
||||
CREATE TABLE IF NOT EXISTS relay_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE, -- bcrypt hash of the token
|
||||
token_prefix TEXT NOT NULL, -- first 8 chars for display (e.g., "rl_abc123")
|
||||
description TEXT DEFAULT '',
|
||||
combo_id TEXT, -- optional: restrict to a specific combo
|
||||
allowed_models TEXT DEFAULT '[]', -- JSON array of model patterns (e.g., ["claude-*", "gpt-*"])
|
||||
max_tokens_per_request INTEGER DEFAULT 128000,
|
||||
max_requests_per_minute INTEGER DEFAULT 60,
|
||||
max_requests_per_day INTEGER DEFAULT 10000,
|
||||
max_cost_per_day REAL DEFAULT 0, -- 0 = unlimited
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
expires_at INTEGER, -- optional TTL
|
||||
last_used_at INTEGER,
|
||||
metadata TEXT DEFAULT '{}'
|
||||
);
|
||||
|
||||
-- Rate limit window tracking
|
||||
CREATE TABLE IF NOT EXISTS relay_rate_limits (
|
||||
token_id TEXT NOT NULL,
|
||||
window_start INTEGER NOT NULL, -- unix timestamp of window start
|
||||
request_count INTEGER DEFAULT 0,
|
||||
cost REAL DEFAULT 0,
|
||||
PRIMARY KEY (token_id, window_start),
|
||||
FOREIGN KEY (token_id) REFERENCES relay_tokens(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Relay request logs
|
||||
CREATE TABLE IF NOT EXISTS relay_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_id TEXT NOT NULL,
|
||||
request_id TEXT, -- X-Request-Id
|
||||
model TEXT,
|
||||
prompt_tokens INTEGER DEFAULT 0,
|
||||
completion_tokens INTEGER DEFAULT 0,
|
||||
cost REAL DEFAULT 0,
|
||||
status TEXT DEFAULT 'success', -- success, rate_limited, auth_failed, error
|
||||
status_code INTEGER DEFAULT 200,
|
||||
latency_ms INTEGER DEFAULT 0,
|
||||
client_ip TEXT,
|
||||
user_agent TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (token_id) REFERENCES relay_tokens(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_logs_token ON relay_logs(token_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_logs_created ON relay_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_tokens_prefix ON relay_tokens(token_prefix);
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_rate_limits_window ON relay_rate_limits(token_id, window_start);
|
||||
294
src/lib/db/relayProxies.ts
Normal file
294
src/lib/db/relayProxies.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Relay Proxy DB module
|
||||
*
|
||||
* Manages relay tokens, rate limits, and usage tracking for serverless relay proxies.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { getDbInstance } from "./core";
|
||||
import { rowToCamel } from "./core";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RelayToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenHash: string;
|
||||
tokenPrefix: string;
|
||||
description: string;
|
||||
comboId: string | null;
|
||||
allowedModels: string;
|
||||
maxTokensPerRequest: number;
|
||||
maxRequestsPerMinute: number;
|
||||
maxRequestsPerDay: number;
|
||||
maxCostPerDay: number;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
expiresAt: number | null;
|
||||
lastUsedAt: number | null;
|
||||
metadata: string;
|
||||
}
|
||||
|
||||
export interface RelayTokenRow {
|
||||
id: string;
|
||||
name: string;
|
||||
token_hash: string;
|
||||
token_prefix: string;
|
||||
description: string;
|
||||
combo_id: string | null;
|
||||
allowed_models: string;
|
||||
max_tokens_per_request: number;
|
||||
max_requests_per_minute: number;
|
||||
max_requests_per_day: number;
|
||||
max_cost_per_day: number;
|
||||
enabled: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
expires_at: number | null;
|
||||
last_used_at: number | null;
|
||||
metadata: string;
|
||||
}
|
||||
|
||||
export interface CreateRelayTokenInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
comboId?: string;
|
||||
allowedModels?: string[];
|
||||
maxTokensPerRequest?: number;
|
||||
maxRequestsPerMinute?: number;
|
||||
maxRequestsPerDay?: number;
|
||||
maxCostPerDay?: number;
|
||||
expiresAt?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RelayTokenWithSecret extends RelayToken {
|
||||
rawToken: string; // Only returned once on creation
|
||||
}
|
||||
|
||||
export interface RelayLogRow {
|
||||
id: number;
|
||||
token_id: string;
|
||||
request_id: string | null;
|
||||
model: string | null;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
cost: number;
|
||||
status: string;
|
||||
status_code: number;
|
||||
latency_ms: number;
|
||||
client_ip: string | null;
|
||||
user_agent: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function generateId(): string {
|
||||
return "rl_" + randomBytes(16).toString("hex");
|
||||
}
|
||||
|
||||
function generateToken(): string {
|
||||
return "relay_" + randomBytes(24).toString("hex");
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
// Simple hash for token comparison (not bcrypt-heavy for performance)
|
||||
const { createHash } = require("node:crypto");
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
// ── CRUD ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createRelayToken(input: CreateRelayTokenInput): RelayTokenWithSecret {
|
||||
const db = getDbInstance();
|
||||
const id = generateId();
|
||||
const rawToken = generateToken();
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const prefix = "rl_" + rawToken.slice(6, 14);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO relay_tokens (id, name, token_hash, token_prefix, description, combo_id, allowed_models,
|
||||
max_tokens_per_request, max_requests_per_minute, max_requests_per_day, max_cost_per_day,
|
||||
enabled, created_at, updated_at, expires_at, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id, input.name, tokenHash, prefix, input.description || "", input.comboId || null,
|
||||
JSON.stringify(input.allowedModels || ["*"]),
|
||||
input.maxTokensPerRequest || 128000,
|
||||
input.maxRequestsPerMinute || 60,
|
||||
input.maxRequestsPerDay || 10000,
|
||||
input.maxCostPerDay || 0,
|
||||
now, now, input.expiresAt || null,
|
||||
JSON.stringify(input.metadata || {}),
|
||||
);
|
||||
|
||||
const token = db.prepare("SELECT * FROM relay_tokens WHERE id = ?").get(id) as RelayTokenRow;
|
||||
return { ...rowToCamel<RelayToken>(token), rawToken };
|
||||
}
|
||||
|
||||
export function getRelayTokens(): RelayToken[] {
|
||||
const db = getDbInstance();
|
||||
const rows = db.prepare("SELECT * FROM relay_tokens ORDER BY created_at DESC").all() as RelayTokenRow[];
|
||||
return rows.map((r) => ({ ...rowToCamel<RelayToken>(r), enabled: r.enabled === 1 }));
|
||||
}
|
||||
|
||||
export function getRelayToken(id: string): RelayToken | null {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM relay_tokens WHERE id = ?").get(id) as RelayTokenRow | undefined;
|
||||
if (!row) return null;
|
||||
return { ...rowToCamel<RelayToken>(row), enabled: row.enabled === 1 };
|
||||
}
|
||||
|
||||
export function getRelayTokenByHash(tokenHash: string): (RelayToken & { rawToken?: string }) | null {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM relay_tokens WHERE token_hash = ? AND enabled = 1").get(tokenHash) as RelayTokenRow | undefined;
|
||||
if (!row) return null;
|
||||
return { ...rowToCamel<RelayToken>(row), enabled: row.enabled === 1 };
|
||||
}
|
||||
|
||||
export function updateRelayToken(id: string, updates: Partial<CreateRelayTokenInput>): RelayToken | null {
|
||||
const db = getDbInstance();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sets: string[] = ["updated_at = ?"];
|
||||
const params: unknown[] = [now];
|
||||
|
||||
if (updates.name !== undefined) { sets.push("name = ?"); params.push(updates.name); }
|
||||
if (updates.description !== undefined) { sets.push("description = ?"); params.push(updates.description); }
|
||||
if (updates.comboId !== undefined) { sets.push("combo_id = ?"); params.push(updates.comboId); }
|
||||
if (updates.allowedModels !== undefined) { sets.push("allowed_models = ?"); params.push(JSON.stringify(updates.allowedModels)); }
|
||||
if (updates.maxTokensPerRequest !== undefined) { sets.push("max_tokens_per_request = ?"); params.push(updates.maxTokensPerRequest); }
|
||||
if (updates.maxRequestsPerMinute !== undefined) { sets.push("max_requests_per_minute = ?"); params.push(updates.maxRequestsPerMinute); }
|
||||
if (updates.maxRequestsPerDay !== undefined) { sets.push("max_requests_per_day = ?"); params.push(updates.maxRequestsPerDay); }
|
||||
if (updates.maxCostPerDay !== undefined) { sets.push("max_cost_per_day = ?"); params.push(updates.maxCostPerDay); }
|
||||
|
||||
params.push(id);
|
||||
db.prepare(`UPDATE relay_tokens SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
||||
return getRelayToken(id);
|
||||
}
|
||||
|
||||
export function deleteRelayToken(id: string): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM relay_tokens WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
export function toggleRelayToken(id: string, enabled: boolean): RelayToken | null {
|
||||
const db = getDbInstance();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
db.prepare("UPDATE relay_tokens SET enabled = ?, updated_at = ? WHERE id = ?").run(enabled ? 1 : 0, now, id);
|
||||
return getRelayToken(id);
|
||||
}
|
||||
|
||||
// ── Usage / Rate Limit ───────────────────────────────────────────────────────
|
||||
|
||||
export function checkRateLimit(tokenId: string): { allowed: boolean; remaining: number; resetIn: number } {
|
||||
const db = getDbInstance();
|
||||
const token = db.prepare("SELECT * FROM relay_tokens WHERE id = ?").get(tokenId) as RelayTokenRow | undefined;
|
||||
if (!token) return { allowed: false, remaining: 0, resetIn: 0 };
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const minuteWindow = Math.floor(now / 60) * 60;
|
||||
const dayWindow = Math.floor(now / 86400) * 86400;
|
||||
|
||||
// Check minute rate
|
||||
const minuteRow = db.prepare(
|
||||
"SELECT request_count, cost FROM relay_rate_limits WHERE token_id = ? AND window_start = ?",
|
||||
).get(tokenId, minuteWindow) as { request_count: number; cost: number } | undefined;
|
||||
|
||||
const minuteCount = minuteRow?.request_count || 0;
|
||||
if (minuteCount >= token.max_requests_per_minute) {
|
||||
return { allowed: false, remaining: 0, resetIn: 60 - (now % 60) };
|
||||
}
|
||||
|
||||
// Check daily rate
|
||||
const dayRow = db.prepare(
|
||||
"SELECT SUM(request_count) as total FROM relay_rate_limits WHERE token_id = ? AND window_start >= ?",
|
||||
).get(tokenId, dayWindow) as { total: number } | undefined;
|
||||
|
||||
const dayCount = dayRow?.total || 0;
|
||||
if (dayCount >= token.max_requests_per_day) {
|
||||
return { allowed: false, remaining: 0, resetIn: 86400 - (now % 86400) };
|
||||
}
|
||||
|
||||
const remaining = Math.min(
|
||||
token.max_requests_per_minute - minuteCount,
|
||||
token.max_requests_per_day - dayCount,
|
||||
);
|
||||
|
||||
return { allowed: true, remaining, resetIn: 60 - (now % 60) };
|
||||
}
|
||||
|
||||
export function recordRelayUsage(
|
||||
tokenId: string,
|
||||
params: {
|
||||
requestId?: string;
|
||||
model?: string;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
cost?: number;
|
||||
status?: string;
|
||||
statusCode?: number;
|
||||
latencyMs?: number;
|
||||
clientIp?: string;
|
||||
userAgent?: string;
|
||||
},
|
||||
): void {
|
||||
const db = getDbInstance();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const minuteWindow = Math.floor(now / 60) * 60;
|
||||
|
||||
// Update rate limit window
|
||||
db.prepare(`
|
||||
INSERT INTO relay_rate_limits (token_id, window_start, request_count, cost)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(token_id, window_start) DO UPDATE SET
|
||||
request_count = request_count + 1,
|
||||
cost = cost + ?
|
||||
`).run(tokenId, minuteWindow, params.cost || 0, params.cost || 0);
|
||||
|
||||
// Update last_used_at
|
||||
db.prepare("UPDATE relay_tokens SET last_used_at = ? WHERE id = ?").run(now, tokenId);
|
||||
|
||||
// Insert log
|
||||
db.prepare(`
|
||||
INSERT INTO relay_logs (token_id, request_id, model, prompt_tokens, completion_tokens, cost,
|
||||
status, status_code, latency_ms, client_ip, user_agent, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
tokenId,
|
||||
params.requestId || null,
|
||||
params.model || null,
|
||||
params.promptTokens || 0,
|
||||
params.completionTokens || 0,
|
||||
params.cost || 0,
|
||||
params.status || "success",
|
||||
params.statusCode || 200,
|
||||
params.latencyMs || 0,
|
||||
params.clientIp || null,
|
||||
params.userAgent || null,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRelayUsage(tokenId: string, since: number): { requestCount: number; totalCost: number } {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare(
|
||||
"SELECT COUNT(*) as request_count, COALESCE(SUM(cost), 0) as total_cost FROM relay_logs WHERE token_id = ? AND created_at >= ?",
|
||||
).get(tokenId, since) as { request_count: number; total_cost: number };
|
||||
return { requestCount: row.request_count, totalCost: row.total_cost };
|
||||
}
|
||||
|
||||
export function getRelayLogs(tokenId?: string, limit = 50): RelayLogRow[] {
|
||||
const db = getDbInstance();
|
||||
if (tokenId) {
|
||||
return db.prepare(
|
||||
"SELECT * FROM relay_logs WHERE token_id = ? ORDER BY created_at DESC LIMIT ?",
|
||||
).all(tokenId, limit) as RelayLogRow[];
|
||||
}
|
||||
return db.prepare(
|
||||
"SELECT * FROM relay_logs ORDER BY created_at DESC LIMIT ?",
|
||||
).all(limit) as RelayLogRow[];
|
||||
}
|
||||
@@ -433,6 +433,7 @@ export {
|
||||
} from "./db/contextHandoffs";
|
||||
|
||||
export type { HandoffPayload } from "./db/contextHandoffs";
|
||||
|
||||
export {
|
||||
getAllMiddlewareHooks,
|
||||
getEnabledMiddlewareHooks,
|
||||
@@ -478,6 +479,7 @@ export {
|
||||
getRelayUsage,
|
||||
getRelayLogs,
|
||||
} from "./db/relayProxies";
|
||||
|
||||
export type {
|
||||
RelayToken,
|
||||
RelayTokenRow,
|
||||
|
||||
Reference in New Issue
Block a user