Route stable athena-image alias to selected image profile

This commit is contained in:
Mikei386
2026-09-29 09:36:56 +02:00
parent baad2b11a5
commit f337ade65a
4 changed files with 27 additions and 7 deletions
+7 -1
View File
@@ -51,7 +51,7 @@ Alle Inferenzrouten benötigen `Authorization: Bearer <Deck-API-Token>`.
`model` ist immer der **API-Profilname**, nicht der GGUF-Dateiname. Bildaufträge `model` ist immer der **API-Profilname**, nicht der GGUF-Dateiname. Bildaufträge
verwenden die gespeicherte Auflösung, Schritte, Guidance und Seed; eine explizite verwenden die gespeicherte Auflösung, Schritte, Guidance und Seed; eine explizite
`size` muss der Profilauflösung entsprechen. Das Bild liegt wie beim GUI-Test im `size` gilt als Formatwunsch; maßgeblich bleibt die Profilauflösung. Das Bild liegt wie beim GUI-Test im
eigenen Jobverzeichnis. Kein Bildprompt wird persistiert; PNG-Metadaten sind eigenen Jobverzeichnis. Kein Bildprompt wird persistiert; PNG-Metadaten sind
deaktiviert. n=1; Ausgabe zunächst als Base64, keine öffentlich abrufbaren Bild-URLs. deaktiviert. n=1; Ausgabe zunächst als Base64, keine öffentlich abrufbaren Bild-URLs.
Die API ist bewusst ein Teilumfang: noch keine `/v1/responses`, Embeddings, Die API ist bewusst ein Teilumfang: noch keine `/v1/responses`, Embeddings,
@@ -172,3 +172,9 @@ Profilfreigaben: mehrere Chatprofile, höchstens ein Bildprofil. Die Auswahl ein
`GET /v1/models` listet ausschließlich Chatprofile, damit Chatclients keine Bild-/Audio-Profile anbieten. Deck-Erweiterungen: `GET /v1/images/models` für Bildprofile, `GET /v1/audio/speech/models` für TTS und `GET /v1/audio/transcriptions/models` für STT. Alle Listen erfordern denselben Bearer-Token und enthalten nur freigegebene, ausführbare Profile. Die Inferenzrouten bleiben unverändert. Die Verwaltungs-API liefert weiterhin die Gesamtübersicht. `GET /v1/models` listet ausschließlich Chatprofile, damit Chatclients keine Bild-/Audio-Profile anbieten. Deck-Erweiterungen: `GET /v1/images/models` für Bildprofile, `GET /v1/audio/speech/models` für TTS und `GET /v1/audio/transcriptions/models` für STT. Alle Listen erfordern denselben Bearer-Token und enthalten nur freigegebene, ausführbare Profile. Die Inferenzrouten bleiben unverändert. Die Verwaltungs-API liefert weiterhin die Gesamtübersicht.
Chat akzeptiert `reasoning_effort`: `none`, `minimal`, `low`, `medium`, `high`, `xhigh` werden unverändert an llama.cpp weitergereicht; `null` wird wie ein fehlendes Feld behandelt. Der installierte Build unterstützt das Feld; die konkrete Wirkung hängt vom Modell-Chattemplate ab. `none` deaktiviert dort das Reasoning. Deck erfindet keine Tokenbudgets und ignoriert gesetzte Werte nicht stillschweigend. Chat akzeptiert `reasoning_effort`: `none`, `minimal`, `low`, `medium`, `high`, `xhigh` werden unverändert an llama.cpp weitergereicht; `null` wird wie ein fehlendes Feld behandelt. Der installierte Build unterstützt das Feld; die konkrete Wirkung hängt vom Modell-Chattemplate ab. `none` deaktiviert dort das Reasoning. Deck erfindet keine Tokenbudgets und ignoriert gesetzte Werte nicht stillschweigend.
### Fester Bildname
`athena-image` bezeichnet immer das aktuell freigegebene Bildprofil. `/v1/images/models` veröffentlicht ausschließlich diesen Alias, sofern ein ausführbares Bildprofil freigegeben ist. Direkte Namen aktivierter Bildprofile bleiben aus Kompatibilitätsgründen aufrufbar. Ohne Freigabe folgt `model_not_found`. Laufende Generierungen verwenden ihr bereits übernommenes Profil; wartende Anfragen werden bei Profiländerungen abgelehnt und können erneut gesendet werden.
Die Bildgröße wird aus dem Profil übernommen. `size` (z. B. `1536x1024` oder `auto`) ist ein Wunsch und verändert weder Profil noch Speicherbedarf. Die JSON-Antwort enthält zusätzlich `athena_deck.size`, `requested_size` und `size_policy: profile`. Es erfolgt keine automatische Skalierung oder Änderung des Seitenverhältnisses. In Hermes einmalig `image_gen.openai.model: athena-image` setzen.
+7 -4
View File
@@ -5,6 +5,7 @@ import json
import os import os
from pathlib import Path from pathlib import Path
import secrets import secrets
import re
import socket import socket
import threading import threading
import time import time
@@ -111,12 +112,12 @@ class Endpoint:
return bool(record and record.get('api_token_hash') and secrets.compare_digest(hashlib.sha256(header[7:].encode()).hexdigest(),record['api_token_hash'])) return bool(record and record.get('api_token_hash') and secrets.compare_digest(hashlib.sha256(header[7:].encode()).hexdigest(),record['api_token_hash']))
def find_profile(self,name,kind): def find_profile(self,name,kind):
if not isinstance(name,str):raise APIError('model muss den API-Namen eines aktivierten Profils enthalten.') if not isinstance(name,str):raise APIError('model muss den API-Namen eines aktivierten Profils enthalten.')
row=next((p for p in self.rows() if p['name']==name and p['kind']==kind and p['enabled']),None) row=next((p for p in self.rows() if (p['name']==name or (kind=='image' and name=='athena-image')) and p['kind']==kind and p['enabled']),None)
if not row:raise APIError('Modellprofil nicht aktiviert oder unbekannt.',404,'model_not_found') if not row:raise APIError('Modellprofil nicht aktiviert oder unbekannt.',404,'model_not_found')
if not row['runnable']:raise APIError('Profil derzeit nicht ausführbar: '+' '.join(row['blockers']),503,'model_unavailable') if not row['runnable']:raise APIError('Profil derzeit nicht ausführbar: '+' '.join(row['blockers']),503,'model_unavailable')
return row return row
def model_list(self,kind="chat"): def model_list(self,kind="chat"):
return dict(object='list',data=[dict(id=p['name'],object='model',created=int(p['updated_at']),owned_by='athena-deck') for p in self.rows() if p['enabled'] and p['runnable'] and p['kind']==kind]) return dict(object='list',data=[dict(id='athena-image' if kind=='image' else p['name'],object='model',created=int(p['updated_at']),owned_by='athena-deck') for p in self.rows() if p['enabled'] and p['runnable'] and p['kind']==kind])
class APIHTTPServer(ThreadingHTTPServer): class APIHTTPServer(ThreadingHTTPServer):
daemon_threads=True daemon_threads=True
@@ -265,7 +266,9 @@ class APIHandler(BaseHTTPRequestHandler):
if set(data)-{'model','prompt','n','size','response_format','user'}:raise APIError('Nicht unterstützte Bildparameter. Schritte/Guidance/Seed stehen im Profil.') if set(data)-{'model','prompt','n','size','response_format','user'}:raise APIError('Nicht unterstützte Bildparameter. Schritte/Guidance/Seed stehen im Profil.')
if data.get('n',1)!=1 or data.get('response_format','b64_json')!='b64_json':raise APIError('Unterstützt werden n=1 und response_format=b64_json.') if data.get('n',1)!=1 or data.get('response_format','b64_json')!='b64_json':raise APIError('Unterstützt werden n=1 und response_format=b64_json.')
profile=ep.find_profile(data.get('model'),'image');params=profile['parameters'] profile=ep.find_profile(data.get('model'),'image');params=profile['parameters']
if data.get('size',f"{params['width']}x{params['height']}")!=f"{params['width']}x{params['height']}":raise APIError('size muss der im Profil gespeicherten Auflösung entsprechen.') size=data.get('size')
if size is not None and (not isinstance(size,str) or (size!='auto' and not re.fullmatch(r'[1-9][0-9]{1,4}x[1-9][0-9]{1,4}',size))):raise APIError('size muss auto oder eine Auflösung wie 1024x1024 sein.')
# The selected profile owns resource limits; client size is only a preference.
def allowed():return ep.allowed() and any(p['id']==profile['id'] and p['revision']==profile['revision'] and p['enabled'] for p in ep.rows()) def allowed():return ep.allowed() and any(p['id']==profile['id'] and p['revision']==profile['revision'] and p['enabled'] for p in ep.rows())
with ep.scheduler.lease(('image',),allowed=allowed): with ep.scheduler.lease(('image',),allowed=allowed):
return self.generate_image(ep,profile,data) return self.generate_image(ep,profile,data)
@@ -275,7 +278,7 @@ class APIHandler(BaseHTTPRequestHandler):
while time.monotonic()<deadline: while time.monotonic()<deadline:
current=ep.images.status()['job'] current=ep.images.status()['job']
if not current or current['id']!=job['id']:raise InferenceError('Bildauftrag nicht mehr verfügbar.') if not current or current['id']!=job['id']:raise InferenceError('Bildauftrag nicht mehr verfügbar.')
if current['state']=='complete':return self.send({'created':int(time.time()),'data':[{'b64_json':base64.b64encode(ep.images.image(job['id'])).decode()}]}) if current['state']=='complete':return self.send({'created':int(time.time()),'data':[{'b64_json':base64.b64encode(ep.images.image(job['id'])).decode()}],'athena_deck':{'model':'athena-image','size':f"{profile['parameters']['width']}x{profile['parameters']['height']}",'requested_size':data.get('size'),'size_policy':'profile'}})
if current['state']!='running':raise InferenceError(current['phase']) if current['state']!='running':raise InferenceError(current['phase'])
time.sleep(.25) time.sleep(.25)
ep.images.stop();raise InferenceError('Zeitlimit der Bildgenerierung überschritten.') ep.images.stop();raise InferenceError('Zeitlimit der Bildgenerierung überschritten.')
+1 -1
View File
@@ -8,7 +8,7 @@ window.ProfilesUI=(()=>{
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;}; const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
async function load(){try{const [p,c,h,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; 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_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):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>${CatalogUI.executionHTML(p.execution)}<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').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_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):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>${CatalogUI.executionHTML(p.execution)}<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.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`); el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Der feste API-Name athena-image verwendet das hier freigegebene Bildprofil und dessen Auflösung. Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} 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);}}); 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);}});
root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}}); root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}});
root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components))); root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components)));
+12 -1
View File
@@ -59,7 +59,7 @@ class EndpointTests(unittest.TestCase):
self.rows[-1].update(runnable=True,blockers=[]) self.rows[-1].update(runnable=True,blockers=[])
self.rows.append(dict(id='stt',name='stt',kind='stt',runnable=True,blockers=[],parameters={},updated_at=1)) self.rows.append(dict(id='stt',name='stt',kind='stt',runnable=True,blockers=[],parameters={},updated_at=1))
for name in ('alpha','image','audio','stt'):self.enable(name) for name in ('alpha','image','audio','stt'):self.enable(name)
for route,expected in [('/v1/models','alpha'),('/v1/images/models','image'),('/v1/audio/speech/models','audio'),('/v1/audio/transcriptions/models','stt')]: for route,expected in [('/v1/models','alpha'),('/v1/images/models','athena-image'),('/v1/audio/speech/models','audio'),('/v1/audio/transcriptions/models','stt')]:
self.assertEqual([p['id'] for p in self.request(route)[1]['data']],[expected]) self.assertEqual([p['id'] for p in self.request(route)[1]['data']],[expected])
self.assertEqual(self.request(route,token='bad')[0],401) self.assertEqual(self.request(route,token='bad')[0],401)
self.rows[-1]['runnable']=False self.rows[-1]['runnable']=False
@@ -97,6 +97,17 @@ class EndpointTests(unittest.TestCase):
self.assertEqual(set(self.ep.config['enabled_profiles']),{'alpha','beta','image2'}) self.assertEqual(set(self.ep.config['enabled_profiles']),{'alpha','beta','image2'})
self.assertEqual({r['id'] for r in self.request()[1]['data']},{'alpha','beta'}) self.assertEqual({r['id'] for r in self.request()[1]['data']},{'alpha','beta'})
self.ep.enable(dict(id='image2',enabled=False));self.assertEqual(set(self.ep.config['enabled_profiles']),{'alpha','beta'}) self.ep.enable(dict(id='image2',enabled=False));self.assertEqual(set(self.ep.config['enabled_profiles']),{'alpha','beta'})
def test_image_alias_follows_selection_and_profile_size(self):
self.rows[2]['parameters']['height']=512
self.rows.append(dict(self.rows[2],id='image2',name='image2'))
self.ep.images.start.return_value={'id':'job'};self.ep.images.status.return_value={'job':{'id':'job','state':'complete'}};self.ep.images.image.return_value=b'png'
self.assertEqual(self.request('/v1/images/generations',dict(model='athena-image',prompt='synthetic'))[0],404)
for name in ('image','image2'):
self.enable(name)
status,result=self.request('/v1/images/generations',dict(model='athena-image',prompt='synthetic',size='1536x1024'))
self.assertEqual(status,200);self.assertEqual(result['athena_deck']['size'],'512x512');self.assertEqual(self.ep.images.start.call_args.args[0],name)
self.assertEqual([m['id'] for m in self.request('/v1/images/models')[1]['data']],['athena-image'])
self.assertEqual(self.request('/v1/images/generations',dict(model='athena-image',prompt='synthetic',size=[]))[0],400)
def test_invalid_image_selection_keeps_previous(self): def test_invalid_image_selection_keeps_previous(self):
self.rows.append(dict(self.rows[2],id='badimage',name='badimage',runnable=False,blockers=['missing'])) self.rows.append(dict(self.rows[2],id='badimage',name='badimage',runnable=False,blockers=['missing']))
self.enable('image') self.enable('image')