echodict/llama.cpp
version https://git-lfs.github.com/spec/v1 oid sha256:cfc44b7ba25614df70e6b65e3341cae0310163bd32fd31a6b928a542df433faf size 30786
0762
1#!/usr/bin/env python32"""3Test tool calling capability via chat completions endpoint.4 5Each test case contains:6 - tools: list of tool definitions (OpenAI-compatible)7 - messages: initial conversation messages8 - mock_tool_responses: dict mapping tool_name -> callable(arguments) -> str (JSON)9 - validate: callable(tool_calls_history, final_content) -> (passed: bool, reason: str)10"""11 12import argparse13import json14import requests15import sys16 17# ---------------------------------------------------------------------------18# Color / formatting helpers19# ---------------------------------------------------------------------------20 21RESET = "\x1b[0m"22BOLD = "\x1b[1m"23DIM = "\x1b[2m"24# Foreground colors25CYAN = "\x1b[36m"26YELLOW = "\x1b[33m"27GREEN = "\x1b[32m"28RED = "\x1b[31m"29BLUE = "\x1b[34m"30WHITE = "\x1b[97m"31 32 33def _print(text="", end="\n"):34 sys.stdout.write(text + end)35 sys.stdout.flush()36 37 38def print_header(title):39 bar = "─" * 6040 _print(f"\n{BOLD}{CYAN}┌{bar}┐{RESET}")41 _print(42 f"{BOLD}{CYAN}│ {WHITE}{title}{CYAN}{' ' * max(0, 58 - len(title))}│{RESET}"43 )44 _print(f"{BOLD}{CYAN}└{bar}┘{RESET}")45 46 47def print_tool_call(name, args):48 args_str = json.dumps(args)49 _print(50 f"\n {BOLD}{YELLOW}⚙ tool call{RESET} {CYAN}{name}{RESET}{DIM}({args_str}){RESET}"51 )52 53 54def print_tool_result(result):55 preview = result[:160] + ("…" if len(result) > 160 else "")56 _print(f" {DIM}{BLUE}↳ result{RESET} {DIM}{preview}{RESET}")57 58 59def print_model_output(text):60 # printed inline during streaming; prefix with a visual marker on first chunk61 sys.stdout.write(text)62 sys.stdout.flush()63 64 65def print_pass(reason):66 _print(f"\n{BOLD}{GREEN}✔ PASS{RESET} {reason}")67 68 69def print_fail(reason):70 _print(f"\n{BOLD}{RED}✘ FAIL{RESET} {reason}")71 72 73def print_info(msg):74 _print(f"{DIM}{msg}{RESET}")75 76 77# ---------------------------------------------------------------------------78# HTTP helpers79# ---------------------------------------------------------------------------80 81 82def chat_completion(url, messages, tools=None, stream=False):83 payload = {84 "messages": messages,85 "stream": stream,86 "max_tokens": 4096,87 }88 if tools:89 payload["tools"] = tools90 payload["tool_choice"] = "auto"91 92 try:93 response = requests.post(url, json=payload, stream=stream)94 response.raise_for_status()95 except requests.exceptions.RequestException as e:96 body = e.response.content if (e.response is not None) else b""97 print_fail(f"Request error: {e} | body: {body}")98 return None99 100 full_content = ""101 reasoning_content = ""102 tool_calls: list[dict] = []103 104 if stream:105 for line in response.iter_lines():106 if not line:107 continue108 decoded = line.decode("utf-8")109 if not decoded.startswith("data: "):110 continue111 data_str = decoded[6:]112 if data_str == "[DONE]":113 break114 try:115 data = json.loads(data_str)116 except json.JSONDecodeError:117 continue118 choices = data.get("choices", [])119 if not choices:120 continue121 delta = choices[0].get("delta", {})122 if delta.get("reasoning_content"):123 reasoning_content += delta["reasoning_content"]124 if delta.get("content"):125 full_content += delta["content"]126 print_model_output(delta["content"])127 for tc in delta.get("tool_calls", []):128 idx = tc.get("index", 0)129 while len(tool_calls) <= idx:130 tool_calls.append(131 {132 "id": "",133 "type": "function",134 "function": {"name": "", "arguments": ""},135 }136 )137 if "id" in tc:138 tool_calls[idx]["id"] += tc["id"]139 if "function" in tc:140 if "name" in tc["function"]:141 tool_calls[idx]["function"]["name"] += tc["function"]["name"]142 if "arguments" in tc["function"]:143 tool_calls[idx]["function"]["arguments"] += tc["function"][144 "arguments"145 ]146 else:147 data = response.json()148 choices = data.get("choices", [])149 if choices:150 msg = choices[0].get("message", {})151 full_content = msg.get("content") or ""152 reasoning_content = msg.get("reasoning_content") or ""153 tool_calls = msg.get("tool_calls") or []154 if full_content:155 print_model_output(full_content)156 157 result = {"content": full_content, "tool_calls": tool_calls}158 if reasoning_content:159 result["reasoning_content"] = reasoning_content160 return result161 162 163def run_agentic_loop(url, messages, tools, mock_tool_responses, stream, max_turns=6):164 """165 Drive the multi-turn tool-call loop:166 1. Send messages to model.167 2. If the model returns tool calls, execute mocks and append results.168 3. Repeat until no more tool calls or max_turns reached.169 170 Returns (all_tool_calls, final_content).171 """172 msgs = list(messages)173 all_tool_calls: list[dict] = []174 175 for _ in range(max_turns):176 result = chat_completion(url, msgs, tools=tools, stream=stream)177 if result is None:178 return all_tool_calls, None179 180 tcs = result.get("tool_calls") or []181 content = result.get("content") or ""182 183 if not tcs:184 # Print a visual separator before the final model response185 if content:186 _print(f"\n{DIM}{'·'*60}{RESET}")187 _print(f"{DIM} model response:{RESET}\n")188 return all_tool_calls, content189 190 # Record tool calls for validation191 all_tool_calls.extend(tcs)192 193 # Append assistant message with tool calls194 assistant_msg: dict = {195 "role": "assistant",196 "content": content,197 "tool_calls": tcs,198 }199 reasoning = result.get("reasoning_content")200 if reasoning:201 assistant_msg["reasoning_content"] = reasoning202 msgs.append(assistant_msg)203 204 # Execute each tool call via mock and append tool result messages205 for tc in tcs:206 tool_name = tc["function"]["name"]207 try:208 args = json.loads(tc["function"]["arguments"])209 except json.JSONDecodeError:210 args = {}211 212 print_tool_call(tool_name, args)213 214 mock_fn = mock_tool_responses.get(tool_name)215 if mock_fn:216 tool_result = mock_fn(args)217 else:218 tool_result = json.dumps({"error": f"Unknown tool: {tool_name}"})219 220 print_tool_result(tool_result)221 222 msgs.append(223 {224 "role": "tool",225 "tool_call_id": tc.get("id", ""),226 "content": tool_result,227 }228 )229 230 return all_tool_calls, None231 232 233# ---------------------------------------------------------------------------234# Test case runner235# ---------------------------------------------------------------------------236 237 238def run_test(url, test_case, stream):239 name = test_case["name"]240 mode = f"{'stream' if stream else 'non-stream'}"241 print_header(f"{name} [{mode}]")242 243 all_tool_calls, final_content = run_agentic_loop(244 url,245 messages=test_case["messages"],246 tools=test_case["tools"],247 mock_tool_responses=test_case["mock_tool_responses"],248 stream=stream,249 )250 251 if final_content is None and not all_tool_calls:252 print_fail("No response from server.")253 return False254 255 passed, reason = test_case["validate"](all_tool_calls, final_content)256 if passed:257 print_pass(reason)258 else:259 print_fail(reason)260 return passed261 262 263# ---------------------------------------------------------------------------264# Test case definitions265# ---------------------------------------------------------------------------266 267# ---- Test 1: E-commerce multi-step search (Azzoo = anonymized marketplace) ----268 269_AZZOO_TOOLS = [270 {271 "type": "function",272 "function": {273 "name": "azzoo_search_products",274 "description": (275 "Search for products on Azzoo marketplace by keyword. "276 "Returns a list of matching products with IDs, titles, ratings and prices."277 ),278 "parameters": {279 "type": "object",280 "properties": {281 "query": {282 "type": "string",283 "description": "Search keyword or phrase",284 },285 "page": {286 "type": "string",287 "description": "Page number (1-based)",288 "default": "1",289 },290 },291 "required": ["query"],292 },293 },294 },295 {296 "type": "function",297 "function": {298 "name": "azzoo_get_product",299 "description": "Retrieve detailed information about a specific Azzoo product including specs and price.",300 "parameters": {301 "type": "object",302 "properties": {303 "product_id": {304 "type": "string",305 "description": "Azzoo product identifier (e.g. AZB12345)",306 },307 },308 "required": ["product_id"],309 },310 },311 },312 {313 "type": "function",314 "function": {315 "name": "azzoo_get_reviews",316 "description": "Fetch customer reviews for an Azzoo product.",317 "parameters": {318 "type": "object",319 "properties": {320 "product_id": {321 "type": "string",322 "description": "Azzoo product identifier",323 },324 "page": {325 "type": "string",326 "description": "Review page number",327 "default": "1",328 },329 },330 "required": ["product_id"],331 },332 },333 },334]335 336_AZZOO_SEARCH_RESULT = {337 "results": [338 {339 "product_id": "AZB00001",340 "title": "SteelBrew Pro Kettle 1.7L",341 "rating": 4.6,342 "price": 34.99,343 },344 {345 "product_id": "AZB00002",346 "title": "HeatKeep Gooseneck Kettle",347 "rating": 4.3,348 "price": 27.50,349 },350 {351 "product_id": "AZB00003",352 "title": "QuickBoil Stainless Kettle",353 "rating": 4.1,354 "price": 21.00,355 },356 ]357}358_AZZOO_PRODUCT_RESULT = {359 "product_id": "AZB00001",360 "title": "SteelBrew Pro Kettle 1.7L",361 "price": 34.99,362 "rating": 4.6,363 "review_count": 2847,364 "specs": {365 "material": "18/8 stainless steel",366 "capacity": "1.7 L",367 "auto_shutoff": True,368 "keep_warm": "30 min",369 "warranty": "2 years",370 },371}372_AZZOO_REVIEWS_RESULT = {373 "product_id": "AZB00001",374 "average_rating": 4.6,375 "reviews": [376 {377 "rating": 5,378 "title": "Excellent build quality",379 "body": "Very sturdy, boils fast and stays warm longer than expected.",380 },381 {382 "rating": 5,383 "title": "Great for loose-leaf tea",384 "body": "The wide spout makes filling a teapot easy. No leaks after months of use.",385 },386 {387 "rating": 3,388 "title": "Minor lid issue",389 "body": "The lid doesn't always click shut properly, but overall happy with it.",390 },391 {392 "rating": 4,393 "title": "Good value",394 "body": "Heats quickly and the auto shutoff works reliably.",395 },396 ],397}398 399AZZOO_TEST_CASE = {400 "name": "Azzoo E-commerce: search -> product detail -> reviews",401 "messages": [402 {403 "role": "user",404 "content": (405 "I need a durable stainless steel tea kettle for my weekly tea gatherings. "406 "Please search Azzoo for 'stainless steel tea kettle', then get full details "407 "on the top-rated result, and finally fetch its customer reviews so I can "408 "check for recurring complaints. Give me a summary with pros and cons."409 ),410 }411 ],412 "tools": _AZZOO_TOOLS,413 "mock_tool_responses": {414 "azzoo_search_products": lambda _: json.dumps(_AZZOO_SEARCH_RESULT),415 "azzoo_get_product": lambda _: json.dumps(_AZZOO_PRODUCT_RESULT),416 "azzoo_get_reviews": lambda _: json.dumps(_AZZOO_REVIEWS_RESULT),417 },418 "validate": lambda tcs, content: _validate_azzoo(tcs, content),419}420 421 422def _validate_azzoo(tcs, content):423 names = [tc["function"]["name"] for tc in tcs]424 if not names:425 return False, "No tool calls made"426 if "azzoo_search_products" not in names:427 return False, f"Expected azzoo_search_products to be called, got: {names}"428 # After search the model should look up product details429 if "azzoo_get_product" not in names and "azzoo_get_reviews" not in names:430 return False, f"Expected follow-up product/review lookup, got: {names}"431 # Verify product lookup used an ID from search results432 for tc in tcs:433 if tc["function"]["name"] == "azzoo_get_product":434 try:435 args = json.loads(tc["function"]["arguments"])436 pid = args.get("product_id", "")437 if not pid:438 return False, "azzoo_get_product called with empty product_id"439 except json.JSONDecodeError:440 return False, "azzoo_get_product arguments are not valid JSON"441 if not content:442 return False, "No final summary produced"443 return True, f"All expected tools called in order: {names}"444 445 446# ---- Test 2: Fitness BMI + exercise recommendations ----447 448_FITNESS_TOOLS = [449 {450 "type": "function",451 "function": {452 "name": "calculate_bmi",453 "description": "Calculate Body Mass Index (BMI) from weight and height.",454 "parameters": {455 "type": "object",456 "properties": {457 "weight_kg": {458 "type": "number",459 "description": "Body weight in kilograms",460 },461 "height_m": {"type": "number", "description": "Height in meters"},462 },463 "required": ["weight_kg", "height_m"],464 },465 },466 },467 {468 "type": "function",469 "function": {470 "name": "get_exercises",471 "description": (472 "Fetch a list of exercises filtered by muscle group, difficulty, category, "473 "and/or force type."474 ),475 "parameters": {476 "type": "object",477 "properties": {478 "muscle": {479 "type": "string",480 "description": "Target muscle group (e.g. chest, back, legs)",481 },482 "difficulty": {483 "type": "string",484 "description": "Difficulty level: beginner, intermediate, expert",485 },486 "category": {487 "type": "string",488 "description": "Exercise category (e.g. strength, cardio, stretching)",489 },490 "force": {491 "type": "string",492 "description": "Force type: push, pull, static",493 },494 },495 "required": [],496 },497 },498 },499]500 501_BMI_RESULT = {"bmi": 24.5, "category": "Normal weight", "healthy_range": "18.5 – 24.9"}502_EXERCISES_RESULT = {503 "exercises": [504 {505 "name": "Push-Up",506 "muscle": "chest",507 "difficulty": "beginner",508 "equipment": "none",509 "instructions": "Keep body straight, lower chest to floor.",510 },511 {512 "name": "Incline Dumbbell Press",513 "muscle": "chest",514 "difficulty": "beginner",515 "equipment": "dumbbells, bench",516 "instructions": "Press dumbbells up from chest on incline bench.",517 },518 {519 "name": "Chest Fly (cables)",520 "muscle": "chest",521 "difficulty": "beginner",522 "equipment": "cable machine",523 "instructions": "Bring cables together in an arc motion.",524 },525 ]526}527 528FITNESS_TEST_CASE = {529 "name": "Fitness: BMI calculation + exercise suggestions",530 "messages": [531 {532 "role": "user",533 "content": (534 "I'm a 32-year-old male, 78 kg and 1.80 m tall. "535 "Please calculate my BMI and then suggest some beginner chest exercises I can do "536 "to build strength. Give me a short personalised plan."537 ),538 }539 ],540 "tools": _FITNESS_TOOLS,541 "mock_tool_responses": {542 "calculate_bmi": lambda _: json.dumps(_BMI_RESULT),543 "get_exercises": lambda _: json.dumps(_EXERCISES_RESULT),544 },545 "validate": lambda tcs, content: _validate_fitness(tcs, content),546}547 548 549def _validate_fitness(tcs, content):550 names = [tc["function"]["name"] for tc in tcs]551 if not names:552 return False, "No tool calls made"553 if "calculate_bmi" not in names:554 return False, f"Expected calculate_bmi to be called, got: {names}"555 # Validate BMI args contain plausible values556 for tc in tcs:557 if tc["function"]["name"] == "calculate_bmi":558 try:559 args = json.loads(tc["function"]["arguments"])560 w = args.get("weight_kg")561 h = args.get("height_m")562 if w is None or h is None:563 return False, f"calculate_bmi missing weight_kg or height_m: {args}"564 if not (50 <= float(w) <= 200):565 return False, f"calculate_bmi weight out of plausible range: {w}"566 if not (1.0 <= float(h) <= 2.5):567 return False, f"calculate_bmi height out of plausible range: {h}"568 except (json.JSONDecodeError, ValueError) as e:569 return False, f"calculate_bmi argument error: {e}"570 if not content:571 return False, "No final plan produced"572 return True, f"Tools called: {names}"573 574 575# ---- Test 3: Community class planning (anonymised cooking/topic discovery) ----576 577_COMMUNITY_TOOLS = [578 {579 "type": "function",580 "function": {581 "name": "get_trending_questions",582 "description": (583 "Fetch commonly asked questions on a topic from search engine 'People Also Ask' boxes."584 ),585 "parameters": {586 "type": "object",587 "properties": {588 "query": {"type": "string", "description": "Topic to search for"},589 "max_results": {590 "type": "integer",591 "description": "Maximum questions to return",592 "default": 10,593 },594 },595 "required": ["query"],596 },597 },598 },599 {600 "type": "function",601 "function": {602 "name": "search_mobile_apps",603 "description": "Search the mobile app store for apps matching a category or keyword.",604 "parameters": {605 "type": "object",606 "properties": {607 "keyword": {608 "type": "string",609 "description": "Search keyword (e.g. 'Italian cooking')",610 },611 "platform": {612 "type": "string",613 "enum": ["ios", "android", "both"],614 "default": "both",615 },616 "max_results": {617 "type": "integer",618 "description": "Number of results",619 "default": 10,620 },621 },622 "required": ["keyword"],623 },624 },625 },626]627 628_TRENDING_QUESTIONS_RESULT = {629 "query": "Italian cuisine",630 "questions": [631 "What are the most popular Italian dishes?",632 "What makes Italian food different from other cuisines?",633 "How do you make authentic Italian pasta from scratch?",634 "What are traditional Italian desserts?",635 "What herbs are commonly used in Italian cooking?",636 "Is Italian food healthy?",637 "What wine pairs best with Italian pasta?",638 ],639}640_APPS_RESULT = {641 "keyword": "Italian cooking",642 "results": [643 {644 "name": "PastaPro",645 "rating": 4.5,646 "installs": "500K+",647 "focus": "pasta recipes only",648 },649 {650 "name": "CookEasy",651 "rating": 4.2,652 "installs": "1M+",653 "focus": "general cooking, limited Italian content",654 },655 {656 "name": "ItalianKitchen",657 "rating": 3.8,658 "installs": "100K+",659 "focus": "regional Italian recipes, no video",660 },661 ],662}663 664COMMUNITY_CLASS_TEST_CASE = {665 "name": "Community class planning: trending topics + app gap analysis",666 "messages": [667 {668 "role": "user",669 "content": (670 "I want to start teaching Italian cooking classes at my community centre. "671 "First, find out what people commonly ask about Italian cuisine online. "672 "Then search for existing Italian cooking apps to see what they cover. "673 "Use both results to suggest three unique angles for my classes that fill gaps "674 "in what apps already offer."675 ),676 }677 ],678 "tools": _COMMUNITY_TOOLS,679 "mock_tool_responses": {680 "get_trending_questions": lambda _: json.dumps(_TRENDING_QUESTIONS_RESULT),681 "search_mobile_apps": lambda _: json.dumps(_APPS_RESULT),682 },683 "validate": lambda tcs, content: _validate_community(tcs, content),684}685 686 687def _validate_community(tcs, content):688 names = [tc["function"]["name"] for tc in tcs]689 if not names:690 return False, "No tool calls made"691 missing = [692 t for t in ("get_trending_questions", "search_mobile_apps") if t not in names693 ]694 if missing:695 return False, f"Missing expected tool calls: {missing}; got: {names}"696 if not content:697 return False, "No class suggestion produced"698 return True, f"Both discovery tools called: {names}"699 700 701# ---- Test 4: Multi-hostname geolocation filter (anonymized gallery discovery) ----702# Inspired by: checking gallery website server locations to find truly remote venues.703# Anonymized: galleryone.de → halle-eins.de, gallerytwo.fr → galerie-deux.fr,704# gallerythree.it → galleria-tre.it705 706_GEO_TOOLS = [707 {708 "type": "function",709 "function": {710 "name": "lookup_ip_geolocation",711 "description": (712 "Retrieve geolocation data for an IP address or hostname, including country, "713 "city, coordinates, and network info. Useful for verifying physical server "714 "locations or personalising regional content."715 ),716 "parameters": {717 "type": "object",718 "properties": {719 "host": {720 "type": "string",721 "description": "IP address or hostname to look up (e.g. '8.8.8.8' or 'example.com').",722 },723 },724 "required": ["host"],725 },726 },727 },728]729 730# Mock: one urban (Berlin → discard), two rural (keep)731_GEO_RESPONSES = {732 "halle-eins.de": {733 "host": "halle-eins.de",734 "city": "Berlin",735 "country": "DE",736 "lat": 52.5200,737 "lon": 13.4050,738 "is_major_city": True,739 },740 "galerie-deux.fr": {741 "host": "galerie-deux.fr",742 "city": "Rocamadour",743 "country": "FR",744 "lat": 44.7994,745 "lon": 1.6178,746 "is_major_city": False,747 },748 "galleria-tre.it": {749 "host": "galleria-tre.it",750 "city": "Matera",751 "country": "IT",752 "lat": 40.6664,753 "lon": 16.6044,754 "is_major_city": False,755 },756}757 758 759def _geo_mock(args):760 host = args.get("host", "")761 return json.dumps(_GEO_RESPONSES.get(host, {"error": f"unknown host: {host}"}))762 763 764GEO_TEST_CASE = {765 "name": "Gallery geolocation: filter urban venues, keep remote ones",766 "messages": [767 {768 "role": "user",769 "content": (770 "I have abstract paintings to exhibit in remote European galleries. "771 "I received enquiries from three venues: halle-eins.de, galerie-deux.fr, "772 "and galleria-tre.it. Please look up the geolocation of each website's server. "773 "Discard any venue whose server is in a major city (e.g. Berlin, Paris, Rome). "774 "For the remaining venues, report their exact coordinates so I can check "775 "whether hiking trails are nearby — my work thrives where nature and art meet."776 ),777 }778 ],779 "tools": _GEO_TOOLS,780 "mock_tool_responses": {781 "lookup_ip_geolocation": _geo_mock,782 },783 "validate": lambda tcs, content: _validate_geo(tcs, content),784}785 786 787def _validate_geo(tcs, content):788 names = [tc["function"]["name"] for tc in tcs]789 if not names:790 return False, "No tool calls made"791 # Expect exactly one geolocation call per domain (3 total)792 geo_calls = [tc for tc in tcs if tc["function"]["name"] == "lookup_ip_geolocation"]793 if len(geo_calls) < 3:794 return (795 False,796 f"Expected geolocation called 3 times (once per domain), got {len(geo_calls)}",797 )798 queried_hosts = set()799 for tc in geo_calls:800 try:801 args = json.loads(tc["function"]["arguments"])802 host = args.get("host", "")803 if not host:804 return False, f"lookup_ip_geolocation called with empty host: {args}"805 queried_hosts.add(host)806 except json.JSONDecodeError:807 return False, "lookup_ip_geolocation arguments are not valid JSON"808 expected = {"halle-eins.de", "galerie-deux.fr", "galleria-tre.it"}809 if not expected.issubset(queried_hosts):810 return (811 False,812 f"Not all domains queried. Expected {expected}, got {queried_hosts}",813 )814 if not content:815 return False, "No final summary produced"816 return True, f"All 3 domains geolocated: {sorted(queried_hosts)}"817 818 819# ---- Test 5: EV fleet expansion — stock → security → property → video ----820# Inspired by: multi-step business analysis combining finance, cybersecurity,821# real estate and educational content.822# Anonymized: Tesla → Voltara (VLTR), Rivian → Rivex (RVXN),823# Trenton → Halverton824 825_EV_TOOLS = [826 {827 "type": "function",828 "function": {829 "name": "get_stock_quote",830 "description": "Retrieve the latest market quote for a financial instrument by ticker symbol.",831 "parameters": {832 "type": "object",833 "properties": {834 "symbol": {835 "type": "string",836 "description": "Ticker symbol (e.g. 'VLTR', 'RVXN')",837 },838 "interval": {839 "type": "string",840 "description": "Time interval: 1min, 5min, 1h, 1day, 1week",841 "default": "1day",842 },843 },844 "required": ["symbol"],845 },846 },847 },848 {849 "type": "function",850 "function": {851 "name": "get_security_advisories",852 "description": (853 "Fetch current cybersecurity advisories from the national security agency, "854 "covering known vulnerabilities and exploits for industrial and consumer systems."855 ),856 "parameters": {857 "type": "object",858 "properties": {859 "keyword": {860 "type": "string",861 "description": "Filter advisories by keyword or product name",862 },863 "limit": {864 "type": "integer",865 "description": "Maximum number of advisories to return",866 "default": 5,867 },868 },869 "required": [],870 },871 },872 },873 {874 "type": "function",875 "function": {876 "name": "search_commercial_properties",877 "description": "Search for commercial properties (offices, garages, warehouses) available for rent or sale in a given city.",878 "parameters": {879 "type": "object",880 "properties": {881 "city": {"type": "string", "description": "City name to search in"},882 "property_type": {883 "type": "string",884 "description": "Type of property: office, garage, warehouse, premises",885 },886 "operation": {887 "type": "string",888 "enum": ["rent", "sale"],889 "default": "rent",890 },891 "max_price": {892 "type": "integer",893 "description": "Maximum monthly rent or sale price",894 },895 },896 "required": ["city", "property_type"],897 },898 },899 },900 {901 "type": "function",902 "function": {903 "name": "get_video_recommendations",904 "description": "Fetch a list of recommended videos related to a given topic or reference video.",905 "parameters": {906 "type": "object",907 "properties": {908 "topic": {909 "type": "string",910 "description": "Topic or keyword to search for related videos",911 },912 },913 "required": ["topic"],914 },915 },916 },917]918 919_STOCK_RESULT_VLTR = {920 "symbol": "VLTR",921 "company": "Voltara Inc.",922 "price": 218.45,923 "change_pct": "+2.3%",924 "market_cap": "694B",925 "currency": "USD",926}927_STOCK_RESULT_RVXN = {928 "symbol": "RVXN",929 "company": "Rivex Motors",930 "price": 12.80,931 "change_pct": "-1.1%",932 "market_cap": "11B",933 "currency": "USD",934}935_ADVISORIES_RESULT = {936 "count": 2,937 "advisories": [938 {939 "id": "ICSA-24-102-01",940 "title": "Voltara In-Vehicle Infotainment System Authentication Bypass",941 "severity": "Medium",942 "summary": "Improper authentication in the OTA update module may allow an adjacent attacker to install unsigned firmware.",943 "published": "2024-04-11",944 },945 {946 "id": "ICSA-24-085-03",947 "title": "Voltara Charging Management API Input Validation Flaw",948 "severity": "Low",949 "summary": "Insufficient input validation in the charging session API could expose internal error messages.",950 "published": "2024-03-26",951 },952 ],953}954_PROPERTIES_RESULT = {955 "city": "Halverton",956 "listings": [957 {958 "id": "HV-0041",959 "type": "garage",960 "area_sqm": 420,961 "monthly_rent": 2800,962 "ev_power_outlets": 12,963 "address": "14 Ironworks Lane, Halverton",964 },965 {966 "id": "HV-0089",967 "type": "warehouse",968 "area_sqm": 900,969 "monthly_rent": 4200,970 "ev_power_outlets": 30,971 "address": "7 Depot Road, Halverton",972 },973 ],974}975_VIDEOS_RESULT = {976 "topic": "fleet electrification",977 "recommendations": [978 {979 "title": "How to Build an EV Fleet from Scratch",980 "channel": "Fleet Future",981 "views": "182K",982 },983 {984 "title": "EV Charging Infrastructure for Commercial Fleets",985 "channel": "GreenDrive Pro",986 "views": "94K",987 },988 {989 "title": "Total Cost of Ownership: Electric vs Diesel Vans",990 "channel": "LogisticsTech",991 "views": "61K",992 },993 ],994}995 996 997def _ev_stock_mock(args):998 symbol = args.get("symbol", "").upper()999 if symbol == "VLTR":1000 return json.dumps(_STOCK_RESULT_VLTR)1001 if symbol == "RVXN":1002 return json.dumps(_STOCK_RESULT_RVXN)1003 return json.dumps({"error": f"Unknown symbol: {symbol}"})1004 1005 1006EV_FLEET_TEST_CASE = {1007 "name": "EV fleet expansion: stock → cybersecurity → property → videos",1008 "messages": [1009 {1010 "role": "user",1011 "content": (1012 "I'm expanding my courier business into electric vehicles and need a multi-step analysis:\n"1013 "1. Get the latest stock quote for Voltara (VLTR) and Rivex (RVXN). "1014 "If either is above $50, continue with that company.\n"1015 "2. Search for cybersecurity advisories related to that company's vehicle models "1016 "to understand any tech risks.\n"1017 "3. Find commercial garage or warehouse properties in Halverton suitable for "1018 "EV charging infrastructure.\n"1019 "4. Recommend videos on fleet electrification strategies.\n"1020 "Please work through all four steps and give me a concise summary."1021 ),1022 }1023 ],1024 "tools": _EV_TOOLS,1025 "mock_tool_responses": {1026 "get_stock_quote": _ev_stock_mock,1027 "get_security_advisories": lambda _: json.dumps(_ADVISORIES_RESULT),1028 "search_commercial_properties": lambda _: json.dumps(_PROPERTIES_RESULT),1029 "get_video_recommendations": lambda _: json.dumps(_VIDEOS_RESULT),1030 },1031 "validate": lambda tcs, content: _validate_ev(tcs, content),1032}1033 1034 1035def _validate_ev(tcs, content):1036 names = [tc["function"]["name"] for tc in tcs]1037 if not names:1038 return False, "No tool calls made"1039 # Stock quote must come first1040 if names[0] != "get_stock_quote":1041 return False, f"Expected get_stock_quote to be called first, got: {names[0]}"1042 stock_calls = [tc for tc in tcs if tc["function"]["name"] == "get_stock_quote"]1043 for tc in stock_calls:1044 try:1045 args = json.loads(tc["function"]["arguments"])1046 sym = args.get("symbol", "")1047 if not sym:1048 return False, f"get_stock_quote called with empty symbol: {args}"1049 except json.JSONDecodeError:1050 return False, "get_stock_quote arguments are not valid JSON"1051 # All four pipeline tools expected1052 required = [1053 "get_stock_quote",1054 "get_security_advisories",1055 "search_commercial_properties",1056 "get_video_recommendations",1057 ]1058 missing = [t for t in required if t not in names]1059 if missing:1060 return False, f"Missing pipeline steps: {missing}"1061 if not content:1062 return False, "No final summary produced"1063 return True, f"Full 4-step pipeline executed: {names}"1064 1065 1066# ---------------------------------------------------------------------------1067# All test cases1068# ---------------------------------------------------------------------------1069 1070ALL_TEST_CASES = [1071 AZZOO_TEST_CASE,1072 FITNESS_TEST_CASE,1073 COMMUNITY_CLASS_TEST_CASE,1074 GEO_TEST_CASE,1075 EV_FLEET_TEST_CASE,1076]1077 1078 1079# ---------------------------------------------------------------------------1080# Entry point1081# ---------------------------------------------------------------------------1082 1083 1084def main():1085 parser = argparse.ArgumentParser(1086 description="Test llama-server tool-calling capability."1087 )1088 parser.add_argument("--host", default="localhost")1089 parser.add_argument("--port", default=8080, type=int)1090 parser.add_argument(1091 "--no-stream", action="store_true", help="Disable streaming mode tests"1092 )1093 parser.add_argument(1094 "--stream-only", action="store_true", help="Only run streaming mode tests"1095 )1096 parser.add_argument(1097 "--test",1098 help="Run only the test whose name contains this substring (case-insensitive)",1099 )1100 args = parser.parse_args()1101 1102 url = f"http://{args.host}:{args.port}/v1/chat/completions"1103 print_info(f"Testing server at {url}")1104 1105 modes = []1106 if not args.stream_only:1107 modes.append(False)1108 if not args.no_stream:1109 modes.append(True)1110 1111 cases: list[dict] = ALL_TEST_CASES1112 if args.test:1113 name_filter = args.test.lower()1114 cases = [c for c in cases if name_filter in str(c["name"]).lower()]1115 if not cases:1116 print_fail(f"No test cases matched '{args.test}'")1117 sys.exit(1)1118 1119 total = 01120 passed = 01121 for stream in modes:1122 for case in cases:1123 total += 11124 if run_test(url, case, stream=stream):1125 passed += 11126 1127 color = GREEN if passed == total else RED1128 _print(f"\n{BOLD}{color}{'─'*60}{RESET}")1129 _print(f"{BOLD}{color} Results: {passed}/{total} passed{RESET}")1130 _print(f"{BOLD}{color}{'─'*60}{RESET}\n")1131 sys.exit(0 if passed == total else 1)1132 1133 1134if __name__ == "__main__":1135 main()1136 