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

Deine Profile

Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.

'; 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=`

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

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

KV-Cache

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

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

Vision-Projektor

Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. Das Projektor-Gerät wird unabhängig von den Sprachmodell-GPUs gewählt. Der Sprachmodell-Split verteilt ausschließlich die Sprachmodell-Gewichte. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.

Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.

`:''}

Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}

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

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

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

Penalties

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

Penalties: Wiederholung 1,0 sowie Presence und Frequency 0 sind neutral. Wiederholung über 1 bremst wiederholte Tokens; Presence bewertet ihr Auftreten, Frequency ihre Häufigkeit. Profilwerte gelten für Testchat und API, sofern der Client keine eigenen Werte sendet.

KV-Cache

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

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

Vision-Projektor

Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. Das Projektor-Gerät wird unabhängig von den Sprachmodell-GPUs gewählt. Der Sprachmodell-Split verteilt ausschließlich die Sprachmodell-Gewichte. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.

Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.

`:''}

Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}

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

CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.

';advanced.append(threads);el('profile-form').insertBefore(advanced,el('profile-form').querySelector('.note'));} if(kind==='chat'){bindProjectorDiscovery();const f=el('profile-form');const reserveFields=()=>{for(const n of ['reserve_first','reserve_second'])f.elements[n].disabled=f.elements.gpu_reserve_mode.value!=='manual';};f.elements.gpu_reserve_mode.onchange=reserveFields;reserveFields();} if(kind==='video')bindVideoDevices(data); 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')