Add internal chat testing with memory diagnostics and cancellation

This commit is contained in:
Mikei386
2026-09-28 20:43:40 +02:00
parent ff5d36252a
commit 7ada7f6f20
15 changed files with 265 additions and 24 deletions
+25
View File
@@ -129,3 +129,28 @@ Listener gestoppt und Modell entladen. Keine vorhandenen Nutzerprompts oder
Anwendungslogs gelesen. Testprofile und Testzugang gehören nicht zur normalen Anwendungslogs gelesen. Testprofile und Testzugang gehören nicht zur normalen
Deck-Konfiguration. Der Test weist keine vollständige OpenAI-Kompatibilität oder Deck-Konfiguration. Der Test weist keine vollständige OpenAI-Kompatibilität oder
Langzeitstabilität nach. Langzeitstabilität nach.
## Sprachmodelle → Testen
Der interne Testchat verwendet denselben Scheduler und llama.cpp-Worker wie der
API-Endpunkt, braucht aber keine Veröffentlichung des Profils und keinen
API-Token im Browser. Profilauswahl, fortlaufender Textchat, Antwortlimit (bis
4096 Token), Abbrechen und explizites Entladen stehen bereit. Der Modellprozess
bleibt nach einer Antwort geladen; andere Profile/Bildaufträge wechseln regulär.
Entladen wird bei aktiven Anfragen abgewiesen. Abbrechen schließt nur die eigene
Chatverbindung; während des eigenen Modellstarts bricht es diesen Start ab.
Der Verlauf liegt nur im Browser-Arbeitsspeicher. Beim Wechsel der Ansicht oder
des Profils beginnt ein neuer Verlauf. Der Server hält nur den aktuellen Testjob
mit gestreamter Antwort vorübergehend im RAM; weder Prompt noch Antwort werden
als Chatdatei oder Log gespeichert. Reguläre Admin-Sitzung/CSRF-Schutz gelten für
GET `/api/v1/chat-tests` und POST `/api/v1/chat-tests/start`, `/cancel`, `/unload`.
Start: `profile_id`, Textnachrichten `messages`, `max_tokens`. Kein Bearer-Zugriff.
Diagnose zeigt Ladephase, Wartezeit/Reservierungen, reale GPU-Belegung und die
Speicherprognose mit Reserve je GPU, RAM-Budget und GPU-Layerlimit. Eine Prognose
ist kein Nachweis für fehlerfreien Betrieb. Bestätigte Cgroup-OOM-Kills werden
als RAM-OOM gemeldet; ein GPU-OOM wird ohne eindeutigen Nachweis nicht behauptet.
Ein nicht eindeutig diagnostizierter Prozessabbruch nennt Speicher, Modell/Build
oder Zeitlimit als mögliche Ursachen. Bestehende Anwendungslogs werden nicht gelesen.
+1 -1
View File
@@ -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. Textprofile sind am eigenen [OpenAI-kompatiblen Endpunkt](ENDPOINT.md) ausführbar. Audio und Video 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. Sprachmodelle besitzen außerdem einen internen Testchat mit Abbruch und Speicherdiagnose. Details: [Modellverwaltung](STUDIO.md).
## Zugang und API-Token ## Zugang und API-Token
+2
View File
@@ -155,3 +155,5 @@ Leere Geräte-/Gewichtelisten bedeuten automatische Auswahl/Verteilung.
Alte Profile bleiben lesbar; fehlende neue Felder erhalten bei Anzeige/Speichern Alte Profile bleiben lesbar; fehlende neue Felder erhalten bei Anzeige/Speichern
Standardwerte (automatische Geräte, keine Aufteilung, Temperature 0.8, Top-p 0.95, Standardwerte (automatische Geräte, keine Aufteilung, Temperature 0.8, Top-p 0.95,
Top-k 40). Bestehende Dateien werden beim Lesen nicht umgeschrieben. Top-k 40). Bestehende Dateien werden beim Lesen nicht umgeschrieben.
Sprachmodelle besitzen jetzt den Reiter **Testen**: interner Textchat mit Streaming, Profilauswahl und Speicherdiagnose. Öffentliche Profilfreigabe ist dafür nicht erforderlich. Details: [ENDPOINT.md](ENDPOINT.md#sprachmodelle--testen).
+27
View File
@@ -0,0 +1,27 @@
window.ChatTestUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const gib=v=>v==null?'unbekannt':(v/1024).toFixed(2)+' GiB';
async function api(path,data){const r=await fetch('/api/v1/'+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||'Anfrage fehlgeschlagen');return v;}
function put(el,html){if(el._chatHtml!==html){el.innerHTML=html;el._chatHtml=html;}}
function html(){return `<section id="chat-test"><h2>Sprachmodell testen</h2><p>Ein einfacher Textchat mit deinem gespeicherten Profil. Nutzt denselben Worker wie API-Anfragen; eine öffentliche Profilfreigabe ist nicht erforderlich.</p><form id="chat-test-form" class="card"><div class="form-grid"><label>Chatprofil<select id="chat-profile" required></select></label><label>Maximale Antwort-Token<input id="chat-limit" type="number" min="1" max="4096" value="1024" required></label></div><p id="chat-profile-info"></p><div id="chat-transcript" aria-live="polite"></div><label>Nachricht<textarea id="chat-prompt" rows="4" maxlength="8000" required placeholder="Schreibe eine kurze Testnachricht …"></textarea></label><div class="card-actions"><button id="chat-send" disabled>Senden</button><button type="button" class="secondary" id="chat-clear">Neuer Chat</button></div><p class="small">Der Verlauf bleibt nur in dieser Ansicht und wird bei Profilwechsel oder Neuladen verworfen. Prompts und Antworten werden nicht als Chatdatei gespeichert.</p></form><p id="chat-message" role="alert"></p><section class="card"><h3>Laufzeit & Diagnose</h3><div id="chat-state" role="status"></div><div class="card-actions"><button class="secondary" id="chat-cancel" disabled>Test abbrechen</button><button class="secondary" id="chat-unload">Modell entladen</button></div><p>Entladen ist nur ohne laufende Anfragen möglich. Abbrechen beendet nur diesen Test; andere Antworten werden nicht gestoppt.</p></section><section class="card"><h3>Speicherprüfung</h3><div id="chat-memory"></div><p class="small">Prognose mit Reserve, keine Garantie für jede Anfrage. GPU-Layer können automatisch auf die CPU ausgelagert werden. Bei Fehlern helfen häufig kleinerer Kontext oder Microbatch. <a class="link" href="#hardware">Hardware-Messwerte →</a></p></section></section>`;}
function bind(){
const root=document.querySelector('#chat-test'),el=id=>root.querySelector('#'+id);let profiles=[],messages=[],turns=[],ownJob=null,finished=null,status=null,pending=false;
const notice=t=>{if(root.isConnected)el('chat-message').textContent=t;};
function transcript(){put(el('chat-transcript'),turns.map(t=>`<div class="note"><strong>${t.role==='user'?'Du':'Modell'}</strong><p class="chat-text">${e(t.content)}</p>${t.reasoning?`<details><summary>Reasoning-Ausgabe</summary><pre>${e(t.reasoning)}</pre></details>`:''}</div>`).join(''));}
function selection(){const p=profiles.find(p=>p.id===el('chat-profile').value),running=status?.job?.state==='running';el('chat-profile-info').textContent=p?`${p.parameters.context.toLocaleString('de-DE')} Token gesamt · ${p.parameters.slots} Slots · Batch ${p.parameters.batch} / Microbatch ${p.parameters.ubatch}${p.blockers.length?' · '+p.blockers.join(' '):''}`:'Zuerst ein Sprachmodellprofil anlegen.';el('chat-send').disabled=pending||running||!p?.runnable;el('chat-profile').disabled=pending||running;el('chat-clear').disabled=pending||running;el('chat-cancel').disabled=!running;el('chat-unload').disabled=!!status?.scheduler.active_requests;}
async function refresh(){try{const [s,h]=await Promise.all([api('chat-tests'),api('hardware').catch(()=>({gpus:[]}))]);if(!root.isConnected)return;status=s;const j=s.job,w=s.worker,plan=w.memory_plan;
put(el('chat-state'),`<p><strong>${e(j?.phase||'Noch kein Chat-Test gestartet.')}</strong></p><p>Worker: ${e(w.phase||w.state)} · ${e(w.profile_name||'kein Modell geladen')}<br>${s.scheduler.active_requests} laufende / ${s.scheduler.waiting_requests} wartende Anfragen</p>${j?.error?`<p role="alert">${e(j.error)}</p>`:''}${w.error?`<p>${e(w.error)}</p>`:''}${j?`<p>Dauer: ${Math.max(0,Math.round((j.finished_at||Date.now()/1000)-j.started_at))} s</p>`:''}`);
put(el('chat-memory'),(plan?`<p>Letzte Prognose für ${e(plan.profile_name||w.profile_name||'das Modell')}</p><p><strong>${plan.fits?'Prognose passt einschließlich Reserve':'Prognose passt nicht in den verfügbaren Speicher'}</strong> · ${plan.context.toLocaleString('de-DE')} Token / ${plan.slots} Slots · GPU-Layerlimit ${plan.gpu_layers===999?'alle':plan.gpu_layers}</p>${plan.gpus.map(g=>`<p>${e(g.name)}: geschätzt ${gib(g.required_mib)} + ${gib(g.reserve_mib)} Reserve / ${gib(g.free_mib)} beim Prüfen frei</p>`).join('')}<p>RAM-Budget: ${gib(plan.host_required_mib)} benötigt / ${gib(plan.host_available_mib)} verfügbar</p>`:'<p>Noch keine Speicherprognose für einen Modellstart vorhanden.</p>')+`<h4>Aktuelle GPU-Belegung</h4>${(h.gpus||[]).map(g=>`<p>${e(g.name)}: ${gib(g.used_mib)} / ${gib(g.total_mib)} belegt · ${g.percent??'—'} % Auslastung</p>`).join('')||'<p>GPU-Messwerte nicht verfügbar.</p>'}`);
if(j&&j.id===ownJob){let response=turns[turns.length-1];if(response?.role!=='assistant'){response={role:'assistant',content:'',reasoning:''};turns.push(response);}response.content=j.answer;response.reasoning=j.reasoning;transcript();
if(j.state!=='running'&&finished!==j.id){finished=j.id;if(j.state==='complete'&&j.answer)messages.push({role:'assistant',content:j.answer});else{messages=[];notice('Der nächste Prompt beginnt einen neuen Kontext; der letzte Test wurde nicht vollständig beantwortet.');}if(j.finish_reason==='length')notice('Antwort am Tokenlimit beendet. Bei Bedarf das Antwortlimit erhöhen.');}
}selection();
}catch(error){notice(error.message);}finally{if(root.isConnected)setTimeout(refresh,1500);}}
api('profiles').then(s=>{if(!root.isConnected)return;profiles=s.profiles.filter(p=>p.kind==='chat');el('chat-profile').innerHTML=profiles.map(p=>`<option value="${e(p.id)}">${e(p.name)}${p.runnable?'':' · nicht ausführbar'}</option>`).join('')||'<option value="">Zuerst ein Chatprofil anlegen</option>';selection();}).catch(err=>notice(err.message));
const clear=()=>{messages=[];turns=[];ownJob=null;finished=null;transcript();notice('Neuer Chat.');};el('chat-clear').onclick=clear;el('chat-profile').onchange=()=>{clear();selection();};
el('chat-test-form').onsubmit=async event=>{event.preventDefault();pending=true;selection();const text=el('chat-prompt').value.trim();try{const next=[...messages,{role:'user',content:text}];const job=await api('chat-tests/start',{profile_id:el('chat-profile').value,messages:next,max_tokens:Number(el('chat-limit').value)});messages=next;turns.push({role:'user',content:text});ownJob=job.id;finished=null;status={...status,job};el('chat-prompt').value='';transcript();notice('Test gestartet. Speicherprüfung, Modellstart und Antwort erscheinen unter Diagnose.');}catch(error){notice(error.message);}finally{pending=false;selection();}};
el('chat-cancel').onclick=async()=>{try{await api('chat-tests/cancel',{});notice('Abbruch angefordert.');}catch(error){notice(error.message);}};
el('chat-unload').onclick=async()=>{try{await api('chat-tests/unload',{});notice('Eigener Modellprozess entladen.');}catch(error){notice(error.message);}};
refresh();
}
return {html,bind};
})();
+91
View File
@@ -0,0 +1,91 @@
"""Ephemeral admin chat tests using the same worker and leases as the API."""
import json
import socket
import threading
import time
import uuid
from inference import InferenceError
class ChatTests:
def __init__(self,profiles,worker,scheduler):
self.profiles=profiles;self.worker=worker;self.scheduler=scheduler
self.lock=threading.RLock();self.job=None;self.cancel=threading.Event();self.socket=None
def status(self):
with self.lock:job=dict(self.job) if self.job else None
return dict(job=job,worker=self.worker.status(),scheduler=self.scheduler.status())
def start(self,data):
if set(data)!={'profile_id','messages','max_tokens'}:raise ValueError('Profil, Nachrichten und Antwortlimit erforderlich.')
messages=data['messages'];limit=data['max_tokens']
if type(limit)!=int or not 1<=limit<=4096:raise ValueError('Antwortlimit: 1–4096 Token.')
if not isinstance(messages,list) or not 1<=len(messages)<=32:raise ValueError('1–32 Textnachrichten erforderlich.')
for m in messages:
if not isinstance(m,dict) or set(m)!={'role','content'} or m['role'] not in ('user','assistant','system') or not isinstance(m['content'],str) or not m['content'].strip():raise ValueError('Nur Textnachrichten mit Rolle user, assistant oder system unterstützt.')
if sum(len(m['content']) for m in messages)>24000:raise ValueError('Testverlauf zu lang (maximal 24.000 Zeichen). Neuen Chat beginnen.')
profile=next((p for p in self.profiles.status()['profiles'] if p['id']==data['profile_id'] and p['kind']=='chat'),None)
if not profile or not profile['runnable']:raise ValueError('Kein ausführbares Chatprofil. Modell und CUDA-Build prüfen.')
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Ein Chat-Test läuft bereits.')
self.cancel.clear();self.job=dict(id=uuid.uuid4().hex,state='running',phase='Wartet auf freie Modellreservierung',profile_id=profile['id'],profile_name=profile['name'],started_at=time.time(),answer='',reasoning='',error=None)
threading.Thread(target=self._run,args=(profile,messages,limit),daemon=True).start()
return dict(self.job)
def phase(self,text):
with self.lock:self.job['phase']=text
def stop(self):
self.cancel.set()
with self.lock:sock=self.socket
if sock:
try:sock.shutdown(socket.SHUT_RDWR)
except OSError:pass
return {'cancellation_requested':True}
def unload(self):
if not self.scheduler.unload_idle():raise ValueError('Es laufen noch Anfragen. Erst deren Ende abwarten; andere Antworten werden nicht abgebrochen.')
return self.status()
def _run(self,profile,messages,limit):
conn=None;outcome='failed';message='Chat-Test fehlgeschlagen.';error=None
try:
key=('chat',profile['id'],profile['revision'])
def allowed():return not self.cancel.is_set() and any(p['id']==profile['id'] and p['revision']==profile['revision'] for p in self.profiles.status()['profiles'])
def prepare():
self.phase('Speicher wird geprüft · Profil wird geladen')
self.worker.ensure(profile,cancel=self.cancel.is_set)
with self.scheduler.lease(key,profile['parameters']['slots'],prepare,allowed=allowed):
if self.cancel.is_set():raise InterruptedError()
self.phase('Antwort wird erzeugt')
body=dict(model=profile['name'],messages=messages,max_tokens=limit,stream=True,**{k:profile['parameters'][k] for k in ('temperature','top_p','top_k')})
conn,token=self.worker.connect()
conn.request('POST','/v1/chat/completions',json.dumps(body).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+token})
with self.lock:self.socket=conn.sock
if self.cancel.is_set():raise InterruptedError()
response=conn.getresponse()
if response.status!=200:
raise InferenceError('llama.cpp hat den Test abgelehnt. Kontextbudget, Nachrichtenlänge und Profilparameter prüfen.')
buffer=b'';total=0;done=False;deadline=time.monotonic()+600
while time.monotonic()<deadline:
if self.cancel.is_set():raise InterruptedError()
chunk=response.read1(8192)
if not chunk:break
buffer+=chunk;total+=len(chunk)
if total>4*1024**2:raise InferenceError('Testantwort überschreitet 4 MiB.')
while b'\n' in buffer:
line,buffer=buffer.split(b'\n',1);line=line.strip()
if not line.startswith(b'data:'):continue
payload=line[5:].strip()
if payload==b'[DONE]':done=True;break
event=json.loads(payload)
if 'error' in event:raise InferenceError('Der Modellworker meldet einen Fehler während der Antwort.')
for choice in event.get('choices',[]):
delta=choice.get('delta',{})
with self.lock:
for source,target in [('content','answer'),('reasoning_content','reasoning')]:
if isinstance(delta.get(source),str):self.job[target]+=delta[source]
if choice.get('finish_reason'):self.job['finish_reason']=choice['finish_reason']
if done:break
if not done:raise InferenceError('Antwort unterbrochen oder Zeitlimit erreicht. '+(self.worker.status().get('error') or 'Ein GPU-OOM ist ohne eindeutigen Nachweis nicht bestätigt.'))
outcome='complete';message='Antwort fertig · Modell bleibt für weitere Anfragen geladen'
except Exception as exc:
cancelled=self.cancel.is_set()
message='Chat-Test abgebrochen.' if cancelled else (str(exc) if isinstance(exc,InferenceError) else 'Verbindung zum Modell unterbrochen. '+(self.worker.status().get('error') or 'Speicherfehler, Modellabsturz oder Zeitlimit möglich; Ursache nicht eindeutig.'))
outcome='cancelled' if cancelled else 'failed';error=None if cancelled else message
finally:
if conn:conn.close()
with self.lock:self.socket=None;self.job.update(state=outcome,error=error,phase=message,finished_at=time.time())
+2 -2
View File
@@ -12,8 +12,8 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \
&& /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock && /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 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 chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py /app/
COPY endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/ COPY chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
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 \
+1 -1
View File
@@ -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 = ['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'] FILES = ['chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','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):
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/studio.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><a href="#audio">Audio <span>04</span></a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtimes">Laufzeiten <span>↗</span></a><a class="nav-sub" href="#runtime">llama.cpp</a><a class="nav-sub" href="#image-runtime">Bildgenerierung</a><a class="nav-sub" href="#docker-runtime">Docker</a><a href="#endpoint">Endpunkt &amp; Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang &amp; API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html> <!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="/chat-test-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/studio.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><a href="#audio">Audio <span>04</span></a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtimes">Laufzeiten <span>↗</span></a><a class="nav-sub" href="#runtime">llama.cpp</a><a class="nav-sub" href="#image-runtime">Bildgenerierung</a><a class="nav-sub" href="#docker-runtime">Docker</a><a href="#endpoint">Endpunkt &amp; Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang &amp; API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
+35 -15
View File
@@ -72,7 +72,7 @@ class Scheduler:
class LlamaWorker: class LlamaWorker:
def __init__(self,root,catalog,runtime): def __init__(self,root,catalog,runtime):
self.root=Path(root);self.catalog=catalog;self.runtime=runtime;self.lock=threading.RLock() 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=[] 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=[];self.memory_plan=None;self.phase='Gestoppt';self.oom_before=0
def build(self): def build(self):
state=self.runtime.status() state=self.runtime.status()
build=next((b for b in state['builds'] if b['id']==state['active']),None) build=next((b for b in state['builds'] if b['id']==state['active']),None)
@@ -88,10 +88,17 @@ class LlamaWorker:
if not p.get('model') or not p['model']['file'].endswith('.gguf'):raise InferenceError('Ein lokales GGUF-Modell wird benötigt.') if not p.get('model') or not p['model']['file'].endswith('.gguf'):raise InferenceError('Ein lokales GGUF-Modell wird benötigt.')
return [] return []
except ValueError as exc:return [str(exc)] except ValueError as exc:return [str(exc)]
@staticmethod
def oom_count():
try:return int(dict(line.split() for line in Path('/sys/fs/cgroup/memory.events').read_text().splitlines()).get('oom_kill',0))
except (OSError,ValueError):return 0
def crash_message(self):
if self.oom_count()>self.oom_before:return 'OOM-Kill im Deck-RAM-Bereich erkannt. Das RAM-Limit wurde überschritten; Kontext oder Modellgröße reduzieren.'
return 'Modellprozess beendet. GPU-OOM oder Modell-/Buildfehler möglich; Ursache nicht eindeutig bestätigt. Kontext oder Microbatch reduzieren und erneut testen.'
def status(self): def status(self):
with self.lock: 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.' if self.process and self.process.poll() is not None and self.state=='ready':self.state='failed';self.error=self.crash_message()
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)) 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),memory_plan=self.memory_plan,phase=self.phase)
def stop(self): def stop(self):
with self.lock: with self.lock:
self.generation+=1 self.generation+=1
@@ -106,18 +113,18 @@ class LlamaWorker:
try:p.wait(timeout=10) try:p.wait(timeout=10)
except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait(timeout=5) except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait(timeout=5)
except ProcessLookupError:pass except ProcessLookupError:pass
self.process=None;self.profile=None;self.port=None;self.state='stopped';self.devices=[];self.key=None self.process=None;self.profile=None;self.port=None;self.state='stopped';self.phase='Gestoppt';self.devices=[];self.key=None
(self.root/'worker.key').unlink(missing_ok=True) (self.root/'worker.key').unlink(missing_ok=True)
def ensure(self,profile): def ensure(self,profile,cancel=lambda:False):
with self.lock: 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 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 self.stop();self.state='loading';self.error=None;self.memory_plan=None;self.phase='Speicherprüfung';self.oom_before=self.oom_count();generation=self.generation
try:self._start(profile,generation) try:self._start(profile,generation,cancel)
except Exception as exc: except Exception as exc:
self.stop() self.stop()
with self.lock:self.state='failed';self.error=str(exc) if isinstance(exc,ValueError) else 'llama.cpp konnte nicht gestartet werden.' with self.lock:self.state='failed';self.phase='Modellstart fehlgeschlagen';self.error=str(exc) if isinstance(exc,ValueError) else 'llama.cpp konnte nicht gestartet werden.'
raise InferenceError(self.error) from None raise InferenceError(self.error) from None
def _start(self,profile,generation): def _start(self,profile,generation,cancel=lambda:False):
directory=self.build();params=profile['parameters'];entry=self.catalog.entry(profile['model_id']) 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() model=(self.catalog.root/entry['id']/('model'+Path(entry['file']).suffix)).resolve()
devices=probe();ids=params['gpu_devices'] devices=probe();ids=params['gpu_devices']
@@ -141,7 +148,8 @@ class LlamaWorker:
tool=str(directory/'build/bin/llama-fit-params') tool=str(directory/'build/bin/llama-fit-params')
margins=[max(1024,round(g['total_mib']*.05)) for g in selected] margins=[max(1024,round(g['total_mib']*.05)) for g in selected]
def estimate(layers,extra=()): def estimate(layers,extra=()):
output=self._fit_command([tool]+common+['--gpu-layers',str(layers),'--fit-print','on']+list(extra),env,generation) if cancel():raise InferenceError('Modellstart abgebrochen.')
output=self._fit_command([tool]+common+['--gpu-layers',str(layers),'--fit-print','on']+list(extra),env,generation,cancel)
rows={} rows={}
for line in output.splitlines(): for line in output.splitlines():
parts=line.split() parts=line.split()
@@ -150,6 +158,7 @@ class LlamaWorker:
gpu_fits=all(rows['CUDA'+str(i)]+margins[i]<=g['free_mib'] for i,g in enumerate(selected)) 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) 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) host_fits=mem.get('MemAvailable',0)>=need+4*GIB and (headroom is None or headroom>=need)
with self.lock:self.memory_plan=dict(profile_name=profile['name'],estimated=True,gpu_layers=layers,context=params['context'],slots=params['slots'],fits=gpu_fits and host_fits,host_required_mib=round(need/1024**2),host_available_mib=round(min(mem.get('MemAvailable',0)-4*GIB,headroom if headroom is not None else mem.get('MemAvailable',0))/1024**2),gpus=[dict(name=g.get('name',g['uuid']),uuid=g['uuid'],free_mib=g['free_mib'],required_mib=rows['CUDA'+str(i)],reserve_mib=margins[i]) for i,g in enumerate(selected)])
return gpu_fits and host_fits,rows return gpu_fits and host_fits,rows
extra=[] extra=[]
if params['tensor_split']: if params['tensor_split']:
@@ -165,8 +174,9 @@ class LlamaWorker:
else:high=middle-1 else:high=middle-1
if best is None:raise InferenceError('Kontext und fester GPU-Split passen nicht sicher in GPU/RAM.') if best is None:raise InferenceError('Kontext und fester GPU-Split passen nicht sicher in GPU/RAM.')
layers,memory=best layers,memory=best
estimate(layers)
else: else:
output=self._fit_command([tool]+common,env,generation) output=self._fit_command([tool]+common,env,generation,cancel)
flags=shlex.split(output.strip());fit={} flags=shlex.split(output.strip());fit={}
if len(flags)%2:raise InferenceError('Fit-Werkzeug lieferte ungültige Parameter.') if len(flags)%2:raise InferenceError('Fit-Werkzeug lieferte ungültige Parameter.')
for i in range(0,len(flags),2): for i in range(0,len(flags),2):
@@ -180,6 +190,8 @@ class LlamaWorker:
extra=['--tensor-split',fit['-ts']] extra=['--tensor-split',fit['-ts']]
fits,memory=estimate(layers,extra) fits,memory=estimate(layers,extra)
if not fits:raise InferenceError('Speicherprognose überschreitet die GPU-/RAM-Reserve.') if not fits:raise InferenceError('Speicherprognose überschreitet die GPU-/RAM-Reserve.')
if cancel():raise InferenceError('Modellstart abgebrochen.')
with self.lock:self.phase='Modell wird geladen'
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'] 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) 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] with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
@@ -193,27 +205,35 @@ class LlamaWorker:
self.profile=profile;self.port=port;self.key=key;self.devices=[g['uuid'] for g in selected] self.profile=profile;self.port=port;self.key=key;self.devices=[g['uuid'] for g in selected]
deadline=time.monotonic()+300 deadline=time.monotonic()+300
while time.monotonic()<deadline: while time.monotonic()<deadline:
if cancel():raise InferenceError('Modellstart abgebrochen.')
with self.lock: 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.') if not self.process or self.process.poll() is not None:raise InferenceError(self.crash_message())
current={g['uuid']:g for g in probe()} 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.') 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) conn=http.client.HTTPConnection('127.0.0.1',port,timeout=2)
try: try:
conn.request('GET','/health',headers={'Authorization':'Bearer '+key});r=conn.getresponse() conn.request('GET','/health',headers={'Authorization':'Bearer '+key});r=conn.getresponse()
if r.status==200: if r.status==200:
with self.lock:self.state='ready' with self.lock:self.state='ready';self.phase='Modell bereit'
threading.Thread(target=self._monitor,args=(generation,),daemon=True).start() threading.Thread(target=self._monitor,args=(generation,),daemon=True).start()
return return
except OSError:pass except OSError:pass
finally:conn.close() finally:conn.close()
time.sleep(1) time.sleep(1)
raise InferenceError('llama.cpp wurde nicht rechtzeitig bereit.') raise InferenceError('llama.cpp wurde nicht rechtzeitig bereit.')
def _fit_command(self,args,env,generation): def _fit_command(self,args,env,generation,cancel=lambda:False):
with self.lock: with self.lock:
if self.generation!=generation:raise InferenceError('Modellstart abgebrochen.') if self.generation!=generation:raise InferenceError('Modellstart abgebrochen.')
process=subprocess.Popen(args,env=env,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True) process=subprocess.Popen(args,env=env,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True)
self.fit_process=process self.fit_process=process
try:output,_=process.communicate(timeout=180) try:
deadline=time.monotonic()+180
while True:
if cancel():
process.terminate();process.communicate(timeout=3);raise InferenceError('Modellstart abgebrochen.')
try:output,_=process.communicate(timeout=1);break
except subprocess.TimeoutExpired:
if time.monotonic()>=deadline:raise
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
process.kill();process.communicate();raise InferenceError('Zeitlimit der Speicher-Einpassung überschritten.') from None process.kill();process.communicate();raise InferenceError('Zeitlimit der Speicher-Einpassung überschritten.') from None
finally: finally:
+1 -1
View File
@@ -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 = {'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'} FILES = {'chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','image_runtime.py','image_encoder_node.py','docker_support.py','docker-ui.js','deploy/image-requirements.lock','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py', 'runtime-ui.js', '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)
+12 -1
View File
@@ -15,6 +15,7 @@ from image_test import ImageTests
from docker_support import DockerSupport from docker_support import DockerSupport
from inference import LlamaWorker,Scheduler from inference import LlamaWorker,Scheduler
from endpoint import Endpoint from endpoint import Endpoint
from chat_test import ChatTests
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
@@ -88,6 +89,7 @@ class Server(ThreadingHTTPServer):
if self.endpoint.config['autostart']: if self.endpoint.config['autostart']:
try:self.endpoint.start() try:self.endpoint.start()
except ValueError as exc:self.endpoint.error=str(exc) except ValueError as exc:self.endpoint.error=str(exc)
self.chat_tests=ChatTests(self.profiles,self.worker,self.scheduler)
self.sessions = {} self.sessions = {}
self.login_attempts = [] self.login_attempts = []
self.auth_lock = threading.Lock() self.auth_lock = threading.Lock()
@@ -239,7 +241,7 @@ 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 = {'/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')} routes = {'/chat-test-ui.js':('chat-test-ui.js','text/javascript'),'/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)
@@ -247,6 +249,7 @@ class Handler(BaseHTTPRequestHandler):
endpoint=self.server.endpoint.status() endpoint=self.server.endpoint.status()
public_endpoint={key:endpoint[key] for key in ('state','port','counts','active_requests')} 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)) 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/chat-tests':return self.respond(self.server.chat_tests.status())
if self.path == '/api/v1/endpoint':return self.respond(self.server.endpoint.status()) 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())
@@ -334,6 +337,13 @@ 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/chat-tests/start','/api/v1/chat-tests/cancel','/api/v1/chat-tests/unload'):
try:
data=self.read_json()
if self.path.endswith('/start'):return self.respond(self.server.chat_tests.start(data))
if data:raise ValueError('Keine Parameter erwartet.')
return self.respond(self.server.chat_tests.stop() if self.path.endswith('/cancel') else self.server.chat_tests.unload())
except ValueError as exc:return self.respond({'error':str(exc)},400)
if self.path in ('/api/v1/endpoint/start','/api/v1/endpoint/stop','/api/v1/endpoint/config','/api/v1/endpoint/profile'): if self.path in ('/api/v1/endpoint/start','/api/v1/endpoint/stop','/api/v1/endpoint/config','/api/v1/endpoint/profile'):
try: try:
data=self.read_json();ep=self.server.endpoint data=self.read_json();ep=self.server.endpoint
@@ -423,6 +433,7 @@ def main():
try: try:
server.serve_forever() server.serve_forever()
finally: finally:
server.chat_tests.stop()
server.endpoint.close() server.endpoint.close()
server.image_runtime.stop() server.image_runtime.stop()
server.image_tests.stop() server.image_tests.stop()
+3 -2
View File
@@ -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'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`; const body=['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'],...(['image','chat'].includes(page)?[['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'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`; document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
if(['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'&&page==='chat')ChatTestUI.bind();
else if(section==='test'||(section==='running'&&page==='image'))ImageTestUI.bind(section==='running');
else if(section==='running'&&page==='chat')EndpointUI.bindRunning(); 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');
+2
View File
@@ -18,3 +18,5 @@ nav a.nav-sub{padding-left:28px;font-size:12px;color:#a4bda8}.component-card{mar
#image-test textarea{width:100%;background:#101718;color:#e4eee5;border:1px solid #465348;border-radius:8px;padding:14px;font:inherit;resize:vertical;margin:10px 0 20px}.generated-image{max-width:100%;height:auto;display:block;border-radius:8px}#image-test-result figure{margin:20px 0}#image-test-state{margin-top:20px} #image-test textarea{width:100%;background:#101718;color:#e4eee5;border:1px solid #465348;border-radius:8px;padding:14px;font:inherit;resize:vertical;margin:10px 0 20px}.generated-image{max-width:100%;height:auto;display:block;border-radius:8px}#image-test-result figure{margin:20px 0}#image-test-state{margin-top:20px}
.hub-base-filter{display:flex;align-items:center;gap:.6rem;margin-top:1rem}.hub-base-filter input{width:auto;margin:0} .hub-base-filter{display:flex;align-items:center;gap:.6rem;margin-top:1rem}.hub-base-filter input{width:auto;margin:0}
.chat-text,#chat-transcript pre{white-space:pre-wrap;overflow-wrap:anywhere;}
+50
View File
@@ -0,0 +1,50 @@
import json
import threading
import time
import unittest
from types import SimpleNamespace
from unittest.mock import Mock
from chat_test import ChatTests
from inference import Scheduler,InferenceError,LlamaWorker
class Reply:
status=200
def __init__(self,events):self.events=iter(events)
def read1(self,_):return next(self.events,b'')
class Worker:
def __init__(self):self.error=None;self.loaded=False;self.conn=Mock();self.conn.getresponse.return_value=Reply([b'data: {"choices":[{"delta":{"content":"OK"}}]}\n\n',b'data: [DONE]\n\n'])
def stop(self):self.loaded=False
def ensure(self,p,cancel=lambda:False):
if self.error:raise InferenceError(self.error)
self.loaded=True
def status(self):return dict(state='ready' if self.loaded else 'stopped',error=self.error)
def connect(self):return self.conn,'synthetic-only'
class ChatTestsTests(unittest.TestCase):
def setUp(self):
self.profile=dict(id='p',name='test',revision=1,kind='chat',runnable=True,parameters=dict(slots=2,temperature=.2,top_p=.8,top_k=20))
self.worker=Worker();self.manager=ChatTests(SimpleNamespace(status=lambda:dict(profiles=[self.profile])),self.worker,Scheduler(self.worker))
def request(self):return dict(profile_id='p',messages=[dict(role='user',content='synthetic test')],max_tokens=8)
def finish(self):
deadline=time.monotonic()+3
while self.manager.status()['job']['state']=='running' and time.monotonic()<deadline:time.sleep(.01)
return self.manager.status()['job']
def test_answer_stream_and_no_prompt_in_status(self):
self.manager.start(self.request());job=self.finish();self.assertEqual(job['state'],'complete');self.assertEqual(job['answer'],'OK');self.assertNotIn('synthetic test',json.dumps(job));self.assertNotIn('messages',job)
self.assertTrue(self.worker.loaded);self.manager.unload();self.assertFalse(self.worker.loaded)
def test_memory_rejection_reaches_user_and_releases_lease(self):
self.worker.error='GPU-Split passt nicht in den Speicher.';self.manager.start(self.request());job=self.finish();self.assertEqual(job['state'],'failed');self.assertIn('Speicher',job['error']);self.assertEqual(self.manager.scheduler.active,0)
def test_queue_cancel_does_not_stop_other_request(self):
with self.manager.scheduler.lease(('other',)):
self.worker.loaded=True;self.manager.start(self.request());self.manager.stop();job=self.finish();self.assertEqual(job['state'],'cancelled');self.assertTrue(self.worker.loaded)
with self.assertRaises(ValueError):self.manager.unload()
def test_truncated_stream_is_not_success(self):
self.worker.conn.getresponse.return_value=Reply([b'data: {"choices":[]}\n\n']);self.manager.start(self.request());self.assertEqual(self.finish()['state'],'failed')
def test_validation(self):
for data in [dict(self.request(),max_tokens=True),dict(self.request(),messages=[dict(role='user',content='')]),dict(self.request(),profile_id='missing')]:
with self.assertRaises(ValueError):self.manager.start(data)
def test_oom_claim_requires_counter_evidence(self):
worker=object.__new__(LlamaWorker);worker.oom_before=4;worker.oom_count=lambda:4;self.assertIn('nicht eindeutig',worker.crash_message());worker.oom_count=lambda:5;self.assertIn('OOM-Kill im Deck-RAM',worker.crash_message())
if __name__=='__main__':unittest.main()
+12
View File
@@ -92,6 +92,18 @@ class Tests(unittest.TestCase):
with urllib.request.urlopen(req) as r:self.assertEqual(json.load(r)['port'],8120) with urllib.request.urlopen(req) as r:self.assertEqual(json.load(r)['port'],8120)
configure.assert_called_once_with({'port':8120}) configure.assert_called_once_with({'port':8120})
def test_chat_test_routes_are_admin_only(self):
self.assertIsNone(self.request('chat-tests')['job'])
for path,method,body in [('chat-tests','GET',None),('chat-tests/start','POST',b'{}')]:
req=urllib.request.Request(self.url+'/api/v1/'+path,method=method,data=body,headers={'Authorization':'Bearer '+self.api_token,'X-Athena-Deck':'1','Content-Type':'application/json'})
with self.assertRaises(urllib.error.HTTPError) as exc:urllib.request.urlopen(req)
self.assertEqual(exc.exception.code,401);exc.exception.close()
with patch.object(self.server.chat_tests,'start',return_value={'state':'running'}) as start:
data={'profile_id':'test','messages':[{'role':'user','content':'synthetic'}],'max_tokens':8}
req=urllib.request.Request(self.url+'/api/v1/chat-tests/start',data=json.dumps(data).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'})
with urllib.request.urlopen(req) as r:self.assertEqual(json.load(r)['state'],'running')
start.assert_called_once_with(data)
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)