Add owned OpenAI endpoint and coordinated native model switching

This commit is contained in:
Mikei386
2026-09-28 20:24:12 +02:00
parent 8d03a9a979
commit ff5d36252a
25 changed files with 952 additions and 177 deletions
+7 -7
View File
@@ -51,9 +51,8 @@ API-Clients verwenden `Authorization: Bearer <API-TOKEN>`. 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 <API-TOKEN>' 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
+131
View File
@@ -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 <Installation>`.
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 <Deck-API-Token>`.
| 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":"<Profil-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.
+10
View File
@@ -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).
+19 -41
View File
@@ -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 <API-TOKEN>' http://127.0.0.1:8108/api/v1/status
curl -X POST -H 'Authorization: Bearer <API-TOKEN>' http://127.0.0.1:8108/api/v1/demo/start
curl -X POST -H 'Authorization: Bearer <API-TOKEN>' 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
+2 -2
View File
@@ -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,
+3 -4
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmt=(v,unit='')=>v==null?'Nicht verfügbar':`${Number(v).toLocaleString('de-DE',{maximumFractionDigits:1})}${unit}`;
const heading=(tag,title,desc)=>`<div class="kicker">${tag}</div><h1>${title}</h1><p>${desc}</p>`;
@@ -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.')+`<div class="grid"><section class="card"><div class="label">Anwendung</div><div class="value"><i></i> Bereit</div><p>Athena Deck läuft auf ${esc(s.location)}.</p><div class="note">Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1</div></section><section class="card"><div class="label">Betriebsmodus</div><div class="value">Isoliert</div><p>Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.</p><div class="note">Keine Modell- oder Router-Steuerung angebunden</div></section><section class="card wide"><div class="card-top"><h2>Demo-Dienst</h2><span class="pill">LOKAL / CPU</span></div><p>Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.</p><div class="metrics">${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}</div><button id="start" ${busy||d.state==='running'?'disabled':''}>Dienst starten</button><button id="stop" class="secondary" ${busy||d.state!=='running'?'disabled':''}>Dienst stoppen</button><div class="note">Lädt keine Modelle · endet beim Beenden von Athena Deck</div></section></div>`;
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+=`<section class="empty">Hardware nicht verfügbar<p>${esc(h.errors.join(' '))}</p></section>`;else{const c=h.cpu,r=h.ram;html+=`<div class="grid"><section class="card"><div class="label">CPU</div><h2>${esc(c.name)}</h2><div class="metrics">${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}</div>${bar(c.percent)}</section><section class="card"><div class="label">System-RAM</div><h2>Arbeitsspeicher</h2><div class="metrics">${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'))}</div>${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}</section>`;for(const g of h.gpus)html+=`<section class="card"><div class="card-top"><span class="label">GPU ${g.index}</span><span class="tag">${fmt(g.temperature_c,' °C')}</span></div><h2>${esc(g.name)}</h2><div class="metrics">${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'))}</div><small>GPU-Rechenlast</small>${bar(g.percent)}<small>VRAM-Belegung</small>${bar(g.total_mib&&g.used_mib!=null?100*g.used_mib/g.total_mib:null)}</section>`;if(h.gpus.length<2)html+='<section class="card">Nicht alle zwei GPUs verfügbar.</section>';html+=`</div><p class="note">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.<br>${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}<br>${esc(h.errors.join(' '))}</p>`;}
}else{const p=placeholders[page]||placeholders.services;html=heading('ARBEITSBEREICH / VORBEREITET',p[0],p[1])+`<section class="empty"><div class="symbol">+</div><h2>Platz für den nächsten Schritt.</h2><p>${p[2]}</p><span class="pill">Platzhalter · noch keine Funktionen</span><p class="note">Installation, Downloads, Presets und Modellwechsel sind in dieser Version nicht enthalten.</p></section>`;}
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);
-22
View File
@@ -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()
+2 -2
View File
@@ -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 \
+25 -7
View File
@@ -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()
+31
View File
@@ -0,0 +1,31 @@
window.EndpointUI=(()=>{
const e=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 `<div class="grid"><section class="card wide"><div class="card-top"><h2>OpenAI-kompatibler Endpunkt</h2><span class="pill">${e(stateName[s.state])}</span></div><div class="metrics"><div><small>Erreichbarkeit</small><strong>${s.reachable?'Listener bereit':'Nicht erreichbar'}</strong></div><div><small>Lokaler API-Port</small><strong>${s.port}</strong></div><div><small>Laufende / wartende Aufträge</small><strong>${s.scheduler.active_requests} / ${s.scheduler.waiting_requests}</strong></div></div><p><code>${e(s.base_url)}</code> · <a class="link" href="#endpoint">Port und Profile einstellen →</a></p><p>Geladen: ${e(s.worker.profile_name||'Kein Sprachmodell')} · ${e(stateName[s.worker.state]||s.worker.state)}</p>${s.worker.error||s.error?`<p role="alert">${e(s.worker.error||s.error)}</p>`:''}<p>Aktivierte Profile werden angeboten. Erst eine Anfrage lädt das Modell. Wechsel warten auf laufende Antworten; Bildaufträge entladen vorher das eigene Sprachmodell.</p></section>${[['llm','Sprachmodelle'],['image','Bildgenerierung'],['tts','Text → Sprache (TTS)'],['stt','Sprache → Text (STT)']].map(([key,label])=>{const c=s.counts[key];return `<section class="card"><h3>${label}</h3><p>${c.supported?`${c.enabled} Profile aktiviert · ${c.available} ausführbar`:'Noch keine eigene Laufzeit eingerichtet'}</p><span class="pill">${c.loaded?'AKTIV IM SPEICHER':c.available?'BEI ANFRAGE LADEN':'NICHT AKTIV'}</span></section>`}).join('')}</div>`;}
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=`<section id="endpoint-page" data-settings="${settings}"><div class="kicker">${settings?'EINSTELLUNGEN / API':'ATHENA DECK / ROUTER'}</div><h1>${settings?'Endpunkt & Profile':'Dein Modell-Endpunkt'}</h1><p>Eigener Router für Deck-Profile. Bestehende Athena-Dienste bleiben unabhängig.</p><div class="card-actions"><button id="endpoint-start">Endpunkt starten</button><button class="secondary" id="endpoint-stop">Endpunkt stoppen</button></div><p id="endpoint-message" role="status"></p><div id="endpoint-summary"></div>${settings?'<section class="card"><h2>Lokaler API-Port</h2><form id="endpoint-port-form"><label>Port<input id="endpoint-port" name="port" type="number" required min="1024" max="65535"></label><button id="endpoint-port-save">Port speichern</button></form><p id="endpoint-port-help"></p><p>Der Zugriff verwendet den API-Token aus <a class="link" href="#access">Zugang & API</a>. Verwaltungsoberfläche und API haben getrennte Ports.</p></section><section class="card"><h2>Am Endpunkt angebotene Profile</h2><p>Nur explizit aktivierte, ausführbare Profile erscheinen in <code>GET /v1/models</code>. Aktivieren lädt noch kein Modell.</p><div id="endpoint-profiles"></div></section><section class="card"><h2>API-Routen</h2><p><code>POST /v1/chat/completions</code> · Text und Streaming<br><code>POST /v1/images/generations</code> · Qwen-Image-Rezept, ein Bild als b64_json<br><code>POST /v1/audio/speech</code> und <code>POST /v1/audio/transcriptions</code> · noch nicht eingerichtet (501)</p><p>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.</p></section>':''}</section>`;
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=>`<div class="note"><strong>${e(p.name)}</strong> · ${e(p.kind)} · ${p.runnable?'ausführbar':e(p.blockers.join(' '))}<br><button class="secondary" data-enable="${e(p.id)}" data-value="${!p.enabled}" ${!p.runnable&&!p.enabled?'disabled':''}>${p.enabled?'Am Endpunkt deaktivieren':'Am Endpunkt aktivieren'}</button></div>`).join('')||'<p>Noch keine Profile angelegt. Erstelle sie in der jeweiligen Modellkategorie.</p>');
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=()=>'<section id="endpoint-running"><p>Laufzeitstatus wird geladen …</p></section>';
async function bindRunning(){const root=document.querySelector('#endpoint-running');if(!root)return;try{const s=await api();if(root.isConnected)root.innerHTML=`<section class="card"><h2>${e(s.worker.profile_name||'Kein Sprachmodell geladen')}</h2><p>${e(stateName[s.worker.state]||s.worker.state)} · ${s.scheduler.active_requests} laufende Anfragen · ${s.scheduler.waiting_requests} wartend</p><p>${e(s.worker.error||'Aktivierte Profile werden bei einer API-Anfrage automatisch geladen.')}</p><a class="link" href="#endpoint">Endpunkt und Profile verwalten →</a></section>`;}catch(error){if(root.isConnected)root.textContent=error.message;}if(root.isConnected)setTimeout(()=>{if(root.isConnected)bindRunning();},3000);}
return {render,runningHTML,bindRunning};
})();
+221
View File
@@ -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 0<length<=1024*1024 or self.headers.get('Content-Type','').split(';')[0]!='application/json':raise APIError('JSON-Anfrage bis 1 MiB erforderlich.',413)
try:data=json.loads(self.rfile.read(length),parse_constant=lambda _:(_ for _ in ()).throw(ValueError()))
except (ValueError,UnicodeError):raise APIError('Ungültiges JSON.') from None
if not isinstance(data,dict):raise APIError('JSON-Objekt erforderlich.')
if self.path=='/v1/chat/completions':return self.chat(ep,data)
return self.image(ep,data)
except (APIError,InferenceError) as exc:
if isinstance(exc,InferenceError):
with ep.lock:ep.error=str(exc)
self.failure(exc)
except (BrokenPipeError,ConnectionResetError):pass
except Exception:self.failure(APIError('Worker nicht erreichbar oder Zeitlimit überschritten.',503,'worker_unavailable'))
finally:
self.close_connection=True
if admitted:
with ep.lock:ep.inflight-=1
def chat(self,ep,data):
supported={'model','messages','stream','stream_options','temperature','top_p','top_k','max_tokens','max_completion_tokens','stop','seed','tools','tool_choice','parallel_tool_calls','response_format','presence_penalty','frequency_penalty','logprobs','top_logprobs','user','n'}
if set(data)-supported:raise APIError('Nicht unterstützte Chat-Felder: '+', '.join(sorted(set(data)-supported)))
if type(data.get('stream',False)) is not bool:raise APIError('stream muss true oder false sein.')
if data.get('n',1)!=1:raise APIError('Zunächst wird n=1 unterstützt.')
messages=data.get('messages')
if not isinstance(messages,list) or not messages or any(not isinstance(m,dict) for m in messages):raise APIError('messages muss eine nichtleere Liste sein.')
for message in messages:
content=message.get('content')
if isinstance(content,list) and any(not isinstance(c,dict) or c.get('type')!='text' for c in content):raise APIError('Dieses Chatprofil unterstützt derzeit nur Text, keinen Vision-Projektor.')
profile=ep.find_profile(data.get('model'),'chat')
# Re-resolve after waiting so edits/disable cannot silently launch a stale profile.
key=('chat',profile['id'],profile['revision'])
def allowed():return ep.allowed() and any(p['id']==profile['id'] and p['revision']==profile['revision'] and p['enabled'] for p in ep.rows())
with ep.scheduler.lease(key,profile['parameters']['slots'],lambda:ep.worker.ensure(profile),allowed=allowed):
body=dict(data)
for field in ('temperature','top_p','top_k'):body.setdefault(field,profile['parameters'][field])
conn,key=ep.worker.connect()
try:
conn.request('POST','/v1/chat/completions',body=json.dumps(body).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+key})
response=conn.getresponse()
if response.status!=200:
response.read(65536);raise APIError('llama.cpp hat die Anfrage abgelehnt. Kontext und Anfrageparameter prüfen.',response.status if 400<=response.status<600 else 502,'upstream_error')
if not data.get('stream'):
raw=response.read(16*1024*1024+1)
if len(raw)>16*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()<deadline:
chunk=response.read1(8192)
if not chunk:return
self.wfile.write(chunk);self.wfile.flush()
raise InferenceError('Zeitlimit der Streaming-Antwort überschritten.')
finally:conn.close()
def image(self,ep,data):
if set(data)-{'model','prompt','n','size','response_format','user'}:raise APIError('Nicht unterstützte Bildparameter. Schritte/Guidance/Seed stehen im Profil.')
if data.get('n',1)!=1 or data.get('response_format','b64_json')!='b64_json':raise APIError('Unterstützt werden n=1 und response_format=b64_json.')
profile=ep.find_profile(data.get('model'),'image');params=profile['parameters']
if data.get('size',f"{params['width']}x{params['height']}")!=f"{params['width']}x{params['height']}":raise APIError('size muss der im Profil gespeicherten Auflösung entsprechen.')
def allowed():return ep.allowed() and any(p['id']==profile['id'] and p['revision']==profile['revision'] and p['enabled'] for p in ep.rows())
with ep.scheduler.lease(('image',),allowed=allowed):
return self.generate_image(ep,profile,data)
def generate_image(self,ep,profile,data):
job=ep.images.start(profile['id'],data.get('prompt'),reserved=True)
deadline=time.monotonic()+2100
while time.monotonic()<deadline:
current=ep.images.status()['job']
if not current or current['id']!=job['id']:raise InferenceError('Bildauftrag nicht mehr verfügbar.')
if current['state']=='complete':return self.send({'created':int(time.time()),'data':[{'b64_json':base64.b64encode(ep.images.image(job['id'])).decode()}]})
if current['state']!='running':raise InferenceError(current['phase'])
time.sleep(.25)
ep.images.stop();raise InferenceError('Zeitlimit der Bildgenerierung überschritten.')
+23 -9
View File
@@ -17,6 +17,16 @@ PYTHON=Path('/opt/deck-image-python/bin/python')
COMFY=Path('/opt/deck-comfy')
GIB=1024**3
def cgroup_headroom():
"""Exclude reclaimable inactive file cache, not live anonymous allocations."""
try:
raw=Path('/sys/fs/cgroup/memory.max').read_text().strip()
if raw=='max':return None
used=int(Path('/sys/fs/cgroup/memory.current').read_text())
stat=dict(line.split() for line in Path('/sys/fs/cgroup/memory.stat').read_text().splitlines())
return int(raw)-max(0,used-int(stat.get('inactive_file',0)))
except FileNotFoundError:return None
def probe():
rows=subprocess.run(['nvidia-smi','--query-gpu=uuid,name,memory.total,memory.free','--format=csv,noheader,nounits'],capture_output=True,text=True,check=True,timeout=5).stdout
apps=subprocess.run(['nvidia-smi','--query-compute-apps=gpu_uuid,pid','--format=csv,noheader,nounits'],capture_output=True,text=True,check=True,timeout=5).stdout
@@ -47,7 +57,7 @@ def workflow(prompt,params,seed):
class ImageTests:
def __init__(self,root,profiles):
self.root=Path(root);self.profiles=profiles;self.runtime=None;self.lock=threading.RLock();self.process=None;self.cancel=threading.Event();self.job=None
self.root=Path(root);self.profiles=profiles;self.runtime=None;self.lock=threading.RLock();self.process=None;self.cancel=threading.Event();self.job=None;self.acquire=lambda wait=False:lambda:None
if (self.root/'status.json').exists():
self.job=json.loads((self.root/'status.json').read_text())
if self.job.get('state')=='running':self.job.update(state='interrupted',phase='Deck wurde neu gestartet; Test erneut starten.')
@@ -73,7 +83,12 @@ class ImageTests:
return {'cancellation_requested':True}
def model_path(self,item):
return (self.profiles.catalog.root/item['id']/('model'+Path(item['file']).suffix)).resolve()
def start(self,profile_id,prompt):
def start(self,profile_id,prompt,wait=False,reserved=False):
if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=4000:raise ValueError('Bitte einen Prompt mit 1–4000 Zeichen eingeben.')
release=(lambda:None) if reserved else self.acquire(wait)
try:return self._start(profile_id,prompt,release)
except Exception:release();raise
def _start(self,profile_id,prompt,release):
if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=4000:raise ValueError('Bitte einen Prompt mit 1–4000 Zeichen eingeben.')
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Ein Bildtest läuft bereits.')
@@ -89,19 +104,16 @@ class ImageTests:
vae=self.profiles._component(model,'vae',profile.get('components',{}).get('vae'))
mem={line.split(':')[0]:int(line.split()[1])*1024 for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith(('MemAvailable:','MemTotal:'))}
required_ram=max(20*GIB,encoder['size']*2+4*GIB)
try:
raw=Path('/sys/fs/cgroup/memory.max').read_text().strip()
current=int(Path('/sys/fs/cgroup/memory.current').read_text())
if raw!='max' and int(raw)-current<required_ram:raise ValueError('Deck-RAM-Limit reicht für Textencoder und Arbeitsdaten nicht aus.')
except FileNotFoundError:pass
headroom=cgroup_headroom()
if headroom is not None and headroom<required_ram:raise ValueError('Deck-RAM-Limit reicht für Textencoder und Arbeitsdaten nicht aus.')
if mem.get('MemAvailable',0)<required_ram+4*GIB:raise ValueError('Aktuell zu wenig freier System-RAM; produktive Dienste bleiben unverändert.')
gpu,encoder_gpu=select_gpus(probe(),model['size'],encoder['size'],vae['size']);self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
if shutil.disk_usage(self.root).free<10*GIB:raise ValueError('Weniger als 10 GiB freier Plattenspeicher.')
job_id=uuid.uuid4().hex;seed=params['seed'] if params['seed']>=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:
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.')
+1 -1
View File
@@ -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="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/studio.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 / PROTOTYP 01</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><a href="#audio">Audio <span>04</span></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="#docker-runtime">Docker</a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang &amp; API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Getrennte Steuerungsinstanz<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.6 · Entwicklung</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="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/studio.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><a href="#audio">Audio <span>04</span></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="#docker-runtime">Docker</a><a href="#endpoint">Endpunkt &amp; Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang &amp; 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>
+241
View File
@@ -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<slots)):
self.queue.pop(0);self.active+=1;claimed=True
switch=self.key!=key;self.key=key
# Also verifies/restarts a crashed worker when there are no other leases.
self.transition=self.active==1
break
if time.monotonic()>=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)<staging+4*GIB:raise InferenceError('Zu wenig freier RAM für Modellladen und Host-Reserve.')
headroom=cgroup_headroom()
if headroom is not None and headroom<staging:raise InferenceError('Deck-RAM-Limit reicht für das Laden dieses Modells nicht aus.')
# Never inherit LLAMA_ARG_* or user HF credentials into the model server.
env={k:v for k,v in os.environ.items() if not k.startswith(('LLAMA_','HF_','DECK_'))}
env.update(CUDA_VISIBLE_DEVICES=','.join(g['uuid'] for g in selected),HF_HUB_OFFLINE='1',OMP_NUM_THREADS=str(params['threads']))
common=['--model',str(model),'--ctx-size',str(params['context']),'--parallel',str(params['slots']),'--batch-size',str(params['batch']),'--ubatch-size',str(params['ubatch']),'--cache-type-k','q4_0','--cache-type-v','q4_0','--flash-attn','on','--split-mode',params['split_mode'],'--fit-target',','.join(str(max(1024,round(g['total_mib']*.05))) for g in selected)]
if params['tensor_split']:common+=['--tensor-split',','.join(map(str,params['tensor_split']))]
tool=str(directory/'build/bin/llama-fit-params')
margins=[max(1024,round(g['total_mib']*.05)) for g in selected]
def estimate(layers,extra=()):
output=self._fit_command([tool]+common+['--gpu-layers',str(layers),'--fit-print','on']+list(extra),env,generation)
rows={}
for line in output.splitlines():
parts=line.split()
if len(parts)==4 and all(x.isdigit() for x in parts[1:]):rows[parts[0]]=sum(map(int,parts[1:]))
if 'Host' not in rows or any('CUDA'+str(i) not in rows for i in range(len(selected))):raise InferenceError('Speicherprognose unvollständig; Start abgebrochen.')
gpu_fits=all(rows['CUDA'+str(i)]+margins[i]<=g['free_mib'] for i,g in enumerate(selected))
need=max(staging,rows['Host']*1024**2+4*GIB)
host_fits=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()<deadline:
with self.lock:
if not self.process or self.process.poll() is not None:raise InferenceError('llama.cpp-Start fehlgeschlagen; Modell/Build oder Speicher passt nicht.')
current={g['uuid']:g for g in probe()}
if any(current.get(g['uuid'],{}).get('processes',2)>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
+1 -1
View File
@@ -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)
+4 -3
View File
@@ -4,10 +4,11 @@ window.ProfilesUI=(()=>{
const html=()=>'<section id="live-profiles"><div class="section-heading"><div><h2>Deine Profile</h2><p>Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.</p></div><button id="profile-new">Neues Profil</button></div><p id="profile-message" role="status"></p><div id="profile-list" class="profile-list"></div><div id="profile-editor"></div></section>';
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=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${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><details><summary>Was fehlt zur Ausführung?</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button>${kind==='image'?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
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=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${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><details><summary>${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><button class="secondary" data-publish="${p.id}" ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}>${enabled.includes(p.id)?'Am Endpunkt deaktivieren':'Am Endpunkt aktivieren'}</button><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button>${kind==='image'?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
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)));
+3
View File
@@ -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):
+28 -56
View File
@@ -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__':
+2 -1
View File
@@ -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():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.</p><div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(page==='image'?[['test','Testen']]:[]),['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==='test'?ImageTestUI.html():section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():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=['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>Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.</p><div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(page==='image'?[['test','Testen']]:[]),['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==='test'?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>`;
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);});
+3 -3
View File
@@ -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)
+8 -1
View File
@@ -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())
+119
View File
@@ -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()<deadline:time.sleep(.01)
self.assertEqual(self.worker.name,'alpha');self.assertEqual(self.worker.stopped,[])
self.worker.block.set();self.assertIn(b'[DONE]',r.read());conn.close();thread.join(3);self.assertEqual(result[0][0],200);self.assertEqual(self.worker.name,'beta')
def test_port_conflict_and_graceful_stop(self):
with self.assertRaises(ValueError):self.ep.configure(dict(port=self.port+1))
self.ep.stop();deadline=time.monotonic()+3
while self.ep.state!='stopped' and time.monotonic()<deadline:time.sleep(.02)
self.assertEqual(self.ep.state,'stopped')
with socket.socket() as sock:
sock.bind(('127.0.0.1',self.port))
with self.assertRaises(ValueError):self.ep.start()
self.ep.start();self.assertTrue(self.ep.status()['reachable'])
def test_image_unloads_chat_and_returns_openai_shape(self):
self.enable('alpha');self.enable('image');self.request('/v1/chat/completions',dict(model='alpha',messages=[{}]))
self.images.start.return_value={'id':'new'};self.images.status.return_value={'job':{'id':'new','state':'complete'}};self.images.image.return_value=b'synthetic-png'
status,data=self.request('/v1/images/generations',dict(model='image',prompt='synthetic',response_format='b64_json'))
self.assertEqual(status,200);self.assertIn('b64_json',data['data'][0]);self.assertIsNone(self.worker.name);self.images.start.assert_called_once_with('image','synthetic',reserved=True)
def test_disabled_and_edited_queued_profiles_are_rejected(self):
self.enable('alpha');self.enable('beta');self.worker.block.clear()
first=threading.Thread(target=lambda:self.request('/v1/chat/completions',dict(model='alpha',messages=[{}],stream=True)));first.start()
deadline=time.monotonic()+2
while not self.worker.requests and time.monotonic()<deadline:time.sleep(.01)
result=[];second=threading.Thread(target=lambda:result.append(self.request('/v1/chat/completions',dict(model='beta',messages=[{}]))));second.start()
deadline=time.monotonic()+2
while not self.ep.scheduler.status()['waiting_requests'] and time.monotonic()<deadline:time.sleep(.01)
self.ep.enable(dict(id='beta',enabled=False));self.worker.block.set();first.join(4);second.join(4);self.assertEqual(result[0][0],503);self.assertNotIn('beta',self.worker.started)
class SchedulerTests(unittest.TestCase):
def test_parallel_slots_and_fifo_switch(self):
w=FakeWorker();s=Scheduler(w)
with s.lease('a',2):
with s.lease('a',2):self.assertEqual(s.status()['active_requests'],2)
with self.assertRaises(InferenceError):
with s.lease('b',timeout=0):pass
with s.lease('b'):self.assertEqual(s.status()['active_requests'],1)
self.assertEqual(s.status()['active_requests'],0)
def test_failed_prepare_releases_queue_and_can_retry(self):
s=Scheduler(FakeWorker())
with self.assertRaises(ValueError):
with s.lease('bad',prepare=lambda:(_ for _ in ()).throw(ValueError('bad'))):pass
with s.lease('good'):self.assertFalse(s.status()['switching'])
self.assertEqual(s.status()['active_requests'],0)
if __name__=='__main__':unittest.main()
+42
View File
@@ -0,0 +1,42 @@
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch,Mock
from inference import LlamaWorker,InferenceError
from profiles import SCHEMAS,CHAT_GPU_DEFAULTS
class WorkerTests(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory();self.root=Path(self.tmp.name);directory=self.root/'runtime'/'build-id'/'build/bin';directory.mkdir(parents=True);(directory/'llama-server').touch()
self.entry=dict(id='model',file='test.gguf',size=1024**3)
self.runtime=SimpleNamespace(root=self.root/'runtime',status=lambda:dict(active='build-id',builds=[dict(id='build-id',backend='CUDA',fit_tool=True)]))
self.worker=LlamaWorker(self.root/'worker',SimpleNamespace(root=self.root/'models',entry=lambda _:self.entry),self.runtime)
self.profile=dict(id='p',name='medium',revision=1,model_id='model',parameters={**{k:v[2] for k,v in SCHEMAS['chat'].items()},**CHAT_GPU_DEFAULTS,'gpu_devices':['gpu-first','gpu-second'],'split_mode':'layer','tensor_split':[85,15]})
self.gpus=[dict(uuid='gpu-first',processes=0,total_mib=16000,free_mib=15000),dict(uuid='gpu-second',processes=0,total_mib=12000,free_mib=11000)]
def tearDown(self):self.tmp.cleanup()
def patches(self):
real=Path.read_text
return patch.object(Path,'read_text',lambda path,*a,**kw:'MemAvailable: 50000000 kB\n' if str(path)=='/proc/meminfo' else real(path,*a,**kw))
def test_fixed_split_uses_prediction_and_preserves_device_order(self):
process=Mock();process.poll.return_value=None
http=Mock();http.getresponse.return_value.status=200
with self.patches(),patch('inference.probe',return_value=self.gpus),patch('inference.cgroup_headroom',return_value=32*1024**3),patch.object(self.worker,'_fit_command',return_value='CUDA0 9000 1000 1000\nCUDA1 2000 1000 1000\nHost 512 0 256\n') as fit,patch('inference.subprocess.Popen',return_value=process) as launch,patch('inference.http.client.HTTPConnection',return_value=http),patch('inference.threading.Thread'):
self.worker.ensure(self.profile)
args=launch.call_args.args[0];env=launch.call_args.kwargs['env']
self.assertEqual(env['CUDA_VISIBLE_DEVICES'],'gpu-first,gpu-second');self.assertEqual(args[args.index('--tensor-split')+1],'85,15');self.assertIn('--kv-unified',args);self.assertIn('--fit-print',fit.call_args.args[0]);self.assertEqual(self.worker.status()['state'],'ready')
def test_busy_gpu_is_rejected_before_fit_or_spawn(self):
self.gpus[0]['processes']=1
with patch('inference.probe',return_value=self.gpus),patch('inference.subprocess.Popen') as launch:
with self.assertRaises(InferenceError):self.worker.ensure(self.profile)
launch.assert_not_called()
def test_fixed_split_exhaustion_does_not_load_model(self):
with self.patches(),patch('inference.probe',return_value=self.gpus),patch('inference.cgroup_headroom',return_value=32*1024**3),patch.object(self.worker,'_fit_command',return_value='CUDA0 50000 1000 1000\nCUDA1 2000 1000 1000\nHost 512 0 256\n') as fit,patch('inference.subprocess.Popen') as launch:
with self.assertRaises(InferenceError):self.worker.ensure(self.profile)
launch.assert_not_called();self.assertLessEqual(fit.call_count,11)
def test_build_symlink_cannot_escape_deck_state(self):
self.runtime.status=lambda:dict(active='outside',builds=[dict(id='outside',backend='CUDA',fit_tool=True)])
(self.root/'runtime'/'outside').symlink_to('/usr')
with self.assertRaises(InferenceError):self.worker.build()
if __name__=='__main__':unittest.main()
+1 -1
View File
@@ -88,7 +88,7 @@ class APITests(unittest.TestCase):
self.thread.start()
self.base=f'http://127.0.0.1:{self.server.server_port}'
def tearDown(self):
self.server.shutdown();self.server.demo.stop();self.server.server_close();self.thread.join();self.state.cleanup()
self.server.shutdown();self.server.endpoint.close();self.server.server_close();self.thread.join();self.state.cleanup()
def post(self,path,body,headers=None):
req=urllib.request.Request(self.base+'/api/v1/'+path,data=json.dumps(body).encode(),headers={'Content-Type':'application/json','X-Athena-Deck':'1','Cookie':self.cookie,**(headers or {})})
return urllib.request.urlopen(req)
+19 -10
View File
@@ -23,22 +23,19 @@ class Tests(unittest.TestCase):
self.url=f'http://127.0.0.1:{self.server.server_port}'
def tearDown(self):
self.server.shutdown()
self.server.demo.stop()
self.server.endpoint.close()
self.server.server_close()
self.thread.join()
self.state.cleanup()
def request(self,path,method='GET',headers=None):
req=urllib.request.Request(self.url+'/api/v1/'+path,method=method,headers={"Cookie":self.cookie,**(headers or {})})
with urllib.request.urlopen(req) as r:return json.load(r)
def test_lifecycle(self):
self.assertEqual(self.request('status')['demo']['state'],'stopped')
a=self.request('demo/start','POST',{'X-Athena-Deck':'1'})
self.assertTrue(a['reachable'])
self.assertEqual(a['pid'],self.request('demo/start','POST',{'X-Athena-Deck':'1'})['pid'])
child=self.server.demo.process
self.assertEqual(self.request('demo/stop','POST',{'X-Athena-Deck':'1'})['state'],'stopped')
self.assertIsNotNone(child.poll())
self.assertFalse(self.request('demo/stop','POST',{'X-Athena-Deck':'1'})['reachable'])
def test_demo_removed_from_status(self):
state=self.request('status')
self.assertNotIn('demo',state)
self.assertEqual(state['endpoint']['state'],'stopped')
with self.assertRaises(urllib.error.HTTPError) as exc:self.request('demo')
self.assertEqual(exc.exception.code,404);exc.exception.close()
def test_profile_and_download_routes(self):
from pathlib import Path
target=Path(self.state.name)/'models'/('a'*64);target.mkdir(parents=True)
@@ -83,6 +80,18 @@ class Tests(unittest.TestCase):
with urllib.request.urlopen(req) as result:self.assertEqual(result.status,expected)
except urllib.error.HTTPError as error:self.assertEqual(error.code,expected);error.close()
install.assert_called_once()
def test_endpoint_admin_only_configuration(self):
self.assertEqual(self.request('endpoint')['state'],'stopped')
url=self.url+'/api/v1/endpoint/config'
for headers,expected in [({'Authorization':'Bearer '+self.api_token,'X-Athena-Deck':'1'},401),({'Cookie':self.cookie},403)]:
req=urllib.request.Request(url,data=b'{"port":8120}',headers={'Content-Type':'application/json',**headers})
with self.assertRaises(urllib.error.HTTPError) as exc:urllib.request.urlopen(req)
self.assertEqual(exc.exception.code,expected);exc.exception.close()
with patch.object(self.server.endpoint,'configure',return_value={'port':8120}) as configure:
req=urllib.request.Request(url,data=b'{"port":8120}',headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'})
with urllib.request.urlopen(req) as r:self.assertEqual(json.load(r)['port'],8120)
configure.assert_called_once_with({'port':8120})
def test_control_guard(self):
for headers in ({},{'X-Athena-Deck':'1','Origin':'http://evil.invalid'}):
with self.assertRaises(urllib.error.HTTPError) as e:self.request('demo/start','POST',headers)