Files
llmrouter/docs/referenz.md
T
elton 7c6e747145
llmrouter CI / bun test + build (push) Successful in 4s
llmrouter CI / Build and publish image (push) Successful in 21s
minOutput je Profil senkbar, neue Rolle coder
Das 64K-Output-Gate war global und trieb auto/mass:fast auf solar-pro4
(0,42 statt 0,27 $/1M), weil ling-3.0-flash mit 32768 Output-Token
durchfiel. Gemessen 09.09.: 1.415 Masse-Requests, 1.146 davon unter 100
Completion-Token — das Gate verfehlte dort seinen Zweck. Jetzt kann je
Schwelle-Profil minOutput gesetzt werden (Default 65536);
auto/mass:fast laeuft mit 32768, max_output unbekannt bleibt abgelehnt.

Dazu role/coder -> auto/coding:junior (ACL fuer persona-spark), damit
Code-Arbeit nicht ueber role/worker in die Masse faellt; der
engineer-Agent der Mac-opencode.json wechselt auf role/coder.

Refs #1283
2026-09-09 17:03:51 +02:00

14 KiB

llmrouter — technische Referenz

Leichtgewichtiger OpenAI-kompatibler Reverse-Proxy in Bun/TypeScript, der vor den Upstreams sitzt und llm.lan bedient (seit 2026-09-05, Nachfolger des LiteLLM-Gateways). 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
auto/<profil>[:stufe] Profil auf drei Achsen (mass, mass:fast, allround, coding, leader je :junior/:senior) — billigstes Modell über den geforderten Untergrenzen; minOutput senkt das 64K-Output-Gate je Profil
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 404 — unbekannte Namen sind ein Fehler, keine stille Weiterleitung (die LiteLLM-Durchreiche ist mit deren Abschaltung 2026-09-07 entfallen)

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).

Profile (auto/, 42i/intern#1252) sind Schwellen wie die 42i/*-Stufen, nur auf bis zu drei Achsen statt einer: index (Intelligence), coding und agentic, jede optional. Das trennt Fälle, die eine einzelne Zahl nicht sieht — gemini-3.8-flash hat Coding 76,3 und Agentic 41,2 und trägt damit Text, aber keinen Werkzeug-Rundlauf. auto/mass ist der Sonderfall: fest auf dem lokalen Qwen (kostet nichts) und mit ausweich auf auto/mass:fast, falls der Knoten steht — ohne den hinge sonst jede Titelzeile jedes Agenten, weil auto/mass überall als small_model hängt. Über die Achsen hinaus kann ein Profil das 64K-Output-Gate je Stufe senken: minOutput (Default 65536, Andreas 2026-09-09). auto/mass:fast läuft mit 32768 — Masse-Arbeit schreibt fast nur kleine Completion-Häufchen, das Gate trieb sie sonst un nötig auf upstage/solar-pro4; max_output unbekannt bleibt abgelehnt.

Rollen entkoppeln Agenten von Modellnamen: ein Agent ruft role/worker, welches Modell dahintersteht, stellt man in config.json um, ohne Container. Seit 2026-09-07 zeigen alle Rollen auf auto/-Profile: xo/teamleader/lead → auto/leader:junior, coder/engineer/qa/perf → auto/coding:junior, worker → auto/mass:fast, der Rest → auto/allround:junior, inka fest auf Luna. Keine Rolle steht auf einer Senior-Stufe (Andreas: hochstufen einzeln und mit Anlass).

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.

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-5alt/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-5alt/anthropic/claude-opus-5:highopenai/gpt-5.6-sol:xhigh; claude-sonnet-5:high → Rückfall auf max → z-ai/glm-5.3-flash; claude-haiku-4-5-20251001inclusionai/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 mit KEYS_FILE=… bun run src/keys.ts add <name> [--token <bestehend>]; mit --token lä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_KEY schützt GET /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 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=/data/keys.json ADMIN_KEY=OPENROUTER_API_KEY=… bun run src/server.ts
bun run build      # dist/llmrouter, ein statisches Linux-Binary

Der Produktionseinsatz (LXC 185020, llm.lan, Caddy, Deploy-Runbook, Störung) steht in betrieb.md.

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.