No description
  • Go 96.2%
  • Dockerfile 2.8%
  • Just 1%
Find a file
Viktor Varland a12bfe848b
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/image Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
docs: changelog for v0.3.0
2026-08-30 11:43:12 +02:00
.just fix: refuse a mistyped provider instead of panicking 2026-08-29 22:59:09 +02:00
.woodpecker fix: refuse a mistyped provider instead of panicking 2026-08-29 22:59:09 +02:00
cmd/eval feat!: name the service and the model, or do not start 2026-08-29 23:24:42 +02:00
internal feat: pass the line through to the question 2026-08-30 11:43:04 +02:00
testdata fix: keep a settled judgement when reviewing again 2026-08-29 22:34:11 +02:00
.gitignore feat: keep a run so a model is not paid for twice 2026-08-29 21:56:24 +02:00
CHANGELOG.md docs: changelog for v0.3.0 2026-08-30 11:43:12 +02:00
Containerfile docs: say why a boot logs a DNS failure and needs no DNS setting 2026-08-30 08:38:23 +02:00
go.mod chore(bump): spis 2026-08-30 11:42:00 +02:00
go.sum chore(bump): spis 2026-08-30 11:42:00 +02:00
justfile fix: refuse a mistyped provider instead of panicking 2026-08-29 22:59:09 +02:00
main.go feat!: name the service and the model, or do not start 2026-08-29 23:24:42 +02:00
README.md docs: do not claim what the deployment was running 2026-08-30 09:26:29 +02:00

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 dl read 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.jsonlthe 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 init has not been run, so there is no changelog tooling and no branch or release pipeline. .woodpecker/image.yml is 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.