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
+32 -27
View File
@@ -9,37 +9,42 @@ Keine Format-Übersetzung, keine Datenbank, kein Admin-UI. Alle Upstreams
sprechen OpenAI-Format (OpenRouter, llama-server, Ollama, Mistral), deshalb
reicht Durchreichen. Entscheidung Andreas, 2026-09-05.
## Aliasse (config.json)
## Namensräume
Zwei Arten:
| Name | Bedeutung |
|---|---|
| `42i/marvin-2609:low\|medium\|high\|max` | konfigurierte Stufen (`aliases`, Art `schwelle`: Index ≥ 25/45/49/53, Kontext ≥ 256k/256k/500k/500k) |
| `42i/marvin-2609-core:none\|medium` | lokales Qwen, fest |
| `openrouter/<id>[:effort]` | 1:1 an OpenRouter, Effort als `reasoning.effort` |
| `alt/<id>[:effort]` | **günstigste Alternative derselben Klasse** wie `<id>` auf dieser Stufe |
| `role/<name>` | Rolle aus `rollen`, zeigt auf einen der obigen Namen |
| alles andere | Durchreiche an LiteLLM (`fallback`), unverändert mit Client-Key |
- **`alternative`** — der Alias nennt ein Referenzmodell mit optionaler
Effort-Stufe (`openai/gpt-5.6-luna:low`, `anthropic/claude-opus-5:medium`).
Gerufen wird das **günstigste OpenRouter-Modell derselben Klasse**, nach der
Regel in `../llmlite/openrouter-auswahl.ts` (Intelligence-Index je Stufe von
Artificial Analysis, Kontext, Tool-Calls, Sperrliste, Live-Probe, Last-Probe
bei Wechsel). Findet sich nichts Billigeres, wird die Referenz selbst gerufen.
Die Effort-Stufe des Ziels wird als `reasoning.effort` gesetzt; ein vom
Client mitgeschicktes `reasoning_effort` wird verworfen, die Klasse gehört
zum Alias.
- **`schwelle`** — Index-Schwelle (0100) plus Mindestkontext, ohne Referenzmodell:
das günstigste Modell, das beides erfüllt, mit der niedrigsten ausreichenden
Effort-Stufe. Stand 2026-09-05: low 25/256k, medium 40/256k, high 47/500k,
max 50/500k. Stabiler als eine Referenz, sagt aber weniger über die Bedeutung.
- **`fest`** — Upstream und Modell stehen fest (lokales Qwen, TTS/STT,
Embeddings, kira auf deizo). Optional ein `body`-Patch, etwa
`chat_template_kwargs.enable_thinking` für die Qwen-Stufen.
**Klasse** (`alt/`): Intelligence-, Coding- und Agentic-Index jeweils mindestens
95 % der Referenz, Kontext mindestens 90 %, gewichtet billiger. Intelligence und
Coding kommen je Effort-Stufe von Artificial Analysis, Agentic nur je Modell (AA
misst ihn nicht je Stufe). Hat die Referenz einen Wert, den ein Kandidat nicht
hat, fällt der Kandidat raus. Je Kandidat zählt die niedrigste Stufe, die die
Klasse erreicht; die Stufe des Ziels wird als `reasoning.effort` gesetzt, ein
vom Client mitgeschicktes `reasoning_effort` verworfen. Findet sich nichts
Billigeres, wird die Referenz selbst gerufen.
Die Namen sind versioniert wie bei LiteLLM: `42i/marvin-2609:low``:max`,
`42i/marvin-2609-core:none|medium`. **Unbekannte Namen** (alles aus der
LiteLLM-Zeit, `42i/marvin-2608:*`) reicht der Router unverändert mit dem
Client-Key an LiteLLM auf :4000 durch (`fallback`). Caddy zeigt `llm.lan`
auf den Router; ein Agent wechselt also per Modellname 2608 → 2609, nicht
per base_url, und rollt per Umbenennen zurück. Durchgereichte Anfragen stehen
im Log mit Upstream `litellm`, ohne Kosten (die kennt nur LiteLLM).
`alt/` wird **bei der ersten Anfrage** aufgelöst (Latenz- und Live-Probe, keine
Last-Probe) und dann im 12-h-Takt mit erneuert. Die konfigurierten `42i/*`-Stufen
fahren bei einem Wechsel zusätzlich die Last-Probe (78k Kontext, dreimal).
Wird LiteLLM abgeschaltet, entfällt `fallback`, und `altnamen` (Übersetzung
2608 → 2609) übernimmt, damit auch Nachzügler nicht brechen.
**Rollen** entkoppeln Agenten von Modellnamen: ein Agent ruft `role/worker`,
welches Modell dahintersteht, stellt man in `config.json` um, ohne Container.
Vorbelegung: xo/architect/teamleader/lead → `42i/marvin-2609:high`, alle
anderen → `:medium`.
**Rechte** (`key-acls.json`): Name → erlaubte Namen, `*` am Ende ist ein
Präfix-Muster (`openrouter/*`, `alt/*`, `role/*`); `[]` = alles. Geprüft wird
der angefragte Name, vor der Rollenauflösung.
`/v1/models` zeigt je Key alles Erlaubte: Aliasse, Rollen, alle OpenRouter-
Modelle des Kontos als `openrouter/…` und `alt/…`, jeweils auch mit den bei AA
gemessenen Effort-Stufen, plus die LiteLLM-Liste im Parallelbetrieb.
## Auflösung und Takt
+25 -1
View File
@@ -128,5 +128,29 @@
"fallback": {
"upstream": "litellm",
"_doku": "Unbekannte Modellnamen (z.B. 42i/marvin-2608:*) gehen unverändert mit dem Client-Key an LiteLLM. Umstellung je Agent = Modellname 2608 -> 2609. Fällt weg, wenn LiteLLM abgeschaltet wird; dann kommen die 2608-Namen als altnamen zurück."
}
},
"_rollen_doku": "Rollen entkoppeln Agenten von Modellnamen: ein Agent ruft role/worker, welches Modell dahintersteht, stellt man HIER um -- aus der Ferne, ohne Container-Deploy. Ziele: 42i/*-Alias, openrouter/<id>[:effort] (1:1) oder alt/<id>[:effort] (guenstigste Alternative). Vorbelegung 2026-09-05 (Elton): Fuehrungsrollen high, Rest medium -- Andreas' Feinjustierung ausstehend.",
"rollen": {
"xo": "42i/marvin-2609:high",
"architect": "42i/marvin-2609:high",
"teamleader": "42i/marvin-2609:high",
"lead": "42i/marvin-2609:high",
"worker": "42i/marvin-2609:medium",
"engineer": "42i/marvin-2609:medium",
"po": "42i/marvin-2609:medium",
"speaker": "42i/marvin-2609:medium",
"support": "42i/marvin-2609:medium",
"assistant": "42i/marvin-2609:medium",
"privacy": "42i/marvin-2609:medium",
"service": "42i/marvin-2609:medium",
"ops": "42i/marvin-2609:medium",
"marketing": "42i/marvin-2609:medium",
"qa": "42i/marvin-2609:medium",
"perf": "42i/marvin-2609:medium",
"i18n": "42i/marvin-2609:medium",
"docs": "42i/marvin-2609:medium",
"ar": "42i/marvin-2609:medium",
"archivist": "42i/marvin-2609:medium"
},
"_namen_doku": "Namensraeume: 42i/* = konfigurierte Aliasse (aliases); openrouter/<id>[:effort] = 1:1 an OpenRouter, Effort als reasoning.effort; alt/<id>[:effort] = guenstigste Alternative derselben Klasse (Intelligence, Coding, Agentic je >= 95 % der Referenz, Kontext >= 90 %, billiger), aufgeloest bei der ersten Anfrage und im 12h-Takt erneuert, OHNE Last-Probe; role/<name> = Rolle (rollen). Alles andere -> fallback (LiteLLM)."
}
+25 -10
View File
@@ -1,33 +1,40 @@
{
"_note": "Rechte je Client-Key: Name -> erlaubte Aliasse (neue Namen; Altnamen werden vor der Pruefung uebersetzt). [] = alles. Die Token selbst liegen NICHT hier, sondern in KEYS_FILE auf dem Host (token -> name). Abgeleitet aus llmlite/key-acls.json am 2026-09-05: role/* und -auto gestrichen (Andreas), -auto -> :medium. Stand 2026-09-05 abends: Stufen low/medium/high/max; none->low, extra->high, ultra->max, mass->low. Namen ab 2026-09-05: 42i/marvin-2609:*; die Rechte gelten nur fuer Router-Aliasse, unbekannte Namen (2608) reicht der Router an LiteLLM durch, dort gelten dessen ACLs.",
"_note": "Rechte je Client-Key: Name -> erlaubte Aliasse (neue Namen; Altnamen werden vor der Pruefung uebersetzt). [] = alles. Die Token selbst liegen NICHT hier, sondern in KEYS_FILE auf dem Host (token -> name). Abgeleitet aus llmlite/key-acls.json am 2026-09-05: role/* und -auto gestrichen (Andreas), -auto -> :medium. Stand 2026-09-05 abends: Stufen low/medium/high/max; none->low, extra->high, ultra->max, mass->low. Namen ab 2026-09-05: 42i/marvin-2609:*; die Rechte gelten nur fuer Router-Aliasse, unbekannte Namen (2608) reicht der Router an LiteLLM durch, dort gelten dessen ACLs. Muster mit * erlaubt (openrouter/*, alt/*, role/*). Rollen aus der LiteLLM-Zeit je Key wieder eingetragen; Elton und Andreas duerfen openrouter/* und alt/*.",
"keys": {
"persona-spark": [
"42i/marvin-2609:medium",
"kira-reasoning",
"kira-stt",
"marvin-stt",
"marvin-tts"
"marvin-tts",
"role/engineer",
"role/worker"
],
"persona-buzz": [
"42i/marvin-2609:medium",
"kira-reasoning",
"kira-stt",
"marvin-stt",
"marvin-tts"
"marvin-tts",
"role/ops",
"role/worker"
],
"persona-jessie": [
"42i/marvin-2609:medium",
"kira-reasoning",
"kira-stt",
"marvin-stt",
"marvin-tts"
"marvin-tts",
"role/assistant",
"role/marketing"
],
"persona-klara": [
"42i/marvin-2609:medium",
"kira-reasoning",
"kira-stt",
"marvin-stt",
"marvin-tts"
"marvin-tts",
"role/service"
],
"persona-elton": [
"42i/marvin-2609:low",
@@ -41,21 +48,26 @@
"marvin-stt",
"marvin-stt-turbo",
"marvin-tts",
"marvin-tts-turbo"
"marvin-tts-turbo",
"role/*",
"openrouter/*",
"alt/*"
],
"persona-festus": [
"42i/marvin-2609-core:none",
"42i/marvin-2609-core:medium",
"42i/marvin-2609:medium",
"kira-reasoning",
"kira-stt"
"kira-stt",
"role/privacy"
],
"persona-aria": [
"42i/marvin-2609-core:none",
"42i/marvin-2609-core:medium",
"42i/marvin-2609:medium",
"kira-reasoning",
"kira-stt"
"kira-stt",
"role/architect"
],
"persona-inka": [
"42i/marvin-2609-core:none",
@@ -92,14 +104,17 @@
"marvin-stt",
"marvin-stt-turbo",
"marvin-tts",
"marvin-tts-turbo"
"marvin-tts-turbo",
"role/worker"
],
"lab-xo": [
"42i/marvin-2609:low",
"42i/marvin-2609:high",
"42i/marvin-2609:medium",
"kira-reasoning",
"kira-stt"
"kira-stt",
"role/xo",
"role/*"
],
"team-copilot": [
"42i/marvin-2609:low",
+9 -1
View File
@@ -26,6 +26,8 @@ export type Config = {
altnamen?: Record<string, string>;
/** Unbekannte Modellnamen unveraendert an diesen Upstream (Parallelbetrieb mit LiteLLM). */
fallback?: { upstream: string };
/** role/<name> -> Zielname (42i/*, openrouter/*, alt/*). Agenten kennen nur Rollen; Modelle stellt man hier um. */
rollen?: Record<string, string>;
};
export const BASE = resolve(dirname(import.meta.path), "..");
@@ -45,7 +47,7 @@ export function configLaden(): Config {
return c;
}
/** Name -> erlaubte Aliasse; leere Liste = alles. */
/** Name -> erlaubte Namen oder Muster (`openrouter/*`, `alt/*`, `role/*`); leere Liste = alles. */
export function aclLaden(): Map<string, string[]> {
const pfad = process.env.LLMROUTER_ACLS ?? `${dirname(CONFIG_PATH)}/key-acls.json`;
const d = JSON.parse(readFileSync(pfad, "utf8")) as { keys: Record<string, string[]> };
@@ -63,6 +65,12 @@ export function tokenLaden(): Map<string, string> {
}
}
/** Prueft einen angefragten Namen gegen die ACL-Liste; `*` am Ende ist ein Praefix-Muster. */
export function erlaubt(liste: string[], name: string): boolean {
if (liste.length === 0) return true;
return liste.some((m) => (m.endsWith("*") ? name.startsWith(m.slice(0, -1)) : m === name));
}
export function upstreamKey(u: Upstream): string | undefined {
return u.keyEnv === undefined ? undefined : process.env[u.keyEnv];
}
+108 -37
View File
@@ -1,19 +1,25 @@
/**
* resolver.ts — loest die "alternative"-Aliasse auf das billigste Modell
* derselben Klasse auf (openrouter-auswahl.ts aus llmlite, gleiche Regel wie
* das Anzeige-Werkzeug und der alte Tier-Updater).
* resolver.ts — Klassen-Auflösung fuer den Router.
*
* Takt: OpenRouter-Preise aendern sich oft -> alle refreshHours neu holen.
* AA-Indexe aendern sich nie, es kommen nur Modelle hinzu -> aa-cache.json,
* alle aaRefreshDays erneuert. Ergebnis in state.json, damit ein Neustart
* nicht mit leerer Zuordnung startet; Wechsel landen in changes.log.
* Drei Verbraucher:
* - konfigurierte Aliasse (42i/*): schwelle oder alternative, mit Last-Probe
* bei Wechsel, alle refreshHours neu (Preise aendern sich oft)
* - alt/<openrouter-id>[:effort] auf Abruf: erste Anfrage loest auf
* (Latenz + Live-Probe, KEINE Last-Probe -- die kostet 210k Token und
* darf nicht von jedem beliebigen Modellnamen ausgeloest werden), Ergebnis
* landet im State und wird im Takt mit erneuert
* - /models: braucht die Kontoliste und die AA-Varianten
*
* Aufruf als Skript: `bun run src/resolver.ts [--force] [--dry-run]`
* Daten: OpenRouter-Listen im Speicher (refreshHours), AA-Varianten auf Platte
* (aa-cache.json, aaRefreshDays -- Indexe aendern sich nie, nur Modelle kommen
* dazu). State in state.json, Wechsel in changes.log.
*
* Skript: `bun run src/resolver.ts [--force] [--dry-run] [alt/<ref> ...]`
*/
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, statSync } from "fs";
import {
apiKeyHolen, aaKeyHolen, listenHolen, kandidatenBauen, sperrliste, guenstigereAlternative, billigsterUeberSchwelle,
variantenHolen, referenzParsen, referenzFormat, type Variante, type Effort,
variantenHolen, referenzFormat, type Variante, type Effort, type Candidate, type Model,
} from "../../llmlite/openrouter-auswahl.ts";
import { configLaden, type Config } from "./config.ts";
@@ -21,6 +27,8 @@ export type Zuordnung = {
model: string;
effort: Effort | null;
index: number;
coding: number | null;
agentic: number | null;
weighted: number;
referenz: string;
seit: string;
@@ -29,6 +37,15 @@ export type Zuordnung = {
};
export type State = { aktualisiert: string; zuordnung: Record<string, Zuordnung> };
export type Daten = {
geholt: number;
konto: Model[];
alle: Map<string, Candidate>;
varianten: Variante[];
gesperrt: Map<string, string>;
key: string | undefined;
};
const statePfad = (c: Config) => `${c.logDir}/state.json`;
const aaPfad = (c: Config) => `${c.logDir}/aa-cache.json`;
@@ -39,12 +56,15 @@ export function stateLaden(c: Config): State {
return { aktualisiert: "1970-01-01T00:00:00.000Z", zuordnung: {} };
}
}
export function stateVeraltet(c: Config, s: State): boolean {
return Date.now() - Date.parse(s.aktualisiert) > c.refreshHours * 3_600_000;
}
function stateSchreiben(c: Config, s: State): void {
mkdirSync(c.logDir, { recursive: true });
writeFileSync(statePfad(c), JSON.stringify(s, null, 1));
}
async function variantenMitCache(c: Config, modelle: Parameters<typeof variantenHolen>[1]): Promise<Variante[]> {
async function variantenMitCache(c: Config, modelle: Model[]): Promise<Variante[]> {
const pfad = aaPfad(c);
const frisch = existsSync(pfad) && Date.now() - statSync(pfad).mtimeMs < c.aaRefreshDays * 86_400_000;
if (frisch) return JSON.parse(readFileSync(pfad, "utf8")) as Variante[];
@@ -59,19 +79,63 @@ async function variantenMitCache(c: Config, modelle: Parameters<typeof varianten
return varianten;
}
let datenCache: Daten | undefined;
let datenLaeuft: Promise<Daten> | undefined;
/** OpenRouter-Listen + AA-Varianten, im Speicher fuer refreshHours. */
export async function datenHolen(c: Config, force = false): Promise<Daten> {
if (!force && datenCache !== undefined && Date.now() - datenCache.geholt < c.refreshHours * 3_600_000) return datenCache;
if (datenLaeuft !== undefined) return datenLaeuft;
datenLaeuft = (async () => {
const key = apiKeyHolen();
const listen = await listenHolen(key);
const gesperrt = sperrliste();
const { alle } = kandidatenBauen(listen, gesperrt);
const varianten = await variantenMitCache(c, listen.voll);
datenCache = { geholt: Date.now(), konto: listen.konto ?? listen.voll, alle, varianten, gesperrt, key };
return datenCache;
})().finally(() => { datenLaeuft = undefined; });
return datenLaeuft;
}
function zuordnungAus(k: Candidate, referenz: string, seit: string, istReferenz: boolean): Zuordnung {
const n = (v: number) => (Number.isFinite(v) ? v : null);
return { model: k.id, effort: k.effort ?? null, index: k.index, coding: n(k.coding), agentic: n(k.agentic), weighted: k.weighted, referenz, seit, istReferenz };
}
function wechselMelden(c: Config, alias: string, alt: Zuordnung | undefined, ziel: Zuordnung, log: (z: string) => void, dryRun: boolean): void {
const zeile = `${alias}: ${alt ? referenzFormat(alt.model, alt.effort) : "(neu)"} -> ${referenzFormat(ziel.model, ziel.effort)} (Index ${ziel.index.toFixed(1)}${ziel.coding !== null ? `, Coding ${ziel.coding.toFixed(1)}` : ""}${ziel.agentic !== null ? `, Agentic ${ziel.agentic.toFixed(1)}` : ""}, ${ziel.weighted.toFixed(2)} $/1M gewichtet, ${ziel.referenz}${ziel.istReferenz ? ", = Referenz" : ""})`;
log(zeile);
if (!dryRun) { mkdirSync(c.logDir, { recursive: true }); appendFileSync(`${c.logDir}/changes.log`, `${new Date().toISOString()} ${zeile}\n`); }
}
/**
* Loest alle alternative-Aliasse auf. Die Last-Probe (78k Kontext, dreimal,
* kostet ~210k Token) laeuft nur fuer einen Kandidaten, der die bisherige
* Zuordnung abloesen wuerde -- wie im alten Tier-Updater.
* alt/<ref>: guenstigste Alternative derselben Klasse, ohne Last-Probe.
* Schreibt ins uebergebene State-Objekt (Schluessel "alt/<ref>").
*/
export async function alternativeAufloesen(c: Config, state: State, ref: string, log: (z: string) => void = console.log, opts?: { dryRun?: boolean; daten?: Daten }): Promise<Zuordnung> {
const d = opts?.daten ?? (await datenHolen(c));
const schluessel = `alt/${ref}`;
const alt = state.zuordnung[schluessel];
const abgelehnt: string[] = [];
const { referenz, auswahl } = await guenstigereAlternative(ref, d.alle, d.key, (id, grund) => abgelehnt.push(`${id}: ${grund}`), d.gesperrt, { varianten: d.varianten, lasttest: false });
const jetzt = new Date().toISOString();
const ziel = auswahl === null ? zuordnungAus(referenz, ref, alt?.seit ?? jetzt, true) : zuordnungAus(auswahl.candidate, ref, alt?.seit ?? jetzt, false);
if (alt === undefined || alt.model !== ziel.model || alt.effort !== ziel.effort) { ziel.seit = jetzt; wechselMelden(c, schluessel, alt, ziel, log, opts?.dryRun === true); }
for (const a of abgelehnt) log(` abgelehnt ${schluessel}: ${a}`);
state.zuordnung[schluessel] = ziel;
if (!opts?.dryRun) stateSchreiben(c, state);
return ziel;
}
/**
* Voller Lauf: konfigurierte Aliasse (mit Last-Probe bei Wechsel) und alle
* bereits bekannten alt/-Eintraege (ohne Last-Probe) neu aufloesen.
*/
export async function aufloesen(c: Config, bisher: State, opts?: { dryRun?: boolean; log?: (z: string) => void }): Promise<State> {
const log = opts?.log ?? ((z: string) => console.log(z));
const key = apiKeyHolen();
const listen = await listenHolen(key);
const gesperrt = sperrliste();
const { alle } = kandidatenBauen(listen, gesperrt);
const varianten = await variantenMitCache(c, listen.voll);
const d = await datenHolen(c, true);
const neu: State = { aktualisiert: new Date().toISOString(), zuordnung: { ...bisher.zuordnung } };
const dryRun = opts?.dryRun === true;
for (const [alias, def] of Object.entries(c.aliases)) {
if (def.art === "fest") continue;
@@ -79,33 +143,33 @@ export async function aufloesen(c: Config, bisher: State, opts?: { dryRun?: bool
try {
const abgelehnt: string[] = [];
const melden = (id: string, grund: string) => abgelehnt.push(`${id}: ${grund}`);
const o = { varianten, lasttest: c.lasttest, bestand: alt?.model };
const o = { varianten: d.varianten, lasttest: c.lasttest, bestand: alt?.model };
let ziel: Zuordnung;
if (def.art === "alternative") {
const { referenz, auswahl } = await guenstigereAlternative(def.referenz, alle, key, melden, gesperrt, o);
ziel = auswahl === null
? { model: referenz.id, effort: referenz.effort ?? null, index: referenz.index, weighted: referenz.weighted, referenz: def.referenz, seit: alt?.seit ?? neu.aktualisiert, istReferenz: true }
: { model: auswahl.candidate.id, effort: auswahl.candidate.effort ?? null, index: auswahl.candidate.index, weighted: auswahl.candidate.weighted, referenz: def.referenz, seit: alt?.seit ?? neu.aktualisiert, istReferenz: false };
const { referenz, auswahl } = await guenstigereAlternative(def.referenz, d.alle, d.key, melden, d.gesperrt, o);
ziel = auswahl === null ? zuordnungAus(referenz, def.referenz, alt?.seit ?? neu.aktualisiert, true) : zuordnungAus(auswahl.candidate, def.referenz, alt?.seit ?? neu.aktualisiert, false);
} else {
const beschreibung = `Index>=${def.index}, Kontext>=${def.minContext}`;
const { auswahl } = await billigsterUeberSchwelle(def.index, def.minContext, alle, key, melden, gesperrt, o);
const { auswahl } = await billigsterUeberSchwelle(def.index, def.minContext, d.alle, d.key, melden, d.gesperrt, o);
if (auswahl === null) throw new Error(`kein Modell erfuellt ${beschreibung}`);
ziel = { model: auswahl.candidate.id, effort: auswahl.candidate.effort ?? null, index: auswahl.candidate.index, weighted: auswahl.candidate.weighted, referenz: beschreibung, seit: alt?.seit ?? neu.aktualisiert, istReferenz: false };
}
const gleich = alt !== undefined && alt.model === ziel.model && alt.effort === ziel.effort;
if (!gleich) {
ziel.seit = neu.aktualisiert;
const zeile = `${neu.aktualisiert} ${alias}: ${alt ? referenzFormat(alt.model, alt.effort) : "(neu)"} -> ${referenzFormat(ziel.model, ziel.effort)} (Index ${ziel.index.toFixed(1)}, ${ziel.weighted.toFixed(2)} $/1M gewichtet, ${ziel.referenz}${ziel.istReferenz ? ", = Referenz" : ""})`;
log(zeile);
if (!opts?.dryRun) { mkdirSync(c.logDir, { recursive: true }); appendFileSync(`${c.logDir}/changes.log`, zeile + "\n"); }
ziel = zuordnungAus(auswahl.candidate, beschreibung, alt?.seit ?? neu.aktualisiert, false);
}
if (alt === undefined || alt.model !== ziel.model || alt.effort !== ziel.effort) { ziel.seit = neu.aktualisiert; wechselMelden(c, alias, alt, ziel, log, dryRun); }
for (const a of abgelehnt) log(` abgelehnt ${alias}: ${a}`);
neu.zuordnung[alias] = ziel;
} catch (e) {
log(`FEHLER ${alias}: ${(e as Error).message}${alt ? " -- behalte " + referenzFormat(alt.model, alt.effort) : " -- KEINE Zuordnung"}`);
}
}
if (!opts?.dryRun) { mkdirSync(c.logDir, { recursive: true }); writeFileSync(statePfad(c), JSON.stringify(neu, null, 1)); }
for (const schluessel of Object.keys(bisher.zuordnung).filter((k) => k.startsWith("alt/"))) {
try {
await alternativeAufloesen(c, neu, schluessel.slice(4), log, { dryRun: true, daten: d });
} catch (e) {
log(`FEHLER ${schluessel}: ${(e as Error).message} -- Eintrag entfernt`);
delete neu.zuordnung[schluessel];
}
}
if (!dryRun) stateSchreiben(c, neu);
return neu;
}
@@ -114,10 +178,17 @@ if (import.meta.main) {
const s = stateLaden(c);
const force = process.argv.includes("--force");
const dryRun = process.argv.includes("--dry-run");
if (!force && !stateVeraltet(c, s)) {
const refs = process.argv.slice(2).filter((a) => a.startsWith("alt/")).map((a) => a.slice(4));
const tabelle = (st: State) => console.table(Object.entries(st.zuordnung).map(([alias, z]) => ({
alias, ziel: referenzFormat(z.model, z.effort), index: z.index.toFixed(1), coding: z.coding?.toFixed(1) ?? "—", agentic: z.agentic?.toFixed(1) ?? "—", "$/1M": z.weighted.toFixed(2), referenz: z.referenz, seit: z.seit.slice(0, 16),
})));
if (refs.length > 0) {
for (const r of refs) await alternativeAufloesen(c, s, r, console.log, { dryRun });
tabelle({ ...s, zuordnung: Object.fromEntries(Object.entries(s.zuordnung).filter(([k]) => refs.some((r) => k === `alt/${r}`))) });
} else if (!force && !stateVeraltet(c, s)) {
console.log(`state.json ist frisch (${s.aktualisiert}); --force erzwingt.`);
tabelle(s);
} else {
const neu = await aufloesen(c, s, { dryRun });
console.table(Object.entries(neu.zuordnung).map(([alias, z]) => ({ alias, ziel: referenzFormat(z.model, z.effort), index: z.index.toFixed(1), "$/1M": z.weighted.toFixed(2), referenz: z.referenz, seit: z.seit.slice(0, 16) })));
tabelle(await aufloesen(c, s, { dryRun }));
}
}
+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}`);