Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
45 KiB
Backlog
What a review of the code on 2026-10-04 (commit c2238ab) found should be done
next: security fixes first, then guardrails, then features. Written for
whoever implements it — a person or a model — without the review in hand.
Every item names the files it touches and what "done" is. Paths are relative
to the repository root. Sizes: S about a week, M two to four weeks, L more.
Conventions this codebase keeps, which every item here must keep too:
- A change is refused before anything is written (validate, then persist).
- Nothing on the serving path logs prompt text, keys or passwords.
- Each item gets unit tests beside the code (
*_test.go), and the TUI (internal/cli/tui_*.go),docs/console.mdandCHANGELOG.mdare updated when behaviour an operator sees changes. go test ./...,go vet ./...andgovulncheck ./...pass. No release is cut and nothing is deployed to the PoC cluster without being told to.
A. Security — do first
Status (2026-10-04): done and merged. A1–A9, A6b included — etcd and Patroni's API on TLS, the PoC moved in maintenance mode without a failover. Deliberately narrower than written: the admin API is a token on loopback rather than a unix socket (A5); thread ids are bounded and documented as project-wide rather than scoped per user (A8); Python engines resolve with an explicit PyTorch index and a PEP 751 lock rather than locks shipped per engine version (A8). All of A is live on the PoC.
A1. Toolchain (S, hours)
govulncheck finds 11 standard-library vulnerabilities reachable from this
code at Go 1.26.3 (net/http, crypto/x509, os.Root, net/textproto). Raise the
go directive in go.mod to 1.26.6 or later, rebuild, run govulncheck ./... until it reports none reachable. Add govulncheck to the Makefile's
check target.
A2. Release signing is effectively off (S)
internal/release/trusted.pubis empty, soTrustedKey()returns "" andrelease.go:103skips signature verification. Ship a real public key; make a missing or invalid signature fatal. Drop theLLMGW_RELEASE_KEYenvironment override, or honour it only in-tags devbuilds.internal/onboard/onboard.go:365: the node-side download checks only a same-originchecksums.txt. Verify the signature there too (simplest: have the node runllmgwitself to fetch and verify once it has any binary), andshellQuotetheReleasesURL, which is interpolated into single quotes unescaped.internal/agent/upgrade.go:44: accept onlyhttps://release bases.- Done when: a tampered binary or checksums file is refused by self-upgrade, node onboarding and agent upgrade, with a test for each.
A3. Agent port without TLS is anonymous and plaintext (S)
internal/cli/server.go:423 serves h2c on :7443 when agent_tls is not
configured; agentsvc then accepts any node id. Refuse to start control
mode without TLS unless agent_tls.insecure: true is set explicitly; when
insecure, bind agent_listen to 127.0.0.1 and log a warning at startup and
in llmgw doctor. Keep server init writing TLS as it does.
A4. Model profiles reach root on every node (M)
Profile args and env from a model definition are appended to the engine
command unchecked (internal/agent/engines/llamacpp.go:170, vllm.go:151,
sglang.go:98); internal/agent/engines/unsafeenv.go is a denylist that
misses NCCL_NET_PLUGIN, NCCL_TUNER_PLUGIN, NCCL_PROFILER_PLUGIN,
CUDA_INJECTION64_PATH, NVTX_INJECTION64_PATH, TRITON_* paths; engines
run as root.
- Replace the env denylist with an allowlist (CUDA_/NCCL_ tuning keys minus
anything ending in
_PLUGINor containingINJECTION,VLLM_*,HF_*minus path keys,OMP_*,GGML_*). Apply at catalog validation (internal/control/catalog/catalog.go, whereUnsafeEnvis called) and again in the agent. - Deny engine flags that write files or run code:
--trust-remote-code,--log-file,--slot-save-path,--chat-templatewith a path,--host,--api-key,--port; vLLM/SGLang equivalents. Allowlist is better than denylist if the flag set is enumerable per engine. - Run engines as an unprivileged user with the video/render groups
(
internal/agent/supervisor, systemd unit ininternal/cli/serverinit_cluster.go:52). Weights cache must be readable by it. - Done when: a profile with
--trust-remote-codeorNCCL_NET_PLUGINis refused atctl model addand at the agent;psshows engines not as root.
A5. Admin API trusts any local process (M)
internal/cli/server.go:357 serves the admin API and pprof on
127.0.0.1:9090 with no authentication; adminapi.actorOf
(internal/control/adminapi/api.go:1613) takes the actor from the
client-sent X-Llmgw-Actor header.
- Serve the admin API on a unix socket (
/run/llmgw/admin.sock, 0660, groupllmgw-admin) by default, keeping TCP as an option that requires a bearer token read from a root-only file (/etc/llmgw/secrets/admin-token). - Take the actor from
SO_PEERCRED(uid → username) on the socket, or from the token's name; ignore the header. - Refuse
admin_listenon a non-loopback address unlessadmin_insecure: true. - Put pprof behind its own
debug.pprof: truesetting, off by default. ctlClient(internal/cli/ctl.go), the console andnode addmust work over the socket;sudo llmgwalready runs as root.
A6. Patroni REST and etcd are unauthenticated and plaintext (M)
internal/pgha/config.go:99 writes a restapi: with no authentication
and :54 etcd over http:// with no client auth; hba.go:56 PATCHes
Patroni's config unauthenticated. Anyone reaching :8008 or :2379 owns the
store.
- Generate a Patroni REST username/password at setup, store it in
/etc/llmgw/secrets/, writerestapi.authenticationandrestapi.certfile/cafilefrom the cluster CA; send it frominternal/pgha/hba.goandstatus.go. - etcd: client and peer TLS from the cluster CA with
--client-cert-auth. - Document, and have
server initwrite, nftables rules limiting 2379/2380/8008/5432 to member addresses (see A9). - Done when: an unauthenticated
curl :8008/configis refused and the store status in the console still works.
A7. PostgreSQL traffic is plaintext (S)
internal/pgha/config.go:168 and internal/cli/serverinit_postgres.go:330
use sslmode=disable; HBA lines are host. Enable server TLS in Patroni's
postgresql parameters with a certificate from the cluster CA, write
hostssl lines, use sslmode=verify-full&sslrootcert=/etc/llmgw/ca.pem.
Existing clusters: llmgw server must keep working against a store that
has not been migrated, with a warning.
A8. Smaller fixes, each S or less
- Capacity fallback skips the allowlist.
internal/router/router.go:700builds[canonical]+entry.Fallbackswithoutpermits. Filter the chain withr.permits(p, model); price against the model that served. - Suspended users keep working API keys.
store.(*DB).Resolve(internal/store/memberships.go) never joinsusers.state;Authenticator.Invalidate()is never called. Reject keys whose owner is not active; callInvalidateon revoke, suspend, limit change. - Join codes never expire server-side.
internal/join/join.go:103checks expiry only inParse;admit(:212) does not. Check it there; bind the listener to the chosen address, not:7444on all interfaces; use the peer's address rather than the claimedhello.Addr, and show the joiner's address for the operator to confirm before secrets are sent. - Leftover sudoers grant.
internal/onboard/gossh.go:248writesNOPASSWD:ALLand removes it only inClose()(:271). Write the grant with a self-removal (at/systemd-run timer, 30 min) so a crash leaves nothing; wipe password buffers after use. Inssh.go:39pass--before the target. - Auth cache and pre-auth DoS.
internal/auth/auth.go:127: unbounded map, one DB query per bogus well-formed key. Bounded LRU, negative cache with short TTL, per-IP token bucket before the DB. - HTTP server hardening.
server.go:351-360, the h2c agent server, both mirror servers: setReadHeaderTimeout(exists),IdleTimeout,MaxHeaderBytes, a body-read deadline,netutil.LimitListener; connectWithReadMaxByteson the agent stream. - Login/signup abuse. Default
signup_open: false; per-IP token buckets on/loginand/signup; bounded wait for argon2 slots; CSRF token on the login form; the per-address throttle must not lock the real owner out (use IP+address pairs). - Trusted proxies.
internal/web/account.go:557,landing.go:60honourX-Forwarded-Proto/Hostfrom anyone; addrouter.trusted_proxiesand only then trust them;Cache-Control: no-storeon/setup/*. - Certificate revocation.
DELETE /api/v1/nodes/{id}only drops inventory; add a revoked-CN/serial list in the store checked inagentsvcConnectand mirroronlyClients;serverinit_clusteralways passes--api-client. - Engine exposure. Engines bind
0.0.0.0(llamacpp.go:102,vllm.go:109): bind the management address. SGLang receives its key on the command line (sglang.go:102): pass it by env or file. - Backups.
internal/cli/backup.go: encrypt archives to anagerecipient frombackup.recipientin server.yaml; keep the directory 0700. - Python dependency confusion.
internal/agent/provision/python.go:121uses--extra-index-urlwithunsafe-best-match. Pin torch to the PyTorch index explicitly (uv[[index]] explicit = true),first-index, hash-pinned lockfiles per engine version. - Control-plane SSRF.
internal/sourcecheck/sourcecheck.go:39andgguf.FromURLuse the default client; reuse the mirror'sfetchClient(false)(private-address filter) and refuse a proxy unless configured; block NAT64/6to4-embedded private addresses ininternal/mirror/safefetch.go. - Low:
agentsvc/service.go:430indexesn.GPUs[0]without a length check (CPU-only node panics the handler); thread ids are client-chosen with no length cap and shared per project (router/threads.go) — cap length, document, offer per-user scoping;/v1/modelslists models the key may not use (handlers.go:799); sessions have no idle timeout andPurgeSessions/PurgeClaimsare never called — add a janitor; add a CSP header and sethtmx.config.allowEval=false; cap GGUF array nesting depth ininternal/gguf/gguf.go; mirror.sourcesidecars are served by the public file server — exclude dotfiles from:8081; prunemodelserve.jobsand the mirrorrunningmap; KaTeX → 0.16.21+.
A9. Firewall (S)
No machine in a default install has a firewall. Have server init, node add and server add write an nftables ruleset: ssh open; :8080 open;
:7443 and :9091 open to the cluster's networks; :9100 to the
monitoring host; :2379/2380/8008/5432 to store members and control
planes; mirror :8081 to the cluster, :8082 to control planes; :9090
Prometheus and :3000 Grafana to operators. llmgw doctor reports a
missing ruleset. Make it skippable with --no-firewall.
B. Guardrails (M–L, build in this order)
Status (2026-10-04, branch guardrails-b): B1–B4 done, and tried live on
the PoC. That found four bugs, all fixed with tests:
- a shadow default merged with an attached policy of unwritten mode left it in shadow;
- a streamed word held back at one release was dropped at the next;
- llama.cpp continues a conversation ending on the answer, so Llama Guard's verdict on answers is now asked with its own prompt as a completion;
- a judge model was named in a client's "no endpoint" error.
The PoC runs observe (shadow) for everyone, and Llama Guard 3 1B
(llama-guard-3-1b, CPU, on cuda01) as a judge, which blocks a harmful
request in ~0.7s and a harmful answer in ~1.4s there. Still left: see each
item.
Today nothing on the request path reads, classifies, redacts or filters
message text in either direction (confirmed; the only "moderation" hit is a
comment in internal/router/budget_fallback.go:20). Existing controls are
about who, how much and how fast: allowlists (router/handlers.go:450),
RPM/TPM/budget (internal/accounting), context precheck
(router/context_limit.go), admission bands (router/admission),
timeouts (router/proxy/proxy.go:26-57). Gaps to close on the way: no
default output cap when the client omits max_tokens; catalog.Capabilities
is advertised but never enforced; tool results are trusted input.
B1. Hooks in the proxy and router (S)
Done (branch guardrails-b): internal/guard holds the interface;
proxy Filter/StreamFilter; the input hook runs after loadThread (the
simpler choice); error codes, headers, Responses incomplete, usage columns
guard_*. Moved to B2: holding delta.tool_calls until they are whole and
reading their arguments — that is the tool policy's filter, not the proxy's.
internal/router/proxy/proxy.go: add optionalInputRewrite func(body []byte) ([]byte, Verdict)on the request and two output hooks:FilterforrelayBuffered(betweenReadAllandWriteHeader) andStreamFilter{OnEvent(data) (emit []string, stop bool); OnEnd()}forrelayStream, called where each whole SSE event is assembled (eventBuffer, beforeio.WriteStringat:343). Nil hooks cost nothing.internal/router/handlers.go: call the input policy after the allowlist and limits and before admission (around:395); the principal, scopes and the model that will serve are known there. Thread history is spliced later (:416): scan stored turns when written, or move the hook afterloadThread(decide; the second is simpler).- Blocking conventions: input → 400
invalid_request_errorwithcode: "content_policy_violation"(todaywriteErroralways sendscode: null); output non-stream → 200 withfinish_reason: "content_filter"and empty/redacted content; output stream → a final chunk{"choices":[{"index":0,"delta":{},"finish_reason":"content_filter"}]}, thendata: [DONE], then cancel upstream. Headersx-llmgw-guard: blocked|redacted|flaggedandx-llmgw-guard-policyset before the proxy writes headers. internal/router/responses.go: mapcontent_filtertoresponse.incompletewithincomplete_details.reason: "content_filter"(today it always emitsresponse.completed,:525).- Tool calls: hold
delta.tool_callsuntilfinish_reason: "tool_calls", then validate;replyDelta(reply.go:33) must also readtool_calls[].function.arguments. - Accounting: a blocked input is recorded like
RecordRefused(nothing charged); a blocked output is charged (the GPU was used).store.UsagegainsGuardAction,GuardPolicy,GuardCategory,GuardMs.
B2. Package internal/guard (M)
Mostly done (branch guardrails-b): policies in server.yaml with
attachments in the store (ctl guard attach), narrowed by the limits' rule;
guard/detect (validated PII, secrets, injection through normalisation and
decoding); redaction with consistent placeholders before prefix routing;
tool allow/deny/strip, call count and argument schemas; capabilities and
structured output (opt-in); default max_tokens; pinned system prompt and
dropping the client's; no_store (threads, notes, prefix tree, external
models); shadow mode. Left: a guard_events table — the usage row's
guard_* columns carry action/policy/category/time, not rule and stage;
language identification; the re-identification map that restores
redacted values in answers.
Policy,Effectiveresolution over the tenancy tree with the same narrowing rule as limits (internal/store/sqlite.go:383-416tighten): deny sets union, allow sets intersect,modetakes the strictest ofenforce > shadow > off,failtakesclosed > open,no_storeat any level wins, thresholds take the minimum.- Store:
guard_policies(scope, scope_id, policy json)andguard_events(request_id, scopes, policy, rule, category, action, stage, score, judge_ms, shadow)— ids, categories, offsets and lengths only, never text. Migrations for sqlite and postgres. Pruned withusage_retention. guard/detect: regex/keyword sets; PII with validators — email, phone, IBAN (mod-97), card (Luhn), TCKN (checksum), IP, secrets (sk-, AWS, JWT, PEM); normaliser (NFKC, strip zero-width/bidi, homoglyph fold, base64/hex/URL decode to depth 2); injection heuristics (role-switch phrases, fake chat-template tokens, long base64, invisible Unicode) — default actionflag; language id.- Redaction rewrites
messages[].contentin the upstream body with placeholders ([EMAIL_1]) via the samemap[string]json.RawMessageround-tripwithModel/spliceThreaduse; an optional re-identification map lives only in request memory and restores originals in the output. Redact before prefix routing soPromptPrefixhashes the redacted text. guard/toolpolicy: tool name allow/deny (strip or 400), JSON-Schema validation of assembled arguments against the declaredparameters, argument regex rules,max_calls_per_turn. EnforceCapabilities.Tools/Visionhere: atoolsarray or image parts to a model without the capability → 400.guard/structured: validateresponse_format.json_schemaon the final text →content_filter, or flag in shadow.- Output constraints: inject a default
max_tokenswhen absent (from the policy orMaxOutput), language check, structured check. - System-prompt pinning per project: prepend before
checkContextFitsand beforespliceThread; optionally drop clientsystem/developerroles; exclude the pin from stored thread turns; apply to ResponsesinstructionsintoChat. no_store:loadThreadandPutNoterefuse;captureBytes()returns 0; skip the prefix-tree observe (router.go:840); disable External fallbacks for the scope (filter indispatchandbudgetFallback).
B3. Judge model on the cluster (M)
Done (branch guardrails-b): role: guard + guard.format; hidden and
refused to clients and fallbacks; critical by default, never on demand;
Router.Judge past admission/accounting/guardrails; llamaguard3,
shieldgemma and json parsers; 5-minute cache; latency budget then
fail open/closed; streamed answers read every 600 characters, or held with
buffer_all. Simpler than written: the input judge runs after admission, not
alongside it, and shadow mode judges synchronously within the budget rather
than after the response. Not enforced: two replicas on different nodes —
documented, left to scaling.min.
catalog.Model.Role: "guard"plusGuard{Format: llamaguard3 | shieldgemma | json, Categories}. Guard models are hidden from/v1/models, the chat picker and allowlists; placed like any model, classcritical,scaling.min ≥ 1, at least 2 replicas on different nodes.guard/judge: calls the guard model through the router's owndispatchwith an internal principal and its own admission band (never the tenant's queue); parsers per format (Llama Guardsafe/unsafe\nS1,S10; ShieldGemma yes/no probability → per-tenant thresholds; generic JSON verdict); verdict cache keyed by hash of normalised new turns (5 min);latency_budget(default 250 ms) thenfailopen/closed; input judge runs in parallel with admission queueing.- Streaming strategy: default sentence-buffered scanning (regex/PII per
sentence or ~200 chars before release; judge every 400–800 chars on the
cumulative tail; final judge at end);
buffer_allforfail: closedscopes. Document that released text cannot be retracted. - Shadow mode runs everything asynchronously after the response
(
context.WithoutCancel, likesaveThread) and only records.
B4. Config, metrics, UI (S)
Done (branch guardrails-b): library in server.yaml, attachments via
ctl guard, the console's Guardrails… with "applies from" marks, and
the portal's /admin/guard; the metrics and alerts below (judge latency
labelled by model only at the judge; the check latency by stage); ctl request shows the verdict; the chat page says "stopped by policy"; the
profile lists effective policies; docs/guardrails.md and data.md written;
the access log carries action, stage and policy only. Attaching from the
portal is not done — the page shows; the console and ctl change.
- Gateway-level policy library in
server.yamlunderguard:(see the YAML in the review, reproduced indocs/guardrails.mdto be written); attachments to scopes in the store viallmgw keys guard, the console ("Guardrails…" beside "Limits…" ininternal/cli/tui_people.go, with "inherited from team:X" badges) and the portal. - Metrics in
router/metrics.gostyle:llmgw_guard_checks_total{stage,policy,action,category,mode},llmgw_guard_latency_seconds{stage,judge_model},llmgw_guard_judge_errors_total{reason},llmgw_guard_fail_open_total,llmgw_guard_stream_stops_total,llmgw_guard_redactions_total{kind},llmgw_tenant_guard_blocks_total{tenant}. Alerts: fail-open > 1% for 5m, judge p95 over budget, judge has no ready replicas, per-tenant block rate3× its 7-day baseline, any
secretdetected in output. - Portal
/admin/guard: violations by tenant/category/policy over time, shadow vs enforce, judge health, recent events by request id;ctl request <id>shows the verdict; chat page renderscontent_filteras "stopped by policy X";/melists effective policies. - Update
docs/data.md: the judge sees all text but in-cluster; External fallbacks send prompts outside the cluster (the current "nothing leaves the cluster" is wrong);no_storesemantics. - Never log: prompt/completion text, PII values, the re-identification
map, judge inputs or rationale that quotes input. Check
accessLog'serrfield and span errors.
C. Features, ranked by value × feasibility
- Done (branch
features-c):audit_events, written by the admin API middleware and the /admin pages (with each change's result),ctl audit, the console's Audit view; staff roles viewer / operator / billing / admin. Left: before/after values (the request's fields are recorded, not the state they replaced); a tenant-admin role that changes its own organization (managers still only view it); hiding the forms a viewer cannot submit. Audit log and roles (M). Today "audit" is slog lines (internal/web/admin_users.go:403-480) and roles are theIsAdminflag plus an org "manager" who can view. Addaudit_events(who, what, before/after, source) written by theadminAction/tenancyActionwrappers and by every admin-API mutation; roles viewer / operator / tenant-admin / billing;ctl audit. Depends on A5 for a real actor. - Done (branch
features-c): OIDC sign-in (auth.oidc, PKCE, verified email, roles and memberships from groups,no_passwords) and SCIM 2.0 (/scim/v2Users and Groups). Left: the sign-in cookie is signed with a per-process key, so with two control planes behind a balancer the callback must reach the one that started it (sticky sessions) until the key comes from the store. OIDC/SSO login (M), then SCIM (M).internal/account/service.go,internal/web/account.go; map IdP groups to org/team/admin. - Done (branch
features-c): key lifetime limits (gateway, org, team, project),keys rotate --overlap, kinds and expiry inkeys list. Service accounts and key lifecycle (S). Per-org maximum key TTL,keys rotatewith overlap, last-used shown, project-owned service keys listed apart.internal/cli/keys.go,internal/store/keys.go,internal/auth/auth.go. - Done (branch
features-c): CSV export from the rollups (API, ctl, console). Left: Parquet, and a scheduled push to S3/SFTP. Usage export (S). CSV/Parquet by org/team/project/model/period from the rollups (internal/store/usageseries.go),GET /api/v1/usage/export, optional scheduled push to S3/SFTP. - Done (branch
features-c):notify.webhooks(model parked, node lost, budget spent, update available; signed JSON or Slack; retries, hourly de-duplication),ctl notify test, Alertmanager receiver templates. Left:server initand the console do not yet ask for a receiver — the templates are filled in by hand; each control plane sends budget events on its own, so two behind a balancer may both send one. Alerting out of the box (S).deploy/monitoring/alertmanager.ymlshipsreceiver: none; add Slack/email/webhook/PagerDuty receiver templates driven byserver initor the console; llmgw-native webhooks for model parked, budget spent, update available (from the reconciler andmodelserve). - Done (branch
features-c2):metadata,x-llmgw-tagsanduseron the usage record (migration 0023),ctl requestshows them,router.tag_labelsmetrics with 100 values per tag. Left: export andctl usageby tag or end user (the hourly and daily sums do not carry tags; it would read the request records, within their retention). Request metadata and tags (S). Acceptmetadata/userfrom the body orx-llmgw-tags; store on the usage row; tenant metric labels behind an allowlist.router/handlers.goparse,accounting/recorder.go. - Done (branch
features-c2):ctl model canary/promote/canaries, session-sticky split withx-llmgw-variantpins, rollback on error rate or TTFT,canary_rolled_backnotification, console menu. Left: A/B between two different models (the split is between a model and its newer weights); the comparison is per control plane and lives in memory, so a restart begins it again; a key-level pin. Canary and weighted A/B between model revisions (M).control/modelserve/updates.goreplaceswaps every replica; add weighted routing across two revisions/aliases (scheduler filter, alias resolution inrouter/router.go:569), a header/key pin, automatic rollback on error-rate or TTFT regression from per-replica metrics. - Done (branch
features-c2):/v1/filesand/v1/batches(OpenAI's shape), worker under a store lease, lines kept as served, lowest band, usage taggedbatch=<id>, output and error files with retention,no_storerefused. Left: the Responses API (/v1/responses) as a batch endpoint; the console andctldo not list batches (an operator sees them through usage tags); a file is one row in the store, so a store backup carries batch files with it. Batch API and Files (M–L)./v1/files,/v1/batches, JSONL storage, a worker feeding the bronze band, accounting. Newinternal/router/batch. - Done (branch
features-c2):keys requests(retention, sample), kept after redaction,ctl request --body/--replay, console, pruning. Left: a streamed answer is kept as its text, not its chunks; replay is one request at a time (a set of them against a model is item 21's eval). Request/response logging and replay (M). Opt-in per project with retention like threads; sampled or full; redaction hooks from B;ctl request <id> --body; replay against another model. - Done (branch
features-c2): Responsestext.format,input_image,previous_response_id(thread in the id); rerank and score passthrough; transcription and translation as multipart;/v1/moderationsby the judge. Left: audio is charged no tokens (an engine's duration usage is not read);input_imagebyfile_id; moderation scores are 0 or 1, as a judge says yes or no. OpenAI compatibility gaps (S–M each). Responses:text.format→response_format,input_image,previous_response_idvia threads (router/responses.go). Rerank/score passthrough for vLLM,/v1/audio/transcriptionsfor a whisper engine,/v1/moderationsmapped to the guard model. - Done (branch
features-c2):/v1/messagesandcount_tokens, streamed and not, tools, images,x-api-key, Anthropic error shape. Left: extended thinking (a reasoning model's thoughts are not mapped to thinking blocks); prompt caching; the account page's "Connect your editor" does not offer Claude Code yet. Anthropic/v1/messages(M). Translate likeresponses.go. - Done (branch
features-c2):server restore(dry run, version check, decryption, modes and owners, SQLite and PostgreSQL),backup_copy_to(directory or ssh), the drill in docs/install.md. Left: restoring a replicated store's members (restore into the primary; Patroni replicates it); the console's Back up now does not offer restore; an ssh copy holds the archive in memory while it sends it.server restore(S). Restore is manual shell today (docs/install.md:1187); add the command with version check, dry run, off-host copy target; a documented DR drill. - Done (branch
features-c2): TPOT, inter-token and tokens/s histograms by model, pool and tenant;slopressure in admission's saturation;llmgw_model_slo_secondsandllmgw-sloalerts. Left: the p99 is per control plane, from its own requests; an SLO per band or per tenant; placement does not yet add replicas for an SLO (item 14). SLO-based admission and token-level metrics (S–M). TPOT and inter-token histograms, output tokens/s per model and tenant inrouter/metrics.go; usecatalog.SLOinadmission/saturation.go. - Done (branch
features-c2):scaling.schedule(days, from, to, min, max; past midnight),scaling.prewarm,control.timezone; queue-depth scaling was there already. Left:ctl scalesets min and max only (a schedule is set in the definition); scaling for an SLO rather than for queue depth. Scheduled and warm-pool autoscaling (S–M). Time-of-day min/max, pre-warm, queue-depth scaling incatalog.Scaling,control/reconciler/demand.go,headroom.go. - Done (branch
features-c2):env:andvault:references wherever a secret file is named (store address, api_key_file, OIDC, SCIM, webhooks), Vault KV with token or AppRole,secrets.master_keysealing cluster_secrets (Vault transit or a local key). Left: the certificate authority's key is still a file in /etc/llmgw (signing in Vault's PKI engine instead is its own change); rotating the master key;backup_copy_keyandadmin_token_fileare files only. Secrets management (S–M). Vault/KMS forapi_key_file, the DSN, the CA key,cluster_secrets(store/secrets.go,store/seal.go); envelope-encrypt them in backups (with A8 backups). - Done (branch
features-c2):users forget(pseudonymised usage and sums, kept requests, files, batches, limits and audit names),keys residency --in-cluster(asked-for, fallback and canary). Left: an export of everything held about a person (article 15); residency by region between several clusters; backups keep what was forgotten until they age out. GDPR and residency (S). "Forget user" anonymisesuser_idin usage and rollups and erases threads/memory (store/hierarchy.go:241leaves it today); per-project "never route to an External provider". - Done (branch
features-c2): exact-match answer cache per project (keys cache --ttl/--any/--off, console Cache answers…,projectscolumns, 30-second setting cache), reproducible answers only unless--any, LRU in memory bounded byrouter.cache.max_bytesandmax_entry_bytes, streamed answers kept and replayed as chunks,x-llmgw-cacheandCache-Control: no-cache, guardrails before a hit and the output policy on it,no_storeand threads bypass, a hit recorded ascache_hitwith no tokens and no cost (migration 0027), metrics, docs. Left: semantic (embedding-similarity) caching through the embeddings models; a cache shared across control planes (each process keeps its own, deliberately: a shared one would write every answer to the store); a hit still waits its turn in admission, because the guardrails judge the request after admission and the key is the request they let through; logprobs in a streamed answer leave it unkept;users forgetdoes not reach into the cache (answers age out by the TTL); no listing or flushing of what is cached (--offstops it being served at once, the memory is freed by TTL and LRU). Response/semantic cache (M). Exact-match first, per project, opt-in, TTL; semantic via the embeddings models later. Beforedispatchinrouter/handlers.go. - Done (branch
features-c2): offline bundles —llmgw bundle create(releases with signed checksums, every llama.cpp build per arch/variant with GitHub's checksums, starter/judge and named Hugging Face model files pinned and checked; directory or tar; manifest sha256),bundle import(checked, all-or-nothing, into the mirror under_llmgw/),bundle trust,bundle show --verify;offline.mirror/offline.checksumsin server.yaml, with nodes installing llama.cpp from the mirror against checksums the control plane sends over the agent stream (protoEngineWant.offline_*), releases defaulting to the mirror fornode add,ctl upgradeand agent upgrades, update checks off;server init --bundleand the setup form's bundle fields; docs/data.md's offline claim corrected; install.md "Offline sites". A Helm chart for the control plane (deploy/helm/llmgw, one replica, SQLite PVC or external PostgreSQL, Secrets, Services, Ingress, ServiceMonitor) withdeploy/container/Dockerfileand docs/kubernetes.md; GPU nodes stay outside Kubernetes by design. Left: vLLM/SGLang offline (a wheelhouse of several GB per CUDA build and a uv--find-linksinstall path); the NVML release binary and uv in bundles; the setup wizard's preflight still fails a machine without internet;join's printedcurlfrom the mirror checks nothing; models in a bundle are single files (no whole- repository safetensors sets yet); the chart runs one replica (a standby is never ready, so HA in Kubernetes needs a different readiness design), has not been installed on a real cluster, and ships no agent DaemonSet; no published container image. Kubernetes/Helm and an air-gapped bundle (M).deploy/,agent/provision;docs/data.mdclaims offline operation but install assumes the internet. - Done (branch
features-c2):adminapi.Routesandinternal/docgen/openapigeneratingdocs/admin-api.openapi.json(3.1, bearer security, every route, query parameters, schemas from the Go types with their comments), served embedded atGET /api/v1/openapi.json, kept true by a route-table test and a staleness test; validated with openapi-spec-validator and openapi-generator-cli.GET /api/v1/models/{name}.docs/admin-api.md.terraform-provider- llmgw/(terraform-plugin-framework):llmgw_model,llmgw_quota,llmgw_guard_attachment, data sourcesllmgw_modelsandllmgw_nodes; client tests against a fake admin API and the OpenAPI document; acceptance tests with Terraform and OpenTofu against the fake; one apply, empty plan, in-place change and destroy against a realllmgw serverrun by hand. Left: organizations, teams, projects, people and keys are not in the admin API (they arellmgw org/users/keysagainst the store), so not in the provider; a model's definition beyond scaling, class, owner and adapters is changed by replacing it (the API has no edit, andresourcesis not used yet);canary,slo,scaling.scheduleandprewarm,guardrole andoriginare not inllmgw_model; models cannot be imported; the provider is not published to a registry and has no release build; the acceptance tests are not run in CI (they need Terraform); the document's schemas mark nothing required and give no enums;catalog.SLO's fields have no JSON tags, so they areTTFTp99andTPOTp99on the wire. OpenAPI for the admin API, Terraform provider (M). Generate fromadminapi/api.goroutes viadocgen. - Done (branch
features-c2): spans for the reconcile pass (begun only when a pass does something), placement with its answer or rejections, scale-up/down, drain, stop, eviction, preemption, reaping, restarts and lost nodes; areplica startspan from placement to serving whose context travels to the agent in the start command (ControlMessage.trace), where weights fetch, engine launch, rendezvous and readiness wait continue the trace; stops, drains, adoption and engine installs; modelserve jobs as a trace with a span per step linked to the replica's start; an agenttracingblock; docs in install.md's "Tracing". Left: the router's span for a request does not link to the replica it was sent to's start; heartbeats, fabric scans, logs and agent upgrades are not traced; the supervisor has no restart span because it never restarts an engine itself (a restart is the reconciler'srestart replicaand reaches a node as a stop and a start); canary and promote jobs, which landed onfeatures-c2after this was branched, are traced only through the shared step recording (as<kind> model), unchecked by a test. Tracing beyond the router (S). Spans for reconciler, placement, agent start (obs/trace.go,control/reconciler,agent/supervisor). - Done (branch
features-c2):internal/bench/eval(suites in YAML or JSON lines; exact, contains, regex, number, a JSON Schema subset, and a judge model with a rubric; weights and a threshold), run on the control plane throughRouter.Replay;llmgw ctl eval MODEL --suite FILE|NAME,--history,--run,--suites; runs and their cases kept (migration 0028,eval_runsandeval_cases);model update/promote --eval SUITE --min-score N, a model's standing gate (ctl model gate,eval:in the catalog) — a promote asks the canary first, an update tries the new weights as a 0% canary and promotes or removes it; the console's model menu;arithmeticandjson-formatindeploy/eval/, carried in the binary. Left: the suite of a model's standing gate is read by name on the control plane at the time of the update, so two control planes need the samecontrol.eval_dir; a gated update needs room for one more replica while it runs (there is no in-place gate, and a declared model in server.yaml cannot be updated at all); no comparison against the old weights' score (the gate is an absolute threshold, not "no worse than before"); a run is not stopped once started (Esc in the console only stops watching it), and the in-memory progress of a run lives on the control plane that started it; runs are never pruned; the JSON Schema support is a subset (no$ref,oneOf,not,uniqueItems); judge cases go through the same concurrency as the model's, and a judge's answers are not kept, only its score and reason; a gateway without a control plane cannot run suites (there is no mode that asks through the client API with a key). Eval harness (M).llmgw evalextendinginternal/benchwith quality suites gatingmodel update. - Done (branch
features-c2): MIG instances discovered on both paths (nvidia-smi-L+-q -x; NVML through an interface with a faithful fake) and reported instead of their card, each with its MIG UUID, own memory and class<card class>-mig-<profile>; device numbers 1000 + 100 × card + instance, shown ascard/migN; placement on MIG classes; tp/pp/gpus/nodes above 1 on a MIG class refused in catalog validation and again in placement;CUDA_VISIBLE_DEVICES=MIG-<uuid>for vLLM and llama.cpp (no Vulkan device variable on MIG); per-instance memory and processes in heartbeats and the GPU check, card sensors under each instance, utilisation absent rather than zero; doctor MIG findings (mig -lgi/-lciparsed);ctl nodes, console,ctl status,ctl model plan/serveby class; docs/install.md MIG section. Written and tested against fakes only — untested on real MIG hardware. Left: run it on an A100/H100 in MIG mode (check-L/-q -x/mig -lgi/-lciand NVML MIG device names against the parsers, and thatCUDA_VISIBLE_DEVICES=MIG-…reaches vLLM's workers through Ray); instances changed while the agent runs are only seen when it restarts (no rescan); llmgw does not create or reshape instances (no mig-parted integration); quotas count an instance as one GPU, the same as a whole card; SGLang on MIG not checked; device numbers assume fewer than 100 instances per card and that instance numbers stay put while shards run on them. MIG (M–L). Treat a MIG slice as a GPU class inagent/discovery/gpu_nvml.goandcontrol/placement. - Done (branch
features-c2): enginetrtllm(internal/agent/engines/trtllm.go):trtllm-servethrough a launcher of llmgw's that demands the replica's key (trtllm-serve has none) and writes the options file that turns on/prometheus/metricsand the iteration statistics; the scrape contract for it (scrape.TRTLLMMapping, with a per-engine metrics path); found, not installed —engines.trtllm.pythoninagent.yaml, probed from the package's metadata; catalog validation (GPU class, one node, no GGUF source, the flags a profile may not give);ctl engine-check trtllm, which askstrtllm-serve serve --helpthrough its module. Ollama/GGUF import:mirror import-ollama(manifest parsing, blob digest check, projector beside the model, all or nothing, a model definition printed),mirror importfor any local file, and the console's I on the Mirror view. Left: TensorRT-LLM has not run on real hardware — the first node with one should runengine-checkand serve a model before anyone relies on it; no installer for it; no multi-node TensorRT-LLM replica (it needs an MPI or Slurm launch from one node); Triton Inference Server as an engine is not done; trtllm's queue gauges are set per iteration, so an idle engine shows its last batch until the next request;--num_serve_frontendsis refused because extra front ends would not demand the key. The Ollama import runs on the mirror machine only — the mirror's API takes no files, so an Ollama elsewhere means copying the blob or running the import there; Ollama models kept as separate tensors or as several weight layers, and its LoRA adapters, are refused or left with a sentence; files an import run withsudowrites are root's, asmirror add's are. More engines (M each). TensorRT-LLM/Triton adapter; Ollama/GGUF import.agent/engines/engine.gointerface and the metric contract inrouter/scrape. - Lower: precise KV-index routing (plan phase 6), LoRA affinity for
vLLM, multi-cluster federation via External providers, facility-aware
placement (
docs/facility-aware-placement.md), portal i18n, SDK/docs portal. Done so far (branchfeatures-c2):- 24 (portal i18n): built — English/Turkish catalogue with fallback, per-
language templates, cookie/Accept-Language/
landing.languagechoice and a footer switcher, Turkish numbers and dates, every template and handler message translated, tests that no template text stays English; left — errors built with fmt.Errorf in other packages (store, catalog, mirror, adminapi) still English on Turkish pages, percent sign placement is mixed (%50 on limits, 93% on cluster pages), RFC3339 footer timestamps and plain-text http.Error bodies untranslated, the /docs documents themselves English, a per-person language column (cookie only today). - 24 (SDK/docs portal): built — /docs renders the embedded manual (goldmark,
GFM, heading anchors, links rewritten, no script; public,
landing.docs_sign_into require a session), and docs/clients.md shows the official OpenAI/Anthropic SDKs against llmgw. Left: the console's generated "Commands" page is not in the portal (it is built from the cobra tree, which internal/web cannot see); no search across documents.
- 24 (portal i18n): built — English/Turkish catalogue with fallback, per-
language templates, cookie/Accept-Language/
D. Facts worth knowing before starting
- Admin API:
127.0.0.1:9090, no auth,ctlClientininternal/cli/ctl.go. Metrics on:9091with/prometheus/targetsbeside them. - Store:
internal/store, migrations underinternal/store/migrations/{sqlite,postgres}; the PoC runs PostgreSQL under Patroni. - The console (
sudo llmgw) isinternal/cli/tui_console.gowith actions intui_actions.go; every operator-visible change needs a row action or a form there, and a line indocs/console.md. - Tests of the console use a fake admin API (
internal/cli/tui_actions_test.go,adminStub); tests of serving use fakes ininternal/control/modelserve/modelserve_test.go. go generate ./...regeneratesdocs/reference.mdandinternal/docgen/reference_gen.gowhenever a config struct changes; the docgen test fails otherwise.