From 06c6292cf366494fe3e2ef0e40c79486b6761681 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:03:39 +0200 Subject: [PATCH] Expose model capability hints and configurable vision projector placement --- STUDIO.md | 8 ++++++++ catalog-ui.js | 4 ++-- catalog.py | 21 +++++++++++++++------ endpoint.py | 18 ++++++++++++++++-- inference.py | 20 ++++++++++++++++++-- profiles-ui.js | 10 +++++----- profiles.py | 10 ++++++++-- test_endpoint.py | 8 ++++++++ test_model_management.py | 4 ++-- test_vision.py | 30 ++++++++++++++++++++++++++++++ 10 files changed, 112 insertions(+), 21 deletions(-) create mode 100644 test_vision.py diff --git a/STUDIO.md b/STUDIO.md index f2c46f0..3fce93d 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -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. diff --git a/catalog-ui.js b/catalog-ui.js index 2a548c3..e0534b2 100644 --- a/catalog-ui.js +++ b/catalog-ui.js @@ -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 => ``).join('') || '

Keine Treffer

Versuche einen anderen Namen. Die Suche ist auf den aktuellen Bereich begrenzt.

'; + el('hub-results').innerHTML = sorted.map(m => ``).join('') || '

Keine Treffer

Versuche einen anderen Namen. Die Suche ist auf den aktuellen Bereich begrenzt.

'; root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo)); } function renderLibrary() { @@ -77,7 +77,7 @@ window.CatalogUI = (() => { el('hub-files').innerHTML = '

Modelldetails werden geladen …

'; try { const result = await api('/files?'+new URLSearchParams({repo})); if (!root.isConnected || sequence !== detailSequence) return; detail = result; - el('hub-files').innerHTML = `MODELLDETAILS

${escape(repo)}

${detail.gated ? 'Zugang beschränkt' : 'Öffentlich'}${detail.files.length} unterstützte Dateien

Lizenz: ${escape(detail.license || 'Nicht angegeben')} · Modellkarte lesen ↗

Wähle eine Datei für die Speicherprüfung.
${detail.gated ? '

Dieses Repository benötigt eine Freigabe. Downloads mit Hugging-Face-Zugangsdaten werden noch nicht unterstützt.

' : ''}

Variante oder Datei auswählen

Einzeldatei-Download auf Athena. Geteilte Modelle und Bild-/Audio-/Video-Pipelines benötigen weitere Dateien; dies installiert noch keinen vollständigen Worker.

Feste Quellversion

${escape(detail.revision)}

`; + el('hub-files').innerHTML = `MODELLDETAILS

${escape(repo)}

${detail.gated ? 'Zugang beschränkt' : 'Öffentlich'}${detail.files.length} unterstützte Dateien

Vision: ${escape(detail.capabilities?.vision||'Unbekannt')} · Tool-Calling: ${escape(detail.capabilities?.tools||'Unbekannt')}. Hub-Hinweise, keine Laufzeitgarantie; fehlende Angaben bedeuten nicht „nein“.

Lizenz: ${escape(detail.license || 'Nicht angegeben')} · Modellkarte lesen ↗

Wähle eine Datei für die Speicherprüfung.
${detail.gated ? '

Dieses Repository benötigt eine Freigabe. Downloads mit Hugging-Face-Zugangsdaten werden noch nicht unterstützt.

' : ''}

Variante oder Datei auswählen

Einzeldatei-Download auf Athena. Geteilte Modelle und Bild-/Audio-/Video-Pipelines benötigen weitere Dateien; dies installiert noch keinen vollständigen Worker.

Feste Quellversion

${escape(detail.revision)}

`; 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; diff --git a/catalog.py b/catalog.py index dcd6074..fea7c05 100644 --- a/catalog.py +++ b/catalog.py @@ -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) diff --git a/endpoint.py b/endpoint.py index 7277151..b4ec816 100644 --- a/endpoint.py +++ b/endpoint.py @@ -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()) diff --git a/inference.py b/inference.py index 9da25b3..aebacb8 100644 --- a/inference.py +++ b/inference.py @@ -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)=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] diff --git a/profiles-ui.js b/profiles-ui.js index 6d9bd34..1f997e1 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -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=()=>'

Deine Profile

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

'; async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;} function bind(kind,modelId){ - const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},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=>`
GESPEICHERT AUF ATHENA

${e(p.name)}

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

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

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

${e(t)}

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

Noch keine Profile

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

`; el('profile-list').insertAdjacentHTML('afterbegin',`

${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.

`); 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=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).map(([k,[min,max,defaultValue]])=>``).join('')}
${kind==='chat'?gpuEditor(data.parameters)+`

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.

`:''}

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.':''}

`; + el('profile-editor').innerHTML=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).map(([k,[min,max,defaultValue]])=>``).join('')}
${kind==='chat'?gpuEditor(data.parameters)+`

Vision-Projektor

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.

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.

`:''}

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.':''}

`; if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='Erweitert: CPU-Threads

CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.

';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'}); diff --git a/profiles.py b/profiles.py index c9a7f0e..b760ebe 100644 --- a/profiles.py +++ b/profiles.py @@ -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] diff --git a/test_endpoint.py b/test_endpoint.py index 5320b00..f6e5d18 100644 --- a/test_endpoint.py +++ b/test_endpoint.py @@ -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'): diff --git a/test_model_management.py b/test_model_management.py index 29d3aa8..099c345 100644 --- a/test_model_management.py +++ b/test_model_management.py @@ -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)) diff --git a/test_vision.py b/test_vision.py new file mode 100644 index 0000000..6d559cd --- /dev/null +++ b/test_vision.py @@ -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()