/** * 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 { 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(pfad: string, key?: string): Promise { const headers: Record = { 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 = sperrliste(), ): { kandidaten: Candidate[]; alle: Map; 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(); 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 { 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 { 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, ``-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 { 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 { 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(); 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(); 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, key: string | undefined, abgelehnt?: (id: string, grund: string) => void, gesperrt: Map = 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, key: string | undefined, abgelehnt?: (id: string, grund: string) => void, gesperrt: Map = 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, regel: { minIndex: number; minCoding?: number; minAgentic?: number; minContext: number; maxWeighted?: number; ausser?: string }, varianten: Variante[], gesperrt: Map, ): 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 { 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; }