toolcallbench 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toolcallbench-1.0.0/LICENSE +21 -0
- toolcallbench-1.0.0/PKG-INFO +113 -0
- toolcallbench-1.0.0/README.md +87 -0
- toolcallbench-1.0.0/pyproject.toml +50 -0
- toolcallbench-1.0.0/setup.cfg +4 -0
- toolcallbench-1.0.0/src/toolcallbench/__init__.py +3 -0
- toolcallbench-1.0.0/src/toolcallbench/__main__.py +6 -0
- toolcallbench-1.0.0/src/toolcallbench/backends/__init__.py +43 -0
- toolcallbench-1.0.0/src/toolcallbench/backends/base.py +42 -0
- toolcallbench-1.0.0/src/toolcallbench/backends/ollama.py +86 -0
- toolcallbench-1.0.0/src/toolcallbench/backends/openai_compat.py +96 -0
- toolcallbench-1.0.0/src/toolcallbench/backends/scripted.py +90 -0
- toolcallbench-1.0.0/src/toolcallbench/checks.py +225 -0
- toolcallbench-1.0.0/src/toolcallbench/cli.py +436 -0
- toolcallbench-1.0.0/src/toolcallbench/config.py +99 -0
- toolcallbench-1.0.0/src/toolcallbench/data/configs/m4-16gb.yaml +22 -0
- toolcallbench-1.0.0/src/toolcallbench/data/configs/my-models.yaml +19 -0
- toolcallbench-1.0.0/src/toolcallbench/data/configs/quant-sweep-qwen3-8b.yaml +17 -0
- toolcallbench-1.0.0/src/toolcallbench/data/configs/smoke-scripted.yaml +6 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/README.md +17 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/config/settings.json +7 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/data/books.csv +7 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/data/members.csv +4 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/pytest.ini +3 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/scripts/report.py +23 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/src/shelf/__init__.py +3 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/src/shelf/catalog.py +33 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/src/shelf/fees.py +20 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/src/shelf/loans.py +46 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/src/shelf/models.py +30 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/src/shelf/settings.py +12 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/src/shelf/utils.py +19 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/tests/test_catalog.py +19 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/tests/test_fees.py +16 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/tests/test_loans.py +32 -0
- toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/tests/test_utils.py +14 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/chain-01.yaml +27 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/chain-02.yaml +20 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/chain-03.yaml +19 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/chain-04.yaml +38 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/edit-01.yaml +23 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/edit-02.yaml +19 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/edit-03.yaml +32 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/edit-04.yaml +23 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/edit-05.yaml +23 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/failure-01.yaml +10 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/failure-02.yaml +20 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/failure-03.yaml +20 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/read-01.yaml +15 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/read-02.yaml +15 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/read-03.yaml +16 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/read-04.yaml +15 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/read-05.yaml +16 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/run-01.yaml +15 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/run-02.yaml +15 -0
- toolcallbench-1.0.0/src/toolcallbench/data/tasks/run-03.yaml +15 -0
- toolcallbench-1.0.0/src/toolcallbench/models.py +79 -0
- toolcallbench-1.0.0/src/toolcallbench/patching.py +130 -0
- toolcallbench-1.0.0/src/toolcallbench/report.py +197 -0
- toolcallbench-1.0.0/src/toolcallbench/runner.py +90 -0
- toolcallbench-1.0.0/src/toolcallbench/store.py +225 -0
- toolcallbench-1.0.0/src/toolcallbench/tasks.py +155 -0
- toolcallbench-1.0.0/src/toolcallbench/tools.py +306 -0
- toolcallbench-1.0.0/src/toolcallbench.egg-info/PKG-INFO +113 -0
- toolcallbench-1.0.0/src/toolcallbench.egg-info/SOURCES.txt +76 -0
- toolcallbench-1.0.0/src/toolcallbench.egg-info/dependency_links.txt +1 -0
- toolcallbench-1.0.0/src/toolcallbench.egg-info/entry_points.txt +2 -0
- toolcallbench-1.0.0/src/toolcallbench.egg-info/requires.txt +8 -0
- toolcallbench-1.0.0/src/toolcallbench.egg-info/top_level.txt +1 -0
- toolcallbench-1.0.0/tests/test_checks.py +99 -0
- toolcallbench-1.0.0/tests/test_cli.py +140 -0
- toolcallbench-1.0.0/tests/test_config.py +42 -0
- toolcallbench-1.0.0/tests/test_http_backends.py +68 -0
- toolcallbench-1.0.0/tests/test_patching.py +41 -0
- toolcallbench-1.0.0/tests/test_scripts.py +32 -0
- toolcallbench-1.0.0/tests/test_store_report.py +48 -0
- toolcallbench-1.0.0/tests/test_tasks.py +77 -0
- toolcallbench-1.0.0/tests/test_tools.py +94 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ToolCallBench contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toolcallbench
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Benchmark how reliably local LLMs call tools on your own repo and hardware
|
|
5
|
+
Author-email: Herin Patel <herinpatel15113@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/herinpatel15/ToolCallBench
|
|
8
|
+
Project-URL: Issues, https://github.com/herinpatel15/ToolCallBench/issues
|
|
9
|
+
Keywords: llm,benchmark,tool-calling,ollama,function-calling
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Testing
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pyyaml>=6
|
|
19
|
+
Requires-Dist: jsonschema>=4
|
|
20
|
+
Requires-Dist: pytest>=7
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
23
|
+
Requires-Dist: build; extra == "dev"
|
|
24
|
+
Requires-Dist: twine; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# ToolCallBench
|
|
28
|
+
|
|
29
|
+
Find out which local language model calls tools reliably **on your own machine**.
|
|
30
|
+
|
|
31
|
+
ToolCallBench gives a model six tools (read, list, search, edit, patch, run a command) and 20
|
|
32
|
+
small tasks on a sample Python project. It checks the results with plain code, not with
|
|
33
|
+
another model, and writes a report that compares models and quantization levels.
|
|
34
|
+
|
|
35
|
+
It is tuned for a **MacBook M4 with 16 GB of memory** running Ollama, and works with any
|
|
36
|
+
OpenAI-compatible local server (llama.cpp, LM Studio, vLLM).
|
|
37
|
+
|
|
38
|
+
## Install and run
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install toolcallbench
|
|
42
|
+
|
|
43
|
+
mkdir my-bench && cd my-bench
|
|
44
|
+
toolcallbench init # creates configs/ with example settings
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Edit `configs/my-models.yaml` and list the models you have pulled in Ollama, then:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
toolcallbench doctor --probe --config configs/my-models.yaml # check Ollama, models, tool calling
|
|
51
|
+
toolcallbench run --config configs/my-models.yaml # run the 20 tasks
|
|
52
|
+
toolcallbench sessions # list past runs
|
|
53
|
+
toolcallbench report --session <id> # Markdown + HTML report in ./results/reports
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Every setting can also be given as an argument, and arguments override the config file:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
toolcallbench run --model qwen3:8b --model gemma4:e4b --repeats 3 --num-ctx 16384 --groups read,edit
|
|
60
|
+
toolcallbench run --help
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Other commands: `toolcallbench list-tasks`, `toolcallbench validate-tasks`, `toolcallbench init --with-tasks`
|
|
64
|
+
(copies the tasks and sample repo so you can edit them; a folder with its own `tasks/` and
|
|
65
|
+
`fixtures/repo/` uses them instead of the built-in ones). Results are written to `./results`.
|
|
66
|
+
|
|
67
|
+
No model yet? `toolcallbench run --backend scripted --repeats 1` runs the whole pipeline without one.
|
|
68
|
+
|
|
69
|
+
Ollama must be running (`ollama serve`) with the models pulled (`ollama pull qwen3:8b`).
|
|
70
|
+
On a 16 GB Mac, start from the `m4-16gb.yaml` example config. See [docs/USER_GUIDE.md](docs/USER_GUIDE.md).
|
|
71
|
+
|
|
72
|
+
### Develop
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
git clone <repo> && cd toolcallbench
|
|
76
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
77
|
+
pip install -e ".[dev]"
|
|
78
|
+
python -m pytest
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## What a report tells you
|
|
82
|
+
|
|
83
|
+
- Pass rate overall and for read, edit, run, chain and failure tasks
|
|
84
|
+
- Why runs failed (13 failure types, such as `invalid_json`, `text_tool_call`, `patch_failed`)
|
|
85
|
+
- Tokens per second, seconds per task, and calls compared with a reference solution
|
|
86
|
+
- Every setting that affects the result (context length, seed, KV cache type, versions)
|
|
87
|
+
|
|
88
|
+
## Status
|
|
89
|
+
|
|
90
|
+
Version 1.0.0. The harness, tasks, scoring, backends (checked against mock servers) and
|
|
91
|
+
reports are covered by automated tests. **No real model has been benchmarked yet**; run it on
|
|
92
|
+
your Mac to get the first numbers (`docs/TEST_PLAN.md` lists exactly what was and was not tested).
|
|
93
|
+
|
|
94
|
+
## Documentation
|
|
95
|
+
|
|
96
|
+
| Document | Purpose |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| [BUILD_FROM_SCRATCH.md](BUILD_FROM_SCRATCH.md) | Day-by-day guide to build this project yourself, with all code |
|
|
99
|
+
| [docs/PRD.md](docs/PRD.md) | Why it exists, who it is for, requirements |
|
|
100
|
+
| [docs/TECH_SPEC.md](docs/TECH_SPEC.md) | Exact behavior: tools, scoring, config, storage |
|
|
101
|
+
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Diagrams: context, components, data flow, sequence, states, storage |
|
|
102
|
+
| [docs/TEST_PLAN.md](docs/TEST_PLAN.md) | What is tested and how requirements map to tests |
|
|
103
|
+
| [docs/SECURITY.md](docs/SECURITY.md) | What protects you and what does not |
|
|
104
|
+
| [docs/DECISIONS.md](docs/DECISIONS.md) | Why it is built this way |
|
|
105
|
+
| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Install, run, read the report, troubleshoot |
|
|
106
|
+
| [docs/CLI_REFERENCE.md](docs/CLI_REFERENCE.md) | Every command, option and config key |
|
|
107
|
+
| [docs/MODEL_GUIDE_M4_16GB.md](docs/MODEL_GUIDE_M4_16GB.md) | Memory budget and model choices for 16 GB |
|
|
108
|
+
| [docs/TASK_CATALOG.md](docs/TASK_CATALOG.md) | All 20 tasks (generated) |
|
|
109
|
+
| [docs/ROADMAP.md](docs/ROADMAP.md), [CHANGELOG.md](CHANGELOG.md), [CONTRIBUTING.md](CONTRIBUTING.md) | Project housekeeping |
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# ToolCallBench
|
|
2
|
+
|
|
3
|
+
Find out which local language model calls tools reliably **on your own machine**.
|
|
4
|
+
|
|
5
|
+
ToolCallBench gives a model six tools (read, list, search, edit, patch, run a command) and 20
|
|
6
|
+
small tasks on a sample Python project. It checks the results with plain code, not with
|
|
7
|
+
another model, and writes a report that compares models and quantization levels.
|
|
8
|
+
|
|
9
|
+
It is tuned for a **MacBook M4 with 16 GB of memory** running Ollama, and works with any
|
|
10
|
+
OpenAI-compatible local server (llama.cpp, LM Studio, vLLM).
|
|
11
|
+
|
|
12
|
+
## Install and run
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install toolcallbench
|
|
16
|
+
|
|
17
|
+
mkdir my-bench && cd my-bench
|
|
18
|
+
toolcallbench init # creates configs/ with example settings
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Edit `configs/my-models.yaml` and list the models you have pulled in Ollama, then:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
toolcallbench doctor --probe --config configs/my-models.yaml # check Ollama, models, tool calling
|
|
25
|
+
toolcallbench run --config configs/my-models.yaml # run the 20 tasks
|
|
26
|
+
toolcallbench sessions # list past runs
|
|
27
|
+
toolcallbench report --session <id> # Markdown + HTML report in ./results/reports
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Every setting can also be given as an argument, and arguments override the config file:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
toolcallbench run --model qwen3:8b --model gemma4:e4b --repeats 3 --num-ctx 16384 --groups read,edit
|
|
34
|
+
toolcallbench run --help
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Other commands: `toolcallbench list-tasks`, `toolcallbench validate-tasks`, `toolcallbench init --with-tasks`
|
|
38
|
+
(copies the tasks and sample repo so you can edit them; a folder with its own `tasks/` and
|
|
39
|
+
`fixtures/repo/` uses them instead of the built-in ones). Results are written to `./results`.
|
|
40
|
+
|
|
41
|
+
No model yet? `toolcallbench run --backend scripted --repeats 1` runs the whole pipeline without one.
|
|
42
|
+
|
|
43
|
+
Ollama must be running (`ollama serve`) with the models pulled (`ollama pull qwen3:8b`).
|
|
44
|
+
On a 16 GB Mac, start from the `m4-16gb.yaml` example config. See [docs/USER_GUIDE.md](docs/USER_GUIDE.md).
|
|
45
|
+
|
|
46
|
+
### Develop
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
git clone <repo> && cd toolcallbench
|
|
50
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
51
|
+
pip install -e ".[dev]"
|
|
52
|
+
python -m pytest
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## What a report tells you
|
|
56
|
+
|
|
57
|
+
- Pass rate overall and for read, edit, run, chain and failure tasks
|
|
58
|
+
- Why runs failed (13 failure types, such as `invalid_json`, `text_tool_call`, `patch_failed`)
|
|
59
|
+
- Tokens per second, seconds per task, and calls compared with a reference solution
|
|
60
|
+
- Every setting that affects the result (context length, seed, KV cache type, versions)
|
|
61
|
+
|
|
62
|
+
## Status
|
|
63
|
+
|
|
64
|
+
Version 1.0.0. The harness, tasks, scoring, backends (checked against mock servers) and
|
|
65
|
+
reports are covered by automated tests. **No real model has been benchmarked yet**; run it on
|
|
66
|
+
your Mac to get the first numbers (`docs/TEST_PLAN.md` lists exactly what was and was not tested).
|
|
67
|
+
|
|
68
|
+
## Documentation
|
|
69
|
+
|
|
70
|
+
| Document | Purpose |
|
|
71
|
+
| --- | --- |
|
|
72
|
+
| [BUILD_FROM_SCRATCH.md](BUILD_FROM_SCRATCH.md) | Day-by-day guide to build this project yourself, with all code |
|
|
73
|
+
| [docs/PRD.md](docs/PRD.md) | Why it exists, who it is for, requirements |
|
|
74
|
+
| [docs/TECH_SPEC.md](docs/TECH_SPEC.md) | Exact behavior: tools, scoring, config, storage |
|
|
75
|
+
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Diagrams: context, components, data flow, sequence, states, storage |
|
|
76
|
+
| [docs/TEST_PLAN.md](docs/TEST_PLAN.md) | What is tested and how requirements map to tests |
|
|
77
|
+
| [docs/SECURITY.md](docs/SECURITY.md) | What protects you and what does not |
|
|
78
|
+
| [docs/DECISIONS.md](docs/DECISIONS.md) | Why it is built this way |
|
|
79
|
+
| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Install, run, read the report, troubleshoot |
|
|
80
|
+
| [docs/CLI_REFERENCE.md](docs/CLI_REFERENCE.md) | Every command, option and config key |
|
|
81
|
+
| [docs/MODEL_GUIDE_M4_16GB.md](docs/MODEL_GUIDE_M4_16GB.md) | Memory budget and model choices for 16 GB |
|
|
82
|
+
| [docs/TASK_CATALOG.md](docs/TASK_CATALOG.md) | All 20 tasks (generated) |
|
|
83
|
+
| [docs/ROADMAP.md](docs/ROADMAP.md), [CHANGELOG.md](CHANGELOG.md), [CONTRIBUTING.md](CONTRIBUTING.md) | Project housekeeping |
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "toolcallbench"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Benchmark how reliably local LLMs call tools on your own repo and hardware"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Herin Patel", email = "herinpatel15113@gmail.com" }]
|
|
14
|
+
keywords = ["llm", "benchmark", "tool-calling", "ollama", "function-calling"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Software Development :: Testing",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"pyyaml>=6",
|
|
24
|
+
"jsonschema>=4",
|
|
25
|
+
"pytest>=7", # the benchmark's fixture repository is checked by running its tests
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["pytest>=7", "build", "twine"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/herinpatel15/ToolCallBench"
|
|
33
|
+
Issues = "https://github.com/herinpatel15/ToolCallBench/issues"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
toolcallbench = "toolcallbench.cli:main"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["src"]
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.dynamic]
|
|
42
|
+
version = { attr = "toolcallbench.__version__" }
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.package-data]
|
|
45
|
+
toolcallbench = ["data/**/*"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
norecursedirs = ["fixtures", "data", ".git", "results"]
|
|
50
|
+
addopts = "-q"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Model backends and a factory."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..models import Task
|
|
7
|
+
from .base import Backend, BackendError
|
|
8
|
+
from .ollama import OllamaBackend
|
|
9
|
+
from .openai_compat import OpenAICompatBackend
|
|
10
|
+
from .scripted import PROFILES, ScriptedBackend
|
|
11
|
+
|
|
12
|
+
BACKEND_KINDS = ("ollama", "openai-compat", "scripted")
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"BACKEND_KINDS",
|
|
16
|
+
"PROFILES",
|
|
17
|
+
"Backend",
|
|
18
|
+
"BackendError",
|
|
19
|
+
"OllamaBackend",
|
|
20
|
+
"OpenAICompatBackend",
|
|
21
|
+
"ScriptedBackend",
|
|
22
|
+
"make_backend",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def make_backend(
|
|
27
|
+
kind: str,
|
|
28
|
+
*,
|
|
29
|
+
model: str,
|
|
30
|
+
host: str,
|
|
31
|
+
options: dict[str, Any],
|
|
32
|
+
think: bool | None,
|
|
33
|
+
timeout: float,
|
|
34
|
+
tasks: list[Task],
|
|
35
|
+
profile: str = "oracle",
|
|
36
|
+
) -> Backend:
|
|
37
|
+
if kind == "ollama":
|
|
38
|
+
return OllamaBackend(model, host=host, options=options, think=think, timeout=timeout)
|
|
39
|
+
if kind == "openai-compat":
|
|
40
|
+
return OpenAICompatBackend(model, base_url=host, options=options, timeout=timeout)
|
|
41
|
+
if kind == "scripted":
|
|
42
|
+
return ScriptedBackend(tasks, profile=profile)
|
|
43
|
+
raise ValueError(f"unknown backend '{kind}'; choose from {', '.join(BACKEND_KINDS)}")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Backend interface and a small JSON-over-HTTP helper (standard library only)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..models import BackendResponse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BackendError(RuntimeError):
|
|
13
|
+
"""The model server could not be reached or returned an error. Not a model failure."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Backend:
|
|
17
|
+
name = "backend"
|
|
18
|
+
|
|
19
|
+
def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> BackendResponse:
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
def check(self) -> None:
|
|
23
|
+
"""Raise BackendError if the server or the model is not usable. Called once before a sweep."""
|
|
24
|
+
|
|
25
|
+
def describe(self) -> dict[str, Any]:
|
|
26
|
+
"""Facts about the server and model, recorded in the session settings."""
|
|
27
|
+
return {"backend": self.name}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def request_json(method: str, url: str, payload: dict[str, Any] | None, timeout: float) -> Any:
|
|
31
|
+
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
32
|
+
request = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"})
|
|
33
|
+
try:
|
|
34
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
35
|
+
return json.loads(response.read().decode("utf-8"))
|
|
36
|
+
except urllib.error.HTTPError as exc:
|
|
37
|
+
body = exc.read().decode("utf-8", "replace")[:500]
|
|
38
|
+
raise BackendError(f"HTTP {exc.code} from {url}: {body}") from exc
|
|
39
|
+
except json.JSONDecodeError as exc:
|
|
40
|
+
raise BackendError(f"{url} returned invalid JSON") from exc
|
|
41
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
42
|
+
raise BackendError(f"cannot reach {url}: {exc}") from exc
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Ollama backend: POST /api/chat with tools, non-streaming."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..models import BackendResponse, RawToolCall
|
|
7
|
+
from .base import Backend, BackendError, request_json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OllamaBackend(Backend):
|
|
11
|
+
name = "ollama"
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
model: str,
|
|
16
|
+
host: str = "http://localhost:11434",
|
|
17
|
+
options: dict[str, Any] | None = None,
|
|
18
|
+
think: bool | None = None,
|
|
19
|
+
timeout: float = 300.0,
|
|
20
|
+
keep_alive: str = "30m",
|
|
21
|
+
):
|
|
22
|
+
self.model = model
|
|
23
|
+
self.host = host.rstrip("/")
|
|
24
|
+
self.options = dict(options or {})
|
|
25
|
+
self.think = think
|
|
26
|
+
self.timeout = timeout
|
|
27
|
+
self.keep_alive = keep_alive
|
|
28
|
+
|
|
29
|
+
# ------------------------------------------------------------ server facts
|
|
30
|
+
|
|
31
|
+
def version(self) -> str:
|
|
32
|
+
return str(request_json("GET", f"{self.host}/api/version", None, self.timeout).get("version", "unknown"))
|
|
33
|
+
|
|
34
|
+
def list_models(self) -> list[str]:
|
|
35
|
+
data = request_json("GET", f"{self.host}/api/tags", None, self.timeout)
|
|
36
|
+
return [m.get("name", "") for m in data.get("models", [])]
|
|
37
|
+
|
|
38
|
+
def show(self) -> dict[str, Any]:
|
|
39
|
+
return request_json("POST", f"{self.host}/api/show", {"model": self.model}, self.timeout)
|
|
40
|
+
|
|
41
|
+
def check(self) -> None:
|
|
42
|
+
installed = self.list_models()
|
|
43
|
+
wanted = {self.model, self.model if ":" in self.model else self.model + ":latest"}
|
|
44
|
+
if not wanted & set(installed):
|
|
45
|
+
raise BackendError(f"model '{self.model}' is not installed in Ollama; run: ollama pull {self.model}")
|
|
46
|
+
|
|
47
|
+
def describe(self) -> dict[str, Any]:
|
|
48
|
+
info: dict[str, Any] = {"backend": self.name, "host": self.host}
|
|
49
|
+
try:
|
|
50
|
+
info["backend_version"] = self.version()
|
|
51
|
+
shown = self.show()
|
|
52
|
+
details = shown.get("details", {})
|
|
53
|
+
info["quant"] = details.get("quantization_level", "")
|
|
54
|
+
info["parameter_size"] = details.get("parameter_size", "")
|
|
55
|
+
info["family"] = details.get("family", "")
|
|
56
|
+
info["capabilities"] = shown.get("capabilities", [])
|
|
57
|
+
except BackendError:
|
|
58
|
+
pass
|
|
59
|
+
return info
|
|
60
|
+
|
|
61
|
+
# -------------------------------------------------------------------- chat
|
|
62
|
+
|
|
63
|
+
def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> BackendResponse:
|
|
64
|
+
payload: dict[str, Any] = {
|
|
65
|
+
"model": self.model,
|
|
66
|
+
"messages": messages,
|
|
67
|
+
"tools": tools,
|
|
68
|
+
"stream": False,
|
|
69
|
+
"options": self.options,
|
|
70
|
+
"keep_alive": self.keep_alive,
|
|
71
|
+
}
|
|
72
|
+
if self.think is not None:
|
|
73
|
+
payload["think"] = self.think
|
|
74
|
+
data = request_json("POST", f"{self.host}/api/chat", payload, self.timeout)
|
|
75
|
+
message = data.get("message") or {}
|
|
76
|
+
calls = []
|
|
77
|
+
for item in message.get("tool_calls") or []:
|
|
78
|
+
function = item.get("function") or {}
|
|
79
|
+
calls.append(RawToolCall(name=str(function.get("name", "")), arguments=function.get("arguments", {})))
|
|
80
|
+
return BackendResponse(
|
|
81
|
+
content=message.get("content") or "",
|
|
82
|
+
tool_calls=calls,
|
|
83
|
+
eval_tokens=int(data.get("eval_count") or 0),
|
|
84
|
+
eval_seconds=float(data.get("eval_duration") or 0) / 1e9,
|
|
85
|
+
prompt_tokens=int(data.get("prompt_eval_count") or 0),
|
|
86
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""OpenAI-compatible backend for llama.cpp `llama-server`, LM Studio, vLLM and similar servers.
|
|
2
|
+
|
|
3
|
+
Tool arguments arrive as JSON strings here, so this backend exercises the invalid-JSON path.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..models import BackendResponse, RawToolCall
|
|
12
|
+
from .base import Backend, BackendError, request_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_openai_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
16
|
+
"""Convert the runner's Ollama-style messages to the OpenAI chat format (adds tool call ids)."""
|
|
17
|
+
out: list[dict[str, Any]] = []
|
|
18
|
+
pending: list[str] = []
|
|
19
|
+
counter = 0
|
|
20
|
+
for message in messages:
|
|
21
|
+
if message["role"] == "assistant" and message.get("tool_calls"):
|
|
22
|
+
pending = []
|
|
23
|
+
calls = []
|
|
24
|
+
for item in message["tool_calls"]:
|
|
25
|
+
counter += 1
|
|
26
|
+
call_id = f"call_{counter}"
|
|
27
|
+
pending.append(call_id)
|
|
28
|
+
function = item["function"]
|
|
29
|
+
calls.append(
|
|
30
|
+
{
|
|
31
|
+
"id": call_id,
|
|
32
|
+
"type": "function",
|
|
33
|
+
"function": {"name": function["name"], "arguments": json.dumps(function.get("arguments", {}))},
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
out.append({"role": "assistant", "content": message.get("content") or "", "tool_calls": calls})
|
|
37
|
+
elif message["role"] == "tool":
|
|
38
|
+
call_id = pending.pop(0) if pending else "call_0"
|
|
39
|
+
out.append({"role": "tool", "tool_call_id": call_id, "content": message["content"]})
|
|
40
|
+
else:
|
|
41
|
+
out.append({"role": message["role"], "content": message.get("content") or ""})
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OpenAICompatBackend(Backend):
|
|
46
|
+
name = "openai-compat"
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
model: str,
|
|
51
|
+
base_url: str = "http://localhost:8080",
|
|
52
|
+
options: dict[str, Any] | None = None,
|
|
53
|
+
timeout: float = 300.0,
|
|
54
|
+
):
|
|
55
|
+
self.model = model
|
|
56
|
+
self.base_url = base_url.rstrip("/")
|
|
57
|
+
self.options = dict(options or {})
|
|
58
|
+
self.timeout = timeout
|
|
59
|
+
|
|
60
|
+
def check(self) -> None:
|
|
61
|
+
request_json("GET", f"{self.base_url}/v1/models", None, self.timeout)
|
|
62
|
+
|
|
63
|
+
def describe(self) -> dict[str, Any]:
|
|
64
|
+
return {"backend": self.name, "host": self.base_url}
|
|
65
|
+
|
|
66
|
+
def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> BackendResponse:
|
|
67
|
+
payload: dict[str, Any] = {
|
|
68
|
+
"model": self.model,
|
|
69
|
+
"messages": to_openai_messages(messages),
|
|
70
|
+
"tools": tools,
|
|
71
|
+
"stream": False,
|
|
72
|
+
}
|
|
73
|
+
for key in ("temperature", "seed"):
|
|
74
|
+
if key in self.options:
|
|
75
|
+
payload[key] = self.options[key]
|
|
76
|
+
started = time.monotonic()
|
|
77
|
+
data = request_json("POST", f"{self.base_url}/v1/chat/completions", payload, self.timeout)
|
|
78
|
+
elapsed = time.monotonic() - started
|
|
79
|
+
try:
|
|
80
|
+
message = data["choices"][0]["message"]
|
|
81
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
82
|
+
raise BackendError("response has no choices[0].message") from exc
|
|
83
|
+
calls = []
|
|
84
|
+
for item in message.get("tool_calls") or []:
|
|
85
|
+
function = item.get("function") or {}
|
|
86
|
+
calls.append(RawToolCall(name=str(function.get("name", "")), arguments=function.get("arguments", "")))
|
|
87
|
+
usage = data.get("usage") or {}
|
|
88
|
+
timings = data.get("timings") or {}
|
|
89
|
+
eval_seconds = float(timings["predicted_ms"]) / 1000 if "predicted_ms" in timings else elapsed
|
|
90
|
+
return BackendResponse(
|
|
91
|
+
content=message.get("content") or "",
|
|
92
|
+
tool_calls=calls,
|
|
93
|
+
eval_tokens=int(usage.get("completion_tokens") or 0),
|
|
94
|
+
eval_seconds=eval_seconds,
|
|
95
|
+
prompt_tokens=int(usage.get("prompt_tokens") or 0),
|
|
96
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""A fake model that replays each task's reference solution, or fails in a chosen way.
|
|
2
|
+
|
|
3
|
+
It is used to validate the tasks, to test the harness without a real model, and for dry runs.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..models import BackendResponse, RawToolCall, Task
|
|
11
|
+
from .base import Backend, BackendError
|
|
12
|
+
|
|
13
|
+
PROFILES = (
|
|
14
|
+
"oracle",
|
|
15
|
+
"invalid_json",
|
|
16
|
+
"unknown_tool",
|
|
17
|
+
"wrong_tool",
|
|
18
|
+
"text_call",
|
|
19
|
+
"no_call",
|
|
20
|
+
"call_limit",
|
|
21
|
+
"bad_edit",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ScriptedBackend(Backend):
|
|
26
|
+
name = "scripted"
|
|
27
|
+
|
|
28
|
+
def __init__(self, tasks: list[Task], profile: str = "oracle"):
|
|
29
|
+
if profile not in PROFILES:
|
|
30
|
+
raise ValueError(f"unknown profile '{profile}'; choose from {', '.join(PROFILES)}")
|
|
31
|
+
self.by_prompt = {task.prompt: task for task in tasks}
|
|
32
|
+
self.profile = profile
|
|
33
|
+
|
|
34
|
+
def describe(self) -> dict[str, Any]:
|
|
35
|
+
return {"backend": self.name, "profile": self.profile}
|
|
36
|
+
|
|
37
|
+
def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> BackendResponse:
|
|
38
|
+
prompt = next((m["content"] for m in messages if m["role"] == "user"), "")
|
|
39
|
+
task = self.by_prompt.get(prompt)
|
|
40
|
+
if task is None:
|
|
41
|
+
raise BackendError("scripted backend received a prompt that matches no task")
|
|
42
|
+
turn = sum(1 for m in messages if m["role"] == "assistant")
|
|
43
|
+
content, calls = self._step(task, turn)
|
|
44
|
+
return BackendResponse(content=content, tool_calls=calls, eval_tokens=8, eval_seconds=0.01, prompt_tokens=100)
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------ steps
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _oracle(task: Task, turn: int) -> tuple[str, list[RawToolCall]]:
|
|
50
|
+
if turn >= len(task.reference):
|
|
51
|
+
return "Done.", []
|
|
52
|
+
step = task.reference[turn]
|
|
53
|
+
if "calls" in step:
|
|
54
|
+
return "", [RawToolCall(c["tool"], dict(c.get("args", {}))) for c in step["calls"]]
|
|
55
|
+
return str(step["final"]), []
|
|
56
|
+
|
|
57
|
+
def _step(self, task: Task, turn: int) -> tuple[str, list[RawToolCall]]:
|
|
58
|
+
profile = self.profile
|
|
59
|
+
if profile == "oracle":
|
|
60
|
+
return self._oracle(task, turn)
|
|
61
|
+
if profile == "call_limit":
|
|
62
|
+
return "", [RawToolCall("list_dir", {"path": "."})]
|
|
63
|
+
if profile == "no_call":
|
|
64
|
+
return "I believe the answer is 42.", []
|
|
65
|
+
if profile == "bad_edit":
|
|
66
|
+
content, calls = self._oracle(task, turn)
|
|
67
|
+
for call in calls:
|
|
68
|
+
if call.name == "edit_file":
|
|
69
|
+
call.arguments = {**call.arguments, "old_text": "THIS TEXT IS NOT IN THE FILE"}
|
|
70
|
+
elif call.name == "apply_patch":
|
|
71
|
+
call.arguments = {"patch": "--- a/missing.py\n+++ b/missing.py\n@@ -1,1 +1,1 @@\n-x\n+y\n"}
|
|
72
|
+
return content, calls
|
|
73
|
+
if turn >= 1:
|
|
74
|
+
return "Done.", []
|
|
75
|
+
first = next((c for step in task.reference for c in step.get("calls", [])), None)
|
|
76
|
+
if first is None:
|
|
77
|
+
# The task needs no tools. A wrong_tool model calls one anyway; the others answer correctly.
|
|
78
|
+
if profile == "wrong_tool":
|
|
79
|
+
return "", [RawToolCall("list_dir", {})]
|
|
80
|
+
return self._oracle(task, turn)
|
|
81
|
+
tool, args = first["tool"], dict(first.get("args", {}))
|
|
82
|
+
if profile == "invalid_json":
|
|
83
|
+
return "", [RawToolCall(tool, '{"path": ')]
|
|
84
|
+
if profile == "unknown_tool":
|
|
85
|
+
return "", [RawToolCall("search_everything", {"query": "x"})]
|
|
86
|
+
if profile == "wrong_tool":
|
|
87
|
+
return "", [RawToolCall("list_dir", {})]
|
|
88
|
+
if profile == "text_call":
|
|
89
|
+
return json.dumps({"name": tool, "arguments": args}), []
|
|
90
|
+
raise AssertionError(profile) # pragma: no cover
|