Spaces:
Running on Zero
Running on Zero
File size: 24,711 Bytes
24cc077 2031f77 0e86155 2031f77 95a1fbc 0e86155 c2215d3 2031f77 e49e31a 2031f77 6c60ea0 2031f77 ed1475b a6d3251 e49e31a 76e0bfa a6e1920 6c60ea0 95a1fbc 2031f77 6c60ea0 95a1fbc 6c60ea0 2031f77 a6e1920 3315dab 76e0bfa 3315dab 0e86155 3315dab 0e86155 3315dab 0e86155 6c60ea0 3315dab 6c60ea0 3315dab 0e86155 6c60ea0 3315dab 0e86155 6c60ea0 be1eee5 6c60ea0 0e86155 6c60ea0 0e86155 6c60ea0 0e86155 6c60ea0 0e86155 6c60ea0 0e86155 3315dab 6c60ea0 76e0bfa a6e1920 5a037eb a6e1920 5a037eb 54a5f89 a6e1920 76e0bfa 623f6f8 857589b 623f6f8 e49e31a 2031f77 ed1475b 2031f77 1d0ef9e 857589b 1d0ef9e 857589b b4691b7 2031f77 ed1475b 2031f77 2a138ca 2031f77 623f6f8 2031f77 a6d3251 2031f77 a6d3251 2031f77 6c60ea0 2031f77 6c60ea0 2031f77 c2215d3 2031f77 95a1fbc 2031f77 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 | """Shared CPU routes and delivery API for hosted ALD training."""
import base64
import hashlib
import json
from pathlib import Path
import re
import subprocess
import threading
from fastapi import HTTPException, Request
from fastapi.responses import FileResponse, Response, HTMLResponse, JSONResponse
from starlette.concurrency import run_in_threadpool
from delivery import create_api, CapacityExceeded, InvalidAnalysisOutput, publish_worker_result
from worker import run_reported
from build_info import build_identity
from training_config import validate_config
from hf_identity import oauth_config
from builtin_tracks import TrackLibrary
from steam_leaderboard import compare_public_best_lap, milliseconds, SteamUnavailable
from bucket_storage import (BucketStorageError, MAX_REQUEST as BUCKET_MAX_REQUEST,
MAX_RECIPE_LIBRARY, run_bucket_operation, valid_bucket_id, valid_bundle_key,
valid_recipe_key, validate_recipe_library, AUTOSAVE_APP_KEY, winner_sidecar_filename)
from portable_bundle import (BundleError, load_bundle, MAX_BUNDLE, MAX_NETWORK,
MAX_ANALYSIS_ARCHIVE, MAX_ANALYSIS_FILE_BYTES)
ROOT=Path(__file__).resolve().parent
EVOLUTION_SIDECAR=re.compile(r'^[a-z0-9][a-z0-9-]{0,63}-(formula|rally|snowmobile|truck)-[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}(?:-(fitness-winner|fastest-lap-winner|clean-laps-winner))?\.evolution$')
def create_app(worker=run_reported,store=None,gpu_worker=None,track_library=None,require_analysis=False):
app=create_api(store)
admission=threading.BoundedSemaphore(1)
leaderboard_admission=threading.BoundedSemaphore(1)
bucket_admission=threading.BoundedSemaphore(1)
library=track_library if track_library is not None else TrackLibrary(ROOT/'builtin_tracks')
@app.middleware('http')
async def bucket_no_store(request:Request, call_next):
response=await call_next(request)
if request.url.path.startswith('/api/bucket/'):
response.headers['Cache-Control']='no-store'
return response
def bucket_token(request:Request):
authorization=request.headers.get('authorization','')
if not authorization.startswith('Bearer '):
raise HTTPException(401,'Storage credential required')
token=authorization[7:]
if not token or len(token)>4096 or any(ch.isspace() or ord(ch)<33 for ch in token):
raise HTTPException(401,'Storage credential required')
return token
async def bucket_body(request:Request, token:str, *, limit:int, fields:set[str]):
chunks=bytearray()
try:
async for chunk in request.stream():
if len(chunks)+len(chunk)>limit: raise HTTPException(413,'Storage request too large')
chunks.extend(chunk)
def unique_object(pairs):
result={}
for key,value in pairs:
if key in result: raise ValueError('duplicate key')
result[key]=value
return result
value=json.loads(chunks.decode('utf-8'),object_pairs_hook=unique_object)
if not isinstance(value,dict) or set(value)!=fields:
raise ValueError('invalid body')
except HTTPException:
raise
except Exception:
raise HTTPException(400,'Invalid storage request') from None
finally:
chunks.clear()
if not valid_bucket_id(value.get('bucket_id')):
raise HTTPException(400,'Invalid storage request')
return value
def bucket_http_error(exc:BucketStorageError):
status={'unauthorized':401,'forbidden':403,'not_found':404,'invalid_request':400,
'invalid_bundle':400,'storage_unavailable':502,'timeout':504}.get(exc.category,502)
detail=('Storage credential is invalid' if status==401 else
'Storage access is unavailable' if status==403 else
'Invalid portable bundle' if exc.category=='invalid_bundle' else
'Storage operation timed out' if status==504 else
'Storage operation failed')
return HTTPException(status,detail)
async def bucket_operation(request:Request, operation:str, *, fields:set[str], limit:int=4096):
token=bucket_token(request)
if not bucket_admission.acquire(blocking=False):
raise HTTPException(429,'Storage operation busy')
try:
body=await bucket_body(request,token,limit=limit,fields=fields)
bundle_raw=None
bundle_obj=None
bundle_files=None
if operation=='save':
bundle=body.get('bundle')
if not isinstance(bundle,str) or len(bundle.encode('utf-8'))>MAX_BUNDLE:
raise HTTPException(400,'Invalid portable bundle')
try: bundle_obj,bundle_files=load_bundle(bundle.encode('utf-8'))
except BundleError: raise HTTPException(400,'Invalid portable bundle') from None
bundle_raw=bundle.encode('utf-8')
call_args={'bundle_base64':base64.b64encode(bundle_raw).decode('ascii')}
elif operation=='recipes_save':
library=body.get('library')
if not isinstance(library,str): raise HTTPException(400,'Invalid recipe library')
try: library_raw=library.encode('utf-8')
except UnicodeEncodeError: raise HTTPException(400,'Invalid recipe library') from None
if len(library_raw)>MAX_RECIPE_LIBRARY:
raise HTTPException(413,'Recipe library too large')
try: validate_recipe_library(library_raw)
except Exception: raise HTTPException(400,'Invalid recipe library') from None
call_args={'library_base64':base64.b64encode(library_raw).decode('ascii')}
elif operation=='restore':
if not valid_bundle_key(body.get('key')):
raise HTTPException(400,'Invalid storage request')
call_args={'key':body['key']}
elif operation=='recipes_restore':
if not valid_recipe_key(body.get('key')):
raise HTTPException(400,'Invalid storage request')
call_args={'key':body['key']}
else:
call_args={}
try:
result=await run_in_threadpool(run_bucket_operation,operation,token,body['bucket_id'],**call_args)
except BucketStorageError as exc:
raise bucket_http_error(exc) from None
if operation=='save':
result['_validated_bundle_bytes']=bundle_raw
result['_validated_bundle_files']=bundle_files
result['_validated_bundle_has_receipt']='run_receipt' in bundle_obj['artifacts']
if operation=='recipes_save':
result['_validated_library_bytes']=library_raw
result['_validated_bucket_id']=body['bucket_id']
return result
finally:
bucket_admission.release()
@app.post('/api/bucket/connect')
async def bucket_connect(request:Request):
result=await bucket_operation(request,'connect',fields={'bucket_id'})
return JSONResponse(result,headers={'Cache-Control':'no-store'})
@app.post('/api/bucket/list')
async def bucket_list(request:Request):
result=await bucket_operation(request,'list',fields={'bucket_id'})
return JSONResponse(result,headers={'Cache-Control':'no-store'})
@app.post('/api/bucket/save')
async def bucket_save(request:Request):
result=await bucket_operation(request,'save',fields={'bucket_id','bundle'},limit=BUCKET_MAX_REQUEST)
raw=result.pop('_validated_bundle_bytes',None)
bundle_files=result.pop('_validated_bundle_files',None)
has_receipt=result.pop('_validated_bundle_has_receipt',False)
verified=result.get('verified_files')
required={'bundle.json'}
if has_receipt:
required|={'report.md','generation-history.csv'}
if isinstance(bundle_files,dict) and 'analysis_archive.gz' in bundle_files:
required.add('analysis-archive.gz')
required.update(name for name in bundle_files if name.startswith('analysis/'))
if 'analysis/stars.json' in bundle_files:
required.add('star-check.txt')
key_match=AUTOSAVE_APP_KEY.fullmatch(result.get('key',''))
if key_match is None:
raise HTTPException(502,'Storage operation failed')
for role in ('fitness','fastest_lap','clean_laps'):
inner=f'analysis/winners/{role}.evolution'
if inner in bundle_files:
required.add(winner_sidecar_filename(key_match.group('track'),key_match.group('vehicle'),
key_match.group('timestamp'),key_match.group('uuid')[:8],role))
evolution=[name for name in verified if isinstance(name,str)
and (match:=EVOLUTION_SIDECAR.fullmatch(name)) and match.group(2) is None] if isinstance(verified,dict) else []
if len(evolution)!=1 or not isinstance(verified,dict) or set(verified)!=(required|{evolution[0]}):
raise HTTPException(502,'Storage operation failed')
if not isinstance(raw,bytes) or not isinstance(bundle_files,dict):
raise HTTPException(502,'Storage operation failed')
network=bundle_files['selected_network.evolution']
safe_verified={}
total=0
per_file_limit=MAX_ANALYSIS_FILE_BYTES
for name,proof in verified.items():
if (not isinstance(proof,dict) or set(proof)!={'bytes','sha256'}
or type(proof['bytes']) is not int or not 0<=proof['bytes']<=per_file_limit
or not isinstance(proof['sha256'],str) or not re.fullmatch(r'[0-9a-f]{64}',proof['sha256'])):
raise HTTPException(502,'Storage operation failed')
total+=proof['bytes']
safe_verified[name]={'bytes':proof['bytes'],'sha256':proof['sha256']}
if (total>MAX_BUNDLE+MAX_NETWORK+320*1024*1024
or safe_verified['bundle.json']!={'bytes':len(raw),'sha256':hashlib.sha256(raw).hexdigest()}
or safe_verified[evolution[0]]!={'bytes':len(network),'sha256':hashlib.sha256(network).hexdigest()}):
raise HTTPException(502,'Storage operation failed')
analysis_archive=bundle_files.get('analysis_archive.gz')
if analysis_archive is not None and safe_verified.get('analysis-archive.gz')!={
'bytes':len(analysis_archive),'sha256':hashlib.sha256(analysis_archive).hexdigest()}:
raise HTTPException(502,'Storage operation failed')
for filename,content in bundle_files.items():
if filename.startswith('analysis/') and safe_verified.get(filename)!={
'bytes':len(content),'sha256':hashlib.sha256(content).hexdigest()}:
raise HTTPException(502,'Storage operation failed')
for role in ('fitness','fastest_lap','clean_laps'):
inner=f'analysis/winners/{role}.evolution'
key_match=AUTOSAVE_APP_KEY.fullmatch(result.get('key',''))
sidecar=(winner_sidecar_filename(key_match.group('track'),key_match.group('vehicle'),
key_match.group('timestamp'),key_match.group('uuid')[:8],role) if key_match else '')
if inner in bundle_files and safe_verified.get(sidecar)!={
'bytes':len(bundle_files[inner]),'sha256':hashlib.sha256(bundle_files[inner]).hexdigest()}:
raise HTTPException(502,'Storage operation failed')
return JSONResponse({key:result[key] for key in ('status','bucket_id','key','bytes','sha256')}|{'verified_files':safe_verified},headers={'Cache-Control':'no-store'})
@app.post('/api/bucket/restore')
async def bucket_restore(request:Request):
result=await bucket_operation(request,'restore',fields={'bucket_id','key'})
return Response(result['bundle_bytes'],media_type='application/json',headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
@app.post('/api/bucket/recipes/list')
async def bucket_recipes_list(request:Request):
result=await bucket_operation(request,'recipes_list',fields={'bucket_id'})
items=result.get('items')
if (not isinstance(items,list) or type(result.get('truncated')) is not bool
or type(result.get('scan_incomplete')) is not bool or len(items)>50):
raise HTTPException(502,'Storage operation failed')
clean=[]
for item in items:
if (not isinstance(item,dict) or set(item)!={'key','bytes','label','created_at'}
or not valid_recipe_key(item.get('key')) or type(item.get('bytes')) is not int
or not 0<=item['bytes']<=MAX_RECIPE_LIBRARY
or not isinstance(item.get('label'),str) or len(item['label'])>192
or (item.get('created_at') is not None and not isinstance(item.get('created_at'),str))):
raise HTTPException(502,'Storage operation failed')
clean.append(item)
return JSONResponse({'items':clean,'truncated':result['truncated'],
'scan_incomplete':result['scan_incomplete']},headers={'Cache-Control':'no-store'})
@app.post('/api/bucket/recipes/save')
async def bucket_recipes_save(request:Request):
result=await bucket_operation(request,'recipes_save',fields={'bucket_id','library'},
limit=MAX_RECIPE_LIBRARY*6+4096)
raw=result.pop('_validated_library_bytes',None)
expected_bucket=result.pop('_validated_bucket_id',None)
verified=result.pop('verified_bytes',None)
if (not isinstance(raw,bytes) or verified!=raw or result.get('status')!='saved_verified'
or result.get('bucket_id')!=expected_bucket or not valid_recipe_key(result.get('key'))
or result.get('bytes')!=len(raw) or result.get('sha256')!=hashlib.sha256(raw).hexdigest()):
raise HTTPException(502,'Storage operation failed')
return JSONResponse({key:result[key] for key in ('status','bucket_id','key','bytes','sha256')},
headers={'Cache-Control':'no-store'})
@app.post('/api/bucket/recipes/restore')
async def bucket_recipes_restore(request:Request):
result=await bucket_operation(request,'recipes_restore',fields={'bucket_id','key'})
raw=result.get('recipe_bytes')
if not isinstance(raw,bytes) or not valid_recipe_key(result.get('key')):
raise HTTPException(502,'Storage operation failed')
return Response(raw,media_type='application/json',headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
@app.get('/api/tracks')
def track_catalog():
return JSONResponse(library.catalog(),headers={'Cache-Control':'no-store'})
@app.post('/api/leaderboard/compare')
async def leaderboard_compare(request:Request):
data=bytearray()
async for chunk in request.stream():
if len(data)+len(chunk)>4096: raise HTTPException(413,'Request too large')
data.extend(chunk)
try:
body=json.loads(data)
if not isinstance(body,dict) or set(body)!={'track_id','track_sha256','vehicle_type','lap_seconds'}:
raise ValueError()
if not isinstance(body['track_id'],str) or not isinstance(body['track_sha256'],str): raise ValueError()
if type(body['vehicle_type']) is not int or not 0<=body['vehicle_type']<=3: raise ValueError()
milliseconds(body['lap_seconds'])
except Exception: raise HTTPException(400,'Invalid leaderboard comparison request') from None
finally: data.clear()
entry=next((track for track in library.catalog()['tracks'] if track['id']==body['track_id']),None)
if entry is None:
return {'status':'unsupported_track','message':'Steam comparison is available only for supported built-in tracks.'}
if body['track_sha256']!=entry['sha256']:
raise HTTPException(400,'Track identity did not match the built-in catalog')
if not leaderboard_admission.acquire(blocking=False):
return {'status':'busy','message':'Another Steam check is in progress. The completed result is unaffected.'}
try:
result=await run_in_threadpool(compare_public_best_lap,entry,body['lap_seconds'])
if result.get('status')=='ok':result['vehicle_type']=body['vehicle_type']
return result
except SteamUnavailable as error:
result={'status':'unavailable','failure_category':error.failure_category,
'message':'Steam could not provide a complete public leaderboard. The completed result is unaffected.'}
if error.failure_category == 'rate_limited':
result['message']='Steam is limiting leaderboard requests. Checks pause for one minute; your completed result is unaffected.'
if error.upstream_http_status is not None:
result['upstream_http_status']=error.upstream_http_status
return result
finally:
leaderboard_admission.release()
@app.get('/api/tracks/{identifier}/file')
def reference_track(identifier:str):
try:payload=library.read(identifier)
except KeyError:raise HTTPException(404,'Built-in track not found') from None
except (ValueError,OSError):raise HTTPException(503,'Built-in track validation failed') from None
return Response(payload,media_type='application/octet-stream',headers={'Cache-Control':'no-store'})
@app.get('/track-library.js')
def track_script():return FileResponse(ROOT/'track-library.js',media_type='text/javascript')
@app.get('/track-map.js')
def map_script():return FileResponse(ROOT/'track-map.js',media_type='text/javascript',headers={'Cache-Control':'no-store'})
@app.get('/assets/track-atlas.json')
def track_atlas():return FileResponse(ROOT/'assets/track-atlas.json',media_type='application/json')
@app.get('/api/auth/config')
def auth_config():
return JSONResponse(oauth_config(),headers={'Cache-Control':'no-store'})
@app.get('/hf-auth.js')
def auth_script(): return FileResponse(ROOT/'hf-auth.js',media_type='text/javascript')
@app.get('/hf-oauth.js')
def oauth_script(): return FileResponse(ROOT/'vendor/hf-oauth/index.js',media_type='text/javascript')
@app.get('/api/capabilities')
def capabilities():
return {'cpu':True,'gpu':gpu_worker is not None,'default':'cpu','configurable_training':True,'build':build_identity()}
@app.get('/')
def index():
# Deliver the bootstrap with the authenticated document: private HF
# iframe subresource access can fail before an external script runs.
script=(ROOT/'client.js').read_text().replace('</script','<\\/script')
map_js=(ROOT/'track-map.js').read_text().replace('</script','<\\/script')
return HTMLResponse((ROOT/'index.html').read_text().replace(
'<script src="/track-map.js"></script>','<script>'+map_js+'</script>').replace(
'<script src="/client.js"></script>','<script>'+script+'</script>'),
headers={'Referrer-Policy':'no-referrer','Cache-Control':'no-store'})
@app.get('/client.js')
def script(): return FileResponse(ROOT/'client.js',media_type='text/javascript')
@app.get('/feedback')
def feedback(): return FileResponse(ROOT/'FEEDBACK.md',media_type='text/plain')
@app.get('/policy')
def policy(): return FileResponse(ROOT/'DATA_POLICY.md',media_type='text/plain')
@app.get('/help')
def help_page(): return FileResponse(ROOT/'help.html',media_type='text/html')
@app.post('/api/process')
async def process(request:Request):
if not admission.acquire(blocking=False): raise HTTPException(429,'Worker busy; try later')
try:
data=bytearray()
async for chunk in request.stream():
if len(data)+len(chunk)>3*1024*1024: raise HTTPException(413,'Request too large')
data.extend(chunk)
try:
body=json.loads(data)
automatic=body.get('automatic',True)
backend=body.get('backend','cpu')
if backend not in ('cpu','gpu'): raise ValueError()
if type(automatic) is not bool: raise ValueError()
track=base64.b64decode(body['track'],validate=True)
network=base64.b64decode(body['network'],validate=True)
if not track or not network or max(len(track),len(network))>1024*1024: raise ValueError()
except Exception: raise HTTPException(400,'Invalid input envelope') from None
finally: data.clear()
try:config=validate_config(body['settings']) if 'settings' in body else None
except ValueError as error:raise HTTPException(400,str(error)) from None
selected=worker if backend=='cpu' else gpu_worker
if selected is None: raise HTTPException(503,'GPU is unavailable; select CPU to run without a GPU lease')
try:
result=await run_in_threadpool(selected,track,network,config) if config is not None else await run_in_threadpool(selected,track,network)
payload,report=result if isinstance(result,tuple) else (result,None)
published,public_report=publish_worker_result(
app.state.results,payload,report,automatic,require_analysis=require_analysis)
return {**published,'backend':backend,'report':public_report}
except CapacityExceeded: raise HTTPException(503,'Result capacity reached') from None
except InvalidAnalysisOutput: raise HTTPException(422,'Processing failed; complete analysis could not be verified.') from None
except Exception: raise HTTPException(422,'Processing failed; no result published. Cleanup status requires verification.') from None
finally: admission.release()
@app.post('/api/replay')
async def recorded_replay(request:Request):
# Share CPU admission with training; recordings never allocate a GPU.
if not admission.acquire(blocking=False):
raise HTTPException(429,'Worker busy; finish the current run before recording a drive')
data=bytearray()
try:
async for chunk in request.stream():
if len(data)+len(chunk)>3*1024*1024:
raise HTTPException(413,'Replay input exceeds the upload limit')
data.extend(chunk)
try:
body=json.loads(data)
if not isinstance(body,dict) or set(body)!={'track','network','ticks'}:
raise ValueError()
if type(body['ticks']) is not int or not 1<=body['ticks']<=900:
raise ValueError()
track=base64.b64decode(body['track'],validate=True)
network=base64.b64decode(body['network'],validate=True)
if not track or not network or max(len(track),len(network))>1024*1024:
raise ValueError()
except Exception:
raise HTTPException(400,'Invalid replay input; select a track, a network and up to 15 seconds') from None
finally:
data.clear()
from replay import record_replay, ReplayError
try:
recording=await run_in_threadpool(record_replay,track,network,
binary=ROOT/'bin/altd-sim',importer=ROOT/'bin/extract_saved_track',
scene_template=ROOT/'assets/formula_template.json',ticks=body['ticks'])
recording['build']=build_identity()
return JSONResponse(recording,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
except (TimeoutError,subprocess.TimeoutExpired):
raise HTTPException(504,'Recording reached its time limit. Try a 5-second drive.') from None
except (ReplayError,OSError,ValueError,KeyError,TypeError,subprocess.CalledProcessError):
raise HTTPException(422,'The engine could not produce a complete recorded drive. No estimated playback was substituted.') from None
finally:
data.clear()
admission.release()
return app
app=create_app()
if __name__=='__main__':
import uvicorn
uvicorn.run(app,host='127.0.0.1',port=47831,access_log=False)
|