From 67cf57a5b752ce0789eeb6e71dc5d2000a2e159e Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:58:36 +0200 Subject: [PATCH] Restore Video workspace and route native video API through shared endpoint --- README.md | 14 +++++++++--- deploy/Dockerfile | 2 +- deploy/docker_helper.py | 47 ++++++++++++++++++++++++++++++++++++++- deploy/install.py | 2 +- docs/DOCKER_SERVICES.md | 4 +++- endpoint-ui.js | 2 +- endpoint.py | 13 +++++++++++ index.html | 2 +- network/install_remote.py | 2 +- studio.js | 1 - test_endpoint.py | 12 ++++++++-- test_video_proxy.py | 41 ++++++++++++++++++++++++++++++++++ video-ui.js | 8 +++---- video.py | 6 ++--- video_proxy.py | 30 +++++++++++++++++++++++++ 15 files changed, 166 insertions(+), 20 deletions(-) create mode 100644 test_video_proxy.py create mode 100644 video_proxy.py diff --git a/README.md b/README.md index 714cd02..eced015 100644 --- a/README.md +++ b/README.md @@ -151,13 +151,13 @@ Interne API (nur angemeldete Administratorsitzung): `GET /api/v1/huggingface` li ### Video: externe Dienste statt Deck-Profile -Deck verwaltet für Video ausschließlich den aktiven Dienst, Start/Stopp und die GPU-Freigabe. Unter **Weitere Dienste → Aktiver Videodienst** einen administrativ eingerichteten Dienst wählen; unter **Übersicht → Video** starten. **LLM** beendet den Videodienst. Modellauswahl, Komponenten, Auflösung, Dauer, Prompts und Generierung erfolgen ausschließlich über dessen Original-API und Oberfläche. Heute ist LTX Desktop angebunden; weitere containerisierte Videodienste lassen sich mit derselben Dienststeuerung registrieren. Keine Übersetzung zwischen APIs. +Deck verwaltet für Video ausschließlich den aktiven Dienst, Start/Stopp und die GPU-Freigabe. Unter **Video → Aktiver Videodienst** einen administrativ eingerichteten Dienst wählen; unter **Übersicht → Video** starten. **LLM** beendet den Videodienst. Modellauswahl, Komponenten, Auflösung, Dauer, Prompts und Generierung erfolgen ausschließlich über dessen Original-API und Oberfläche. Heute ist LTX Desktop angebunden; weitere containerisierte Videodienste lassen sich mit derselben Dienststeuerung registrieren. Keine Übersetzung zwischen APIs. -Videoprofile, Testformular, eigene Video-Laufzeitinstallation und `/v1/videos`-Generierung wurden entfernt. Alte Profil- und Modelldaten bleiben auf Platte erhalten, werden jedoch nicht mehr als aktive Videoprofile angeboten. `/v1/videos…` liefert HTTP 410 mit Verweis auf die Original-API. Der ursprüngliche Deck-Python-Video-Worker wird nicht mehr installiert oder gestartet. +Der Video-Bereich ist wieder Teil der KI-Werkzeuge. Generierungsparameter bleiben ausschließlich in der Video-Anwendung. Das frühere Testformular, die eigene Video-Laufzeitinstallation und `/v1/videos`-Generierung sind entfernt. Alte Profil- und Modelldaten bleiben auf Platte erhalten, werden jedoch nicht mehr als aktive Videoprofile angeboten. `/v1/videos…` liefert HTTP 410 mit Verweis auf die Original-API. Der ursprüngliche Deck-Python-Video-Worker wird nicht mehr installiert oder gestartet. Die Steuerung erfolgt über den beschränkten Docker-Systemhelfer, ohne Docker-Socket in Deck. Root registriert Container-IDs, API-Adresse und Healthcheck in `/var/lib/athena-deck-docker/video-services.json`. Nur registrierte Dienste können gestartet/gestoppt werden; beliebige Docker-Aktionen sind ausgeschlossen. Siehe [Videodienste](docs/DOCKER_SERVICES.md#videodienste). -Vor Videostart beendet Deck seine eigenen GPU-Aufträge, wartet auf Freigabe und verweigert den Start bei fremden GPU-Prozessen. Der alte Router bleibt unberührt. Während Video aktiv ist, sind Chat/Bild/TTS-GPU-Aufträge gesperrt. CPU-STT bleibt verfügbar. Beim Deck-Neustart wird ein laufender registrierter Videodienst erkannt und die GPU-Sperre wiederhergestellt; kein automatischer Dienststart und kein Abbruch externer Generierung beim bloßen Deck-Neustart. +Vor Videostart beendet Deck seine eigenen GPU-Aufträge, wartet auf Freigabe und verweigert den Start bei fremden GPU-Prozessen. Der alte Router bleibt unberührt. Im Videomodus wird der gemeinsame API-Port vollständig auf die Original-API des aktiven Videodienstes umgeschaltet. Chat/Bild/TTS/STT-Clients können diesen Port erst nach dem Rückwechsel auf LLM wieder verwenden. Beim Deck-Neustart wird ein laufender registrierter Videodienst erkannt und die GPU-Sperre wiederhergestellt; kein automatischer Dienststart und kein Abbruch externer Generierung beim bloßen Deck-Neustart. LTX auf Athena: originale API am Host `http://127.0.0.1:41955`, bestehender SSH-Zugang über LTX Athena. Alternativer Tunnel vom Mac: @@ -166,3 +166,11 @@ ssh -N -i /Users/mike_i386/.ssh/athena_key -o BatchMode=yes -o ExitOnForwardFail ``` Dann API unter `http://127.0.0.1:41956`; Authentifizierung mit dem bestehenden LTX-Token, nicht dem Deck-Token. Keine Zugangsdaten in URLs. Der Port stellt eine API bereit, keine Browser-Studio-Oberfläche. + +### Gemeinsamer API-Port: Protokollwechsel + +Im LLM-Modus bleibt der Deck-Endpunkt OpenAI-kompatibel. Im Videomodus werden HTTP-Pfad, Query, Methode, Inhalt, Antwortstatus und Antwortdaten unverändert an die registrierte Video-API weitergereicht. Es gibt keine Modell-/Parameterübersetzung. Beispiel auf Port 8120: `POST /api/generate`, `GET /api/generation/progress`, `POST /api/generate/cancel`. Kein `/v1`-Präfix für LTX. Während des Moduswechsels HTTP 503. + +Am gemeinsamen Port gilt weiterhin der Deck-Bearer-Token. Der root-eigene Helfer setzt intern den separaten LTX-Token; er gibt ihn niemals an Deck oder den Browser zurück. Der interne LTX-Port 41955 bleibt unverändert, sodass der bisherige LTX-Athena-Zugang weiter funktioniert. HTTP GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS und Range-Header werden transportiert. Request-Bodies benötigen Content-Length (maximal 256 MiB); kein WebSocket-Transport. Die geprüfte LTX-Desktop-API verwendet für Fortschritt HTTP-Polling. + +Ein separates LTX-Webfrontend gehört als Docker-Anwendung unter Weitere Dienste, nicht die Video-Laufzeit-Auswahl. Dieses Webfrontend ist noch nicht implementiert. Der vorhandene LTX-Athena-Client benötigt neben API-Zugriff weiterhin seine Dateiübertragung über SSH/SCP; ein Browser-Port muss Electron-Dateifunktionen separat ersetzen. Weiterleitung allein stellt keine neuen Datei-Upload-/Downloadrouten im LTX-Backend bereit. diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 0c7099b..005de18 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -14,7 +14,7 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins WORKDIR /app COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock -COPY api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py video.py catalog.py hub_auth.py auth.py collect_hardware.py /app/ +COPY api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py /app/ COPY video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/ COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ diff --git a/deploy/docker_helper.py b/deploy/docker_helper.py index fd97e64..e2dc48f 100644 --- a/deploy/docker_helper.py +++ b/deploy/docker_helper.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Root-owned local helper. Only inventory and fresh Docker install; no generic RPC.""" import argparse +import http.client +from urllib.parse import urlsplit import re import urllib.request import urllib.error @@ -149,6 +151,46 @@ class Manager: if data['action']=='install':return self.install() raise ValueError('Aktion nicht erlaubt.') +def video_http(manager,data,source,target): + """Fixed registered upstream only; no model translation or secret returned to Deck.""" + sent=False;connection=None + try: + if set(data)!={'action','service','method','path','length','headers'}:raise ValueError('Ungültige Proxy-Anfrage.') + method=data['method'];path=data['path'];length=data['length'];headers=data['headers'] + if method not in ('GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS') or not isinstance(path,str) or not path.startswith('/') or path.startswith('//') or any(ord(c)<32 for c in path):raise ValueError('Ungültige HTTP-Anfrage.') + if type(length) is not int or not 0<=length<=256*1024*1024 or not isinstance(headers,dict):raise ValueError('Ungültige Anfragegröße.') + allowed={'content-type','accept','range','if-range','if-none-match','if-modified-since'} + if any(not isinstance(k,str) or k.lower() not in allowed or not isinstance(v,str) or '\r' in v or '\n' in v for k,v in headers.items()):raise ValueError('Ungültige Header.') + config=next((c for c in manager.video_configs() if c['id']==data['service']),None) + if config is None:raise ValueError('Videodienst nicht freigegeben.') + if manager.video_status(config)['running'] is not True:raise ValueError('Videodienst nicht gestartet.') + url=urlsplit(config['api_url']) + if url.scheme not in ('http','https') or not url.hostname or url.username or url.password:raise ValueError('Ungültiges Backend-Ziel.') + connection=(http.client.HTTPSConnection if url.scheme=='https' else http.client.HTTPConnection)(url.hostname,url.port,timeout=3600) + connection.putrequest(method,path,skip_accept_encoding=True) + for key,value in headers.items():connection.putheader(key,value) + if config.get('token_file'):connection.putheader('Authorization','Bearer '+Path(config['token_file']).read_text().strip()) + connection.putheader('Content-Length',str(length));connection.putheader('Connection','close');connection.endheaders() + remaining=length + while remaining: + chunk=source.read(min(1024*1024,remaining)) + if not chunk:raise ValueError('Unvollständige Anfrage.') + connection.send(chunk);remaining-=len(chunk) + response=connection.getresponse();sent=True + target.write(f'HTTP/1.1 {response.status} {response.reason}\r\n'.encode()) + hop={'connection','keep-alive','proxy-authenticate','proxy-authorization','te','trailer','transfer-encoding','upgrade'} + for key,value in response.getheaders(): + if key.lower() not in hop:target.write(f'{key}: {value}\r\n'.encode('latin-1')) + target.write(b'Connection: close\r\n\r\n');target.flush() + if method!='HEAD': + while chunk:=response.read(1024*1024):target.write(chunk);target.flush() + except Exception: + if not sent: + body=b'{"error":"Video-API nicht erreichbar oder Anfrage nicht erlaubt."}' + target.write(b'HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\nContent-Length: '+str(len(body)).encode()+b'\r\nConnection: close\r\n\r\n'+body) + finally: + if connection:connection.close() + class Handler(socketserver.StreamRequestHandler): def handle(self): self.connection.settimeout(20) @@ -157,7 +199,10 @@ class Handler(socketserver.StreamRequestHandler): if uid not in (0,self.server.client_uid):raise ValueError('Client nicht erlaubt.') raw=self.rfile.readline(4097) if len(raw)>4096:raise ValueError('Anfrage zu groß.') - result=self.server.manager.dispatch(json.loads(raw)) + data=json.loads(raw) + if isinstance(data,dict) and data.get('action')=='video-http': + self.connection.settimeout(3600);video_http(self.server.manager,data,self.rfile,self.wfile);return + result=self.server.manager.dispatch(data) except Exception as exc:result={'error':str(exc) if isinstance(exc,ValueError) else 'Docker-Systemhelfer nicht verfügbar.'} self.wfile.write(json.dumps(result).encode()+b'\n') diff --git a/deploy/install.py b/deploy/install.py index 3aa487a..cdc0bf1 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -14,7 +14,7 @@ import urllib.request ROOT = Path(__file__).resolve().parent.parent LABEL = 'de.casaderoll.athena-deck.standalone' -FILES = ['deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile','deploy/image-requirements.lock'] +FILES = ['deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile','deploy/image-requirements.lock'] def run(*args, check=True, interactive=False): diff --git a/docs/DOCKER_SERVICES.md b/docs/DOCKER_SERVICES.md index 941f8c9..b7c3cca 100644 --- a/docs/DOCKER_SERVICES.md +++ b/docs/DOCKER_SERVICES.md @@ -98,6 +98,8 @@ Zusätzlich zum labelgefilterten Inventar kann der root-eigene Helfer explizit r Keine Secrets werden an Deck übertragen. Der Helfer akzeptiert nur `video-status`, `video-start` und `video-stop` für registrierte IDs. Container-Neuerstellung erfordert bewusste Aktualisierung der ID. Healthchecks folgen keinen Redirects. Die GUI erlaubt nur die Auswahl registrierter Dienste, keine freien Containerbefehle, Zieladressen oder Secret-Dateipfade. -Deck speichert lediglich die aktive Dienst-ID. Ein Wechsel ist nur bei gestoppten Videodiensten möglich. API-Adresse und Protokoll gehören zum Dienst; beim Wechsel auf eine andere Engine ändert sich auch deren Original-API. Client-Kompatibilität wird nicht übersetzt. Die Integration setzt einen vorhandenen, eingerichteten Dienst voraus; sie installiert keine beliebige Video-Engine automatisch. +Der eigenständige KI-Bereich Video speichert lediglich die aktive Laufzeit-ID. Weitere Dienste ist für separate Webfrontends vorgesehen. Ein Wechsel ist nur bei gestoppten Videodiensten möglich. Der gemeinsame Deck-API-Port reicht im Videomodus die Original-API unverändert weiter; beim Wechsel auf eine andere Engine ändert sich deren API-Vertrag. Client-Kompatibilität wird nicht übersetzt. Die Integration setzt einen vorhandenen, eingerichteten Dienst voraus; sie installiert keine beliebige Video-Engine automatisch. Interne Verwaltungs-API: GET `/api/v1/video`, POST `/api/v1/video/service` mit `{ "id": "ltx" }`, POST `/api/v1/video/mode` mit `{ "mode": "video" }` oder `llm`. Bestehender Sitzungsschutz gilt. Start/Stop laufen asynchron und zeigen Phase sowie API-Bereitschaft. + +Der zusätzliche Transport `video-http` ist nur über das private Unix-Socket zugänglich und an registrierte Dienst-IDs gebunden. Zielhost und Upstream-Zugangsdaten stammen ausschließlich aus der root-eigenen Konfiguration, niemals aus einer Client-Anfrage. Keine Redirect-Verfolgung, kein freier HTTP-Proxy. Payloads werden gestreamt und nicht protokolliert. Der öffentliche Deck-Endpunkt prüft vorher seinen Bearer-Token und den aktiven Videomodus. diff --git a/endpoint-ui.js b/endpoint-ui.js index 6743bbd..3863829 100644 --- a/endpoint-ui.js +++ b/endpoint-ui.js @@ -16,7 +16,7 @@ window.EndpointUI=(()=>{ if(pending)return;const id=sequence; try{const s=await api();if(!root.isConnected||id!==sequence)return; put(root.querySelector('#endpoint-summary'),summary(s)); - if(s.video)VideoUI.mode(root.querySelector('#video-mode-panel'),s.video,s.profiles); + if(s.video)VideoUI.mode(root.querySelector('#video-mode-panel'),s.video,s.profiles,s.port); root.querySelector('#endpoint-start').disabled=s.state!=='stopped';root.querySelector('#endpoint-stop').disabled=s.state!=='running'; if(settings){const port=root.querySelector('#endpoint-port');if(document.activeElement!==port)port.value=s.port;port.disabled=s.state!=='stopped';root.querySelector('#endpoint-port-save').disabled=s.state!=='stopped'; root.querySelector('#endpoint-port-help').textContent=s.allowed_ports.length?'Docker-Testinstallation: veröffentlichte Loopback-Ports '+s.allowed_ports.join(', ')+'. Portwechsel nur bei gestopptem Endpunkt. Der SSH-Tunnel muss denselben Port weiterleiten.':'Nativer Dienst: freie Ports zwischen 1024 und 65535, nur bei gestopptem Endpunkt.'; diff --git a/endpoint.py b/endpoint.py index 3f18b64..1cc78ce 100644 --- a/endpoint.py +++ b/endpoint.py @@ -1,3 +1,4 @@ +from video_proxy import relay """Separate authenticated OpenAI-compatible API; only explicitly enabled Deck profiles.""" import base64 import hashlib @@ -155,6 +156,11 @@ class APIHandler(BaseHTTPRequestHandler): return False def do_GET(self):self.route() def do_POST(self):self.route() + def do_PUT(self):self.route() + def do_PATCH(self):self.route() + def do_DELETE(self):self.route() + def do_HEAD(self):self.route() + def do_OPTIONS(self):self.route() def route(self): ep=self.server.endpoint;admitted=False try: @@ -163,6 +169,13 @@ class APIHandler(BaseHTTPRequestHandler): with ep.lock: if not ep.allowed():raise APIError('Endpunkt wird gestoppt.',503,'endpoint_stopping') ep.inflight+=1;admitted=True + if ep.video and ep.scheduler.gpu_mode!='llm': + if ep.scheduler.gpu_mode=='switching':raise APIError('Moduswechsel läuft.',503,'mode_switching') + video=ep.video.status() + if not video.get('service',{}).get('ready'):raise APIError('Video-API noch nicht bereit.',503,'video_not_ready') + try:relay(self,video['selected']) + except ValueError as exc:raise APIError(str(exc)) from None + return model_routes={'/v1/models':'chat','/v1/images/models':'image','/v1/audio/speech/models':'audio','/v1/audio/transcriptions/models':'stt'} if self.command=='GET' and self.path in model_routes:return self.send(ep.model_list(model_routes[self.path])) if self.command=='GET' and self.path=='/health':return self.send({'status':'ok','service':'athena-deck-api'}) diff --git a/index.html b/index.html index 1dd6a67..f9bdfe8 100644 --- a/index.html +++ b/index.html @@ -1 +1 @@ -Athena Deck
ATHENA CONTROL SURFACEv0.7 · Router
+Athena Deck
ATHENA CONTROL SURFACEv0.7 · Router
diff --git a/network/install_remote.py b/network/install_remote.py index d43b7f4..8b0012b 100644 --- a/network/install_remote.py +++ b/network/install_remote.py @@ -12,7 +12,7 @@ import sys NAME = 'athena-deck-network' BASE = Path('/opt/athena-deck') -FILES = {'deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','image_runtime.py','image_encoder_node.py','docker_support.py','docker-ui.js','deploy/image-requirements.lock','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py', 'runtime-ui.js', 'video.py','video-ui.js','catalog.py','hub_auth.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'} +FILES = {'deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','image_runtime.py','image_encoder_node.py','docker_support.py','docker-ui.js','deploy/image-requirements.lock','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py', 'runtime-ui.js', 'video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'} def run(*args, **kwargs): return subprocess.run(args, capture_output=True, timeout=600, **kwargs) diff --git a/studio.js b/studio.js index 7fb0c2e..2b6a2c1 100644 --- a/studio.js +++ b/studio.js @@ -10,7 +10,6 @@ const Studio=(()=>{ if(modelId)section='profiles'; const body=page==='stt-runtime'?STTUI.html(true):page==='tts-runtime'?TTSUI.html(true):['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`
ATHENA / MODELLVERWALTUNG

${labels[page]}

${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}

${audioDescriptions[page]&&!['audio','stt'].includes(page)?'

Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.

':''}
${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(['audio','stt'].includes(page)?[['setup','Einrichten']]:[]),...(['image','chat','audio','stt'].includes(page)?[['test','Testen'],...(page==='chat'?[['auto','Auto-Test']]:[])]:[]),['running','Laufend']].map(([id,label])=>``).join('')}
${section==='setup'?(page==='stt'?STTUI.html(true):TTSUI.html(true)):['test','running'].includes(section)&&page==='stt'?STTUI.html():['test','running'].includes(section)&&page==='audio'?TTSUI.html():section==='auto'?AutoTestUI.html():section==='test'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'

Keine von Deck gestarteten Modelle

Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.

llama.cpp-Builds verwalten →
':CatalogUI.html(section==='library',section==='downloads')}
`; document.querySelector('#view').innerHTML=`
${body}
`; - if(page==='services'){const section=document.createElement('div');document.querySelector('#studio').prepend(section);VideoUI.services(section);} if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;} if(page==='stt-runtime'){STTUI.bind(true);return;} if(page==='tts-runtime'){TTSUI.bind(true);return;} diff --git a/test_endpoint.py b/test_endpoint.py index 3646a1f..c9d7eca 100644 --- a/test_endpoint.py +++ b/test_endpoint.py @@ -77,10 +77,18 @@ class EndpointTests(unittest.TestCase): self.assertFalse(any(p['kind']=='video' for p in self.ep.rows())) with self.assertRaises(ValueError):self.ep.enable({'id':'video-old','enabled':True}) def test_video_mode_rejects_chat_without_starting_llama(self): - self.ep.video=Mock();self.ep.scheduler.gpu_mode='video' + self.ep.video=Mock();self.ep.video.status.return_value={'service':{'ready':False}};self.ep.scheduler.gpu_mode='video' code,body=self.request('/v1/chat/completions',dict(model='alpha',messages=[dict(role='user',content='synthetic')])) - self.assertEqual(code,503);self.assertEqual(body['error']['code'],'video_mode_active');self.assertFalse(self.worker.started) + self.assertEqual(code,503);self.assertEqual(body['error']['code'],'video_not_ready');self.assertFalse(self.worker.started) self.ep.video=None + def test_video_mode_routes_original_api_even_on_models_path(self): + from unittest.mock import patch + self.ep.video=Mock();self.ep.video.status.return_value={'service':{'ready':True},'selected':'ltx'};self.ep.scheduler.gpu_mode='video' + def forward(handler,service): + self.assertEqual(service,'ltx');handler.send({'ltx_native':handler.path},207) + with patch('endpoint.relay',side_effect=forward): + code,body=self.request('/v1/models');self.assertEqual(code,207);self.assertEqual(body,{'ltx_native':'/v1/models'}) + self.ep.scheduler.gpu_mode='llm';self.ep.video=None def test_video_api_redirect_is_explicit_not_translation(self): code,body=self.request('/v1/videos',{'prompt':'synthetic'}) self.assertEqual(code,410);self.assertEqual(body['error']['code'],'use_ltx_api') diff --git a/test_video_proxy.py b/test_video_proxy.py new file mode 100644 index 0000000..99c566c --- /dev/null +++ b/test_video_proxy.py @@ -0,0 +1,41 @@ +import io +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer +from pathlib import Path +from unittest.mock import Mock +from deploy.docker_helper import video_http + +class Upstream(BaseHTTPRequestHandler): + def log_message(self,*args):pass + def do_GET(self):self.answer() + def do_POST(self):self.answer() + def do_DELETE(self):self.answer() + def answer(self): + self.server.received=(self.command,self.path,dict(self.headers),self.rfile.read(int(self.headers.get('Content-Length','0')))) + body=b'\x00original-LTX-response\xff' + self.send_response(422 if self.path=='/api/invalid' else 206) + self.send_header('Content-Type','application/octet-stream');self.send_header('Content-Length',str(len(body)));self.send_header('Content-Range','bytes 0-22/23');self.end_headers();self.wfile.write(body) +class VideoProxyTests(unittest.TestCase): + def test_preserves_routes_payload_status_range_and_backend_auth(self): + server=ThreadingHTTPServer(('127.0.0.1',0),Upstream);threading.Thread(target=server.serve_forever,daemon=True).start() + try: + with tempfile.TemporaryDirectory() as d: + token=Path(d)/'token';token.write_text('synthetic-upstream-secret') + manager=Mock();manager.video_configs.return_value=[dict(id='ltx',api_url=f'http://127.0.0.1:{server.server_port}',token_file=str(token))];manager.video_status.return_value={'running':True} + for method,path in [('POST','/api/generate?native=1'),('POST','/api/generate/cancel'),('GET','/api/invalid'),('DELETE','/api/models/delete')]: + payload=b'{"native_field":"unchanged"}';out=io.BytesIO() + video_http(manager,dict(action='video-http',service='ltx',method=method,path=path,length=len(payload),headers={'Content-Type':'application/json','Range':'bytes=0-22'}),io.BytesIO(payload),out) + raw=out.getvalue();head,body=raw.split(b'\r\n\r\n',1) + self.assertIn(b'422' if path.endswith('invalid') else b'206',head) + self.assertEqual(body,b'\x00original-LTX-response\xff') + received=server.received;self.assertEqual(received[:2],(method,path));self.assertEqual(received[3],payload) + self.assertEqual(received[2]['Authorization'],'Bearer synthetic-upstream-secret') + self.assertNotIn(b'synthetic-upstream-secret',raw) + finally:server.shutdown();server.server_close() + def test_rejects_unregistered_targets_header_injection_and_absolute_urls(self): + manager=Mock();manager.video_configs.return_value=[] + for changes in ({},{'path':'http://example.com/'},{'headers':{'Authorization':'bad'}},{'headers':{'Accept':'ok\r\nbad'}},{'length':-1}): + request=dict(action='video-http',service='unknown',method='GET',path='/health',length=0,headers={});request.update(changes);out=io.BytesIO() + video_http(manager,request,io.BytesIO(),out);self.assertIn(b'502',out.getvalue()) diff --git a/video-ui.js b/video-ui.js index a707093..b4b0ec1 100644 --- a/video-ui.js +++ b/video-ui.js @@ -1,15 +1,15 @@ window.VideoUI=(()=>{ const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const s=await r.json();if(!r.ok)throw Error(s.error||'Anfrage fehlgeschlagen');return s;} - function mode(root,s){ + function mode(root,s,profiles=[],port=null){ if(root.dataset.busy==='1')return; - root.innerHTML=`

GPU-Modus

${e(s.service?.name||'Kein Videodienst eingerichtet')}

${s.state==='switching'?`
${e(s.switch_phase)}
`:`

${s.mode==='llm'?'LLM-Betrieb · Videodienst gestoppt':s.service?.ready?'Video-API bereit':s.service?.running?'Videodienst läuft · API noch nicht bereit':'Dienststatus nicht prüfbar · GPU-Aufträge gesperrt'}

`}${s.service?.api_url?`

Original-API: ${e(s.service.api_url)}

${e(s.service.message)}

`:''}${s.error?``:''}

Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.

Aktiven Videodienst unter Weitere Dienste wählen
`; - root.querySelectorAll('[data-mode]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';root.querySelectorAll('button').forEach(x=>x.disabled=true);root.querySelector('.video-message').textContent='Moduswechsel wird angefordert …';try{const next=await api('video/mode',{mode:b.dataset.mode});root.dataset.busy='0';mode(root,next);}catch(x){root.querySelector('.video-message').textContent=x.message;root.dataset.busy='0';}}); + root.innerHTML=`

GPU-Modus

Gemeinsamer API-Port: ${e(port||"siehe Endpunkt")} · LLM-Modus: OpenAI-API · Video-Modus: Original-API der Video-Laufzeit

${e(s.service?.name||'Kein Videodienst eingerichtet')}

${s.state==='switching'?`
${e(s.switch_phase)}
`:`

${s.mode==='llm'?'LLM-Betrieb · Videodienst gestoppt':s.service?.ready?'Video-API bereit':s.service?.running?'Videodienst läuft · API noch nicht bereit':'Dienststatus nicht prüfbar · GPU-Aufträge gesperrt'}

`}${s.service?.api_url?`

Interner Backend-Zugang: ${e(s.service.api_url)}

${e(s.service.message)}

`:''}${s.error?``:''}

Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.

Aktiven Videodienst im Bereich Video wählen
`; + root.querySelectorAll('[data-mode]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';root.querySelectorAll('button').forEach(x=>x.disabled=true);root.querySelector('.video-message').textContent='Moduswechsel wird angefordert …';try{const next=await api('video/mode',{mode:b.dataset.mode});root.dataset.busy='0';mode(root,next,profiles,port);}catch(x){root.querySelector('.video-message').textContent=x.message;root.dataset.busy='0';}}); } function services(root){ if(!root||root.dataset.bound)return;root.dataset.bound='1'; async function poll(){if(!root.isConnected)return;try{const s=await api('video');if(root.dataset.busy!=='1'){root.innerHTML=`

Aktiver Videodienst

Die Übersicht startet den hier gewählten Dienst. Jeder Dienst behält seine eigene API; keine Übersetzung von Generierungsparametern.

${s.services.map(x=>``).join('')||'

Noch kein Videodienst durch den Administrator zugeordnet.

'}

Zum Wechseln zuerst in der Übersicht auf LLM schalten.

`;root.querySelectorAll('input').forEach(b=>b.onchange=async()=>{root.dataset.busy='1';try{await api('video/service',{id:b.value});root.querySelector('.video-message').textContent='Videodienst aktiviert. Start über die Übersicht.';}catch(x){root.querySelector('.video-message').textContent=x.message;}finally{root.dataset.busy='0';}});}}catch(x){root.textContent=x.message;}if(root.isConnected)setTimeout(poll,5000);}poll(); } - function runtime(){const view=document.querySelector('#view');if(view.querySelector('#video-service-page'))return;view.innerHTML='

Videodienst

Deck verwaltet keine Videoprofile. Der aktive Dienst wird in der Übersicht gestartet und über seine Original-API bedient.

Zur Übersicht
';services(view.querySelector('#video-services'));} + function runtime(){const view=document.querySelector('#view');if(view.querySelector('#video-service-page'))return;view.innerHTML='

Video

Video ist ein KI-Bereich in Deck. Die Übersicht startet die gewählte Video-Laufzeit. Im Videomodus stellt der gemeinsame API-Port deren Original-API bereit. Das separate LTX-Webfrontend gehört unter Weitere Dienste.

Zur Übersicht
';services(view.querySelector('#video-services'));} return {mode,services,runtime}; })(); diff --git a/video.py b/video.py index 9bc5d8e..8b31301 100644 --- a/video.py +++ b/video.py @@ -36,7 +36,7 @@ class Video: with self.lock: if self.state=='switching':raise ValueError('Moduswechsel läuft bereits.') self.refresh() - if not self.service:raise ValueError('Zuerst einen eingerichteten Videodienst unter Weitere Dienste aktivieren.') + if not self.service:raise ValueError('Zuerst einen eingerichteten Videodienst im Bereich Video aktivieren.') with self.scheduler.cv:self.scheduler.gpu_mode='switching';self.scheduler.cv.notify_all() self.state='switching';self.error=None;self.target_mode=mode;self.switch_started_at=time.time();self.switch_phase='Deck-GPU-Aufträge beenden' if mode=='video' else 'Videodienst beenden und GPU-Speicher freigeben' self.thread=threading.Thread(target=self._switch,args=(mode,),daemon=True);self.thread.start() @@ -51,13 +51,13 @@ class Video: if time.monotonic()>deadline:raise ValueError('Deck-Aufträge konnten noch nicht beendet werden.') self.scheduler.cv.wait(.2) self.scheduler.key=None - self.switch_phase='Freie GPUs prüfen und Videodienst-Dienst starten' + self.switch_phase='Freie GPUs prüfen und Videodienst starten' self.helper.call('video-start',self.selected);self.switch_phase='Auf Original-API des Dienstes warten' deadline=time.monotonic()+240 while True: service=next(s for s in self.helper.call('video-status')['services'] if s['id']==self.selected) if service.get('ready'):break - if not service.get('running'):raise ValueError('Videodienst-Dienst wurde beendet. Installation prüfen.') + if not service.get('running'):raise ValueError('Videodienst wurde beendet. Installation prüfen.') if time.monotonic()>deadline:raise ValueError('Videodienst läuft, seine API ist noch nicht bereit. Erneut prüfen oder auf LLM zurückschalten.') time.sleep(2) else: diff --git a/video_proxy.py b/video_proxy.py new file mode 100644 index 0000000..c5cb3a7 --- /dev/null +++ b/video_proxy.py @@ -0,0 +1,30 @@ +"""HTTP transport only: preserve the selected backend's API paths and payloads.""" +import http.client +import json +import os +import socket + +MAX_BODY=256*1024*1024 +REQUEST_HEADERS={'content-type','accept','range','if-range','if-none-match','if-modified-since'} +HOP_HEADERS={'connection','keep-alive','proxy-authenticate','proxy-authorization','te','trailer','transfer-encoding','upgrade'} + +def relay(handler,service): + if handler.headers.get('Transfer-Encoding'):raise ValueError('Content-Length erforderlich; Chunked-Uploads nicht unterstützt.') + length=int(handler.headers.get('Content-Length','0')) + if not 0<=length<=MAX_BODY:raise ValueError('Anfrage größer als 256 MiB oder ungültige Länge.') + data=dict(action='video-http',service=service,method=handler.command,path=handler.path,length=length,headers={k:v for k,v in handler.headers.items() if k.lower() in REQUEST_HEADERS}) + path=os.environ.get('DECK_DOCKER_HELPER_SOCKET','/run/athena-deck-docker/control.sock') + with socket.socket(socket.AF_UNIX) as sock: + sock.settimeout(3600);sock.connect(path);sock.sendall(json.dumps(data).encode()+b'\n') + remaining=length + while remaining: + chunk=handler.rfile.read(min(1024*1024,remaining)) + if not chunk:raise ValueError('Unvollständige Anfrage.') + sock.sendall(chunk);remaining-=len(chunk) + response=http.client.HTTPResponse(sock);response.begin() + handler.sent=True;handler.send_response(response.status) + for key,value in response.getheaders(): + if key.lower() not in HOP_HEADERS|{'server','date'}:handler.send_header(key,value) + handler.send_header('Connection','close');handler.end_headers() + if handler.command!='HEAD': + while chunk:=response.read(1024*1024):handler.wfile.write(chunk)