Files
zeekay 58b4135ff6 catalog: enso is the Enso router, listed at enso-flash's price
hanzoai/ai v1.833.349 makes enso, enso-auto and hanzo/enso the paid
router over the three tiers, billed at the tier that answers, and lists
each at a request's that states no depth: enso-flash's 0.33 / 1.65.
enso-free is the one free model. The seed's enso row and billedRetail
carry enso-flash's price, so Reprice moves the live row off 0.00.
2026-10-15 19:50:15 -07:00

536 lines
19 KiB
Go

package catalogentry
import (
_ "embed"
"slices"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/json"
)
// hanzoCatalogSeed is the initial platform catalog: the ten cloud-primitive
// categories and their capabilities, aligned to the single source of truth in
// hanzo.ai lib/data/cloud-primitives.ts, enriched with per-capability
// route/docs/github and public pricing + admin-only economics. It is the
// STARTING state / offline fallback; once seeded, the CMS (admin FE editing this
// catalog) is authoritative. Re-baseline by re-aligning to cloud-primitives.ts.
//
//go:embed seed/hanzo-catalog.json
var hanzoCatalogSeed []byte
// infraTiersSeed is the initial infra-tier catalog: the cloud VM plans, GPU
// tiers, and managed-datastore tiers, categories "cloud" / "gpu" / "datastore".
// It is a VERBATIM snapshot of the pricing source of truth
// (hanzoai/pricing src/models.mjs cloudPlans + gpuTiers, and datastore.json) —
// commerce is the CMS source-of-truth these tiers are read from; the pricing
// service reads them back via GET /v1/commerce/catalog?brand=infra. The
// structured per-tier spec rides Metadata (the JSON hatch), the monthly (cloud/
// datastore) or hourly (gpu) display price rides PriceCents. Re-baseline by
// re-running the pricing→commerce generator against the pricing source.
//
//go:embed seed/infra-tiers.json
var infraTiersSeed []byte
// ensoModelsSeed is the Enso family: the six SKUs the enso service serves, at
// the price the model API bills. Each paid SKU sells at ONE fixed price
// (hanzoai/ai controllers/rates.go ensoPrices, owner 2026-10-15): enso-flash
// 0.33 / 1.65, enso-pro 4.40 / 22.00, enso-ultra 5.50 / 27.50 a million tokens,
// with cache reads and writes in the same multiple. enso is the router over the
// three (hanzoai/ai v1.833.349), billed at the tier that answers and listed at a
// request's that states no depth, enso-flash's. Retail
// is the one number a sync may not write — UpsertModels strips Price by design —
// so the prices arrive here, and [Reprice] reads them back over the store on
// every boot. It carries no cost: cost comes only from a sync.
//
// enso-vl and enso-vl-pro are seeded UNPUBLISHED. They are internal vision
// engines the family reaches through its vision fallback, never listed SKUs, so
// they carry their rates — a price the billing history can reference — without
// appearing on the public price list.
//
//go:embed seed/enso-models.json
var ensoModelsSeed []byte
// infraCategories is the exact set of infra-tier categories. It is BOTH the
// "infra" brand's category scope (see brandCategories in projection.go, which
// surfaces these — and only these — under GET /v1/commerce/catalog?brand=infra)
// and the seed gate. One definition, one way.
var infraCategories = []string{"cloud", "gpu", "datastore"}
// SeedRow is the @hanzo/products snapshot shape (the exact CatalogEntry
// contract). `id` == `slug`; `pricingId` may be JSON null (→ "").
type SeedRow struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
BrandColor string `json:"brandColor"`
IconKey string `json:"iconKey"`
Slug string `json:"slug"`
Route string `json:"route"`
DocsUrl string `json:"docsUrl"`
ApiPath string `json:"apiPath"`
ApiRoute string `json:"apiRoute"`
GithubUrl string `json:"githubUrl"`
External bool `json:"external"`
PricingId string `json:"pricingId"` // null → ""
Pricing *Pricing `json:"pricing"`
Private *Economics `json:"private"`
Brands []string `json:"brands"`
Repo string `json:"repo"`
Admin bool `json:"admin"`
Kind string `json:"kind"` // service (default) | client
Status string `json:"status"`
Gcp string `json:"gcp"`
}
// HanzoSeedRows returns the parsed embedded snapshot.
func HanzoSeedRows() ([]SeedRow, error) {
var rows []SeedRow
if err := json.DecodeBytes(hanzoCatalogSeed, &rows); err != nil {
return nil, err
}
return rows, nil
}
// SeedIfEmpty seeds the catalog only when it is currently empty — a cheap single
// count query gates the full per-row create, so it is safe to call on every
// bootstrap. Once any entry exists (seeded or CMS-created) it never re-runs, so
// CMS state stays authoritative.
func SeedIfEmpty(db *datastore.Datastore) (created int, err error) {
n, err := Query(db).Count()
if err != nil {
return 0, err
}
if n > 0 {
return 0, nil
}
return Seed(db)
}
// Seed loads the embedded @hanzo/products snapshot into db (which MUST be
// namespaced to the catalog-owning "system" org). IDEMPOTENT + NON-DESTRUCTIVE:
// an entry whose slug already exists is left UNTOUCHED so CMS edits are never
// clobbered; only missing slugs are created. Order is the snapshot index (stable
// display order within a category). Returns the number created.
func Seed(db *datastore.Datastore) (created int, err error) {
rows, err := HanzoSeedRows()
if err != nil {
return 0, err
}
for i, r := range rows {
slug := r.Slug
if slug == "" {
slug = r.ID
}
existing := New(db)
ok, qerr := existing.Query().Filter("Slug=", slug).Get()
if qerr != nil {
return created, qerr
}
if ok {
continue
}
e := New(db)
e.Slug = slug
e.Name = r.Name
e.Category = r.Category
e.BrandColor = r.BrandColor
e.IconKey = r.IconKey
e.Route = r.Route
e.DocsUrl = r.DocsUrl
e.ApiPath = r.ApiPath
e.ApiRoute = r.ApiRoute
e.GithubUrl = r.GithubUrl
e.External = r.External
e.PricingId = r.PricingId
e.Pricing = r.Pricing
e.Private = r.Private
e.Brands = r.Brands
e.Repo = r.Repo
e.Admin = r.Admin
e.Role = r.Kind
e.Status = r.Status
e.Gcp = r.Gcp
e.Order = i
e.Published = true
if err := e.Create(); err != nil {
return created, err
}
created++
}
return created, nil
}
// Correct sets every existing entry's API ADDRESS to the one the snapshot
// declares — ApiPath, its host-qualified spelling ApiRoute, and Role (kind).
// Rows the snapshot does not name are left alone, and no row is created here:
// birth is [Seed]'s job, and this is only ever a correction.
//
// # Why an address is not a merchandising choice
//
// Everything else on a row — name, price, order, status, whether it is published
// — is a decision a human makes in admin.hanzo.ai, and Seed is non-destructive
// precisely so a deploy can never overwrite one. An address is not that kind of
// value. Either the fleet answers at that path or it does not, and the fleet is
// the only thing that knows; a catalog that disagrees is not expressing a
// preference, it is wrong.
//
// It was wrong for a long time, and nothing could say so. Production first booted
// on a 95-row snapshot, the snapshot was later cut to 60, and because BOTH gates
// only ever create, the dropped rows stayed in the store forever carrying whatever
// address they were born with. Thirty-one of the 84 products a customer sees named
// a path that answers 404 — most of them literally "/v1/" + the slug, a guess
// never once checked against the router. This list was the estate's last
// hand-copied claim about its own routes: the published document is derived from
// the router and gated on prose, so a served route CANNOT be missing from it, and
// only this list could drift.
//
// So an address now travels the way the plan catalog's prices already do (see
// runPlansSeed) — as a DEPLOY, reviewed like a deploy, rather than as a script
// someone runs against production holding a SuperAdmin bearer. And cloud's
// catalog_address_test.go refuses to build a fleet whose snapshot names a path
// that fleet does not serve, so what arrives here has already been read back
// against the document it is a claim about.
func Correct(db *datastore.Datastore) (corrected int, err error) {
rows, err := HanzoSeedRows()
if err != nil {
return 0, err
}
for _, r := range rows {
slug := r.Slug
if slug == "" {
slug = r.ID
}
e := New(db)
ok, qerr := e.Query().Filter("Slug=", slug).Get()
if qerr != nil {
return corrected, qerr
}
// Kind, not the raw field: a stored "service" and a snapshot that says
// nothing are the SAME kind, and comparing the strings would rewrite the
// row on every boot forever. Twice is once — correct_test.go pins it.
if !ok || (e.ApiPath == r.ApiPath && e.ApiRoute == r.ApiRoute && KindOf(e) == Kind(r.Kind)) {
continue
}
e.ApiPath = r.ApiPath
e.ApiRoute = r.ApiRoute
e.Role = Kind(r.Kind)
if err := e.Update(); err != nil {
return corrected, err
}
corrected++
}
return corrected, nil
}
// Rename moves every entry sitting in a RETIRED category to the one that
// replaced it, per renamedCategories (projection.go). It returns the number
// moved and is idempotent: a second run finds nothing, because the first left
// no row carrying a retired label.
//
// A category rename is not the same act as an address correction, and this is
// not [Correct] with a wider reach. Correct rewrites a field on rows that are
// working fine; this one only ever touches rows that are ALREADY broken. The
// canonical list is what scoped() filters on, so the instant a label leaves it
// every row still naming it stops being projected — the products do not move,
// they disappear. Reading the old label is therefore never a preference to
// respect; it is a row that has been orphaned by a taxonomy change and has
// exactly one right home.
//
// That narrowness is the safety property. Which of the ten categories a product
// belongs to IS a merchandising decision, made in admin.hanzo.ai, and this
// cannot overwrite one: a row in a canonical category never matches the map.
//
// Like every other catalog reconciliation here, it travels as a DEPLOY rather
// than as a script someone runs against production holding a SuperAdmin bearer.
func Rename(db *datastore.Datastore) (moved int, err error) {
if len(renamedCategories) == 0 {
return 0, nil
}
// Two steps, and the split is not stylistic: GetAll fills plain structs that
// are not bound to the store, so writing one back panics. A row is READ in
// bulk to find out which slugs are stranded, then re-fetched through New(db)
// to be written — the same shape [Correct] uses, for the same reason.
entries := make([]*CatalogEntry, 0, 128)
if _, err := Query(db).GetAll(&entries); err != nil {
return 0, err
}
stranded := make(map[string]string, len(renamedCategories))
for _, e := range entries {
if to, retired := renamedCategories[e.Category]; retired {
stranded[e.Slug] = to
}
}
for slug, to := range stranded {
e := New(db)
ok, qerr := e.Query().Filter("Slug=", slug).Get()
if qerr != nil {
return moved, qerr
}
if !ok || renamedCategories[e.Category] == "" {
continue
}
e.Category = to
if err := e.Update(); err != nil {
return moved, err
}
moved++
}
return moved, nil
}
// InfraSeedRow is one infra-tier seed record: a catalog-entry addressed by a
// globally-unique slug, carrying its display price (PriceCents) and the full
// structured spec in the Metadata JSON hatch (vcpus/memoryGB/… for cloud,
// gpu/vram/price for gpu, replicas/ramGiB/…/usage for datastore). It is a
// deliberately SMALLER shape than SeedRow — infra tiers are pricing rows, not
// console modules, so they carry no iconKey/brandColor/route/apiPath.
type InfraSeedRow struct {
Slug string `json:"slug"`
Name string `json:"name"`
Category string `json:"category"`
Description string `json:"description"`
PriceCents currency.Cents `json:"priceCents"`
Currency currency.Type `json:"currency"`
Order int `json:"order"`
Metadata map[string]interface{} `json:"metadata"`
}
// InfraSeedRows returns the parsed embedded infra-tier snapshot.
func InfraSeedRows() ([]InfraSeedRow, error) {
var rows []InfraSeedRow
if err := json.DecodeBytes(infraTiersSeed, &rows); err != nil {
return nil, err
}
return rows, nil
}
// SeedInfra loads the embedded infra-tier snapshot into db (which MUST be
// namespaced to the catalog-owning "system" org). IDEMPOTENT + NON-DESTRUCTIVE,
// exactly like Seed: an entry whose slug already exists is left UNTOUCHED so CMS
// edits are never clobbered; only missing slugs are created. Returns the number
// created.
func SeedInfra(db *datastore.Datastore) (created int, err error) {
rows, err := InfraSeedRows()
if err != nil {
return 0, err
}
for _, r := range rows {
existing := New(db)
ok, qerr := existing.Query().Filter("Slug=", r.Slug).Get()
if qerr != nil {
return created, qerr
}
if ok {
continue
}
e := New(db)
e.Slug = r.Slug
e.Name = r.Name
e.Category = r.Category
e.Description = r.Description
e.PriceCents = r.PriceCents
e.Currency = r.Currency
e.Metadata = r.Metadata
e.Order = r.Order
e.Published = true
if err := e.Create(); err != nil {
return created, err
}
created++
}
return created, nil
}
// SeedInfraIfEmpty seeds the infra tiers only when NONE are present yet — a
// cheap per-category count gates the full per-row create, so it is safe to call
// on every bootstrap (mirrors SeedIfEmpty). Once any infra tier exists (seeded
// or CMS-edited) it never re-runs, so CMS state — including deletions — stays
// authoritative. The gate is SEPARATE from the whole-catalog SeedIfEmpty gate,
// so the infra tiers seed independently of the 95-row hanzo product snapshot.
func SeedInfraIfEmpty(db *datastore.Datastore) (created int, err error) {
total := 0
for _, cat := range infraCategories {
n, cerr := Query(db).Filter("Category=", cat).Count()
if cerr != nil {
return 0, cerr
}
total += n
}
if total > 0 {
return 0, nil
}
return SeedInfra(db)
}
// ModelSeedRow is one first-party model's opening state: the descriptor a
// family owns plus the rate vector carrying BOTH the upstream cost and the
// retail price. It is deliberately not ModelRow — a ModelRow is what a SYNCER
// publishes and may state only cost, whereas a seed states the initial admin
// decision and therefore may state price.
type ModelSeedRow struct {
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Published bool `json:"published"`
Order int `json:"order"`
Spec ModelSpec `json:"spec"`
Rates []Rate `json:"rates"`
}
// EnsoSeedRows returns the parsed embedded Enso family snapshot.
func EnsoSeedRows() ([]ModelSeedRow, error) {
var rows []ModelSeedRow
if err := json.DecodeBytes(ensoModelsSeed, &rows); err != nil {
return nil, err
}
return rows, nil
}
// SeedEnsoModels loads the embedded Enso family into db (which MUST be
// namespaced to the catalog-owning "system" org). IDEMPOTENT + NON-DESTRUCTIVE
// exactly like Seed and SeedInfra: a slug that already exists is left UNTOUCHED,
// so a price an admin has since edited is never reset to the seed. Only missing
// slugs are created. Returns the number created.
func SeedEnsoModels(db *datastore.Datastore) (created int, err error) {
rows, err := EnsoSeedRows()
if err != nil {
return 0, err
}
for _, r := range rows {
existing := New(db)
ok, qerr := existing.Query().Filter("Slug=", r.Slug).Get()
if qerr != nil {
return created, qerr
}
if ok {
continue
}
e := New(db)
e.Slug = r.Slug
e.Name = r.Name
e.Description = r.Description
e.Category = CategoryForFamily(r.Spec.Family)
e.Status = StatusEnabled
e.Spec = specPtr(r.Spec)
e.Rates = r.Rates
e.Order = r.Order
e.Published = r.Published
if err := e.Create(); err != nil {
return created, err
}
created++
}
return created, nil
}
// Reprice sets each Enso row the seed names to the seed's price, rate by rate,
// and returns how many rows it moved. A rate the row lacks is added; a cost (a
// sync's), a name, an order, whether it is published, a row the seed does not
// name and a row an admin deleted are left alone. Prices compare as decimals, so
// "4.4" where the seed says "4.40" moves nothing, and twice is once.
//
// # Why an Enso price is no longer an admin's
//
// Everywhere else on the catalog a price is a decision a human makes in
// admin.hanzo.ai, and every seed is non-destructive so a deploy cannot undo one.
// Enso's is not that kind of value any more. The model API sells each Enso SKU
// at one price set in code and bills that price whatever a row here says, so a
// row stating another number is a price nobody is charged — the drift that once
// had the estate advertising 20/60 while charging 4/20. Like an address
// ([Correct]), it is read back from the place it is declared, on every boot, and
// a change to it is a deploy.
func Reprice(db *datastore.Datastore) (repriced int, err error) {
rows, err := EnsoSeedRows()
if err != nil {
return 0, err
}
for _, r := range rows {
e := New(db)
ok, qerr := e.Query().Filter("Slug=", r.Slug).Get()
if qerr != nil {
return repriced, qerr
}
if !ok {
continue
}
rates, moved := priced(e.Rates, r.Rates)
if !moved {
continue
}
e.Rates = rates
if err := e.Update(); err != nil {
return repriced, err
}
repriced++
}
return repriced, nil
}
// priced is have with each of want's prices laid over the rate of the same
// identity (key, unit, context rung) and each rate of want's that have lacks
// appended, and whether anything moved.
func priced(have, want []Rate) ([]Rate, bool) {
out := slices.Clone(have)
moved := false
for _, w := range want {
i := slices.IndexFunc(out, w.same)
if i < 0 {
out = append(out, w)
moved = true
continue
}
a, okA := dec(out[i].Price)
b, okB := dec(w.Price)
if okA && okB && a.Equal(b) {
continue
}
out[i].Price = w.Price
moved = true
}
return out, moved
}
// SeedEnsoModelsIfEmpty seeds the Enso family only when this seed has never been
// applied, so it is safe on every bootstrap (mirrors SeedInfraIfEmpty). Gated
// per-family, so seeding Enso is independent of Zen, of the third-party mirror,
// and of the product catalog. Once the seed has run it never re-runs, leaving CMS
// state — INCLUDING DELETIONS — authoritative.
//
// "Has this seed been applied" is asked of the SEED'S OWN SLUGS, not of the enso
// category. It used to count category rows, which answers a different question
// and got it wrong in production: a deployment carried one enso row that this
// seed does not contain (enso-free, created by another path), the count came back
// non-zero, and the five SKUs the enso service actually sells — enso, enso-flash,
// enso-ultra and the two vision rows — were never created. The catalog served a
// single free tier while every paid tier 400'd as an unknown model, and the guard
// read as working correctly the whole time.
//
// A foreign row cannot answer whether we have seeded, so it no longer votes. A
// row we DID seed still does, which is what keeps a deletion authoritative.
func SeedEnsoModelsIfEmpty(db *datastore.Datastore) (created int, err error) {
rows, err := EnsoSeedRows()
if err != nil {
return 0, err
}
for _, r := range rows {
e := New(db)
ok, qerr := e.Query().Filter("Slug=", r.Slug).Get()
if qerr != nil {
return 0, qerr
}
if ok {
return 0, nil // the seed has run; curation wins from here
}
}
return SeedEnsoModels(db)
}