Backends and model routing
The relay ships two backends. They are deliberately different kinds of adapter — one supervises a subprocess, the other is an HTTP client — which is what proves the neutral model in the middle is doing its job: adding a backend touched neither the wire adapters nor the core.
claude |
ollama |
|
|---|---|---|
| What it is | the claude CLI, one supervised subprocess per request |
a local Ollama server over HTTP |
| Cost | your Anthropic subscription | free (runs on your machine) |
| Streaming | ✅ | ✅ |
| Client-defined tools | ✅ via the MCP bridge | ✅ natively — but only on some models, see below |
max_tokens |
❌ not enforced (no CLI flag) | ✅ enforced (num_predict) |
temperature / top_p / top_k / stop |
❌ dropped (signaled) | ✅ honored |
| Images / PDFs | ✅ via the attachment bridge | ✅ natively (base64 images) |
| Agentic execution | ✅ opt-in | ❌ — Ollama only generates |
| Reported cost | total_cost_usd from the CLI |
0 (local inference) |
Capabilities are resolved per request, from the backend that will
actually serve it: ask haiku for max_tokens and you get the
not-enforced warning; ask a local model and it is enforced.
Ollama and client tools: the tools badge is not enough
This matters the moment you point an agent client (OpenCode, LangChain…) at
a local model: an agent sends its toolset on every request, even a plain
"hello". So a model without tool calling fails outright — Ollama answers
"llama3 does not support tools" and nothing works, tools or not.
Worse, Ollama's tools capability badge is necessary but not sufficient.
A model can carry it and still write the call as text (a markdown JSON block)
instead of emitting a structured tool_call that Ollama parses — in which case
the client never runs it, and the turn just ends. Whether it works depends on
the model and its Ollama template, and the only way to know is to try.
Measured on an Apple M4 / 16 GB, with an OpenCode-sized request (~5k characters of system prompt, four tools):
| model | time | real tool_use? |
|---|---|---|
granite4.1:3b |
~3 s | ✅ |
llama3.1:8b |
~10 s | ✅ |
qwen3.5 (9.7B, thinking) |
13–45 s | ✅ but slow |
qwen2.5-coder (3b, 7b) |
3–5 s | ❌ writes the call as text |
phi3, llama3, dolphin-mixtral |
— | ❌ no tool support at all |
Check a model before relying on it:
ollama show <model> | grep -i tools # necessary
# then actually send it a tool and see whether a tool_call comes back
Thinking models
The backend sends think: false. A thinking model (qwen3.x) otherwise puts
its reasoning in Ollama's thinking field and the answer in content only
once it is done — and since the backend surfaces content alone, the caller
would get an empty answer, or a silent gap long enough for an agent client to
cancel. See the roadmap
for surfacing it properly instead.
Routing by model name
The client keeps doing what it does against the real API — naming a model. The relay decides which backend that means:
flowchart LR
req(["model: ?"]) --> route{"in<br/>RELAY_MODEL_ROUTES?"}
route -- "granite4.1:3b…" --> ollama["ollama backend<br/>(local, free)"]
route -- "unrouted (haiku, sonnet…)" --> def["RELAY_BACKEND<br/>= claude (subscription)"]
RELAY_BACKEND=claude \
RELAY_MODEL_ROUTES="granite4.1:3b=ollama,llama3.1:8b=ollama" \
./relay
# same endpoint, same client, same credential — different engine
curl … -d '{"model":"haiku", …}' # → claude CLI, your subscription
curl … -d '{"model":"granite4.1:3b", …}' # → local Ollama, free
Anything without a route goes to RELAY_BACKEND. A route naming a backend
that does not exist refuses to start (fail fast) rather than silently falling
back.
This is the DQ-2 answer: routing by model name keeps clients backend-agnostic — no per-provider URL prefix, no custom header — exactly as the real API behaves, and as other relays (LiteLLM, OpenRouter) do it.
Why route at all?
- Spend where it matters. Send classification, extraction, or draft work
to a local model for nothing, and keep the subscription for what needs it.
The cost accounting shows it plainly: a
haikurequest logscost_usd=0.0237, a local one logscost_usd=0. - Get the API features the CLI lacks. Sampling control and enforced
max_tokenswork on the Ollama backend — route a model there when a workload actually needs them. - Keep working offline. A local model answers with no network at all.
What this is not: a provider router
Routing exists here to compose the sources you already own — a subscription reachable only through its CLI, and local compute you run yourself. It is not there to aggregate HTTP providers.
If a service has an API and you hold a key for it (Mistral, Groq, OpenRouter,
Together…), a backend here would add nothing: call the API directly, or put a
real router in front. The relay already speaks /v1/chat/completions, so
LiteLLM (or any OpenAI-compatible router) can register agent-relay as one of
its providers — each tool doing its job:
your clients ──▶ LiteLLM (routing, retries, budgets, many providers)
├──▶ agent-relay ← your Claude subscription, as an API
├──▶ Mistral / Groq / … (direct, they already have APIs)
└──▶ …
What justifies this project is the other half — turning a subscription-bound agent CLI into a well-behaved API: the MCP tool bridge with process parking, agentic execution as a service, session continuity, and the security guarantees around a subprocess running as your user. None of that is routing.
Adding another backend
One package under internal/backend/, registering itself in an init():
func init() { core.Register("mybackend", New) }
It implements three methods — Name, Capabilities, Infer — and the rest
of the relay (auth, limiter, timeouts, both wire formats, traces, cost
accounting, rate limiting) applies to it unchanged. That is the whole point
of the neutral model (REQ-BK-03).
But first, check it earns its place: a backend is worth adding only when it brings a source of tokens you already own that has no other API — a subscription behind a CLI, or a local runtime. Not to add a provider, and not to prove the architecture (Ollama did that, once). Every adapter is permanent surface: upstream drift, tests, docs, one more column in the table above.