# 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/[:effort]` | 1:1 an OpenRouter, Effort als `reasoning.effort` | | `alt/[:effort]` | **günstigste Alternative derselben Klasse** wie `` auf dieser Stufe | | `auto/[: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/` | Rolle aus `rollen`, zeigt auf einen der obigen Namen | | `claude-*` (nackte Anthropic-ID) | Claude Code/Desktop: wird zu `alt/anthropic/[: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-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): ```bash 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`. ```bash 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 [--token ]`; 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`): ```bash 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: ```bash 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`): ```bash 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: ```bash 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 ```bash 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.