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:
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user