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) ScopeRules(ctx context.Context, org string) ([]ScopeRule, 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) ScopeRules ¶
ScopeRules lists the org's per-scope rate-limit rules (the rate-limited subset of its spend-alert rows). It is the config source for the cloud ScopeRateLimit middleware, which caches it with a short TTL and fails open on error. Org is sent as X-Org-Id so the rules are the caller org's own — never another tenant's.
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.
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) 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.