Expose model capability hints and configurable vision projector placement
This commit is contained in:
1 parent
a36a151c94
commit
06c6292cf3
10 files changed
+112
-21
No files matched your search
@@ -177,3 +177,11 @@ Admin-API: GET /api/v1/auto-tests; POST /start mit model_id, max_context, mtp; P
|
||||
### Layer-Feinsuche
|
||||
|
||||
Nach der ersten erfolgreichen Dual-GPU-Stufe erhöht Deck den geplanten Anteil der 5080 jeweils um eine Schicht bis vor die zuvor abgelehnte Verteilung. Die Anzahl wird aus GGUF block_count/nextn_predict_layers einschließlich Ausgabeschicht berechnet; Tensor-Split wird zwischen Rundungsgrenzen gesetzt. Jede Stufe prüft erneut Speicher und Microbatch128/64/256. Wenn alle drei scheitern, endet die Feinsuche. Erfolgreiche Ergebnisse bleiben einzeln speicherbar; mehr 5080-Layer bedeutet nicht zwingend mehr Geschwindigkeit. Die angezeigte Layerzahl ist eine aus Metadaten und llama.cpp-Layer-Split-Regel berechnete Zuordnung, keine Log-Auswertung. Fehlende/ungültige GGUF-Metadaten überspringen die Feinsuche mit sichtbarem Hinweis. Laufende Testreihen werden nicht nachträglich verändert.
|
||||
|
||||
## Vision und Tool-Calling
|
||||
|
||||
Entdecken zeigt positive Hub-Tags als Hinweise, Projektordateien separat und ansonsten „Unbekannt“. Dies ist kein Kompatibilitätsnachweis; kein Rückschluss aus Modellnamen. Tool-Calling wird bereits an llama.cpp weitergereicht; Fähigkeiten hängen von Modell und Chat-Template ab.
|
||||
|
||||
Passende mmproj-GGUF über Entdecken herunterladen, anschließend im Chat-Profil auswählen. „Deaktiviert“ schaltet Vision aus. Projektorziel CPU oder eine ausgewählte GPU; für ein GPU-Ziel muss diese Karte auch in der GPU-Auswahl enthalten sein. Die Reihenfolge legt CUDA0/CUDA1 fest, die GUI benennt die tatsächlichen Karten. Modell und Projektor müssen zusammenpassen; der Dateiname reicht als Nachweis nicht.
|
||||
|
||||
Zusätzliche konservative Speicherplanung: Projektordateigröße plus 2 GiB Arbeitsreserve auf dem Zielgerät, zusätzlich im Lade-RAM. Das ist keine exakte Vision-Peak-Prognose. Die bisherigen Auto-Tests bleiben reine Texttests ohne Projektor. Der interne Chat-Test ist weiterhin Text; Vision ist über /v1/chat/completions mit einem eingebetteten PNG/JPEG/WebP-Bild im image_url-Feld möglich. Externe URLs sind gesperrt, die gesamte JSON-Anfrage bleibt auf 1 MiB beschränkt.
|
||||
+2
-2
@@ -22,7 +22,7 @@ window.CatalogUI = (() => {
|
||||
function renderModels() {
|
||||
const sorted = [...models].sort(el('hub-sort').value === 'name' ? (a,b) => a.repo.localeCompare(b.repo) : el('hub-sort').value === 'newest' ? (a,b) => (Date.parse(b.created_at)||0)-(Date.parse(a.created_at)||0) : (a,b) => (b.downloads||0)-(a.downloads||0));
|
||||
el('hub-count').textContent = `${models.length} Ergebnisse · höchstens 20 pro Suche`;
|
||||
el('hub-results').innerHTML = sorted.map(m => `<button type="button" class="hub-model ${selected === m.repo ? 'selected' : ''}" data-repo="${escape(m.repo)}" aria-pressed="${selected === m.repo}"><span class="hub-author">${escape(m.repo.split('/')[0])}</span><strong>${escape(m.repo.split('/').slice(1).join('/'))}</strong><span class="hub-model-meta">↓ ${number(m.downloads)} Downloads · ${m.gated ? 'Zugang beschränkt' : 'Öffentlich'}</span><span class="hub-model-meta" data-capacity="${escape(m.repo)}">${escape(m.capacity ? `${size(m.capacity.min_bytes)} – ${size(m.capacity.max_bytes)} Hauptgewichte · kleinste Variante: ${fitText(m.capacity)}` : m.capacityError || 'Größe und Speicher werden ermittelt …')}</span>${state.entries.some(x => x.repo === m.repo) ? '<span class="tag">Dateien in Bibliothek</span>' : ''}</button>`).join('') || '<div class="empty"><h3>Keine Treffer</h3><p>Versuche einen anderen Namen. Die Suche ist auf den aktuellen Bereich begrenzt.</p></div>';
|
||||
el('hub-results').innerHTML = sorted.map(m => `<button type="button" class="hub-model ${selected === m.repo ? 'selected' : ''}" data-repo="${escape(m.repo)}" aria-pressed="${selected === m.repo}"><span class="hub-author">${escape(m.repo.split('/')[0])}</span><strong>${escape(m.repo.split('/').slice(1).join('/'))}</strong>${kind==='chat'?`<span class="hub-model-meta">Vision: ${escape(m.capabilities?.vision||'Unbekannt')} · Tools: ${escape(m.capabilities?.tools||'Unbekannt')}</span>`:''}<span class="hub-model-meta">↓ ${number(m.downloads)} Downloads · ${m.gated ? 'Zugang beschränkt' : 'Öffentlich'}</span><span class="hub-model-meta" data-capacity="${escape(m.repo)}">${escape(m.capacity ? `${size(m.capacity.min_bytes)} – ${size(m.capacity.max_bytes)} Hauptgewichte · kleinste Variante: ${fitText(m.capacity)}` : m.capacityError || 'Größe und Speicher werden ermittelt …')}</span>${state.entries.some(x => x.repo === m.repo) ? '<span class="tag">Dateien in Bibliothek</span>' : ''}</button>`).join('') || '<div class="empty"><h3>Keine Treffer</h3><p>Versuche einen anderen Namen. Die Suche ist auf den aktuellen Bereich begrenzt.</p></div>';
|
||||
root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo));
|
||||
}
|
||||
function renderLibrary() {
|
||||
@@ -77,7 +77,7 @@ window.CatalogUI = (() => {
|
||||
el('hub-files').innerHTML = '<p role="status">Modelldetails werden geladen …</p>';
|
||||
try {
|
||||
const result = await api('/files?'+new URLSearchParams({repo})); if (!root.isConnected || sequence !== detailSequence) return; detail = result;
|
||||
el('hub-files').innerHTML = `<span class="kicker">MODELLDETAILS</span><h2 class="hub-filename">${escape(repo)}</h2><div class="hub-badges"><span class="pill">${detail.gated ? 'Zugang beschränkt' : 'Öffentlich'}</span><span class="pill">${detail.files.length} unterstützte Dateien</span></div><p>Lizenz: ${escape(detail.license || 'Nicht angegeben')} · <a class="link" href="${escape(detail.url)}" target="_blank" rel="noopener noreferrer">Modellkarte lesen ↗</a></p><div class="hub-fit" id="hub-fit-result">Wähle eine Datei für die Speicherprüfung.</div>${detail.gated ? '<p class="warning">Dieses Repository benötigt eine Freigabe. Downloads mit Hugging-Face-Zugangsdaten werden noch nicht unterstützt.</p>' : ''}<h3>Variante oder Datei auswählen</h3><div class="hub-file-controls"><label>Dateiname filtern<input id="hub-file-filter" placeholder="z. B. Q4_K_M"></label><label>Format<select id="hub-format"><option value="">Alle Formate</option><option value=".gguf">GGUF</option><option value=".safetensors">Safetensors</option><option value=".json">JSON / Konfiguration</option></select></label></div><div id="hub-variants" class="hub-variants"></div><div class="hub-review"><p id="hub-selection" class="hub-filename"></p><button id="hub-download" disabled>Ausgewählte Datei herunterladen</button><p class="small">Einzeldatei-Download auf Athena. Geteilte Modelle und Bild-/Audio-/Video-Pipelines benötigen weitere Dateien; dies installiert noch keinen vollständigen Worker.</p><details><summary>Feste Quellversion</summary><p class="hub-filename">${escape(detail.revision)}</p></details></div>`;
|
||||
el('hub-files').innerHTML = `<span class="kicker">MODELLDETAILS</span><h2 class="hub-filename">${escape(repo)}</h2><div class="hub-badges"><span class="pill">${detail.gated ? 'Zugang beschränkt' : 'Öffentlich'}</span><span class="pill">${detail.files.length} unterstützte Dateien</span></div><p>Vision: ${escape(detail.capabilities?.vision||'Unbekannt')} · Tool-Calling: ${escape(detail.capabilities?.tools||'Unbekannt')}. Hub-Hinweise, keine Laufzeitgarantie; fehlende Angaben bedeuten nicht „nein“.</p><p>Lizenz: ${escape(detail.license || 'Nicht angegeben')} · <a class="link" href="${escape(detail.url)}" target="_blank" rel="noopener noreferrer">Modellkarte lesen ↗</a></p><div class="hub-fit" id="hub-fit-result">Wähle eine Datei für die Speicherprüfung.</div>${detail.gated ? '<p class="warning">Dieses Repository benötigt eine Freigabe. Downloads mit Hugging-Face-Zugangsdaten werden noch nicht unterstützt.</p>' : ''}<h3>Variante oder Datei auswählen</h3><div class="hub-file-controls"><label>Dateiname filtern<input id="hub-file-filter" placeholder="z. B. Q4_K_M"></label><label>Format<select id="hub-format"><option value="">Alle Formate</option><option value=".gguf">GGUF</option><option value=".safetensors">Safetensors</option><option value=".json">JSON / Konfiguration</option></select></label></div><div id="hub-variants" class="hub-variants"></div><div class="hub-review"><p id="hub-selection" class="hub-filename"></p><button id="hub-download" disabled>Ausgewählte Datei herunterladen</button><p class="small">Einzeldatei-Download auf Athena. Geteilte Modelle und Bild-/Audio-/Video-Pipelines benötigen weitere Dateien; dies installiert noch keinen vollständigen Worker.</p><details><summary>Feste Quellversion</summary><p class="hub-filename">${escape(detail.revision)}</p></details></div>`;
|
||||
el('hub-file-filter').oninput = renderFiles; el('hub-format').onchange = renderFiles; renderFiles();
|
||||
el('hub-download').onclick = async () => {
|
||||
const chosen = el('hub-variants').querySelector('input:checked'); if (!chosen || starting) return;
|
||||
|
||||
+15
-6
@@ -39,11 +39,13 @@ def repo_id(value):
|
||||
def file_role(filename):
|
||||
"""One classification shared by library UI and profile validation."""
|
||||
name=filename.lower();parts=PurePosixPath(name).parts
|
||||
if any(p in ('text_encoder','text_encoders') for p in parts) or re.search(r'(?:^|[/_-])(?:text.encoder|qwen3vl|t5xxl|clip_l|clip_g)(?:[/_.-]|$)',name):
|
||||
if re.search(r'(?:^|[/_-])mmproj(?:[/_.-]|$)',name):
|
||||
role,label='vision_projector','Vision-Projektor'
|
||||
elif any(p in ('text_encoder','text_encoders') for p in parts) or re.search(r'(?:^|[/_-])(?:text.encoder|qwen3vl|t5xxl|clip_l|clip_g)(?:[/_.-]|$)',name):
|
||||
role,label='text_encoder','Textencoder'
|
||||
elif 'vae' in parts or re.search(r'(?:^|[/_-])vae(?:[/_.-]|$)',name):
|
||||
role,label='vae','VAE'
|
||||
elif re.search(r'(?:^|[/_-])(?:mmproj|lora|adapter)(?:[/_.-]|$)',name):
|
||||
elif re.search(r'(?:^|[/_-])(?:lora|adapter)(?:[/_.-]|$)',name):
|
||||
role,label='auxiliary','Zusatzkomponente'
|
||||
elif name.endswith(('.gguf','.safetensors')):
|
||||
role,label='model','Modelldatei'
|
||||
@@ -58,6 +60,13 @@ def is_derived_model(model):
|
||||
tags=model.get('tags') or []
|
||||
return any(isinstance(tag,str) and (tag.startswith('base_model:') or tag.lower() in {'lora','peft','adapter','merge','mergekit','finetune','fine-tuned','quantized','gguf','gptq','awq'}) for tag in tags)
|
||||
|
||||
def capabilities(model):
|
||||
tags={t.lower() for t in (model.get('tags') or []) if isinstance(t,str)}
|
||||
vision=bool(tags & {'image-text-to-text','image-to-text','vision','multimodal'}) or model.get('pipeline_tag') in ('image-text-to-text','image-to-text')
|
||||
files=any(file_role(x.get('rfilename',''))['role']=='vision_projector' for x in (model.get('siblings') or []))
|
||||
tools=bool(tags & {'tool-use','tool-calling','function-calling','function_calling'})
|
||||
return dict(vision='Hinweis laut Hub' if vision else 'Projektordatei vorhanden' if files else 'Unbekannt',tools='Hinweis laut Hub' if tools else 'Unbekannt')
|
||||
|
||||
class Catalog:
|
||||
def __init__(self,root):
|
||||
self.root=Path(root);self.lock=threading.RLock();self.job=None;self.cancel=threading.Event()
|
||||
@@ -105,11 +114,11 @@ class Catalog:
|
||||
elif '/' in q:direct=repo_id(q)
|
||||
if direct:
|
||||
item=metadata('/api/models/'+direct)
|
||||
return dict(models=[dict(repo=item['id'],downloads=item.get('downloads'),gated=item.get('gated',False),created_at=item.get('createdAt'))],base_only=base_only,scanned=1,notice='Direkt ausgewähltes Repository: Kategorie- und Basismodellfilter werden für diesen Treffer nicht angewendet.')
|
||||
return dict(models=[dict(repo=item['id'],downloads=item.get('downloads'),gated=item.get('gated',False),created_at=item.get('createdAt'),capabilities=capabilities(item))],base_only=base_only,scanned=1,notice='Direkt ausgewähltes Repository: Kategorie- und Basismodellfilter werden für diesen Treffer nicht angewendet.')
|
||||
if q.lower().endswith('.gguf'):
|
||||
q=q[:-5];notice='GGUF-Dateiname als Repository-Suchbegriff verwendet. Die Datei wählst du anschließend in den Modelldetails.'
|
||||
query=dict(search=q,filter=KINDS[kind],limit=100 if base_only else 20,sort='createdAt' if sort=='newest' else 'downloads',direction=-1)
|
||||
if base_only:query['expand']=['downloads','createdAt','gated','cardData','tags']
|
||||
query['expand']=['downloads','createdAt','gated','cardData','tags','pipeline_tag']
|
||||
rows=metadata('/api/models?'+urllib.parse.urlencode(query,doseq=True))
|
||||
if not rows and q and kind=='chat':
|
||||
fallback=dict(query,filter='gguf',expand=['downloads','createdAt','gated','cardData','tags','pipeline_tag'])
|
||||
@@ -118,7 +127,7 @@ class Catalog:
|
||||
if rows:notice='GGUF-Sprachmodelle ohne reguläres Kategorie-Tag gefunden. Bei aktiver Basismodellauswahl bleiben Quantisierungen ausgeblendet.'
|
||||
scanned=len(rows)
|
||||
if base_only:rows=[x for x in rows if not is_derived_model(x)]
|
||||
return {'models':[dict(repo=x['id'],downloads=x.get('downloads'),gated=x.get('gated',False),created_at=x.get('createdAt')) for x in rows[:20]],'base_only':base_only,'scanned':scanned,'notice':notice}
|
||||
return {'models':[dict(repo=x['id'],downloads=x.get('downloads'),gated=x.get('gated',False),created_at=x.get('createdAt'),capabilities=capabilities(x)) for x in rows[:20]],'base_only':base_only,'scanned':scanned,'notice':notice}
|
||||
def files(self,repo):
|
||||
repo=repo_id(repo)
|
||||
with self.cache_lock:
|
||||
@@ -134,7 +143,7 @@ class Catalog:
|
||||
size=f.get('size',f.get('lfs',{}).get('size'))
|
||||
if not isinstance(size,int) or size<1:continue
|
||||
files.append(dict(name=name,size=size,sha256=f.get('lfs',{}).get('sha256')))
|
||||
result=dict(repo=repo,revision=revision,files=files,gated=data.get('gated',False),license=(data.get('cardData') or {}).get('license'),url='https://huggingface.co/'+repo)
|
||||
result=dict(capabilities=capabilities(data),repo=repo,revision=revision,files=files,gated=data.get('gated',False),license=(data.get('cardData') or {}).get('license'),url='https://huggingface.co/'+repo)
|
||||
with self.cache_lock:
|
||||
if len(self.cache)>=64:self.cache.pop(next(iter(self.cache)))
|
||||
self.cache[repo]=(time.monotonic(),result)
|
||||
|
||||
+16
-2
@@ -179,10 +179,24 @@ class APIHandler(BaseHTTPRequestHandler):
|
||||
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.')
|
||||
profile=ep.find_profile(data.get('model'),'chat')
|
||||
images=0
|
||||
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')
|
||||
if isinstance(content,list):
|
||||
for part in content:
|
||||
if not isinstance(part,dict):raise APIError('Ungültiger Nachrichteninhalt.')
|
||||
if part.get('type')=='text':continue
|
||||
if part.get('type')!='image_url' or not profile['parameters'].get('vision_projector'):raise APIError('Für Bildeingaben einen Vision-Projektor im Profil aktivieren.')
|
||||
value=part.get('image_url')
|
||||
url=value.get('url') if isinstance(value,dict) else None
|
||||
if not isinstance(url,str) or not url.startswith(('data:image/png;base64,','data:image/jpeg;base64,','data:image/webp;base64,')):raise APIError('Nur eingebettete PNG/JPEG/WebP-Bilder; keine externen URLs.')
|
||||
import base64,binascii
|
||||
try:raw=base64.b64decode(url.split(',',1)[1],validate=True)
|
||||
except (ValueError,binascii.Error):raise APIError('Ungültige Bildkodierung.') from None
|
||||
if not raw:raise APIError('Leeres Bild.')
|
||||
images+=1
|
||||
if images>1:raise APIError('Zunächst ein Bild pro Anfrage unterstützt.')
|
||||
# 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())
|
||||
|
||||
+18
-2
@@ -85,6 +85,9 @@ class LlamaWorker:
|
||||
def blockers(self,p):
|
||||
try:
|
||||
self.build()
|
||||
if p['parameters'].get('vision_projector'):
|
||||
projector=self.catalog.entry(p['parameters']['vision_projector'])
|
||||
if projector.get('role')!='vision_projector':raise InferenceError('Vision-Projektor fehlt oder ist ungültig.')
|
||||
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)]
|
||||
@@ -141,7 +144,17 @@ class LlamaWorker:
|
||||
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
|
||||
projector=None;vision_args=[];vision_bytes=0;vision_device=params.get('vision_device','cpu')
|
||||
if params.get('vision_projector'):
|
||||
projector=self.catalog.entry(params['vision_projector'])
|
||||
if projector.get('role')!='vision_projector':raise InferenceError('Kein gültiger Vision-Projektor.')
|
||||
vision_bytes=projector['size']+2*GIB
|
||||
vision_args=['--mmproj',str((self.catalog.root/projector['id']/'model.gguf').resolve())]
|
||||
if vision_device=='cpu':vision_args+=['--no-mmproj-offload']
|
||||
else:
|
||||
if vision_device not in [g['uuid'] for g in selected]:raise InferenceError('Projektor-GPU ist nicht ausgewählt.')
|
||||
vision_args+=['--mmproj-device','CUDA'+str([g['uuid'] for g in selected].index(vision_device)),'--mmproj-offload']
|
||||
staging=entry['size']+4*GIB+vision_bytes
|
||||
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.')
|
||||
@@ -161,6 +174,9 @@ class LlamaWorker:
|
||||
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.')
|
||||
if projector:
|
||||
target='Host' if vision_device=='cpu' else 'CUDA'+str([g['uuid'] for g in selected].index(vision_device))
|
||||
rows[target]+=round(vision_bytes/1024**2)
|
||||
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)
|
||||
@@ -202,7 +218,7 @@ class LlamaWorker:
|
||||
if cancel():raise InferenceError('Modellstart abgebrochen.')
|
||||
if plan_only:return
|
||||
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+vision_args+['--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']
|
||||
if mtp:launch+=['--spec-type','draft-mtp','--spec-draft-n-max',str(params.get('mtp_tokens',2)),'--spec-draft-p-min',str(params.get('mtp_min_p',.05)),'--spec-draft-type-k','f16','--spec-draft-type-v','f16']
|
||||
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]
|
||||
|
||||
+5
-5
@@ -1,12 +1,12 @@
|
||||
window.ProfilesUI=(()=>{
|
||||
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const labels={gpu_offload:'GPU-Ausführung',mtp:'MTP',mtp_tokens:'MTP: maximale Draft-Token',mtp_min_p:'MTP: Mindestwahrscheinlichkeit',temperature:'Temperature',top_p:'Top-p',top_k:'Top-k',gpu_devices:'GPUs (in Reihenfolge)',split_mode:'GPU-Split',tensor_split:'GPU-Verteilung',context:'Gesamtes Kontextbudget (Token)',slots:'Parallele Slots',threads:'CPU-Threads',batch:'Batch-Größe',ubatch:'Microbatch-Größe',width:'Breite (Pixel)',height:'Höhe (Pixel)',steps:'Schritte',seed:'Seed (−1 = zufällig)',guidance:'Guidance / CFG',speed:'Sprechgeschwindigkeit',frames:'Bildanzahl',fps:'Bilder pro Sekunde'};
|
||||
const labels={vision_projector:'Vision-Projektor',vision_device:'Projektor-Gerät',gpu_offload:'GPU-Ausführung',mtp:'MTP',mtp_tokens:'MTP: maximale Draft-Token',mtp_min_p:'MTP: Mindestwahrscheinlichkeit',temperature:'Temperature',top_p:'Top-p',top_k:'Top-k',gpu_devices:'GPUs (in Reihenfolge)',split_mode:'GPU-Split',tensor_split:'GPU-Verteilung',context:'Gesamtes Kontextbudget (Token)',slots:'Parallele Slots',threads:'CPU-Threads',batch:'Batch-Größe',ubatch:'Microbatch-Größe',width:'Breite (Pixel)',height:'Höhe (Pixel)',steps:'Schritte',seed:'Seed (−1 = zufällig)',guidance:'Guidance / CFG',speed:'Sprechgeschwindigkeit',frames:'Bildanzahl',fps:'Bilder pro Sekunde'};
|
||||
const html=()=>'<section id="live-profiles"><div class="section-heading"><div><h2>Deine Profile</h2><p>Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.</p></div><button id="profile-new">Neues Profil</button></div><p id="profile-message" role="status"></p><div id="profile-list" class="profile-list"></div><div id="profile-editor"></div></section>';
|
||||
async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;}
|
||||
function bind(kind,modelId){
|
||||
const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},enabled=[],gpus=[],panelSequence=0;
|
||||
const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},enabled=[],gpus=[],projectors=[],panelSequence=0;
|
||||
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
|
||||
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;
|
||||
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')]);projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length;
|
||||
el('profile-list').innerHTML=rows.map(p=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p><details><summary>${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label><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').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`);
|
||||
root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message('Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}});
|
||||
@@ -35,11 +35,11 @@ window.ProfilesUI=(()=>{
|
||||
function editor(p=null,id=null){
|
||||
panelSequence++;
|
||||
if(!models.length)return;const data=p||{id:null,revision:0,name:'',model_id:id||models[0].id,parameters:Object.fromEntries(Object.entries(schema).map(([k,v])=>[k,v[2]]))};
|
||||
el('profile-editor').innerHTML=`<section class="card editor"><h2>${data.id?'Profil bearbeiten':'Profil anlegen'}</h2><form id="profile-form"><div class="form-grid"><label>API-Profilname<input name="name" required minlength="2" maxlength="64" pattern="[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}" placeholder="z. B. qwen-image-test" value="${e(data.name)}"></label><label>Heruntergeladene Modelldatei<select name="model_id">${models.map(m=>`<option value="${m.id}" ${m.id===data.model_id?'selected':''}>${e(m.file)} · ${(m.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${Object.entries(schema).map(([k,[min,max,defaultValue]])=>`<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="${['guidance','speed','temperature','top_p','mtp_min_p'].includes(k)?'any':['width','height'].includes(k)?64:1}" value="${e(data.parameters[k]??defaultValue)}"></label>`).join('')}</div>${kind==='chat'?gpuEditor(data.parameters)+`<label><input name="mtp" type="checkbox" ${data.parameters.mtp?'checked':''}> MTP aktivieren (eingebautes Draft-Modul)</label><p class="small">Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.</p>`:''}<p class="note">Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}</p><button>Profil auf Athena speichern</button><button class="secondary" type="button" id="profile-close">Schließen</button><p id="profile-error" role="alert"></p></form></section>`;
|
||||
el('profile-editor').innerHTML=`<section class="card editor"><h2>${data.id?'Profil bearbeiten':'Profil anlegen'}</h2><form id="profile-form"><div class="form-grid"><label>API-Profilname<input name="name" required minlength="2" maxlength="64" pattern="[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}" placeholder="z. B. qwen-image-test" value="${e(data.name)}"></label><label>Heruntergeladene Modelldatei<select name="model_id">${models.map(m=>`<option value="${m.id}" ${m.id===data.model_id?'selected':''}>${e(m.file)} · ${(m.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${Object.entries(schema).map(([k,[min,max,defaultValue]])=>`<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="${['guidance','speed','temperature','top_p','mtp_min_p'].includes(k)?'any':['width','height'].includes(k)?64:1}" value="${e(data.parameters[k]??defaultValue)}"></label>`).join('')}</div>${kind==='chat'?gpuEditor(data.parameters)+`<h3>Vision-Projektor</h3><label>Projektor<select name="vision_projector"><option value="">Deaktiviert · nur Text</option>${projectors.map(x=>`<option value="${e(x.id)}" ${data.parameters.vision_projector===x.id?'selected':''}>${e(x.repo)} / ${e(x.file)}</option>`).join('')}</select></label><label>Projektor ausführen auf<select name="vision_device"><option value="cpu">CPU</option>${gpus.map(g=>`<option value="${e(g.uuid)}" ${data.parameters.vision_device===g.uuid?'selected':''}>${e(g.name)}</option>`).join('')}</select></label><p>Passende mmproj.gguf unter Entdecken herunterladen. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. GPU-Ziel muss oben ausgewählt sein. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.</p><label><input name="mtp" type="checkbox" ${data.parameters.mtp?'checked':''}> MTP aktivieren (eingebautes Draft-Modul)</label><p class="small">Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.</p>`:''}<p class="note">Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}</p><button>Profil auf Athena speichern</button><button class="secondary" type="button" id="profile-close">Schließen</button><p id="profile-error" role="alert"></p></form></section>`;
|
||||
if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='<summary>Erweitert: CPU-Threads</summary><p>CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.</p>';advanced.append(threads);el('profile-form').insertBefore(advanced,el('profile-form').querySelector('.note'));}
|
||||
el('profile-close').onclick=()=>el('profile-editor').replaceChildren();
|
||||
el('profile-form').onsubmit=async event=>{event.preventDefault();const form=event.target,values=new FormData(form),button=form.querySelector('button');button.disabled=true;
|
||||
try{const parameters=Object.fromEntries(Object.keys(schema).map(k=>[k,Number(values.get(k))]));if(kind==='chat'){parameters.mtp=values.has('mtp');parameters.gpu_offload=values.get('gpu_offload');if(values.get('gpu_second')&&!values.get('gpu_first'))throw Error('Bitte zuerst die erste GPU auswählen.');if((data.parameters.gpu_devices||[]).length>2)throw Error('Dieses Profil enthält mehr als zwei GPUs; der Editor unterstützt derzeit zwei.');parameters.gpu_devices=[values.get('gpu_first'),values.get('gpu_second')].filter(Boolean);parameters.split_mode=values.get('split_mode');const raw=String(values.get('tensor_split')).trim();parameters.tensor_split=raw?raw.split(',').map(x=>x.trim()?Number(x):NaN):[];}await api('profiles/save',{id:data.id,revision:data.revision,name:values.get('name'),kind,model_id:values.get('model_id'),parameters});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Profil auf Athena gespeichert. Kein Modell gestartet.');}
|
||||
try{const parameters=Object.fromEntries(Object.keys(schema).map(k=>[k,Number(values.get(k))]));if(kind==='chat'){parameters.vision_projector=values.get('vision_projector')||null;parameters.vision_device=values.get('vision_device');parameters.mtp=values.has('mtp');parameters.gpu_offload=values.get('gpu_offload');if(values.get('gpu_second')&&!values.get('gpu_first'))throw Error('Bitte zuerst die erste GPU auswählen.');if((data.parameters.gpu_devices||[]).length>2)throw Error('Dieses Profil enthält mehr als zwei GPUs; der Editor unterstützt derzeit zwei.');parameters.gpu_devices=[values.get('gpu_first'),values.get('gpu_second')].filter(Boolean);parameters.split_mode=values.get('split_mode');const raw=String(values.get('tensor_split')).trim();parameters.tensor_split=raw?raw.split(',').map(x=>x.trim()?Number(x):NaN):[];}await api('profiles/save',{id:data.id,revision:data.revision,name:values.get('name'),kind,model_id:values.get('model_id'),parameters});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Profil auf Athena gespeichert. Kein Modell gestartet.');}
|
||||
catch(error){if(root.isConnected)el('profile-error').textContent=error.message;}finally{button.disabled=false;}
|
||||
};
|
||||
el('profile-editor').scrollIntoView({behavior:'smooth',block:'start'});
|
||||
|
||||
+8
-2
@@ -12,13 +12,15 @@ SCHEMAS={
|
||||
'audio':{'speed':(.25,4,1)},
|
||||
'video':{'width':(256,1920,768),'height':(256,1088,512),'frames':(1,241,33),'fps':(1,60,24),'steps':(1,100,20),'seed':(-1,2147483647,-1)}
|
||||
}
|
||||
CHAT_GPU_DEFAULTS={'gpu_devices':[], 'split_mode':'none', 'tensor_split':[], 'mtp':False,'gpu_offload':'auto'}
|
||||
CHAT_GPU_DEFAULTS={'gpu_devices':[], 'split_mode':'none', 'tensor_split':[], 'mtp':False,'gpu_offload':'auto','vision_projector':None,'vision_device':'cpu'}
|
||||
CHAT_SAMPLING={'temperature':.8,'top_p':.95,'top_k':40,'mtp_tokens':2,'mtp_min_p':.05}
|
||||
|
||||
def chat_parameters(params):
|
||||
params={**CHAT_GPU_DEFAULTS,**CHAT_SAMPLING,**params}
|
||||
if params['gpu_offload'] not in ('auto','full'):raise ValueError('Ungültiger GPU-Auslagerungsmodus.')
|
||||
if type(params['mtp']) is not bool:raise ValueError('MTP muss an oder aus sein.')
|
||||
if params['vision_projector'] is not None and (not isinstance(params['vision_projector'],str) or not re.fullmatch(r'[a-f0-9]{64}',params['vision_projector'])):raise ValueError('Ungültige Projektor-ID.')
|
||||
if params['vision_device']!='cpu' and params['vision_device'] not in params['gpu_devices']:raise ValueError('Projektor-GPU muss in der GPU-Auswahl enthalten sein.')
|
||||
devices=params['gpu_devices'];split=params['tensor_split']
|
||||
if not isinstance(devices,list) or len(devices)>16 or any(not isinstance(v,str) or not re.fullmatch(r'GPU-[0-9a-fA-F-]{36}',v) for v in devices) or len(set(devices))!=len(devices):raise ValueError('GPUs müssen als eindeutige, geordnete GPU-UUIDs angegeben werden.')
|
||||
if params['split_mode'] not in ('none','layer','row'):raise ValueError('Ungültiger GPU-Split-Modus.')
|
||||
@@ -66,7 +68,11 @@ class Profiles:
|
||||
model=self.catalog.entry(data['model_id'])
|
||||
if model['repo']==QWEN_REPO and any(model['file'] in r['files'] for r in QWEN_COMPONENTS.values()):raise ValueError('Textencoder und VAE werden über Komponenten zugeordnet, nicht als Hauptmodell.')
|
||||
if model['kind']!=kind or not model['profile_eligible']:raise ValueError('Eine Gewichtsdatei dieses Bereichs auswählen, keine Konfiguration.')
|
||||
if kind=='chat' and isinstance(params,dict):params=chat_parameters(params)
|
||||
if kind=='chat' and isinstance(params,dict):
|
||||
params=chat_parameters(params)
|
||||
if params['vision_projector']:
|
||||
projector=self.catalog.entry(params['vision_projector'])
|
||||
if projector.get('role')!='vision_projector' or not projector['file'].endswith('.gguf'):raise ValueError('Eine heruntergeladene mmproj-GGUF-Datei auswählen.')
|
||||
if not isinstance(params,dict) or set(params)!=(set(SCHEMAS[kind]) | (set(CHAT_GPU_DEFAULTS) if kind=='chat' else set())):raise ValueError('Unvollständige oder unbekannte Profilparameter.')
|
||||
for key,(lo,hi,_) in SCHEMAS[kind].items():
|
||||
value=params[key]
|
||||
|
||||
@@ -66,6 +66,14 @@ class EndpointTests(unittest.TestCase):
|
||||
self.enable('image')
|
||||
with self.assertRaises(ValueError):self.enable('badimage')
|
||||
self.assertEqual(self.ep.config['enabled_profiles'],['image'])
|
||||
def test_vision_needs_projector_and_rejects_remote_urls(self):
|
||||
self.enable('alpha')
|
||||
data=dict(model='alpha',messages=[{'role':'user','content':[{'type':'image_url','image_url':{'url':'https://example.com/a.png'}}]}])
|
||||
self.assertEqual(self.request('/v1/chat/completions',data)[0],400)
|
||||
self.rows[0]['parameters']['vision_projector']='synthetic'
|
||||
self.assertEqual(self.request('/v1/chat/completions',data)[0],400)
|
||||
data['messages'][0]['content'][0]['image_url']['url']='data:image/png;base64,aGVsbG8='
|
||||
self.assertEqual(self.request('/v1/chat/completions',data)[0],200)
|
||||
def test_profile_switch_and_sampling_defaults(self):
|
||||
for name in ('alpha','beta'):self.enable(name)
|
||||
for name in ('alpha','beta'):
|
||||
|
||||
@@ -26,7 +26,7 @@ class ManagementTests(unittest.TestCase):
|
||||
req.update(id=saved['id'],revision=1)
|
||||
self.profiles.save(req)
|
||||
restarted=Profiles(self.root/'profiles.json',self.catalog)
|
||||
self.assertEqual(restarted.status()['profiles'][0]['parameters'],dict(params,mtp=False,mtp_tokens=2,mtp_min_p=.05,gpu_offload='auto'))
|
||||
self.assertEqual(restarted.status()['profiles'][0]['parameters'],dict(params,mtp=False,mtp_tokens=2,mtp_min_p=.05,gpu_offload='auto',vision_projector=None,vision_device='cpu'))
|
||||
for change in [dict(gpu_offload='unknown'),dict(mtp='yes'),dict(mtp_tokens=0),dict(mtp_tokens=True),dict(mtp_min_p=1.1),dict(gpu_devices=devices*2),dict(tensor_split=[85]),dict(split_mode='none'),dict(top_p=1.1),dict(temperature=float('nan')),dict(top_k=True),dict(tensor_split=[0,100]),dict(gpu_devices=['CUDA0','CUDA1'])]:
|
||||
with self.subTest(change=change),self.assertRaises(ValueError):self.profiles.save(dict(req,revision=2,parameters=dict(params,**change)))
|
||||
|
||||
@@ -97,7 +97,7 @@ class ManagementTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):restarted.save(request)
|
||||
def test_auxiliary_files_cannot_be_profile_models(self):
|
||||
from catalog import file_role
|
||||
for name,role in [('text_encoders/qwen3vl_8b_int8_convrot.safetensors','text_encoder'),('vae/qwen_image_2.1_vae_bf16.safetensors','vae'),('qwen_image_vae.safetensors','vae'),('mmproj-model.gguf','auxiliary')]:
|
||||
for name,role in [('text_encoders/qwen3vl_8b_int8_convrot.safetensors','text_encoder'),('vae/qwen_image_2.1_vae_bf16.safetensors','vae'),('qwen_image_vae.safetensors','vae'),('mmproj-model.gguf','vision_projector')]:
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(file_role(name)['role'],role)
|
||||
path=self.root/'models'/self.model_id/'entry.json';data=json.loads(path.read_text());data['file']=name;path.write_text(json.dumps(data))
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import unittest
|
||||
from unittest.mock import patch,Mock
|
||||
from catalog import capabilities,file_role
|
||||
from profiles import chat_parameters
|
||||
from test_inference import WorkerTests
|
||||
|
||||
class CapabilitiesTests(unittest.TestCase):
|
||||
def test_unknown_is_not_negative_or_name_guess(self):
|
||||
self.assertEqual(capabilities({'id':'org/vision-tools'})['vision'],'Unbekannt')
|
||||
self.assertEqual(capabilities({'tags':['function-calling']})['tools'],'Hinweis laut Hub')
|
||||
self.assertEqual(capabilities({'pipeline_tag':'image-text-to-text'})['vision'],'Hinweis laut Hub')
|
||||
self.assertEqual(capabilities({'siblings':[{'rfilename':'mmproj-model.gguf'}]})['vision'],'Projektordatei vorhanden')
|
||||
def test_projector_is_not_standalone_or_text_encoder(self):
|
||||
self.assertEqual(file_role('mmproj-qwen3vl.gguf')['role'],'vision_projector');self.assertFalse(file_role('mmproj-qwen3vl.gguf')['profile_eligible'])
|
||||
def test_projector_device_requires_selected_gpu(self):
|
||||
with self.assertRaises(ValueError):chat_parameters({'vision_device':'GPU-missing'})
|
||||
|
||||
class ProjectorLaunchTests(WorkerTests):
|
||||
def test_cpu_and_gpu_projector_flags_and_reserve(self):
|
||||
for device in ('cpu','gpu-second'):
|
||||
self.worker.stop();self.profile['parameters'].update(vision_projector='p',vision_device=device)
|
||||
self.worker.catalog.entry=lambda ident:dict(id='p',file='mmproj.gguf',size=1024**3,role='vision_projector') if ident=='p' else self.entry
|
||||
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'),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];self.assertIn('--mmproj',args)
|
||||
if device=='cpu':self.assertIn('--no-mmproj-offload',args)
|
||||
else:self.assertEqual(args[args.index('--mmproj-device')+1],'CUDA1');self.assertEqual(self.worker.memory_plan['gpus'][1]['required_mib'],7072)
|
||||
self.worker.process=None
|
||||
if __name__=='__main__':unittest.main()
|
||||
Reference in new issue
Block a user