Files
commerce/metering/LLM.md

8.3 KiB
Raw Permalink Blame History

metering — prepaid pay-for-everything across OSS Hanzo products

The ONE way every commercially-deployable OSS Hanzo product gets prepaid per-org billing wired, and the PaaS one-click deploy that provisions it. Commerce is the single billing source of truth; this is its canonical client + the deploy renderer.

The one way (decomplected)

ONE metering core, THREE adapters (one per request-path shape), ONE deploy renderer. No product reimplements billing; no second balance gate exists.

                      commerce /v1/billing/{balance,usage}   (source of truth)
                                    ^            ^
                                    | Authorize  | Record (per-org debit)
                    ┌───────────────┴────────────┴───────────────┐
                    │   metering.Client  (leaf, stdlib-only)      │   ← the core
                    │   X-Org-Id=<org>  Bearer <KMS token>        │
                    │   fail-closed gate · per-ORG billing key    │
                    └───────────────┬────────────┬───────────────┘
        net/http adapter            │ in-process │            non-Go adapter
   metering.Middleware (gin/chi/    │  hook      │      metering/proxy + cmd/
   mux/net-http Go services)        │            │      meter-proxy (sidecar)
                                    │            │
                              base plugins/metering            vector, search,
                              (core.Router hook)               any HTTP upstream
  • Billing is per-ORG. The balance key is the org slug (X-Org-Id), NOT org/sub — exactly like the proven LLM gate (ai/routers/filter_balance.go resolveBillingKey -> user.Owner). IdentityFromGatewayHeaders sets User=<org> (billing) and Actor=<org/sub> (audit). Keying per-user checks an empty ledger and denies a funded org — the bug this design prevents.

  • Org routing header is X-Org-Id (commerce's service-token path: middleware/accesstoken.go reads c.Header("X-Org-Id"), then falls back to COMMERCE_SERVICE_ORG, then "hanzo"). NOT X-IAM-Org-Id, and NOT X-Hanzo-Org — commerce reads that one NOWHERE outside tests, so it does not route and it does not authorize. The CODE has always been right (metering.go headerOrg); these lines were wrong, and they were the instructions.

    A wrong header is SILENT both ways, which is why it has to be stated exactly. On a no-mask route the bearer alone still authorizes and the debit lands on the FALLBACK org — a real charge against the wrong tenant. On a scoped one commerce answers 401 "sign in to view billing", byte-identical to sending no credential at all, so a caller that fails closed reads it as "no balance" rather than "asked wrongly". That is exactly how hanzo.chat showed a funded org no credits (2026-08-08). Assert the header where the request is BUILT — an end-to-end test cannot tell a refusal from an empty ledger.

  • Fail-closed, with no switch. Balance or spend cap unknown -> deny (503); out-of-funds or over the cap -> 402. There is no fail-open, disabled, test-ledger or tier-aware mode: New refuses an empty BaseURL, a nil client refuses every call, a Middleware with no Price refuses every metered request, and the meter never sends X-Hanzo-Test — a tenant's mode is its org's.

  • Every call is a row. The middleware records every served request at the price Price gives it, zero included; commerce writes a $0 call as a usage row with its cost (costMicros) and payer (paidBy).

  • KMS-only token. COMMERCE_SERVICE_TOKEN is always from a KMS-backed secret; never inlined, never read from disk by this package.

Files

Path What
metering.go the Client: Authorize (gate) + Record (debit).
middleware.go Middleware (net/http) + IdentityFromGatewayHeaders (per-org).
env.go FromEnv + canonical env var names.
proxy/price.go PriceTable + ParsePriceTable — billable unit as config.
proxy/proxy.go proxy.New — metered httputil.ReverseProxy.
proxy/cmd/meter-proxy the universal sidecar binary (env-driven).
proxy/Dockerfile static scratch image -> ghcr.io/hanzoai/meter-proxy.
deploy/catalog.go product inventory + billable unit per product.
deploy/render.go Render(Product, Tenant) -> operator hanzo.ai/v1 Service CR.
../../../base/plugins/metering/ base's in-process gate (its router isn't net/http).

Commercial OSS product inventory (billable unit)

Product Image Unit Adapter Status
vector (Qdrant) hanzoai/vector vector op (write 2c / read 1c) meter-proxy PROVEN live
search (Meili) hanzoai/search document (index 3c / query 1c) meter-proxy PROVEN live
base hanzoai/base record write (1c) in-process plugin PROVEN (gate loop)
gateway/LLM hanzoai/gateway+ai tokens (existing LLM gate) already live
functions hanzoai/functions compute-second Middleware (imperative) needs price hook
sign/esign hanzoai/sign request / envelope Middleware needs wrap
analytics hanzoai/analytics event ingested Middleware needs wrap
bot hanzoai/bot request / message Middleware needs wrap
flow hanzoai/flow run / step Middleware needs wrap
kms hanzoai/kms secret op (likely free/infra) n/a — platform dep
iam hanzoai/iam seat (per active user) sweeper, not per-req needs seat sweeper
s3 (SeaweedFS) hanzoai/s3 GB-month + request sweeper + proxy needs storage sweeper

Units fall into three metering shapes:

  • per-request (vector, search, functions, sign, analytics, bot, flow): the proxy (non-Go) or Middleware (Go). DONE pattern.
  • per-record/in-process (base): the in-process plugin. DONE pattern.
  • per-seat / per-GB-month (iam, s3 storage): a periodic sweeper that reads the product's state and calls Record on a schedule — NOT a request gate. Designed, not yet built (the one remaining shape).

Adopt it (per shape)

Go net/http service:

m, _ := metering.FromEnv()
h = m.Middleware(metering.MiddlewareConfig{Provider: "sign", Price: priceFn})(h)

Non-Go product (sidecar, zero product changes): deploy meter-proxy next to it; set METER_PROXY_UPSTREAM=http://127.0.0.1:<port>, METER_PROXY_PROVIDER, METER_PROXY_PRICES (the price table), and the commerce env.

base: metering.MustRegister(app, metering.Config{}) in main wiring.

PaaS one-click deploy

deploy.Render(product, tenant) emits a per-tenant hanzo.ai/v1 Service CR: the product container + the meter-proxy sidecar (for meterable products), with COMMERCE_SERVICE_ORG=<tenant org>, the token via secretKeyRef (never inlined), a per-tenant PVC, and optional ingress. The control plane (platform.hanzo.ai / console) applies the CR; the operator reconciles. No new API surface — the deploy path is "emit a CR", the same lifecycle every Hanzo service uses. CR is schema-clean against the operator's Service/Container types (sidecar carries no ports — containers share the pod netns).

Proof (real metering, live commerce TEST ledger)

  • vector: 5 upserts×2c + 3 searches×1c through the meter-proxy binary -> balance 1000c -> 987c (exactly 13c). Health checks: 0c. Per-org isolation: the hanzo live ledger (755c) untouched.
  • search: same binary, search price table: 2 index×3c + 4 query×1c -> 1000c -> 990c (exactly 10c).
  • fail-closed: unfunded org -> 402, upstream NEVER reached (0 hits).
  • base: in-process gate, fake commerce: funded org write -> 1c debit (acme 100c -> 99c, debit user = org slug); unfunded -> 402; reads free; health skipped.
  • Unit suites: metering, metering/proxy, metering/deploy, base/plugins/metering all green.

Complement: OSS-payout (money out)

This is money-IN (customers pay per-org for OSS products). The SBOM-driven OSS-payout pipeline (commerce ossattr/ossfunding/billing/engine) is money-OUT: the same RecordUsage spend feeds AccrueOSSPayout, paying upstream maintainers up to 25%. Two halves of "commercialize OSS", one ledger.