RecursionAI
All Research
July 23, 2026·8 min read

Courier vs Ollama: Tool-Calling, Measured

CourierBenchmarkTool-CallingOllamaBFCL

Everyone runs the same open models. So the real question isn't which model, it's which engine gets the most out of it. We put Courier head-to-head with Ollama on tool-calling, the capability that makes a local model actually do things, using the identical model, identical quantization, and a scorer neither of us wrote.

On the flagship model, Courier wins on accuracy and speed at the same time, even when Ollama is allowed to think and Courier isn't.

ModelGemma 4 26B-A4BQuantmatched 4-bitScorerBFCL v4 ast_checkerCases1,000 · temp 0
0.902
Courier's best 26B tool-calling — beats Ollama's best (0.874)
+13.5
points ahead on parallel calls, matched no-think
+37%
faster decode — 123.8 vs 90.1 tok/s
2.3×
sooner to first token — 0.12s vs 0.28s

The test

Both engines served the same model at matched 4-bit (Courier MLX 4bit, Ollama q4_K_M), on the same Apple Silicon hardware. We measured the native function-calling path on the OpenAI /v1/chat/completions contract for both, send tools, read back tool_calls, and scored every response with BFCL v4's own ast_checker, the Berkeley Function-Calling Leaderboard's official grader. Nobody graded their own homework.

These are BFCL's single-turn AST categories, the benchmark's deterministic core (exact function name, arguments, and types), not the full v4 agentic suite. That's the slice that cleanly isolates the serving layer, which is what we're measuring here.

We even controlled for thinking mode: Gemma 4 can "reason" before answering, and Ollama turns that on by default. To keep it fair, we forced Ollama to no-think for the matched comparison and verified it per run (0/1000 reasoning traces when off, 1000/1000 when on). Then we let it think anyway, to see if reasoning could close the gap.

Show the exact harness — bfcl_fc_harness.py
#!/usr/bin/env python
"""FC (function-calling) harness for BFCL against any OpenAI-compatible endpoint.

WHY THIS EXISTS
---------------
BFCL's built-in Gemma handler treats Gemma as a PROMPT model (tools embedded as
text, output parsed by a Gemma-specific parser). That is not what we want to
measure. Courier's value is that it exposes NATIVE `tool_calls` with FSM-verified
validity on /v1/chat/completions — turning a model that isn't natively
tool-trained into a reliable tool-caller. To measure THAT, we run the FC path:
send OpenAI `tools`, read back `tool_calls`, and score with BFCL's OWN AST
checker. Same model, same endpoint contract on both runtimes; the runtime's
tool-calling layer is the only variable.

Scoring is BFCL's official `ast_checker` — NOT a reimplementation — so nobody
can say we graded ourselves. We only supply the transport (call each endpoint)
and the format conversion (OpenAI tool_calls -> BFCL's decoded shape).

Run once per endpoint (Courier :9100, Ollama :11434), same category, compare.
"""
import argparse
import json
import os
import time

import requests

from bfcl_eval.constants.enums import Language
from bfcl_eval.eval_checker.ast_eval.ast_checker import ast_checker

_DATA = "bfcl-env/lib/python3.10/site-packages/bfcl_eval/data"

# BFCL function schemas use Gorilla's type names; the OpenAI tools API wants
# JSON-schema names. Map the ones that differ; leave the rest.
_TYPE_MAP = {"dict": "object", "float": "number", "tuple": "array", "any": "string"}


def _convert_schema(node):
    """Recursively rewrite Gorilla type names to JSON-schema names in place."""
    if isinstance(node, dict):
        if "type" in node and isinstance(node["type"], str):
            node["type"] = _TYPE_MAP.get(node["type"], node["type"])
        for v in node.values():
            _convert_schema(v)
    elif isinstance(node, list):
        for v in node:
            _convert_schema(v)
    return node


def _to_openai_tools(functions):
    tools = []
    for fn in functions:
        params = _convert_schema(json.loads(json.dumps(fn.get("parameters", {}))))
        if params.get("type") != "object":
            params["type"] = "object"
        tools.append({"type": "function", "function": {
            "name": fn["name"], "description": fn.get("description", ""), "parameters": params,
        }})
    return tools


def _decode_tool_calls(message):
    """OpenAI tool_calls -> BFCL decoded shape: [{func_name: {arg: value}}]."""
    out = []
    for tc in (message.get("tool_calls") or []):
        fn = tc.get("function", {})
        name = fn.get("name", "")
        try:
            args = json.loads(fn.get("arguments") or "{}")
        except (json.JSONDecodeError, TypeError):
            args = {"__unparseable__": fn.get("arguments")}
        out.append({name: args})
    return out


def _call(base_url, model, api_key, messages, tools, timeout):
    headers = {"Content-Type": "application/json"}
    if api_key:
        headers["Authorization"] = f"Bearer {api_key}"
    body = {"model": model, "messages": messages, "tools": tools,
            "tool_choice": "auto", "temperature": 0.0, "stream": False}
    r = requests.post(f"{base_url}/chat/completions", headers=headers,
                      json=body, timeout=timeout)
    r.raise_for_status()
    return r.json()["choices"][0]["message"]


def run(category, base_url, model, api_key, limit, timeout, out_path):
    with open(f"{_DATA}/BFCL_v4_{category}.json") as f:
        cases = [json.loads(l) for l in f if l.strip()]
    with open(f"{_DATA}/possible_answer/BFCL_v4_{category}.json") as f:
        answers = {json.loads(l)["id"]: json.loads(l) for l in f if l.strip()}
    if limit:
        cases = cases[:limit]

    # A registered model name so ast_checker's convert_func_name lookup resolves;
    # gemma-3-27b is underscore_to_dot=False so it never mangles names.
    score_name = "google/gemma-3-27b-it"
    lang = Language.JAVA if "java" in category else (
        Language.JAVASCRIPT if "javascript" in category else Language.PYTHON)

    results, correct, errors, latencies = [], 0, 0, []
    for i, case in enumerate(cases):
        cid = case["id"]
        tools = _to_openai_tools(case["function"])
        messages = case["question"][0]  # single-turn: first turn's message list
        rec = {"id": cid}
        try:
            t0 = time.time()
            msg = _call(base_url, model, api_key, messages, tools, timeout)
            latencies.append(time.time() - t0)
            decoded = _decode_tool_calls(msg)
            verdict = ast_checker(case["function"], decoded, answers[cid]["ground_truth"],
                                  lang, category, score_name)
            rec["valid"] = bool(verdict.get("valid"))
            rec["n_calls"] = len(decoded)
            if not rec["valid"]:
                rec["error"] = verdict.get("error")
            correct += rec["valid"]
        except Exception as e:  # transport/parse failure counts as a miss, recorded
            errors += 1
            rec["valid"] = False
            rec["exception"] = str(e)[:200]
        results.append(rec)
        print(f"  [{i+1}/{len(cases)}] {cid}: {'OK' if rec['valid'] else 'x'}", flush=True)

    n = len(cases)
    summary = {
        "category": category, "endpoint": base_url, "model": model,
        "n": n, "correct": correct, "accuracy": round(correct / n, 4) if n else 0.0,
        "transport_errors": errors,
        "median_latency_s": round(sorted(latencies)[len(latencies)//2], 2) if latencies else None,
    }
    with open(out_path, "w") as f:
        json.dump({"summary": summary, "results": results}, f, indent=2)
    print(json.dumps(summary, indent=2))
    return summary


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--category", default="simple_python")
    ap.add_argument("--base-url", required=True, help="e.g. http://127.0.0.1:9100/v1")
    ap.add_argument("--model", required=True)
    ap.add_argument("--api-key", default=os.getenv("BFCL_ENDPOINT_KEY", ""))
    ap.add_argument("--limit", type=int, default=0)
    ap.add_argument("--timeout", type=int, default=300)
    ap.add_argument("--out", required=True)
    a = ap.parse_args()
    run(a.category, a.base_url, a.model, a.api_key, a.limit, a.timeout, a.out)

Accuracy - tool-calling (26B-A4B)

Accuracy · Gemma 4 26B-A4B · BFCL (1,000 cases)CourierOllama
thinkingCourier
0.902best
no-thinkCourier
0.897
thinkingOllama
0.874
no-thinkOllama
0.850
0accuracy1.0

Courier wins in every mode. +4.7 points over Ollama on matched terms. Courier's best (0.902) beats Ollama's best (0.874), i.e. our engine without reasoning still edges Ollama with it.

Where it really shows: parallel tool calls

Parallel calls: asking for several tools at once and seeing the output structure. This is crucial, as weaker tool-calling drifts into malformed calls.

Parallel-call accuracy · 26B-A4BCourierOllama
no-thinkCourier
0.830best
thinkingOllama
0.780
no-thinkOllama
0.695
0accuracy1.0

A +13.5-point gap on matched terms. Stripped of reasoning, Ollama's parallel tool-calling wobbles; Courier's constrained-decoding layer holds every call to a valid shape. Courier's no-think score (0.830) even beats Ollama with thinking (0.780). This is the Courier's reliability moat, quantified.

Speed - decode rate & first-token latency

Same 4-bit weights, single-stream, steady-state:

Decode · tokens per secondCourierOllama
Courier26B-A4B
123.8best
Ollama26B-A4B
90.1
CourierE4B
122.3best
OllamaE4B
84.5
0tok/s140
Time to first token · seconds (lower is better)CourierOllama
Courier26B-A4B
0.122sbest
Ollama26B-A4B
0.277s
CourierE4B
0.106sbest
OllamaE4B
0.262s
0seconds0.30

Courier is ~37-45% faster to generate and reaches the first token ~2.3-2.5x sooner on both models, both metrics.

The hidden cost of "just turn on thinking"

Reasoning does lift Ollama's accuracy, but look at what it costs on 26B parallel calls:

Median tokens per response · 26B parallel callsCourierOllama
no-thinkCourier
75best
thinkingOllama
294
0tokens300
Median latency per response · secondsCourierOllama
no-thinkCourier
1.13sbest
thinkingOllama
3.5s
0seconds4.0

Ollama-thinking spends ~3x the time and ~4x the tokens to buy accuracy that still lands below Courier's instant no-think answer.

Why Courier wins

It's not a faster chip or a secret model; it's the reliability layer. Local models don't emit tool calls in one consistent format, and they drift under pressure. Courier's reliability layer guides generation to emit valid tool-call structure and parses defensively, so calls come out well-formed even when the raw model would fumble them. This is seen most visibly on parallel calls, where it opens a +13.5-point lead. Courier's recent update pushed decode from 62 → 123 tok/s, so that reliability comes with the fastest generation too.

Same model, same weights; Courier just gets more out of them. More accurate tool calls, faster tokens, lower latency, measured on the same hardware and graded by Berkeley's own checker.

Honest footnote: on the much smaller E4B, reasoning matters more with thinking on, Ollama edges Courier there (0.897 vs 0.882). The reliability advantage is clearest on the flagship 26B, and thinking (now validated as worthwhile) is coming to Courier.


Methodology: Apple Silicon (M-series, 128 GB). Gemma 4 26B-A4B & E4B, matched 4-bit. Function-calling path on the OpenAI /v1/chat/completions contract for both engines. Scored with BFCL v4's official ast_checker. 1,000 cases/condition, temp 0, single-stream.

Author: Ryker Ross

Want more like this?

Subscribe to get new articles delivered to your inbox.