feat(proxy): serverless relay endpoints with rate limiting (#2675)

Integrated into release/v3.8.3
This commit is contained in:
Hernan Javier Ardila Sanchez
2026-05-24 21:52:08 +02:00
committed by GitHub
parent 989994d2e5
commit ee1ce57065
9 changed files with 888 additions and 1 deletions

View File

@@ -138,6 +138,6 @@ self.addEventListener("notificationclick", (event) => {
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
}),
})
);
});

View 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>
);
}

View 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 />;
}

View 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 });
}

View 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 });
}
}

View 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" } },
);
}
}

View 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
View 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[];
}

View File

@@ -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,