Cavin

Phase 3: Provider abstraction

AI Agents / 2026-09-18

Goal: the harness runs against a single Groq model today. Swapping models, or adding a second provider later, should mean changing one value, not rewriting backend.py.

Think of backend.py today as a lamp wired straight into the wall, no plug. It works, but changing the outlet means cutting wires. A provider abstraction is the plug: one shape everything above it uses, so what's on the other end of the cord can change without touching the lamp.

1. Rewrite backend.py around a provider class

Phase 0's complete and phase 1's chat both hardcode the model name and rebuild the Groq client on every call. Replace the whole file with a class that owns its client and its model, plus a small registry that picks a class by name:

Pythonbackend.py
import os
import time
from typing import Protocol

from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()

class Completion:
    """One reply from a provider, with what it cost to get it."""

    def __init__(self, message, model: str, prompt_tokens: int, completion_tokens: int, latency_ms: int):
        self.message = message
        self.model = model
        self.prompt_tokens = prompt_tokens
        self.completion_tokens = completion_tokens
        self.latency_ms = latency_ms

class Provider(Protocol):
    def complete(self, messages: list, tools: list | None = None) -> Completion: ...

class GroqProvider:
    def __init__(self, model: str | None = None):
        self.model = model or os.environ.get("MODEL", "openai/gpt-oss-120b")
        self.client = OpenAI(
            api_key=os.environ["GROQ_API_KEY"],
            base_url="https://api.groq.com/openai/v1",
        )

    def complete(self, messages: list, tools: list | None = None) -> Completion:
        kwargs = {"model": self.model, "messages": messages}
        if tools:
            kwargs["tools"] = tools

        start = time.perf_counter()
        response = self.client.chat.completions.create(**kwargs)
        latency_ms = round((time.perf_counter() - start) * 1000)

        return Completion(
            message=response.choices[0].message,
            model=self.model,
            prompt_tokens=response.usage.prompt_tokens,
            completion_tokens=response.usage.completion_tokens,
            latency_ms=latency_ms,
        )

PROVIDERS = {"groq": GroqProvider}

def get_provider(name: str | None = None) -> Provider:
    name = name or os.environ.get("BACKEND", "groq")
    if name not in PROVIDERS:
        raise ValueError(f"unknown backend: {name}")
    return PROVIDERS[name]()

if __name__ == "__main__":
    provider = get_provider()
    result = provider.complete([{"role": "user", "content": "Say hello in one sentence."}])
    print(result.message.content)

Completion carries the reply plus what it cost to get it: which model answered, prompt and completion tokens, and how long the call took. GroqProvider.complete reads its model from the MODEL environment variable when one is set, and falls back to phase 0's model otherwise. get_provider reads BACKEND the same way. Leave it unset and you get "groq", the only entry in PROVIDERS right now.

This build stops at one provider. Adding a second, free or paid, means writing one more class with the same complete(messages, tools) method and adding it to PROVIDERS. Smaller or less capable models produce more malformed tool calls, and that's where phase 2's guardrail code earns its keep.

2. Point the model at a config flag

Add the new variable to .env, next to GROQ_API_KEY:

INI.env
MODEL=openai/gpt-oss-120b

Leave BACKEND out of .env for now. get_provider already defaults to "groq", and there's nothing else to pick from yet.

3. Update harness.py to use the provider

Replace the from backend import chat line and the single chat(messages, tools=TOOLS) call. Get one provider when the module loads, call provider.complete inside the loop, and log what each call cost:

Pythonharness.py
import json
import sys
import time

from backend import get_provider
from guardrails import NEEDS_APPROVAL, check, log
from tools import REGISTRY, TOOLS

MAX_STEPS = 10
SYSTEM_PROMPT = "You are a coding assistant. When a task needs a tool, call the tool right away. Do not announce what you are about to do, and never ask the user for permission in your reply. The harness asks for approval by itself."

provider = get_provider()

def approve(name: str, args: dict) -> bool:
    print(f"\nThe model wants to use {name}:")
    for key, value in args.items():
        print(f"  {key}: {value}")
    return input("Allow? [y/N] ").strip().lower() == "y"

def execute(name: str, args: dict) -> tuple[str, str]:
    problem = check(name, args)
    if problem:
        return "blocked", f"blocked: {problem}"
    if name in NEEDS_APPROVAL and not approve(name, args):
        return "denied", "denied: the user said no"
    try:
        return "ok", REGISTRY[name](**args)
    except Exception as error:
        return "error", f"error: {error}"

def run(task: str) -> str:
    run_id = time.strftime("%Y%m%d-%H%M%S")
    log({"run_id": run_id, "event": "task", "task": task})
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": task},
    ]
    total_tokens = 0
    total_latency_ms = 0

    for step in range(MAX_STEPS):
        result = provider.complete(messages, tools=TOOLS)
        message = result.message
        total_tokens += result.prompt_tokens + result.completion_tokens
        total_latency_ms += result.latency_ms
        log(
            {
                "run_id": run_id,
                "event": "usage",
                "step": step + 1,
                "prompt_tokens": result.prompt_tokens,
                "completion_tokens": result.completion_tokens,
                "latency_ms": result.latency_ms,
            }
        )

        if not message.tool_calls:
            log({"run_id": run_id, "event": "answer", "content": message.content})
            print(f"\n{result.model}: {total_tokens} tokens, {total_latency_ms}ms total")
            return message.content

        messages.append(
            {
                "role": "assistant",
                "content": message.content,
                "tool_calls": [
                    {
                        "id": call.id,
                        "type": "function",
                        "function": {
                            "name": call.function.name,
                            "arguments": call.function.arguments,
                        },
                    }
                    for call in message.tool_calls
                ],
            }
        )

        for call in message.tool_calls:
            name = call.function.name
            try:
                args = json.loads(call.function.arguments)
                outcome, result_text = execute(name, args)
            except Exception as error:
                args, outcome, result_text = call.function.arguments, "error", f"error: {error}"
            print(f"[{step + 1}] {name} {call.function.arguments} -> {outcome}")
            log(
                {
                    "run_id": run_id,
                    "event": "tool_call",
                    "step": step + 1,
                    "tool": name,
                    "args": args,
                    "outcome": outcome,
                    "result": result_text[:500],
                }
            )
            messages.append(
                {"role": "tool", "tool_call_id": call.id, "content": result_text}
            )

    log({"run_id": run_id, "event": "stopped", "reason": "step limit"})
    return "stopped: hit the step limit"

if __name__ == "__main__":
    print(run(sys.argv[1]))

Two things change here. provider = get_provider() now runs once, above run, so the harness builds one Groq client for the whole run instead of one per call. Rename the tool-execution loop's local variable too, from result to result_text, because result already holds what provider.complete returned. Keep the old name, and three lines later result points at something else.

total_tokens and total_latency_ms add up across every step, then print once, when the loop returns. That's the running total the original plan called for. Per-step numbers still go to runs.jsonl, tagged usage, next to the tool_call and answer lines phase 2 already writes there.

4. Run it

Run a task you've run before, so you have something to compare against:

Bash
python harness.py "Run git log --oneline and summarize the commits."

The output should look the same as phase 2, with one new line at the end:

Output
[1] read_file backend.py -> ok
openai/gpt-oss-120b: 1148 tokens, 812ms total

Now change .env without touching any code:

INI.env
MODEL=openai/gpt-oss-20b

Run the same command again. The final line should now start with openai/gpt-oss-20b instead of openai/gpt-oss-120b, and nothing else about the run should change.

Done when: editing MODEL in .env changes the model name in the final printed line, with no other file touched.

5. Troubleshooting

If both runs in step 4 worked and the numbers looked reasonable, skip to step 6. Otherwise, match the symptom:

  • ImportError: cannot import name 'chat' from 'backend'. harness.py still has the old import line. Replace it with from backend import get_provider.
  • ValueError: unknown backend. BACKEND is set in .env to something other than groq, or it's misspelled. PROVIDERS has one entry, so remove the line or set it to groq.
  • Every run reports the same token count regardless of the task. total_tokens is declared inside the for step loop instead of above it, so it resets on every step instead of adding up.
  • The final tokens... total line never prints. Check that the print call sits before return message.content, not after it.
  • A logged tool result looks like a Completion object instead of text, or the run crashes with an AttributeError on a string. The tool-loop variable is back to result, overwriting the one provider.complete returned a few lines up. Rename it to result_text (or anything other than result) everywhere the for call in message.tool_calls block uses it.
  • KeyError: 'GROQ_API_KEY'. Same fix as phase 0: confirm .env is in the project folder and the key has no extra spaces.
  • The final line still names the old model after you edit .env. Save the file, then run python harness.py again. MODEL loads once when the process starts, so a process already running won't see the change.
  • AttributeError: 'Completion' object has no attribute 'model'. harness.py got updated but backend.py didn't. Open backend.py and confirm Completion.__init__ takes a model parameter and sets self.model, and that GroqProvider.complete's return Completion(...) passes model=self.model.

Stuck?

Ask an agent something like: "I'm turning a hardcoded API call into a provider class with a registry, here's my code, does this shape make sense for adding a second backend later?" Paste backend.py as it stands now.

6. Commit and push

Bash
git add .
git commit -m "phase 3: provider abstraction with cost and latency tracking"
git tag v0.4-providers
git push origin main --tags

This tag follows phase 2's v0.3-guardrails. Run git diff v0.3-guardrails v0.4-providers to see the whole phase in one view.