char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
0adede866
char/aliceai-t5-moe
1#!/usr/bin/env python3 2""" 3Test structured output capability via chat completions endpoint. 4 5Each test case contains: 6- response_format: OpenAI-compatible response_format specification. 7Both "json_schema" and "json_object" are accepted; with 8"json_object" a schema can be supplied via extra_body. 9- extra_body (optional): dict of extra top-level request fields merged into 10the request payload (mirrors the OpenAI SDK's extra_body 11feature; llama.cpp reads a top-level "json_schema" here). 12- messages: initial conversation messages 13- tools (optional): tool definitions (for mixed tool + structured tests) 14- mock_tool_responses (optional): dict mapping tool_name -> callable(arguments) -> str (JSON) 15- apply_stage: "always" to apply response_format to every request, 16"after_tools" to run the tool loop plain, then request a 17structured summary in a follow-up user turn. 18- followup (optional, for after_tools): user message appended before the 19final structured call. 20- validate: callable(parsed_json, tool_calls_history, raw_content) -> (passed: bool, reason: str) 21""" 22 23import argparse 24import json 25import requests 26import sys 27from typing import Any ,cast 28 29# --------------------------------------------------------------------------- 30# Color / formatting helpers 31# --------------------------------------------------------------------------- 32 33RESET = "\x1b[0m" 34BOLD = "\x1b[1m" 35DIM = "\x1b[2m" 36CYAN = "\x1b[36m" 37YELLOW = "\x1b[33m" 38GREEN = "\x1b[32m" 39RED = "\x1b[31m" 40BLUE = "\x1b[34m" 41WHITE = "\x1b[97m" 42MAGENTA = "\x1b[35m" 43 44 45def _print (text = "" ,end = "\n" ): 46sys .stdout .write (text + end ) 47sys .stdout .flush () 48 49 50def print_header (title ): 51bar = "─" * 60 52_print (f"\n { BOLD } { CYAN } ┌ { bar } ┐ { RESET } " ) 53_print ( 54f" { BOLD } { CYAN } │ { WHITE } { title } { CYAN } { ' ' * max ( 0 , 58 - len ( title )) } │ { RESET } " 55 ) 56_print (f" { BOLD } { CYAN } └ { bar } ┘ { RESET } " ) 57 58 59def print_tool_call (name ,args ): 60args_str = json .dumps (args ) 61_print ( 62f"\n { BOLD } { YELLOW } ⚙ tool call { RESET } { CYAN } { name } { RESET } { DIM } ( { args_str } ) { RESET } " 63 ) 64 65 66def print_tool_result (result ): 67preview = result [:160 ]+ ("…" if len (result )> 160 else "" ) 68_print (f" { DIM } { BLUE } ↳ result { RESET } { DIM } { preview } { RESET } " ) 69 70 71def print_model_output (text ): 72sys .stdout .write (text ) 73sys .stdout .flush () 74 75 76def print_pass (reason ): 77_print (f"\n { BOLD } { GREEN } ✔ PASS { RESET } { reason } " ) 78 79 80def print_fail (reason ): 81_print (f"\n { BOLD } { RED } ✘ FAIL { RESET } { reason } " ) 82 83 84def print_info (msg ): 85_print (f" { DIM } { msg } { RESET } " ) 86 87 88def print_schema_note (label ,rf ,extra_body = None ): 89kind = rf .get ("type" ,"?" ) 90name = "" 91if kind == "json_schema" : 92name = rf .get ("json_schema" , {}).get ("name" ,"" ) 93elif kind == "json_object" and extra_body and "json_schema" in extra_body : 94extra_schema = extra_body ["json_schema" ]or {} 95name = extra_schema .get ("title" )or "extra_body.json_schema" 96_print (f" { DIM } { MAGENTA } ⟐ response_format [ { label } ]: { kind } " 97f" { ( ' / ' + name ) if name else '' } { RESET } " ) 98 99 100# --------------------------------------------------------------------------- 101# HTTP helpers 102# --------------------------------------------------------------------------- 103 104 105def chat_completion (url ,messages ,tools = None ,response_format = None ,stream = False , 106extra_body = None ): 107payload = { 108"messages" :messages , 109"stream" :stream , 110"max_tokens" :8192 , 111 } 112if tools : 113payload ["tools" ]= tools 114payload ["tool_choice" ]= "auto" 115if response_format is not None : 116payload ["response_format" ]= response_format 117if extra_body : 118payload .update (extra_body ) 119 120try : 121response = requests .post (url ,json = payload ,stream = stream ) 122response .raise_for_status () 123except requests .exceptions .RequestException as e : 124body = e .response .content if (e .response is not None )else b"" 125print_fail (f"Request error: { e } | body: { body } " ) 126return None 127 128full_content = "" 129reasoning_content = "" 130tool_calls :list [dict ]= [] 131 132if stream : 133for line in response .iter_lines (): 134if not line : 135continue 136decoded = line .decode ("utf-8" ) 137if not decoded .startswith ("data: " ): 138continue 139data_str = decoded [6 :] 140if data_str == "[DONE]" : 141break 142try : 143data = json .loads (data_str ) 144except json .JSONDecodeError : 145continue 146choices = data .get ("choices" , []) 147if not choices : 148continue 149delta = choices [0 ].get ("delta" , {}) 150if delta .get ("reasoning_content" ): 151reasoning_content += delta ["reasoning_content" ] 152if delta .get ("content" ): 153full_content += delta ["content" ] 154print_model_output (delta ["content" ]) 155for tc in delta .get ("tool_calls" , []): 156idx = tc .get ("index" ,0 ) 157while len (tool_calls )<= idx : 158tool_calls .append ( 159 { 160"id" :"" , 161"type" :"function" , 162"function" : {"name" :"" ,"arguments" :"" }, 163 } 164 ) 165if "id" in tc : 166tool_calls [idx ]["id" ]+= tc ["id" ] 167if "function" in tc : 168if "name" in tc ["function" ]: 169tool_calls [idx ]["function" ]["name" ]+= tc ["function" ]["name" ] 170if "arguments" in tc ["function" ]: 171tool_calls [idx ]["function" ]["arguments" ]+= tc ["function" ][ 172"arguments" 173 ] 174else : 175data = response .json () 176choices = data .get ("choices" , []) 177if choices : 178msg = choices [0 ].get ("message" , {}) 179full_content = msg .get ("content" )or "" 180reasoning_content = msg .get ("reasoning_content" )or "" 181tool_calls = msg .get ("tool_calls" )or [] 182if full_content : 183print_model_output (full_content ) 184 185result = {"content" :full_content ,"tool_calls" :tool_calls } 186if reasoning_content : 187result ["reasoning_content" ]= reasoning_content 188return result 189 190 191def run_tool_loop ( 192url ,messages ,tools ,mock_tool_responses ,stream ,response_format = None , 193extra_body = None ,max_turns = 6 , 194): 195""" 196Drive the tool-call loop. If response_format is provided it is applied to 197every request. Returns (all_tool_calls, final_messages, final_content). 198""" 199msgs = list (messages ) 200all_tool_calls :list [dict ]= [] 201 202for _ in range (max_turns ): 203result = chat_completion ( 204url ,msgs ,tools = tools ,response_format = response_format ,stream = stream , 205extra_body = extra_body , 206 ) 207if result is None : 208return all_tool_calls ,msgs ,None 209 210tcs = result .get ("tool_calls" )or [] 211content = result .get ("content" )or "" 212 213if not tcs : 214if content : 215_print (f"\n { DIM } { '·' * 60 } { RESET } " ) 216return all_tool_calls ,msgs ,content 217 218all_tool_calls .extend (tcs ) 219 220assistant_msg :dict = { 221"role" :"assistant" , 222"content" :content , 223"tool_calls" :tcs , 224 } 225reasoning = result .get ("reasoning_content" ) 226if reasoning : 227assistant_msg ["reasoning_content" ]= reasoning 228msgs .append (assistant_msg ) 229 230for tc in tcs : 231tool_name = tc ["function" ]["name" ] 232try : 233args = json .loads (tc ["function" ]["arguments" ]) 234except json .JSONDecodeError : 235args = {} 236 237print_tool_call (tool_name ,args ) 238 239mock_fn = mock_tool_responses .get (tool_name )if mock_tool_responses else None 240if mock_fn : 241tool_result = mock_fn (args ) 242else : 243tool_result = json .dumps ({"error" :f"Unknown tool: { tool_name } " }) 244 245print_tool_result (tool_result ) 246 247msgs .append ( 248 { 249"role" :"tool" , 250"tool_call_id" :tc .get ("id" ,"" ), 251"content" :tool_result , 252 } 253 ) 254 255return all_tool_calls ,msgs ,None 256 257 258# --------------------------------------------------------------------------- 259# Test case runner 260# --------------------------------------------------------------------------- 261 262 263def _try_parse_json (text ): 264"""Attempt to parse text as JSON, trimming common markdown fences.""" 265if text is None : 266return None 267stripped = text .strip () 268if stripped .startswith ("```" ): 269lines = stripped .splitlines () 270if lines and lines [0 ].startswith ("```" ): 271lines = lines [1 :] 272if lines and lines [- 1 ].strip ().startswith ("```" ): 273lines = lines [:- 1 ] 274stripped = "\n" .join (lines ).strip () 275try : 276return json .loads (stripped ) 277except json .JSONDecodeError : 278return None 279 280 281def run_test (url ,test_case ,stream ): 282name = test_case ["name" ] 283mode = f" { 'stream' if stream else 'non-stream' } " 284apply_stage = test_case .get ("apply_stage" ,"always" ) 285print_header (f" { name } [ { mode } ] ( { apply_stage } )" ) 286 287response_format = test_case ["response_format" ] 288extra_body = test_case .get ("extra_body" ) 289print_schema_note (apply_stage ,response_format ,extra_body ) 290 291tools = test_case .get ("tools" ) 292mocks = test_case .get ("mock_tool_responses" )or {} 293 294all_tcs :list [dict ]= [] 295final_content = None 296 297if apply_stage == "always" : 298all_tcs ,_msgs ,final_content = run_tool_loop ( 299url , 300messages = list (test_case ["messages" ]), 301tools = tools , 302mock_tool_responses = mocks , 303stream = stream , 304response_format = response_format , 305extra_body = extra_body , 306 ) 307elif apply_stage == "after_tools" : 308# Phase 1: plain tool loop, no response_format applied yet. 309all_tcs ,msgs ,interim_content = run_tool_loop ( 310url , 311messages = list (test_case ["messages" ]), 312tools = tools , 313mock_tool_responses = mocks , 314stream = stream , 315response_format = None , 316 ) 317if interim_content : 318msgs .append ({"role" :"assistant" ,"content" :interim_content }) 319followup = test_case .get ( 320"followup" , 321"Now output the answer strictly as JSON matching the provided schema. " 322"Do not include commentary." , 323 ) 324msgs .append ({"role" :"user" ,"content" :followup }) 325 326# Phase 2: request final structured output. Tools are not passed so the 327# model focuses on producing the schema-constrained answer. 328_print (f"\n { DIM } { MAGENTA } ⟐ follow-up turn with response_format applied { RESET } " ) 329result = chat_completion ( 330url ,msgs ,tools = None ,response_format = response_format ,stream = stream , 331extra_body = extra_body , 332 ) 333final_content = result ["content" ]if result else None 334else : 335print_fail (f"Unknown apply_stage: { apply_stage } " ) 336return False 337 338if final_content is None : 339print_fail ("No final content from server." ) 340return False 341 342parsed = _try_parse_json (final_content ) 343if parsed is None : 344print_fail (f"Final content is not valid JSON: { final_content [: 200 ]!r } " ) 345return False 346 347passed ,reason = test_case ["validate" ](parsed ,all_tcs ,final_content ) 348if passed : 349print_pass (reason ) 350else : 351print_fail (reason ) 352return passed 353 354 355# --------------------------------------------------------------------------- 356# Test case definitions 357# --------------------------------------------------------------------------- 358 359# ---- Test 1: Book metadata extraction (always / json_schema) ---- 360 361_BOOK_SCHEMA = { 362"type" :"json_schema" , 363"json_schema" : { 364"name" :"book_metadata" , 365"strict" :True , 366"schema" : { 367"type" :"object" , 368"additionalProperties" :False , 369"properties" : { 370"title" : {"type" :"string" }, 371"author" : {"type" :"string" }, 372"year" : {"type" :"integer" }, 373"genre" : { 374"type" :"string" , 375"enum" : [ 376"fiction" , 377"non-fiction" , 378"fantasy" , 379"sci-fi" , 380"mystery" , 381"biography" , 382"history" , 383"other" , 384 ], 385 }, 386"page_count" : {"type" :"integer" }, 387 }, 388"required" : ["title" ,"author" ,"year" ,"genre" ,"page_count" ], 389 }, 390 }, 391} 392 393BOOK_TEST_CASE = { 394"name" :"Book metadata extraction (json_schema, always)" , 395"response_format" :_BOOK_SCHEMA , 396"apply_stage" :"always" , 397"messages" : [ 398 { 399"role" :"user" , 400"content" : ( 401"Extract book metadata from this description: " 402"'Dune is a 1965 science fiction epic by Frank Herbert, spanning roughly " 403"688 pages in its first edition, set on the desert planet Arrakis.' " 404"Return the data as JSON." 405 ), 406 } 407 ], 408"validate" :lambda parsed ,tcs ,raw :_validate_book (parsed ), 409} 410 411 412def _validate_book (parsed ): 413required = {"title" ,"author" ,"year" ,"genre" ,"page_count" } 414missing = required - parsed .keys () 415if missing : 416return False ,f"Missing fields: { missing } " 417if not isinstance (parsed ["title" ],str )or not parsed ["title" ]: 418return False ,"title must be a non-empty string" 419if not isinstance (parsed ["author" ],str )or "herbert" not in parsed ["author" ].lower (): 420return False ,f"author unexpected: { parsed [ 'author' ]!r } " 421if not isinstance (parsed ["year" ],int )or parsed ["year" ]!= 1965 : 422return False ,f"year should be 1965, got { parsed [ 'year' ]!r } " 423if parsed ["genre" ]not in { 424"fiction" ,"non-fiction" ,"fantasy" ,"sci-fi" ,"mystery" , 425"biography" ,"history" ,"other" , 426 }: 427return False ,f"genre not in enum: { parsed [ 'genre' ]!r } " 428if not isinstance (parsed ["page_count" ],int )or parsed ["page_count" ]<= 0 : 429return False ,f"page_count should be positive int: { parsed [ 'page_count' ]!r } " 430return True ,f"Book: { parsed [ 'title' ] } ( { parsed [ 'year' ] } ) / { parsed [ 'genre' ] } " 431 432 433# ---- Test 2: Sentiment classification (always / enum-constrained) ---- 434 435_SENTIMENT_SCHEMA = { 436"type" :"json_schema" , 437"json_schema" : { 438"name" :"sentiment_analysis" , 439"strict" :True , 440"schema" : { 441"type" :"object" , 442"additionalProperties" :False , 443"properties" : { 444"sentiment" : { 445"type" :"string" , 446"enum" : ["positive" ,"negative" ,"neutral" ], 447 }, 448"confidence" : {"type" :"number" }, 449"keywords" : { 450"type" :"array" , 451"items" : {"type" :"string" }, 452"minItems" :1 , 453"maxItems" :5 , 454 }, 455 }, 456"required" : ["sentiment" ,"confidence" ,"keywords" ], 457 }, 458 }, 459} 460 461SENTIMENT_TEST_CASE = { 462"name" :"Sentiment analysis with enum and array" , 463"response_format" :_SENTIMENT_SCHEMA , 464"apply_stage" :"always" , 465"messages" : [ 466 { 467"role" :"user" , 468"content" : ( 469"Analyse the sentiment of this review and return JSON with the " 470"detected sentiment label, a confidence score between 0 and 1, " 471"and up to five keyword strings that drove the classification:\n\n" 472"'This product completely exceeded my expectations. The build " 473"quality is phenomenal, it arrived a day early, and customer " 474"support was delightful when I had a setup question.'" 475 ), 476 } 477 ], 478"validate" :lambda parsed ,tcs ,raw :_validate_sentiment (parsed ), 479} 480 481 482def _validate_sentiment (parsed ): 483if parsed .get ("sentiment" )not in {"positive" ,"negative" ,"neutral" }: 484return False ,f"sentiment not in enum: { parsed . get ( 'sentiment' )!r } " 485if parsed ["sentiment" ]!= "positive" : 486return False ,f"expected positive sentiment, got { parsed [ 'sentiment' ] } " 487conf = parsed .get ("confidence" ) 488if not isinstance (conf , (int ,float ))or not (0.0 <= conf <= 1.0 ): 489return False ,f"confidence not in [0,1]: { conf !r } " 490kws = parsed .get ("keywords" ) 491if not isinstance (kws ,list )or not (1 <= len (kws )<= 5 ): 492return False ,f"keywords length out of range: { kws !r } " 493if not all (isinstance (k ,str )and k for k in kws ): 494return False ,f"keywords must be non-empty strings: { kws !r } " 495return True ,f"sentiment= { parsed [ 'sentiment' ] } conf= { conf } kws= { kws } " 496 497 498# ---- Test: json_object + extra_body.json_schema (always) ---- 499# 500# Exercises the llama.cpp-specific path where the OpenAI SDK would send 501# response_format={"type": "json_object"} and tunnel the schema through 502# extra_body.json_schema (which becomes a top-level "json_schema" field on 503# the request body). 504 505_PRODUCT_JSON_OBJECT_SCHEMA = { 506"$schema" :"https://json-schema.org/draft/2020-12/schema" , 507"$id" :"https://example.com/product.schema.json" , 508"title" :"Product" , 509"description" :"A product in the catalog" , 510"type" :"object" , 511} 512 513PRODUCT_JSON_OBJECT_TEST_CASE = { 514"name" :"json_object response_format with extra_body json_schema" , 515"response_format" : {"type" :"json_object" }, 516"extra_body" : {"json_schema" :_PRODUCT_JSON_OBJECT_SCHEMA }, 517"apply_stage" :"always" , 518"messages" : [ 519 { 520"role" :"system" , 521"content" : ( 522"Extract structured data from the provided text according to the " 523"JSON schema. Return only valid JSON matching the schema exactly." 524 ), 525 }, 526 { 527"role" :"user" , 528"content" :"Product: Wireless Headphones, ID: 101, In Stock: Yes" , 529 }, 530 ], 531"validate" :lambda parsed ,tcs ,raw :_validate_product_json_object (parsed ), 532} 533 534 535def _validate_product_json_object (parsed ): 536if not isinstance (parsed ,dict ): 537return False ,f"expected JSON object, got { type ( parsed ). __name__ } : { parsed !r } " 538if not parsed : 539return False ,f"expected non-empty object, got { parsed !r } " 540return True ,f"product object with { len ( parsed ) } field(s): { sorted ( parsed . keys ()) } " 541 542 543# ---- Test 3: Nested recipe schema (always) ---- 544 545_RECIPE_SCHEMA = { 546"type" :"json_schema" , 547"json_schema" : { 548"name" :"recipe" , 549"strict" :True , 550"schema" : { 551"type" :"object" , 552"additionalProperties" :False , 553"properties" : { 554"name" : {"type" :"string" }, 555"servings" : {"type" :"integer" }, 556"ingredients" : { 557"type" :"array" , 558"minItems" :2 , 559"items" : { 560"type" :"object" , 561"additionalProperties" :False , 562"properties" : { 563"item" : {"type" :"string" }, 564"quantity" : {"type" :"string" }, 565 }, 566"required" : ["item" ,"quantity" ], 567 }, 568 }, 569"steps" : { 570"type" :"array" , 571"minItems" :2 , 572"items" : {"type" :"string" }, 573 }, 574"prep_time_minutes" : {"type" :"integer" }, 575 }, 576"required" : ["name" ,"servings" ,"ingredients" ,"steps" ,"prep_time_minutes" ], 577 }, 578 }, 579} 580 581RECIPE_TEST_CASE = { 582"name" :"Nested recipe with arrays of objects" , 583"response_format" :_RECIPE_SCHEMA , 584"apply_stage" :"always" , 585"messages" : [ 586 { 587"role" :"user" , 588"content" : ( 589"Give me a simple 4-serving scrambled eggs recipe as structured JSON. " 590"Include the recipe name, servings, ingredients (each with item and " 591"quantity), preparation steps, and total prep time in minutes." 592 ), 593 } 594 ], 595"validate" :lambda parsed ,tcs ,raw :_validate_recipe (parsed ), 596} 597 598 599def _validate_recipe (parsed ): 600required = {"name" ,"servings" ,"ingredients" ,"steps" ,"prep_time_minutes" } 601missing = required - parsed .keys () 602if missing : 603return False ,f"Missing fields: { missing } " 604if not isinstance (parsed ["name" ],str )or not parsed ["name" ]: 605return False ,"name must be a non-empty string" 606if not isinstance (parsed ["servings" ],int )or parsed ["servings" ]<= 0 : 607return False ,f"servings must be positive int: { parsed [ 'servings' ]!r } " 608ings = parsed ["ingredients" ] 609if not isinstance (ings ,list )or len (ings )< 2 : 610return False ,f"ingredients must be array of >=2: got { ings !r } " 611for i ,ing in enumerate (ings ): 612if not isinstance (ing ,dict ): 613return False ,f"ingredient[ { i } ] is not an object: { ing !r } " 614ing_d = cast (dict [str ,Any ],ing ) 615item_val = ing_d .get ("item" ) 616qty_val = ing_d .get ("quantity" ) 617if item_val is None or qty_val is None : 618return False ,f"ingredient[ { i } ] missing item/quantity: { ing !r } " 619if not isinstance (item_val ,str )or not isinstance (qty_val ,str ): 620return False ,f"ingredient[ { i } ] fields must be strings: { ing !r } " 621steps = parsed ["steps" ] 622if not isinstance (steps ,list )or len (steps )< 2 : 623return False ,f"steps must be array of >=2 strings: got { steps !r } " 624if not all (isinstance (s ,str )and s for s in steps ): 625return False ,"all steps must be non-empty strings" 626pt = parsed ["prep_time_minutes" ] 627if not isinstance (pt ,int )or pt <= 0 : 628return False ,f"prep_time_minutes must be positive int: { pt !r } " 629return True ,f"recipe ' { parsed [ 'name' ] } ' with { len ( ings ) } ingredients, { len ( steps ) } steps" 630 631 632# ---- Test 4: Tool call -> structured product comparison (after_tools) ---- 633 634_SHOP_TOOLS = [ 635 { 636"type" :"function" , 637"function" : { 638"name" :"search_products" , 639"description" :"Search a product catalogue by keyword." , 640"parameters" : { 641"type" :"object" , 642"properties" : { 643"query" : {"type" :"string" }, 644 }, 645"required" : ["query" ], 646 }, 647 }, 648 }, 649 { 650"type" :"function" , 651"function" : { 652"name" :"get_product_details" , 653"description" :"Get detailed specs for a product by ID." , 654"parameters" : { 655"type" :"object" , 656"properties" : { 657"product_id" : {"type" :"string" }, 658 }, 659"required" : ["product_id" ], 660 }, 661 }, 662 }, 663] 664 665_SHOP_SEARCH_RESULT = { 666"results" : [ 667 {"product_id" :"LAP-001" ,"title" :"AeroBook 13 Pro" ,"price" :1399.0 ,"rating" :4.7 }, 668 {"product_id" :"LAP-002" ,"title" :"QuantumSlim 14" ,"price" :1199.0 ,"rating" :4.4 }, 669 {"product_id" :"LAP-003" ,"title" :"NimbusWork Ultra 15" ,"price" :999.0 ,"rating" :4.2 }, 670 ], 671} 672_SHOP_PRODUCT_DETAILS = { 673"LAP-001" : { 674"product_id" :"LAP-001" , 675"title" :"AeroBook 13 Pro" , 676"cpu" :"M-series 10-core" , 677"ram_gb" :16 , 678"storage_gb" :512 , 679"battery_hours" :18 , 680"weight_kg" :1.24 , 681"price" :1399.0 , 682 }, 683"LAP-002" : { 684"product_id" :"LAP-002" , 685"title" :"QuantumSlim 14" , 686"cpu" :"Core i7 12-core" , 687"ram_gb" :16 , 688"storage_gb" :512 , 689"battery_hours" :12 , 690"weight_kg" :1.35 , 691"price" :1199.0 , 692 }, 693"LAP-003" : { 694"product_id" :"LAP-003" , 695"title" :"NimbusWork Ultra 15" , 696"cpu" :"Ryzen 7 8-core" , 697"ram_gb" :16 , 698"storage_gb" :1024 , 699"battery_hours" :10 , 700"weight_kg" :1.70 , 701"price" :999.0 , 702 }, 703} 704 705 706def _shop_details_mock (args ): 707pid = args .get ("product_id" ,"" ) 708if pid in _SHOP_PRODUCT_DETAILS : 709return json .dumps (_SHOP_PRODUCT_DETAILS [pid ]) 710return json .dumps ({"error" :f"unknown product_id: { pid } " }) 711 712 713_SHOP_COMPARISON_SCHEMA = { 714"type" :"json_schema" , 715"json_schema" : { 716"name" :"laptop_comparison" , 717"strict" :True , 718"schema" : { 719"type" :"object" , 720"additionalProperties" :False , 721"properties" : { 722"recommendation" : {"type" :"string" }, 723"ranked_candidates" : { 724"type" :"array" , 725"minItems" :2 , 726"items" : { 727"type" :"object" , 728"additionalProperties" :False , 729"properties" : { 730"product_id" : {"type" :"string" }, 731"title" : {"type" :"string" }, 732"score" : {"type" :"number" }, 733"reason" : {"type" :"string" }, 734 }, 735"required" : ["product_id" ,"title" ,"score" ,"reason" ], 736 }, 737 }, 738 }, 739"required" : ["recommendation" ,"ranked_candidates" ], 740 }, 741 }, 742} 743 744SHOP_COMPARISON_TEST_CASE = { 745"name" :"Tool calls then structured laptop comparison (after_tools)" , 746"response_format" :_SHOP_COMPARISON_SCHEMA , 747"apply_stage" :"after_tools" , 748"tools" :_SHOP_TOOLS , 749"mock_tool_responses" : { 750"search_products" :lambda _ :json .dumps (_SHOP_SEARCH_RESULT ), 751"get_product_details" :_shop_details_mock , 752 }, 753"messages" : [ 754 { 755"role" :"user" , 756"content" : ( 757"I need a lightweight laptop for travel. Please search the catalogue " 758"for 'ultraportable laptop', then fetch detailed specs for at least two " 759"of the top candidates. Once you've gathered the data I'll ask you to " 760"produce a structured comparison." 761 ), 762 } 763 ], 764"followup" : ( 765"Thanks. Now produce the final comparison strictly as JSON matching the " 766"laptop_comparison schema: your single best recommendation (the product_id), " 767"and a ranked_candidates array of at least two laptops, each with " 768"product_id, title, a numeric score, and a short reason." 769 ), 770"validate" :lambda parsed ,tcs ,raw :_validate_shop_comparison (parsed ,tcs ), 771} 772 773 774def _validate_shop_comparison (parsed ,tcs ): 775names = [tc ["function" ]["name" ]for tc in tcs ] 776if "search_products" not in names : 777return False ,f"expected search_products tool call, got { names } " 778if "get_product_details" not in names : 779return False ,f"expected get_product_details tool call, got { names } " 780if "recommendation" not in parsed or not isinstance (parsed ["recommendation" ],str ): 781return False ,f"recommendation missing or not a string: { parsed !r } " 782cands = parsed .get ("ranked_candidates" ) 783if not isinstance (cands ,list )or len (cands )< 2 : 784return False ,f"ranked_candidates must be >=2: { cands !r } " 785valid_ids = set (_SHOP_PRODUCT_DETAILS .keys ()) 786candidate_pids :list = [] 787for i ,c in enumerate (cands ): 788if not isinstance (c ,dict ): 789return False ,f"candidate[ { i } ] not an object: { c !r } " 790c_d = cast (dict [str ,Any ],c ) 791pid = c_d .get ("product_id" ) 792title = c_d .get ("title" ) 793score = c_d .get ("score" ) 794reason = c_d .get ("reason" ) 795for k ,v in (("product_id" ,pid ), ("title" ,title ), 796 ("score" ,score ), ("reason" ,reason )): 797if v is None : 798return False ,f"candidate[ { i } ] missing { k } : { c !r } " 799if pid not in valid_ids : 800return False ,f"candidate[ { i } ].product_id not in catalogue: { pid !r } " 801if not isinstance (score , (int ,float )): 802return False ,f"candidate[ { i } ].score not numeric: { score !r } " 803candidate_pids .append (pid ) 804recommendation = parsed ["recommendation" ] 805if recommendation not in valid_ids and recommendation not in candidate_pids : 806return False ,f"recommendation { recommendation !r } not in candidates" 807return True , ( 808f"tools= { names } ; recommended= { parsed [ 'recommendation' ] } ; " 809f" { len ( cands ) } ranked candidates" 810 ) 811 812 813# ---- Test 5: Multi-step research then structured report (after_tools) ---- 814 815_RESEARCH_TOOLS = [ 816 { 817"type" :"function" , 818"function" : { 819"name" :"get_country_stats" , 820"description" :"Fetch basic statistics for a country (population, GDP, capital)." , 821"parameters" : { 822"type" :"object" , 823"properties" : { 824"country" : {"type" :"string" }, 825 }, 826"required" : ["country" ], 827 }, 828 }, 829 }, 830 { 831"type" :"function" , 832"function" : { 833"name" :"get_climate_info" , 834"description" :"Fetch climate information for a country." , 835"parameters" : { 836"type" :"object" , 837"properties" : { 838"country" : {"type" :"string" }, 839 }, 840"required" : ["country" ], 841 }, 842 }, 843 }, 844] 845 846_COUNTRY_STATS = { 847"norway" : { 848"country" :"Norway" , 849"capital" :"Oslo" , 850"population" :5_480_000 , 851"gdp_usd_trillion" :0.48 , 852"currency" :"NOK" , 853 } 854} 855_CLIMATE_INFO = { 856"norway" : { 857"country" :"Norway" , 858"climate_zone" :"subarctic / temperate coastal" , 859"avg_winter_temp_c" :- 4.5 , 860"avg_summer_temp_c" :16.0 , 861"annual_precipitation_mm" :1400 , 862 } 863} 864 865 866def _country_stats_mock (args ): 867c = args .get ("country" ,"" ).strip ().lower () 868if c in _COUNTRY_STATS : 869return json .dumps (_COUNTRY_STATS [c ]) 870return json .dumps ({"error" :f"unknown country: { c } " }) 871 872 873def _climate_info_mock (args ): 874c = args .get ("country" ,"" ).strip ().lower () 875if c in _CLIMATE_INFO : 876return json .dumps (_CLIMATE_INFO [c ]) 877return json .dumps ({"error" :f"unknown country: { c } " }) 878 879 880_RESEARCH_REPORT_SCHEMA = { 881"type" :"json_schema" , 882"json_schema" : { 883"name" :"country_report" , 884"strict" :True , 885"schema" : { 886"type" :"object" , 887"additionalProperties" :False , 888"properties" : { 889"country" : {"type" :"string" }, 890"capital" : {"type" :"string" }, 891"population" : {"type" :"integer" }, 892"climate_summary" : {"type" :"string" }, 893"highlights" : { 894"type" :"array" , 895"minItems" :2 , 896"maxItems" :5 , 897"items" : {"type" :"string" }, 898 }, 899"suitable_for_tourism" : {"type" :"boolean" }, 900 }, 901"required" : [ 902"country" ,"capital" ,"population" , 903"climate_summary" ,"highlights" ,"suitable_for_tourism" , 904 ], 905 }, 906 }, 907} 908 909COUNTRY_REPORT_TEST_CASE = { 910"name" :"Research pipeline then structured country report (after_tools)" , 911"response_format" :_RESEARCH_REPORT_SCHEMA , 912"apply_stage" :"after_tools" , 913"tools" :_RESEARCH_TOOLS , 914"mock_tool_responses" : { 915"get_country_stats" :_country_stats_mock , 916"get_climate_info" :_climate_info_mock , 917 }, 918"messages" : [ 919 { 920"role" :"user" , 921"content" : ( 922"I'm preparing a short briefing on Norway. Please call the " 923"get_country_stats and get_climate_info tools to gather data " 924"first. Afterwards I'll ask for a structured summary." 925 ), 926 } 927 ], 928"followup" : ( 929"Based on the tool results, produce the briefing as JSON matching the " 930"country_report schema. Populate every required field and provide between " 931"two and five highlights." 932 ), 933"validate" :lambda parsed ,tcs ,raw :_validate_country_report (parsed ,tcs ), 934} 935 936 937def _validate_country_report (parsed ,tcs ): 938names = [tc ["function" ]["name" ]for tc in tcs ] 939for required_tool in ("get_country_stats" ,"get_climate_info" ): 940if required_tool not in names : 941return False ,f"missing tool call { required_tool !r } : got { names } " 942required = { 943"country" ,"capital" ,"population" , 944"climate_summary" ,"highlights" ,"suitable_for_tourism" , 945 } 946missing = required - parsed .keys () 947if missing : 948return False ,f"missing report fields: { missing } " 949if "norway" not in parsed ["country" ].lower (): 950return False ,f"country should reference Norway: { parsed [ 'country' ]!r } " 951if "oslo" not in parsed ["capital" ].lower (): 952return False ,f"capital should be Oslo: { parsed [ 'capital' ]!r } " 953if not isinstance (parsed ["population" ],int )or parsed ["population" ]< 1_000_000 : 954return False ,f"population implausible: { parsed [ 'population' ]!r } " 955if not isinstance (parsed ["climate_summary" ],str )or not parsed ["climate_summary" ]: 956return False ,"climate_summary must be a non-empty string" 957hls = parsed ["highlights" ] 958if not isinstance (hls ,list )or not (2 <= len (hls )<= 5 ): 959return False ,f"highlights length out of range: { hls !r } " 960if not all (isinstance (h ,str )and h for h in hls ): 961return False ,"each highlight must be a non-empty string" 962if not isinstance (parsed ["suitable_for_tourism" ],bool ): 963return False ,f"suitable_for_tourism must be bool: { parsed [ 'suitable_for_tourism' ]!r } " 964return True , ( 965f"tools= { names } ; report for { parsed [ 'country' ] } " 966f"(pop { parsed [ 'population' ] } , { len ( hls ) } highlights)" 967 ) 968 969 970# --------------------------------------------------------------------------- 971# All test cases 972# --------------------------------------------------------------------------- 973 974ALL_TEST_CASES = [ 975BOOK_TEST_CASE , 976SENTIMENT_TEST_CASE , 977PRODUCT_JSON_OBJECT_TEST_CASE , 978RECIPE_TEST_CASE , 979SHOP_COMPARISON_TEST_CASE , 980COUNTRY_REPORT_TEST_CASE , 981] 982 983 984# --------------------------------------------------------------------------- 985# Entry point 986# --------------------------------------------------------------------------- 987 988 989def main (): 990parser = argparse .ArgumentParser ( 991description = "Test llama-server structured-output capability." 992 ) 993parser .add_argument ("--host" ,default = "localhost" ) 994parser .add_argument ("--port" ,default = 8080 ,type = int ) 995parser .add_argument ( 996"--no-stream" ,action = "store_true" ,help = "Disable streaming mode tests" 997 ) 998parser .add_argument ( 999"--stream-only" ,action = "store_true" ,help = "Only run streaming mode tests" 1000 ) 1001parser .add_argument ( 1002"--test" , 1003help = "Run only the test whose name contains this substring (case-insensitive)" , 1004 ) 1005args = parser .parse_args () 1006 1007url = f"http:// { args . host } : { args . port } /v1/chat/completions" 1008print_info (f"Testing server at { url } " ) 1009 1010modes :list [bool ]= [] 1011if not args .stream_only : 1012modes .append (False ) 1013if not args .no_stream : 1014modes .append (True ) 1015 1016cases :list [dict ]= ALL_TEST_CASES 1017if args .test : 1018name_filter = args .test .lower () 1019cases = [c for c in cases if name_filter in str (c ["name" ]).lower ()] 1020if not cases : 1021print_fail (f"No test cases matched ' { args . test } '" ) 1022sys .exit (1 ) 1023 1024total = 0 1025passed = 0 1026for stream in modes : 1027for case in cases : 1028total += 1 1029if run_test (url ,case ,stream = stream ): 1030passed += 1 1031 1032color = GREEN if passed == total else RED 1033_print (f"\n { BOLD } { color } { '─' * 60 } { RESET } " ) 1034_print (f" { BOLD } { color } Results: { passed } / { total } passed { RESET } " ) 1035_print (f" { BOLD } { color } { '─' * 60 } { RESET } \n" ) 1036sys .exit (0 if passed == total else 1 ) 1037 1038 1039if __name__ == "__main__" : 1040main ()