Parameter-based cost estimation from GenSpend data — implementation plan
Plan, 2026-08-12. Targets the GenSpend v1 API (https://genspend.io/api/v1/…), which evolves
additively only.
1. The problem
The cost estimate NodeTool shows (and gates budgets on) is unit_price × quantity, where
quantity is only fan-out (num_images etc.). The unit is carried but never applied:
- A model billed per video second is estimated at one second, whatever
durationthe node sets. 103 of the 283 GenSpend catalog entries areper-video-second— for those, a 10s clip is under-estimated 10×. - Resolution ladders are collapsed at sync time to a single number
(
scripts/sync-genspend-pricing.mjs:120-132): same model twice → keep the cheaper, different models on one id → keep the dearer. Aseedance-2node at 1080p and one at 480p estimate the same. - Audio-state pricing (silent vs with-audio, up to 2× apart), reference-image surcharges, and reference-video re-rates are not represented at all.
GenSpend now publishes all of this — per-spec variants[], surcharges[], capabilities
(clip-length envelopes), and dataFlags[] — through GET /api/v1/export (~92 KB gzipped) and
prices it with POST /api/v1/quote. The plan: pull the parameterized data in the nightly sync,
compute locally from the shipped snapshot (NodeTool is local-first; the browser bundle and the
packaged Electron backend must both price without a network call), and verify our arithmetic
against /quote in the sync workflow, where the network already exists.
2. Current architecture (what changes, what stays)
scripts/sync-genspend-pricing.mjs nightly pull → generated JSON ← CHANGES
scripts/genspend/{match,normalize,inventory} GenSpend↔NodeTool id matching ← stays
packages/model-pricing/src/genspend-catalog.ts typed catalog wrapper ← CHANGES (schema v3)
packages/model-pricing/src/index.ts getModelUnitPrice lookup ← CHANGES (param-aware)
packages/node-sdk/src/cost-estimate.ts estimateWorkflowCost ← CHANGES (units × params)
packages/protocol/src/creative.ts NodeCostEstimate types ← CHANGES (additive)
web/src/hooks/useWorkflowCostEstimate.ts editor hook ← CHANGES (+ bug fix)
web/src/components/costs/WorkflowCostEstimatePanel.tsx the panel ← CHANGES
packages/websocket/src/unified-websocket-runner.ts estimateRunCost budget gate ← inherits for free
packages/websocket/src/sdk/sdk-static-preflight-service.ts preflight ← inherits for free
The matching pipeline (alias → variant → receipt → provider-id → catalog, in
scripts/genspend/match.mjs) is unchanged: it answers “which NodeTool provider:model_id keys
does this GenSpend offering price”, and that question is the same whether the value stored is a
scalar or a grid. FAL and kie keep their own catalogs and keep winning the lookup order — this
plan parameterizes the GenSpend tier only (a later PR can apply the same unit arithmetic to the
FAL per-video-second entries, which have the same defect).
3. Phase 1 — pull job: ship the grid, not one rung
Files: scripts/sync-genspend-pricing.mjs, packages/model-pricing/src/genspend-catalog.ts,
packages/model-pricing/tests/genspend-sync.test.ts, .github/workflows/genspend-pricing.yml.
3.1 Switch the fetch to /api/v1/export
/export is the same projection as /models plus surcharges, open dataFlags, and the
embedded usage block, assembled server-side so the two cannot drift. Keep the ETag /
If-None-Match handling, the strong-ETag workaround, the retry loop, and the refuse-to-write-empty
guard exactly as they are; only the URL and the envelope shape change
(export has {schemaVersion, generatedAt, counts, usage, models}).
3.2 Schema v3: GenspendPrice grows a grid
Bump SCHEMA_VERSION to 3 (which also correctly invalidates the stored ETag on the first run).
Each entry keeps today’s scalar fields — unit_price / billing_unit stay the base-spec
price, so every existing consumer keeps working unmodified through the migration — and adds:
interface GenspendVariant {
price_usd: number;
unit_class: string; // may differ per row (e.g. per-generation duration rows)
resolution?: string; // "480p" | "720p" | "1080p" | "2K" | "4K" | image sizes
duration_seconds?: number;
with_audio?: boolean;
video_input?: boolean;
tier?: string;
is_base: boolean;
}
interface GenspendSurcharge {
kind: "input_image" | "input_video_second" | "per_request";
spec?: string; // resolution scope for input_video_second
unit_price_usd: number;
free_allowance: number;
label?: string; // per_request extras ("prompt expansion")
}
interface GenspendPrice {
/* existing v2 fields unchanged: unit_price, billing_unit, unit_class,
model_slug, match, live, source_url, tier?, resolution? */
variants?: GenspendVariant[]; // the provider's published grid, typed facets only
surcharges?: GenspendSurcharge[];
clip_seconds?: { set?: number[]; min?: number; max?: number } | null;
data_flags?: Array<{ kind: string; severity: "quote_wrong" | "spec_gap" | "cosmetic" }>;
}
Rules, following GenSpend’s own usage guidance verbatim:
- Store facets, drop raw
specstrings. The raw string is GenSpend’s truth surface, but we only compute on the typed facets; a variant whose facets are allnullbeyondis_basecontributes nothing and is dropped. This keeps the JSON small. - The base-scalar pick changes for the better. Today the collapse picks a rung arbitrarily
(cheapest/dearest by trust rule). With the grid shipped,
unit_pricebecomes the base-spec row (is_base: true) when one exists, falling back to the current rule. The dearer-model budget-gate rule for two different models on one id stays. quote_wrongflags gate at sync time and at display time. The sync stores the flag; the calculator refuses to price a flagged entry (returns null → confidence “unknown”). Today that fires zero times; it keeps us honest when it doesn’t.clipSeconds: nullmeans decline every duration — store it as-is, never as permissive.- Cosmetic flags are dropped at sync time (display-text only, and we render our own text).
3.3 Size and diff hygiene
The v2 JSON is ~2 600 lines. Variants exist mostly on video models (~100 keys), typically 4–8
rows each; surcharges on a handful. Expected growth: roughly 2–3×, still far below the 840 KB
full export because we ship only matched keys and typed facets. Measure after the first build and
record the number in the PR. Keep the updatedAt-carry-over so an unchanged nightly run still
produces no diff.
3.4 Workflow: add a parity gate
Extend .github/workflows/genspend-pricing.yml with a step after the sync, before the PR is
opened: node scripts/genspend/parity-check.mjs re-prices ~15 fixed cases (cheapest-provider
image, a resolution ladder, per-second × duration, with/without audio, additive reference images
with an allowance, a resolution-scoped video re-rate, a rung we must decline, per-request extras
excluded from the total) through our calculator against the freshly-built catalog and asserts
each equals POST /api/v1/quote for the same step. This mirrors GenSpend’s own
export-parity.ts, which currently passes 17/17 — a drift in our port fails the nightly job
instead of shipping a wrong number. The script needs the network, so it runs in this workflow
only, never in the PR quality gate.
Prove the gate can fail once before merging: perturb one local price and watch the step go red.
4. Phase 2 — the shared calculator: params in, breakdown out
Files: packages/model-pricing/src/index.ts (+ new genspend-calc.ts),
packages/node-sdk/src/cost-estimate.ts, packages/protocol/src/creative.ts,
packages/node-sdk/src/ (new pricing-params.ts), tests in both packages.
4.1 New lookup signature (additive)
export interface ModelPriceParams {
resolution?: string; // normalized: "480p".."4K", "512×512", …
seconds?: number; // output duration
withAudio?: boolean;
referenceImages?: number;
megapixels?: number;
}
export interface ModelParamPrice extends ModelUnitPricingLike {
breakdown?: string; // "5 s × $0.205/s at 720p"
assumptions?: string[]; // "resolution not set on node — priced at base spec 720p"
warnings?: string[]; // "reference-image surcharge not captured — treat as at least"
declined?: string; // set instead of a price when we refuse to extrapolate
}
export function getModelUnitPrice(
model: SelectedModel,
params?: ModelPriceParams
): ModelUnitPricingLike | null; // unchanged behavior when params omitted
getModelUnitPrice keeps its exact current behavior with no params — every existing call site
(runner budget gate, chat spend, preflight) compiles and behaves identically until Phase 3
threads params through.
4.2 Selection rules (a deliberate subset of GenSpend’s calculator)
Implemented in packages/model-pricing/src/genspend-calc.ts, pure and I/O-free, mirroring the
rules GenSpend documents (and per their handoff, never “scan variants and take the cheapest”):
- Narrow
variantsby every param we hold:resolution(normalized,768p→720ptier per their parser; we implement the same 5-tier video / 4-tier image mapping),with_audio(unset param → prefer the base spec, the honest default deliverable),video_input === falsefor plain t2v/i2v. - No row matches a stated resolution → decline (
declined: "no published price at 1080p"), never reuse another rung. Resolution unset on the node → base-spec row plus anassumptionsentry. per-video-second/per-audio-second: multiply byseconds; clamp check againstclip_seconds— outside the envelope → decline.secondsunknown → price one second and record the assumption (confidence stays “estimate”, the panel shows “per second”).per-image/per-generation: unit price as today; fan-out multiplication stays inestimateWorkflowCost.- Surcharges:
input_imageaddsmax(0, refs − free_allowance) × rate.input_video_secondreplaces generation cost withrate × (input + output)seconds, scoped by resolution — no scoped row for the requested resolution → decline the re-rate and warn (understating is the direction that hurts).per_requestis surfaced inwarnings/assumptions, never added silently. (Reference params are unlikely to be readable from nodes in v1 — the shapes ship in the calculator anyway so the parity gate exercises them and the UI can grow into them.) quote_wrongdata flag → decline.spec_gap→ exact at base spec, adds a warning off it.
Deliberately out of scope for v1 (declined or base-priced with an assumption, matching what the catalog can’t express): token-billed image inputs, per-candidate agentic billing, quality axes.
4.3 Parameter extraction from node data
New packages/node-sdk/src/pricing-params.ts, the sibling of FAN_OUT_PROPERTY_NAMES
(web/src/utils/aiModelNodes.ts moves its list here or re-exports — one source of truth):
- Duration: first present of
duration,duration_seconds,seconds,num_seconds,video_length,video_durationwith a finite positive value; alsonum_frames ÷ fpswhen both exist. Values may be strings on some nodes ("5","5s") — parse leniently, extract strictly. - Resolution: first present of
resolution,image_size,size,quality;width×height→ nearest tier. Normalize to GenSpend’s accepted sets; an unrecognized value maps to unset (assumption), never to a guessed tier. - Audio:
generate_audio,with_audio,audioboolean properties.
Before writing the list, enumerate the actual property names: grep the generator manifests
(packages/fal-nodes, packages/replicate-nodes, kie) and packages/base-nodes video/image
node definitions, and record the census in the PR — the list must come from what nodes ship,
not from memory.
estimateWorkflowCost gains an optional getParams?: (node) => ModelPriceParams input the
callers supply (keeps node-sdk hermetic), threads the result into getModelPrice, and:
- multiplies
seconds-billed prices by extracted duration (the calculator returns the already-multipliedunit_pricefor per-second classes, soestimated_coststaysunit_price × quantity— fan-out still applies on top: 2 clips × 5 s each), - copies
breakdown/assumptions/warnings/declinedonto the item, - applies the existing
isVagueBillingUnitguard to thegetModelPricepath too (today it only guards FAL metadata — a"units"-billed GenSpend fallback is currently summed; that’s a bug).
4.4 Protocol (additive)
NodeCostEstimate in packages/protocol/src/creative.ts gains optional
breakdown?: string, assumptions?: string[], warnings?: string[], and estimated_cost gains
a documented “lower bound when warnings is non-empty” semantic. CostConfidence stays as-is —
“estimate” covers parameterized figures; declined entries are “unknown” with the reason in
assumptions.
5. Phase 3 — wire the callers (and fix the data-shape bug)
Files: web/src/hooks/useWorkflowCostEstimate.ts, web/src/utils/aiModelNodes.ts,
packages/websocket/src/unified-websocket-runner.ts,
packages/websocket/src/sdk/sdk-static-preflight-service.ts, tests.
5.1 The bug: node.data vs node.data.properties
Editor nodes store property values under data.properties (web/src/stores/NodeData.ts:25-27),
but useWorkflowCostEstimate.ts:58,65 passes node.data straight through — so selectedModel
and nodeExpectedQuantity read the top level and find nothing. In the live editor, generic
model-picker nodes almost certainly price as “unknown” and every quantity is 1; the hook’s unit
test passes because its fixture is flat. Fix first, with a test whose fixture uses the real
nested shape (reproduce the failure before enforcing the fix — watch the current code go red
against the nested fixture). The server preflight already handles both shapes
(sdk-static-preflight-service.ts:373-378) and is the pattern to follow.
5.2 Thread params
- Web hook: build
getParamsfromextractPricingParams(node.data.properties)and pass it toestimateWorkflowCost. The hook already subscribes to theNodeStoreviauseSyncExternalStore, so adurationedit re-prices on the next store tick — verify a property edit actually bumps the subscribed version, and re-render cost is bounded by the existing memoized estimate. - Runner:
estimateRunCost/estimateNotetoolSpend/ preflight pass the same extractor over the graph’s node data. The budget gate stays a floor by policy — declines contribute 0 to the reserved amount exactly as unknowns do today, and per-second pricing only raises the gate, never lowers it (base-spec fallback is by construction ≥ the old collapsed-cheaper number for same-model grids).
6. Phase 4 — the panel
File: web/src/components/costs/WorkflowCostEstimatePanel.tsx (+ its test).
- Qty → Units. The quantity column becomes a units cell rendered from the item:
2 × 5 s @ 720p,4 images,1 generation. Fan-out and duration read as one phrase. - Breakdown is the trust surface. A row with
breakdownshows it in the row’s tooltip/expand (the panel already has per-row tooltips for unknowns);assumptionsrender as a muted sub-line (“resolution not set — priced at 720p”). - Lower bounds are labeled. Any item with
warnings(unpriced surcharge) renders its cost as≥ $Xand the panel total as≥ $Ywhen any contributing item is a lower bound — “at least”, never a false exact. - Declines are actionable unknowns. A declined item keeps the warning icon but the tooltip
says why (“no published price at 1080p for this provider — try 720p”), which is the reason
GenSpend returns
nullinstead of extrapolating. - Staleness stays visible. The attribution line already renders
updatedAt; keep it, and addcatalogGeneratedAtto the tooltip. - All rendering through
ui_primitivesand design tokens per repo rules; no raw MUI.
Out of scope here but noted for a follow-up: per-node price chips (FalPricingFooter,
KieCreditsFooter) could show the parameterized figure for GenSpend-priced nodes; today they are
FAL/kie-only.
7. Test plan
Per repo rules: every new check is proven able to fail once; the parity gate must also assert it found and priced cases (an audit that matches nothing passes vacuously).
| Layer | Test |
|---|---|
| Sync | genspend-sync.test.ts: v3 entries carry variants/surcharges/flags from a fixture export; base-spec pick; quote_wrong gating; cosmetic flags dropped; empty-variants entries identical to v2 shape; unchanged-run produces no diff |
| Calculator | packages/model-pricing/tests/: ladder narrowing, decline on stated-but-missing rung, base-spec on unset resolution, seconds multiplication, clip envelope decline, audio axis, additive refs with allowance, re-rate replace + scoped decline, per_request excluded, 768p → 720p tier parse, unrecognized resolution → assumption not guess |
| Parity | scripts/genspend/parity-check.mjs vs /api/v1/quote, nightly workflow only; seeded with GenSpend’s documented figures (seedance-2 720p/5s = $0.56, minimax-h3 2K/6s + 8 refs = $1.02, kie re-rate 5s+5s = $1.25) |
| Estimator | packages/node-sdk/tests/cost-estimate.test.ts: params threading, vague-unit guard on the model path, breakdown/assumption propagation, fan-out × duration composition |
| Web hook | nested data.properties fixture (red on current code first), duration edit re-prices |
| Panel | units cell, ≥ rendering, decline tooltip, assumptions sub-line |
| Budget gate | application-budget-gate.test.ts: per-second model with duration raises the reserved amount; decline reserves 0 (floor semantics unchanged) |
Post-change: npm run typecheck && npm run lint && npm run test, plus every workspace
nodetool affected names.
8. Sequencing and risk
Four PRs, each shippable alone:
- Sync v3 (schema +
/export+ parity gate). No behavior change downstream — new fields are dead weight until Phase 2. Riskiest diff is the generated JSON; the nightly PR review checklist covers it. - Calculator + estimator (params accepted, unused by callers). Pure packages, dense tests.
- Callers + bug fix. The
data.propertiesfix alone changes live numbers (nodes that priced as “unknown” start pricing) — call that out in the PR since the budget gate tightens. - Panel.
Risks worth naming:
- The catalog is a snapshot; GenSpend corrects continuously. Mitigated exactly as they advise:
nightly re-fetch with
If-None-Match,updatedAtvisible in the UI, parity gate on every sync. - Our port can drift from their calculator. The parity gate is the control; if it becomes a
maintenance burden, the fallback GenSpend offers is vendoring their
calc.ts+apply-surcharges.ts— keep our calculator’s interface narrow enough that swapping the implementation is contained togenspend-calc.ts. - Property-name census is heuristic. A node whose duration property we don’t recognize prices at one second with an assumption line — visible, not silent. The census list is data, easy to extend.
- Tightened budget gate can refuse runs it used to admit. That is the point, but it lands in PR 3 with its own callout; the gate’s fail-open error handling is untouched.
9. Explicitly not in this plan
- Calling
POST /api/v1/quotefrom the product at edit time (a keystroke stream describing what a user builds leaves the machine; GenSpend themselves recommend local compute for local-first products). A “why is this the price?” affordance that makes one deliberate/quotecall for the breakdown string is a reasonable follow-up. - Parameterizing the FAL/kie catalog tiers (same defect, different generators — follow-up).
- LLM token pricing (
per-1m-tokensentries; prompt length is unknowable pre-run). - Reading reference image/video counts from node inputs (shapes ship in the calculator; the extractor can learn them later).