caniollama 0.1.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.
- caniollama-0.1.0/.github/workflows/ci.yml +77 -0
- caniollama-0.1.0/.github/workflows/release.yml +42 -0
- caniollama-0.1.0/.gitignore +60 -0
- caniollama-0.1.0/CHANGELOG.md +7 -0
- caniollama-0.1.0/LICENSE +21 -0
- caniollama-0.1.0/PKG-INFO +75 -0
- caniollama-0.1.0/README.md +58 -0
- caniollama-0.1.0/docs/privacy.md +27 -0
- caniollama-0.1.0/docs/test-suite-spec.md +50 -0
- caniollama-0.1.0/pyproject.toml +39 -0
- caniollama-0.1.0/src/compat_check/__init__.py +3 -0
- caniollama-0.1.0/src/compat_check/__main__.py +4 -0
- caniollama-0.1.0/src/compat_check/cli.py +98 -0
- caniollama-0.1.0/src/compat_check/config.py +19 -0
- caniollama-0.1.0/src/compat_check/discovery.py +37 -0
- caniollama-0.1.0/src/compat_check/models.py +68 -0
- caniollama-0.1.0/src/compat_check/ollama_client.py +69 -0
- caniollama-0.1.0/src/compat_check/report.py +44 -0
- caniollama-0.1.0/src/compat_check/submission.py +49 -0
- caniollama-0.1.0/src/compat_check/suite/__init__.py +0 -0
- caniollama-0.1.0/src/compat_check/suite/cases.py +87 -0
- caniollama-0.1.0/src/compat_check/suite/runner.py +57 -0
- caniollama-0.1.0/src/compat_check/suite/scoring.py +58 -0
- caniollama-0.1.0/src/compat_check/suite/version.py +8 -0
- caniollama-0.1.0/tests/conftest.py +24 -0
- caniollama-0.1.0/tests/test_cli.py +16 -0
- caniollama-0.1.0/tests/test_scoring.py +55 -0
- caniollama-0.1.0/tests/test_submission.py +67 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- name: Install uv
|
|
18
|
+
uses: astral-sh/setup-uv@v3
|
|
19
|
+
with:
|
|
20
|
+
version: "latest"
|
|
21
|
+
|
|
22
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
23
|
+
run: uv python install ${{ matrix.python-version }}
|
|
24
|
+
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: uv sync --all-extras --dev
|
|
27
|
+
|
|
28
|
+
- name: Lint
|
|
29
|
+
run: uv run ruff check .
|
|
30
|
+
|
|
31
|
+
- name: Type check
|
|
32
|
+
run: uv run mypy src
|
|
33
|
+
|
|
34
|
+
- name: Test
|
|
35
|
+
run: uv run pytest -v
|
|
36
|
+
|
|
37
|
+
suite-version-check:
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/checkout@v4
|
|
41
|
+
with:
|
|
42
|
+
fetch-depth: 0
|
|
43
|
+
|
|
44
|
+
- name: Check SUITE_VERSION bump if cases.py changed
|
|
45
|
+
run: |
|
|
46
|
+
# Skip on main branch (no base to compare against)
|
|
47
|
+
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
|
48
|
+
echo "Running on main branch, skipping check"
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
# For PRs, compare against the base branch
|
|
53
|
+
BASE_REF="${{ github.base_ref || 'main' }}"
|
|
54
|
+
git fetch origin "$BASE_REF"
|
|
55
|
+
|
|
56
|
+
# Check if cases.py was modified
|
|
57
|
+
if git diff --name-only "origin/$BASE_REF"...HEAD | grep -q "src/compat_check/suite/cases.py"; then
|
|
58
|
+
echo "cases.py was modified, checking if SUITE_VERSION was bumped..."
|
|
59
|
+
|
|
60
|
+
# Check if version.py was also modified
|
|
61
|
+
if ! git diff --name-only "origin/$BASE_REF"...HEAD | grep -q "src/compat_check/suite/version.py"; then
|
|
62
|
+
echo "ERROR: cases.py was modified but version.py was not updated"
|
|
63
|
+
echo "Please bump SUITE_VERSION in src/compat_check/suite/version.py"
|
|
64
|
+
exit 1
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# Check if SUITE_VERSION value actually changed
|
|
68
|
+
if ! git diff "origin/$BASE_REF"...HEAD src/compat_check/suite/version.py | grep -q "^[+-]SUITE_VERSION"; then
|
|
69
|
+
echo "ERROR: version.py was modified but SUITE_VERSION value was not changed"
|
|
70
|
+
echo "Please bump SUITE_VERSION in src/compat_check/suite/version.py"
|
|
71
|
+
exit 1
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
echo "✓ SUITE_VERSION was properly bumped"
|
|
75
|
+
else
|
|
76
|
+
echo "cases.py was not modified, no version bump required"
|
|
77
|
+
fi
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*.*.*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
|
|
14
|
+
- name: Install uv
|
|
15
|
+
uses: astral-sh/setup-uv@v3
|
|
16
|
+
with:
|
|
17
|
+
version: "latest"
|
|
18
|
+
|
|
19
|
+
- name: Build package
|
|
20
|
+
run: uv build
|
|
21
|
+
|
|
22
|
+
- name: Upload build artifacts
|
|
23
|
+
uses: actions/upload-artifact@v4
|
|
24
|
+
with:
|
|
25
|
+
name: dist
|
|
26
|
+
path: dist/
|
|
27
|
+
|
|
28
|
+
publish:
|
|
29
|
+
needs: build
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
environment: pypi
|
|
32
|
+
permissions:
|
|
33
|
+
id-token: write # required for PyPI trusted publishing (OIDC), no stored API token
|
|
34
|
+
steps:
|
|
35
|
+
- name: Download build artifacts
|
|
36
|
+
uses: actions/download-artifact@v4
|
|
37
|
+
with:
|
|
38
|
+
name: dist
|
|
39
|
+
path: dist/
|
|
40
|
+
|
|
41
|
+
- name: Publish to PyPI
|
|
42
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
lib/
|
|
14
|
+
lib64/
|
|
15
|
+
parts/
|
|
16
|
+
sdist/
|
|
17
|
+
var/
|
|
18
|
+
wheels/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.installed.cfg
|
|
21
|
+
*.egg
|
|
22
|
+
|
|
23
|
+
# Virtual environments
|
|
24
|
+
.venv/
|
|
25
|
+
venv/
|
|
26
|
+
ENV/
|
|
27
|
+
env/
|
|
28
|
+
|
|
29
|
+
# uv
|
|
30
|
+
uv.lock
|
|
31
|
+
|
|
32
|
+
# Testing
|
|
33
|
+
.pytest_cache/
|
|
34
|
+
.coverage
|
|
35
|
+
htmlcov/
|
|
36
|
+
.tox/
|
|
37
|
+
|
|
38
|
+
# Linting
|
|
39
|
+
.ruff_cache/
|
|
40
|
+
.mypy_cache/
|
|
41
|
+
|
|
42
|
+
# IDEs
|
|
43
|
+
.vscode/
|
|
44
|
+
.idea/
|
|
45
|
+
*.swp
|
|
46
|
+
*.swo
|
|
47
|
+
*~
|
|
48
|
+
|
|
49
|
+
# OS
|
|
50
|
+
.DS_Store
|
|
51
|
+
Thumbs.db
|
|
52
|
+
|
|
53
|
+
# Data files (except templates)
|
|
54
|
+
compat-registry/data/results/*/
|
|
55
|
+
compat-registry/data/*.db
|
|
56
|
+
compat-registry/data/*.db-journal
|
|
57
|
+
|
|
58
|
+
# Build artifacts
|
|
59
|
+
*.whl
|
|
60
|
+
*.tar.gz
|
caniollama-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 caniollama 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,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: caniollama
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Standardized compatibility checker for local Ollama model tool-calling support
|
|
5
|
+
Project-URL: Homepage, https://github.com/AnjalLLL/caniollama
|
|
6
|
+
Project-URL: Repository, https://github.com/AnjalLLL/caniollama
|
|
7
|
+
Project-URL: Issues, https://github.com/AnjalLLL/caniollama/issues
|
|
8
|
+
Author: caniollama contributors
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Requires-Dist: pydantic>=2.6
|
|
14
|
+
Requires-Dist: rich>=13.7
|
|
15
|
+
Requires-Dist: typer>=0.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# caniollama
|
|
19
|
+
|
|
20
|
+
Does this local Ollama model actually support tool calling? Find out in seconds, not by
|
|
21
|
+
reading a six-month-old blog post.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uvx caniollama check qwen2.5-coder:7b
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
or test everything you've pulled:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uvx caniollama check
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## What it does
|
|
34
|
+
|
|
35
|
+
Runs a small, fixed test suite against a locally running Ollama instance and classifies
|
|
36
|
+
the result into one of three real answers:
|
|
37
|
+
|
|
38
|
+
1. **Structured pass** -- the model returns a proper `tool_calls` field.
|
|
39
|
+
2. **JSON-in-content fallback** -- the model knows tool calling exists but emits the
|
|
40
|
+
intent as JSON-shaped text instead of the structured field.
|
|
41
|
+
3. **Rejected by backend** -- Ollama 400s the moment `tools` is included.
|
|
42
|
+
|
|
43
|
+
After a run, you'll be asked -- every time, explicitly -- whether to submit the result
|
|
44
|
+
(metadata only: model name, quantization, Ollama version, pass/fail per case) to the
|
|
45
|
+
public [caniollama registry](https://github.com/caniollama/caniollama-registry). See
|
|
46
|
+
[`docs/privacy.md`](docs/privacy.md) for exactly what is and isn't ever sent.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
No install needed for a one-off check:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uvx caniollama check <model>
|
|
54
|
+
# or
|
|
55
|
+
pipx run caniollama check <model>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
For repeated use:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv tool install caniollama
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv sync
|
|
68
|
+
uv run pytest
|
|
69
|
+
uv run ruff check .
|
|
70
|
+
uv run mypy src
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# caniollama
|
|
2
|
+
|
|
3
|
+
Does this local Ollama model actually support tool calling? Find out in seconds, not by
|
|
4
|
+
reading a six-month-old blog post.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
uvx caniollama check qwen2.5-coder:7b
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
or test everything you've pulled:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
uvx caniollama check
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## What it does
|
|
17
|
+
|
|
18
|
+
Runs a small, fixed test suite against a locally running Ollama instance and classifies
|
|
19
|
+
the result into one of three real answers:
|
|
20
|
+
|
|
21
|
+
1. **Structured pass** -- the model returns a proper `tool_calls` field.
|
|
22
|
+
2. **JSON-in-content fallback** -- the model knows tool calling exists but emits the
|
|
23
|
+
intent as JSON-shaped text instead of the structured field.
|
|
24
|
+
3. **Rejected by backend** -- Ollama 400s the moment `tools` is included.
|
|
25
|
+
|
|
26
|
+
After a run, you'll be asked -- every time, explicitly -- whether to submit the result
|
|
27
|
+
(metadata only: model name, quantization, Ollama version, pass/fail per case) to the
|
|
28
|
+
public [caniollama registry](https://github.com/caniollama/caniollama-registry). See
|
|
29
|
+
[`docs/privacy.md`](docs/privacy.md) for exactly what is and isn't ever sent.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
No install needed for a one-off check:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uvx caniollama check <model>
|
|
37
|
+
# or
|
|
38
|
+
pipx run caniollama check <model>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For repeated use:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv tool install caniollama
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv sync
|
|
51
|
+
uv run pytest
|
|
52
|
+
uv run ruff check .
|
|
53
|
+
uv run mypy src
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# What caniollama sends, and what it never sends
|
|
2
|
+
|
|
3
|
+
## Sent, only if you say yes, every single run
|
|
4
|
+
|
|
5
|
+
- Model name (e.g. `qwen2.5-coder:7b`)
|
|
6
|
+
- Quantization level (e.g. `q4_K_M`), if Ollama reports one
|
|
7
|
+
- Ollama version
|
|
8
|
+
- Test suite version
|
|
9
|
+
- Coarse OS (`linux` / `macos` / `windows`)
|
|
10
|
+
- Pass/fail outcome per test case (one of: structured pass, JSON-in-content
|
|
11
|
+
fallback, rejected by backend, error/timeout)
|
|
12
|
+
- Timestamp
|
|
13
|
+
|
|
14
|
+
## Never sent, under any circumstances
|
|
15
|
+
|
|
16
|
+
- The test prompts themselves
|
|
17
|
+
- Any model output, structured or otherwise
|
|
18
|
+
- Anything from your local filesystem, environment, or network beyond the above
|
|
19
|
+
- Anything if you decline the prompt -- there is no "phone home anyway" path
|
|
20
|
+
|
|
21
|
+
## Why so little
|
|
22
|
+
|
|
23
|
+
This project measures whether the *plumbing* works (does tool calling return
|
|
24
|
+
a structured field, does it fall back to text, does the backend reject the
|
|
25
|
+
request) -- not how good a model's reasoning is. That narrower question needs
|
|
26
|
+
far less data than a quality benchmark would, so we ask for less than we
|
|
27
|
+
technically could.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Test suite spec — v1.0.0
|
|
2
|
+
|
|
3
|
+
This documents what each fixed case in `src/compat_check/suite/cases.py` tests and why.
|
|
4
|
+
Any change to a prompt, a tool schema, or a scoring rule in `scoring.py` requires bumping
|
|
5
|
+
`SUITE_VERSION` in `suite/version.py` — this is what keeps registry results comparable
|
|
6
|
+
over time (see `compat-registry`'s `docs/data-format.md`).
|
|
7
|
+
|
|
8
|
+
## `single_tool_basic`
|
|
9
|
+
One relevant tool offered, one unambiguous prompt ("What is the weather in Tokyo?").
|
|
10
|
+
This is the baseline: does the model return a structured `tool_calls` field at all,
|
|
11
|
+
under the easiest possible conditions. If a model fails this one, it almost certainly
|
|
12
|
+
falls into compatibility category 2 or 3.
|
|
13
|
+
|
|
14
|
+
## `multi_tool_5x`
|
|
15
|
+
Same prompt shape, but five tools offered (one relevant, four irrelevant no-ops). Tests
|
|
16
|
+
whether structured calling holds up once the model has to *select* the right tool from
|
|
17
|
+
a small list, rather than just echoing the only option it was given.
|
|
18
|
+
|
|
19
|
+
## `no_tools_needed_control`
|
|
20
|
+
A prompt with no tool relevance at all ("Say hello in French"), with a weather tool
|
|
21
|
+
still offered. This is a control: a model that calls a tool here anyway isn't
|
|
22
|
+
necessarily marked as failing (over-eager tool use is a reasoning-quality concern, out
|
|
23
|
+
of scope for this project), but it's tracked separately so the registry can eventually
|
|
24
|
+
surface "tends to over-call tools" as its own signal if that becomes useful.
|
|
25
|
+
|
|
26
|
+
## `multiturn_tool_result`
|
|
27
|
+
First turn asks for weather; the suite then injects a synthetic tool result message and
|
|
28
|
+
asks again. Tests whether the model can continue a conversation that already contains a
|
|
29
|
+
completed tool call/result correctly, rather than looping, re-calling the same tool, or
|
|
30
|
+
breaking format. This is the case most likely to expose category 2 behavior in models
|
|
31
|
+
that pass `single_tool_basic` — first-turn tool calling is the easy case; handling the
|
|
32
|
+
result correctly is where a lot of "sort of supports tools" models fall down.
|
|
33
|
+
|
|
34
|
+
## Deliberately out of scope
|
|
35
|
+
|
|
36
|
+
- Tool call **argument correctness** (e.g. did it extract "Tokyo" correctly) — that's
|
|
37
|
+
reasoning quality, not plumbing, and belongs to a benchmark project, not this one.
|
|
38
|
+
- Parallel/simultaneous tool calls — a plausible v1.1.0 addition once the four cases
|
|
39
|
+
above are validated against enough real models.
|
|
40
|
+
- Streaming responses (`stream: true`) — Ollama's tool-calling behavior under streaming
|
|
41
|
+
differs from non-streaming in ways worth a dedicated case, deferred to a future
|
|
42
|
+
suite version.
|
|
43
|
+
|
|
44
|
+
## Adding a new case
|
|
45
|
+
|
|
46
|
+
1. Add the `TestCase` to `cases.py`.
|
|
47
|
+
2. Add scoring logic to `scoring.py` if the existing pass/fail heuristics don't cover it.
|
|
48
|
+
3. Document it here.
|
|
49
|
+
4. Bump `SUITE_VERSION`.
|
|
50
|
+
5. Add a fixture-based test in `tests/test_scoring.py`.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "caniollama"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Standardized compatibility checker for local Ollama model tool-calling support"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [{ name = "caniollama contributors" }]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"httpx>=0.27",
|
|
11
|
+
"pydantic>=2.6",
|
|
12
|
+
"typer>=0.12",
|
|
13
|
+
"rich>=13.7",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
caniollama = "compat_check.cli:app"
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/AnjalLLL/caniollama"
|
|
21
|
+
Repository = "https://github.com/AnjalLLL/caniollama"
|
|
22
|
+
Issues = "https://github.com/AnjalLLL/caniollama/issues"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "ruff>=0.5", "mypy>=1.10", "respx>=0.21"]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/compat_check"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
line-length = 100
|
|
36
|
+
target-version = "py310"
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from compat_check import __version__
|
|
9
|
+
from compat_check.config import settings
|
|
10
|
+
from compat_check.discovery import current_os, discover_models, discover_ollama_version
|
|
11
|
+
from compat_check.models import RunReport
|
|
12
|
+
from compat_check.ollama_client import OllamaClient
|
|
13
|
+
from compat_check.report import render_report
|
|
14
|
+
from compat_check.submission import prompt_for_submission_consent, submit_result
|
|
15
|
+
from compat_check.suite.runner import run_suite
|
|
16
|
+
from compat_check.suite.version import SUITE_VERSION
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="caniollama",
|
|
20
|
+
help="Does this local Ollama model actually support tool calling? Find out in seconds.",
|
|
21
|
+
no_args_is_help=False,
|
|
22
|
+
)
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def version() -> None:
|
|
28
|
+
"""Show the package and test-suite versions."""
|
|
29
|
+
console.print(f"caniollama {__version__} (test suite {SUITE_VERSION})")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command()
|
|
33
|
+
def check(
|
|
34
|
+
model: str = typer.Argument(
|
|
35
|
+
None, help="Model to test, e.g. 'qwen2.5-coder:7b'. Omit to test all pulled models."
|
|
36
|
+
),
|
|
37
|
+
ollama_url: str = typer.Option(settings.ollama_base_url, help="Ollama base URL."),
|
|
38
|
+
submit: bool = typer.Option(
|
|
39
|
+
None,
|
|
40
|
+
"--submit/--no-submit",
|
|
41
|
+
help="Skip the interactive prompt: always/never submit. Default is to ask.",
|
|
42
|
+
),
|
|
43
|
+
registry_url: str = typer.Option(settings.registry_base_url, help="Registry base URL."),
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Run the compatibility test suite against a local Ollama model (or all pulled models)."""
|
|
46
|
+
asyncio.run(_check_async(model, ollama_url, submit, registry_url))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _check_async(
|
|
50
|
+
model: str | None, ollama_url: str, submit_flag: bool | None, registry_url: str
|
|
51
|
+
) -> None:
|
|
52
|
+
client = OllamaClient(base_url=ollama_url)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
ollama_version = await discover_ollama_version(client)
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
console.print(f"[bold red]Could not reach Ollama at {ollama_url}: {exc}[/]")
|
|
58
|
+
raise typer.Exit(code=1) from exc
|
|
59
|
+
|
|
60
|
+
targets: list[tuple[str, str | None]]
|
|
61
|
+
if model:
|
|
62
|
+
targets = [(model, None)]
|
|
63
|
+
else:
|
|
64
|
+
discovered = await discover_models(client)
|
|
65
|
+
if not discovered:
|
|
66
|
+
console.print("[yellow]No locally pulled models found.[/]")
|
|
67
|
+
raise typer.Exit(code=0)
|
|
68
|
+
targets = [(m.name, m.quantization) for m in discovered]
|
|
69
|
+
|
|
70
|
+
for target_name, target_quantization in targets:
|
|
71
|
+
console.print(f"[dim]Running suite against {target_name}...[/]")
|
|
72
|
+
results = await run_suite(client, target_name)
|
|
73
|
+
report = RunReport(
|
|
74
|
+
model_name=target_name,
|
|
75
|
+
model_quantization=target_quantization,
|
|
76
|
+
ollama_version=ollama_version,
|
|
77
|
+
suite_version=SUITE_VERSION,
|
|
78
|
+
os_name=current_os(),
|
|
79
|
+
results=results,
|
|
80
|
+
)
|
|
81
|
+
render_report(report, console)
|
|
82
|
+
|
|
83
|
+
should_submit = submit_flag
|
|
84
|
+
if should_submit is None:
|
|
85
|
+
should_submit = prompt_for_submission_consent(console)
|
|
86
|
+
|
|
87
|
+
if should_submit:
|
|
88
|
+
ok = await submit_result(report, base_url=registry_url)
|
|
89
|
+
if ok:
|
|
90
|
+
console.print("[green]Submitted. Thanks for contributing to the registry![/]\n")
|
|
91
|
+
else:
|
|
92
|
+
console.print("[yellow]Submission failed -- registry may be unreachable.[/]\n")
|
|
93
|
+
else:
|
|
94
|
+
console.print("[dim]Not submitted.[/]\n")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
app()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Runtime configuration, resolved from environment variables with sane defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Settings:
|
|
11
|
+
ollama_base_url: str = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
|
12
|
+
registry_base_url: str = os.environ.get(
|
|
13
|
+
"CANIOLLAMA_REGISTRY_URL", "https://registry.caniollama.dev"
|
|
14
|
+
)
|
|
15
|
+
request_timeout_seconds: float = float(os.environ.get("CANIOLLAMA_TIMEOUT", "30"))
|
|
16
|
+
submit_timeout_seconds: float = float(os.environ.get("CANIOLLAMA_SUBMIT_TIMEOUT", "10"))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
settings = Settings()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Finds locally pulled Ollama models and the current Ollama/OS metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from compat_check.ollama_client import OllamaClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class DiscoveredModel:
|
|
13
|
+
name: str # e.g. "qwen2.5-coder:7b"
|
|
14
|
+
quantization: str | None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _guess_quantization(model_tag: dict) -> str | None:
|
|
18
|
+
details = model_tag.get("details") or {}
|
|
19
|
+
return details.get("quantization_level")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def discover_models(client: OllamaClient) -> list[DiscoveredModel]:
|
|
23
|
+
raw = await client.list_models()
|
|
24
|
+
return [
|
|
25
|
+
DiscoveredModel(name=m["name"], quantization=_guess_quantization(m))
|
|
26
|
+
for m in raw
|
|
27
|
+
if "name" in m
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def discover_ollama_version(client: OllamaClient) -> str:
|
|
32
|
+
return await client.version()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def current_os() -> str:
|
|
36
|
+
system = platform.system().lower()
|
|
37
|
+
return {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(system, system)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Data shapes used across the CLI.
|
|
2
|
+
|
|
3
|
+
`SubmissionPayload` is deliberately the *only* model allowed to reach
|
|
4
|
+
`submission.py`'s network call. Every field is a bounded type (str version
|
|
5
|
+
tag, enum, datetime) -- none of them are capable of carrying a prompt or a
|
|
6
|
+
raw model output. That's a structural guarantee, not just a convention;
|
|
7
|
+
`tests/test_submission.py` asserts it via schema introspection.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CaseOutcome(str, Enum):
|
|
19
|
+
"""The three real-world answers to 'does tool calling work', plus a bucket for errors."""
|
|
20
|
+
|
|
21
|
+
STRUCTURED_PASS = "structured_pass" # category 1: native tool_calls field
|
|
22
|
+
JSON_IN_CONTENT_FALLBACK = "json_fallback" # category 2: intent, but shoved into content
|
|
23
|
+
REJECTED_BY_BACKEND = "rejected" # category 3: backend 400s on `tools`
|
|
24
|
+
TIMEOUT_OR_ERROR = "error"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CaseResult(BaseModel):
|
|
28
|
+
"""Local-only, detailed result for a single test case. Never submitted as-is."""
|
|
29
|
+
|
|
30
|
+
case_id: str
|
|
31
|
+
outcome: CaseOutcome
|
|
32
|
+
latency_ms: int | None = None
|
|
33
|
+
detail: str | None = None # short internal note, e.g. "HTTP 400", never model output
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RunReport(BaseModel):
|
|
37
|
+
"""Full local report for a single model run. Rich detail lives here, on disk / terminal only."""
|
|
38
|
+
|
|
39
|
+
model_name: str
|
|
40
|
+
model_quantization: str | None
|
|
41
|
+
ollama_version: str
|
|
42
|
+
suite_version: str
|
|
43
|
+
os_name: str
|
|
44
|
+
results: list[CaseResult]
|
|
45
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
46
|
+
|
|
47
|
+
def to_submission_payload(self) -> SubmissionPayload:
|
|
48
|
+
return SubmissionPayload(
|
|
49
|
+
model_name=self.model_name,
|
|
50
|
+
model_quantization=self.model_quantization,
|
|
51
|
+
ollama_version=self.ollama_version,
|
|
52
|
+
suite_version=self.suite_version,
|
|
53
|
+
os=self.os_name,
|
|
54
|
+
case_results={r.case_id: r.outcome for r in self.results},
|
|
55
|
+
timestamp=self.timestamp,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SubmissionPayload(BaseModel):
|
|
60
|
+
"""The ONLY thing ever sent to the registry. Metadata only -- never prompts, never outputs."""
|
|
61
|
+
|
|
62
|
+
model_name: str
|
|
63
|
+
model_quantization: str | None = None
|
|
64
|
+
ollama_version: str
|
|
65
|
+
suite_version: str
|
|
66
|
+
os: str
|
|
67
|
+
case_results: dict[str, CaseOutcome]
|
|
68
|
+
timestamp: datetime
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Thin async client around the local Ollama HTTP API. No third-party ollama SDK dependency
|
|
2
|
+
by design -- keeps this package's dependency footprint minimal, per the "installs in seconds"
|
|
3
|
+
requirement."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from compat_check.config import settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OllamaError(Exception):
|
|
15
|
+
"""Raised for connection failures or unexpected non-tool-related errors."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OllamaRejected(Exception):
|
|
19
|
+
"""Raised when Ollama returns a 4xx specifically because `tools` was included
|
|
20
|
+
(compatibility category 3: the backend refuses the request outright)."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, status_code: int, body: str):
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
self.body = body
|
|
25
|
+
super().__init__(f"Ollama rejected request with tools: HTTP {status_code}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OllamaClient:
|
|
29
|
+
def __init__(self, base_url: str | None = None, timeout: float | None = None):
|
|
30
|
+
self.base_url = base_url or settings.ollama_base_url
|
|
31
|
+
self.timeout = timeout or settings.request_timeout_seconds
|
|
32
|
+
|
|
33
|
+
async def list_models(self) -> list[dict[str, Any]]:
|
|
34
|
+
async with httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout) as client:
|
|
35
|
+
resp = await client.get("/api/tags")
|
|
36
|
+
resp.raise_for_status()
|
|
37
|
+
return resp.json().get("models", [])
|
|
38
|
+
|
|
39
|
+
async def version(self) -> str:
|
|
40
|
+
async with httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout) as client:
|
|
41
|
+
resp = await client.get("/api/version")
|
|
42
|
+
resp.raise_for_status()
|
|
43
|
+
return resp.json().get("version", "unknown")
|
|
44
|
+
|
|
45
|
+
async def chat(
|
|
46
|
+
self,
|
|
47
|
+
model: str,
|
|
48
|
+
messages: list[dict[str, Any]],
|
|
49
|
+
tools: list[dict[str, Any]] | None = None,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""POST /api/chat. Raises OllamaRejected if the backend 4xx's specifically because
|
|
52
|
+
`tools` was included -- that's compatibility category 3, a signal we want to catch
|
|
53
|
+
distinctly from a generic connection error."""
|
|
54
|
+
|
|
55
|
+
payload: dict[str, Any] = {"model": model, "messages": messages, "stream": False}
|
|
56
|
+
if tools:
|
|
57
|
+
payload["tools"] = tools
|
|
58
|
+
|
|
59
|
+
async with httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout) as client:
|
|
60
|
+
try:
|
|
61
|
+
resp = await client.post("/api/chat", json=payload)
|
|
62
|
+
resp.raise_for_status()
|
|
63
|
+
return resp.json()
|
|
64
|
+
except httpx.HTTPStatusError as exc:
|
|
65
|
+
if tools and 400 <= exc.response.status_code < 500:
|
|
66
|
+
raise OllamaRejected(exc.response.status_code, exc.response.text) from exc
|
|
67
|
+
raise OllamaError(str(exc)) from exc
|
|
68
|
+
except httpx.RequestError as exc:
|
|
69
|
+
raise OllamaError(str(exc)) from exc
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Builds the local, human-readable summary of a run. This is the ONLY place
|
|
2
|
+
detailed results are shown -- rich detail stays local, never leaves the machine
|
|
3
|
+
except as the reduced SubmissionPayload (see models.py)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from compat_check.models import CaseOutcome, RunReport
|
|
11
|
+
|
|
12
|
+
_OUTCOME_STYLE = {
|
|
13
|
+
CaseOutcome.STRUCTURED_PASS: ("[bold green]structured pass[/]", "✅"),
|
|
14
|
+
CaseOutcome.JSON_IN_CONTENT_FALLBACK: ("[yellow]json fallback[/]", "⚠️"),
|
|
15
|
+
CaseOutcome.REJECTED_BY_BACKEND: ("[bold red]rejected by backend[/]", "⛔"),
|
|
16
|
+
CaseOutcome.TIMEOUT_OR_ERROR: ("[red]error / timeout[/]", "❌"),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def render_report(report: RunReport, console: Console | None = None) -> None:
|
|
21
|
+
console = console or Console()
|
|
22
|
+
|
|
23
|
+
console.print(
|
|
24
|
+
f"\n[bold]{report.model_name}[/] "
|
|
25
|
+
f"(quant: {report.model_quantization or 'unknown'}, "
|
|
26
|
+
f"ollama {report.ollama_version}, suite {report.suite_version})\n"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
table = Table(show_header=True, header_style="bold")
|
|
30
|
+
table.add_column("Case")
|
|
31
|
+
table.add_column("Outcome")
|
|
32
|
+
table.add_column("Latency")
|
|
33
|
+
table.add_column("Detail")
|
|
34
|
+
|
|
35
|
+
for result in report.results:
|
|
36
|
+
label, icon = _OUTCOME_STYLE[result.outcome]
|
|
37
|
+
latency = f"{result.latency_ms} ms" if result.latency_ms is not None else "-"
|
|
38
|
+
table.add_row(result.case_id, f"{icon} {label}", latency, result.detail or "")
|
|
39
|
+
|
|
40
|
+
console.print(table)
|
|
41
|
+
|
|
42
|
+
passed = sum(1 for r in report.results if r.outcome == CaseOutcome.STRUCTURED_PASS)
|
|
43
|
+
total = len(report.results)
|
|
44
|
+
console.print(f"\n[bold]{passed}/{total}[/] cases returned structured tool calls.\n")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Opt-in submission to the public registry.
|
|
2
|
+
|
|
3
|
+
Hard rule: the ONLY thing constructed and sent here is a SubmissionPayload
|
|
4
|
+
(see models.py), which is structurally incapable of holding a prompt or a raw
|
|
5
|
+
model output. Nothing in this module should ever import or touch case
|
|
6
|
+
prompts/messages/content -- if a future change makes that necessary, it is a
|
|
7
|
+
sign the privacy boundary is being violated and should be reconsidered, not
|
|
8
|
+
routed around.
|
|
9
|
+
|
|
10
|
+
The prompt is shown every run, after results are already displayed -- never
|
|
11
|
+
before, never silently skipped, never persisted as a "don't ask again"
|
|
12
|
+
default. See docs/privacy.md for the full data-handling explanation shown to
|
|
13
|
+
users.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
from compat_check.config import settings
|
|
22
|
+
from compat_check.models import RunReport
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def prompt_for_submission_consent(console: Console | None = None) -> bool:
|
|
26
|
+
console = console or Console()
|
|
27
|
+
console.print(
|
|
28
|
+
"\n[bold]Share this result with the public caniollama registry?[/]\n"
|
|
29
|
+
"Only this gets sent, nothing else: model name, quantization, Ollama "
|
|
30
|
+
"version, suite version, OS, and pass/fail per test case.\n"
|
|
31
|
+
"[dim]Prompts and model output are never sent. See docs/privacy.md.[/]\n"
|
|
32
|
+
)
|
|
33
|
+
answer = console.input("Submit? [y/N] ").strip().lower()
|
|
34
|
+
return answer in ("y", "yes")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def submit_result(report: RunReport, base_url: str | None = None) -> bool:
|
|
38
|
+
"""Returns True on a confirmed, successful submission. Never raises on
|
|
39
|
+
network failure -- a failed submission should never crash a local run."""
|
|
40
|
+
|
|
41
|
+
payload = report.to_submission_payload()
|
|
42
|
+
url = f"{(base_url or settings.registry_base_url).rstrip('/')}/submissions"
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
async with httpx.AsyncClient(timeout=settings.submit_timeout_seconds) as client:
|
|
46
|
+
resp = await client.post(url, json=payload.model_dump(mode="json"))
|
|
47
|
+
return resp.status_code in (200, 201)
|
|
48
|
+
except httpx.RequestError:
|
|
49
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Fixed, deterministic test cases. Each targets one dimension of tool-calling
|
|
2
|
+
compatibility. Prompts are intentionally simple and unambiguous -- the goal is
|
|
3
|
+
to test plumbing ('does it call the tool'), not reasoning quality.
|
|
4
|
+
|
|
5
|
+
Do not tweak prompt wording casually: any change here requires bumping
|
|
6
|
+
SUITE_VERSION in version.py.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
_GET_WEATHER_TOOL = {
|
|
15
|
+
"type": "function",
|
|
16
|
+
"function": {
|
|
17
|
+
"name": "get_weather",
|
|
18
|
+
"description": "Get the current weather for a city.",
|
|
19
|
+
"parameters": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"properties": {"city": {"type": "string", "description": "City name"}},
|
|
22
|
+
"required": ["city"],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_EXTRA_TOOLS = [
|
|
28
|
+
{
|
|
29
|
+
"type": "function",
|
|
30
|
+
"function": {
|
|
31
|
+
"name": f"noop_tool_{i}",
|
|
32
|
+
"description": f"Placeholder tool #{i}, not relevant to the prompt.",
|
|
33
|
+
"parameters": {"type": "object", "properties": {}},
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
for i in range(1, 5)
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class TestCase:
|
|
42
|
+
case_id: str
|
|
43
|
+
description: str
|
|
44
|
+
messages: list[dict[str, Any]]
|
|
45
|
+
tools: list[dict[str, Any]]
|
|
46
|
+
# If True, the correct behavior is to NOT call a tool -- used to catch
|
|
47
|
+
# models that over-eagerly call tools even when none are needed.
|
|
48
|
+
expects_no_tool_call: bool = False
|
|
49
|
+
multiturn_followup: dict[str, Any] | None = field(default=None)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
CASES: list[TestCase] = [
|
|
53
|
+
TestCase(
|
|
54
|
+
case_id="single_tool_basic",
|
|
55
|
+
description="One relevant tool, unambiguous prompt -- baseline structured call.",
|
|
56
|
+
messages=[{"role": "user", "content": "What is the weather in Tokyo?"}],
|
|
57
|
+
tools=[_GET_WEATHER_TOOL],
|
|
58
|
+
),
|
|
59
|
+
TestCase(
|
|
60
|
+
case_id="multi_tool_5x",
|
|
61
|
+
description="Five tools offered, only one relevant -- tests selection under noise.",
|
|
62
|
+
messages=[{"role": "user", "content": "What is the weather in Nairobi?"}],
|
|
63
|
+
tools=[_GET_WEATHER_TOOL, *_EXTRA_TOOLS],
|
|
64
|
+
),
|
|
65
|
+
TestCase(
|
|
66
|
+
case_id="no_tools_needed_control",
|
|
67
|
+
description="No tool is relevant -- control case; a good model should NOT call one.",
|
|
68
|
+
messages=[{"role": "user", "content": "Say hello in French."}],
|
|
69
|
+
tools=[_GET_WEATHER_TOOL],
|
|
70
|
+
expects_no_tool_call=True,
|
|
71
|
+
),
|
|
72
|
+
TestCase(
|
|
73
|
+
case_id="multiturn_tool_result",
|
|
74
|
+
description=(
|
|
75
|
+
"Second turn: a tool result is already in the conversation. Tests whether "
|
|
76
|
+
"the model handles a prior tool call/result correctly rather than looping."
|
|
77
|
+
),
|
|
78
|
+
messages=[
|
|
79
|
+
{"role": "user", "content": "What is the weather in Oslo?"},
|
|
80
|
+
],
|
|
81
|
+
tools=[_GET_WEATHER_TOOL],
|
|
82
|
+
multiturn_followup={
|
|
83
|
+
"role": "tool",
|
|
84
|
+
"content": '{"city": "Oslo", "condition": "cloudy", "temp_c": 8}',
|
|
85
|
+
},
|
|
86
|
+
),
|
|
87
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Executes the fixed case list against one model, producing CaseResults.
|
|
2
|
+
|
|
3
|
+
Every case is isolated: one case erroring or timing out never aborts the run
|
|
4
|
+
for the remaining cases.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from compat_check.models import CaseOutcome, CaseResult
|
|
12
|
+
from compat_check.ollama_client import OllamaClient, OllamaError, OllamaRejected
|
|
13
|
+
from compat_check.suite.cases import CASES, TestCase
|
|
14
|
+
from compat_check.suite.scoring import score_response
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def _run_case(client: OllamaClient, model: str, case: TestCase) -> CaseResult:
|
|
18
|
+
messages = list(case.messages)
|
|
19
|
+
started = time.monotonic()
|
|
20
|
+
try:
|
|
21
|
+
response = await client.chat(model=model, messages=messages, tools=case.tools)
|
|
22
|
+
|
|
23
|
+
if case.multiturn_followup:
|
|
24
|
+
# Feed the assistant's reply + a synthetic tool result back in, then
|
|
25
|
+
# score the model's *second* response -- this is what actually tests
|
|
26
|
+
# multi-turn tool-result handling, not just the first call.
|
|
27
|
+
messages.append(response.get("message", {}))
|
|
28
|
+
messages.append(case.multiturn_followup)
|
|
29
|
+
response = await client.chat(model=model, messages=messages, tools=case.tools)
|
|
30
|
+
|
|
31
|
+
outcome = score_response(case, response)
|
|
32
|
+
latency_ms = int((time.monotonic() - started) * 1000)
|
|
33
|
+
return CaseResult(case_id=case.case_id, outcome=outcome, latency_ms=latency_ms)
|
|
34
|
+
|
|
35
|
+
except OllamaRejected as exc:
|
|
36
|
+
latency_ms = int((time.monotonic() - started) * 1000)
|
|
37
|
+
return CaseResult(
|
|
38
|
+
case_id=case.case_id,
|
|
39
|
+
outcome=CaseOutcome.REJECTED_BY_BACKEND,
|
|
40
|
+
latency_ms=latency_ms,
|
|
41
|
+
detail=f"HTTP {exc.status_code}",
|
|
42
|
+
)
|
|
43
|
+
except OllamaError as exc:
|
|
44
|
+
latency_ms = int((time.monotonic() - started) * 1000)
|
|
45
|
+
return CaseResult(
|
|
46
|
+
case_id=case.case_id,
|
|
47
|
+
outcome=CaseOutcome.TIMEOUT_OR_ERROR,
|
|
48
|
+
latency_ms=latency_ms,
|
|
49
|
+
detail=str(exc)[:200],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def run_suite(client: OllamaClient, model: str) -> list[CaseResult]:
|
|
54
|
+
results = []
|
|
55
|
+
for case in CASES:
|
|
56
|
+
results.append(await _run_case(client, model, case))
|
|
57
|
+
return results
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Classifies a raw /api/chat response (or a raised client exception) into a CaseOutcome.
|
|
2
|
+
|
|
3
|
+
This is the piece most likely to need refinement as new models and Ollama
|
|
4
|
+
versions are observed -- any change here is a SUITE_VERSION bump.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from compat_check.models import CaseOutcome
|
|
13
|
+
from compat_check.suite.cases import TestCase
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _content_looks_like_tool_call_json(content: str) -> bool:
|
|
17
|
+
"""Heuristic for category 2: model knows the *shape* of a tool call but emits it
|
|
18
|
+
as JSON-ish text in `content` instead of the structured `tool_calls` field."""
|
|
19
|
+
stripped = content.strip()
|
|
20
|
+
if not stripped.startswith(("{", "[")):
|
|
21
|
+
return False
|
|
22
|
+
try:
|
|
23
|
+
parsed = json.loads(stripped)
|
|
24
|
+
except (json.JSONDecodeError, TypeError):
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
candidates = parsed if isinstance(parsed, list) else [parsed]
|
|
28
|
+
for candidate in candidates:
|
|
29
|
+
if not isinstance(candidate, dict):
|
|
30
|
+
continue
|
|
31
|
+
keys = {k.lower() for k in candidate}
|
|
32
|
+
if {"name", "arguments"} <= keys or {"function", "arguments"} <= keys:
|
|
33
|
+
return True
|
|
34
|
+
if "tool_calls" in keys or "function_call" in keys:
|
|
35
|
+
return True
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def score_response(case: TestCase, response: dict[str, Any]) -> CaseOutcome:
|
|
40
|
+
message = response.get("message", {})
|
|
41
|
+
tool_calls = message.get("tool_calls")
|
|
42
|
+
content = message.get("content", "") or ""
|
|
43
|
+
|
|
44
|
+
if case.expects_no_tool_call:
|
|
45
|
+
# A structured tool call here is arguably "too eager", but plumbing-wise the
|
|
46
|
+
# model still demonstrated structured calling -- so we still score it a pass
|
|
47
|
+
# for *capability*; over-eagerness is a quality concern, out of scope here.
|
|
48
|
+
if tool_calls:
|
|
49
|
+
return CaseOutcome.STRUCTURED_PASS
|
|
50
|
+
if _content_looks_like_tool_call_json(content):
|
|
51
|
+
return CaseOutcome.JSON_IN_CONTENT_FALLBACK
|
|
52
|
+
return CaseOutcome.STRUCTURED_PASS # correctly answered without calling a tool
|
|
53
|
+
|
|
54
|
+
if tool_calls:
|
|
55
|
+
return CaseOutcome.STRUCTURED_PASS
|
|
56
|
+
if _content_looks_like_tool_call_json(content):
|
|
57
|
+
return CaseOutcome.JSON_IN_CONTENT_FALLBACK
|
|
58
|
+
return CaseOutcome.JSON_IN_CONTENT_FALLBACK if content else CaseOutcome.TIMEOUT_OR_ERROR
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The test suite's own version, independent of the package's release version.
|
|
2
|
+
|
|
3
|
+
Bump this any time a case prompt, a tool schema, or a scoring rule changes.
|
|
4
|
+
Every submitted result is tagged with this so a 'pass' under an old, looser
|
|
5
|
+
suite is never silently compared against a 'pass' under a newer, stricter one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
SUITE_VERSION = "1.0.0"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from compat_check.models import RunReport
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def sample_run_report() -> RunReport:
|
|
10
|
+
from compat_check.models import CaseOutcome, CaseResult
|
|
11
|
+
|
|
12
|
+
return RunReport(
|
|
13
|
+
model_name="qwen2.5-coder:7b",
|
|
14
|
+
model_quantization="q4_K_M",
|
|
15
|
+
ollama_version="0.6.2",
|
|
16
|
+
suite_version="1.0.0",
|
|
17
|
+
os_name="linux",
|
|
18
|
+
results=[
|
|
19
|
+
CaseResult(case_id="single_tool_basic", outcome=CaseOutcome.STRUCTURED_PASS, latency_ms=120),
|
|
20
|
+
CaseResult(case_id="multi_tool_5x", outcome=CaseOutcome.STRUCTURED_PASS, latency_ms=180),
|
|
21
|
+
CaseResult(case_id="no_tools_needed_control", outcome=CaseOutcome.STRUCTURED_PASS, latency_ms=90),
|
|
22
|
+
CaseResult(case_id="multiturn_tool_result", outcome=CaseOutcome.JSON_IN_CONTENT_FALLBACK, latency_ms=200),
|
|
23
|
+
],
|
|
24
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from typer.testing import CliRunner
|
|
2
|
+
|
|
3
|
+
from compat_check.cli import app
|
|
4
|
+
|
|
5
|
+
runner = CliRunner()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_version_command_runs():
|
|
9
|
+
result = runner.invoke(app, ["version"])
|
|
10
|
+
assert result.exit_code == 0
|
|
11
|
+
assert "caniollama" in result.stdout
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_check_fails_cleanly_when_ollama_unreachable():
|
|
15
|
+
result = runner.invoke(app, ["check", "--ollama-url", "http://127.0.0.1:1"])
|
|
16
|
+
assert result.exit_code == 1
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from compat_check.models import CaseOutcome
|
|
2
|
+
from compat_check.suite.cases import CASES
|
|
3
|
+
from compat_check.suite.scoring import score_response
|
|
4
|
+
|
|
5
|
+
BASIC_CASE = next(c for c in CASES if c.case_id == "single_tool_basic")
|
|
6
|
+
CONTROL_CASE = next(c for c in CASES if c.case_id == "no_tools_needed_control")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_structured_tool_call_scores_pass():
|
|
10
|
+
response = {
|
|
11
|
+
"message": {
|
|
12
|
+
"role": "assistant",
|
|
13
|
+
"content": "",
|
|
14
|
+
"tool_calls": [
|
|
15
|
+
{"function": {"name": "get_weather", "arguments": {"city": "Tokyo"}}}
|
|
16
|
+
],
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
assert score_response(BASIC_CASE, response) == CaseOutcome.STRUCTURED_PASS
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_json_in_content_scores_fallback():
|
|
23
|
+
response = {
|
|
24
|
+
"message": {
|
|
25
|
+
"role": "assistant",
|
|
26
|
+
"content": '{"name": "get_weather", "arguments": {"city": "Tokyo"}}',
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
assert score_response(BASIC_CASE, response) == CaseOutcome.JSON_IN_CONTENT_FALLBACK
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_plain_text_with_no_tool_call_scores_error():
|
|
33
|
+
response = {"message": {"role": "assistant", "content": ""}}
|
|
34
|
+
assert score_response(BASIC_CASE, response) == CaseOutcome.TIMEOUT_OR_ERROR
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_plain_prose_answer_scores_fallback_bucket():
|
|
38
|
+
response = {"message": {"role": "assistant", "content": "It's sunny in Tokyo today."}}
|
|
39
|
+
assert score_response(BASIC_CASE, response) == CaseOutcome.JSON_IN_CONTENT_FALLBACK
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_control_case_correctly_skips_tool_call():
|
|
43
|
+
response = {"message": {"role": "assistant", "content": "Bonjour !"}}
|
|
44
|
+
assert score_response(CONTROL_CASE, response) == CaseOutcome.STRUCTURED_PASS
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_control_case_still_passes_if_model_calls_tool_anyway():
|
|
48
|
+
response = {
|
|
49
|
+
"message": {
|
|
50
|
+
"role": "assistant",
|
|
51
|
+
"content": "",
|
|
52
|
+
"tool_calls": [{"function": {"name": "get_weather", "arguments": {"city": "?"}}}],
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
assert score_response(CONTROL_CASE, response) == CaseOutcome.STRUCTURED_PASS
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Structural privacy guarantee tests: assert SubmissionPayload's schema cannot
|
|
2
|
+
carry free text long enough to be a prompt or a model output, and that
|
|
3
|
+
submission never touches case content."""
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from compat_check import submission
|
|
11
|
+
from compat_check.models import SubmissionPayload
|
|
12
|
+
from compat_check.suite.cases import CASES
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_submission_payload_fields_are_bounded():
|
|
16
|
+
"""Every field must be a version tag, enum, coarse label, or timestamp --
|
|
17
|
+
never a free-text/content-shaped field."""
|
|
18
|
+
allowed_unbounded_str_fields = {"model_name", "model_quantization", "ollama_version"}
|
|
19
|
+
for name in SubmissionPayload.model_fields:
|
|
20
|
+
if name == "case_results":
|
|
21
|
+
# dict[str, CaseOutcome] -- values are constrained to the enum, not free text
|
|
22
|
+
continue
|
|
23
|
+
if name in allowed_unbounded_str_fields:
|
|
24
|
+
# These carry short identifiers (e.g. "qwen2.5-coder:7b"), not prompts/outputs
|
|
25
|
+
continue
|
|
26
|
+
# suite_version, os, timestamp: also bounded/short by construction
|
|
27
|
+
assert name in {"suite_version", "os", "timestamp"}, (
|
|
28
|
+
f"Unexpected field '{name}' on SubmissionPayload -- review for privacy leakage"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_submission_module_never_imports_case_prompts():
|
|
33
|
+
"""submission.py's *code* (not comments/docstrings) should never touch case
|
|
34
|
+
message/prompt content."""
|
|
35
|
+
source = inspect.getsource(submission)
|
|
36
|
+
code_lines = [
|
|
37
|
+
line for line in source.splitlines()
|
|
38
|
+
if line.strip() and not line.strip().startswith(("#", '"""', "'''"))
|
|
39
|
+
]
|
|
40
|
+
code_only = "\n".join(code_lines)
|
|
41
|
+
forbidden_tokens = ["case.messages", "case.tools", 'message.get("content']
|
|
42
|
+
for token in forbidden_tokens:
|
|
43
|
+
assert token not in code_only, (
|
|
44
|
+
f"submission.py references '{token}' in executable code -- privacy violation risk"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_to_submission_payload_only_carries_outcomes(sample_run_report):
|
|
49
|
+
payload = sample_run_report.to_submission_payload()
|
|
50
|
+
for case_id, outcome in payload.case_results.items():
|
|
51
|
+
assert case_id in {c.case_id for c in CASES}
|
|
52
|
+
assert outcome in {
|
|
53
|
+
"structured_pass",
|
|
54
|
+
"json_fallback",
|
|
55
|
+
"rejected",
|
|
56
|
+
"error",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
async def test_submit_result_returns_false_on_network_error(monkeypatch, sample_run_report):
|
|
62
|
+
async def _boom(*args, **kwargs):
|
|
63
|
+
raise httpx.ConnectError("no route to host")
|
|
64
|
+
|
|
65
|
+
monkeypatch.setattr(httpx.AsyncClient, "post", _boom)
|
|
66
|
+
ok = await submission.submit_result(sample_run_report, base_url="http://unreachable.invalid")
|
|
67
|
+
assert ok is False
|