Doku: Einstiegs-README, technische Referenz, Betriebsstand

README als Einstieg mit Docker-Schnellstart; die bisherige README lebt als
docs/referenz.md weiter, der Betriebsstand ist aus info nach docs/betrieb.md
portiert.
This commit is contained in:
2026-09-08 09:48:13 +02:00
parent 1f99b73161
commit 000b244541
3 changed files with 524 additions and 227 deletions
+41 -227
View File
@@ -1,243 +1,57 @@
# llmrouter # llmrouter
Leichtgewichtiger Ersatz für den LiteLLM-Gateway `llm.lan` (siehe `../llmlite`). Leichtgewichtiger OpenAI-kompatibler Reverse-Proxy in Bun/TypeScript, der vor
Ein OpenAI-kompatibler Reverse-Proxy in Bun/TypeScript mit genau vier den Upstreams sitzt und `llm.lan` bedient (seit 2026-09-05, Nachfolger des
Eingriffen je Anfrage: Client-Key prüfen, Alias auf ein Modell auflösen, LiteLLM-Gateways). Er kennt vier Eingriffe je Anfrage: Client-Key prüfen,
Usage und Kosten aus der Antwort mitlesen, eine Log-Zeile schreiben. 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.
Keine Format-Übersetzung, keine Datenbank, kein Admin-UI. Alle Upstreams Seit 2026-09-08 lebt das Projekt hier in `spine/llmrouter` (vorher
sprechen OpenAI-Format (OpenRouter, llama-server, Ollama, Mistral), deshalb Unterverzeichnis `llmrouter/` im Repo `42i/agents`).
reicht Durchreichen. Entscheidung Andreas, 2026-09-05.
## Namensräume ## Dokumentation
| Name | Bedeutung | - [docs/referenz.md](docs/referenz.md) — Namensräume, Auflösung, Claude-Code-
|---|---| Anschluss, Keys, Sprechtext-Normalisierung, Log-Wege: die technische
| `42i/marvin-2609:low\|medium\|high\|max` | konfigurierte Stufen (`aliases`, Art `schwelle`: Index ≥ 25/45/49/53, Kontext ≥ 256k/256k/500k/500k) | Referenz.
| `42i/marvin-2609-core:none\|medium` | lokales Qwen, fest | - [docs/betrieb.md](docs/betrieb.md) — wo der Router läuft (LXC 185020,
| `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 | `llm.lan`), Deploy-Runbook, Störungstabelle, Verlauf.
| `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 |
| `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 ## Schnellstart (Docker)
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 ```sh
Last-Probe) und dann im 12-h-Takt mit erneuert. Die konfigurierten `42i/*`-Stufen cp config.json key-acls.json data/
fahren bei einem Wechsel zusätzlich die Last-Probe (78k Kontext, dreimal). cp .env.example .env # Upstream-Keys eintragen (Quelle: OpenBao)
docker compose up -d
**Profile** (`auto/`, 42i/intern#1252) sind Schwellen wie die `42i/*`-Stufen, curl -s http://localhost:4010/health
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.
**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`, worker/engineer/qa/perf → `auto/coding:junior`, 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, 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):
```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 Alle Caches und Einstellungen liegen unter `./data` (siehe
ANTHROPIC_BASE_URL http://llm.lan:4010` vor dem App-Start; wirkt dann auf alle [data/README.md](data/README.md)); das Image selbst enthält nur Code und die
Sessions. Testprojekt mit README: `~/src/lab/llmrouter-claude`. Demo-Konfiguration. Ohne Keys startet der Router trotzdem — `/health` ist
bedienbar, das Auffrischen der Modelllisten scheitert dann graceful.
Verifiziert 2026-09-05: `claude -p --model claude-opus-5``alt/anthropic/claude-opus-5:high` ## Entwicklung
`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 ```sh
`llmlan`-Provider aus `/v1/models` des Routers, Stufen als `variants`; die Stufe bun install # keine Dependencies, nur für Konvention
steckt im Modellnamen. `openrouter/` und `alt/` nur für die Anbieter in `ANBIETER` bun test # Tests (u. a. Sprechtext-Normalisierung)
und nur, wo der Router ein `alt/` anbietet (Index bekannt). bun run start # Router lokal starten
bun run src/resolver.ts --force # Auflösung jetzt fahren, Tabelle zeigen
## Auflösung und Takt bun run build # statisches Linux-Binary nach dist/
`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. Für lokale Läufe ohne Last-Probe (spart ~210k Token je Wechsel):
`LLMROUTER_LASTTEST=0 bun run src/resolver.ts --force`.
## Keys und Rechte Der Router liest `config.json` aus dem Repo-Verzeichnis, wenn
`LLMROUTER_CONFIG` nicht gesetzt ist; Logs landen relativ dazu unter `log/`.
- `key-acls.json` (versioniert): Name → erlaubte Aliasse, `[]` = alles. ## CI
- `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) `.gitea/workflows/ci.yml` nach dem Muster der live-Workflows: `bun test` und
Build je Push/PR, und jeder Push nach `main` published
Vor jeder Synthese über `/v1/audio/speech` läuft `input` durch `git.42i.org/spine/llmrouter:latest` (plus `sha-<kurz>`-Tag) in die Registry
`src/sprechtext.ts`, für alle TTS-Ziele gleich (lokales Qwen-TTS, Mistral und verifiziert den Pull per `/health`. Tags: `latest` und `sha-<kurz>`, nur
Voxtral, künftig Fish). Befund 2026-09-06 mit Fish S2 Pro: Beträge linux/amd64.
(„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`):
```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=/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.
+239
View File
@@ -0,0 +1,239 @@
# llmrouter — Betrieb (`llm.lan`, seit 2026-09-05)
Der schlanke Nachfolger des LiteLLM-Gateways: ein OpenAI-kompatibler
Reverse-Proxy in Bun/TypeScript. `llm.lan` zeigt auf ihn. Er kennt vier
Eingriffe je Anfrage: Client-Key prüfen, Modellnamen auflösen, Kosten aus der
Antwort mitlesen, eine Log-Zeile schreiben. Keine Datenbank, kein Admin-UI,
keine Format-Übersetzung — alle Upstreams sprechen OpenAI-Format.
> **Wofür wichtig:** Alle Agenten (Spark, Buzz, Klara, Jessie, Inka …),
> opencode auf dem Mac und Claude Code im Terminal sprechen nur diesen
> Endpunkt. Fällt er aus, steht die Agenten-Arbeit. Provider-Keys (OpenRouter,
> Mistral, deizo) liegen nur hier.
>
> Diese Seite ist der Betriebsstand für Störung, Wartung und Nachfolge; die
> technische Referenz steht in [referenz.md](referenz.md).
## Wo läuft was
| Was | Wo |
|---|---|
| Router (`llmrouter`) | LXC **185020** (`llmlite`, `10.18.5.20`) auf `s18p1`, systemd-Unit `llmrouter`, Port **4010** |
| Alter Port **4000** | seit 2026-09-07 von Caddy auf 4010 geleitet (vorher LiteLLM). Hart verdrahtete Clients — Agenten-Images, spark-desktop, ältere Skripte — laufen darüber weiter |
| LiteLLM | **abgeschaltet am 2026-09-07** (`systemctl stop/disable llmlite`). Dateien und Container liegen unangetastet in `/srv/llmlite`, letzte Kosten-Datenbank als `spend-final-20260907.sql.gz` |
| Caddy (`llm.lan`, TLS über `acme.lan`) | derselbe LXC, **eigener oci-Dienst `caddy`** (`/srv/caddy`, systemd-Unit `caddy.service`), `reverse_proxy localhost:4010` |
| Dateien | `/srv/llmrouter/`: Binaries `llmrouter`, `llmrouter-resolve`, `llmrouter-stats`, `llmrouter-keys`; `config.json`, `key-acls.json`, `model-blocklist.json`; `keys.json` (Token → Name, 0600); `.env` (0600); `log/` |
| Docker-Image (Demo/Basis) | `git.42i.org/spine/llmrouter:latest`, gebaut von CI; `docker compose up` mit `./data`-Mount als Demo |
| Lokales Qwen | LLM-Knoten LXC 189080, `10.18.5.30:11507` |
| Sprachdienste lokal | `10.18.5.30:8803` (Qwen3-TTS + whisper.cpp), Ollama `:11434` (Embeddings) |
| Kira im Prod-RZ (deizo) | `10.6.42.111:8000/8001/8002` über den WireGuard-Peer des LXC |
Normalzustand: `curl http://llm.lan:4010/health` liefert `{"ok":true,"zuordnung":"<Zeitstempel>"}`;
der Zeitstempel ist jünger als 12 Stunden.
## Namensräume — was ein Client rufen kann
Die vollständige Tabelle der Namensräume (Stufen, Profile, Rollen, `alt/`,
Claude-Code-Pfad) steht in [referenz.md](referenz.md#namensräume). Kurzfassung:
| Name | Bedeutung |
|---|---|
| `role/<name>` | Rolle (`xo`, `architect`, `teamleader`, `worker`, `qa` …) — **Agenten kennen nur Rollen** |
| `auto/<profil>[:stufe]` | Profile auf drei Achsen: `mass`, `mass:fast`, `allround`, `coding`, `leader` je `:junior`/`:senior` |
| `42i/marvin-2609:low|medium|high|max` | konfigurierte Stufen (Index ≥ 25/45/49/53) |
| `alt/<id>[:effort]` | günstigste Alternative derselben Klasse; aufgelöst bei der ersten Anfrage, dann alle 12 h |
| `openrouter/<id>[:effort]` | 1:1 an OpenRouter |
| `deizo/kira-*`, `42i/marvin-*` | feste Ziele (Prod-RZ, lokal, Cloud-Sprachdienste) |
| alles andere | **404** — kein stiller Fallback mehr seit der LiteLLM-Abschaltung |
Ein Modellwechsel steht in `log/changes.log` (im LXC:
`/srv/llmrouter/log/changes.log`).
## Rollen und Profile — drei Ebenen
Ein Agent nennt **nie** ein Modell. Er fragt seine Rolle, die Rolle zeigt auf
ein Profil, und das Profil sucht sich das billigste Modell über den
geforderten Untergrenzen. Umgestellt wird auf jeder Ebene in `config.json`
ohne Deploy, ohne Container anzufassen (42i/intern#1252).
| Profil | Regel | trifft am 2026-09-07 | $/1M gew. |
|---|---|---|---|
| `auto/mass` | fest: lokales Qwen, Ausweich auf `mass:fast` | qwen36:35b | 0 |
| `auto/mass:fast` | Index ≥ 25 | ling-3.0-flash | 0,27 |
| `auto/allround:junior` | Index ≥ 40 | glm-5.3-flash | 0,95 |
| `auto/allround:senior` | Index ≥ 49 | grok-4.6:high | 26,00 |
| `auto/coding:junior` | Coding ≥ 68, Agentic ≥ 45 | glm-5.3-flash | 0,95 |
| `auto/coding:senior` | Coding ≥ 76, Agentic ≥ 50 | grok-4.6:high | 26,00 |
| `auto/leader:junior` | Index ≥ 45, Agentic ≥ 50 | glm-5.3-flash | 0,95 |
| `auto/leader:senior` | Coding ≥ 74, Agentic ≥ 53 | glm-5.3:max | 15,66 |
Warum drei Achsen und nicht eine Zahl: `gemini-3.8-flash` hat Coding 76,3 und
Agentic **41,2** — es schreibt guten Text und trägt den Werkzeug-Rundlauf nicht.
Ein einzelner Index sieht diesen Unterschied strukturell nicht.
**Rollenbelegung:** `xo`, `teamleader`, `lead``auto/leader:junior`;
`worker`, `engineer`, `qa`, `perf``auto/coding:junior`; alle übrigen →
`auto/allround:junior`; `inka` fest auf `openrouter/openai/gpt-5.6-luna`.
**Keine Rolle steht auf einer Senior-Stufe** (Andreas, 2026-09-07) — hochgestuft
wird einzeln und mit Anlass, nicht vorsorglich.
**Der Ausweich** gilt für Ziele ohne eigenen Rückfall — festes Ziel wie
`auto/mass` (lokales Qwen) oder Schwelle auf einem Ausfall-Modell wie
`auto/mass:fast`. Steht der Knoten, hält eine fremde Arbeit die Karte oder
liefert der Upstream 429/5xx, weicht der Router **einmal** auf den
konfigurierten Ausweich aus; die Log-Zeile trägt dann
`auto/mass->auto/mass:fast`, der Wechsel ist also sichtbar. Die Kette:
`auto/mass``auto/mass:fast` (ling) → `auto/coding:junior`
(glm-5.3-flash) — ling erfüllt die coding:junior-Schwellen strukturell nie,
der Rückfall landet also garantiert auf einem tauglichen Modell. Ohne das
hinge jede Titelzeile jedes Agenten, weil `auto/mass` überall als
`small_model` hängt. Wichtig: Die Ausweich-Auflösung läuft durch die
ACL-Prüfung des aufrufenden Keys — die Container-Personas brauchen die
Kettenziele deshalb in `key-acls.json`, sonst schlägt der Fallback still
fehl (403) und der ursprüngliche Fehler geht durch.
## Wer läuft worauf
| Wer | Rolle | Ziel heute |
|---|---|---|
| agent-spark | `role/engineer` | glm-5.3-flash |
| agent-buzz | `role/ops` | glm-5.3-flash |
| agent-jessie | `role/assistant` | glm-5.3-flash |
| agent-klara | `role/service` | glm-5.3-flash |
| Leiter auf spark-desktop (xo, xo-chat, teamleiter, worker, kanon, thema, diagnose, logbuch) | `role/xo`, `role/teamleader`, `role/worker`, `role/architect`, `role/archivist`, `role/ops` | glm-5.3-flash |
| Inka — Hirn | `role/inka` | **fest** `openai/gpt-5.6-luna` |
| Inka — STT / TTS | `42i/marvin-stt-turbo` / `-tts-turbo` | Voxtral Small / Grok Voice |
| latenztolerante Masse, `small_model` | `auto/mass` | lokales Qwen, 0 $ |
`role/inka` ist bewusst ein fester Modellname statt eines Profils: Die drei
Achsen messen **keine Antwortlatenz**, und am Telefon entscheidet genau die.
Gemessen am 2026-09-07 mit Inkas Zuschnitt (1719k Token Systemprompt, vier
Züge): Luna 0,91,9 s bis zum ersten Wort, `glm-5.3-flash` 1,95,6 s. Der Cache
greift bei beiden; die Zeit geht bei GLM für 70133 Denk-Token drauf, und
Reasoning lässt sich dort nicht abschalten (`"Reasoning is mandatory for this
endpoint"`). Nachmessen: `tools/telefon-latenz.py` in diesem Repo.
## Keys und Rechte
- Client-Token: `keys.json` (Token → Name; im LXC
`/srv/llmrouter/keys.json`, in der Docker-Demo `./data/keys.json`). Die
Werte der Personas liegen in OpenBao `agents/<name>/litellm` Feld `key`; es
sind **dieselben Token wie bei LiteLLM**. Noch ohne Token im Router:
`inka-mx`, `nightjob`, `lab-xo`, `team-copilot` (LiteLLM kennt nur Hashes).
Anlegen:
`KEYS_FILE=keys.json bun run src/keys.ts add <name> [--token <bestehend>]`.
- Rechte: `key-acls.json` (Name → erlaubte Namen, `*` als Präfix-Muster),
versioniert in diesem Repo. Elton und Andreas dürfen `openrouter/*` und
`alt/*`, die Agenten nur ihre Rollen und `:medium`.
- Upstream-Keys in `.env` (LXC: `/srv/llmrouter/.env`): `OPENROUTER_API_KEY`,
`MISTRAL_API_KEY`, `KIRA_REASONING_KEY`, `KIRA_STT_KEY`, `KIRA_TTS_KEY`,
`ARTIFICIALANALYSIS_API_KEY`, `ADMIN_KEY`. Quellen in OpenBao:
`agents/jessie/litellm` (OpenRouter, AA, Kira) und `agents/jessie/llmrouter`
(`admin_key`).
- OpenRouter-Konto: **ZDR-Guardrail bleibt bewusst** (Andreas 05.09.2026).
Alibaba- und Meta-gehostete Modelle sind dadurch nicht wählbar; der Router
baut nur auf der Kontoliste auf und kann nichts wählen, was dagegen verstößt.
## Log und Kosten
Je Anfrage eine Zeile: Key, angefragter Name, aufgelöster Alias, tatsächliches
Modell, Effort, Tokens, **Kosten wie von OpenRouter gemeldet**. Lokale Ziele
haben keine Kosten.
```bash
# Tagesstatistik je 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
# aktuelle Zuordnung aller Stufen und alt/-Referenzen
curl -s http://llm.lan:4010/zuordnung -H "Authorization: Bearer <ADMIN_KEY>" | python3 -m json.tool
# einzelne Anfragen / Modellwechsel / Journal
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'
ssh s18p1.lan 'pct exec 185020 -- journalctl -u llmrouter -n 50 --no-pager -o cat'
```
Die Antwort trägt `x-llmrouter-model`, damit ein Client sieht, was wirklich
lief. Weitere Log-Wege (SQLite, Stats-Binary): [referenz.md](referenz.md).
## Wartung und Deploy
Quelle dieses Projekts ist dieses Repo (`spine/llmrouter`). Der Prod-Einsatz
läuft weiter als statisches Binary im LXC (Stand 2026-09-08); ein Umstieg auf
das Docker-Image ist offen und nicht dringend.
1. Änderung in diesem Repo (Config, Code), committen, pushen. CI baut und
published danach `git.42i.org/spine/llmrouter:latest` — für den
Binary-Weg irrelevant, aber der Stand, den jede Demo zieht.
2. Binaries bauen: `bun run build` (dazu
`bun build --compile --target=bun-linux-x64 src/resolver.ts --outfile dist/llmrouter-resolve`).
3. Ausrollen: `scp` nach `s18p1`, dann `pct push 185020 <datei> /srv/llmrouter/<datei>.neu`,
im LXC `mv`, `chmod 755`, `systemctl restart llmrouter`.
4. Nach Config-Änderungen an Stufen oder `alt/`: `rm /srv/llmrouter/log/state.json`
vor dem Neustart, sonst bleibt die alte Zuordnung. Der Erstlauf fährt für jede
Stufe die Last-Probe (≈210k Token je Stufe).
5. **Caddyfile-Falle:** die Datei ist als Bind-Mount im Container; ein `mv`
tauscht den Inode und `caddy reload` sieht die alte Datei. Nach Änderung
`oci restart caddy`. Quelle: [caddy/Caddyfile](caddy/Caddyfile).
## Störung
| Symptom | Prüfen | Tun |
|---|---|---|
| Agenten bekommen 502/Timeout | `curl http://llm.lan:4010/health`; `systemctl status llmrouter` im LXC | `systemctl restart llmrouter`; Journal lesen |
| `… ist noch nicht aufgelöst (503)` | `/zuordnung` leer oder alt | `POST /auffrischen` mit Admin-Key, oder `rm log/state.json` + Neustart; braucht OpenRouter- und AA-Zugang |
| 401 für einen Agenten | Journal zeigt `401 … (Key n Zeichen)` | Token fehlt in `keys.json` → mit `keys.ts add --token` nachtragen |
| 403 `darf … nicht rufen` | `key-acls.json` | Recht ergänzen (versioniert in diesem Repo), Neustart |
| Zahlen im TTS falsch gesprochen | Sprechtext-Regel greift nur für PLZ/Nummern | Fish über OpenRouter zerlegt deutsche Beträge/Daten — deshalb Grok Voice bis Fish bei Uwe läuft (live/live#2247) |
| `llm.lan` antwortet mit LiteLLM-Fehlern statt Router | Caddy zeigt auf 4000 | Caddyfile prüfen, `oci restart caddy` |
**Rückfall ohne Router:** Caddyfile auf `localhost:4000` stellen und Caddy neu
starten — LiteLLM bedient die `2608`-Namen weiter, die `2609`-, `alt/`- und
`role/`-Namen fehlen dann.
## Zugehörige Werkzeuge
- `~/src/tools/openrouter-models.ts` (git.home `andreas/tools`): zeigt die
Stufentabelle oder die Alternative zu einer Modell-ID (`bun run ./openrouter-models.ts anthropic/claude-opus-5:medium`).
- `~/.agents/bin/opencode-normalize`: erzeugt den `llmlan`-Provider in der
opencode-Config aus `/v1/models` des Routers, Effort-Stufen als `variants`.
- `claude-lan` (Funktion in `~/.zshrc`): Claude Code im Terminal über den
Router; die Desktop-App kann das nicht (sie erzwingt `api.anthropic.com`).
- Auswahl-Regel: [src/openrouter-auswahl.ts](../src/openrouter-auswahl.ts) —
seit 2026-09-08 eine eigenständige Kopie (vendored aus `42i/agents`
`llmlite/`, wo die llmlite-Fassung für den Tier-Updater weiterlebt).
## Verlauf
- 2026-09-08: **Umzug nach `spine/llmrouter`** (git.42i.org). Das komplette
Projekt verließ das Repo `42i/agents` (Historie per subtree split bewahrt);
dazu Docker-Image, Compose-Demo mit `./data`-Mount und Gitea-Actions-CI
nach dem live-Muster. Die Referenz-Doku liegt im Repo (`README.md`,
`docs/`), die Betriebsstand-Seite in `info` bleibt als Zeiger.
- 2026-09-08: **Ausweich-Kette für die Massenarbeit** (42i/agents 4e92f17,
42i/intern#1252). Anlass: ling-3.0-flash fiel am Vorabend aus (23:2023:52,
5× 502 nach ~270 s hängendem Upstream, 117× 429). `auto/mass:fast` hat jetzt
selbst einen Ausweich auf `auto/coding:junior`, und der Ausweich greift auch
bei 429/5xx-Antworten — vorher nur bei fetch-Fehlern und nur für fest-Ziele.
ACLs der Container-Personas um die Kettenziele ergänzt.
- 2026-09-08: **Live-Probe reasoning-tolerant** (42i/agents 312e43c). ling
denkt vor jedem Werkzeugaufruf mit variabler Länge; bei `max_tokens: 400`
scheiterte die Probe willkürlich und kippte `marvin-2609:low` und
`alt/claude-haiku-4.5` auf teurere Modelle (solar-pro4 bzw. glm-5.3-flash),
ohne dass ling defekt war. Budget auf 2000 Token, Timeout auf 90 s.
- 2026-09-07: **LiteLLM abgeschaltet, alles läuft über Rollen.** Alle Agenten
(Container spark/buzz/jessie/klara, die Leiter auf spark-desktop, der XO) rufen
`role/*` statt eines Modellnamens; der XO und das Logbuch kamen dabei vom
Claude-Max-Abo auf opencode. Der `fallback` an LiteLLM ist aus der Config
entfernt — unbekannte Namen sind jetzt 404 statt einer stillen Weiterleitung.
Keine Rolle steht mehr auf der teuren Stufe (42i/intern#1252).
- 2026-09-07: **Caddy aus dem LiteLLM-Stack gelöst** und eigener oci-Dienst
(`/srv/caddy`, Quelle `llmrouter/caddy/`, ausgerollt über
`agents-pull`). Vorher hing er in `llmlite/compose.yml` — ein `podman-compose
down` für LiteLLM hätte die TLS-Terminierung von `llm.lan` mitgenommen und den
Router von außen unerreichbar gemacht, obwohl er läuft. Vorarbeit zur
Abschaltung von LiteLLM (42i/intern#1252).
- 2026-09-05: Router gebaut und in Betrieb, `llm.lan` umgestellt; Stufen
25/45/49/53; Namensräume `openrouter/`, `alt/`, `role/`, `deizo/`; Claude-Code-Pfad.
- 2026-09-06: Sprechtext-Normalisierung (Postleitzahl); TTS-Turbo auf Grok
Voice `ara`, STT-Turbo auf Voxtral Small 24B nach Hör- und Telefontest;
Auftrag an Uwe für Fish S2 Pro und Voxtral Small im Prod-RZ (live/live#2247).
+244
View File
@@ -0,0 +1,244 @@
# 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 |
| `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.
**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`, worker/engineer/qa/perf → `auto/coding:junior`, 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.