openrouter-auswahl.ts vendored, Importpfade umgestellt

Der Router verlaesst 42i/agents: die gemeinsame Modellauswahl wandert als
eigenstaendige Kopie nach src/ (die llmlite-Fassung bleibt dort fuer den
Tier-Updater). Referenz-Stellen zu LiteLLM-Durchreiche und Parallelbetrieb
korrigiert (entfallen mit der Abschaltung 2026-09-07).
This commit is contained in:
2026-09-08 09:48:13 +02:00
parent bf7bc2c0b9
commit 3c0a8ead85
3 changed files with 625 additions and 2 deletions
+623
View File
@@ -0,0 +1,623 @@
/**
* openrouter-auswahl.ts — die gemeinsame Modellauswahl fuer OpenRouter.
*
* 2026-09-08 nach spine/llmrouter vendored (aus 42i/agents llmlite/), weil der
* Router das alte 42i/agents-Repo verlassen hat. Diese Kopie entwickelt sich
* hier weiter; die llmlite-Fassung im agents-Repo bleibt eigenstaendig.
*
* EINE Quelle fuer zwei Verbraucher, damit Anzeige und Wirklichkeit nicht
* auseinanderlaufen (im Herkunfts-Repo):
* - ~/.agents/llmlite/openrouter-tiers.ts setzt die marvin-*-Stufen am
* Gateway (alle 3 h, launchd)
* - ~/src/tools/openrouter-models.ts zeigt dasselbe zur Ansicht
*
* Vorher waren das zwei Implementierungen derselben Regel. Sie sind am
* 2026-08-21 auseinandergelaufen (das eine Werkzeug kannte eine Hysterese,
* das andere nicht) und zeigten fuer dieselbe Frage verschiedene Modelle.
* Wer hier etwas aendert, aendert es fuer beide -- so ist es gemeint.
*
* Die Regel: je Index-Schwelle das billigste Modell, das
* - ueber der Schwelle liegt (Artificial-Analysis-Intelligence-Index),
* - fuer DIESES Konto nutzbar ist (/models/user, s. u.),
* - mindestens 256k Kontext hat und Tool-Calls beherrscht,
* - nicht auf der Sperrliste steht,
* - schnell genug antwortet (falls OpenRouter eine Messung hat),
* - und die Live-Probe mit echtem Werkzeugaufruf besteht.
* Bewusst OHNE Wechselschwelle: eine Hysterese laesst eine Stufe auf einem
* Modell festhaengen, das inzwischen teurer ist als die Stufe darueber.
*/
import { readFileSync } from "fs";
import { spawnSync } from "child_process";
export const API = process.env.OPENROUTER_API_URL ?? "https://openrouter.ai/api/v1";
export const MIN_CONTEXT = 256_000;
export const MAX_LATENCY_MS = 3_000;
export const INPUT_WEIGHT = 10; // Agenten-Lastprofil: Input dominiert
export const OUTPUT_WEIGHT = 1;
export type Model = {
id: string;
name?: string;
context_length?: number;
pricing?: { prompt?: string; completion?: string; input_cache_read?: string; input_cache_write?: string };
supported_parameters?: string[];
top_provider?: { context_length?: number };
benchmarks?: { artificial_analysis?: { intelligence_index?: number; coding_index?: number; agentic_index?: number } };
};
export type Candidate = {
id: string;
/** Intelligence-Index (AA); NaN, wenn weder OpenRouter noch AA einen kennen. */
index: number;
/** Coding-Index (AA), NaN wenn unbekannt. Je Effort-Stufe aus den Varianten, sonst OpenRouter-Zahl. */
coding: number;
/** Agentic-Index (AA ueber OpenRouter), NaN wenn unbekannt. Nur je Modell, AA misst ihn nicht je Stufe. */
agentic: number;
input: number;
output: number;
/**
* Preis eines Cache-TREFFERS je 1M Tokens, null wenn das Modell keinen
* ausweist (dann rechnet der Anbieter Cache-Treffer zum vollen Input-Preis).
*
* Das ist bei uns die WICHTIGSTE Preisangabe, auch wenn sie die kleinste
* Zahl ist: ueber 95 % der Tokens eines Agentenlaufs sind Cache-Treffer,
* weil jeder Zug den ganzen bisherigen Verlauf erneut schickt. `input` gilt
* nur fuer das bisschen Neues je Zug.
*/
cacheRead: number | null;
/** Preis fuer das ANLEGEN eines Cache-Eintrags je 1M, null = zum Input-Preis. */
cacheWrite: number | null;
weighted: number;
context: number;
frei: boolean;
/** Kann Tool-Calls und ist kein Batch-Modell -- unabhaengig von MIN_CONTEXT. */
tauglich: boolean;
/** Effort-Stufe, auf die sich `index` bezieht; null = OpenRouter-Zahl (Hoechststufe). */
effort?: Effort | null;
};
export type Auswahl = { candidate: Candidate; latency: number | null };
/**
* Holt den API-Schluessel: erst Umgebung, dann OpenBao ueber den lokalen
* secret-Helfer. Ohne Schluessel faellt der Aufrufer auf die ungefilterte
* Modell-Liste zurueck und zeigt dann Modelle, die dieses Konto nicht rufen
* darf. Der Helfer gibt den Wert auf stdout aus -- er landet nie auf einer
* Kommandozeile.
*/
export function apiKeyHolen(): string | undefined {
const ausUmgebung = process.env.OPENROUTER_API_KEY;
if (ausUmgebung !== undefined && ausUmgebung.length > 0) return ausUmgebung;
const helfer = `${process.env.HOME}/.agents/bin/secret`;
const ergebnis = spawnSync(helfer, ["get", "jessie", "litellm", "openrouter_api_key"], { encoding: "utf8", timeout: 30_000 });
const key = (ergebnis.stdout ?? "").trim();
return ergebnis.status === 0 && key.length > 20 ? key : undefined;
}
/**
* Sperrliste: Modelle, die im echten Agentenbetrieb versagt haben. Index,
* Preis und die Live-Probe sehen diese Klasse von Defekt nicht, deshalb wird
* sie von Hand gepflegt. Nur `gesperrt` wirkt; `aufgehoben` ist Gedaechtnis.
*/
export function sperrliste(): Map<string, string> {
const pfad = process.env.MODEL_BLOCKLIST ?? `${process.env.HOME}/.agents/llmlite/model-blocklist.json`;
try {
const liste = JSON.parse(readFileSync(pfad, "utf8")) as { gesperrt: { id: string; reason: string }[] };
return new Map(liste.gesperrt.map((eintrag) => [eintrag.id, eintrag.reason]));
} catch {
return new Map();
}
}
async function json<T>(pfad: string, key?: string): Promise<T> {
const headers: Record<string, string> = { Accept: "application/json" };
if (key !== undefined) headers.Authorization = `Bearer ${key}`;
const antwort = await fetch(`${API}${pfad}`, { headers });
if (!antwort.ok) throw new Error(`OpenRouter antwortet ${antwort.status} auf ${pfad}`);
return (await antwort.json()) as T;
}
/**
* Holt beide Listen.
*
* `/models/user` ist dieselbe Liste, gefiltert nach den Datenschutz-,
* Provider- und Guardrail-Einstellungen des Kontos -- und mit DESSEN Preisen.
* Beides zaehlt: Solar-Pro4 war am 2026-08-21 in `/models` normal gelistet und
* lieferte im Betrieb 404 ("No endpoints available matching your guardrail
* restrictions and data policy"), und 37 der 337 nutzbaren Modelle sind dort
* anders bepreist als in der oeffentlichen Liste. Nur die `benchmarks` fehlen
* ihr -- dafuer bleibt `/models` das Nachschlagewerk.
*/
export async function listenHolen(key?: string): Promise<{ voll: Model[]; konto: Model[] | null }> {
const voll = (await json<{ data: Model[] }>("/models")).data;
if (!Array.isArray(voll) || voll.length < 100) throw new Error(`OpenRouter-Modellliste unplausibel (${voll?.length} Modelle)`);
if (key === undefined) return { voll, konto: null };
try {
const konto = (await json<{ data: Model[] }>("/models/user", key)).data;
return { voll, konto: Array.isArray(konto) && konto.length >= 50 ? konto : null };
} catch {
return { voll, konto: null };
}
}
/**
* Preis je 1M Tokens. Null heisst "kein Preis angegeben" (Modell faellt raus),
* 0 heisst "kostenlos" -- das ist ein gueltiger Kandidat.
*
* Kostenlose Modelle waren frueher pauschal ausgeschlossen, weil sie im
* Verdacht standen, dem Datenschutz nicht zu genuegen. Seit die Auswahl auf
* /models/user fusst, ist dieser Verdacht erledigt: die Liste ist bereits nach
* unseren Datenschutz- und Guardrail-Einstellungen gefiltert. Was uebrig
* bleibt, ist ein anderes Risiko -- Ratengrenzen. Das faengt die Probe ab
* (z-ai/glm-5.2:free antwortete am 2026-08-21 dreimal in Folge mit 429),
* deshalb braucht es keinen Preisfilter, sondern nur die Messung.
*/
function preis(wert: string | undefined): number | null {
if (wert === undefined) return null;
const zahl = Number(wert);
if (!Number.isFinite(zahl) || zahl < 0) return null;
return zahl === 0 ? 0 : Number((zahl * 1_000_000).toPrecision(10)); // USD je 1M Tokens, ohne Float-Muell
}
export function gewichtet(input: number, output: number): number {
return INPUT_WEIGHT * input + OUTPUT_WEIGHT * output;
}
/**
* Baut aus den Listen die Kandidaten. Grundlage ist die Kontoliste (Preise und
* Verfuegbarkeit), der Index kommt aus der vollen Liste. `alle` enthaelt auch
* die aussortierten -- der Tier-Updater braucht sie, um den Preis eines
* bereits konfigurierten Modells nachzuziehen.
*/
export function kandidatenBauen(
listen: { voll: Model[]; konto: Model[] | null },
gesperrt: Map<string, string> = sperrliste(),
): { kandidaten: Candidate[]; alle: Map<string, Candidate>; uebersprungen: string[] } {
const benchNach = new Map(listen.voll.map((m) => [m.id, m.benchmarks?.artificial_analysis]));
const grundliste = listen.konto ?? listen.voll;
const kandidaten: Candidate[] = [];
const alle = new Map<string, Candidate>();
const uebersprungen: string[] = [];
const zahl = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : Number.NaN);
for (const modell of grundliste) {
const bench = benchNach.get(modell.id);
const index = zahl(bench?.intelligence_index);
const input = preis(modell.pricing?.prompt);
const output = preis(modell.pricing?.completion);
const context = modell.context_length ?? modell.top_provider?.context_length ?? 0;
if (input === null || output === null) continue;
const kandidat: Candidate = {
id: modell.id, index, coding: zahl(bench?.coding_index), agentic: zahl(bench?.agentic_index), input, output,
cacheRead: preis(modell.pricing?.input_cache_read),
cacheWrite: preis(modell.pricing?.input_cache_write),
weighted: gewichtet(input, output), context, frei: input === 0 && output === 0,
tauglich: false,
};
const istBatch = /batch/u.test(`${modell.id} ${modell.name ?? ""}`) || modell.supported_parameters?.includes("batch") === true;
const kannTools = modell.supported_parameters?.includes("tools") === true;
kandidat.tauglich = kannTools && !istBatch;
alle.set(modell.id, kandidat);
if (!Number.isFinite(index) || context < MIN_CONTEXT || !kandidat.tauglich) continue; // die Tier-Leiter braucht die OpenRouter-Zahl
if (gesperrt.has(modell.id)) { uebersprungen.push(modell.id); continue; }
kandidaten.push(kandidat);
}
return { kandidaten, alle, uebersprungen };
}
/** Beste gemeldete Latenz eines Modells, oder null wenn OpenRouter keine hat. */
export async function latenz(modelId: string): Promise<number | null> {
try {
const daten = await json<{ data?: { endpoints?: { latency_last_30m?: number | null }[] } }>(`/models/${modelId}/endpoints`);
const werte = (daten.data?.endpoints ?? [])
.map((e) => e.latency_last_30m)
.filter((w): w is number => typeof w === "number" && Number.isFinite(w));
return werte.length === 0 ? null : Math.min(...werte);
} catch {
return null;
}
}
/**
* Live-Probe: fordert einen Werkzeugaufruf an und akzeptiert nur eine
* strukturierte tool_calls-Antwort. Faengt tote Endpunkte, fuer das Konto
* gesperrte Modelle und solche, die ueberhaupt keine Tool-Calls liefern.
*
* Zwei Grenzen, beide am 2026-08-21 gemessen:
* - `tool_choice: "required"` geht nicht ueberall (Z.AI/GLM-5.3 antwortet
* "Tool choice must be auto"), deshalb fordert der Prompt den Aufruf an.
* - Die Probe trifft immer nur EINEN Anbieter. Ein Modell wird bei
* OpenRouter von vielen bedient (0731: 30 Stueck) und der Router wechselt
* zwischen ihnen -- ein einzelner kaputter Anbieter bleibt unsichtbar.
* Dafuer ist die Sperrliste da, nicht die Probe.
* - `max_tokens` muss Raum fuer Denk-Zwischentext lassen: Billigmodelle wie
* ling-3.0-flash denken seit ~2026-09-07 vor jedem Werkzeugaufruf, mit
* variabler Laenge (gemessen 29-55 Token, Ausreisser deutlich drueber).
* Bei 400 Token scheiterte die Probe deshalb willkuerlich -- am
* 2026-09-07 kippten zwei Fehlurteile 42i/marvin-2609:low und
* alt/anthropic/claude-haiku-4.5 auf teurere Modelle, ohne dass ling
* defekt war. 2000 Token und 90 s decken das ab; im Fehlerfall kostet
* die Probe immer noch weniger als ein echter Agentenzug.
*/
export async function probe(modelId: string, key: string | undefined): Promise<boolean> {
if (key === undefined) return true; // ohne Schluessel nicht pruefbar: nicht aussortieren
try {
const antwort = await fetch(`${API}/chat/completions`, {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({
model: modelId,
messages: [{ role: "user", content: "Rufe das Werkzeug ping auf." }],
tools: [{ type: "function", function: { name: "ping", description: "Antwortet mit pong.", parameters: { type: "object", properties: {} } } }],
tool_choice: "auto",
max_tokens: 2000,
}),
signal: AbortSignal.timeout(90_000),
});
if (!antwort.ok) return false;
const körper = (await antwort.json()) as { error?: unknown; choices?: { message?: { tool_calls?: unknown[] } }[] };
if (körper.error !== undefined) return false;
const aufrufe = körper.choices?.[0]?.message?.tool_calls;
return Array.isArray(aufrufe) && aufrufe.length > 0;
} catch {
return false;
}
}
// Fuelltext fuer die Last-Probe: rund 78k Token, Eltons echte Groessenordnung
// in einem Ticket-Lauf. Bewusst kein Zufall -- gleiche Probe, gleiches Urteil.
const LAST_FUELLTEXT = Array.from({ length: 2199 }, (_, i) => {
const n = i + 1;
return `${String(n).padStart(5, "0")} Forderung ${n}: zugestellt, Widerspruch, Mahnbescheid, Gericht Hamburg, Streitwert ${n * 7} Euro.`;
}).join("\n");
/**
* Last-Probe: dieselbe Frage wie die kleine Probe, aber mit rund 78k Token
* Kontext davor -- und dreimal, wobei ALLE DREI bestehen muessen.
*
* Der Grund steht in der Sperrliste: DeepSeek V4 Flash 0731 hat am 2026-08-21
* vier Teamlaeufe stumm sterben lassen, bestand aber jede kleine Probe. Erst
* unter echter Last kippt es -- gemessen 0 von 3, waehrend GPT-5.6 Luna 3 von 3
* schafft, OBWOHL Luna im Agentic-Index NIEDRIGER liegt (46,9 gegen 48,4).
* Der Index misst kurze Kontexte; er sagt nichts darueber, ob ein Modell seine
* Werkzeugaufrufe behaelt, wenn der Prompt gross wird. Genau diese Luecke
* schliesst diese Probe -- und nur sie, keine Kennzahl.
*
* Beobachtete Ausfallbilder, alle mit finish_reason "stop", also rc=0 fuer den
* Aufrufer: Werkzeug-Argumente als nackter Text, `<tool_call>`-Markup im
* Fliesstext, der Befehl als Markdown-Block, halluzinierte Antwort ohne
* Aufruf, oder eine leere Antwort.
*
* Kostet rund 210k Prompt-Token je geprueftem Modell, deshalb laeuft sie NUR
* fuer einen Kandidaten, der das Bestandsmodell abloesen wuerde.
*/
export async function lastprobe(modelId: string, key: string | undefined): Promise<boolean> {
if (key === undefined) return true;
for (let versuch = 0; versuch < 3; versuch += 1) {
try {
const antwort = await fetch(`${API}/chat/completions`, {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({
model: modelId,
messages: [
{ role: "system", content: `Du bist ein Coding-Agent und arbeitest ausschliesslich ueber Werkzeugaufrufe.\n\nAktenlage:\n${LAST_FUELLTEXT}` },
{ role: "user", content: "Stelle mit dem bash-Werkzeug fest, auf welchem Branch der Checkout steht. Rufe jetzt das Werkzeug auf." },
],
tools: [{ type: "function", function: { name: "bash", description: "Fuehrt einen Shell-Befehl aus.", parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } } }],
tool_choice: "auto",
max_tokens: 1500,
}),
signal: AbortSignal.timeout(300_000),
});
if (!antwort.ok) return false;
const körper = (await antwort.json()) as { error?: unknown; choices?: { message?: { tool_calls?: unknown[] } }[] };
if (körper.error !== undefined) return false;
const aufrufe = körper.choices?.[0]?.message?.tool_calls;
if (!Array.isArray(aufrufe) || aufrufe.length === 0) return false;
} catch {
return false;
}
}
return true;
}
/**
* Das billigste taugliche Modell ueber der Schwelle. Geht die nach Preis
* sortierten Kandidaten durch und nimmt den ersten, der Latenz- und
* Live-Probe besteht; `abgelehnt` meldet die uebersprungenen mit Grund.
*
* `opts.bestand` + `opts.lasttest` schalten zusaetzlich die Last-Probe fuer
* jeden Kandidaten frei, der das Bestandsmodell abloesen wuerde. Das
* Anzeige-Werkzeug laesst das aus, sonst kostet jeder Blick auf die Tabelle
* Geld.
*
* Ohne Wechselschwelle bleibt die Leiter automatisch richtig herum: die
* Kandidaten einer niedrigeren Schwelle sind eine Obermenge der hoeheren
* (Index > 38 enthaelt alles mit Index > 48), eine untere Stufe kann also nie
* teurer werden als die darueber.
*/
export async function billigsterTauglicher(
kandidaten: Candidate[],
schwelle: number,
key: string | undefined,
abgelehnt?: (id: string, grund: string) => void,
opts?: { bestand?: string; lasttest?: boolean },
): Promise<Auswahl | null> {
const infrage = kandidaten
.filter((k) => k.index > schwelle)
.sort((a, b) => a.weighted - b.weighted);
return erstesTaugliches(infrage, key, abgelehnt, opts);
}
/**
* Anteil der Referenzwerte, den eine Alternative mindestens erreichen muss --
* fuer Intelligence, Coding UND Agentic (Andreas 2026-09-05: "mindestens -5 %").
* Vorher 3 Indexpunkte absolut; relativ traegt die Regel bis in die kleinen Klassen.
*/
export const INDEX_TOLERANZ = 0.95;
/**
* Anteil des Referenz-Kontexts, den eine Alternative mindestens haben muss.
* Nicht 1.0, weil die Anbieter dieselbe Klasse verschieden beziffern:
* 1.050.000 (OpenAI) gegen 1.048.576 (2^20, Z.AI) ist kein Unterschied.
*/
export const KONTEXT_TOLERANZ = 0.9;
// ---------------------------------------------------------------------------
// Effort-Achse: Artificial Analysis misst jede Reasoning-Stufe als eigenes
// Modell ("GPT-5.6 Luna (high)", "Claude Opus 5 (Adaptive Reasoning, Medium
// Effort)"). OpenRouter uebernimmt davon nur EINE Zahl je Modell-ID -- die
// der Hoechststufe (Luna 43,4 = max; medium sind 30,2). Wer ein Modell auf
// medium faehrt, vergleicht mit der OpenRouter-Zahl also die falsche Klasse.
// Deshalb holt der Referenzmodus die Stufen direkt bei AA und bildet sie ueber
// den Modellnamen auf OpenRouter-IDs ab; Preis und Verfuegbarkeit bleiben
// OpenRouter-Sache, weil dort abgerechnet wird.
// ---------------------------------------------------------------------------
export const AA_API = process.env.ARTIFICIALANALYSIS_API_URL ?? "https://artificialanalysis.ai/api/v2";
export const EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
export type Effort = (typeof EFFORTS)[number];
export type Variante = { id: string; effort: Effort | null; index: number; coding: number | null; aaName: string };
/** Schreibweise `anbieter/modell[:effort]` -- der Doppelpunkt ist bei OpenRouter auch Teil von IDs (`:free`), deshalb zaehlt nur ein bekannter Effort als Stufe. */
export function referenzParsen(eingabe: string): { id: string; effort: Effort | null } {
const doppelpunkt = eingabe.lastIndexOf(":");
if (doppelpunkt > 0) {
const stufe = eingabe.slice(doppelpunkt + 1).toLowerCase();
if ((EFFORTS as readonly string[]).includes(stufe)) return { id: eingabe.slice(0, doppelpunkt), effort: stufe as Effort };
}
return { id: eingabe, effort: null };
}
export function referenzFormat(id: string, effort: Effort | null | undefined): string {
return effort === null || effort === undefined ? id : `${id}:${effort}`;
}
/** AA-Schluessel: Umgebung, sonst OpenBao (gleicher Eintrag wie der OpenRouter-Schluessel). */
export function aaKeyHolen(): string | undefined {
const ausUmgebung = process.env.ARTIFICIALANALYSIS_API_KEY;
if (ausUmgebung !== undefined && ausUmgebung.length > 0) return ausUmgebung;
const helfer = `${process.env.HOME}/.agents/bin/secret`;
const ergebnis = spawnSync(helfer, ["get", "jessie", "litellm", "artificialanalysis_api_key"], { encoding: "utf8", timeout: 30_000 });
const key = (ergebnis.stdout ?? "").trim();
return ergebnis.status === 0 && key.length > 10 ? key : undefined;
}
/** Liest die Effort-Stufe aus dem AA-Namen; null, wenn AA keine Stufe nennt (z. B. "Qwen3.8 Max", "(Reasoning)"). */
export function effortAusName(aaName: string): Effort | null {
const klammer = /\(([^)]*)\)\s*$/u.exec(aaName)?.[1]?.toLowerCase();
if (klammer === undefined) return null;
if (/non-?reasoning/u.test(klammer)) return "none";
for (const stufe of ["xhigh", "minimal", "medium", "high", "low", "max"] as const) {
if (new RegExp(`\\b${stufe}\\b`, "u").test(klammer)) return stufe;
}
return null;
}
function namenNorm(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]/gu, "");
}
/**
* Holt die AA-Varianten und bildet sie auf OpenRouter-IDs ab. Abbildung ueber
* den normalisierten Anzeigenamen (OpenRouter: "Anthropic: Claude Opus 5",
* AA: "Claude Opus 5 (Adaptive Reasoning, Medium Effort)" ohne Klammer) --
* nur exakte Treffer, ein Substring-Match wuerde GLM-5.3 auf GLM-5.3 Flash
* legen. Fuehren mehrere IDs denselben Namen (`:free`, `:exacto`), gewinnt
* die ohne Suffix. `unzugeordnet` nennt die AA-Namen ohne OpenRouter-Gegenstueck.
*/
export async function variantenHolen(aaKey: string, modelle: Model[]): Promise<{ varianten: Variante[]; unzugeordnet: string[] }> {
const antwort = await fetch(`${AA_API}/data/llms/models`, { headers: { "x-api-key": aaKey, Accept: "application/json" } });
if (!antwort.ok) throw new Error(`Artificial Analysis antwortet ${antwort.status}`);
const daten = (await antwort.json()) as { data?: { name: string; evaluations?: { artificial_analysis_intelligence_index?: number | null; artificial_analysis_coding_index?: number | null } }[] };
if (!Array.isArray(daten.data) || daten.data.length < 100) throw new Error(`AA-Modellliste unplausibel (${daten.data?.length})`);
const idNachName = new Map<string, string>();
for (const m of modelle) {
if (m.name === undefined || m.id.includes(":")) continue;
const anzeige = namenNorm(m.name.includes(": ") ? m.name.slice(m.name.indexOf(": ") + 2) : m.name);
if (!idNachName.has(anzeige)) idNachName.set(anzeige, m.id);
}
const varianten: Variante[] = [];
const unzugeordnet = new Set<string>();
for (const v of daten.data) {
const index = v.evaluations?.artificial_analysis_intelligence_index;
if (typeof index !== "number" || !Number.isFinite(index)) continue;
const basis = v.name.replace(/\s*\([^)]*\)\s*$/u, "");
const id = idNachName.get(namenNorm(basis));
if (id === undefined) { unzugeordnet.add(basis); continue; }
varianten.push({ id, effort: effortAusName(v.name), index, coding: v.evaluations?.artificial_analysis_coding_index ?? null, aaName: v.name });
}
return { varianten, unzugeordnet: [...unzugeordnet].sort() };
}
const effortRang = (e: Effort | null): number => (e === null ? EFFORTS.length : EFFORTS.indexOf(e));
/**
* Referenzmodus: das billigste Modell derselben Klasse wie die Referenz
* (`anbieter/modell[:effort]`). "Gleiche Klasse" heisst
* - Intelligence-Index mindestens Referenz minus INDEX_TOLERANZ,
* - Kontext mindestens KONTEXT_TOLERANZ des Referenz-Kontexts,
* - Tool-Calls, kein Batch, nicht gesperrt (wie ueberall),
* - und gewichtet billiger als die Referenz -- sonst ist es keine Alternative.
* Mit `varianten` zaehlt je Modell die NIEDRIGSTE Effort-Stufe, die die Klasse
* noch erreicht (der Tokenpreis ist je Stufe gleich, aber weniger Effort heisst
* weniger Output-Token); ohne AA-Daten zu einem Modell gilt die OpenRouter-Zahl.
* Eine Referenz MIT Stufe braucht die AA-Variante, sonst Fehler -- eine
* geratene Zahl waere schlimmer als keine.
* Bewusst NICHT an MIN_CONTEXT gebunden: die Referenz setzt die Messlatte.
* Die Referenz selbst muss nicht tauglich sein (sie darf z. B. gesperrt sein),
* sie braucht nur Index und Preis.
*/
export async function guenstigereAlternative(
referenzEingabe: string,
alle: Map<string, Candidate>,
key: string | undefined,
abgelehnt?: (id: string, grund: string) => void,
gesperrt: Map<string, string> = sperrliste(),
opts?: { lasttest?: boolean; varianten?: Variante[]; bestand?: string },
): Promise<{ referenz: Candidate; auswahl: Auswahl | null; infrage: Candidate[] }> {
const { id: referenzId, effort } = referenzParsen(referenzEingabe);
const basis = alle.get(referenzId);
if (basis === undefined) throw new Error(`Referenzmodell ${referenzId} unbekannt oder ohne Preis`);
const varianten = opts?.varianten ?? [];
let referenz: Candidate = { ...basis, effort: null };
if (effort !== null) {
const eigene = varianten.filter((v) => v.id === referenzId && v.effort !== null);
let variante = eigene.find((v) => v.effort === effort);
if (variante === undefined && eigene.length > 0) {
// Stufe nicht gemessen (Claude Code schickt z. B. "high" fuer Sonnet 5, AA hat nur
// max/none): die naechste gemessene Stufe DARUNTER vertritt sie, sonst die naechste darueber.
const rang = effortRang(effort);
const darunter = eigene.filter((v) => effortRang(v.effort) <= rang).sort((a, b) => effortRang(b.effort) - effortRang(a.effort))[0];
const darueber = eigene.filter((v) => effortRang(v.effort) > rang).sort((a, b) => effortRang(a.effort) - effortRang(b.effort))[0];
variante = darunter ?? darueber;
}
if (variante === undefined) {
if (!Number.isFinite(basis.index)) throw new Error(`Artificial Analysis kennt fuer ${referenzId} keine Stufen und OpenRouter keinen Index`);
referenz = { ...basis, effort }; // keine Stufen gemessen: die Modellzahl (Hoechststufe) vertritt jede Stufe
} else {
referenz = { ...basis, index: variante.index, coding: variante.coding ?? Number.NaN, effort: variante.effort };
}
} else if (!Number.isFinite(referenz.index)) {
// OpenRouter kennt keinen Index: die staerkste AA-Stufe vertritt das Modell
const beste = varianten.filter((v) => v.id === referenzId).sort((x, y) => y.index - x.index)[0];
if (beste === undefined) throw new Error(`Referenzmodell ${referenzId} hat weder bei OpenRouter noch bei Artificial Analysis einen Index`);
referenz = { ...basis, index: beste.index, coding: beste.coding ?? Number.NaN, effort: beste.effort };
}
const infrage = klasseFiltern(alle, {
minIndex: referenz.index * INDEX_TOLERANZ,
minCoding: Number.isFinite(referenz.coding) ? referenz.coding * INDEX_TOLERANZ : undefined,
// Agentic gibt es nur je Modell (Hoechststufe). Er passt zur Referenz nur,
// wenn die ohne Stufe oder auf max gerufen wird -- sonst wuerde Opus-5-low
// den Agentic-Wert von Opus-5-max verlangen und jede low-Alternative faellt.
minAgentic: Number.isFinite(referenz.agentic) && (referenz.effort === null || referenz.effort === "max") ? referenz.agentic * INDEX_TOLERANZ : undefined,
minContext: referenz.context * KONTEXT_TOLERANZ,
maxWeighted: referenz.weighted,
ausser: referenz.id,
}, varianten, gesperrt);
const auswahl = await erstesTaugliches(infrage, key, abgelehnt, opts);
return { referenz, auswahl, infrage };
}
/**
* Schwellenmodus: das billigste Modell mit Index >= `schwelle` und Kontext
* >= `minContext`, ohne Referenzmodell. Stabiler als eine Referenz (eine Zahl
* verschwindet nicht aus einer Liste), sagt aber weniger darueber, was die
* Stufe bedeuten soll. Effort-Stufen wie im Referenzmodus: je Modell die
* niedrigste, die die Schwelle erreicht.
*/
export async function billigsterUeberSchwelle(
schwelle: number,
minContext: number,
alle: Map<string, Candidate>,
key: string | undefined,
abgelehnt?: (id: string, grund: string) => void,
gesperrt: Map<string, string> = sperrliste(),
// minCoding/minAgentic (42i/intern#1252): eine Schwelle darf auf mehr als der
// Intelligence-Achse liegen. Ein Modell kann generalistisch gut aussehen und
// agentisch durchfallen -- gemini-3.8-flash hat Coding 76,3 und Agentic 41,2
// und taugt damit fuer Text, nicht fuer den Werkzeug-Rundlauf. Wer nur eine
// der Achsen fordert, uebergibt fuer die anderen keinen Wert; minIndex 0
// heisst "auf dieser Achse keine Anforderung".
opts?: { lasttest?: boolean; varianten?: Variante[]; bestand?: string; minCoding?: number; minAgentic?: number },
): Promise<{ auswahl: Auswahl | null; infrage: Candidate[] }> {
const infrage = klasseFiltern(alle, { minIndex: schwelle, minContext, minCoding: opts?.minCoding, minAgentic: opts?.minAgentic }, opts?.varianten ?? [], gesperrt);
const auswahl = await erstesTaugliches(infrage, key, abgelehnt, opts);
return { auswahl, infrage };
}
/**
* Gemeinsamer Filter: taugliche, nicht gesperrte Modelle, je Modell die
* niedrigste Effort-Stufe, die ALLE geforderten Werte erreicht, nach Preis
* sortiert. Intelligence und Coding kommen je Stufe aus den AA-Varianten
* (sonst die OpenRouter-Zahl des Modells), Agentic nur je Modell. Fordert die
* Regel einen Wert, den ein Kandidat nicht hat, faellt er raus -- lieber ein
* Modell weniger als eines, dessen Klasse wir nicht kennen.
*/
function klasseFiltern(
alle: Map<string, Candidate>,
regel: { minIndex: number; minCoding?: number; minAgentic?: number; minContext: number; maxWeighted?: number; ausser?: string },
varianten: Variante[],
gesperrt: Map<string, string>,
): Candidate[] {
const infrage: Candidate[] = [];
for (const k of alle.values()) {
if (k.id === regel.ausser || !k.tauglich || gesperrt.has(k.id)) continue;
if (k.context < regel.minContext) continue;
if (regel.maxWeighted !== undefined && k.weighted >= regel.maxWeighted) continue;
if (regel.minAgentic !== undefined && !(k.agentic >= regel.minAgentic)) continue;
const eigene = varianten.filter((v) => v.id === k.id);
const stufen = eigene.length === 0
? [{ effort: null as Effort | null, index: k.index, coding: Number.isFinite(k.coding) ? k.coding : null }]
: eigene.map((v) => ({ effort: v.effort, index: v.index, coding: v.coding }));
const passende = stufen
.filter((v) => v.index >= regel.minIndex && (regel.minCoding === undefined || (v.coding ?? Number.NaN) >= regel.minCoding))
.sort((a, b) => effortRang(a.effort) - effortRang(b.effort));
if (passende.length === 0) continue;
infrage.push({ ...k, index: passende[0].index, coding: passende[0].coding ?? Number.NaN, effort: passende[0].effort });
}
return infrage.sort((a, b) => a.weighted - b.weighted || effortRang(a.effort) - effortRang(b.effort));
}
/**
* Geht die bereits sortierten Kandidaten durch und nimmt den ersten, der
* Latenz- und Live-Probe besteht; `abgelehnt` meldet die uebersprungenen.
*/
async function erstesTaugliches(
infrage: Candidate[],
key: string | undefined,
abgelehnt?: (id: string, grund: string) => void,
opts?: { bestand?: string; lasttest?: boolean },
): Promise<Auswahl | null> {
for (const kandidat of infrage) {
const gemessen = await latenz(kandidat.id);
if (gemessen !== null && (gemessen < 0 || gemessen > MAX_LATENCY_MS)) {
abgelehnt?.(kandidat.id, `Latenz ${gemessen} ms ueber ${MAX_LATENCY_MS} ms`);
continue;
}
if (!(await probe(kandidat.id, key))) {
abgelehnt?.(kandidat.id, "Live-Probe mit Werkzeugaufruf fehlgeschlagen");
continue;
}
// Nur ein Wechselkandidat muss unter Last bestehen; der Bestand hat sich
// im echten Betrieb bereits bewaehrt und wuerde hier nur Geld kosten.
//
// Ausnahme: kostenlose Modelle werden IMMER unter Last geprueft, auch als
// Bestand. Ihr Risiko ist nicht die Faehigkeit, sondern die Ratengrenze --
// und die kann sich taeglich aendern, ohne dass sich am Modell etwas tut.
// Die Probe kostet bei ihnen ohnehin nichts.
if (opts?.lasttest === true && (kandidat.id !== opts.bestand || kandidat.frei)) {
if (!(await lastprobe(kandidat.id, key))) {
abgelehnt?.(kandidat.id, "Last-Probe (78k Kontext, dreimal) nicht bestanden — verliert unter Last die Werkzeugaufrufe");
continue;
}
}
return { candidate: kandidat, latency: gemessen };
}
return null;
}
+1 -1
View File
@@ -20,7 +20,7 @@ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, sta
import {
apiKeyHolen, aaKeyHolen, listenHolen, kandidatenBauen, sperrliste, guenstigereAlternative, billigsterUeberSchwelle,
variantenHolen, referenzFormat, type Variante, type Effort, type Candidate, type Model,
} from "../../llmlite/openrouter-auswahl.ts";
} from "./openrouter-auswahl.ts";
import { configLaden, type Config } from "./config.ts";
export type Zuordnung = {
+1 -1
View File
@@ -18,7 +18,7 @@ import { configLaden, aclLaden, tokenLaden, upstreamKey, erlaubt, type Config, t
import { aufloesen, alternativeAufloesen, datenHolen, stateLaden, stateVeraltet, type State } from "./resolver.ts";
import { Log, usageLesen, type Eintrag } from "./log.ts";
import { sprechtextNormalisieren } from "./sprechtext.ts";
import { referenzFormat, referenzParsen, EFFORTS, type Effort } from "../../llmlite/openrouter-auswahl.ts";
import { referenzFormat, referenzParsen, EFFORTS, type Effort } from "./openrouter-auswahl.ts";
const config: Config = configLaden();
const acl = aclLaden();