Route stable athena-image alias to selected image profile
This commit is contained in:
+7
-1
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user