diff --git a/ACCESS.md b/ACCESS.md index 138f9f2..01fab83 100644 --- a/ACCESS.md +++ b/ACCESS.md @@ -51,9 +51,8 @@ API-Clients verwenden `Authorization: Bearer `. Aktuell erlaubt sind: |---|---| | GET | `/api/v1/status` | | GET | `/api/v1/hardware` | -| GET | `/api/v1/demo` | -| POST | `/api/v1/demo/start` | -| POST | `/api/v1/demo/stop` | +| GET | API-Port: `/v1/models`, `/health` | +| POST | API-Port: `/v1/chat/completions`, `/v1/images/generations` | API-Clients brauchen weder Cookie noch Browser-CSRF-Header. Ein vorhandener Authorization-Header wird ausdrücklich geprüft; ein ungültiger Token wird nicht @@ -66,10 +65,11 @@ Beispiel (Platzhalter ersetzen): curl -H 'Authorization: Bearer ' http://127.0.0.1:8108/api/v1/status ``` -Die späteren Modell-/Inferenz-Endpunkte existieren noch nicht. Ihre Autorisierung -muss beim Ergänzen explizit an die Token-Prüfung angeschlossen werden. Es wird kein -produktiver Router-Token verändert. Der WireGuard-Zugriffsmodus gilt weiterhin vor -der Authentifizierung: ein gültiger Token umgeht keine gesperrte LAN-/Tunnelroute. +Der separate Inferenz-Listener prüft denselben Deck-Token bei jeder Anfrage. +Endpunkt starten/stoppen, Port und Profilfreigaben ändern erfordert weiterhin die +Admin-Sitzung. Der alte produktive Router-Token wird nicht geändert. Der neue +Listener ist zunächst nur via Loopback/SSH erreichbar; das bestehende +WireGuard-Modul wird nicht automatisch erweitert. Details: [ENDPOINT.md](ENDPOINT.md). ## Speicherung und Serverbetrieb diff --git a/ENDPOINT.md b/ENDPOINT.md new file mode 100644 index 0000000..44dc3ac --- /dev/null +++ b/ENDPOINT.md @@ -0,0 +1,131 @@ +# Eigener OpenAI-kompatibler Endpunkt + +Die Übersicht steuert den API-Listener: Start, Stopp, Erreichbarkeit, Port, +aktivierte/ausführbare Profile pro Kategorie und tatsächlich geladener Worker. +Die Demo-API wurde entfernt. Einstellungen → **Endpunkt & Profile** konfiguriert +Port und Veröffentlichungen. Alternativ kann jedes ausführbare Profil direkt +im Profileditor am Endpunkt aktiviert werden. Neue Profile sind standardmäßig +nicht veröffentlicht. Profilfreigabe lädt noch keine Gewichte. + +## Zugriff und Port + +UI und Inferenz-API sind getrennte Listener. Standard für Inferenz: **8120**. +Der vorhandene Deck-API-Token gilt für alle Inferenzrouten; Kennwort/Cookie gelten +nur für die Verwaltung. Tokenrotation wirkt sofort auf neue Anfragen. Keine +Token, Prompts oder Antworten werden in Zugriffslogs geschrieben. Intern erhält +llama.cpp einen unabhängigen kurzlebigen Schlüssel in einer Datei mit Modus 0600. + +Die aktuelle Docker-Testinstallation veröffentlicht ausschließlich Host-Loopback +**8120–8124**. Innerhalb dieses Bereichs ist der Port bei gestopptem Endpunkt +in der GUI frei wählbar. Der Installer prüft neue Ports vor dem Ersetzen des +eigenen Containers; vorhandene fremde Dienste werden nie gestoppt. +Anderer Bereich: `./install.sh --update --api-ports 8130-8134 --directory `. +Danach gegebenenfalls den gespeicherten API-Port in der GUI korrigieren. +Bei nativem Betrieb ist jeder freie Port 1024–65535 möglich; standardmäßig wird +nur an 127.0.0.1 gebunden. Kein automatischer Firewall-, WireGuard- oder DNS-Umbau. + +Auf dem Arbeitsplatz für API-Zugriff, zusätzlich zum bestehenden UI-Tunnel: + +```sh +ssh -i /Users/mike_i386/.ssh/athena_key -o BatchMode=yes \ + -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 \ + -N -L 8120:127.0.0.1:8120 root@192.168.1.212 +``` + +Client-Basisadresse: `http://127.0.0.1:8120/v1`. Bei Portänderung den Tunnel +entsprechend anpassen. Der Endpunkt merkt sich Start/Stopp; nach regulärem +Deck-Neustart startet nur der Listener automatisch, kein Modell. + +## Routen + +Alle Inferenzrouten benötigen `Authorization: Bearer `. + +| Methode | Pfad | Verhalten | +|---|---|---| +| GET | `/v1/models` | Nur freigegebene, ausführbare Profilnamen; kein Modellstart | +| GET | `/health` | Listener-Health, ebenfalls authentifiziert | +| POST | `/v1/chat/completions` | Textchat, JSON und SSE-Streaming, Sampling-Defaults aus dem Profil | +| POST | `/v1/images/generations` | Qwen-Image-2.1-Rezept, n=1, `b64_json` | +| POST | `/v1/audio/speech` | 501: noch kein eigener TTS-Worker | +| POST | `/v1/audio/transcriptions` | 501: noch kein eigener STT-Worker | + +`model` ist immer der **API-Profilname**, nicht der GGUF-Dateiname. Bildaufträge +verwenden die gespeicherte Auflösung, Schritte, Guidance und Seed; eine explizite +`size` muss der Profilauflösung entsprechen. Das Bild liegt wie beim GUI-Test im +eigenen Jobverzeichnis. Kein Bildprompt wird persistiert; PNG-Metadaten sind +deaktiviert. n=1; Ausgabe zunächst als Base64, keine öffentlich abrufbaren Bild-URLs. +Die API ist bewusst ein Teilumfang: noch keine `/v1/responses`, Embeddings, +Vision-Eingaben, Bildbearbeitung, Video oder Audio-Worker. TTS/STT benötigen keinen +eigenen Port, sondern eigene Routen und Worker hinter demselben Listener. + +Chat akzeptiert übliche Nachrichten, Tools, response_format, Sampling-Overrides +und Streaming-Optionen; unbekannte Erweiterungsfelder werden abgelehnt. Maximal +1 MiB JSON pro Anfrage, 16 MiB gepufferte Chatantwort. Keine Chunked-Uploads oder +Cross-Origin-Browserfreigabe. Internes llama.cpp-HTTP-Zeitlimit: 120 Sekunden ohne +Antwortdaten, maximal 600 Sekunden für laufende SSE-Ausgabe. + +## Prozesssteuerung und Speicher + +- Eigener nativer llama-server aus dem gewählten, geprüften CUDA-Build. Kein + produktiver Container wird gestartet, gestoppt oder umkonfiguriert. +- Start erfolgt nach Anfrage. Mehrere Profile dürfen dieselbe Modelldatei nutzen. + Ein anderes Profil bzw. eine neue Profilrevision entlädt zunächst den eigenen + Worker und startet ihn mit den gespeicherten Parametern neu. +- FIFO-Warteschlange (maximal 16 wartende Aufträge, 600 Sekunden Wartezeit). + Gleiches Chatprofil darf bis zur konfigurierten Slotzahl parallel antworten. + Ein wartender Wechsel verhindert, dass neue Chats ihn dauerhaft überholen. + Streaming hält die Modellreservierung bis zum Ende der Ausgabe. +- Bildaufträge benötigen beide freien GPUs. GUI-Bildtests und API nutzen dieselbe + Reservierung. GUI meldet bei belegter Reservierung einen Konflikt; API wartet. + Das Sprachmodell wird vor Bildgenerierung entladen. ComfyUI wird nach jedem Bild + vollständig beendet; die nächste Textanfrage lädt ihr Profil erneut. +- Stoppen nimmt keine neuen Anfragen an, verwirft wartende Anfragen und lässt + bereits laufende Antworten/Generierungen fertig werden, dann wird entladen. + SIGTERM beendet eigene Worker. Keine Garantie für SIGKILL bei nativem Betrieb; + das spätere systemd-Paket muss Prozesse über seine Cgroup vollständig aufräumen. +- GPU-Auswahl über UUID-Reihenfolge; busy GPUs werden abgewiesen. Speicherprüfung + vor jedem Start: reale freie GPU-/RAM-Kapazität, Cgroup-Limit, Reserven. Inaktiver, + reclaimbarer Dateicache wird vom Cgroup-Verbrauch abgezogen, anonyme Belegung nicht. + Ein zusätzlicher GPU-Prozess während des Betriebs beendet nur den Deck-Worker. +- llama.cpp: Gesamtkontext und Slots bleiben unverändert. Shared KV (`--kv-unified`), + K/V q4_0, Flash Attention on, load-mode none; derzeit diese festen Backend-Defaults. + Fit ist eine **Prognose**, kein OOM-Test. Feste Tensor-Splits werden separat per + `--fit-print` geprüft; falls nötig wird in höchstens zehn Schritten eine passende + GPU-Layerzahl gesucht. Verhältnisse und Kontext werden dabei nicht umgeschrieben. + Ohne feste Verteilung verwendet Deck die geprüften Fit-Parameter des Builds. + +Aktive Modellstarts bleiben an freie Ressourcen gebunden. Der alte Router kann +weiterlaufen; fordert er dieselben GPUs an, beendet Deck bei erkanntem Konflikt +seinen eigenen Worker. Das ersetzt keine serverweite Ressourcenkoordination. + +## Interne Verwaltungs-API + +Nur angemeldete Oberflächensitzungen plus `X-Athena-Deck: 1`, kein API-Token: + +| Methode | Route | JSON | +|---|---|---| +| GET | `/api/v1/endpoint` | Status, Port, Zähler, Worker, Warteschlange, Profile | +| POST | `/api/v1/endpoint/start` | `{}` | +| POST | `/api/v1/endpoint/stop` | `{}` | +| POST | `/api/v1/endpoint/config` | `{"port":8120}`; nur gestoppt | +| POST | `/api/v1/endpoint/profile` | `{"id":"","enabled":true}` | + +Persistenz: `endpoint.json` im Deck-Zustandsverzeichnis. Aktivierte, später +unvollständige/gelöschte Profile verschwinden aus `/v1/models`; bestehende Requests +nutzen ihren konsistenten Profilsnapshot. Wartende Requests eines geänderten oder +deaktivierten Profils werden abgewiesen. Aktive Buildänderungen gelten nach dem +nächsten Entladen; Modellprofile selbst werden nicht automatisch verändert. + +## Verifiziert am 28.09.2026 + +Isolierte HTTP-/Scheduler-Tests: Tokenrotation, Adminschutz, explizite Freigabe, +Profilwechsel, Streaming-Leases, ungültige Anfragen, Warteschlange und Portkonflikte. +GUI: Freigabe, Start/Stopp, Status und Portformular. + +Echter Test auf Athena mit separaten Testprofilen und synthetischen Eingaben: +Qwen IQ4_XS Pure, Medium 160000/2 Slots/85:15 → zweites Profil 4096/1 Slot mit SSE +→ Qwen-Image-Rezept (512×512, 4 Schritte) → LLM. PNG geprüft; anschließend eigener +Listener gestoppt und Modell entladen. Keine vorhandenen Nutzerprompts oder +Anwendungslogs gelesen. Testprofile und Testzugang gehören nicht zur normalen +Deck-Konfiguration. Der Test weist keine vollständige OpenAI-Kompatibilität oder +Langzeitstabilität nach. diff --git a/INSTALL.md b/INSTALL.md index 79a28b6..2981d2a 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -194,3 +194,13 @@ entfernt; Startzeiten zuvor vorhandener Container unverändert. Es wurde **keine dauerhafte Installation auf Athena ausgeführt**. Die interaktive Erstinstallation mit deinen echten Zugangsdaten und die optionale NVIDIA-Telemetrie sind noch nicht als persistente Installation abgenommen. + + +## Separater Inferenz-Port + +Neue Installationen veröffentlichen zusätzlich ausschließlich an Host-Loopback +8120–8124. Mit `--api-ports 8120-8124` lässt sich dieser Bereich auch bei `--update` +für eine vorhandene Deck-Installation ergänzen. Alle neuen Ports werden vor dem +Update auf Kollisionen geprüft. Die GUI erlaubt den Wechsel innerhalb des Bereichs +bei gestopptem Endpunkt. Nativer Betrieb kennt diese Docker-Beschränkung nicht. +Betrieb, Authentifizierung und SSH-Tunnel: [ENDPOINT.md](ENDPOINT.md). diff --git a/README.md b/README.md index a861ca7..611b284 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Athena Deck · Prototyp 0.4 +# Athena Deck · Router 0.7 Eigenständige neue Oberfläche, Python-Standardbibliothek und HTML/CSS/JavaScript. Keine Installation von Python-Paketen oder Frontend-Builds nötig. Python >= 3.10. @@ -17,7 +17,7 @@ Katalog, Datei-Downloads, Bibliothek, serverseitig gespeicherte Profile und llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine konservative Gewichts-Speicherprüfung; noch keine vollständige Laufzeitprognose. -Bildprofile mit vollständigem Qwen-Image-2.1-GGUF-Rezept lassen sich unter „Testen“ ausführen. Andere Modellstarts sind noch nicht angebunden. Details: [Modellverwaltung](STUDIO.md). +Bildprofile mit vollständigem Qwen-Image-2.1-GGUF-Rezept lassen sich unter „Testen“ ausführen. Textprofile sind am eigenen [OpenAI-kompatiblen Endpunkt](ENDPOINT.md) ausführbar. Audio und Video sind noch nicht angebunden. Details: [Modellverwaltung](STUDIO.md). ## Zugang und API-Token @@ -33,38 +33,21 @@ angebunden; der Zugriff erfolgt über SSH-Tunnel. Der bestehende Gateway bleibt ## Zielsystem und Entwicklung Athena Deck läuft vollständig auf einem Debian-Server mit RTX 5080 und RTX 3060. -Oberfläche, API, Demo-Prozess und Hardware-Erfassung laufen dort. Der Arbeitsplatz +Oberfläche, API, Router und Hardware-Erfassung laufen dort. Der Arbeitsplatz benötigt nur Browser und SSH; er ist kein Anwendungsserver. Auch Builds und -Integrationstests werden auf Athena ausgeführt. Modelllaufzeiten sind noch nicht angebunden. +Integrationstests werden auf Athena ausgeführt. Chat- und Qwen-Image-Laufzeiten sind am eigenen Endpunkt angebunden. Die isolierte Entwicklungsinstallation ist in [DEVELOPMENT.md](DEVELOPMENT.md) beschrieben. Hardware wird direkt über `/proc`, hwmon und nvidia-smi gelesen; -Deck benötigt dafür keinen SSH-Schlüssel. Der Demo-Prozess lädt kein Modell. +Deck benötigt dafür keinen SSH-Schlüssel. Der eigene Router ist in [ENDPOINT.md](ENDPOINT.md) dokumentiert. ## Interne API v1 -Alle Antworten JSON. Zugangsdaten werden geschützt persistiert; Demo-Zustand ist flüchtig. GUI verwendet nur diese API. - -| Methode | Pfad | Ergebnis | -|---|---|---| -| GET | `/api/v1/status` | Name, Version, Laufzeit, Modus, Demo-Zustand | -| GET | `/api/v1/hardware` | Verfügbarkeit, Messzeit, CPU, RAM, GPU-Liste, Fehler | -| GET | `/api/v1/demo` | state, reachable, pid, port, location | -| POST | `/api/v1/demo/start` | Idempotent starten und Health prüfen | -| POST | `/api/v1/demo/stop` | Idempotent eigenen Kindprozess stoppen | - -Browser-POST benötigt Sitzung und `X-Athena-Deck: 1`; Browser-Origin muss zum Host passen. -API-Clients verwenden für freigegebene Dienst-Endpunkte den separaten Bearer-Token. -Host-Allowlist: localhost oder 127.0.0.1 mit tatsächlichem UI-Port. -Keine CORS-Freigabe. 403 bei ungültigem Zugriff, 404 bei unbekannten Pfaden, -503 bei fehlgeschlagener Demo-Bereitschaft. Keine frei übergebbaren Befehle, -Prozess-IDs, Service-Namen oder Remote-Ziele. - -```sh -curl -H 'Authorization: Bearer ' http://127.0.0.1:8108/api/v1/status -curl -X POST -H 'Authorization: Bearer ' http://127.0.0.1:8108/api/v1/demo/start -curl -X POST -H 'Authorization: Bearer ' http://127.0.0.1:8108/api/v1/demo/stop -``` +Die GUI verwendet eine sitzungsgeschützte Verwaltungs-API. `GET /api/v1/status` +und `GET /api/v1/hardware` sind auch mit dem separaten API-Token lesbar. +Die Übersicht steuert den eigenen OpenAI-kompatiblen API-Listener; Demo-Dienst +und Demo-Routen wurden entfernt. Port, Profilfreigaben, Inferenzrouten, +Betriebsgrenzen und SSH-Tunnel: **[ENDPOINT.md](ENDPOINT.md)**. Hardware: `available` bezeichnet Erfolg der Hardware-Abfrage, einzelne Messwerte können trotzdem fehlen (`null`). Ein Ausfall der Erfassung liefert available=false, @@ -80,21 +63,16 @@ Keine Historie und kein Hintergrund-Collector bei geschlossener Hardware-Seite. ## Struktur und Grenzen -- `server.py`: API, separater HardwareProvider, eigenständiger DemoService. -- `demo.py`: Health-only HTTP-Kindprozess auf dem Debian-Server, ohne Modellabhängigkeiten. -- `collect_hardware.py`: fest begrenzte lesende Linux-Hardware-Abfragen. -- `index.html`, `app.js`, `style.css`: neue responsive Oberfläche. -- `test_server.py`: Prozess-Lebenszyklus, Kontrollgrenzen, Hardware-Ausfall. +- `server.py`: Verwaltungs-API und lesende Hardware-Abfragen. +- `endpoint.py`: separater authentifizierter Inferenz-Listener. +- `inference.py`: native llama.cpp-Prozesse und gemeinsame GPU-Reservierung. +- `image_test.py`: eigene ComfyUI-Aufträge, gemeinsam mit dem Router koordiniert. +- `endpoint-ui.js`: Übersicht, Port und Profilfreigaben. -Sprachmodelle/Chat, Bildgenerierung, Audio und Video besitzen jetzt eine -gemeinsame Modellverwaltung; Weitere Dienste bleibt Platzhalter. Downloads und Profile sind aktiv, Chats und Modellwechsel noch nicht. -Spätere Modellprofile und native llama.cpp-Worker sollten eigene Service-Adapter -mit derselben Status-/Start-/Stopp-Trennung erhalten. Noch kein Worker-Registry, -Scheduler oder produktionsreifer Worker-Supervisor. Die optionale Server-Instanz -hat eine eigene Passwortanmeldung; ihre Netzwerkgrenzen stehen in der Modul-Dokumentation. -SIGKILL/Absturz-Cleanup ist nicht implementiert; regulär Ctrl+C/SIGTERM verwenden. -Der Hardware-Collector ändert keine Host-Konfiguration. Die optionale -Netzwerkmodul-Installation erzeugt einen eigenen Docker-Container samt Portbindungen. +Sprachmodelle und Qwen-Image-Profile sind ausführbar. Audio-/Video-Laufzeiten +bleiben vorbereitet; weitere Docker-Dienste werden nur mit Deck-Labels angezeigt. +Der native systemd-Installer ist noch nicht vollständig; aktuell gilt der isolierte +Docker-Testinstaller. Keine Migration oder Steuerung des bisherigen Routers. ## Verifikation am 28.09.2026 diff --git a/STUDIO.md b/STUDIO.md index 0e1eb7e..ba1e37b 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -141,8 +141,8 @@ Top-p und Top-k sowie geordnete GPU-UUIDs, Split-Modus (`none`, `layer`, `row`) und relative Verteilungsgewichte. Zwei GPU-Auswahlen zeigen die echten Kartennamen; die Reihenfolge definiert die vorgesehenen logischen CUDA0/CUDA1-Geräte. UUIDs verhindern Verwechslungen mit wechselnden nvidia-smi-Indizes. CPU-Threads stehen -unter „Erweitert“. Die Auswahl startet keinen Worker; die Chat-Ausführung ist -weiterhin nicht angebunden. Die GPU-Verteilung ist keine Speicherzusage. +unter „Erweitert“. Die Auswahl startet keinen Worker. Nach expliziter Freigabe am Endpunkt lädt +der eigene Router das Profil bei einer Chat-Anfrage; siehe [ENDPOINT.md](ENDPOINT.md). Die GPU-Verteilung ist keine Speicherzusage. Medium aus der Referenztabelle: Kontext 160000 insgesamt für zwei Slots, RTX 5080 zuerst, RTX 3060 danach, Layer-Split 85,15, Batch 2048, Microbatch 256, diff --git a/app.js b/app.js index ca118e8..5b5d24f 100644 --- a/app.js +++ b/app.js @@ -1,5 +1,5 @@ const view=document.querySelector('#view'), error=document.querySelector('#error'); -let busy=false, refreshing=false; +let refreshing=false; const esc=v=>String(v??'Nicht verfügbar').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const fmt=(v,unit='')=>v==null?'Nicht verfügbar':`${Number(v).toLocaleString('de-DE',{maximumFractionDigits:1})}${unit}`; const heading=(tag,title,desc)=>`
${tag}

${title}

${desc}

`; @@ -11,10 +11,9 @@ async function refresh(){if(refreshing)return;refreshing=true;const page=locatio if(['chat','image','audio','video','runtime','runtimes','image-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;} if(page==='access'){await accessPage();return;} if(page==='network'){await networkPage();return;} -if(page==='home'){const s=await api('status'),d=s.demo;html=heading('DEIN KONTROLLZENTRUM','Alles beginnt mit einer klaren Basis.','Eine kleine Oberfläche für Status, Hardware und den ersten steuerbaren Dienst.')+`
Anwendung
Bereit

Athena Deck läuft auf ${esc(s.location)}.

Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1
Betriebsmodus
Isoliert

Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.

Keine Modell- oder Router-Steuerung angebunden

Demo-Dienst

LOKAL / CPU

Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.

${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}
Lädt keine Modelle · endet beim Beenden von Athena Deck
`; +if(page==='home'||page==='endpoint'){await EndpointUI.render(page==='endpoint');return; }else if(page==='hardware'){const h=await api('hardware');html=heading('TELEMETRIE / ATHENA','Hardware im Blick.','Live-Messwerte vom Host 192.168.1.212. Aktualisierung alle fünf Sekunden. GPU-Rechenlast und belegter VRAM werden getrennt dargestellt.');if(!h.available)html+=`
Hardware nicht verfügbar

${esc(h.errors.join(' '))}

`;else{const c=h.cpu,r=h.ram;html+=`
CPU

${esc(c.name)}

${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}
${bar(c.percent)}
System-RAM

Arbeitsspeicher

${metric('Belegt',fmt(r.used_bytes==null?null:r.used_bytes/2**30,' GiB'))}${metric('Gesamt',fmt(r.total_bytes==null?null:r.total_bytes/2**30,' GiB'))}
${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}
`;for(const g of h.gpus)html+=`
GPU ${g.index}${fmt(g.temperature_c,' °C')}

${esc(g.name)}

${metric('GPU-Auslastung',fmt(g.percent,' %'))}${metric('VRAM belegt / gesamt',fmt(g.used_mib==null?null:g.used_mib/1024)+' / '+fmt(g.total_mib==null?null:g.total_mib/1024,' GiB'))}
GPU-Rechenlast${bar(g.percent)}VRAM-Belegung${bar(g.total_mib&&g.used_mib!=null?100*g.used_mib/g.total_mib:null)}
`;if(h.gpus.length<2)html+='
Nicht alle zwei GPUs verfügbar.
';html+=`

Die Werte gelten für den gesamten Server einschließlich anderer Dienste. Beim Bildtest laufen Textencoder auf der RTX 3060 und Bildmodell auf der RTX 5080; 0 % GPU-Rechenlast bedeutet nicht, dass kein Modell geladen ist. Nach dem Test wird der eigene Worker entladen.
${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}
${esc(h.errors.join(' '))}

`;} }else{const p=placeholders[page]||placeholders.services;html=heading('ARBEITSBEREICH / VORBEREITET',p[0],p[1])+`
+

Platz für den nächsten Schritt.

${p[2]}

Platzhalter · noch keine Funktionen

Installation, Downloads, Presets und Modellwechsel sind in dieser Version nicht enthalten.

`;} -if((location.hash.slice(1)||'home')===page){view.innerHTML=html;for(const action of ['start','stop'])document.getElementById(action)?.addEventListener('click',()=>control(action));} +if((location.hash.slice(1)||'home')===page){view.innerHTML=html;} }catch(e){error.textContent=e.message;view.innerHTML=heading('VERBINDUNG','Status nicht verfügbar','Die API ist gerade nicht erreichbar. Angezeigte Messwerte wurden verworfen.');}finally{refreshing=false;}} -async function control(action){busy=true;document.querySelectorAll('button').forEach(b=>b.disabled=true);try{await api('demo/'+action,'POST')}catch(e){error.textContent=e.message;busy=false;return}busy=false;await refresh()} window.addEventListener('hashchange',refresh);refresh();setInterval(refresh,5000); diff --git a/demo.py b/demo.py deleted file mode 100644 index 10e0a0a..0000000 --- a/demo.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Owned child: health-only HTTP server, inherited bound loopback socket.""" -import json -import socket -import sys -from http.server import BaseHTTPRequestHandler, HTTPServer - -class Handler(BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({'service': 'athena-deck-demo', 'status': 'ok'}).encode() - self.send_response(200 if self.path == '/health' else 404) - self.send_header('Content-Type', 'application/json') - self.end_headers() - self.wfile.write(body) - - def log_message(self, *args): - pass - -server = HTTPServer(('127.0.0.1', 0), Handler, bind_and_activate=False) -server.socket.close() -server.socket = socket.socket(fileno=int(sys.argv[1])) -server.server_address = server.socket.getsockname() -server.serve_forever() diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 6bcef17..6ec2f73 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -12,8 +12,8 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \ && /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock WORKDIR /app COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock -COPY docker_support.py image_encoder_node.py image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py demo.py /app/ -COPY 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 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 catalog.py auth.py collect_hardware.py /app/ +COPY 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 \ DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \ diff --git a/deploy/install.py b/deploy/install.py index 87c7d61..414110c 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 = ['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','catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','demo.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 = ['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','catalog.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): @@ -98,6 +98,9 @@ def launch(config): '-p','127.0.0.1:'+str(config['port'])+':8108', '--health-cmd', 'python3 -c "import urllib.request,json; assert json.load(urllib.request.urlopen(\'http://127.0.0.1:8108/api/v1/auth/status\',timeout=3))[\'initialized\']"', '--health-interval','30s','--health-timeout','5s','--health-retries','3'] + if config.get('api_ports'): + args+=['-e','DECK_API_BIND=0.0.0.0','-e','DECK_API_PORTS='+','.join(map(str,config['api_ports']))] + for port in config['api_ports']:args+=['-p',f'127.0.0.1:{port}:{port}'] if config.get('development_setup'): args[args.index('--health-cmd')+1] = 'python3 -c "import urllib.request; urllib.request.urlopen(\'http://127.0.0.1:8108/api/v1/auth/status\',timeout=3)"' # Explicit isolated development bootstrap, reachable only through host loopback/SSH. @@ -151,6 +154,9 @@ def install(args): if base.exists(): raise RuntimeError('Installationsverzeichnis existiert bereits. Es wird nicht überschrieben.') free_port(args.port) + api_ports=parse_api_ports(args.api_ports or '8120-8124') + if args.port in api_ports:raise RuntimeError('Verwaltungsport und API-Ports müssen verschieden sein.') + for port in api_ports:free_port(port) parent=base.parent while not parent.exists():parent=parent.parent if shutil.disk_usage(parent).free < 25*1024**3: @@ -162,7 +168,7 @@ def install(args): for sub in ('state','models','backups','bootstrap'): (base/sub).mkdir(mode=0o700) os.chown(base/'state',65534,65534) - config=dict(owner=LABEL,base=str(base),name=args.name,port=args.port,image=image,previous_image=None,gpu_telemetry=args.gpu_telemetry,image_runtime=True,docker_helper=True) + config=dict(owner=LABEL,base=str(base),name=args.name,port=args.port,api_ports=api_ports,image=image,previous_image=None,gpu_telemetry=args.gpu_telemetry,image_runtime=True,docker_helper=True) run(sys.executable,str(ROOT/'deploy/setup_docker_helper.py'),'--client-uid','65534','--client-gid','65534') write_manifest(base,config) provision(config) @@ -177,23 +183,26 @@ def install(args): print('Generierter API-Token: geschützte Datei '+str(base/'bootstrap/api-token.txt')+'. In Passwortmanager übernehmen und Datei danach entfernen.') -def update(base, rollback=False, force=False): +def update(base, rollback=False, force=False, api_ports=None): config=load_manifest(base) previous=owned(config['name'],base) if not previous: raise RuntimeError('Kein installierter Deck-Container vorhanden.') + selected_ports=config.get('api_ports',[]) if api_ports is None else parse_api_ports(api_ports) + if config['port'] in selected_ports:raise RuntimeError('Verwaltungsport und API-Ports müssen verschieden sein.') + for port in set(selected_ports)-set(config.get('api_ports',[])):free_port(port) image=config.get('previous_image') if rollback else build() if not image:raise RuntimeError('Kein vorheriger Build gespeichert.') - if image==config['image'] and not force: + if image==config['image'] and selected_ports==config.get('api_ports',[]) and not force: print('Dieser Quellstand ist bereits installiert.');return backup_name=config['name']+'-previous' if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.') stamp=str(time.time_ns()) - shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime')) + shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','router-verification-*')) was_running=previous['State']['Running'] if was_running:run('docker','stop','--time','15',config['name']) run('docker','rename',config['name'],backup_name) - new=dict(config,image=image,previous_image=config['image']) + new=dict(config,image=image,previous_image=config['image'],api_ports=selected_ports) try: launch(new);ready(new);write_manifest(base,new) except Exception: @@ -206,6 +215,14 @@ def update(base, rollback=False, force=False): print('Nur Athena Deck wurde aktualisiert. Vorheriges Image bleibt für --rollback erhalten.') +def parse_api_ports(value): + import re + if not isinstance(value,str) or not re.fullmatch(r'[0-9]{4,5}(?:-[0-9]{4,5})?',value):raise RuntimeError('API-Port oder kleiner Portbereich erwartet, z. B. 8120-8124.') + parts=[int(x) for x in value.split('-')];start=parts[0];end=parts[-1] + if not 1024<=start<=end<=65535 or end-start>=16:raise RuntimeError('Maximal 16 nicht privilegierte API-Ports verwenden.') + return list(range(start,end+1)) + + def main(): parser=argparse.ArgumentParser(description='Athena Deck auf Debian 12/13 getrennt installieren. Kein WireGuard, keine Host-Paket- oder Treiberänderungen.') actions=parser.add_mutually_exclusive_group(required=True) @@ -214,6 +231,7 @@ def main(): parser.add_argument('--directory',type=Path,default=Path('/opt/athena-deck-standalone')) parser.add_argument('--name',default='athena-deck-standalone') parser.add_argument('--port',type=int,default=8110) + parser.add_argument('--api-ports',help='Separater API-Port oder Bereich, z. B. 8120-8124. Installationsstandard: 8120-8124; Updates behalten den bisherigen Bereich.') parser.add_argument('--gpu-telemetry',action='store_true',help='Vorhandenes NVIDIA Container Toolkit für Messwerte und Fit-Prüfung nutzen; installiert keine Treiber.') args=parser.parse_args() import re @@ -248,7 +266,7 @@ def main(): if item:run('docker','start',config['name']) else:launch(config) ready(config);print('Deck bereit.') - elif args.update or args.rollback:update(args.directory,args.rollback) + elif args.update or args.rollback:update(args.directory,args.rollback,api_ports=args.api_ports) if __name__=='__main__': try:main() diff --git a/endpoint-ui.js b/endpoint-ui.js new file mode 100644 index 0000000..2decd2b --- /dev/null +++ b/endpoint-ui.js @@ -0,0 +1,31 @@ +window.EndpointUI=(()=>{ + const e=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + const stateName={running:'Läuft',stopped:'Gestoppt',stopping:'Wird gestoppt · laufende Aufträge werden beendet',loading:'Modell lädt',ready:'Modell bereit',failed:'Fehlgeschlagen'}; + let pending=false,sequence=0; + async function api(path='',data){const r=await fetch('/api/v1/endpoint'+path,data!==undefined?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Endpunkt nicht erreichbar');return v;} + function put(el,html){if(el._deckHtml!==html){el.innerHTML=html;el._deckHtml=html;}} + function summary(s){return `

OpenAI-kompatibler Endpunkt

${e(stateName[s.state])}
Erreichbarkeit${s.reachable?'Listener bereit':'Nicht erreichbar'}
Lokaler API-Port${s.port}
Laufende / wartende Aufträge${s.scheduler.active_requests} / ${s.scheduler.waiting_requests}

${e(s.base_url)} · Port und Profile einstellen →

Geladen: ${e(s.worker.profile_name||'Kein Sprachmodell')} · ${e(stateName[s.worker.state]||s.worker.state)}

${s.worker.error||s.error?`

${e(s.worker.error||s.error)}

`:''}

Aktivierte Profile werden angeboten. Erst eine Anfrage lädt das Modell. Wechsel warten auf laufende Antworten; Bildaufträge entladen vorher das eigene Sprachmodell.

${[['llm','Sprachmodelle'],['image','Bildgenerierung'],['tts','Text → Sprache (TTS)'],['stt','Sprache → Text (STT)']].map(([key,label])=>{const c=s.counts[key];return `

${label}

${c.supported?`${c.enabled} Profile aktiviert · ${c.available} ausführbar`:'Noch keine eigene Laufzeit eingerichtet'}

${c.loaded?'AKTIV IM SPEICHER':c.available?'BEI ANFRAGE LADEN':'NICHT AKTIV'}
`}).join('')}
`;} + async function render(settings=false){ + const view=document.querySelector('#view');let root=view.querySelector('#endpoint-page'); + if(!root||root.dataset.settings!==String(settings)){ + sequence++;view.innerHTML=`
${settings?'EINSTELLUNGEN / API':'ATHENA DECK / ROUTER'}

${settings?'Endpunkt & Profile':'Dein Modell-Endpunkt'}

Eigener Router für Deck-Profile. Bestehende Athena-Dienste bleiben unabhängig.

${settings?'

Lokaler API-Port

Der Zugriff verwendet den API-Token aus Zugang & API. Verwaltungsoberfläche und API haben getrennte Ports.

Am Endpunkt angebotene Profile

Nur explizit aktivierte, ausführbare Profile erscheinen in GET /v1/models. Aktivieren lädt noch kein Modell.

API-Routen

POST /v1/chat/completions · Text und Streaming
POST /v1/images/generations · Qwen-Image-Rezept, ein Bild als b64_json
POST /v1/audio/speech und POST /v1/audio/transcriptions · noch nicht eingerichtet (501)

Alle Routen nutzen denselben Port und Bearer-Token. Bei Bildaufträgen bestimmt das Profil Auflösung, Schritte, Guidance und Seed. Noch keine Bildbearbeitung oder Vision-Eingaben.

':''}
`; + root=view.querySelector('#endpoint-page'); + for(const action of ['start','stop'])root.querySelector('#endpoint-'+action).onclick=()=>mutate('/'+action,{}); + root.querySelector('#endpoint-port-form')?.addEventListener('submit',event=>{event.preventDefault();mutate('/config',{port:Number(root.querySelector('#endpoint-port').value)});}); + } + if(pending)return;const id=sequence; + try{const s=await api();if(!root.isConnected||id!==sequence)return; + put(root.querySelector('#endpoint-summary'),summary(s)); + 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.'; + put(root.querySelector('#endpoint-profiles'),s.profiles.map(p=>`
${e(p.name)} · ${e(p.kind)} · ${p.runnable?'ausführbar':e(p.blockers.join(' '))}
`).join('')||'

Noch keine Profile angelegt. Erstelle sie in der jeweiligen Modellkategorie.

'); + root.querySelectorAll('[data-enable]').forEach(b=>{const p=s.profiles.find(p=>p.id===b.dataset.enable);b.disabled=!p.runnable&&!p.enabled;b.onclick=()=>mutate('/profile',{id:b.dataset.enable,enabled:b.dataset.value==='true'});}); + } + }catch(error){if(root.isConnected)root.querySelector('#endpoint-message').textContent=error.message;} + } + async function mutate(path,data){if(pending)return;pending=true;const root=document.querySelector('#endpoint-page');root.querySelectorAll('button').forEach(b=>b.disabled=true);try{await api(path,data);root.querySelector('#endpoint-message').textContent=path==='/stop'?'Neue Anfragen werden abgewiesen; laufende Aufträge werden beendet.':'Einstellung übernommen.';}catch(error){if(root.isConnected)root.querySelector('#endpoint-message').textContent=error.message;}finally{pending=false;if(root.isConnected)render(root.dataset.settings==='true');}} + const runningHTML=()=>'

Laufzeitstatus wird geladen …

'; + async function bindRunning(){const root=document.querySelector('#endpoint-running');if(!root)return;try{const s=await api();if(root.isConnected)root.innerHTML=`

${e(s.worker.profile_name||'Kein Sprachmodell geladen')}

${e(stateName[s.worker.state]||s.worker.state)} · ${s.scheduler.active_requests} laufende Anfragen · ${s.scheduler.waiting_requests} wartend

${e(s.worker.error||'Aktivierte Profile werden bei einer API-Anfrage automatisch geladen.')}

Endpunkt und Profile verwalten →
`;}catch(error){if(root.isConnected)root.textContent=error.message;}if(root.isConnected)setTimeout(()=>{if(root.isConnected)bindRunning();},3000);} + return {render,runningHTML,bindRunning}; +})(); diff --git a/endpoint.py b/endpoint.py new file mode 100644 index 0000000..5074004 --- /dev/null +++ b/endpoint.py @@ -0,0 +1,221 @@ +"""Separate authenticated OpenAI-compatible API; only explicitly enabled Deck profiles.""" +import base64 +import hashlib +import json +import os +from pathlib import Path +import secrets +import socket +import threading +import time +from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer +from inference import InferenceError + +class APIError(ValueError): + def __init__(self,message,status=400,code='invalid_request_error'): + super().__init__(message);self.status=status;self.code=code + +class Endpoint: + def __init__(self,root,profiles,worker,scheduler,images,credentials,management_port): + self.root=Path(root);self.profiles=profiles;self.worker=worker;self.scheduler=scheduler;self.images=images;self.credentials=credentials + self.management_port=management_port;self.lock=threading.RLock();self.http=None;self.thread=None;self.state='stopped';self.error=None;self.inflight=0 + self.allowed_ports=[int(p) for p in os.environ.get('DECK_API_PORTS','').split(',') if p] + self.config=dict(port=self.allowed_ports[0] if self.allowed_ports else 8120,enabled_profiles=[],autostart=False) + path=self.root/'endpoint.json' + if path.exists():self.config.update(json.loads(path.read_text())) + def persist(self): + self.root.mkdir(parents=True,exist_ok=True,mode=0o700) + path=self.root/'endpoint.tmp';path.write_text(json.dumps(self.config));path.replace(self.root/'endpoint.json') + def rows(self): + rows=self.profiles.status()['profiles'] + with self.lock:enabled=set(self.config['enabled_profiles']) + return [dict(p,enabled=p['id'] in enabled) for p in rows] + def status(self): + rows=self.rows();worker=self.worker.status();job=self.images.status()['job'];counts={} + for key,kind in [('llm','chat'),('image','image'),('tts','tts'),('stt','stt')]: + subset=[p for p in rows if p['kind']==kind] + counts[key]=dict(enabled=sum(p['enabled'] for p in subset),available=sum(p['enabled'] and p['runnable'] for p in subset),loaded=bool(worker['state']=='ready' and kind=='chat') if kind=='chat' else bool(kind=='image' and job and job['state']=='running'),supported=kind in ('chat','image')) + scheduler_status=self.scheduler.status() + with self.lock:return dict(state=self.state,reachable=bool(self.thread and self.thread.is_alive() and self.state=='running'),port=self.config['port'],bind=os.environ.get('DECK_API_BIND','127.0.0.1'),base_url=f"http://127.0.0.1:{self.config['port']}/v1",allowed_ports=self.allowed_ports,error=self.error,counts=counts,worker=worker,scheduler=scheduler_status,profiles=[dict(id=p['id'],name=p['name'],kind=p['kind'],enabled=p['enabled'],runnable=p['runnable'],blockers=p['blockers']) for p in rows],active_requests=self.inflight) + def configure(self,data): + if set(data)!={'port'} or type(data['port']) is not int or not 1024<=data['port']<=65535:raise ValueError('Port zwischen 1024 und 65535 erforderlich.') + port=data['port'] + with self.lock: + if self.state!='stopped':raise ValueError('Endpunkt zuerst vollständig stoppen.') + if port==self.management_port:raise ValueError('Der Verwaltungsport ist bereits belegt.') + if self.allowed_ports and port not in self.allowed_ports:raise ValueError('Dieser Port ist im Docker-Installer nicht freigegeben.') + try: + with socket.socket() as sock:sock.bind((os.environ.get('DECK_API_BIND','127.0.0.1'),port)) + except OSError:raise ValueError('Port ist bereits belegt.') from None + self.config['port']=port;self.persist() + return self.status() + def enable(self,data): + if set(data)!={'id','enabled'} or type(data['enabled']) is not bool:raise ValueError('Profil-ID und Aktivierung erforderlich.') + row=next((p for p in self.rows() if p['id']==data['id']),None) + if not row:raise ValueError('Profil nicht gefunden.') + if data['enabled'] and not row['runnable']:raise ValueError('Profil nicht ausführbar: '+' '.join(row['blockers'])) + with self.lock: + enabled=set(self.config['enabled_profiles']) + if data['enabled']:enabled.add(data['id']) + else:enabled.discard(data['id']) + self.config['enabled_profiles']=sorted(enabled);self.persist() + return self.status() + def start(self): + with self.lock: + if self.state=='running':return {'state':'running','port':self.config['port']} + if self.state!='stopped':raise ValueError('Endpunkt wird noch gestoppt.') + record=self.credentials.read() + if not record or not record.get('api_token_hash'):raise ValueError('Zuerst unter Zugang & API einen API-Token einrichten.') + if self.allowed_ports and self.config['port'] not in self.allowed_ports:raise ValueError('Gespeicherter Port ist nicht im Docker-Installer freigegeben.') + if self.config['port']==self.management_port:raise ValueError('Verwaltungsport kann nicht als API-Port verwendet werden.') + try:http=APIHTTPServer((os.environ.get('DECK_API_BIND','127.0.0.1'),self.config['port']),APIHandler) + except OSError:raise ValueError('API-Port bereits belegt; kein anderer Dienst wurde verändert.') from None + http.endpoint=self;self.http=http;self.state='running';self.error=None + self.thread=threading.Thread(target=http.serve_forever,daemon=True);self.thread.start() + self.config['autostart']=True;self.persist() + return self.status() + def stop(self): + with self.lock: + self.config['autostart']=False;self.persist() + if self.state=='running': + self.state='stopping';threading.Thread(target=self._drain,daemon=True).start() + return self.status() + def _drain(self): + http=self.http + if http:http.shutdown();http.server_close() + while True: + with self.lock:pending=self.inflight + if not pending and self.scheduler.unload_idle():break + time.sleep(.1) + with self.lock:self.http=None;self.thread=None;self.state='stopped' + def close(self): + # Process shutdown does not change the user's autostart preference. + with self.lock:self.state='stopping';http=self.http + if http:http.shutdown();http.server_close() + self.images.stop();self.worker.stop() + def allowed(self): + with self.lock:return self.state=='running' + def authenticate(self,header): + if not header.startswith('Bearer ') or len(header)>300:return False + record=self.credentials.read() + return bool(record and record.get('api_token_hash') and secrets.compare_digest(hashlib.sha256(header[7:].encode()).hexdigest(),record['api_token_hash'])) + def find_profile(self,name,kind): + if not isinstance(name,str):raise APIError('model muss den API-Namen eines aktivierten Profils enthalten.') + row=next((p for p in self.rows() if p['name']==name and p['kind']==kind and p['enabled']),None) + if not row:raise APIError('Modellprofil nicht aktiviert oder unbekannt.',404,'model_not_found') + if not row['runnable']:raise APIError('Profil derzeit nicht ausführbar: '+' '.join(row['blockers']),503,'model_unavailable') + return row + def model_list(self): + return dict(object='list',data=[dict(id=p['name'],object='model',created=int(p['updated_at']),owned_by='athena-deck') for p in self.rows() if p['enabled'] and p['runnable']]) + +class APIHTTPServer(ThreadingHTTPServer): + daemon_threads=True + def __init__(self,*args,**kwargs): + self.admission=threading.BoundedSemaphore(32);super().__init__(*args,**kwargs) + def process_request(self,request,address): + if not self.admission.acquire(blocking=False):self.shutdown_request(request);return + try:super().process_request(request,address) + except Exception:self.admission.release();raise + def process_request_thread(self,*args): + try:super().process_request_thread(*args) + finally:self.admission.release() + def handle_error(self,*args):pass # No request bodies, prompts, or traces in logs. + +class APIHandler(BaseHTTPRequestHandler): + protocol_version='HTTP/1.1' + def setup(self):super().setup();self.connection.settimeout(15);self.sent=False + def log_message(self,*args):pass + def send(self,payload,status=200): + body=json.dumps(payload).encode();self.sent=True + self.send_response(status);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.send_header('Connection','close');self.end_headers();self.wfile.write(body);self.close_connection=True + def failure(self,exc): + if self.sent:return + self.send({'error':{'message':str(exc),'type':getattr(exc,'code','server_error'),'param':None,'code':getattr(exc,'code','worker_unavailable')}},getattr(exc,'status',503)) + def do_GET(self):self.route() + def do_POST(self):self.route() + def route(self): + ep=self.server.endpoint;admitted=False + try: + if not ep.authenticate(self.headers.get('Authorization','')):raise APIError('Gültiger API-Bearer-Token erforderlich.',401,'invalid_api_key') + if self.headers.get('Origin'):raise APIError('Browserzugriff erfolgt über die Deck-Verwaltung; keine Cross-Origin-API-Freigabe.',403) + with ep.lock: + if not ep.allowed():raise APIError('Endpunkt wird gestoppt.',503,'endpoint_stopping') + ep.inflight+=1;admitted=True + if self.command=='GET' and self.path=='/v1/models':return self.send(ep.model_list()) + if self.command=='GET' and self.path=='/health':return self.send({'status':'ok','service':'athena-deck-api'}) + if self.command!='POST':raise APIError('Route nicht gefunden.',404) + if self.path in ('/v1/audio/speech','/v1/audio/transcriptions'):raise APIError('Für TTS/STT ist noch keine Deck-Laufzeit eingerichtet.',501,'not_implemented') + if self.path not in ('/v1/chat/completions','/v1/images/generations'):raise APIError('Route nicht implementiert.',404) + if self.headers.get('Transfer-Encoding'):raise APIError('Chunked Upload wird nicht unterstützt.') + try:length=int(self.headers.get('Content-Length','0')) + except ValueError:raise APIError('Ungültige Content-Length.') from None + if not 016*1024*1024:raise InferenceError('Modellantwort überschreitet 16 MiB.') + return self.send(json.loads(raw)) + self.sent=True;self.connection.settimeout(30) + self.send_response(200);self.send_header('Content-Type','text/event-stream');self.send_header('Cache-Control','no-store');self.send_header('Connection','close');self.end_headers() + deadline=time.monotonic()+600 + while time.monotonic()=0 else secrets.randbelow(2147483648) self.cancel.clear();self.job=dict(id=job_id,state='running',phase='Bildlaufzeit startet',profile_id=profile_id,profile_name=profile['name'],started_at=time.time(),gpu=gpu['name'],encoder_gpu=encoder_gpu['name'],seed=seed,parameters=params) - self._save();threading.Thread(target=self._run,args=(job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae),daemon=True).start() + self._save();threading.Thread(target=self._run,args=(job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae,release),daemon=True).start() return dict(self.job) - def _run(self,job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae): + def _run(self,job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae,release=lambda:None): directory=self.root/job_id;process=None try: directory.mkdir(mode=0o700) @@ -166,12 +178,14 @@ class ImageTests: except InterruptedError:final_state='cancelled';phase='Bildtest abgebrochen' except Exception as exc:final_state='failed';phase=str(exc) if isinstance(exc,ValueError) else 'Bildlaufzeit nicht erreichbar oder nicht bereit. Komponenten und Installation prüfen.' finally: - if process and process.poll() is None: - try:os.killpg(process.pid,signal.SIGTERM);process.wait(timeout=10) - except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait() - except ProcessLookupError:pass - with self.lock: - self.process=None;self.job.update(state=final_state,phase=phase,finished_at=time.time());self._save() + try: + if process and process.poll() is None: + try:os.killpg(process.pid,signal.SIGTERM);process.wait(timeout=10) + except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait() + except ProcessLookupError:pass + with self.lock: + self.process=None;self.job.update(state=final_state,phase=phase,finished_at=time.time());self._save() + finally:release() def image(self,job_id): with self.lock: if not self.job or self.job['id']!=job_id or self.job['state']!='complete':raise ValueError('Ergebnisbild nicht verfügbar.') diff --git a/index.html b/index.html index 30534f0..85eb1cd 100644 --- a/index.html +++ b/index.html @@ -1 +1 @@ -Athena Deck
ATHENA CONTROL SURFACEv0.6 · Entwicklung
+Athena Deck
ATHENA CONTROL SURFACEv0.7 · Router
diff --git a/inference.py b/inference.py new file mode 100644 index 0000000..7ff2819 --- /dev/null +++ b/inference.py @@ -0,0 +1,241 @@ +"""Owned llama.cpp worker and fair GPU leases. No production service control.""" +import contextlib +import http.client +import json +import os +from pathlib import Path +import secrets +import shlex +import signal +import socket +import subprocess +import threading +import time +from image_test import probe, GIB, cgroup_headroom + +class InferenceError(ValueError): + pass + +class Scheduler: + """FIFO admission; same-profile requests share configured slots, switches drain.""" + def __init__(self, worker): + self.worker=worker;self.cv=threading.Condition();self.queue=[];self.key=None;self.active=0;self.transition=False + def status(self): + with self.cv:return dict(active_requests=self.active,waiting_requests=len(self.queue),switching=self.transition) + @contextlib.contextmanager + def lease(self, key, slots=1, prepare=None, timeout=600, allowed=lambda:True): + ticket=object();deadline=time.monotonic()+timeout;claimed=False + with self.cv: + if len(self.queue)>=16:raise InferenceError('Warteschlange voll. Bitte später erneut versuchen.') + self.queue.append(ticket) + try: + while True: + if not allowed():raise InferenceError('Endpunkt wird gestoppt oder Profil ist nicht mehr aktiviert.') + if self.queue[0] is ticket and not self.transition and (not self.active or (self.key==key and self.active=deadline:raise InferenceError('Ressourcen noch belegt. Anfrage erneut versuchen.') + self.cv.wait(min(1,max(.01,deadline-time.monotonic()))) + finally: + if ticket in self.queue:self.queue.remove(ticket) + self.cv.notify_all() + try: + if self.transition: + try: + if switch:self.worker.stop() + if prepare:prepare() + except Exception: + self.worker.stop() + with self.cv:self.key=None + raise + finally: + with self.cv:self.transition=False;self.cv.notify_all() + yield + finally: + if claimed: + with self.cv:self.active-=1;self.cv.notify_all() + def image_reservation(self, wait=False): + lease=self.lease(('image',),timeout=600 if wait else 0) + lease.__enter__() + return lambda:lease.__exit__(None,None,None) + def unload_idle(self): + with self.cv: + if self.active:return False + self.transition=True + try:self.worker.stop();self.key=None + finally:self.transition=False;self.cv.notify_all() + return True + +class LlamaWorker: + def __init__(self,root,catalog,runtime): + self.root=Path(root);self.catalog=catalog;self.runtime=runtime;self.lock=threading.RLock() + self.process=None;self.fit_process=None;self.generation=0;self.port=None;self.profile=None;self.state='stopped';self.error=None;self.key=None;self.devices=[] + def build(self): + state=self.runtime.status() + build=next((b for b in state['builds'] if b['id']==state['active']),None) + if not build or build['backend']!='CUDA':raise InferenceError('Kein aktiver CUDA-Build. Unter Laufzeiten → llama.cpp einen Build auswählen.') + directory=(self.runtime.root/build['id']).resolve() + allowed=(self.runtime.root.resolve(),(self.runtime.root.parent/'runtime-verification').resolve()) + if not any(directory.is_relative_to(root) for root in allowed):raise InferenceError('Ungültiger Build-Pfad.') + if not (directory/'build/bin/llama-server').is_file() or not build.get('fit_tool'):raise InferenceError('llama-server oder Fit-Werkzeug fehlen.') + return directory + def blockers(self,p): + try: + self.build() + if not p.get('model') or not p['model']['file'].endswith('.gguf'):raise InferenceError('Ein lokales GGUF-Modell wird benötigt.') + return [] + except ValueError as exc:return [str(exc)] + def status(self): + with self.lock: + if self.process and self.process.poll() is not None and self.state=='ready':self.state='failed';self.error='llama.cpp wurde unerwartet beendet.' + return dict(state=self.state,profile_id=self.profile['id'] if self.profile else None,profile_name=self.profile['name'] if self.profile else None,port=self.port,error=self.error,gpus=list(self.devices)) + def stop(self): + with self.lock: + self.generation+=1 + if self.fit_process and self.fit_process.poll() is None: + self.fit_process.terminate() + try:self.fit_process.wait(timeout=3) + except subprocess.TimeoutExpired:self.fit_process.kill();self.fit_process.wait() + p=self.process + if p and p.poll() is None: + try: + os.killpg(p.pid,signal.SIGTERM) + try:p.wait(timeout=10) + except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait(timeout=5) + except ProcessLookupError:pass + self.process=None;self.profile=None;self.port=None;self.state='stopped';self.devices=[];self.key=None + (self.root/'worker.key').unlink(missing_ok=True) + def ensure(self,profile): + with self.lock: + if self.process and self.process.poll() is None and self.profile and (self.profile['id'],self.profile['revision'])==(profile['id'],profile['revision']):return + self.stop();self.state='loading';self.error=None;generation=self.generation + try:self._start(profile,generation) + except Exception as exc: + self.stop() + with self.lock:self.state='failed';self.error=str(exc) if isinstance(exc,ValueError) else 'llama.cpp konnte nicht gestartet werden.' + raise InferenceError(self.error) from None + def _start(self,profile,generation): + directory=self.build();params=profile['parameters'];entry=self.catalog.entry(profile['model_id']) + model=(self.catalog.root/entry['id']/('model'+Path(entry['file']).suffix)).resolve() + devices=probe();ids=params['gpu_devices'] + if ids: + selected=[next((g for g in devices if g['uuid']==ident),None) for ident in ids] + if any(g is None for g in selected):raise InferenceError('Eine ausgewählte GPU ist nicht verfügbar.') + else: + selected=sorted([g for g in devices if g['processes']==0],key=lambda g:g['free_mib'],reverse=True) + if params['split_mode']=='none':selected=selected[:1] + if not selected or any(g['processes'] or g['free_mib']<2048 for g in selected):raise InferenceError('Benötigte GPU ist durch einen anderen Dienst belegt. Deck stoppt keine fremden Prozesse.') + mem={line.split(':')[0]:int(line.split()[1])*1024 for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith('MemAvailable:')} + staging=entry['size']+4*GIB + if mem.get('MemAvailable',0)=need+4*GIB and (headroom is None or headroom>=need) + return gpu_fits and host_fits,rows + extra=[] + if params['tensor_split']: + # Upstream automatic fitting refuses user tensor_split. Predict the + # fixed split instead; bounded layer search preserves ratio/context. + layers=999;fits,memory=estimate(layers) + if not fits: + low,high,best=0,998,None + for _ in range(10): + if low>high:break + middle=(low+high)//2;ok,usage=estimate(middle) + if ok:best=(middle,usage);low=middle+1 + else:high=middle-1 + if best is None:raise InferenceError('Kontext und fester GPU-Split passen nicht sicher in GPU/RAM.') + layers,memory=best + else: + output=self._fit_command([tool]+common,env,generation) + flags=shlex.split(output.strip());fit={} + if len(flags)%2:raise InferenceError('Fit-Werkzeug lieferte ungültige Parameter.') + for i in range(0,len(flags),2): + if flags[i] not in ('-c','-ngl','-ts'):raise InferenceError('Unbekannte Fit-Option.') + fit[flags[i]]=flags[i+1] + if fit.get('-c')!=str(params['context']) or not fit.get('-ngl','').isdigit():raise InferenceError('Gewünschter Kontext konnte nicht unverändert eingepasst werden.') + layers=int(fit['-ngl']) + if '-ts' in fit: + parts=fit['-ts'].split(',') + if len(parts)!=len(selected) or any(not x.replace('.','',1).isdigit() for x in parts):raise InferenceError('Ungültige automatische GPU-Verteilung.') + extra=['--tensor-split',fit['-ts']] + fits,memory=estimate(layers,extra) + if not fits:raise InferenceError('Speicherprognose überschreitet die GPU-/RAM-Reserve.') + launch=common+extra+['--gpu-layers',str(layers),'--fit','off','--kv-unified','--threads',str(params['threads']),'--load-mode','none','--host','127.0.0.1','--alias',profile['name'],'--no-webui','--log-disable'] + self.root.mkdir(parents=True,exist_ok=True,mode=0o700) + with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1] + key=secrets.token_urlsafe(32);keypath=self.root/'worker.key' + fd=os.open(keypath,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600) + with os.fdopen(fd,'w') as f:f.write(key+'\n') + launch+=['--port',str(port),'--api-key-file',str(keypath)] + with self.lock: + if self.generation!=generation:raise InferenceError('Modellstart abgebrochen.') + self.process=subprocess.Popen([str(directory/'build/bin/llama-server')]+launch,env=env,cwd=directory,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True) + self.profile=profile;self.port=port;self.key=key;self.devices=[g['uuid'] for g in selected] + deadline=time.monotonic()+300 + while time.monotonic()1 for g in selected):raise InferenceError('Ein weiterer Prozess verwendet die reservierte GPU. Deck bricht seinen Start ab.') + conn=http.client.HTTPConnection('127.0.0.1',port,timeout=2) + try: + conn.request('GET','/health',headers={'Authorization':'Bearer '+key});r=conn.getresponse() + if r.status==200: + with self.lock:self.state='ready' + threading.Thread(target=self._monitor,args=(generation,),daemon=True).start() + return + except OSError:pass + finally:conn.close() + time.sleep(1) + raise InferenceError('llama.cpp wurde nicht rechtzeitig bereit.') + def _fit_command(self,args,env,generation): + with self.lock: + if self.generation!=generation:raise InferenceError('Modellstart abgebrochen.') + process=subprocess.Popen(args,env=env,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True) + self.fit_process=process + try:output,_=process.communicate(timeout=180) + except subprocess.TimeoutExpired: + process.kill();process.communicate();raise InferenceError('Zeitlimit der Speicher-Einpassung überschritten.') from None + finally: + with self.lock:self.fit_process=None + if process.returncode:raise InferenceError('Speicher-Einpassung fehlgeschlagen. Kontext, Split oder Modellgröße reduzieren.') + return output + def _monitor(self,generation): + while True: + time.sleep(3) + with self.lock: + if self.generation!=generation or not self.process or self.process.poll() is not None:return + selected=list(self.devices) + try: + current={g['uuid']:g for g in probe()} + conflict=any(current.get(ident,{}).get('processes',2)>1 for ident in selected) + except Exception:conflict=True + if conflict: + with self.lock: + if self.generation!=generation:return + self.stop();self.state='failed';self.error='GPU-Konflikt oder Telemetrie ausgefallen. Nur der eigene Modellprozess wurde beendet.' + return + def connect(self): + with self.lock: + if not self.process or self.process.poll() is not None:raise InferenceError('Modellprozess nicht verfügbar.') + return http.client.HTTPConnection('127.0.0.1',self.port,timeout=120),self.key diff --git a/network/install_remote.py b/network/install_remote.py index e53843a..fa67377 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 = {'image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py', 'runtime-ui.js', 'catalog.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'demo.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 = {'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', 'catalog.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/profiles-ui.js b/profiles-ui.js index dc3de7f..ec9dabd 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -4,10 +4,11 @@ window.ProfilesUI=(()=>{ const html=()=>'

Deine Profile

Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.

'; 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 v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;} function bind(kind,modelId){ - const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},gpus=[],panelSequence=0; + const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},enabled=[],gpus=[],panelSequence=0; const message=t=>{if(root.isConnected)el('profile-message').textContent=t;}; - async function load(){try{const [p,c,h]=await Promise.all([api('profiles'),api('catalog'),kind==='chat'?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]})]);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length; - el('profile-list').innerHTML=rows.map(p=>`
GESPEICHERT AUF ATHENA

${e(p.name)}

${e(p.model?.file||'Modelldatei nicht verfügbar')}

${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}

Was fehlt zur Ausführung?${p.blockers.map(t=>`

${e(t)}

`).join('')}
${kind==='image'?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; + async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),kind==='chat'?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length; + el('profile-list').innerHTML=rows.map(p=>`
GESPEICHERT AUF ATHENA

${e(p.name)}

${e(p.model?.file||'Modelldatei nicht verfügbar')}

${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}

${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`

${e(t)}

`).join('')}
${kind==='image'?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; + root.querySelectorAll('[data-publish]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{await api('endpoint/profile',{id:b.dataset.publish,enabled:!enabled.includes(b.dataset.publish)});await load();message('Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){message(error.message);b.disabled=false;}}); root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}}); root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components))); root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit))); diff --git a/profiles.py b/profiles.py index 48696ec..9576f8d 100644 --- a/profiles.py +++ b/profiles.py @@ -35,6 +35,7 @@ class Profiles: def __init__(self,path,catalog): self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock() self.image_runtime_ready=lambda:False + self.chat_blockers=None self.rows=json.loads(self.path.read_text()) if self.path.exists() else [] def status(self): with self.lock: @@ -52,6 +53,8 @@ class Profiles: except ValueError as exc:p['model']=None;p['blockers']=[str(exc)] if p.get('model') and p['model']['repo']==QWEN_REPO and p['model']['file'].endswith('.gguf') and self.image_runtime_ready(): p['blockers']=[b for b in p['blockers'] if not b.startswith('Eine eigene Bildlaufzeit')] + if p['kind']=='chat' and self.chat_blockers and p.get('model'):p['blockers']=self.chat_blockers(p) + if p['kind']=='image' and (p['parameters']['width']>1024 or p['parameters']['height']>1024):p['blockers'].append('Die Bildlaufzeit unterstützt derzeit maximal 1024 × 1024 Pixel.') p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured' return {'profiles':rows,'schemas':SCHEMAS} def save(self,data): diff --git a/server.py b/server.py index 574b1b4..2bb3f2f 100644 --- a/server.py +++ b/server.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Athena Deck prototype: loopback API, owned demo process, read-only telemetry.""" +"""Athena Deck prototype: loopback API, owned model processes, read-only telemetry.""" import argparse import os import hashlib @@ -13,6 +13,8 @@ from runtime import Runtime from image_runtime import ImageRuntime from image_test import ImageTests from docker_support import DockerSupport +from inference import LlamaWorker,Scheduler +from endpoint import Endpoint from urllib.parse import urlsplit, parse_qs from network.client import NetworkClient from network.config import parse_config, ConfigError @@ -29,49 +31,6 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent -class DemoService: - def __init__(self): - self.lock = threading.RLock() - self.process = None - self.port = None - - def status(self): - with self.lock: - running = self.process is not None and self.process.poll() is None - reachable = False - if running: - try: - with urllib.request.urlopen(f'http://127.0.0.1:{self.port}/health', timeout=.5) as response: - reachable = json.load(response).get('service') == 'athena-deck-demo' - except (OSError, ValueError): - pass - return dict(state='running' if running else 'stopped', reachable=reachable, pid=self.process.pid if running else None, port=self.port if running else None, location='Deck · isolierter Demo-Prozess') - - def start(self): - with self.lock: - if self.process is None or self.process.poll() is not None: - with socket.socket() as sock: - sock.bind(('127.0.0.1', 0)) - sock.listen(8) - self.port = sock.getsockname()[1] - self.process = subprocess.Popen([sys.executable, str(ROOT/'demo.py'), str(sock.fileno())], pass_fds=(sock.fileno(),), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - for _ in range(30): - if self.status()['reachable']: - break - time.sleep(.05) - return self.status() - - def stop(self): - with self.lock: - if self.process is not None and self.process.poll() is None: - self.process.terminate() - try: - self.process.wait(timeout=3) - except subprocess.TimeoutExpired: - self.process.kill() - self.process.wait() - return self.status() - class HardwareProvider: def __init__(self): self.lock = threading.Lock() @@ -110,7 +69,6 @@ class Server(ThreadingHTTPServer): self.image_tests.runtime = self.image_runtime self.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"] self.docker = DockerSupport() - self.demo = DemoService() self.hardware = HardwareProvider() self.started = time.time() self.network = NetworkClient() @@ -122,6 +80,14 @@ class Server(ThreadingHTTPServer): if os.environ.get('DECK_REQUIRE_SETUP') == '1' and self.credentials.read() is None: self.server_close() raise RuntimeError('Serverzugang muss vor dem Start eingerichtet werden.') + self.worker=LlamaWorker(self.catalog.root.parent/'llama-worker',self.catalog,self.runtime) + self.scheduler=Scheduler(self.worker) + self.profiles.chat_blockers=self.worker.blockers + self.image_tests.acquire=self.scheduler.image_reservation + self.endpoint=Endpoint(self.catalog.root.parent,self.profiles,self.worker,self.scheduler,self.image_tests,self.credentials,self.server_port) + if self.endpoint.config['autostart']: + try:self.endpoint.start() + except ValueError as exc:self.endpoint.error=str(exc) self.sessions = {} self.login_attempts = [] self.auth_lock = threading.Lock() @@ -158,7 +124,7 @@ class Handler(BaseHTTPRequestHandler): return secrets.compare_digest(actual,record['api_token_hash']) def token_route(self): - return (self.command == 'GET' and self.path in ('/api/v1/status','/api/v1/hardware','/api/v1/demo')) or (self.command == 'POST' and self.path in ('/api/v1/demo/start','/api/v1/demo/stop')) + return self.command == 'GET' and self.path in ('/api/v1/status','/api/v1/hardware') def session_token(self): try: @@ -273,12 +239,15 @@ class Handler(BaseHTTPRequestHandler): return self.respond({'error':'Anmeldung erforderlich.'},401) if not self.authenticated() and self.path == '/': return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8') - routes = {'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')} + routes = {'/endpoint-ui.js':('endpoint-ui.js','text/javascript'),'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')} if self.path in routes: name, mime = routes[self.path] return self.respond((ROOT/name).read_bytes(), mime=mime) if self.path == '/api/v1/status': - return self.respond(dict(name='Athena Deck', version='0.6.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location=os.environ.get('DECK_LOCATION', 'Athena · Debian-Server'), demo=self.server.demo.status())) + endpoint=self.server.endpoint.status() + public_endpoint={key:endpoint[key] for key in ('state','port','counts','active_requests')} + return self.respond(dict(name='Athena Deck', version='0.7.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location=os.environ.get('DECK_LOCATION', 'Athena · Debian-Server'), endpoint=public_endpoint)) + if self.path == '/api/v1/endpoint':return self.respond(self.server.endpoint.status()) if self.path == '/api/v1/docker':return self.respond(self.server.docker.status()) if self.path == '/api/v1/image-runtime':return self.respond(self.server.image_runtime.status()) if self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status()) @@ -317,8 +286,6 @@ class Handler(BaseHTTPRequestHandler): return self.respond({'error':str(exc) if isinstance(exc,ValueError) else 'Katalog nicht erreichbar.'},400) if self.path == '/api/v1/network': return self.respond(self.server.network.status(self.ingress())) - if self.path == '/api/v1/demo': - return self.respond(self.server.demo.status()) return self.respond({'error': 'Not found'}, 404) def do_POST(self): @@ -367,6 +334,15 @@ class Handler(BaseHTTPRequestHandler): raise ValueError('Ungültige Laufzeitaktion.') except ValueError as exc:return self.respond({'error':str(exc)},400) except (OSError, subprocess.SubprocessError):return self.respond({'error':'Laufzeitaktion fehlgeschlagen; Speicher und Werkzeuge prüfen.'},503) + if self.path in ('/api/v1/endpoint/start','/api/v1/endpoint/stop','/api/v1/endpoint/config','/api/v1/endpoint/profile'): + try: + data=self.read_json();ep=self.server.endpoint + if self.path.endswith('/config'):return self.respond(ep.configure(data)) + if self.path.endswith('/profile'):return self.respond(ep.enable(data)) + if data:raise ValueError('Keine Parameter erwartet.') + return self.respond(ep.start() if self.path.endswith('/start') else ep.stop()) + except ValueError as exc:return self.respond({'error':str(exc)},400) + except OSError:return self.respond({'error':'Endpunkt konnte nicht konfiguriert werden.'},503) if self.path in ('/api/v1/image-runtime/install','/api/v1/image-runtime/cancel'): try: if self.read_json():raise ValueError('Keine Parameter erwartet.') @@ -431,11 +407,7 @@ class Handler(BaseHTTPRequestHandler): except (ValueError, OSError, subprocess.SubprocessError) as exc: message = str(exc) if isinstance(exc, ValueError) else 'Netzwerkmodul nicht erreichbar.' return self.respond({'error':message},400) - if self.path not in ('/api/v1/demo/start', '/api/v1/demo/stop'): - return self.respond({'error': 'Not found'}, 404) - action = self.server.demo.start if self.path.endswith('/start') else self.server.demo.stop - result = action() - self.respond(result, 503 if self.path.endswith('/start') and not result['reachable'] else 200) + return self.respond({'error':'Not found'},404) def main(): @@ -451,11 +423,11 @@ def main(): try: server.serve_forever() finally: + server.endpoint.close() server.image_runtime.stop() server.image_tests.stop() server.runtime.stop() server.catalog.stop() - server.demo.stop() server.server_close() if __name__ == '__main__': diff --git a/studio.js b/studio.js index b681d80..4fac666 100644 --- a/studio.js +++ b/studio.js @@ -6,13 +6,14 @@ const Studio=(()=>{ if(!force&&document.querySelector('#studio')?.dataset.page===page)return; if(category!==page){category=page;section='discover';} if(modelId)section='profiles'; - const body=['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`
ATHENA / MODELLVERWALTUNG

${labels[page]}

Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.

${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(page==='image'?[['test','Testen']]:[]),['running','Laufend']].map(([id,label])=>``).join('')}
${section==='test'?ImageTestUI.html():section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():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')}
`; + const body=['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`
ATHENA / MODELLVERWALTUNG

${labels[page]}

Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.

${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(page==='image'?[['test','Testen']]:[]),['running','Laufend']].map(([id,label])=>``).join('')}
${section==='test'?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(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;} if(page==='runtime'){RuntimeUI.bind();return;} if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;} if(page==='runtimes')return; if(section==='test'||(section==='running'&&page==='image'))ImageTestUI.bind(section==='running'); + else if(section==='running'&&page==='chat')EndpointUI.bindRunning(); else if(section==='profiles')ProfilesUI.bind(page,modelId); else if(section!=='running')CatalogUI.bind(page,section==='library',section==='downloads'); document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);}); diff --git a/test_auth.py b/test_auth.py index 4f3b557..a24841f 100644 --- a/test_auth.py +++ b/test_auth.py @@ -77,7 +77,7 @@ class AccessAPITests(unittest.TestCase): self.base=f'http://127.0.0.1:{self.server.server_port}' self.password=secrets.token_urlsafe(32);self.token=secrets.token_urlsafe(32) def tearDown(self): - self.server.shutdown();self.server.demo.stop();self.server.server_close();self.thread.join();self.directory.cleanup() + self.server.shutdown();self.server.endpoint.close();self.server.server_close();self.thread.join();self.directory.cleanup() def request(self,path,body=None,cookie=None,token=None,headers=None): h={**(headers or {})} if body is not None:h.update({'Content-Type':'application/json','X-Athena-Deck':'1'}) @@ -127,8 +127,8 @@ class AccessAPITests(unittest.TestCase): for secret in (new,self.token,self.password,'api_token_hash','salt'):self.assertNotIn(secret,raw) def test_api_client_without_browser_headers(self): self.setup_login() - req=urllib.request.Request(self.base+'/api/v1/demo/start',method='POST',headers={'Authorization':'Bearer '+self.token}) - with urllib.request.urlopen(req) as r:self.assertTrue(json.load(r)['reachable']) + req=urllib.request.Request(self.base+'/api/v1/status',method='GET',headers={'Authorization':'Bearer '+self.token}) + with urllib.request.urlopen(req) as r:self.assertEqual(json.load(r)['name'],'Athena Deck') def test_wrong_password_and_logout(self): cookie=self.setup_login() self.assertEqual(self.request('/api/v1/auth/token',dict(current_password='wrong',new_token=secrets.token_urlsafe(32)),cookie=cookie)[0],400) diff --git a/test_deploy.py b/test_deploy.py index af05ae7..715200b 100644 --- a/test_deploy.py +++ b/test_deploy.py @@ -13,11 +13,13 @@ class InstallTests(unittest.TestCase): with patch.object(installer,'inspect',return_value={'Config':{'Labels':{}}}): with self.assertRaises(RuntimeError):installer.owned('athena-deck-test',Path('/opt/test')) def test_launch_is_loopback_and_unprivileged(self): - config=dict(base='/opt/athena-deck-test',name='athena-deck-test',port=8119,image='test:only',gpu_telemetry=False) + config=dict(base='/opt/athena-deck-test',name='athena-deck-test',port=8119,api_ports=[8120,8121],image='test:only',gpu_telemetry=False) with patch.object(installer,'run') as run: installer.launch(config) args=run.call_args.args self.assertIn('127.0.0.1:8119:8108',args) + self.assertIn('127.0.0.1:8120:8120',args) + self.assertIn('DECK_API_PORTS=8120,8121',args) self.assertIn('--read-only',args) self.assertEqual(args[args.index('--cap-drop')+1],'ALL') for forbidden in ('--privileged','--gpus','--cap-add','host','/var/run/docker.sock'): @@ -44,6 +46,11 @@ class InstallTests(unittest.TestCase): def test_requires_preprovisioned_credentials(self): with tempfile.TemporaryDirectory() as directory,patch.dict(os.environ,{'DECK_REQUIRE_SETUP':'1'}): with self.assertRaises(RuntimeError):Server(0,state_dir=directory) + def test_api_port_range_is_bounded(self): + self.assertEqual(installer.parse_api_ports('8120-8124'),list(range(8120,8125))) + for value in ('80','1-65535','8124-8120','8120,8121','8000-8100'): + with self.assertRaises(RuntimeError):installer.parse_api_ports(value) + def test_source_allowlist_exists(self): for name in installer.FILES:self.assertTrue((installer.ROOT/name).is_file()) diff --git a/test_endpoint.py b/test_endpoint.py new file mode 100644 index 0000000..194772e --- /dev/null +++ b/test_endpoint.py @@ -0,0 +1,119 @@ +import contextlib +import hashlib +import http.client +import json +import socket +import tempfile +import threading +import time +import unittest +from pathlib import Path +from types import SimpleNamespace +from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer +from unittest.mock import Mock +from endpoint import Endpoint +from inference import Scheduler,InferenceError + +class FakeWorker: + def __init__(self):self.name=None;self.started=[];self.stopped=[];self.requests=[];self.block=threading.Event();self.block.set() + def stop(self): + if self.name:self.stopped.append(self.name) + self.name=None + def ensure(self,p):self.name=p['name'];self.started.append(self.name) + def status(self):return dict(state='ready' if self.name else 'stopped',profile_name=self.name,profile_id=self.name,port=0,error=None,gpus=[]) + def connect(self):return http.client.HTTPConnection('127.0.0.1',self.http.server_port,timeout=3),'internal-test-only' + +class Upstream(BaseHTTPRequestHandler): + def log_message(self,*args):pass + def do_POST(self): + w=self.server.worker;data=json.loads(self.rfile.read(int(self.headers['Content-Length'])));w.requests.append(data) + if data.get('stream'): + self.send_response(200);self.send_header('Content-Type','text/event-stream');self.end_headers();self.wfile.write(b'data: {"choices":[]}\n\n');self.wfile.flush();w.block.wait(3);self.wfile.write(b'data: [DONE]\n\n') + else: + body=json.dumps(dict(id='test',model=data['model'],choices=[{'message':{'role':'assistant','content':'synthetic'}}])).encode();self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body) + +class EndpointTests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory();self.worker=FakeWorker();self.worker.http=ThreadingHTTPServer(('127.0.0.1',0),Upstream);self.worker.http.worker=self.worker;threading.Thread(target=self.worker.http.serve_forever,daemon=True).start() + self.rows=[dict(id=n,name=n,kind='chat',revision=1,updated_at=1,runnable=True,blockers=[],parameters=dict(slots=2,temperature=.2,top_p=.8,top_k=20)) for n in ('alpha','beta')] + self.rows.append(dict(id='image',name='image',kind='image',revision=1,updated_at=1,runnable=True,blockers=[],parameters=dict(width=512,height=512))) + self.rows.append(dict(id='audio',name='audio',kind='audio',revision=1,updated_at=1,runnable=False,blockers=['No worker'],parameters={})) + self.profiles=SimpleNamespace(status=lambda:dict(profiles=self.rows));self.record={'api_token_hash':hashlib.sha256(b'A'*32).hexdigest()} + self.images=Mock();self.images.status.return_value=dict(job=None) + self.ep=Endpoint(self.tmp.name,self.profiles,self.worker,Scheduler(self.worker),self.images,SimpleNamespace(read=lambda:self.record),1) + with socket.socket() as s:s.bind(('127.0.0.1',0));self.port=s.getsockname()[1] + self.ep.configure({'port':self.port});self.ep.start() + def tearDown(self): + self.worker.block.set();self.ep.close();self.worker.http.shutdown();self.worker.http.server_close();self.tmp.cleanup() + def request(self,path='/v1/models',data=None,token='A'*32): + conn=http.client.HTTPConnection('127.0.0.1',self.port,timeout=5);headers={'Content-Type':'application/json','Authorization':'Bearer '+token} + conn.request('POST' if data is not None else 'GET',path,body=json.dumps(data) if data is not None else None,headers=headers);r=conn.getresponse();body=r.read();conn.close();return r.status,(body if data and data.get('stream') else json.loads(body)) + def enable(self,name):self.ep.enable(dict(id=name,enabled=True)) + def test_explicit_publication_auth_rotation_and_missing_audio(self): + self.assertEqual(self.request()[1]['data'],[]);self.assertEqual(self.request(token='bad')[0],401) + self.enable('alpha');self.assertEqual([p['id'] for p in self.request()[1]['data']],['alpha']);self.assertFalse(self.worker.started) + with self.assertRaises(ValueError):self.enable('audio') + self.assertEqual(self.request('/v1/audio/speech',{})[0],501) + self.record['api_token_hash']=hashlib.sha256(b'B'*32).hexdigest();self.assertEqual(self.request()[0],401);self.assertEqual(self.request(token='B'*32)[0],200) + def test_profile_switch_and_sampling_defaults(self): + for name in ('alpha','beta'):self.enable(name) + for name in ('alpha','beta'): + status,data=self.request('/v1/chat/completions',dict(model=name,messages=[dict(role='user',content='test')])) + self.assertEqual(status,200);self.assertEqual(data['model'],name) + self.assertEqual(self.worker.stopped,['alpha']);self.assertEqual(self.worker.requests[-1]['temperature'],.2) + self.assertEqual(self.ep.status()['counts']['llm']['enabled'],2) + def test_unknown_or_unsupported_request_does_not_load(self): + self.enable('alpha') + for req in [dict(model='unknown',messages=[{}]),dict(model='alpha',messages=[dict(content=[dict(type='image_url')])]),dict(model='alpha',messages=[{}],cache_file='/tmp/foo')]: + self.assertGreaterEqual(self.request('/v1/chat/completions',req)[0],400) + self.assertFalse(self.worker.started) + def test_stream_lease_blocks_switch_until_stream_finishes(self): + self.enable('alpha');self.enable('beta');self.worker.block.clear() + conn=http.client.HTTPConnection('127.0.0.1',self.port,timeout=5) + conn.request('POST','/v1/chat/completions',json.dumps(dict(model='alpha',messages=[{}],stream=True)),headers={'Content-Type':'application/json','Authorization':'Bearer '+'A'*32});r=conn.getresponse();self.assertEqual(r.status,200) + result=[];thread=threading.Thread(target=lambda:result.append(self.request('/v1/chat/completions',dict(model='beta',messages=[{}]))));thread.start() + deadline=time.monotonic()+2 + while not self.ep.scheduler.status()['waiting_requests'] and time.monotonic()