Skip to content

Commit 9eb0f59

Browse files
authored
fix: treat vague fal billing units as unknown pricing (#4730)
1 parent 15bd7a8 commit 9eb0f59

7 files changed

Lines changed: 95 additions & 2 deletions

File tree

packages/fal-nodes/src/fal-cost.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,14 @@ function inferQuantity(
159159
return 1;
160160
}
161161

162+
function isVagueBillingUnit(unit: string): boolean {
163+
return /\bunits?\b|\bcredits?\b/i.test(unit.trim());
164+
}
165+
162166
/**
163167
* Estimate the cost of a completed FAL call. Returns `null` when no pricing is
164-
* known for the node type.
168+
* known for the node type, or when the billing unit is vague (e.g. "units" or
169+
* "credits") and the unit_price doesn't represent a meaningful per-output cost.
165170
*/
166171
export function estimateFalCost(
167172
nodeType: string,
@@ -174,6 +179,8 @@ export function estimateFalCost(
174179
const unitPrice = finiteNumber(pricing.unit_price);
175180
if (unitPrice == null) return null;
176181

182+
if (isVagueBillingUnit(pricing.billing_unit)) return null;
183+
177184
const quantity = inferQuantity(pricing.billing_unit, res, args);
178185
const cost = unitPrice * quantity;
179186

packages/fal-nodes/tests/fal-cost.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ describe("estimateFalCost", () => {
1818
expect(estimateFalCost("fal.nope.DoesNotExist", {})).toBeNull();
1919
});
2020

21+
it("returns null for vague billing units (units/credits)", () => {
22+
// fal.3d_to_3d.Sam33DAlign has billing_unit: "units"
23+
const pricing = getFalPricing("fal.3d_to_3d.Sam33DAlign");
24+
expect(pricing).not.toBeNull();
25+
expect(pricing!.billing_unit).toBe("units");
26+
expect(estimateFalCost("fal.3d_to_3d.Sam33DAlign", {})).toBeNull();
27+
});
28+
2129
it("treats per-generation calls as a single unit", () => {
2230
const pricing = getFalPricing(GENERATIONS)!;
2331
expect(pricing.billing_unit).toBe("generations");

packages/node-sdk/src/cost-estimate.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ interface ResolvedPrice {
125125
confidence: CostConfidence;
126126
}
127127

128+
function isVagueBillingUnit(unit: string): boolean {
129+
return /\bunits?\b|\bcredits?\b/i.test(unit.trim());
130+
}
131+
128132
/**
129133
* The model selected on a generic node, read from the value of its first
130134
* provider-model property (e.g. `model` on TextToImage). Returns null when the
@@ -172,6 +176,9 @@ function resolvePrice(
172176
): ResolvedPrice | null {
173177
const fal = metadata?.fal_unit_pricing;
174178
if (fal && Number.isFinite(fal.unit_price)) {
179+
if (isVagueBillingUnit(fal.billing_unit)) {
180+
return null;
181+
}
175182
return {
176183
provider: "fal",
177184
model: fal.endpoint_id ?? null,

packages/node-sdk/tests/cost-estimate.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "../src/cost-estimate.js";
77

88
const FAL_TYPE = "fal.image.FluxSchnell";
9+
const FAL_VAGUE_TYPE = "fal.text_to_image.GptImage2";
910
const KIE_TYPE = "kie.video.Veo";
1011
const LLM_TYPE = "nodetool.agents.Agent";
1112
const GENERIC_TYPE = "nodetool.image.TextToImage";
@@ -20,6 +21,15 @@ const metadataByType: Record<string, NodeMetadataLike> = {
2021
source: "bundle"
2122
}
2223
},
24+
[FAL_VAGUE_TYPE]: {
25+
fal_unit_pricing: {
26+
endpoint_id: "openai/gpt-image-2",
27+
unit_price: 1,
28+
billing_unit: "units",
29+
currency: "USD",
30+
source: "bundle"
31+
}
32+
},
2333
[KIE_TYPE]: {
2434
kie_unit_pricing: {
2535
model_id: "veo3",
@@ -90,6 +100,21 @@ describe("estimateWorkflowCost", () => {
90100
expect(estimate.total).toBeCloseTo(2.5, 10);
91101
});
92102

103+
it("treats fal nodes with vague billing (units/credits) as unknown", () => {
104+
const estimate = estimateWorkflowCost({
105+
nodes: [{ id: "v1", type: FAL_VAGUE_TYPE }],
106+
getMetadata
107+
});
108+
109+
expect(estimate.unknown_count).toBe(1);
110+
expect(estimate.total).toBe(0);
111+
112+
const item = estimate.items[0];
113+
expect(item.confidence).toBe("unknown");
114+
expect(item.estimated_cost).toBe(0);
115+
expect(item.provider).toBeNull();
116+
});
117+
93118
it("reports unpriced nodes as unknown without affecting the total", () => {
94119
const estimate = estimateWorkflowCost({
95120
nodes: [

web/src/utils/__tests__/formatFalUnitPricing.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ describe("isFalVagueBillingSummary", () => {
1616
expect(isFalVagueBillingSummary({ billing_unit: "Units" })).toBe(true);
1717
});
1818

19+
it('returns true for "credit" and "credits"', () => {
20+
expect(isFalVagueBillingSummary({ billing_unit: "credit" })).toBe(true);
21+
expect(isFalVagueBillingSummary({ billing_unit: "credits" })).toBe(true);
22+
expect(isFalVagueBillingSummary({ billing_unit: "Credits" })).toBe(true);
23+
});
24+
1925
it("returns false for specific billing units", () => {
2026
expect(isFalVagueBillingSummary({ billing_unit: "image" })).toBe(false);
2127
expect(isFalVagueBillingSummary({ billing_unit: "second" })).toBe(false);
@@ -24,6 +30,7 @@ describe("isFalVagueBillingSummary", () => {
2430

2531
it("handles whitespace-padded values", () => {
2632
expect(isFalVagueBillingSummary({ billing_unit: " units " })).toBe(true);
33+
expect(isFalVagueBillingSummary({ billing_unit: " credits " })).toBe(true);
2734
});
2835
});
2936

@@ -60,6 +67,26 @@ describe("formatFalUnitPricingShort", () => {
6067
const result = formatFalUnitPricingShort(p);
6168
expect(result).toContain("0.00");
6269
});
70+
71+
it('returns "pricing varies" for vague billing units', () => {
72+
const p = {
73+
unit_price: 1,
74+
currency: "USD",
75+
billing_unit: "units",
76+
endpoint_id: "openai/gpt-image-2",
77+
} as FalUnitPricing;
78+
expect(formatFalUnitPricingShort(p)).toBe("pricing varies");
79+
});
80+
81+
it('returns "pricing varies" for credits billing', () => {
82+
const p = {
83+
unit_price: 1,
84+
currency: "USD",
85+
billing_unit: "credits",
86+
endpoint_id: "fal-ai/gpt-image-1-mini",
87+
} as FalUnitPricing;
88+
expect(formatFalUnitPricingShort(p)).toBe("pricing varies");
89+
});
6390
});
6491

6592
describe("formatFalUnitPricingTooltip", () => {

web/src/utils/formatFalUnitPricing.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ describe("isFalVagueBillingSummary", () => {
3131

3232
it("is case-insensitive", () => {
3333
expect(isFalVagueBillingSummary({ billing_unit: "Units" })).toBe(true);
34+
expect(isFalVagueBillingSummary({ billing_unit: "Credits" })).toBe(true);
35+
});
36+
37+
it("returns true when billing_unit is 'credits'", () => {
38+
expect(isFalVagueBillingSummary({ billing_unit: "credits" })).toBe(true);
39+
expect(isFalVagueBillingSummary({ billing_unit: "credit" })).toBe(true);
3440
});
3541

3642
it("returns false for 'megapixel'", () => {
@@ -63,6 +69,16 @@ describe("formatFalUnitPricingShort", () => {
6369
const result = formatFalUnitPricingShort(makePricing({ unit_price: 0 }));
6470
expect(result).toContain("$");
6571
});
72+
73+
it('returns "pricing varies" for vague billing (units)', () => {
74+
const result = formatFalUnitPricingShort(makePricing({ billing_unit: "units", unit_price: 1 }));
75+
expect(result).toBe("pricing varies");
76+
});
77+
78+
it('returns "pricing varies" for vague billing (credits)', () => {
79+
const result = formatFalUnitPricingShort(makePricing({ billing_unit: "credits", unit_price: 1 }));
80+
expect(result).toBe("pricing varies");
81+
});
6682
});
6783

6884
describe("formatFalUnitPricingTooltip", () => {

web/src/utils/formatFalUnitPricing.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ function formatMoney(amount: number, currency: string): string {
3030
* proportional billing may diverge strongly from what users expect one image/run to cost.
3131
*/
3232
export function isFalVagueBillingSummary(p: { billing_unit: string }): boolean {
33-
return /\bunits?\b/i.test(p.billing_unit.trim());
33+
return /\bunits?\b|\bcredits?\b/i.test(p.billing_unit.trim());
3434
}
3535

3636
/** Label for historical per-run estimate from fal.ai pricing/estimate API. */
@@ -40,6 +40,9 @@ export function formatFalPerRunEstimate(totalCost: number, currency: string): st
4040

4141
/** Compact label for node chrome (fal monetary `unit_price`). */
4242
export function formatFalUnitPricingShort(p: FalUnitPricing): string {
43+
if (isFalVagueBillingSummary(p)) {
44+
return "pricing varies";
45+
}
4346
return formatMoney(p.unit_price, p.currency);
4447
}
4548

0 commit comments

Comments
 (0)