Files
llmrouter/src/resolver.ts
T
elton 7c6e747145
llmrouter CI / bun test + build (push) Successful in 4s
llmrouter CI / Build and publish image (push) Successful in 21s
minOutput je Profil senkbar, neue Rolle coder
Das 64K-Output-Gate war global und trieb auto/mass:fast auf solar-pro4
(0,42 statt 0,27 $/1M), weil ling-3.0-flash mit 32768 Output-Token
durchfiel. Gemessen 09.09.: 1.415 Masse-Requests, 1.146 davon unter 100
Completion-Token — das Gate verfehlte dort seinen Zweck. Jetzt kann je
Schwelle-Profil minOutput gesetzt werden (Default 65536);
auto/mass:fast laeuft mit 32768, max_output unbekannt bleibt abgelehnt.

Dazu role/coder -> auto/coding:junior (ACL fuer persona-spark), damit
Code-Arbeit nicht ueber role/worker in die Masse faellt; der
engineer-Agent der Mac-opencode.json wechselt auf role/coder.

Refs #1283
2026-09-09 17:03:51 +02:00

202 lines
10 KiB
TypeScript

/**
* resolver.ts — Klassen-Auflösung fuer den Router.
*
* 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
*
* 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, referenzFormat, type Variante, type Effort, type Candidate, type Model,
} from "./openrouter-auswahl.ts";
import { configLaden, type Config } from "./config.ts";
export type Zuordnung = {
model: string;
effort: Effort | null;
index: number;
coding: number | null;
agentic: number | null;
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> };
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`;
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;
}
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: 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[];
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;
}
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`); }
}
/**
* 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 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;
const alt = bisher.zuordnung[alias];
try {
const abgelehnt: string[] = [];
const melden = (id: string, grund: string) => abgelehnt.push(`${id}: ${grund}`);
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, 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 teile = [
def.index !== undefined ? `Index>=${def.index}` : null,
def.coding !== undefined ? `Coding>=${def.coding}` : null,
def.agentic !== undefined ? `Agentic>=${def.agentic}` : null,
`Kontext>=${def.minContext}`,
def.minOutput !== undefined ? `Output>=${def.minOutput}` : null,
].filter((t): t is string => t !== null);
const beschreibung = teile.join(", ");
const { auswahl } = await billigsterUeberSchwelle(def.index ?? 0, def.minContext, d.alle, d.key, melden, d.gesperrt, { ...o, minCoding: def.coding, minAgentic: def.agentic, minOutput: def.minOutput });
if (auswahl === null) throw new Error(`kein Modell erfuellt ${beschreibung}`);
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"}`);
}
}
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;
}
if (import.meta.main) {
const c = configLaden();
const s = stateLaden(c);
const force = process.argv.includes("--force");
const dryRun = process.argv.includes("--dry-run");
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 {
tabelle(await aufloesen(c, s, { dryRun }));
}
}