router: HTTP/1.1 chunked Transfer-Encoding-Unterstützung

- Zentrale _read_body() Helper für Content-Length und chunked encoding
- _read_chunked_body(): hex Chunk-Größen, Chunk Extensions, 0-Chunk,
  Trailer, MAX_UPLOAD_SIZE (50 MB Default)
- Malformed chunked encoding → HTTP 400
- _transcribe(), _speech(), _image_generate(), _forward() nutzen Helper
- Temporäres STT-DEBUG-Logging entfernt
- 16 Regressionstests (dev/test_chunked.py):
  Content-Length, chunked, Multi-Chunk, Boundary über Chunk-Grenzen,
  Chunk Extensions, 0-Chunk mit Trailer, malformed Chunk Size,
  Uploadgrößenlimit, Open-WebUI-artiger WebM-Request
This commit is contained in:
Mikei386
2026-08-19 16:31:22 +02:00
parent e81f2ab515
commit ed471196d4
2 changed files with 566 additions and 8 deletions
+434
View File
@@ -0,0 +1,434 @@
#!/usr/bin/env python3
"""Regressionstests für HTTP/1.1 chunked Transfer-Encoding im Router.
Testet:
1. Multipart + Content-Length (bestehender Pfad)
2. Multipart + Transfer-Encoding chunked
3. Mehrere unterschiedlich große Chunks
4. Boundary über Chunk-Grenzen verteilt
5. Chunk Extensions
6. Terminierender 0-Chunk
7. Malformed Chunk Size
8. Uploadgrößenlimit
9. Echter Open-WebUI-artiger WebM-Multipart-Request ohne Content-Length
Startet Mock-llama.cpp, Mock-TTS, Mock-STT und den Router,
führt dann HTTP-Requests gegen den Router aus.
"""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
import time
import urllib.request
import urllib.error
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ROUTER = os.path.join(BASE, "router", "ai_profile_router.py")
MOCK_LLAMA = os.path.join(BASE, "dev", "mock_upstream.py")
MOCK_TTS = os.path.join(BASE, "dev", "mock_tts_worker.py")
MOCK_STT = os.path.join(BASE, "dev", "mock_stt_worker.py")
FAKE_PROFILE = os.path.join(BASE, "dev", "fake-llama-profile.sh")
FAKE_PROFILE_DIR = os.path.join(BASE, "dev", "fake-profile-dir")
PORTS = {"router": 18091, "llama": 18090, "tts": 18089, "stt": 18088}
MAX_UPLOAD_SIZE = 1024 * 1024 # 1 MB für Tests
passed = 0
failed = 0
procs: list[subprocess.Popen] = []
def report(name: str, ok: bool, detail: str = "") -> None:
global passed, failed
if ok:
passed += 1
print(f" ✓ {name}")
else:
failed += 1
print(f" ✗ {name} {detail}")
def http_request(
method: str, port: int, path: str,
body: bytes | None = None, headers: dict | None = None,
) -> tuple[int, bytes]:
url = f"http://127.0.0.1:{port}{path}"
req = urllib.request.Request(url, data=body, method=method)
if headers:
for k, v in headers.items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
def raw_http_request(
port: int, raw_request: bytes
) -> tuple[int, bytes]:
"""Sendet einen rohen HTTP-Request und liefert (status, body)."""
sock = socket.create_connection(("127.0.0.1", port), timeout=10)
sock.sendall(raw_request)
sock.shutdown(socket.SHUT_WR)
data = b""
while True:
chunk = sock.recv(65536)
if not chunk:
break
data += chunk
sock.close()
parts = data.split(b"\r\n\r\n", 1)
if len(parts) < 2:
return 0, b""
status_line = parts[0].split(b"\n")[0].decode()
status = int(status_line.split()[1])
resp_body = parts[1]
for line in parts[0].split(b"\n"):
if line.lower().startswith(b"content-length:"):
cl = int(line.split(b":")[1].strip())
resp_body = resp_body[:cl]
break
return status, resp_body
def build_chunked_body(chunks: list[bytes]) -> bytes:
body = b""
for chunk in chunks:
body += f"{len(chunk):x}\r\n".encode() + chunk + b"\r\n"
body += b"0\r\n\r\n"
return body
def build_chunked_body_with_ext(
chunks: list[tuple[bytes, str | None]]
) -> bytes:
body = b""
for chunk, ext in chunks:
if ext:
body += f"{len(chunk):x};{ext}\r\n".encode() + chunk + b"\r\n"
else:
body += f"{len(chunk):x}\r\n".encode() + chunk + b"\r\n"
body += b"0\r\n\r\n"
return body
def build_multipart(
fields: dict[str, str],
file_data: bytes | None = None,
filename: str = "test.webm",
boundary: str = "testboundary123",
) -> bytes:
body = b""
for name, value in fields.items():
body += (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{name}"\r\n'
f"\r\n"
f"{value}\r\n"
).encode()
if file_data is not None:
body += (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; '
f'filename="{filename}"\r\n'
f"Content-Type: audio/webm\r\n"
f"\r\n"
).encode() + file_data + b"\r\n"
body += f"--{boundary}--\r\n".encode()
return body
def start_process(cmd: list[str], env: dict) -> subprocess.Popen:
p = subprocess.Popen(
cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
procs.append(p)
return p
def wait_port(port: int, timeout: float = 10.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with socket.create_connection(("127.0.0.1", port), timeout=1):
return True
except OSError:
time.sleep(0.1)
return False
def cleanup() -> None:
for p in procs:
try:
p.terminate()
p.wait(timeout=3)
except Exception:
try:
p.kill()
except Exception:
pass
def main() -> None:
global procs
env = os.environ.copy()
env.update({
"ROUTER_HOST": "127.0.0.1",
"ROUTER_PORT": str(PORTS["router"]),
"UPSTREAM_URL": f"http://127.0.0.1:{PORTS['llama']}",
"PROFILE_SCRIPT": FAKE_PROFILE,
"PROFILE_DIR": FAKE_PROFILE_DIR,
"TTS_WORKER_URL": f"http://127.0.0.1:{PORTS['tts']}",
"STT_WORKER_URL": f"http://127.0.0.1:{PORTS['stt']}",
"MAX_UPLOAD_SIZE": str(MAX_UPLOAD_SIZE),
"LOG_LEVEL": "WARNING",
})
print("Starte Mock-Server und Router ...")
start_process([sys.executable, MOCK_LLAMA],
{**env, "MOCK_PORT": str(PORTS["llama"])})
start_process([sys.executable, MOCK_TTS],
{**env, "MOCK_TTS_PORT": str(PORTS["tts"])})
start_process([sys.executable, MOCK_STT],
{**env, "MOCK_STT_PORT": str(PORTS["stt"])})
start_process([sys.executable, ROUTER], env)
for name, port in PORTS.items():
if not wait_port(port, timeout=10):
print(f"FEHLER: {name} (Port {port}) nicht erreichbar")
cleanup()
sys.exit(1)
print("Alle Server erreichbar.\n")
# Fake WebM-Daten (Opus-Header + Dummy-Bytes)
fake_webm = (b"\x1a\x45\xdf\xa3" # EBML magic
b"\x00" * 100 + b"FAKE_WEBM_DATA" * 50)
# ------------------------------------------------------------------
# Test 1: Multipart + Content-Length (bestehender Pfad)
# ------------------------------------------------------------------
print("Test 1: Multipart + Content-Length")
mp = build_multipart({"model": "whisper-1", "language": "de"},
file_data=fake_webm)
status, body = http_request(
"POST", PORTS["router"], "/v1/audio/transcriptions",
body=mp,
headers={"Content-Type":
"multipart/form-data; boundary=testboundary123"})
data = json.loads(body) if body else {}
report("HTTP 200", status == 200, f"got {status}")
report("text vorhanden", "text" in data, str(data))
# ------------------------------------------------------------------
# Test 2: Multipart + Transfer-Encoding chunked (einfach)
# ------------------------------------------------------------------
print("Test 2: Multipart + chunked (einfach)")
mp = build_multipart({"model": "whisper-1"}, file_data=fake_webm)
chunked = build_chunked_body([mp])
raw = (
b"POST /v1/audio/transcriptions HTTP/1.1\r\n"
b"Host: 127.0.0.1\r\n"
b"Content-Type: multipart/form-data; boundary=testboundary123\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Connection: close\r\n"
b"\r\n"
) + chunked
status, body = raw_http_request(PORTS["router"], raw)
data = json.loads(body) if body else {}
report("HTTP 200", status == 200, f"got {status}")
report("text vorhanden", "text" in data, str(data))
# ------------------------------------------------------------------
# Test 3: Mehrere unterschiedlich große Chunks
# ------------------------------------------------------------------
print("Test 3: Mehrere unterschiedlich große Chunks")
mp = build_multipart({"model": "whisper-1"}, file_data=fake_webm)
# In 5 Chunks aufteilen (unterschiedlich groß)
chunks = []
sizes = [10, 50, 7, 100, 33]
pos = 0
for s in sizes:
if pos < len(mp):
chunks.append(mp[pos:pos + s])
pos += s
if pos < len(mp):
chunks.append(mp[pos:])
chunked = build_chunked_body(chunks)
raw = (
b"POST /v1/audio/transcriptions HTTP/1.1\r\n"
b"Host: 127.0.0.1\r\n"
b"Content-Type: multipart/form-data; boundary=testboundary123\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Connection: close\r\n"
b"\r\n"
) + chunked
status, body = raw_http_request(PORTS["router"], raw)
data = json.loads(body) if body else {}
report("HTTP 200", status == 200, f"got {status}")
report("text vorhanden", "text" in data, str(data))
# ------------------------------------------------------------------
# Test 4: Boundary über Chunk-Grenzen verteilt
# ------------------------------------------------------------------
print("Test 4: Boundary über Chunk-Grenzen verteilt")
mp = build_multipart({"model": "whisper-1"}, file_data=fake_webm)
# Boundary-String finden und Chunk-Grenze genau dorthin setzen
boundary_str = b"--testboundary123"
idx = mp.find(boundary_str, 10) # zweite Boundary (vor file)
if idx == -1:
idx = len(mp) // 2
# Chunk 1 endet mitten in der Boundary
split_at = idx + len(boundary_str) // 2
chunks = [mp[:split_at], mp[split_at:]]
chunked = build_chunked_body(chunks)
raw = (
b"POST /v1/audio/transcriptions HTTP/1.1\r\n"
b"Host: 127.0.0.1\r\n"
b"Content-Type: multipart/form-data; boundary=testboundary123\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Connection: close\r\n"
b"\r\n"
) + chunked
status, body = raw_http_request(PORTS["router"], raw)
data = json.loads(body) if body else {}
report("HTTP 200", status == 200, f"got {status}")
report("text vorhanden", "text" in data, str(data))
# ------------------------------------------------------------------
# Test 5: Chunk Extensions
# ------------------------------------------------------------------
print("Test 5: Chunk Extensions")
mp = build_multipart({"model": "whisper-1"}, file_data=fake_webm)
chunks_ext = [
(mp[:20], "ext1=value1"),
(mp[20:60], None),
(mp[60:], "ext2=value2;ext3=value3"),
]
chunked = build_chunked_body_with_ext(chunks_ext)
raw = (
b"POST /v1/audio/transcriptions HTTP/1.1\r\n"
b"Host: 127.0.0.1\r\n"
b"Content-Type: multipart/form-data; boundary=testboundary123\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Connection: close\r\n"
b"\r\n"
) + chunked
status, body = raw_http_request(PORTS["router"], raw)
data = json.loads(body) if body else {}
report("HTTP 200", status == 200, f"got {status}")
report("text vorhanden", "text" in data, str(data))
# ------------------------------------------------------------------
# Test 6: Terminierender 0-Chunk (bereits in allen Tests enthalten,
# hier explizit mit Trailer)
# ------------------------------------------------------------------
print("Test 6: 0-Chunk mit Trailer")
mp = build_multipart({"model": "whisper-1"}, file_data=fake_webm)
chunked = build_chunked_body([mp])
# Trailer hinzufügen
chunked_with_trailer = chunked.replace(
b"0\r\n\r\n", b"0\r\nTrailer-Test: value\r\n\r\n")
raw = (
b"POST /v1/audio/transcriptions HTTP/1.1\r\n"
b"Host: 127.0.0.1\r\n"
b"Content-Type: multipart/form-data; boundary=testboundary123\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Connection: close\r\n"
b"\r\n"
) + chunked_with_trailer
status, body = raw_http_request(PORTS["router"], raw)
data = json.loads(body) if body else {}
report("HTTP 200", status == 200, f"got {status}")
report("text vorhanden", "text" in data, str(data))
# ------------------------------------------------------------------
# Test 7: Malformed Chunk Size
# ------------------------------------------------------------------
print("Test 7: Malformed Chunk Size")
raw = (
b"POST /v1/audio/transcriptions HTTP/1.1\r\n"
b"Host: 127.0.0.1\r\n"
b"Content-Type: multipart/form-data; boundary=testboundary123\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Connection: close\r\n"
b"\r\n"
b"XYZ\r\n" # ungültige hexadezimale Größe
b"0\r\n\r\n"
)
status, body = raw_http_request(PORTS["router"], raw)
report("HTTP 400", status == 400, f"got {status}")
# ------------------------------------------------------------------
# Test 8: Uploadgrößenlimit
# ------------------------------------------------------------------
print("Test 8: Uploadgrößenlimit")
# MAX_UPLOAD_SIZE = 1 MB, also 2 MB senden
big_data = b"A" * (2 * 1024 * 1024)
mp = build_multipart({"model": "whisper-1"}, file_data=big_data)
chunked = build_chunked_body([mp[:1024 * 1024], mp[1024 * 1024:]])
raw = (
b"POST /v1/audio/transcriptions HTTP/1.1\r\n"
b"Host: 127.0.0.1\r\n"
b"Content-Type: multipart/form-data; boundary=testboundary123\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Connection: close\r\n"
b"\r\n"
) + chunked
try:
status, body = raw_http_request(PORTS["router"], raw)
report("HTTP 400 (zu groß)", status == 400, f"got {status}")
except (BrokenPipeError, ConnectionResetError, OSError):
# Server schließt Verbindung bei zu großem Upload – OK
report("HTTP 400 (zu groß)", True, "Verbindung geschlossen")
# ------------------------------------------------------------------
# Test 9: Open-WebUI-artiger WebM-Multipart ohne Content-Length
# ------------------------------------------------------------------
print("Test 9: Open-WebUI-artiger WebM-Multipart (chunked)")
# Realistischer Open-WebUI-Request: WebM-Datei + model + language
webm_data = (b"\x1a\x45\xdf\xa3" + b"\x00" * 50
+ b"WEBM_OPUS_AUDIO_DATA" * 100)
boundary = "950bd961b24c4a32801e31b128c85e09"
mp = build_multipart(
{"model": "whisper-1", "language": "de"},
file_data=webm_data,
filename="recording.webm",
boundary=boundary,
)
# In mehrere Chunks aufteilen (wie Open WebUI es tut)
chunk_size = 4096
chunks = [mp[i:i + chunk_size]
for i in range(0, len(mp), chunk_size)]
chunked = build_chunked_body(chunks)
raw = (
b"POST /v1/audio/transcriptions HTTP/1.1\r\n"
b"Host: 127.0.0.1\r\n"
b"Content-Type: multipart/form-data; "
b"boundary=" + boundary.encode() + b"\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Connection: close\r\n"
b"\r\n"
) + chunked
status, body = raw_http_request(PORTS["router"], raw)
data = json.loads(body) if body else {}
report("HTTP 200", status == 200, f"got {status}")
report("text vorhanden", "text" in data, str(data))
# ------------------------------------------------------------------
# Zusammenfassung
# ------------------------------------------------------------------
print(f"\n== Ergebnis: {passed} bestanden, {failed} fehlgeschlagen ==")
cleanup()
sys.exit(1 if failed else 0)
if __name__ == "__main__":
try:
main()
finally:
cleanup()