Skip to content

Commit b7c118c

Browse files
sigmachiralityindentcursoragent
authored
fix: sf nodes extend price calculation (#253)
Co-authored-by: Daniel Tao <danieltaox@gmail.com> Co-authored-by: Indent <noreply@indent.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 7a72664 commit b7c118c

2 files changed

Lines changed: 136 additions & 5 deletions

File tree

src/helpers/test/quote.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, expect, it } from "vitest";
2+
import { GPUS_PER_NODE } from "../../lib/constants.ts";
3+
import { getPricePerGpuHourFromQuote } from "../quote.ts";
4+
5+
function makeQuote(opts: {
6+
priceCents: number;
7+
quantity: number;
8+
durationHours: number;
9+
}) {
10+
const start = new Date("2025-06-01T00:00:00Z");
11+
const end = new Date(start.getTime() + opts.durationHours * 3600 * 1000);
12+
return {
13+
price: opts.priceCents,
14+
quantity: opts.quantity,
15+
start_at: start.toISOString(),
16+
end_at: end.toISOString(),
17+
};
18+
}
19+
20+
function pricePerNodeHourFromQuote(
21+
quote: ReturnType<typeof makeQuote>,
22+
): number {
23+
const pricePerGpuHour = getPricePerGpuHourFromQuote(quote);
24+
return (pricePerGpuHour * GPUS_PER_NODE) / 100;
25+
}
26+
27+
describe("getPricePerGpuHourFromQuote", () => {
28+
it("returns correct per-GPU-hour price for a single node", () => {
29+
// 1 node, 4 hours, $12/node/hr = $48 total = 4800 cents
30+
const quote = makeQuote({
31+
priceCents: 4800,
32+
quantity: 1,
33+
durationHours: 4,
34+
});
35+
const pricePerNodeHour = pricePerNodeHourFromQuote(quote);
36+
expect(pricePerNodeHour).toBeCloseTo(12.0);
37+
});
38+
39+
it("normalizes correctly regardless of quantity in quote", () => {
40+
// 8 nodes, 4 hours, $12/node/hr = $384 total = 38400 cents
41+
const quote = makeQuote({
42+
priceCents: 38400,
43+
quantity: 8,
44+
durationHours: 4,
45+
});
46+
const pricePerNodeHour = pricePerNodeHourFromQuote(quote);
47+
expect(pricePerNodeHour).toBeCloseTo(12.0);
48+
});
49+
});
50+
51+
describe("multi-node total price calculation (extend confirmation)", () => {
52+
it("computes correct total using per-node-hour rates", () => {
53+
// Simulates extending 16 nodes for 4 hours at $12/node/hr
54+
// Each quote is for 1 node (quantity: 1)
55+
const requestedDurationHours = 4;
56+
const nodeCount = 16;
57+
58+
const quotes = Array.from({ length: nodeCount }, () =>
59+
makeQuote({ priceCents: 4800, quantity: 1, durationHours: 4 }),
60+
);
61+
62+
const totalPricePerHour = quotes.reduce((acc, quote) => {
63+
const pricePerGpuHour = getPricePerGpuHourFromQuote(quote);
64+
const pricePerNodeHour = (pricePerGpuHour * GPUS_PER_NODE) / 100;
65+
return acc + pricePerNodeHour;
66+
}, 0);
67+
const totalEstimate = totalPricePerHour * requestedDurationHours;
68+
69+
// 16 nodes * $12/hr * 4 hours = $768
70+
expect(totalEstimate).toBeCloseTo(768);
71+
});
72+
73+
it("handles quotes with longer duration than requested without overestimating", () => {
74+
// Quote returned for 5 hours (due to flexibility), but we only want 4 hours
75+
const requestedDurationHours = 4;
76+
const nodeCount = 16;
77+
78+
// 1 node, 5 hours, $12/node/hr = $60 total = 6000 cents
79+
const quotes = Array.from({ length: nodeCount }, () =>
80+
makeQuote({ priceCents: 6000, quantity: 1, durationHours: 5 }),
81+
);
82+
83+
const totalPricePerHour = quotes.reduce((acc, quote) => {
84+
const pricePerGpuHour = getPricePerGpuHourFromQuote(quote);
85+
const pricePerNodeHour = (pricePerGpuHour * GPUS_PER_NODE) / 100;
86+
return acc + pricePerNodeHour;
87+
}, 0);
88+
const totalEstimate = totalPricePerHour * requestedDurationHours;
89+
90+
// Rate is $12/hr, so 16 * 4 * 12 = $768 (not $960)
91+
expect(totalEstimate).toBeCloseTo(768);
92+
});
93+
94+
it("OLD BUG: raw price sum with quantity=8 would have been 8x too high", () => {
95+
// This test demonstrates the old bug:
96+
// Each quote was requested with quantity=8 (nodes) instead of 1,
97+
// and the total was computed as raw sum of prices / 100
98+
const nodeCount = 16;
99+
100+
// 8 nodes, 4 hours, $12/node/hr = $384 total = 38400 cents per quote
101+
const quotes = Array.from({ length: nodeCount }, () =>
102+
makeQuote({ priceCents: 38400, quantity: 8, durationHours: 4 }),
103+
);
104+
105+
// Old calculation: sum raw prices / 100
106+
const oldTotal = quotes.reduce((acc, q) => acc + q.price, 0) / 100;
107+
// This gave $6,144 (8x the correct $768)
108+
expect(oldTotal).toBeCloseTo(6144);
109+
110+
// With 5-hour quotes (duration flexibility), it would have been ~10x
111+
const quotesWithFlexDuration = Array.from({ length: nodeCount }, () =>
112+
makeQuote({ priceCents: 48000, quantity: 8, durationHours: 5 }),
113+
);
114+
const oldTotalFlex =
115+
quotesWithFlexDuration.reduce((acc, q) => acc + q.price, 0) / 100;
116+
// $7,680 - exactly matching the user's reported bug
117+
expect(oldTotalFlex).toBeCloseTo(7680);
118+
});
119+
});

src/lib/nodes/extend.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ async function extendNodeAction(
195195
extendableNodes.map(async ({ node }) => {
196196
return await getQuote({
197197
instanceType: `${node.gpu_type.toLowerCase()}v` as const,
198-
quantity: 8,
198+
quantity: 1,
199199
minStartTime: node.end_at ? new Date(node.end_at * 1000) : "NOW",
200200
maxStartTime: node.end_at ? new Date(node.end_at * 1000) : "NOW",
201201
minDurationSeconds: minDurationSeconds,
@@ -223,11 +223,23 @@ async function extendNodeAction(
223223
const pricePerNodeHour = (pricePerGpuHour * GPUS_PER_NODE) / 100;
224224
confirmationMessage += ` for ~$${pricePerNodeHour.toFixed(2)}/node/hr`;
225225
} else if (filteredQuotes.length > 1) {
226-
const totalPrice = filteredQuotes.reduce((acc, quote) => {
227-
return acc + (quote.value?.price ?? 0);
226+
const durationHours = options.duration! / 3600;
227+
const pricedQuotes = filteredQuotes.filter((q) => q.value);
228+
const totalPricePerHour = pricedQuotes.reduce((acc, quote) => {
229+
const pricePerGpuHour = getPricePerGpuHourFromQuote(quote.value!);
230+
const pricePerNodeHour = (pricePerGpuHour * GPUS_PER_NODE) / 100;
231+
return acc + pricePerNodeHour;
228232
}, 0);
229-
// If there's multiple nodes, show the total price, as nodes could be on different zones or have different hardware
230-
confirmationMessage += ` for ~$${totalPrice / 100}`;
233+
const totalEstimate = totalPricePerHour * durationHours;
234+
if (pricedQuotes.length < extendableNodes.length) {
235+
// Some nodes had no liquidity quote; flag that the estimate only
236+
// covers the priced subset so the user isn't surprised by a higher bill.
237+
confirmationMessage += ` for ~$${totalEstimate.toFixed(2)} (estimate covers ${pricedQuotes.length} of ${extendableNodes.length} ${pluralizeNodes(
238+
extendableNodes.length,
239+
)}; remaining nodes will extend up to --max-price)`;
240+
} else {
241+
confirmationMessage += ` for ~$${totalEstimate.toFixed(2)}`;
242+
}
231243
} else {
232244
confirmationMessage = chalk.red(
233245
"No nodes available matching your requirements. This is likely due to insufficient capacity. Attempt to extend anyway",

0 commit comments

Comments
 (0)