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:
hanzo-dev
2026-07-27 20:00:01 -07:00
parent cee35e92bd
commit cab1b716c5
15 changed files with 207 additions and 59 deletions
+22 -12
View File
@@ -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 }
+11
View File
@@ -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)
}
+3 -4
View File
@@ -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)
+7 -8
View File
@@ -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
+111
View File
@@ -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
}
+2 -3
View File
@@ -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)
}
+5 -5
View File
@@ -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) ─────
+1 -2
View File
@@ -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
)
+1 -2
View File
@@ -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)
+9 -5
View File
@@ -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
+1 -1
View File
@@ -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)
+2 -3
View File
@@ -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)
+4 -5
View File
@@ -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
}
+27 -8
View File
@@ -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 ─────────────────────────────────────────────────────
+1 -1
View File
@@ -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)
}