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
+64
View File
@@ -0,0 +1,64 @@
/**
* config.ts — laedt config.json, key-acls.json und die Token-Datei.
*
* Drei Quellen, bewusst getrennt:
* - config.json versioniert: Aliasse, Upstreams, Altnamen
* - key-acls.json versioniert: Name -> erlaubte Aliasse
* - KEYS_FILE NUR auf dem Host: Token -> Name (Secret)
* Upstream-Schluessel kommen aus der Umgebung (keyEnv je Upstream).
*/
import { readFileSync } from "fs";
import { dirname, resolve } from "path";
export type Upstream = { base: string; keyEnv?: string; maxParallel?: number };
export type Alias =
| { art: "alternative"; referenz: string }
| { art: "fest"; upstream: string; model: string; body?: Record<string, unknown> };
export type Config = {
port: number;
logDir: string;
refreshHours: number;
aaRefreshDays: number;
lasttest: boolean;
upstreams: Record<string, Upstream>;
aliases: Record<string, Alias>;
altnamen: Record<string, string>;
};
export const BASE = resolve(dirname(import.meta.path), "..");
export const CONFIG_PATH = process.env.LLMROUTER_CONFIG ?? `${BASE}/config.json`;
export function configLaden(): Config {
const c = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Config;
for (const [name, a] of Object.entries(c.aliases)) {
if (a.art === "fest" && c.upstreams[a.upstream] === undefined) throw new Error(`Alias ${name}: Upstream ${a.upstream} fehlt`);
}
for (const [alt, ziel] of Object.entries(c.altnamen)) {
if (c.aliases[ziel] === undefined) throw new Error(`Altname ${alt} zeigt auf unbekannten Alias ${ziel}`);
}
c.logDir = resolve(dirname(CONFIG_PATH), c.logDir);
if (process.env.LLMROUTER_LASTTEST === "0") c.lasttest = false; // fuer lokale Tests: spart ~210k Token je Wechsel
return c;
}
/** Name -> erlaubte Aliasse; leere Liste = alles. */
export function aclLaden(): Map<string, string[]> {
const pfad = process.env.LLMROUTER_ACLS ?? `${dirname(CONFIG_PATH)}/key-acls.json`;
const d = JSON.parse(readFileSync(pfad, "utf8")) as { keys: Record<string, string[]> };
return new Map(Object.entries(d.keys));
}
/** Token -> Name. Datei fehlt = kein Client kommt rein (bewusst, nicht "alle"). */
export function tokenLaden(): Map<string, string> {
const pfad = process.env.KEYS_FILE;
if (pfad === undefined) return new Map();
try {
return new Map(Object.entries(JSON.parse(readFileSync(pfad, "utf8")) as Record<string, string>));
} catch {
return new Map();
}
}
export function upstreamKey(u: Upstream): string | undefined {
return u.keyEnv === undefined ? undefined : process.env[u.keyEnv];
}