Files
AI-Profile-Router/dev/test_local.sh
T

815 lines
37 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"
# llama.cpp-Clients fragen zuerst den nativen Endpunkt ab. Er muss ebenfalls
# den gesamten virtuellen Katalog liefern und darf nicht auf das aktive Profil
# des Upstreams zusammenschrumpfen.
RESP=$(curl -sf "$BASE/models?autoload=false")
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"]["status"]["value"]=="loaded", ids
assert all(ids[name]["status"]["value"]=="unloaded" for name in ids if name!="qwen-fast"), ids
' && ok "nativer /models-Katalog enthält alle fünf Profile" || bad "/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"
echo "== Test 3b: Hermes-Reasoning erreicht das llama.cpp-Chat-Template"
RESP=$(curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \
-d '{"model":"qwen-fast","reasoning_effort":"none","messages":[{"role":"user","content":"Hallo"}]}')
echo "$RESP" | python3 -c '
import json,sys
d=json.load(sys.stdin)
assert d.get("mock_reasoning_effort") is None, d
assert d.get("mock_chat_template_kwargs") == {"enable_thinking": False}, d
' && ok "none wird als enable_thinking=false weitergegeben" || bad "Reasoning none"
RESP=$(curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \
-d '{"model":"qwen-fast","reasoning_effort":"medium","messages":[{"role":"user","content":"Hallo"}]}')
echo "$RESP" | python3 -c '
import json,sys
d=json.load(sys.stdin)
assert d.get("mock_reasoning_effort") is None, d
assert d.get("mock_chat_template_kwargs") == {
"enable_thinking": True, "reasoning_effort": "medium"}, d
' && ok "medium erreicht chat_template_kwargs" || bad "Reasoning medium"
# --- 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"
echo "== Test 4b: Client-Abbruch gibt den Router-Slot frei"
command curl -H "Authorization: Bearer $TEST_ROUTER_KEY" -sfN \
"$BASE/v1/chat/completions" -H "Content-Type: application/json" \
-d '{"model":"qwen-fast","stream":true,"mock_stream_delay":2,"messages":[{"role":"user","content":"Abbruch"}]}' \
>/tmp/aborted-stream.txt 2>/dev/null &
ABORT_PID=$!
sleep 0.4
kill "$ABORT_PID" 2>/dev/null || true
wait "$ABORT_PID" 2>/dev/null || true
for _ in $(seq 1 30); do
ACTIVE=$(curl -sf "$BASE/status" | python3 -c 'import json,sys; print(json.load(sys.stdin)["qwen"]["active_chats"])')
[[ $ACTIVE == 0 ]] && break
sleep 0.1
done
CODE=$(curl -s -o /tmp/after-abort.json -w "%{http_code}" "$BASE/v1/chat/completions" \
-H "Content-Type: application/json" \
-d '{"model":"qwen-fast","messages":[{"role":"user","content":"noch frei?"}]}')
[[ ${ACTIVE:-1} == 0 && $CODE == 200 ]] \
&& ok "abgebrochener Stream gibt Lease frei; Folgerequest erfolgreich" \
|| bad "Stream-Abbruch hinterließ active_chats=${ACTIVE:-?}, HTTP $CODE"
if [[ ${ROUTER_TEST_QUICK:-0} == 1 ]]; then
echo
echo "== Schnellergebnis: $PASS bestanden, $FAIL fehlgeschlagen =="
[[ $FAIL -eq 0 ]]
exit
fi
# --- 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/<datei>"
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)"
# --- 12b. Referenzbild-Bearbeitung ------------------------------------------------------
echo "== Test 12b: POST /v1/images/edits mit lokalem Referenzbild"
python3 - <<'PY' >/tmp/edit-request.json
import base64, json
png = open('/tmp/test_dl.png', 'rb').read()
print(json.dumps({
'prompt': 'Behalte die Person bei und ändere nur den Hintergrund',
'size': '1024x1024',
'steps': 25,
'guidance': 1.0,
'response_format': 'b64_json',
'image_b64': base64.b64encode(png).decode(),
}))
PY
rm -f /tmp/test_worker_requests.jsonl
RESP=$(curl -sf "$BASE/v1/images/edits" -H "Content-Type: application/json" \
--data-binary @/tmp/edit-request.json)
echo "$RESP" | python3 -c '
import base64,json,sys
d=json.load(sys.stdin)
assert base64.b64decode(d["data"][0]["b64_json"])[:4] == b"\x89PNG"
' || bad "Bildbearbeitung liefert kein PNG"
REFS=$(tail -1 /tmp/test_worker_requests.jsonl | python3 -c \
'import json,sys; print(len(json.load(sys.stdin).get("source_files", [])))')
[ "$REFS" = "1" ] && ! find /tmp/test-images -name '.edit-*.ref' | grep -q . \
&& ok "Referenzbild übergeben und danach gelöscht" || bad "Referenzbild-Pfad/Cleanup"
# --- 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"])=={"alloy"}, tts
' && ok "Status: TTS erreichbar und bereit" || 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":"qwen3-tts","input":"Hallo Welt","voice":"alloy","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":"alloy"}')
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":"alloy"}')
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":"alloy"}')
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":"alloy"}')
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":"alloy","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 "qwen3-tts" in ids, ids
' && ok "Audio-Modelle: whisper-1 + qwen3-tts" || 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 "alloy" in ids, ids
' && ok "Audio-Voices: alloy" || 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":"alloy"}')
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 ]