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

${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(' · ')}

${kind==='image'?`

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')}

`:''}${CatalogUI.executionHTML(p.execution)}
${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=>`

${e(t)}

`).join('')}
${kind==='image'?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; + el('profile-list').innerHTML=rows.map(p=>`
${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'}

${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(' · ')}

${kind==='image'?`

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')}

`:''}${CatalogUI.executionHTML(p.execution)}
${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=>`

${e(t)}

`).join('')}
${kind==='image'?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; el('profile-list').insertAdjacentHTML('afterbegin',`

${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=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).filter(([k])=>!k.endsWith('penalty')).map(([k,[min,max,defaultValue]])=>``).join('')}
${kind==='video'?videoDevices(data):''}${kind==='chat'?`

Penalties

${['repeat_penalty','presence_penalty','frequency_penalty'].map(k=>{const [min,max,defaultValue]=schema[k];return ``;}).join('')}
`+gpuEditor(data.parameters)+`

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.

KV-Cache

${["k","v"].map(axis=>``).join("")}

q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.

Vision-Projektor

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.

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.

`:''}

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.':''}

`; + el('profile-editor').innerHTML=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).filter(([k])=>!k.endsWith('penalty')).map(([k,[min,max,defaultValue]])=>``).join('')}
${kind==='video'?videoDevices(data):''}${kind==='chat'?`

Penalties

${['repeat_penalty','presence_penalty','frequency_penalty'].map(k=>{const [min,max,defaultValue]=schema[k];return ``;}).join('')}
`+gpuEditor(data.parameters)+`

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.

KV-Cache

${["k","v"].map(axis=>``).join("")}

q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.

Vision-Projektor

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.

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.

`:''}

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.':''}

`; if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='Erweitert: CPU-Threads

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')