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.
Files changed (78) hide show
  1. toolcallbench-1.0.0/LICENSE +21 -0
  2. toolcallbench-1.0.0/PKG-INFO +113 -0
  3. toolcallbench-1.0.0/README.md +87 -0
  4. toolcallbench-1.0.0/pyproject.toml +50 -0
  5. toolcallbench-1.0.0/setup.cfg +4 -0
  6. toolcallbench-1.0.0/src/toolcallbench/__init__.py +3 -0
  7. toolcallbench-1.0.0/src/toolcallbench/__main__.py +6 -0
  8. toolcallbench-1.0.0/src/toolcallbench/backends/__init__.py +43 -0
  9. toolcallbench-1.0.0/src/toolcallbench/backends/base.py +42 -0
  10. toolcallbench-1.0.0/src/toolcallbench/backends/ollama.py +86 -0
  11. toolcallbench-1.0.0/src/toolcallbench/backends/openai_compat.py +96 -0
  12. toolcallbench-1.0.0/src/toolcallbench/backends/scripted.py +90 -0
  13. toolcallbench-1.0.0/src/toolcallbench/checks.py +225 -0
  14. toolcallbench-1.0.0/src/toolcallbench/cli.py +436 -0
  15. toolcallbench-1.0.0/src/toolcallbench/config.py +99 -0
  16. toolcallbench-1.0.0/src/toolcallbench/data/configs/m4-16gb.yaml +22 -0
  17. toolcallbench-1.0.0/src/toolcallbench/data/configs/my-models.yaml +19 -0
  18. toolcallbench-1.0.0/src/toolcallbench/data/configs/quant-sweep-qwen3-8b.yaml +17 -0
  19. toolcallbench-1.0.0/src/toolcallbench/data/configs/smoke-scripted.yaml +6 -0
  20. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/README.md +17 -0
  21. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/config/settings.json +7 -0
  22. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/data/books.csv +7 -0
  23. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/data/members.csv +4 -0
  24. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/pytest.ini +3 -0
  25. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/scripts/report.py +23 -0
  26. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/src/shelf/__init__.py +3 -0
  27. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/src/shelf/catalog.py +33 -0
  28. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/src/shelf/fees.py +20 -0
  29. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/src/shelf/loans.py +46 -0
  30. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/src/shelf/models.py +30 -0
  31. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/src/shelf/settings.py +12 -0
  32. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/src/shelf/utils.py +19 -0
  33. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/tests/test_catalog.py +19 -0
  34. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/tests/test_fees.py +16 -0
  35. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/tests/test_loans.py +32 -0
  36. toolcallbench-1.0.0/src/toolcallbench/data/fixtures/repo/tests/test_utils.py +14 -0
  37. toolcallbench-1.0.0/src/toolcallbench/data/tasks/chain-01.yaml +27 -0
  38. toolcallbench-1.0.0/src/toolcallbench/data/tasks/chain-02.yaml +20 -0
  39. toolcallbench-1.0.0/src/toolcallbench/data/tasks/chain-03.yaml +19 -0
  40. toolcallbench-1.0.0/src/toolcallbench/data/tasks/chain-04.yaml +38 -0
  41. toolcallbench-1.0.0/src/toolcallbench/data/tasks/edit-01.yaml +23 -0
  42. toolcallbench-1.0.0/src/toolcallbench/data/tasks/edit-02.yaml +19 -0
  43. toolcallbench-1.0.0/src/toolcallbench/data/tasks/edit-03.yaml +32 -0
  44. toolcallbench-1.0.0/src/toolcallbench/data/tasks/edit-04.yaml +23 -0
  45. toolcallbench-1.0.0/src/toolcallbench/data/tasks/edit-05.yaml +23 -0
  46. toolcallbench-1.0.0/src/toolcallbench/data/tasks/failure-01.yaml +10 -0
  47. toolcallbench-1.0.0/src/toolcallbench/data/tasks/failure-02.yaml +20 -0
  48. toolcallbench-1.0.0/src/toolcallbench/data/tasks/failure-03.yaml +20 -0
  49. toolcallbench-1.0.0/src/toolcallbench/data/tasks/read-01.yaml +15 -0
  50. toolcallbench-1.0.0/src/toolcallbench/data/tasks/read-02.yaml +15 -0
  51. toolcallbench-1.0.0/src/toolcallbench/data/tasks/read-03.yaml +16 -0
  52. toolcallbench-1.0.0/src/toolcallbench/data/tasks/read-04.yaml +15 -0
  53. toolcallbench-1.0.0/src/toolcallbench/data/tasks/read-05.yaml +16 -0
  54. toolcallbench-1.0.0/src/toolcallbench/data/tasks/run-01.yaml +15 -0
  55. toolcallbench-1.0.0/src/toolcallbench/data/tasks/run-02.yaml +15 -0
  56. toolcallbench-1.0.0/src/toolcallbench/data/tasks/run-03.yaml +15 -0
  57. toolcallbench-1.0.0/src/toolcallbench/models.py +79 -0
  58. toolcallbench-1.0.0/src/toolcallbench/patching.py +130 -0
  59. toolcallbench-1.0.0/src/toolcallbench/report.py +197 -0
  60. toolcallbench-1.0.0/src/toolcallbench/runner.py +90 -0
  61. toolcallbench-1.0.0/src/toolcallbench/store.py +225 -0
  62. toolcallbench-1.0.0/src/toolcallbench/tasks.py +155 -0
  63. toolcallbench-1.0.0/src/toolcallbench/tools.py +306 -0
  64. toolcallbench-1.0.0/src/toolcallbench.egg-info/PKG-INFO +113 -0
  65. toolcallbench-1.0.0/src/toolcallbench.egg-info/SOURCES.txt +76 -0
  66. toolcallbench-1.0.0/src/toolcallbench.egg-info/dependency_links.txt +1 -0
  67. toolcallbench-1.0.0/src/toolcallbench.egg-info/entry_points.txt +2 -0
  68. toolcallbench-1.0.0/src/toolcallbench.egg-info/requires.txt +8 -0
  69. toolcallbench-1.0.0/src/toolcallbench.egg-info/top_level.txt +1 -0
  70. toolcallbench-1.0.0/tests/test_checks.py +99 -0
  71. toolcallbench-1.0.0/tests/test_cli.py +140 -0
  72. toolcallbench-1.0.0/tests/test_config.py +42 -0
  73. toolcallbench-1.0.0/tests/test_http_backends.py +68 -0
  74. toolcallbench-1.0.0/tests/test_patching.py +41 -0
  75. toolcallbench-1.0.0/tests/test_scripts.py +32 -0
  76. toolcallbench-1.0.0/tests/test_store_report.py +48 -0
  77. toolcallbench-1.0.0/tests/test_tasks.py +77 -0
  78. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """ToolCallBench: measure how reliably local LLMs call tools on your own repo and hardware."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -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