Files
Athena-Deck/endpoint.py
T

364 lines
26 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 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.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()
with self.lock:return dict(video=self.video.status() if self.video else None,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)
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.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:
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.video and ep.scheduler.gpu_mode!='llm':
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
model_routes={'/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:return self.send(ep.model_list(model_routes[self.path]))
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.video and ep.scheduler.gpu_mode!='llm' and self.path!='/v1/audio/transcriptions':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/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/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.')
profile=ep.find_profile(data.get('model'),'audio')
try:job=ep.tts.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=ep.tts.status()['job']
if current and current['id']==job['id'] and current['state'] in ('failed','cancelled'):raise APIError(current['phase'],503)
try:body=ep.tts.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 music_generation(self,ep,data):
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 in ('temperature','top_p','top_k'):body.setdefault(field,profile['parameters'][field])
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
if len(reference_images or [])>image_capabilities(profile.get('model'))['reference_images']:raise APIError('Das aktive Bildprofil unterstützt diese Anzahl Referenzbilder nicht.')
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.')