The Grove data plane. One Go binary that terminates TLS, authenticates callers, picks an engine, proxies the request, meters what it cost, and upgrades itself without dropping a connection.
One binary, two planes, decided entirely by which id it is given:
| Gateway Server | Ingress Server | |
|---|---|---|
| env | GROVE_GATEWAY_ID |
GROVE_INGRESS_ID |
| holds | keys, users, groups, usage | nothing tenant-shaped |
| picks | a route (network or engine) | a replica in its own VPC |
| meters | yes | no — usage belongs to a tenant it cannot see |
Both ids set is a startup refusal. The tenant stages are not disabled on an ingress, they are never registered, so no handler on that box could read a key store even if one were pushed to it.
- Terminates TLS on
:443with the fleet wildcard, reloading it from disk when it changes, and redirects:80. - Resolves the caller — bearer → key → user → groups — and refuses on the credential, the credit balance, or the model grant.
- Picks an engine: region tier, capacity gate, session stickiness, least in flight.
- Rewrites the request body where the endpoint's schema allows it, and swaps the client's key for the engine's own.
- Proxies it, streaming the response through untouched, and reads the usage frame out on the way.
- Records tokens and the hop's outcome, on every request including the ones that were abandoned.
- Answers
/v1/modelsand/v1/creditsitself, and/metrics/nodebehind basic auth. - Upgrades its own binary and reloads its own configuration without dropping a connection.
Requirements: Redis (loopback, or the Network's shared store), a certificate on disk, and an admin token. It refuses to start without the last one, and refuses to start as an ingress without a data token.
Reading order if you are new to it: Where it sits → Layers → The request path. If you are about to change something, skip to How to do things and Things worth knowing.
┌──────────────────────────────────────────────┐
│ Grove (Frappe control plane) │
│ keys · users · groups · models · placements │
└───────┬──────────────────────────▲───────────┘
POST /grove-admin/state │ │ GET /grove-admin/usage + ack
every 2 min (hash-gated) │ │ hourly (drain)
▼ │
client ──► latency DNS ──► ┌───────────────────────────────────┐
api.<zone> │ GATEWAY SERVER (tenant plane) │
│ TLS · auth · quota · route · meter│
│ Redis: loopback or Network store │
└────┬──────────────────────┬────────┘
direct │ │ ingress
▼ ▼
┌──────────────────────┐ ┌────────────────────────────┐
│ engine box │ │ INGRESS SERVER (infra) │
│ nginx :80 │ │ picks a replica in its VPC│
│ └─ vLLM │ │ holds NO tenant state │
└──────────────────────┘ └────────┬───────────────────┘
▼ private IP
┌──────────────────────┐
│ engine box · vLLM │
└──────────────────────┘
The control plane pushes; the gateway never calls back. Grove projects its state into each
box's Redis over /grove-admin/* and pulls usage counters back out. Between syncs the
gateway is autonomous — if Grove is down, traffic keeps flowing on the last table it was given.
Two route kinds. A direct row names an engine the gateway dials itself. An ingress row names
an Ingress Server that will pick a replica of its own, so replica topology never leaves its VPC and
a pod restarting in one region is invisible to a gateway in another. Which kind a model gets is the
control plane's decision; the gateway just reads kind. An empty kind is direct — that is what
every route pushed before the split carried.
A gateway's state lives in one Redis: loopback, or the Gateway Store its Network's
gateways share (GROVE_REDIS_ADDR + GROVE_REDIS_PASSWORD). Its contents are either pushed (keys,
users, groups, routes) or derived (sticky, in-flight, health, usage). On a shared store in-flight is
one counter, so a directly dialled replica's cap holds across those gateways. A dead store fails its
gateways closed: nothing authenticates and /healthz reports it. The store has to be a single Redis,
not a Cluster: authentication is one script that follows a key to its user and groups, records it is
not handed by name.
The whole service is four rings, and imports only ever point inward:
┌──────────────────────────────────────────────────────────┐
│ transport/http net/http · httputil · crypto/tls │
│ ┌────────────────────────────────────────────────────┐ │
│ │ service admission · routing · metering │ │
│ │ catalog · transform · provisioning│ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ domain PickRoute · Evaluate · ParseUsage│ │ │
│ │ │ stdlib only. No I/O. No net/http.│ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └───────────────────▲────────────────────────────────┘ │
└──────────────────────┼─────────────────────────────────────┘
│ interfaces declared here
┌──────────┴──────────┐
│ repository │ ← redis/ and memory/ implement it
└─────────────────────┘
cmd/pathway the only place that knows all four exist
service depends on the repository interfaces, never on an implementation — so the arrow from
redis/ points up into repository, not sideways into service. That inversion is the only
reason the services are testable.
Five invariants carry the whole thing, and cmd/pathway/architecture_test.go enforces
them — each is one import statement away from being broken, with nothing failing and nothing
looking wrong:
| Invariant | Protects |
|---|---|
only transport/http/** imports net/http |
swapping the router stays a one-package rewrite |
only repository/redis imports go-redis |
swapping the store stays a one-folder change |
domain imports nothing from this module, and does no I/O |
the rules stay runnable in another process |
service never imports a repository implementation |
the services stay testable — tests exempt, that is what memory is for |
repository knows domain types and nothing else |
imports point inward, including where the compiler would not catch it |
Stated as tests rather than as habits, because the thing they prevent is silent: the service goes on working while the property that made it testable quietly goes away.
domain — the rules, with the I/O taken out. PickRoute gets a slice of routes and returns
one; it does not know where routes come from. Evaluate gets three records and returns a status.
ParseUsage gets bytes. Every hard decision in this service is one of these, and every one is a
pure function — which is why they carry the most test cases and the fewest mocks. It is also what
lets a different process run the identical rule: an ingress does, and a remote router could.
repository — what storage must answer, not how. The interfaces speak domain types and plain
values; no Redis vocabulary crosses the line. Deliberately coarse: Usage.Add(prefix, fields)
takes a map the service built, rather than the service knowing about HINCRBY. InFlight.Counts
returns numbers, not routes — the repository does not know what a Route is.
service — the orchestration between them. Fetch, apply a domain rule, act on the result. Each
service is a plain struct holding the repositories it needs, constructed once at startup. They
return domain.Denial — one refusal vocabulary — so no service invents its own error taxonomy for
a handler to translate.
transport/http — everything HTTP, and nothing else. Routing, decoding, status codes, TLS,
the reverse proxy, the signal loop. It is the thickest ring because that is where the framework
lives, and thick is fine as long as no decision hides in it.
| Decision | Rejected | Why |
|---|---|---|
Repository interfaces in one repository package |
one interface per consumer, Go-style | Nine repositories with one implementation each. Consumer-side interfaces would have meant nine near-duplicate declarations and no reader able to see the storage contract in one place |
| Services are concrete structs | an interface per service | An interface with one implementation is a lie about the design. The extension point is the middleware chain — see below |
One middleware.State per request |
a context key per field | The stages are ordered and each reads what the ones above wrote. That is a per-request record; N context keys would be the same object with the type safety spread thin |
| The chain is a list of names | a hardcoded handler tree | Order is the semantics here. meter below route is a correctness requirement, not a style choice, and a list makes it reviewable in one line |
| A separate transform registry | folding body rewrites into middleware | Different lifecycle: a transform is gated per endpoint and answers "did I change anything", so the body is re-encoded only when something touched it |
domain.Denial as the one error type |
per-package error types | Every gate speaks 401/403/429/503 already. One errors.As at the edge beats a translation table |
| The proxy is its own package | inline in the handler | It owns a transport pool and a response tee — real state with a lifecycle, and the only part that would change wholesale for HTTP/3 |
| Config split by lifetime | one config file, or all env | Identity needs a restart; tunables must not. Splitting on that axis makes "does this need a restart?" answerable by looking at where the value lives |
Extending this service means picking one of three, and the choice is not a matter of taste:
| Add a… | When | Costs |
|---|---|---|
| middleware | it needs the HTTP request or response — headers, body, timing, or the ability to refuse | one file + one Register + a name in the chain |
| transform | it rewrites the request body for particular endpoints | one file + one Register + a name in the list |
| repository impl | it changes where data lives, not what happens | one folder + one line in main.go |
The test: does it need to be on the request path, or is it a destination for data? Metering already
receives everything a usage row contains, so archiving usage to SQLite is a repository, not a
middleware — a middleware there would re-derive the same record from State and leave two places
that know how to read it.
No plugin system, no dependency-injection container, no code generation, no interface{} registry
of everything. Extension is a compiled-in file plus a name in a list, which is enough for a service
whose extensions ship in the same binary.
No abstraction over HTTP itself. transport/http is allowed to be net/http-shaped; hiding that
behind a "framework-agnostic" layer would buy portability nobody needs at the cost of every reader
having to learn a second vocabulary.
No metrics-per-anything. The access log carries what a request did; the process log carries why.
Warnings and worse are mirrored into error.log as well, pinned at Warn so moving the process log
level while hunting a failure never changes what that file holds.
A Prometheus surface is a middleware whenever someone wants one.
The rule everywhere: one owner per piece of state that can drift.
| State | Lives | Lifetime | Owner |
|---|---|---|---|
middleware.State |
request context | one request | the chain |
| identity, decision, usage line | inside that State | one request | whichever stage wrote it |
| resolved tunables | config.Live |
until SIGUSR1 | built.reload |
| the middleware chain | atomic.Pointer in Server |
until SIGUSR1 | Server.SetChain |
| transport pool | proxy.Proxy |
until a tunable changes | Proxy.Reconfigure |
| certificate | certLoader |
until the file's mtime moves | the loader |
| sticky, in-flight, health, usage | the box's Redis | minutes to a pull cycle | this box (or its store's gateways) |
| keys, users, groups, routes | the box's Redis | until the next push | the control plane |
The last row is the important one. Everything pushed is a projection: the gateway never edits it, never merges into it, and never treats a local change as authoritative. Anything it does own is either derivable (in-flight, health) or drained (usage).
One goroutine per request, as net/http gives it. What is shared between them:
config.Live—atomic.Pointer, swapped whole. A request that started under the old values finishes under them, which is correct for every knob here.- The middleware chain — one
atomic.Pointer[http.Handler], so a reload never rebuilds the mux or interrupts a request mid-chain. transform.Chain—RWMutex, read per request and replaced in place, so the middleware's pointer to it stays valid across a reload.- The transport pool —
RWMutexwith double-checked insert; replaced wholesale onReconfigure, so in-flight requests finish on the transport they started with. - The drain flag —
atomic.Bool. - Redis —
go-redispools connections; every multi-key read is a pipeline or one script, and the usage write is a transaction so a drain never sees half a request.
Nothing takes a lock across an I/O call, and no request-scoped value is shared between requests.
Kept open and reused. proxy.Proxy holds one http.Transport per target host and verification
setting, with Go's keep-alive: up to 64 idle connections per host, each closed after 90s unused.
ForceAttemptHTTP2 is on, so a target that offers h2 — OpenAI, Anthropic and Baseten all do —
carries its concurrent requests as streams on one connection, whichever of the vendor's keys each
one dials with: the key is a header, not a connection. An HTTP/1.1 target takes one connection per
request in flight, out of the same pool.
A dial and a TLS handshake are paid only by the first request to a host after:
- a start or a binary upgrade;
- 90s with no traffic to that host;
- a SIGUSR1 that changed any tunable, because
Reconfiguredrops the whole pool.
Each of those is a full handshake: no TLS session cache is set. Neither number is a tunable; both
are set in proxy.transportFor.
An HTTP/2 connection that has delivered nothing for 15s is pinged, and closed if no answer comes
within another 90s (PingAfter and PingTimeout in proxy.Options). A quiet one that answers is
pinged again every 15s; one that does not gets the single ping, which TCP keeps retransmitting, so
a blip shorter than 90s costs nothing. That is what finds a connection that died without a FIN:
under traffic it is never idle, so the 90s idle close never reaches it.
It is not architecture for its own sake — it bought three specific things:
- The decisions became testable without Redis.
PickRouteandEvaluatewere always pure; the layering is what stopped them being reachable only through a live store. The services got their first tests at all, viarepository/memory. - The data path became testable without nginx. When Lua owned the bytes, "does a stream reach
the client unbuffered" was not a question any test could ask. It is now one
httptestcase. - The seams are where change actually arrives. Every request since this was built — SQLite for
usage, offloading routing, a config file — landed on one of the three seams without touching
domainorservice.
| Question | File |
|---|---|
| Which engine gets this request? | domain/route.go — PickRoute |
| May this caller use this model? | domain/access.go — CanUse, Evaluate |
| How many tokens did that cost? | domain/usage.go — ParseUsage |
| Is this target broken? | domain/health.go |
| What does Redis look like? | repository/redis/ — every key name and TTL |
| The request pipeline | transport/http/middleware/builtin.go |
| The proxy itself | transport/http/proxy/ |
| Shutdown, drain, binary upgrade | transport/http/lifecycle.go |
| How a reload reaches running state | cmd/pathway/main.go — built.reload |
client
│ TLS, HTTP/2
▼
recover → accesslog → drain → auth → quota → body → modelaccess → payloadlog → route → meter → fallback → retry → transform → upstreamauth
│
▼
proxy ──► engine (or ingress ──► engine)
| Stage | Does |
|---|---|
recover |
panic → 500, so nothing below can drop a connection |
accesslog |
mints the request id, times the request, writes the one durable line per request |
drain |
while shutting down: 503 + Retry-After + "gateway is restarting" |
auth |
bearer → key → user → groups, once, into the request state |
quota |
the credit flag the control plane pushed, or a prepaid balance spent → 402; then the holder's rate limits → 429 + Retry-After |
body |
bounded read + JSON decode, or a streaming form parse; model and the session hint come out here. Not a JSON object, or no model in it (or in the form, or an upgrade's query) → 400; over max_body_bytes → 413; not all here within 60s → 408 |
modelaccess |
CanUse → 403 |
payloadlog |
the prompt as the client sent it and the output as it received it, one line per request to GROVE_PAYLOAD_LOG. Runs only when that file is set and the user's log_payloads is on. See The payload log |
route |
surface / sticky / region / capacity / least-in-flight, waiting up to capacity_wait for room; claims an in-flight slot, except on a vendor row |
meter |
deferred release + usage record — runs on disconnect, panic and dead upstream alike |
fallback |
runs the stages below again on the next model in the body's fallbacks when the one serving cannot (5xx, or a key failure retry could not rotate away). See Fallback models |
retry |
runs the stages below again with another credential when a vendor refuses the one dialled (429, 401/402/403); counts every attempt against its key |
transform |
the registered body rewrites; re-encodes only if one changed something |
upstreamauth |
swaps in the engine's internal key, sets the forwarding and ingress headers |
Order is load-bearing in two places. drain sits above auth, so a restarting gateway answers the
same way whether or not the caller's key is any good. meter sits directly below route, because
route claims a slot and everything below it must give that slot back.
The ingress chain is the same machinery, six entries: recover, accesslog, drain,
ingressauth, pick, upstreamauth.
model decides routing and access, and on the multipart endpoints — /v1/audio/transcriptions,
/v1/audio/translations, /v1/images/edits, /v1/files — it arrives as a form field rather than a
JSON key. body branches on the content type and reads the form through a tee, stopping at model,
then hands the request on as the exact bytes the client sent: same boundary, same part ordering, same
encoding. A form is never re-encoded, and never becomes a transform.Body, so the transform stage
skips it rather than trying to serialise a form as JSON.
Two consequences worth knowing:
- No priority injection. vLLM priority is written into the JSON body, and a form has nowhere to put it, so these requests run at the engine's default priority.
- Memory is bounded by a threshold, not by file size. A client that sends
modelbefore its file captures almost nothing. One that sends it last has to capture everything it walked past, and that goes to memory up to 1 MiB and to a temp file beyond — nginx'sclient_body_buffer_sizesplit, for the same reason. The file is created inos.TempDir()and unlinked immediately, so the descriptor is the only handle and the space returns when the request ends, including if the process dies first. SetTMPDIRon the unit to move it. An unwritable spill directory logs once and falls back to memory, whichmax_body_bytesstill bounds — an upload is not worth refusing over a full disk. - A request that announces a
Content-Lengthovermax_body_bytesis refused before anything is read; a chunked one is caught mid-stream instead, after the route was already picked. - Only what
bodyreads is on a clock. Every bodybodyreads — a whole JSON body, a form up to itsmodel— must arrive within 60 seconds of the headers or the request is a 408: header timeouts stop at the headers, and without this a client dripping its body holds a goroutine and up tomax_body_bytesof buffer for as long as it likes. The rest of a form streams through the proxy unbounded in time, on purpose: a deadline there would fail inside the hop, where a slow client reads as a failed upstream and counts against it.
A WebSocket upgrade — /v1/realtime, a live transcription session — is a GET with no body at all,
so body takes the model from the query string, which is where the OpenAI realtime API puts it
(?model=…, and ?user= for the session hint). It reads and restores nothing: stamping a
Content-Length on an upgrade breaks the handshake before it reaches an engine.
Everything else applies unchanged — the key is resolved, the grant is checked, a route is picked,
and /v1/realtime is not a path any output claims, so any model may serve it. Two consequences:
- Usage lands at disconnect, not at connect.
meteris deferred, and for a hijacked connection the handler does not return until the session ends, so an open session is unbilled for as long as it stays open — and it is onerequest_counthowever long it ran. There are no token counts: once the connection is hijacked the usage tee never sees a frame. - The in-flight slot is held for the whole session. The capacity gate was written for requests measured in seconds, so a long realtime session makes its engine look busier than it is.
ModifyResponse deliberately skips the usage tee on a 101: ReverseProxy needs that body to stay an
io.ReadWriteCloser to write back to the engine, and wrapping it fails the handshake outright.
The one place the gateway keeps customer content rather than metadata, so it is off twice over: the
box needs GROVE_PAYLOAD_LOG, and the user needs log_payloads (pushed on user:<name>, read per
request, so turning it takes effect on the next one). One JSON line per request, written when the
request ends — a client that hung up mid-stream still gets one, with what it had received:
rid (joins the access line), key, user, model, fallback (the model whose output this is,
when not the one asked for; else -), path (after the /anthropic strip),
status, prompt, output, output_encoding (only when base64), prompt_bytes,
output_bytes.
It sits between modelaccess and route: refusals above it (400, 401, 402, 403, 408, 413) leave no
line; everything from routing down does, once per request whatever fallback and retry did.
What is kept:
- Text, whole.
promptis the body as the client sent it, before any transform;outputis what the client received, after the model swap, error event included. Never truncated. A stream is its rawdata:frames, not reassembled text. - Inline media up to 256 KiB, as sent. Past it — a
data:URI anywhere, or base64 underdata/b64_json(OpenAIinput_audio,audio, generated images; Anthropicsource) — the item becomes[media image/png 834512 bytes sha256:9f2c…]: its type, decoded size and the first 16 hex of its hash, enough to match a file the customer sends without keeping it. - A file response (
audio/*,image/*,video/*,application/octet-stream— speech) is base64 withoutput_encoding: "base64"up to 256 KiB, else[media audio/mpeg 2097152 bytes]; the recorder stops holding it at the limit. The client gets every byte either way. - An upload (transcription) is its form fields as a JSON object, each file a stand-in —
"file": "[file talk.mp3 audio/mpeg]"— never the file. Only parts beforemodelare seen, since the rest streams to the upstream unread;prompt_bytesis the whole form'sContent-Length,-1when chunked. - Realtime sessions are not logged: once the connection is hijacked nothing passes the recorder.
- Secrets, never. Every format OpenRouter's Secrets guardrail detects (the
list — AWS, GitHub, GitLab,
OpenAI, Anthropic, OpenRouter, Google, Stripe, Slack, npm, SendGrid, Hugging Face, Databricks,
Atlassian, Doppler, Linear, Shopify, Telegram, age, JWTs, Bitcoin extended and Ethereum keys, PEM
private keys, PyPI, DigitalOcean), and Grove's own keys (
gr_…), becomes[SECRET:<format>]under OpenRouter's labels (payloadsecrets.go). A known prefix followed by at least 16 key characters is enough, whatever the length past that — OpenRouter's own rule for most formats, and a superset of its match where it pins a length or marker, so a key of an odd size still goes; the floor keeps names likehf_tokenintact. Two keep a strict shape because their prefix says too little: Ethereum (0x+ exactly 64 hex) and JWT (three dotted parts). OpenRouter's two Bitcoin WIF formats are left out on purpose: they have no prefix, only a length and an alphabet ordinary ids share. ABEGIN … PRIVATE KEYcut off before itsENDline is redacted to the end of the text. It runs on every string in the prompt, output (each stream event on its own) and upload fields — but not inside inline media, where a base64 image can hold runs shaped like a key and redacting them would corrupt it — and not on a file response. What it misses: a secret the model streams across several events, anything without a known prefix (a password), and the shape-alikes it catches wrongly (an Ethereum transaction hash looks like a private key, as OpenRouter also accepts). A body with no secret and no big media is logged byte-for-byte; one with either is re-encoded.
A text response is held in memory until its line is written; a long stream for an opted-in user costs its size.
What each layer contributes, for one POST /v1/chat/completions:
| Layer | ||
|---|---|---|
| 1 | transport/http |
TLS handshake, ServeMux matches host + path |
| 2 | middleware/auth |
reads the Authorization header |
| 3 | service/admission |
Identify → one store read |
| 4 | repository/redis |
one script: HGETALL key:…, then the user:… it names and each model_group:… that names |
| 5 | domain |
Evaluate — pure, no I/O |
| 6 | middleware/body |
bounded read, JSON decode, model out |
| 7 | service/routing |
Pick — sticky read, in-flight counts, health |
| 8 | domain |
PickRoute — pure again; the whole selection rule |
| 9 | service/routing |
claim the slot |
| 10 | middleware/transform |
body rewrites, re-encode if changed |
| 11 | transport/http/proxy |
forward, stream back, scrape the usage frame |
| 12 | middleware/meter (deferred) |
release the slot, record usage and outcome |
Two things fall out of that. The decisions — steps 5 and 8 — are pure functions over data someone else fetched, which is why they are the parts with real test coverage. And nothing below step 1 knows it is speaking HTTP.
The response is never buffered. FlushInterval: -1 pushes each write straight through, and the
usage scraper (proxy/usagetee.go) reads the bytes it is already copying and writes nothing back —
so the client sees exactly the stream the engine produced, at the engine's own pace.
One exception, at the end only. When an event stream breaks off — the upstream went silent for
upstream_read_timeout, or its body failed — while the client is still there, proxy/streamend.go
closes the event in progress and appends one error event in the surface's shape, the one its SDK
raises on, then ends the stream cleanly:
data: {"error":{"message":"upstream went silent","type":"api_error"}} (OpenAI surface)
event: error
data: {"error":{"message":"upstream went silent","type":"api_error"},"type":"error"} (/anthropic)
upstream went silent for the timeout, upstream broke off the stream otherwise. Without it the
200 has long gone out and the client sees only a dropped connection. It sits outside the usage
scraper, so usage and the cut are read off the upstream's own bytes exactly as before. An event
already cut mid-line still reaches the client broken, ahead of the error. A body that is one JSON
document cannot be repaired this way and is still just dropped.
Clients speak one of two shapes, and a vendor is only ever reached on one of two paths:
/v1/chat/completions (OpenAI) and /anthropic/v1/messages (Anthropic). Nothing converts between
them. Engines we run take both shapes and their own extras, and are sent what the client sent; what
follows is about a hop to a vendor.
The fields the gateway knows on each shape:
| Shape | Fields |
|---|---|
| OpenAI chat | model, messages, max_completion_tokens (older name max_tokens), temperature, top_p, n, stop, stream, stream_options, presence_penalty, frequency_penalty, logit_bias, logprobs, top_logprobs, seed, user, tools, tool_choice, parallel_tool_calls, response_format, reasoning_effort, metadata, store, service_tier, modalities, audio, prediction, prompt_cache_key, safety_identifier, verbosity, web_search_options |
| Anthropic messages | model, messages, max_tokens, system, metadata, stop_sequences, stream, temperature, top_p, top_k, tools, tool_choice, thinking, service_tier, output_config, container, mcp_servers, context_management |
| both, the gateway's own | fallbacks — read here, never forwarded |
What a vendor is sent in place of what the client sent:
| Field | Vendor gets |
|---|---|
model |
its own id for the model (modelmap) |
max_completion_tokens / max_tokens (OpenAI shape) |
max_completion_tokens at OpenAI, max_tokens at every other vendor; sent under both names, the newer one's value (vendorfields) |
stream_options (OpenAI shape) |
include_usage: true added on a stream (streamusage). continuous_usage_stats is added for Baseten and for an engine of ours, not for OpenAI (400s on it), DeepSeek (ignores it) or a vendor the gateway does not know |
a tool's type (Anthropic shape) |
"type": "custom" removed at DeepSeek, which refuses it; a tool with no type means the same. Any other type stays (vendorfields) |
a developer message (OpenAI shape) |
sent as a system message at DeepSeek, which knows no such role (vendorfields) |
| a past tool-call turn that came back without its reasoning | at DeepSeek an empty one is added: reasoning_content: "" on the OpenAI shape, an empty thinking block on the Anthropic one. Not when the caller disabled thinking (vendorfields) |
thinking, when a tool is forced |
at DeepSeek {"type": "disabled"} is added when tool_choice is "required" or names a function (tool on the Anthropic shape) and the caller sent no thinking or reasoning_effort. The caller is told: X-Grove-Changed: thinking=disabled (vendorfields) |
reasoning_effort, when the request has tools (OpenAI shape) |
at OpenAI "none" is added when the caller sent no reasoning_effort. The caller is told: X-Grove-Changed: reasoning_effort=none (vendorfields) |
temperature, top_p, logprobs, top_logprobs (OpenAI shape) |
at OpenAI, unless reasoning_effort is "none": dropped, but for a temperature or top_p of 1. The caller is told: X-Grove-Changed: temperature=default, top_p=default (vendorfields) |
service_tier |
dropped (servicetier) |
| every other field, listed above or not | as the client sent it |
- A field not on the list is forwarded, not dropped and not refused: the list is what the gateway stands behind, not a gate, and a vendor's new field works the day it ships. The vendor refuses what it does not take.
- Three rewrites change what the model does, and the caller is told of each in
X-Grove-Changed. A forced tool switches DeepSeek's thinking off, and tools switch OpenAI's reasoning off (reasoning_effort: "none"), since each refuses the two together: only when the caller said nothing about thinking or effort, and one who asked for both gets the vendor's refusal. The third overrides what the caller asked for: while an OpenAI model reasons, itstemperature,top_p,logprobsandtop_logprobsare dropped, since OpenAI takes none then. - A listed field a vendor cannot take is sent anyway; dropping it would change what the caller asked for. On a fallback, that vendor's refusal moves the request on (see Fallback models).
- Where an upstream differs is one table,
vendorsininternal/service/transform/vendors.go, keyed by the vendor's name as the control plane pushes it: the field it reads the output cap from (outputCap), whether it is asked for usage on every chunk of a stream (usagePerChunk), and four things DeepSeek refuses: a tool typed"custom"(untypedTools), adevelopermessage (developerAsSystem), a past tool call with no reasoning (blankReasoning) and a forced tool while thinking (unthinkForcedTool). One is OpenAI's: a function tool while the model reasons (unreasonWithTools) and sampling of the caller's while it reasons (defaultSampling). One more is a header, not a field: an Anthropic front that reads the key as a Bearer (bearerOnAnthropic, Baseten), read byupstreamauth. vendor_quirks.md shows each with what the caller sent and what the vendor gets. It names OpenAI, DeepSeek and Baseten; an engine of ours and a vendor it does not name each have an entry of their own.vendorfieldsandstreamusageread it per attempt on the client's own body, so a request that moves to another vendor is named afresh for that one. Add a line when the access log'sattemptline, or a probe, shows a vendor differing — and the row below with it. - Of an upstream's response headers only a named few reach the client (see Headers).
- Responses are not rewritten: same shape in, same shape out, with
modelswapped back to the name the client knows — whatever the vendor wrote there, since one asked by an alias answers under the name behind it.
What each upstream was seen to do, through a gateway, and where the gateway acts on it. This is the one list of it: add a row or a column when a new difference shows up, with the date it was measured.
| Engines we run (vLLM) | OpenAI | DeepSeek | Baseten | Handled by | |
|---|---|---|---|---|---|
| Output cap field (OpenAI shape) | either name | max_completion_tokens only; 400 on max_tokens |
max_tokens (2026-10-04: capped at 16) |
max_tokens (2026-10-05: capped at 16) |
vendorfields, from vendors |
stream_options.continuous_usage_stats |
taken | 400 (2026-10-05) | ignored (2026-10-05) | taken (2026-10-05) | streamusage, from vendors: sent to our engines and Baseten |
| Usage on every chunk of a stream | yes, when asked | no, the last chunk only | no, the last chunk only | yes, asked or not | asked all the same |
| A stream that is cut is metered | yes, by the last whole chunk | no; OpenAI bills nothing for it either (2026-09-30) | no | yes, by the last chunk received (2026-10-05: 44 + 33 tokens billed) | the usage tee |
model in the answer |
the id it was started under | the id it was asked by | may differ: deepseek-v4-flash answers as deepseek-flash (2026-10-04) |
not checked | the response swap writes the client's id whatever came back |
| Anthropic shape | yes | no | yes, its own front | yes; it reads the key as a Bearer, and answers 401 please check the api-key you provided to x-api-key (2026-10-05) |
the route's dialect; upstreamauth sends Baseten a Bearer, from vendors |
A tool typed "custom" (Anthropic shape; Anthropic's API reference takes it, and litellm always sends it) |
not checked | no such shape | 422 unknown variant `custom`, expected `web_search_20250305` or `web_search_20260209` ; the same tool with no type is a 200 with a tool_use (2026-10-05) |
taken (2026-10-05) | vendorfields, from vendors: DeepSeek gets the tool without the type |
| A server tool other than web search (Anthropic shape) | not checked | no such shape | 422 unknown variant for bash, text editor, web fetch and code execution (2026-10-05) |
taken and not run: the answer is prose (2026-10-05) | not handled: the vendor refuses |
A forced tool: tool_choice "required" or a named function, tool on the Anthropic shape |
not checked | not checked | 400 Thinking mode does not support this tool_choice while thinking is on, its default; taken with thinking disabled, and any on the Anthropic shape is taken (2026-10-05) |
taken on both shapes (2026-10-05) | vendorfields, from vendors: DeepSeek gets thinking disabled, unless the caller spoke of thinking |
| A past tool-call turn sent back without its reasoning | not checked | not checked | 400 The `reasoning_content` in the thinking mode must be passed back to the API, and The `content[].thinking` … on the Anthropic shape; taken with it, with it empty, or with thinking disabled (2026-10-05) |
taken on both shapes; a thinking block with no signature is a 400 (2026-10-05) |
vendorfields, from vendors: DeepSeek gets an empty one |
| A function tool while the model reasons (chat) | not checked | 400 Function tools with reasoning_effort are not supported … use /v1/responses or set reasoning_effort to 'none' on luna and sol; taken with reasoning_effort: "none" (2026-10-05) |
taken (2026-10-05) | taken (2026-10-05) | vendorfields, from vendors: OpenAI gets reasoning_effort: "none" when the caller set none |
A temperature or top_p other than 1, or logprobs (chat) |
not checked | while the model reasons, its default: 400 'temperature' does not support 0.7 with this model. Only the default (1) value is supported., 400 'top_p' is not supported with this model and the same for logprobs, on luna and sol; 1 is taken for the first two, and all of them with reasoning_effort: "none" (2026-10-06) |
not checked | not checked | vendorfields, from vendors: OpenAI gets none of them unless reasoning is off |
A developer message |
not checked | followed (2026-10-05) | 422 unknown variant `developer`, expected one of `system`, `user`, `assistant`, `tool`, `latest_reminder` (2026-10-05) |
followed (2026-10-05) | vendorfields, from vendors: DeepSeek gets it as system |
A past tool call with no type |
not checked | 400 Missing required parameter (2026-10-05) |
422 missing field `type` (2026-10-05) |
taken (2026-10-05) | not handled: the vendor refuses |
Fields only OpenAI reads: prediction, prompt_cache_key, prompt_cache_retention, verbosity, store, web_search_options |
not checked | its own | all six ignored (2026-10-05) | 400 Extra inputs are not permitted on prediction and web_search_options; the other four ignored (2026-10-05) |
not handled |
| A remote image URL | not checked | not checked | downloads it itself. A Pexels photo was a 400 unsupported image 9 times in 12: Pexels serves AVIF to an Accept that offers it and JPEG otherwise, and DeepSeek takes JPEG, PNG, GIF and WebP only, judged by the file's bytes (its guide). With fm=jpg in the URL, 8 of 8 were taken. A Wikimedia PNG is a 400 Failed to download image. The image as base64 is taken. Only flash reads images: pro answers 200 and says it cannot see one (2026-10-05) |
the Pexels photo is taken, the Wikimedia PNG a 500; base64 is taken (2026-10-05) | not handled: the gateway downloads nothing |
What Baseten bills for a stream that was cut has not been compared with its own usage report.
What a client sees on an answer:
| Header | Whose | When |
|---|---|---|
X-Request-Id, Request-Id |
the gateway's | every answer. One id under both names; the second is what Anthropic's SDK reads |
X-Grove-Fallback |
the gateway's | only when a fallback model served: that model's key |
X-Grove-Changed |
the gateway's | only when the gateway changed what the model does for the vendor that answered: reasoning_effort=none, thinking=disabled, or a dropped field as temperature=default, comma-separated (see vendor_quirks.md) |
Retry-After |
the gateway's on its own 429 and 503, else the upstream's, relayed | when either says when to come back |
Content-Type, Content-Length, Content-Encoding, Content-Disposition, Cache-Control |
the upstream's, relayed | as it sent them |
Upgrade, Connection, Sec-WebSocket-* |
the upstream's, relayed | an upgrade's handshake |
Nothing else an upstream sends reaches the client. The list is relayed in
internal/transport/http/proxy/proxy.go; a header joins it by being added there. What stops at the
gateway: a vendor's ids and timestamps, its cookies, alt-svc, via, strict-transport-security,
its own request ids, and the rate limits of the account we call it with (x-ratelimit-*), which
are ours and not the caller's. Before the list (2026-10-05) a Baseten answer carried twelve
x-baseten-* and four x-ratelimit-* headers to the client.
Which model served has no header of its own: it is the body's model on every answer, and
X-Grove-Fallback when it is not the model that was asked for.
Every X-Grove-* header, and who it is between:
| Header | From → to | For |
|---|---|---|
X-Grove-Session |
client → gateway | names the caller's session (see Session affinity) |
X-Grove-Fallback |
gateway → client | the fallback that served |
X-Grove-Changed |
gateway → client | what the gateway changed in the request that alters what the model does, as field=value |
X-Grove-Model, X-Grove-Session-Key |
gateway → ingress | the model to pick a replica of, and the session to keep on it. Taken off a request to anything that is not an ingress |
X-Grove-Engine |
ingress → gateway | the replica that served: how usage reaches a placement the gateway never picked. Read here, not relayed to the client |
X-Grove-Reason |
ingress → gateway | why the ingress refused; no-replica keeps that 503 from counting against it. Read here, not relayed |
X-Grove-Admin-Token |
control plane → gateway | gates every admin endpoint |
domain.PickRoute decides, in this order. Every step narrows the set the next one sees.
- Healthy and routable. A row must be
healthy(pushed by the control plane, then possibly flipped off by passive ejection) and have a non-emptyengine_url. None left → 503. - Region tier. If the gateway has a region and any row shares it, everything else is dropped. Two tiers only, no ranking within them: a cross-region hop costs so much more than the gap between two remote regions that ordering the far ones is precision nobody can feel. A row with no region counts as remote — the safe reading of "unknown".
- Capacity. A row is out if
in_flight >= capacity(the engine's--max-num-seqs). Capacity 0 means uncapped. None left → wait up tocapacity_wait(default0s) for a slot, re-reading the counts every 50ms, then 429 — deliberately not 503: the model is up, come back shortly. Counts are perengine_urlin Redis and shared by every gateway, so a vendor's rows on one base URL share one cap. - Stickiness. If the caller's session is pinned to a row still in the set, that row wins. Stickiness loses to capacity by construction — a warm prefix cache is not worth queueing behind a full engine when a replica is idle.
- Least in flight. Otherwise the row with the fewest live requests. A tie keeps the first.
A caller names its session with the X-Grove-Session header or the body's user field; the header
wins. The pin lives at sticky:<session> for 30 minutes and holds a caller to one engine so its
prefix cache stays warm.
A caller that names nothing is balanced by default. Setting synthetic_session_ttl synthesises
one from sha256(meter_id|model), which pins a whole API key to one engine — what a
single-placement fleet always did, and the lever to pull if balancing goes wrong.
Behind an ingress the same rule runs one tier down, keyed on sha256(session). The gateway's pin
selects a network; the ingress's selects a box, which is the one that actually keeps a cache
warm. The hash is the boundary: the gateway never learns which box, the ingress never learns which
tenant.
No prober. Every request reports how its hop went, and three consecutive failures take a target out for 60 seconds. A success clears the count outright, so it is three in a row, not three ever.
What counts as a failure is the part that matters: a connection error, a 502 or a 504 mean the hop
is broken. A 503 carrying X-Grove-Reason: no-replica means an ingress answered perfectly well and
one model has nowhere to go behind it — counting that would let a single unplaced model pull an
ingress out of rotation for every other model on it.
A client that left before the upstream answered is not a failure either. The hop has no status, which would otherwise read as a dead connection, and three impatient clients in a row would take a healthy model away from everyone for a minute. It moves the count in neither direction.
An upstream that answered and then did not finish is a failure, whatever status it had sent: its
body broke off (cut=upstream), or it went silent for the read timeout (cut=upstream_idle). A
client leaving in the middle of a good answer is not, and clears the count like any served request.
One that ends a stream early but cleanly cannot be told from one that finished, and counts as served.
This is cheaper than active probing and strictly better informed: a probe tests a path no customer is on.
A caller may name other models to take the request when the one asked for cannot, in a JSON body:
{"model": "qwen/qwen3-4b", "messages": [], "fallbacks": ["anthropic/claude-sonnet-5-5"]}At most 3 names; anything else in fallbacks is a 400. The field never reaches an upstream. The
gateway never picks a fallback of its own: the caller named the model, and pays its price.
The list is not judged when the request arrives: a fallback that cannot serve is found at its
turn and passed over. One with no route on this surface and path — nothing translates between the
OpenAI and Anthropic shapes, and a model answers only the paths its outputs allow — is skipped
without a dial, as is one the caller is not granted or one with no routes right now. So is one
that declares its inputs and lacks what the request carries: a model that takes ["text"] is not
sent a request with an image in it. The image and file parts of messages are what is
looked for, on either shape; the model asked for is never held to this, only its stand-ins. One
that is dialled and refuses the request (a model that declares no inputs sent an image, or any
model sent a field it does not take) costs that dial, and the next is tried.
A fallback is tried, in list order, when:
routefinds nowhere for the model to go — no healthy server (503) or every one full (429) — before anything is dialled; or- the upstream answers 5xx, or with a key failure (429, 401/402/403) after
retryhas walked every key the model has; or - the model serving is itself a fallback and refuses the request, with any status from 400 up. A stand-in that will not take the request as written costs one dial, not the request: models on one shape still differ in what they accept.
A 4xx from the model the client asked for is the request's fault and is relayed at once, as is the answer of a client that left.
Every attempt starts from the client's own body and is rewritten for the vendor it goes to (see
The client interface): a request sent with max_tokens reaches DeepSeek
under that name and OpenAI as max_completion_tokens.
A response that has begun is never moved: the decision is made on the status line, and once a 200 has gone out the client holds part of one model's answer. A stream that breaks after that ends in the error event of Streaming, on the model that began it, billed for what it reported. The same holds for a vendor that answers 200 and puts its error in the stream's first event: the gateway does not read ahead, so that is relayed too.
Each fallback must be granted to the caller like any model; one that is not is skipped. It is picked with no session, so the caller's pin stays on the model they asked for. When no model is left, the last answer dialled is the client's, headers and body.
The request is billed once, on the model that served: usage lands under that model and its pricing,
and the access and payload lines carry it as fallback. The client reads it off the response:
X-Grove-Fallback: <model> is set on any answer that is not the first model's — an audio body has
nowhere else to say so — and the body's model names it too. The model that failed keeps its
health mark and gives its slot back, and leaves an attempt line on the access log (see
Correlation).
Three records, resolved in order — key: → user: → model_group:. A user names any number of
groups; their grants are unioned into one before the gates run.
The split is deliberate. A credential's only fact of its own is whether it has been revoked; who holds it, what they may call and whether they are over budget are facts about the person. So one leaked key dies without touching the rest, and a budget flip is one write however many keys that person holds.
Evaluate then runs three gates, in this order:
| Gate | Status | Why in this position |
|---|---|---|
key is active |
401 | Checked first: a revoked key is 401 even for a holder who is also over quota, because the key is the thing that is wrong |
| holder has credit | 402 | limited is a pushed flag; prepaid && spent >= budget is this Redis's own counter against the amount the user loaded. Either → 402 |
CanUse(model) |
403 |
CanUse is the whole access rule and fails closed:
deny wins over everything usr.Deny[model] → false
otherwise, every group's grant ∪ user's own allow → true
nothing granted it → false
/v1/models filters its list with the same function, so the catalogue can never advertise
something the inference path would refuse.
Two, both read off the user record before the body is, both answering 402
credit balance exhausted. The control plane's: its own balance priced from the pull, flipped as
limited and pushed. This box's own: prepaid && spent >= budget, where spent is the one counter
this process keeps (see The records themselves). A client cannot tell which one refused it.
Per holder, pushed on the user record as limits: a comma list of <metric>:<window>:<value>,
e.g. requests:1m:200,total_tokens:1h:50000. No entry = uncapped. Checked in quota, after the
credit gates, so a holder with no balance does not use up a request.
| Metrics | requests — counted as the request is admitted. total_tokens — prompt + completion as the answer reports them, cache reads included |
| Windows | 1m 1h 1d 1M. They reset on the UTC clock (top of the minute, the hour, midnight, the 1st), not from the holder's first request |
| Counter | lim:<user>:<metric>:<window>:<bucket>, kept two windows. Bucket = floor(unix / seconds), or 2026-10 for the month |
| Refusal | 429 rate limit exceeded: 200 requests per 1m (rate_limit_error), Retry-After = seconds to the window's end. Over several limits at once, the one that resets last is named |
Requests are exact, tokens are check-then-debit. One Lua script reads every counter and, only
when all have room, counts the request — so a refusal counts nothing and two requests cannot both
take the last slot. Tokens are unknown until the answer: meter adds them to the window the answer
ended in. The request that crosses a token limit completes; the overshoot is the requests in flight
times their size. A request with no token usage (audio seconds) moves only the request limits.
What counts: every request quota admits, whatever happens below it (a 403 on the model, a full
engine). fallback and retry sit below, so a fallback model or a vendor key rotation is still
one request. A response cut short
is debited what usage it reported. A debit the store refuses is logged and dropped, never spooled:
replayed later, it would land in a window the tokens were not used in.
Scope: the counters live in this gateway's store, so a limit is exact across every gateway sharing it and separate on a gateway with its own Redis. The limit store failing is a 503 for a holder with limits; one without never reads it. A push carrying a limit this binary cannot read is refused 400, naming the user.
One usage record per request, written in a single transaction so a control-plane drain never sees half of one.
Where the numbers come from. The response body, read one of two ways by its Content-Type:
- An event stream (
text/event-stream) is read by the line: the last line containing"usage", the final frame of an OpenAI stream. Streaming requests only have one because thestreamusagetransform forcesstream_options.include_usageon the way in. An engine of ours and Baseten are also sentcontinuous_usage_stats, so every chunk carries the count so far and a stream that is cut is metered by the last chunk that arrived whole; OpenAI's and DeepSeek's cut stream has reported nothing by then. - Any other body is one document and is kept whole. It is never split into lines: OpenAI
prints a body that is not streamed over many lines, and the line that names
"usage"is then"usage": {and holds none of it.
Either way, what is held past 1 MiB is its tail only, which is no longer a document, so
ParseUsage falls back to the last "usage": object in it — a long body, or one carrying
logprobs, still meters.
The first such line is kept beside the last, because an Anthropic stream splits its counts:
message_start carries the prompt and its cache buckets, message_delta the output. The two are
merged field by field, the larger winning (domain.MergeUsage), and Total is raised to
Prompt + Completion. A first line with nothing to read, such as OpenAI's "usage":null chunks,
is ignored. First and last only: a vendor that splits three ways needs a per-event merge.
The upstream is never asked to compress: Accept-Encoding is dropped on the way out, since a
gzip body is one the tee cannot read and the request would meter as zero tokens.
Two engine shapes, one meaning. vLLM answers OpenAI-shaped on /v1/chat/completions and
Anthropic-shaped on /v1/messages, and they disagree about what "input tokens" means:
| OpenAI | Anthropic | |
|---|---|---|
| prompt | prompt_tokens — includes cache |
input_tokens — excludes it |
| cached | prompt_tokens_details.cached_tokens |
cache_read_input_tokens |
| total | total_tokens |
absent |
| written | prompt_tokens_details.cache_write_tokens — inside the prompt |
cache_creation_input_tokens, the hour part under cache_creation.ephemeral_1h_input_tokens |
Both are normalised to: Prompt = the full input processed, Total = Prompt + Completion,
Cached ⊆ Prompt. Cache creation is a write, so it lands in Prompt but not in Cached.
Billable is then Total - Cached on either shape.
cached_tokens is only non-zero when the engine was started with
--enable-prompt-tokens-details. Without it every request bills as uncached.
Each metric is written three times into the same hash — flat, m:<metric>:<model>, and
m:<metric>:<deployment> — so one drain carries the aggregate and both breakdowns. Zero values are
skipped entirely; a field that never moved should not appear.
What gets priced. Beside the display fields, the counters the control plane's table names.
The table rides inside every pricing (pricing.counters, domain.CounterTable): a root row is a
bucket the response fills — prompt_tokens (the whole prompt), cached_tokens, cache_write_tokens
(the five-minute writes on the Anthropic shape, every write on the OpenAI one),
cache_write_1h_tokens, audio_tokens, completion_tokens (the whole completion),
completion_audio_tokens, request_count — and a derived row is a root plus a prompt-size
threshold (base, min_prompt_tokens), today the four *_above_272k counters. The bucket → root
mapping is code (metering.UsageFields); everything else about a counter is the pushed row. A
pricing pushed without a table, or no pricing at all, is an empty table: every bucket is counted
under its root and nothing is priced — the control plane always pushes one, so a pricing without
it is logged once as an error.
Counting records what the vendor reports; charging subtracts (CounterTable.Cost). A counter's rate
is charged on what its parts left of it, and each part at its own rate, so a token bills once. A root
row's part_of names its container (cached_tokens, cache_write_tokens, cache_write_1h_tokens
and audio_tokens are parts of prompt_tokens; completion_audio_tokens of completion_tokens); a
derived row's parts are its base's parts at the same threshold.
audio_tokens is the audio part of the prompt — prompt_tokens_details.audio_tokens
on a chat, input_token_details.audio_tokens on a token-shaped transcription — capped at what the
cache left. completion_audio_tokens is the audio part of the completion,
completion_tokens_details.audio_tokens, capped at the completion. audio_seconds is a display
field, not priced: a transcription's {"type":"duration","seconds":N} usage, or the top-level
duration of a verbose_json body, which carries no usage at all. A duration-shaped
transcription, a translation and a realtime session therefore bill request_count only. Cache buckets
that exceed the prompt cannot be credited: the whole prompt bills as plain, logged once per model.
Above a threshold. A vendor may charge the whole request more once the prompt passes some size (OpenAI: 272 000 tokens). Two rules, one on each side, and neither knows the other:
- Counting (
metering.landed). For a request whose prompt — plain, cached, written and audio together — is strictly past a derived row's threshold, each root bucket lands under its derived row at the highest such threshold, instead of the root, flat, per model and per pricing alike. The pricing's rates are not consulted, only its table. A root with no derived row at that threshold stays where it is: a container's derived row takes what is left after the parts that stayed. Hour writes and audio have one rate at any prompt size and no derived row, so a long request's stay with the base counters: its hour writes and prompt audio are counted inprompt_tokens, its audio output incompletion_tokens, and only the rest above 272k. A 300 000 prompt with 10 000 hour writes isprompt_tokens_above_272k290 000 andprompt_tokens10 000.total_tokensholds every request. - Charging (
CounterTable.Rate). A counter is charged at its own rate. A derived counter the pricing holds no rate for is charged at its base counter's: noprompt_tokens_above_272krate means theprompt_tokensrate. So a pricing without those rates charges as it always did.
The control plane prices a drain from the same table and the same fallback, so both sides price
the same amounts at the same rates. A new bracket is rows in the push, not a release here; a new
root (a vendor fee reported in usage) is one parser line that fills its bucket, plus its row.
Cost lands with its counters, tagged by the pricing that charged it. The route carries the
pricing in force; a price change lands with the push that carries it, and a request is charged by
the pricing its gateway held. One Lua script per request: HINCRBY every counter, each priced counter again as p:<pricing id>:<counter>,
then cost and p:<pricing id>:cost — Σ counter × rate, nano-USD, truncated per counter, 0 on an
unpriced route — then, for a prepaid holder only, HINCRBY user:<u> spent cost and HSET user_spent user_balance (budget − spent, negative once overspent; last writer wins) on the usage hash. A free
holder's usage is counted and priced the same, but tagged f:<pricing id>: instead of p:; their
spent never moves and the drain carries no balance for them, so turning them prepaid later starts
them at what they load. The tag is set per request, so a drain that spans a flip carries both and the
pull bills only the p: part. The pull prices each
p:<pricing id> or f:<pricing id> group with that same pricing's table, so the two sides can only disagree when they
hold different rates for one pricing id. A drain therefore never sees a counter without its cost, or a cost without the spend it
moved. The spend moves only on a holder the control plane has pushed.
Every request — admitted or refused, a health probe, a scrape, an admin call — gets a uuid v7
where the access line starts: time-ordered, opaque, canonical and overriding whatever the client
sent. It is answered under both X-Request-Id and Request-Id, so an OpenAI SDK's and an Anthropic
SDK's _request_id are both ours whatever the route. An ingress adopts the gateway's id rather than
minting one, and vLLM adopts X-Request-Id as its own request id, so one grep for it crosses the
gateway log, the ingress log and the engine log.
Where it went — key, model, deployment, engine, upstream — is on the access line under rid, not
in the id. So is how it ended, as cut: - for a response that finished, else who ended it —
client_left, upstream_idle (silent for the read timeout) or upstream (its body broke off).
An upstream that ends a stream early but cleanly cannot be told from one that finished, and reads
-. On a provider route the vendor's own id is upstream_rid on that line and never reaches
the client; ours never reaches the vendor. attempts is how many times an upstream was dialled
for the request — 0 when it was refused before any, more than 1 when retry moved it to another
vendor key or fallback to another model. model is the one the client asked for; fallback is
the model that served instead, - when the one asked for did.
The access line describes the last attempt only. Every attempt before it — one the client never
saw, because retry or fallback held its answer and moved the request on — leaves a line of its
own in the same log, msg="attempt", under the same rid:
| Field | Is |
|---|---|
attempt |
which dial failed, from 1; 0 for a model route could not place, so nothing was dialled |
model |
the model this attempt was on |
upstream, deployment, engine, upstream_rid, upstream_status, reason, cut |
as on the access line, for this attempt; reason is the pick's refusal when nothing was dialled, upstream unavailable or upstream timed out when the dial gave no status |
upstream_key |
id of the vendor credential dialled; - on an engine |
rt |
seconds this attempt took |
moved, to |
what the request moved to: key and the next credential's id, or model and the fallback |
A request that dialled three times is two attempt lines and one access line. A fallback passed
over without a dial (not granted, no route on this surface, takes less than the request carries)
is in the process log, fallback skipped.
One file in transport/http/middleware/, one Register, one name in the chain.
func init() { Register("guardrails", newGuardrails) }
func newGuardrails(deps Deps) (Middleware, error) {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
state := From(r) // Identity, Model, Body, Decision — whatever ran above
if bad(state.Body) {
deny(w, r, domain.Deny(http.StatusForbidden, "blocked by policy"))
return
}
next.ServeHTTP(w, r)
})
}, nil
}Then name it in config.json → middleware, and SIGUSR1. Nothing else changes. A name that is not
registered is refused and the running chain is kept.
If it needs something not on Deps, that is the signal to add it there — and to ask whether the
thing it needs belongs on the request path at all.
Body rewrites specifically. Same shape, in service/transform/:
func init() { Register(alias{}) }
type alias struct{}
func (alias) Name() string { return "alias" }
func (alias) Endpoints() []string { return nil } // nil = every path
func (alias) Apply(ctx Context, body Body) (bool, error) { … } // bool = "I changed something"Each transform declares its own endpoint gate, so adding one for /v1/messages does not mean
editing a shared check. Return false when nothing changed — a body no transform touched is
forwarded byte-for-byte rather than re-encoded.
Registered is not running: a transform runs only while the transforms list names it, and the
default list is config.Defaults().
A dropped field is logged for you. A transform that removes a field just deletes it; the chain
compares what the caller sent with what is left and writes one Warn per user and field —
request field dropped user=… field=… transform=… — since nothing upstream errors on a field that
is not there. The name only, never the value. Top-level fields; a rewritten one is not a drop.
| transform | what it does |
|---|---|
modelmap |
rewrites model to the route's upstream_model |
streamusage |
forces stream_options.include_usage on a streaming completion, and continuous_usage_stats beside it for an upstream the vendors table says takes it (our engines, Baseten) |
servicetier |
drops a caller's service_tier on every hop: it picks the vendor's price class, which is never the caller's to choose |
vendorfields |
on a vendor hop, sends the output cap under the one name that vendor reads, from the vendors table of where an upstream differs from the shape (see The client interface). Today the output cap: max_completion_tokens for OpenAI (400s on the old name), max_tokens for every other (they ignore the new one, uncapping output). Logged as a drop of the other name |
cachesalt |
prefixes a caller's cache_salt with their tenant, strips it on a vendor hop. Not in the default list |
Implement the interface in repository/repository.go and wire it in cmd/pathway/main.go.
Nothing in service/ knows which one it got. For a second destination (say a local SQLite archive
of usage), a decorator holding two repository.Usage values is the whole change — the primary's
error is returned, the archive's is logged.
A middleware, not a new abstraction: offload("route") above route, POSTing the decision inputs
under a deadline. On 200 it puts the answer in the state and route no-ops; on timeout or error it
logs and falls through. Fallback is chain order.
The invariant if you build this: the remote decides, the gateway always claims. A slow-but-alive remote that answers after you gave up would otherwise claim a slot nothing ever releases, and that engine leaves rotation permanently.
Split by lifetime, and disjoint — nothing appears in both halves.
GROVE_ADMIN_TOKEN |
required; the process refuses to start without it |
GROVE_GATEWAY_ID / GROVE_INGRESS_ID |
which plane; both set is a refusal |
GROVE_INGRESS_TOKEN |
required on an ingress; blank there refuses every gateway |
GROVE_GATEWAY_REGION |
this gateway's region, which PickRoute prefers |
GROVE_GATEWAY_GEOGRAPHY |
this gateway's geography; a user pinned to another (any pin, when blank) gets 403 this key is restricted to geography <g> on inference and /v1/models |
GROVE_REDIS_ADDR |
default 127.0.0.1:6379; the store's private address when shared |
GROVE_REDIS_PASSWORD |
the shared store's requirepass; blank = no AUTH |
GROVE_LISTEN_HTTP / GROVE_LISTEN_HTTPS |
the data path; at least one is required |
GROVE_PUBLIC_HOST |
the shared name customer traffic arrives on |
GROVE_SELF_HOST |
this box's own name — carries /grove-admin and the scrape |
GROVE_TLS_CERT / GROVE_TLS_KEY |
fleet wildcard; hot-reloaded from disk on change |
GROVE_HTPASSWD |
bcrypt htpasswd for /metrics/node |
GROVE_NODE_EXPORTER_URL |
default http://127.0.0.1:9100/metrics |
GROVE_ACCESS_LOG |
file for the per-request line; blank → stdout |
GROVE_ERROR_LOG |
file mirroring Warn and above out of the process log; blank → stdout only |
GROVE_PAYLOAD_LOG |
file for prompts and outputs of users flagged log_payloads; customer content, so its own file and retention. Blank turns payloadlog off box-wide |
GROVE_CONFIG |
tunables path; default /etc/pathway/config.json |
GROVE_PID_FILE |
optional |
{
"log_level": "info",
"middleware": ["recover", "accesslog", "drain", "auth", "quota", "body", "modelaccess",
"payloadlog", "route", "meter", "fallback", "retry", "transform", "upstreamauth"],
"transforms": ["modelmap", "streamusage", "servicetier", "vendorfields"],
"synthetic_session_ttl": "0s",
"capacity_wait": "0s",
"max_body_bytes": 33554432,
"upstream_read_timeout": "600s",
"upstream_tls_verify": false,
"drain_timeout": "630s",
"lame_duck": "5s",
"upgrade_timeout": "30s",
"usage_retention": "168h",
"usage_spool": "/var/lib/pathway/usage-spool.jsonl",
"usage_spool_max_bytes": 1073741824,
"maintenance": false
}Every field is optional; an omitted one keeps its default, and a missing file is all defaults.
A rejected file changes nothing. Bad JSON, an unknown key, a bad duration, or an unregistered middleware name → one error line and the running configuration is kept, applied whole or not at all. Parsing is strict on purpose: a knob that silently became its default is a knob the operator believes they turned.
upstream_read_timeout bounds an upstream's silence, not a request's length: the wait for its
headers, and every wait for more of its body after them. A model that thinks for ten minutes before
its first token needs it raised; a stream that talks for an hour does not. Running out before the
headers is a 504 upstream timed out; after them, an event stream ends in an error event (see
Streaming).
synthetic_session_ttl is the one worth knowing. 0s balances every caller that names no session
of its own; 30m pins each API key to one engine, which is what a single-placement fleet always
did and the lever to pull if balancing goes wrong.
capacity_wait is how long a request waits for a slot when every upstream of its model is at its
capacity, before the 429. 0s refuses at once. Nothing is claimed or billed while it waits, and a
client that leaves stops the wait. A vendor row is never counted and has no cap here: the vendor's
own 429 is its cap.
maintenance: true refuses every new data request with 503 maintenance (Retry-After: 30) and
fails /healthz, while requests already running finish. It lives in the file, so a box restarted
in maintenance comes back in it. GET /grove-admin/in-flight → {"maintenance", "in_flight"} is
how the control plane knows the box has gone idle. An older binary refuses a file carrying this key
(unknown field), so it is written only once this release is deployed.
SIGHUP |
upgrade — fork a child on the current binary, hand it the listening sockets |
SIGUSR1 |
reload — re-read config.json |
SIGTERM |
drain — stop serving, finish what is in flight, exit |
The child inherits the listening file descriptors and starts accepting immediately, while the parent drains behind it. No connection is refused and none is queued unanswered — which matters here because a streaming completion runs for minutes, so a stop/start would leave clients waiting that long.
A bad binary is a no-op. If the child fails to start or never signals ready, the parent logs it and keeps serving on the old binary.
Two things to respect:
- Replace the binary by rename, never by truncation — overwriting a running executable fails
with
text file busy.ansible.builtin.copyalready writes-then-renames. - Start it by absolute path. The child is spawned from
os.Args[0]; a relative one resolves against the working directory. The process warns once at startup if it sees one.
The flag flips first and the process keeps accepting for lame_duck (default 5s), answering 503.
That window is what lets a health check notice: Server.Shutdown closes the listener at once, so
without it a fresh connection gets refused rather than an answer, and only an already-open keep-alive
connection would ever see the 503. Then live handlers get drain_timeout (default 630s — longer
than the upstream read timeout, so a stream the engine would have finished is never cut here first),
and anything past that is closed. Closed handlers still run their defers, so every in-flight slot is
released and whatever usage was captured is recorded.
No lame-duck on an upgrade handover: the child is already accepting on the same socket.
Size lame_duck to at least one health-check interval of whatever sits in front: shorter, and the
balancer can still be sending when the socket closes, and those clients get connection refused
instead of a retryable 503. 0 skips the window and closes the socket at once. Requests already
running are never refused by it — only new ones are.
Grove pushes state; the gateway projects it into local Redis and never calls back. These shapes
are the interface — changing one means changing agent_sync.py and usage_pull.py too.
| Key | Type | Written by |
|---|---|---|
key:<sha256(secret)> |
hash | state push, keys section |
user:<Grove User> |
hash | state push, users section; spent by the gateway |
model_group:<Model Group> |
hash | state push, groups section |
deploy:<model> |
JSON array of routes | state push, routes section |
grove:state_hash |
hash | state push — per-section/bucket fingerprints of what this box holds |
lim:<user>:<metric>:<window>:<bucket> |
counter, kept two windows | the gateway — see Rate limiting |
usage:<key prefix> |
hash | the gateway; set aside by GET /grove-admin/usage |
drained:<drain id>:<key prefix> |
hash, kept usage_retention once acked |
GET /grove-admin/usage renames a live counter here |
drain:unacked |
set of <drain id>:<key prefix> |
every counter set aside and not yet acked |
adjust:<id> |
string, 7 days | POST /grove-admin/spend-adjust — ids already applied |
accrued:<request id> |
string, 7 days | the spool's replay — requests already landed from it |
sticky:<session> |
string, 30m | the gateway |
inflight:<engine> |
sorted set, member = request id; never a vendor's URL | the gateway |
health:<target> |
counter, 60s | the gateway |
pk:<key id> |
hash, lifetime | the gateway — what each vendor credential answered; read by GET /grove-admin/provider-keys |
Nothing the control plane pushes carries a TTL. The push is hash-gated, so a record that expired
would stay missing until its section changed; only gateway-owned keys expire
(TestPushedStateNeverExpires).
POST /grove-admin/state is desired state, whole, and absence prunes. The body carries any
subset of four sections — groups, users, keys, routes — each stamped with a
hash Grove computed. The agent applies the whole body in ONE Redis MULTI: HSET every named
record, DEL every record in a pushed section the payload does not name, then store the hashes in
grove:state_hash. A Redis error is a 500 and none of it lands — the hashes never claim state
that did not arrive. Only model_group:/user:/key:/deploy: are ever pruned; usage,
sticky, inflight and health keys are the gateway's own.
Every admin body is decoded strictly: a field this binary does not know is a 400 naming it
(bad body: json: unknown field "credentials"), nothing is stored and no hash is written, so the
Pathway Sync row says which field and the next tick pushes again. The alternative — keep the
record without the field under the hash of the full payload — is drift no later push would see,
which is what happened once. Consequence: a new route field ships in the binary before Grove
pushes it.
GET /grove-admin/state-hash returns that stored map. Grove diffs its computed hashes against it
every 2 minutes and pushes only what differs — an in-sync box costs one GET. users and keys
are split into 256 buckets (domain.BucketOf = sha256(id)[:2], same rule Grove uses) hashed
independently, so one minted key ships one bucket, not the population. A wiped Redis has no
hashes, reads as total drift, and is fully rebuilt on the next tick — that is the only repair
path and the only one needed. Both planes mount these endpoints; an ingress only ever receives
the routes section. Contract: plan_agent_state_sync.md in the infer-plat tree. The old
per-section PUT/DELETE endpoints remain for one release after the control plane cuts over.
Every admin endpoint is gated on X-Grove-Admin-Token, compared in constant time, and mounted on
GROVE_SELF_HOST only — a push has to reach one gateway, and the public name means all of them.
GET /grove-admin/in-flight is read-only: {"maintenance": bool, "in_flight": n}, where
in_flight counts data requests past the drain stage, realtime sessions until they close.
GET /grove-admin/usage[?keys=p1,p2] answers {"drains": {"<drain id>": {prefix: hash}}} and
deletes nothing. Each live usage:<prefix> — every one, or only the listed prefixes, with no SCAN —
is RENAMEd to drained:<id>:<prefix> under a new drain id and added to drain:unacked, in one Lua
call, so a request metered mid-drain lands wholly in the drain or wholly on a fresh counter. The
answer is every pair still in drain:unacked (only the listed prefixes' when keys is given),
old drains included. POST /grove-admin/usage/ack {"acks": {"<drain id>": [prefix, ...]}} takes
the pairs the control plane recorded out of drain:unacked and keeps them for usage_retention
(default a week), answering {"ok": true, "count": n}; a pair not waiting is a no-op. A key it never
acks is answered again, under its own id, on every pull, while the rest move on: a control plane
that failed to record one user loses nothing and holds nobody else back, and one that recorded it
but lost the ack dedupes on the (drain id, prefix) pair. Nothing live and nothing waiting answers
{"drains": {}}.
The spool. A request that finishes while the store is down (new ones already 503 at admission)
cannot land its usage. It is appended as one JSON line — {"id": "<request id>", "prefix", "fields", "cost", "user", "budget", "at"} — to usage_spool and fsynced. At start and every 5 s while it is
non-empty the gateway PINGs the store; once it answers, each line is replayed through the accrue
script behind SET accrued:<request id> NX EX 604800, so a pass that dies half way lands nothing
twice, and the file is rewritten (tmp + rename) with only the lines still failing. A line that fails
five passes while the store answers is set aside in <usage_spool>.dead as {"id", "line", "error"}; one that does not parse goes there at once. Past usage_spool_max_bytes (1 GiB) usage is
logged and dropped. A store still down counts no tries. Only losing the box's disk loses spooled
usage.
The pull hands the dead lines over: GET /grove-admin/usage also answers "dead": [{"id", "line", "error"}] and "spool": {"depth", "replayed", "dropped", "dead"} (lines waiting now, and counts
since the process started). The control plane lands or records each dead line and names it in the
ack — "dead": ["<request id>", ...] beside "acks" — which removes it; count includes them.
GET /grove-admin/provider-keys?ids=a,b answers {id: {requests, ok, rate_limited, rejected, failed, last_used, last_rate_limited}} per vendor credential, lifetime and never drained; an id
never dialled is zeros. The control plane sums it across stores for the operator.
POST /grove-admin/spend-adjust {"user", "delta", "id"} corrects one holder's spent on this store
by delta nano-USD, once per id — a retry answers {"spent", "applied": false} and moves
nothing. A holder this store does not hold is a 404; nothing is invented.
key:<sha256(secret)> status user prefix can_read_balance
user:<Grove User> email group (comma list) allow deny limited log_payloads geography
prepaid budget spent limits
model_group:<Model Group> models
usage:<key prefix> request_count prompt_tokens completion_tokens total_tokens cached_tokens
cache_write_tokens cache_write_1h_tokens audio_tokens completion_audio_tokens
prompt_tokens_above_272k cached_tokens_above_272k
cache_write_tokens_above_272k completion_tokens_above_272k
audio_seconds
cost user_spent user_balance
m:<metric>:<model> m:<metric>:<deployment>
p:<pricing id>:<priced counter> p:<pricing id>:cost
group / allow / deny / models are comma lists; blank parses to a map that answers false
to everything, which is the fail-closed default. group holds every group the user is in, so a
name containing a comma would split — Grove refuses one.
prepaid / budget / spent are the credit gate. budget is the amount the user loaded — Σ
their credits, nano-USD — and the same number on every store. spent is this Redis's own lifetime
counter: every metered request of a prepaid holder moves it, a push never does (the push is an HSET
of the fields it names), and only spend-adjust corrects it. This box's balance is budget − spent; Grove keeps its
own from the same credits and audits the two every pull. prepaid && spent >= budget → 402 credit balance exhausted (billing_error on the Anthropic surface), at
quota before the body is read, and /v1/models refuses through the same Evaluate. limited
is the control plane's verdict and refuses alike. The gate is exact within one store — every
gateway on it moves the one counter — and eventual across stores, where each box sees only its own
spend and limited from the pull is what stops the rest; the overspend that window allows lands as a
negative balance on the control plane.
deploy:<model> is a JSON array, replaced whole:
[{
"engine_url": "https://10.0.0.9/e/md-00007",
"internal_key": "<the engine's own key>",
"healthy": true,
"region": "ap-south-1",
"capacity": 1024,
"deployment": "MD-00007",
"server": "INF-1",
"kind": "direct",
"pricing": {"id": "mp-a1", "rates": {"prompt_tokens": 3000000000, "completion_tokens": 15000000000}}
}]engine_url is a base; the path the client asked for is appended to it. in_flight is
computed here and never pushed — the control plane has no view of what is running right now.
input_modalities and output_modalities are what the model takes and gives (["text","image"],
["text"]), stamped on every row of the model. The outputs are the path gate: chat and messages
need text, /v1/embeddings needs embeddings, /v1/audio/transcriptions and /translations
need transcription; a model that does not give it is our own 404 before anything is dialled, and
any other path is nobody's to refuse. The inputs are read only for a fallback, which is passed over
when it lacks what the request carries (see Fallback models). A row that declares nothing — one pushed before
the control plane said — is unrestricted and not judged. There is no modality field: a push
that carries one is refused like any unknown field.
pricing is the Model Pricing in force — its id and its sell price per counter, nano-USD per unit
(Mtok, minute, request) — stamped on every row of the model. Absent on an unpriced model, and such
a request costs 0.
A kind: "provider" row dials a third-party vendor instead of anything we run, and carries its
own fields:
[{
"engine_url": "https://api.anthropic.com",
"internal_key": "",
"credentials": [{"id": "<key row id>", "secret": "<the vendor's API key>"}, ...],
"key_selection": "round_robin",
"healthy": true,
"capacity": 0,
"vendor": "anthropic",
"kind": "provider",
"upstream_model": "claude-sonnet-4-5-20250929",
"api_version": "2023-06-01",
"dialect": "anthropic"
}]upstream_model is what the modelmap transform puts in body.model; blank means send the
caller's unchanged, which is every route we run ourselves — an engine is started under the Grove
id. A realtime upgrade carries its model in the query string and has no body for a transform to
rewrite, so the same substitution is applied to ?model= in the transform stage. Access, routing, metering and /v1/models all key on the id the caller sent, so the rewrite
cannot desync a grant from a route. On a provider hop the client's Authorization is deleted
rather than replaced, the vendor's own key goes in the header its dialect expects (x-api-key
plus anthropic-version for an Anthropic front, a Bearer for an OpenAI-compatible one and for an
Anthropic front the vendors table says reads one), and the
certificate is verified whatever upstream_tls_verify says — that hop leaves our network carrying
someone else's key.
credentials is every key the control plane holds with the vendor, each under the id it is
counted by, in ring order; internal_key stays blank on a provider row and is the one-key spelling
an engine or ingress row keeps (Keyring() reads either). route takes the keys in turn, one
cursor per ring in this process — a vendor's ring is the same on every model it serves, so its keys
share the turn across models, which is how the limits they share are drawn on evenly; two gateways
each take even turns without agreeing on whose it is. key_selection is carried for the day there
is a second strategy. A session still changes key between requests, so a vendor's prompt cache is
per credential. The retry stage then walks on round the ring from the loser, on the key's own
failures only: a 429 spends the key — each is tried once, then the rate-limited ones are opened
once more, since a quota window may have slid — and a 401/402/403 retires it for the rest of the
request. The second exhaustion is the
client's answer, headers and body as the vendor sent them. Nothing else is retried: a 502 on one
key is a 502 on the next. A held attempt leaks nothing to the client; the request is billed once
whatever it walked, and the access line's attempts counts the dials. Every attempt lands in
pk:<key id> (requests, ok, rate_limited, rejected, failed, last_used,
last_rate_limited), lifetime, which GET /grove-admin/provider-keys?ids=a,b answers.
dialect is the API shape the row speaks, openai or anthropic, and nothing translates between
them: a request reaches only rows of its surface's dialect. A vendor with both fronts is two rows. On
a row we run, blank means both, since vLLM answers both natively; on a provider row, blank is
malformed and the row serves nothing.
A provider route is also deny-by-default on the surface check. Serves is generous to an engine
because an engine answers on more than the OpenAI core (/tokenize, /v1/rerank), but a vendor is a
closed set we already know, so ServesRoute holds it to its dialect's paths — /v1/chat/completions
for openai, /v1/messages for anthropic. Anything else is our 404 at routing.go, above
meter, instead of a round trip that comes back as theirs. The paths are one table,
vendorPaths in domain/dialect.go, path → dialect: serving another (/v1/responses for
openai, say) is a row there.
The surface is not read off the path. The /anthropic alias strips its prefix before the chain
runs, so below it /anthropic/v1/messages and a stray root /v1/messages look the same; the alias
records the surface on the request as it strips (respond.WithDialect), and everything below —
routing.Request.Dialect, the refusal's shape, the proxy's own errors — reads respond.Dialect.
Unmarked is OpenAI, because root is the OpenAI surface.
- A key record written before access moved off the credential carries
group/allow/deny/modelsitself. Ifuser:<name>is missing, those are used. This is what makes the control plane and the gateway deployable in either order. - A current key with no user record resolves to nothing rather than falling back to something. Fail closed — the difference between the two is the whole quarantine.
status: "rate_limited"was once a third value on the credential. It is lifted off on read, sostatusmeans only "is this live".- A route with no
kindisdirect, which is what every route pushed before the split was. - A route with no
upstream_modelsends the caller'smodelunchanged, which is what every route pushed before the vendor split did.
POST /v1/* |
the data path for OpenAI clients. /v1/messages here is a 404 pointing at /anthropic |
POST /anthropic/v1/* |
the data path for Anthropic clients (ANTHROPIC_BASE_URL=<gateway>/anthropic): /v1/messages only — anything else under it is a 404 in Anthropic's shape — keyed by x-api-key or a Bearer |
GET /v1/models |
answered here, never forwarded — an engine only knows its own model. With a key: what that key may use through the OpenAI surface. Without one: 401 |
GET /anthropic/v1/models |
the same, in Anthropic's list shape, for what that key may use through the Anthropic surface |
GET /v1/credits |
answered here: what the key's holder has left on this store, in US dollars — {"balance", "spent", "is_free_user"}. balance is budget − spent, the figure quota gates on, negative once overspent and still readable then. Only for a key pushed with can_read_balance; any other key gets 403 this key cannot read the balance. A free holder reads zeros and "is_free_user": true |
| any other method on either | 405 Allow: GET, not forwarded — a chat body POSTed at the list would otherwise reach the proxy and be refused as a model that "does not serve" the path |
GET /healthz |
200, or 503 while draining or in maintenance |
GET /metrics/node |
node_exporter behind bcrypt basic auth |
The rule everywhere: degrade toward serving. A gateway that refuses traffic because a counter was unreadable turns one broken dependency into an outage.
| What fails | What happens |
|---|---|
| Redis unreachable at startup | refuses to start — a gateway that cannot read its keys serves nothing, and finding out on the first customer request would report it as a routing fault |
key: / user: / model_group: read fails |
503, never 401 — "we cannot read your key" must not send someone to rotate a credential that was fine |
| in-flight counts unreadable | every count reads 0, so the pick degrades to first-healthy. Balancing is an optimisation on a table that is already correct |
| health counters unreadable | every route stays as the control plane pushed it. Ejection is an optimisation too |
| sticky read/write fails | one cold prefix cache, not a wrong answer |
| usage write fails | the request already succeeded, so it is not failed retroactively: its usage goes to the spool (below) and lands once the store answers. spent did not move either, so the gate runs loose by that request until then |
| the engine is dead | 502 upstream unavailable, and the hop counts against the target — three in a row and it leaves rotation |
| the engine accepts the connection but sends no headers | 504 upstream timed out after upstream_read_timeout (a dial that runs out is a 504 too); counts against the target |
| the client leaves before the upstream answers | the upstream call is cancelled at once; the access line reads 499 cut=client_left, and the hop does not count against the target |
| the client leaves in the middle of a response | the upstream call is cancelled at once; cut=client_left. What the upstream had reported by then is metered, the rest is not |
| the upstream goes silent after its headers | cut after upstream_read_timeout of silence, cut=upstream_idle, and the hop counts against the target. An event stream ends in an upstream went silent error event; any other body is dropped. A stream that keeps talking is never cut, however long it runs |
| an HTTP/2 upstream connection dies without closing | pinged after 15s with nothing received and closed 90s later: a request waiting on it gets 502 upstream unavailable and counts against the target, and the next one dials a fresh connection |
pathway outgrows the unit's MemoryMax |
the kernel kills it inside its own cgroup and systemd starts it again 2s later: live streams drop, the rest of the box is untouched. Without a cap the whole box runs out and the kernel picks what dies |
| the upstream's body breaks off | cut=upstream, and the hop counts against the target. An event stream ends in an upstream broke off the stream error event; any other body is dropped |
| the client drips its body | 408 once 60s pass without the part body reads; nothing is routed or billed |
| every replica is full | waits up to capacity_wait, then 429, distinct from 503 on purpose: the model is up. Nothing is claimed or billed while waiting |
| the model has no placement | 503 |
| the control plane is down | nothing happens. The gateway serves the last table it was pushed, indefinitely |
| the tunables file is bad | the running configuration is kept, one error line says why |
| a new binary will not start | the old process keeps serving; the upgrade is a no-op |
| the certificate file is unreadable | the certificate already in memory keeps being served |
Every refusal speaks its surface's shape — {"error":{"message":…,"type":…}} for OpenAI clients,
{"type":"error","error":{…}} under /anthropic — so a client parses them the same way whichever
gate produced it. type is the error's class, named off the status as the
Anthropic API names it (authentication_error, rate_limit_error, …), except maintenance and
draining, which a 503 alone does not say.
| Means | Client should | |
|---|---|---|
| 400 | the body is not a JSON object, names no model, or its fallbacks is not a list of at most 3 names |
fix the request |
| 401 | no key, unknown key, revoked key | fix the credential |
| 403 | the key exists but may not use this model | ask for access |
| 408 | the body did not arrive within 60s of the headers | resend on a working connection |
| 413 | body over max_body_bytes |
send less |
| 402 | out of prepaid credit | top up; nothing to retry |
| 429 | every replica at capacity, or the holder is over a rate limit | back off and retry — Retry-After is set for a rate limit |
| 499 | the client left before the upstream answered. Only ever in the access line: nobody is there to receive it | – |
| 502 | the engine could not be reached or failed the hop | retry; another replica may take it |
| 504 | the engine sent no headers within upstream_read_timeout, or could not be dialled in time |
retry; another replica may take it |
| 503 | the model has no healthy placement, a store is unreadable, or the gateway is draining or in maintenance | retry with backoff — Retry-After is set when draining or in maintenance |
go build ./... && go vet ./... && go test ./...
go test -race ./internal/transport/httpThe tests need nothing running: repository/memory is a behaving in-memory store, and
internal/transport/http/dataplane_test.go drives the whole chain against a fake vLLM over
httptest. That is the file to read first — it shows the request as the engine receives it.
Against a real Redis:
redis-server --port 6399 --save '' --daemonize yes
GROVE_ADMIN_TOKEN=tok GROVE_GATEWAY_ID=gw-dev \
GROVE_REDIS_ADDR=127.0.0.1:6399 GROVE_LISTEN_HTTP=127.0.0.1:8080 \
go run ./cmd/pathwaySeed it the way the control plane does, then call it:
curl -XPUT localhost:8080/grove-admin/groups -H 'X-Grove-Admin-Token: tok' \
-d '{"groups":[{"name":"acme","models":"qwen3-4b"}]}'
curl -XPUT localhost:8080/grove-admin/users -H 'X-Grove-Admin-Token: tok' \
-d '{"users":[{"name":"you","group":"acme,beta"}]}'
curl -XPUT localhost:8080/grove-admin/keys -H 'X-Grove-Admin-Token: tok' \
-d "{\"keys\":[{\"key_hash\":\"$(printf gr_demo | sha256sum | cut -d' ' -f1)\",\"prefix\":\"dev\",\"user\":\"you\",\"status\":\"active\"}]}"
curl -XPUT localhost:8080/grove-admin/routes -H 'X-Grove-Admin-Token: tok' \
-d '{"routes":{"qwen3-4b":[{"engine_url":"http://127.0.0.1:8000","internal_key":"k","healthy":true,"deployment":"MD-1","kind":"direct"}]}}'
curl localhost:8080/v1/chat/completions -H 'Authorization: Bearer gr_demo' \
-d '{"model":"qwen3-4b","messages":[]}'
redis-cli -p 6399 HGETALL usage:devWith nothing listening on :8000 that last call is a 502 and only request_count accrues — which
is itself the correct behaviour, and worth seeing once: an abandoned or failed request still counts
as a request, and the failure counts against the target. Point engine_url at a real vLLM (or the
fake in dataplane_test.go) for token counts.
Tagged versions publish a static linux/amd64 and linux/arm64 binary (CGO_ENABLED=0,
-trimpath) plus a sha256sums.txt. A control plane downloads the checksummed asset, installs it to
/usr/local/bin/pathway, and runs it under systemd as an unprivileged user with PIDFile= inside a
RuntimeDirectory= — the PID changes on an upgrade and systemd follows the child through the file
tableflip writes there (Type=notify + NotifyAccess=all is the other way to do it). Grove drives this
from its install_gateway_agent role.
- New binary → copy +
systemctl reload(SIGHUP). No dropped connections. - Changed tunable → write
config.json+ SIGUSR1. No restart. - Changed
agent.env→ restart. The child reads the environment from systemd, so a reload would not pick it up. This is the only case that drains, and it is rare. - Renewed certificate → copy the file. Nothing else: the loader watches its mtime.
A v* tag push releases that commit. For any other branch or name, dispatch the same workflow:
gh workflow run release.yml -f ref=dialect -f tag=dialect-2026-09-15 # add -R owner/fork for a forkIt builds ref, creates tag on that commit, and publishes the same assets as a pre-release (pass
-f prerelease=false to let it become Latest). In Grove, set Pathway Release and Pathway Repo
in Grove Settings, then create a Pathway Update, tick Gateways and/or Ingresses, and Start: it
deploys each server in turn and stops at the first failure. A fork must enable Actions once, and its
releases must be public: boxes download them unauthenticated.
The release build, minus the tag — so what you ship is what CI would have shipped:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
-ldflags "-s -w -X main.version=dev-$(git rev-parse --short HEAD)" \
-o dist/pathway-linux-amd64 ./cmd/pathway
./dist/pathway-linux-amd64 --version # dev-<sha>Static (CGO_ENABLED=0), so it runs on any Linux box whatever its libc; GOARCH=arm64 for a
Graviton front box. Without -X main.version it reports plain dev.
A build off your machine instead of a release, through the same play, with the same tracking (an Ansible Play under the Gateway Server). From the Grove bench root:
bench --site grove.localhost execute frappe.enqueue_doc --kwargs \
'{"doctype":"Gateway Server","name":"gw1-ap-south-1","method":"_deploy_agent","now":True,"agent_binary":"/abs/path/to/pathway-linux-amd64"}'agent_binary is an extra-var of Grove's install_gateway_agent role: set, that file is copied to
the box in place of the checksummed download, and nothing else about the deploy changes — agent.env
and config.json are still written whole from the doc, and the binary lands with a systemctl reload
(SIGHUP) unless agent.env moved. The Setup job (provision) takes it too, for a box that needs the
unit shipped as well. now: True runs it in this process rather than the queue, so a worker on
stale code is not in the way. Gateway Server only for now; the Ingress Server jobs do not take it.
The doc keeps reporting the pinned release: a box on a dev build reads as that release until the next Deploy Agent click puts it back — which is the point. A dev deploy is a temporary state, and the button is how it ends.
domain/must not grow an import. Its purity is what lets a remote router, or a second process, run the identical rule.- Anything that claims an in-flight slot must release it.
meterdoes it in adeferfor exactly this reason. One owner for that counter, always. - The usage scraper must never modify the response. It reads what it is already copying. A metering feature that reordered or buffered a token stream would be worse than no metering.
upstream_tls_verifyis per target, not per listener. That is the thing nginx could not do —proxy_ssl_verifyis a per-location directive, so one self-signed box pinned verification off for every target sharing that location. Defaultedfalsefor parity; it is a default, not a ceiling — and it is not a floor either: akind: "provider"hop verifies whatever it is set to, because that request leaves our network carrying a vendor's own API key. The transport pool is keyed on the setting as well as the host, so the two can never share a connection.- Least-in-flight balances concurrent traffic, not sequential. A sequential caller releases its slot before the next pick, so both replicas read zero and the tie takes the first. Fine for one chatty client — it keeps a prefix cache warm — but a fleet of many sequential clients pins them all to one engine.
- The HTTP/2 ping is what stops a dead upstream connection eating requests. One that dies
quietly (no FIN, no RST) keeps taking new requests for as long as traffic keeps it busy: the 90s
idle close only runs on a connection with no request on it. Without the ping each request hangs
until
upstream_read_timeout, and the connection goes only when the kernel gives up retransmitting (about 15 minutes at Linux defaults). It is set throughTransport.HTTP2, which is whygo.modasks for Go 1.24. - The memory limits live in the unit, not in this code. Grove writes a drop-in with
MemoryMaxandGOMEMLIMITat 90% of it, so the collector works harder before the kernel kills anything.MemoryMaxmoves under the running process;GOMEMLIMITis environment, and a SIGHUP child copies its parent's, so a changed one is only read at a full restart. During a handover the draining parent shares the cgroup, each with its own 90%.