- anthropicModus (alt|openrouter), Normalisierung der IDs (Datum, [1m], Punkte), Client-Effort aus output_config.effort als Stufe, HEAD /api/hello -> 200 - Auswahl: nicht gemessene Stufe faellt auf die naechste gemessene darunter (sonst darueber, sonst Modellzahl) -- Sonnet 5 hat nur max/none - /models: alt/ nur bei bestimmbarer Klasse; opencode-normalize nimmt openrouter/+alt/ genau dann (GLM 5.3 Flash war weggefallen) - README: Abschnitt Claude Code und Claude Desktop Verifiziert per claude -p mit claude-sonnet-5, claude-opus-5, claude-haiku-4-5-20251001. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
162 lines
8.4 KiB
Markdown
162 lines
8.4 KiB
Markdown
# 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.
|
||
|
||
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. Mistral/OpenRouter, fest |
|
||
| `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 |
|
||
| `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
|
||
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).
|
||
|
||
**Rollen** entkoppeln Agenten von Modellnamen: ein Agent ruft `role/worker`,
|
||
welches Modell dahintersteht, stellt man in `config.json` um, ohne Container.
|
||
Vorbelegung: xo/architect/teamleader/lead → `42i/marvin-2609:high`, alle
|
||
anderen → `:medium`.
|
||
|
||
**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
|
||
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`.
|
||
|
||
## Log und Statistik
|
||
|
||
Je Anfrage eine Zeile in `log/requests.jsonl` und in `log/requests.sqlite`:
|
||
Key, Alias, angefragter Name, tatsächliches Modell, Effort, Tokens
|
||
(prompt/completion/cached), **Kosten wie von OpenRouter gemeldet**
|
||
(`usage.cost`, auch im Stream über den letzten Chunk). Lokale Upstreams haben
|
||
keine Kosten, das Feld bleibt leer, wir erfinden keine Preise.
|
||
|
||
```bash
|
||
bun run src/stats.ts 7 # Tag × Key: Anfragen, Tokens, Kosten, Modelle
|
||
curl -H "Authorization: Bearer $ADMIN_KEY" http://llm.lan:4010/stats?days=7
|
||
```
|
||
|
||
Die Antwort trägt `x-llmrouter-model`, damit ein Client sieht, was wirklich lief.
|
||
|
||
## 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.
|