metering

package
v1.801.470 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 5, 2026 License: Apache-2.0, MIT Imports: 17 Imported by: 0

README

metering — the one way to meter usage to commerce

github.com/hanzoai/commerce/metering is the single, reusable hook every Hanzo product uses to charge for usage. It is the DRY replacement for the balance-check and usage-record logic that was copy-pasted across the LLM/cloud path (ai/routers/filter_balance.go, ai/controllers/openai_api.go, gateway/auth_middleware.go). Any product — search, functions, storage, a CLI — imports this package and gets the proven, fail-closed billing gate plus usage recording, against commerce, the single billing source of truth.

What it does

Two operations, matching the proven cloud/gateway path:

Op Commerce endpoint Purpose
Authorize GET /v1/billing/balance (or GET /v1/billing/tier when tier-aware) Pre-request balance gate. Fail-closed by default.
Record POST /v1/billing/usage Post-request usage write (debits the user's balance ledger).

Auth is the commerce service token (admin-scoped S2S):

Authorization: Bearer ${COMMERCE_SERVICE_TOKEN}
X-IAM-Org-Id: <tenant org slug>

The token is a secret and must come from KMS (the operator wires it from a KMS-backed secret into COMMERCE_SERVICE_TOKEN). This package never reads it from disk.

Use it (middleware — the common case)

meter, _ := metering.FromEnv() // COMMERCE_URL + COMMERCE_SERVICE_TOKEN (KMS) + COMMERCE_SERVICE_ORG

mux.Use(meter.Middleware(metering.MiddlewareConfig{
    Provider: "search",
    Price: func(r *http.Request, status int, in metering.AuthInput) int64 {
        if status >= 200 && status < 300 { return 5 } // 5¢ per successful request
        return 0
    },
    Skip: func(r *http.Request) bool { return r.URL.Path == "/healthz" },
}))

Middleware is plain 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.

It reads the caller identity from the gateway-minted X-User-Id / X-Org-Id headers (the trust boundary). The gateway strips client-supplied copies on ingress, so these are safe to trust downstream.

Use it (imperative — per-unit pricing)

in := metering.IdentityFromGatewayHeaders(r)
if err := meter.Authorize(ctx, in); err != nil {
    // ErrInsufficientBalance -> 402 ; other -> 503 (fail-closed)
}
// ... do work, measure cost ...
meter.Record(ctx, metering.Usage{User: in.User, Org: in.Org, AmountCents: cents, Provider: "functions"})

Configuration (env, operator-wired)

Var Default Notes
COMMERCE_URL http://commerce.hanzo.svc.cluster.local:8001 Commerce base (no /v1 suffix).
COMMERCE_SERVICE_TOKEN Admin-scoped S2S token. KMS-sourced.
COMMERCE_SERVICE_ORG hanzo Default tenant org (X-IAM-Org-Id).
METERING_TIER_AWARE false Gate on effectiveAvailable (prepaid + included plan allotment).
METERING_FAIL_OPEN false Allow-on-error. Leave false for paid products.
METERING_DISABLED false Force not-configured mode for local dev.

Fail-closed contract (aligned with the gateway)

Authorize returns:

  • nil → allow.
  • ErrInsufficientBalance → out of funds → map to 402.
  • any other error → balance unknown → fail-closed: deny → map to 503 (set FailOpen to allow instead).

When no COMMERCE_URL is configured the client is in "not configured" mode: Authorize allows and Record is a no-op, so a product can ship the wrap before its tenant billing is wired.

This is the same balance source and the same status mapping the gateway uses — no divergent logic.

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}&currency={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)
}
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
}

Index

Examples

Constants

View Source
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.

View Source
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".

View Source
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

View Source
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").

View Source
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.

View Source
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

func IdentityFromGatewayHeaders(r *http.Request) AuthInput

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

func FromEnv() (*Client, error)

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

func New(cfg Config) (*Client, error)

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

func (c *Client) Authorize(ctx context.Context, in AuthInput) error

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

func (c *Client) AuthorizeVerdict(ctx context.Context, in AuthInput) (Verdict, error)

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

func (c *Client) Enabled() bool

Enabled reports whether a commerce BaseURL is configured. When false, Authorize always allows and Record is a no-op.

func (*Client) Middleware

func (c *Client) Middleware(cfg MiddlewareConfig) func(http.Handler) http.Handler

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

func (c *Client) Record(ctx context.Context, u Usage) (*RecordResult, error)

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

func (c *Client) Tier(ctx context.Context, subject, org string) (string, error)

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

type HTTPDoer interface {
	Do(req *http.Request) (*http.Response, error)
}

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

type PriceFunc func(r *http.Request, status int, in AuthInput) int64

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

type ScopeRule struct {
	Project      string
	Service      string
	RateLimitRpm int
}

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

func (u Usage) Clone() Usage

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

func (u Usage) Money() money.Amount

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL