Add editable LLM penalties and preserve sampling through backup restore

This commit is contained in:
Mikei386 committed 2026-10-01 21:40:44 +02:00
1 parent 3779c846db
commit 37b5394df1
14 files changed
+218 -14

No files matched your search

+51
View File
@@ -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.
+3
View File
@@ -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
View File
@@ -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,{}
+2 -2
View File
@@ -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
View File
@@ -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
+73
View File
@@ -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
View File
@@ -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
View File
@@ -1,6 +1,6 @@
window.ProfilesUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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):
+11
View File
@@ -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')]:
+1 -1
View File
@@ -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)))
+46
View File
@@ -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')