One model can answer a prompt. A working model portfolio has a harder job: it must send each approved task to an identified model, keep private work on permitted routes, and show when a route is too slow, too costly or simply wrong. This guide gives a technical lead seven explicit routes to test: five local Ollama roles and two optional OpenAI API roles. The local coding path splits patch drafting from diff review, so the second pass can challenge the first.

The models below are candidates, not winners of a benchmark. Their tags and API paths were checked against current first-party documentation on 21 October 2026. BIG CHANGE did not download or run the models, inspect a reader's hardware, make a paid API call or measure response quality. You will make the keep, replace or disable decision from your own permitted cases.

The big change

  • What changed: The seven-role matrix makes routing visible: triage, extraction, summary, code draft and code review stay local; editing and planning require separate cloud permission.
  • Why it matters: A dry run reports the selected role, backend, model and input hash without sending the task. Execution has no automatic provider fallback.
  • What to watch: Keep only routes whose exact installed models, outputs, time, resource use and cloud charges pass your own checks. An untested route remains unapproved.

What you need

This is for a technical lead who can approve small test inputs, inspect the machine running Ollama and decide whether any text may be sent to a paid cloud API. Use Python 3 with its standard library, Ollama, and a private working directory. You need an OpenAI API key only if you later approve a cloud test. Check your organization's data rules before creating the test files.

The example uses qwen3.5:2b, qwen3.5:4b, gemma4:e2b, qwen2.5-coder:7b and qwen3.5:9b locally; the two cloud candidates are gpt-6-luna and gpt-6.1-sol. The Ollama model pages, Gemma 4 page and Qwen 2.5 Coder page list these candidate families and sizes. The OpenAI model references and GPT-6.1 Sol reference list the cloud IDs. A listed tag or model ID does not prove that your installation, hardware or account can use it.

Choose a few small, approved cases for each role and write down the expected result before calling a model. Triage needs your real labels; extraction needs source facts and a rule for missing fields; summary needs source line references; the coding pair needs a harmless fixture, a proposed diff and acceptance checks; cloud editing and planning need separately approved, de-identified inputs. Keep a manual path for any case that fails the check.

1. Pin the local environment and permissions

Install Ollama through its supported platform instructions. Check the Ollama version, machine RAM, available GPU memory and free disk space in your own environment. On Windows, inspect Task Manager's Performance view; on macOS, inspect About This Mac and Activity Monitor; on Linux, use the system's memory and GPU tools, such as free -h and nvidia-smi when an NVIDIA GPU is present. Record free capacity as well as installed capacity. Pull only the five tags you intend to test, one at a time, after checking their model pages and storage needs:

Terminal
ollama --version
ollama pull qwen3.5:2b
ollama pull qwen3.5:4b
ollama pull gemma4:e2b
ollama pull qwen2.5-coder:7b
ollama pull qwen3.5:9b
curl http://127.0.0.1:11434/api/tags
ollama ps

The Ollama tags endpoint returns each installed name and digest. Save that readback, plus your Ollama version, operating system, CPU/GPU, RAM/VRAM, quantization and available disk, in a private test record. Recheck digests after any pull or update. ollama ps shows what is loaded and whether it is using CPU or GPU memory; a model page's size is no substitute for an observed load on your machine. The Ollama FAQ explains memory, queueing and concurrent loads. Run cases sequentially first, because loading several models at once can alter both memory use and timing.

If local-only handling is required, disable Ollama cloud features in its documented settings, restart it and confirm the log reports Ollama cloud disabled: true. Also verify the Ollama listener is bound to loopback and that local logs and saved result files meet your privacy rules. A 127.0.0.1 URL in this sample does not inspect the rest of your Ollama configuration.

For cloud tests, separately confirm your account can call both model IDs through the Responses API, review the current price table and data controls, set a spending limit outside this script, and obtain approval for each input. The sample sends store: false; that setting does not promise zero provider retention. Keep the two cloud rows untested if access, permission or price approval is missing.

2. Save the seven routes

Create routing-config.json in the private working directory. The task strings are short guardrails for the sample request, not proof that a model will obey them. Edit them to match your acceptance criteria and record any config change with the corresponding test run. Do not turn a local row into a cloud row merely to make a case pass.

JSON
{
  "schema": "original-seven-role-reader-recipe-v1",
  "policy": "explicit-role-only; no automatic fallback or provider escalation",
  "localEndpoint": "http://127.0.0.1:11434",
  "cloudEndpoint": "https://api.openai.com/v1/responses",
  "roles": {
    "triage": {"backend": "local", "model": "qwen3.5:2b", "task": "Classify the reader's approved small request against reader-owned labels"},
    "extract": {"backend": "local", "model": "qwen3.5:4b", "task": "Extract explicit requirements and missing facts without inventing them"},
    "summarize": {"backend": "local", "model": "gemma4:e2b", "task": "Summarize approved project context with source line references"},
    "code_draft": {"backend": "local", "model": "qwen2.5-coder:7b", "task": "Propose a small code patch for the reader's fixture; no tool execution"},
    "code_review": {"backend": "local", "model": "qwen3.5:9b", "task": "Review that proposed diff against acceptance checks; no tool execution"},
    "cloud_edit": {"backend": "cloud", "model": "gpt-6-luna", "task": "Edit an explicitly cloud-approved, de-identified answer"},
    "cloud_plan": {"backend": "cloud", "model": "gpt-6.1-sol", "task": "Plan or second-review an explicitly cloud-approved complex case"}
  }
}

The local code sub-stack has two explicit handoffs. Send an approved fixture and a request for a small patch to code_draft. Have a human inspect the proposed text before putting the proposed diff, fixture context and acceptance checks into a separate code_review input. Neither role runs a command or modifies a repository. A reviewer finding is evidence to inspect, not permission to apply the patch.

3. Save the router and run a dry check

Save the following as route.py alongside the JSON file. It has a fixed loopback Ollama endpoint and a fixed official HTTPS cloud endpoint. It rejects an unknown backend or endpoint edit, refuses HTTP redirects, ignores environment proxies, requires --allow-cloud for a cloud execution, and never switches providers after an error. These boundaries address accidental routing in this sample; review the code and your network environment before sending sensitive work.

Python
"""Reader-run seven-role sample. Dry-run by default; no automatic fallback."""
import argparse
import hashlib
import json
import os
import pathlib
import sys
import time
import urllib.error
import urllib.request

HERE = pathlib.Path(__file__).resolve().parent
CONFIG = json.loads((HERE / "routing-config.json").read_text(encoding="utf-8"))
LOCAL_ENDPOINT = "http://127.0.0.1:11434"
CLOUD_ENDPOINT = "https://api.openai.com/v1/responses"


class NoRedirect(urllib.request.HTTPRedirectHandler):
    """A provider response cannot redirect a prompt or credential elsewhere."""

    def redirect_request(self, req, fp, code, msg, headers, newurl):
        raise urllib.error.HTTPError(req.full_url, code, "redirect blocked", headers, fp)


def validate_config():
    if CONFIG.get("localEndpoint") != LOCAL_ENDPOINT:
        raise ValueError("local endpoint must be the fixed loopback address")
    if CONFIG.get("cloudEndpoint") != CLOUD_ENDPOINT:
        raise ValueError("cloud endpoint must be the fixed official HTTPS address")
    roles = CONFIG.get("roles")
    if not isinstance(roles, dict) or len(roles) != 7:
        raise ValueError("expected exactly seven named roles")
    for name, route in roles.items():
        if not isinstance(route, dict) or route.get("backend") not in ("local", "cloud"):
            raise ValueError(f"unknown backend for role {name}")
        if not isinstance(route.get("model"), str) or not route["model"]:
            raise ValueError(f"missing model for role {name}")


def request_json(url, payload, headers):
    data = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(url, data=data, headers=headers, method="POST")
    # Ignore environment proxy settings and refuse all HTTP redirects.
    opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
    with opener.open(req, timeout=120) as response:
        return json.load(response)


def main():
    try:
        validate_config()
    except ValueError as exc:
        print(f"invalid routing config: {exc}", file=sys.stderr)
        return 2
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("role", choices=CONFIG["roles"])
    parser.add_argument("input", type=pathlib.Path, help="Reader-owned, permitted UTF-8 task file")
    parser.add_argument("--execute", action="store_true", help="Actually call selected provider")
    parser.add_argument("--allow-cloud", action="store_true", help="Required for any paid cloud call")
    parser.add_argument("--out", type=pathlib.Path, help="New private JSON result file; never overwritten")
    args = parser.parse_args()
    route = CONFIG["roles"][args.role]
    raw = args.input.read_bytes()
    prompt = raw.decode("utf-8")
    if not prompt.strip():
        parser.error("input file is empty")
    identity = {"role": args.role, "backend": route["backend"], "model": route["model"],
                "inputSha256": hashlib.sha256(raw).hexdigest(), "inputBytes": len(raw)}
    if not args.execute:
        print(json.dumps({**identity, "state": "dry-run; no provider call", "task": route["task"]}, indent=2))
        return
    if not args.out:
        parser.error("--out is required with --execute")
    if args.out.exists():
        parser.error("output exists; use a new private path to preserve the previous result")
    if route["backend"] == "cloud" and not args.allow_cloud:
        parser.error("cloud route requires explicit --allow-cloud after permission and cost review")

    started = time.monotonic()
    if route["backend"] == "local":
        # A local endpoint can still be exposed or configured for cloud features; verify settings first.
        url = CONFIG["localEndpoint"] + "/api/chat"
        payload = {"model": route["model"], "messages": [{"role": "system", "content": route["task"]},
                   {"role": "user", "content": prompt}], "stream": False, "keep_alive": 0}
        headers = {"Content-Type": "application/json"}
    elif route["backend"] == "cloud":
        key = os.environ.get("OPENAI_API_KEY")
        if not key:
            parser.error("OPENAI_API_KEY is absent")
        url = CONFIG["cloudEndpoint"]
        payload = {"model": route["model"], "instructions": route["task"],
                   "input": prompt, "store": False}
        headers = {"Content-Type": "application/json", "Authorization": "Bearer " + key}
    else:
        # Defense in depth if a caller bypasses the configuration readback.
        parser.error("unsupported backend")

    try:
        result = request_json(url, payload, headers)
    except urllib.error.HTTPError as exc:
        print(json.dumps({**identity, "state": "http-error; no automatic retry", "httpStatus": exc.code,
                          "elapsedSeconds": round(time.monotonic() - started, 3)}), file=sys.stderr)
        return 2
    except (urllib.error.URLError, TimeoutError) as exc:
        print(json.dumps({**identity, "state": "request outcome unknown; do not blindly retry",
                          "errorType": type(exc).__name__,
                          "elapsedSeconds": round(time.monotonic() - started, 3)}), file=sys.stderr)
        return 2

    if route["backend"] == "local":
        output = result.get("message", {}).get("content", "")
        usage = {k: result.get(k) for k in ("prompt_eval_count", "eval_count", "total_duration", "load_duration")}
        response_id = None
        status = result.get("done_reason")
    elif route["backend"] == "cloud":
        output = "\n".join(part.get("text", "") for item in result.get("output", [])
                           if item.get("type") == "message" for part in item.get("content", [])
                           if part.get("type") == "output_text")
        usage = result.get("usage", {})
        response_id = result.get("id")
        status = result.get("status")
    else:
        parser.error("unsupported backend")
    record = {**identity, "state": "response received", "responseId": response_id,
              "responseStatus": status, "elapsedSeconds": round(time.monotonic() - started, 3),
              "usage": usage, "output": output}
    # Output can contain private input; create only at a reader-selected private path.
    with args.out.open("x", encoding="utf-8") as handle:
        json.dump(record, handle, ensure_ascii=False, indent=2)
        handle.write("\n")
    print(json.dumps({**identity, "state": "saved", "resultFile": str(args.out),
                      "responseStatus": status, "elapsedSeconds": record["elapsedSeconds"]}))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Put one permitted UTF-8 task in case.txt. A dry run does not read a provider key or send the file:

Terminal
python route.py triage case.txt
python route.py code_draft case.txt
python route.py cloud_plan case.txt

Each result should say dry-run; no provider call and show the exact role, backend, model, input byte count and SHA-256 hash. Compare it with the route table and the input you meant to use. The hash helps identify which local file produced a result; it is not a privacy review of that file. If a route or permission is wrong, stop here and correct the private config or input.

4. Test each role on permitted cases

For a local role, use a new private output path for every run:

Terminal
python route.py triage case.txt --execute --out triage-001.json

Repeat for extract, summarize, code_draft and code_review with role-specific inputs. The saved record contains the input hash, chosen model, response text, elapsed wall time and any timing/token fields returned by Ollama's chat API. Its keep_alive: 0 setting asks Ollama to unload the model after a response; this makes repeated load time relevant to the measurements. Record cold and repeated runs separately if your eventual workload keeps models resident. Confirm the actual installed digest for each run from the saved tags readback. A successful HTTP response says nothing by itself about answer quality.

Cloud calls are a distinct, explicit step after permission and budget checks. Put only a cloud-approved, de-identified task in the input file, set OPENAI_API_KEY in your environment without placing it in the file or shell history, and use a new private output path:

Terminal
python route.py cloud_edit approved-edit.txt --execute --allow-cloud --out cloud-edit-001.json
python route.py cloud_plan approved-plan.txt --execute --allow-cloud --out cloud-plan-001.json

Run only the approved call or calls; those commands can incur charges. The code uses the documented Responses API text path, records the response ID, status, usage and elapsed time, and saves output privately. Confirm that responseStatus is complete and that the returned text actually answers the task. A missing or incomplete output is a failed case. Cloud prices and access can vary by account and date. For ordinary per-million-token input and output rates, calculate (input tokens × input rate + output tokens × output rate) / 1,000,000; apply any current cached-input, tool or account-specific terms separately. Set the per-case cap before testing and compare the actual billing record afterward.

For each route, keep a row with: case ID and approved input class; config revision; exact model name and local digest or cloud model ID; machine/resource observations; expected result; observed result; pass/fail and reason; elapsed seconds; local load and token fields or cloud usage; estimated charge; permission decision; reviewer; and manual fallback. Use the same frozen cases when comparing candidates. Never mix a cold local load with a warm run without labeling them. A cloud model unavailable to your account remains untested, not failed quality.

5. Challenge the routing boundary without provider calls

Save this test_route.py in the same directory, then run python -m unittest -v test_route.py. It mocks requests. The checks cover a mistyped backend, altered endpoint, attempted cloud execution without consent, allowed sample paths and a blocked redirect. They do not test model quality, provider availability or your hardware.

Python
"""Call-free regression checks for the reader example's routing boundary."""
import contextlib
import copy
import io
import json
import pathlib
import sys
import tempfile
import unittest
import urllib.error
import urllib.request
from unittest import mock

import route


class RoutingBoundaryTests(unittest.TestCase):
    def setUp(self):
        self.original = copy.deepcopy(route.CONFIG)
        self.tmp = tempfile.TemporaryDirectory()
        self.base = pathlib.Path(self.tmp.name)
        self.input = self.base / "case.txt"
        self.input.write_text("Reader-owned test case", encoding="utf-8")

    def tearDown(self):
        route.CONFIG = self.original
        self.tmp.cleanup()

    def run_main(self, *args):
        with mock.patch.object(sys, "argv", ["route.py", *args]), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
            return route.main()

    def test_unknown_backend_cannot_select_cloud(self):
        route.CONFIG["roles"]["triage"]["backend"] = "cluod"
        with mock.patch.object(route, "request_json") as send, mock.patch.dict(route.os.environ, {"OPENAI_API_KEY": "fake"}):
            code = self.run_main("triage", str(self.input), "--execute", "--out", str(self.base / "out.json"))
        self.assertEqual(code, 2)
        send.assert_not_called()

    def test_endpoint_changes_fail_before_request_or_key(self):
        for field, wrong in (("localEndpoint", "http://127.0.0.2:11434"),
                             ("cloudEndpoint", "https://api.openai.com.evil.test/v1/responses")):
            with self.subTest(field=field):
                route.CONFIG[field] = wrong
                with mock.patch.object(route, "request_json") as send, mock.patch.dict(route.os.environ, {"OPENAI_API_KEY": "fake"}):
                    code = self.run_main("cloud_plan", str(self.input), "--execute", "--allow-cloud", "--out", str(self.base / "out.json"))
                self.assertEqual(code, 2)
                send.assert_not_called()
                route.CONFIG = copy.deepcopy(self.original)

    def test_cloud_needs_explicit_consent_and_valid_routes_work(self):
        with mock.patch.object(route, "request_json") as send, mock.patch.dict(route.os.environ, {"OPENAI_API_KEY": "fake"}):
            with self.assertRaises(SystemExit):
                self.run_main("cloud_plan", str(self.input), "--execute", "--out", str(self.base / "cloud.json"))
            send.assert_not_called()
            self.assertIsNone(self.run_main("triage", str(self.input)))
            self.assertIsNone(self.run_main("cloud_plan", str(self.input)))
            send.assert_not_called()
            send.return_value = {"message": {"content": "test"}, "done_reason": "stop"}
            self.assertEqual(self.run_main("triage", str(self.input), "--execute", "--out", str(self.base / "local.json")), 0)
            self.assertEqual(send.call_args.args[0], route.LOCAL_ENDPOINT + "/api/chat")
            self.assertNotIn("Authorization", send.call_args.args[2])
            send.return_value = {"status": "completed", "output": [], "usage": {}}
            self.assertEqual(self.run_main("cloud_plan", str(self.input), "--execute", "--allow-cloud", "--out", str(self.base / "cloud.json")), 0)
            self.assertEqual(send.call_args.args[0], route.CLOUD_ENDPOINT)
            self.assertEqual(send.call_args.args[2]["Authorization"], "Bearer fake")

    def test_transport_disables_proxy_and_redirect(self):
        def fake_builder(*handlers):
            self.assertTrue(any(isinstance(h, route.NoRedirect) for h in handlers))
            self.assertTrue(any(isinstance(h, urllib.request.ProxyHandler) and h.proxies == {} for h in handlers))
            class FakeOpener:
                def open(self, req, timeout):
                    raise urllib.error.HTTPError(req.full_url, 302, "redirect blocked", {"Location": "https://evil.test"}, None)
            return FakeOpener()
        with mock.patch.object(urllib.request, "build_opener", side_effect=fake_builder):
            with self.assertRaises(urllib.error.HTTPError):
                route.request_json(route.CLOUD_ENDPOINT, {"input": "private"}, {"Authorization": "Bearer fake"})
        req = urllib.request.Request(route.CLOUD_ENDPOINT, headers={"Authorization": "Bearer fake"})
        with self.assertRaises(urllib.error.HTTPError):
            route.NoRedirect().redirect_request(req, None, 302, "Found", {}, "https://evil.test")


if __name__ == "__main__":
    unittest.main()

The packet behind this guide passed these four call-free regressions and 47 independent call-free checks of its sample assets. That establishes the behavior of the example under those checks. It is not a claim that any of the seven models ran or that a real network request succeeded.

Decide what stays enabled

Review the seven rows individually. Keep a route only when its exact model and environment are identified, the permitted cases meet the written quality rule, latency fits the actual workflow, and its privacy and operating cost are approved. Local execution still consumes storage, memory, power and maintenance time. Cloud charges depend on the actual token use, rate card and any account terms. For code_draft, require the separate diff review and human checks before any patch is applied; for code_review, inspect false positives and missed defects as carefully as fluent explanations.

If a local tag is absent, a model does not fit memory, or Ollama returns an overload error, keep that route disabled and record the exact readback. Reassess a smaller documented candidate with fresh cases and a new config record. If a cloud call fails or times out, its outcome may be unknown; inspect provider state and billing before considering another paid request. This sample does not retry or escalate a failed local case to cloud. When quality is poor, revise the rubric or candidate deliberately; use the manual process until a new run passes.

For a simpler local starting point, our guides to check one Ollama chat endpoint and connect Ollama to Open WebUI and Continue cover setup tasks. The two-model FAQ benchmark is a separate way to freeze cases for one evaluation task. This portfolio asks you to maintain seven distinct routing decisions and their evidence.

Sources & further reading