CoolFace
Datasetpublic

echodict/llama.cpp

version https://git-lfs.github.com/spec/v1 oid sha256:cfc44b7ba25614df70e6b65e3341cae0310163bd32fd31a6b928a542df433faf size 30786

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes762downloads
server-test-function-call.py1136 linesDownload Raw Back to scripts
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