From a908313c8f7a2e0aa8d84f2db1f07f109abc4e7e Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Sat, 3 Oct 2026 23:35:30 +0200
Subject: [PATCH] Separate stable API profile IDs from display names with
legacy aliases
---
API_PROFILE_IDENTITIES.md | 9 +++++++++
backup.py | 13 +++++++++++--
endpoint.py | 6 +++---
profiles-ui.js | 9 +++++----
profiles.py | 29 +++++++++++++++++++++++++++--
test_endpoint.py | 9 +++++++++
test_profile_identity.py | 24 ++++++++++++++++++++++++
7 files changed, 88 insertions(+), 11 deletions(-)
create mode 100644 API_PROFILE_IDENTITIES.md
create mode 100644 test_profile_identity.py
diff --git a/API_PROFILE_IDENTITIES.md b/API_PROFILE_IDENTITIES.md
new file mode 100644
index 0000000..41bdde4
--- /dev/null
+++ b/API_PROFILE_IDENTITIES.md
@@ -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.
diff --git a/backup.py b/backup.py
index cf13207..584da4c 100644
--- a/backup.py
+++ b/backup.py
@@ -36,11 +36,17 @@ def validate(doc):
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.')
ids.add(m['id'])
- pids=set();names=set()
+ pids=set();names=set();api_names=set()
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.')
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.')
+ 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 not isinstance(params,dict):raise ValueError('Ungültige Profilparameter.')
params=chat_parameters(params) if kind=='chat' else params
@@ -393,7 +399,10 @@ class Backup:
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)
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)))
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')})
diff --git a/endpoint.py b/endpoint.py
index 7026838..ba8f623 100644
--- a/endpoint.py
+++ b/endpoint.py
@@ -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']))
def find_profile(self,name,kind):
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['runnable']:raise APIError('Profil derzeit nicht ausführbar: '+' '.join(row['blockers']),503,'model_unavailable')
return row
@@ -155,7 +155,7 @@ class Endpoint:
data=[]
for p in self.rows():
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':
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})
@@ -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 data.get('response_format','wav')!='wav':raise APIError('Derzeit wird nur WAV unterstützt; response_format=wav setzen.')
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:
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.')
diff --git a/profiles-ui.js b/profiles-ui.js
index f9883c9..0143301 100644
--- a/profiles-ui.js
+++ b/profiles-ui.js
@@ -8,7 +8,7 @@ window.ProfilesUI=(()=>{
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;
const openRows=new Set([...root.querySelectorAll('.detail-row[open]')].map(x=>x.dataset.rowId));
- el('profile-list').innerHTML=rows.map(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(' · ')} 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')} ${e(t)} ${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'} ${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(' · ')} 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')} ${e(t)} ${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}${e(p.name)}${e(p.model?.file||'Modelldatei nicht verfügbar')}${p.parameters.context?`${e(p.parameters.context)} Token · ${e(p.parameters.slots||1)} Slots`:e(p.execution?.runtime||'Laufzeit prüfen')}${enabled.includes(p.id)?'API: freigegeben':'API: nicht freigegeben'}${p.runnable?'Bereit · lädt bei Anfrage':'Einrichtung fehlt'}
${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Steuerung aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}
${p.blockers.map(t=>`Noch keine Profile
${e(p.name)}API-ID: ${e(p.api_id||p.name)}${e(p.model?.file||'Modelldatei nicht verfügbar')}${p.parameters.context?`${e(p.parameters.context)} Token · ${e(p.parameters.slots||1)} Slots`:e(p.execution?.runtime||'Laufzeit prüfen')}${enabled.includes(p.id)?'API: freigegeben':'API: nicht freigegeben'}${p.runnable?'Bereit · lädt bei Anfrage':'Einrichtung fehlt'}
${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Steuerung aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}
${p.blockers.map(t=>`Noch keine Profile
${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.
`); 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;}}); @@ -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-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-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);}} async function presets(profile){ panelSequence++; @@ -146,13 +146,14 @@ window.ProfilesUI=(()=>{ 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(!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=`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'));} 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); + 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-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;} }; el('profile-editor').scrollIntoView({behavior:'smooth',block:'start'}); diff --git a/profiles.py b/profiles.py index 3391d05..d9ef7e1 100644 --- a/profiles.py +++ b/profiles.py @@ -1,3 +1,4 @@ +import unicodedata """Server-persisted library-backed profiles with validated, typed parameters.""" import json 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 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: def _recipe(self,model):return component_recipe(model) or getattr(self,'runtime_recipe',lambda m:None)(model) def __init__(self,path,catalog): @@ -145,6 +160,10 @@ class Profiles: self.prompt_enhancer_ready=lambda task:False 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 [] + 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. # Merge on startup so its old in-memory saves cannot discard that baseline. 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' 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): - 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'] 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.') @@ -231,8 +250,14 @@ class Profiles: 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 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 {} - 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] 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 diff --git a/test_endpoint.py b/test_endpoint.py index 7bb9eaa..559ff36 100644 --- a/test_endpoint.py +++ b/test_endpoint.py @@ -61,6 +61,15 @@ class EndpointTests(unittest.TestCase): 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.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): self.rows[0]['parameters'].update(context=160000,slots=2) self.rows[0]['model']={'repo':'Qwen/Qwen3.8-27B'} diff --git a/test_profile_identity.py b/test_profile_identity.py new file mode 100644 index 0000000..0e1ad42 --- /dev/null +++ b/test_profile_identity.py @@ -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')