Add editable LLM penalties and preserve sampling through backup restore
This commit is contained in:
1 parent
3779c846db
commit
37b5394df1
14 files changed
+218
-14
No files matched your search
@@ -0,0 +1,51 @@
|
||||
# LLM-Sampling und Penalties
|
||||
|
||||
Unter Chat & Sprachmodelle → Profile → Bearbeiten sind Temperatur, Top-p,
|
||||
Top-k und im Abschnitt **Penalties** diese Werte editierbar:
|
||||
|
||||
| Feld | Neutral | Bedeutung |
|
||||
|---|---:|---|
|
||||
| Wiederholungs-Penalty (`repeat_penalty`) | 1,0 | Über 1 dämpft bereits erzeugte Tokens |
|
||||
| Presence-Penalty | 0 | Berücksichtigt, ob ein Token bereits vorkam |
|
||||
| Frequency-Penalty | 0 | Berücksichtigt, wie oft ein Token bereits vorkam |
|
||||
|
||||
Die Profilwerte gelten im Testchat und am OpenAI-kompatiblen Chat-Endpunkt.
|
||||
Explizite Werte eines API-Clients haben Vorrang. `repeat_penalty` ist eine
|
||||
llama.cpp-Erweiterung, Presence und Frequency sind OpenAI-Felder. Es gibt keine
|
||||
Hermes-spezifische Übersetzung. Neue Felder werden bei älteren Profilen mit
|
||||
neutralen Werten ergänzt; das Speichern verändert keine GPU-Einstellungen.
|
||||
Backup/Restore übernimmt die Parameter zusammen mit den Profilen.
|
||||
|
||||
## Geprüfte Empfehlungen (2026-10-01)
|
||||
|
||||
| Gewichte | Temperatur | Top-p | Top-k | Wiederholung | Presence | Frequency |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| Qwen3.8 27B, Thinking | 1,0 | 0,95 | 20 | 1,0 | 0 | 0 |
|
||||
| Gemma 4 31B IT | 1,0 | 0,95 | 64 | 1,0 | 0 | 0 |
|
||||
|
||||
Quellen: [Qwen](https://huggingface.co/Qwen/Qwen3.8-27B#best-practices),
|
||||
[Gemma](https://huggingface.co/google/gemma-4-31B-it#best-practices),
|
||||
[llama.cpp-API](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md).
|
||||
Qwen nennt Wiederholung 1,0 und Presence 0 für Thinking ausdrücklich. Frequency
|
||||
0 ist ein neutraler Deck-Wert, keine gesonderte Modellkarten-Empfehlung.
|
||||
Gemma nennt keine abweichenden Penalties; dort bleiben sie neutral.
|
||||
|
||||
Die verwendeten MIX- und JonathanColetti-Quantisierungen nennen keine eigenen
|
||||
Sampling-Empfehlungen; die Werte des Qwen-Basismodells werden übernommen.
|
||||
Für Qwen ohne Thinking empfiehlt die Modellkarte stattdessen Temperatur 0,7,
|
||||
Top-p 0,8, Top-k 20, Presence 1,5 und Wiederholung 1,0. Deck schaltet die
|
||||
Profilwerte bei einer einzelnen Anfrage mit ausgeschaltetem Thinking nicht
|
||||
automatisch um. Bei Bedarf ein eigenes Profil mit diesen Werten speichern.
|
||||
OBLITERATUS-Empfehlungen werden nicht auf andere unzensierte Varianten übertragen.
|
||||
|
||||
## Bestehende Installation gezielt konfigurieren
|
||||
|
||||
`python3 deploy/configure_llm_sampling.py --installation /opt/athena-deck-dev/runtime`
|
||||
zeigt die geplanten Änderungen. `--apply` schreibt sie ausschließlich, wenn der
|
||||
zugehörige Deck-Container gestoppt ist, und sichert vorher die Profildatei.
|
||||
Unbekannte Modellquellen und Nicht-Chat-Profile bleiben unberührt. Das optionale
|
||||
`--add-large-ultra` ergänzt die vom alten Router angeforderten Large-/Ultra-Profile
|
||||
auf Basis des geprüften Medium-Modells; API-Freigaben werden nicht geändert.
|
||||
|
||||
Bild-, Video-, Trenn-, STT- und derzeitige Audio-Profile besitzen keine solchen
|
||||
Sampling-Felder. Die Implementierung fügt ihnen keine wirkungslosen Optionen hinzu.
|
||||
@@ -39,6 +39,9 @@ Vorhandenes Docker wird vorausgesetzt; produktive Dienste werden nicht veränder
|
||||
|
||||
## Neu: Modellverwaltung und llama.cpp-Einstellungen
|
||||
|
||||
LLM-Profile bieten editierbare [Sampling-Werte und Penalties](LLM_SAMPLING.md),
|
||||
die im Testchat und am API-Endpunkt verwendet und mit den Profilen gesichert werden.
|
||||
|
||||
Katalog, Datei-Downloads, Bibliothek, serverseitig gespeicherte Profile und
|
||||
llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden
|
||||
abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine
|
||||
|
||||
+10
-2
@@ -3,7 +3,9 @@
|
||||
Effort is a template hint, not a guaranteed compute budget. llama.cpp forwards
|
||||
positive levels to the model template; it handles 'none' as thinking disabled.
|
||||
"""
|
||||
CHAT_FIELDS=frozenset({'model','messages','stream','stream_options','temperature','top_p','top_k','max_tokens','max_completion_tokens','stop','seed','tools','tool_choice','parallel_tool_calls','response_format','presence_penalty','frequency_penalty','logprobs','top_logprobs','user','n','reasoning_effort'})
|
||||
import math
|
||||
|
||||
CHAT_FIELDS=frozenset({'model','messages','stream','stream_options','temperature','top_p','top_k','max_tokens','max_completion_tokens','stop','seed','tools','tool_choice','parallel_tool_calls','response_format','repeat_penalty','presence_penalty','frequency_penalty','logprobs','top_logprobs','user','n','reasoning_effort'})
|
||||
LLAMA_EFFORTS=frozenset({'none','low','medium','high','xhigh'})
|
||||
# The installed llama.cpp server rejects minimal and max. Use nearest supported hints.
|
||||
EFFORT_ALIASES={'minimal':'low','max':'xhigh','ultra':'xhigh'}
|
||||
@@ -14,7 +16,13 @@ def normalize_chat(data):
|
||||
"""Return a new request and non-sensitive translation metadata; never mutate input."""
|
||||
unknown=set(data)-CHAT_FIELDS
|
||||
if unknown:raise CompatibilityError('Nicht unterstützte Chat-Felder: '+', '.join(sorted(unknown)))
|
||||
body=dict(data);requested=body.get('reasoning_effort')
|
||||
body=dict(data)
|
||||
for key,lo,hi in [('repeat_penalty',0,2),('presence_penalty',-2,2),('frequency_penalty',-2,2)]:
|
||||
if key in body:
|
||||
value=body[key]
|
||||
if isinstance(value,bool) or not isinstance(value,(int,float)) or not math.isfinite(value) or not lo<=value<=hi:
|
||||
raise CompatibilityError('Ungültiger '+key+'-Wert.')
|
||||
requested=body.get('reasoning_effort')
|
||||
if requested is None:
|
||||
body.pop('reasoning_effort',None)
|
||||
return body,{}
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path,PurePosixPath
|
||||
from auth import atomic_write,validate_record
|
||||
from backup_codec import seal,open_backup
|
||||
from catalog import repo_id,file_role,KINDS
|
||||
from profiles import SCHEMAS,CHAT_GPU_DEFAULTS,chat_parameters
|
||||
from profiles import SCHEMAS,CHAT_GPU_DEFAULTS,chat_parameters,FLOAT_PARAMETERS
|
||||
SETTINGS=('separator-profiles.json','profiles.json','endpoint.json','audio-policy.json','video/comfy-selection.json','models/huggingface.json','video/comfy-client-token','video/original-work/settings.json')
|
||||
MAX_HISTORY=40*1024**2
|
||||
VERSION=1
|
||||
@@ -47,7 +47,7 @@ def validate(doc):
|
||||
if set(params)!=(set(SCHEMAS[kind])|(set(CHAT_GPU_DEFAULTS) if kind=='chat' else set(params)&{'video_device','text_encoder_device'} if kind=='video' else set())):raise ValueError('Unbekannte Profilparameter.')
|
||||
for key,(lo,hi,_) in SCHEMAS[kind].items():
|
||||
value=params[key]
|
||||
if isinstance(value,bool) or not isinstance(value,(int,float)) or not lo<=value<=hi or key not in ('temperature','top_p','guidance','speed','mtp_min_p') and type(value) is not int:raise ValueError('Ungültiger Profilparameter: '+key)
|
||||
if isinstance(value,bool) or not isinstance(value,(int,float)) or not lo<=value<=hi or key not in FLOAT_PARAMETERS and type(value) is not int:raise ValueError('Ungültiger Profilparameter: '+key)
|
||||
if kind=='chat' and (params['ubatch']>params['batch'] or params.get('vision_projector') and params['vision_projector'] not in ids):raise ValueError('Ungültige Chat-Zuordnung.')
|
||||
if kind in ('image','video') and (params['width']%64 or params['height']%64):raise ValueError('Ungültige Bildgröße.')
|
||||
components=p.get('components',{})
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from inference import InferenceError
|
||||
from profiles import generation_parameters
|
||||
|
||||
class ChatTests:
|
||||
def __init__(self,profiles,worker,scheduler):
|
||||
@@ -52,7 +53,7 @@ class ChatTests:
|
||||
with self.scheduler.lease(key,profile['parameters']['slots'],prepare,allowed=allowed):
|
||||
if self.cancel.is_set():raise InterruptedError()
|
||||
self.phase('Antwort wird erzeugt')
|
||||
body=dict(model=profile['name'],messages=messages,max_tokens=limit,stream=True,stream_options={"include_usage":True},**{k:profile['parameters'][k] for k in ('temperature','top_p','top_k')})
|
||||
body=dict(model=profile['name'],messages=messages,max_tokens=limit,stream=True,stream_options={"include_usage":True},**generation_parameters(profile['parameters']))
|
||||
conn,token=self.worker.connect()
|
||||
conn.request('POST','/v1/chat/completions',json.dumps(body).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+token})
|
||||
with self.lock:self.socket=conn.sock
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Explicit offline application of reviewed model-card defaults; never load weights."""
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
|
||||
QWEN_SOURCES={
|
||||
'jpetrina/Qwen3.8-27B-IQ4_XS-pure-GGUF',
|
||||
'vmarcelo/Qwen3.8-27B-MIX_GGUF',
|
||||
'JonathanColetti/Qwen3.8-27B-Uncensored-GGUF',
|
||||
}
|
||||
NEUTRAL=dict(repeat_penalty=1.0,presence_penalty=0.0,frequency_penalty=0.0)
|
||||
|
||||
def recommendations(entry):
|
||||
repo=entry.get('repo')
|
||||
if repo in QWEN_SOURCES:
|
||||
return dict(temperature=1.0,top_p=.95,top_k=20,**NEUTRAL)
|
||||
if repo=='ggml-org/gemma-4-31B-it-GGUF':
|
||||
return dict(temperature=1.0,top_p=.95,top_k=64,**NEUTRAL)
|
||||
return None
|
||||
|
||||
def configure(rows,entries,add_large_ultra=False):
|
||||
result=copy.deepcopy(rows);report=[]
|
||||
for row in result:
|
||||
if row.get('kind')!='chat':continue
|
||||
recommended=recommendations(entries.get(row['model_id'],{}))
|
||||
if recommended is None:
|
||||
report.append(dict(name=row['name'],state='unknown-source'));continue
|
||||
changed=any(row['parameters'].get(k)!=v for k,v in recommended.items())
|
||||
if changed:
|
||||
row['parameters'].update(recommended);row['revision']+=1;row['updated_at']=time.time()
|
||||
report.append(dict(name=row['name'],state='updated' if changed else 'unchanged',sampling=recommended))
|
||||
if add_large_ultra:
|
||||
medium=next((r for r in result if r.get('name')=='Qwen3.8 27B - Medium' and r.get('kind')=='chat'),None)
|
||||
if not medium or entries.get(medium['model_id'],{}).get('repo')!='jpetrina/Qwen3.8-27B-IQ4_XS-pure-GGUF':
|
||||
raise ValueError('Das geprüfte Medium-Profil fehlt; keine Profile erstellt.')
|
||||
for suffix,context,split,micro in [('Large',192000,[86,14],256),('Ultra',262144,[80,20],128)]:
|
||||
name='Qwen3.8 27B - '+suffix
|
||||
if any(r['name']==name for r in result):continue
|
||||
row=copy.deepcopy(medium);row.update(id=uuid.uuid4().hex,revision=1,name=name,updated_at=time.time())
|
||||
row['parameters'].update(context=context,slots=1,batch=2048,ubatch=micro,tensor_split=split,split_mode='layer',gpu_offload='full',gpu_reserve_mode='none',gpu_reserve_mib={})
|
||||
if suffix=='Ultra':row['parameters']['vision_device']='cpu'
|
||||
result.append(row);report.append(dict(name=name,state='created',sampling=recommendations(entries[medium['model_id']])))
|
||||
return result,report
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--installation',type=Path,required=True)
|
||||
parser.add_argument('--apply',action='store_true')
|
||||
parser.add_argument('--add-large-ultra',action='store_true')
|
||||
args=parser.parse_args();base=args.installation.resolve();state=base/'state';path=state/'profiles.json'
|
||||
original=path.read_bytes();rows=json.loads(original)
|
||||
entries={row['model_id']:json.loads((state/'models'/row['model_id']/'entry.json').read_text()) for row in rows}
|
||||
configured,report=configure(rows,entries,args.add_large_ultra)
|
||||
if args.apply and configured!=rows:
|
||||
manifest=json.loads((base/'installation.json').read_text())
|
||||
container=json.loads(subprocess.check_output(['docker','inspect',manifest['name']],text=True))[0]
|
||||
if container['Config'].get('Labels',{}).get('de.casaderoll.athena-deck.standalone')!=str(base) or container['State']['Running']:
|
||||
raise RuntimeError('Nur bei gestopptem, dieser Installation zugeordnetem Deck-Container anwenden.')
|
||||
if path.read_bytes()!=original:raise RuntimeError('Profile wurden zwischenzeitlich geändert.')
|
||||
backup=base/'backups'/str(time.time_ns());backup.mkdir(parents=True,mode=0o700)
|
||||
(backup/'profiles.json').write_bytes(original)
|
||||
temporary=path.with_suffix('.sampling.tmp');temporary.write_text(json.dumps(configured))
|
||||
metadata=path.stat();os.chmod(temporary,metadata.st_mode);os.chown(temporary,metadata.st_uid,metadata.st_gid)
|
||||
temporary.replace(path)
|
||||
print(json.dumps(dict(applied=args.apply,profiles=report),ensure_ascii=False,indent=2))
|
||||
|
||||
if __name__=='__main__':main()
|
||||
+2
-1
@@ -12,6 +12,7 @@ import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
||||
from api_compat import normalize_chat,CompatibilityError
|
||||
from profiles import generation_parameters
|
||||
from inference import InferenceError
|
||||
from stt import read_upload
|
||||
|
||||
@@ -325,7 +326,7 @@ class APIHandler(BaseHTTPRequestHandler):
|
||||
def allowed():return ep.allowed() and any(p['id']==profile['id'] and p['revision']==profile['revision'] and p['enabled'] for p in ep.rows())
|
||||
with ep.scheduler.lease(key,profile['parameters']['slots'],lambda:ep.worker.ensure(profile),allowed=allowed):
|
||||
body=dict(data)
|
||||
for field in ('temperature','top_p','top_k'):body.setdefault(field,profile['parameters'][field])
|
||||
for field,value in generation_parameters(profile['parameters']).items():body.setdefault(field,value)
|
||||
conn,key=ep.worker.connect()
|
||||
try:
|
||||
conn.request('POST','/v1/chat/completions',body=json.dumps(body).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+key})
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
window.ProfilesUI=(()=>{
|
||||
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const labels={video_device:'Videopipeline',text_encoder_device:'Textencoder-Gerät',gpu_reserve_mode:'GPU-Reservemodus',gpu_reserve_mib:'GPU-Reserve (MiB)',vision_projector:'Vision-Projektor',vision_device:'Projektor-Gerät',cache_type_k:'KV-Cache K',cache_type_v:'KV-Cache V',gpu_offload:'GPU-Ausführung',mtp:'MTP',mtp_tokens:'MTP: maximale Draft-Token',mtp_min_p:'MTP: Mindestwahrscheinlichkeit',temperature:'Temperature',top_p:'Top-p',top_k:'Top-k',gpu_devices:'GPUs (in Reihenfolge)',split_mode:'GPU-Split',tensor_split:'GPU-Verteilung',context:'Gesamtes Kontextbudget (Token)',slots:'Parallele Slots',threads:'CPU-Threads',batch:'Batch-Größe',ubatch:'Microbatch-Größe',width:'Breite (Pixel)',height:'Höhe (Pixel)',steps:'Schritte',seed:'Seed (−1 = zufällig)',guidance:'Guidance / CFG',speed:'Sprechgeschwindigkeit',frames:'Bildanzahl',fps:'Bilder pro Sekunde'};
|
||||
const labels={video_device:'Videopipeline',text_encoder_device:'Textencoder-Gerät',gpu_reserve_mode:'GPU-Reservemodus',gpu_reserve_mib:'GPU-Reserve (MiB)',vision_projector:'Vision-Projektor',vision_device:'Projektor-Gerät',cache_type_k:'KV-Cache K',cache_type_v:'KV-Cache V',gpu_offload:'GPU-Ausführung',mtp:'MTP',mtp_tokens:'MTP: maximale Draft-Token',mtp_min_p:'MTP: Mindestwahrscheinlichkeit',repeat_penalty:'Wiederholungs-Penalty',presence_penalty:'Presence-Penalty',frequency_penalty:'Frequency-Penalty',temperature:'Temperature',top_p:'Top-p',top_k:'Top-k',gpu_devices:'GPUs (in Reihenfolge)',split_mode:'GPU-Split',tensor_split:'GPU-Verteilung',context:'Gesamtes Kontextbudget (Token)',slots:'Parallele Slots',threads:'CPU-Threads',batch:'Batch-Größe',ubatch:'Microbatch-Größe',width:'Breite (Pixel)',height:'Höhe (Pixel)',steps:'Schritte',seed:'Seed (−1 = zufällig)',guidance:'Guidance / CFG',speed:'Sprechgeschwindigkeit',frames:'Bildanzahl',fps:'Bilder pro Sekunde'};
|
||||
const html=()=>'<section id="live-profiles"><div class="section-heading"><div><h2>Deine Profile</h2><p>Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.</p></div><button id="profile-new">Neues Profil</button></div><p id="profile-message" role="status"></p><div id="profile-list" class="profile-list"></div><div id="profile-editor"></div></section>';
|
||||
async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;}
|
||||
function bind(kind,modelId){
|
||||
@@ -135,7 +135,7 @@ 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=`<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).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','mtp_min_p'].includes(k)?'any':['width','height'].includes(k)?64:1}" value="${e(data.parameters[k]??defaultValue)}"></label>`).join('')}</div>${kind==='video'?videoDevices(data):''}${kind==='chat'?gpuEditor(data.parameters)+`<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>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','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'){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);
|
||||
|
||||
+8
-3
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
from catalog import file_role
|
||||
|
||||
SCHEMAS={
|
||||
'chat':{'context':(512,2097152,8192),'slots':(1,16,1),'threads':(1,256,6),'batch':(1,8192,512),'ubatch':(1,8192,128),'temperature':(0,5,.8),'top_p':(0,1,.95),'top_k':(0,1000,40),'mtp_tokens':(1,8,2),'mtp_min_p':(0,1,.05)},
|
||||
'chat':{'context':(512,2097152,8192),'slots':(1,16,1),'threads':(1,256,6),'batch':(1,8192,512),'ubatch':(1,8192,128),'temperature':(0,5,.8),'top_p':(0,1,.95),'top_k':(0,1000,40),'repeat_penalty':(0,2,1.0),'presence_penalty':(-2,2,0.0),'frequency_penalty':(-2,2,0.0),'mtp_tokens':(1,8,2),'mtp_min_p':(0,1,.05)},
|
||||
'image':{'width':(256,2048,1024),'height':(256,2048,1024),'steps':(1,100,25),'seed':(-1,2147483647,-1),'guidance':(0,30,1)},
|
||||
'audio':{'speed':(.25,4,1)},
|
||||
'stt':{},'music':{},'voice':{},
|
||||
@@ -26,7 +26,12 @@ def video_parameters(params):
|
||||
return params
|
||||
|
||||
CHAT_GPU_DEFAULTS={'gpu_devices':[], 'split_mode':'none', 'tensor_split':[], 'mtp':False,'gpu_offload':'auto','gpu_reserve_mode':'auto','gpu_reserve_mib':{},'vision_projector':None,'vision_device':'cpu','cache_type_k':'q4_0','cache_type_v':'q4_0'}
|
||||
CHAT_SAMPLING={'temperature':.8,'top_p':.95,'top_k':40,'mtp_tokens':2,'mtp_min_p':.05}
|
||||
FLOAT_PARAMETERS=frozenset({'guidance','speed','temperature','top_p','mtp_min_p','repeat_penalty','presence_penalty','frequency_penalty'})
|
||||
CHAT_GENERATION_DEFAULTS={'temperature':.8,'top_p':.95,'top_k':40,'repeat_penalty':1.0,'presence_penalty':0.0,'frequency_penalty':0.0}
|
||||
CHAT_SAMPLING={**CHAT_GENERATION_DEFAULTS,'mtp_tokens':2,'mtp_min_p':.05}
|
||||
|
||||
def generation_parameters(params):
|
||||
return {key:params.get(key,default) for key,default in CHAT_GENERATION_DEFAULTS.items()}
|
||||
|
||||
def chat_parameters(params):
|
||||
params={**CHAT_GPU_DEFAULTS,**CHAT_SAMPLING,**params}
|
||||
@@ -185,7 +190,7 @@ class Profiles:
|
||||
if not isinstance(params,dict) or set(params)!=(set(SCHEMAS[kind]) | (set(CHAT_GPU_DEFAULTS) if kind=='chat' else set(VIDEO_DEVICE_DEFAULTS) if kind=='video' else set())):raise ValueError('Unvollständige oder unbekannte Profilparameter.')
|
||||
for key,(lo,hi,_) in SCHEMAS[kind].items():
|
||||
value=params[key]
|
||||
floating=key in ('guidance','speed','temperature','top_p','mtp_min_p')
|
||||
floating=key in FLOAT_PARAMETERS
|
||||
if isinstance(value,bool) or not isinstance(value,(float,int) if floating else int) or not lo<=value<=hi:raise ValueError('Ungültiger Parameter: '+key)
|
||||
if kind in ('image','video') and (params['width']%64 or params['height']%64):raise ValueError('Breite und Höhe müssen durch 64 teilbar sein.')
|
||||
if kind=='chat' and params['ubatch']>params['batch']:raise ValueError('Microbatch darf nicht größer als Batch sein.')
|
||||
|
||||
+2
-2
@@ -37,7 +37,7 @@ class RestoreTests(unittest.TestCase):
|
||||
data=b'model-weights-not-in-backup';repo='test/model';revision='a'*40;filename='model.gguf';ident=hashlib.sha256((repo+revision+filename).encode()).hexdigest();p=self.root/'models'/ident;p.mkdir(parents=True)
|
||||
(p/'model.gguf').write_bytes(data);entry=dict(repo=repo,revision=revision,file=filename,size=len(data),sha256=hashlib.sha256(data).hexdigest(),kind='chat',state='downloaded');(p/'entry.json').write_text(json.dumps(entry));return ident
|
||||
def profile(self,ident):
|
||||
params={k:v[2] for k,v in SCHEMAS['chat'].items()};params.update(CHAT_GPU_DEFAULTS);params.update(CHAT_SAMPLING)
|
||||
params={k:v[2] for k,v in SCHEMAS['chat'].items()};params.update(CHAT_GPU_DEFAULTS);params.update(CHAT_SAMPLING);params.update(repeat_penalty=1.15,presence_penalty=.5,frequency_penalty=.2)
|
||||
self.server.profiles.save(dict(id=None,revision=0,name='Test Model',kind='chat',model_id=ident,parameters=params))
|
||||
def wait(self):
|
||||
for _ in range(100):
|
||||
@@ -63,7 +63,7 @@ class RestoreTests(unittest.TestCase):
|
||||
ident=self.model();self.profile(ident);raw=self.server.backup.export('backup secure password');self.server.profiles.rows=[];(self.root/'profiles.json').write_text('[]')
|
||||
current=self.server.credentials.read();summary=self.server.backup.inspect(raw,'backup secure password');self.assertFalse(summary['blockers'])
|
||||
self.server.backup.start(dict(id=summary['id'],services=[],confirm=True,restore_credentials=False));job=self.wait()
|
||||
self.assertEqual(job['state'],'complete',job);self.assertEqual(self.server.profiles.rows[0]['name'],'Test Model');self.assertEqual(self.server.credentials.read(),current);self.assertTrue(any(x['state']=='reused' for x in job['items']));self.assertEqual(len(list((self.root/'recovery').glob('before-*.adbackup'))),1)
|
||||
self.assertEqual(job['state'],'complete',job);self.assertEqual(self.server.profiles.rows[0]['name'],'Test Model');self.assertEqual(self.server.profiles.rows[0]['parameters']['repeat_penalty'],1.15);self.assertEqual(self.server.credentials.read(),current);self.assertTrue(any(x['state']=='reused' for x in job['items']));self.assertEqual(len(list((self.root/'recovery').glob('before-*.adbackup'))),1)
|
||||
def test_empty_target_downloads_pinned_model_then_restores(self):
|
||||
ident=self.model();self.profile(ident);doc=self.server.backup.snapshot();data=(self.root/'models'/ident/'model.gguf').read_bytes()
|
||||
import shutil
|
||||
|
||||
@@ -33,6 +33,11 @@ class ChatTestsTests(unittest.TestCase):
|
||||
def test_answer_stream_and_no_prompt_in_status(self):
|
||||
self.manager.start(self.request());job=self.finish();self.assertEqual(job['state'],'complete');self.assertEqual(job['answer'],'OK');self.assertNotIn('synthetic test',json.dumps(job));self.assertNotIn('messages',job)
|
||||
self.assertTrue(self.worker.loaded);self.manager.unload();self.assertFalse(self.worker.loaded)
|
||||
def test_profile_penalties_reach_test_worker(self):
|
||||
self.profile['parameters'].update(repeat_penalty=1.15,presence_penalty=.5,frequency_penalty=.2)
|
||||
self.manager.start(self.request());self.assertEqual(self.finish()['state'],'complete')
|
||||
body=json.loads(self.worker.conn.request.call_args.args[2])
|
||||
self.assertEqual([body[k] for k in ('repeat_penalty','presence_penalty','frequency_penalty')],[1.15,.5,.2])
|
||||
def test_memory_rejection_reaches_user_and_releases_lease(self):
|
||||
self.worker.error='GPU-Split passt nicht in den Speicher.';self.manager.start(self.request());job=self.finish();self.assertEqual(job['state'],'failed');self.assertIn('Speicher',job['error']);self.assertEqual(self.manager.scheduler.active,0)
|
||||
def test_queue_cancel_does_not_stop_other_request(self):
|
||||
|
||||
@@ -209,6 +209,17 @@ class EndpointTests(unittest.TestCase):
|
||||
self.assertEqual(status,200);self.assertEqual(data['model'],name)
|
||||
self.assertEqual(self.worker.stopped,['alpha']);self.assertEqual(self.worker.requests[-1]['temperature'],.2)
|
||||
self.assertEqual(self.ep.status()['counts']['llm']['enabled'],2)
|
||||
def test_profile_penalties_and_explicit_client_override(self):
|
||||
self.enable('alpha')
|
||||
self.rows[0]['parameters'].update(repeat_penalty=1.15,presence_penalty=.5,frequency_penalty=.2)
|
||||
request=dict(model='alpha',messages=[dict(role='user',content='synthetic')])
|
||||
self.assertEqual(self.request('/v1/chat/completions',request)[0],200)
|
||||
sent=self.worker.requests[-1]
|
||||
self.assertEqual([sent[k] for k in ('repeat_penalty','presence_penalty','frequency_penalty')],[1.15,.5,.2])
|
||||
self.assertEqual(self.request('/v1/chat/completions',dict(request,presence_penalty=0,repeat_penalty=1))[0],200)
|
||||
self.assertEqual(self.worker.requests[-1]['presence_penalty'],0)
|
||||
self.assertEqual(self.worker.requests[-1]['repeat_penalty'],1)
|
||||
self.assertEqual(self.request('/v1/chat/completions',dict(request,repeat_penalty=-1))[0],400)
|
||||
def test_unknown_or_unsupported_request_does_not_load(self):
|
||||
self.enable('alpha')
|
||||
for req in [dict(model='unknown',messages=[{}]),dict(model='alpha',messages=[dict(content=[dict(type='image_url')])]),dict(model='alpha',messages=[{}],cache_file='/tmp/foo')]:
|
||||
|
||||
@@ -39,7 +39,7 @@ class ManagementTests(unittest.TestCase):
|
||||
req.update(id=saved['id'],revision=1)
|
||||
self.profiles.save(req)
|
||||
restarted=Profiles(self.root/'profiles.json',self.catalog)
|
||||
self.assertEqual(restarted.status()['profiles'][0]['parameters'],dict(params,mtp=False,mtp_tokens=2,mtp_min_p=.05,gpu_offload='auto',gpu_reserve_mode='auto',gpu_reserve_mib={},vision_projector=None,vision_device='cpu',cache_type_k='q4_0',cache_type_v='q4_0'))
|
||||
self.assertEqual(restarted.status()['profiles'][0]['parameters'],dict(params,repeat_penalty=1.0,presence_penalty=0.0,frequency_penalty=0.0,mtp=False,mtp_tokens=2,mtp_min_p=.05,gpu_offload='auto',gpu_reserve_mode='auto',gpu_reserve_mib={},vision_projector=None,vision_device='cpu',cache_type_k='q4_0',cache_type_v='q4_0'))
|
||||
for change in [dict(gpu_offload='unknown'),dict(mtp='yes'),dict(mtp_tokens=0),dict(mtp_tokens=True),dict(mtp_min_p=1.1),dict(gpu_devices=devices*2),dict(tensor_split=[85]),dict(split_mode='none'),dict(top_p=1.1),dict(temperature=float('nan')),dict(top_k=True),dict(tensor_split=[0,100]),dict(gpu_devices=['CUDA0','CUDA1'])]:
|
||||
with self.subTest(change=change),self.assertRaises(ValueError):self.profiles.save(dict(req,revision=2,parameters=dict(params,**change)))
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from profiles import Profiles,SCHEMAS,chat_parameters
|
||||
from api_compat import normalize_chat,CompatibilityError
|
||||
from deploy.configure_llm_sampling import configure
|
||||
|
||||
class SamplingTests(unittest.TestCase):
|
||||
def test_legacy_save_roundtrip_and_validation(self):
|
||||
model=dict(id='m',kind='chat',profile_eligible=True,repo='test/chat',file='model.gguf')
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'profiles.json';catalog=SimpleNamespace(entry=lambda _:model)
|
||||
profiles=Profiles(path,catalog)
|
||||
params={k:v[2] for k,v in SCHEMAS['chat'].items() if not k.endswith('penalty')}
|
||||
payload=dict(id=None,revision=0,name='Legacy',kind='chat',model_id='m',parameters=params)
|
||||
saved=profiles.save(payload)
|
||||
self.assertEqual(saved['parameters']['repeat_penalty'],1.0)
|
||||
payload.update(id=saved['id'],revision=saved['revision'],parameters=saved['parameters'])
|
||||
payload['parameters'].update(repeat_penalty=1.15,presence_penalty=.5,frequency_penalty=-.2)
|
||||
profiles.save(payload)
|
||||
restored=Profiles(path,catalog).rows[0]
|
||||
self.assertEqual(restored['parameters']['repeat_penalty'],1.15)
|
||||
for field,value in [('repeat_penalty',-1),('presence_penalty',3),('frequency_penalty',float('nan')),('repeat_penalty',True)]:
|
||||
bad=copy.deepcopy(payload);bad['revision']=restored['revision'];bad['parameters'][field]=value
|
||||
with self.assertRaises(ValueError):profiles.save(bad)
|
||||
self.assertEqual(json.loads(path.read_text())[0],restored)
|
||||
def test_client_penalties_validation_and_no_mutation(self):
|
||||
request=dict(repeat_penalty=1.15,presence_penalty=.5,frequency_penalty=-.2)
|
||||
self.assertEqual(normalize_chat(request)[0],request)
|
||||
for field,value in [('repeat_penalty',-1),('presence_penalty',3),('frequency_penalty',float('inf')),('repeat_penalty',True)]:
|
||||
with self.assertRaises(CompatibilityError):normalize_chat({field:value})
|
||||
def test_preset_application_preserves_unrelated_settings_and_is_idempotent(self):
|
||||
rows=[dict(id='q',name='Qwen3.8 27B - Medium',kind='chat',model_id='q',revision=7,parameters=dict(context=160000,slots=1,batch=2048,ubatch=256,gpu_devices=['5080','3060'],tensor_split=[85,15],vision_device='3060',temperature=.2)),dict(id='i',name='Image',kind='image',model_id='i',parameters={}),dict(id='u',name='Unknown',kind='chat',model_id='u',parameters={})]
|
||||
entries={'q':dict(repo='jpetrina/Qwen3.8-27B-IQ4_XS-pure-GGUF')}
|
||||
result,report=configure(rows,entries)
|
||||
self.assertEqual(result[0]['parameters']['temperature'],1)
|
||||
self.assertEqual(result[0]['revision'],8)
|
||||
for key in ('context','slots','batch','ubatch','gpu_devices','tensor_split','vision_device'):
|
||||
self.assertEqual(result[0]['parameters'][key],rows[0]['parameters'][key])
|
||||
self.assertEqual(result[1:],rows[1:]);self.assertEqual(rows[0]['parameters']['temperature'],.2)
|
||||
self.assertEqual(configure(result,entries)[0],result)
|
||||
expanded,_=configure(result,entries,True)
|
||||
self.assertEqual(expanded[-2]['parameters']['tensor_split'],[86,14]);self.assertEqual(expanded[-1]['parameters']['vision_device'],'cpu')
|
||||
Reference in new issue
Block a user