Files
hanzo-dev cab1b716c5 clients: seven apps stop linking the ai module for a table they can create
cmd/{analytics,ask,evals,leaderboard,link,rollingcap,usage} each linked
github.com/hanzoai/ai/object, and it cost them 1310-1314 packages against a
671-package core floor. All seven are now 672-677, with ZERO ai packages.

The 638 packages were buying a guaranteed error. aiobject.EnsureCloudUsageTable
execs through object.DatastoreExec, whose connection is opened only by
object.InitDatastore, which runs only inside aimod.Mount — and none of these
seven link the ai module, so the call ALWAYS returned "datastore: not connected"
and every one of the nine call sites silently took its failure branch. The
symptom was an honest-empty dashboard against a warehouse that was up. The next
line at each site already queried clients/datastore, whose connection IS live in
exactly these binaries.

So the DDL moves there — clients/datastore/cloudusage.go, verbatim, guarded by
Ready() and latching only on success, following clients/sbom's ensureTable. It is
deliberately a SECOND copy: ai keeps its own for the write path. A func var the
host injects is the obvious alternative and it is a seam that can never be wired,
because the whole point of these binaries is that they do not link ai. Two copies
of idempotent DDL against one table converge; a nil hook does not.

Two call sites were not the table at all:

  clients/answer reached aiobject.Crawl for page reads. clients/websearch already
  has a native Crawl4AI client against the same service, and answer already
  imports websearch — so Crawl is now exported there and crawl() became its len==1
  case. One dial path, one auth path, one decode path, and a duplicate client
  gone. Net new packages: zero.

  clients/rollingcap read aiobject.TierReader(), and THAT WAS A LIVE BUG: the
  rolling AI-spend cap has been dead in every deployment. aiobject's tier reader
  is a COPY clients/ai installs at ai.Mount; cloud's is the source, set by
  wireTierReader in BuildDeps before MountAll ever runs. cmd/rollingcap never
  links clients/ai, and in the unified binary apps.Wire() mounts rollingcap
  BEFORE ai — so the copy was nil either way and Mount took its no-op early-out
  on every boot. It reads cloud.TierReader() now and the cap is live.

  Its other half needed a real seam, so cloud.RollingCapReader joins the four in
  ai.go. SetRollingCapReader is the one EXPORTED setter there and the deviation is
  forced: the other four are written by build.go/durable.go inside package cloud,
  but this producer is clients/rollingcap, which sits above the edge. clients/ai
  installs a TRAMPOLINE rather than a snapshot — it resolves the reader per call
  — so mount order cannot silence the cap a second time.

Not done here: clients/admin/finance has the same import and is mid-edit by
another change. It is also the one app that would not reach the floor (2184 ->
1612 measured), because it independently pulls part of ai/object's closure.

Measured, CGO_ENABLED=0 go list -deps ./cmd/<app>:

  analytics    1311 -> 674     leaderboard  1311 -> 673
  ask          1311 -> 675     link         1314 -> 676
  evals        1311 -> 673     rollingcap   1310 -> 672
  usage        1314 -> 677     ai packages    25 -> 0  (all seven)

Tests: cloud, apps, clients/{rollingcap,usage,analytics,answer,websearch,
leaderboard} all ok. vet clean on the touched set plus ./ and clients/ai.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 20:00:01 -07:00

54 lines
2.1 KiB
Go

package cloud
import (
"context"
tasksclient "github.com/hanzoai/tasks/pkg/sdk/client"
)
// Callbacks the ai module makes back into the host. Declared here, installed by
// apps/ — importing ai/object here would put ~1480 packages under every
// subsystem, and nothing here reads back from it.
// UsageEvent mirrors the ai module's payload. Separate on purpose: sharing the
// type would reintroduce the import.
type UsageEvent struct {
Subject string
Namespace string
USD string // exact decimal USD ("0.00132"), never a rounded cent
Currency string
Model string
Provider string
RequestID string
}
type (
TierReaderFunc func(ctx context.Context, subject, namespace string) (string, error)
BalanceReaderFunc func(ctx context.Context, subject, namespace, currency string) (int64, error)
UsageRecorderFunc func(ctx context.Context, u UsageEvent) error
IngestDialerFunc func(org string) (tasksclient.Client, error)
RollingCapReaderFunc func(ctx context.Context, subject, namespace string) (bool, error)
)
var (
tierReader TierReaderFunc
balanceReader BalanceReaderFunc
usageRecorder UsageRecorderFunc
ingestDialer IngestDialerFunc
rollingCapReader RollingCapReaderFunc
)
// nil means that subsystem isn't co-resident; apps/ leaves it uninstalled.
func TierReader() TierReaderFunc { return tierReader }
func BalanceReader() BalanceReaderFunc { return balanceReader }
func UsageRecorder() UsageRecorderFunc { return usageRecorder }
func IngestDialer() IngestDialerFunc { return ingestDialer }
func RollingCapReader() RollingCapReaderFunc { return rollingCapReader }
// SetRollingCapReader is the one EXPORTED setter here, and the deviation is
// forced: the four above are written directly by build.go/durable.go, which are
// inside this package, but the rolling cap is produced by clients/rollingcap —
// it imports clients/flags, which imports this package, so it can only ever live
// above that edge and needs a door. nil clears it (no cap installed).
func SetRollingCapReader(f RollingCapReaderFunc) { rollingCapReader = f }