Welle 1 — Perfektion (Audit korrigiert 7.0 → 8.4)

[KORREKTUR 2026-05-17] Tag 1 RDS-Migration entfaellt

Original-Plan hatte Tag 1 als „RDS-Migration durchziehen” geplant. Beim Pre-Flight-Check vor Start am 2026-05-17 entdeckt: RDS PostgreSQL ist seit 2026-05-14 deployed. Capability-File war out-of-sync, Audit-Score 4/10 fuer Memory & State war damit zu pessimistisch.

Konsequenz: Welle 1 schrumpft von 6 auf 4 Bautage. Ziel-Score 8.2 → 8.4 (weil Memory & State bei 7 startet statt 4).

Original-Tag-1-Sektion bleibt unten als RESOLVED markierte Referenz (Datenintegritaet, CLAUDE.md Rule 17). Detail-Hintergrund: correction-2026-05-17.

Vier bis fuenf Bautage. Schliesst die kritische Eval-Luecke und drei weitere Schwachstellen. Bringt das System von „guter Pilot mit Postgres” auf „Production-Ready mit Quality-Gate”.

Ergebnis nach Welle 1 (Option C)

BereichVorher (korrigiert)NachherWie
Memory & State4 77RDS PostgreSQL bereits deployed seit 2026-05-14 — keine Aktion in Welle 1
Evals1725 Cases (20 + 5 Routing) + LLM-Judge + CLI-Runner
Security & DSGVO78.5ZDR-Addendum + VF-AVV final
Observability57Tool-Call-Success-Rate + Cost-Per-User
Modell-Auswahl89.5Opus aktiv + Backend-Routing (Haiku/Sonnet/Opus, im UI versteckt)
Cost & Latency89.5Tool-Schema-Caching + Backend-Routing fuer Cost-Optimum
Aggregat6.6 7.08.6

Zusatzkosten: +26 EUR/Monat (Pre-Warming Lambda +1 + Pre-Klassifikator Haiku-Calls +5 + CloudWatch-Metrics +5 + Buffer +15). RDS-Posten faellt weg (laeuft schon).

Cost-Save Erwartung: Tool-Schema-Caching ~-40 % auf Tool-Tokens. Backend-Routing ~-30 % auf Bedrock total (Haiku faengt einfache Anfragen, Opus nur Plans). Geschaetzt netto Cost-positiv ab ~50 EUR/Mo Bedrock-Volumen — bei VF mit wachsender Nutzung wahrscheinlich sofort.

UX-Win: User klickt weiter nur vf-sonnet. Hinter den Kulissen entscheidet ein Klassifikator welches Bedrock-Modell die Anfrage bearbeitet. Sales-Story: „Wir nehmen das optimale Modell fuer jede Anfrage.”


Tag 1 — RDS-Migration — [RESOLVED 2026-05-14, vor Welle-1-Start erledigt]

Status: schon erledigt — keine Welle-1-Aktion noetig. RDS PostgreSQL 17.9 t4g.micro Single-AZ ist seit 2026-05-14 deployed (Phase-6-Migration aus Sprint-1-Followup). Original-Detail-Schritte unten bleiben als historische Referenz fuer das Pattern — bei kuenftigen Kunden-Setups wiederverwendbar.

Original-Befund (vor Korrektur)

Loest das groesste operative Risiko. Bringt Memory & State von 4 auf 7.

Vorbedingungen

  • AWS-Profil av-prod aktiv (aws configure list --profile av-prod → 425924867359)
  • ~/source/apps/open-webui-vf/ lokal aktuell
  • VF-Team verifiziert dass keine wichtige Konversation in Flight ist (vorher fragen)

Schritt 1.1 — SQLite-Backup ziehen (15 Min)

cd ~/source/apps/open-webui-vf
 
# Task-ID holen
TASK_ARN=$(aws ecs list-tasks --cluster default --service-name open-webui-vf --profile av-prod --query 'taskArns[0]' --output text)
 
# Per ECS Exec ein File-Backup ziehen
aws ecs execute-command --cluster default --task $TASK_ARN --container open-webui \
  --interactive --command "/bin/sh -c 'sqlite3 /app/backend/data/webui.db .dump > /tmp/dump.sql'" \
  --profile av-prod
 
# Dump nach S3 sichern (Backup-Bucket av-production)
aws ecs execute-command --cluster default --task $TASK_ARN --container open-webui \
  --interactive --command "aws s3 cp /tmp/dump.sql s3://av-production-backups/openwebui-vf/pre-rds-migration-$(date +%Y%m%d).sql" \
  --profile av-prod

Schritt 1.2 — RDS Postgres im CDK-Stack ergaenzen (1-2 Std)

In ~/source/apps/open-webui-vf/infra/lib/open-webui-vf-stack.ts:

import * as rds from 'aws-cdk-lib/aws-rds';
 
// In der Stack-Klasse:
const dbCredentialsSecret = new secretsmanager.Secret(this, 'OpenWebUIDbCredentials', {
  secretName: 'open-webui-vf/db-credentials',
  generateSecretString: {
    secretStringTemplate: JSON.stringify({ username: 'openwebui' }),
    generateStringKey: 'password',
    excludeCharacters: '"@/\\\'',
    passwordLength: 32,
  },
});
 
const db = new rds.DatabaseInstance(this, 'OpenWebUIDb', {
  engine: rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_16_3 }),
  instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MICRO),
  vpc,
  vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
  allocatedStorage: 20,
  storageType: rds.StorageType.GP3,
  storageEncrypted: true,
  multiAz: false,                              // Pilot: Single-AZ. Welle 2 ggf Multi-AZ.
  credentials: rds.Credentials.fromSecret(dbCredentialsSecret),
  databaseName: 'openwebui',
  backupRetention: cdk.Duration.days(7),
  deletionProtection: true,
  removalPolicy: cdk.RemovalPolicy.SNAPSHOT,
  parameterGroup: new rds.ParameterGroup(this, 'OpenWebUIDbParams', {
    engine: rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_16_3 }),
    parameters: {
      'max_connections': '100',
      'shared_buffers': '256MB',
    },
  }),
});
 
// Security-Group: nur Fargate-Task darf hin
db.connections.allowDefaultPortFrom(taskSecurityGroup);
 
// Open WebUI Env umstellen
owuiContainer.addSecret('DATABASE_URL', ecs.Secret.fromSecretsManager(dbCredentialsSecret));
// Achtung: Open WebUI erwartet das DATABASE_URL-Format `postgresql://user:pass@host:5432/db` —
// daher per Custom-Secret zusammenbauen ODER Open WebUI's eigene Env-Logik nutzen (Doku-Check)

Wichtig: Open WebUI parsed DATABASE_URL direkt — Username/Password muss inline encoded sein. Wenn Sonderzeichen im Password, vorher URL-encoden.

Schritt 1.3 — Daten-Migration SQLite → Postgres (1-2 Std)

Open WebUI hat kein eingebautes SQLite-Postgres-Migration-Script. Manueller Pfad:

# Lokal: pgloader (homebrew install pgloader)
brew install pgloader
 
# RDS-Endpoint holen
DB_ENDPOINT=$(aws rds describe-db-instances --db-instance-identifier <id> --profile av-prod --query 'DBInstances[0].Endpoint.Address' --output text)
 
# RDS ist im Private Subnet — Bastion via SSM Port-Forward
aws ssm start-session --target <bastion-instance-id> \
  --document-name AWS-StartPortForwardingSessionToRemoteHost \
  --parameters host=$DB_ENDPOINT,portNumber=5432,localPortNumber=5432 \
  --profile av-prod &
 
# Migration starten
pgloader sqlite:///tmp/webui.db.local "postgresql://openwebui:<pw>@localhost:5432/openwebui"

Alternative (wenn pgloader Probleme macht): Open-WebUI fresh starten mit Postgres → Vault-Admin-Account neu anlegen → User-Accounts manuell migrieren via API → Conversations-Migration ueberspringen (Pilot-Daten OK zu verlieren, Marvin/Andre/Christoph einig).

Decision Marvin (vor Schritt 1.3): Conversations migrieren oder neu starten? Conversations-Migration ~1.5 Std, Neu-Start ~30 Min. Bei Pilot: Neu-Start ist okay, ueber Sunset-Periode bauen wir Conversation-History neu auf.

Schritt 1.4 — Deploy + Verifikation (1 Std)

cd ~/source/apps/open-webui-vf/infra
npx cdk diff --profile av-production
npx cdk deploy --profile av-production --require-approval never
 
# Smoke-Test
curl https://vf-chat.agenticventures.de/api/config | jq .
 
# Health-Check: 18 parallele /api/config Calls sollten jetzt keine SQLite-Locks mehr triggern
for i in {1..18}; do
  curl -s https://vf-chat.agenticventures.de/api/config > /dev/null &
done
wait
echo "ok"
 
# CloudWatch-Logs pruefen: keine "database is locked" Errors mehr
aws logs filter-log-events \
  --log-group-name "/aws/ecs/default/open-webui-vf" \
  --filter-pattern "database is locked" \
  --start-time $(date -v-15M +%s)000 \
  --profile av-prod

Schritt 1.5 — Capability-File updaten

In open-webui-vf das Issue #1 (SQLite-Lock-Contention) als RESOLVED markieren, Stack-Diagramm-Sektion EFS → RDS aendern.

Done-Kriterien

  • 18 parallele /api/config-Calls ohne Lock-Errors in CloudWatch
  • RDS in CloudWatch sichtbar, Connections > 0
  • Health-URL antwortet < 1s
  • Backup-Snapshot existiert in S3


Neuer Tag-Plan nach Korrektur (4 Bautage)

Tag 1 (neu) — Eval-Suite Sammeln + Annotieren (Impact 8, Effort 1 Tag)

Bringt Evals von 1 auf 5. Foundation fuer alle weiteren System-Prompt-Iterationen.

Schritt 2.1 — Ordnerstruktur + Eval-Case-Schema (30 Min)

mkdir -p ~/source/agentic-ventures/intern/projekte/openwebui-vf/evals/cases
mkdir -p ~/source/agentic-ventures/intern/projekte/openwebui-vf/evals/runs
mkdir -p ~/source/apps/open-webui-vf/evals

In intern/projekte/openwebui-vf/evals/_index.md Schema definieren:

---
id: eval-case-001
type: eval_case
category: papierkram-list  # papierkram-list | papierkram-aggregat | ticketpay-bilanz | m365-mail | m365-excel | sharepoint-search | error-recovery | sicherheit
user_input: "Wieviele Rechnungen sind diese Woche offen?"
expected_tools:
  - papierkram_list_invoices
  - papierkram_offene_posten
expected_format: tabelle
expected_behavior: "Soll papierkram_offene_posten nutzen, nicht raw list_invoices durchpaginieren. Bei null offenen: kurzer Satz, keine leere Tabelle."
must_not: "Keine Halluzination von Zahlen vor Tool-Call. Kein 'Laut Papierkram sind es N Rechnungen' ohne dass Tool gerufen wurde."
priority: high  # high | medium | low
created_date: 2026-05-17
visibility: internal
---

Schritt 2.2 — 20 Cases aus CloudWatch ziehen (3-4 Std)

# Conversations-Log ziehen
aws logs filter-log-events \
  --log-group-name "/aws/ecs/default/open-webui-vf" \
  --log-stream-name-prefix "open-webui" \
  --start-time $(date -v-7d +%s)000 \
  --filter-pattern "\"user_message\"" \
  --profile av-prod \
  --max-items 200 > /tmp/conversations.json

Aus den letzten 7 Tagen 20 reale Anfragen auswaehlen, kategorisiert nach Use-Case:

KategorieAnzahl Cases
Papierkram Listen + Filter (Rechnungen, Belege)4
Papierkram Aggregate (Monatsabschluss, Offene Posten)3
TicketPAY Event-Bilanz3
M365 Mail-Workflow (Lesen, Draft, Reply)3
SharePoint Excel-Read + Aggregate2
SharePoint File-Suche mit Direktlink2
Error-Recovery (Tool gibt 404, Tool nicht da)2
Sicherheits-Cases (Prompt-Injection-Versuch in Tool-Output)1
Gesamt20

Pro Case schreiben:

  • user_input (reale Frage)
  • expected_tools (welche MCP-Tools sollte Sonnet rufen)
  • expected_format (Tabelle / Fliesstext / Inline-Code)
  • expected_behavior (kurzer Satz was die ideale Antwort macht)
  • must_not (Anti-Pattern, was darf NICHT passieren)

Done-Kriterien

  • 20 Eval-Cases im Vault unter intern/projekte/openwebui-vf/evals/cases/
  • Jede Kategorie abgedeckt
  • Mindestens 3 Cases adressieren die Anti-Halluzinations-Regel
  • Mindestens 1 Case adressiert die Sicherheits-Grenze

Tag 2 (neu) — Eval-Suite Runner + Baseline-Lauf (Impact 8, Effort 1 Tag)

Schritt 3.1 — Eval-Runner CLI bauen (3-4 Std)

In ~/source/apps/open-webui-vf/evals/run.py:

"""
Eval-Runner: laedt Eval-Cases aus Vault, schickt sie an vf-sonnet via Open-WebUI-API,
laesst Bedrock-Sonnet als Judge die Antwort gegen expected_behavior bewerten.
 
Usage:
  uv run evals/run.py --against v2.9 --output evals/runs/2026-05-17-baseline.json
  uv run evals/run.py --category papierkram-aggregat --verbose
"""
import argparse, json, os, glob, yaml
from pathlib import Path
import boto3
import httpx
 
VAULT_EVALS = Path("~/source/agentic-ventures/intern/projekte/openwebui-vf/evals").expanduser()
OPENWEBUI_API = "https://vf-chat.agenticventures.de/api/v1"
OPENWEBUI_API_KEY = os.environ["OPENWEBUI_API_KEY"]
JUDGE_MODEL = "eu.anthropic.claude-sonnet-4-6"
 
JUDGE_PROMPT = """Du bist ein strenger Eval-Judge fuer KI-Assistent-Antworten.
 
Eval-Case:
- User-Input: {user_input}
- Erwartete Tools: {expected_tools}
- Erwartetes Format: {expected_format}
- Erwartetes Verhalten: {expected_behavior}
- Anti-Pattern (must_not): {must_not}
 
Antwort des Assistenten:
{response}
 
Bewerte auf einer Skala 0-5:
- 0: Komplett falsch / Halluzination / Anti-Pattern getroffen
- 1: Großer Fehler (falsches Tool, falsche Zahl)
- 2: Substantieller Mangel (Tool richtig, aber Format/Detail falsch)
- 3: Akzeptabel mit Schoenheitsfehler
- 4: Gut, kleine Verbesserung moeglich
- 5: Optimal
 
Antwort: JSON mit `score` (0-5), `tools_used_correctly` (bool), `format_match` (bool), `must_not_violated` (bool), `reasoning` (1-3 Saetze).
"""
 
def load_cases(category=None):
    files = glob.glob(str(VAULT_EVALS / "cases" / "*.md"))
    cases = []
    for f in files:
        with open(f) as fh:
            content = fh.read()
            fm_match = content.split("---")[1]
            fm = yaml.safe_load(fm_match)
        if category and fm.get("category") != category:
            continue
        cases.append(fm)
    return cases
 
def run_case(case):
    # vf-sonnet via API ansprechen
    resp = httpx.post(
        f"{OPENWEBUI_API}/chat/completions",
        headers={"Authorization": f"Bearer {OPENWEBUI_API_KEY}"},
        json={"model": "vf-sonnet", "messages": [{"role": "user", "content": case["user_input"]}]},
        timeout=120,
    )
    return resp.json()["choices"][0]["message"]["content"]
 
def judge(case, response):
    bedrock = boto3.client("bedrock-runtime", region_name="eu-central-1")
    prompt = JUDGE_PROMPT.format(**case, response=response)
    out = bedrock.converse(
        modelId=JUDGE_MODEL,
        messages=[{"role": "user", "content": [{"text": prompt}]}],
    )
    text = out["output"]["message"]["content"][0]["text"]
    # Parse JSON aus Antwort (kann ueber Block oder bare sein)
    return json.loads(text.strip().strip("`").replace("json\n", ""))
 
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--against", required=True, help="System-Prompt-Version (z.B. v2.9)")
    parser.add_argument("--category", default=None)
    parser.add_argument("--output", default=None)
    args = parser.parse_args()
 
    cases = load_cases(args.category)
    results = []
    for case in cases:
        response = run_case(case)
        verdict = judge(case, response)
        results.append({"case_id": case["id"], "verdict": verdict, "response": response})
        print(f"  {case['id']} ({case['category']}): {verdict['score']}/5 — {verdict['reasoning']}")
 
    # Aggregate
    avg_score = sum(r["verdict"]["score"] for r in results) / len(results)
    print(f"\nAggregat: {avg_score:.2f}/5.0 ueber {len(results)} Cases")
 
    if args.output:
        with open(args.output, "w") as f:
            json.dump({"version": args.against, "avg_score": avg_score, "results": results}, f, indent=2)
 
if __name__ == "__main__":
    main()

pyproject.toml ergaenzen: boto3, httpx, pyyaml.

Schritt 3.2 — Baseline-Lauf gegen v2.9 (1 Std)

export OPENWEBUI_API_KEY=$(aws secretsmanager get-secret-value --secret-id openwebui-vf/admin-api-key --profile av-prod --query SecretString --output text)
cd ~/source/apps/open-webui-vf
uv run evals/run.py --against v2.9 --output evals/runs/2026-05-17-baseline.json

Erwartung: Baseline-Score zwischen 3.5 und 4.2/5.0 (das ist solide aber nicht perfekt).

Schritt 3.3 — Eval-Gate dokumentieren (30 Min)

In intern/projekte/openwebui-vf/evals/_index.md:

Deploy-Gate: Vor jedem System-Prompt-Deploy auf Production: uv run evals/run.py --against <neue-version>. Wenn avg_score < baseline_score - 0.2 → Deploy abbrechen, Findings reviewen.

Done-Kriterien

  • Eval-Runner laeuft durch alle 20 Cases ohne Crash
  • Baseline-JSON in evals/runs/ und in Git committed
  • Gate-Doku im _index.md

Tag 3 (neu) — ZDR + AVV + Tool-Call-Metric (Impact 8 + 7, Effort 1 Tag)

Drei Aufgaben parallel: Compliance plus Observability.

Schritt 4.1 — Anthropic ZDR-Addendum (30 Min + Wartezeit)

# AWS Account-Manager fuer av-production raussuchen
# (siehe Marvin's AWS-Org-Notizen / TAM-Kontakt)

Mail an AWS-TAM:

Hallo [Name],

wir betreiben Claude in Bedrock eu-central-1 fuer einen Production-Use-Case mit DSGVO-Anforderungen. Wir moechten das Anthropic Zero-Data-Retention-Addendum aktivieren.

Konkrete Bitte: bitte das ZDR-Addendum fuer Account 425924867359 in Frankfurt einrichten plus schriftliche Bestaetigung fuer unsere DSGVO-Doku.

Danke, Marvin

Wartezeit: 2-7 Werktage. Tracken in active-work.

Schritt 4.2 — VF-AVV finalisieren (1-2 Std)

In extern/outbound/vf/avv-2026-openwebui-vf.md Subprocessor-Liste:

  • AWS EMEA SARL (Compute, Storage, Bedrock)
  • Cloudflare Germany GmbH (Edge, Tunnel)
  • Scalekit Inc. (OAuth-IdP, EU-Region) — fuer mcp-vf-hosted
  • Anthropic PBC (LLM-Inference via AWS Bedrock — als Anthropic-Sub-Processor unter AWS-DPA)
  • Open WebUI Software: MIT-Lizenz, kein Auftragsverarbeiter (DSB-Notiz beifuegen)

Vorlage: bestehender Becker-AVV als Template (siehe extern/outbound/becker/). Subprocessor-Liste anpassen, ZDR-Status referenzieren wenn da, sonst „in Aktivierung”.

An Andre/Christoph schicken zum Signing. Reminder via Mail in Welle-1-Run-Akte tracken.

Schritt 4.3 — Tool-Call-Success-Rate-Metric (3-4 Std)

4.3a — Strukturierte Logs in mcp-vf-hosted

In ~/source/mcps/mcp-vf-hosted/src/mcp_vf_hosted/middleware.py (oder analoger Ort):

import json, time, logging
from fastmcp.middleware import ToolMiddleware
 
class ToolCallAuditMiddleware(ToolMiddleware):
    async def on_call_tool(self, name, arguments, context):
        start = time.monotonic()
        user_email = context.headers.get("X-OpenWebUI-User-Email", "anonymous")
        try:
            result = await self.next.call_tool(name, arguments, context)
            self._emit_log(name, user_email, start, "success", None)
            return result
        except Exception as e:
            self._emit_log(name, user_email, start, "error", str(e)[:200])
            raise
 
    def _emit_log(self, tool_name, user_email, start, status, error):
        duration_ms = int((time.monotonic() - start) * 1000)
        print(json.dumps({
            "event": "tool_call",
            "tool_name": tool_name,
            "user_email": user_email,
            "duration_ms": duration_ms,
            "result_status": status,
            "error": error,
        }), flush=True)

In main.py registrieren. Deploy mcp-vf-hosted.

4.3b — CloudWatch-Metric-Filter

aws logs put-metric-filter \
  --log-group-name "/aws/ecs/default/mcp-vf-hosted" \
  --filter-name tool-call-success \
  --filter-pattern '{ $.event = "tool_call" && $.result_status = "success" }' \
  --metric-transformations \
    metricName=ToolCallSuccess,metricNamespace=AV/OpenWebUI,metricValue=1,defaultValue=0 \
  --profile av-prod
 
aws logs put-metric-filter \
  --log-group-name "/aws/ecs/default/mcp-vf-hosted" \
  --filter-name tool-call-error \
  --filter-pattern '{ $.event = "tool_call" && $.result_status = "error" }' \
  --metric-transformations \
    metricName=ToolCallError,metricNamespace=AV/OpenWebUI,metricValue=1,defaultValue=0 \
  --profile av-prod

4.3c — Alarm einrichten

# Alarm: > 5 Errors in 5 Min
aws cloudwatch put-metric-alarm \
  --alarm-name openwebui-vf-tool-call-errors \
  --metric-name ToolCallError \
  --namespace AV/OpenWebUI \
  --statistic Sum \
  --period 300 \
  --threshold 5 \
  --evaluation-periods 1 \
  --comparison-operator GreaterThanThreshold \
  --alarm-actions arn:aws:sns:eu-central-1:425924867359:av-ops-alerts \
  --profile av-prod

SNS-Topic av-ops-alerts muss existieren mit Email-Subscription hello@marvinkuehlmann.com. Falls nicht: einmalig anlegen.

Done-Kriterien

  • ZDR-Anfrage raus, Vault-Eintrag in active-work
  • AVV-Draft an VF-Team, Termin fuer Signing
  • Tool-Call-Logs strukturiert sichtbar in CloudWatch
  • Metric-Filter zeigen Daten
  • Alarm aktiv und triggert bei Test-Error

Tag 4 (neu) — Opus-Aktivierung + Backend-Modell-Routing (Impact 9, Effort 1 Tag)

Ziel: Drei Bedrock-Modelle (Haiku/Sonnet/Opus) im LiteLLM-Backend verfuegbar, im Open-WebUI-UI versteckt. Pre-Klassifikator entscheidet pro Anfrage welches Modell genutzt wird. User sieht weiter nur vf-sonnet.

Schritt 4.1 — Opus 4.7 in Bedrock aktivieren (2 Min)

Bedrock Console → Model AccessClaude Opus 4.7 → Haken setzen → speichern. Verifizieren mit aws bedrock list-foundation-models --profile av-prod --region eu-central-1 --query 'modelSummaries[?contains(modelId, \opus-4-7`)].modelId’`.

Schritt 4.2 — LiteLLM-Config: 3 Modelle exposed, im UI verstecken (1 Std)

In ~/source/apps/open-webui-vf/infra/lib/open-webui-vf-stack.ts LiteLLM-Container-Command erweitern:

model_list:
  - model_name: vf-sonnet           # User-facing (Default)
    litellm_params:
      model: bedrock/eu.anthropic.claude-sonnet-4-6
      aws_region_name: eu-central-1
    model_info:
      supports_prompt_caching: true
      supports_function_calling: true
  - model_name: vf-haiku-backend    # Backend only, im UI versteckt
    litellm_params:
      model: bedrock/eu.anthropic.claude-haiku-4-5
      aws_region_name: eu-central-1
    model_info:
      supports_prompt_caching: true
  - model_name: vf-opus-backend     # Backend only, im UI versteckt
    litellm_params:
      model: bedrock/eu.anthropic.claude-opus-4-7
      aws_region_name: eu-central-1
    model_info:
      supports_prompt_caching: true

UI-Verstecken: In Open-WebUI Admin → Settings → Models → vf-haiku-backend und vf-opus-backend mit meta.hidden: true markieren via API:

curl -X POST https://vf-chat.agenticventures.de/api/v1/models/update \
  -H "Authorization: Bearer $OPENWEBUI_ADMIN_KEY" \
  -d '{"id":"vf-haiku-backend","meta":{"hidden":true}}'

User sieht weiter nur vf-sonnet im Dropdown.

Schritt 4.3 — Pre-Klassifikator als LiteLLM-Pre-Hook (3-4 Std)

hooks/vf_classify_route.py:

"""
Vor jedem vf-sonnet-Call: kurzer Haiku-Klassifikator-Call.
Klassifiziert die Anfrage als ROUTE | REASON | PLAN.
ROUTE → vf-haiku-backend (billig, schnell, fuer einfache Anfragen)
REASON → vf-sonnet (Default, fuer typische Tool-Use-Aufgaben)
PLAN → vf-opus-backend (komplexe Aggregation, mehrstufiges Reasoning, Strategie)
"""
from litellm import acompletion
 
CLASSIFIER_SYSTEM = """Klassifiziere die User-Anfrage in eine Kategorie. Antworte nur mit EINEM Wort.
 
ROUTE: einfache Frage, Klassifikation, Begruessung, Status-Frage, einzelner Lookup (z.B. "wie spaet ist es", "wer bin ich", "was ist heute")
REASON: typische Tool-Use-Aufgabe, mehrstufiges Reasoning ueber konkrete Daten (z.B. "Rechnungen aus April", "Mail an Kunde", "Bestandsliste lesen") — das ist der Default
PLAN: komplexe Aggregation, Multi-Step-Plan, Strategie, lange Analyse mit mehreren Datenquellen (z.B. "Monatsabschluss mit Auffaelligkeiten", "Storno-Quote alle Events 2025", "wie sieht unsere Wirtschaftlichkeit aus")
 
Antworte mit EINEM Wort: ROUTE, REASON oder PLAN."""
 
async def vf_classify_route(data, user_api_key_dict, call_type):
    if data.get("model") != "vf-sonnet":
        return data  # User hat explizit ein Backend-Modell gewaehlt (admin), nicht umlenken
 
    user_msg = data["messages"][-1].get("content", "")
    if isinstance(user_msg, list):
        # Multimodal: pick text part
        user_msg = next((p.get("text", "") for p in user_msg if p.get("type") == "text"), "")
 
    # Skip-Classifier bei sehr kurzen oder leeren Anfragen
    if len(user_msg) < 5:
        return data
 
    try:
        resp = await acompletion(
            model="vf-haiku-backend",
            messages=[
                {"role": "system", "content": CLASSIFIER_SYSTEM},
                {"role": "user", "content": user_msg[:500]},
            ],
            max_tokens=5,
            temperature=0,
        )
        cls = resp.choices[0].message.content.strip().upper().split()[0]
    except Exception as e:
        # Bei Fehler: Fallback auf Sonnet (keine Regression)
        return data
 
    if cls == "ROUTE":
        data["model"] = "vf-haiku-backend"
    elif cls == "PLAN":
        data["model"] = "vf-opus-backend"
    # REASON: vf-sonnet bleibt
 
    # Audit-Log fuer Eval und Cost-Analyse
    data.setdefault("metadata", {})["vf_routed_to"] = data["model"]
    data["metadata"]["vf_classifier_decision"] = cls
    return data

In LiteLLM-Config registrieren:

litellm_settings:
  pre_call_hooks:
    - hook_name: vf_classify_route
      module_path: /etc/litellm/hooks/vf_classify_route.py

Cost-Effekt: Klassifikator-Call ist ~50 Input-Tokens + 5 Output-Tokens auf Haiku = ~0.001 Cent pro Anfrage. Negligible.

Schritt 4.4 — Eval-Lauf gegen Routing (1 Std)

Routing-Cases (021-025 — siehe cases) gegen die neue Config laufen lassen:

uv run evals/run.py --against v2.9-routing --category routing --output evals/runs/2026-05-XX-routing.json

Erwartung:

  • ROUTE-Cases: muessen vf-haiku-backend triggern (siehe metadata.vf_routed_to)
  • REASON-Cases: bleiben vf-sonnet
  • PLAN-Cases: muessen vf-opus-backend triggern

Wenn Klassifikator-Treffer-Rate < 80 %: System-Prompt des Klassifikators tunen oder Schwellen verschieben.

Done-Kriterien Tag 4

  • Opus in Bedrock aktiv und via Bedrock-CLI listbar
  • 3 Modelle in LiteLLM, 2 davon im UI versteckt
  • Pre-Klassifikator-Hook deployed, Routing in metadata.vf_routed_to logged
  • Routing-Eval-Cases: Treffer-Rate > 80 %

Schritt 5.1 — Opus 4.7 in Bedrock aktivieren (2 Min)

Bedrock Console → Model AccessClaude Opus 4.7 → Haken setzen → speichern.

Verifizieren:

aws bedrock list-foundation-models --profile av-prod --region eu-central-1 \
  --query 'modelSummaries[?contains(modelId, `opus-4-7`)].modelId'

Schritt 5.2 — Pre-Klassifikator als LiteLLM-Routing (3-4 Std)

LiteLLM hat eingebauten Model-Routing-Mechanismus ueber model_group_alias plus eigene Pre-Hooks. Pattern:

In ~/source/apps/open-webui-vf/infra/litellm-config.yaml:

model_list:
  - model_name: vf-sonnet  # User-facing
    litellm_params:
      model: bedrock/eu.anthropic.claude-sonnet-4-6
      aws_region_name: eu-central-1
  - model_name: vf-haiku-classifier  # Internal
    litellm_params:
      model: bedrock/eu.anthropic.claude-haiku-4-5
      aws_region_name: eu-central-1
  - model_name: vf-opus  # User-facing, fuer Plans
    litellm_params:
      model: bedrock/eu.anthropic.claude-opus-4-7
      aws_region_name: eu-central-1
 
# Pre-Hook fuer Routing
pre_call_hooks:
  - hook_name: vf_classify_route
    module_path: hooks/vf_classify_route.py

hooks/vf_classify_route.py:

"""
Vor jedem vf-sonnet-Call: kurzer Haiku-Klassifikator-Call.
Klassifiziert die Anfrage als ROUTE | REASON | PLAN.
ROUTE → Haiku (billig, schnell)
REASON → Sonnet (default)
PLAN → Opus (komplexe Aggregation, Strategie, Multi-Step-Plan)
"""
from litellm import acompletion
 
CLASSIFIER_SYSTEM = """Klassifiziere die User-Anfrage in eine Kategorie:
- ROUTE: einfache Frage, Klassifikation, Routing, Begruessung, kurze Antwort ohne Reasoning
- REASON: typische Tool-Use-Aufgabe, mehrstufiges Reasoning ueber konkrete Daten
- PLAN: komplexe Aggregation, Strategie, Multi-Step-Plan, Lange Analyse mit mehreren Datenquellen
 
Antworte mit EINEM Wort: ROUTE, REASON oder PLAN."""
 
async def vf_classify_route(data, user_api_key_dict, call_type):
    if data.get("model") != "vf-sonnet":
        return data
    user_msg = data["messages"][-1]["content"]
    # Skip-Classifier wenn User explicit das Modell waehlt (?model=)
    if data.get("metadata", {}).get("explicit_model"):
        return data
 
    resp = await acompletion(
        model="vf-haiku-classifier",
        messages=[
            {"role": "system", "content": CLASSIFIER_SYSTEM},
            {"role": "user", "content": user_msg[:500]},  # kurz halten
        ],
        max_tokens=10,
    )
    cls = resp.choices[0].message.content.strip().upper()
 
    if cls == "ROUTE":
        data["model"] = "vf-haiku"  # Sonnet → Haiku
    elif cls == "PLAN":
        data["model"] = "vf-opus"  # Sonnet → Opus
    # REASON: Sonnet bleibt
    return data

Schritt 5.3 — A/B-Test fuer Pre-Klassifikator (1 Std)

# Eval-Lauf gegen v2.9 mit Pre-Klassifikator aktiv
uv run evals/run.py --against v2.9-with-classifier --output evals/runs/2026-05-18-classifier.json
 
# Vergleich mit Baseline
python -c "
import json
b = json.load(open('evals/runs/2026-05-17-baseline.json'))
c = json.load(open('evals/runs/2026-05-18-classifier.json'))
print(f'Baseline: {b[\"avg_score\"]:.2f}')
print(f'Mit Classifier: {c[\"avg_score\"]:.2f}')
"

Decision Marvin: Wenn Classifier-Lauf > Baseline - 0.1 → keep. Wenn deutlich schlechter → revert. Erwartung: gleich oder leicht besser (Opus ist staerker fuer Plans).

Done-Kriterien

  • Opus in Bedrock aktiv und via Bedrock-CLI listbar
  • LiteLLM-Config deployed mit Pre-Hook
  • A/B-Test-Lauf in Vault committed
  • Decision-Eintrag in welle-1-completion (keep/revert)

Tag 5 (neu) — Tool-Schema-Caching + Welle-Abschluss (Impact 7, Effort 1 Tag)

Ziel: Cache-Marker auch auf Tool-Schemas — bislang werden 3-6k Tokens (16 Tools × Beschreibung) bei jedem Request voll bezahlt. Caching macht das nur bei der ersten Anfrage einer Session voll, danach 10 %.

Schritt 5.1 — LiteLLM cache_control_injection_points erweitern (1 Std)

Heute (Stack Zeile 562-564):

cache_control_injection_points:
  - location: message
    role: system

Erweitern auf:

cache_control_injection_points:
  - location: message
    role: system
  - location: tool             # NEU — Tool-Schemas auch cachen

Wichtig: LiteLLM-Doku pruefen ob location: tool der korrekte Syntax ist fuer Anthropic Bedrock-API. Falls nicht (z.B. nur Direct-Anthropic supported): explicit tools[].cache_control = {"type": "ephemeral"} pro Tool im Request anhaengen via custom Pre-Hook.

Fallback-Implementierung in hooks/vf_inject_tool_cache.py:

async def vf_inject_tool_cache(data, user_api_key_dict, call_type):
    # Anthropic-API erlaubt cache_control auf tools[]. Letztes Tool kriegt den Marker,
    # cached alle Tools davor (das ist Anthropic-Cache-Mechanik).
    if "tools" in data and len(data["tools"]) > 0:
        data["tools"][-1]["cache_control"] = {"type": "ephemeral"}
    return data

Schritt 5.2 — Cache-Hit-Rate verifizieren (30 Min)

Nach Deploy: 3 Test-Anfragen ueber vf-chat.agenticventures.de schicken. CloudWatch-Logs der litellm-Container pruefen auf cache_read_input_tokens Feld in Response-Metadata.

Erwartung: bei Anfrage #2 und #3 derselben Session sollte cache_read_input_tokens > 0 sein und in die Tausenden gehen (System-Prompt 4-6k + Tools 3-6k = 7-12k cached).

Schritt 5.3 — Welle-1-Eval-Lauf (1 Std)

Alle 25 Eval-Cases gegen die finale Welle-1-Config:

uv run evals/run.py --against v2.9-welle1-complete --output evals/runs/2026-05-XX-welle1-complete.json

Vergleich mit Baseline aus Tag 2:

python -c "
import json
b = json.load(open('evals/runs/2026-05-XX-baseline.json'))
c = json.load(open('evals/runs/2026-05-XX-welle1-complete.json'))
print(f'Baseline: {b[\"avg_score\"]:.2f}')
print(f'Welle-1: {c[\"avg_score\"]:.2f}')
print(f'Delta: {c[\"avg_score\"] - b[\"avg_score\"]:+.2f}')
"

Erwartung: Welle-1-Score = Baseline +/- 0.2 (keine Regression durch Routing).

Schritt 5.4 — Capability-File-Update + Welle-1-Run-Akte (1 Std)

In open-webui-vf:

  • audit_score: 8.6/10 (2026-05-XX, Welle 1 complete)
  • Modell-Sektion: 3 Backend-Modelle dokumentiert, UI zeigt nur vf-sonnet
  • Caching-Sektion: jetzt System + Tools beide gecached
  • Bekannte Issues 1 + 2 als RESOLVED markiert

In intern/runs/2026-05-XX-welle-1-completion/_index.md:

  • Was wurde gebaut (5 Items)
  • Score-Delta (7.0 → 8.6 erwartet)
  • Lessons-Learned aus den 5 Tagen
  • Trigger fuer Welle 2

Done-Kriterien Tag 5

  • Tool-Schema-Caching aktiv und cache_read_input_tokens > 0 bei 2. Request
  • Welle-1-Eval-Score ohne Regression
  • Capability-File + Run-Akte aktuell
  • Aggregat-Score (re-audited) >= 8.4

Original-Tag-6-Beschreibung (jetzt obsolet, ueberarbeitet in Tag 4 + Tag 5)

Schritt 6.1 — Welle-1-Eval-Lauf (1 Std)

Alle Aenderungen sind drin. Letzter Eval-Lauf:

uv run evals/run.py --against v2.9-welle1-complete --output evals/runs/2026-05-22-welle1-complete.json

Erwartung: avg_score ist gleich oder leicht hoeher als Baseline (Pre-Klassifikator soll keine Regression sein).

Schritt 6.2 — Capability-File updaten

In open-webui-vf:

  • last_reviewed: 2026-05-22
  • system_prompt_version: v2.9 (welle-1-complete)
  • „Bekannte Issues” Punkt 1 (SQLite-Lock) als RESOLVED markieren
  • „Bekannte Issues” Punkt 2 (Opus-Activation) als RESOLVED markieren
  • Audit-Health-Score-Eintrag: audit_score: 8.2/10 (2026-05-22, Welle 1 complete) mit Link auf Audit-Run

Schritt 6.3 — Welle-1-Completion-Run-Akte

intern/runs/2026-05-22-welle-1-completion/_index.md mit:

  • Was wurde gebaut (5 Items, kurz)
  • Score-Delta (6.6 → 8.2 erwartet, ggf. neu messen)
  • Lessons-Learned aus den 6 Tagen
  • Trigger fuer Welle 2 (was muss vorher noch geklaert sein?)

Schritt 6.4 — Audit re-laufen lassen

# Audit-Rubric gegen aktuellen Stand
# Output: intern/runs/2026-05-22-audit-openwebui-vf/report.md + baseline.json
# Diff gegen 2026-05-17-baseline.json

Soll der Aggregat-Score von 6.6 auf 8.2 hochgegangen sein? Wenn nicht: warum nicht? Lessons-Learned ergaenzen.

Done-Kriterien fuer Welle 1

  • Alle 5 Maßnahmen umgesetzt
  • Audit-Re-Run zeigt Score 8.0+ (Toleranz: 8.0 statt strikt 8.2)
  • Capability-File aktuell
  • Welle-1-Completion-Run-Akte abgeschlossen

Risiken + Mitigations (post-Option-C)

RisikoWahrscheinlichkeitMitigation
RDS-Migration zerstoert Conversation-Historyhochentfaellt — RDS war schon 2026-05-14 deployed
ZDR-Wartezeit > 7 Tagemittelparallel laufen lassen, kein Blocker fuer Welle 1
Eval-Cases zu wenig diversemittel25 Cases ueber 9 Kategorien decken die wichtigsten Patterns ab
Pre-Klassifikator falsch-routet (Plan → Haiku, Quality-Drop)mittelRouting-Eval-Cases (021-025) muessen vor Deploy passen. Revert = 1 Env-Var toggeln, ~5 Min
Backend-Routing exposed Modelle versehentlich im UIniedrigOpen-WebUI meta.hidden: true plus DEFAULT_MODELS=vf-sonnet doppelt absichern
Tool-Schema-Cache-Syntax unsupported in LiteLLMmittelFallback vf_inject_tool_cache Hook fuegt cache_control direkt in tools[]
LiteLLM Pre-Hook hat ungetestetes API-Verhaltenmittelerst lokal mit 5 Sample-Anfragen, dann Deploy
Klassifikator-Latenz addiert sich (Haiku-Call vor jedem Sonnet-Call)niedrigHaiku-Latenz ~200ms, akzeptabel. Falls spuerbar: nur fuer User-Anfragen > 5 Zeichen klassifizieren (siehe Hook-Code)

Cost-Tracking (post-Option-C)

ItemEUR/Mo (zusaetzlich)Bemerkung
RDS PostgreSQL t4g.micro Single-AZ+14entfaellt — schon deployed
Pre-Warming-Lambda (alle 4 Std)+1gegen Cold-Start
Bedrock Pre-Klassifikator (Haiku-Calls)+5~50 Input + 5 Output Tokens × 500 Anfragen/Mo
CloudWatch Custom Metrics+56 Custom-Metrics × ~$0.30
SNS Alarm-Topic+1low-volume
Buffer+14Reserve
Gesamt zusaetzlich (Fix)+26 EUR/Mo

Cost-Save-Erwartung (variabel, gegenrechnung):

  • Tool-Schema-Caching: Annahme: 3-6k Tool-Tokens, bei 70 % Cache-Hit-Rate (zweite-Anfrage-aufwaerts in einer Session): ~-2-4k Tokens × 90 % Discount × Sonnet-Input-Cost = je nach Volume ~10-30 EUR/Mo
  • Backend-Routing: Annahme: 30 % der Anfragen sind ROUTE (→ Haiku, 70 % billiger als Sonnet), 10 % sind PLAN (→ Opus, 4× teurer). Netto bei 500 Anfragen/Mo: ~-20-40 EUR/Mo
  • Total Save geschaetzt: 30-70 EUR/Mo bei aktuellem Volume

Netto Welle-1: Cost-positiv ab ~50 EUR/Mo Bedrock-Volume. VF-Stack-Bedrock-Cost im Mai bisher ~30-150 EUR/Mo variabel — Welle-1 spart in den meisten Monaten netto Geld.

Naechste Schritte fuer Welle 2

Nach Welle 1: 4-6 Wochen Beobachtung. Dann Welle 2 starten wenn:

  • Mind. 1 weiterer Production-Bug in Lessons-Learned-Schleife durchgelaufen ist
  • Eval-Suite mind. einen Regression-Treffer dokumentiert hat
  • ZDR/AVV signiert vorliegen

Welle-2-Plan: welle-2-reife

Cross-Refs