char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
d9f918d2d
char/aliceai-t5-moe
1// tests the HF model resolution and the model handler assembly end-to-end on 2// synthetic repo listings: a local httplib server bound to the loopback 3// serves hardcoded HF API responses, so the real client, hf_cache, resolution 4// and CLI parsing run against them without external network access 5 6#include "arg.h" 7#include "common.h" 8#include "download.h" 9#include "http.h" 10#include "log.h" 11 12#include "json.h" 13 14#include <algorithm> 15#include <cstdio> 16#include <cstdlib> 17#include <filesystem> 18#include <map> 19#include <thread> 20#include <string> 21#include <vector> 22 23// the case and reordering being checked, printed with every failure 24static std::string g_context ; 25 26// independent of NDEBUG, so the checks stay alive in Release builds 27#define REQUIRE (x ) do { \ 28 if (!(x)) { \ 29 fprintf(stderr, "%s:%d: [%s] REQUIRE(%s) failed\n", \ 30 __FILE__, __LINE__, g_context.c_str(), #x); \ 31 std::abort(); \ 32 } \ 33} while (0) 34 35#define REQUIRE_EQ (actual ,expected ) do { \ 36 if (!((actual) == (expected))) { \ 37 fprintf(stderr, "%s:%d: [%s] REQUIRE_EQ(%s, %s) failed\n actual: '%s'\n expected: '%s'\n", \ 38 __FILE__, __LINE__, g_context.c_str(), #actual, #expected, \ 39 std::string(actual).c_str(), std::string(expected).c_str()); \ 40 std::abort(); \ 41 } \ 42} while (0) 43 44// 45// synthetic repos keyed by repo id, served over the loopback by a real 46// httplib server, so the tested code runs its own client and transport 47// 48 49static std::map < std::string , std::vector < std::string >> g_repos ; 50 51static const char * COMMIT = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ; 52 53// the server lives in main, so its destructor runs before the static teardown 54// tears down the winsock state httplib brings in 55static void serve_repos (httplib::Server & server ) { 56server .Get (R"(/api/models/(.+)/refs)" , [](const httplib::Request & req , httplib::Response & res ) { 57if (g_repos .count (req .matches [1 ])) { 58res .set_content (common_json {{"branches" , common_json::array ({common_json {{"name" ,"main" }, {"targetCommit" ,COMMIT }} })}}.dump (), 59"application/json" ); 60 }else { 61res .status = 404 ; 62 } 63 }); 64server .Get (R"(/api/models/(.+)/tree/.+)" , [](const httplib::Request & req , httplib::Response & res ) { 65if (!g_repos .count (req .matches [1 ])) { 66res .status = 404 ; 67return ; 68 } 69auto files = common_json::array (); 70size_t i = 0 ; 71for (const auto & p :g_repos [req .matches [1 ]]) { 72char oid [41 ]; 73snprintf (oid ,sizeof (oid ),"%040lx" , (unsigned long )++ i ); 74files .push_back ({{"type" ,"file" }, {"path" ,p }, {"size" ,1 }, {"oid" ,oid }}); 75 } 76res .set_content (files .dump (),"application/json" ); 77 }); 78} 79 80static common_params_model model_ref (const std::string & hf_repo ,const std::string & hf_file = "" ) { 81common_params_model m ; 82m .hf_repo = hf_repo ; 83m .hf_file = hf_file ; 84return m ; 85} 86 87// the model cache is isolated under a temporary directory named after the 88// loopback port, so concurrent runs on a shared machine keep their own, and 89// the local path the handler wires for a file is snapshots/<commit>/<path> 90static std::filesystem::path cache_dir ; 91 92static std::string cached (std::string repo_id ,const std::string & path ) { 93string_replace_all (repo_id ,"/" ,"--" ); 94return (cache_dir / ("models--" + repo_id ) /"snapshots" /COMMIT /path ).string (); 95} 96 97// 98// fixtures mimicking real repo layouts 99// 100 101// flat layout in the style of ggml-org/gemma-4-31B-it-GGUF 102static const std::vector < std::string > flat = { 103"README.md" , 104"model-BF16.gguf" , 105"model-Q4_K_M.gguf" , 106"model-Q8_0.gguf" , 107"mmproj-model-BF16.gguf" , 108"mmproj-model-Q8_0.gguf" , 109"mtp-model-BF16.gguf" , 110"mtp-model-Q4_0.gguf" , 111"mtp-model-Q8_0.gguf" , 112"dflash-model-BF16.gguf" , 113"dflash-model-Q8_0.gguf" , 114}; 115 116// quants in subdirectories with sharded files and root sidecars, 117// in the style of stepfun-ai/Step-3.7-Flash-GGUF 118static const std::vector < std::string > subdir = { 119"mmproj-model-f16.gguf" , 120"model-mtp-BF16.gguf" , 121"model-mtp-Q8_0.gguf" , 122"Q3_K_M/model-Q3_K_M-00001-of-00003.gguf" , 123"Q3_K_M/model-Q3_K_M-00002-of-00003.gguf" , 124"Q3_K_M/model-Q3_K_M-00003-of-00003.gguf" , 125"Q8_0/model-Q8_0-00001-of-00002.gguf" , 126"Q8_0/model-Q8_0-00002-of-00002.gguf" , 127}; 128 129// sidecar quants exist where the full model quant does not, 130// in the style of ggml-org/Qwen3.6-27B-GGUF 131static const std::vector < std::string > hole = { 132"model-BF16.gguf" , 133"model-Q4_K_M.gguf" , 134"model-Q8_0.gguf" , 135"mtp-model-BF16.gguf" , 136"mtp-model-Q4_0.gguf" , 137"mtp-model-Q8_0.gguf" , 138"dflash-model-BF16.gguf" , 139"dflash-model-Q8_0.gguf" , 140}; 141 142// unsloth-style naming with UD quants and a suffix MTP file 143static const std::vector < std::string > unsloth = { 144"model-UD-Q8_K_XL.gguf" , 145"mmproj-BF16.gguf" , 146"model-MTP-BF16.gguf" , 147}; 148 149// bartowski-style vendor prefix and mradermacher-style dot quant 150static const std::vector < std::string > vendors = { 151"TheDrummer_Model-24B-v4.1-Q8_0.gguf" , 152"BlackSheep-24B.Q8_0.gguf" , 153}; 154 155// every speculative sidecar type at the same quant 156static const std::vector < std::string > quad = { 157"model-Q8_0.gguf" , 158"mtp-model-Q8_0.gguf" , 159"dflash-model-Q8_0.gguf" , 160"eagle3-model-Q8_0.gguf" , 161"dspark-model-Q8_0.gguf" , 162}; 163 164static const std::vector < std::string > dflash_only = { 165"model-Q8_0.gguf" , 166"dflash-model-Q8_0.gguf" , 167}; 168 169static const std::vector < std::string > eagle3_only = { 170"model-Q8_0.gguf" , 171"eagle3-model-Q8_0.gguf" , 172}; 173 174// a single full quant with dspark sidecars at other quants, 175// in the style of ggml-org/DeepSeek-V4-Flash-0731-GGUF 176static const std::vector < std::string > spark = { 177"README.md" , 178"model-MXFP4.gguf" , 179"dspark-model-BF16.gguf" , 180"dspark-model-MXFP4.gguf" , 181}; 182 183// dspark outranks dflash in the type auto-selection 184static const std::vector < std::string > dspark_dflash = { 185"model-Q8_0.gguf" , 186"dflash-model-Q8_0.gguf" , 187"dspark-model-Q8_0.gguf" , 188}; 189 190// 191// table-driven plan resolution through the real entry point, 192// each case replayed on multiple deterministic reorderings of the listing, 193// except the cases whose pick legitimately depends on the listing order 194// 195 196struct plan_case { 197const char * name ; 198const std::vector < std::string > files ; 199const char * hf_repo ; 200const char * hf_file ; 201bool sidecars ;// request mmproj + mtp + dflash + eagle3 + dspark 202bool order_dependent ;// the expected pick depends on the listing order 203const char * primary ; 204 std::vector < std::string > model_files ; 205const char * mmproj ; 206const char * mtp ; 207const char * dflash ; 208const char * eagle3 ; 209const char * dspark ; 210}; 211 212static const plan_case plan_cases []= { 213// exact tag picks the matching primary, sidecars follow the tag 214 {"flat exact tag" ,flat ,"test/repo:Q8_0" ,"" , true, false, 215"model-Q8_0.gguf" , {"model-Q8_0.gguf" }, 216"mmproj-model-Q8_0.gguf" ,"mtp-model-Q8_0.gguf" ,"dflash-model-Q8_0.gguf" ,"" ,"" }, 217 218// no tag falls back to the default quant preference 219 {"flat default" ,flat ,"test/repo" ,"" , false, false, 220"model-Q4_K_M.gguf" , {"model-Q4_K_M.gguf" }, 221"" ,"" ,"" ,"" ,"" }, 222 223// no tag and no default match falls back to the first model in the listing 224 {"unsloth fallback" ,unsloth ,"test/repo" ,"" , true, true, 225"model-UD-Q8_K_XL.gguf" , {"model-UD-Q8_K_XL.gguf" }, 226"mmproj-BF16.gguf" ,"" ,"" ,"" ,"" }, 227 228// explicit hf_file picks that exact file 229 {"flat hf_file" ,flat ,"test/repo" ,"model-BF16.gguf" , false, false, 230"model-BF16.gguf" , {"model-BF16.gguf" }, 231"" ,"" ,"" ,"" ,"" }, 232 233// missing hf_file resolves nothing 234 {"flat missing hf_file" ,flat ,"test/repo" ,"nope.gguf" , false, false, 235"" , {}, 236"" ,"" ,"" ,"" ,"" }, 237 238// a sharded primary brings all its parts, a subdir primary finds the root sidecar 239 {"subdir shards" ,subdir ,"test/repo:Q3_K_M" ,"" , true, false, 240"Q3_K_M/model-Q3_K_M-00001-of-00003.gguf" , 241 {"Q3_K_M/model-Q3_K_M-00001-of-00003.gguf" , 242"Q3_K_M/model-Q3_K_M-00002-of-00003.gguf" , 243"Q3_K_M/model-Q3_K_M-00003-of-00003.gguf" }, 244"mmproj-model-f16.gguf" ,"model-mtp-Q8_0.gguf" ,"" ,"" ,"" }, 245 246// a tag with no matching full model still resolves the requested sidecars 247 {"hole tag sidecar" ,hole ,"test/repo:Q4_0" ,"" , true, false, 248"" , {}, 249"" ,"mtp-model-Q4_0.gguf" ,"dflash-model-Q8_0.gguf" ,"" ,"" }, 250 251// the same tag without a requested sidecar resolves nothing 252 {"hole tag alone" ,hole ,"test/repo:Q4_0" ,"" , false, false, 253"" , {}, 254"" ,"" ,"" ,"" ,"" }, 255 256// no tag anchors the sidecars on the primary quant 257 {"hole default anchor" ,hole ,"test/repo" ,"" , true, false, 258"model-Q4_K_M.gguf" , {"model-Q4_K_M.gguf" }, 259"" ,"mtp-model-Q4_0.gguf" ,"dflash-model-Q8_0.gguf" ,"" ,"" }, 260 261// the mtp- keyword is case sensitive, a suffix -MTP file is not discovered 262 {"unsloth suffix mtp" ,unsloth ,"test/repo:Q8_K_XL" ,"" , true, false, 263"model-UD-Q8_K_XL.gguf" , {"model-UD-Q8_K_XL.gguf" }, 264"mmproj-BF16.gguf" ,"" ,"" ,"" ,"" }, 265 266// vendor prefixes and the dot quant convention both match the tag, 267// first match wins between two files at the same quant 268 {"vendor prefix" ,vendors ,"test/repo:Q8_0" ,"" , false, true, 269"TheDrummer_Model-24B-v4.1-Q8_0.gguf" , {"TheDrummer_Model-24B-v4.1-Q8_0.gguf" }, 270"" ,"" ,"" ,"" ,"" }, 271 272// every sidecar type resolves at the tag 273 {"quad exact tag" ,quad ,"test/repo:Q8_0" ,"" , true, false, 274"model-Q8_0.gguf" , {"model-Q8_0.gguf" }, 275"" ,"mtp-model-Q8_0.gguf" ,"dflash-model-Q8_0.gguf" ,"eagle3-model-Q8_0.gguf" ,"dspark-model-Q8_0.gguf" }, 276 277// no tag anchors the dspark sidecar on the only full quant 278 {"spark default anchor" ,spark ,"test/repo" ,"" , true, false, 279"model-MXFP4.gguf" , {"model-MXFP4.gguf" }, 280"" ,"" ,"" ,"" ,"dspark-model-MXFP4.gguf" }, 281 282// a tag with no matching full model still resolves the exact dspark sidecar 283 {"spark tag sidecar" ,spark ,"test/repo:BF16" ,"" , true, false, 284"" , {}, 285"" ,"" ,"" ,"" ,"dspark-model-BF16.gguf" }, 286}; 287 288static void check_plan (const plan_case & c ) { 289common_download_opts opts ; 290opts .download_mmproj = c .sidecars ; 291opts .download_mtp = c .sidecars ; 292opts .download_dflash = c .sidecars ; 293opts .download_eagle3 = c .sidecars ; 294opts .download_dspark = c .sidecars ; 295 296auto plan = common_download_get_hf_plan (model_ref (c .hf_repo ,c .hf_file ),opts ); 297 298REQUIRE_EQ (plan .primary .path ,c .primary ); 299REQUIRE_EQ (plan .mmproj .path ,c .mmproj ); 300REQUIRE_EQ (plan .mtp .path ,c .mtp ); 301REQUIRE_EQ (plan .dflash .path ,c .dflash ); 302REQUIRE_EQ (plan .eagle3 .path ,c .eagle3 ); 303REQUIRE_EQ (plan .dspark .path ,c .dspark ); 304 305// exact shard set, order insensitive; the primary must be the first split 306 std::vector < std::string > actual ; 307for (const auto & f :plan .model_files ) { 308actual .push_back (f .path ); 309 } 310 std::sort (actual .begin (),actual .end ()); 311auto expected = c .model_files ; 312 std::sort (expected .begin (),expected .end ()); 313REQUIRE (actual == expected ); 314if (!expected .empty ()) { 315REQUIRE (plan .primary .path == expected .front ()); 316 } 317} 318 319static void test_plan_resolution () { 320printf ("test-model-resolution: plan resolution on %zu cases\n" ,sizeof (plan_cases ) /sizeof (plan_cases [0 ])); 321 322for (const auto & c :plan_cases ) { 323printf (" %s\n" ,c .name ); 324// invariant: the resolution is insensitive to the listing order 325for (size_t rot = 0 ;rot < c .files .size ();++ rot ) { 326if (c .order_dependent && rot > 0 ) { 327continue ; 328 } 329g_context = std::string (c .name )+ ", reordering " + std::to_string (rot ); 330auto files = c .files ; 331 std::rotate (files .begin (),files .begin ()+ rot ,files .end ()); 332if (rot %2 == 1 ) { 333 std::reverse (files .begin (),files .end ()); 334 } 335g_repos ["test/repo" ]= files ; 336check_plan (c ); 337 } 338 } 339g_repos .clear (); 340} 341 342// 343// end-to-end assembly: real CLI parsing, real handler init resolving over the 344// loopback, downloads skipped by flipping offline before apply 345// 346 347static void assemble (std::vector < std::string > argv ,common_params & params ) { 348 std::vector < char *> cargv ; 349g_context .clear (); 350for (auto & a :argv ) { 351g_context += g_context .empty () ?a :" " + a ; 352cargv .push_back (a .data ()); 353 } 354bool ok = common_params_parse ((int )cargv .size (),cargv .data (),params ,LLAMA_EXAMPLE_SERVER ); 355REQUIRE (ok ); 356 357auto handler = common_models_handler_init (params ,LLAMA_EXAMPLE_SERVER ); 358 359// skip the network execution, on_done still wires the params 360params .offline = true; 361common_models_handler_apply (handler ,params ); 362} 363 364static void test_task_assembly () { 365printf ("test-model-resolution: end-to-end assembly\n" ); 366 367g_repos ["test/main" ]= flat ; 368g_repos ["test/hole" ]= hole ; 369g_repos ["test/quad" ]= quad ; 370g_repos ["test/dflash" ]= dflash_only ; 371g_repos ["test/eagle3" ]= eagle3_only ; 372g_repos ["test/spark" ]= spark ; 373g_repos ["test/pair" ]= dspark_dflash ; 374g_repos ["test/small" ]= {"draft-model-Q4_K_M.gguf" }; 375g_repos ["test/preset" ]= {"preset.ini" ,"model-Q8_0.gguf" }; 376 377 { 378// plain -hf wires the model and its mmproj, nothing speculative 379common_params params ; 380assemble ({"server" ,"-hf" ,"test/main:Q8_0" },params ); 381REQUIRE_EQ (params .model .path ,cached ("test/main" ,"model-Q8_0.gguf" )); 382REQUIRE_EQ (params .mmproj .path ,cached ("test/main" ,"mmproj-model-Q8_0.gguf" )); 383REQUIRE (params .speculative .draft .mparams .path .empty ()); 384 } 385 { 386// --no-mmproj disables the mmproj discovery 387common_params params ; 388assemble ({"server" ,"-hf" ,"test/main:Q8_0" ,"--no-mmproj" },params ); 389REQUIRE (params .mmproj .path .empty ()); 390 } 391 { 392// an explicit --mmproj wins over the discovery 393common_params params ; 394assemble ({"server" ,"-hf" ,"test/main:Q8_0" ,"--mmproj" ,"/local/mmproj.gguf" },params ); 395REQUIRE (params .mmproj .path == "/local/mmproj.gguf" ); 396 } 397 { 398// -hf with a spec type wires the sidecar of the main repo as fallback draft 399common_params params ; 400assemble ({"server" ,"-hf" ,"test/main:Q8_0" ,"--spec-type" ,"draft-mtp" },params ); 401REQUIRE_EQ (params .speculative .draft .mparams .path ,cached ("test/main" ,"mtp-model-Q8_0.gguf" )); 402 } 403 { 404// -hfd with a spec type wires the draft repo sidecar at its tag, 405// not its full model, and suppresses the main repo fallback 406common_params params ; 407assemble ({"server" ,"-hf" ,"test/hole:Q8_0" ,"-hfd" ,"test/hole:Q4_0" ,"--spec-type" ,"draft-mtp" },params ); 408REQUIRE_EQ (params .speculative .draft .mparams .path ,cached ("test/hole" ,"mtp-model-Q4_0.gguf" )); 409 } 410 { 411// an explicit -md file wins over the sidecar resolution 412common_params params ; 413assemble ({"server" ,"-hf" ,"test/main:Q8_0" ,"-hfd" ,"test/main" ,"-md" ,"mtp-model-BF16.gguf" ,"--spec-type" ,"draft-mtp" },params ); 414REQUIRE_EQ (params .speculative .draft .mparams .path ,cached ("test/main" ,"mtp-model-BF16.gguf" )); 415 } 416 { 417// -hfd without a spec type auto-selects the type, mtp first when all ship 418common_params params ; 419assemble ({"server" ,"-hf" ,"test/main:Q8_0" ,"-hfd" ,"test/quad:Q8_0" },params ); 420REQUIRE (params .speculative .types == std::vector < enum common_speculative_type > {COMMON_SPECULATIVE_TYPE_DRAFT_MTP }); 421REQUIRE_EQ (params .speculative .draft .mparams .path ,cached ("test/quad" ,"mtp-model-Q8_0.gguf" )); 422 } 423 { 424// auto-selection with only a dflash sidecar 425common_params params ; 426assemble ({"server" ,"-hf" ,"test/main:Q8_0" ,"-hfd" ,"test/dflash:Q8_0" },params ); 427REQUIRE (params .speculative .types == std::vector < enum common_speculative_type > {COMMON_SPECULATIVE_TYPE_DRAFT_DFLASH }); 428REQUIRE_EQ (params .speculative .draft .mparams .path ,cached ("test/dflash" ,"dflash-model-Q8_0.gguf" )); 429 } 430 { 431// auto-selection with only an eagle3 sidecar 432common_params params ; 433assemble ({"server" ,"-hf" ,"test/main:Q8_0" ,"-hfd" ,"test/eagle3:Q8_0" },params ); 434REQUIRE (params .speculative .types == std::vector < enum common_speculative_type > {COMMON_SPECULATIVE_TYPE_DRAFT_EAGLE3 }); 435REQUIRE_EQ (params .speculative .draft .mparams .path ,cached ("test/eagle3" ,"eagle3-model-Q8_0.gguf" )); 436 } 437 { 438// auto-selection prefers dspark over dflash when both ship 439common_params params ; 440assemble ({"server" ,"-hf" ,"test/main:Q8_0" ,"-hfd" ,"test/pair:Q8_0" },params ); 441REQUIRE (params .speculative .types == std::vector < enum common_speculative_type > {COMMON_SPECULATIVE_TYPE_DRAFT_DSPARK }); 442REQUIRE_EQ (params .speculative .draft .mparams .path ,cached ("test/pair" ,"dspark-model-Q8_0.gguf" )); 443 } 444 { 445// -hf with the dspark spec type wires the sidecar of the main repo, 446// anchored on the only full quant 447common_params params ; 448assemble ({"server" ,"-hf" ,"test/spark" ,"--spec-type" ,"draft-dspark" },params ); 449REQUIRE_EQ (params .model .path ,cached ("test/spark" ,"model-MXFP4.gguf" )); 450REQUIRE_EQ (params .speculative .draft .mparams .path ,cached ("test/spark" ,"dspark-model-MXFP4.gguf" )); 451 } 452 { 453// -hfd on a repo without sidecars keeps resolving a full model as draft 454common_params params ; 455assemble ({"server" ,"-hf" ,"test/main:Q8_0" ,"-hfd" ,"test/small" },params ); 456REQUIRE (params .speculative .types == std::vector < enum common_speculative_type > {COMMON_SPECULATIVE_TYPE_NONE }); 457REQUIRE_EQ (params .speculative .draft .mparams .path ,cached ("test/small" ,"draft-model-Q4_K_M.gguf" )); 458 } 459 { 460// a preset repo wires the preset and clears the model for router mode 461common_params params ; 462assemble ({"server" ,"-hf" ,"test/preset" },params ); 463REQUIRE_EQ (params .models_preset ,cached ("test/preset" ,"preset.ini" )); 464REQUIRE (params .model .path .empty ()); 465REQUIRE (params .model .hf_repo .empty ()); 466 } 467 468g_repos .clear (); 469} 470 471int main (void ) { 472// unbuffered, so a crash cannot swallow the reports already printed 473setvbuf (stdout ,nullptr ,_IONBF ,0 ); 474setvbuf (stderr ,nullptr ,_IONBF ,0 ); 475 476// the negative cases legitimately log errors on every reordering, 477// keep the output down to the reports 478common_log_pause (common_log_main ()); 479 480// the loopback endpoint also keeps the client init from rejecting 481// https on the builds without TLS support 482 httplib::Server server ; 483serve_repos (server ); 484int port = server .bind_to_any_port ("127.0.0.1" ); 485 486// isolate the cache, its location is read once so it is set 487// before anything else 488cache_dir = std::filesystem::temp_directory_path () / 489 ("test-model-resolution-cache-" + std::to_string (port )); 490 std::filesystem::remove_all (cache_dir ); 491common_set_env ("LLAMA_CACHE" ,cache_dir .string ()); 492 493 std::thread server_thread ([& server ] {server .listen_after_bind (); }); 494server .wait_until_ready (); 495common_set_env ("MODEL_ENDPOINT" ,"http://127.0.0.1:" + std::to_string (port )+ "/" ); 496 497test_plan_resolution (); 498test_task_assembly (); 499 500server .stop (); 501server_thread .join (); 502 503 std::filesystem::remove_all (cache_dir ); 504printf ("test-model-resolution: all tests OK\n" ); 505return 0 ; 506}