Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b59f77a36d |
@@ -197,3 +197,24 @@ jobs:
|
||||
|
||||
echo "OK: containment holds — clients/controlplane has zero buildable files by default and is linked into no cmd/ binary"
|
||||
|
||||
- name: positive proof — package cloud does not link the inference module
|
||||
# Package cloud is imported by nearly every clients/* package, so one import
|
||||
# edge out of it is an edge out of all of them: importing
|
||||
# github.com/hanzoai/ai/object here pulls that module's whole closure —
|
||||
# k8s client-go, the AWS/Azure/Volcengine SDKs, go-git, docker, ~1.5k
|
||||
# packages — into every one, to hand the module four callbacks over plain
|
||||
# data. The callbacks now go the other way: cloud exposes
|
||||
# RegisterBillingInstaller and the composition root (apps/ai.go), which is
|
||||
# linked only by cmd/, installs them.
|
||||
#
|
||||
# A re-added `aiobject.SetX(...)` in build.go compiles and passes every test
|
||||
# — the cost is invisible until someone measures a link again. Same graph
|
||||
# assertion as the containment step above, for the same reason.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if go list -deps github.com/hanzoai/cloud | grep -qx 'github.com/hanzoai/ai/object'; then
|
||||
echo "::error::package cloud reaches github.com/hanzoai/ai/object — that edge re-links ~1480 packages into every package that imports cloud. Install the module's hooks from apps (cloud.RegisterBillingInstaller), not from the root."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: package cloud does not reach github.com/hanzoai/ai/object"
|
||||
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
tasksclient "github.com/hanzoai/tasks/pkg/sdk/client"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/money"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
)
|
||||
|
||||
// ai.go binds what the co-resident inference module (hanzoai/ai) resolves from its
|
||||
// HOST process: the money plane its prepaid gate reads and debits, and the durable
|
||||
// queue it hands long ingests to. The module declares each as a typed hook over
|
||||
// plain data and calls whatever the host installed — so a debit is a direct Go call,
|
||||
// not an HTTP request the host has to re-route back to itself.
|
||||
//
|
||||
// The composition root is the ONE place that may know both sides. Package cloud must
|
||||
// not: it is imported by nearly every clients/* package, so calling the module's
|
||||
// setters from there linked the module's whole closure (~1.5k packages) into all of
|
||||
// them to hand over four functions. Same decoupling as wire_seams.go and ledger.go —
|
||||
// the root composes leaves that must not import each other.
|
||||
|
||||
// installAI installs every host binding the inference module resolves. Called by
|
||||
// Wire(), before cloud.Serve, so both bindings are in place before the module mounts
|
||||
// and long before it can serve a request.
|
||||
func installAI() {
|
||||
// The durable queue. dialTasks resolves the engine at DIAL time, not here: the
|
||||
// engine starts after MountAll, long after Wire().
|
||||
aiobject.SetIngestDialer(dialTasks)
|
||||
|
||||
// The money plane needs the metering client and the published ledger, so cloud
|
||||
// calls back once BuildDeps has built both.
|
||||
cloud.RegisterBillingInstaller(installBilling)
|
||||
}
|
||||
|
||||
// installBilling binds the module's prepaid gate to cloud's in-process money plane.
|
||||
// Three hooks, each fail-SAFE or fail-CLOSED exactly as the money it guards demands:
|
||||
//
|
||||
// - TIER (subscription): the caller's commerce plan, read through the SAME
|
||||
// co-resident commerce client the metering gate bills over — in-process when
|
||||
// commerce is folded in, S2S HTTP with the service token otherwise, NEVER an
|
||||
// authed self-call to the cloud edge. That self-call is the toothless-gate bug:
|
||||
// the edge 401/403s a service call to /v1/billing/*, so the module's own HTTP
|
||||
// lookup always returned "" in-cluster and every tier-gated SKU failed OPEN.
|
||||
// Client.Tier folds a commerce error or an unknown plan to "", which the gate
|
||||
// treats as ALLOW, so a commerce blip never locks out a paying caller.
|
||||
//
|
||||
// - BALANCE + USAGE (cash): the per-org SQLite double-entry wallet. There is NO
|
||||
// exempt path (hanzoai/ai >= v1.805.8): every principal is gated on a positive
|
||||
// prepaid balance, fail-closed.
|
||||
//
|
||||
// A hook left unset is not a gap: the module then takes its own HTTP billing path,
|
||||
// which is the correct behavior for a split deployment where the money layer is not
|
||||
// co-resident.
|
||||
func installBilling(deps cloud.Deps) {
|
||||
if m := deps.Metering; m != nil && m.Enabled() {
|
||||
aiobject.SetTierReader(m.Tier)
|
||||
deps.Logger.Info("ai per-tier SKU gate wired to co-resident commerce (in-process tier read, fail-safe)")
|
||||
}
|
||||
|
||||
fin := finance.Current()
|
||||
if fin == nil {
|
||||
return // money layer not co-resident (split-deploy); ai falls back to HTTP.
|
||||
}
|
||||
|
||||
// Money is billed to the SUBJECT's wallet, inside the org's ledger.
|
||||
//
|
||||
// org = which ledger (the tenant's books)
|
||||
// subject = which wallet in it (ai resolves it: a person => "org/name", an
|
||||
// org-owned application/service key => the org's own account)
|
||||
//
|
||||
// So a personal account has a PERSONAL balance and a personal plan, and an org
|
||||
// pays for what its applications and service keys spend — which is the product:
|
||||
// sign up as yourself, then stand up an org whose users are your customers.
|
||||
//
|
||||
// Keying both hooks on the org collapsed every member onto the tenant's pool
|
||||
// wallet: every new signup lives in "hanzo", so a brand-new $0 account read
|
||||
// HANZO's balance and sailed through the gate — we enforced our own wallet.
|
||||
//
|
||||
// The invariant that must never break: the gate READ and the usage DEBIT key on
|
||||
// the SAME wallet, or spend can outrun the balance that admitted it. Both use
|
||||
// subject; keep them together. The gate reads a coarse cents balance (a >0
|
||||
// threshold only); the DEBIT is 18-decimal-exact.
|
||||
aiobject.SetBalanceReader(func(ctx context.Context, subject, namespace, currency string) (int64, error) {
|
||||
bal, err := fin.Balance(ctx, namespace, subject, currency, false)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return bal.Cents(), nil
|
||||
})
|
||||
// The DEBIT is exact: the module emits the cost as a decimal-USD string, parsed
|
||||
// here to 18-decimal USD (1e-18) so a sub-cent call bills precisely and is never floored.
|
||||
aiobject.SetUsageRecorder(func(ctx context.Context, u aiobject.UsageEvent) error {
|
||||
amt, err := money.ParseUSD(u.USD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fin.RecordUsage(ctx, types.UsageInput{
|
||||
Org: u.Namespace, Subject: u.Subject, Amount: amt,
|
||||
Currency: u.Currency, Model: u.Model, Provider: u.Provider, RequestID: u.RequestID,
|
||||
})
|
||||
})
|
||||
deps.Logger.Info("ai prepaid gate wired to the in-process finance ledger (per-subject wallet, 18-decimal-exact, fail-closed)")
|
||||
}
|
||||
|
||||
// dialTasks opens a client on the process's embedded tasks engine — the ONE durable
|
||||
// queue — for the module's ai-ingest workflows. The engine binds loopback and shares
|
||||
// cloud's trust boundary, so this dial is ungated; data isolation lives in the
|
||||
// workflow INPUT (owner-scoped), which is why every org enqueues into the engine's
|
||||
// always-registered `default` namespace rather than a per-org one (the embedded
|
||||
// engine registers no others, and dialing an unregistered namespace makes the worker
|
||||
// poll forever and silently forces ingest back inline).
|
||||
//
|
||||
// Before the engine is up — and if it failed to embed at all — this reports the
|
||||
// module's own ErrTasksNotConfigured, which its ingest handler answers by running
|
||||
// the ingest inline. That is the same fail-soft path an uninstalled dialer takes, so
|
||||
// the durable plane is opt-in-by-availability and never a hard dependency.
|
||||
func dialTasks(string) (tasksclient.Client, error) {
|
||||
emb := cloud.EmbeddedTasks()
|
||||
if emb == nil {
|
||||
return nil, aiobject.ErrTasksNotConfigured
|
||||
}
|
||||
return tasksclient.Dial(tasksclient.Options{
|
||||
HostPort: fmt.Sprintf("127.0.0.1:%d", emb.ZAPPort()),
|
||||
Namespace: "default",
|
||||
})
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/money"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
)
|
||||
|
||||
// TestWireInstallsAIHostBindings walks the PRODUCTION path — Wire() then
|
||||
// cloud.BuildDeps, exactly what every cmd/*/main.go does — and proves each host
|
||||
// binding the inference module resolves is installed and dispatches to the real
|
||||
// in-process implementation. The module's setters are package globals with no
|
||||
// registry to inspect, so a cut that quietly stopped calling them would compile,
|
||||
// boot, serve, and bill nobody; this is the assertion that makes that impossible.
|
||||
func TestWireInstallsAIHostBindings(t *testing.T) {
|
||||
// The ledger is a per-org SQLite file, and an encryption-capable build refuses to
|
||||
// open one unencrypted. One deterministic key roots the at-rest layer for the
|
||||
// process; the files themselves are fresh per t.TempDir().
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 1)
|
||||
}
|
||||
t.Setenv("CLOUD_KMS_MASTER_KEY_REF", base64.StdEncoding.EncodeToString(key))
|
||||
|
||||
// Start from an empty module so nothing here can pass on a leftover hook.
|
||||
aiobject.SetTierReader(nil)
|
||||
aiobject.SetBalanceReader(nil)
|
||||
aiobject.SetUsageRecorder(nil)
|
||||
finance.Publish(nil)
|
||||
t.Cleanup(func() {
|
||||
cloud.RegisterBillingInstaller(nil)
|
||||
finance.Publish(nil)
|
||||
})
|
||||
|
||||
Wire()
|
||||
|
||||
if aiobject.BalanceReader() != nil || aiobject.TierReader() != nil {
|
||||
t.Fatal("Wire() must only REGISTER the money bindings; they need the built deps")
|
||||
}
|
||||
|
||||
cloud.BuildDeps(&cloud.Config{
|
||||
Brand: "hanzo",
|
||||
DataDir: t.TempDir(),
|
||||
Enable: []string{"commerce"}, // money layer co-resident (the unified binary)
|
||||
})
|
||||
|
||||
tier, balance, usage := aiobject.TierReader(), aiobject.BalanceReader(), aiobject.UsageRecorder()
|
||||
if tier == nil {
|
||||
t.Error("per-tier SKU gate reader not installed: every tier-gated SKU fails OPEN")
|
||||
}
|
||||
if balance == nil {
|
||||
t.Fatal("prepaid balance reader not installed: the spend gate admits everyone")
|
||||
}
|
||||
if usage == nil {
|
||||
t.Fatal("usage recorder not installed: completions serve free")
|
||||
}
|
||||
|
||||
// The bindings must reach the SAME wallet in both directions. Credit a person's
|
||||
// wallet inside an org ledger, then read and debit it through the module's hooks:
|
||||
// swapping subject and namespace (the shipped bug this guards) would read the org
|
||||
// pool instead, so the balance would not be the 500 cents deposited here.
|
||||
ctx := context.Background()
|
||||
fin := finance.Current()
|
||||
if _, err := fin.Deposit(ctx, types.DepositInput{
|
||||
Org: "acme", Subject: "acme/bob", Amount: money.FromCents(500),
|
||||
Currency: "usd", Ref: "test-grant",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed deposit: %v", err)
|
||||
}
|
||||
|
||||
got, err := balance(ctx, "acme/bob", "acme", "usd")
|
||||
if err != nil {
|
||||
t.Fatalf("balance hook: %v", err)
|
||||
}
|
||||
if got != 500 {
|
||||
t.Fatalf("balance hook read %d cents, want 500 — the gate is not reading the subject's wallet", got)
|
||||
}
|
||||
|
||||
// The debit parses the module's decimal-USD string exactly, never floored to a
|
||||
// whole cent. Two 1.005 USD calls take 2.01 off 5.00, leaving 2.99 — a floored
|
||||
// debit would leave 3.00, and the gate's coarse cents read separates the two.
|
||||
// That the balance moves at all is the same-wallet proof: a debit keyed on the
|
||||
// org pool would leave this subject's 500 untouched.
|
||||
for _, id := range []string{"test-call-1", "test-call-2"} {
|
||||
if err := usage(ctx, aiobject.UsageEvent{
|
||||
Subject: "acme/bob", Namespace: "acme", USD: "1.005", Currency: "usd",
|
||||
Model: "zen", Provider: "hanzo", RequestID: id,
|
||||
}); err != nil {
|
||||
t.Fatalf("usage hook: %v", err)
|
||||
}
|
||||
}
|
||||
got, err = balance(ctx, "acme/bob", "acme", "usd")
|
||||
if err != nil {
|
||||
t.Fatalf("balance hook after debit: %v", err)
|
||||
}
|
||||
if got != 299 {
|
||||
t.Fatalf("balance after two 1.005 USD debits = %d cents, want 299", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestDialerFallsBackInline proves the durable-queue binding keeps the
|
||||
// module's fail-soft contract. The dialer resolves the embedded engine at dial time,
|
||||
// so with no engine it must report the module's OWN ErrTasksNotConfigured — the
|
||||
// sentinel the ingest handler matches on to run the ingest inline. Any other error
|
||||
// would surface as a 5xx on a path that is supposed to degrade silently.
|
||||
func TestIngestDialerFallsBackInline(t *testing.T) {
|
||||
Wire()
|
||||
|
||||
if _, err := dialTasks("acme"); !errors.Is(err, aiobject.ErrTasksNotConfigured) {
|
||||
t.Fatalf("dialTasks with no embedded engine = %v, want ErrTasksNotConfigured", err)
|
||||
}
|
||||
if _, err := aiobject.EnqueueIngest(context.Background(), "acme", &aiobject.IngestRequest{}, "en"); !errors.Is(err, aiobject.ErrTasksNotConfigured) {
|
||||
t.Fatalf("EnqueueIngest through the installed dialer = %v, want ErrTasksNotConfigured (handler falls back to inline)", err)
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -183,12 +183,19 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
// Wire returns every linked subsystem as a cloud.MountSpec, in mount order. The
|
||||
// slice position IS the order: cloud.MountAll iterates it as-given, registering each
|
||||
// subsystem's teardown as a zip shutdown hook so teardown runs in reverse (LIFO).
|
||||
// Enablement is a separate axis: cloud.Serve mounts only the specs cfg.Enabled(name)
|
||||
// admits, so a STAGED subsystem is linked but inert until named.
|
||||
// Wire composes the process: it installs the host bindings the co-resident
|
||||
// inference module resolves (see ai.go) and returns every linked subsystem as a
|
||||
// cloud.MountSpec, in mount order. The slice position IS the order: cloud.MountAll
|
||||
// iterates it as-given, registering each subsystem's teardown as a zip shutdown hook
|
||||
// so teardown runs in reverse (LIFO). Enablement is a separate axis: cloud.Serve
|
||||
// mounts only the specs cfg.Enabled(name) admits, so a STAGED subsystem is linked
|
||||
// but inert until named.
|
||||
//
|
||||
// The bindings are NOT a MountSpec on purpose: a spec is skipped whenever its name
|
||||
// is absent from an explicit CLOUD_ENABLE list, and a money gate that silently stops
|
||||
// being installed is a revenue leak, not a degraded feature.
|
||||
func Wire() []cloud.MountSpec {
|
||||
installAI()
|
||||
return []cloud.MountSpec{
|
||||
// embedded NATS :4222 + JetStream.
|
||||
{Name: "pubsub", Mount: pubsub.Mount, Shutdown: pubsub.Shutdown},
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package cloud_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
)
|
||||
|
||||
// The money plane the co-resident inference module gates on is installed by the
|
||||
// composition root through cloud.RegisterBillingInstaller — package cloud hands over
|
||||
// the built Deps and never imports the module. A cut that compiles but stops calling
|
||||
// the installer is a revenue leak, not a build break, so these tests pin the two
|
||||
// halves of the contract: BuildDeps calls it, and it calls it with everything the
|
||||
// bindings need already in place.
|
||||
|
||||
// TestBuildDepsInvokesBillingInstaller proves BuildDeps invokes the registered
|
||||
// installer exactly once, with the metering client built and the finance ledger
|
||||
// ALREADY PUBLISHED — the ordering the tier read and the prepaid gate depend on. The
|
||||
// ledger is asserted from INSIDE the callback: asserting it afterwards would pass
|
||||
// even if the installer ran first.
|
||||
func TestBuildDepsInvokesBillingInstaller(t *testing.T) {
|
||||
finance.Publish(nil)
|
||||
t.Cleanup(func() {
|
||||
cloud.RegisterBillingInstaller(nil)
|
||||
finance.Publish(nil)
|
||||
})
|
||||
|
||||
calls := 0
|
||||
var metering bool
|
||||
var ledger bool
|
||||
cloud.RegisterBillingInstaller(func(deps cloud.Deps) {
|
||||
calls++
|
||||
metering = deps.Metering != nil && deps.Metering.Enabled()
|
||||
ledger = finance.Current() != nil
|
||||
})
|
||||
|
||||
cloud.BuildDeps(&cloud.Config{
|
||||
Brand: "hanzo",
|
||||
DataDir: t.TempDir(),
|
||||
Enable: []string{"commerce"}, // money layer co-resident (the unified binary)
|
||||
})
|
||||
|
||||
if calls != 1 {
|
||||
t.Fatalf("billing installer ran %d times, want exactly 1 per BuildDeps", calls)
|
||||
}
|
||||
if !metering {
|
||||
t.Error("installer ran before the metering client was built: the per-tier SKU gate would never be installed")
|
||||
}
|
||||
if !ledger {
|
||||
t.Error("installer ran before the finance ledger was published: the prepaid balance/usage hooks would never be installed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDepsWithoutBillingInstaller proves an unregistered seam is inert, not
|
||||
// fatal: a binary that links cloud without the composition root (cmd/kmsreseal, any
|
||||
// test harness) boots normally and leaves the module's own HTTP billing path alone.
|
||||
func TestBuildDepsWithoutBillingInstaller(t *testing.T) {
|
||||
cloud.RegisterBillingInstaller(nil)
|
||||
t.Cleanup(func() { finance.Publish(nil) })
|
||||
|
||||
deps := cloud.BuildDeps(&cloud.Config{Brand: "hanzo", DataDir: t.TempDir()})
|
||||
if deps.Logger == nil {
|
||||
t.Fatal("BuildDeps must still build deps with no billing installer registered")
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud/cek"
|
||||
"github.com/hanzoai/cloud/clients/commerce/transport"
|
||||
"github.com/hanzoai/cloud/clients/metering"
|
||||
@@ -20,9 +19,7 @@ import (
|
||||
"github.com/hanzoai/cloud/clients"
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/gateway/edge"
|
||||
"github.com/hanzoai/cloud/clients/money"
|
||||
"github.com/hanzoai/cloud/clients/s3admin"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
)
|
||||
|
||||
// BuildDeps constructs the Deps used by every subsystem's Mount(app, deps).
|
||||
@@ -96,7 +93,6 @@ func BuildDeps(cfg *Config) Deps {
|
||||
// commerce URL yields a !Enabled() client, so the wrap is a transparent
|
||||
// pass-through and a dev deployment is never blocked.
|
||||
deps.Metering = buildMeteringClient(cfg, logger)
|
||||
wireTierReader(deps.Metering, logger)
|
||||
// AI (completions, WRITE) and Embed (embeddings, READ-ONLY) are DISTINCT
|
||||
// credentials by concern: completions never ride the read-only publishable
|
||||
// (pk-) key — the gateway 403s a pk- key on any write endpoint — so deps.AI
|
||||
@@ -104,7 +100,7 @@ func BuildDeps(cfg *Config) Deps {
|
||||
// least-privilege for a read-only call). Both meter through the ONE commerce path.
|
||||
deps.AI = meteredAIClient(pickCompletionsClient(cfg, logger), deps)
|
||||
deps.Embed = meteredAIClient(pickEmbedClient(cfg, logger), deps)
|
||||
wireFinance(cfg, logger)
|
||||
publishLedger(cfg, logger)
|
||||
deps.O11y = pick(cfg, logger, "o11y", "O11y", cfg.O11yZAPAddr, clients.O11yRPCAt, clients.DisabledO11y)
|
||||
deps.VFS = pickVFSClient(cfg, logger)
|
||||
deps.MQ = pick(cfg, logger, "mq", "MQ", cfg.MQZAPAddr, clients.MQRPCAt, clients.DisabledMQ)
|
||||
@@ -127,9 +123,37 @@ func BuildDeps(cfg *Config) Deps {
|
||||
}
|
||||
deps.GatewayPolicy = gp
|
||||
|
||||
// Last: hand the composition root the built deps so it can bind the co-resident
|
||||
// inference module's prepaid gate to the money plane above (metering client +
|
||||
// published ledger). MountAll has not run yet, so the gate is bound before any
|
||||
// subsystem — and long before any request — can read it.
|
||||
if billingInstaller != nil {
|
||||
billingInstaller(deps)
|
||||
}
|
||||
|
||||
return deps
|
||||
}
|
||||
|
||||
// billingInstaller is the registered bootstrap that binds cloud's money plane — the
|
||||
// caller's commerce plan tier, their prepaid wallet balance, and the usage debit —
|
||||
// into the co-resident inference module (hanzoai/ai). The composition root registers
|
||||
// it; BuildDeps invokes it once, after the metering client and the finance ledger
|
||||
// exist and before MountAll.
|
||||
//
|
||||
// The inversion is what keeps the inference module OUT of this package's import
|
||||
// graph. Calling the module's setters from here made every package that imports
|
||||
// cloud — most of clients/* — link the module's entire closure, ~1.5k packages, to
|
||||
// hand over four functions over plain data. Same pattern as telemetryInstaller and
|
||||
// kmsClientFactory. Exactly one registration.
|
||||
var billingInstaller func(Deps)
|
||||
|
||||
// RegisterBillingInstaller installs that bootstrap. apps (the composition root, the
|
||||
// ONE package that imports both cloud and the inference module) calls this from
|
||||
// Wire(), before Serve. It is the ONE inversion point for the module's money seam;
|
||||
// unregistered — a binary without the module linked — leaves the module's own HTTP
|
||||
// billing path in place, unchanged.
|
||||
func RegisterBillingInstaller(f func(Deps)) { billingInstaller = f }
|
||||
|
||||
// staticEdgePolicy projects the static env/flag edge config into the boot-default
|
||||
// policy the edge.Store layers runtime overrides on top of. A disabled
|
||||
// per-IP limiter (CLOUD_EDGE_RATELIMIT=false) maps to PerIPRPM 0 (a live no-op).
|
||||
@@ -209,80 +233,22 @@ func boolStr(b bool, t, f string) string {
|
||||
return f
|
||||
}
|
||||
|
||||
// wireTierReader installs the embedded ai module's per-tier SKU gate reader so it
|
||||
// resolves the caller's commerce subscription tier through the SAME co-resident
|
||||
// commerce client the metering gate bills over — in-process (the commerce transport) when
|
||||
// commerce is folded in, S2S HTTP with the service token otherwise — NEVER an authed
|
||||
// self-call to the cloud edge. That self-call is the toothless-gate bug: the edge
|
||||
// 401/403s a service call to /v1/billing/*, so the ai module's own HTTP lookup always
|
||||
// returned "" in-cluster and every tier-gated SKU failed OPEN. This mirrors
|
||||
// wireFinance's SetBalanceReader: cloud owns the co-resident read, ai stays
|
||||
// transport-agnostic. Fail-safe is preserved — Client.Tier folds a commerce error or
|
||||
// an unknown plan to "", which the gate treats as ALLOW, so a commerce blip never
|
||||
// locks out a paying caller. No-op when commerce is unreachable (metering !Enabled),
|
||||
// leaving ai's standalone HTTP fallback in place.
|
||||
func wireTierReader(m *metering.Client, log luxlog.Logger) {
|
||||
if m == nil || !m.Enabled() {
|
||||
// publishLedger constructs the ONE in-process finance ledger (per-org SQLite
|
||||
// double-entry prepaid wallet) and publishes it for every money consumer to resolve
|
||||
// by the narrow finance.Client: the edge meter, the admin credit grant, the
|
||||
// entitlements standing read, and the inference module's prepaid gate (bound in the
|
||||
// composition root — see RegisterBillingInstaller). Publishing before MountAll is
|
||||
// what lets those consumers resolve it lazily, per request.
|
||||
//
|
||||
// No-op when the money layer is not co-resident (split-deploy): finance.Current()
|
||||
// then stays nil and each consumer takes its own documented path — fail closed for
|
||||
// the credit/standing reads, the module's own HTTP billing for the prepaid gate.
|
||||
func publishLedger(cfg *Config, log luxlog.Logger) {
|
||||
if !cfg.Enabled("commerce") {
|
||||
return
|
||||
}
|
||||
aiobject.SetTierReader(func(ctx context.Context, subject, namespace string) (string, error) {
|
||||
return m.Tier(ctx, subject, namespace)
|
||||
})
|
||||
log.Info("ai per-tier SKU gate wired to co-resident commerce (in-process tier read, fail-safe)")
|
||||
}
|
||||
|
||||
// wireFinance constructs the ONE in-process finance ledger (per-org SQLite
|
||||
// double-entry prepaid wallet), publishes it for every money consumer to resolve by
|
||||
// the narrow finance.Client, and installs the embedded ai router's balance-read +
|
||||
// usage-debit hooks so the PREPAID gate dispatches DIRECTLY to it — a typed in-proc
|
||||
// call, no HTTP, no socket. There is NO exempt path (hanzoai/ai >= v1.805.8): every
|
||||
// principal is gated on a positive prepaid balance, fail-closed. MUST run before
|
||||
// ai.Mount (the ai gate reads the hook per request; the hook must be installed first)
|
||||
// — which BuildDeps guarantees (deps are built before MountAll).
|
||||
func wireFinance(cfg *Config, log luxlog.Logger) {
|
||||
if !cfg.Enabled("commerce") {
|
||||
return // money layer not co-resident (split-deploy); ai falls back to HTTP.
|
||||
}
|
||||
fin := finance.New(cfg.DataDir)
|
||||
finance.Publish(fin)
|
||||
// Money is billed to the SUBJECT's wallet, inside the org's ledger.
|
||||
//
|
||||
// org = which ledger (the tenant's books)
|
||||
// subject = which wallet in it (ai resolves it: a person => "org/name", an
|
||||
// org-owned application/service key => the org's own account)
|
||||
//
|
||||
// So a personal account has a PERSONAL balance and a personal plan, and an org
|
||||
// pays for what its applications and service keys spend — which is the product:
|
||||
// sign up as yourself, then stand up an org whose users are your customers.
|
||||
//
|
||||
// Keying both hooks on the org collapsed every member onto the tenant's pool
|
||||
// wallet: every new signup lives in "hanzo", so a brand-new $0 account read
|
||||
// HANZO's balance and sailed through the gate — we enforced our own wallet.
|
||||
//
|
||||
// The invariant that must never break: the gate READ and the usage DEBIT key on
|
||||
// the SAME wallet, or spend can outrun the balance that admitted it. Both use
|
||||
// subject; keep them together. The gate reads a coarse cents balance (a >0
|
||||
// threshold only); the DEBIT is 18-decimal-exact.
|
||||
aiobject.SetBalanceReader(func(ctx context.Context, subject, namespace, currency string) (int64, error) {
|
||||
bal, err := fin.Balance(ctx, namespace, subject, currency, false)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return bal.Cents(), nil
|
||||
})
|
||||
// The DEBIT is exact: the ai module emits the cost as a decimal-USD string, parsed
|
||||
// here to 18-decimal USD (1e-18) so a sub-cent call bills precisely and is never floored.
|
||||
aiobject.SetUsageRecorder(func(ctx context.Context, u aiobject.UsageEvent) error {
|
||||
amt, err := money.ParseUSD(u.USD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fin.RecordUsage(ctx, types.UsageInput{
|
||||
Org: u.Namespace, Subject: u.Subject, Amount: amt,
|
||||
Currency: u.Currency, Model: u.Model, Provider: u.Provider, RequestID: u.RequestID,
|
||||
})
|
||||
})
|
||||
log.Info("finance ledger wired (per-subject wallet in the org ledger, 18-decimal-exact, fail-closed)", "dataDir", cfg.DataDir)
|
||||
finance.Publish(finance.New(cfg.DataDir))
|
||||
log.Info("finance ledger published (per-subject wallet in the org ledger, 18-decimal-exact)", "dataDir", cfg.DataDir)
|
||||
}
|
||||
|
||||
// pick resolves one inter-subsystem client under the HIP-0106 wiring rule shared
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
// no principal, which answers "sign in to view billing". The proxy was calling itself.
|
||||
//
|
||||
// WHERE THE MONEY IS. Co-resident, the prepaid wallet lives in cloud's OWN finance ledger
|
||||
// (clients/finance, per-org double-entry SQLite): wireFinance (build.go) points the ai
|
||||
// (clients/finance, per-org double-entry SQLite): installBilling (apps/ai.go) points the ai
|
||||
// prepaid gate's balance read at it, the edge meter debits it, and an admin grant credits
|
||||
// it (clients/admin/core.grantDeposit prefers finance.Current() for exactly this reason).
|
||||
// So the customer's balance is read from that ledger DIRECTLY — no HTTP hop, nothing to
|
||||
|
||||
@@ -19,7 +19,7 @@ package entitlements
|
||||
// THE ADDRESS IS LOAD-BEARING. A money gate that reads a different wallet than the
|
||||
// debit writes is the bug this codebase has already shipped twice, both times by
|
||||
// keying the ORG POOL: "every new signup lives in 'hanzo', so a brand-new $0
|
||||
// account read HANZO's balance and sailed through the gate" (build.go wireFinance).
|
||||
// account read HANZO's balance and sailed through the gate" (apps/ai.go installBilling).
|
||||
// Read on the org pool, this paywall would admit every free signup in the shared
|
||||
// org for as long as the platform's own pool is funded — a total bypass. So the
|
||||
// credit leg reads principal.WalletOf's address and nothing else.
|
||||
|
||||
@@ -13,7 +13,7 @@ package principal
|
||||
// times the same way — a gate keyed on the ORG POOL while the debit spent a
|
||||
// PERSON's wallet:
|
||||
//
|
||||
// - build.go wireFinance: "Keying both hooks on the org collapsed every member
|
||||
// - apps/ai.go installBilling: "Keying both hooks on the org collapsed every member
|
||||
// onto the tenant's pool wallet: every new signup lives in 'hanzo', so a
|
||||
// brand-new $0 account read HANZO's balance and sailed through the gate."
|
||||
// - middleware_billing.go identityFromCtx: "this gate checked the pool's balance
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
//
|
||||
// It composes three co-resident GLOBALS and owns no state of its own:
|
||||
//
|
||||
// aiobject.TierReader() — the caller's commerce plan tier (installed by wireTierReader)
|
||||
// finance.Current() — the per-org ledger's windowed sum (installed by wireFinance)
|
||||
// aiobject.TierReader() — the caller's commerce plan tier (installed by apps/ai.go)
|
||||
// finance.Current() — the per-org ledger's windowed sum (published by cloud.BuildDeps)
|
||||
// flags.Int(key) — the admin-editable per-tier caps (the platform-switch registry)
|
||||
//
|
||||
// The two knobs are platform switches, so admin.hanzo.ai renders and edits them live
|
||||
@@ -89,8 +89,8 @@ func init() {
|
||||
// Mount installs the rolling-cap reader. It registers no routes — the platform
|
||||
// switches (registered in init) surface in the admin cockpit on their own. No-op when
|
||||
// the tier or finance layer is not co-resident (standalone / split deploy): ai's hook
|
||||
// stays nil, behavior unchanged. Runs after MountAll's prerequisites — wireTierReader
|
||||
// + wireFinance have already installed the globals it reads.
|
||||
// stays nil, behavior unchanged. Runs after MountAll's prerequisites — the composition
|
||||
// root's installBilling has already installed the globals it reads.
|
||||
func Mount(_ *zip.App, _ cloud.Deps) error {
|
||||
tier := aiobject.TierReader()
|
||||
fin := finance.Current()
|
||||
|
||||
+14
-19
@@ -12,9 +12,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
tasksauth "github.com/hanzoai/tasks/pkg/auth"
|
||||
tasksclient "github.com/hanzoai/tasks/pkg/sdk/client"
|
||||
tasksengine "github.com/hanzoai/tasks/pkg/tasks"
|
||||
)
|
||||
|
||||
@@ -37,21 +35,21 @@ const durableGatedZAPPort = 9999
|
||||
var embeddedTasks *tasksengine.Embedded
|
||||
|
||||
// EmbeddedTasks returns the ONE in-process tasks engine, or nil until
|
||||
// wireDurableIngest has run (or if it failed to start). The Tasks HTTP/UI surface
|
||||
// (clients/tasks, mounted at /v1/tasks/*) serves on THIS shared engine — there is
|
||||
// exactly one engine per process, shared by ai's durable ingest AND the Tasks
|
||||
// product surface, never a second Embed. The surface resolves it lazily (per
|
||||
// request) because subsystem Mount runs during MountAll, before wireDurableIngest.
|
||||
// wireDurableIngest has run (or if it failed to start). Every consumer resolves it
|
||||
// through THIS accessor, lazily, per request: the Tasks HTTP/UI surface
|
||||
// (clients/tasks, mounted at /v1/tasks/*) and the inference module's ingest dialer
|
||||
// (apps/ai.go) both run on the ONE shared engine, never a second Embed — and both
|
||||
// are wired before the engine starts, since Mount and Wire run ahead of it.
|
||||
func EmbeddedTasks() *tasksengine.Embedded { return embeddedTasks }
|
||||
|
||||
// wireDurableIngest embeds the ONE hanzoai/tasks engine IN-PROCESS — the unified durable
|
||||
// queue (there is no second async system; tasks/CONTRACT) — and injects a per-org
|
||||
// loopback ZAP dialer into ai's ingest. A long ingest (github/crawl/s3) then runs as a
|
||||
// durable workflow in the OWNER's namespace (CONTRACT §6: namespace maps 1:1 to org),
|
||||
// tracked in the ONE Tasks product. In-process ZAP = mega fast, low latency/memory, no
|
||||
// HTTP. Fail-soft by construction: any embed error leaves ai's dialer unset →
|
||||
// EnqueueIngest returns ErrTasksNotConfigured → the handler runs ingest inline (always
|
||||
// works). Called once, after MountAll (ai is mounted) and before Listen.
|
||||
// queue (there is no second async system; tasks/CONTRACT). A long ingest
|
||||
// (github/crawl/s3) then runs as a durable workflow in the OWNER's namespace
|
||||
// (CONTRACT §6: namespace maps 1:1 to org), tracked in the ONE Tasks product.
|
||||
// In-process ZAP = mega fast, low latency/memory, no HTTP. Fail-soft by
|
||||
// construction: any embed error leaves EmbeddedTasks nil → the dialer reports
|
||||
// ErrTasksNotConfigured → the handler runs ingest inline (always works). Called
|
||||
// once, after MountAll and before Listen.
|
||||
func wireDurableIngest(ctx context.Context, deps Deps) {
|
||||
// A stable data dir the engine owns. Cloud's container is distroless (no /tmp), so
|
||||
// Embed's default os.MkdirTemp("") fallback fails — pin it to cloud's data root.
|
||||
@@ -77,11 +75,8 @@ func wireDurableIngest(ctx context.Context, deps Deps) {
|
||||
return
|
||||
}
|
||||
embeddedTasks = emb
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", emb.ZAPPort())
|
||||
aiobject.SetIngestDialer(func(org string) (tasksclient.Client, error) {
|
||||
return tasksclient.Dial(tasksclient.Options{HostPort: addr, Namespace: "default"})
|
||||
})
|
||||
deps.Logger.Info("durable ingest wired: in-process tasks engine", "addr", addr, "dataDir", dataDir)
|
||||
deps.Logger.Info("durable ingest: in-process tasks engine up",
|
||||
"addr", fmt.Sprintf("127.0.0.1:%d", emb.ZAPPort()), "dataDir", dataDir)
|
||||
|
||||
// Expose the SAME engine on a cluster-reachable, IDENTITY-GATED ZAP listener so the
|
||||
// standalone tasksd's consumers (auto, hanzo-playground, platform) run their durable
|
||||
|
||||
Reference in New Issue
Block a user