403 lines
30 KiB
Python
403 lines
30 KiB
Python
from video_proxy import relay
|
||
"""Separate authenticated OpenAI-compatible API; only explicitly enabled Deck profiles."""
|
||
import base64
|
||
import hashlib
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import secrets
|
||
import re
|
||
import socket
|
||
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
|
||
|
||
class APIError(ValueError):
|
||
def __init__(self,message,status=400,code='invalid_request_error'):
|
||
super().__init__(message);self.status=status;self.code=code
|
||
|
||
def chat_upstream_error(status, raw):
|
||
"""Expose known, safe llama.cpp limits without reflecting request content."""
|
||
try:
|
||
detail=json.loads(raw)
|
||
error=detail.get('error',{}) if isinstance(detail,dict) else {}
|
||
if error.get('type')=='exceed_context_size_error':
|
||
needed=error.get('n_prompt_tokens');available=error.get('n_ctx')
|
||
if all(type(value) is int and 0<value<=2097152 for value in (needed,available)):
|
||
needed_text=f'{needed:,}'.replace(',','.');available_text=f'{available:,}'.replace(',','.')
|
||
return APIError(
|
||
f'Die Anfrage benötigt {needed_text} Kontext-Tokens, das aktive Profil bietet {available_text}. '
|
||
'Kontext im Profil erhöhen oder die Anfrage verkürzen.',
|
||
400,'context_length_exceeded')
|
||
except (ValueError,TypeError,AttributeError):
|
||
pass
|
||
return APIError('llama.cpp hat die Anfrage abgelehnt. Kontext und Anfrageparameter prüfen.',
|
||
status if 400<=status<600 else 502,'upstream_error')
|
||
|
||
class Endpoint:
|
||
def __init__(self,root,profiles,worker,scheduler,images,credentials,management_port):
|
||
self.root=Path(root);self.profiles=profiles;self.worker=worker;self.scheduler=scheduler;self.images=images;self.tts=None;self.voxcpm=None;self.embeddings=None;self.stt=None;self.music=None;self.video=None;self.credentials=credentials
|
||
self.management_port=management_port;self.lock=threading.RLock();self.http=None;self.thread=None;self.state='stopped';self.error=None;self.inflight=0
|
||
self.allowed_ports=[int(p) for p in os.environ.get('DECK_API_PORTS','').split(',') if p]
|
||
self.config=dict(port=self.allowed_ports[0] if self.allowed_ports else 8120,enabled_profiles=[],autostart=False)
|
||
path=self.root/'endpoint.json'
|
||
if path.exists():self.config.update(json.loads(path.read_text()))
|
||
image_ids={p['id'] for p in self.profiles.status()['profiles'] if p['kind']=='image'}
|
||
selected=[i for i in self.config['enabled_profiles'] if i in image_ids]
|
||
if len(selected)>1:
|
||
self.config['enabled_profiles']=[i for i in self.config['enabled_profiles'] if i not in image_ids or i==selected[0]];self.persist()
|
||
def persist(self):
|
||
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||
path=self.root/'endpoint.tmp';path.write_text(json.dumps(self.config));path.replace(self.root/'endpoint.json')
|
||
def rows(self):
|
||
rows=self.profiles.status()['profiles']
|
||
with self.lock:enabled=set(self.config['enabled_profiles'])
|
||
return [dict(p,enabled=p['id'] in enabled) for p in rows if p['kind']!='video']
|
||
def status(self):
|
||
rows=self.rows();worker=self.worker.status();job=self.images.status()['job'];counts={}
|
||
for key,kind in [('llm','chat'),('image','image'),('tts','audio'),('stt','stt'),('music','music')]:
|
||
subset=[p for p in rows if p['kind']==kind]
|
||
counts[key]=dict(enabled=sum(p['enabled'] for p in subset),available=sum(p['enabled'] and p['runnable'] for p in subset),loaded=bool(worker['state']=='ready' and kind=='chat') if kind=='chat' else bool(self.tts and self.tts.status().get('loaded')) if kind=='audio' else bool(self.stt and self.stt.status().get('loaded')) if kind=='stt' else bool(self.music and self.music.status().get('loaded')) if kind=='music' else bool(kind=='image' and job and job['state']=='running'),supported=kind in ('chat','image') or (kind=='audio' and self.tts is not None) or (kind=='stt' and self.stt is not None) or (kind=='music' and self.music is not None))
|
||
scheduler_status=self.scheduler.status()
|
||
# Video status can call back into endpoint.rows(); never hold our lock
|
||
# while acquiring another service's lock. Concurrent GUI requests otherwise deadlock.
|
||
video_status=self.video.status() if self.video else None
|
||
with self.lock:return dict(video=video_status,state=self.state,reachable=bool(self.thread and self.thread.is_alive() and self.state=='running'),port=self.config['port'],bind=os.environ.get('DECK_API_BIND','127.0.0.1'),base_url=f"http://127.0.0.1:{self.config['port']}/v1",allowed_ports=self.allowed_ports,error=self.error,counts=counts,worker=worker,scheduler=scheduler_status,profiles=[dict(id=p['id'],name=p['name'],kind=p['kind'],enabled=p['enabled'],runnable=p['runnable'],blockers=p['blockers']) for p in rows],active_requests=self.inflight)
|
||
def configure(self,data):
|
||
if set(data)!={'port'} or type(data['port']) is not int or not 1024<=data['port']<=65535:raise ValueError('Port zwischen 1024 und 65535 erforderlich.')
|
||
port=data['port']
|
||
with self.lock:
|
||
if self.state!='stopped':raise ValueError('Endpunkt zuerst vollständig stoppen.')
|
||
if port==self.management_port:raise ValueError('Der Verwaltungsport ist bereits belegt.')
|
||
if self.allowed_ports and port not in self.allowed_ports:raise ValueError('Dieser Port ist im Docker-Installer nicht freigegeben.')
|
||
try:
|
||
with socket.socket() as sock:sock.bind((os.environ.get('DECK_API_BIND','127.0.0.1'),port))
|
||
except OSError:raise ValueError('Port ist bereits belegt.') from None
|
||
self.config['port']=port;self.persist()
|
||
return self.status()
|
||
def enable(self,data):
|
||
if set(data)!={'id','enabled'} or type(data['enabled']) is not bool:raise ValueError('Profil-ID und Aktivierung erforderlich.')
|
||
rows=self.rows()
|
||
row=next((p for p in rows if p['id']==data['id']),None)
|
||
if not row:raise ValueError('Profil nicht gefunden.')
|
||
if data['enabled'] and not row['runnable']:raise ValueError('Profil nicht ausführbar: '+' '.join(row['blockers']))
|
||
with self.lock:
|
||
enabled=set(self.config['enabled_profiles'])
|
||
if data['enabled']:
|
||
if row['kind']=='image':enabled.difference_update(p['id'] for p in rows if p['kind']=='image')
|
||
enabled.add(data['id'])
|
||
else:enabled.discard(data['id'])
|
||
self.config['enabled_profiles']=sorted(enabled);self.persist()
|
||
if not data['enabled'] and row['kind']=='audio' and self.tts:
|
||
job=self.tts.status().get('job') or {}
|
||
if job.get('profile_id')==row['id'] and job.get('state')!='running':self.tts.stop()
|
||
if not data['enabled'] and row['kind']=='stt' and self.stt:
|
||
job=self.stt.status().get('job') or {}
|
||
if job.get('profile_id')==row['id'] and job.get('state')!='running':self.stt.stop()
|
||
return self.status()
|
||
def start(self):
|
||
with self.lock:
|
||
if self.state=='running':return {'state':'running','port':self.config['port']}
|
||
if self.state!='stopped':raise ValueError('Endpunkt wird noch gestoppt.')
|
||
record=self.credentials.read()
|
||
if not record or not record.get('api_token_hash'):raise ValueError('Zuerst unter Zugang & API einen API-Token einrichten.')
|
||
if self.allowed_ports and self.config['port'] not in self.allowed_ports:raise ValueError('Gespeicherter Port ist nicht im Docker-Installer freigegeben.')
|
||
if self.config['port']==self.management_port:raise ValueError('Verwaltungsport kann nicht als API-Port verwendet werden.')
|
||
try:http=APIHTTPServer((os.environ.get('DECK_API_BIND','127.0.0.1'),self.config['port']),APIHandler)
|
||
except OSError:raise ValueError('API-Port bereits belegt; kein anderer Dienst wurde verändert.') from None
|
||
http.endpoint=self;self.http=http;self.state='running';self.error=None
|
||
self.thread=threading.Thread(target=http.serve_forever,daemon=True);self.thread.start()
|
||
self.config['autostart']=True;self.persist()
|
||
return self.status()
|
||
def stop(self):
|
||
with self.lock:
|
||
self.config['autostart']=False;self.persist()
|
||
if self.state=='running':
|
||
self.state='stopping';threading.Thread(target=self._drain,daemon=True).start()
|
||
return self.status()
|
||
def _drain(self):
|
||
http=self.http
|
||
if http:http.shutdown();http.server_close()
|
||
while True:
|
||
with self.lock:pending=self.inflight
|
||
if not pending and self.scheduler.unload_idle():break
|
||
time.sleep(.1)
|
||
if self.embeddings:self.embeddings.stop()
|
||
with self.lock:self.http=None;self.thread=None;self.state='stopped'
|
||
def close(self):
|
||
# Process shutdown does not change the user's autostart preference.
|
||
with self.lock:self.state='stopping';http=self.http
|
||
if http:http.shutdown();http.server_close()
|
||
if self.music:self.music.stop()
|
||
if self.tts:self.tts.stop()
|
||
if self.embeddings:self.embeddings.stop()
|
||
if self.voxcpm:self.voxcpm.stop()
|
||
if self.stt:self.stt.stop()
|
||
self.images.stop();self.worker.stop()
|
||
def allowed(self):
|
||
with self.lock:return self.state=='running'
|
||
def authenticate(self,header):
|
||
if not header.startswith('Bearer ') or len(header)>300:return False
|
||
record=self.credentials.read()
|
||
return bool(record and record.get('api_token_hash') and secrets.compare_digest(hashlib.sha256(header[7:].encode()).hexdigest(),record['api_token_hash']))
|
||
def find_profile(self,name,kind):
|
||
if not isinstance(name,str):raise APIError('model muss den API-Namen eines aktivierten Profils enthalten.')
|
||
row=next((p for p in self.rows() if (p['name']==name or (kind=='image' and name=='athena-image')) and p['kind']==kind and p['enabled']),None)
|
||
if not row:raise APIError('Modellprofil nicht aktiviert oder unbekannt.',404,'model_not_found')
|
||
if not row['runnable']:raise APIError('Profil derzeit nicht ausführbar: '+' '.join(row['blockers']),503,'model_unavailable')
|
||
return row
|
||
def model_list(self,kind="chat"):
|
||
return dict(object='list',data=[dict(id='athena-image' if kind=='image' else p['name'],object='model',created=int(p['updated_at']),owned_by='athena-deck') for p in self.rows() if p['enabled'] and p['runnable'] and p['kind']==kind])
|
||
|
||
class APIHTTPServer(ThreadingHTTPServer):
|
||
daemon_threads=True
|
||
def __init__(self,*args,**kwargs):
|
||
self.admission=threading.BoundedSemaphore(32);super().__init__(*args,**kwargs)
|
||
def process_request(self,request,address):
|
||
if not self.admission.acquire(blocking=False):self.shutdown_request(request);return
|
||
try:super().process_request(request,address)
|
||
except Exception:self.admission.release();raise
|
||
def process_request_thread(self,*args):
|
||
try:super().process_request_thread(*args)
|
||
finally:self.admission.release()
|
||
def handle_error(self,*args):pass # No request bodies, prompts, or traces in logs.
|
||
|
||
class APIHandler(BaseHTTPRequestHandler):
|
||
protocol_version='HTTP/1.1'
|
||
def setup(self):super().setup();self.connection.settimeout(15);self.sent=False
|
||
def log_message(self,*args):pass
|
||
def compatibility_headers(self):
|
||
info=getattr(self,'compatibility',{})
|
||
if info.get('ignored'):
|
||
self.send_header('X-Athena-Compatibility-Ignored',','.join(info['ignored']))
|
||
if 'requested' in info:
|
||
self.send_header('X-Athena-Reasoning-Requested',info['requested'])
|
||
self.send_header('X-Athena-Reasoning-Effective',info['effective'])
|
||
self.send_header('X-Athena-Reasoning-Semantics',info['semantics'])
|
||
def send(self,payload,status=200):
|
||
body=json.dumps(payload).encode();self.sent=True
|
||
self.send_response(status);self.compatibility_headers();self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.send_header('Connection','close');self.end_headers();self.wfile.write(body);self.close_connection=True
|
||
def failure(self,exc):
|
||
if self.sent:return
|
||
self.send({'error':{'message':str(exc),'type':getattr(exc,'code','server_error'),'param':None,'code':getattr(exc,'code','worker_unavailable')}},getattr(exc,'status',503))
|
||
def video_route(self,ep):
|
||
if self.path.startswith('/v1/videos'):
|
||
raise APIError('Video wird ausschließlich über die originale LTX-API auf dem separaten LTX-Port bedient.',410,'use_ltx_api')
|
||
return False
|
||
def do_GET(self):self.route()
|
||
def do_POST(self):self.route()
|
||
def do_PUT(self):self.route()
|
||
def do_PATCH(self):self.route()
|
||
def do_DELETE(self):self.route()
|
||
def do_HEAD(self):self.route()
|
||
def do_OPTIONS(self):self.route()
|
||
def route(self):
|
||
ep=self.server.endpoint;admitted=False
|
||
try:
|
||
browser=bool(ep.video and ep.scheduler.gpu_mode=='video' and getattr(ep,'video_browser_auth',lambda h:False)(self.headers))
|
||
service_auth=bool(ep.video and callable(getattr(type(ep.video),'service_authenticated',None)) and ep.video.service_authenticated(self.headers.get('Authorization','')))
|
||
if not browser and not service_auth and not ep.authenticate(self.headers.get('Authorization','')):raise APIError('Gültiger API-Bearer-Token oder Deck-Anmeldung erforderlich.',401,'invalid_api_key')
|
||
origin=self.headers.get('Origin')
|
||
if origin and (not browser or origin not in ('http://'+self.headers.get('Host',''),'https://'+self.headers.get('Host',''))):raise APIError('Cross-Origin-API-Zugriff nicht erlaubt.',403)
|
||
with ep.lock:
|
||
if not ep.allowed():raise APIError('Endpunkt wird gestoppt.',503,'endpoint_stopping')
|
||
ep.inflight+=1;admitted=True
|
||
if ep.scheduler.gpu_mode=='separator' and self.path not in ('/v1/embeddings','/v1/embeddings/models'):raise APIError('Audio-Trennung aktiv. Für Chat, Bilder oder Musik die Betriebsart wechseln.',503,'separator_mode_active')
|
||
if ep.video and ep.scheduler.gpu_mode not in ('llm','music') and self.path not in ('/v1/embeddings','/v1/embeddings/models'):
|
||
if ep.scheduler.gpu_mode=='switching':raise APIError('Moduswechsel läuft.',503,'mode_switching')
|
||
video=ep.video.status()
|
||
if not video.get('service',{}).get('ready'):raise APIError('Video-API noch nicht bereit.',503,'video_not_ready')
|
||
try:ep.video.relay(self) if callable(getattr(type(ep.video),'relay',None)) else relay(self,video['selected'])
|
||
except ValueError as exc:raise APIError(str(exc)) from None
|
||
return
|
||
if self.command=='GET' and self.path=='/v1/capabilities':
|
||
from api_compat import capabilities
|
||
return self.send(capabilities())
|
||
model_routes={'/v1/embeddings/models':'embeddings','/v1/models':'chat','/v1/images/models':'image','/v1/audio/speech/models':'audio','/v1/audio/transcriptions/models':'stt','/v1/audio/music/models':'music'}
|
||
if self.command=='GET' and self.path in model_routes:
|
||
listing=ep.model_list(model_routes[self.path])
|
||
if self.path=='/v1/audio/speech/models':listing['data']+=ep.model_list('voice')['data']
|
||
return self.send(listing)
|
||
if self.command=='GET' and self.path=='/health':return self.send({'status':'ok','service':'athena-deck-api'})
|
||
if self.video_route(ep):return
|
||
if self.command!='POST':raise APIError('Route nicht gefunden.',404)
|
||
if ep.scheduler.gpu_mode=='separator' and self.path not in ('/v1/embeddings','/v1/embeddings/models'):raise APIError('Audio-Trennung aktiv. Für Chat, Bilder oder Musik die Betriebsart wechseln.',503,'separator_mode_active')
|
||
if ep.scheduler.gpu_mode=='music' and self.path not in ('/v1/audio/music','/v1/embeddings'):raise APIError('Musik-Modus aktiv. Für andere KI-Aufträge auf LLM wechseln.',503,'music_mode_active')
|
||
if ep.video and ep.scheduler.gpu_mode not in ('llm','music') and self.path not in ('/v1/audio/transcriptions','/v1/embeddings'):raise APIError('Video-Modus aktiv; Chat-, Bild- und TTS-Aufträge sind gesperrt. Auf LLM zurückschalten.',503,'video_mode_active')
|
||
if self.path=='/v1/audio/transcriptions':return self.transcription(ep)
|
||
if self.path=='/v1/images/edits':
|
||
from image_upload import read_image_upload
|
||
try:
|
||
fields,images=read_image_upload(self)
|
||
if not images:raise ValueError('Mindestens ein Referenzbild erforderlich.')
|
||
if set(fields)-{'model','prompt','size','n','response_format'}:raise ValueError('Nicht unterstützte Bildparameter.')
|
||
fields['n']=int(fields.get('n','1'))
|
||
return self.image(ep,fields,images)
|
||
except ValueError as exc:raise APIError(str(exc)) from None
|
||
if self.path not in ('/v1/embeddings','/v1/chat/completions','/v1/images/generations','/v1/audio/speech','/v1/audio/music'):raise APIError('Route nicht implementiert.',404)
|
||
if self.headers.get('Transfer-Encoding'):raise APIError('Chunked Upload wird nicht unterstützt.')
|
||
try:length=int(self.headers.get('Content-Length','0'))
|
||
except ValueError:raise APIError('Ungültige Content-Length.') from None
|
||
if not 0<length<=1024*1024 or self.headers.get('Content-Type','').split(';')[0]!='application/json':raise APIError('JSON-Anfrage bis 1 MiB erforderlich.',413)
|
||
try:data=json.loads(self.rfile.read(length),parse_constant=lambda _:(_ for _ in ()).throw(ValueError()))
|
||
except (ValueError,UnicodeError):raise APIError('Ungültiges JSON.') from None
|
||
if not isinstance(data,dict):raise APIError('JSON-Objekt erforderlich.')
|
||
if self.path=='/v1/embeddings':return self.embedding(ep,data)
|
||
if self.path=='/v1/chat/completions':return self.chat(ep,data)
|
||
if self.path=='/v1/audio/music':return self.music_generation(ep,data)
|
||
if self.path=='/v1/audio/speech':return self.speech(ep,data)
|
||
return self.image(ep,data)
|
||
except (APIError,InferenceError) as exc:
|
||
if isinstance(exc,InferenceError):
|
||
with ep.lock:ep.error=str(exc)
|
||
self.failure(exc)
|
||
except (BrokenPipeError,ConnectionResetError):pass
|
||
except Exception:self.failure(APIError('Worker nicht erreichbar oder Zeitlimit überschritten.',503,'worker_unavailable'))
|
||
finally:
|
||
self.close_connection=True
|
||
if admitted:
|
||
with ep.lock:ep.inflight-=1
|
||
def transcription(self,ep):
|
||
if not ep.stt:raise APIError('STT-Laufzeit nicht eingerichtet.',501,'not_implemented')
|
||
try:
|
||
fields,audio=read_upload(self)
|
||
if 'profile_id' in fields:raise ValueError('API-Profilname als model erforderlich.')
|
||
profile=ep.find_profile(fields.get('model'),'stt')
|
||
job=ep.stt.start(profile['id'],audio,fields.get('language','de'))
|
||
except APIError:raise
|
||
except ValueError as exc:raise APIError(str(exc)) from None
|
||
deadline=time.monotonic()+280
|
||
while time.monotonic()<deadline:
|
||
current=ep.stt.status()['job']
|
||
if not current or current['id']!=job['id']:raise APIError('STT-Ergebnis nicht mehr verfügbar.',409)
|
||
if current['state']=='complete':return self.send({'text':current['text']})
|
||
if current['state']!='running':raise APIError(current['phase'],503)
|
||
time.sleep(.2)
|
||
ep.stt.stop();raise APIError('STT-Zeitlimit überschritten.',504)
|
||
def speech(self,ep,data):
|
||
if not ep.tts:raise APIError('TTS-Laufzeit nicht eingerichtet.',501)
|
||
if set(data)-{'model','input','voice','response_format','speed','language'}:raise APIError('Nicht unterstützte TTS-Felder.')
|
||
if data.get('response_format','wav')!='wav':raise APIError('Derzeit wird nur WAV unterstützt; response_format=wav setzen.')
|
||
from profiles import voice_recipe
|
||
voice=next((p for p in ep.rows() if p.get('model') and voice_recipe(p['model']) and p['name']==data.get('model') and p['enabled']),None)
|
||
if voice:
|
||
profile=ep.find_profile(data.get('model'),voice['kind']);backend=ep.voxcpm
|
||
if data.get('voice','default')!='default' or data.get('speed',1)!=1:raise APIError('VoxCPM2: voice=default und speed=1 verwenden; Referenzstimme im Deck-Testbereich.')
|
||
try:job=backend.start(profile['id'],data.get('input'),wait=True)
|
||
except ValueError as exc:raise APIError(str(exc)) from None
|
||
else:
|
||
profile=ep.find_profile(data.get('model'),'audio');backend=ep.tts
|
||
try:job=backend.start(profile['id'],data.get('input'),speaker=data.get('voice','Ryan'),language=data.get('language','Auto'),speed=data.get('speed'),wait=True)
|
||
except ValueError as exc:raise APIError(str(exc)) from None
|
||
end=time.monotonic()+620
|
||
while time.monotonic()<end:
|
||
current=backend.status()['job']
|
||
if current and current['id']==job['id'] and current['state'] in ('failed','cancelled'):raise APIError(current['phase'],503)
|
||
try:body=backend.audio(job['id'])
|
||
except (ValueError,OSError):time.sleep(.25);continue
|
||
self.sent=True;self.send_response(200);self.send_header('Content-Type','audio/wav');self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.send_header('Connection','close');self.end_headers();self.wfile.write(body);return
|
||
raise APIError('TTS-Zeitlimit überschritten.',504)
|
||
def embedding(self,ep,data):
|
||
if not ep.embeddings:raise APIError('Embedding-Worker nicht eingerichtet.',501)
|
||
if set(data)-{'model','input','encoding_format','user'}:raise APIError('Nicht unterstützte Embedding-Felder.')
|
||
if data.get('encoding_format','float')!='float':raise APIError('Nur encoding_format=float unterstützt.')
|
||
value=data.get('input');texts=[value] if isinstance(value,str) else value
|
||
if not isinstance(texts,list) or not 1<=len(texts)<=32 or any(not isinstance(t,str) or not t.strip() or len(t)>16000 for t in texts):raise APIError('input: 1–32 nichtleere Texte, jeweils maximal 16000 Zeichen. Kontextgrenze wird zusätzlich von llama.cpp geprüft.')
|
||
profile=ep.find_profile(data.get('model'),'embeddings')
|
||
try:return self.send(ep.embeddings.generate(profile,{k:v for k,v in data.items() if k!='user'}))
|
||
except ValueError as exc:raise APIError(str(exc)) from None
|
||
|
||
def music_generation(self,ep,data):
|
||
if ep.scheduler.gpu_mode!='music':raise APIError('Bitte in der Übersicht auf Musik umschalten.',503,'music_mode_required')
|
||
if not ep.music:raise APIError('Musikworker nicht eingerichtet.',501)
|
||
if set(data)-{'model','lyrics','style','seed','max_tokens','steps'}:raise APIError('Nicht unterstützte Musikfelder.')
|
||
profile=ep.find_profile(data.get('model'),'music')
|
||
try:job=ep.music.start(profile['id'],data.get('lyrics'),data.get('style'),seed=data.get('seed',1234),max_tokens=data.get('max_tokens',400),steps=data.get('steps',8),wait=True)
|
||
except ValueError as exc:raise APIError(str(exc)) from None
|
||
end=time.monotonic()+1850
|
||
while time.monotonic()<end:
|
||
current=ep.music.status()['job']
|
||
if current and current['id']==job['id'] and current['state'] in ('failed','cancelled'):raise APIError(current['phase'],503)
|
||
try:body=ep.music.audio(job['id'])
|
||
except (ValueError,OSError):time.sleep(.5);continue
|
||
self.sent=True;self.send_response(200);self.send_header('Content-Type','audio/wav');self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.end_headers();self.wfile.write(body);return
|
||
ep.music.stop();raise APIError('Musik-Zeitlimit überschritten.',504)
|
||
def chat(self,ep,data):
|
||
try:data,self.compatibility=normalize_chat(data)
|
||
except CompatibilityError as exc:raise APIError(str(exc)) from None
|
||
if type(data.get('stream',False)) is not bool:raise APIError('stream muss true oder false sein.')
|
||
if data.get('n',1)!=1:raise APIError('Zunächst wird n=1 unterstützt.')
|
||
messages=data.get('messages')
|
||
if not isinstance(messages,list) or not messages or any(not isinstance(m,dict) for m in messages):raise APIError('messages muss eine nichtleere Liste sein.')
|
||
profile=ep.find_profile(data.get('model'),'chat')
|
||
images=0
|
||
for message in messages:
|
||
content=message.get('content')
|
||
if isinstance(content,list):
|
||
for part in content:
|
||
if not isinstance(part,dict):raise APIError('Ungültiger Nachrichteninhalt.')
|
||
if part.get('type')=='text':continue
|
||
if part.get('type')!='image_url' or not profile['parameters'].get('vision_projector'):raise APIError('Für Bildeingaben einen Vision-Projektor im Profil aktivieren.')
|
||
value=part.get('image_url')
|
||
url=value.get('url') if isinstance(value,dict) else None
|
||
if not isinstance(url,str) or not url.startswith(('data:image/png;base64,','data:image/jpeg;base64,','data:image/webp;base64,')):raise APIError('Nur eingebettete PNG/JPEG/WebP-Bilder; keine externen URLs.')
|
||
import base64,binascii
|
||
try:raw=base64.b64decode(url.split(',',1)[1],validate=True)
|
||
except (ValueError,binascii.Error):raise APIError('Ungültige Bildkodierung.') from None
|
||
if not raw:raise APIError('Leeres Bild.')
|
||
images+=1
|
||
if images>1:raise APIError('Zunächst ein Bild pro Anfrage unterstützt.')
|
||
# Re-resolve after waiting so edits/disable cannot silently launch a stale profile.
|
||
key=('chat',profile['id'],profile['revision'])
|
||
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,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})
|
||
response=conn.getresponse()
|
||
if response.status!=200:
|
||
raise chat_upstream_error(response.status,response.read(65536))
|
||
if not data.get('stream'):
|
||
raw=response.read(16*1024*1024+1)
|
||
if len(raw)>16*1024*1024:raise InferenceError('Modellantwort überschreitet 16 MiB.')
|
||
return self.send(json.loads(raw))
|
||
self.sent=True;self.connection.settimeout(30)
|
||
self.send_response(200);self.compatibility_headers();self.send_header('Content-Type','text/event-stream');self.send_header('Cache-Control','no-store');self.send_header('Connection','close');self.end_headers()
|
||
deadline=time.monotonic()+600
|
||
while time.monotonic()<deadline:
|
||
chunk=response.read1(8192)
|
||
if not chunk:return
|
||
self.wfile.write(chunk);self.wfile.flush()
|
||
raise InferenceError('Zeitlimit der Streaming-Antwort überschritten.')
|
||
finally:conn.close()
|
||
def image(self,ep,data,reference_images=None):
|
||
if set(data)-{'model','prompt','n','size','response_format','user'}:raise APIError('Nicht unterstützte Bildparameter. Schritte/Guidance/Seed stehen im Profil.')
|
||
if data.get('n',1)!=1 or data.get('response_format','b64_json')!='b64_json':raise APIError('Unterstützt werden n=1 und response_format=b64_json.')
|
||
profile=ep.find_profile(data.get('model'),'image');params=profile['parameters']
|
||
from profiles import image_capabilities
|
||
limit=image_capabilities(profile.get('model'))['reference_images']
|
||
if len(reference_images or [])>limit:raise APIError(f"Bildprofil {profile['name']}: {len(reference_images or [])} Referenzbilder erhalten; die Deck-Laufzeitanbindung erlaubt maximal {limit}.")
|
||
size=data.get('size')
|
||
if size is not None and (not isinstance(size,str) or (size!='auto' and not re.fullmatch(r'[1-9][0-9]{1,4}x[1-9][0-9]{1,4}',size))):raise APIError('size muss auto oder eine Auflösung wie 1024x1024 sein.')
|
||
# The selected profile owns resource limits; client size is only a preference.
|
||
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(('image',),allowed=allowed):
|
||
return self.generate_image(ep,profile,data,reference_images or [])
|
||
def generate_image(self,ep,profile,data,reference_images=None):
|
||
options={'reference_images':reference_images} if reference_images else {}
|
||
job=ep.images.start(profile['id'],data.get('prompt'),reserved=True,**options)
|
||
deadline=time.monotonic()+2100
|
||
while time.monotonic()<deadline:
|
||
current=ep.images.status()['job']
|
||
if not current or current['id']!=job['id']:raise InferenceError('Bildauftrag nicht mehr verfügbar.')
|
||
if current['state']=='complete':return self.send({'created':int(time.time()),'data':[{'b64_json':base64.b64encode(ep.images.image(job['id'])).decode()}],'athena_deck':{'model':'athena-image','size':f"{profile['parameters']['width']}x{profile['parameters']['height']}",'requested_size':data.get('size'),'size_policy':'profile'}})
|
||
if current['state']!='running':raise InferenceError(current['phase'])
|
||
time.sleep(.25)
|
||
ep.images.stop();raise InferenceError('Zeitlimit der Bildgenerierung überschritten.')
|