Separate stable API profile IDs from display names with legacy aliases
This commit is contained in:
1 parent
0cffcf4295
commit
a908313c8f
7 files changed
+88
-11
No files matched your search
@@ -0,0 +1,9 @@
|
|||||||
|
# Anzeigename und API-ID
|
||||||
|
|
||||||
|
Profile haben einen lesbaren `name` und eine stabile `api_id`. Neue IDs werden aus dem Namen mit Kleinbuchstaben und Bindestrichen vorgeschlagen. Der Benutzer kann die ID ändern; sie wird beim späteren Umbenennen nicht automatisch verändert. IDs enthalten keine Leerzeichen.
|
||||||
|
|
||||||
|
Vorhandene Profile erhalten beim Start eine deterministische, dauerhaft gespeicherte ID. Bei Kollisionen wird eine numerische Endung verwendet. Bisherige Namen bleiben in `api_aliases` erreichbar. Auch beim Ändern von Name oder ID bleibt die vorherige Identität als Alias erhalten; Konflikte mit anderen Profilen werden abgelehnt.
|
||||||
|
|
||||||
|
`/v1/models` liefert die API-ID als `id`, und den Anzeigenamen als `name` und `display_name`. Requests dürfen weiterhin einen alten Namen verwenden. Der gemeinsame Bildalias `athena-image` bleibt erhalten. Backup und Restore erhalten die Identitätsfelder; alte Backups werden beim Restore migriert.
|
||||||
|
|
||||||
|
Moltis lehnt Modell-IDs mit Leerzeichen bereits bei Discovery ab. Die Trennung ermöglicht die automatische Erkennung ohne Umbenennen bestehender Client-Konfigurationen oder feste Modelllisten.
|
||||||
@@ -36,11 +36,17 @@ def validate(doc):
|
|||||||
repo_id(source['repo'])
|
repo_id(source['repo'])
|
||||||
if not re.fullmatch('[a-f0-9]{40}',source['revision']) or source['file'].startswith('/') or '..' in PurePosixPath(source['file']).parts or '\\' in source['file'] or any(ord(c)<32 for c in source['file']):raise ValueError('Ungültige Ersatzquelle.')
|
if not re.fullmatch('[a-f0-9]{40}',source['revision']) or source['file'].startswith('/') or '..' in PurePosixPath(source['file']).parts or '\\' in source['file'] or any(ord(c)<32 for c in source['file']):raise ValueError('Ungültige Ersatzquelle.')
|
||||||
ids.add(m['id'])
|
ids.add(m['id'])
|
||||||
pids=set();names=set()
|
pids=set();names=set();api_names=set()
|
||||||
for p in profiles:
|
for p in profiles:
|
||||||
if not isinstance(p,dict) or not re.fullmatch('[a-f0-9]{32}',p.get('id','')) or p['id'] in pids or p.get('name') in names:raise ValueError('Ungültige oder doppelte Profil-ID.')
|
if not isinstance(p,dict) or not re.fullmatch('[a-f0-9]{32}',p.get('id','')) or p['id'] in pids or p.get('name') in names:raise ValueError('Ungültige oder doppelte Profil-ID.')
|
||||||
name=p.get('name','');kind=p.get('kind');params=p.get('parameters')
|
name=p.get('name','');kind=p.get('kind');params=p.get('parameters')
|
||||||
if not isinstance(name,str) or not 2<=len(name)<=64 or not re.fullmatch(r'[\w .-]+',name) or not name[0].isalnum() or not name[-1].isalnum() or kind not in SCHEMAS or p.get('model_id') not in ids:raise ValueError('Ungültiges Profil oder Modellreferenz.')
|
if not isinstance(name,str) or not 2<=len(name)<=64 or not re.fullmatch(r'[\w .-]+',name) or not name[0].isalnum() or not name[-1].isalnum() or kind not in SCHEMAS or p.get('model_id') not in ids:raise ValueError('Ungültiges Profil oder Modellreferenz.')
|
||||||
|
api_id=p.get('api_id');aliases=p.get('api_aliases',[])
|
||||||
|
if api_id is not None and (not isinstance(api_id,str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_-]{0,63}',api_id)):raise ValueError('Ungültige API-ID im Backup.')
|
||||||
|
if not isinstance(aliases,list) or len(aliases)>100 or any(not isinstance(a,str) or not 1<=len(a)<=64 or any(ord(c)<32 for c in a) for a in aliases):raise ValueError('Ungültige API-Aliase im Backup.')
|
||||||
|
identities=set([name,*aliases]+([api_id] if api_id else []))
|
||||||
|
if identities & api_names:raise ValueError('Doppelte API-ID oder Alias im Backup.')
|
||||||
|
api_names.update(identities)
|
||||||
if type(p.get('revision')) is not int or not 1<=p['revision']<=1000000:raise ValueError('Ungültige Profilrevision.')
|
if type(p.get('revision')) is not int or not 1<=p['revision']<=1000000:raise ValueError('Ungültige Profilrevision.')
|
||||||
if not isinstance(params,dict):raise ValueError('Ungültige Profilparameter.')
|
if not isinstance(params,dict):raise ValueError('Ungültige Profilparameter.')
|
||||||
params=chat_parameters(params) if kind=='chat' else params
|
params=chat_parameters(params) if kind=='chat' else params
|
||||||
@@ -393,7 +399,10 @@ class Backup:
|
|||||||
if name=='video/comfy-client-token':
|
if name=='video/comfy-client-token':
|
||||||
path=self.root/name;path.parent.mkdir(parents=True,exist_ok=True);path.write_text(value+'\n');path.chmod(0o600)
|
path=self.root/name;path.parent.mkdir(parents=True,exist_ok=True);path.write_text(value+'\n');path.chmod(0o600)
|
||||||
else:atomic_write(self.root/name,value)
|
else:atomic_write(self.root/name,value)
|
||||||
with s.profiles.lock:s.profiles.rows=copy.deepcopy(doc['settings'].get('profiles.json',[]))
|
with s.profiles.lock:
|
||||||
|
from profiles import migrate_api_ids
|
||||||
|
s.profiles.rows=copy.deepcopy(doc['settings'].get('profiles.json',[]));migrate_api_ids(s.profiles.rows)
|
||||||
|
s.profiles.path.write_text(json.dumps(s.profiles.rows))
|
||||||
with s.endpoint.lock:s.endpoint.config=copy.deepcopy(doc['settings'].get('endpoint.json',dict(port=8120,enabled_profiles=[],autostart=False)))
|
with s.endpoint.lock:s.endpoint.config=copy.deepcopy(doc['settings'].get('endpoint.json',dict(port=8120,enabled_profiles=[],autostart=False)))
|
||||||
s.video.selection=copy.deepcopy(doc['settings'].get('video/comfy-selection.json',{}))
|
s.video.selection=copy.deepcopy(doc['settings'].get('video/comfy-selection.json',{}))
|
||||||
policy=doc['settings'].get('audio-policy.json',{k:{'mode':'auto','profile_id':None} for k in ('tts','stt')})
|
policy=doc['settings'].get('audio-policy.json',{k:{'mode':'auto','profile_id':None} for k in ('tts','stt')})
|
||||||
|
|||||||
+3
-3
@@ -147,7 +147,7 @@ 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 or (kind=='image' and name=='athena-image')) and p['kind']==kind and p['enabled']),None)
|
row=next((p for p in self.rows() if (name in [p.get('api_id',p['name']),p['name'],*p.get('api_aliases',[])] 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
|
||||||
@@ -155,7 +155,7 @@ class Endpoint:
|
|||||||
data=[]
|
data=[]
|
||||||
for p in self.rows():
|
for p in self.rows():
|
||||||
if not (p['enabled'] and p['runnable'] and p['kind']==kind):continue
|
if not (p['enabled'] and p['runnable'] and p['kind']==kind):continue
|
||||||
row=dict(id='athena-image' if kind=='image' else p['name'],object='model',created=int(p['updated_at']),owned_by='athena-deck')
|
row=dict(id='athena-image' if kind=='image' else p.get('api_id',p['name']),name=p['name'],display_name=p['name'],object='model',created=int(p['updated_at']),owned_by='athena-deck')
|
||||||
if kind=='chat':
|
if kind=='chat':
|
||||||
params=p.get('parameters',{})
|
params=p.get('parameters',{})
|
||||||
row.update(context_window=int(params.get('context',8192)),parallel_slots=int(params.get('slots',1)),context_scope='shared',status={'configuration_only':True})
|
row.update(context_window=int(params.get('context',8192)),parallel_slots=int(params.get('slots',1)),context_scope='shared',status={'configuration_only':True})
|
||||||
@@ -316,7 +316,7 @@ class APIHandler(BaseHTTPRequestHandler):
|
|||||||
if set(data)-{'model','input','voice','response_format','speed','language'}:raise APIError('Nicht unterstützte TTS-Felder.')
|
if set(data)-{'model','input','voice','response_format','speed','language'}:raise APIError('Nicht unterstützte TTS-Felder.')
|
||||||
if data.get('response_format','wav')!='wav':raise APIError('Derzeit wird nur WAV unterstützt; response_format=wav setzen.')
|
if data.get('response_format','wav')!='wav':raise APIError('Derzeit wird nur WAV unterstützt; response_format=wav setzen.')
|
||||||
from profiles import voice_recipe
|
from profiles import voice_recipe
|
||||||
voice=next((p for p in ep.rows() if p.get('model') and voice_recipe(p['model']) and p['name']==data.get('model') and p['enabled']),None)
|
voice=next((p for p in ep.rows() if p.get('model') and voice_recipe(p['model']) and data.get('model') in [p.get('api_id',p['name']),p['name'],*p.get('api_aliases',[])] and p['enabled']),None)
|
||||||
if voice:
|
if voice:
|
||||||
profile=ep.find_profile(data.get('model'),voice['kind']);backend=ep.voxcpm
|
profile=ep.find_profile(data.get('model'),voice['kind']);backend=ep.voxcpm
|
||||||
if data.get('voice','default')!='default' or data.get('speed',1)!=1:raise APIError('VoxCPM2: voice=default und speed=1 verwenden; Referenzstimme im Deck-Testbereich.')
|
if data.get('voice','default')!='default' or data.get('speed',1)!=1:raise APIError('VoxCPM2: voice=default und speed=1 verwenden; Referenzstimme im Deck-Testbereich.')
|
||||||
|
|||||||
+5
-4
@@ -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'),['chat','video'].includes(kind)?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);entries=c.entries;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'),['chat','video'].includes(kind)?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);entries=c.entries;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;
|
||||||
const openRows=new Set([...root.querySelectorAll('.detail-row[open]')].map(x=>x.dataset.rowId));
|
const openRows=new Set([...root.querySelectorAll('.detail-row[open]')].map(x=>x.dataset.rowId));
|
||||||
el('profile-list').innerHTML=rows.map(p=>`<details class="detail-row" data-row-id="${e(p.id)}" ${openRows.has(p.id)?'open':''}><summary class="detail-summary"><strong>${e(p.name)}</strong><span class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</span><span>${p.parameters.context?`${e(p.parameters.context)} Token · ${e(p.parameters.slots||1)} Slots`:e(p.execution?.runtime||'Laufzeit prüfen')}</span><span>${enabled.includes(p.id)?'API: freigegeben':'API: nicht freigegeben'}<small>${p.runnable?'Bereit · lädt bei Anfrage':'Einrichtung fehlt'}</small></span></summary><div class="detail-body"><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(['video_device','text_encoder_device'].includes(k)?({auto:'Automatisch · RTX 5080 bevorzugt',same:'Wie Videopipeline'}[v]||gpus.find(g=>g.uuid===v)?.name||v):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>${kind==='image'?`<p>Prompt-Aufwerter: Text-zu-Bild ${p.prompt_enhancer?.t2i?'an':'aus'} · Bildbearbeitung ${p.prompt_enhancer?.i2i?'an':'aus'} · Gerät ${e(p.prompt_enhancer?.device||'auto')}</p>`:''}${CatalogUI.executionHTML(p.execution)}<details><summary>${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Steuerung aktivieren':'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-presets="${p.id}">Presets (${(p.presets||[]).length})</button><button class="secondary" data-delete="${p.id}">Löschen</button><button class="secondary" data-components="${p.id}">Einrichtung & Komponenten</button>${kind==='image'?`<button class="secondary" data-enhancers="${p.id}">Prompt-Aufwerter</button>`:''}</div></div></details>`).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=>`<details class="detail-row" data-row-id="${e(p.id)}" ${openRows.has(p.id)?'open':''}><summary class="detail-summary"><strong>${e(p.name)}</strong><span class="small">API-ID: ${e(p.api_id||p.name)}</span><span class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</span><span>${p.parameters.context?`${e(p.parameters.context)} Token · ${e(p.parameters.slots||1)} Slots`:e(p.execution?.runtime||'Laufzeit prüfen')}</span><span>${enabled.includes(p.id)?'API: freigegeben':'API: nicht freigegeben'}<small>${p.runnable?'Bereit · lädt bei Anfrage':'Einrichtung fehlt'}</small></span></summary><div class="detail-body"><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(['video_device','text_encoder_device'].includes(k)?({auto:'Automatisch · RTX 5080 bevorzugt',same:'Wie Videopipeline'}[v]||gpus.find(g=>g.uuid===v)?.name||v):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>${kind==='image'?`<p>Prompt-Aufwerter: Text-zu-Bild ${p.prompt_enhancer?.t2i?'an':'aus'} · Bildbearbeitung ${p.prompt_enhancer?.i2i?'an':'aus'} · Gerät ${e(p.prompt_enhancer?.device||'auto')}</p>`:''}${CatalogUI.executionHTML(p.execution)}<details><summary>${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Steuerung aktivieren':'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-presets="${p.id}">Presets (${(p.presets||[]).length})</button><button class="secondary" data-delete="${p.id}">Löschen</button><button class="secondary" data-components="${p.id}">Einrichtung & Komponenten</button>${kind==='image'?`<button class="secondary" data-enhancers="${p.id}">Prompt-Aufwerter</button>`:''}</div></div></details>`).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'?'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.':kind==='video'?'Genau ein Videoprofil kann am API-Endpunkt freigegeben sein. Ein neuer Haken ersetzt die bisherige Auswahl. Die Steuerung lädt dieses Profil beim Wechsel auf Video; API-Name: athena-video. Zum Ändern der Freigabe zuerst auf LLM wechseln.':kind==='music'?'Gib ein eingerichtetes Musikprofil frei, um es in der Steuerung verwenden zu können.':kind==='voice'?'Eingerichtete VoxCPM2-Profile können Sprache erzeugen und am Sprach-API-Endpunkt freigegeben werden. Stimmklonen mit WAV-Referenz steht unter Testen.':'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.':kind==='video'?'Genau ein Videoprofil kann am API-Endpunkt freigegeben sein. Ein neuer Haken ersetzt die bisherige Auswahl. Die Steuerung lädt dieses Profil beim Wechsel auf Video; API-Name: athena-video. Zum Ändern der Freigabe zuerst auf LLM wechseln.':kind==='music'?'Gib ein eingerichtetes Musikprofil frei, um es in der Steuerung verwenden zu können.':kind==='voice'?'Eingerichtete VoxCPM2-Profile können Sprache erzeugen und am Sprach-API-Endpunkt freigegeben werden. Stimmklonen mit WAV-Referenz steht unter Testen.':'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(kind==='video'?'Video-Freigabe gespeichert. Die Steuerung lädt dieses Profil beim Wechsel auf Video.':'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(kind==='video'?'Video-Freigabe gespeichert. Die Steuerung lädt dieses Profil beim Wechsel auf Video.':'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;}});
|
||||||
@@ -16,7 +16,7 @@ window.ProfilesUI=(()=>{
|
|||||||
root.querySelectorAll('[data-enhancers]').forEach(b=>b.onclick=()=>enhancers(rows.find(p=>p.id===b.dataset.enhancers)));
|
root.querySelectorAll('[data-enhancers]').forEach(b=>b.onclick=()=>enhancers(rows.find(p=>p.id===b.dataset.enhancers)));
|
||||||
root.querySelectorAll('[data-presets]').forEach(b=>b.onclick=()=>presets(rows.find(p=>p.id===b.dataset.presets)));
|
root.querySelectorAll('[data-presets]').forEach(b=>b.onclick=()=>presets(rows.find(p=>p.id===b.dataset.presets)));
|
||||||
root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit)));
|
root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit)));
|
||||||
root.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>{const p=rows.find(p=>p.id===b.dataset.copy);editor({...p,presets:[],id:null,revision:0,name:p.name.slice(0,55)+'-kopie'});});
|
root.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>{const p=rows.find(p=>p.id===b.dataset.copy);editor({...p,presets:[],api_id:'',id:null,revision:0,name:p.name.slice(0,55)+'-kopie'});});
|
||||||
}catch(error){message(error.message);}}
|
}catch(error){message(error.message);}}
|
||||||
async function presets(profile){
|
async function presets(profile){
|
||||||
panelSequence++;
|
panelSequence++;
|
||||||
@@ -146,13 +146,14 @@ window.ProfilesUI=(()=>{
|
|||||||
panelSequence++;
|
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]]))};
|
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]]))};
|
||||||
if(!p&&kind==='image'&&models.find(m=>m.id===data.model_id)?.repo==='RunningHubAI/rh-flux.2-klein-9b-fp16-unet-2067980602644717569'){data.parameters.steps=4;data.parameters.guidance=1;}
|
if(!p&&kind==='image'&&models.find(m=>m.id===data.model_id)?.repo==='RunningHubAI/rh-flux.2-klein-9b-fp16-unet-2067980602644717569'){data.parameters.steps=4;data.parameters.guidance=1;}
|
||||||
el('profile-editor').innerHTML=`<section class="card editor"><h2>${data.id?'Profil bearbeiten':'Profil anlegen'}</h2><form id="profile-form"><div class="form-grid"><label>API-Profilname<input name="name" required minlength="2" maxlength="64" placeholder="z. B. Qwen3.8 27B MEDIUM" value="${e(data.name)}"></label><label>Heruntergeladene Modelldatei<select name="model_id">${models.map(m=>`<option value="${m.id}" ${m.id===data.model_id?'selected':''}>${e(m.file)} · ${(m.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${Object.entries(schema).filter(([k])=>!k.endsWith('penalty')).map(([k,[min,max,defaultValue]])=>`<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="${['guidance','speed','temperature','top_p','min_p','mtp_min_p','repeat_penalty','presence_penalty','frequency_penalty'].includes(k)?'any':['width','height'].includes(k)?64:1}" value="${e(data.parameters[k]??defaultValue)}"></label>`).join('')}</div>${kind==='video'?videoDevices(data):''}${kind==='chat'?`<h3>Penalties</h3><div class="form-grid">${['repeat_penalty','presence_penalty','frequency_penalty'].map(k=>{const [min,max,defaultValue]=schema[k];return `<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="any" value="${e(data.parameters[k]??defaultValue)}"></label>`;}).join('')}</div>`+gpuEditor(data.parameters)+`<p class="small">Penalties: Wiederholung 1,0 sowie Presence und Frequency 0 sind neutral. Wiederholung über 1 bremst wiederholte Tokens; Presence bewertet ihr Auftreten, Frequency ihre Häufigkeit. Profilwerte gelten für Testchat und API, sofern der Client keine eigenen Werte sendet.</p><h3>KV-Cache</h3><div class="form-grid">${["k","v"].map(axis=>`<label>${axis.toUpperCase()}-Cache<select name="cache_type_${axis}">${["q4_0","q8_0","f16"].map(type=>`<option value="${type}" ${(data.parameters[`cache_type_${axis}`]||"q4_0")===type?"selected":""}>${type}</option>`).join("")}</select></label>`).join("")}</div><p class="small">q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.</p><h3>Vision-Projektor</h3><label>Projektor<select name="vision_projector"><option value="">Kein Projektor zugeordnet · Vision aus</option>${projectors.map(x=>`<option value="${e(x.id)}" ${data.parameters.vision_projector===x.id?'selected':''}>${e(x.download_source?.repo||x.repo)} / ${e(x.download_source?.file||x.file)}</option>`).join('')}</select></label><section id="projector-discovery" class="card"></section><label>Projektor ausführen auf<select name="vision_device"><option value="cpu">CPU</option>${gpus.map(g=>`<option value="${e(g.uuid)}" ${data.parameters.vision_device===g.uuid?'selected':''}>${e(g.name)}</option>`).join('')}</select></label><p>Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. Das Projektor-Gerät wird unabhängig von den Sprachmodell-GPUs gewählt. Der Sprachmodell-Split verteilt ausschließlich die Sprachmodell-Gewichte. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.</p><label><input name="mtp" type="checkbox" ${data.parameters.mtp?'checked':''}> MTP aktivieren (eingebautes Draft-Modul)</label><p class="small">Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.</p>`:''}<p class="note">Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}</p><button>Profil auf Athena speichern</button><button class="secondary" type="button" id="profile-close">Schließen</button><p id="profile-error" role="alert"></p></form></section>`;
|
el('profile-editor').innerHTML=`<section class="card editor"><h2>${data.id?'Profil bearbeiten':'Profil anlegen'}</h2><form id="profile-form"><div class="form-grid"><label>Anzeigename<input name="name" required minlength="2" maxlength="64" placeholder="z. B. Qwen3.8 27B MEDIUM" value="${e(data.name)}"></label><label>API-ID<input name="api_id" required maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9_-]{0,63}" value="${e(data.api_id||'')}"><span class="small">Stabile Kennung für Anwendungen. Umbenennen des Anzeigenamens ändert diese ID nicht.</span></label><label>Heruntergeladene Modelldatei<select name="model_id">${models.map(m=>`<option value="${m.id}" ${m.id===data.model_id?'selected':''}>${e(m.file)} · ${(m.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${Object.entries(schema).filter(([k])=>!k.endsWith('penalty')).map(([k,[min,max,defaultValue]])=>`<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="${['guidance','speed','temperature','top_p','min_p','mtp_min_p','repeat_penalty','presence_penalty','frequency_penalty'].includes(k)?'any':['width','height'].includes(k)?64:1}" value="${e(data.parameters[k]??defaultValue)}"></label>`).join('')}</div>${kind==='video'?videoDevices(data):''}${kind==='chat'?`<h3>Penalties</h3><div class="form-grid">${['repeat_penalty','presence_penalty','frequency_penalty'].map(k=>{const [min,max,defaultValue]=schema[k];return `<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="any" value="${e(data.parameters[k]??defaultValue)}"></label>`;}).join('')}</div>`+gpuEditor(data.parameters)+`<p class="small">Penalties: Wiederholung 1,0 sowie Presence und Frequency 0 sind neutral. Wiederholung über 1 bremst wiederholte Tokens; Presence bewertet ihr Auftreten, Frequency ihre Häufigkeit. Profilwerte gelten für Testchat und API, sofern der Client keine eigenen Werte sendet.</p><h3>KV-Cache</h3><div class="form-grid">${["k","v"].map(axis=>`<label>${axis.toUpperCase()}-Cache<select name="cache_type_${axis}">${["q4_0","q8_0","f16"].map(type=>`<option value="${type}" ${(data.parameters[`cache_type_${axis}`]||"q4_0")===type?"selected":""}>${type}</option>`).join("")}</select></label>`).join("")}</div><p class="small">q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.</p><h3>Vision-Projektor</h3><label>Projektor<select name="vision_projector"><option value="">Kein Projektor zugeordnet · Vision aus</option>${projectors.map(x=>`<option value="${e(x.id)}" ${data.parameters.vision_projector===x.id?'selected':''}>${e(x.download_source?.repo||x.repo)} / ${e(x.download_source?.file||x.file)}</option>`).join('')}</select></label><section id="projector-discovery" class="card"></section><label>Projektor ausführen auf<select name="vision_device"><option value="cpu">CPU</option>${gpus.map(g=>`<option value="${e(g.uuid)}" ${data.parameters.vision_device===g.uuid?'selected':''}>${e(g.name)}</option>`).join('')}</select></label><p>Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. Das Projektor-Gerät wird unabhängig von den Sprachmodell-GPUs gewählt. Der Sprachmodell-Split verteilt ausschließlich die Sprachmodell-Gewichte. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.</p><label><input name="mtp" type="checkbox" ${data.parameters.mtp?'checked':''}> MTP aktivieren (eingebautes Draft-Modul)</label><p class="small">Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.</p>`:''}<p class="note">Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}</p><button>Profil auf Athena speichern</button><button class="secondary" type="button" id="profile-close">Schließen</button><p id="profile-error" role="alert"></p></form></section>`;
|
||||||
if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='<summary>Erweitert: CPU-Threads</summary><p>CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.</p>';advanced.append(threads);el('profile-form').insertBefore(advanced,el('profile-form').querySelector('.note'));}
|
if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='<summary>Erweitert: CPU-Threads</summary><p>CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.</p>';advanced.append(threads);el('profile-form').insertBefore(advanced,el('profile-form').querySelector('.note'));}
|
||||||
if(kind==='chat'){bindProjectorDiscovery();const f=el('profile-form');const reserveFields=()=>{for(const n of ['reserve_first','reserve_second'])f.elements[n].disabled=f.elements.gpu_reserve_mode.value!=='manual';};f.elements.gpu_reserve_mode.onchange=reserveFields;reserveFields();}
|
if(kind==='chat'){bindProjectorDiscovery();const f=el('profile-form');const reserveFields=()=>{for(const n of ['reserve_first','reserve_second'])f.elements[n].disabled=f.elements.gpu_reserve_mode.value!=='manual';};f.elements.gpu_reserve_mode.onchange=reserveFields;reserveFields();}
|
||||||
if(kind==='video')bindVideoDevices(data);
|
if(kind==='video')bindVideoDevices(data);
|
||||||
|
const identityForm=el('profile-form');let automaticId=!data.id&&!data.api_id;identityForm.elements.api_id.oninput=()=>{automaticId=false;};identityForm.elements.name.oninput=()=>{if(automaticId)identityForm.elements.api_id.value=identityForm.elements.name.value.replace(/ß/g,'ss').normalize('NFKD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,64).replace(/-+$/g,'')||'model';};identityForm.elements.name.oninput();
|
||||||
el('profile-close').onclick=()=>el('profile-editor').replaceChildren();
|
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:not([type=button])');button.disabled=true;
|
el('profile-form').onsubmit=async event=>{event.preventDefault();const form=event.target,values=new FormData(form),button=form.querySelector('button:not([type=button])');button.disabled=true;
|
||||||
try{const parameters=Object.fromEntries(Object.keys(schema).map(k=>[k,Number(values.get(k))]));if(kind==='video'){parameters.video_device=values.get('video_device');parameters.text_encoder_device=values.get('text_encoder_device');}if(kind==='chat'){parameters.vision_projector=values.get('vision_projector')||null;parameters.vision_device=values.get('vision_device');parameters.mtp=values.has('mtp');parameters.cache_type_k=values.get('cache_type_k');parameters.cache_type_v=values.get('cache_type_v');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.gpu_reserve_mode=values.get('gpu_reserve_mode');parameters.gpu_reserve_mib=parameters.gpu_reserve_mode==='manual'?Object.fromEntries([['gpu_first','reserve_first'],['gpu_second','reserve_second']].filter(([g])=>values.get(g)).map(([g,r])=>[values.get(g),Number(values.get(r))])):{};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==='video'){parameters.video_device=values.get('video_device');parameters.text_encoder_device=values.get('text_encoder_device');}if(kind==='chat'){parameters.vision_projector=values.get('vision_projector')||null;parameters.vision_device=values.get('vision_device');parameters.mtp=values.has('mtp');parameters.cache_type_k=values.get('cache_type_k');parameters.cache_type_v=values.get('cache_type_v');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.gpu_reserve_mode=values.get('gpu_reserve_mode');parameters.gpu_reserve_mib=parameters.gpu_reserve_mode==='manual'?Object.fromEntries([['gpu_first','reserve_first'],['gpu_second','reserve_second']].filter(([g])=>values.get(g)).map(([g,r])=>[values.get(g),Number(values.get(r))])):{};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'),api_id:values.get('api_id'),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;}
|
catch(error){if(root.isConnected)el('profile-error').textContent=error.message;}finally{button.disabled=false;}
|
||||||
};
|
};
|
||||||
el('profile-editor').scrollIntoView({behavior:'smooth',block:'start'});
|
el('profile-editor').scrollIntoView({behavior:'smooth',block:'start'});
|
||||||
|
|||||||
+27
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import unicodedata
|
||||||
"""Server-persisted library-backed profiles with validated, typed parameters."""
|
"""Server-persisted library-backed profiles with validated, typed parameters."""
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
@@ -137,6 +138,20 @@ def audio_package_recipe(model,source):
|
|||||||
return {f'runtime_file_{i}':dict(label='Laufzeit-Paket: '+f,files=[f]) for i,f in enumerate(extras)} or None
|
return {f'runtime_file_{i}':dict(label='Laufzeit-Paket: '+f,files=[f]) for i,f in enumerate(extras)} or None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def api_slug(name):
|
||||||
|
text=unicodedata.normalize('NFKD',name.replace('ß','ss')).encode('ascii','ignore').decode().lower()
|
||||||
|
return re.sub(r'[^a-z0-9]+','-',text).strip('-')[:64].rstrip('-') or 'model'
|
||||||
|
|
||||||
|
def migrate_api_ids(rows):
|
||||||
|
used={r['api_id'] for r in rows if r.get('api_id')}
|
||||||
|
for row in rows:
|
||||||
|
if not row.get('api_id'):
|
||||||
|
base=api_slug(row['name']);candidate=base;i=2
|
||||||
|
while candidate in used or any(candidate in [r['name'],*r.get('api_aliases',[])] for r in rows if r is not row):
|
||||||
|
suffix='-'+str(i);candidate=base[:64-len(suffix)]+suffix;i+=1
|
||||||
|
row['api_id']=candidate;used.add(candidate)
|
||||||
|
row.setdefault('api_aliases',[row['name']])
|
||||||
|
|
||||||
class Profiles:
|
class Profiles:
|
||||||
def _recipe(self,model):return component_recipe(model) or getattr(self,'runtime_recipe',lambda m:None)(model)
|
def _recipe(self,model):return component_recipe(model) or getattr(self,'runtime_recipe',lambda m:None)(model)
|
||||||
def __init__(self,path,catalog):
|
def __init__(self,path,catalog):
|
||||||
@@ -145,6 +160,10 @@ class Profiles:
|
|||||||
self.prompt_enhancer_ready=lambda task:False
|
self.prompt_enhancer_ready=lambda task:False
|
||||||
self.video_blockers=None;self.chat_blockers=None;self.tts_blockers=None;self.stt_blockers=None
|
self.video_blockers=None;self.chat_blockers=None;self.tts_blockers=None;self.stt_blockers=None
|
||||||
self.rows=json.loads(self.path.read_text()) if self.path.exists() else []
|
self.rows=json.loads(self.path.read_text()) if self.path.exists() else []
|
||||||
|
original=json.dumps(self.rows)
|
||||||
|
migrate_api_ids(self.rows)
|
||||||
|
if self.path.exists() and json.dumps(self.rows)!=original:
|
||||||
|
temp=self.path.with_suffix('.identity.tmp');temp.write_text(json.dumps(self.rows));temp.replace(self.path)
|
||||||
# First baselines may be captured while a pre-Preset server still runs.
|
# First baselines may be captured while a pre-Preset server still runs.
|
||||||
# Merge on startup so its old in-memory saves cannot discard that baseline.
|
# Merge on startup so its old in-memory saves cannot discard that baseline.
|
||||||
pending=self.path.with_name('profile-presets-pending.json')
|
pending=self.path.with_name('profile-presets-pending.json')
|
||||||
@@ -203,7 +222,7 @@ class Profiles:
|
|||||||
p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured'
|
p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured'
|
||||||
return {'profiles':[p for p in rows if p['kind']!='video'],'schemas':{k:v for k,v in SCHEMAS.items() if k!='video'}}
|
return {'profiles':[p for p in rows if p['kind']!='video'],'schemas':{k:v for k,v in SCHEMAS.items() if k!='video'}}
|
||||||
def save(self,data):
|
def save(self,data):
|
||||||
if set(data)!={'id','revision','name','kind','model_id','parameters'}:raise ValueError('Ungültige Profilfelder.')
|
if set(data)-{'api_id'}!={'id','revision','name','kind','model_id','parameters'}:raise ValueError('Ungültige Profilfelder.')
|
||||||
kind=data['kind'];name=data['name'];params=data['parameters']
|
kind=data['kind'];name=data['name'];params=data['parameters']
|
||||||
if kind=='video':raise ValueError('Videoprofile werden nicht mehr in Deck verwaltet. Originale LTX-Oberfläche verwenden.')
|
if kind=='video':raise ValueError('Videoprofile werden nicht mehr in Deck verwaltet. Originale LTX-Oberfläche verwenden.')
|
||||||
if not isinstance(kind,str) or kind not in SCHEMAS or not isinstance(name,str) or not (2<=len(name)<=64 and name[0].isalnum() and name[-1].isalnum() and re.fullmatch(r'[\w .-]+',name)):raise ValueError('API-Name: 2–64 Zeichen; Buchstaben, Ziffern, Leerzeichen, Punkt, Bindestrich oder Unterstrich. Anfang und Ende müssen Buchstabe oder Ziffer sein.')
|
if not isinstance(kind,str) or kind not in SCHEMAS or not isinstance(name,str) or not (2<=len(name)<=64 and name[0].isalnum() and name[-1].isalnum() and re.fullmatch(r'[\w .-]+',name)):raise ValueError('API-Name: 2–64 Zeichen; Buchstaben, Ziffern, Leerzeichen, Punkt, Bindestrich oder Unterstrich. Anfang und Ende müssen Buchstabe oder Ziffer sein.')
|
||||||
@@ -231,8 +250,14 @@ class Profiles:
|
|||||||
if data['id'] is not None and not existing:raise ValueError('Profil nicht gefunden.')
|
if data['id'] is not None and not existing:raise ValueError('Profil nicht gefunden.')
|
||||||
if data['revision']!=(existing['revision'] if existing else 0):raise ValueError('Profil wurde zwischenzeitlich geändert. Ansicht neu laden.')
|
if data['revision']!=(existing['revision'] if existing else 0):raise ValueError('Profil wurde zwischenzeitlich geändert. Ansicht neu laden.')
|
||||||
if any(p['name']==name and p is not existing for p in self.rows):raise ValueError('API-Profilname bereits vergeben.')
|
if any(p['name']==name and p is not existing for p in self.rows):raise ValueError('API-Profilname bereits vergeben.')
|
||||||
|
api_id=data.get('api_id') or (existing.get('api_id') if existing else api_slug(name))
|
||||||
|
if not isinstance(api_id,str) or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}',api_id):raise ValueError('API-ID: 1–64 Buchstaben, Ziffern, Bindestrich oder Unterstrich, ohne Leerzeichen.')
|
||||||
|
aliases=list(existing.get('api_aliases',[]) if existing else [])
|
||||||
|
if existing:
|
||||||
|
aliases=list(dict.fromkeys(aliases+[existing['name'],existing['api_id']]))
|
||||||
|
if any(set([api_id,name,*aliases]) & set([p.get('api_id',p['name']),p['name'],*p.get('api_aliases',[])]) for p in self.rows if p is not existing):raise ValueError('API-ID oder Alias bereits vergeben.')
|
||||||
components=existing.get('components',{}) if existing and existing['model_id']==model['id'] else {}
|
components=existing.get('components',{}) if existing and existing['model_id']==model['id'] else {}
|
||||||
row=dict(presets=existing.get('presets',[]) if existing else [],components=components,prompt_enhancer=existing.get('prompt_enhancer',dict(PROMPT_ENHANCER_DEFAULTS)) if existing and existing['model_id']==model['id'] and kind=='image' else dict(PROMPT_ENHANCER_DEFAULTS) if kind=='image' else None,id=existing['id'] if existing else uuid.uuid4().hex,revision=data['revision']+1,name=name,kind=kind,model_id=model['id'],parameters=params,updated_at=time.time())
|
row=dict(api_id=api_id,api_aliases=aliases,presets=existing.get('presets',[]) if existing else [],components=components,prompt_enhancer=existing.get('prompt_enhancer',dict(PROMPT_ENHANCER_DEFAULTS)) if existing and existing['model_id']==model['id'] and kind=='image' else dict(PROMPT_ENHANCER_DEFAULTS) if kind=='image' else None,id=existing['id'] if existing else uuid.uuid4().hex,revision=data['revision']+1,name=name,kind=kind,model_id=model['id'],parameters=params,updated_at=time.time())
|
||||||
rows=[row if p is existing else p for p in self.rows] if existing else self.rows+[row]
|
rows=[row if p is existing else p for p in self.rows] if existing else self.rows+[row]
|
||||||
self.path.parent.mkdir(parents=True,exist_ok=True,mode=0o700)
|
self.path.parent.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||||
temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows
|
temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ class EndpointTests(unittest.TestCase):
|
|||||||
def request(self,path='/v1/models',data=None,token='A'*32):
|
def request(self,path='/v1/models',data=None,token='A'*32):
|
||||||
conn=http.client.HTTPConnection('127.0.0.1',self.port,timeout=5);headers={'Content-Type':'application/json','Authorization':'Bearer '+token}
|
conn=http.client.HTTPConnection('127.0.0.1',self.port,timeout=5);headers={'Content-Type':'application/json','Authorization':'Bearer '+token}
|
||||||
conn.request('POST' if data is not None else 'GET',path,body=json.dumps(data) if data is not None else None,headers=headers);r=conn.getresponse();body=r.read();conn.close();return r.status,(body if data and data.get('stream') else json.loads(body))
|
conn.request('POST' if data is not None else 'GET',path,body=json.dumps(data) if data is not None else None,headers=headers);r=conn.getresponse();body=r.read();conn.close();return r.status,(body if data and data.get('stream') else json.loads(body))
|
||||||
|
def test_api_identity_and_legacy_name_share_profile(self):
|
||||||
|
self.rows[0].update(name='Qwen Medium',api_id='qwen-medium',api_aliases=['Old Name'])
|
||||||
|
self.enable('alpha')
|
||||||
|
_,listing=self.request()
|
||||||
|
self.assertEqual(listing['data'][0]['id'],'qwen-medium')
|
||||||
|
self.assertEqual(listing['data'][0]['display_name'],'Qwen Medium')
|
||||||
|
for name in ['qwen-medium','Qwen Medium','Old Name']:
|
||||||
|
self.assertEqual(self.ep.find_profile(name,'chat')['id'],'alpha')
|
||||||
|
|
||||||
def test_discovery_reports_profile_context_without_loading(self):
|
def test_discovery_reports_profile_context_without_loading(self):
|
||||||
self.rows[0]['parameters'].update(context=160000,slots=2)
|
self.rows[0]['parameters'].update(context=160000,slots=2)
|
||||||
self.rows[0]['model']={'repo':'Qwen/Qwen3.8-27B'}
|
self.rows[0]['model']={'repo':'Qwen/Qwen3.8-27B'}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import unittest,json
|
||||||
|
from test_profile_presets import PresetTests
|
||||||
|
from profiles import api_slug,Profiles
|
||||||
|
|
||||||
|
class IdentityTests(PresetTests):
|
||||||
|
def test_stable_id_and_legacy_alias_after_rename(self):
|
||||||
|
r=self.row
|
||||||
|
self.assertEqual(r['api_id'],'image-test')
|
||||||
|
r=self.p.save(dict(id=r['id'],revision=r['revision'],name='Anderer Name',kind='image',model_id='model',parameters=r['parameters']))
|
||||||
|
self.assertEqual(r['api_id'],'image-test');self.assertIn('Image Test',r['api_aliases'])
|
||||||
|
self.assertEqual(Profiles(self.path,self.p.catalog).rows[0]['api_id'],'image-test')
|
||||||
|
def test_collision_and_invalid_id_rejected(self):
|
||||||
|
data=dict(id=None,revision=0,name='Andere',kind='image',model_id='model',parameters=self.row['parameters'])
|
||||||
|
for key in ['image-test','not valid']:
|
||||||
|
with self.assertRaises(ValueError):self.p.save(dict(data,api_id=key))
|
||||||
|
def test_legacy_migration_collision_is_persistent(self):
|
||||||
|
rows=[dict(self.row,id='a',name='A B'),dict(self.row,id='b',name='A-B')]
|
||||||
|
for r in rows:r.pop('api_id');r.pop('api_aliases')
|
||||||
|
self.path.write_text(json.dumps(rows));p=Profiles(self.path,self.p.catalog)
|
||||||
|
self.assertEqual(len({r['api_id'] for r in p.rows}),2)
|
||||||
|
self.assertEqual(p.rows,Profiles(self.path,self.p.catalog).rows)
|
||||||
|
def test_slug(self):
|
||||||
|
self.assertEqual(api_slug('Qwen3.8 27B - Medium'),'qwen3-8-27b-medium')
|
||||||
|
self.assertEqual(api_slug('Größe'),'grosse')
|
||||||
Reference in new issue
Block a user