From 713954d43de23e5e524bed20a8c207b29583ae79 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Feb 2026 04:16:47 +0000 Subject: [PATCH 1/2] Fix node extension price calculation showing ~10x actual cost Two bugs caused the multi-node extend confirmation to display ~10x the correct price: 1. The quote API was called with quantity=8 (GPUs per node) instead of quantity=1 (one node per quote). The API's quantity parameter means 'number of nodes', not GPUs. This inflated each quote's total price by 8x. 2. The multi-node total was computed by summing raw quote prices, which include the full quoted duration. Because the quote request adds flexibility (up to +1 hour), a 4-hour request could yield 5-hour quotes, adding another ~1.25x. Combined: 8 * 1.25 = 10x. Fix: - Change quantity from 8 to 1 in the per-node getQuote call - Normalize multi-node total using per-node-hour rates (via getPricePerGpuHourFromQuote) multiplied by the actual requested duration, matching the single-node code path Co-authored-by: Daniel Tao --- src/helpers/test/quote.test.ts | 119 +++++++++++++++++++++++++++++++++ src/lib/nodes/extend.ts | 14 ++-- 2 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 src/helpers/test/quote.test.ts diff --git a/src/helpers/test/quote.test.ts b/src/helpers/test/quote.test.ts new file mode 100644 index 00000000..ab216f96 --- /dev/null +++ b/src/helpers/test/quote.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { GPUS_PER_NODE } from "../../lib/constants.ts"; +import { getPricePerGpuHourFromQuote } from "../quote.ts"; + +function makeQuote(opts: { + priceCents: number; + quantity: number; + durationHours: number; +}) { + const start = new Date("2025-06-01T00:00:00Z"); + const end = new Date(start.getTime() + opts.durationHours * 3600 * 1000); + return { + price: opts.priceCents, + quantity: opts.quantity, + start_at: start.toISOString(), + end_at: end.toISOString(), + }; +} + +function pricePerNodeHourFromQuote( + quote: ReturnType, +): number { + const pricePerGpuHour = getPricePerGpuHourFromQuote(quote); + return (pricePerGpuHour * GPUS_PER_NODE) / 100; +} + +describe("getPricePerGpuHourFromQuote", () => { + it("returns correct per-GPU-hour price for a single node", () => { + // 1 node, 4 hours, $12/node/hr = $48 total = 4800 cents + const quote = makeQuote({ + priceCents: 4800, + quantity: 1, + durationHours: 4, + }); + const pricePerNodeHour = pricePerNodeHourFromQuote(quote); + expect(pricePerNodeHour).toBeCloseTo(12.0); + }); + + it("normalizes correctly regardless of quantity in quote", () => { + // 8 nodes, 4 hours, $12/node/hr = $384 total = 38400 cents + const quote = makeQuote({ + priceCents: 38400, + quantity: 8, + durationHours: 4, + }); + const pricePerNodeHour = pricePerNodeHourFromQuote(quote); + expect(pricePerNodeHour).toBeCloseTo(12.0); + }); +}); + +describe("multi-node total price calculation (extend confirmation)", () => { + it("computes correct total using per-node-hour rates", () => { + // Simulates extending 16 nodes for 4 hours at $12/node/hr + // Each quote is for 1 node (quantity: 1) + const requestedDurationHours = 4; + const nodeCount = 16; + + const quotes = Array.from({ length: nodeCount }, () => + makeQuote({ priceCents: 4800, quantity: 1, durationHours: 4 }), + ); + + const totalPricePerHour = quotes.reduce((acc, quote) => { + const pricePerGpuHour = getPricePerGpuHourFromQuote(quote); + const pricePerNodeHour = (pricePerGpuHour * GPUS_PER_NODE) / 100; + return acc + pricePerNodeHour; + }, 0); + const totalEstimate = totalPricePerHour * requestedDurationHours; + + // 16 nodes * $12/hr * 4 hours = $768 + expect(totalEstimate).toBeCloseTo(768); + }); + + it("handles quotes with longer duration than requested without overestimating", () => { + // Quote returned for 5 hours (due to flexibility), but we only want 4 hours + const requestedDurationHours = 4; + const nodeCount = 16; + + // 1 node, 5 hours, $12/node/hr = $60 total = 6000 cents + const quotes = Array.from({ length: nodeCount }, () => + makeQuote({ priceCents: 6000, quantity: 1, durationHours: 5 }), + ); + + const totalPricePerHour = quotes.reduce((acc, quote) => { + const pricePerGpuHour = getPricePerGpuHourFromQuote(quote); + const pricePerNodeHour = (pricePerGpuHour * GPUS_PER_NODE) / 100; + return acc + pricePerNodeHour; + }, 0); + const totalEstimate = totalPricePerHour * requestedDurationHours; + + // Rate is $12/hr, so 16 * 4 * 12 = $768 (not $960) + expect(totalEstimate).toBeCloseTo(768); + }); + + it("OLD BUG: raw price sum with quantity=8 would have been 8x too high", () => { + // This test demonstrates the old bug: + // Each quote was requested with quantity=8 (nodes) instead of 1, + // and the total was computed as raw sum of prices / 100 + const nodeCount = 16; + + // 8 nodes, 4 hours, $12/node/hr = $384 total = 38400 cents per quote + const quotes = Array.from({ length: nodeCount }, () => + makeQuote({ priceCents: 38400, quantity: 8, durationHours: 4 }), + ); + + // Old calculation: sum raw prices / 100 + const oldTotal = quotes.reduce((acc, q) => acc + q.price, 0) / 100; + // This gave $6,144 (8x the correct $768) + expect(oldTotal).toBeCloseTo(6144); + + // With 5-hour quotes (duration flexibility), it would have been ~10x + const quotesWithFlexDuration = Array.from({ length: nodeCount }, () => + makeQuote({ priceCents: 48000, quantity: 8, durationHours: 5 }), + ); + const oldTotalFlex = + quotesWithFlexDuration.reduce((acc, q) => acc + q.price, 0) / 100; + // $7,680 - exactly matching the user's reported bug + expect(oldTotalFlex).toBeCloseTo(7680); + }); +}); diff --git a/src/lib/nodes/extend.ts b/src/lib/nodes/extend.ts index 780d8a39..882e6677 100644 --- a/src/lib/nodes/extend.ts +++ b/src/lib/nodes/extend.ts @@ -195,7 +195,7 @@ async function extendNodeAction( extendableNodes.map(async ({ node }) => { return await getQuote({ instanceType: `${node.gpu_type.toLowerCase()}v` as const, - quantity: 8, + quantity: 1, minStartTime: node.end_at ? new Date(node.end_at * 1000) : "NOW", maxStartTime: node.end_at ? new Date(node.end_at * 1000) : "NOW", minDurationSeconds: minDurationSeconds, @@ -223,11 +223,15 @@ async function extendNodeAction( const pricePerNodeHour = (pricePerGpuHour * GPUS_PER_NODE) / 100; confirmationMessage += ` for ~$${pricePerNodeHour.toFixed(2)}/node/hr`; } else if (filteredQuotes.length > 1) { - const totalPrice = filteredQuotes.reduce((acc, quote) => { - return acc + (quote.value?.price ?? 0); + const durationHours = options.duration! / 3600; + const totalPricePerHour = filteredQuotes.reduce((acc, quote) => { + if (!quote.value) return acc; + const pricePerGpuHour = getPricePerGpuHourFromQuote(quote.value); + const pricePerNodeHour = (pricePerGpuHour * GPUS_PER_NODE) / 100; + return acc + pricePerNodeHour; }, 0); - // If there's multiple nodes, show the total price, as nodes could be on different zones or have different hardware - confirmationMessage += ` for ~$${totalPrice / 100}`; + const totalEstimate = totalPricePerHour * durationHours; + confirmationMessage += ` for ~$${totalEstimate.toFixed(0)}`; } else { confirmationMessage = chalk.red( "No nodes available matching your requirements. This is likely due to insufficient capacity. Attempt to extend anyway", From 61cb8e577c1b54cc6c609f0d41187929a7fac3cf Mon Sep 17 00:00:00 2001 From: Daniel Tao Date: Wed, 27 May 2026 00:43:02 +0000 Subject: [PATCH 2/2] Address review: flag partial-quote totals and use 2-decimal precision - Surface "estimate covers N of M nodes" when any per-node getQuote returns null so the displayed total doesn't silently understate the bill when some nodes hit no-liquidity but the extend loop still attempts them up to --max-price. - Switch the multi-node total from .toFixed(0) to .toFixed(2) to match the single-node /node/hr display. Generated with [Indent](https://indent.com) Co-Authored-By: Indent --- src/lib/nodes/extend.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/lib/nodes/extend.ts b/src/lib/nodes/extend.ts index 882e6677..89d705ec 100644 --- a/src/lib/nodes/extend.ts +++ b/src/lib/nodes/extend.ts @@ -224,14 +224,22 @@ async function extendNodeAction( confirmationMessage += ` for ~$${pricePerNodeHour.toFixed(2)}/node/hr`; } else if (filteredQuotes.length > 1) { const durationHours = options.duration! / 3600; - const totalPricePerHour = filteredQuotes.reduce((acc, quote) => { - if (!quote.value) return acc; - const pricePerGpuHour = getPricePerGpuHourFromQuote(quote.value); + const pricedQuotes = filteredQuotes.filter((q) => q.value); + const totalPricePerHour = pricedQuotes.reduce((acc, quote) => { + const pricePerGpuHour = getPricePerGpuHourFromQuote(quote.value!); const pricePerNodeHour = (pricePerGpuHour * GPUS_PER_NODE) / 100; return acc + pricePerNodeHour; }, 0); const totalEstimate = totalPricePerHour * durationHours; - confirmationMessage += ` for ~$${totalEstimate.toFixed(0)}`; + if (pricedQuotes.length < extendableNodes.length) { + // Some nodes had no liquidity quote; flag that the estimate only + // covers the priced subset so the user isn't surprised by a higher bill. + confirmationMessage += ` for ~$${totalEstimate.toFixed(2)} (estimate covers ${pricedQuotes.length} of ${extendableNodes.length} ${pluralizeNodes( + extendableNodes.length, + )}; remaining nodes will extend up to --max-price)`; + } else { + confirmationMessage += ` for ~$${totalEstimate.toFixed(2)}`; + } } else { confirmationMessage = chalk.red( "No nodes available matching your requirements. This is likely due to insufficient capacity. Attempt to extend anyway",