- Go 96.2%
- Dockerfile 2.8%
- Just 1%
| .just | ||
| .woodpecker | ||
| cmd/eval | ||
| internal | ||
| testdata | ||
| .gitignore | ||
| CHANGELOG.md | ||
| Containerfile | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| main.go | ||
| README.md | ||
spis-resolver-llm
The resolver that estimates the food phrases spis could not place.
vitals holds what was eaten as it was typed. spis says what it was made of,
from a table where every figure cites a Livsmedelsverket food number or a
barcode. What is left over is mostly food eaten away from home — 1 plate of loaded nachos, 1 portion of ragu, palmful of raspberries — and a table of
ingredients is permanently unable to place it. This is the second opinion on
exactly that remainder.
It is a spis resolver and nothing else: it implements that interface, is
served over the transport that package provides, and greets spis so it gets
asked. No CLI, no schedule, no idea a food record exists. Queues,
addresses, filing, precedence and re-reading all live over there.
What it is actually asked to do
Not to recall nutrition. spis hands over the foods its own tables offer for
the phrase and the recipe it holds for the dish, and the good answer is one of
those with a weight against it:
PHRASE: 1 portion of ragu
THE TABLE COULD NOT PLACE IT: nothing in the table names this
FOODS THIS KITCHEN CAN COST (id — name — per 100 g):
slv:6704 — Köttfärssås Bolognese m. nötfärs bacon [Rå] — 110 kcal, 8.0 g protein, …
RECIPE spis:bolognese — "Beef ragù", feeds 3 adult portions
one portion is about 330 g as bought
this kitchen could not cost it: bolognese has no portion to weigh against
· 550 g nötfärs = 550 g of notfars (182 kcal, 20.1 g protein per 100 g)
· 1 burk krossade tomater = 390 g of krossade_tomater (25 kcal, 1.1 g protein per 100 g)
· 1 tärning buljongtärning
Naming slv:6704 or the parts of that pot means spis computes the figures,
from the same tables and the same arithmetic its built-in resolver uses. An
egg read here and an egg read there come to the same numbers, because the
same code produced both — consistency is not a convention this is trusted to
honour, it is something it cannot break.
So the model's job is judgement: which of these is it, how much of it was
there, is a plate of nachos closer to 250 g or 450. Those are things a model is
good at and a prefix search is not — the mirror's search reaches Vattenkrasse
for water, and a rule that took the best hit would file watercress against a
glass of water.
The four answers, best first
| what it means | who supplies the figures | |
|---|---|---|
food |
one of the offered foods, with grams | the table — checkable |
parts |
several of them, for a dish with no single row | the table — checkable |
figures |
a composition the model supplied itself | the model — nobody can check it |
decline |
not food, or no idea | nobody |
decline is a real answer and often the right one. A phrase left unread
leaves the day short in a way somebody can see; a phrase read wrongly leaves it
wrong in a way nobody can. The record is built around that asymmetry and so
is the prompt.
Cooking yield is handled by choosing, not by arithmetic. The tables carry raw and cooked as separate foods with very different figures — pasta is 358 kcal per 100 g dry and 128 boiled — so a candidate's note says which it is and the answer names the cooked row. Nothing here applies a shrinkage factor.
What it files, and the mark that matters
Always estimated on the food axis, whatever the citation says. A reading
whose numbers came out of Livsmedelsverket because a model picked that row has
checkable figures and a guessed identification, and the record has a rung for
exactly that. Costing a food here must not launder the guess.
That mark reaches day totals, not just items — 105 g protein, 34 g of it estimated — which is the whole reason it exists. Nothing reads items.
An estimate is temporary by design. It is a resolved item, so the unread queue
never offers it again; spisd's nightly sweep is what replaces it with a real
reading the day the table learns that food.
Guardrails
Every number is rounded before it is filed — 50 kcal above 200, 10 below, and 5 g. Three significant figures on a guess is a claim, and 782 kcal propagates into a day, a week and a table somebody reads as measured. The grams are always coarsened, because that is a guess whoever answers; the macros only when the model supplied them, since rounding a table lookup would coarsen it for sitting beside a guess.
The step tracks the width of the error, and that width is not constant. A tomato is eighteen kcal, and to the nearest fifty it is filed as fifty — not a coarse statement of eighteen but a different number, wrong by a factor of three, saying a salad cost what a biscuit did. The same step that tells the truth about a plate of nachos lies about vegetables. The note beside each reading names the step it actually got, so a figure never claims a precision the ladder does not have.
Refused rather than filed:
- a reading of no weight — it would mark the phrase read, take it out of the queue, and leave the day quietly short
- more than 3 kg in one phrase — a unit confusion,
2 dlread as 2000 g - denser than fat (>9.5 kcal/g) — impossible whatever the food was
- macros that do not account for the energy on Atwater 4/4/9, in a wide band, with a fixed allowance for the rounding that precedes the check — 20 kcal, which is one step of carbohydrate. Without it the check measured its own arithmetic: a leaf of lettuce is 1.5 g of carbohydrate, which rounds honestly to nothing, and nothing was then asked to account for the energy that survived. That refused every vegetable in the record until it was noticed.
A refused answer is not remembered, so a model that produced an impossible figure once is asked again rather than served it forever.
Providers
Three names, one implementation. OpenAI, OpenRouter and Google speak the same
/v1/chat/completions shape and differ in a base URL, a key, how a model is
named, two optional headers and a setting or two around the edges — none of
which is a difference in the protocol. They stay three names because the
citation has to say which one answered, and
openrouter/anthropic/claude-opus-5 is longer than it looks like it should be
and is the truth.
The compatibility covers the messages, the schema and the token ceiling, and stops there. Each service has its own vocabulary for the settings around those, and an unknown field is refused rather than ignored — so the request is built common and each provider adds its own names to it.
LLM_PROVIDER=openrouter # openrouter, google or openai
LLM_MODEL=google/gemini-3.7-flash # as that service names it
OPENROUTER_API_KEY=… # or GEMINI_API_KEY / OPENAI_API_KEY
Neither has a default, and this refuses to start without both. A default service and a default model would be a decision nobody made: every reading this files cites the model that supplied its figure, and "whatever the binary shipped with" is not something to have cited under a month of numbers. The choice moves as well — it is measured over real phrases, and what won in August is a question again in November — so it lives where changing it is a deployment somebody reviewed rather than a constant that rides along with an unrelated upgrade.
What production runs, and why: openrouter and google/gemini-3.7-flash, on
the evidence below. Prefer the pinned id over the google/gemini-flash-latest
alias — it points at the same model today and it is a name that moves, which is
the one thing a citation must not do.
Reaching Gemini directly
Google publishes an OpenAI-compatible endpoint beside its own API, so this is the same adapter with a different base URL and its own spelling for how hard to think:
LLM_PROVIDER=google
LLM_MODEL=gemini-3.7-flash # no vendor prefix on this route
GEMINI_API_KEY=… # from aistudio.google.com
Note that Google's own examples show the native API — a different URL on the
same host, X-goog-api-key instead of a bearer token, and a contents/parts
body. This uses the OpenAI-compatible endpoint beside it, which wants
Authorization: Bearer and the shape every other provider here speaks. The same
AI Studio key works for both.
That key is not the same thing as a Gemini app subscription. AI Pro / AI Ultra / AI Plus are consumer plans for the Gemini app and NotebookLM; API access is issued separately by AI Studio and billed through a Google Cloud project, which has its own free tier. Check by opening aistudio.google.com and looking for Get API key — if it issues you one, that is the credential this wants, whatever you are subscribed to.
The citation distinguishes the routes: google/gemini-3.7-flash direct against
openrouter/google/gemini-3.7-flash through the router. The same model reached
two ways is two things to trust, and a bad batch is found by grepping citations.
It runs for nothing, and nothing is roughly what it is worth on a busy
evening. Seventeen phrases put through the free tier came back with seven
503 This model is currently experiencing high demand and one dropped
connection — a pool shared with everybody else on the free tier, and it decides
whether a phrase is read tonight. The nine that got through were fine, which is
the point: what the free tier costs is not money, it is availability, and
that is why production goes through the paid route and this one is for
experiments. The measured price there is about $0.003 a phrase, so a few new
ones a day is around a quarter a month.
The quotas bite before that anyway. A key's free tier allows a few hundred questions a day, and this asks one phrase at a time, only for phrases the table declined, only once each — a few new ones a day is a fraction of that. The minute cap is the one that hurts, and only on a backlog: a model answers in about three seconds, so left alone a pass runs at twenty a minute.
Hence -pace, default twelve seconds, which is five a minute. That is what
the door does, not what the page says — the published figure is ten a minute
and the refusal that comes back reads limit: 5, model: gemini-3.7-flash. It
costs nothing where it is not needed — the errand runs quarter-hourly and a pass
is a handful of phrases — and a pass that runs out of its minute declines the
rest, which come back next time. RESOLVER_PACE=0 turns it off.
That default is a Google free-tier number, and it is the wrong one anywhere
else. Twelve seconds against the coordinator's one-minute budget is five
phrases a pass — a rate limit nobody asked for, on a paid route with its own and
much higher one. Set RESOLVER_PACE to what the service being paid for actually
allows; through OpenRouter that is nearer a second than twelve.
Even then a pass is bounded by what the model takes: about three seconds a phrase, so fifteen or so in a minute however small the pace is. A backlog therefore still runs past the budget, which is why what happens next matters.
A pass that does run out of time now declines what it did not reach, and
spis files the readings it did produce. That used to be a lie in this
paragraph: the coordinator's patience is a client timeout, so the connection
died under a response that was already computed and every answer in it was
thrown away — a pass filed nothing at all until the whole queue happened to fit
inside the minute. The budget travels in a header now (Spis-Read-Budget), the
resolver package turns it into a deadline that expires just before the
coordinator gives up, and a phrase there is no time left to ask about is
declined rather than asked and abandoned mid-question.
Where a service does refuse for asking too often, it says when to come back.
Google states it twice over — as a RetryInfo and again in the sentence,
"Please retry in 4.423233869s" — and spis-eval, which is the only thing here
that waits rather than shrugging, takes it at its word. Its own idea of how long
to wait was wrong in both directions: twenty seconds where the answer was
fifty-four, which retries into a closed door and spends another request against
the exhausted quota, and twenty where the answer was two.
Keep OpenRouter for spis-eval even if production goes direct. One
credential reaching every vendor is what makes the comparison possible at all;
a Google key can only ask Google. The eval would also eat the daily allowance
seventeen questions at a time.
Why that model
Measured with spis-eval over the same seventeen real phrases, not chosen off
a model card — and through OpenRouter, which is the route production asks on:
| model | chose | supplied | declined | broken | run | median |
|---|---|---|---|---|---|---|
gemini-3.7-flash |
7 | 9 | 1 | 0 | $0.043 | 3.1s |
gemini-3.1-flash-lite |
7 | 6 | 4 | 0 | $0.014 | 1.5s |
claude-sonnet-5 |
6 | 9 | 1 | 0 | $0.113 | 3.9s |
gpt-oss-120b |
2 | 1 | 12 | 1 | $0.002 | 3.9s |
The two Gemini models tie on how often they name a food out of the table, which is what makes a reading checkable. They separate on the remainder — where this kitchen offers nothing at all, the lite model declines and this one estimates:
4 slices energibröd lite: decline flash: 140 g, dense seeded bread
1 plate of loaded nachos lite: decline flash: 350 g, cheese/meat/toppings
1 portion pasta with ragu lite: decline flash: 400 g, pasta med köttfärssås
Those are the phrases nothing else will ever answer, so covering them is the whole job. Price decides nothing at this volume — a few new phrases a day behind the cache is pennies a month whichever wins.
Revisit it with the harness rather than by reasoning about it. Behaviour moves under a fixed model name, seventeen phrases is a small sample, and what this measures is how often a model produces a checkable reading rather than how often it is right — that needs the judgement file below.
OpenRouter in production, and no Anthropic adapter. One account reaches every model including Anthropic's, and the open question here is which model can read a Swedish food diary well enough to be trusted with a day's totals — answering that wants a config change, not a new adapter and a second account. Hand-rolled rather than through a vendor SDK, because no official Go SDK covers OpenRouter: an SDK would serve at most one of the three and this file would still exist.
A model that cannot honour the schema costs coverage, not correctness. If it answers prose instead, the parse fails, the phrase is declined and stays unread — which is the safe direction. Worth watching when trying a small or free model: everything declining looks like nothing working, and it is.
Swapping provider does not rewrite history. An item already filed is out of
the unread queue, so this never sees it again; a config change affects only new
phrases, and existing estimates stay attached to the model that made them. Doing
it deliberately is spis stale --resolver llm-v1, which re-asks from scratch.
The answer cache is keyed by phrase, provider and model, and prompt version. The model in that key is the load-bearing part: without it, switching provider would serve the old model's numbers under the new model's citation.
Running it
SPIS_URL=http://spis.home.arpa:8080 the coordinator
SPIS_TOKEN=… spis's own token — see below
RESOLVER_URL=http://spis-resolver-llm:8080 where spis calls back
Two secrets, in two directions. The token spis presents when it calls is
generated here at startup and handed over in the greeting — "use this to talk to
me" — so nobody places it at both ends and a restart invalidates the old one.
SPIS_TOKEN is the other direction: greeting asks for a place in the chain that
writes into a food record, so the door is the same one that guards the library.
The registration is a lease, renewed every minute. Miss five and spis skips
this and carries on down the chain; it picks straight back up when it returns.
So a resolver that is down costs a late reading rather than a wrong one, and a
container that moves needs no configuration change anywhere.
spis resolvers shows where it sits.
The first minute has no DNS, and that is fine
A boot looks like this, and nothing here is wrong:
06:35:10 spis-resolver-llm dda6c4a serving :8080, asking openrouter/google/gemini-3.7-flash
06:35:10 registering with http://spis.home.arpa:8080: … lookup spis.home.arpa on
[::1]:53: connection refused — retrying for up to 1m0s
06:35:17 reached http://spis.home.arpa:8080 after 7s
The image is FROM scratch built CGO_ENABLED=0, so name lookups are Go's own
resolver reading /etc/resolv.conf itself — no libc, no nsswitch, nothing to
fall back on. A container is a process with no init: the network around it, and
that file with it, are arranged a second or so after the binary is already
running. [::1]:53 is not a configured nameserver; it is Go's hardcoded default
for "there is no resolver file yet", so a greeting sent in that gap is fired at
the container's own loopback and refused.
Do not fix this by setting a nameserver. There was never one missing —
spis.home.arpa resolves perfectly a moment later, and under incus the gap has
measured about seven seconds. resolver.Register waits it out for a minute
before giving up, so the boot costs those seconds and nothing else. A DNS= on
the unit would be a setting nobody needs, added for a failure that had already
stopped happening.
What the window does not cover is a name nothing serves or a coordinator
that was never deployed — those look identical for the first second and are not
survivable, so after the minute the process dies with the reason in the log. A
refused greeting is not waited out at all: a wrong SPIS_TOKEN is an answer,
and it costs a boot rather than a boot and a minute.
The same gap applies outbound: openrouter.ai is equally unresolvable for those
seconds. Nothing asks a resolver that early in practice, and a phrase that did
land there fails once and is offered again on the errand's next pass — a late
reading rather than a wrong one, which is the same property the lease gives.
Choosing a model
cmd/eval asks several models the same questions and prints what each made of
them. The open question is which one can read a Swedish food diary well enough
to be trusted with a day's totals, and that is not answerable from a model card.
go build -o spis-eval ./cmd/eval
OPENROUTER_API_KEY=… ./spis-eval -provider openrouter \
-models "google/gemma-4-31b-it:free,anthropic/claude-opus-5"
OPENROUTER_API_KEY=… ./spis-eval -provider openrouter \
-models anthropic/claude-opus-5 -repeat 3
GEMINI_API_KEY=… ./spis-eval -provider google -models gemini-flash-latest
-provider has no default either, for the same reason LLM_PROVIDER does not:
the route is half of what is measured. The same model through OpenRouter and
through Google's own endpoint is priced differently, rate limited differently,
and — on the free tier — available differently.
It runs against testdata/asks.jsonl — the questions exactly as spis put
them, captured once from the real unread queue and committed. So the only
thing that varies between two runs is the model, and a result from last month
stays comparable after the library has grown. Nothing needs spis or vitals
running.
PER MODEL
MODEL CHOSE PARTS MADE DECL REFUSD BROKEN MEDIAN COST
openrouter/google/gemma-…-it:free 4 0 1 5 0 6 1.2s $0.0000
openrouter/anthropic/claude-opus-5 6 0 1 9 0 0 3.4s $0.0192
CHOSE/PARTS |
named foods from the table — figures anybody can check |
MADE |
supplied its own figures — nobody can check those |
DECL |
said it did not know, which is a real answer |
REFUSD |
answered, and the guardrails would not file it |
BROKEN |
no answer at all: ignored the schema, refused, HTTP failed |
A model already measured is not paid for twice. Runs are kept in
eval-results.json, and adding a candidate to the list only asks the new one:
openrouter/openai/gpt-oss-120b: kept from 2026-08-29 21:53, not asking again
openrouter/anthropic/claude-sonnet-5: no current result, asking
What expires a kept run is the QUESTION changing, not the model list growing.
Every run records a fingerprint of the prompt, the schema and the request knobs,
plus one of the question set — so a new candidate leaves the others standing,
and raising a token ceiling invalidates all of them. Both are right, and neither
depends on anybody remembering: a max_tokens too low to fit a reasoning
model's answer once made gpt-5 look incapable, and every result from before
that fix was describing the bug. --fresh asks anyway.
Progress goes to stderr and the report to stdout, so spis-eval … > run.txt
keeps a clean report in the file and shows progress on the console. Off a
terminal that progress is one line per question with its timing and a note
whenever a rate limit is being waited out — a run against a busy provider takes
tens of minutes, and the difference between slow and stuck is worth being able
to see.
BROKEN is the first thing to look at on a small or free model. Not every
endpoint honours a strict JSON schema, and one that does not answers prose —
which is refused, so the phrase stays unread. Safe, and useless: a column of
BROKEN looks like nothing working because it is.
Scoring against what a person decided
The columns above measure everything AROUND correctness. To score correctness somebody has to say what a fair answer looks like, once:
spis-eval -provider openrouter -models "a,b" -review # drafts testdata/judged.json
$EDITOR testdata/judged.json # set each verdict, narrow the bands, delete "drafted"
spis-eval -provider openrouter -models "a,b" # scores RIGHT / WRONG / MISSED
A band where the weight is a guess, one number where it is not. Four slices
of bacon is 30 g or 60 g depending on the slice and both are fair, so demanding
one number there would score honest answers wrong. A Barebells bar is 55 g
because it is printed on the wrapper, so a range there would accept a model
that guessed. "grams": [55] is exact, [25, 60] is a range, and anything else
is complained about rather than ignored — the version that read a one-number
band as no band looked tight and silently accepted any weight.
That exactness works because a guess is rounded to 5 g before it is filed, so 55 is a weight an answer can land on.
And some phrases are not a resolver's job at all. A Barebells bar has a
barcode; Open Food Facts answers it exactly and for ever. Asking a model to
guess at something with a wrapper measures a hole in the library rather than the
model, so "verdict": "elsewhere" scores it apart — it would otherwise punish
the honest answer, which is to decline what you cannot know, and reward a lucky
guess. The fix is spis off <barcode> --add, after which the phrase resolves
through the table and leaves the fixture on the next capture.
Worth knowing what that implies about the whole exercise: the fixture is a snapshot of the library's gaps, and a good few of them close by scanning a packet rather than by finding a better model.
-review is safe to re-run, and re-running it as candidates arrive is the
point: a judgement already settled is kept exactly as it stands, and only the
evidence beneath it is refreshed. So the file accumulates every model's answer
to every phrase side by side — which is the view that shows one model declining
a drink as "not a food" while another finds the right row, and it is where most
of what is wrong with the prompt turns out to be visible.
A draft is circular and the report says so. It is derived from what models
answered, so scoring against it only proves they agree with themselves. Each
entry carries "drafted": true until somebody has looked at it, and the report
warns for as long as any remain.
RIGHT |
answered within what was accepted, or declined where declining was right |
WRONG |
answered outside it, or answered where there was nothing honest to say |
MISSED |
declined a phrase somebody decided was answerable — safe, and no use |
UNJUDGED |
nobody has decided, or the model produced no answer to judge |
ELSEWHERE |
never a resolver's job — a barcode or a row in the table closes it |
A failure is never scored. A model that produced nothing has said nothing about the food, and folding that into accuracy would mix "wrong about bacon" with "the provider was busy".
The set is 16 phrases, being what the table could not place out of 23 real ones
— brands (4 slices västerbottenost), restaurant food (1 plate of loaded nachos), a drink (4 dl prosecco), and 1 foo bar, which is in there because
spis offers it five candidates and the right answer is still to decline.
To refresh it after the library or the queue has moved:
SPIS_URL=… SPIS_TOKEN=… RESOLVER_URL=http://127.0.0.1:8791 ./spis-eval -capture
spis resolve # in another shell; no --apply, nothing is filed
It registers under the real resolver's name, so it is offered exactly what the real one would be, and declines everything.
Building it
spis is on the private Forgejo, so a checkout needs a credential that can
reach it. After any change to which version this requires:
go mod tidy # records the hashes for spis and what it pulls in
That is not optional and it cannot be skipped by building against a sibling
checkout: a replace pointing at ../spis resolves the code without ever
touching go.sum, so everything compiles locally and CI fails on the first
go mod download with missing go.sum entry. The image build does the same
thing for the same reason.
Not done yet
wrangle inithas not been run, so there is no changelog tooling and no branch or release pipeline..woodpecker/image.ymlis hand-written and is not wrangle's business.- Nothing has been run against a real provider. The request shape, the parsing, the rounding, the checks, the cache and the chain are all tested, and the whole path has been driven end to end against a local stand-in — but no request has ever left the building. The first real call will be the first real call.