diff --git a/src/openrouter-auswahl.ts b/src/openrouter-auswahl.ts new file mode 100644 index 0000000..e48b80d --- /dev/null +++ b/src/openrouter-auswahl.ts @@ -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 { + 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; +} diff --git a/src/resolver.ts b/src/resolver.ts index 3dbc0da..e078c8e 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -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 = { diff --git a/src/server.ts b/src/server.ts index adfc3a2..f9db678 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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();