Restore Video workspace and route native video API through shared endpoint
This commit is contained in:
@@ -151,13 +151,13 @@ Interne API (nur angemeldete Administratorsitzung): `GET /api/v1/huggingface` li
|
|||||||
|
|
||||||
### Video: externe Dienste statt Deck-Profile
|
### 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).
|
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:
|
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.
|
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.
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
|
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
|
||||||
COPY deploy/tts-requirements.lock /app/deploy/tts-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 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/
|
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
||||||
|
|||||||
+46
-1
@@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Root-owned local helper. Only inventory and fresh Docker install; no generic RPC."""
|
"""Root-owned local helper. Only inventory and fresh Docker install; no generic RPC."""
|
||||||
import argparse
|
import argparse
|
||||||
|
import http.client
|
||||||
|
from urllib.parse import urlsplit
|
||||||
import re
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -149,6 +151,46 @@ class Manager:
|
|||||||
if data['action']=='install':return self.install()
|
if data['action']=='install':return self.install()
|
||||||
raise ValueError('Aktion nicht erlaubt.')
|
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):
|
class Handler(socketserver.StreamRequestHandler):
|
||||||
def handle(self):
|
def handle(self):
|
||||||
self.connection.settimeout(20)
|
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.')
|
if uid not in (0,self.server.client_uid):raise ValueError('Client nicht erlaubt.')
|
||||||
raw=self.rfile.readline(4097)
|
raw=self.rfile.readline(4097)
|
||||||
if len(raw)>4096:raise ValueError('Anfrage zu groß.')
|
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.'}
|
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')
|
self.wfile.write(json.dumps(result).encode()+b'\n')
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ import urllib.request
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
LABEL = 'de.casaderoll.athena-deck.standalone'
|
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):
|
def run(*args, check=True, interactive=False):
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ window.EndpointUI=(()=>{
|
|||||||
if(pending)return;const id=sequence;
|
if(pending)return;const id=sequence;
|
||||||
try{const s=await api();if(!root.isConnected||id!==sequence)return;
|
try{const s=await api();if(!root.isConnected||id!==sequence)return;
|
||||||
put(root.querySelector('#endpoint-summary'),summary(s));
|
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';
|
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';
|
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.';
|
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.';
|
||||||
|
|||||||
+13
@@ -1,3 +1,4 @@
|
|||||||
|
from video_proxy import relay
|
||||||
"""Separate authenticated OpenAI-compatible API; only explicitly enabled Deck profiles."""
|
"""Separate authenticated OpenAI-compatible API; only explicitly enabled Deck profiles."""
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -155,6 +156,11 @@ class APIHandler(BaseHTTPRequestHandler):
|
|||||||
return False
|
return False
|
||||||
def do_GET(self):self.route()
|
def do_GET(self):self.route()
|
||||||
def do_POST(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):
|
def route(self):
|
||||||
ep=self.server.endpoint;admitted=False
|
ep=self.server.endpoint;admitted=False
|
||||||
try:
|
try:
|
||||||
@@ -163,6 +169,13 @@ class APIHandler(BaseHTTPRequestHandler):
|
|||||||
with ep.lock:
|
with ep.lock:
|
||||||
if not ep.allowed():raise APIError('Endpunkt wird gestoppt.',503,'endpoint_stopping')
|
if not ep.allowed():raise APIError('Endpunkt wird gestoppt.',503,'endpoint_stopping')
|
||||||
ep.inflight+=1;admitted=True
|
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'}
|
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 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'})
|
if self.command=='GET' and self.path=='/health':return self.send({'status':'ok','service':'athena-deck-api'})
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/auto-test-ui.js" defer></script><script src="/chat-test-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/stt-ui.js" defer></script><script src="/tts-ui.js" defer></script><script src="/studio.js" defer></script><script src="/video-ui.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><p class="nav-heading">AUDIO</p><a class="nav-sub" href="#audio">Text-to-Speech (TTS)</a><a class="nav-sub" href="#stt">Spracherkennung (STT)</a><a class="nav-sub" href="#music">Musik</a><a class="nav-sub" href="#voice">Stimme / Voice</a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtimes">Laufzeiten <span>↗</span></a><a class="nav-sub" href="#runtime">llama.cpp</a><a class="nav-sub" href="#image-runtime">Bildgenerierung</a><a class="nav-sub" href="#stt-runtime">Spracherkennung</a><a class="nav-sub" href="#tts-runtime">Text-to-Speech</a><a class="nav-sub" href="#docker-runtime">Docker</a><a href="#endpoint">Endpunkt & Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/auto-test-ui.js" defer></script><script src="/chat-test-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/stt-ui.js" defer></script><script src="/tts-ui.js" defer></script><script src="/studio.js" defer></script><script src="/video-ui.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><p class="nav-heading">AUDIO</p><a class="nav-sub" href="#audio">Text-to-Speech (TTS)</a><a class="nav-sub" href="#stt">Spracherkennung (STT)</a><a class="nav-sub" href="#music">Musik</a><a class="nav-sub" href="#voice">Stimme / Voice</a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtimes">Laufzeiten <span>↗</span></a><a class="nav-sub" href="#runtime">llama.cpp</a><a class="nav-sub" href="#image-runtime">Bildgenerierung</a><a class="nav-sub" href="#stt-runtime">Spracherkennung</a><a class="nav-sub" href="#tts-runtime">Text-to-Speech</a><a class="nav-sub" href="#docker-runtime">Docker</a><a href="#endpoint">Endpunkt & Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import sys
|
|||||||
|
|
||||||
NAME = 'athena-deck-network'
|
NAME = 'athena-deck-network'
|
||||||
BASE = Path('/opt/athena-deck')
|
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):
|
def run(*args, **kwargs):
|
||||||
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
|
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const Studio=(()=>{
|
|||||||
if(modelId)section='profiles';
|
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():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}</p>${audioDescriptions[page]&&!['audio','stt'].includes(page)?'<p class="note">Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.</p>':''}<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['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])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${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'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
|
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():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}</p>${audioDescriptions[page]&&!['audio','stt'].includes(page)?'<p class="note">Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.</p>':''}<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['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])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${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'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
|
||||||
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
|
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
|
||||||
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(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;}
|
||||||
if(page==='stt-runtime'){STTUI.bind(true);return;}
|
if(page==='stt-runtime'){STTUI.bind(true);return;}
|
||||||
if(page==='tts-runtime'){TTSUI.bind(true);return;}
|
if(page==='tts-runtime'){TTSUI.bind(true);return;}
|
||||||
|
|||||||
+10
-2
@@ -77,10 +77,18 @@ class EndpointTests(unittest.TestCase):
|
|||||||
self.assertFalse(any(p['kind']=='video' for p in self.ep.rows()))
|
self.assertFalse(any(p['kind']=='video' for p in self.ep.rows()))
|
||||||
with self.assertRaises(ValueError):self.ep.enable({'id':'video-old','enabled':True})
|
with self.assertRaises(ValueError):self.ep.enable({'id':'video-old','enabled':True})
|
||||||
def test_video_mode_rejects_chat_without_starting_llama(self):
|
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')]))
|
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
|
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):
|
def test_video_api_redirect_is_explicit_not_translation(self):
|
||||||
code,body=self.request('/v1/videos',{'prompt':'synthetic'})
|
code,body=self.request('/v1/videos',{'prompt':'synthetic'})
|
||||||
self.assertEqual(code,410);self.assertEqual(body['error']['code'],'use_ltx_api')
|
self.assertEqual(code,410);self.assertEqual(body['error']['code'],'use_ltx_api')
|
||||||
|
|||||||
@@ -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())
|
||||||
+4
-4
@@ -1,15 +1,15 @@
|
|||||||
window.VideoUI=(()=>{
|
window.VideoUI=(()=>{
|
||||||
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
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;}
|
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;
|
if(root.dataset.busy==='1')return;
|
||||||
root.innerHTML=`<section class="card"><h2>GPU-Modus</h2><button data-mode="llm" ${s.mode==='llm'||s.state==='switching'?'disabled':''}>LLM</button><button data-mode="video" ${!s.service?.id||s.mode==='video'||s.state==='switching'?'disabled':''}>Video</button><h3>${e(s.service?.name||'Kein Videodienst eingerichtet')}</h3>${s.state==='switching'?`<div class="video-activity" role="status"><strong>${e(s.switch_phase)}</strong><progress></progress></div>`:`<p>${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'}</p>`}${s.service?.api_url?`<p>Original-API: <code>${e(s.service.api_url)}</code></p><p>${e(s.service.message)}</p>`:''}${s.error?`<p class="video-error" role="alert">${e(s.error)}</p>`:''}<p>Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.</p><a href="#services">Aktiven Videodienst unter Weitere Dienste wählen</a><div class="video-message" role="status"></div></section>`;
|
root.innerHTML=`<section class="card"><h2>GPU-Modus</h2><p>Gemeinsamer API-Port: <strong>${e(port||"siehe Endpunkt")}</strong> · LLM-Modus: OpenAI-API · Video-Modus: Original-API der Video-Laufzeit</p><button data-mode="llm" ${s.mode==='llm'||s.state==='switching'?'disabled':''}>LLM</button><button data-mode="video" ${!s.service?.id||s.mode==='video'||s.state==='switching'?'disabled':''}>Video</button><h3>${e(s.service?.name||'Kein Videodienst eingerichtet')}</h3>${s.state==='switching'?`<div class="video-activity" role="status"><strong>${e(s.switch_phase)}</strong><progress></progress></div>`:`<p>${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'}</p>`}${s.service?.api_url?`<p>Interner Backend-Zugang: <code>${e(s.service.api_url)}</code></p><p>${e(s.service.message)}</p>`:''}${s.error?`<p class="video-error" role="alert">${e(s.error)}</p>`:''}<p>Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.</p><a href="#video">Aktiven Videodienst im Bereich Video wählen</a><div class="video-message" role="status"></div></section>`;
|
||||||
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.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){
|
function services(root){
|
||||||
if(!root||root.dataset.bound)return;root.dataset.bound='1';
|
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=`<section class="card"><h2>Aktiver Videodienst</h2><p>Die Übersicht startet den hier gewählten Dienst. Jeder Dienst behält seine eigene API; keine Übersetzung von Generierungsparametern.</p>${s.services.map(x=>`<label><input type="radio" name="video-service" value="${e(x.id)}" ${x.id===s.selected?'checked':''} ${s.mode!=='llm'?'disabled':''}> ${e(x.name)} · ${e(x.api_url)}</label>`).join('')||'<p>Noch kein Videodienst durch den Administrator zugeordnet.</p>'}<p>Zum Wechseln zuerst in der Übersicht auf LLM schalten.</p><p class="video-message" role="status"></p></section>`;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();
|
async function poll(){if(!root.isConnected)return;try{const s=await api('video');if(root.dataset.busy!=='1'){root.innerHTML=`<section class="card"><h2>Aktiver Videodienst</h2><p>Die Übersicht startet den hier gewählten Dienst. Jeder Dienst behält seine eigene API; keine Übersetzung von Generierungsparametern.</p>${s.services.map(x=>`<label><input type="radio" name="video-service" value="${e(x.id)}" ${x.id===s.selected?'checked':''} ${s.mode!=='llm'?'disabled':''}> ${e(x.name)} · ${e(x.api_url)}</label>`).join('')||'<p>Noch kein Videodienst durch den Administrator zugeordnet.</p>'}<p>Zum Wechseln zuerst in der Übersicht auf LLM schalten.</p><p class="video-message" role="status"></p></section>`;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='<section id="video-service-page"><h1>Videodienst</h1><p>Deck verwaltet keine Videoprofile. Der aktive Dienst wird in der Übersicht gestartet und über seine Original-API bedient.</p><a href="#home">Zur Übersicht</a><div id="video-services"></div></section>';services(view.querySelector('#video-services'));}
|
function runtime(){const view=document.querySelector('#view');if(view.querySelector('#video-service-page'))return;view.innerHTML='<section id="video-service-page"><h1>Video</h1><p>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.</p><a href="#home">Zur Übersicht</a><div id="video-services"></div></section>';services(view.querySelector('#video-services'));}
|
||||||
return {mode,services,runtime};
|
return {mode,services,runtime};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class Video:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
if self.state=='switching':raise ValueError('Moduswechsel läuft bereits.')
|
if self.state=='switching':raise ValueError('Moduswechsel läuft bereits.')
|
||||||
self.refresh()
|
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()
|
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.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()
|
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.')
|
if time.monotonic()>deadline:raise ValueError('Deck-Aufträge konnten noch nicht beendet werden.')
|
||||||
self.scheduler.cv.wait(.2)
|
self.scheduler.cv.wait(.2)
|
||||||
self.scheduler.key=None
|
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'
|
self.helper.call('video-start',self.selected);self.switch_phase='Auf Original-API des Dienstes warten'
|
||||||
deadline=time.monotonic()+240
|
deadline=time.monotonic()+240
|
||||||
while True:
|
while True:
|
||||||
service=next(s for s in self.helper.call('video-status')['services'] if s['id']==self.selected)
|
service=next(s for s in self.helper.call('video-status')['services'] if s['id']==self.selected)
|
||||||
if service.get('ready'):break
|
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.')
|
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)
|
time.sleep(2)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user