pricesrpc+proxy: metered draw-down of prepaid L402 tokens, with a reference pricer daemon#247
pricesrpc+proxy: metered draw-down of prepaid L402 tokens, with a reference pricer daemon#247Roasbeef wants to merge 14 commits into
Conversation
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
In this commit, we extend the Prices service beyond the single up-front GetPrice query so an external pricer can meter prepaid L402 tokens over their whole lifecycle. ChallengeMinted tells the pricer which token ID a fresh challenge was minted under and at what price, so a paid token can be matched to the balance it purchased. AuthorizeRequest is consulted on every authenticated request, letting the pricer decide when a token's balance is exhausted and a fresh 402 is due. ReportUsage delivers a capped tail of the completed response body (e.g. the trailing usage object of an SSE stream), which is where the actual cost of a metered call becomes known. The motivating use case is metered LLM inference: a client buys a bundle of tokens with one Lightning payment, draws it down across many streaming requests, and re-purchases via a new challenge once the pricer says the bundle is spent.
In this commit, we add the MeteredPricer interface: a Pricer that can also be consulted per authenticated request (AuthorizeRequest), be told about freshly minted challenges (ChallengeMinted), and receive response usage reports (ReportUsage). GRPCPricer implements it over the new pricesrpc methods. The service-level dynamicprice config grows a 'metered' flag to opt a service into this flow, plus 'usagetailbytes' to bound the captured response tail. While here, we switch request serialization to httputil.DumpRequest, which buffers and restores the body on the request. The old r.Write call consumed the body outright, so a dynamic pricer returning a zero price would let the request through to the backend with an empty body. The restore also matters for AuthorizeRequest, which runs on requests that are about to be forwarded.
In this commit, we wire the metered pricer into the proxy's request path. For a service with dynamicprice.metered set, every authenticated request is first authorized against the pricer: if the token's prepaid balance is exhausted, the proxy answers with a fresh 402 challenge (priced by the pricer, falling back to GetPrice) instead of forwarding the request. Before any challenge for a metered service goes out, the pricer is notified of the minted token ID via ChallengeMinted; if that notification fails we abort the challenge, since the client would otherwise pay for a bundle the pricer will not honor. On the response side, the ModifyResponse hook wraps the body of an authorized metered request in an observer that captures a bounded tail of the bytes streaming through and reports the usage (status, content type, tail, completion flag) to the pricer exactly once, when the body hits EOF or is closed early by a disconnect. The copy loop itself is untouched, so SSE pass-through behavior does not change. Requests authenticated through non-L402 schemes (e.g. MPP sessions) skip pricer metering, as their draw-down is accounted for by the session authenticator itself. While here, we fix the freebie path to send the dynamically fetched price into handlePaymentRequired instead of the static service price it previously used.
In this commit, we add meterd, a reference implementation of the
pricesrpc.Prices service that aperture's dynamicprice.metered mode
talks to. The daemon sells prepaid bundles of LLM tokens over L402: a
client pays a single Lightning invoice for a bundle of N tokens
(1,000,000 by default), draws it down across requests, and receives a
fresh 402 challenge once the bundle is spent.
A bundle is priced at the model's blended per-token rate, the average
of the configured input and output rates:
price_sats = ceil(bundletokens * (inputmsat + outputmsat) / 2 / 1000)
GetPrice resolves the model from the JSON body of the serialized HTTP
request, falling back to a configurable default model. ChallengeMinted
books a bundle for the minted token ID, idempotently. AuthorizeRequest
allows requests while tokens remain and otherwise denies them with the
quote for the next bundle attached, so aperture can mint the follow-up
challenge without another GetPrice round trip. ReportUsage extracts
the final usage object from the captured response tail, scanning SSE
data lines for streamed responses and brace-matching the last "usage"
object for plain (possibly front-truncated) JSON bodies, then debits
the bundle: exact per-direction pricing when the prompt/completion
split is known, the blended rate otherwise. Debits round up and
remaining balances round down, so rounding never favors the client.
Balances live in a mutex-guarded in-memory store that can persist to a
JSON state file on every change, and a janitor expires bundles that
were never used by an authorized request within 24 hours.
Configuration comes from a YAML file plus flag overrides, with
optional TLS; the server speaks plaintext when no certificate is
configured, matching aperture's dynamicprice.insecure mode. A thin
main lives at cmd/meterd.
fd347a2 to
67cdebe
Compare
|
Addressed the review findings and force-pushed (branch Metering bypasses closed (must-fix):
Correctness / accounting (should-fix):
Nits: a metered request whose L402 header is present-but-unparseable now returns 500 instead of free unmetered access; the usage brace-matcher requires Note on CI vs local: the pre-existing |
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
In this commit, we open up meterd so a downstream daemon can embed the metering logic as a library rather than forking the binary. The bundle store moves behind an exported Store interface (with the existing JSON file store renamed to JSONStore as its default implementation), and the static models map moves behind a RateSource interface that owns both model resolution and the per-model bundle sizing. NewServer grows functional options (WithStore, WithRateSource) so an embedder can swap in a database-backed store or a dynamically refreshed price table while reusing the booking, reservation, and draw-down logic unchanged. Existing call sites keep working: without options, NewServer builds the same JSON store and static rate table as before.
|
Pushed one more commit, da3b973: meterd now exposes a |
In this commit, we regenerate the pricesrpc gateway and message stubs through the repo's dockerized gen_protos_docker.sh, using the protoc-gen-go and grpc-gateway versions pinned in go.mod. The committed prices.pb.go and prices.pb.gw.go had drifted from the pinned toolchain versions, so CI's rpc-check job, which regenerates the stubs from scratch and diffs against the committed files, was failing. Regenerating locally with the same docker image CI uses produces a byte-identical diff to the one CI reports, so this brings the committed files back in sync with go.mod without hand editing any generated code.
…tine In this commit, we fix a data race between the detached usage-report goroutine spawned by usageObservingBody.report and the tests that exercise it. reportUsageWithRetry read the package-level reportInitialBackoff variable, and TestReportUsageWithRetry mutated that same variable to shrink the backoff for its own run. Because the report goroutine is detached from the request that spawned it, a goroutine left over from an earlier test (TestUsageObservingBody or one of the checkMeteredAccess tests) could still be reading the global while the next test wrote to it, tripping the race detector under unit-race and unit-race dbbackend=postgres. We replace the two package-level knobs, reportMaxAttempts and reportInitialBackoff, with a small reportSchedule struct carrying both the attempt count and the initial backoff. usageObservingBody now carries its own schedule, defaulting to defaultReportSchedule in production, and reportUsageWithRetry takes the schedule as a parameter instead of reading globals. Tests that need a fast schedule build their own reportSchedule value and pass it in, rather than mutating shared state that a leftover goroutine from a previous test might still be reading.
|
Pushed two fixes for the CI failures on this branch. The RPC check job was failing because the committed pricesrpc/prices.pb.go and prices.pb.gw.go had drifted from the protoc-gen-go and grpc-gateway versions pinned in go.mod. Regenerating locally through pricesrpc/gen_protos_docker.sh (same dockerized toolchain CI uses) produced a diff that matches CI's own reported diff line for line. Committed the regenerated stubs as is, no hand edits. The unit-race failures (TestCheckMeteredAccess, TestReportUsageWithRetry, TestUsageObservingBody) were a real data race. usageObservingBody.report spawns a detached goroutine (reportUsageWithRetry) that read the package-level reportInitialBackoff variable, while TestReportUsageWithRetry mutated that same variable to shrink its backoff. Since the report goroutine outlives the request and the test that triggered it, a goroutine left over from an earlier test could still be reading the global while the next test wrote to it. Fixed by replacing the two package-level knobs with a reportSchedule struct that's passed explicitly into reportUsageWithRetry, defaulting to defaultReportSchedule in production. Tests now build their own schedule and pass it in rather than mutating shared state. Verified proxy, meterd, and pricer with -race locally (bounded runs, not the full proxy suite since some of those tests are known to fail locally for environmental reasons unrelated to this PR). |
In this commit, we close the loop between an authorization's reservation and the usage report that should release it. The proto had no way to correlate the two, so the price server could only release an approximate default estimate on every report; mismatched estimates accumulated as reservation residue until a bundle with real balance read as exhausted. AuthorizeRequestResponse gains a reserved_estimate field carrying the per-request token estimate the price server reserved, aperture threads it through the metering context into the usage observer, and ReportUsageRequest echoes it back verbatim. A zero value falls back to the old approximate behavior, so older pricers and proxies interoperate unchanged.
In this commit, we make the draw-down accounting exact on both sides of a usage report. The bundle is priced at the blended (input+output)/2 rate, but the buyer chooses the prompt/completion mix, so a raw token debit lets an output-heavy workload consume more msat value than it pays for. When the usage split is known, the debit is now weighted by direction, charging a completion token as outRate/blend bundle tokens, with the division rounding up so it never favors the client. The blended margin is then pure margin rather than partial insurance against an adversarial mix. On the release side, the authorization response now carries the reserved estimate and the report echoes it back, so the exact reservation is released instead of the configured default. Mismatched estimates previously accumulated as residue until a bundle with real balance read as exhausted; reports without an echo keep the old approximate release.
In this commit, we stop unknown models from riding another model's rates. Model resolution used to fall back to the default model for any identifier it did not recognize, which quietly defeated the authorize path's mismatch guard: a client holding a cheap-model bundle could name an upstream-hosted model the seller never priced, resolve into the booked default, and have aperture proxy the expensive model upstream while the debit ran at the cheap rate. Resolution now only falls back when the request names no model at all. A non-empty unknown model fails GetPrice and ChallengeMinted with InvalidArgument, and the authorize path denies it outright, so a model without a configured price is neither quoted nor served. The RateSource contract documents this as a requirement for embedders.
In this commit, we close the window in which a freshly paid bundle could be evicted as mint spam. Payment happens at the proxy, so the store cannot tell a just-paid bundle from an un-paid one until its first authorized request arrives; the count-based eviction treated both alike and a burst of unauthenticated challenge mints could push a just-paid bundle out before the client's retry landed. The store gains an eviction age floor: never-authorized bundles younger than it still count against the cap but are not candidates for eviction. The server wires the floor to the janitor's TTL, so within the window a paid bundle is safe and beyond it the janitor reaps un-paid bundles anyway. The worst-case state retained under sustained mint spam is the mint rate times the TTL, with aperture's rate limits as the front line.
|
Four follow-up commits on top, tightening the metering accounting after another review pass:
|
In this commit, we appease the prealloc linter: the eviction scan now allocates its candidate slice with the bundle map's capacity up front instead of growing it per append.
In this commit, we teach the service header config to expand ${NAME}
environment references at startup. The existing !file+hex and
!file+base64 directives cover file-borne credentials like macaroons,
but an upstream bearer token (e.g. a model API key) had to be pasted
into the config file verbatim. With this change, the config can carry
"Bearer ${UPSTREAM_KEY}" instead, keeping the secret out of the file
and in the process environment.
A reference to an unset or empty variable fails startup rather than
substituting an empty string, so a missing secret can't silently turn
into a malformed header upstream. Only the braced form is recognized,
leaving literal dollar signs in header values untouched.
In this commit, we fix the director to replace a client-supplied header with the configured service value instead of appending after it. The comment always said overwrite, but the code used Header.Add, so a request's own Authorization (the L402 header itself) rode ahead of a configured upstream credential in the header list. Backends that read only the first value then rejected the request; Baseten's model API 403s on exactly this. Caught by the first live run against a real upstream: the L402 leg paid and settled fine, and the paid retry bounced off the backend with a bad-api-key error until the credential actually replaced the L402 header.
In this PR, we extend the dynamic pricing hook so an external pricer can meter prepaid L402 tokens over their whole lifecycle, rather than only quoting a price at challenge time. The motivating shape is any metered, streaming service where the true cost of a request is only known after the response completes: LLM inference is the canonical example, where a client buys a bundle of tokens with one payment, draws it down across many streaming calls, and should be re-challenged exactly when the bundle runs dry. Today aperture only consults the pricer on unauthenticated requests, so once a token is minted it grants access until expiry and the seller has no per-request say and no view of actual usage.
The new pricesrpc surface
GetPricestays as is. Three RPCs join it, all invoked by aperture against the configured price server:Metering is opt-in per service via
dynamicprice.metered, withusagetailbytesbounding the captured tail. Requests authenticated through non-L402 schemes (e.g. MPP sessions) skip pricer metering, as session draw-down is already accounted for by the session authenticator, so the two models compose.Proxy integration
The authorize check runs right after successful authentication; the response observer wraps the body in the
ModifyResponsehook, captures a bounded tail as bytes stream through, and reports usage exactly once on EOF or early close, off the request path. The copy loop itself is untouched, so SSE pass-through behavior (immediate flushing) does not change.While here, two small fixes the work surfaced: request serialization for the pricer now uses
httputil.DumpRequest, which restores the body it reads (the oldr.Writeconsumed it outright, so a zero-price dynamic response forwarded a bodyless request to the backend), and the freebie path now sends the dynamically fetched price into the challenge instead of the static service price.meterd: a reference metering pricer
meterd(newcmd/meterd) is a small daemon implementing the full service: it quotes token bundles at a blended per-token msat rate from a per-model price table, books balances on ChallengeMinted, draws them down from the usage objects it extracts out of SSE or JSON response tails (front-truncation safe), and denies AuthorizeRequest with a re-quote once a bundle is spent. State persists as JSON across restarts. It doubles as the executable spec for what a metering price server must implement.The full cycle has been exercised end to end on regtest against a real LND backend: 402 quoted and booked, invoice paid over a channel, paid retry authorized and streamed, exact usage debited, token reused without re-payment, and a drained bundle answered with a fresh challenge.
See each commit message for a detailed description w.r.t the incremental changes.