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.
@@ -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
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - Unreleased
4
+ ### Added
5
+ - Initial CLI: `caniollama check [model]`
6
+ - Fixed test suite v1.0.0: single tool, 5-tool selection, no-tool control, multi-turn tool result
7
+ - Opt-in, metadata-only submission to the registry
@@ -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,3 @@
1
+ """caniollama - does this local Ollama model actually support tool calling?"""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from compat_check.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -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