llmrouter: Namensräume openrouter/, alt/, role/; Klasse über drei Indexe relativ
- openrouter/<id>[:effort] 1:1, alt/<id>[:effort] günstigste Alternative auf Abruf (ohne Last-Probe, im Takt erneuert), role/<name> aus config.rollen - Klasse: Intelligence, Coding, Agentic je >= 95 % der Referenz (relativ statt 3 Punkte), Kontext >= 90 %, billiger; Kandidaten auch ohne OpenRouter-Index, wenn AA Stufen kennt (OpenRouter führt Intelligence nur für 53 Modelle) - ACL-Muster mit *, /models mit allen Konto-Modellen je Namensraum und Stufe - Rollen aus der LiteLLM-Zeit übernommen (Führung high, Rest medium) Andreas 2026-09-05. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+108
-37
@@ -1,19 +1,25 @@
|
||||
/**
|
||||
* 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).
|
||||
* resolver.ts — Klassen-Auflösung fuer den Router.
|
||||
*
|
||||
* 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.
|
||||
* Drei Verbraucher:
|
||||
* - konfigurierte Aliasse (42i/*): schwelle oder alternative, mit Last-Probe
|
||||
* bei Wechsel, alle refreshHours neu (Preise aendern sich oft)
|
||||
* - alt/<openrouter-id>[:effort] auf Abruf: erste Anfrage loest auf
|
||||
* (Latenz + Live-Probe, KEINE Last-Probe -- die kostet 210k Token und
|
||||
* darf nicht von jedem beliebigen Modellnamen ausgeloest werden), Ergebnis
|
||||
* landet im State und wird im Takt mit erneuert
|
||||
* - /models: braucht die Kontoliste und die AA-Varianten
|
||||
*
|
||||
* Aufruf als Skript: `bun run src/resolver.ts [--force] [--dry-run]`
|
||||
* Daten: OpenRouter-Listen im Speicher (refreshHours), AA-Varianten auf Platte
|
||||
* (aa-cache.json, aaRefreshDays -- Indexe aendern sich nie, nur Modelle kommen
|
||||
* dazu). State in state.json, Wechsel in changes.log.
|
||||
*
|
||||
* Skript: `bun run src/resolver.ts [--force] [--dry-run] [alt/<ref> ...]`
|
||||
*/
|
||||
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, statSync } from "fs";
|
||||
import {
|
||||
apiKeyHolen, aaKeyHolen, listenHolen, kandidatenBauen, sperrliste, guenstigereAlternative, billigsterUeberSchwelle,
|
||||
variantenHolen, referenzParsen, referenzFormat, type Variante, type Effort,
|
||||
variantenHolen, referenzFormat, type Variante, type Effort, type Candidate, type Model,
|
||||
} from "../../llmlite/openrouter-auswahl.ts";
|
||||
import { configLaden, type Config } from "./config.ts";
|
||||
|
||||
@@ -21,6 +27,8 @@ export type Zuordnung = {
|
||||
model: string;
|
||||
effort: Effort | null;
|
||||
index: number;
|
||||
coding: number | null;
|
||||
agentic: number | null;
|
||||
weighted: number;
|
||||
referenz: string;
|
||||
seit: string;
|
||||
@@ -29,6 +37,15 @@ export type Zuordnung = {
|
||||
};
|
||||
export type State = { aktualisiert: string; zuordnung: Record<string, Zuordnung> };
|
||||
|
||||
export type Daten = {
|
||||
geholt: number;
|
||||
konto: Model[];
|
||||
alle: Map<string, Candidate>;
|
||||
varianten: Variante[];
|
||||
gesperrt: Map<string, string>;
|
||||
key: string | undefined;
|
||||
};
|
||||
|
||||
const statePfad = (c: Config) => `${c.logDir}/state.json`;
|
||||
const aaPfad = (c: Config) => `${c.logDir}/aa-cache.json`;
|
||||
|
||||
@@ -39,12 +56,15 @@ export function stateLaden(c: Config): State {
|
||||
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;
|
||||
}
|
||||
function stateSchreiben(c: Config, s: State): void {
|
||||
mkdirSync(c.logDir, { recursive: true });
|
||||
writeFileSync(statePfad(c), JSON.stringify(s, null, 1));
|
||||
}
|
||||
|
||||
async function variantenMitCache(c: Config, modelle: Parameters<typeof variantenHolen>[1]): Promise<Variante[]> {
|
||||
async function variantenMitCache(c: Config, modelle: Model[]): 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[];
|
||||
@@ -59,19 +79,63 @@ async function variantenMitCache(c: Config, modelle: Parameters<typeof varianten
|
||||
return varianten;
|
||||
}
|
||||
|
||||
let datenCache: Daten | undefined;
|
||||
let datenLaeuft: Promise<Daten> | undefined;
|
||||
/** OpenRouter-Listen + AA-Varianten, im Speicher fuer refreshHours. */
|
||||
export async function datenHolen(c: Config, force = false): Promise<Daten> {
|
||||
if (!force && datenCache !== undefined && Date.now() - datenCache.geholt < c.refreshHours * 3_600_000) return datenCache;
|
||||
if (datenLaeuft !== undefined) return datenLaeuft;
|
||||
datenLaeuft = (async () => {
|
||||
const key = apiKeyHolen();
|
||||
const listen = await listenHolen(key);
|
||||
const gesperrt = sperrliste();
|
||||
const { alle } = kandidatenBauen(listen, gesperrt);
|
||||
const varianten = await variantenMitCache(c, listen.voll);
|
||||
datenCache = { geholt: Date.now(), konto: listen.konto ?? listen.voll, alle, varianten, gesperrt, key };
|
||||
return datenCache;
|
||||
})().finally(() => { datenLaeuft = undefined; });
|
||||
return datenLaeuft;
|
||||
}
|
||||
|
||||
function zuordnungAus(k: Candidate, referenz: string, seit: string, istReferenz: boolean): Zuordnung {
|
||||
const n = (v: number) => (Number.isFinite(v) ? v : null);
|
||||
return { model: k.id, effort: k.effort ?? null, index: k.index, coding: n(k.coding), agentic: n(k.agentic), weighted: k.weighted, referenz, seit, istReferenz };
|
||||
}
|
||||
|
||||
function wechselMelden(c: Config, alias: string, alt: Zuordnung | undefined, ziel: Zuordnung, log: (z: string) => void, dryRun: boolean): void {
|
||||
const zeile = `${alias}: ${alt ? referenzFormat(alt.model, alt.effort) : "(neu)"} -> ${referenzFormat(ziel.model, ziel.effort)} (Index ${ziel.index.toFixed(1)}${ziel.coding !== null ? `, Coding ${ziel.coding.toFixed(1)}` : ""}${ziel.agentic !== null ? `, Agentic ${ziel.agentic.toFixed(1)}` : ""}, ${ziel.weighted.toFixed(2)} $/1M gewichtet, ${ziel.referenz}${ziel.istReferenz ? ", = Referenz" : ""})`;
|
||||
log(zeile);
|
||||
if (!dryRun) { mkdirSync(c.logDir, { recursive: true }); appendFileSync(`${c.logDir}/changes.log`, `${new Date().toISOString()} ${zeile}\n`); }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* alt/<ref>: guenstigste Alternative derselben Klasse, ohne Last-Probe.
|
||||
* Schreibt ins uebergebene State-Objekt (Schluessel "alt/<ref>").
|
||||
*/
|
||||
export async function alternativeAufloesen(c: Config, state: State, ref: string, log: (z: string) => void = console.log, opts?: { dryRun?: boolean; daten?: Daten }): Promise<Zuordnung> {
|
||||
const d = opts?.daten ?? (await datenHolen(c));
|
||||
const schluessel = `alt/${ref}`;
|
||||
const alt = state.zuordnung[schluessel];
|
||||
const abgelehnt: string[] = [];
|
||||
const { referenz, auswahl } = await guenstigereAlternative(ref, d.alle, d.key, (id, grund) => abgelehnt.push(`${id}: ${grund}`), d.gesperrt, { varianten: d.varianten, lasttest: false });
|
||||
const jetzt = new Date().toISOString();
|
||||
const ziel = auswahl === null ? zuordnungAus(referenz, ref, alt?.seit ?? jetzt, true) : zuordnungAus(auswahl.candidate, ref, alt?.seit ?? jetzt, false);
|
||||
if (alt === undefined || alt.model !== ziel.model || alt.effort !== ziel.effort) { ziel.seit = jetzt; wechselMelden(c, schluessel, alt, ziel, log, opts?.dryRun === true); }
|
||||
for (const a of abgelehnt) log(` abgelehnt ${schluessel}: ${a}`);
|
||||
state.zuordnung[schluessel] = ziel;
|
||||
if (!opts?.dryRun) stateSchreiben(c, state);
|
||||
return ziel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Voller Lauf: konfigurierte Aliasse (mit Last-Probe bei Wechsel) und alle
|
||||
* bereits bekannten alt/-Eintraege (ohne Last-Probe) neu aufloesen.
|
||||
*/
|
||||
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 d = await datenHolen(c, true);
|
||||
const neu: State = { aktualisiert: new Date().toISOString(), zuordnung: { ...bisher.zuordnung } };
|
||||
const dryRun = opts?.dryRun === true;
|
||||
|
||||
for (const [alias, def] of Object.entries(c.aliases)) {
|
||||
if (def.art === "fest") continue;
|
||||
@@ -79,33 +143,33 @@ export async function aufloesen(c: Config, bisher: State, opts?: { dryRun?: bool
|
||||
try {
|
||||
const abgelehnt: string[] = [];
|
||||
const melden = (id: string, grund: string) => abgelehnt.push(`${id}: ${grund}`);
|
||||
const o = { varianten, lasttest: c.lasttest, bestand: alt?.model };
|
||||
const o = { varianten: d.varianten, lasttest: c.lasttest, bestand: alt?.model };
|
||||
let ziel: Zuordnung;
|
||||
if (def.art === "alternative") {
|
||||
const { referenz, auswahl } = await guenstigereAlternative(def.referenz, alle, key, melden, gesperrt, o);
|
||||
ziel = 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 { referenz, auswahl } = await guenstigereAlternative(def.referenz, d.alle, d.key, melden, d.gesperrt, o);
|
||||
ziel = auswahl === null ? zuordnungAus(referenz, def.referenz, alt?.seit ?? neu.aktualisiert, true) : zuordnungAus(auswahl.candidate, def.referenz, alt?.seit ?? neu.aktualisiert, false);
|
||||
} else {
|
||||
const beschreibung = `Index>=${def.index}, Kontext>=${def.minContext}`;
|
||||
const { auswahl } = await billigsterUeberSchwelle(def.index, def.minContext, alle, key, melden, gesperrt, o);
|
||||
const { auswahl } = await billigsterUeberSchwelle(def.index, def.minContext, d.alle, d.key, melden, d.gesperrt, o);
|
||||
if (auswahl === null) throw new Error(`kein Modell erfuellt ${beschreibung}`);
|
||||
ziel = { model: auswahl.candidate.id, effort: auswahl.candidate.effort ?? null, index: auswahl.candidate.index, weighted: auswahl.candidate.weighted, referenz: beschreibung, 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, ${ziel.referenz}${ziel.istReferenz ? ", = Referenz" : ""})`;
|
||||
log(zeile);
|
||||
if (!opts?.dryRun) { mkdirSync(c.logDir, { recursive: true }); appendFileSync(`${c.logDir}/changes.log`, zeile + "\n"); }
|
||||
ziel = zuordnungAus(auswahl.candidate, beschreibung, alt?.seit ?? neu.aktualisiert, false);
|
||||
}
|
||||
if (alt === undefined || alt.model !== ziel.model || alt.effort !== ziel.effort) { ziel.seit = neu.aktualisiert; wechselMelden(c, alias, alt, ziel, log, dryRun); }
|
||||
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)); }
|
||||
for (const schluessel of Object.keys(bisher.zuordnung).filter((k) => k.startsWith("alt/"))) {
|
||||
try {
|
||||
await alternativeAufloesen(c, neu, schluessel.slice(4), log, { dryRun: true, daten: d });
|
||||
} catch (e) {
|
||||
log(`FEHLER ${schluessel}: ${(e as Error).message} -- Eintrag entfernt`);
|
||||
delete neu.zuordnung[schluessel];
|
||||
}
|
||||
}
|
||||
if (!dryRun) stateSchreiben(c, neu);
|
||||
return neu;
|
||||
}
|
||||
|
||||
@@ -114,10 +178,17 @@ if (import.meta.main) {
|
||||
const s = stateLaden(c);
|
||||
const force = process.argv.includes("--force");
|
||||
const dryRun = process.argv.includes("--dry-run");
|
||||
if (!force && !stateVeraltet(c, s)) {
|
||||
const refs = process.argv.slice(2).filter((a) => a.startsWith("alt/")).map((a) => a.slice(4));
|
||||
const tabelle = (st: State) => console.table(Object.entries(st.zuordnung).map(([alias, z]) => ({
|
||||
alias, ziel: referenzFormat(z.model, z.effort), index: z.index.toFixed(1), coding: z.coding?.toFixed(1) ?? "—", agentic: z.agentic?.toFixed(1) ?? "—", "$/1M": z.weighted.toFixed(2), referenz: z.referenz, seit: z.seit.slice(0, 16),
|
||||
})));
|
||||
if (refs.length > 0) {
|
||||
for (const r of refs) await alternativeAufloesen(c, s, r, console.log, { dryRun });
|
||||
tabelle({ ...s, zuordnung: Object.fromEntries(Object.entries(s.zuordnung).filter(([k]) => refs.some((r) => k === `alt/${r}`))) });
|
||||
} else if (!force && !stateVeraltet(c, s)) {
|
||||
console.log(`state.json ist frisch (${s.aktualisiert}); --force erzwingt.`);
|
||||
tabelle(s);
|
||||
} 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) })));
|
||||
tabelle(await aufloesen(c, s, { dryRun }));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user