LLM-API-Rate-Limiting und Kostenkontrollen implementieren: Token-Budgets, Pro-Key-Throttling und Nutzungs-Dashboards
Bevor du beginnst
- Node.js- und TypeScript-Grundkenntnisse
- Docker-Grundkenntnisse (Images erstellen, Container ausfuehren)
- Ein OpenAI-API-Key
Was du lernen wirst
- Einen Fastify-Reverse-Proxy einrichten, der Anfragen an OpenAI weiterleitet
- Token mit tiktoken zaehlen und Anfrage-Kosten vor dem Senden schaetzen
- Pro-Key-API-Authentifizierung mit gehashten Keys in SQLite implementieren
- Sliding-Window-Rate-Limiting fuer RPM und TPM aufbauen
- Pro-Key taeglich und monatlich Token-Budgets mit graceful Degradation durchsetzen
- Exact-Match-Request-Caching hinzufuegen, um redundante API-Aufrufe zu reduzieren
- Streaming-SSE-Antworten mit End-of-Stream-Accounting behandeln
- Ein Nutzungs-Dashboard mit Chart.js erstellen und mit Docker deployen
Auf dieser Seite
Wenn Ihr Team LLM-APIs direkt nutzt, haben Sie das Problem bereits erlebt: Eine ausser Kontrolle geratene Integration, eine falsch konfigurierte Retry-Schleife oder ein enthusiastischer Entwickler kann in Minuten Hunderte von Dollar verbrennen. Provider-seitige Rate-Limits schuetzen den Provider, nicht Ihr Budget. Sie brauchen Kontrollen auf Ihrer Seite.
Dieses Tutorial fuehrt durch den Aufbau eines LLM-API-Proxys, der zwischen Ihren Anwendungen und OpenAI (oder einem kompatiblen Provider) sitzt und Pro-Key-Rate-Limits, Token-Budgets, Caching und Kostenverfolgung durchsetzt. Es ist der praktische Begleiter zu LLM API Rate Limiting and Cost Control. Der vollstaendige Quellcode ist auf GitHub unter llm-budget-proxy.
Bevor Sie beginnen, klonen Sie das Repo und installieren Sie die Abhaengigkeiten:
git clone https://github.com/InkByteStudio/llm-budget-proxy.git
cd llm-budget-proxy
npm install
Schritt 1: Den Proxy-Server einrichten (8 min)
Der Proxy ist ein Fastify-Server, der Anfragen auf POST /v1/chat/completions akzeptiert, sie durch eine Middleware-Kette (Auth, Rate-Limit, Budget-Check) leitet und an OpenAI weiterleitet. Beginnen Sie mit dem Server-Einstiegspunkt und dem Config-Loader.
Konfiguration mit YAML und Umgebungsvariablen
Die Konfiguration liegt in config/config.yml und unterstuetzt ${ENV_VAR}-Substitution, damit Sie nie Geheimnisse in die Datei selbst schreiben:
server:
port: 3000
host: "0.0.0.0"
adminKey: "${ADMIN_API_KEY}"
provider:
name: openai
baseUrl: "https://api.openai.com"
apiKey: "${OPENAI_API_KEY}"
rateLimits:
default:
rpm: 60
tpm: 100000
overrides: []
budgets:
defaultDaily: 10.00
defaultMonthly: 100.00
alertThresholds:
- percent: 80
action: warn
- percent: 95
action: downgrade
- percent: 100
action: block
cache:
enabled: true
defaultTtlSeconds: 3600
maxEntries: 10000
database:
path: "./data/llm-budget-proxy.db"
Der Loader liest diese Datei, substituiert Umgebungsvariablen und validiert das Ergebnis mit Zod:
File: src/config/loader.ts
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { parse as parseYaml } from "yaml";
import { configSchema, type Config } from "./schema.js";
function substituteEnvVars(obj: unknown): unknown {
if (typeof obj === "string") {
return obj.replace(/\$\{(\w+)\}/g, (_, varName) => {
return process.env[varName] ?? "";
});
}
if (Array.isArray(obj)) {
return obj.map(substituteEnvVars);
}
if (obj !== null && typeof obj === "object") {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = substituteEnvVars(value);
}
return result;
}
return obj;
}
export function loadConfig(configPath?: string): Config {
const resolvedPath = configPath ?? resolve("config", "config.yml");
if (!existsSync(resolvedPath)) {
throw new Error(`Config file not found: ${resolvedPath}`);
}
const raw = readFileSync(resolvedPath, "utf-8");
const parsed = parseYaml(raw);
const substituted = substituteEnvVars(parsed);
const result = configSchema.safeParse(substituted);
if (!result.success) {
const errors = result.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`Invalid configuration:\n${errors}`);
}
return result.data;
}
Der Server-Einstiegspunkt
File: src/server.ts
import Fastify from "fastify";
import { loadConfig } from "./config/loader.js";
import { loadPricingManifest } from "./pricing/manifest.js";
import { getDb, closeDb } from "./storage/db.js";
import { createAuthMiddleware } from "./middleware/auth.js";
import { createRateLimiter } from "./middleware/rate-limiter.js";
import { createBudgetChecker } from "./middleware/budget-checker.js";
import { createProxyHandler } from "./proxy/handler.js";
import { registerDashboardRoutes } from "./dashboard/api.js";
async function main(): Promise<void> {
const config = loadConfig();
loadPricingManifest();
const db = getDb(config.database.path);
const app = Fastify({ logger: true, bodyLimit: 10 * 1024 * 1024 });
app.get("/health", async () => ({
status: "ok",
uptime: process.uptime(),
version: "1.0.0",
}));
registerDashboardRoutes(app, db, config);
const authMiddleware = createAuthMiddleware(db);
const rateLimiter = createRateLimiter(config);
const budgetChecker = createBudgetChecker(db, config);
const proxyHandler = createProxyHandler(db, config);
app.post("/v1/chat/completions", {
preHandler: [authMiddleware, rateLimiter, budgetChecker],
}, proxyHandler);
const shutdown = async (): Promise<void> => {
await app.close();
closeDb();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
await app.listen({ port: config.server.port, host: config.server.host });
}
main().catch((err) => {
console.error("Failed to start:", err);
process.exit(1);
});
Die Middleware-Kette laeuft der Reihe nach: Key authentifizieren, Rate-Limits pruefen, Budget pruefen, dann Anfrage weiterleiten. Jede Middleware kann die Anfrage mit einer Fehlerantwort kurzschliessen. Der Proxy-Handler selbst validiert auch den Request-Body vor der Weiterleitung: Wenn das model-Feld fehlt oder kein String ist, gibt er sofort eine 400 zurueck. Upstream-JSON-Antworten werden in einem try/catch geparst, sodass fehlerhafte Antworten zu einer sauberen 502 fuehren, anstatt den Prozess zum Absturz zu bringen.
Verifizieren
Starten Sie den Server und bestaetigen Sie, dass er antwortet:
export OPENAI_API_KEY=sk-your-key-here
export ADMIN_API_KEY=admin-dev-key
npm run dev
curl http://localhost:3000/health
# {"status":"ok","uptime":1.23,"version":"1.0.0"}
Der Proxy laeuft, wird aber auf /v1/chat/completions eine 401 zurueckgeben, bis Sie einen API-Key erstellen (Schritt 3).
Schritt 2: Token-Zaehlung und Kostenschaetzung hinzufuegen (7 min)
Bevor eine Anfrage weitergeleitet wird, zaehlt der Proxy die Input-Token und schaetzt die Worst-Case-Kosten. Diese Schaetzung treibt Rate-Limiting und Budget-Durchsetzung an.
Token-Zaehlung mit tiktoken
File: src/proxy/token-counter.ts
import { encoding_for_model, type TiktokenModel } from "tiktoken";
const MODEL_ENCODING_MAP: Record<string, TiktokenModel> = {
"gpt-4o": "gpt-4o",
"gpt-4o-mini": "gpt-4o-mini",
"gpt-4-turbo": "gpt-4-turbo",
"gpt-4": "gpt-4",
"gpt-3.5-turbo": "gpt-3.5-turbo",
};
const DEFAULT_ENCODING: TiktokenModel = "gpt-4o";
export function countTokens(messages: ChatMessage[], model: string): number {
const tiktokenModel = MODEL_ENCODING_MAP[model] ?? DEFAULT_ENCODING;
let enc;
try {
enc = encoding_for_model(tiktokenModel);
} catch {
enc = encoding_for_model(DEFAULT_ENCODING);
}
try {
let tokenCount = 0;
for (const message of messages) {
tokenCount += 4; // message overhead
if (message.role) {
tokenCount += enc.encode(message.role).length;
}
if (typeof message.content === "string") {
tokenCount += enc.encode(message.content).length;
} else if (Array.isArray(message.content)) {
for (const part of message.content) {
if (part.type === "text" && part.text) {
tokenCount += enc.encode(part.text).length;
}
}
}
}
tokenCount += 2; // reply priming
return tokenCount;
} finally {
enc.free();
}
}
export interface ChatMessage {
role: string;
content: string | ContentPart[];
name?: string;
}
interface ContentPart {
type: string;
text?: string;
}
Die Funktion fuegt 4 Token pro Nachricht fuer den Chat-ML-Overhead und 2 Token am Ende fuer Reply-Priming hinzu, entsprechend den dokumentierten Token-Zaehlregeln von OpenAI.
Kostenschaetzung
File: src/proxy/cost-estimator.ts
import { getModelPricing, type PricingModel } from "../pricing/manifest.js";
export interface CostEstimate {
inputTokens: number;
maxOutputTokens: number;
inputCost: number;
worstCaseOutputCost: number;
worstCaseTotalCost: number;
model: string;
pricing: PricingModel;
}
export function estimateCost(
model: string,
inputTokens: number,
maxTokens?: number,
): CostEstimate | null {
const pricing = getModelPricing(model);
if (!pricing) return null;
const maxOutputTokens = maxTokens ?? pricing.maxOutputTokens;
const inputCost = (inputTokens / 1000) * pricing.inputPer1k;
const worstCaseOutputCost = (maxOutputTokens / 1000) * pricing.outputPer1k;
return {
inputTokens,
maxOutputTokens,
inputCost,
worstCaseOutputCost,
worstCaseTotalCost: inputCost + worstCaseOutputCost,
model,
pricing,
};
}
export function calculateActualCost(
model: string,
inputTokens: number,
outputTokens: number,
): number {
const pricing = getModelPricing(model);
if (!pricing) return 0;
return (inputTokens / 1000) * pricing.inputPer1k
+ (outputTokens / 1000) * pricing.outputPer1k;
}
Die Schaetzung verwendet Worst-Case-Mathematik: (Input-Token * Input-Preis) + (max_tokens * Output-Preis). Das ist absichtlich konservativ. Nachdem die Antwort eintrifft, berechnet calculateActualCost die tatsaechlichen Kosten aus dem Usage-Objekt, das OpenAI zurueckgibt.
Das Pricing-Manifest
Modellpreise leben in config/pricing.yml, damit Sie sie aktualisieren koennen, ohne Code zu aendern:
version: "2026-03-14"
provider: openai
models:
gpt-4o:
inputPer1k: 0.0025
outputPer1k: 0.01
maxOutputTokens: 16384
gpt-4o-mini:
inputPer1k: 0.00015
outputPer1k: 0.0006
maxOutputTokens: 16384
gpt-4-turbo:
inputPer1k: 0.01
outputPer1k: 0.03
maxOutputTokens: 4096
Halten Sie pricing.yml in der Versionskontrolle und aktualisieren Sie es, wenn sich Provider-Preise aendern. Der Proxy lehnt Anfragen fuer Modelle ab, die nicht in diesem Manifest aufgefuehrt sind, was Ueberraschungskosten durch unbekannte Modelle verhindert.
Verifizieren
Sie koennen die Token-Zaehlung mit einem schnellen Unit-Test oder durch Pruefen der X-Input-Tokens- und X-Estimated-Cost-Header bei jeder proxierten Antwort verifizieren (sichtbar nach Schritt 3).
Schritt 3: API-Key-Authentifizierung implementieren (7 min)
Jede Anfrage muss einen vom Proxy ausgestellten API-Key enthalten. Dies ist nicht der OpenAI-Key (den der Proxy serverseitig haelt). Proxy-Keys werden mit SHA-256 vor der Speicherung gehasht, sodass die Datenbank nie Klartext-Keys enthaelt.
SQLite-Schema
Das Datenbankschema wird automatisch vom Migrations-Modul erstellt. Die relevante Tabelle:
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
key_prefix TEXT NOT NULL,
team TEXT,
budget_id INTEGER REFERENCES budgets(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked_at TEXT,
metadata TEXT
);
Key-Generierung und -Lookup
File: src/storage/keys.ts
import { createHash, randomBytes } from "node:crypto";
import type Database from "better-sqlite3";
export function hashKey(plaintext: string): string {
return createHash("sha256").update(plaintext).digest("hex");
}
export function generateKey(): string {
return `lbp_${randomBytes(16).toString("hex")}`;
}
export function createKey(
db: Database.Database,
name: string,
team: string | null,
budgetId: number | null,
): CreateKeyResult {
const key = generateKey();
const keyHash = hashKey(key);
const keyPrefix = key.slice(0, 12);
const stmt = db.prepare(`
INSERT INTO api_keys (name, key_hash, key_prefix, team, budget_id)
VALUES (?, ?, ?, ?, ?)
`);
const result = stmt.run(name, keyHash, keyPrefix, team, budgetId);
return { id: result.lastInsertRowid as number, name, key, keyPrefix };
}
export function lookupKey(
db: Database.Database,
plaintext: string,
): KeyRecord | null {
const keyHash = hashKey(plaintext);
const row = db.prepare(`
SELECT id, name, key_prefix, team, budget_id, created_at, revoked_at
FROM api_keys WHERE key_hash = ?
`).get(keyHash) as KeyRecord | undefined;
return row ?? null;
}
Keys verwenden das Format lbp_ gefolgt von 32 Hex-Zeichen. Bei jeder Anfrage hasht die Middleware den bereitgestellten Token mit SHA-256 und schlaegt ihn nach:
File: src/middleware/auth.ts
import type { FastifyRequest, FastifyReply } from "fastify";
import type Database from "better-sqlite3";
import { timingSafeEqual } from "node:crypto";
import { lookupKey } from "../storage/keys.js";
export function createAuthMiddleware(db: Database.Database) {
return async function authMiddleware(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const authHeader = request.headers.authorization;
if (!authHeader) {
reply.code(401).send({
error: "missing_api_key",
message: "Authorization header required",
});
return;
}
const match = authHeader.match(/^Bearer\s+(.+)$/i);
if (!match) {
reply.code(401).send({
error: "invalid_auth_format",
message: "Expected: Authorization: Bearer <key>",
});
return;
}
const token = match[1];
if (!token.startsWith("lbp_")) {
reply.code(401).send({
error: "invalid_key_format",
message: "API key must start with lbp_",
});
return;
}
const keyRecord = lookupKey(db, token);
if (!keyRecord) {
reply.code(401).send({
error: "invalid_api_key",
message: "API key not found",
});
return;
}
if (keyRecord.revoked_at) {
reply.code(401).send({
error: "key_revoked",
message: "API key has been revoked",
});
return;
}
request.keyRecord = keyRecord;
};
}
Erstellen Sie Ihren ersten Key
Verwenden Sie die Admin-API, um einen Key zu erstellen:
curl -X POST http://localhost:3000/api/keys \
-H "Authorization: Bearer admin-dev-key" \
-H "Content-Type: application/json" \
-d '{"name": "dev-test", "team": "engineering"}'
Die Antwort enthaelt den Klartext-Key genau einmal. Speichern Sie ihn. Der Proxy speichert nur den Hash.
Verifizieren
# Without a key — 401
curl -s -o /dev/null -w "%{http_code}" \
-X POST http://localhost:3000/v1/chat/completions
# With a valid key — 200 (forwarded to OpenAI)
curl -X POST http://localhost:3000/v1/chat/completions \
-H "Authorization: Bearer lbp_your-key-here" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Say hello"}]}'
Schritt 4: Pro-Key-Rate-Limiting hinzufuegen (7 min)
Der Rate-Limiter erzwingt zwei Sliding-Window-Limits pro Key: Anfragen pro Minute (RPM) und Token pro Minute (TPM). Beide sind pro Key-Muster konfigurierbar.
File: src/middleware/rate-limiter.ts
import type { FastifyRequest, FastifyReply } from "fastify";
import type { Config } from "../config/schema.js";
interface WindowEntry {
timestamp: number;
tokens: number;
}
const windows = new Map<string, WindowEntry[]>();
const WINDOW_MS = 60_000;
export function createRateLimiter(config: Config) {
return async function rateLimiterMiddleware(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const keyRecord = request.keyRecord;
if (!keyRecord) return;
const limits = resolveRateLimits(config, keyRecord.name);
const windowKey = `key:${keyRecord.id}`;
const now = Date.now();
const entries = windows.get(windowKey) ?? [];
const validEntries = entries.filter((e) => now - e.timestamp < WINDOW_MS);
windows.set(windowKey, validEntries);
const currentRpm = validEntries.length;
const currentTpm = validEntries.reduce((sum, e) => sum + e.tokens, 0);
reply.header("X-RateLimit-Limit-RPM", limits.rpm);
reply.header("X-RateLimit-Remaining-RPM", Math.max(0, limits.rpm - currentRpm));
reply.header("X-RateLimit-Limit-TPM", limits.tpm);
reply.header("X-RateLimit-Remaining-TPM", Math.max(0, limits.tpm - currentTpm));
if (currentRpm >= limits.rpm) {
const oldestValid = validEntries[0];
const retryAfter = oldestValid
? Math.ceil((oldestValid.timestamp + WINDOW_MS - now) / 1000)
: 60;
reply.code(429).header("Retry-After", retryAfter).send({
error: "rate_limit_exceeded",
message: `RPM limit exceeded (${limits.rpm}/min)`,
retryAfter,
});
return;
}
if (currentTpm >= limits.tpm) {
const oldestValid = validEntries[0];
const retryAfter = oldestValid
? Math.ceil((oldestValid.timestamp + WINDOW_MS - now) / 1000)
: 60;
reply.code(429).header("Retry-After", retryAfter).send({
error: "token_rate_limit_exceeded",
message: `TPM limit exceeded (${limits.tpm}/min)`,
retryAfter,
});
return;
}
};
}
Die Implementierung verwendet eine In-Memory-Map von WindowEntry-Arrays, indiziert nach API-Key-ID. Bei jeder Anfrage werden Eintraege, die aelter als 60 Sekunden sind, entfernt. Nach Abschluss der Anfrage schiebt eine recordRequest-Funktion einen neuen Eintrag mit der Token-Anzahl.
Jede Antwort enthaelt vier Rate-Limit-Header, damit Aufrufer client-seitiges Backoff implementieren koennen: X-RateLimit-Limit-RPM, X-RateLimit-Remaining-RPM, X-RateLimit-Limit-TPM und X-RateLimit-Remaining-TPM.
Das In-Memory-Sliding-Window funktioniert gut fuer Single-Instance-Deployments. Wenn Sie mehrere Proxy-Instanzen hinter einem Load-Balancer betreiben, verschieben Sie den Window-State nach Redis oder eine geteilte SQLite-WAL-Datenbank.
Verifizieren
Setzen Sie ein niedriges RPM-Limit in config.yml (z.B. rpm: 3) und senden Sie Burst-Anfragen:
for i in $(seq 1 5); do
curl -s -o /dev/null -w "Request $i: %{http_code}\n" \
-X POST http://localhost:3000/v1/chat/completions \
-H "Authorization: Bearer lbp_your-key-here" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
done
Die ersten drei Anfragen geben 200 zurueck. Die vierte und fuenfte geben 429 mit einem Retry-After-Header zurueck.
Schritt 5: Budget-Limits durchsetzen (8 min)
Rate-Limits kontrollieren die Geschwindigkeit. Budgets kontrollieren die Gesamtausgaben. Der Budget-Checker laeuft nach dem Rate-Limiter und vergleicht die akkumulierten Kosten des Keys mit konfigurierbaren Schwellenwerten.
Budget-Schema und Status-Abfrage
CREATE TABLE IF NOT EXISTS budgets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
period TEXT NOT NULL CHECK (period IN ('daily', 'monthly')),
limit_dollars REAL NOT NULL,
reset_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
Die getBudgetStatus-Funktion summiert cost_dollars aus usage_records fuer die aktuelle Periode und berechnet verbraucht, verbleibend und Prozent genutzt:
export function getBudgetStatus(
db: Database.Database,
budgetId: number,
): BudgetStatus | null {
const budget = getBudget(db, budgetId);
if (!budget) return null;
maybeResetBudget(db, budget);
const periodStart = computePeriodStart(budget.period);
const row = db.prepare(`
SELECT COALESCE(SUM(cost_dollars), 0) as consumed
FROM usage_records
WHERE key_id IN (SELECT id FROM api_keys WHERE budget_id = ?)
AND created_at >= ?
`).get(budgetId, periodStart) as { consumed: number };
const consumed = row.consumed;
const remaining = Math.max(0, budget.limit_dollars - consumed);
const percentUsed = budget.limit_dollars > 0
? (consumed / budget.limit_dollars) * 100
: 0;
return { budget, consumed_dollars: consumed, remaining_dollars: remaining, percent_used: percentUsed };
}
Die Budget-Checker-Middleware
File: src/middleware/budget-checker.ts
Die Middleware liest die sortierten alertThresholds aus der Konfiguration und wendet den ersten passenden Schwellenwert an. Es gibt drei moegliche Aktionen:
- warn: Setzt einen
X-Budget-Warning: approaching_limit-Header. Die Anfrage wird fortgesetzt. - downgrade: Schreibt das
model-Feld auf ein guenstigeres Modell um (z.B.gpt-4oaufgpt-4o-mini). Standardmaessig in der Konfiguration deaktiviert. - block: Gibt 402 Payment Required mit Budget-Details zurueck.
for (const threshold of thresholds) {
if (status.percent_used >= threshold.percent) {
if (threshold.action === "block") {
reply.code(402).send({
error: "budget_exceeded",
message: `${status.budget.period} budget exhausted`,
budget: {
period: status.budget.period,
limit: status.budget.limit_dollars,
consumed: status.consumed_dollars,
remaining: status.remaining_dollars,
},
});
return;
}
if (threshold.action === "downgrade" && config.modelDowngrade.enabled) {
const currentModel = body?.model as string | undefined;
if (currentModel) {
const rule = config.modelDowngrade.rules.find((r) => r.from === currentModel);
if (rule) {
(body as Record<string, unknown>).model = rule.to;
reply.header("X-Model-Downgraded", "true");
reply.header("X-Original-Model", currentModel);
}
}
}
if (threshold.action === "warn") {
reply.header("X-Budget-Warning", "approaching_limit");
}
break;
}
}
Modell-Downgrade ist standardmaessig deaktiviert (modelDowngrade.enabled: false in der Konfiguration). Aktivieren Sie es nur, wenn Ihre Aufrufer es tolerieren koennen, Antworten von einem guenstigeren Modell zu erhalten. Einige Anwendungen brechen, wenn sich das Modell unerwartet aendert.
Jede Antwort enthaelt X-Budget-Limit-, X-Budget-Remaining- und X-Budget-Period-Header, damit Aufrufer immer ihren Budget-Status kennen.
Verifizieren
Erstellen Sie einen Key mit einem winzigen Budget und senden Sie Anfragen, bis das Budget erschoepft ist:
# Create a key with a $0.01 daily budget
curl -X POST http://localhost:3000/api/keys \
-H "Authorization: Bearer admin-dev-key" \
-H "Content-Type: application/json" \
-d '{"name":"budget-test","budgetPeriod":"daily","budgetLimit":0.01}'
# Send requests until you get a 402
curl -X POST http://localhost:3000/v1/chat/completions \
-H "Authorization: Bearer lbp_the-new-key" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"Write a paragraph about budgets."}]}'
Nach ein oder zwei gpt-4o-Anfragen sehen Sie eine 402-Antwort mit der Budget-Aufschluesselung.
Schritt 6: Caching und Streaming-Unterstuetzung hinzufuegen (8 min)
Caching eliminiert redundante API-Aufrufe. Streaming erfordert spezielle Behandlung, da Nutzungsdaten erst im letzten SSE-Chunk ankommen.
Exact-Match-Caching
Der Cache hasht den gesamten Request-Body (mit sortierten Schluesseln fuer Determinismus) und speichert die Antwort in SQLite mit einem TTL:
File: src/storage/cache.ts
import { createHash } from "node:crypto";
import type Database from "better-sqlite3";
export function computeRequestHash(body: Record<string, unknown>): string {
const normalized = JSON.stringify(sortKeys(body));
return createHash("sha256").update(normalized).digest("hex");
}
function sortKeys(obj: unknown): unknown {
if (obj === null || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(sortKeys);
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(obj as Record<string, unknown>).sort()) {
sorted[key] = sortKeys((obj as Record<string, unknown>)[key]);
}
return sorted;
}
export function getCachedResponse(
db: Database.Database,
requestHash: string,
): CacheEntry | null {
const row = db.prepare(`
SELECT * FROM request_cache
WHERE request_hash = ? AND expires_at > datetime('now')
`).get(requestHash) as CacheEntry | undefined;
return row ?? null;
}
export function setCachedResponse(
db: Database.Database,
requestHash: string,
model: string,
responseBody: string,
inputTokens: number,
outputTokens: number,
ttlSeconds: number,
): void {
db.prepare(`
INSERT OR REPLACE INTO request_cache
(request_hash, model, response_body, input_tokens, output_tokens, expires_at)
VALUES (?, ?, ?, ?, ?, datetime('now', '+' || ? || ' seconds'))
`).run(requestHash, model, responseBody, inputTokens, outputTokens, ttlSeconds);
}
Cache-Treffer geben die gespeicherte Antwort mit X-Cache: HIT zurueck und verzeichnen null Kosten in der Nutzungstabelle. Cache-Treffer enthalten auch X-Budget-Limit-, X-Budget-Remaining- und X-Budget-Period-Header, damit Aufrufer immer einen aktuellen Budget-Status haben, auch bei gecachten Antworten. Ein Hintergrund-Timer raeumt abgelaufene Eintraege auf und erzwingt die maxEntries-Obergrenze alle fuenf Minuten; das Raeumungsintervall wird beim Herunterfahren bereinigt, um Ressourcen-Lecks zu vermeiden.
Streaming-SSE-Unterstuetzung
Wenn der Client "stream": true sendet, injiziert der Proxy stream_options.include_usage = true, damit OpenAI einen letzten Chunk mit Token-Zaehlung zurueckgibt. Der Proxy leitet jeden SSE-Chunk in Echtzeit an den Client weiter und extrahiert am Ende Nutzungsdaten:
// Inject stream_options so OpenAI returns usage in the final chunk
if (!body.stream_options || typeof body.stream_options !== "object") {
body.stream_options = {};
}
(body.stream_options as Record<string, unknown>).include_usage = true;
// ... forward chunks to client ...
// Parse each SSE line looking for the usage object
const lines = chunk.split("\n");
for (const line of lines) {
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
try {
const data = JSON.parse(line.slice(6));
if (data.usage) {
finalUsage = data.usage;
}
} catch {
// skip unparseable chunks
}
}
Der Proxy behandelt auch Client-Disconnects, indem er einen AbortController an das close-Event der rohen Anfrage bindet. Wenn der Client sich mitten im Stream trennt, bricht der Proxy die Upstream-Anfrage ab und verzeichnet teilweise Nutzung.
Streaming-Antworten werden nicht gecacht. Der Cache speichert nur nicht-streamende Antworten. Wenn Sie Caching fuer Streaming-Workloads benoetigen, erwaegen Sie einen semantischen Cache auf einer hoeheren Schicht.
Verifizieren
Senden Sie dieselbe nicht-streamende Anfrage zweimal und pruefen Sie den Cache-Header:
# First request — X-Cache: MISS
curl -s -D - -X POST http://localhost:3000/v1/chat/completions \
-H "Authorization: Bearer lbp_your-key" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"What is 2+2?"}]}' \
| grep X-Cache
# Second identical request — X-Cache: HIT, zero cost
curl -s -D - -X POST http://localhost:3000/v1/chat/completions \
-H "Authorization: Bearer lbp_your-key" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"What is 2+2?"}]}' \
| grep X-Cache
Die zweite Anfrage gibt sofort X-Cache: HIT und X-Request-Cost: 0.000000 zurueck.
Schritt 7: Dashboard und Alerting erstellen (5 min)
Der Proxy stellt ein Single-Page-Dashboard unter /dashboard bereit und exponiert JSON-API-Endpunkte fuer Nutzungsdaten. Beide sind durch den ADMIN_API_KEY geschuetzt.
Dashboard-API-Endpunkte
File: src/dashboard/api.ts
Die Admin-API bietet vier Endpunkte:
GET /api/usage— rohe Nutzungsdatensaetze mit optionalen Filtern (key_id,start,end,limit)GET /api/usage/summary— aggregierte Kosten, Token und Anfragezahlen pro Key fuer die aktuelle PeriodeGET /api/usage/timeseries— zeitlich gebuckelte Kosten- und Anfragedaten fuer Diagramme (stuendliche oder taegliche Granularitaet)GET /api/budgets— aktueller Budget-Status fuer alle Keys
Alle Endpunkte erfordern Authorization: Bearer <ADMIN_API_KEY> oder ?admin_key=<key> als Query-Parameter. Der Admin-Key-Vergleich verwendet crypto.timingSafeEqual, um Timing-Angriffe zu verhindern, die den Key-Wert durch Antwortzeit-Analyse lecken koennten.
Die Dashboard-Oberflaeche
Das Dashboard ist eine einzelne index.html-Datei, die unter /dashboard ausgeliefert wird. Es laedt Chart.js von einem CDN und rendert:
- Zusammenfassungskarten mit Gesamtkosten, Anfragezahl, Cache-Hit-Rate und aktiven Keys
- Ein Liniendiagramm der Kosten im Zeitverlauf
- Eine Tabelle der Pro-Key-Nutzung mit Kosten, Token-Zahlen und Cache-Statistiken
Das Dashboard holt Daten von den Admin-API-Endpunkten mit dem Admin-Key, der in ein Formularfeld eingegeben wird. Kein Build-Schritt ist erforderlich.
Webhook-Alerting
Wenn ein Budget-Schwellenwert ueberschritten wird, sendet der Proxy eine POST-Anfrage an die konfigurierte webhookUrl mit einem JSON-Payload:
export interface WebhookPayload {
event: "budgetWarning" | "budgetExceeded" | "anomaly";
timestamp: string;
keyName: string;
team: string | null;
details: {
budgetName: string;
period: "daily" | "monthly";
limitDollars: number;
consumedDollars: number;
percentUsed: number;
};
}
Alerts werden pro Event-Typ und Key-Name mit einem einstuendigen Cooldown entprellt. Das verhindert die Ueberflutung Ihres Slack-Channels oder PagerDuty, wenn ein Key nahe einem Schwellenwert schwebt.
Verifizieren
Oeffnen Sie http://localhost:3000/dashboard in Ihrem Browser, geben Sie Ihren Admin-Key ein und bestaetigen Sie, dass die Diagramme mit Daten aus Ihren Testanfragen gerendert werden. Wenn Sie eine WEBHOOK_URL konfiguriert haben, pruefen Sie, ob Budget-Warnungen erscheinen, wenn ein Key den 80%-Schwellenwert ueberschreitet.
Schritt 8: Containerisieren und deployen (5 min)
Das Projekt enthaelt ein Multi-Stage-Dockerfile und eine docker-compose.yml fuer das Produktions-Deployment.
Dockerfile
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# Production stage
FROM node:20-alpine
RUN addgroup -g 1001 -S proxyuser && \
adduser -S proxyuser -u 1001
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
COPY --from=builder /app/dist ./dist
COPY config/ ./config/
COPY src/dashboard/index.html ./dist/dashboard/index.html
RUN mkdir -p /app/data && chown proxyuser:proxyuser /app/data
USER proxyuser
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
Die Build-Stage kompiliert TypeScript. Die Production-Stage laeuft als Non-Root-proxyuser und enthaelt einen Health-Check, der den /health-Endpunkt abfragt.
docker-compose.yml
services:
proxy:
build: .
ports:
- "3000:3000"
volumes:
- proxy-data:/app/data
- ./config:/app/config:ro
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY}"
ADMIN_API_KEY: "${ADMIN_API_KEY:-admin-dev-key}"
WEBHOOK_URL: "${WEBHOOK_URL:-}"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
volumes:
proxy-data:
Das proxy-data-Volume persistiert die SQLite-Datenbank ueber Container-Neustarts hinweg. Das Config-Verzeichnis wird schreibgeschuetzt gemountet.
End-to-End-Verifizierung
Erstellen Sie eine .env-Datei vom Beispiel und starten Sie den Stack:
cp .env.example .env
# Edit .env with your real OPENAI_API_KEY
docker compose up --build -d
Warten Sie, bis der Health-Check besteht, dann erstellen Sie einen Key und senden Sie eine Anfrage:
# Create a key
curl -X POST http://localhost:3000/api/keys \
-H "Authorization: Bearer admin-dev-key" \
-H "Content-Type: application/json" \
-d '{"name":"docker-test","team":"ops","budgetPeriod":"daily","budgetLimit":5.00}'
# Send a request through the proxy
curl -X POST http://localhost:3000/v1/chat/completions \
-H "Authorization: Bearer lbp_the-returned-key" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello from Docker"}]}'
# Check the dashboard
open http://localhost:3000/dashboard
Sie sollten die Anfrage im Dashboard mit Kosten, Token-Zahlen und Latenz sehen.
Zusammenfassung
Sie haben nun einen funktionierenden LLM-API-Proxy, der Pro-Key-Authentifizierung, Sliding-Window-Rate-Limits, konfigurierbare Budget-Schwellenwerte mit Warn-/Downgrade-/Block-Aktionen, Exact-Match-Caching, Streaming-Unterstuetzung mit End-of-Stream-Accounting, ein Nutzungs-Dashboard und Webhook-Alerting durchsetzt. Der gesamte Stack laeuft in einem einzelnen Docker-Container mit SQLite fuer die Persistenz.
Die haeufigsten naechsten Schritte sind das Hinzufuegen von Unterstuetzung fuer zusaetzliche Provider jenseits von OpenAI, die Implementierung von semantischem Caching fuer hoehere Trefferraten, das Hinzufuegen von Pro-Team-Budget-Rollups und die Integration der Webhook-Alerts mit Ihrem bestehenden Incident-Response-Tooling.
Fuer die vollstaendige Architektur-Begruendung und den Kostenmodellierungsansatz siehe LLM API Rate Limiting and Cost Control. Um eine druckbare Checkliste fuer das Deployment von LLM-Kostenkontrollen herunterzuladen, siehe die LLM Cost Control Checklist.
Verwandte Artikel
Einen benutzerdefinierten MCP-Server erstellen, absichern und deployen: Von der Tool-Definition bis zur Produktion
Schritt-fuer-Schritt-Tutorial zum Erstellen eines MCP-Servers jenseits von Hello-World mit PostgreSQL, Authentifizierung, Query-Sandboxing und Docker-Deployment.
Von Ingress NGINX zu Gateway API migrieren vor dem End-of-Life
Erfahren Sie, wie Sie von ingress-nginx zu Gateway API migrieren, mit einem stufenweisen Audit, Uebersetzung, Side-by-Side-Validierung und sicherem Cutover-Plan vor Supportende.