Documentation
¶
Overview ¶
Package metering is how any product charges for usage: check the balance before, record the cost after.
It is the ONE way every Hanzo product meters usage to commerce — the single billing source of truth — so that every product (not only the LLM/cloud path) can be paid for.
It provides two operations, matching the proven cloud/gateway path:
Authorize: a pre-request balance gate. Fail-closed by default — if the balance cannot be determined the request is denied, exactly like the gateway's prepaid-balance gate (gateway/auth_middleware.go). With TierAware enabled it consults the tier-aware effective balance, which folds in the tenant's included plan allotment (e.g. the free-tier daily credit) so included usage is honored before prepaid funds.
Record: a post-request usage write. Records a usage event (cost in cents) against commerce, which debits the user's balance ledger.
The HTTP contract is commerce's canonical billing API, mounted under /v1 (commerce/api/billing/handlers.go):
GET {BaseURL}/v1/billing/balance?user={user}¤cy={cur}
GET {BaseURL}/v1/billing/tier?user={user} (tier-aware)
POST {BaseURL}/v1/billing/usage
Auth is the commerce service token (admin-scoped S2S), sent as
Authorization: Bearer {Token}
plus the tenant org as the X-Org-Id header. The token is a secret and MUST be sourced from KMS (never plaintext); this package never reads it from disk — the caller supplies it (typically from an env var the operator wires from a KMS-backed secret, e.g. COMMERCE_SERVICE_TOKEN).
Its only intra-repo dependency is the in-process finance seam (clients/finance): when a co-resident finance ledger is published, Authorize's balance read and Record's usage debit resolve it DIRECTLY (a typed in-proc call, no HTTP); otherwise both fall back to the commerce billing HTTP contract above. It pulls in NO commerce server internals, so any product — Go service, CLI, or job — can meter through it: it is the canonical client for commerce's billing API.
Example ¶
Example shows the ONE way a non-LLM product opts into pay-for-everything: build the client from the operator-wired env vars and wrap the handler. Every request is then balance-gated (fail-closed) and metered to commerce.
package main
import (
"net/http"
"github.com/hanzoai/cloud/apps/metering"
)
func main() {
// Token comes from a KMS-backed secret via COMMERCE_SERVICE_TOKEN — never
// plaintext. COMMERCE_URL defaults to the in-cluster commerce address.
meter, err := metering.FromEnv()
if err != nil {
panic(err)
}
// Price by outcome: charge a flat 5 cents per successful search request.
priceSearch := func(r *http.Request, status int, in metering.AuthInput) int64 {
if status >= 200 && status < 300 {
return 5
}
return 0
}
mux := http.NewServeMux()
mux.HandleFunc("/v1/search", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"results":[]}`))
})
// One wrap meters the whole surface.
handler := meter.Middleware(metering.MiddlewareConfig{
Provider: "search",
Price: priceSearch,
Skip: func(r *http.Request) bool { return r.URL.Path == "/healthz" },
})(mux)
_ = handler // http.ListenAndServe(":8080", handler)
}
Output:
Example (Imperative) ¶
Example_imperative shows direct use without middleware — for products that price per unit of work discovered during the request (e.g. functions billed by execution time), gating up front and recording the measured cost after.
package main
import (
"net/http"
"github.com/hanzoai/cloud/apps/metering"
)
func main() {
meter, _ := metering.FromEnv()
handle := func(w http.ResponseWriter, r *http.Request) {
in := metering.IdentityFromGatewayHeaders(r)
// Pre-request gate (fail-closed).
if err := meter.Authorize(r.Context(), in); err != nil {
if err == metering.ErrInsufficientBalance {
http.Error(w, "insufficient balance", http.StatusPaymentRequired)
} else {
http.Error(w, "billing unavailable", http.StatusServiceUnavailable)
}
return
}
// ... do the work, measuring cost ...
costCents := int64(12)
w.WriteHeader(http.StatusOK)
// Post-request record (best-effort).
_, _ = meter.Record(r.Context(), metering.Usage{
User: in.User,
Org: in.Org,
AmountCents: costCents,
Provider: "functions",
RequestID: r.Header.Get("X-Request-Id"),
Status: "success",
})
}
_ = handle
}
Output:
Index ¶
- Constants
- Variables
- type AuthInput
- type Client
- func (c *Client) Authorize(ctx context.Context, in AuthInput) error
- func (c *Client) AuthorizeVerdict(ctx context.Context, in AuthInput) (Verdict, error)
- func (c *Client) Enabled() bool
- func (c *Client) Middleware(cfg MiddlewareConfig) func(http.Handler) http.Handler
- func (c *Client) Record(ctx context.Context, u Usage) (*RecordResult, error)
- func (c *Client) Tier(ctx context.Context, subject, org string) (string, error)
- type Config
- type HTTPDoer
- type MiddlewareConfig
- type PriceFunc
- type RecordResult
- type ScopeRule
- type Usage
- type Verdict
Examples ¶
Constants ¶
const ( // EnvBaseURL is the commerce service base URL. // Default: http://commerce.hanzo.svc.cluster.local:8001 EnvBaseURL = "COMMERCE_URL" // EnvToken is the commerce service token (admin-scoped S2S). The operator // wires this from a KMS-backed secret; it is NEVER stored in plaintext in // the repo or image. EnvToken = "COMMERCE_SERVICE_TOKEN" // EnvOrg is the default tenant org slug (X-Org-Id) for S2S calls when a // request carries no org. Default: hanzo. EnvOrg = "COMMERCE_SERVICE_ORG" // EnvTierAware ("true") gates on the tier-aware effective balance // (prepaid + included plan allotment) instead of bare prepaid balance. EnvTierAware = "METERING_TIER_AWARE" // EnvFailOpen ("true") flips the gate to allow-on-error. Default is // fail-closed (deny), matching the gateway. Set only where availability // outranks billing. EnvFailOpen = "METERING_FAIL_OPEN" // EnvDisabled ("true") forces "not configured" mode regardless of // COMMERCE_URL — Authorize allows, Record is a no-op. For local dev. EnvDisabled = "METERING_DISABLED" // EnvTest ("true") routes all calls to commerce's TEST ledger // (X-Hanzo-Test: true). For staging/sandbox so debits never hit real money. EnvTest = "METERING_TEST" )
Environment variables a product reads to wire the metering Client. These are the canonical names the operator injects (the token from a KMS-backed secret); products MUST NOT invent their own. See gateway/auth_middleware.go (DefaultAuthConfig) for the same names on the gateway side.
const ( HeaderUserID = "X-User-Id" HeaderOrgID = "X-Org-Id" HeaderAccount = "X-Billing-Account-Id" )
Identity headers minted by the Hanzo gateway (the trust boundary). A product behind the gateway reads the caller identity from these — it never trusts a client-supplied value (the gateway strips them on ingress). See commerce/CLAUDE.md "Gateway Trust Headers".
const DefaultBaseURL = "http://commerce.hanzo.svc.cluster.local:8001"
DefaultBaseURL is the in-cluster commerce address. Matches the gateway's AUTH_BILLING_URL default so both gate on the same balance source.
Variables ¶
var ErrInsufficientBalance = errors.New("metering: insufficient balance")
ErrInsufficientBalance is returned by Authorize when commerce confirms the user's available balance is non-positive. It is distinct from a connectivity failure so callers can map it to HTTP 402 (vs 503 for "unknown").
var ErrSpendCapExceeded = errors.New("metering: spend cap exceeded")
ErrSpendCapExceeded is returned by Authorize when the caller is FUNDED but a configured per-scope spend cap (issue #70) would be exceeded by this request. It is DISTINCT from ErrInsufficientBalance: the balance is fine, the tenant's own policy ceiling is not — callers map it to a 402 spend_cap_exceeded, not the out-of-funds insufficient_balance.
var OnCapError func(error)
OnCapError, when set, is called (best-effort) whenever the cap check FAILS OPEN — a timeout or any error on the authorize call. It lets the host log/alert on a degraded cap without this leaf package taking a logger dependency. nil = no-op.
Functions ¶
This section is empty.
Types ¶
type AuthInput ¶
type AuthInput struct {
User string
Actor string
Org string
Currency string
// Amount, when non-zero, gates on available >= Amount instead of the bare
// available > 0. Use it to authorize a known up-front charge (e.g. the first
// hour of a machine) so a 1-cent balance cannot green-light an arbitrarily
// expensive request. Zero preserves the "any positive balance" gate.
//
// It is the exact, typed value — the same one Usage.Amount carries, at the
// ledger's own 18-decimal precision — so the gate and the debit that follows
// it weigh the SAME number. A cents-rounded gate admitted a charge the debit
// then wrote in full, which is how a sub-cent price gets authorized against a
// figure nobody spent.
Amount money.Amount
// AmountCents is the same charge in whole cents, for the HTTP path to
// commerce and for callers that have not got a typed value. Amount wins when
// both are set.
AmountCents int64
// Project and Service scope the per-scope spend cap + rate limit (issue #70).
// Service is server-derived (route/provider). Empty = the org-wide default
// scope. Forwarded to commerce so the right scope cap is resolved; they never
// change which BALANCE is gated — that is the address (Org, User), always.
Project string
Service string
// ProjectValidated reports whether Project is bound to a VALIDATED identity
// claim. When false, commerce DEGRADES a project-scoped hard cap to a soft warn
// (records + warns, never 402) so a forgeable X-Project-Id can neither hard-stop
// nor be evaded. The org and service axes are always validated. Today IAM mints
// no project claim, so cloud sends false; when it does, cloud sends true and
// project caps auto-harden.
ProjectValidated bool
}
AuthInput identifies who to authorize.
(Org, User) IS THE MONEY'S ADDRESS: Org names the LEDGER that holds the balance, User the ACCOUNT within it. Both halves are resolved by the ONE rule — principal.WalletOf, which is hanzoai/account.Payer — and a caller passes what it resolved, never a re-derivation of its own.
User is therefore the payer's SUBJECT, not "the org slug". For a pooled tenant the two coincide, because Payer answers the org itself and finance reads the org pool from a bare slug — which is why "User is always the org" held for years and why it was wrong: in the shared signup org, whose members are strangers to each other, Payer answers "<org>/<name>" and the pool is a balance that member neither owns nor can spend. A gate keyed on the org there checks a pool while the debit spends a person, and clients/principal/wallet.go catalogues what that costs.
A caller that legitimately holds only an org — a resource meter billing an org's build minutes, say — passes the org and gets the pool; that is the same rule, answered for an org credential, not an exception to it.
Actor is the full "org/sub" identity (e.g. "hanzo/alice") recorded on the usage transaction for the audit trail. It is ATTRIBUTION ONLY: for a machine key the payer is the org while the actor is the key, so the two axes are never each other.
Currency defaults to "usd".
func IdentityFromGatewayHeaders ¶
IdentityFromGatewayHeaders builds an AuthInput from the gateway-minted identity headers. User — the account this request pays from — is resolved by the ONE rule every layer that touches money shares (hanzoai/account.Payer), so this client cannot key a different account than the gate that authorizes the request or the ledger that records it. The full "{org}/{sub}" identity is recorded as Actor for the audit trail only; it never decides which balance is gated.
When there is no org (anonymous / org-less token) User falls back to the bare sub so a per-user balance can still gate; without either, User is empty and the fail-closed gate denies (anonymous traffic must be bypassed via Skip).
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client meters usage to commerce. It is safe for concurrent use.
func FromEnv ¶
FromEnv builds a Client from the canonical environment variables. This is the one-liner products use at startup:
meter, _ := metering.FromEnv()
mux.Use(meter.Middleware(metering.MiddlewareConfig{Provider: "search", Price: priceSearch}))
func New ¶
New builds a metering Client from cfg. It returns an error only for an unparseable BaseURL; an empty BaseURL is valid ("not configured" mode).
func (*Client) Authorize ¶
Authorize is the pre-request gate. It is the thin error-mapping wrapper over AuthorizeVerdict, preserving the proven three-outcome contract:
(nil) -> allow.
(ErrInsufficientBalance) -> deny: out of funds (map to HTTP 402).
(ErrSpendCapExceeded) -> deny: funded but over a per-scope cap (HTTP 402
spend_cap_exceeded — distinct from out-of-funds).
(other error) -> balance unknown; with the default fail-closed
posture this denies (map to HTTP 503).
With FailOpen it returns nil (allow).
When the client is not configured (no BaseURL) it always allows.
func (*Client) AuthorizeVerdict ¶
AuthorizeVerdict is the full pre-request gate: it checks FUNDS first (the money-safety backstop, honoring the fail-open/closed posture on a connectivity error) and, only when funded, layers the per-scope SPEND CAP verdict.
Spend caps are a POLICY OVERLAY, not a funds check: the balance gate already prevents overspending real money, so a cap-endpoint failure FAILS OPEN (degrades to funds-only gating) regardless of the funds fail posture — a commerce limits blip must never take down all paid traffic. An older commerce without the endpoint (404) is likewise treated as "no cap configured".
The returned WarnPct (>0 when at/over a covering cap's soft threshold) lets the caller emit X-Spend-Warn from this one round trip.
func (*Client) Enabled ¶
Enabled reports whether a commerce BaseURL is configured. When false, Authorize always allows and Record is a no-op.
func (*Client) Middleware ¶
Middleware returns net/http middleware that gates every request on the caller's commerce balance (fail-closed by default) and records usage after a successful response. It is the ONE way a non-LLM product opts into pay-for-everything: wrap the handler once and every request is metered.
It is plain net/http middleware (func(http.Handler) http.Handler), so it composes with the standard library, gorilla/mux (.Use), chi, and anything that speaks http.Handler — no per-framework variants.
func (*Client) Record ¶
Record writes a usage event to commerce, debiting the user's balance.
It is a no-op (nil, nil) when the client is not configured or when AmountCents <= 0 (commerce treats zero-cost usage as "skipped"). Usage recording is deliberately decoupled from gating: the work already happened and must be recorded, so balance is NOT re-checked here — exactly as commerce's RecordUsage documents.
Provider is the service name doing the metering when no model/provider is natural (e.g. "search", "functions"); set it on Usage.Provider.
func (*Client) Tier ¶
Tier resolves the subject's commerce subscription-plan NAME (free | starter | pro | enterprise) via GET /v1/billing/tier?user=<subject>, scoped to org (X-Org-Id). This is the in-process (co-resident) — or S2S HTTP — read the embedded ai module's per-tier SKU gate consumes (via aiobject.SetTierReader) INSTEAD of an authed self-call to the cloud edge: the edge 401/403s a service call to /v1/billing/*, so the ai module's own HTTP path always returned "" in-cluster and the gate failed OPEN. This rides the SAME transport and service token the metering gate already bills over, so it reaches commerce's OWN service-token middleware (which reads the tenant from X-Org-Id), never the cloud edge.
Empty subject or a not-configured client returns ("", nil): the gate treats an unknown tier as ALLOW (fail-safe), so a commerce hiccup never locks out a paying caller. Unlike fetchAvailable this does NOT short-circuit to the finance ledger — the plan tier is a commerce subscription fact, not a wallet balance.
type Config ¶
type Config struct {
// BaseURL is the commerce service base, e.g.
// "http://commerce.hanzo.svc.cluster.local:8001". No trailing /v1 — the
// client appends the canonical billing paths itself.
BaseURL string
// Token is the commerce service token (admin-scoped). MUST come from KMS;
// never hard-code or read from a file. Sent as "Authorization: Bearer".
Token string
// Org is the tenant org slug (e.g. "hanzo") sent as X-Org-Id so
// commerce resolves the right tenant namespace. Per-request Org on the
// Usage/AuthInput overrides this default.
Org string
// TierAware, when true, makes Authorize consult GET /v1/billing/tier and
// gate on the effective balance (prepaid + included plan allotment such as
// the free-tier daily credit) instead of the bare prepaid balance. This is
// the same effectiveAvailable commerce computes in GetTier.
TierAware bool
// FailOpen inverts the default fail-closed posture: when commerce cannot be
// reached, Authorize allows the request instead of denying it. Leave false
// for paid products; set true only where availability outranks billing
// (and accept the revenue leak). Mirrors the gateway, which is fail-closed.
FailOpen bool
// Test routes every call to commerce's TEST ledger (X-Hanzo-Test: true) so
// balances and debits hit the sandbox books, not real money. Production
// metering leaves this false. Used for end-to-end proofs and staging.
Test bool
// Timeout bounds each commerce HTTP call. Default 5s (the gateway's value).
Timeout time.Duration
// HTTPClient overrides the underlying HTTP client. When nil a client with
// Timeout is created.
HTTPClient HTTPDoer
}
Config configures a Client. Only BaseURL is conceptually required; an empty BaseURL puts the client in "not configured" mode where Authorize allows and Record is a no-op — matching the gateway's behavior when no billing URL is set, so a product can adopt metering before its tenant billing is wired.
func ConfigFromEnv ¶
func ConfigFromEnv() Config
ConfigFromEnv builds a Config from the canonical environment variables. It applies the in-cluster commerce default and the fail-closed/usd defaults.
type HTTPDoer ¶
HTTPDoer is the minimal HTTP surface the client needs. *http.Client satisfies it; tests and instrumented transports can substitute their own.
type MiddlewareConfig ¶
type MiddlewareConfig struct {
// Price computes the per-request cost in cents. Required — without it the
// middleware would gate but never charge, which is not metering.
Price PriceFunc
// Provider labels the recorded usage (e.g. "search", "functions"). It is
// stored on the commerce transaction so spend can be attributed per product.
Provider string
// Identify extracts the billing identity (IAM user + org) from a request.
// Defaults to IdentityFromGatewayHeaders, which reads the gateway-minted
// X-User-Id / X-Org-Id headers.
Identify func(*http.Request) AuthInput
// Skip lets a request bypass metering entirely (health checks, public
// paths). Returning true means: no gate, no record. Optional.
Skip func(*http.Request) bool
// OnDenied renders the response when Authorize denies. Defaults to a JSON
// 402 (insufficient balance) / 503 (balance unknown, fail-closed) — the
// same status mapping the gateway uses.
OnDenied func(w http.ResponseWriter, r *http.Request, err error)
// OnRecordError is invoked (best-effort, async) if recording usage fails.
// Optional — typically wired to the product's logger/metrics. The request
// has already succeeded; recording failure must not affect the response.
OnRecordError func(r *http.Request, u Usage, err error)
}
MiddlewareConfig configures Middleware.
type PriceFunc ¶
PriceFunc computes the cost (in cents) to record for a completed request. It is called AFTER the wrapped handler runs, with the captured status code and the per-request context, so it can price by outcome (e.g. charge only on success) and by work done (bytes, rows, units recorded on the context by the handler). Return 0 to record nothing.
type RecordResult ¶
type RecordResult struct {
TransactionID string `json:"transactionId"`
User string `json:"user"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Type string `json:"type"`
}
RecordResult is the commerce response to a usage write.
type ScopeRule ¶
ScopeRule is one scope's rate-limit config, consumed by the cloud ScopeRateLimit middleware. Only rows with a positive RateLimitRpm are returned.
The rows are READ over the internal plane (plane.FinanceScopeRules), not over this client: the reader is an edge middleware, and a GET /v1/billing/alerts through the commerce transport re-dispatched the whole shared app back through that same middleware until the depth guard 502'd. This type is the shape the limiter keeps; the wire that fills it is the socket.
type Usage ¶
type Usage struct {
User string `json:"user"` // the ACCOUNT half of the debit's address (see AuthInput.User) — a pooled org's slug, or the payer subject the gate authorized.
Actor string `json:"actor,omitempty"` // org/sub identity for the audit trail (commerce ignores unknown fields today; forward-compatible).
Org string `json:"-"` // routed via X-Org-Id, not the body.
Currency string `json:"currency,omitempty"`
// Amount is the exact debit, typed. Not serialized: the co-resident finance
// path reads it directly; the HTTP path derives the wire fields below from it.
Amount money.Amount `json:"-"`
// AmountCents is the debit in whole cents (legacy wire field). Set by older
// callers and by Record when serializing a typed Amount for commerce. When
// Amount is set, this is ignored on the co-resident path.
AmountCents int64 `json:"amount"`
// AmountMicros is the debit in micro-USD (1e6 = $1), sub-cent precision for the
// HTTP path so a tiny per-call cost is not lost to cent rounding. Commerce
// prefers it over AmountCents (usage.go: effMicros); when set, AmountCents may
// be 0. Zero/absent → commerce falls back to AmountCents*10000. Ignored on the
// co-resident path when Amount is set.
AmountMicros int64 `json:"amountMicros,omitempty"`
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
// Project and Service attribute this debit to a scope so commerce records the
// dimensions the per-scope spend cap sums over (issue #70). Empty = the
// org-wide default scope.
Project string `json:"project,omitempty"`
Service string `json:"service,omitempty"`
PromptTokens int `json:"promptTokens,omitempty"`
CompletionTokens int `json:"completionTokens,omitempty"`
TotalTokens int `json:"totalTokens,omitempty"`
RequestID string `json:"requestId,omitempty"`
Premium bool `json:"premium,omitempty"`
Stream bool `json:"stream,omitempty"`
Status string `json:"status,omitempty"`
ClientIP string `json:"clientIp,omitempty"`
}
Usage is one usage event to record. The amount (the cost to debit) is the essential beside the billing key (User); the rest is descriptive metadata commerce stores on the transaction.
Amount is the debit as an exact money.Amount — the canonical, typed value, native 18-decimal USD (the co-resident finance ledger's precision). One typed value, no precedence rules: a caller that has the exact cost (zen, which prices per token at 18-dp) sets Amount directly and the co-resident path debits it with NO rounding. The legacy int64 wire fields (AmountCents, AmountMicros) remain only for the HTTP path to commerce and for older callers that build a Usage without a money.Amount; from them Record reconstructs the same money.Amount. Amount, when non-zero, always wins.
func (Usage) Clone ¶ added in v1.801.381
Clone returns a Usage that OWNS every string it carries.
A Usage assembled inside a request handler routinely carries zero-copy views into the server's reused request arena: c.User(), c.RequestID() and the forwarded client IP are header reads that alias fasthttp's buffer, and that buffer is handed to the NEXT request on the same connection the instant the handler returns. Retaining such a Usage past its handler is a use-after-free whose symptom is not a crash — it is another caller's bytes marshalled onto this caller's debit, on a connection two tenants took turns on.
So anything that retains a Usage clones it first. [ResourceMeter.MeterUsage] records on a background goroutine and clones there, once, rather than each of its callers having to remember.
func (Usage) Money ¶ added in v1.801.350
amountMoney returns the canonical typed debit. Amount wins; otherwise the int64 wire fields are reconstructed (micros preferred, then cents) so a legacy Usage without a typed Amount still debits. The result is zero when no amount is set, which Record treats as "skip". Money is the debit this Usage carries, as the one exact value — resolving the precedence the type documents: the typed Amount when set, else micro-USD, else whole cents.
It is EXPORTED because "is there anything to bill here?" is the same question wherever it is asked, and asking it any other way gets a different answer. The resource meter asked it as `AmountCents <= 0 && AmountMicros <= 0` and so dropped, silently and before Record ever saw it, every usage priced only as a typed Amount — which is exactly the shape a per-token 18-dp caller sends. Money billed nobody and appeared nowhere: not an error, not a log, no row.
One question, one answer, one place. A caller that needs the value and a caller that only needs to know whether there IS one both read this.
type Verdict ¶
type Verdict struct {
Allow bool
Reason string // "", "insufficient_balance", "spend_cap"
WarnPct int
CapCents int64
SpentCents int64
}
Verdict is the full gate outcome AuthorizeVerdict returns, so a gate can render the distinct denial shapes AND emit the soft-warn header from ONE round trip.
Allow=true, Reason="", WarnPct=p -> allow; if p>0 emit X-Spend-Warn. Allow=false, Reason="insufficient_balance" -> 402 out of funds. Allow=false, Reason="spend_cap", Cap/Spent -> 402 spend cap exceeded.