Files

21 KiB
Raw Permalink Blame History

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.

Wo läuft was

Was Wo
Router (llmrouter) LXC 185020 (llmlite, 10.18.5.20) auf s18p1, OCI-Container llmrouter_llmrouter_1 (podman-compose, systemd-Unit llmrouter), Bild git.42i.org/spine/llmrouter:latest, 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/: compose.yml, .env (0600); Laufzeit unter data/: config.json, key-acls.json, keys.json (0600), model-blocklist.json, log/ (requests.jsonl, changes.log, state.json, aa-cache.json). Sidecar-Binaries daneben, werden nicht mehr gebaut (Ersatz: podman run aus dem Image, siehe Deploy)
Docker-Image (Prod) git.42i.org/spine/llmrouter:latest, gebaut von CI — seit 2026-09-09 läuft der Prod-Einsatz darauf
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. 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
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/data/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, Output ≥ 32768 (je Profil senkbar, sonst 65536) 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, leadauto/leader:junior; workerauto/mass:fast; coder, engineer, qa, perfauto/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. role/coder existiert seit 2026-09-09 neben engineer, damit Code-Arbeit nicht über role/worker (Masse) läuft.

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/massauto/mass:fast (ling) → auto/coding:junior (glm-5.3-flash) — ling erfüllt die coding:junior-Schwellen strukturell nie, der Rückfall landet also garantiert auf einem tauglichen Modell. Ohne das hinge jede Titelzeile jedes Agenten, weil auto/mass überall als small_model hängt. Wichtig: Die Ausweich-Auflösung läuft durch die ACL-Prüfung des aufrufenden Keys — die Container-Personas brauchen die Kettenziele deshalb in key-acls.json, sonst schlägt der Fallback still fehl (403) und der ursprüngliche Fehler geht durch.

Wer läuft worauf

Wer Rolle Ziel heute
agent-spark role/engineer glm-5.3-flash
agent-buzz role/ops glm-5.3-flash
agent-jessie role/assistant glm-5.3-flash
agent-klara role/service glm-5.3-flash
Leiter auf spark-desktop (xo, xo-chat, teamleiter, worker, kanon, thema, diagnose, logbuch) role/xo, role/teamleader, role/worker, role/architect, role/archivist, role/ops glm-5.3-flash
Inka — Hirn role/inka fest openai/gpt-5.6-luna
Inka — STT / TTS 42i/marvin-stt-turbo / -tts-turbo Voxtral Small / Grok Voice
latenztolerante Masse, small_model auto/mass lokales Qwen, 0 $

role/inka ist bewusst ein fester Modellname statt eines Profils: Die drei Achsen messen keine Antwortlatenz, und am Telefon entscheidet genau die. Gemessen am 2026-09-07 mit Inkas Zuschnitt (1719k Token Systemprompt, vier Züge): Luna 0,91,9 s bis zum ersten Wort, glm-5.3-flash 1,95,6 s. Der Cache greift bei beiden; die Zeit geht bei GLM für 70133 Denk-Token drauf, und Reasoning lässt sich dort nicht abschalten ("Reasoning is mandatory for this endpoint"). Nachmessen: tools/telefon-latenz.py in diesem Repo.

Keys und Rechte

  • Client-Token: keys.json (Token → Name; im LXC /srv/llmrouter/data/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.

# 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/data/log/requests.jsonl'
ssh s18p1.lan 'pct exec 185020 -- cat /srv/llmrouter/data/log/changes.log'
ssh s18p1.lan 'pct exec 185020 -- podman logs -l -n 50'

Die Antwort trägt x-llmrouter-model, damit ein Client sieht, was wirklich lief. Weitere Log-Wege (SQLite, Stats-Binary): referenz.md.

Wartung und Deploy

Quelle dieses Projekts ist dieses Repo (spine/llmrouter). Der Prod-Einsatz läuft seit dem 2026-09-09 als OCI-Container im LXC (podman-compose hinter der Unit llmrouter, Bild git.42i.org/spine/llmrouter:latest, Laufzeitdaten unter /srv/llmrouter/data/). Der alte Binary-Weg ist eingestellt — Server-Updates nur noch über das CI-Image; die Sidecar-Binaries (resolve, stats, keys) werden nicht mehr gebaut, dafür läuft das Werkzeug aus dem Image (siehe Schritt 3).

  1. Änderung in diesem Repo (Config, Code), committen, pushen. CI baut und published danach git.42i.org/spine/llmrouter:latest.
  2. Im LXC Bild ziehen und Unit neu starten:
    ssh s18p1.lan 'pct exec 185020 -- podman pull git.42i.org/spine/llmrouter:latest'
    ssh s18p1.lan 'pct exec 185020 -- systemctl restart llmrouter'
    
    Nach Config-Änderungen an Stufen oder alt/: rm /srv/llmrouter/data/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).
  3. Sidecar-Werkzeuge ohne Binary — Beispiel Tagesstatistik:
    ssh s18p1.lan "pct exec 185020 -- podman run --rm --env-file /srv/llmrouter/.env \
      -e LLMROUTER_CONFIG=/data/config.json -e KEYS_FILE=/data/keys.json \
      -v /srv/llmrouter/data:/data git.42i.org/spine/llmrouter:latest bun src/stats.ts 7"
    
    (analog src/keys.ts, src/resolver.ts)
  4. Rollback auf die Binary (Notfall): podman-compose down im /srv/llmrouter, dann die gesicherte Unit zurück (cp /etc/systemd/system/llmrouter.service.binary-bak /etc/systemd/system/llmrouter.service, systemctl daemon-reload, systemctl enable --now llmrouter) — die flachen Dateien und log/ liegen unverändert daneben.
  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.

Umstieg aufs Image (geprüft am 2026-09-08, vollzogen am 2026-09-09)

Voraussetzungen alle erfüllt: podman 5.4.2 im LXC (kein Docker nötig — Muster wie caddy.service: podman-compose + systemd-oneshot), das Image ist öffentlich pullbar (anonym, wie 42i/caddy), Multi-Arch enthält amd64. Layout wandert vorab ins Hausmuster: alles Laufzeitige unter ./data (siehe data/README.md), der Compose-Dienst mountet ./data:/data. Einmalig migrieren — Kopie, nicht Verschieben, damit die Binary-Unit bis zum Umschalten lauffähig bleibt:

cd /srv/llmrouter
mkdir data
cp config.json key-acls.json keys.json model-blocklist.json data/
cp -a log data/log        # state.json + aa-cache.json wandern mit

cp -a log ist der entscheidende Schritt: state.json (Zuordnung) und aa-cache.json (AA-Index-Cache) liegen im logDir. Mit ihnen startet der Container sofort mit frischer Zuordnung; ohne sie gibt es eine Startlücke (~40 s bei LLMROUTER_LASTTEST=0, sonst ≈210k Token), in der Stufen mit 503 antworten („noch nicht aufgelöst").

services:
  llmrouter:
    image: git.42i.org/spine/llmrouter:latest
    env_file: [.env]
    environment:
      # .env trägt die Host-Pfade — im Container auf /data übersteuern:
      LLMROUTER_CONFIG: /data/config.json
      LLMROUTER_ACLS: /data/key-acls.json
      KEYS_FILE: /data/keys.json
      MODEL_BLOCKLIST: /data/model-blocklist.json
    ports: ["127.0.0.1:4010:4010"]   # Caddy zeigt weiter auf localhost:4010
    volumes: ["./data:/data"]
    restart: unless-stopped
  • logDir steht in config.json auf ./log — relativ zur Config aufgelöst, im Container also /data/log, ohne Config-Änderung.
  • Sidecar-Binaries (llmrouter-resolve, -stats, -keys) bleiben auf dem LXC; nach dem Umstieg mit den data/-Pfaden aufrufen (KEYS_FILE=/srv/llmrouter/data/keys.json …). Die alten flachen Dateien (config.json, keys.json, die .bak-Kopien) können erst aufgeräumt werden, wenn der Umstieg hält.
  • Ablauf: compose-Datei + Unit (Muster caddy.service) anlegen → podman-compose up -d/health prüfen (Zeitstempel aktuell) → systemctl stop/disable llmrouter. Rollback: Binary-Unit wieder aktivieren, Container stoppen.
  • Updates danach: podman pull + Container neu starten; CI published :latest (und :sha-<sha>) bei jedem Push auf main (paths-Filter — Doku-Commits bauen nicht).

Prüfstand 2026-09-08 (Probelauf auf dem LXC, Produktion unberührt, Port 4011, Log-Dir nach /tmp ausgelagert): Image startet mit der Produktions-Config (25 Aliasse, 21 Rollen, 12 Token), /models byte-identisch zur Binary, Chat via /chat/completions 200 über beide Wege (42i/marvin-2609:mediumz-ai/glm-5.3-flash), komplette Zuordnungs-Auffrischung im Container funktioniert. Der Mount ist hostseitig — ./data:/data ergibt dem Container gegenüber denselben /data-Inhalt wie der getestete Mount. Container und Image danach rückstandsfrei entfernt.

Störung

Symptom Prüfen Tun
Agenten bekommen 502/Timeout curl http://llm.lan:4010/health; podman ps im LXC systemctl restart llmrouter; podman logs -l
… ist noch nicht aufgelöst (503) /zuordnung leer oder alt POST /auffrischen mit Admin-Key, oder rm /srv/llmrouter/data/log/state.json + Neustart; braucht OpenRouter- und AA-Zugang
401 für einen Agenten podman logs -l zeigt 401 … (Key n Zeichen) Token fehlt in data/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 — 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-09: Output-Gate je Profil senkbar, neue Rolle coder. Das 64K-Output-Gate (MIN_OUTPUT) trieb auto/mass:fast auf solar-pro4 (0,42 $/1M), weil ling mit 32768 Output-Token durchfiel — gemessen 09.09.: 1.415 Masse-Requests, 1.146 davon unter 100 Completion-Token; das Gate verfehlte dort seinen Zweck und kostete ~35 % Aufschlag. Jetzt ist minOutput pro Profil in config.json setzbar (Default bleibt 65536), auto/mass:fast läuft mit 32768. Dazu role/coderauto/coding:junior neben engineer, ACL für persona-spark, der engineer-Agent in der Mac-opencode.json von role/worker auf role/coder umgezogen. Deploy über CI-Image (Run #8) + podman pull + Neustart, Configs nach data/ gesynct, state.json gelöscht und frisch aufgelöst: auto/mass:fast → ling-3.0-flash wiederhergestellt (0,27 $/1M, Referenz trägt Output>=32768), role/coder in /v1/models, Chat je Rolle 200. Seit dem Image-Betrieb antwortet Port 4010 nur auf 127.0.0.1 des LXC — von außen nur noch über Caddy (443/4000).

  • 2026-09-09: Umstieg aufs Image vollzogen. Prod läuft als OCI-Container (llmrouter_llmrouter_1, podman-compose hinter der Unit llmrouter, Bild :latest von Run #7/e144034). Laufzeitdaten wanderten nach /srv/llmrouter/data/ (Hausmuster ./data:/data), state.json und aa-cache.json mitkopiert — Zuordnung unverändert, keine Auffrischung. Verifiziert: /health lokal und über https://llm.lan 200, Chat 200. Der Binary-Deploy-Weg (bun build, scp, pct push) ist eingestellt; Updates nur noch über CI-Image + podman pull + Neustart. Unit-Rollback liegt als llmrouter.service.binary-bak.

  • 2026-09-08: Umstieg aufs Image geprüft. Probelauf des CI-Images auf dem LXC neben dem Binary (Port 4011): Konfig-/ACL-/Keys-Parität (/models byte-identisch), Chat 200 über beide Wege, Zuordnungs-Refresh läuft im Container. Rezept und Fallstricke stehen jetzt in Wartung und Deploy; Prod läuft weiter auf der Binary. Dazu Registry-Login-Fix in der CI (vertauschte Secret-Werte, 401er am Vormittag) und Login-Kommentar korrigiert.

  • 2026-09-08: Umzug nach spine/llmrouter (git.42i.org). Das komplette Projekt verließ das Repo 42i/agents (Historie per subtree split bewahrt); dazu Docker-Image, Compose-Demo mit ./data-Mount und Gitea-Actions-CI nach dem live-Muster. Die Referenz-Doku liegt im Repo (README.md, docs/), die Betriebsstand-Seite in info bleibt als Zeiger.

  • 2026-09-08: Ausweich-Kette für die Massenarbeit (42i/agents 4e92f17, 42i/intern#1252). Anlass: ling-3.0-flash fiel am Vorabend aus (23:2023:52, 5× 502 nach ~270 s hängendem Upstream, 117× 429). auto/mass:fast hat jetzt selbst einen Ausweich auf auto/coding:junior, und der Ausweich greift auch bei 429/5xx-Antworten — vorher nur bei fetch-Fehlern und nur für fest-Ziele. ACLs der Container-Personas um die Kettenziele ergänzt.

  • 2026-09-08: Live-Probe reasoning-tolerant (42i/agents 312e43c). ling denkt vor jedem Werkzeugaufruf mit variabler Länge; bei max_tokens: 400 scheiterte die Probe willkürlich und kippte marvin-2609:low und alt/claude-haiku-4.5 auf teurere Modelle (solar-pro4 bzw. glm-5.3-flash), ohne dass ling defekt war. Budget auf 2000 Token, Timeout auf 90 s.

  • 2026-09-07: LiteLLM abgeschaltet, alles läuft über Rollen. Alle Agenten (Container spark/buzz/jessie/klara, die Leiter auf spark-desktop, der XO) rufen role/* statt eines Modellnamens; der XO und das Logbuch kamen dabei vom Claude-Max-Abo auf opencode. Der fallback an LiteLLM ist aus der Config entfernt — unbekannte Namen sind jetzt 404 statt einer stillen Weiterleitung. Keine Rolle steht mehr auf der teuren Stufe (42i/intern#1252).

  • 2026-09-07: Caddy aus dem LiteLLM-Stack gelöst und eigener oci-Dienst (/srv/caddy, Quelle llmrouter/caddy/, ausgerollt über agents-pull). Vorher hing er in llmlite/compose.yml — ein podman-compose down für LiteLLM hätte die TLS-Terminierung von llm.lan mitgenommen und den Router von außen unerreichbar gemacht, obwohl er läuft. Vorarbeit zur Abschaltung von LiteLLM (42i/intern#1252).

  • 2026-09-05: Router gebaut und in Betrieb, llm.lan umgestellt; Stufen 25/45/49/53; Namensräume openrouter/, alt/, role/, deizo/; Claude-Code-Pfad.

  • 2026-09-06: Sprechtext-Normalisierung (Postleitzahl); TTS-Turbo auf Grok Voice ara, STT-Turbo auf Voxtral Small 24B nach Hör- und Telefontest; Auftrag an Uwe für Fish S2 Pro und Voxtral Small im Prod-RZ (live/live#2247).