#!/bin/bash # Lokaler Test des AI Profile Router (Mock-llama.cpp + Fake-Profil-Skript). # Startet Mock + Router, führt alle Testfälle aus, beendet beide. set -uo pipefail cd "$(dirname "$0")/.." UP_PORT="${UP_PORT:-18080}" RT_PORT="${RT_PORT:-18081}" TTS_PORT="${TTS_PORT:-18082}" STT_PORT="${STT_PORT:-18083}" BASE="http://127.0.0.1:$RT_PORT" FAKE_DIR="$PWD/dev/fake-profile-dir" TEST_ROUTER_KEY="test-router-key-0123456789-abcdefghijklmnopqrstuvwxyz" PASS=0 FAIL=0 # Produktive Authentifizierung für alle Integrationstests. `command curl` # umgeht diese Funktion bei den gezielten anonymen Negativtests. curl() { command curl -H "Authorization: Bearer $TEST_ROUTER_KEY" "$@"; } cleanup() { CURRENT_MOCK_PID="$(cat /tmp/mock_upstream_pid 2>/dev/null || true)" kill "${MOCK_PID:-}" "${CURRENT_MOCK_PID:-}" "${ROUTER_PID:-}" \ "${TTS_PID:-}" "${STT_PID:-}" 2>/dev/null || true rm -f /tmp/mock_pid2 /tmp/mock_upstream_pid /tmp/test-router-state.json \ /tmp/fake-profile-fail cp "$FAKE_DIR/profile-fast.conf.disabled" "$FAKE_DIR/override.conf" wait 2>/dev/null || true } trap cleanup EXIT ok() { echo " PASS: $1"; PASS=$((PASS+1)); } bad() { echo " FAIL: $1"; FAIL=$((FAIL+1)); } wait_http() { local url="$1" name="$2" for _ in $(seq 1 50); do curl -sf "$url" >/dev/null 2>&1 && return 0 sleep 0.1 done echo "FEHLER: $name wurde nicht bereit: $url" >&2 return 1 } # --- Mock-llama.cpp starten (über Fake-systemctl) ------------------------------ echo "== Starte Mock-llama.cpp (Port $UP_PORT)" cp "$FAKE_DIR/profile-fast.conf.disabled" "$FAKE_DIR/override.conf" rm -f /tmp/test-router-state.json /tmp/mock_upstream_pid FAKE_SYSTEMD_PIDFILE=/tmp/mock_upstream_pid \ FAKE_SYSTEMD_PORT="$UP_PORT" \ FAKE_SYSTEMD_PROFILE_DIR="$FAKE_DIR" \ FAKE_SYSTEMD_MOCK="$PWD/dev/mock_upstream.py" \ FAKE_SYSTEMD_LOG=/tmp/mock_upstream.log \ bash dev/fake-systemctl.sh start sleep 0.5 MOCK_PID=$(cat /tmp/mock_upstream_pid 2>/dev/null || echo "") wait_http "http://127.0.0.1:$UP_PORT/health" "Mock-llama.cpp" || exit 1 # --- Router starten ----------------------------------------------------------- echo "== Starte Router (Port $RT_PORT)" rm -rf /tmp/test-images ROUTER_HOST=127.0.0.1 ROUTER_PORT="$RT_PORT" \ ROUTER_AUTH_MODE=required ROUTER_API_KEY="$TEST_ROUTER_KEY" \ ROUTER_PROFILES_FILE= \ ROUTER_STATE_FILE=/tmp/test-router-state.json \ UPSTREAM_URL="http://127.0.0.1:$UP_PORT" \ PROFILE_SCRIPT="$PWD/dev/fake-llama-profile.sh" \ PROFILE_DIR="$FAKE_DIR" \ SWITCH_TIMEOUT=30 \ SYSTEMCTL_BIN="$PWD/dev/fake-systemctl.sh" \ FAKE_SYSTEMD_PIDFILE=/tmp/mock_upstream_pid \ FAKE_SYSTEMD_PORT="$UP_PORT" \ FAKE_SYSTEMD_PROFILE_DIR="$FAKE_DIR" \ FAKE_SYSTEMD_MOCK="$PWD/dev/mock_upstream.py" \ FAKE_SYSTEMD_LOG=/tmp/mock_upstream_fake.log \ IMAGE_WORKER="$PWD/dev/mock_image_worker.py" \ IMAGE_PYTHON=python3 \ IMAGE_DIR=/tmp/test-images \ IMAGE_WORKER_LOG=/tmp/test_worker.log \ IMAGE_GEN_TIMEOUT=30 \ MOCK_WORKER_LOG=/tmp/test_worker_requests.jsonl \ TTS_WORKER_URL="http://127.0.0.1:$TTS_PORT" \ STT_WORKER_URL="http://127.0.0.1:$STT_PORT" \ python3 router/ai_profile_router.py >/tmp/router_test.log 2>&1 & ROUTER_PID=$! wait_http "$BASE/health" "Router" || { cat /tmp/router_test.log >&2 exit 1 } rm -f /tmp/test_worker_requests.jsonl echo "== Test 0: Authentifizierung + Health/Readiness" CODE=$(command curl -s -o /tmp/err0.json -w "%{http_code}" "$BASE/status") [ "$CODE" = "401" ] && ok "Status ohne Key → 401" || bad "Status ohne Key: HTTP $CODE" CODE=$(command curl -s -o /dev/null -w "%{http_code}" "$BASE/health") [ "$CODE" = "200" ] && ok "öffentliche Liveness → 200" || bad "Liveness: HTTP $CODE" CODE=$(command curl -s -o /dev/null -w "%{http_code}" "$BASE/ready") [ "$CODE" = "200" ] && ok "öffentliche Readiness → 200" || bad "Readiness: HTTP $CODE" # --- Mock-TTS-Worker starten ---------------------------------------------------- echo "== Starte Mock-TTS-Worker (Port $TTS_PORT)" MOCK_TTS_PORT="$TTS_PORT" MOCK_TTS_DELAY=0.1 \ MOCK_TTS_LOG=/tmp/test_tts_requests.jsonl \ python3 dev/mock_tts_worker.py >/tmp/mock_tts.log 2>&1 & TTS_PID=$! wait_http "http://127.0.0.1:$TTS_PORT/status" "Mock-TTS" || exit 1 rm -f /tmp/test_tts_requests.jsonl # --- Mock-STT-Worker starten ---------------------------------------------------- echo "== Starte Mock-STT-Worker (Port $STT_PORT)" MOCK_STT_PORT="$STT_PORT" MOCK_STT_DELAY=0.1 \ MOCK_STT_LOG=/tmp/test_stt_requests.jsonl \ python3 dev/mock_stt_worker.py >/tmp/mock_stt.log 2>&1 & STT_PID=$! wait_http "http://127.0.0.1:$STT_PORT/status" "Mock-STT" || exit 1 rm -f /tmp/test_stt_requests.jsonl # --- 1. /v1/models ------------------------------------------------------------- echo "== Test 1: /v1/models" RESP=$(curl -sf "$BASE/v1/models") echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) ids={m["id"]:m for m in d["data"]} assert set(ids)=={"qwen-fast","qwen-medium","qwen-large","qwen-ultra","qwen-uncensored"}, ids assert ids["qwen-fast"]["context_length"]==76800 assert ids["qwen-medium"]["context_length"]==160000 assert ids["qwen-large"]["context_length"]==192000 assert ids["qwen-ultra"]["context_length"]==262144 assert ids["qwen-uncensored"]["context_length"]==80000 ' && ok "fünf virtuelle Modelle mit korrekten Context Windows" || bad "/v1/models" # --- 2. /status ----------------------------------------------------------------- echo "== Test 2: /status" RESP=$(curl -sf "$BASE/status") echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) assert d["current_profile"]=="fast", d assert d["upstream"]["reachable"] is True, d assert d["upstream"]["ctx"]==76800, d ' && ok "Status zeigt Profil fast + erreichbares Upstream" || bad "/status" # --- 3. Normales Forwarding (non-streaming) ------------------------------------- echo "== Test 3: Forwarding non-streaming (qwen-fast)" RESP=$(curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \ -d '{"model":"qwen-fast","messages":[{"role":"user","content":"Hallo"}]}') echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) assert d["model"].startswith("mock-model-"), d assert "Mock-Antwort" in d["choices"][0]["message"]["content"], d assert d.get("mock_authorization") is None, d ' && ok "Request wurde weitergeleitet, Modell ersetzt" || bad "Forwarding" # --- 4. Streaming ---------------------------------------------------------------- echo "== Test 4: Streaming (SSE)" RESP=$(curl -sfN "$BASE/v1/chat/completions" -H "Content-Type: application/json" \ -d '{"model":"qwen-fast","stream":true,"messages":[{"role":"user","content":"Hallo"}]}') echo "$RESP" echo "$RESP" | grep -q "data: " && echo "$RESP" | grep -q "\[DONE\]" \ && ok "SSE-Stream mit [DONE] erhalten" || bad "Streaming" # --- 5. Tool Calls ----------------------------------------------------------------- echo "== Test 5: Tool Calls" RESP=$(curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \ -d '{"model":"qwen-fast","messages":[{"role":"user","content":"Wetter?"}], "tools":[{"type":"function","function":{"name":"get_weather","parameters":{}}}]}' ) echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) tc=d["choices"][0]["message"]["tool_calls"] assert tc[0]["function"]["name"]=="get_weather", d assert d["choices"][0]["finish_reason"]=="tool_calls", d ' && ok "Tool Calls transparent weitergereicht" || bad "Tool Calls" # --- 6. Profilwechsel fast -> medium ------------------------------------------------ echo "== Test 6: Profilwechsel fast -> medium" RESP=$(curl -sf -X POST "$BASE/medium") echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) assert d["profile"]=="medium" and d["context_length"]==160000, d ' && ok "Profil medium aktiv" || bad "Profilwechsel medium" curl -sf "$BASE/status" | python3 -c ' import json,sys d=json.load(sys.stdin) assert d["current_profile"]=="medium", d assert d["upstream"]["ctx"]==160000, d ' && ok "Status bestätigt medium (ctx 160000)" || bad "Status nach Wechsel" # --- 7. Profilwechsel medium -> fast -------------------------------------------------- echo "== Test 7: Profilwechsel medium -> fast" RESP=$(curl -sf -X POST "$BASE/fast") echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) assert d["profile"]=="fast" and d["context_length"]==76800, d ' && ok "Profil fast wieder aktiv" || bad "Profilwechsel fast" # --- 8. Virtuelles Modell triggert Profilwechsel ---------------------------------------- echo "== Test 8: Chat mit qwen-large triggert Wechsel auf large" RESP=$(curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \ -d '{"model":"qwen-large","messages":[{"role":"user","content":"Hallo"}]}') echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) assert d["model"]=="mock-model-192000", d ' && ok "qwen-large hat Profil large aktiviert und weitergeleitet" || bad "virtuelles Modell" # --- 9. Methoden und ungültiges virtuelles Modell ----------------------------------------- echo "== Test 9: sichere Profilmethoden + ungültiges virtuelles Modell" CODE=$(curl -s -o /tmp/err9.json -w "%{http_code}" "$BASE/large") cat /tmp/err9.json; echo [ "$CODE" = "405" ] && ok "GET /large verändert kein Profil" || bad "erwartet 405, bekam $CODE" CODE=$(curl -s -o /tmp/err9b.json -w "%{http_code}" -X POST "$BASE/v1/chat/completions" \ -H "Content-Type: application/json" -d '{"model":"qwen-huge","messages":[]}') cat /tmp/err9b.json; echo [ "$CODE" = "400" ] && ok "400 bei unbekanntem virtuellen Modell (qwen-huge)" || bad "erwartet 400, bekam $CODE" # Unbekannte einteilige POST-Pfade gehören dem Upstream, nicht dem Profilrouter. CODE=$(curl -s -o /tmp/err9c.json -w "%{http_code}" -X POST "$BASE/tokenize" \ -H "Content-Type: application/json" -d '{"content":"Hallo"}') [ "$CODE" = "404" ] && ok "POST /tokenize wurde transparent weitergeleitet" \ || bad "erwartet Upstream-404, bekam $CODE" echo medium >/tmp/fake-profile-fail CODE=$(curl -s -o /tmp/err9d.json -w "%{http_code}" -X POST "$BASE/medium") [ "$CODE" = "503" ] && ok "fehlgeschlagenes Profilskript → 503" \ || bad "Profilskript-Fehler: HTTP $CODE" rm -f /tmp/fake-profile-fail CODE=$(curl -s -o /tmp/chat9e.json -w "%{http_code}" "$BASE/v1/chat/completions" \ -H "Content-Type: application/json" \ -d '{"model":"qwen-fast","messages":[{"role":"user","content":"noch da?"}]}') [ "$CODE" = "200" ] && ok "vorheriges Profil bleibt nach Skriptfehler verfügbar" \ || bad "Qwen nach Profilskript-Fehler: HTTP $CODE" # --- 10. llama.cpp down -> 502, danach Recovery --------------------------------------------------- echo "== Test 10: Upstream down -> 502, danach Recovery" # Profil auf fast setzen (aus Test 8 ist large aktiv) curl -sf -X POST "$BASE/fast" >/dev/null # Mock stoppen (simuliert Crash) – über Fake-systemctl FAKE_SYSTEMD_PIDFILE=/tmp/mock_upstream_pid FAKE_SYSTEMD_PORT="$UP_PORT" \ bash dev/fake-systemctl.sh stop sleep 0.5 CODE=$(command curl -s -o /dev/null -w "%{http_code}" "$BASE/health") [ "$CODE" = "200" ] && ok "Liveness bleibt bei downem Modell 200" || bad "Liveness down: HTTP $CODE" CODE=$(command curl -s -o /dev/null -w "%{http_code}" "$BASE/ready") [ "$CODE" = "503" ] && ok "Readiness zeigt downes Modell mit 503" || bad "Readiness down: HTTP $CODE" CODE=$(curl -s -o /tmp/err10.json -w "%{http_code}" -X POST "$BASE/v1/chat/completions" \ -H "Content-Type: application/json" -d '{"model":"qwen-fast","messages":[]}') cat /tmp/err10.json; echo [ "$CODE" = "502" ] && ok "502 bei downem Upstream (Profil bereits aktiv)" || bad "erwartet 502, bekam $CODE" # Mock neu starten (simuliert systemctl restart durch das Profil-Skript) FAKE_SYSTEMD_PIDFILE=/tmp/mock_upstream_pid FAKE_SYSTEMD_PORT="$UP_PORT" \ FAKE_SYSTEMD_PROFILE_DIR="$FAKE_DIR" FAKE_SYSTEMD_MOCK="$PWD/dev/mock_upstream.py" \ FAKE_SYSTEMD_LOG=/tmp/mock_upstream2.log \ bash dev/fake-systemctl.sh start RESP=$(curl -sf -X POST "$BASE/fast") echo "$RESP" | python3 -m json.tool # neuen Mock als MOCK_PID übernehmen, damit Cleanup ihn beendet [ -f /tmp/mock_upstream_pid ] && MOCK_PID=$(cat /tmp/mock_upstream_pid) echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) assert d["profile"]=="fast" and d["model"]=="mock-model-76800", d ' && ok "Recovery: /fast wartet auf Upstream, dann Erfolg" || bad "Recovery" # --- 11. Bildgenerierung (Mock-Worker) ------------------------------------------------- echo "== Test 11: POST /v1/images/generations (1024x1024)" RESP=$(curl -sf "$BASE/v1/images/generations" -H "Content-Type: application/json" \ -d '{"prompt":"ein rotes Haus","size":"1024x1024"}') echo "$RESP" | python3 -m json.tool IMG_NAME=$(echo "$RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"][0]["url"].rsplit("/",1)[1])') echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) assert len(d["data"])==1, d assert d["data"][0]["url"].startswith("http://"), d ' && [ -f "/tmp/test-images/$IMG_NAME" ] \ && ok "Bild generiert und gespeichert ($IMG_NAME)" || bad "Bildgenerierung" # --- 12. Bild-Download ----------------------------------------------------------------- echo "== Test 12: GET /images/" CODE=$(curl -s -o /tmp/test_dl.png -w "%{http_code}" -D /tmp/hdr12.txt "$BASE/images/$IMG_NAME") CTYPE=$(grep -i content-type /tmp/hdr12.txt | tr -d "\r") [ "$CODE" = "200" ] && [ -s /tmp/test_dl.png ] && echo "$CTYPE" | grep -qi "image/png" \ && ok "PNG-Download (200, $CTYPE)" || bad "PNG-Download (Code $CODE, $CTYPE)" # --- 13. Bild-Liste --------------------------------------------------------------------- echo "== Test 13: GET /images" RESP=$(curl -sf "$BASE/images") echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c " import json,sys d=json.load(sys.stdin) names=[i['name'] for i in d['images']] assert '$IMG_NAME' in names, names " && ok "Bild in Liste enthalten" || bad "Bild-Liste" # --- 14. Validierung --------------------------------------------------------------------- echo "== Test 14: Validierung (Größe, Prompt, n)" CODE=$(curl -s -o /tmp/err14a.json -w "%{http_code}" "$BASE/v1/images/generations" \ -H "Content-Type: application/json" -d '{"prompt":"x","size":"500x500"}') cat /tmp/err14a.json; echo [ "$CODE" = "400" ] && ok "400 bei ungültiger Größe" || bad "erwartet 400, bekam $CODE" CODE=$(curl -s -o /tmp/err14b.json -w "%{http_code}" "$BASE/v1/images/generations" \ -H "Content-Type: application/json" -d '{"size":"1024x1024"}') cat /tmp/err14b.json; echo [ "$CODE" = "400" ] && ok "400 bei fehlendem Prompt" || bad "erwartet 400, bekam $CODE" CODE=$(curl -s -o /tmp/err14c.json -w "%{http_code}" "$BASE/v1/images/generations" \ -H "Content-Type: application/json" -d '{"prompt":"x","n":9}') cat /tmp/err14c.json; echo [ "$CODE" = "400" ] && ok "400 bei n=9 (max 4)" || bad "erwartet 400, bekam $CODE" # --- 15. b64_json + n=2 + Seed ------------------------------------------------------------- echo "== Test 15: response_format=b64_json, n=2, seed" RESP=$(curl -sf "$BASE/v1/images/generations" -H "Content-Type: application/json" \ -d '{"prompt":"zwei Bilder","size":"1024x1024","n":2,"seed":42,"response_format":"b64_json"}') echo "$RESP" | python3 -c ' import json,sys,base64 d=json.load(sys.stdin) assert len(d["data"])==2, d for item in d["data"]: assert item["url"] is None, item png=base64.b64decode(item["b64_json"]) assert png[:4]==b"\x89PNG", "kein PNG" ' && ok "2 Bilder als b64_json (gültige PNGs)" || bad "b64_json/n=2" # --- 16. /status zeigt Bild-Zustand --------------------------------------------------------- echo "== Test 16: /status mit Bild-Section" RESP=$(curl -sf "$BASE/status") echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) img=d["image"] assert img["phase"]=="idle", img assert img["worker"]=="stopped", img # Worker wird nach Job beendet assert img["model_loaded"] is False, img assert img["last_image"], img assert img["last_error"] is None, img q=d["qwen"] assert q["available"] is True, q assert q["active_chats"]==0, q ' && ok "Status: phase=idle, worker=stopped, qwen verfügbar" || bad "Status Bild-Section" # --- 17. Qwen nach Bildgenerierung erreichbar ------------------------------------------------- echo "== Test 17: Qwen nach Bildgenerierung erreichbar" RESP=$(curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \ -d '{"model":"qwen-fast","messages":[{"role":"user","content":"Hallo"}]}') echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) assert "Mock-Antwort" in d["choices"][0]["message"]["content"], d ' && ok "Chat funktioniert nach Bildgenerierung" || bad "Chat nach Bild" # --- 18. quality=standard stays on the validated four-step FLUX path ---------------------------- echo "== Test 18: quality=standard → 4 Steps" rm -f /tmp/test_worker_requests.jsonl RESP=$(curl -sf "$BASE/v1/images/generations" -H "Content-Type: application/json" \ -d '{"prompt":"standard test","size":"1024x1024","quality":"standard"}') sleep 0.3 STEPS=$(tail -1 /tmp/test_worker_requests.jsonl 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["steps"])' 2>/dev/null || echo "?") [ "$STEPS" = "4" ] && ok "quality=standard → 4 Steps" || bad "erwartet 4 Steps, bekam $STEPS" # --- 19. quality=high stays bounded for the distilled four-step model ----------------------------- echo "== Test 19: quality=high → 4 Steps" rm -f /tmp/test_worker_requests.jsonl RESP=$(curl -sf "$BASE/v1/images/generations" -H "Content-Type: application/json" \ -d '{"prompt":"high test","size":"1024x1024","quality":"high"}') sleep 0.3 STEPS=$(tail -1 /tmp/test_worker_requests.jsonl 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["steps"])' 2>/dev/null || echo "?") [ "$STEPS" = "4" ] && ok "quality=high → 4 Steps" || bad "erwartet 4 Steps, bekam $STEPS" # --- 20. ungültige Qualität → 400 ------------------------------------------------------------------ echo "== Test 20: ungültige Qualität → 400" CODE=$(curl -s -o /tmp/err20.json -w "%{http_code}" "$BASE/v1/images/generations" \ -H "Content-Type: application/json" -d '{"prompt":"x","quality":"bogus"}') cat /tmp/err20.json; echo [ "$CODE" = "400" ] && ok "400 bei ungültiger Qualität" || bad "erwartet 400, bekam $CODE" # --- 21. Image-Fehler → Qwen wiederhergestellt ------------------------------------------------------ echo "== Test 21: Image-Fehler → Qwen wiederhergestellt" curl -sf -X POST "$BASE/fast" >/dev/null CODE=$(curl -s -o /tmp/err21.json -w "%{http_code}" "$BASE/v1/images/generations" \ -H "Content-Type: application/json" -d '{"prompt":"FAIL","size":"1024x1024"}') cat /tmp/err21.json; echo sleep 0.5 RESP=$(curl -sf "$BASE/status") echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) assert d["current_profile"]=="fast", d assert d["upstream"]["reachable"] is True, d assert d["qwen"]["available"] is True, d ' && ok "Qwen nach Image-Fehler wiederhergestellt (fast, erreichbar)" || bad "Qwen nicht wiederhergestellt" # --- 22. Fast → Image → Fast ------------------------------------------------------------------------ echo "== Test 22: Fast → Image → Fast" curl -sf -X POST "$BASE/fast" >/dev/null RESP=$(curl -sf "$BASE/v1/images/generations" -H "Content-Type: application/json" \ -d '{"prompt":"fast test","size":"1024x1024"}') sleep 0.5 PROFILE=$(curl -sf "$BASE/status" | python3 -c 'import json,sys; print(json.load(sys.stdin)["current_profile"])') [ "$PROFILE" = "fast" ] && ok "Fast → Image → Fast" || bad "Profil nach Image: $PROFILE (erwartet fast)" # --- 23. Medium → Image → Medium -------------------------------------------------------------------- echo "== Test 23: Medium → Image → Medium" curl -sf -X POST "$BASE/medium" >/dev/null RESP=$(curl -sf "$BASE/v1/images/generations" -H "Content-Type: application/json" \ -d '{"prompt":"medium test","size":"1024x1024"}') sleep 0.5 PROFILE=$(curl -sf "$BASE/status" | python3 -c 'import json,sys; print(json.load(sys.stdin)["current_profile"])') [ "$PROFILE" = "medium" ] && ok "Medium → Image → Medium" || bad "Profil nach Image: $PROFILE (erwartet medium)" # --- 24. Large → Image → Large ---------------------------------------------------------------------- echo "== Test 24: Large → Image → Large" curl -sf -X POST "$BASE/large" >/dev/null RESP=$(curl -sf "$BASE/v1/images/generations" -H "Content-Type: application/json" \ -d '{"prompt":"large test","size":"1024x1024"}') sleep 0.5 PROFILE=$(curl -sf "$BASE/status" | python3 -c 'import json,sys; print(json.load(sys.stdin)["current_profile"])') [ "$PROFILE" = "large" ] && ok "Large → Image → Large" || bad "Profil nach Image: $PROFILE (erwartet large)" # --- 25. /status während Image-Job ------------------------------------------------------------------- echo "== Test 25: /status während Image-Job" curl -sf -X POST "$BASE/fast" >/dev/null curl -sf "$BASE/v1/images/generations" -H "Content-Type: application/json" \ -d '{"prompt":"SLOW","size":"1024x1024"}' >/tmp/img25.json 2>&1 & IMG_PID=$! sleep 1.5 RESP=$(curl -sf "$BASE/status") echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) img=d["image"] assert img["phase"]!="idle", img assert d["qwen"]["available"] is False, d ' && ok "Status während Image-Job: phase!=idle, qwen unavailable" || bad "Status während Image-Job" wait $IMG_PID sleep 0.5 RESP=$(curl -sf "$BASE/status") echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) assert d["qwen"]["available"] is True, d assert d["image"]["phase"]=="idle", d ' && ok "Nach Image-Job: qwen verfügbar, phase=idle" || bad "Nach Image-Job" # --- 26. paralleler Chat während Image-Job (wartet, kein 502) ---------------------------------------- echo "== Test 26: paralleler Chat während Image-Job (wartet, kein 502)" curl -sf -X POST "$BASE/fast" >/dev/null curl -sf "$BASE/v1/images/generations" -H "Content-Type: application/json" \ -d '{"prompt":"SLOW","size":"1024x1024"}' >/tmp/img26.json 2>&1 & IMG_PID=$! sleep 1.5 START=$(date +%s) CODE=$(curl -s -o /tmp/chat26.json -w "%{http_code}" "$BASE/v1/chat/completions" \ -H "Content-Type: application/json" -d '{"model":"qwen-fast","messages":[{"role":"user","content":"Hallo"}]}') END=$(date +%s) ELAPSED=$((END-START)) cat /tmp/chat26.json; echo wait $IMG_PID [ "$CODE" = "200" ] && [ "$ELAPSED" -ge 2 ] \ && ok "Chat wartete ${ELAPSED}s (kein 502), dann 200" || bad "Chat: Code $CODE, ${ELAPSED}s" # --- 27. TTS: /status zeigt tts-Section --------------------------------------------------------------- echo "== Test 27: /status mit tts-Section" RESP=$(curl -sf "$BASE/status") echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) tts=d["tts"] assert tts["reachable"] is True, tts assert tts["ready"] is True, tts assert set(tts["voices"])=={"claribel"}, tts ' && ok "Status: TTS erreichbar, bereit, 2 Stimmen" || bad "Status tts-Section" # --- 28. TTS: POST /v1/audio/speech (wav) --------------------------------------------------------------- echo "== Test 28: POST /v1/audio/speech (wav)" CODE=$(curl -s -o /tmp/tts28.wav -w "%{http_code}" -D /tmp/hdr28.txt \ "$BASE/v1/audio/speech" -H "Content-Type: application/json" \ -d '{"model":"xtts-v2","input":"Hallo Welt","voice":"claribel","response_format":"wav"}') CTYPE=$(grep -i content-type /tmp/hdr28.txt | tr -d "\r") [ "$CODE" = "200" ] && [ -s /tmp/tts28.wav ] && echo "$CTYPE" | grep -qi "audio/wav" \ && ok "TTS wav (200, $CTYPE, $(stat -f%z /tmp/tts28.wav 2>/dev/null || stat -c%s /tmp/tts28.wav) Bytes)" \ || bad "TTS wav (Code $CODE, $CTYPE)" # --- 29. TTS: POST /v1/audio/speech (mp3, Default) ------------------------------------------------------- echo "== Test 29: POST /v1/audio/speech (mp3, Default)" CODE=$(curl -s -o /tmp/tts29.mp3 -w "%{http_code}" -D /tmp/hdr29.txt \ "$BASE/v1/audio/speech" -H "Content-Type: application/json" \ -d '{"input":"Guten Tag","voice":"claribel"}') CTYPE=$(grep -i content-type /tmp/hdr29.txt | tr -d "\r") [ "$CODE" = "200" ] && [ -s /tmp/tts29.mp3 ] && echo "$CTYPE" | grep -qi "audio/mpeg" \ && ok "TTS mp3 (200, $CTYPE)" || bad "TTS mp3 (Code $CODE, $CTYPE)" # --- 30. TTS: Validierung -------------------------------------------------------------------------------- echo "== Test 30: TTS-Validierung" CODE=$(curl -s -o /tmp/err30a.json -w "%{http_code}" "$BASE/v1/audio/speech" \ -H "Content-Type: application/json" -d '{"voice":"claribel"}') cat /tmp/err30a.json; echo [ "$CODE" = "400" ] && ok "400 bei fehlendem input" || bad "erwartet 400, bekam $CODE" CODE=$(curl -s -o /tmp/err30b.json -w "%{http_code}" "$BASE/v1/audio/speech" \ -H "Content-Type: application/json" -d '{"input":"x","voice":"bogus"}') cat /tmp/err30b.json; echo [ "$CODE" = "400" ] && ok "400 bei ungültiger Stimme" || bad "erwartet 400, bekam $CODE" CODE=$(curl -s -o /tmp/err30c.json -w "%{http_code}" "$BASE/v1/audio/speech" \ -H "Content-Type: application/json" -d '{"input":"x","response_format":"ogg"}') cat /tmp/err30c.json; echo [ "$CODE" = "400" ] && ok "400 bei ungültigem Format" || bad "erwartet 400, bekam $CODE" CODE=$(curl -s -o /tmp/err30d.json -w "%{http_code}" "$BASE/v1/audio/speech" \ -H "Content-Type: application/json" -d '{"input":"x","model":"gpt-4"}') cat /tmp/err30d.json; echo [ "$CODE" = "400" ] && ok "400 bei unbekanntem Modell" || bad "erwartet 400, bekam $CODE" # --- 31. TTS: Worker-Fehler → 503 ------------------------------------------------------------------------ echo "== Test 31: TTS-Worker-Fehler → 503" CODE=$(curl -s -o /tmp/err31.json -w "%{http_code}" "$BASE/v1/audio/speech" \ -H "Content-Type: application/json" -d '{"input":"FAIL","voice":"claribel"}') cat /tmp/err31.json; echo [ "$CODE" = "503" ] && ok "503 bei TTS-Worker-Fehler" || bad "erwartet 503, bekam $CODE" # --- 32. TTS: Worker down → 503 --------------------------------------------------------------------------- echo "== Test 32: TTS-Worker down → 503" kill "$TTS_PID" 2>/dev/null || true sleep 0.5 CODE=$(curl -s -o /tmp/err32.json -w "%{http_code}" "$BASE/v1/audio/speech" \ -H "Content-Type: application/json" -d '{"input":"Hallo","voice":"claribel"}') cat /tmp/err32.json; echo [ "$CODE" = "503" ] && ok "503 bei downem TTS-Worker" || bad "erwartet 503, bekam $CODE" RESP=$(curl -sf "$BASE/status") echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) assert d["tts"]["reachable"] is False, d["tts"] ' && ok "Status: TTS nicht erreichbar" || bad "Status nach TTS-Down" # --- 33. TTS: Worker-Neustart → Recovery ------------------------------------------------------------------- echo "== Test 33: TTS-Worker-Neustart → Recovery" MOCK_TTS_PORT="$TTS_PORT" MOCK_TTS_DELAY=0.1 \ python3 dev/mock_tts_worker.py >/tmp/mock_tts2.log 2>&1 & TTS_PID=$! sleep 0.5 CODE=$(curl -s -o /tmp/tts33.wav -w "%{http_code}" "$BASE/v1/audio/speech" \ -H "Content-Type: application/json" -d '{"input":"Wieder da","voice":"claribel","response_format":"wav"}') [ "$CODE" = "200" ] && [ -s /tmp/tts33.wav ] \ && ok "TTS nach Neustart wieder verfügbar" || bad "TTS-Recovery (Code $CODE)" # --- 34. STT: /status zeigt stt-Section --------------------------------------------------------------- echo "== Test 34: /status mit stt-Section" RESP=$(curl -sf "$BASE/status") echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) stt=d["stt"] assert stt["reachable"] is True, stt assert stt["ready"] is True, stt ' && ok "Status: STT erreichbar, bereit" || bad "Status stt-Section" # --- 35. STT: POST /v1/audio/transcriptions (WAV) --------------------------------------------------------------- echo "== Test 35: POST /v1/audio/transcriptions (WAV)" # Test-WAV erstellen (leere WAV-Header + Daten) python3 -c " import struct, wave with wave.open('/tmp/stt_test.wav', 'w') as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(16000) w.writeframes(b'\x00' * 16000 * 3) # 3 Sekunden Stille " CODE=$(curl -s -o /tmp/stt35.json -w "%{http_code}" \ "$BASE/v1/audio/transcriptions" \ -F "file=@/tmp/stt_test.wav" \ -F "model=whisper-1") cat /tmp/stt35.json; echo [ "$CODE" = "200" ] && python3 -c "import json; d=json.load(open('/tmp/stt35.json')); assert 'text' in d" \ && ok "STT WAV (200, text vorhanden)" || bad "STT WAV (Code $CODE)" # --- 36. STT: POST /v1/audio/transcriptions (language=de) ------------------------------------------------------- echo "== Test 36: POST /v1/audio/transcriptions (language=de)" CODE=$(curl -s -o /tmp/stt36.json -w "%{http_code}" \ "$BASE/v1/audio/transcriptions" \ -F "file=@/tmp/stt_test.wav" \ -F "model=whisper-1" \ -F "language=de") cat /tmp/stt36.json; echo [ "$CODE" = "200" ] && python3 -c "import json; d=json.load(open('/tmp/stt36.json')); assert 'text' in d" \ && ok "STT language=de (200)" || bad "STT language=de (Code $CODE)" # --- 37. STT: unbekanntes Modell → 400 --------------------------------------------------------------------------- echo "== Test 37: STT unbekanntes Modell → 400" CODE=$(curl -s -o /tmp/err37.json -w "%{http_code}" \ "$BASE/v1/audio/transcriptions" \ -F "file=@/tmp/stt_test.wav" \ -F "model=gpt-4") cat /tmp/err37.json; echo [ "$CODE" = "400" ] && ok "400 bei unbekanntem STT-Modell" || bad "erwartet 400, bekam $CODE" # --- 38. STT: Worker down → 503 ----------------------------------------------------------------------------------- echo "== Test 38: STT Worker down → 503" kill "$STT_PID" 2>/dev/null || true sleep 0.5 CODE=$(curl -s -o /tmp/err38.json -w "%{http_code}" \ "$BASE/v1/audio/transcriptions" \ -F "file=@/tmp/stt_test.wav" \ -F "model=whisper-1") cat /tmp/err38.json; echo [ "$CODE" = "503" ] && ok "503 bei downem STT-Worker" || bad "erwartet 503, bekam $CODE" RESP=$(curl -sf "$BASE/status") echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) assert d["stt"]["reachable"] is False, d["stt"] ' && ok "Status: STT nicht erreichbar" || bad "Status nach STT-Down" # --- 39. STT: Worker-Neustart → Recovery --------------------------------------------------------------------------- echo "== Test 39: STT Worker-Neustart → Recovery" MOCK_STT_PORT="$STT_PORT" MOCK_STT_DELAY=0.1 \ python3 dev/mock_stt_worker.py >/tmp/mock_stt2.log 2>&1 & STT_PID=$! sleep 0.5 CODE=$(curl -s -o /tmp/stt39.json -w "%{http_code}" \ "$BASE/v1/audio/transcriptions" \ -F "file=@/tmp/stt_test.wav" \ -F "model=whisper-1") [ "$CODE" = "200" ] && ok "STT nach Neustart wieder verfügbar" || bad "STT-Recovery (Code $CODE)" # --- 40. /v1/audio/models ------------------------------------------------------------------------------------------ echo "== Test 40: GET /v1/audio/models" RESP=$(curl -sf "$BASE/v1/audio/models") echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) ids={m["id"] for m in d["data"]} assert "whisper-1" in ids, ids assert "xtts-v2" in ids, ids ' && ok "Audio-Modelle: whisper-1 + xtts-v2" || bad "Audio-Modelle" # --- 41. /v1/audio/voices ------------------------------------------------------------------------------------------ echo "== Test 41: GET /v1/audio/voices" RESP=$(curl -sf "$BASE/v1/audio/voices") echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) ids={v["id"] for v in d["data"]} assert "claribel" in ids, ids ' && ok "Audio-Voices: claribel" || bad "Audio-Voices" # --- 42. STT + Qwen parallel ---------------------------------------------------------------------------------------- echo "== Test 42: STT + Qwen parallel" # STT-Request im Hintergrund curl -sf "$BASE/v1/audio/transcriptions" \ -F "file=@/tmp/stt_test.wav" \ -F "model=whisper-1" >/tmp/stt42.json 2>&1 & STT_PID42=$! sleep 0.2 # Qwen-Request RESP=$(curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \ -d '{"model":"qwen-fast","messages":[{"role":"user","content":"Hallo"}]}') wait $STT_PID42 echo "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) assert "Mock-Antwort" in d["choices"][0]["message"]["content"], d ' && ok "STT + Qwen parallel (beide 200)" || bad "STT + Qwen parallel" # --- 43. STT + TTS parallel ------------------------------------------------------------------------------------------ echo "== Test 43: STT + TTS parallel" # STT-Request im Hintergrund curl -sf "$BASE/v1/audio/transcriptions" \ -F "file=@/tmp/stt_test.wav" \ -F "model=whisper-1" >/tmp/stt43.json 2>&1 & STT_PID43=$! sleep 0.2 # TTS-Request CODE=$(curl -s -o /tmp/tts43.mp3 -w "%{http_code}" \ "$BASE/v1/audio/speech" -H "Content-Type: application/json" \ -d '{"input":"Hallo","voice":"claribel"}') wait $STT_PID43 [ "$CODE" = "200" ] && [ -s /tmp/tts43.mp3 ] \ && ok "STT + TTS parallel (beide 200)" || bad "STT + TTS parallel (TTS Code $CODE)" # --- 44. Zwei konkurrierende Profilanfragen ------------------------------------------------ echo "== Test 44: Profil-Lease verhindert Wechsel während eines Chats" curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \ -d '{"model":"qwen-large","mock_delay":1.0,"messages":[{"role":"user","content":"Groß"}]}' \ >/tmp/chat44-large.json & CHAT44_PID=$! sleep 0.2 curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \ -d '{"model":"qwen-medium","messages":[{"role":"user","content":"Mittel"}]}' \ >/tmp/chat44-medium.json wait "$CHAT44_PID" python3 -c ' import json large=json.load(open("/tmp/chat44-large.json")) medium=json.load(open("/tmp/chat44-medium.json")) assert large["mock_ctx"] == 192000, large assert medium["mock_ctx"] == 160000, medium ' && ok "konkurrierende Chats behielten jeweils ihr Profil" \ || bad "Profil-Lease bei konkurrierenden Chats" # --- Ergebnis -------------------------------------------------------------------------------------------- echo echo "== Ergebnis: $PASS bestanden, $FAIL fehlgeschlagen ==" [ "$FAIL" -eq 0 ]