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
202 lines
10 KiB
TypeScript
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 }));
|
|
}
|
|
}
|