Skip to content

Commit dc07347

Browse files
authored
Merge pull request #5379 from nodetool-ai/claude/cost-estimation-panel-review-xj5h9c
2 parents 80cd642 + cc4a9ec commit dc07347

37 files changed

Lines changed: 2385 additions & 543 deletions

packages/execution/src/service/workflow-run.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,8 @@ export async function runWorkflow(
559559
// This path builds its own runner instead of going through
560560
// `ExecutionSession`, so it has to attach the spend ledger itself.
561561
// No detach: the listener lives on this context and dies with it.
562+
// No `projectId`/`documentId` either — a run request names a workflow
563+
// and a user, and nothing on this path carries project attribution.
562564
attachRunCostLedger(executionContext, {
563565
userId,
564566
workflowId,

packages/execution/src/session.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ export class ExecutionSession {
8080
// reads. Attached here, at the one seam all surfaces share, so image and
8181
// video spend is recorded whether the run came from the CLI, the debug
8282
// harness, an app, or the websocket server.
83+
// No `projectId`/`documentId`: a session is constructed from a graph and a
84+
// job id, and no host passes project attribution down to it. The rows carry
85+
// a null rather than being attributed to the loose bucket.
8386
const detachLedger = init.recordCosts
8487
? attachRunCostLedger(init.context, {
8588
userId: init.userId,

packages/fal-codegen/src/fal-pricing-fetch.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ async function fetchBatchOnce(
6161
for (const row of json.prices ?? []) {
6262
out[row.endpoint_id] = {
6363
unit_price: row.unit_price,
64-
billing_unit: row.unit,
64+
// FAL's API pads some unit strings with trailing whitespace
65+
// ("1000 characters ") and leaves others empty; trim rather than
66+
// ship either verbatim.
67+
billing_unit: row.unit.trim(),
6568
currency: row.currency
6669
};
6770
}

packages/fal-codegen/tests/fal-pricing-fetch.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,29 @@ describe("fetchFalPricing", () => {
5757
});
5858
});
5959

60+
it("trims whitespace FAL pads the billing unit with", async () => {
61+
vi.stubGlobal(
62+
"fetch",
63+
makeFetchMock((ids) => ({
64+
ok: true,
65+
status: 200,
66+
jsonBody: {
67+
prices: ids.map((id) => ({
68+
endpoint_id: id,
69+
unit_price: 0.02,
70+
unit: "1000 characters ",
71+
currency: "USD"
72+
}))
73+
}
74+
}))
75+
);
76+
77+
const out = await fetchFalPricing(["fal-ai/minimax/speech-02-turbo"], "key");
78+
expect(out["fal-ai/minimax/speech-02-turbo"].billing_unit).toBe(
79+
"1000 characters"
80+
);
81+
});
82+
6083
it("bisects on 404 so one unknown endpoint does not poison the batch", async () => {
6184
const unknown = new Set(["fal-ai/dead"]);
6285
vi.stubGlobal(

packages/model-pricing/README.md

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,33 @@ getModelUnitPrice({ id: "fal-ai/flux/schnell", provider: "fal_ai" });
1616

1717
Looked up in order, first hit wins:
1818

19-
1. **FAL**`@nodetool-ai/fal-nodes/unit-pricing-catalog`, keyed by `endpoint_id`.
20-
2. **kie**`@nodetool-ai/kie-nodes/unit-pricing-catalog`, keyed by `model_id`,
19+
1. **GenSpend, when its entry is parameter-priceable**
20+
`src/generated/genspend-pricing.json`, keyed `<provider_id>:<model_id>`. An
21+
entry qualifies when it publishes a grid (a resolution ladder, a duration
22+
rung, an audio axis, an input surcharge) or bills per second. Those rows say
23+
what a *rung* costs, so they price the run rather than one unit of it.
24+
2. **FAL**`@nodetool-ai/fal-nodes/unit-pricing-catalog`, keyed by `endpoint_id`.
25+
3. **kie**`@nodetool-ai/kie-nodes/unit-pricing-catalog`, keyed by `model_id`,
2126
using the USD conversion (a raw credit figure has no fixed USD value).
22-
3. **GenSpend**`src/generated/genspend-pricing.json`, keyed
23-
`<provider_id>:<model_id>`.
24-
25-
FAL and kie come from the providers themselves, so they stay ahead of GenSpend.
26-
GenSpend covers every other provider NodeTool can run and GenSpend tracks —
27-
Replicate, AtlasCloud, Together, Gemini, OpenAI, MiniMax, ElevenLabs, xAI — plus
28-
any FAL or kie model their own catalogs predate.
27+
4. **GenSpend's flat scalar** — one number per generation, nothing to narrow.
28+
29+
FAL and kie come from the providers themselves, but each carries a single scalar
30+
per endpoint, and for the 260 FAL rows billed per second that scalar is a rate:
31+
reported as the price of a run it understated a 4-second clip by 40×. So a
32+
published GenSpend grid wins, and a FAL/kie scalar is converted here — multiplied
33+
by the duration or output size the node states, and declined outright for a unit
34+
with no fixed value per run (credits) or a rate the node states nothing about
35+
(compute seconds, training steps).
36+
37+
GenSpend covers every other provider it tracks and NodeTool can run — today
38+
that is Replicate, AtlasCloud, Together, Gemini, OpenAI, MiniMax, and
39+
ElevenLabs — plus any FAL or kie model their own catalogs predate. xAI is wired
40+
into `scripts/genspend/match.mjs`'s `PROVIDER_IDS_BY_GENSPEND_SLUG`, but the
41+
shipped catalog holds no `xai:` entries: GenSpend's own catalog has nothing to
42+
match against that slug. Topaz, Reve, Aki, Meshy, and Rodin are enumerated by
43+
the sync's model inventory (`scripts/genspend/inventory.mjs`) but are not yet
44+
in `PROVIDER_IDS_BY_GENSPEND_SLUG`, so none of the five has priced entries
45+
either.
2946

3047
All three are imported as modules, not read off disk, so the estimate works
3148
identically in the browser bundle and inside the packaged Electron backend (no

packages/model-pricing/src/genspend-calc.ts

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export interface ModelParamPrice extends ModelUnitPricingLike {
5151
warnings?: string[];
5252
/** Set instead of a price when we refuse to extrapolate. */
5353
declined?: string;
54+
/** The duration the figure was billed for, when one applied. */
55+
seconds?: number;
56+
/** The rung the figure was billed at, in the catalog's own spelling. */
57+
resolution?: string;
5458
}
5559

5660
/**
@@ -104,10 +108,75 @@ export function normalizeResolution(value: string | undefined): string | null {
104108

105109
const PER_SECOND_CLASSES = new Set(["per-video-second", "per-audio-second"]);
106110

107-
const money = (usd: number): string =>
108-
usd >= 0.01
109-
? `$${usd.toFixed(3).replace(/0+$/, "").replace(/\.$/, "")}`
110-
: `$${usd}`;
111+
/**
112+
* The megapixel rungs image grids are published at. `1K` is GenSpend's name for
113+
* a one-megapixel deliverable, which `normalizeResolution` folds to `1MP`.
114+
*/
115+
const MEGAPIXEL_TIERS: Array<{ megapixels: number; tier: string }> = [
116+
{ megapixels: 0.25, tier: "512×512" },
117+
{ megapixels: 1, tier: "1MP" },
118+
{ megapixels: 4, tier: "2K" },
119+
{ megapixels: 16, tier: "4K" }
120+
];
121+
122+
/** The rung a megapixel count sits closest to, inside a 1.5× band. */
123+
function tierForMegapixels(megapixels: number): string | null {
124+
if (!Number.isFinite(megapixels) || megapixels <= 0) return null;
125+
let best = MEGAPIXEL_TIERS[0];
126+
let bestRatio = Infinity;
127+
for (const candidate of MEGAPIXEL_TIERS) {
128+
const ratio =
129+
megapixels > candidate.megapixels
130+
? megapixels / candidate.megapixels
131+
: candidate.megapixels / megapixels;
132+
if (ratio < bestRatio) {
133+
bestRatio = ratio;
134+
best = candidate;
135+
}
136+
}
137+
return bestRatio <= 1.5 ? best.tier : null;
138+
}
139+
140+
const trimZeros = (text: string): string =>
141+
text.includes(".") ? text.replace(/0+$/, "").replace(/\.$/, "") : text;
142+
143+
/**
144+
* A price as text. Sub-cent figures get as many decimal places as they need:
145+
* `String(3e-7)` is `3e-7`, and a breakdown reading "$3e-7/s" is unreadable.
146+
*/
147+
export const formatUsd = (usd: number): string => {
148+
if (!Number.isFinite(usd)) return "$0";
149+
if (usd >= 0.01) return `$${trimZeros(usd.toFixed(3))}`;
150+
if (usd <= 0) return "$0";
151+
const places = Math.min(12, Math.ceil(-Math.log10(usd)) + 2);
152+
return `$${trimZeros(usd.toFixed(places))}`;
153+
};
154+
155+
const money = formatUsd;
156+
157+
/**
158+
* Whether the catalog can price this entry off stated parameters — a published
159+
* grid, a per-second class the duration multiplies, or an input surcharge.
160+
* A flat per-generation entry with none of those answers the same number the
161+
* provider catalogs carry, so nothing is gained by routing it here.
162+
*/
163+
export function isParameterPriceable(entry: GenspendPrice): boolean {
164+
const rows = entry.variants ?? [];
165+
if (
166+
rows.some(
167+
(row) =>
168+
row.resolution !== undefined ||
169+
row.duration_seconds !== undefined ||
170+
row.with_audio !== undefined ||
171+
row.tier !== undefined ||
172+
row.video_input !== undefined
173+
)
174+
) {
175+
return true;
176+
}
177+
if (PER_SECOND_CLASSES.has(entry.unit_class)) return true;
178+
return (entry.surcharges ?? []).length > 0;
179+
}
111180

112181
/** The shortest duration the model is receipted for, or 1 s when unbounded. */
113182
function shortestClipSecond(clip: GenspendPrice["clip_seconds"]): number {
@@ -232,7 +301,7 @@ export function priceGenspendEntry(
232301
const assumptions: string[] = [];
233302
const warnings: string[] = [];
234303

235-
const resolution =
304+
let resolution =
236305
params.resolution === undefined || params.resolution === null
237306
? null
238307
: normalizeResolution(params.resolution);
@@ -242,6 +311,20 @@ export function priceGenspendEntry(
242311
);
243312
}
244313

314+
// An image grid is laddered by megapixels, and a node that states a pixel
315+
// size states that count without naming a rung. Only read it when the node
316+
// named no resolution and the grid really is laddered.
317+
if (resolution === null && params.megapixels !== undefined) {
318+
const laddered = (entry.variants ?? []).some((row) => row.resolution);
319+
const derived = laddered ? tierForMegapixels(params.megapixels) : null;
320+
if (derived) {
321+
resolution = derived;
322+
assumptions.push(
323+
`priced at the ${derived} rung, from the node's ${params.megapixels} MP output size`
324+
);
325+
}
326+
}
327+
245328
const askedSeconds = params.seconds ?? 0;
246329
const statedSeconds =
247330
Number.isFinite(askedSeconds) && askedSeconds > 0 ? askedSeconds : null;
@@ -368,6 +451,13 @@ export function priceGenspendEntry(
368451
source: "bundle",
369452
breakdown
370453
};
454+
if (seconds !== null) {
455+
price.seconds = seconds;
456+
}
457+
const rungResolution = row?.resolution ?? (resolution ?? undefined);
458+
if (rungResolution !== undefined) {
459+
price.resolution = rungResolution;
460+
}
371461
if (assumptions.length > 0) {
372462
price.assumptions = assumptions;
373463
}

0 commit comments

Comments
 (0)