Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
llmrouter
Leichtgewichtiger Ersatz für den LiteLLM-Gateway llm.lan (siehe ../llmlite).
Ein OpenAI-kompatibler Reverse-Proxy in Bun/TypeScript mit genau vier
Eingriffen je Anfrage: Client-Key prüfen, Alias auf ein Modell auflösen,
Usage und Kosten aus der Antwort mitlesen, eine Log-Zeile schreiben.
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.
Namensräume
| 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 |
42i/marvin-tts|stt|embedding[-turbo], 42i/marvin-free |
TTS/STT/Embeddings lokal bzw. OpenRouter/Mistral, fest; -tts-turbo = Grok Voice (x-ai/grok-voice-tts-1.0, Stimme ara), -stt-turbo = Voxtral Small 24B (mistralai/voxtral-small-24b-2507-stt), beides OpenRouter, Tests 2026-09-06 |
deizo/kira-reasoning, deizo/kira-stt |
Modelle im Prod-RZ deizo, 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 |
claude-* (nackte Anthropic-ID) |
Claude Code/Desktop: wird zu alt/anthropic/<id>[:effort] (siehe unten) |
| alles andere | Durchreiche an LiteLLM (fallback), unverändert mit Client-Key |
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.
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).
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/…, als alt/… nur die mit bestimmbarer
Klasse (Index bei OpenRouter oder Stufen bei AA), jeweils mit den gemessenen
Effort-Stufen, plus die LiteLLM-Liste im Parallelbetrieb.
Claude Code und Claude Desktop
Claude Code spricht Anthropics Messages-Format (/v1/messages,
/v1/messages/count_tokens). Der Router reicht beide Pfade an OpenRouters
Messages-Endpunkt durch, der dasselbe Format für alle Modelle bietet, mit
x-api-key, anthropic-version und anthropic-beta des Clients. Das ist die
einzige Format-Übersetzung, die wir nicht selbst schreiben. Nur für
OpenRouter-Ziele; das lokale Qwen spricht kein Messages-Format und antwortet
mit 400. HEAD /api/hello, Claude Codes Start-Ping, bekommt 200.
Modellnamen. Claude Code schickt nackte Anthropic-IDs (claude-sonnet-5,
claude-opus-5-20260723, claude-haiku-4-5-20251001, claude-sonnet-4-5[1m]).
Der Router normalisiert sie (Datum, -latest, [1m] weg; Versionsziffern mit
Punkt wie bei OpenRouter) und bildet sie je anthropicModus ab:
alt(Standard):claude-opus-5→alt/anthropic/claude-opus-5— die Wahl in Claude bestimmt die Klasse, der Router sucht das günstigste Modell dazu.openrouter:openrouter/anthropic/claude-opus-5— 1:1 das echte Modell.
Die Rechte prüfen den abgebildeten Namen; ein Key braucht also alt/* bzw.
openrouter/*. Eigene Namen gehen weiter per /model alt/x-ai/grok-4.6:high
oder /model 42i/marvin-2609:high.
Effort. Claude Code schickt seine Effort-Einstellung als
output_config.effort mit (/effort bzw. Settings). Der Router hängt sie als
Stufe an: claude-opus-5 + high → alt/anthropic/claude-opus-5:high. Hat AA
die Stufe für das Referenzmodell nicht gemessen (Sonnet 5 hat nur max und
none), vertritt die nächste gemessene Stufe darunter die Referenz, sonst die
nächste darüber; ohne jede Stufe die Modellzahl. Am Ziel wird die Stufe des
Ziels gesetzt, thinking und output_config des Clients werden verworfen.
Anschluss. Die Base-URL muss aus der Prozessumgebung kommen; der
env-Block einer settings.json greift dafür nicht (Claude Code 2.1.220 liest
ANTHROPIC_BASE_URL vor den Settings). Der Key kann per apiKeyHelper aus
OpenBao kommen (der Router trimmt den Zeilenumbruch):
ANTHROPIC_BASE_URL=http://llm.lan:4010 claude --model claude-sonnet-5
# settings.json: "apiKeyHelper": "/Users/andreas/.agents/bin/secret get elton litellm key"
Für Claude Desktop (GUI erbt keine Shell-Umgebung): launchctl setenv ANTHROPIC_BASE_URL http://llm.lan:4010 vor dem App-Start; wirkt dann auf alle
Sessions. Testprojekt mit README: ~/src/lab/llmrouter-claude.
Verifiziert 2026-09-05: claude -p --model claude-opus-5 → alt/anthropic/claude-opus-5:high
→ openai/gpt-5.6-sol:xhigh; claude-sonnet-5 → :high → Rückfall auf max → z-ai/glm-5.3-flash;
claude-haiku-4-5-20251001 → inclusionai/ling-3.0-flash. Kosten kommen auch über den
Messages-Endpunkt von OpenRouter mit.
opencode braucht nichts davon: ~/.agents/bin/opencode-normalize erzeugt den
llmlan-Provider aus /v1/models des Routers, Stufen als variants; die Stufe
steckt im Modellnamen. openrouter/ und alt/ nur für die Anbieter in ANBIETER
und nur, wo der Router ein alt/ anbietet (Index bekannt).
Auflösung und Takt
src/resolver.ts löst alle alternative-Aliasse auf und schreibt
log/state.json. OpenRouter-Preise ändern sich oft: alle refreshHours (12)
neu. AA-Indexe ändern sich nie, nur neue Modelle kommen dazu: log/aa-cache.json,
alle aaRefreshDays (7) neu. Der Server frischt selbst auf (beim Start, wenn
veraltet, und im 15-Minuten-Takt geprüft); Wechsel stehen in log/changes.log.
bun run src/resolver.ts --force # jetzt auflösen, Tabelle zeigen
LLMROUTER_LASTTEST=0 bun run src/resolver.ts --force # ohne Last-Probe (Tests; spart ~210k Token je Wechsel)
Der erste Lauf mit leerem State prüft jeden Alias unter Last, das ist gewollt.
Keys und Rechte
key-acls.json(versioniert): Name → erlaubte Aliasse,[]= alles.KEYS_FILE(nur auf dem Host, 0600): Token → Name. Pflege mitKEYS_FILE=… bun run src/keys.ts add <name> [--token <bestehend>]; mit--tokenlässt sich der LiteLLM-Key eines Agenten übernehmen, sodass der Container nichts merkt.- Upstream-Schlüssel aus der Umgebung:
OPENROUTER_API_KEY,MISTRAL_API_KEY. ADMIN_KEYschütztGET /stats?days=N,GET /zuordnung,POST /auffrischen.
Sprechtext-Normalisierung (TTS)
Vor jeder Synthese über /v1/audio/speech läuft input durch
src/sprechtext.ts, für alle TTS-Ziele gleich (lokales Qwen-TTS, Mistral
Voxtral, künftig Fish). Befund 2026-09-06 mit Fish S2 Pro: Beträge
(„1.248,50 Euro") und Daten („14. August 2026") liest das Modell richtig, eine
Postleitzahl nicht, und die Hausnummer davor verschmilzt damit. Regel (Andreas,
Variante 4 von vier gehörten):
Bergstraße 7, 83646 Bad Tölz → Bergstraße 7; Postleitzahl 8 3 6 4 6, Bad Tölz
Ziffern bleiben Ziffern (übersetzbar), mit Leerzeichen getrennt; das Semikolon
erzwingt die Pause, die Ansage macht die Folge eindeutig. Greift nur bei fünf
Ziffern vor einem großgeschriebenen Ortsnamen; Einheiten („83646 Euro"),
Bindestrich-Nummern („2026-83646") und Zahlen ohne Ortsnamen bleiben
unberührt. Tests: bun test im Router-Verzeichnis.
Cloud-TTS über OpenRouter (/api/v1/audio/speech, 18 Modelle, Preis je
Zeichen, alle streamen). Hörtest 2026-09-06 mit dem Mahntext, Andreas' Urteil:
Grok Voice TTS beste Stimme → 42i/marvin-tts-turbo (Stimme ara; Grok kennt
eve, ara, rex, sal, leo; Erstbyte 0,6 s, 32 s Audio in 5,8 s, $15/1M Zeichen).
Fish S2.1 Pro klingt gut, liest aber Daten und Beträge falsch. MAI Voice 2 Flash
(de-DE-Klaus) ist am schnellsten (1,9 s gesamt), Voxtral Mini TTS hat keine
deutsche Stimme. Qwen-Audio-3.0-TTS und MiniMax sperrt der ZDR-Guardrail.
Cloud-STT über OpenRouter (/api/v1/audio/transcriptions, 20 Modelle).
Test 2026-09-06 mit deutschem Satz (Datum, drei Beträge, PLZ, Telefonnummer),
einmal Studio, einmal simulierte Telefonstrecke (8 kHz, Bandpass, Rauschen,
Opus 12 kbit/s). Fehlerfrei in beiden: Voxtral Small 24B ($0,18/h),
gpt-4o-mini-transcribe (~$0,18/h, Token), gpt-4o-transcribe, MAI Transcribe 1.5.
Whisper large-v3-turbo ist im Studio gleich gut und mit $0,011/h das billigste,
verliert am Telefon aber 3 von 10 Zahlenstellen (19 % WER) — das ist das Modell
hinter 42i/marvin-stt (lokal, Vulkan) und deizo/kira-stt. Deshalb
42i/marvin-stt-turbo → Voxtral Small 24B. Testskripte und Audio: Scratchpad
der Session, Referenztext in der Config-Doku des Alias.
Log und Statistik
Je Anfrage eine Zeile in log/requests.jsonl und in log/requests.sqlite:
Key, angefragter Name, aufgelöster Alias, tatsächliches Modell, Effort, Pfad,
Status, Dauer, Tokens (prompt/completion/cached), Kosten wie von OpenRouter
gemeldet (usage.cost, auch im Stream über den letzten Chunk und über den
Messages-Endpunkt). Lokale Upstreams und die LiteLLM-Durchreiche haben keine
Kosten, das Feld bleibt leer, wir erfinden keine Preise. Die Antwort trägt
x-llmrouter-model, damit ein Client sieht, was wirklich lief.
Vier Wege ins Log
1. Statistik je Tag und Key — vom Mac aus, Admin-Key aus OpenBao
(jessie/llmrouter admin_key):
curl -s "http://llm.lan:4010/stats?days=7" \
-H "Authorization: Bearer $(~/.agents/bin/secret get jessie llmrouter admin_key)" | python3 -m json.tool
2. Aktuelle Zuordnung — konfigurierte Aliasse und alle bisher angefragten
alt/-Referenzen mit Ziel, Effort, Indexen, Preis und Zeitpunkt:
curl -s http://llm.lan:4010/zuordnung \
-H "Authorization: Bearer $(~/.agents/bin/secret get jessie llmrouter admin_key)" | python3 -m json.tool
3. Einzelne Anfragen auf dem Host (LXC 185020, /srv/llmrouter/log):
ssh s18p1.lan 'pct exec 185020 -- tail -20 /srv/llmrouter/log/requests.jsonl'
ssh s18p1.lan 'pct exec 185020 -- cat /srv/llmrouter/log/changes.log' # jeder Modellwechsel der Auflösung
ssh s18p1.lan 'pct exec 185020 -- bash -c "cd /srv/llmrouter && ./llmrouter-stats 7"' # Tagesstatistik als Tabelle
requests.sqlite steht für eigene Abfragen offen (Tabelle requests, Spalten
wie die JSONL-Felder).
4. Dienst-Journal — Start, Auffrischen, abgewiesene Keys (401 mit Key-Länge und Präfix), Upstream-Fehler:
ssh s18p1.lan 'pct exec 185020 -- journalctl -u llmrouter -n 50 --no-pager -o cat'
Manuell auffrischen: POST /auffrischen mit Admin-Key startet den vollen
Lauf (Preise neu, Aliasse mit Last-Probe bei Wechsel, alt/-Einträge neu).
Betrieb
PORT=4010 KEYS_FILE=/srv/llmrouter/keys.json ADMIN_KEY=… OPENROUTER_API_KEY=… bun run src/server.ts
bun run build # dist/llmrouter, ein statisches Linux-Binary
Parallelbetrieb neben LiteLLM (Port 4000) im LXC 185020 auf Port 4010; Caddy
zeigt llm.lan auf 4010, die Umschaltung je Agent ist der Modellname.
Bedient: /v1/chat/completions, /v1/completions, /v1/responses,
/v1/embeddings, /v1/audio/speech, /v1/audio/transcriptions,
/v1/audio/translations, /v1/models (zeigt je Key die erlaubten Aliasse),
/health. Der Präfix /v1 ist optional.