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:
@@ -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
@@ -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 (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/<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: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).
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user