Add owned OpenAI endpoint and coordinated native model switching
This commit is contained in:
@@ -51,9 +51,8 @@ API-Clients verwenden `Authorization: Bearer <API-TOKEN>`. Aktuell erlaubt sind:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| GET | `/api/v1/status` |
|
| GET | `/api/v1/status` |
|
||||||
| GET | `/api/v1/hardware` |
|
| GET | `/api/v1/hardware` |
|
||||||
| GET | `/api/v1/demo` |
|
| GET | API-Port: `/v1/models`, `/health` |
|
||||||
| POST | `/api/v1/demo/start` |
|
| POST | API-Port: `/v1/chat/completions`, `/v1/images/generations` |
|
||||||
| POST | `/api/v1/demo/stop` |
|
|
||||||
|
|
||||||
API-Clients brauchen weder Cookie noch Browser-CSRF-Header. Ein vorhandener
|
API-Clients brauchen weder Cookie noch Browser-CSRF-Header. Ein vorhandener
|
||||||
Authorization-Header wird ausdrücklich geprüft; ein ungültiger Token wird nicht
|
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
|
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
|
Der separate Inferenz-Listener prüft denselben Deck-Token bei jeder Anfrage.
|
||||||
muss beim Ergänzen explizit an die Token-Prüfung angeschlossen werden. Es wird kein
|
Endpunkt starten/stoppen, Port und Profilfreigaben ändern erfordert weiterhin die
|
||||||
produktiver Router-Token verändert. Der WireGuard-Zugriffsmodus gilt weiterhin vor
|
Admin-Sitzung. Der alte produktive Router-Token wird nicht geändert. Der neue
|
||||||
der Authentifizierung: ein gültiger Token umgeht keine gesperrte LAN-/Tunnelroute.
|
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
|
## Speicherung und Serverbetrieb
|
||||||
|
|
||||||
|
|||||||
+131
@@ -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
@@ -194,3 +194,13 @@ entfernt; Startzeiten zuvor vorhandener Container unverändert.
|
|||||||
Es wurde **keine dauerhafte Installation auf Athena ausgeführt**. Die interaktive
|
Es wurde **keine dauerhafte Installation auf Athena ausgeführt**. Die interaktive
|
||||||
Erstinstallation mit deinen echten Zugangsdaten und die optionale NVIDIA-Telemetrie
|
Erstinstallation mit deinen echten Zugangsdaten und die optionale NVIDIA-Telemetrie
|
||||||
sind noch nicht als persistente Installation abgenommen.
|
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).
|
||||||
|
|||||||
@@ -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.
|
Eigenständige neue Oberfläche, Python-Standardbibliothek und HTML/CSS/JavaScript.
|
||||||
Keine Installation von Python-Paketen oder Frontend-Builds nötig. Python >= 3.10.
|
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
|
llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden
|
||||||
abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine
|
abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine
|
||||||
konservative Gewichts-Speicherprüfung; noch keine vollständige Laufzeitprognose.
|
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
|
## Zugang und API-Token
|
||||||
|
|
||||||
@@ -33,38 +33,21 @@ angebunden; der Zugriff erfolgt über SSH-Tunnel. Der bestehende Gateway bleibt
|
|||||||
## Zielsystem und Entwicklung
|
## Zielsystem und Entwicklung
|
||||||
|
|
||||||
Athena Deck läuft vollständig auf einem Debian-Server mit RTX 5080 und RTX 3060.
|
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
|
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)
|
Die isolierte Entwicklungsinstallation ist in [DEVELOPMENT.md](DEVELOPMENT.md)
|
||||||
beschrieben. Hardware wird direkt über `/proc`, hwmon und nvidia-smi gelesen;
|
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
|
## Interne API v1
|
||||||
|
|
||||||
Alle Antworten JSON. Zugangsdaten werden geschützt persistiert; Demo-Zustand ist flüchtig. GUI verwendet nur diese API.
|
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.
|
||||||
| Methode | Pfad | Ergebnis |
|
Die Übersicht steuert den eigenen OpenAI-kompatiblen API-Listener; Demo-Dienst
|
||||||
|---|---|---|
|
und Demo-Routen wurden entfernt. Port, Profilfreigaben, Inferenzrouten,
|
||||||
| GET | `/api/v1/status` | Name, Version, Laufzeit, Modus, Demo-Zustand |
|
Betriebsgrenzen und SSH-Tunnel: **[ENDPOINT.md](ENDPOINT.md)**.
|
||||||
| 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
|
|
||||||
```
|
|
||||||
|
|
||||||
Hardware: `available` bezeichnet Erfolg der Hardware-Abfrage, einzelne
|
Hardware: `available` bezeichnet Erfolg der Hardware-Abfrage, einzelne
|
||||||
Messwerte können trotzdem fehlen (`null`). Ein Ausfall der Erfassung liefert available=false,
|
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
|
## Struktur und Grenzen
|
||||||
|
|
||||||
- `server.py`: API, separater HardwareProvider, eigenständiger DemoService.
|
- `server.py`: Verwaltungs-API und lesende Hardware-Abfragen.
|
||||||
- `demo.py`: Health-only HTTP-Kindprozess auf dem Debian-Server, ohne Modellabhängigkeiten.
|
- `endpoint.py`: separater authentifizierter Inferenz-Listener.
|
||||||
- `collect_hardware.py`: fest begrenzte lesende Linux-Hardware-Abfragen.
|
- `inference.py`: native llama.cpp-Prozesse und gemeinsame GPU-Reservierung.
|
||||||
- `index.html`, `app.js`, `style.css`: neue responsive Oberfläche.
|
- `image_test.py`: eigene ComfyUI-Aufträge, gemeinsam mit dem Router koordiniert.
|
||||||
- `test_server.py`: Prozess-Lebenszyklus, Kontrollgrenzen, Hardware-Ausfall.
|
- `endpoint-ui.js`: Übersicht, Port und Profilfreigaben.
|
||||||
|
|
||||||
Sprachmodelle/Chat, Bildgenerierung, Audio und Video besitzen jetzt eine
|
Sprachmodelle und Qwen-Image-Profile sind ausführbar. Audio-/Video-Laufzeiten
|
||||||
gemeinsame Modellverwaltung; Weitere Dienste bleibt Platzhalter. Downloads und Profile sind aktiv, Chats und Modellwechsel noch nicht.
|
bleiben vorbereitet; weitere Docker-Dienste werden nur mit Deck-Labels angezeigt.
|
||||||
Spätere Modellprofile und native llama.cpp-Worker sollten eigene Service-Adapter
|
Der native systemd-Installer ist noch nicht vollständig; aktuell gilt der isolierte
|
||||||
mit derselben Status-/Start-/Stopp-Trennung erhalten. Noch kein Worker-Registry,
|
Docker-Testinstaller. Keine Migration oder Steuerung des bisherigen Routers.
|
||||||
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.
|
|
||||||
|
|
||||||
## Verifikation am 28.09.2026
|
## Verifikation am 28.09.2026
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
und relative Verteilungsgewichte. Zwei GPU-Auswahlen zeigen die echten Kartennamen;
|
||||||
die Reihenfolge definiert die vorgesehenen logischen CUDA0/CUDA1-Geräte. UUIDs
|
die Reihenfolge definiert die vorgesehenen logischen CUDA0/CUDA1-Geräte. UUIDs
|
||||||
verhindern Verwechslungen mit wechselnden nvidia-smi-Indizes. CPU-Threads stehen
|
verhindern Verwechslungen mit wechselnden nvidia-smi-Indizes. CPU-Threads stehen
|
||||||
unter „Erweitert“. Die Auswahl startet keinen Worker; die Chat-Ausführung ist
|
unter „Erweitert“. Die Auswahl startet keinen Worker. Nach expliziter Freigabe am Endpunkt lädt
|
||||||
weiterhin nicht angebunden. Die GPU-Verteilung ist keine Speicherzusage.
|
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,
|
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,
|
RTX 5080 zuerst, RTX 3060 danach, Layer-Split 85,15, Batch 2048, Microbatch 256,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const view=document.querySelector('#view'), error=document.querySelector('#error');
|
const view=document.querySelector('#view'), error=document.querySelector('#error');
|
||||||
let busy=false, refreshing=false;
|
let refreshing=false;
|
||||||
const esc=v=>String(v??'Nicht verfügbar').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const esc=v=>String(v??'Nicht verfügbar').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
const fmt=(v,unit='')=>v==null?'Nicht verfügbar':`${Number(v).toLocaleString('de-DE',{maximumFractionDigits:1})}${unit}`;
|
const 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>`;
|
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(['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==='access'){await accessPage();return;}
|
||||||
if(page==='network'){await networkPage();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 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>`;}
|
}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;}}
|
}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);
|
window.addEventListener('hashchange',refresh);refresh();setInterval(refresh,5000);
|
||||||
|
|||||||
@@ -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
@@ -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
|
&& /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
|
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
|
||||||
COPY 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 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 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-ui.js docker-ui.js image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/
|
||||||
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
|
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
||||||
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
|
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
|
||||||
|
|||||||
+25
-7
@@ -14,7 +14,7 @@ import urllib.request
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
LABEL = 'de.casaderoll.athena-deck.standalone'
|
LABEL = 'de.casaderoll.athena-deck.standalone'
|
||||||
FILES = ['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):
|
def run(*args, check=True, interactive=False):
|
||||||
@@ -98,6 +98,9 @@ def launch(config):
|
|||||||
'-p','127.0.0.1:'+str(config['port'])+':8108',
|
'-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-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']
|
'--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'):
|
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)"'
|
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.
|
# Explicit isolated development bootstrap, reachable only through host loopback/SSH.
|
||||||
@@ -151,6 +154,9 @@ def install(args):
|
|||||||
if base.exists():
|
if base.exists():
|
||||||
raise RuntimeError('Installationsverzeichnis existiert bereits. Es wird nicht überschrieben.')
|
raise RuntimeError('Installationsverzeichnis existiert bereits. Es wird nicht überschrieben.')
|
||||||
free_port(args.port)
|
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
|
parent=base.parent
|
||||||
while not parent.exists():parent=parent.parent
|
while not parent.exists():parent=parent.parent
|
||||||
if shutil.disk_usage(parent).free < 25*1024**3:
|
if shutil.disk_usage(parent).free < 25*1024**3:
|
||||||
@@ -162,7 +168,7 @@ def install(args):
|
|||||||
for sub in ('state','models','backups','bootstrap'):
|
for sub in ('state','models','backups','bootstrap'):
|
||||||
(base/sub).mkdir(mode=0o700)
|
(base/sub).mkdir(mode=0o700)
|
||||||
os.chown(base/'state',65534,65534)
|
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')
|
run(sys.executable,str(ROOT/'deploy/setup_docker_helper.py'),'--client-uid','65534','--client-gid','65534')
|
||||||
write_manifest(base,config)
|
write_manifest(base,config)
|
||||||
provision(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.')
|
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)
|
config=load_manifest(base)
|
||||||
previous=owned(config['name'],base)
|
previous=owned(config['name'],base)
|
||||||
if not previous:
|
if not previous:
|
||||||
raise RuntimeError('Kein installierter Deck-Container vorhanden.')
|
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()
|
image=config.get('previous_image') if rollback else build()
|
||||||
if not image:raise RuntimeError('Kein vorheriger Build gespeichert.')
|
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
|
print('Dieser Quellstand ist bereits installiert.');return
|
||||||
backup_name=config['name']+'-previous'
|
backup_name=config['name']+'-previous'
|
||||||
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
|
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
|
||||||
stamp=str(time.time_ns())
|
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']
|
was_running=previous['State']['Running']
|
||||||
if was_running:run('docker','stop','--time','15',config['name'])
|
if was_running:run('docker','stop','--time','15',config['name'])
|
||||||
run('docker','rename',config['name'],backup_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:
|
try:
|
||||||
launch(new);ready(new);write_manifest(base,new)
|
launch(new);ready(new);write_manifest(base,new)
|
||||||
except Exception:
|
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.')
|
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():
|
def main():
|
||||||
parser=argparse.ArgumentParser(description='Athena Deck auf Debian 12/13 getrennt installieren. Kein WireGuard, keine Host-Paket- oder Treiberänderungen.')
|
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)
|
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('--directory',type=Path,default=Path('/opt/athena-deck-standalone'))
|
||||||
parser.add_argument('--name',default='athena-deck-standalone')
|
parser.add_argument('--name',default='athena-deck-standalone')
|
||||||
parser.add_argument('--port',type=int,default=8110)
|
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.')
|
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()
|
args=parser.parse_args()
|
||||||
import re
|
import re
|
||||||
@@ -248,7 +266,7 @@ def main():
|
|||||||
if item:run('docker','start',config['name'])
|
if item:run('docker','start',config['name'])
|
||||||
else:launch(config)
|
else:launch(config)
|
||||||
ready(config);print('Deck bereit.')
|
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__':
|
if __name__=='__main__':
|
||||||
try:main()
|
try:main()
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
window.EndpointUI=(()=>{
|
||||||
|
const e=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const stateName={running:'Läuft',stopped:'Gestoppt',stopping:'Wird gestoppt · laufende Aufträge werden beendet',loading:'Modell lädt',ready:'Modell bereit',failed:'Fehlgeschlagen'};
|
||||||
|
let pending=false,sequence=0;
|
||||||
|
async function api(path='',data){const r=await fetch('/api/v1/endpoint'+path,data!==undefined?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Endpunkt nicht erreichbar');return v;}
|
||||||
|
function put(el,html){if(el._deckHtml!==html){el.innerHTML=html;el._deckHtml=html;}}
|
||||||
|
function summary(s){return `<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
@@ -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
@@ -17,6 +17,16 @@ PYTHON=Path('/opt/deck-image-python/bin/python')
|
|||||||
COMFY=Path('/opt/deck-comfy')
|
COMFY=Path('/opt/deck-comfy')
|
||||||
GIB=1024**3
|
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():
|
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
|
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
|
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:
|
class ImageTests:
|
||||||
def __init__(self,root,profiles):
|
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():
|
if (self.root/'status.json').exists():
|
||||||
self.job=json.loads((self.root/'status.json').read_text())
|
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.')
|
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}
|
return {'cancellation_requested':True}
|
||||||
def model_path(self,item):
|
def model_path(self,item):
|
||||||
return (self.profiles.catalog.root/item['id']/('model'+Path(item['file']).suffix)).resolve()
|
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.')
|
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:
|
with self.lock:
|
||||||
if self.job and self.job['state']=='running':raise ValueError('Ein Bildtest läuft bereits.')
|
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'))
|
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:'))}
|
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)
|
required_ram=max(20*GIB,encoder['size']*2+4*GIB)
|
||||||
try:
|
headroom=cgroup_headroom()
|
||||||
raw=Path('/sys/fs/cgroup/memory.max').read_text().strip()
|
if headroom is not None and headroom<required_ram:raise ValueError('Deck-RAM-Limit reicht für Textencoder und Arbeitsdaten nicht aus.')
|
||||||
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
|
|
||||||
if mem.get('MemAvailable',0)<required_ram+4*GIB:raise ValueError('Aktuell zu wenig freier System-RAM; produktive Dienste bleiben unverändert.')
|
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)
|
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.')
|
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)
|
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.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)
|
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
|
directory=self.root/job_id;process=None
|
||||||
try:
|
try:
|
||||||
directory.mkdir(mode=0o700)
|
directory.mkdir(mode=0o700)
|
||||||
@@ -166,12 +178,14 @@ class ImageTests:
|
|||||||
except InterruptedError:final_state='cancelled';phase='Bildtest abgebrochen'
|
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.'
|
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:
|
finally:
|
||||||
|
try:
|
||||||
if process and process.poll() is None:
|
if process and process.poll() is None:
|
||||||
try:os.killpg(process.pid,signal.SIGTERM);process.wait(timeout=10)
|
try:os.killpg(process.pid,signal.SIGTERM);process.wait(timeout=10)
|
||||||
except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait()
|
except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait()
|
||||||
except ProcessLookupError:pass
|
except ProcessLookupError:pass
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.process=None;self.job.update(state=final_state,phase=phase,finished_at=time.time());self._save()
|
self.process=None;self.job.update(state=final_state,phase=phase,finished_at=time.time());self._save()
|
||||||
|
finally:release()
|
||||||
def image(self,job_id):
|
def image(self,job_id):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if not self.job or self.job['id']!=job_id or self.job['state']!='complete':raise ValueError('Ergebnisbild nicht verfügbar.')
|
if not self.job or self.job['id']!=job_id or self.job['state']!='complete':raise ValueError('Ergebnisbild nicht verfügbar.')
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/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 & 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 & Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
||||||
|
|||||||
+241
@@ -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
|
||||||
@@ -12,7 +12,7 @@ import sys
|
|||||||
|
|
||||||
NAME = 'athena-deck-network'
|
NAME = 'athena-deck-network'
|
||||||
BASE = Path('/opt/athena-deck')
|
BASE = Path('/opt/athena-deck')
|
||||||
FILES = {'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):
|
def run(*args, **kwargs):
|
||||||
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
|
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
|
||||||
|
|||||||
+4
-3
@@ -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>';
|
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;}
|
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){
|
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;};
|
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;
|
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>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>`;
|
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-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-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)));
|
root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit)));
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class Profiles:
|
|||||||
def __init__(self,path,catalog):
|
def __init__(self,path,catalog):
|
||||||
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock()
|
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock()
|
||||||
self.image_runtime_ready=lambda:False
|
self.image_runtime_ready=lambda:False
|
||||||
|
self.chat_blockers=None
|
||||||
self.rows=json.loads(self.path.read_text()) if self.path.exists() else []
|
self.rows=json.loads(self.path.read_text()) if self.path.exists() else []
|
||||||
def status(self):
|
def status(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -52,6 +53,8 @@ class Profiles:
|
|||||||
except ValueError as exc:p['model']=None;p['blockers']=[str(exc)]
|
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():
|
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')]
|
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'
|
p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured'
|
||||||
return {'profiles':rows,'schemas':SCHEMAS}
|
return {'profiles':rows,'schemas':SCHEMAS}
|
||||||
def save(self,data):
|
def save(self,data):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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 argparse
|
||||||
import os
|
import os
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -13,6 +13,8 @@ from runtime import Runtime
|
|||||||
from image_runtime import ImageRuntime
|
from image_runtime import ImageRuntime
|
||||||
from image_test import ImageTests
|
from image_test import ImageTests
|
||||||
from docker_support import DockerSupport
|
from docker_support import DockerSupport
|
||||||
|
from inference import LlamaWorker,Scheduler
|
||||||
|
from endpoint import Endpoint
|
||||||
from urllib.parse import urlsplit, parse_qs
|
from urllib.parse import urlsplit, parse_qs
|
||||||
from network.client import NetworkClient
|
from network.client import NetworkClient
|
||||||
from network.config import parse_config, ConfigError
|
from network.config import parse_config, ConfigError
|
||||||
@@ -29,49 +31,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
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:
|
class HardwareProvider:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
@@ -110,7 +69,6 @@ class Server(ThreadingHTTPServer):
|
|||||||
self.image_tests.runtime = self.image_runtime
|
self.image_tests.runtime = self.image_runtime
|
||||||
self.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"]
|
self.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"]
|
||||||
self.docker = DockerSupport()
|
self.docker = DockerSupport()
|
||||||
self.demo = DemoService()
|
|
||||||
self.hardware = HardwareProvider()
|
self.hardware = HardwareProvider()
|
||||||
self.started = time.time()
|
self.started = time.time()
|
||||||
self.network = NetworkClient()
|
self.network = NetworkClient()
|
||||||
@@ -122,6 +80,14 @@ class Server(ThreadingHTTPServer):
|
|||||||
if os.environ.get('DECK_REQUIRE_SETUP') == '1' and self.credentials.read() is None:
|
if os.environ.get('DECK_REQUIRE_SETUP') == '1' and self.credentials.read() is None:
|
||||||
self.server_close()
|
self.server_close()
|
||||||
raise RuntimeError('Serverzugang muss vor dem Start eingerichtet werden.')
|
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.sessions = {}
|
||||||
self.login_attempts = []
|
self.login_attempts = []
|
||||||
self.auth_lock = threading.Lock()
|
self.auth_lock = threading.Lock()
|
||||||
@@ -158,7 +124,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return secrets.compare_digest(actual,record['api_token_hash'])
|
return secrets.compare_digest(actual,record['api_token_hash'])
|
||||||
|
|
||||||
def token_route(self):
|
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):
|
def session_token(self):
|
||||||
try:
|
try:
|
||||||
@@ -273,12 +239,15 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self.respond({'error':'Anmeldung erforderlich.'},401)
|
return self.respond({'error':'Anmeldung erforderlich.'},401)
|
||||||
if not self.authenticated() and self.path == '/':
|
if not self.authenticated() and self.path == '/':
|
||||||
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
|
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:
|
if self.path in routes:
|
||||||
name, mime = routes[self.path]
|
name, mime = routes[self.path]
|
||||||
return self.respond((ROOT/name).read_bytes(), mime=mime)
|
return self.respond((ROOT/name).read_bytes(), mime=mime)
|
||||||
if self.path == '/api/v1/status':
|
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/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-runtime':return self.respond(self.server.image_runtime.status())
|
||||||
if self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.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)
|
return self.respond({'error':str(exc) if isinstance(exc,ValueError) else 'Katalog nicht erreichbar.'},400)
|
||||||
if self.path == '/api/v1/network':
|
if self.path == '/api/v1/network':
|
||||||
return self.respond(self.server.network.status(self.ingress()))
|
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)
|
return self.respond({'error': 'Not found'}, 404)
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
@@ -367,6 +334,15 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
raise ValueError('Ungültige Laufzeitaktion.')
|
raise ValueError('Ungültige Laufzeitaktion.')
|
||||||
except ValueError as exc:return self.respond({'error':str(exc)},400)
|
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)
|
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'):
|
if self.path in ('/api/v1/image-runtime/install','/api/v1/image-runtime/cancel'):
|
||||||
try:
|
try:
|
||||||
if self.read_json():raise ValueError('Keine Parameter erwartet.')
|
if self.read_json():raise ValueError('Keine Parameter erwartet.')
|
||||||
@@ -431,11 +407,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
except (ValueError, OSError, subprocess.SubprocessError) as exc:
|
except (ValueError, OSError, subprocess.SubprocessError) as exc:
|
||||||
message = str(exc) if isinstance(exc, ValueError) else 'Netzwerkmodul nicht erreichbar.'
|
message = str(exc) if isinstance(exc, ValueError) else 'Netzwerkmodul nicht erreichbar.'
|
||||||
return self.respond({'error':message},400)
|
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)
|
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)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -451,11 +423,11 @@ def main():
|
|||||||
try:
|
try:
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
finally:
|
finally:
|
||||||
|
server.endpoint.close()
|
||||||
server.image_runtime.stop()
|
server.image_runtime.stop()
|
||||||
server.image_tests.stop()
|
server.image_tests.stop()
|
||||||
server.runtime.stop()
|
server.runtime.stop()
|
||||||
server.catalog.stop()
|
server.catalog.stop()
|
||||||
server.demo.stop()
|
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ const Studio=(()=>{
|
|||||||
if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
|
if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
|
||||||
if(category!==page){category=page;section='discover';}
|
if(category!==page){category=page;section='discover';}
|
||||||
if(modelId)section='profiles';
|
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>`;
|
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
|
||||||
if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;}
|
if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;}
|
||||||
if(page==='runtime'){RuntimeUI.bind();return;}
|
if(page==='runtime'){RuntimeUI.bind();return;}
|
||||||
if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;}
|
if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;}
|
||||||
if(page==='runtimes')return;
|
if(page==='runtimes')return;
|
||||||
if(section==='test'||(section==='running'&&page==='image'))ImageTestUI.bind(section==='running');
|
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==='profiles')ProfilesUI.bind(page,modelId);
|
||||||
else if(section!=='running')CatalogUI.bind(page,section==='library',section==='downloads');
|
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);});
|
document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);});
|
||||||
|
|||||||
+3
-3
@@ -77,7 +77,7 @@ class AccessAPITests(unittest.TestCase):
|
|||||||
self.base=f'http://127.0.0.1:{self.server.server_port}'
|
self.base=f'http://127.0.0.1:{self.server.server_port}'
|
||||||
self.password=secrets.token_urlsafe(32);self.token=secrets.token_urlsafe(32)
|
self.password=secrets.token_urlsafe(32);self.token=secrets.token_urlsafe(32)
|
||||||
def tearDown(self):
|
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):
|
def request(self,path,body=None,cookie=None,token=None,headers=None):
|
||||||
h={**(headers or {})}
|
h={**(headers or {})}
|
||||||
if body is not None:h.update({'Content-Type':'application/json','X-Athena-Deck':'1'})
|
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)
|
for secret in (new,self.token,self.password,'api_token_hash','salt'):self.assertNotIn(secret,raw)
|
||||||
def test_api_client_without_browser_headers(self):
|
def test_api_client_without_browser_headers(self):
|
||||||
self.setup_login()
|
self.setup_login()
|
||||||
req=urllib.request.Request(self.base+'/api/v1/demo/start',method='POST',headers={'Authorization':'Bearer '+self.token})
|
req=urllib.request.Request(self.base+'/api/v1/status',method='GET',headers={'Authorization':'Bearer '+self.token})
|
||||||
with urllib.request.urlopen(req) as r:self.assertTrue(json.load(r)['reachable'])
|
with urllib.request.urlopen(req) as r:self.assertEqual(json.load(r)['name'],'Athena Deck')
|
||||||
def test_wrong_password_and_logout(self):
|
def test_wrong_password_and_logout(self):
|
||||||
cookie=self.setup_login()
|
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)
|
self.assertEqual(self.request('/api/v1/auth/token',dict(current_password='wrong',new_token=secrets.token_urlsafe(32)),cookie=cookie)[0],400)
|
||||||
|
|||||||
+8
-1
@@ -13,11 +13,13 @@ class InstallTests(unittest.TestCase):
|
|||||||
with patch.object(installer,'inspect',return_value={'Config':{'Labels':{}}}):
|
with patch.object(installer,'inspect',return_value={'Config':{'Labels':{}}}):
|
||||||
with self.assertRaises(RuntimeError):installer.owned('athena-deck-test',Path('/opt/test'))
|
with self.assertRaises(RuntimeError):installer.owned('athena-deck-test',Path('/opt/test'))
|
||||||
def test_launch_is_loopback_and_unprivileged(self):
|
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:
|
with patch.object(installer,'run') as run:
|
||||||
installer.launch(config)
|
installer.launch(config)
|
||||||
args=run.call_args.args
|
args=run.call_args.args
|
||||||
self.assertIn('127.0.0.1:8119:8108',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.assertIn('--read-only',args)
|
||||||
self.assertEqual(args[args.index('--cap-drop')+1],'ALL')
|
self.assertEqual(args[args.index('--cap-drop')+1],'ALL')
|
||||||
for forbidden in ('--privileged','--gpus','--cap-add','host','/var/run/docker.sock'):
|
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):
|
def test_requires_preprovisioned_credentials(self):
|
||||||
with tempfile.TemporaryDirectory() as directory,patch.dict(os.environ,{'DECK_REQUIRE_SETUP':'1'}):
|
with tempfile.TemporaryDirectory() as directory,patch.dict(os.environ,{'DECK_REQUIRE_SETUP':'1'}):
|
||||||
with self.assertRaises(RuntimeError):Server(0,state_dir=directory)
|
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):
|
def test_source_allowlist_exists(self):
|
||||||
for name in installer.FILES:self.assertTrue((installer.ROOT/name).is_file())
|
for name in installer.FILES:self.assertTrue((installer.ROOT/name).is_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()
|
||||||
@@ -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
@@ -88,7 +88,7 @@ class APITests(unittest.TestCase):
|
|||||||
self.thread.start()
|
self.thread.start()
|
||||||
self.base=f'http://127.0.0.1:{self.server.server_port}'
|
self.base=f'http://127.0.0.1:{self.server.server_port}'
|
||||||
def tearDown(self):
|
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):
|
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 {})})
|
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)
|
return urllib.request.urlopen(req)
|
||||||
|
|||||||
+19
-10
@@ -23,22 +23,19 @@ class Tests(unittest.TestCase):
|
|||||||
self.url=f'http://127.0.0.1:{self.server.server_port}'
|
self.url=f'http://127.0.0.1:{self.server.server_port}'
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.server.shutdown()
|
self.server.shutdown()
|
||||||
self.server.demo.stop()
|
self.server.endpoint.close()
|
||||||
self.server.server_close()
|
self.server.server_close()
|
||||||
self.thread.join()
|
self.thread.join()
|
||||||
self.state.cleanup()
|
self.state.cleanup()
|
||||||
def request(self,path,method='GET',headers=None):
|
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 {})})
|
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)
|
with urllib.request.urlopen(req) as r:return json.load(r)
|
||||||
def test_lifecycle(self):
|
def test_demo_removed_from_status(self):
|
||||||
self.assertEqual(self.request('status')['demo']['state'],'stopped')
|
state=self.request('status')
|
||||||
a=self.request('demo/start','POST',{'X-Athena-Deck':'1'})
|
self.assertNotIn('demo',state)
|
||||||
self.assertTrue(a['reachable'])
|
self.assertEqual(state['endpoint']['state'],'stopped')
|
||||||
self.assertEqual(a['pid'],self.request('demo/start','POST',{'X-Athena-Deck':'1'})['pid'])
|
with self.assertRaises(urllib.error.HTTPError) as exc:self.request('demo')
|
||||||
child=self.server.demo.process
|
self.assertEqual(exc.exception.code,404);exc.exception.close()
|
||||||
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_profile_and_download_routes(self):
|
def test_profile_and_download_routes(self):
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
target=Path(self.state.name)/'models'/('a'*64);target.mkdir(parents=True)
|
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)
|
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()
|
except urllib.error.HTTPError as error:self.assertEqual(error.code,expected);error.close()
|
||||||
install.assert_called_once()
|
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):
|
def test_control_guard(self):
|
||||||
for headers in ({},{'X-Athena-Deck':'1','Origin':'http://evil.invalid'}):
|
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)
|
with self.assertRaises(urllib.error.HTTPError) as e:self.request('demo/start','POST',headers)
|
||||||
|
|||||||
Reference in New Issue
Block a user