char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
42f0225fe
char/aliceai-t5-moe
1import pytest 2from utils import * 3import base64 4import requests 5import struct 6 7# sequence state file: magic(4) version(4) payload_size(4), then payload_size llama_token words 8STATE_FILE_HEADER_SIZE = 12 9 10server = ServerPreset .tinyllama2 () 11 12@ pytest . fixture ( autouse = True ) 13def create_server (tmp_path ): 14global server 15server = ServerPreset .tinyllama2 () 16server .slot_save_path = str (tmp_path ) 17server .temperature = 0.0 18 19 20def test_slot_save_restore (): 21global server 22server .start () 23 24# First prompt in slot 1 should be fully processed 25res = server .make_request ("POST" ,"/completion" ,data = { 26"prompt" :"What is the capital of France?" , 27"id_slot" :1 , 28"cache_prompt" :True , 29 }) 30assert res .status_code == 200 31assert match_regex ("(Whiskers|Flana)+" ,res .body ["content" ]) 32assert res .body ["timings" ]["prompt_n" ]== 21 # all tokens are processed 33 34# Save state of slot 1 35res = server .make_request ("POST" ,"/slots/1?action=save" ,data = { 36"filename" :"slot1.bin" , 37 }) 38assert res .status_code == 200 39assert res .body ["n_saved" ]== 84 40 41# Since we have cache, this should only process the last tokens 42res = server .make_request ("POST" ,"/completion" ,data = { 43"prompt" :"What is the capital of Germany?" , 44"id_slot" :1 , 45"cache_prompt" :True , 46 }) 47assert res .status_code == 200 48assert match_regex ("(Jack|said)+" ,res .body ["content" ]) 49assert res .body ["timings" ]["prompt_n" ]== 6 # only different part is processed 50 51# Loading the saved cache into slot 0 52res = server .make_request ("POST" ,"/slots/0?action=restore" ,data = { 53"filename" :"slot1.bin" , 54 }) 55assert res .status_code == 200 56assert res .body ["n_restored" ]== 84 57 58# Since we have cache, slot 0 should only process the last tokens 59res = server .make_request ("POST" ,"/completion" ,data = { 60"prompt" :"What is the capital of Germany?" , 61"id_slot" :0 , 62"cache_prompt" :True , 63 }) 64assert res .status_code == 200 65assert match_regex ("(Jack|said)+" ,res .body ["content" ]) 66assert res .body ["timings" ]["prompt_n" ]== 6 # only different part is processed 67 68# For verification that slot 1 was not corrupted during slot 0 load, same thing should work 69res = server .make_request ("POST" ,"/completion" ,data = { 70"prompt" :"What is the capital of Germany?" , 71"id_slot" :1 , 72"cache_prompt" :True , 73 }) 74assert res .status_code == 200 75assert match_regex ("(Jack|said)+" ,res .body ["content" ]) 76assert res .body ["timings" ]["prompt_n" ]== 1 77 78 79def test_slot_restore_legacy_token_list (): 80global server 81server .start () 82 83res = server .make_request ("POST" ,"/completion" ,data = { 84"prompt" :"What is the capital of France?" , 85"id_slot" :1 , 86"cache_prompt" :True , 87 }) 88assert res .status_code == 200 89 90res = server .make_request ("POST" ,"/slots/1?action=save" ,data = { 91"filename" :"slot_legacy.bin" , 92 }) 93assert res .status_code == 200 94assert res .body ["n_saved" ]== 84 95 96# rewrite the token payload into a plain token list, as written by servers that predate the packed server_tokens format 97path = os .path .join (server .slot_save_path ,"slot_legacy.bin" ) 98with open (path ,"rb" )as f : 99data = bytearray (f .read ()) 100 101# the payload written by this server starts with a packed header: LLAMA_TOKEN_NULL(4) version(4) n_tokens(4) 102packed_header_size = 12 103 104payload_size = struct .unpack_from ("=I" ,data ,STATE_FILE_HEADER_SIZE - 4 )[0 ] 105payload_end = STATE_FILE_HEADER_SIZE + payload_size * 4 106n_tokens = struct .unpack_from ("=I" ,data ,STATE_FILE_HEADER_SIZE + 8 )[0 ] 107assert n_tokens == 84 108 109tokens_start = STATE_FILE_HEADER_SIZE + packed_header_size 110data = data [:STATE_FILE_HEADER_SIZE ]+ data [tokens_start :tokens_start + n_tokens * 4 ]+ data [payload_end :] 111struct .pack_into ("=I" ,data ,STATE_FILE_HEADER_SIZE - 4 ,n_tokens ) 112 113with open (path ,"wb" )as f : 114f .write (data ) 115 116# the plain token list must restore, and the restored KV must be reusable 117res = server .make_request ("POST" ,"/slots/0?action=restore" ,data = { 118"filename" :"slot_legacy.bin" , 119 }) 120assert res .status_code == 200 121assert res .body ["n_restored" ]== 84 122 123res = server .make_request ("POST" ,"/completion" ,data = { 124"prompt" :"What is the capital of Germany?" , 125"id_slot" :0 , 126"cache_prompt" :True , 127 }) 128assert res .status_code == 200 129assert res .body ["timings" ]["prompt_n" ]== 6 # only the different part is processed 130 131 132 133def test_slot_erase (): 134global server 135server .start () 136 137res = server .make_request ("POST" ,"/completion" ,data = { 138"prompt" :"What is the capital of France?" , 139"id_slot" :1 , 140"cache_prompt" :True , 141 }) 142assert res .status_code == 200 143assert match_regex ("(Whiskers|Flana)+" ,res .body ["content" ]) 144assert res .body ["timings" ]["prompt_n" ]== 21 # all tokens are processed 145 146# erase slot 1 147res = server .make_request ("POST" ,"/slots/1?action=erase" ) 148assert res .status_code == 200 149 150# re-run the same prompt, it should process all tokens again 151res = server .make_request ("POST" ,"/completion" ,data = { 152"prompt" :"What is the capital of France?" , 153"id_slot" :1 , 154"cache_prompt" :True , 155 }) 156assert res .status_code == 200 157assert match_regex ("(Whiskers|Flana)+" ,res .body ["content" ]) 158assert res .body ["timings" ]["prompt_n" ]== 21 # all tokens are processed 159 160 161# 162# Multimodal server (mmproj loaded) slot save/restore. 163# 164# A pure-text slot on a multimodal server and a slot containing images must both support save/restore. 165# Erase remains gated on the slot's content. 166# 167 168IMG_URL_CAT = "https://huggingface.co/ggml-org/tinygemma3-GGUF/resolve/main/test/91_cat.png" 169IMG_URL_TRUCK = "https://huggingface.co/ggml-org/tinygemma3-GGUF/resolve/main/test/11_truck.png" 170 171 172def _get_img_base64 (url :str )-> str : 173response = requests .get (url ) 174response .raise_for_status ()# Raise an exception for bad status codes 175return base64 .b64encode (response .content ).decode ("utf-8" ) 176 177 178@ pytest . fixture 179def mmproj_server (): 180# tinygemma3 is a small multimodal model: the mmproj is provided by the HF registry API and auto-downloaded on first run. 181os .environ ['LLAMA_MEDIA_MARKER' ]= '<__media__>' 182mm_server = ServerPreset .tinygemma3 () 183mm_server .slot_save_path = "./tmp" 184mm_server .temperature = 0.0 185return mm_server 186 187 188def test_slot_save_restore_text_only_on_multimodal (mmproj_server ): 189server = mmproj_server 190server .start () 191 192# A pure-text prompt processed on slot 1 of a multimodal server. 193res = server .make_request ("POST" ,"/completion" ,data = { 194"prompt" :"The quick brown fox jumps over the lazy dog." , 195"id_slot" :1 , 196"cache_prompt" :True , 197 }) 198assert res .status_code == 200 199prompt_n = res .body ["timings" ]["prompt_n" ] 200assert prompt_n > 0 # all tokens are processed 201 202# Saving a pure-text slot must succeed even though an mmproj is loaded. 203res = server .make_request ("POST" ,"/slots/1?action=save" ,data = { 204"filename" :"mm_slot1.bin" , 205 }) 206assert res .status_code == 200 207n_saved = res .body ["n_saved" ] 208assert n_saved > 0 # the slot KV (prompt + generated tokens) was written 209 210# Restore the saved state into slot 0; it must round-trip exactly. 211res = server .make_request ("POST" ,"/slots/0?action=restore" ,data = { 212"filename" :"mm_slot1.bin" , 213 }) 214assert res .status_code == 200 215assert res .body ["n_restored" ]== n_saved 216 217# Prefix reuse is not checked with the default SWA cache. 218res = server .make_request ("POST" ,"/completion" ,data = { 219"prompt" :"The quick brown fox jumps over the lazy dog." , 220"id_slot" :0 , 221"cache_prompt" :True , 222 }) 223assert res .status_code == 200 224 225 226def test_slot_save_restore_with_image (mmproj_server ): 227server = mmproj_server 228# Use the full SWA cache so the restored image prefix can be reused. 229server .swa_full = True 230server .start () 231 232prompt_cat = { 233"prompt_string" :"What is this: <__media__>\n" , 234"multimodal_data" : [_get_img_base64 (IMG_URL_CAT )], 235 } 236res = server .make_request ("POST" ,"/completions" ,data = { 237"temperature" :0.0 , 238"top_k" :1 , 239"id_slot" :1 , 240"cache_prompt" :True , 241"prompt" :prompt_cat , 242 }) 243assert res .status_code == 200 244content_cat = res .body ["content" ] 245prompt_n_full = res .body ["timings" ]["prompt_n" ] 246assert res .body ["timings" ]["cache_n" ]== 0 247assert prompt_n_full > 32 # text plus image tokens are all processed 248 249res = server .make_request ("POST" ,"/slots/1?action=save" ,data = { 250"filename" :"mm_slot_image.bin" , 251 }) 252assert res .status_code == 200 253n_saved = res .body ["n_saved" ] 254n_written = res .body ["n_written" ] 255assert n_saved > 0 256assert n_written > 0 257 258res = server .make_request ("POST" ,"/slots/1?action=erase" ) 259assert res .status_code == 200 260 261res = server .make_request ("POST" ,"/slots/0?action=restore" ,data = { 262"filename" :"mm_slot_image.bin" , 263 }) 264assert res .status_code == 200 265assert res .body ["n_restored" ]== n_saved 266assert res .body ["n_read" ]== n_written 267 268# a different image must not reuse the restored image tokens; only the text prefix before the image is common 269res = server .make_request ("POST" ,"/completions" ,data = { 270"temperature" :0.0 , 271"top_k" :1 , 272"id_slot" :0 , 273"cache_prompt" :True , 274"prompt" : { 275"prompt_string" :"What is this: <__media__>\n" , 276"multimodal_data" : [_get_img_base64 (IMG_URL_TRUCK )], 277 }, 278 }) 279assert res .status_code == 200 280cache_n = res .body ["timings" ]["cache_n" ] 281assert cache_n < 16 282assert res .body ["timings" ]["prompt_n" ]== prompt_n_full - cache_n 283 284# restore again and resend the same image: the image tokens must be reused and greedy sampling must reproduce the original content 285res = server .make_request ("POST" ,"/slots/0?action=restore" ,data = { 286"filename" :"mm_slot_image.bin" , 287 }) 288assert res .status_code == 200 289assert res .body ["n_restored" ]== n_saved 290 291res = server .make_request ("POST" ,"/completions" ,data = { 292"temperature" :0.0 , 293"top_k" :1 , 294"id_slot" :0 , 295"cache_prompt" :True , 296"prompt" :prompt_cat , 297 }) 298assert res .status_code == 200 299assert res .body ["timings" ]["cache_n" ]== prompt_n_full - 1 300assert res .body ["timings" ]["prompt_n" ]== 1 301assert res .body ["content" ]== content_cat 302 303 304def test_slot_save_restore_with_two_images (mmproj_server ): 305server = mmproj_server 306server .swa_full = True 307server .n_ctx = 2048 # two images need more than the default 512 per slot 308server .start () 309 310prompt = { 311"prompt_string" :"A: <__media__> B: <__media__>\n" , 312"multimodal_data" : [_get_img_base64 (IMG_URL_CAT ),_get_img_base64 (IMG_URL_TRUCK )], 313 } 314res = server .make_request ("POST" ,"/completions" ,data = { 315"temperature" :0.0 , 316"top_k" :1 , 317"id_slot" :1 , 318"cache_prompt" :True , 319"prompt" :prompt , 320 }) 321assert res .status_code == 200 322prompt_n_full = res .body ["timings" ]["prompt_n" ] 323assert prompt_n_full > 64 324 325res = server .make_request ("POST" ,"/slots/1?action=save" ,data = { 326"filename" :"mm_slot_two_images.bin" , 327 }) 328assert res .status_code == 200 329n_saved = res .body ["n_saved" ] 330 331res = server .make_request ("POST" ,"/slots/0?action=restore" ,data = { 332"filename" :"mm_slot_two_images.bin" , 333 }) 334assert res .status_code == 200 335assert res .body ["n_restored" ]== n_saved 336 337res = server .make_request ("POST" ,"/completions" ,data = { 338"temperature" :0.0 , 339"top_k" :1 , 340"id_slot" :0 , 341"cache_prompt" :True , 342"prompt" :prompt , 343 }) 344assert res .status_code == 200 345assert res .body ["timings" ]["cache_n" ]== prompt_n_full - 1 346assert res .body ["timings" ]["prompt_n" ]== 1 347content = res .body ["content" ] 348 349res = server .make_request ("POST" ,"/slots/1?action=restore" ,data = { 350"filename" :"mm_slot_two_images.bin" , 351 }) 352assert res .status_code == 200 353assert res .body ["n_restored" ]== n_saved 354 355res = server .make_request ("POST" ,"/completions" ,data = { 356"temperature" :0.0 , 357"top_k" :1 , 358"id_slot" :0 , 359"cache_prompt" :True , 360"prompt" :prompt , 361 }) 362assert res .status_code == 200 363assert res .body ["timings" ]["cache_n" ]== prompt_n_full - 1 364assert res .body ["timings" ]["prompt_n" ]== 1 365content = res .body ["content" ] 366 367assert res .body ["content" ]== content 368 369 370def test_slot_save_restore_with_image_across_restart (mmproj_server ): 371server = mmproj_server 372server .swa_full = True 373server .start () 374 375prompt_cat = { 376"prompt_string" :"What is this: <__media__>\n" , 377"multimodal_data" : [_get_img_base64 (IMG_URL_CAT )], 378 } 379res = server .make_request ("POST" ,"/completions" ,data = { 380"temperature" :0.0 , 381"top_k" :1 , 382"id_slot" :0 , 383"cache_prompt" :True , 384"prompt" :prompt_cat , 385 }) 386assert res .status_code == 200 387content = res .body ["content" ] 388prompt_n_full = res .body ["timings" ]["prompt_n" ] 389 390res = server .make_request ("POST" ,"/slots/0?action=save" ,data = { 391"filename" :"mm_slot_restart.bin" , 392 }) 393assert res .status_code == 200 394n_saved = res .body ["n_saved" ] 395 396# restart the server with the same model and mmproj: the saved file must restore in the new process and the image KV must be reused 397server .stop () 398server .start () 399 400res = server .make_request ("POST" ,"/slots/0?action=restore" ,data = { 401"filename" :"mm_slot_restart.bin" , 402 }) 403assert res .status_code == 200 404assert res .body ["n_restored" ]== n_saved 405 406res = server .make_request ("POST" ,"/completions" ,data = { 407"temperature" :0.0 , 408"top_k" :1 , 409"id_slot" :0 , 410"cache_prompt" :True , 411"prompt" :prompt_cat , 412 }) 413assert res .status_code == 200 414assert res .body ["timings" ]["cache_n" ]== prompt_n_full - 1 415assert res .body ["timings" ]["prompt_n" ]== 1 416assert res .body ["content" ]== content 417 418 419def test_slot_save_restore_image_payload_larger_than_context (mmproj_server ): 420server = mmproj_server 421server .swa_full = True 422server .start () 423 424# the slot context, as the server computed it (n_ctx split across the slots) 425res = server .make_request ("GET" ,"/props" ) 426assert res .status_code == 200 427n_ctx_slot = res .body ["default_generation_settings" ]["n_ctx" ] 428 429# a filler token, used to grow the prompt up to the slot context 430res = server .make_request ("POST" ,"/tokenize" ,data = {"content" :" hello" * 8 }) 431assert res .status_code == 200 432assert len (res .body ["tokens" ])== 8 433 434res = server .make_request ("POST" ,"/completions" ,data = { 435"temperature" :0.0 , 436"top_k" :1 , 437"id_slot" :0 , 438"cache_prompt" :True , 439"prompt" : { 440"prompt_string" :"What is this: <__media__>\n" , 441"multimodal_data" : [_get_img_base64 (IMG_URL_CAT )], 442 }, 443 }) 444assert res .status_code == 200 445 446prompt_cat = { 447"prompt_string" :"What is this: <__media__>\n" + " hello" * (n_ctx_slot - res .body ["timings" ]["prompt_n" ]- 8 ), 448"multimodal_data" : [_get_img_base64 (IMG_URL_CAT )], 449 } 450res = server .make_request ("POST" ,"/completions" ,data = { 451"temperature" :0.0 , 452"top_k" :1 , 453"id_slot" :0 , 454"cache_prompt" :True , 455"prompt" :prompt_cat , 456 }) 457assert res .status_code == 200 458prompt_n_full = res .body ["timings" ]["cache_n" ]+ res .body ["timings" ]["prompt_n" ] 459 460res = server .make_request ("POST" ,"/slots/0?action=save" ,data = { 461"filename" :"mm_slot_large_payload.bin" , 462 }) 463assert res .status_code == 200 464 465path = os .path .join (server .slot_save_path ,"mm_slot_large_payload.bin" ) 466with open (path ,"rb" )as f : 467data = bytearray (f .read ()) 468payload_size = struct .unpack_from ("=I" ,data ,STATE_FILE_HEADER_SIZE - 4 )[0 ] 469assert payload_size > n_ctx_slot # the scenario under test: the payload does not fit in n_ctx 470 471# drop the image from the slot, then restore it from the file 472res = server .make_request ("POST" ,"/completion" ,data = { 473"prompt" :"The quick brown fox" , 474"id_slot" :0 , 475"cache_prompt" :True , 476 }) 477assert res .status_code == 200 478 479res = server .make_request ("POST" ,"/slots/0?action=restore" ,data = { 480"filename" :"mm_slot_large_payload.bin" , 481 }) 482assert res .status_code == 200 483 484res = server .make_request ("POST" ,"/completions" ,data = { 485"temperature" :0.0 , 486"top_k" :1 , 487"id_slot" :0 , 488"cache_prompt" :True , 489"prompt" :prompt_cat , 490 }) 491assert res .status_code == 200 492assert res .body ["timings" ]["cache_n" ]== prompt_n_full - 1 493assert res .body ["timings" ]["prompt_n" ]== 1 494 495 496def test_slot_restore_media_file_without_mmproj (mmproj_server ): 497server = mmproj_server 498server .start () 499 500res = server .make_request ("POST" ,"/completions" ,data = { 501"temperature" :0.0 , 502"top_k" :1 , 503"id_slot" :0 , 504"cache_prompt" :True , 505"prompt" : { 506"prompt_string" :"What is this: <__media__>\n" , 507"multimodal_data" : [_get_img_base64 (IMG_URL_CAT )], 508 }, 509 }) 510assert res .status_code == 200 511 512res = server .make_request ("POST" ,"/slots/0?action=save" ,data = { 513"filename" :"mm_slot_no_mmproj.bin" , 514 }) 515assert res .status_code == 200 516 517# restart the same model without the mmproj: restoring the media file must fail gracefully and leave the slot usable 518server .stop () 519server .no_mmproj = True 520server .start () 521 522res = server .make_request ("POST" ,"/slots/0?action=restore" ,data = { 523"filename" :"mm_slot_no_mmproj.bin" , 524 }) 525assert res .status_code == 400 526assert "Cannot restore media tokens without an mmproj" in res .body ["error" ]["message" ] 527 528# A failed restore must leave the slot empty and usable. 529res = server .make_request ("POST" ,"/completions" ,data = { 530"temperature" :0.0 , 531"top_k" :1 , 532"id_slot" :1 , 533"cache_prompt" :True , 534"prompt" :"The quick brown fox" , 535 }) 536assert res .status_code == 200 537content = res .body ["content" ] 538 539res = server .make_request ("POST" ,"/completions" ,data = { 540"temperature" :0.0 , 541"top_k" :1 , 542"id_slot" :0 , 543"cache_prompt" :True , 544"prompt" :"The quick brown fox" , 545 }) 546assert res .status_code == 200 547assert res .body ["timings" ]["cache_n" ]== 0 548assert res .body ["content" ]== content