managarten/docs/plans/llm-fallback-aliases.md
Till JS 7766ea5021 docs(plans): mark llm-fallback-aliases SHIPPED, add M-by-M commit table
All 5 milestones landed today in one continuous session: registry,
health cache, fallback router, observability, and consumer migration.
115 service-side tests, validator covers 2538 files.
2026-04-26 21:27:57 +02:00

250 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# LLM-Fallback via Model-Aliases — Plan
_Drafted 2026-04-26. **Status: SHIPPED** (M1M5 alle gemerged am 2026-04-26)._
Auslöser: Schreiben-Modul-Generation-Ausfall (GPU-Server offline → 75 s Hang → mana-llm 500). Lösung: in einem Tag in fünf Schritten gebaut + getestet (115 Tests grün).
| M | Commit | Geliefert |
|---|---|---|
| M1 | `dff8629e1` | `AliasRegistry` + `aliases.yaml` SSOT, 32 Tests |
| M2 | `59557e62d` | `ProviderHealthCache` + `HealthProbe`, 32 Tests |
| M3 | `3046da3b1` | `_execute_with_fallback`, Streaming pre-first-byte, 22 Tests, alle Legacy-Fallback-Pfade purged |
| M4 | `8a49e3ffd` | `X-Mana-LLM-Resolved`-Header, 3 Prometheus-Metriken, `/v1/aliases` + `/v1/health` Endpoints, SIGHUP-Reload, 16 Tests |
| M5 | `fea3adf5f` | 14 Consumer-Sites migriert, SSOT in `@mana/shared-ai`, `validate-llm-strings.mjs` Drift-Gate über 2538 Files |
**Status der "Open Questions" am Ende:** alle drei dokumentiert geblieben (kein Showstopper für Phase 1):
- 429-Rate-Limit kürzeres Backoff: aktuell wie ConnectError behandelt; Refinement bei Bedarf.
- Alias-Versionierung: nicht nötig solange Reload atomar bleibt.
- mana-credits Modell→Preis-Tabelle: bei nächster Credits-Code-Änderung prüfen.
Macht die LLM-Pipeline resilient gegen Provider-Ausfälle (heute: GPU-Server `mana-gpu` offline, Ollama unerreichbar; morgen: Groq-API-Limit, Anthropic-Outage, …). Statt jedem Consumer eine Retry-Logik mit konkreten Modell-Strings beizubringen, gibt mana-llm zukünftig **Model-Aliases** aus, die Health-Cache-bewusst auf eine Provider-Chain auflösen. Consumer-Code kennt nur noch `mana/long-form`, nicht mehr `ollama/gemma3:12b`.
**Hintergrund:**
- [`services/mana-llm/CLAUDE.md`](../../services/mana-llm/CLAUDE.md) — heutige Provider-Routing-Architektur (Ollama / OpenRouter / Groq / Together / Google), OpenAI-kompatible API
- Konkreter Bug: `services/mana-llm` Logs vom 17:48 → `Chat completion failed on ollama: Server disconnected without sending a response.` nach 75 s, ausgelöst durch toten GPU-Server. Generation-Endpoint in `apps/api/src/modules/writing/routes.ts` hat 500 zurückgegeben.
- Verwandt: [`docs/plans/agent-loop-improvements-m1.md`](./agent-loop-improvements-m1.md) (mana-ai consumiert mana-llm intensiv und ist heute genauso fragil)
## Ziel in einem Satz
Ein Consumer schickt einen semantischen Alias (`"model": "mana/long-form"`); mana-llm löst auf eine Provider-Chain auf, überspringt unhealthy Provider auf Basis eines kontinuierlichen Health-Cache, ruft den ersten verfügbaren auf, und liefert die Antwort plus einen `X-Mana-LLM-Resolved`-Header mit dem tatsächlich genutzten Modell.
## Nicht-Ziele
- **Latenz-/Kosten-optimierte Provider-Auswahl.** Kein "wenn Groq billiger ist, nimm Groq". Erstes _healthy_ Glied der Chain gewinnt — Reihenfolge ist die Strategie, nichts anderes. Die Chain definiert auch die Präferenz.
- **Per-User-Routing.** User A kriegt nicht ein anderes Modell als User B. Alias → Chain ist global. Per-User-Auswahl gehört in die Frontend-Layer (z.B. ein expliziter Modell-Picker im Schreiben-Modul), nicht ins Routing.
- **Mid-Stream-Fallback.** Wenn ein SSE-Stream nach dem ersten Byte abbricht, schlagen wir hart fehl. Provider-Wechsel mitten im Stream + Output-Verschmelzung wäre eine Quelle subtiler Korruption (zwei Modelle, zwei Tonarten, zwei Halluzinationen). Pre-First-Byte-Fallback ist sauber, mid-stream nicht.
- **A/B-Testing zwischen Modellen.** Außerhalb des Scopes. Falls später gewünscht, eigener Mechanismus mit Cohort-Stickiness.
- **Verbundene Quotas / Token-Budgets.** Alias-Layer kennt keine User-Tier-/Credits-Logik — das passiert weiter in den Consumern (Credits-Service-Calls in `mana-credits`).
- **Caller-spezifische Overrides** ("Schreiben-Modul will heute mal `groq/llama-3.3-70b` direkt"). Direkter Modell-String bleibt im Request akzeptiert (Backwards-Compat _innerhalb_ mana-llm-API), aber kein Code im Repo benutzt das mehr — `grep -r "ollama/" apps services packages` findet nach der Migration nur noch `services/mana-llm/aliases.yaml`.
## Entscheidungen (gesetzt)
| Frage | Entscheidung | Warum |
|---|---|---|
| Wo lebt die Logik | **In `mana-llm`** | Heute schon der Provider-Router; alle Consumer (mana-api, mana-ai, web direkt) profitieren ohne Code-Änderung der Caller-Library. Eine Implementierung, ein Health-Cache, ein Konfig-Punkt. |
| Schnittstelle zum Consumer | **Alias als Modell-String** (`"model": "mana/long-form"`) | Behält die OpenAI-kompatible Request-Form. Caller braucht keine neuen Felder, kein neues Schema. Migration = String-Replace. |
| Konfigurations-Format | **YAML neben `config.py`**, hot-reload via SIGHUP | Lesbar, diff-bar, ohne Code-Deploy änderbar. Python-stdlib `yaml`-Parser reicht. |
| Health-Probe-Interval | 30 s, 3 s Timeout pro Probe | Schneller als die typische 75 s-Failure-Latenz, langsam genug um keinen Provider zu DDoSen. |
| Circuit-Breaker | 2 Fehlschläge → 60 s unhealthy → Re-Probe | Standard-Tuning. Verhindert Death-Spiral bei flappy Provider. |
| Fehlerklassen, die Fallback auslösen | `httpx.ConnectError`, `httpx.ReadTimeout`, `httpx.RemoteProtocolError`, HTTP 5xx, "Server disconnected" | Genau die Klassen, die heute den Bug erzeugen. **HTTP 4xx wird propagiert** — das ist Caller-Fehler, nicht Provider-Ausfall. |
| Streaming | Pre-First-Byte-Fallback, Mid-Stream hart fehlschlagen | Saubere Semantik, keine "halben" Outputs. |
| Mana-Aliases als reservierter Namespace | `mana/<class>`, alles andere durchreichen wie heute | Klare Grenze. `ollama/...` / `groq/...` bleiben legaler Direct-Call (für Tests, Debugging) — aber kein Produktcode benutzt es mehr. |
| Beobachtbarkeit | Prometheus + Response-Header | Header zeigt _diesem_ Caller welches Modell antwortete (für Token-Cost-Buchhaltung); Prometheus für Aggregat-Sicht. |
| Migrations-Strategie | **Big-Bang**, ein PR — kein Legacy | User-Vorgabe ("nicht live, unendliche Ressourcen, sauberste Lösung ohne Legacy"). Alle bestehenden `WRITING_MODEL` / `COMIC_STORYBOARD_MODEL` / `MANA_LLM_DEFAULT_MODEL` etc. Env-Vars entfallen ersatzlos. |
## Architektur
```
┌──────────────────────────────────────────┐
Consumer │ POST /v1/chat/completions │
("mana/long- │ { "model": "mana/long-form", ... } │
form") └────────────────────┬─────────────────────┘
┌────────────────────▼─────────────────────┐
│ AliasRegistry.resolve() │
│ mana/long-form → [ │
│ "ollama/gemma3:12b", │
│ "groq/llama-3.3-70b-versatile", │
│ "openrouter/anthropic/claude-3-haiku" │
│ ] │
└────────────────────┬─────────────────────┘
┌────────────────────▼─────────────────────┐
│ Router.execute_with_fallback() │
│ for model in chain: │
│ if not health.is_healthy(provider): │
│ continue │
│ try: return provider.complete(...) │
│ except ConnectError/5xx: │
│ health.mark_unhealthy(provider) │
│ continue │
│ raise NoHealthyProviderError │
└────────────────────┬─────────────────────┘
Response + Header `X-Mana-LLM-Resolved: groq/llama-3.3-70b`
Hintergrund-Loop (alle 30 s):
┌─────────────────────────────────────────┐
│ HealthProbe.tick() │
│ for provider in [ollama, groq, ...]: │
│ probe(provider, timeout=3s) │
│ update health_cache │
└─────────────────────────────────────────┘
```
## Aliases (initialer Stand)
`services/mana-llm/aliases.yaml`:
```yaml
# Alle aktiv genutzten Klassen. Reihenfolge im chain[] = Präferenz.
# Erstes healthy Glied gewinnt.
aliases:
mana/fast-text:
description: "Kurze Antworten, Klassifikation, Single-shot Q&A"
chain:
- ollama/qwen2.5:7b
- groq/llama-3.1-8b-instant
- openrouter/anthropic/claude-3-haiku
mana/long-form:
description: "Schreiben, Essays, Stories, längere Prosa"
chain:
- ollama/gemma3:12b
- groq/llama-3.3-70b-versatile
- openrouter/anthropic/claude-3.5-haiku
mana/structured:
description: "JSON-Output (Comic-Storyboard, Research-Subqueries, Tag-Vorschläge)"
chain:
- ollama/qwen2.5:7b
- groq/llama-3.1-8b-instant
# OpenRouter unterstützt strict JSON-Schema bei den meisten Modellen
- openrouter/openai/gpt-4o-mini
mana/reasoning:
description: "Agent-Missions, Tool-Calls, Mehrstufige Plans"
chain:
# Bewusst Cloud zuerst — lokale 4B-Modelle reichen nicht für Tool-Calls
- openrouter/anthropic/claude-3.5-sonnet
- groq/llama-3.3-70b-versatile
mana/vision:
description: "Multimodal (Bild + Text)"
chain:
- ollama/llava:7b
- google/gemini-2.0-flash-exp
- openrouter/openai/gpt-4o
```
5 Aliases sind genug für den heutigen Stand. Neue kommen dazu wenn ein Use-Case sie braucht — kein "wir bauen schon mal `mana/translation` weil's sein könnte".
**Default-Alias bei undefiniertem `model`-Feld:** `mana/fast-text`. Bisheriger `OLLAMA_DEFAULT_MODEL`-Env entfällt (= "kein Caller darf vergessen, ein Modell zu schicken; wenn er's tut, kriegt er den günstigsten").
## Komponenten in mana-llm
| File | Zweck |
|---|---|
| `services/mana-llm/src/aliases.py` (neu) | `AliasRegistry`-Klasse, lädt + reloaded `aliases.yaml`. Methoden: `resolve(name) -> list[str]`, `is_alias(name) -> bool`, `reload()`. |
| `services/mana-llm/src/health.py` (neu) | `ProviderHealthCache` mit per-Provider-State `{healthy, last_check, consecutive_failures, unhealthy_until}`. Methoden: `is_healthy(provider_id)`, `mark_unhealthy(provider_id, reason)`, `mark_healthy(provider_id)`. |
| `services/mana-llm/src/health_probe.py` (neu) | Hintergrund-`asyncio.Task`. Probe-Strategie pro Provider-Typ: `GET /api/tags` (Ollama) bzw. `GET /v1/models` (OpenAI-compat). Startet bei `app.on_startup`. |
| `services/mana-llm/src/providers/router.py` (umbau) | `chat_completion()` und `chat_completion_stream()` werden zu Wrappern um eine neue `_execute_with_fallback(model_or_alias, request)`-Methode. Heutige Logik (Provider-Picking via "/" splitten) wandert nach innen. |
| `services/mana-llm/src/main.py` (klein erweitert) | Response-Header setzen (`X-Mana-LLM-Resolved`). SIGHUP-Handler für Alias-Reload. Neue Endpunkte `GET /v1/aliases`, `GET /v1/health`. |
| `services/mana-llm/src/utils/metrics.py` (klein erweitert) | Neue Counter `mana_llm_fallback_total{from_model, to_model, reason}`, `mana_llm_alias_resolved_total{alias, target}`, Gauge `mana_llm_provider_healthy{provider}`. |
| `services/mana-llm/aliases.yaml` (neu) | Konfigurations-Datei, im Repo eingecheckt. |
| `services/mana-llm/CLAUDE.md` (update) | Neue Sektion "Aliases & Fallback". Tabelle aller 5 Aliases. Beispiel-Calls aktualisiert. |
| `services/mana-llm/tests/test_aliases.py` (neu) | YAML-Parsing, Reload, unbekannte Alias → Fehler. |
| `services/mana-llm/tests/test_fallback.py` (neu) | Mock-Provider mit injizierbaren Failures, Sequenz-Tests: erstes healthy → erstes nimmt. Erstes unhealthy → zweites. Alle unhealthy → 503. |
## Consumer-Migration
Eigene zentrale Konstanten-Datei statt verstreutem `process.env.WRITING_MODEL || 'ollama/...'`:
`apps/api/src/lib/llm-aliases.ts` (neu):
```ts
/**
* Mana LLM model aliases — single source of truth for which class of model
* each backend feature uses. Resolved server-side by mana-llm; consumers
* never see the underlying provider/model unless they explicitly need to
* (rare — mainly for token-cost accounting via the X-Mana-LLM-Resolved
* response header).
*/
export const MANA_LLM = {
FAST_TEXT: 'mana/fast-text',
LONG_FORM: 'mana/long-form',
STRUCTURED: 'mana/structured',
REASONING: 'mana/reasoning',
VISION: 'mana/vision',
} as const;
```
Ersetzt:
- `apps/api/src/modules/writing/routes.ts:22``DEFAULT_MODEL = process.env.WRITING_MODEL || 'ollama/gemma3:4b'``MANA_LLM.LONG_FORM`. `WRITING_MODEL`-Env weg.
- `apps/api/src/modules/comic/routes.ts:32``STORYBOARD_MODEL = process.env.COMIC_STORYBOARD_MODEL || 'ollama/gemma3:4b'``MANA_LLM.STRUCTURED`. Env weg.
- `apps/api/src/modules/context/routes.ts:14``DEFAULT_SUMMARY_MODEL = process.env.MANA_LLM_DEFAULT_MODEL || 'gemma3:4b'``MANA_LLM.FAST_TEXT`. Env weg.
- `apps/api/src/modules/research/orchestrator.ts:233` — Modell-Strings im `llmJson()`-Call → `MANA_LLM.STRUCTURED`.
- `services/mana-ai/src/planner/*` — sämtliche Modell-Strings → `mana/reasoning` über äquivalente shared Konstante in `services/mana-ai/src/llm-aliases.ts`.
- Web-App: kein direkter Aufruf von mana-llm aus dem Browser heute (geht alles über mana-api), daher hier nichts zu tun.
`grep -r "ollama/\|groq/\|openrouter/\|together/\|google/" apps services packages` muss nach der Migration leer sein, mit Ausnahme von:
- `services/mana-llm/aliases.yaml` (das ist die SSOT)
- `services/mana-llm/tests/` (Test-Fixtures)
- `services/mana-llm/CLAUDE.md` (Beispiele)
Wir validieren das mit einem neuen `validate-llm-strings.mjs` in `scripts/`, eingebunden in `validate:all`.
## Test-Plan
**Unit (Python, mana-llm):**
- `test_aliases.py` — YAML-Parsing-Edge-Cases (leerer Chain, unbekannter Provider in Chain, doppelter Alias, Reload nach Datei-Edit).
- `test_fallback.py` mit Mock-Providern:
- Single-Glied-Chain, Provider OK → 1 Call, Erfolg, Response-Header gesetzt.
- 3-Glied-Chain, erstes unhealthy laut Cache → 0 Calls auf erstes, 1 Call auf zweites.
- 3-Glied-Chain, erstes wirft `ConnectError` → markiert unhealthy, ruft zweites, Erfolg.
- 3-Glied-Chain, alle unhealthy → 503 mit `NoHealthyProviderError`.
- 4xx Fehler vom ersten Provider → KEIN Fallback, propagiert.
- Streaming: erste Bytes flowen → Verbindung bricht → harter Fehler, kein Wechsel.
- `test_health_probe.py` — Probe-Loop markiert unhealthy nach 2 Fehlschlägen, healthy nach 1 Erfolg, respektiert 60s Backoff.
**Integration (Python, mana-llm):**
- Live-Smoke-Test gegen lokales Ollama (in CI nicht aktiv, lokal mit `pytest -m integration`).
**Validation Script (Node, repo-weit):**
- `scripts/validate-llm-strings.mjs` — failed wenn Code in `apps/`/`services/` (außer mana-llm selbst) hardcoded Provider-Strings (`ollama/X`, `groq/X`, etc.) enthält. In `validate:all` einbinden.
**Manual Smoke (vor Merge):**
- GPU-Server hochfahren, generation in /writing macht: `X-Mana-LLM-Resolved: ollama/gemma3:12b`.
- GPU-Server runterfahren, generation macht: `X-Mana-LLM-Resolved: groq/llama-3.3-70b-versatile` in <1s.
- Groq-Key entfernen, generation macht: `X-Mana-LLM-Resolved: openrouter/...`.
- Alle Keys raus, generation macht: 503 mit `NoHealthyProviderError`.
## Milestones
| M | Inhalt | Aufwand-Schätzung |
|---|---|---|
| **M1** | `aliases.yaml` + `AliasRegistry` + Unit-Tests. Resolution funktioniert, aber kein Health-Cache, keine Fallback-Loop nur Alias--erstes-Glied-Aufruf. | 0.5 d |
| **M2** | `ProviderHealthCache` + Hintergrund-Probe-Loop + Unit-Tests. Cache wird befüllt; Router fragt noch nicht ab. | 0.5 d |
| **M3** | `Router.execute_with_fallback()` integriert Cache und Chain-Loop. Streaming-Pre-First-Byte-Logik. Unit-Tests (Mock-Provider). | 1 d |
| **M4** | Response-Header, Prometheus-Metrics, `GET /v1/aliases` + `GET /v1/health` Debug-Endpoints, SIGHUP-Reload, `services/mana-llm/CLAUDE.md` Update. | 0.5 d |
| **M5** | Consumer-Migration: 4 Files in `apps/api/src/modules/*/routes.ts` + mana-ai planner. Env-Vars aus `.env.development` raus. `validate-llm-strings.mjs` + `validate:all`-Hookup. Manual smoke. | 0.5 d |
**Gesamt:** ~3 Entwicklertage. Linear (M1 vor M2 vor M3 …), kein parallelisierbarer Pfad.
## Alternativen, die ich verworfen habe
- **Per-Request Fallback-Chain im Body** (Caller schickt `models: ["...", "..."]`) verlagert die Modellauswahl-Logik zurück in jeden Consumer; jeder Caller müsste die aktuelle Provider-Landschaft kennen; kein Health-Cache jeder Request zahlt 75 s Timeout am toten Provider selbst. Schmutzig und langsam.
- **Fallback in `apps/api/src/lib/llm.ts`** würde nur mana-api-Caller schützen. mana-ai (Background-Mission-Runner) hat seinen eigenen LLM-Client und bleibt fragil. Zwei Implementierungen, zwei Bug-Quellen, doppelte Health-Caches.
- **LiteLLM/OpenRouter-as-a-service** als Sidecar statt mana-llm-Erweiterung mehr Komponenten, weniger Kontrolle, Vendor-Lock-in für eine Funktion die in ~300 Zeilen Python lebt. Nicht Mana-style.
- **Cloud-first, Ollama als optionaler Cache** eine separate strategische Entscheidung (Privacy / Kosten / Offline-Modus). Gehört nicht in eine Resilience-Story; kann später diskutiert werden.
- **In-Process-Retry ohne Health-Probe** (Caller versucht erst Ollama, bei Fehler einmal Cloud) jeder Request zahlt einmal die volle Failure-Latenz beim ersten Treffer auf den toten Provider. UX-killer (15 User × 75 s Hang).
- **Dynamische Latency-/Kosten-Optimierung** (mana-llm wählt schnellsten/billigsten Provider) Premature Optimization. Erst messen, dann optimieren. Statische Reihenfolge in der Chain ist transparent und debugbar.
- **Per-User-Routing** (User-Tier entscheidet Modell) gehört in den Tier-/Credits-Layer, nicht ins LLM-Routing. Soll auf mana-credits-Ebene bleiben.
## Open Questions
- **Probe-Strategie für `groq` / `openrouter`:** `GET /v1/models` reicht zum Health-Check, frisst aber bei jedem Tick einen Free-Tier-Quota-Punkt. Bei Groq aktuell 30 RPM unkritisch (= 2 RPM Probe-Last); bei kostenpflichtigen Tiers später erwägen, nur on-demand zu prüfen.
- **Wie reagieren wir auf 429 (Rate-Limit)?** Vorschlag: temporär als unhealthy markieren mit kurzem Backoff (15 s, nicht 60 s), aber separat von Connection-Errors zählen flappy-throttling soll die Zwei-Strikes-Regel nicht triggern. Im M3 nachdenken.
- **Alias-Versionierung:** Wenn wir `mana/long-form` morgen umstellen (gemma3 gemma4), bleibt der Name. Wollen wir je versionierte Aliases (`mana/long-form-v2`)? Heute: nein, Reload ist atomar. Falls A/B-Testing nötig wird, dann.
- **Caller-side Model-Awareness für Token-Pricing:** Der Resolved-Header sagt _welches_ Modell antwortete; mana-credits muss daraus den Preis ableiten. Heißt: mana-credits braucht eine Modell--Preis-Tabelle. Existiert die schon? Prüfen vor M5.