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
250 lines
14 KiB
Markdown
250 lines
14 KiB
Markdown
# 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-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 <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`):
|
|
|
|
```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.
|