char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
56381e407
char/aliceai-t5-moe
1import threading 2import pytest 3from utils import * 4 5server :ServerProcess 6 7@ pytest . fixture ( autouse = True ) 8def create_server (): 9global server 10server = ServerPreset .router () 11 12 13def test_router_props (): 14global server 15server .models_max = 2 16server .no_models_autoload = True 17server .start () 18res = server .make_request ("GET" ,"/props" ) 19assert res .status_code == 200 20assert res .body ["role" ]== "router" 21assert res .body ["max_instances" ]== 2 22assert res .body ["models_autoload" ]is False 23assert res .body ["build_info" ].startswith ("b" ) 24 25 26@ pytest . mark . parametrize ( 27"model,success" , 28[ 29( "ggml-org/tinygemma3-GGUF:Q8_0" , True ), 30( "non-existent/model" , False ), 31] 32) 33def test_router_chat_completion_stream (model :str ,success :bool ): 34global server 35server .start () 36content = "" 37ex :ServerError | None = None 38try : 39res = server .make_stream_request ("POST" ,"/chat/completions" ,data = { 40"model" :model , 41"max_tokens" :16 , 42"messages" : [ 43 {"role" :"user" ,"content" :"hello" }, 44 ], 45"stream" :True , 46 }) 47for data in res : 48if data ["choices" ]: 49choice = data ["choices" ][0 ] 50if choice ["finish_reason" ]in ["stop" ,"length" ]: 51assert "content" not in choice ["delta" ] 52else : 53assert choice ["finish_reason" ]is None 54content += choice ["delta" ]["content" ]or '' 55except ServerError as e : 56ex = e 57 58if success : 59assert ex is None 60assert len (content )> 0 61else : 62assert ex is not None 63assert content == "" 64 65 66def _get_model_ids (is_reload :bool ,headers :dict | None = None )-> set [str ]: 67res = server .make_request ( 68"GET" ,"/models" + ("?reload=1" if is_reload else "" ),headers = headers 69 ) 70assert res .status_code == 200 71return {item ["id" ]for item in res .body .get ("data" , [])} 72 73 74def _get_model_status (model_id :str ,headers :dict | None = None )-> str : 75res = server .make_request ("GET" ,"/models" ,headers = headers ) 76assert res .status_code == 200 77for item in res .body .get ("data" , []): 78if item .get ("id" )== model_id or item .get ("model" )== model_id : 79return item ["status" ]["value" ] 80raise AssertionError (f"Model { model_id } not found in /models response" ) 81 82 83def _wait_for_model_status (model_id :str ,desired :set [str ],timeout :int = 60 ,headers :dict | None = None )-> str : 84deadline = time .time ()+ timeout 85last_status = None 86while time .time ()< deadline : 87last_status = _get_model_status (model_id ,headers = headers ) 88if last_status in desired : 89return last_status 90time .sleep (0.01 ) 91raise AssertionError ( 92f"Timed out waiting for { model_id } to reach { desired } , last status: { last_status } " 93 ) 94 95 96def _load_model_and_wait ( 97model_id :str ,timeout :int = 60 ,headers :dict | None = None 98)-> None : 99load_res = server .make_request ( 100"POST" ,"/models/load" ,data = {"model" :model_id },headers = headers 101 ) 102assert load_res .status_code == 200 103assert isinstance (load_res .body ,dict ) 104assert load_res .body .get ("success" )is True 105_wait_for_model_status (model_id , {"loaded" },timeout = timeout ,headers = headers ) 106 107 108def test_router_unload_model (): 109global server 110server .start () 111model_id = "ggml-org/tinygemma3-GGUF:Q8_0" 112 113_load_model_and_wait (model_id ) 114 115unload_res = server .make_request ("POST" ,"/models/unload" ,data = {"model" :model_id }) 116assert unload_res .status_code == 200 117assert unload_res .body .get ("success" )is True 118_wait_for_model_status (model_id , {"unloaded" }) 119 120 121def test_router_models_max_evicts_lru (): 122global server 123server .models_max = 2 124server .start () 125 126candidate_models = [ 127"ggml-org/tinygemma3-GGUF:Q8_0" , 128"ggml-org/test-model-stories260K:F32" , 129"ggml-org/test-model-stories260K-infill:F32" , 130 ] 131 132# Load only the first 2 models to fill the cache 133first ,second ,third = candidate_models [:3 ] 134 135_load_model_and_wait (first ,timeout = 120 ) 136_load_model_and_wait (second ,timeout = 120 ) 137 138# Verify both models are loaded 139assert _get_model_status (first )== "loaded" 140assert _get_model_status (second )== "loaded" 141 142# Load the third model - this should trigger LRU eviction of the first model 143_load_model_and_wait (third ,timeout = 120 ) 144 145# Verify eviction: third is loaded, first was evicted 146assert _get_model_status (third )== "loaded" 147assert _get_model_status (first )== "unloaded" 148 149 150# server_lru_sched tests (relying on LLAMA_SERVER_DEBUG_FAKE_TIMING) 151 152MODEL_A = "ggml-org/tinygemma3-GGUF:Q8_0" 153MODEL_B = "ggml-org/test-model-stories260K:F32" 154MODEL_C = "ggml-org/test-model-stories260K-infill:F32" 155 156 157def _tokenize (model_id :str ,timeout :float | None = DEFAULT_REQUEST_TIMEOUT )-> ServerResponse : 158return server .make_request ( 159"POST" ,"/tokenize" ,data = {"model" :model_id ,"content" :"hello world" },timeout = timeout 160 ) 161 162 163class _Bg : 164"""runs one request in a thread, keeps its result, error and finish time""" 165 166def __init__ (self ,fn ): 167self .result = None 168self .error :Exception | None = None 169self .done_at :float = 0.0 170self ._thread = threading .Thread (target = self ._run ,args = (fn ,),daemon = True ) 171 172def _run (self ,fn ): 173try : 174self .result = fn () 175except Exception as e : 176self .error = e 177self .done_at = time .time () 178 179def start (self ): 180self ._thread .start () 181return self 182 183def join (self ,timeout :int = 180 ): 184self ._thread .join (timeout ) 185assert not self ._thread .is_alive (),"background request did not finish in time" 186return self 187 188def assert_ok (self ,what :str ): 189assert self .error is None ,f" { what } raised { self . error !r } " 190assert self .result is not None and self .result .status_code == 200 , \ 191f" { what } failed: { self . result . status_code if self . result else None } { self . result . body if self . result else None } " 192 193 194def test_router_queue_does_not_evict_busy_model (): 195"""a request that finds no free slot waits, and the model serving a request survives it""" 196global server 197server .models_max = 1 198server .start () 199 200_load_model_and_wait (MODEL_A ,timeout = 120 ) 201 202busy = _Bg (lambda :_tokenize (MODEL_A )).start () 203time .sleep (0.5 )# let the request reach the child and take the only slot 204 205# no slot free and MODEL_A is busy, so this queues instead of evicting mid-request 206queued = _Bg (lambda :_tokenize (MODEL_B )).start () 207 208busy .join () 209queued .join () 210 211# had MODEL_A been evicted while serving, its own request would have died 212busy .assert_ok ("request against the busy model" ) 213queued .assert_ok ("queued request" ) 214 215_wait_for_model_status (MODEL_B , {"loaded" },timeout = 120 ) 216assert _get_model_status (MODEL_A )== "unloaded" 217 218 219def test_router_queue_coalesces_requests_for_same_model (): 220"""many requests for one missing model share a slot, so only one model is given up""" 221global server 222server .models_max = 2 223server .start () 224 225_load_model_and_wait (MODEL_A ,timeout = 120 ) 226_load_model_and_wait (MODEL_B ,timeout = 120 ) 227 228# keep MODEL_A busy so MODEL_B is the only model that can be given up 229busy = _Bg (lambda :_tokenize (MODEL_A )).start () 230time .sleep (0.5 ) 231 232waiters = [_Bg (lambda :_tokenize (MODEL_C )).start ()for _ in range (3 )] 233 234busy .join () 235for w in waiters : 236w .join () 237 238busy .assert_ok ("request against the busy model" ) 239for i ,w in enumerate (waiters ): 240w .assert_ok (f"queued request { i } " ) 241 242_wait_for_model_status (MODEL_C , {"loaded" },timeout = 120 ) 243# one entry for 3 requests means one eviction: MODEL_B goes, MODEL_A is left alone. 244# without coalescing the leftover entries still ask for a slot, 245# and MODEL_A is taken too as soon as it goes idle 246assert _get_model_status (MODEL_A )== "loaded" 247assert _get_model_status (MODEL_B )== "unloaded" 248 249 250def test_router_queue_client_disconnect_keeps_model (): 251"""a client that leaves while queued must not cost a running model its slot""" 252global server 253server .models_max = 1 254server .start () 255 256_load_model_and_wait (MODEL_A ,timeout = 120 ) 257 258busy = _Bg (lambda :_tokenize (MODEL_A )).start () 259time .sleep (0.5 ) 260 261# queues behind MODEL_A, then gives up long before MODEL_A goes idle 262with pytest .raises (requests .exceptions .RequestException ): 263_tokenize (MODEL_B ,timeout = 1 ) 264 265busy .join () 266busy .assert_ok ("request against the busy model" ) 267 268# nobody is waiting anymore, so MODEL_A keeps its slot 269time .sleep (3 ) 270assert _get_model_status (MODEL_A )== "loaded" 271assert _get_model_status (MODEL_B )== "unloaded" 272 273 274def test_router_queue_is_fifo (): 275"""the queue is served in arrival order""" 276global server 277server .models_max = 1 278server .start () 279 280_load_model_and_wait (MODEL_A ,timeout = 120 ) 281 282busy = _Bg (lambda :_tokenize (MODEL_A )).start () 283time .sleep (0.5 ) 284 285first = _Bg (lambda :_tokenize (MODEL_B )).start () 286time .sleep (1 )# keep the arrival order unambiguous 287second = _Bg (lambda :_tokenize (MODEL_C )).start () 288 289busy .join () 290first .join () 291second .join () 292 293busy .assert_ok ("request against the busy model" ) 294first .assert_ok ("first queued request" ) 295second .assert_ok ("second queued request" ) 296 297assert first .done_at < second .done_at ,"queue was not served in arrival order" 298 299 300def test_router_queue_two_waiters_share_one_eviction (): 301"""two requests that both find the same idle model must both be served in the end""" 302global server 303server .models_max = 1 304server .start () 305 306_load_model_and_wait (MODEL_A ,timeout = 120 ) 307 308# both arrive while MODEL_A is idle, so both want its slot; only one eviction can happen 309first = _Bg (lambda :_tokenize (MODEL_B )).start () 310second = _Bg (lambda :_tokenize (MODEL_C )).start () 311 312first .join (90 ) 313second .join (90 ) 314 315first .assert_ok ("first queued request" ) 316second .assert_ok ("second queued request" ) 317assert _get_model_status (MODEL_A )== "unloaded" 318 319 320def test_router_no_models_autoload (): 321global server 322server .no_models_autoload = True 323server .start () 324model_id = "ggml-org/tinygemma3-GGUF:Q8_0" 325 326res = server .make_request ( 327"POST" , 328"/v1/chat/completions" , 329data = { 330"model" :model_id , 331"messages" : [{"role" :"user" ,"content" :"hello" }], 332"max_tokens" :4 , 333 }, 334 ) 335assert res .status_code == 400 336assert "error" in res .body 337 338_load_model_and_wait (model_id ) 339 340success_res = server .make_request ( 341"POST" , 342"/v1/chat/completions" , 343data = { 344"model" :model_id , 345"messages" : [{"role" :"user" ,"content" :"hello" }], 346"max_tokens" :4 , 347 }, 348 ) 349assert success_res .status_code == 200 350assert "error" not in success_res .body 351 352 353def test_router_api_key_required (): 354global server 355server .api_key = "sk-router-secret" 356server .start () 357 358model_id = "ggml-org/tinygemma3-GGUF:Q8_0" 359auth_headers = {"Authorization" :f"Bearer { server . api_key } " } 360 361res = server .make_request ( 362"POST" , 363"/v1/chat/completions" , 364data = { 365"model" :model_id , 366"messages" : [{"role" :"user" ,"content" :"hello" }], 367"max_tokens" :4 , 368 }, 369 ) 370assert res .status_code == 401 371assert res .body .get ("error" , {}).get ("type" )== "authentication_error" 372 373_load_model_and_wait (model_id ,headers = auth_headers ) 374 375authed = server .make_request ( 376"POST" , 377"/v1/chat/completions" , 378headers = auth_headers , 379data = { 380"model" :model_id , 381"messages" : [{"role" :"user" ,"content" :"hello" }], 382"max_tokens" :4 , 383 }, 384 ) 385assert authed .status_code == 200 386assert "error" not in authed .body 387 388 389def test_router_reload_models (): 390"""POST /models/reload re-reads the INI preset and updates the model list.""" 391global server 392 393preset_path = os .path .join (TMP_DIR ,"test_reload.ini" ) 394 395# Initial preset: two models 396with open (preset_path ,"w" )as f : 397f .write ( 398"[model-reload-a]\n" 399"hf-repo = ggml-org/test-model-stories260K\n" 400"\n" 401"[model-reload-b]\n" 402"hf-repo = ggml-org/test-model-stories260K-infill\n" 403 ) 404 405server .models_preset = preset_path 406server .start () 407 408ids = _get_model_ids (is_reload = False ) 409assert "model-reload-a" in ids 410assert "model-reload-b" in ids 411 412# Updated preset: remove a, keep b unchanged, add c 413with open (preset_path ,"w" )as f : 414f .write ( 415"[model-reload-b]\n" 416"hf-repo = ggml-org/test-model-stories260K-infill\n" 417"\n" 418"[model-reload-c]\n" 419"hf-repo = ggml-org/test-model-stories260K\n" 420 ) 421 422try : 423ids = _get_model_ids (is_reload = True ) 424assert "model-reload-a" not in ids ,"removed model should no longer appear" 425assert "model-reload-b" in ids ,"unchanged model should still appear" 426assert "model-reload-c" in ids ,"newly added model should appear" 427finally : 428os .remove (preset_path ) 429 430 431def test_router_dedup_cache_models (): 432"""dedup-cache-models hides the cache entry backing a preset from GET /models""" 433global server 434 435preset_path = os .path .join (TMP_DIR ,"test_dedup.ini" ) 436cache_id = "ggml-org/test-model-stories260K:F32" 437 438with open (preset_path ,"w" )as f : 439f .write ( 440"[model-dedup]\n" 441"hf-repo = ggml-org/test-model-stories260K\n" 442"dedup-cache-models = 1\n" 443 ) 444 445server .models_preset = preset_path 446server .start () 447 448try : 449ids = _get_model_ids (is_reload = False ) 450assert "model-dedup" in ids 451assert cache_id not in ids ,"cache model should be hidden by dedup" 452# other cache models are unaffected 453assert "ggml-org/tinygemma3-GGUF:Q8_0" in ids 454 455# the hidden model is only hidden from the listing, it can still be used 456res = server .make_request ("POST" ,"/tokenize" ,data = {"model" :cache_id ,"content" :"hello" }) 457assert res .status_code == 200 458 459# disabling the flag brings the cache entry back on reload 460with open (preset_path ,"w" )as f : 461f .write ( 462"[model-dedup]\n" 463"hf-repo = ggml-org/test-model-stories260K\n" 464 ) 465ids = _get_model_ids (is_reload = True ) 466assert cache_id in ids 467 468# the flag also works from the global section 469with open (preset_path ,"w" )as f : 470f .write ( 471"[*]\n" 472"dedup-cache-models = 1\n" 473"\n" 474"[model-dedup]\n" 475"hf-repo = ggml-org/test-model-stories260K\n" 476 ) 477ids = _get_model_ids (is_reload = True ) 478assert "model-dedup" in ids 479assert cache_id not in ids ,"cache model should be hidden by global dedup" 480finally : 481os .remove (preset_path ) 482 483 484def test_router_remote_preset (): 485global server 486server .model_hf_repo = "ggml-org/test-preset-ci" 487server .model_hf_file = None 488server .offline = False 489server .start () 490 491# Should see preset models in GET /models 492res = server .make_request ("GET" ,"/models" ) 493assert res .status_code == 200 494ids = {item ["id" ]for item in res .body .get ("data" , [])} 495assert "tinygemma3-preset" in ids 496assert "stories260K-test" in ids 497 498# Should be able to load a preset model 499model_id = "tinygemma3-preset" 500_load_model_and_wait (model_id ) 501 502 503MODEL_DOWNLOAD_ID = "ggml-org/test-model-router-download:F16" 504MODEL_DOWNLOAD_TIMEOUT = 30 505 506 507def _listen_sse ( 508server :ServerProcess ,collected :list ,stop :threading .Event ,ready :threading .Event | None = None 509): 510"""Collect /models/sse events into `collected` until `stop` is set. 511 512When `ready` is provided, it is set once the streaming response is open, 513i.e. the server has accepted the connection and registered us as a 514subscriber. Callers that trigger one-shot events (e.g. download_finished) 515must wait on `ready` before acting, otherwise the event can be broadcast 516before this client is subscribed and be lost. 517""" 518url = f"http:// { server . server_host } : { server . server_port } /models/sse" 519try : 520with requests .get (url ,stream = True ,timeout = MODEL_DOWNLOAD_TIMEOUT )as resp : 521if ready is not None : 522ready .set () 523for line_bytes in resp .iter_lines (): 524if stop .is_set (): 525break 526line = line_bytes .decode ("utf-8" ) 527if line .startswith ("data: " ): 528collected .append (json .loads (line [6 :])) 529except Exception : 530pass 531 532 533def _wait_for_sse_event (collected :list ,event_type :str ,model :str ,timeout :int )-> bool : 534deadline = time .time ()+ timeout 535while time .time ()< deadline : 536if any (e .get ("event" )== event_type and e .get ("model" )== model for e in collected ): 537return True 538time .sleep (0.01 ) 539return False 540 541 542def test_router_download_model (): 543"""Case 1: download a model at the model limit, verify SSE events and GET /models.""" 544global server 545server .models_max = 1 546server .start () 547 548# Ensure the model is not present before we start 549server .make_request ("DELETE" ,f"/models?model= { MODEL_DOWNLOAD_ID } " ) 550 551# A download worker must not consume or evict a model slot 552_load_model_and_wait (MODEL_B ,timeout = 120 ) 553 554sse_events :list = [] 555stop = threading .Event () 556sse_ready = threading .Event () 557sse_thread = threading .Thread ( 558target = _listen_sse ,args = (server ,sse_events ,stop ,sse_ready ),daemon = True 559 ) 560sse_thread .start () 561 562# wait for the SSE client to be subscribed before triggering the download, 563# otherwise the one-shot download_finished event can be broadcast before 564# this client is registered and be lost 565assert sse_ready .wait (10 ),"SSE client failed to connect" 566 567# Trigger the download 568res = server .make_request ("POST" ,"/models" ,data = {"model" :MODEL_DOWNLOAD_ID }) 569assert res .status_code == 200 570assert res .body .get ("success" )is True 571 572# Wait for download_finished SSE event 573finished = _wait_for_sse_event ( 574sse_events ,"download_finished" ,MODEL_DOWNLOAD_ID ,MODEL_DOWNLOAD_TIMEOUT 575 ) 576stop .set () 577 578assert finished ,"Never received download_finished SSE event" 579assert any ( 580e .get ("event" )== "download_progress" and e .get ("model" )== MODEL_DOWNLOAD_ID 581for e in sse_events 582 ),"No download_progress events received" 583 584# Model should now appear in GET /models 585ids = _get_model_ids (is_reload = False ) 586assert MODEL_DOWNLOAD_ID in ids ,f" { MODEL_DOWNLOAD_ID } not found in /models after download" 587assert _get_model_status (MODEL_B )== "loaded" 588 589 590def test_router_delete_model (): 591"""Case 2: delete the downloaded model, verify it disappears from GET /models.""" 592global server 593server .start () 594 595# Ensure the model exists (download it if needed) 596if MODEL_DOWNLOAD_ID not in _get_model_ids (is_reload = False ): 597sse_events :list = [] 598stop = threading .Event () 599sse_ready = threading .Event () 600threading .Thread ( 601target = _listen_sse ,args = (server ,sse_events ,stop ,sse_ready ),daemon = True 602 ).start () 603# subscribe before triggering the download so the one-shot 604# download_finished event is not lost (see test_router_download_model) 605assert sse_ready .wait (10 ),"SSE client failed to connect" 606res = server .make_request ("POST" ,"/models" ,data = {"model" :MODEL_DOWNLOAD_ID }) 607assert res .status_code == 200 608finished = _wait_for_sse_event ( 609sse_events ,"download_finished" ,MODEL_DOWNLOAD_ID ,MODEL_DOWNLOAD_TIMEOUT 610 ) 611stop .set () 612assert finished ,"Model did not finish downloading before delete test" 613 614# Delete the model 615del_res = server .make_request ("DELETE" ,f"/models?model= { MODEL_DOWNLOAD_ID } " ) 616assert del_res .status_code == 200 617assert del_res .body .get ("success" )is True 618 619# Model should no longer appear in GET /models 620ids = _get_model_ids (is_reload = False ) 621assert MODEL_DOWNLOAD_ID not in ids ,f" { MODEL_DOWNLOAD_ID } still present after deletion"