llmrouter: Namensräume openrouter/, alt/, role/; Klasse über drei Indexe relativ

- openrouter/<id>[:effort] 1:1, alt/<id>[:effort] günstigste Alternative auf
  Abruf (ohne Last-Probe, im Takt erneuert), role/<name> aus config.rollen
- Klasse: Intelligence, Coding, Agentic je >= 95 % der Referenz (relativ statt
  3 Punkte), Kontext >= 90 %, billiger; Kandidaten auch ohne OpenRouter-Index,
  wenn AA Stufen kennt (OpenRouter führt Intelligence nur für 53 Modelle)
- ACL-Muster mit *, /models mit allen Konto-Modellen je Namensraum und Stufe
- Rollen aus der LiteLLM-Zeit übernommen (Führung high, Rest medium)
Andreas 2026-09-05.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 09:47:22 +02:00
committed by andreas
co-authored by Claude Fable 5.1
parent e13baaab28
commit 34d6c08a3d
6 changed files with 299 additions and 127 deletions
+100 -51
View File
@@ -1,18 +1,23 @@
/**
* server.ts — der llmrouter: OpenAI-kompatibler Reverse-Proxy mit vier
* Eingriffen je Anfrage: Client-Key pruefen, Alias aufloesen und Modell
* Eingriffen je Anfrage: Client-Key pruefen, Namen aufloesen und Modell
* umschreiben, Usage/Kosten aus der Antwort mitlesen, eine Zeile loggen.
*
* Keine Format-Uebersetzung: alle Upstreams sprechen OpenAI-Format
* (OpenRouter, llama-server, Ollama, Mistral). Genau das macht ihn klein.
* Namensraeume (Reihenfolge der Aufloesung):
* role/<name> -> Ziel aus config.rollen, dann weiter
* 42i/... (config.aliases) -> fest / schwelle / alternative (State)
* openrouter/<id>[:effort] -> 1:1 an OpenRouter
* alt/<id>[:effort] -> guenstigste Alternative (auf Abruf, State)
* sonst -> fallback (LiteLLM), unveraendert
*
* Umgebung: PORT (optional), KEYS_FILE (Token -> Name), ADMIN_KEY (fuer
* /stats und /zuordnung), OPENROUTER_API_KEY, MISTRAL_API_KEY.
* Keine Format-Uebersetzung: alle Upstreams sprechen OpenAI-Format.
* Umgebung: PORT, KEYS_FILE, ADMIN_KEY, OPENROUTER_API_KEY, MISTRAL_API_KEY,
* KIRA_*_KEY, ARTIFICIALANALYSIS_API_KEY, LLMROUTER_CONFIG, MODEL_BLOCKLIST.
*/
import { configLaden, aclLaden, tokenLaden, upstreamKey, type Config, type Upstream } from "./config.ts";
import { aufloesen, stateLaden, stateVeraltet, type State } from "./resolver.ts";
import { configLaden, aclLaden, tokenLaden, upstreamKey, erlaubt, type Config, type Upstream } from "./config.ts";
import { aufloesen, alternativeAufloesen, datenHolen, stateLaden, stateVeraltet, type State } from "./resolver.ts";
import { Log, usageLesen, type Eintrag } from "./log.ts";
import { referenzFormat } from "../../llmlite/openrouter-auswahl.ts";
import { referenzFormat, referenzParsen, type Effort } from "../../llmlite/openrouter-auswahl.ts";
const config: Config = configLaden();
const acl = aclLaden();
@@ -37,8 +42,24 @@ async function auffrischen(grund: string): Promise<void> {
}
}
if (stateVeraltet(config, state)) void auffrischen("Start, state veraltet");
else void datenHolen(config).catch((e) => sagen(`Daten laden fehlgeschlagen: ${(e as Error).message}`));
setInterval(() => { if (stateVeraltet(config, state)) void auffrischen("Takt"); tokens = tokenLaden(); }, 15 * 60_000);
// alt/-Aufloesungen auf Abruf, je Referenz nur einmal gleichzeitig
const altLaeuft = new Map<string, Promise<void>>();
async function altSicherstellen(ref: string): Promise<{ model: string; effort: Effort | null } | { fehler: string }> {
const z = state.zuordnung[`alt/${ref}`];
if (z !== undefined) return { model: z.model, effort: z.effort };
let p = altLaeuft.get(ref);
if (p === undefined) {
p = alternativeAufloesen(config, state, ref, sagen).then(() => undefined).finally(() => altLaeuft.delete(ref));
altLaeuft.set(ref, p);
}
try { await p; } catch (e) { return { fehler: (e as Error).message }; }
const neu = state.zuordnung[`alt/${ref}`];
return neu === undefined ? { fehler: `alt/${ref}: keine Zuordnung` } : { model: neu.model, effort: neu.effort };
}
// --- Nebenlaeufigkeit je Upstream (lokales Qwen: ein Slot) ------------------
const semaphoren = new Map<string, { frei: number; warteschlange: (() => void)[] }>();
async function belegen(name: string, u: Upstream): Promise<() => void> {
@@ -50,29 +71,41 @@ async function belegen(name: string, u: Upstream): Promise<() => void> {
return () => { const n = s!.warteschlange.shift(); if (n) n(); else s!.frei += 1; };
}
// --- Aufloesung Alias -> Ziel ------------------------------------------------
type Ziel = { alias: string; upstreamName: string; upstream: Upstream; model: string; effort: string | null; body?: Record<string, unknown>; alternative: boolean };
function zielBestimmen(angefragt: string, keyName: string): Ziel | { fehler: string; status: number } {
const alias = config.altnamen?.[angefragt] ?? angefragt;
const def = config.aliases[alias];
if (def === undefined) {
// Durchreiche: unbekannter Name geht unveraendert an LiteLLM, mit dem
// Client-Key -- dort gelten dessen ACLs. So wechselt ein Agent per
// Modellname (2608 -> 2609), nicht per base_url.
if (config.fallback !== undefined) {
const name = config.fallback.upstream;
return { alias: `(durchreiche)`, upstreamName: name, upstream: config.upstreams[name], model: angefragt, effort: null, alternative: false };
}
return { fehler: `Unbekanntes Modell ${angefragt}`, status: 404 };
// --- Aufloesung Name -> Ziel ---------------------------------------------------
type Ziel = { alias: string; upstreamName: string; upstream: Upstream; model: string; effort: Effort | null; body?: Record<string, unknown>; effortSetzen: boolean };
type Fehler = { fehler: string; status: number };
const OR = (): Upstream => config.upstreams.openrouter;
async function zielBestimmen(angefragt: string, keyName: string): Promise<Ziel | Fehler> {
if (!erlaubt(acl.get(keyName) ?? [], angefragt)) return { fehler: `${keyName} darf ${angefragt} nicht rufen`, status: 403 };
let name = angefragt;
if (name.startsWith("role/")) {
const ziel = config.rollen?.[name.slice(5)];
if (ziel === undefined) return { fehler: `Unbekannte Rolle ${name}`, status: 404 };
name = ziel;
}
const erlaubt = acl.get(keyName) ?? [];
if (erlaubt.length > 0 && !erlaubt.includes(alias)) return { fehler: `${keyName} darf ${alias} nicht rufen`, status: 403 };
if (def.art === "fest") {
return { alias, upstreamName: def.upstream, upstream: config.upstreams[def.upstream], model: def.model, effort: null, body: def.body, alternative: false };
const def = config.aliases[name];
if (def !== undefined) {
if (def.art === "fest") return { alias: name, upstreamName: def.upstream, upstream: config.upstreams[def.upstream], model: def.model, effort: null, body: def.body, effortSetzen: false };
const z = state.zuordnung[name];
if (z === undefined) return { fehler: `${name} ist noch nicht aufgeloest (Zuordnung fehlt) -- spaeter erneut`, status: 503 };
return { alias: name, upstreamName: "openrouter", upstream: OR(), model: z.model, effort: z.effort, effortSetzen: true };
}
const z = state.zuordnung[alias];
if (z === undefined) return { fehler: `${alias} ist noch nicht aufgeloest (Zuordnung fehlt) -- spaeter erneut`, status: 503 };
return { alias, upstreamName: "openrouter", upstream: config.upstreams.openrouter, model: z.model, effort: z.effort, alternative: true };
if (name.startsWith("openrouter/")) {
const { id, effort } = referenzParsen(name.slice(11));
return { alias: name, upstreamName: "openrouter", upstream: OR(), model: id, effort, effortSetzen: effort !== null };
}
if (name.startsWith("alt/")) {
const z = await altSicherstellen(name.slice(4));
if ("fehler" in z) return { fehler: z.fehler, status: 404 };
return { alias: name, upstreamName: "openrouter", upstream: OR(), model: z.model, effort: z.effort, effortSetzen: true };
}
if (config.fallback !== undefined) {
// Durchreiche: unbekannter Name geht unveraendert an LiteLLM, mit dem Client-Key -- dort gelten dessen ACLs.
const u = config.fallback.upstream;
return { alias: "(durchreiche)", upstreamName: u, upstream: config.upstreams[u], model: name, effort: null, effortSetzen: false };
}
return { fehler: `Unbekanntes Modell ${angefragt}`, status: 404 };
}
// --- Anfrage bauen -------------------------------------------------------------
@@ -85,15 +118,45 @@ function jsonBody(orig: Record<string, unknown>, ziel: Ziel): Record<string, unk
b.usage = { include: true }; // Kosten kommen in der Antwort mit
if (b.stream === true) b.stream_options = { ...(b.stream_options as object ?? {}), include_usage: true };
}
if (ziel.alternative) {
// Die Stufe gehoert zum Alias, nicht zum Client: wer marvin:low ruft,
// bekommt die Klasse von marvin:low -- auch wenn er reasoning_effort mitschickt.
if (ziel.effortSetzen) {
// Die Stufe gehoert zum Namen, nicht zum Client: wer 42i/marvin-2609:low
// oder openrouter/x:medium ruft, bekommt genau diese Stufe.
delete b.reasoning_effort; delete b.reasoning;
if (ziel.effort !== null) b.reasoning = { effort: ziel.effort };
}
return b;
}
// --- Modellliste ---------------------------------------------------------------
async function modellListe(keyName: string, auth: string): Promise<{ id: string; object: "model"; owned_by: string }[]> {
const liste = acl.get(keyName) ?? [];
const data: { id: string; object: "model"; owned_by: string }[] = [];
const add = (id: string, owned_by: string) => { if (erlaubt(liste, id)) data.push({ id, object: "model", owned_by }); };
for (const id of Object.keys(config.aliases)) add(id, "42i");
for (const r of Object.keys(config.rollen ?? {})) add(`role/${r}`, "42i");
try {
const d = await datenHolen(config);
const stufen = new Map<string, Effort[]>();
for (const v of d.varianten) if (v.effort !== null) stufen.set(v.id, [...(stufen.get(v.id) ?? []), v.effort]);
for (const m of d.konto) {
if (m.id.includes(":")) { add(`openrouter/${m.id}`, "openrouter"); continue; } // :free, :exacto -- keine Stufen
add(`openrouter/${m.id}`, "openrouter"); add(`alt/${m.id}`, "alt");
for (const e of stufen.get(m.id) ?? []) { add(`openrouter/${m.id}:${e}`, "openrouter"); add(`alt/${m.id}:${e}`, "alt"); }
}
} catch (e) { sagen(`/models ohne OpenRouter-Liste: ${(e as Error).message}`); }
if (config.fallback !== undefined) {
// Im Parallelbetrieb auch die LiteLLM-Liste des Clients, sonst verlieren
// Clients, die Modelle per /models entdecken, die 2608-Namen.
try {
const u = config.upstreams[config.fallback.upstream];
const r = await fetch(`${u.base}/models`, { headers: { authorization: `Bearer ${auth}` }, signal: AbortSignal.timeout(10_000) });
const bekannt = new Set(data.map((m) => m.id));
if (r.ok) for (const m of ((await r.json()) as { data?: { id: string }[] }).data ?? []) if (!bekannt.has(m.id)) data.push({ id: m.id, object: "model", owned_by: config.fallback.upstream });
} catch { /* LiteLLM nicht da: nur eigene Liste */ }
}
return data;
}
// --- Server -------------------------------------------------------------------
Bun.serve({
port: Number(process.env.PORT ?? config.port),
@@ -113,21 +176,7 @@ Bun.serve({
// Client
const keyName = tokens.get(auth);
if (keyName === undefined) return Response.json({ error: { message: "Ungueltiger API-Key" } }, { status: 401 });
if (pfad === "/models") {
const erlaubt = acl.get(keyName) ?? [];
const namen = Object.keys(config.aliases).filter((a) => erlaubt.length === 0 || erlaubt.includes(a));
const data = namen.map((id) => ({ id, object: "model", owned_by: "42i" }));
// Im Parallelbetrieb auch die LiteLLM-Liste des Clients zeigen, sonst
// verlieren Clients, die Modelle per /models entdecken, die 2608-Namen.
if (config.fallback !== undefined) {
try {
const u = config.upstreams[config.fallback.upstream];
const r = await fetch(`${u.base}/models`, { headers: { authorization: `Bearer ${auth}` }, signal: AbortSignal.timeout(10_000) });
if (r.ok) for (const m of ((await r.json()) as { data?: { id: string }[] }).data ?? []) if (!namen.includes(m.id)) data.push({ id: m.id, object: "model", owned_by: config.fallback.upstream });
} catch { /* LiteLLM nicht da: nur eigene Liste */ }
}
return Response.json({ object: "list", data });
}
if (pfad === "/models") return Response.json({ object: "list", data: await modellListe(keyName, auth) });
if (req.method !== "POST" || !(JSON_PFADE.has(pfad) || FORM_PFADE.has(pfad))) return Response.json({ error: { message: `Pfad ${pfad} wird nicht bedient` } }, { status: 404 });
const start = Date.now();
@@ -141,7 +190,7 @@ Bun.serve({
let orig: Record<string, unknown>;
try { orig = (await req.json()) as Record<string, unknown>; } catch { return Response.json({ error: { message: "Body ist kein JSON" } }, { status: 400 }); }
angefragt = String(orig.model ?? "");
const z = zielBestimmen(angefragt, keyName);
const z = await zielBestimmen(angefragt, keyName);
if ("fehler" in z) return Response.json({ error: { message: z.fehler } }, { status: z.status });
ziel = z;
stream = orig.stream === true;
@@ -150,7 +199,7 @@ Bun.serve({
} else {
const form = await req.formData();
angefragt = String(form.get("model") ?? "");
const z = zielBestimmen(angefragt, keyName);
const z = await zielBestimmen(angefragt, keyName);
if ("fehler" in z) return Response.json({ error: { message: z.fehler } }, { status: z.status });
ziel = z;
if (ziel.upstream.clientKey !== true) form.set("model", ziel.model);
@@ -185,7 +234,7 @@ Bun.serve({
const ct = antwort.headers.get("content-type") ?? "";
const weiter = new Headers(antwort.headers);
weiter.delete("content-length"); weiter.delete("content-encoding"); weiter.delete("transfer-encoding");
weiter.set("x-llmrouter-model", referenzFormat(ziel.model, ziel.effort as never));
weiter.set("x-llmrouter-model", referenzFormat(ziel.model, ziel.effort));
if (ct.startsWith("text/event-stream") && antwort.body !== null) {
// Durchreichen und nebenbei den letzten usage-Chunk lesen.
@@ -220,4 +269,4 @@ Bun.serve({
return new Response(antwort.body, { status: antwort.status, headers: weiter });
},
});
sagen(`llmrouter lauscht auf :${process.env.PORT ?? config.port}, ${Object.keys(config.aliases).length} Aliasse, ${tokens.size} Token, Zuordnung von ${state.aktualisiert}`);
sagen(`llmrouter lauscht auf :${process.env.PORT ?? config.port}, ${Object.keys(config.aliases).length} Aliasse, ${Object.keys(config.rollen ?? {}).length} Rollen, ${tokens.size} Token, Zuordnung von ${state.aktualisiert}`);