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:
+115
@@ -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) })));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user