ADR-058 — The LLM gateway: one institutional key, a daily cap, a call log¶
Status¶
Accepted (2026-10-01, decisions of the product owner). The gateway is the
foundation of LLM assistance; ADR-059 (the
"Generate" wand of the editor) and ADR-060
(the nightly review of the questions) build on it and are accepted.
ADR-060 §5 amends the daily cap with a nightly review share;
ADR-063 supersedes §8 below by wiring grading to the gateway.
ADR-080 amends §1 (a multi-turn converse() with
read-only tools, the assist purpose), §4 (the assistant's conversations are
stored, outside llm_calls) and §5 (the assistant's share of the cap).
Delivers F-LLM-04 (the call log) and N-SEC-08 (the key encrypted at rest)
for the platform's own key. Delivered with apps/api/src/modules/llm/
(gateway.ts, service.ts, anthropic.ts, crypto.ts, routes.ts),
@quiz/domain/llm, @quiz/contracts/llm, the migration llm_gateway
(which inserts the settings row), the check llm.budget, the audit actions
llm.settings and llm.test, and the "AI" tab of the Administration page.
Amends:
- F-LLM-01 (docs/spec/02): one provider (Anthropic) and one institutional key, entered by an administrator; no per-teacher key and no OpenAI-compatible client for now. The interface stays provider-agnostic so that both can come back.
- Open question 13 (docs/spec/06), now settled: the institution pays, and one model serves every purpose, Claude Sonnet by default.
- docs/spec/05 §5.3 (
llm_callsgains the purposestestandreview, astatus, and a nullableuser_id;providersbecomes the singletonllm_settings) and §5.6 (the default model is Sonnet for every purpose, grading included). - docs/spec/00, risk table: the per-teacher key was the mitigation of the provider's cost; the daily cap (§5) replaces it.
- ADR-010, rejected alternative 3 ("secrets in the database"): one secret, the provider key, is stored in the database, ENCRYPTED by a master key that lives in the environment like every other secret (§3).
- ADR-045: phase 1 left grading unchanged. That intermediate state is historical: ADR-063 now defines gateway grading and supersedes §8.
Defers N-DATA-05's "no-retention mode" and the data-protection review of what is sent to the provider to a new open question (docs/spec/06, row 43).
Amended 2026-10-11 (security audit L2), scope: §5. The cap is no longer
only a guard against a loop: one teacher must not exhaust the institution's
day. (a) A person's share: a call made for a person is reserved only if
their own spend today (calls in flight at their worst case) stays within
LLM_USER_DAY_SHARE (0.2) of the cap, a constant of @quiz/domain like
ADR-060's and ADR-080's shares, checked under
the same lock before the cap, refused as budget_exhausted (so 429
llm_budget_exhausted on the screens) and, like ADR-080's share, written
nowhere — the llm.budget check stays green. Grading (grade) is left out
(USER_SHARE_EXEMPT of @quiz/domain): a pass is billed to the
evaluation's author and bounded by the students' answers. (b) Re-grades:
an item the model graded is re-graded at most LLM_REGRADES_PER_ITEM_PER_DAY
(3) times a day, each one sending every answer back to the model; the next
is 429 llm_budget_exhausted before anything changes (checkLlmRegradeBudget,
counted on the audit log's grading.regrade entries marked llm, whose
payload shape RegradeAudit the writer and the count share). No lock spans
the count and the entry: two re-grades of one item pressed at the same
instant may both pass; the budget bounds a day, not a race. (c)
Payload: the question configuration a model route takes (the wand, the
concept suggestion, the assistant's editor draft) is at most
LLM_CONFIG_MAX_CHARS (200 000) characters of JSON, refused by the contract.
Amended 2026-10-09: §2 says what the screen writes (models.default only) and how a call resolves its model (the purpose's own, the purpose's fixed model, the default, the platform's), as modelFor does.
Amended 2026-10-12 by ADR-106
§4 (scope: §1's purposes and the L2 amendment above): a purpose project,
exempt from the user share, with a regrade cap of its own; the rules are
ADR-106 §4's.
Context¶
ADR-045 gave the grading pass a call path and a deterministic stub, and left the real provider for later. Three features now want a model: the grading proposals (F-GRADE-02), the "Generate" buttons of the editor (F-LLM-02, ADR-059) and a review of the published questions (ADR-060). They need the same things first: a key, a model, a record of what each call cost, and a guard against a bug that loops on a paid API overnight.
The spec planned a provider list with a key per teacher (F-LLM-01) and Opus for grading (question 13). The product owner decided otherwise on 2026-10-01: the platform pays, the use is small (teachers authoring), and the current Sonnet is good enough for every purpose. A quota per teacher is not wanted; a ceiling against runaway spending is.
Decision¶
1. One gateway, called by purpose, with a validated output¶
The llm module owns an LlmGateway with one method:
purpose is a closed union (test, grade, generate, review, poll since ADR-072, and assist since ADR-080, whose converse() is the gateway's second method), userId
the teacher the call is made for (null for a call no person made), schema
a zod schema the reply must satisfy. The gateway asks the provider for a
STRUCTURED output (JSON matching the schema), parses it, retries once on an
invalid reply, and returns the parsed value or throws a typed LlmError
whose code comes from a closed vocabulary (not_configured,
key_unreadable, budget_exhausted, auth_failed, rate_limited,
refused, invalid_output, timeout, provider_error; LLM_ERROR_CODES
of @quiz/contracts).
A provider is an adapter behind LlmProvider (apps/api/src/modules/llm/);
the only one is Anthropic, through the official SDK. An OpenAI-compatible
adapter, or a European or local model, is a second adapter and a second
value of the provider column, nothing else.
No sampling parameters. The current Anthropic models refuse a
temperature other than the default (a 400 on Sonnet 5.5). A reproducible,
sober answer comes from the structured output, its zod validation and a low
effort for the short purposes, not from a temperature.
2. One settings row, a model per purpose, one model in the screen¶
The table llm_settings holds ONE row (id = 'default', a CHECK): the
provider (anthropic), the encrypted key and its last four characters, the
model per purpose (models jsonb, { purpose → model id }), the daily cap
in USD, who changed it and when. The administration screen offers ONE model
select and writes it as models.default only. A call resolves its model
(modelFor, @quiz/domain/llm) as: the purpose's own entry of models,
else the model fixed for that purpose in code (PURPOSE_MODELS: Haiku for
poll, ADR-071 §12, formerly ADR-072),
else models.default, else the platform's default. A per-purpose choice in
the screen is a screen change, not a schema change.
The selectable models are a closed list in @quiz/domain (LLM_MODELS),
each with its price per million input and output tokens: Claude Sonnet 5.5
(the default), Claude Opus 5.5 and Claude Haiku 4.5. A model leaves the list
the day the provider retires it; the list is code, reviewed like code.
3. The key: encrypted with a master key from the environment¶
LLM_KEY_SECRET(at least 32 characters) is the master key. It is a secret of ADR-010: in the environment file, in the age vault, never in the repository. Without it the gateway is off, the settings screen says so, and a stored ciphertext is ignored.- The provider key is encrypted with AES-256-GCM under a key derived from
LLM_KEY_SECRET(HKDF-SHA-256), with a fresh 12-byte nonce per write and the provider id as additional authenticated data. The row stores the nonce, the ciphertext and the tag, never the key. - The key is write-only: the API accepts it, stores it, and returns only
its last four characters. It is never sent back, never logged, never in
an audit payload. The log redaction (
apps/api/src/redact.ts) learnssk-ant-and thex-api-keyheader, with a test. - A lost or changed master key loses nothing but the key: the gateway reports
key_unreadable, and an administrator enters the key again. - Under
NODE_ENV=production,config.tsrefuses aLLM_KEY_SECRETshorter than 32 characters or equal to a development value, like the other secrets (invariant 3).
4. Every call is logged, never its content¶
llm_calls (owned by the llm module): id, created_at, user_id
(nullable), purpose, provider, model, input_tokens,
output_tokens, cost_usd, duration_ms, status (pending, ok,
error), error (the LlmError code). Never the prompt, never the reply
(F-LLM-04). The cost is computed when the row is written, from the price of
the model the provider says answered, and stored: a later price change does
not rewrite history. It is labelled an ESTIMATE everywhere it is shown.
5. A daily cap against runaway spending, checked before the call¶
Amended by ADR-060 §5: nightly review may spend at most its share of this cap. The cap still bounds all gateway calls.
The cap is not a quota. It exists so that a bug that loops cannot spend thousands overnight, and is set generously enough that no legitimate day reaches it.
- Default 20 USD per day, editable by an administrator in the settings,
bounded by
LLM_DAILY_CAP_MAX_USD(environment, default 100), so that a bug in the settings route cannot lift the ceiling either. - The day is the calendar day in
Europe/Zurich, of the server's clock (app.clock, invariant 5), computed by the database'sdate_trunc. - Reserve, then call. Under a transaction-scoped advisory lock, the
gateway sums today's
cost_usdand inserts apendingrow whose cost is the call's WORST case (maxTokensat the output price plus the prompt's length at the input price). Over the cap, the call fails withbudget_exhaustedand nothing reaches the provider; the day's first refusal is logged, at no cost, and no later one. After the call the row gets its real tokens, cost and status. Parallel calls therefore cannot overshoot together. - A refusal turns the health check of §7 red for the rest of the day. Each
caller words it for its reader: the connection test answers
{ ok: false, error: "budget_exhausted" }; the routes of ADR-059 will answer429 llm_budget_exhausted.
6. The connection test¶
POST /app/api/admin/llm/test, admin only, asks the configured model "What
is the capital of France?" with a schema { answer: string }, and succeeds
when the answer is Paris. It answers { ok, model, latencyMs, error? }, goes
through the gateway (logged, capped, purpose test, the admin as user),
is limited to a few per minute (Budget), and is audited as llm.test.
Saving the settings is audited as llm.settings (never with the key).
7. The system status reads, never calls¶
The periodic health checks (ADR-055) never call the provider: a check that
spends money every five minutes would spend it for nothing. service.llm
reads what the process saw of its own calls (tracked, ADR-055 §6), and is
not_configured without a master key (and without the grading stub); with a
master key but no stored key it reads unused, since that verdict is
synchronous, from the configuration alone. A new check, llm.budget, reads
today's spend against the cap: warn from 80 %, fail at the cap or once the
cap refused a call today (HEALTH_THRESHOLDS.llmBudgetWarn). Neither names a
person (ADR-055 §1).
8. Not wired into grading yet¶
Superseded (2026-10-02) by ADR-063, which wires the grading pass to the gateway after the product owner accepted sending student answers.
The grading pass keeps app.llm, chosen by LLM_PROVIDER as ADR-045 says:
stub in development, none in production. A stored key does NOT make the
essays graded by Anthropic. Before it does, the grading needs what ADR-045's
consequences list: the grading.llm queue, the scrubbing of names typed
into answers (N-DATA-05), the provider shown beside each proposal and a
reply per criterion. Phase 1 sends no student content and no question
content to the provider: the connection test is its only caller.
9. Usage, per teacher, for the administrators¶
A tab of the Administration page shows the settings (key, model, cap, test), today's spend against the cap, and the current month's usage per person and in total: calls, input and output tokens, estimated cost. It is personal data about staff, read by administrators only, like the user list.
Consequences¶
- An administrator pastes a key, picks a model, presses Test, and sees it green in the system status, without SSH.
- Every later feature calls
complete()and inherits the log, the cap and the error vocabulary; ADR-059 and ADR-060 add purposes, not plumbing. - One secret is added to the vault (
LLM_KEY_SECRET), and one secret lives, encrypted, in the database and therefore in its backups: the master key stays out of both. - The price table goes stale when the provider changes its prices; the cost is an estimate, said so, and the cap carries a margin.
- The OpenAI-compatible client, the teacher's own key and a per-teacher quota are not built. Each is an addition to this design, not a change of it.
Alternatives considered¶
- The key in the environment (
ANTHROPIC_API_KEY), as ADR-010 would have it. Simpler — no crypto, no form — but a rotation needs SSH and a restart, and the product owner wants the key managed from the console. The master key keeps ADR-010's principle where it matters: what decrypts the key is never in the database. - A per-teacher quota now. Not wanted: the use is small and a quota would block a teacher for an administrator's decision. The log already attributes every call, so a quota is a query away.
- A cap checked after the call. Simpler, but N parallel calls all see the same total and overshoot together; the reservation costs one lock.
- The test inside the periodic health checks. A paid call every five minutes, counted against the cap, to learn what the last real call already says.