Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions packages/model-pricing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ Looked up in order, first hit wins:
1. **GenSpend, when its entry is parameter-priceable** —
`src/generated/genspend-pricing.json`, keyed `<provider_id>:<model_id>`. An
entry qualifies when it publishes a grid (a resolution ladder, a duration
rung, an audio axis, an input surcharge) or bills per second. Those rows say
what a *rung* costs, so they price the run rather than one unit of it.
rung, an audio axis, an input surcharge), or bills per second, per character,
or per token. Those rows say what a *rung* costs, so they price the run
rather than one unit of it.
2. **FAL** — `@nodetool-ai/fal-nodes/unit-pricing-catalog`, keyed by `endpoint_id`.
3. **kie** — `@nodetool-ai/kie-nodes/unit-pricing-catalog`, keyed by `model_id`,
using the USD conversion (a raw credit figure has no fixed USD value).
Expand All @@ -30,9 +31,9 @@ FAL and kie come from the providers themselves, but each carries a single scalar
per endpoint, and for the 260 FAL rows billed per second that scalar is a rate:
reported as the price of a run it understated a 4-second clip by 40×. So a
published GenSpend grid wins, and a FAL/kie scalar is converted here — multiplied
by the duration or output size the node states, and declined outright for a unit
with no fixed value per run (credits) or a rate the node states nothing about
(compute seconds, training steps).
by the duration, output size, or text length the node states, and declined
outright for a unit with no fixed value per run (credits) or a rate the node
states nothing about (compute seconds, training steps).

GenSpend covers every other provider it tracks and NodeTool can run — today
that is Replicate, AtlasCloud, Together, Gemini, OpenAI, MiniMax, and
Expand All @@ -44,6 +45,16 @@ the sync's model inventory (`scripts/genspend/inventory.mjs`) but are not yet
in `PROVIDER_IDS_BY_GENSPEND_SLUG`, so none of the five has priced entries
either.

## Speech models: characters, not runs

The 22 text-to-speech rows are billed `1m_chars` — ElevenLabs' $100 per million.
That is a rate, and as a per-run figure it read as $100 to voice one line, so
these rows are multiplied by the `characters` a caller states and decline when
none was given. The six rows billed `1m_tokens` decline outright: a speech
model's tokens are the audio it produced, and no text length converts into them.
Passing off the block price as a run's cost is the failure mode both rules
exist to prevent.

All three are imported as modules, not read off disk, so the estimate works
identically in the browser bundle and inside the packaged Electron backend (no
`PACKAGE_RUNTIME_ASSETS` entry needed).
Expand Down
65 changes: 64 additions & 1 deletion packages/model-pricing/src/genspend-calc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export { formatUsd };
/** The same shape with its `readonly` modifiers dropped, for step-by-step construction. */
type Mutable<T> = { -readonly [K in keyof T]: T[K] };

/** A price under construction: the fields are filled in as the rules decide them. */
type PriceFields = Mutable<ModelParamPrice>;

/**
* A price computed from stated parameters, with the reasoning that produced it.
* `unit_price` is the whole per-run figure — a per-second class comes back
Expand All @@ -58,6 +61,8 @@ export interface ModelParamPrice extends ModelUnitPricingLike {
seconds?: number;
/** The rung the figure was billed at, in the catalog's own spelling. */
resolution?: string;
/** The text length the figure was billed for, when one applied. */
characters?: number;
/** The figure came off a published grid row, not a flat rate. */
fromGrid?: boolean;
}
Expand Down Expand Up @@ -113,6 +118,24 @@ export function normalizeResolution(value: string | undefined): string | null {

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

/** Speech classes billing a block of characters, and the block's size. */
const CHARACTER_CLASSES = new Map<string, number>([
["per-1k-chars", 1_000],
["per-1m-chars", 1_000_000]
]);

/**
* Classes billing the model's own tokens. A speech model's token count is the
* audio it produced, not the text it was given, so nothing a caller states
* converts it.
*/
const TOKEN_CLASSES = new Set(["per-1k-tokens", "per-1m-tokens"]);

/** How a block of `size` characters is named in a breakdown. */
function characterBlockLabel(size: number): string {
return size >= 1_000_000 ? `${size / 1_000_000}M chars` : `${size / 1_000}K chars`;
}

/**
* The megapixel rungs image grids are published at. `1K` is GenSpend's name for
* a one-megapixel deliverable, which `normalizeResolution` folds to `1MP`.
Expand Down Expand Up @@ -165,6 +188,11 @@ export function isParameterPriceable(entry: GenspendPrice): boolean {
return true;
}
if (PER_SECOND_CLASSES.has(entry.unit_class)) return true;
// A character or token class is a rate, not a run price. Routing it here is
// what multiplies it by the text length — or declines it — instead of
// letting the flat-scalar path report ElevenLabs' $100/M as $100 a line.
if (CHARACTER_CLASSES.has(entry.unit_class)) return true;
if (TOKEN_CLASSES.has(entry.unit_class)) return true;
return (entry.surcharges ?? []).length > 0;
}

Expand Down Expand Up @@ -350,6 +378,42 @@ export function priceGenspendEntry(
let breakdown: string;
let seconds = statedSeconds;

const charBlock = CHARACTER_CLASSES.get(unitClass);
if (charBlock !== undefined) {
const stated = params.characters;
const characters =
stated !== undefined && Number.isFinite(stated) && stated > 0
? stated
: null;
if (characters === null) {
return declined(
`this model is priced per ${characterBlockLabel(
charBlock
)}, and no text length was given`
);
}
const result: PriceFields = {
unit_price: (unitPrice * characters) / charBlock,
billing_unit: entry.billing_unit,
currency: GENSPEND_CURRENCY,
source: "bundle",
breakdown: `${characters} chars × ${money(unitPrice)}/${characterBlockLabel(
charBlock
)}`,
characters,
fromGrid: row !== null
};
if (assumptions.length > 0) result.assumptions = assumptions;
if (warnings.length > 0) result.warnings = warnings;
return result;
}

if (TOKEN_CLASSES.has(unitClass)) {
return declined(
"this model is priced per token of generated audio, which nothing stated converts"
);
}

if (PER_SECOND_CLASSES.has(unitClass)) {
if (seconds === null) {
if (clip === null)
Expand Down Expand Up @@ -433,7 +497,6 @@ export function priceGenspendEntry(
);
}

type PriceFields = Mutable<ModelParamPrice>;
const price: PriceFields = {
unit_price: cost,
billing_unit: entry.billing_unit,
Expand Down
59 changes: 59 additions & 0 deletions packages/model-pricing/tests/model-pricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,65 @@ describe("getModelUnitPrice", () => {
expect(getModelUnitPrice(model)?.assumptions?.length).toBe(1);
});

it("prices a speech model by the characters it will synthesize", () => {
// ElevenLabs publishes $100 per million characters. Read as a per-run
// figure that made one line of dialogue cost $100.
const model = { id: "eleven_multilingual_v2", provider: "elevenlabs" };
const entry = genspendPricingCatalog.prices["elevenlabs:eleven_multilingual_v2"];
expect(entry?.unit_class).toBe("per-1m-chars");

const price = getModelUnitPrice(model, { characters: 200 });
expect(price?.declined).toBeUndefined();
expect(price?.unit_price).toBeCloseTo((entry.unit_price * 200) / 1_000_000, 12);
expect(price?.breakdown).toContain("200 chars");
expect(price?.characters).toBe(200);
});

it("declines a character-billed model when no text length is given", () => {
const price = getModelUnitPrice({
id: "eleven_multilingual_v2",
provider: "elevenlabs"
});
expect(price?.declined).toContain("no text length");
// The block price must never reach a caller as the price of a run.
expect(price?.unit_price).toBe(0);
});

it("prices a character-billed model that also publishes a variant grid", () => {
// This row is parameter-priceable (it carries a tier variant), so it takes
// the GenSpend calculator's path rather than the flat-scalar one.
const id = "fal-ai/elevenlabs/tts/turbo-v2.5";
const entry = genspendPricingCatalog.prices[`fal_ai:${id}`];
expect(isParameterPriceable(entry)).toBe(true);
const price = getModelUnitPrice({ id, provider: "fal_ai" }, { characters: 1000 });
expect(price?.unit_price).toBeCloseTo((entry.unit_price * 1000) / 1_000_000, 12);
});

it("declines a speech model billed per token of generated audio", () => {
// A script's text says nothing about how many audio tokens come out, so
// there is no honest conversion — $12 per million must not read as $12.
const price = getModelUnitPrice(
{ id: "gpt-4o-mini-tts", provider: "openai" },
{ characters: 200 }
);
expect(price?.declined).toContain("token");
expect(price?.unit_price).toBe(0);
});

it("leaves image and video pricing untouched", () => {
expect(
getModelUnitPrice(
{ id: "fal-ai/flux/schnell", provider: "fal_ai" },
{ resolution: "1K", megapixels: 1 }
)?.unit_price
).toBeGreaterThan(0);
const clip = getModelUnitPrice(
{ id: "fal-ai/kling-video/v2.5-turbo/pro/image-to-video", provider: "fal_ai" },
{ resolution: "1080p", seconds: 5 }
);
expect(clip?.breakdown).toContain("5 s ×");
});

it("warns that a collapsed kie row is the cheapest of its tiers", () => {
const entry = Object.entries(kieUnitPricingCatalog.prices ?? {}).find(
([id, row]) =>
Expand Down
79 changes: 78 additions & 1 deletion packages/node-sdk/src/cost-estimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export interface ModelPriceParams {
/** Duration of a video fed in, which some providers bill instead of output. */
referenceVideoSeconds?: number;
megapixels?: number;
/** Characters of text a speech job will synthesize. */
characters?: number;
}

/**
Expand All @@ -107,6 +109,8 @@ export interface ModelParamPricingLike extends ModelUnitPricingLike {
seconds?: number;
/** The rung the figure was billed at, in the catalog's own spelling. */
resolution?: string;
/** The text length the figure was billed for, when one applied. */
characters?: number;
/**
* The figure came off a published grid row, so it moves with the resolution,
* duration and audio state the node states. A flat rate leaves it unset.
Expand Down Expand Up @@ -159,9 +163,39 @@ const UNSTATED_RATE_UNITS = new Set([
"steps",
"train unit",
"train units",
"1m tokens"
"1m tokens",
// GenSpend's own spelling. A speech model billed per token bills the audio
// tokens it produced, which no caller states — pricing it off the text would
// be a guess dressed as a quote.
"1m_tokens",
"1k_tokens"
]);

/**
* Units billing a block of synthesized characters, and how many characters one
* unit price covers. Speech models publish this as `1m_chars` (GenSpend) or as
* a block spelling the {@link BLOCK_UNIT} branch reads (`"1000 characters"`).
* Left unconverted, ElevenLabs' $100-per-million read as $100 a line.
*/
const CHARACTER_BLOCK_UNITS = new Map<string, number>([
["character", 1],
["characters", 1],
["char", 1],
["chars", 1],
["1k_chars", 1_000],
["1m_chars", 1_000_000]
]);

/** Nouns the block branch prices as characters: `"1000 characters"`. */
const CHARACTER_NOUNS = new Set(["character", "characters", "char", "chars"]);

/** How a block of `size` characters is named in a breakdown. */
function characterBlockLabel(size: number): string {
if (size >= 1_000_000) return `${size / 1_000_000}M chars`;
if (size >= 1_000) return `${size / 1_000}K chars`;
return size === 1 ? "char" : `${size} chars`;
}

/** `"5 seconds"`, `"30 seconds"`, `"16 frames"` — a block of N somethings. */
const BLOCK_UNIT = /^(\d+(?:\.\d+)?) (.+)$/;

Expand All @@ -186,6 +220,34 @@ export interface ScalarPriceOptions {
tierCount?: number;
}

/**
* A character-billed scalar times the text the job will synthesize.
*
* Declines when no text length was stated: a per-character rate says nothing
* about a run on its own, and reporting the block price as the run's cost
* overstates one line of dialogue by four or five orders of magnitude.
*/
function priceCharacters(
price: ModelUnitPricingLike,
blockSize: number,
characters: number | null,
decline: (reason: string) => ModelParamPricingLike,
attach: (result: ModelParamPricingLike) => ModelParamPricingLike
): ModelParamPricingLike {
const label = characterBlockLabel(blockSize);
if (characters === null) {
return decline(
`the catalog prices this model per ${label}, and no text length was given`
);
}
return attach({
...price,
unit_price: (price.unit_price * characters) / blockSize,
breakdown: `${characters} chars × ${formatUsd(price.unit_price)}/${label}`,
characters
});
}

/**
* Turn a catalog scalar into a per-run figure for the stated job.
*
Expand Down Expand Up @@ -310,10 +372,25 @@ export function priceScalarUnit(
});
}

const characters =
params?.characters !== undefined &&
Number.isFinite(params.characters) &&
params.characters > 0
? params.characters
: null;

const charBlock = CHARACTER_BLOCK_UNITS.get(unit);
if (charBlock !== undefined) {
return priceCharacters(price, charBlock, characters, decline, attach);
}

const block = BLOCK_UNIT.exec(unit);
if (block) {
const size = Number(block[1]);
const noun = block[2];
if (CHARACTER_NOUNS.has(noun) && size > 0) {
return priceCharacters(price, size, characters, decline, attach);
}
if (PER_SECOND_UNITS.has(noun) && size > 0) {
if (seconds === null) {
assumptions.push(
Expand Down
13 changes: 10 additions & 3 deletions packages/runtime/tests/providers/manifest-models-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,10 +298,17 @@ describe("loadManifest IO (via loadImageModels)", () => {
it("keys the cache by package+path (no cross-package bleed)", () => {
// A real package yields a non-empty catalog; a different key must not
// return that cached catalog.
//
// The manifest is this package's own. `@nodetool-ai/fal-nodes` is the
// larger catalog, but it *depends on* runtime, so no dependency edge can
// order its build before this test — turbo's `test` dependsOn `^build`
// builds a package's dependencies, never its dependents. On a `--affected`
// CI run that reached runtime, `fal-manifest.json` was simply not on disk
// yet and the catalog came back empty.
const real = loadImageModels(
"@nodetool-ai/fal-nodes",
"fal-manifest.json",
"fal"
"@nodetool-ai/runtime",
"providers/aki-manifest.json",
"aki"
);
expect(real.length).toBeGreaterThan(0);
expect(loadImageModels(uniq(), "x.json", "p")).toEqual([]);
Expand Down
Loading
Loading