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.
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.
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')}
${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=`
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()