llmrouter: leichtgewichtiger Gateway-Ersatz mit Alternativ-Auflösung

OpenAI-kompatibler Reverse-Proxy in Bun: Client-Key -> ACL, Alias ->
Modell (alternative: günstigstes Modell derselben Klasse über
llmlite/openrouter-auswahl inkl. Effort-Achse; fest: lokale Upstreams,
TTS/STT, Embeddings), Usage/Kosten aus der OpenRouter-Antwort (auch im
Stream), JSONL + SQLite-Log, Tagesstatistik je Key. Altnamen der
LiteLLM-Zeit werden übersetzt. Kein 3456/stdcmpt, kein role/*, kein -auto
(Andreas 2026-09-05). Lokal verifiziert: Chat, Stream, ACL, Altname,
Kosten im Log. Parallelbetrieb auf :4001 geplant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 07:03:12 +02:00
co-authored by Claude Fable 5.1
commit 7897fa9651
11 changed files with 667 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
/**
* server.ts — der llmrouter: OpenAI-kompatibler Reverse-Proxy mit vier
* Eingriffen je Anfrage: Client-Key pruefen, Alias aufloesen und Modell
* umschreiben, Usage/Kosten aus der Antwort mitlesen, eine Zeile loggen.
*
* Keine Format-Uebersetzung: alle Upstreams sprechen OpenAI-Format
* (OpenRouter, llama-server, Ollama, Mistral). Genau das macht ihn klein.
*
* Umgebung: PORT (optional), KEYS_FILE (Token -> Name), ADMIN_KEY (fuer
* /stats und /zuordnung), OPENROUTER_API_KEY, MISTRAL_API_KEY.
*/
import { configLaden, aclLaden, tokenLaden, upstreamKey, type Config, type Upstream } from "./config.ts";
import { aufloesen, stateLaden, stateVeraltet, type State } from "./resolver.ts";
import { Log, usageLesen, type Eintrag } from "./log.ts";
import { referenzFormat } from "../../llmlite/openrouter-auswahl.ts";
const config: Config = configLaden();
const acl = aclLaden();
let tokens = tokenLaden();
let state: State = stateLaden(config);
const log = new Log(config.logDir);
const jetzt = () => new Date().toISOString();
const sagen = (z: string) => console.log(`${jetzt()} ${z}`);
// --- Zuordnung frisch halten ------------------------------------------------
let laeuft = false;
async function auffrischen(grund: string): Promise<void> {
if (laeuft) return;
laeuft = true;
try {
sagen(`Zuordnung auffrischen (${grund})`);
state = await aufloesen(config, state, { log: sagen });
} catch (e) {
sagen(`Auffrischen fehlgeschlagen: ${(e as Error).message} -- behalte Stand ${state.aktualisiert}`);
} finally {
laeuft = false;
}
}
if (stateVeraltet(config, state)) void auffrischen("Start, state veraltet");
setInterval(() => { if (stateVeraltet(config, state)) void auffrischen("Takt"); tokens = tokenLaden(); }, 15 * 60_000);
// --- Nebenlaeufigkeit je Upstream (lokales Qwen: ein Slot) ------------------
const semaphoren = new Map<string, { frei: number; warteschlange: (() => void)[] }>();
async function belegen(name: string, u: Upstream): Promise<() => void> {
if (u.maxParallel === undefined) return () => {};
let s = semaphoren.get(name);
if (s === undefined) { s = { frei: u.maxParallel, warteschlange: [] }; semaphoren.set(name, s); }
if (s.frei > 0) s.frei -= 1;
else await new Promise<void>((res) => s!.warteschlange.push(res));
return () => { const n = s!.warteschlange.shift(); if (n) n(); else s!.frei += 1; };
}
// --- Aufloesung Alias -> Ziel ------------------------------------------------
type Ziel = { alias: string; upstreamName: string; upstream: Upstream; model: string; effort: string | null; body?: Record<string, unknown>; alternative: boolean };
function zielBestimmen(angefragt: string, keyName: string): Ziel | { fehler: string; status: number } {
const alias = config.altnamen[angefragt] ?? angefragt;
const def = config.aliases[alias];
if (def === undefined) return { fehler: `Unbekanntes Modell ${angefragt}`, status: 404 };
const erlaubt = acl.get(keyName) ?? [];
if (erlaubt.length > 0 && !erlaubt.includes(alias)) return { fehler: `${keyName} darf ${alias} nicht rufen`, status: 403 };
if (def.art === "fest") {
return { alias, upstreamName: def.upstream, upstream: config.upstreams[def.upstream], model: def.model, effort: null, body: def.body, alternative: false };
}
const z = state.zuordnung[alias];
if (z === undefined) return { fehler: `${alias} ist noch nicht aufgeloest (Zuordnung fehlt) -- spaeter erneut`, status: 503 };
return { alias, upstreamName: "openrouter", upstream: config.upstreams.openrouter, model: z.model, effort: z.effort, alternative: true };
}
// --- Anfrage bauen -------------------------------------------------------------
const JSON_PFADE = new Set(["/chat/completions", "/completions", "/embeddings", "/audio/speech", "/responses"]);
const FORM_PFADE = new Set(["/audio/transcriptions", "/audio/translations"]);
function jsonBody(orig: Record<string, unknown>, ziel: Ziel): Record<string, unknown> {
const b: Record<string, unknown> = { ...orig, model: ziel.model, ...(ziel.body ?? {}) };
if (ziel.upstreamName === "openrouter") {
b.usage = { include: true }; // Kosten kommen in der Antwort mit
if (b.stream === true) b.stream_options = { ...(b.stream_options as object ?? {}), include_usage: true };
}
if (ziel.alternative) {
// Die Stufe gehoert zum Alias, nicht zum Client: wer marvin:low ruft,
// bekommt die Klasse von marvin:low -- auch wenn er reasoning_effort mitschickt.
delete b.reasoning_effort; delete b.reasoning;
if (ziel.effort !== null) b.reasoning = { effort: ziel.effort };
}
return b;
}
// --- Server -------------------------------------------------------------------
Bun.serve({
port: Number(process.env.PORT ?? config.port),
idleTimeout: 255,
async fetch(req) {
const url = new URL(req.url);
const pfad = url.pathname.replace(/^\/v1/u, "");
if (pfad === "/health") return Response.json({ ok: true, zuordnung: state.aktualisiert });
// Admin
const auth = req.headers.get("authorization")?.replace(/^Bearer\s+/iu, "") ?? req.headers.get("x-api-key") ?? "";
const admin = process.env.ADMIN_KEY !== undefined && auth === process.env.ADMIN_KEY;
if (pfad === "/stats") return admin ? Response.json(log.statistik(Number(url.searchParams.get("days") ?? 7))) : new Response("admin", { status: 401 });
if (pfad === "/zuordnung") return admin ? Response.json(state) : new Response("admin", { status: 401 });
if (pfad === "/auffrischen" && req.method === "POST") { if (!admin) return new Response("admin", { status: 401 }); void auffrischen("manuell"); return Response.json({ gestartet: !laeuft }); }
// Client
const keyName = tokens.get(auth);
if (keyName === undefined) return Response.json({ error: { message: "Ungueltiger API-Key" } }, { status: 401 });
if (pfad === "/models") {
const erlaubt = acl.get(keyName) ?? [];
const namen = Object.keys(config.aliases).filter((a) => erlaubt.length === 0 || erlaubt.includes(a));
return Response.json({ object: "list", data: namen.map((id) => ({ id, object: "model", owned_by: "42i" })) });
}
if (req.method !== "POST" || !(JSON_PFADE.has(pfad) || FORM_PFADE.has(pfad))) return Response.json({ error: { message: `Pfad ${pfad} wird nicht bedient` } }, { status: 404 });
const start = Date.now();
let angefragt = "";
let upstreamBody: BodyInit;
let stream = false;
let ziel: Ziel;
const headers = new Headers();
if (JSON_PFADE.has(pfad)) {
let orig: Record<string, unknown>;
try { orig = (await req.json()) as Record<string, unknown>; } catch { return Response.json({ error: { message: "Body ist kein JSON" } }, { status: 400 }); }
angefragt = String(orig.model ?? "");
const z = zielBestimmen(angefragt, keyName);
if ("fehler" in z) return Response.json({ error: { message: z.fehler } }, { status: z.status });
ziel = z;
stream = orig.stream === true;
upstreamBody = JSON.stringify(jsonBody(orig, ziel));
headers.set("content-type", "application/json");
} else {
const form = await req.formData();
angefragt = String(form.get("model") ?? "");
const z = zielBestimmen(angefragt, keyName);
if ("fehler" in z) return Response.json({ error: { message: z.fehler } }, { status: z.status });
ziel = z;
form.set("model", ziel.model);
upstreamBody = form;
}
const k = upstreamKey(ziel.upstream);
if (k !== undefined) headers.set("authorization", `Bearer ${k}`);
if (ziel.upstreamName === "openrouter") { headers.set("http-referer", "https://llm.lan"); headers.set("x-title", `llmrouter/${keyName}`); }
const accept = req.headers.get("accept"); if (accept) headers.set("accept", accept);
const eintrag: Eintrag = {
ts: jetzt(), key: keyName, alias: ziel.alias, angefragt, model: ziel.model, upstream: ziel.upstreamName, effort: ziel.effort,
pfad, status: 0, ms: 0, stream, prompt_tokens: null, completion_tokens: null, cached_tokens: null, cost: null,
};
const abschliessen = (usage?: unknown) => {
eintrag.ms = Date.now() - start;
if (usage !== undefined) Object.assign(eintrag, usageLesen(usage));
try { log.schreiben(eintrag); } catch (e) { sagen(`Log-Fehler: ${(e as Error).message}`); }
};
const freigeben = await belegen(ziel.upstreamName, ziel.upstream);
let antwort: Response;
try {
antwort = await fetch(`${ziel.upstream.base}${pfad}`, { method: "POST", headers, body: upstreamBody, signal: AbortSignal.timeout(600_000) });
} catch (e) {
freigeben();
eintrag.status = 502; eintrag.fehler = (e as Error).message; abschliessen();
return Response.json({ error: { message: `Upstream ${ziel.upstreamName} nicht erreichbar: ${eintrag.fehler}` } }, { status: 502 });
}
eintrag.status = antwort.status;
const ct = antwort.headers.get("content-type") ?? "";
const weiter = new Headers(antwort.headers);
weiter.delete("content-length"); weiter.delete("content-encoding"); weiter.delete("transfer-encoding");
weiter.set("x-llmrouter-model", referenzFormat(ziel.model, ziel.effort as never));
if (ct.startsWith("text/event-stream") && antwort.body !== null) {
// Durchreichen und nebenbei den letzten usage-Chunk lesen.
let rest = ""; let usage: unknown;
const dec = new TextDecoder();
const tee = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, ctrl) {
ctrl.enqueue(chunk);
rest += dec.decode(chunk, { stream: true });
let i: number;
while ((i = rest.indexOf("\n")) >= 0) {
const zeile = rest.slice(0, i).trim(); rest = rest.slice(i + 1);
if (zeile.startsWith("data:") && zeile.includes('"usage"')) {
try { const j = JSON.parse(zeile.slice(5)); if (j.usage) usage = j.usage; } catch { /* Teilzeile */ }
}
}
},
flush() { freigeben(); abschliessen(usage); },
});
return new Response(antwort.body.pipeThrough(tee), { status: antwort.status, headers: weiter });
}
freigeben();
if (ct.includes("application/json")) {
const text = await antwort.text();
let usage: unknown; let fehler: string | undefined;
try { const j = JSON.parse(text); usage = j.usage; if (j.error) fehler = JSON.stringify(j.error).slice(0, 300); } catch { /* egal */ }
if (fehler) eintrag.fehler = fehler;
abschliessen(usage);
return new Response(text, { status: antwort.status, headers: weiter });
}
abschliessen();
return new Response(antwort.body, { status: antwort.status, headers: weiter });
},
});
sagen(`llmrouter lauscht auf :${process.env.PORT ?? config.port}, ${Object.keys(config.aliases).length} Aliasse, ${tokens.size} Token, Zuordnung von ${state.aktualisiert}`);