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)
| Bereich | Vorher (korrigiert) | Nachher | Wie |
|---|---|---|---|
| Memory & State | 7 | RDS PostgreSQL bereits deployed seit 2026-05-14 — keine Aktion in Welle 1 | |
| Evals | 1 | 7 | 25 Cases (20 + 5 Routing) + LLM-Judge + CLI-Runner |
| Security & DSGVO | 7 | 8.5 | ZDR-Addendum + VF-AVV final |
| Observability | 5 | 7 | Tool-Call-Success-Rate + Cost-Per-User |
| Modell-Auswahl | 8 | 9.5 | Opus aktiv + Backend-Routing (Haiku/Sonnet/Opus, im UI versteckt) |
| Cost & Latency | 8 | 9.5 | Tool-Schema-Caching + Backend-Routing fuer Cost-Optimum |
| Aggregat | 8.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-prodaktiv (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-prodSchritt 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-prodSchritt 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/evalsIn 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.jsonAus den letzten 7 Tagen 20 reale Anfragen auswaehlen, kategorisiert nach Use-Case:
| Kategorie | Anzahl Cases |
|---|---|
| Papierkram Listen + Filter (Rechnungen, Belege) | 4 |
| Papierkram Aggregate (Monatsabschluss, Offene Posten) | 3 |
| TicketPAY Event-Bilanz | 3 |
| M365 Mail-Workflow (Lesen, Draft, Reply) | 3 |
| SharePoint Excel-Read + Aggregate | 2 |
| SharePoint File-Suche mit Direktlink | 2 |
| Error-Recovery (Tool gibt 404, Tool nicht da) | 2 |
| Sicherheits-Cases (Prompt-Injection-Versuch in Tool-Output) | 1 |
| Gesamt | 20 |
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.jsonErwartung: 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>. Wennavg_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-prod4.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-prodSNS-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 Access → Claude 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: trueUI-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 dataIn LiteLLM-Config registrieren:
litellm_settings:
pre_call_hooks:
- hook_name: vf_classify_route
module_path: /etc/litellm/hooks/vf_classify_route.pyCost-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.jsonErwartung:
- ROUTE-Cases: muessen
vf-haiku-backendtriggern (siehemetadata.vf_routed_to) - REASON-Cases: bleiben
vf-sonnet - PLAN-Cases: muessen
vf-opus-backendtriggern
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_tologged - Routing-Eval-Cases: Treffer-Rate > 80 %
Schritt 5.1 — Opus 4.7 in Bedrock aktivieren (2 Min)
Bedrock Console → Model Access → Claude 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.pyhooks/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 dataSchritt 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: systemErweitern auf:
cache_control_injection_points:
- location: message
role: system
- location: tool # NEU — Tool-Schemas auch cachenWichtig: 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 dataSchritt 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.jsonVergleich 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.jsonErwartung: 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-22system_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.jsonSoll 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)
| Risiko | Wahrscheinlichkeit | Mitigation |
|---|---|---|
| hoch | entfaellt — RDS war schon 2026-05-14 deployed | |
| ZDR-Wartezeit > 7 Tage | mittel | parallel laufen lassen, kein Blocker fuer Welle 1 |
| Eval-Cases zu wenig diverse | mittel | 25 Cases ueber 9 Kategorien decken die wichtigsten Patterns ab |
| Pre-Klassifikator falsch-routet (Plan → Haiku, Quality-Drop) | mittel | Routing-Eval-Cases (021-025) muessen vor Deploy passen. Revert = 1 Env-Var toggeln, ~5 Min |
| Backend-Routing exposed Modelle versehentlich im UI | niedrig | Open-WebUI meta.hidden: true plus DEFAULT_MODELS=vf-sonnet doppelt absichern |
| Tool-Schema-Cache-Syntax unsupported in LiteLLM | mittel | Fallback vf_inject_tool_cache Hook fuegt cache_control direkt in tools[] |
| LiteLLM Pre-Hook hat ungetestetes API-Verhalten | mittel | erst lokal mit 5 Sample-Anfragen, dann Deploy |
| Klassifikator-Latenz addiert sich (Haiku-Call vor jedem Sonnet-Call) | niedrig | Haiku-Latenz ~200ms, akzeptabel. Falls spuerbar: nur fuer User-Anfragen > 5 Zeichen klassifizieren (siehe Hook-Code) |
Cost-Tracking (post-Option-C)
| Item | EUR/Mo (zusaetzlich) | Bemerkung |
|---|---|---|
| entfaellt — schon deployed | ||
| Pre-Warming-Lambda (alle 4 Std) | +1 | gegen Cold-Start |
| Bedrock Pre-Klassifikator (Haiku-Calls) | +5 | ~50 Input + 5 Output Tokens × 500 Anfragen/Mo |
| CloudWatch Custom Metrics | +5 | 6 Custom-Metrics × ~$0.30 |
| SNS Alarm-Topic | +1 | low-volume |
| Buffer | +14 | Reserve |
| 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
- _index — VF-OpenWebUI-Projekt-Hauptseite
- report — Audit der diese Welle ausloest
- agent-system-best-practices — Source der 10 Prinzipien
- open-webui-vf — Capability-File mit Stack-Status
- open-webui-fargate-bedrock — Pattern-File (Pflicht zum Update bei RDS-Migration!)