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:
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:
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:
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:
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:
[1] read_file backend.py -> ok
openai/gpt-oss-120b: 1148 tokens, 812ms total
Now change .env without touching any code:
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.pystill has the old import line. Replace it withfrom backend import get_provider.ValueError: unknown backend.BACKENDis set in.envto something other thangroq, or it's misspelled.PROVIDERShas one entry, so remove the line or set it togroq.- Every run reports the same token count regardless of the task.
total_tokensis declared inside thefor steploop instead of above it, so it resets on every step instead of adding up. - The final
tokens... totalline never prints. Check that theprintcall sits beforereturn message.content, not after it. - A logged tool
resultlooks like aCompletionobject instead of text, or the run crashes with anAttributeErroron a string. The tool-loop variable is back toresult, overwriting the oneprovider.completereturned a few lines up. Rename it toresult_text(or anything other thanresult) everywhere thefor call in message.tool_callsblock uses it. KeyError: 'GROQ_API_KEY'. Same fix as phase 0: confirm.envis 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 runpython harness.pyagain.MODELloads once when the process starts, so a process already running won't see the change. AttributeError: 'Completion' object has no attribute 'model'.harness.pygot updated butbackend.pydidn't. Openbackend.pyand confirmCompletion.__init__takes amodelparameter and setsself.model, and thatGroqProvider.complete'sreturn Completion(...)passesmodel=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
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.