/** * 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/[: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/ ...]` */ 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 }; export type Daten = { geholt: number; konto: Model[]; alle: Map; varianten: Variante[]; gesperrt: Map; 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 { 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 | undefined; /** OpenRouter-Listen + AA-Varianten, im Speicher fuer refreshHours. */ export async function datenHolen(c: Config, force = false): Promise { 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/: guenstigste Alternative derselben Klasse, ohne Last-Probe. * Schreibt ins uebergebene State-Objekt (Schluessel "alt/"). */ export async function alternativeAufloesen(c: Config, state: State, ref: string, log: (z: string) => void = console.log, opts?: { dryRun?: boolean; daten?: Daten }): Promise { 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 { 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 })); } }