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
+115
View File
@@ -0,0 +1,115 @@
/**
* resolver.ts — loest die "alternative"-Aliasse auf das billigste Modell
* derselben Klasse auf (openrouter-auswahl.ts aus llmlite, gleiche Regel wie
* das Anzeige-Werkzeug und der alte Tier-Updater).
*
* Takt: OpenRouter-Preise aendern sich oft -> alle refreshHours neu holen.
* AA-Indexe aendern sich nie, es kommen nur Modelle hinzu -> aa-cache.json,
* alle aaRefreshDays erneuert. Ergebnis in state.json, damit ein Neustart
* nicht mit leerer Zuordnung startet; Wechsel landen in changes.log.
*
* Aufruf als Skript: `bun run src/resolver.ts [--force] [--dry-run]`
*/
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, statSync } from "fs";
import {
apiKeyHolen, aaKeyHolen, listenHolen, kandidatenBauen, sperrliste, guenstigereAlternative,
variantenHolen, referenzParsen, referenzFormat, type Variante, type Effort,
} from "../../llmlite/openrouter-auswahl.ts";
import { configLaden, type Config } from "./config.ts";
export type Zuordnung = {
model: string;
effort: Effort | null;
index: number;
weighted: number;
referenz: string;
seit: string;
/** true = nichts Billigeres gefunden, die Referenz selbst wird gerufen */
istReferenz: boolean;
};
export type State = { aktualisiert: string; zuordnung: Record<string, Zuordnung> };
const statePfad = (c: Config) => `${c.logDir}/state.json`;
const aaPfad = (c: Config) => `${c.logDir}/aa-cache.json`;
export function stateLaden(c: Config): State {
try {
return JSON.parse(readFileSync(statePfad(c), "utf8")) as State;
} catch {
return { aktualisiert: "1970-01-01T00:00:00.000Z", zuordnung: {} };
}
}
export function stateVeraltet(c: Config, s: State): boolean {
return Date.now() - Date.parse(s.aktualisiert) > c.refreshHours * 3_600_000;
}
async function variantenMitCache(c: Config, modelle: Parameters<typeof variantenHolen>[1]): Promise<Variante[]> {
const pfad = aaPfad(c);
const frisch = existsSync(pfad) && Date.now() - statSync(pfad).mtimeMs < c.aaRefreshDays * 86_400_000;
if (frisch) return JSON.parse(readFileSync(pfad, "utf8")) as Variante[];
const aaKey = aaKeyHolen();
if (aaKey === undefined) {
if (existsSync(pfad)) return JSON.parse(readFileSync(pfad, "utf8")) as Variante[];
throw new Error("kein Artificial-Analysis-Schluessel und kein aa-cache.json");
}
const { varianten } = await variantenHolen(aaKey, modelle);
mkdirSync(c.logDir, { recursive: true });
writeFileSync(pfad, JSON.stringify(varianten));
return varianten;
}
/**
* Loest alle alternative-Aliasse auf. Die Last-Probe (78k Kontext, dreimal,
* kostet ~210k Token) laeuft nur fuer einen Kandidaten, der die bisherige
* Zuordnung abloesen wuerde -- wie im alten Tier-Updater.
*/
export async function aufloesen(c: Config, bisher: State, opts?: { dryRun?: boolean; log?: (z: string) => void }): Promise<State> {
const log = opts?.log ?? ((z: string) => console.log(z));
const key = apiKeyHolen();
const listen = await listenHolen(key);
const gesperrt = sperrliste();
const { alle } = kandidatenBauen(listen, gesperrt);
const varianten = await variantenMitCache(c, listen.voll);
const neu: State = { aktualisiert: new Date().toISOString(), zuordnung: { ...bisher.zuordnung } };
for (const [alias, def] of Object.entries(c.aliases)) {
if (def.art !== "alternative") continue;
const alt = bisher.zuordnung[alias];
try {
const abgelehnt: string[] = [];
const { referenz, auswahl } = await guenstigereAlternative(def.referenz, alle, key,
(id, grund) => abgelehnt.push(`${id}: ${grund}`), gesperrt,
{ varianten, lasttest: c.lasttest, bestand: alt?.model });
const ziel: Zuordnung = auswahl === null
? { model: referenz.id, effort: referenz.effort ?? null, index: referenz.index, weighted: referenz.weighted, referenz: def.referenz, seit: alt?.seit ?? neu.aktualisiert, istReferenz: true }
: { model: auswahl.candidate.id, effort: auswahl.candidate.effort ?? null, index: auswahl.candidate.index, weighted: auswahl.candidate.weighted, referenz: def.referenz, seit: alt?.seit ?? neu.aktualisiert, istReferenz: false };
const gleich = alt !== undefined && alt.model === ziel.model && alt.effort === ziel.effort;
if (!gleich) {
ziel.seit = neu.aktualisiert;
const zeile = `${neu.aktualisiert} ${alias}: ${alt ? referenzFormat(alt.model, alt.effort) : "(neu)"} -> ${referenzFormat(ziel.model, ziel.effort)} (Index ${ziel.index.toFixed(1)}, ${ziel.weighted.toFixed(2)} $/1M gewichtet, Referenz ${def.referenz}${ziel.istReferenz ? ", = Referenz" : ""})`;
log(zeile);
if (!opts?.dryRun) { mkdirSync(c.logDir, { recursive: true }); appendFileSync(`${c.logDir}/changes.log`, zeile + "\n"); }
}
for (const a of abgelehnt) log(` abgelehnt ${alias}: ${a}`);
neu.zuordnung[alias] = ziel;
} catch (e) {
log(`FEHLER ${alias}: ${(e as Error).message}${alt ? " -- behalte " + referenzFormat(alt.model, alt.effort) : " -- KEINE Zuordnung"}`);
}
}
if (!opts?.dryRun) { mkdirSync(c.logDir, { recursive: true }); writeFileSync(statePfad(c), JSON.stringify(neu, null, 1)); }
return neu;
}
if (import.meta.main) {
const c = configLaden();
const s = stateLaden(c);
const force = process.argv.includes("--force");
const dryRun = process.argv.includes("--dry-run");
if (!force && !stateVeraltet(c, s)) {
console.log(`state.json ist frisch (${s.aktualisiert}); --force erzwingt.`);
} else {
const neu = await aufloesen(c, s, { dryRun });
console.table(Object.entries(neu.zuordnung).map(([alias, z]) => ({ alias, ziel: referenzFormat(z.model, z.effort), index: z.index.toFixed(1), "$/1M": z.weighted.toFixed(2), referenz: z.referenz, seit: z.seit.slice(0, 16) })));
}
}