diff --git a/README.md b/README.md index 93cef86..0b1a826 100644 --- a/README.md +++ b/README.md @@ -1,243 +1,57 @@ # 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. +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). Er kennt vier Eingriffe 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. -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. +Seit 2026-09-08 lebt das Projekt hier in `spine/llmrouter` (vorher +Unterverzeichnis `llmrouter/` im Repo `42i/agents`). -## Namensräume +## Dokumentation -| 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 | -| `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 | Durchreiche an LiteLLM (`fallback`), unverändert mit Client-Key | +- [docs/referenz.md](docs/referenz.md) — Namensräume, Auflösung, Claude-Code- + Anschluss, Keys, Sprechtext-Normalisierung, Log-Wege: die technische + Referenz. +- [docs/betrieb.md](docs/betrieb.md) — wo der Router läuft (LXC 185020, + `llm.lan`), Deploy-Runbook, Störungstabelle, Verlauf. -**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. +## Schnellstart (Docker) -`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, 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" +```sh +cp config.json key-acls.json data/ +cp .env.example .env # Upstream-Keys eintragen (Quelle: OpenBao) +docker compose up -d +curl -s http://localhost:4010/health ``` -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`. +Alle Caches und Einstellungen liegen unter `./data` (siehe +[data/README.md](data/README.md)); das Image selbst enthält nur Code und die +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` -→ `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. +## Entwicklung -**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) +```sh +bun install # keine Dependencies, nur für Konvention +bun test # Tests (u. a. Sprechtext-Normalisierung) +bun run start # Router lokal starten +bun run src/resolver.ts --force # Auflösung jetzt fahren, Tabelle zeigen +bun run build # statisches Linux-Binary nach dist/ ``` -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. -- `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`. +## CI -## 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`): - -```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. +`.gitea/workflows/ci.yml` nach dem Muster der live-Workflows: `bun test` und +Build je Push/PR, und jeder Push nach `main` published +`git.42i.org/spine/llmrouter:latest` (plus `sha-`-Tag) in die Registry +und verifiziert den Pull per `/health`. Tags: `latest` und `sha-`, nur +linux/amd64. diff --git a/docs/betrieb.md b/docs/betrieb.md new file mode 100644 index 0000000..678b369 --- /dev/null +++ b/docs/betrieb.md @@ -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":""}`; +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/` | Rolle (`xo`, `architect`, `teamleader`, `worker`, `qa` …) — **Agenten kennen nur Rollen** | +| `auto/[: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/[:effort]` | günstigste Alternative derselben Klasse; aufgelöst bei der ersten Anfrage, dann alle 12 h | +| `openrouter/[: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 (17–19k Token Systemprompt, vier +Züge): Luna 0,9–1,9 s bis zum ersten Wort, `glm-5.3-flash` 1,9–5,6 s. Der Cache +greift bei beiden; die Zeit geht bei GLM für 70–133 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//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 [--token ]`. +- 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 " | 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 /srv/llmrouter/.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:20–23: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). diff --git a/docs/referenz.md b/docs/referenz.md new file mode 100644 index 0000000..df19258 --- /dev/null +++ b/docs/referenz.md @@ -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/[: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 | +| `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. + +**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 [--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.