Files
wehub-resource-sync bbfc60cd69
Publish BFCL to PyPI / build_and_publish (push) Has been cancelled
Update API Zoo Data / send-updates (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:37:27 +08:00

424 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import argparse
import heapq
import multiprocessing as mp
import os
import queue
import shutil
import threading
import traceback
from collections import defaultdict
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
from copy import deepcopy
from typing import Optional
from bfcl_eval.constants.eval_config import (
PROJECT_ROOT,
RESULT_FILE_PATTERN,
RESULT_PATH,
TEST_IDS_TO_GENERATE_PATH,
)
from bfcl_eval.constants.model_config import MODEL_CONFIG_MAPPING
from bfcl_eval.eval_checker.eval_runner_helper import load_file
from bfcl_eval.model_handler.base_handler import BaseHandler
from bfcl_eval.model_handler.local_inference.base_oss_handler import OSSHandler
from bfcl_eval.utils import *
from tqdm import tqdm
def get_args():
parser = argparse.ArgumentParser()
# Refer to model_choice for supported models.
parser.add_argument("--model", type=str, default="gorilla-openfunctions-v2", nargs="+")
# Refer to test_categories for supported categories.
parser.add_argument("--test-category", type=str, default="all", nargs="+")
# Parameters for the model that you want to test.
parser.add_argument("--temperature", type=float, default=0.001)
parser.add_argument("--include-input-log", action="store_true", default=False)
parser.add_argument("--exclude-state-log", action="store_true", default=False)
parser.add_argument("--num-threads", required=False, type=int)
parser.add_argument("--num-gpus", default=1, type=int)
parser.add_argument("--backend", default="vllm", type=str, choices=["vllm", "sglang"])
parser.add_argument("--gpu-memory-utilization", default=0.9, type=float)
parser.add_argument("--result-dir", default=None, type=str)
parser.add_argument("--run-ids", action="store_true", default=False)
parser.add_argument("--allow-overwrite", "-o", action="store_true", default=False)
parser.add_argument(
"--skip-server-setup",
action="store_true",
default=False,
help="Skip vLLM/SGLang server setup and use existing endpoint specified by the LOCAL_SERVER_ENDPOINT and LOCAL_SERVER_PORT environment variables.",
)
# Optional local model path
parser.add_argument(
"--local-model-path",
type=str,
default=None,
help="Specify the path to a local directory containing the model's config/tokenizer/weights for fully offline inference. Use this only if the model weights are stored in a location other than the default HF_HOME directory.",
)
parser.add_argument(
"--lora-modules",
type=str,
default=None,
nargs="*",
help="Specify the path to the LoRA modules for vLLM backend in name=\"path\" format. Can be specified multiple times.",
)
parser.add_argument(
"--enable-lora",
action="store_true",
default=False,
help="Enable LoRA for vLLM backend.",
)
parser.add_argument(
"--max-lora-rank",
type=int,
default=None,
help="Specify the maximum LoRA rank for vLLM backend.",
)
args = parser.parse_args()
print(f"Parsed arguments: {args}")
return args
def build_handler(model_name, temperature):
config = MODEL_CONFIG_MAPPING[model_name]
handler = config.model_handler(
model_name=config.model_name,
temperature=temperature,
registry_name=model_name,
is_fc_model=config.is_fc_model,
)
return handler
def get_involved_test_entries(test_category_args, run_ids):
all_test_categories, all_test_entries_involved = [], []
if run_ids:
all_test_categories, all_test_entries_involved = load_test_entries_from_id_file(
TEST_IDS_TO_GENERATE_PATH
)
else:
all_test_categories = parse_test_category_argument(test_category_args)
for test_category in all_test_categories:
all_test_entries_involved.extend(load_dataset_entry(test_category))
return (
all_test_categories,
all_test_entries_involved,
)
def collect_test_cases(args, model_name, all_test_categories, all_test_entries_involved):
model_name_dir = model_name.replace("/", "_")
model_result_dir = args.result_dir / model_name_dir
existing_result = []
for test_category in all_test_categories:
# TODO: Simplify the handling of memory prerequisite entries/categories
result_file_paths = [
model_result_dir
/ get_directory_structure_by_category(test_category)
/ get_file_name_by_category(test_category, is_result_file=True)
]
if is_memory(test_category):
# Memory test cases have the pre-requisite entries in a separate file
result_file_paths.append(
model_result_dir
/ get_directory_structure_by_category(test_category)
/ get_file_name_by_category(f"{test_category}_prereq", is_result_file=True)
)
for file_path in result_file_paths:
if file_path.exists():
# Not allowing overwrite, we will load the existing results
if not args.allow_overwrite:
existing_result.extend(load_file(file_path))
# Allow overwrite and not running specific test ids, we will delete the existing result file before generating new results
elif not args.run_ids:
file_path.unlink()
# Allow overwrite and running specific test ids, we will do nothing here
else:
pass
if is_memory(test_category):
# We also need to special handle the pre-requisite entries and the snapshot result for memory test cases
snapshot_folder = model_result_dir / "memory_snapshot" / test_category
if snapshot_folder.exists():
if not args.allow_overwrite:
pass
elif not args.run_ids:
shutil.rmtree(snapshot_folder)
else:
# TODO: If run_ids and id involes prereq entries, we should just delete those snapshot files
# It's not implemented yet, but it won't affect the accuracy, as those files will be overwritten anyway (assume generation success)
pass
existing_ids = [entry["id"] for entry in existing_result]
test_cases_to_generate = [
test_case
for test_case in all_test_entries_involved
if test_case["id"] not in existing_ids
]
# Skip format sensitivity test cases for FC models
if (
any(is_format_sensitivity(test_category) for test_category in all_test_categories)
and MODEL_CONFIG_MAPPING[model_name].is_fc_model
):
test_cases_to_generate = [
test_case
for test_case in test_cases_to_generate
if not is_format_sensitivity(test_case["id"])
]
test_cases_to_generate = clean_up_memory_prereq_entries(test_cases_to_generate)
# TODO: Should we move these to the load_dataset_entry function?
test_cases_to_generate = populate_initial_settings_for_memory_test_cases(
test_cases_to_generate, model_result_dir
)
test_cases_to_generate = populate_initial_settings_for_web_search_test_cases(
test_cases_to_generate
)
return sorted(test_cases_to_generate, key=sort_key)
def multi_threaded_inference(handler, test_case, include_input_log, exclude_state_log):
assert type(test_case["function"]) is list
try:
result, metadata = handler.inference(
test_case, include_input_log, exclude_state_log
)
except Exception as e:
# This is usually the case when the model getting stuck on one particular test case.
# For example, timeout error or FC model returning invalid JSON response.
# Since temperature is already set to 0.001, retrying the same test case will not help.
# So we continue the generation process and record the error message as the model response
error_block = (
"-" * 100
+ "\n❗️❗️ Error occurred during inference. Continuing to next test case.\n"
+ f"❗️❗️ Test case ID: {test_case['id']}, Error: {str(e)}\n"
+ traceback.format_exc(limit=10)
+ "-" * 100
)
tqdm.write(error_block)
result = f"Error during inference: {str(e)}"
metadata = {"traceback": traceback.format_exc()}
result_to_write = {
"id": test_case["id"],
"result": result,
**metadata,
}
return result_to_write
def generate_results(args, model_name, test_cases_total):
handler = build_handler(model_name, args.temperature)
if isinstance(handler, OSSHandler):
handler: OSSHandler
is_oss_model = True
# For OSS models, if the user didn't explicitly set the number of threads,
# we default to 100 threads to speed up the inference.
num_threads = (
args.num_threads
if args.num_threads is not None
else LOCAL_SERVER_MAX_CONCURRENT_REQUEST
)
else:
handler: BaseHandler
is_oss_model = False
num_threads = args.num_threads if args.num_threads is not None else 1
# Use a separate thread to write the results to the file to avoid concurrent IO issues
def _writer():
"""Consume result dicts from the queue and write them with exclusive access."""
while True:
item = write_queue.get()
if item is None:
break
handler.write(item, result_dir=args.result_dir, update_mode=args.run_ids)
write_queue.task_done()
write_queue: queue.Queue = queue.Queue()
writer_thread = threading.Thread(target=_writer, daemon=True)
writer_thread.start()
try:
if is_oss_model:
handler.spin_up_local_server(
num_gpus=args.num_gpus,
gpu_memory_utilization=args.gpu_memory_utilization,
backend=args.backend,
skip_server_setup=args.skip_server_setup,
local_model_path=args.local_model_path,
lora_modules=args.lora_modules,
enable_lora=args.enable_lora,
max_lora_rank=args.max_lora_rank,
)
# ───── dependency bookkeeping ──────────────────────────────
dependencies = {
test_case["id"]: set(test_case.get("depends_on", []))
for test_case in test_cases_total
}
children_of = defaultdict(list)
for test_case in test_cases_total:
for dependency_id in test_case.get("depends_on", []):
children_of[dependency_id].append(test_case["id"])
id_to_test_case = {test_case["id"]: test_case for test_case in test_cases_total}
ready_queue = [
(sort_key(id_to_test_case[test_case_id]), test_case_id)
for test_case_id, dependency_ids in dependencies.items()
if not dependency_ids
]
heapq.heapify(ready_queue)
in_flight: dict[Future, str] = {} # future -> test_case_id
completed = set()
with ThreadPoolExecutor(max_workers=num_threads) as pool, tqdm(
total=len(test_cases_total),
desc=f"Generating results for {model_name}",
position=0,
leave=True,
dynamic_ncols=True,
mininterval=0.2,
smoothing=0.1,
bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]",
) as pbar:
# seed initial ready tasks
while ready_queue and len(in_flight) < num_threads:
_, test_case_id = heapq.heappop(ready_queue)
test_case = id_to_test_case[test_case_id]
future = pool.submit(
multi_threaded_inference,
handler,
test_case,
args.include_input_log,
args.exclude_state_log,
)
in_flight[future] = test_case_id
# main scheduler loop
while in_flight:
done, _ = wait(in_flight, return_when=FIRST_COMPLETED)
for future in done:
test_case_id = in_flight.pop(future)
result_dict = future.result()
# Enqueue the result for the writer thread to handle file IO
write_queue.put(result_dict)
# Update progress bar right after inference completes
pbar.update()
completed.add(test_case_id)
# unlock children
for child_id in children_of[test_case_id]:
dependencies[child_id].discard(test_case_id)
if not dependencies[child_id]:
heapq.heappush(
ready_queue,
(sort_key(id_to_test_case[child_id]), child_id),
)
# refill the pool up to max_workers
while ready_queue and len(in_flight) < num_threads:
_, test_case_id = heapq.heappop(ready_queue)
test_case = id_to_test_case[test_case_id]
future = pool.submit(
multi_threaded_inference,
handler,
test_case,
args.include_input_log,
args.exclude_state_log,
)
in_flight[future] = test_case_id
finally:
# Signal writer thread to finish and wait for it
write_queue.put(None)
writer_thread.join()
if is_oss_model:
handler.shutdown_local_server()
def main(args):
# Note: The following environment variables are needed for the memory vector store implementation
# Otherwise you get segfault or huggingface tokenizer warnings
# disable HuggingFace tokenizers thread pool
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# limit all OpenMP/MKL threads to 1
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
# use spawn method for multiprocessing
mp.set_start_method("spawn", force=True)
if type(args.model) is not list:
args.model = [args.model]
if type(args.test_category) is not list:
args.test_category = [args.test_category]
(
all_test_categories,
all_test_entries_involved,
) = get_involved_test_entries(args.test_category, args.run_ids)
for model_name in args.model:
if model_name not in MODEL_CONFIG_MAPPING:
raise ValueError(
f"Unknown model_name '{model_name}'.\n"
"• For officially supported models, please refer to `SUPPORTED_MODELS.md`.\n"
"• For running new models, please refer to `README.md` and `CONTRIBUTING.md`."
)
tqdm.write(f"Generating results for {args.model}")
if args.run_ids:
tqdm.write("Running specific test cases. Ignoring `--test-category` argument.")
else:
tqdm.write(f"Running full test cases for categories: {all_test_categories}.")
if any(is_format_sensitivity(test_category) for test_category in all_test_categories):
for model_name in args.model:
if MODEL_CONFIG_MAPPING[model_name].is_fc_model:
tqdm.write(
"⚠️ Warning: Format sensitivity test cases are only supported for prompting (non-FC) models. "
f"Since {model_name} is a FC model based on its config, the format sensitivity test cases will be skipped."
)
if args.result_dir is not None:
args.result_dir = PROJECT_ROOT / args.result_dir
else:
args.result_dir = RESULT_PATH
for model_name in args.model:
test_cases_total = collect_test_cases(
args,
model_name,
all_test_categories,
deepcopy(all_test_entries_involved),
)
if len(test_cases_total) == 0:
tqdm.write(
f"✅ All selected test cases have been previously generated for {model_name}. No new test cases to generate."
)
else:
generate_results(args, model_name, test_cases_total)
# Sort the result files by id at the end
for model_result_json in args.result_dir.rglob(RESULT_FILE_PATTERN):
sort_file_content_by_id(model_result_json)