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>
This commit is contained in:
@@ -23,21 +23,31 @@ type UsageEvent struct {
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
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 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 }
|
||||
|
||||
@@ -63,5 +63,16 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if d := cloud.IngestDialer(); d != nil {
|
||||
aiobject.SetIngestDialer(d)
|
||||
}
|
||||
// A TRAMPOLINE, not a snapshot like the four above: clients/rollingcap installs
|
||||
// its reader from Mount, and apps.Wire() mounts rollingcap AFTER this package in
|
||||
// some binaries. Resolving cloud.RollingCapReader() per request instead of once
|
||||
// at wire time takes mount order out of the equation entirely.
|
||||
aiobject.SetRollingCapReader(func(ctx context.Context, subject, namespace string) (bool, error) {
|
||||
f := cloud.RollingCapReader()
|
||||
if f == nil {
|
||||
return false, nil // no cap installed → uncapped, the same semantics a nil hook had
|
||||
}
|
||||
return f(ctx, subject, namespace)
|
||||
})
|
||||
return aimod.Mount(app, deps)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/datastore"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
@@ -278,7 +277,7 @@ func overview(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// Ensure the ai-owned ledger table exists (idempotent, latched) so a fresh
|
||||
// warehouse yields honest zeros, not an error. We NEVER create hanzo.events —
|
||||
// that table is operator-owned (unified-analytics.md §3.1).
|
||||
if err := aiobject.EnsureCloudUsageTable(ctx); err != nil {
|
||||
if err := datastore.EnsureCloudUsage(ctx); err != nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
|
||||
}
|
||||
|
||||
@@ -334,7 +333,7 @@ func timeseries(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return err
|
||||
}
|
||||
ctx := c.Context()
|
||||
if err := aiobject.EnsureCloudUsageTable(ctx); err != nil {
|
||||
if err := datastore.EnsureCloudUsage(ctx); err != nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
|
||||
}
|
||||
|
||||
@@ -379,7 +378,7 @@ func top(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return err
|
||||
}
|
||||
ctx := c.Context()
|
||||
if err := aiobject.EnsureCloudUsageTable(ctx); err != nil {
|
||||
if err := datastore.EnsureCloudUsage(ctx); err != nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
|
||||
}
|
||||
limit := topLimit(c)
|
||||
|
||||
@@ -3,7 +3,8 @@ package answer
|
||||
// read.go — the READ stage: the loop's fourth stage, between rank and synthesize.
|
||||
// Search gives a ~600-char snippet; a research-grade answer needs the PAGE. read()
|
||||
// fetches the top sources through the ONE crawl (self-hosted Hanzo Crawl / Crawl4AI
|
||||
// behind ai/object) and replaces each Source's Snippet with the fetched markdown.
|
||||
// behind clients/websearch) and replaces each Source's Snippet with the fetched
|
||||
// markdown.
|
||||
//
|
||||
// It enriches, it never re-identifies: URL/Title/Engine/Favicon are untouched, so
|
||||
// the `sources` frame the client already rendered stays valid. It is also STRICTLY
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud/clients/websearch"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -75,14 +76,12 @@ func read(ctx context.Context, srcs []Source, top int) []Source {
|
||||
// caller keeps the search snippets.
|
||||
//
|
||||
// The crawl runs on its own goroutine so ctx (the loop's 90s bound, or a client
|
||||
// disconnect) cancels the WAIT even though the pinned ai/object.Crawl takes no
|
||||
// ctx; the in-flight HTTP call still ends on its own 30s transport timeout. This
|
||||
// collapses to a direct ctx-carrying call at the next ai bump — the ctx-threaded
|
||||
// signature ships in hanzoai/ai on this same branch.
|
||||
// disconnect) cancels the WAIT even though websearch.Crawl takes no ctx; the
|
||||
// in-flight HTTP call still ends on its own transport timeout.
|
||||
func crawlPages(ctx context.Context, urls []string) []Page {
|
||||
done := make(chan []Page, 1) // buffered: the goroutine never blocks after we give up
|
||||
go func() {
|
||||
res, err := aiobject.Crawl(urls)
|
||||
res, err := websearch.Crawl(urls)
|
||||
if err != nil {
|
||||
done <- nil
|
||||
return
|
||||
@@ -90,7 +89,7 @@ func crawlPages(ctx context.Context, urls []string) []Page {
|
||||
out := make([]Page, 0, len(res))
|
||||
for _, r := range res {
|
||||
if r.Success {
|
||||
out = append(out, Page{URL: r.URL, Markdown: r.Markdown})
|
||||
out = append(out, Page{URL: r.URL, Markdown: string(r.Markdown)})
|
||||
}
|
||||
}
|
||||
done <- out
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// hanzo.cloud_usage is the per-inference spend ledger: the ai router appends one
|
||||
// row per call, and cloud's read surfaces (analytics, usage, evals, leaderboard,
|
||||
// link) aggregate it. A fresh warehouse has no such table, so every reader must
|
||||
// create it idempotently before its first SELECT.
|
||||
//
|
||||
// The DDL below is a SECOND copy of ai/object/cloud_usage.go's, and that is
|
||||
// deliberate. ai keeps its own for its WRITE path; this one serves cloud's read
|
||||
// path. The reason cloud cannot just call ai's: aiobject.EnsureCloudUsageTable
|
||||
// execs through object.DatastoreExec, whose connection is opened only by
|
||||
// object.InitDatastore, which runs only inside aimod.Mount. None of
|
||||
// cmd/{analytics,ask,evals,leaderboard,link,rollingcap,usage} link the ai module,
|
||||
// so that call ALWAYS returned "datastore: not connected" and every caller
|
||||
// silently took its failure branch — an honest-empty dashboard on a warehouse
|
||||
// that was up. Meanwhile THIS package's connection is live in exactly those
|
||||
// binaries.
|
||||
//
|
||||
// The alternative to a copy is a func var the host injects. That seam is never
|
||||
// wired here by construction: the whole point of these binaries is that they do
|
||||
// not link ai, so the var is nil in all seven and the read path is dead again.
|
||||
// Two copies of idempotent DDL against one table converge; a nil hook does not.
|
||||
// Keep in lockstep with ai/object/cloud_usage.go and the zapWriteUsage INSERT.
|
||||
const cloudUsageTableDDL = `
|
||||
CREATE TABLE IF NOT EXISTS hanzo.cloud_usage (
|
||||
id String,
|
||||
timestamp DateTime,
|
||||
owner String,
|
||||
user_id String,
|
||||
organization String,
|
||||
project String,
|
||||
model String,
|
||||
provider String,
|
||||
request_id String,
|
||||
prompt_tokens UInt32,
|
||||
completion_tokens UInt32,
|
||||
total_tokens UInt32,
|
||||
cache_read_tokens UInt32,
|
||||
cache_write_tokens UInt32,
|
||||
cost_cents UInt64,
|
||||
currency String,
|
||||
status String,
|
||||
error_msg String,
|
||||
is_premium UInt8,
|
||||
is_stream UInt8,
|
||||
client_ip String,
|
||||
byo UInt8,
|
||||
fee_cents Int64,
|
||||
account String,
|
||||
cost_nano Int64,
|
||||
billed_nano Int64,
|
||||
margin_nano Int64,
|
||||
unpriced UInt8
|
||||
) ENGINE = ReplacingMergeTree()
|
||||
ORDER BY (timestamp, organization, user_id, id)
|
||||
TTL timestamp + INTERVAL 2 YEAR`
|
||||
|
||||
// cloudUsageColumnMigrations bring an ALREADY-EXISTING table up to the current
|
||||
// schema: CREATE TABLE IF NOT EXISTS is a no-op on a table created before these
|
||||
// columns were added, so each additive column also needs an idempotent
|
||||
// ADD COLUMN IF NOT EXISTS. Applied after the CREATE so both a fresh and a legacy
|
||||
// table converge on the same shape.
|
||||
var cloudUsageColumnMigrations = []string{
|
||||
`ALTER TABLE hanzo.cloud_usage ADD COLUMN IF NOT EXISTS byo UInt8`,
|
||||
`ALTER TABLE hanzo.cloud_usage ADD COLUMN IF NOT EXISTS fee_cents Int64`,
|
||||
`ALTER TABLE hanzo.cloud_usage ADD COLUMN IF NOT EXISTS account String`,
|
||||
// Nano-USD margin ledger (money-of-record): cost_nano = provider COGS,
|
||||
// billed_nano = org debit, margin_nano = billed_nano − cost_nano. cost_cents
|
||||
// stays the derived spend column the console reads.
|
||||
`ALTER TABLE hanzo.cloud_usage ADD COLUMN IF NOT EXISTS cost_nano Int64`,
|
||||
`ALTER TABLE hanzo.cloud_usage ADD COLUMN IF NOT EXISTS billed_nano Int64`,
|
||||
`ALTER TABLE hanzo.cloud_usage ADD COLUMN IF NOT EXISTS margin_nano Int64`,
|
||||
// unpriced = 1 when the model had no configured price and billed at the default,
|
||||
// so the honest "priced?" flag is queryable in the warehouse, not just the span.
|
||||
`ALTER TABLE hanzo.cloud_usage ADD COLUMN IF NOT EXISTS unpriced UInt8`,
|
||||
// project is the caller's org SUB-SCOPE (X-Project-Id); it lets the per-org
|
||||
// metrics board narrow WITHIN an org by project. Additive: a pre-existing row
|
||||
// carries '' (the org's default project == whole-org view).
|
||||
`ALTER TABLE hanzo.cloud_usage ADD COLUMN IF NOT EXISTS project String`,
|
||||
}
|
||||
|
||||
var cloudUsageReady atomic.Bool
|
||||
|
||||
// EnsureCloudUsage creates hanzo.cloud_usage if absent, then applies the additive
|
||||
// column migrations so a pre-existing table gains the newer columns. Only SUCCESS
|
||||
// latches, so a warehouse still connecting at boot is retried on the next call
|
||||
// rather than poisoned forever.
|
||||
func EnsureCloudUsage(ctx context.Context) error {
|
||||
if cloudUsageReady.Load() {
|
||||
return nil
|
||||
}
|
||||
if !Ready() {
|
||||
return fmt.Errorf("datastore not connected")
|
||||
}
|
||||
if err := Exec(ctx, cloudUsageTableDDL); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stmt := range cloudUsageColumnMigrations {
|
||||
if err := Exec(ctx, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cloudUsageReady.Store(true)
|
||||
return nil
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud/clients/datastore"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
@@ -22,7 +21,7 @@ import (
|
||||
// ported to pure Go over our datastore; none of Langfuse's commercial (ee/) code
|
||||
// is used.
|
||||
//
|
||||
// TWO datastore sources, ONE shared client (aiobject peer), both READ-ONLY:
|
||||
// TWO datastore sources, ONE shared client (clients/datastore), both READ-ONLY:
|
||||
// - hanzo.cloud_usage — the proven spend/usage ledger (ai/object-owned; the SAME
|
||||
// table ListObservations reads). Every production generation lands here, so it
|
||||
// is the AUTHORITATIVE source for counts, tokens, cost, errors, model & user
|
||||
@@ -267,7 +266,7 @@ func (t *dsTelemetry) Metrics(ctx context.Context, f MetricsFilter) (Board, erro
|
||||
if !datastore.Ready() {
|
||||
return Board{}, fmt.Errorf("evals telemetry: datastore not connected")
|
||||
}
|
||||
if err := aiobject.EnsureCloudUsageTable(ctx); err != nil {
|
||||
if err := datastore.EnsureCloudUsage(ctx); err != nil {
|
||||
return Board{}, fmt.Errorf("evals telemetry: ensure cloud_usage: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -140,9 +140,9 @@ type Telemetry interface {
|
||||
|
||||
// ── datastore (shared datastore client) implementation ──────────────────────
|
||||
|
||||
// dsTelemetry writes eval telemetry to the datastore over the SHARED ai/object
|
||||
// datastore client. It holds no connection of its own — aiobject owns the peer,
|
||||
// its retry/backoff, its pool and its DATASTORE_* creds. dsTelemetry owns only
|
||||
// dsTelemetry writes eval telemetry to the datastore over cloud's ONE warehouse
|
||||
// connection (clients/datastore). It holds no connection of its own — that package
|
||||
// owns the peer, its retry/backoff, its pool and its DATASTORE_* creds. dsTelemetry owns only
|
||||
// its two tables and the SQL for its rows.
|
||||
type dsTelemetry struct {
|
||||
db string
|
||||
@@ -161,7 +161,7 @@ type dsTelemetry struct {
|
||||
// datastore.Ready() for an honest "unavailable" during the boot window.
|
||||
//
|
||||
// Creds are the ONE shared namespace (KMS-injected, never hard-coded), resolved
|
||||
// by aiobject: DATASTORE_ADDR / DATASTORE_DB / DATASTORE_USER / DATASTORE_PASSWORD.
|
||||
// by clients/datastore: DATASTORE_ADDR / DATASTORE_DB / DATASTORE_USER / DATASTORE_PASSWORD.
|
||||
func newDatastoreTelemetry(log luxlog.Logger) (Telemetry, error) {
|
||||
if getenv("DATASTORE_ADDR") == "" {
|
||||
return nil, nil // no datastore configured — telemetry disabled.
|
||||
@@ -410,7 +410,7 @@ func (t *dsTelemetry) ListTraces(ctx context.Context, f TraceFilter) ([]Trace, e
|
||||
}
|
||||
|
||||
// Close is a no-op: dsTelemetry does not own the shared datastore connection
|
||||
// (aiobject does), so it has nothing to release.
|
||||
// (clients/datastore does), so it has nothing to release.
|
||||
func (t *dsTelemetry) Close() error { return nil }
|
||||
|
||||
// ── in-memory implementation (tests + telemetry-disabled fallback is nil) ─────
|
||||
|
||||
@@ -39,7 +39,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/datastore"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
@@ -53,7 +52,7 @@ var (
|
||||
queryDatastore = datastore.Query
|
||||
execDatastore = datastore.Exec
|
||||
datastoreEnabled = datastore.Ready
|
||||
ensureUsageTable = aiobject.EnsureCloudUsageTable
|
||||
ensureUsageTable = datastore.EnsureCloudUsage
|
||||
nowFn = time.Now
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud/clients/datastore"
|
||||
)
|
||||
|
||||
@@ -502,7 +501,7 @@ func (s *Store) HanzoTotals(ctx context.Context, org string, from, to time.Time)
|
||||
if !datastore.Ready() {
|
||||
return nil, false
|
||||
}
|
||||
if err := aiobject.EnsureCloudUsageTable(ctx); err != nil {
|
||||
if err := datastore.EnsureCloudUsage(ctx); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
q, args := hanzoQuery(org, from, to)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//
|
||||
// It composes three co-resident GLOBALS and owns no state of its own:
|
||||
//
|
||||
// aiobject.TierReader() — the caller's commerce plan tier (installed by wireTierReader)
|
||||
// cloud.TierReader() — the caller's commerce plan tier (installed by wireTierReader)
|
||||
// finance.Current() — the per-org ledger's windowed sum (installed by wireFinance)
|
||||
// flags.Int(key) — the admin-editable per-tier caps (the platform-switch registry)
|
||||
//
|
||||
@@ -35,8 +35,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/flags"
|
||||
@@ -91,12 +89,18 @@ func init() {
|
||||
// stays nil, behavior unchanged. Runs after MountAll's prerequisites — wireTierReader
|
||||
// + wireFinance have already installed the globals it reads.
|
||||
func Mount(_ cloud.Router, _ cloud.Deps) error {
|
||||
tier := aiobject.TierReader()
|
||||
// cloud.TierReader is the SOURCE (wireTierReader sets it in BuildDeps, which
|
||||
// runs before MountAll). aiobject's is a COPY clients/ai makes at ai.Mount, and
|
||||
// reading that copy here made the cap DEAD in every deployment: cmd/rollingcap
|
||||
// never links clients/ai at all, and in the unified binary apps.Wire() mounts
|
||||
// rollingcap BEFORE ai — so the copy was nil either way and this took the
|
||||
// early-out below on every boot. Read the source, not a copy of it.
|
||||
tier := cloud.TierReader()
|
||||
fin := finance.Current()
|
||||
if tier == nil || fin == nil {
|
||||
return nil // money/tier layer not co-resident → no rolling cap
|
||||
}
|
||||
aiobject.SetRollingCapReader(func(ctx context.Context, subject, namespace string) (bool, error) {
|
||||
cloud.SetRollingCapReader(func(ctx context.Context, subject, namespace string) (bool, error) {
|
||||
window := flags.Int(capWindowKey)
|
||||
if window <= 0 {
|
||||
return false, nil // cap disabled globally
|
||||
|
||||
@@ -117,7 +117,7 @@ func TestRollingCapDecision(t *testing.T) {
|
||||
// TestMountNoOpWhenGlobalsUnwired proves Mount installs nothing when the tier/finance
|
||||
// globals are absent (standalone / split deploy) — no panic, no hook.
|
||||
func TestMountNoOpWhenGlobalsUnwired(t *testing.T) {
|
||||
// aiobject.TierReader() and finance.Current() are nil in a bare test binary, so
|
||||
// cloud.TierReader() and finance.Current() are nil in a bare test binary, so
|
||||
// Mount must return nil without installing a reader.
|
||||
if err := Mount(nil, cloud.Deps{}); err != nil {
|
||||
t.Fatalf("Mount with unwired globals must be a no-op nil, got %v", err)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud/clients/datastore"
|
||||
)
|
||||
|
||||
@@ -33,7 +32,7 @@ import (
|
||||
// parameters. No value a client controls is ever concatenated into SQL.
|
||||
|
||||
// warehouse is the account-usage datastore projection. It holds ONLY the
|
||||
// idempotent-DDL latch; the connection itself is aiobject's (a package global), so
|
||||
// idempotent-DDL latch; the connection itself is clients/datastore's (a package global), so
|
||||
// the warehouse owns no closable handle and the usage subsystem needs no Shutdown.
|
||||
// dsReady latches the DDL on success only, so a datastore still connecting at boot
|
||||
// is retried on the next call rather than permanently written off.
|
||||
@@ -506,7 +505,7 @@ func (w *warehouse) HanzoTotals(ctx context.Context, org string, from, to time.T
|
||||
if !datastore.Ready() {
|
||||
return nil, false
|
||||
}
|
||||
if err := aiobject.EnsureCloudUsageTable(ctx); err != nil {
|
||||
if err := datastore.EnsureCloudUsage(ctx); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
q, args := hanzoQuery(org, from, to)
|
||||
|
||||
@@ -54,7 +54,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/commerce/transport"
|
||||
"github.com/hanzoai/cloud/clients/datastore"
|
||||
@@ -86,8 +85,8 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
|
||||
// build constructs the usage state: the commerce S2S reader from its env
|
||||
// (COMMERCE_SERVICE_TOKEN is a KMS-sourced secret already on the cloud env, never
|
||||
// hard-coded) and the account-usage warehouse (a DDL latch over aiobject's shared
|
||||
// datastore — no handle of its own, so the subsystem needs no Shutdown).
|
||||
// hard-coded) and the account-usage warehouse (a DDL latch over clients/datastore's
|
||||
// shared connection — no handle of its own, so the subsystem needs no Shutdown).
|
||||
func build(b cloud.Base) (state, error) {
|
||||
cr := newCommerceReader(transport.BaseURL(os.Getenv("CLOUD_COMMERCE_HTTP_URL")), os.Getenv("COMMERCE_SERVICE_TOKEN"))
|
||||
b.Log.Info("usage surface", "prefix", "/v1/usage", "commerce", cr.configured())
|
||||
@@ -286,7 +285,7 @@ func buildAnalyticsBlock(s *cloud.Service[state], ctx context.Context, org strin
|
||||
if !datastore.Ready() {
|
||||
return empty
|
||||
}
|
||||
if err := aiobject.EnsureCloudUsageTable(ctx); err != nil {
|
||||
if err := datastore.EnsureCloudUsage(ctx); err != nil {
|
||||
s.Log.Debug("cloud_usage ensure failed; analytics honest-empty", "err", err)
|
||||
return empty
|
||||
}
|
||||
@@ -382,7 +381,7 @@ func buildLLMBlock(s *cloud.Service[state], ctx context.Context, org string, sta
|
||||
}
|
||||
// Ensure the ai-owned ledger table exists (idempotent) so a fresh warehouse
|
||||
// yields honest zeros, not an error.
|
||||
if err := aiobject.EnsureCloudUsageTable(ctx); err != nil {
|
||||
if err := datastore.EnsureCloudUsage(ctx); err != nil {
|
||||
s.Log.Debug("cloud_usage ensure failed; llm honest-empty", "err", err)
|
||||
return buildLLM(false, nil), false
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ type firecrawlData struct {
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// crawlRequest / crawlResult mirror Hanzo Crawl's /crawl contract
|
||||
// crawlRequest / CrawlResult mirror Hanzo Crawl's /crawl contract
|
||||
// (ai/object/crawl4ai.go).
|
||||
type crawlRequest struct {
|
||||
Urls []string `json:"urls"`
|
||||
@@ -176,7 +176,10 @@ func (m *markdownField) UnmarshalJSON(b []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type crawlResult struct {
|
||||
// CrawlResult is one URL's outcome. Exported because it is the value Crawl
|
||||
// hands out; Markdown keeps its shape-polymorphic decoder, so a consumer reads it
|
||||
// as string(r.Markdown) exactly like scrapeHandler does below.
|
||||
type CrawlResult struct {
|
||||
URL string `json:"url"`
|
||||
Markdown markdownField `json:"markdown"`
|
||||
Success bool `json:"success"`
|
||||
@@ -191,7 +194,7 @@ type crawlResult struct {
|
||||
type crawlResponse struct {
|
||||
Status string `json:"status"`
|
||||
Success bool `json:"success"`
|
||||
Results []crawlResult `json:"results"`
|
||||
Results []CrawlResult `json:"results"`
|
||||
}
|
||||
|
||||
func scrapeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -229,9 +232,16 @@ func scrapeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// crawl fetches one URL via Hanzo Crawl and returns its markdown result.
|
||||
func crawl(target string) (*crawlResult, error) {
|
||||
body, _ := json.Marshal(crawlRequest{Urls: []string{target}})
|
||||
// Crawl fetches a batch of URLs through Hanzo Crawl in ONE request and returns
|
||||
// every per-result the service produced, in service order. /crawl is natively a
|
||||
// batch endpoint, so this is the primitive and the single-URL crawl below is its
|
||||
// len==1 case — one dial path, one auth path, one decode path.
|
||||
//
|
||||
// Per-URL failure is reported IN BAND (Success=false on that result), not as an
|
||||
// error: a dead page must not discard the pages that did come back. The error
|
||||
// return is reserved for the call itself failing.
|
||||
func Crawl(urls []string) ([]CrawlResult, error) {
|
||||
body, _ := json.Marshal(crawlRequest{Urls: urls})
|
||||
req, err := http.NewRequest(http.MethodPost, crawlEndpoint()+"/crawl", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -253,10 +263,19 @@ func crawl(target string) (*crawlResult, error) {
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cr.Results) == 0 {
|
||||
return cr.Results, nil
|
||||
}
|
||||
|
||||
// crawl fetches one URL via Hanzo Crawl and returns its markdown result.
|
||||
func crawl(target string) (*CrawlResult, error) {
|
||||
res, err := Crawl([]string{target})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(res) == 0 {
|
||||
return nil, fmt.Errorf("hanzo crawl returned no results for %s", target)
|
||||
}
|
||||
return &cr.Results[0], nil
|
||||
return &res[0], nil
|
||||
}
|
||||
|
||||
// ── shared JSON writers ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -346,7 +346,7 @@ func TestScrapeHandlesCrawl4AIObjectMarkdown(t *testing.T) {
|
||||
|
||||
// The bare-string markdown form (older mirror / other crawlers) must still work.
|
||||
func TestMarkdownFieldAcceptsBareString(t *testing.T) {
|
||||
var r crawlResult
|
||||
var r CrawlResult
|
||||
if err := json.Unmarshal([]byte(`{"url":"u","markdown":"# S","success":true}`), &r); err != nil {
|
||||
t.Fatalf("decode bare-string markdown: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user