diff --git a/LLM_SAMPLING.md b/LLM_SAMPLING.md
new file mode 100644
index 0000000..20970df
--- /dev/null
+++ b/LLM_SAMPLING.md
@@ -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.
diff --git a/README.md b/README.md
index d3d8252..3ed10b4 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/api_compat.py b/api_compat.py
index 47e6c94..2486520 100644
--- a/api_compat.py
+++ b/api_compat.py
@@ -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,{}
diff --git a/backup.py b/backup.py
index 4c12be9..5d9b935 100644
--- a/backup.py
+++ b/backup.py
@@ -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',{})
diff --git a/chat_test.py b/chat_test.py
index 0a0e51f..2aac9a3 100644
--- a/chat_test.py
+++ b/chat_test.py
@@ -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
diff --git a/deploy/configure_llm_sampling.py b/deploy/configure_llm_sampling.py
new file mode 100644
index 0000000..48f6577
--- /dev/null
+++ b/deploy/configure_llm_sampling.py
@@ -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()
diff --git a/endpoint.py b/endpoint.py
index 4d3e011..3a3eee6 100644
--- a/endpoint.py
+++ b/endpoint.py
@@ -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})
diff --git a/profiles-ui.js b/profiles-ui.js
index a83ae8e..f7af383 100644
--- a/profiles-ui.js
+++ b/profiles-ui.js
@@ -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=()=>' Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.Deine Profile
${data.id?'Profil bearbeiten':'Profil anlegen'}
${data.id?'Profil bearbeiten':'Profil anlegen'}
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); diff --git a/profiles.py b/profiles.py index 7cefd02..bba672d 100644 --- a/profiles.py +++ b/profiles.py @@ -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.') diff --git a/test_backup.py b/test_backup.py index e42f27c..5302259 100644 --- a/test_backup.py +++ b/test_backup.py @@ -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 diff --git a/test_chat_test.py b/test_chat_test.py index 40b273f..e7a08b8 100644 --- a/test_chat_test.py +++ b/test_chat_test.py @@ -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): diff --git a/test_endpoint.py b/test_endpoint.py index 14f1233..49b0046 100644 --- a/test_endpoint.py +++ b/test_endpoint.py @@ -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')]: diff --git a/test_model_management.py b/test_model_management.py index 4d9ffe7..6fb4c68 100644 --- a/test_model_management.py +++ b/test_model_management.py @@ -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))) diff --git a/test_sampling.py b/test_sampling.py new file mode 100644 index 0000000..aeb9ced --- /dev/null +++ b/test_sampling.py @@ -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')