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
|
||||
|
||||
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/<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 |
|
||||
- [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 <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`.
|
||||
## 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-<kurz>`-Tag) in die Registry
|
||||
und verifiziert den Pull per `/health`. Tags: `latest` und `sha-<kurz>`, nur
|
||||
linux/amd64.
|
||||
|
||||
+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