-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscrape.mjs
More file actions
2187 lines (2055 loc) · 113 KB
/
Copy pathscrape.mjs
File metadata and controls
2187 lines (2055 loc) · 113 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
// Pull GPU rental prices from providers directly and write data.json next to
// gpu-prices.html. Zero dependencies; plain fetch(). Runs two ways:
//
// node scrape.mjs # writes data.json
// node scrape.mjs --dry # print rows, write nothing
// node scrape.mjs --only vast,azure
//
// wrangler deploy # Cloudflare Worker: cron scrapes into
// # KV, fetch() serves / and /data.json
//
// Each provider is one function that returns a list of row objects. If one
// fails, the others still run and that provider's rows are carried forward
// from the previous data.json marked "stale": true — a flaky endpoint
// degrades the data instead of dropping it.
//
// EUR-priced providers (OVH, Scaleway, LeaderGPU, Seeweb) are converted to
// USD with the ECB's daily reference rate. A daily price-history index
// (cheapest $/GPU-hr per GPU and pricing type) is kept in history.json
// locally and in KV + /history.json on the Worker.
//
// Client-side-rendered pricing pages (Replicate, Novita) go through
// Cloudflare Browser Rendering's REST API — same product as the Workers
// `browser` binding, but callable with plain fetch() from both local node and
// the Worker, so there is still no npm dependency. Set CF_ACCOUNT_ID and
// CF_API_TOKEN (token permission: Browser Rendering > Edit): env vars
// locally, `wrangler secret put` on the Worker. Without them those three
// providers are skipped with a hint and everything else still runs. Rendered
// pages are re-fetched at most every 6 hours to stay inside the included
// browser-minutes quota; marketing pages don't move faster than that anyway.
//
// Endpoints last verified 2026-08-29. Probed but not included, and why:
// Vultr fronts its pricing page with a bot challenge that blocks datacenter
// IPs — including Cloudflare's own Browser Rendering (verified: the challenge
// never clears), though the page loads fine in a residential browser;
// TensorDock, Prime Intellect, Hyperbolic and SF Compute require API keys;
// GCP loads its pricing tables in lazy iframes even a rendered DOM doesn't
// contain (their billing API needs a key); Paperspace's fleet is now
// DigitalOcean (covered); TensorWave has no public pricing page (404,
// contact-sales only); Fluidstack dropped public pricing when it pivoted to
// gigawatt-scale enterprise deals; Linode's GPU fleet has no cards this
// table tracks.
const UA_API = "gpu-prices/0.3 (personal price tracker)";
const UA_BROWSER = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
const TIMEOUT_MS = 25000;
const RETRIES = 3;
// Canonical names, so RTX_4090 / NVIDIA GeForce RTX 4090 / 4090 collapse to
// one row group. First match wins, so keep specific patterns (H100 NVL) above
// generic ones (H100). Extend as you see what the providers actually return.
const ALIASES = [
[/h100.*sxm|sxm.*h100|hgx.*h100/, ["H100 SXM", 80, "Hopper"]],
[/h100.*pcie/, ["H100 PCIe", 80, "Hopper"]], // before NVL: "H100 PCIe NVLink" is a PCIe part
[/h100.*nvl/, ["H100 NVL", 94, "Hopper"]],
[/\bh100\b/, ["H100 SXM", 80, "Hopper"]],
[/gb300/, ["GB300", 288, "Blackwell"]],
[/gb200|grace.?blackwell/, ["GB200", 186, "Blackwell"]],
[/gh200|grace.?hopper/, ["GH200", 96, "Hopper"]],
[/\bh200\b/, ["H200", 141, "Hopper"]],
[/\bb200\b|hgx.*b200/, ["B200", 192, "Blackwell"]],
[/\bb300\b/, ["B300", 288, "Blackwell"]],
[/mi300x/, ["MI300X", 192, "CDNA3"]],
[/mi325x/, ["MI325X", 256, "CDNA3"]],
[/mi350x/, ["MI350X", 288, "CDNA4"]],
[/mi355x/, ["MI355X", 288, "CDNA4"]],
[/a100.*pcie/, ["A100 PCIe", 80, "Ampere"]],
[/\ba100\b/, ["A100 SXM", 80, "Ampere"]],
[/l40s/, ["L40S", 48, "Ada"]],
[/\bl40\b/, ["L40", 48, "Ada"]],
[/\ba40\b/, ["A40", 48, "Ampere"]],
[/6000\s*ada/, ["RTX 6000 Ada", 48, "Ada"]],
[/rtx.?pro.?6000|pro.?6000/, ["RTX PRO 6000", 96, "Blackwell"]],
[/a6000/, ["RTX A6000", 48, "Ampere"]],
[/4090/, ["RTX 4090", 24, "Ada"]],
[/5090/, ["RTX 5090", 32, "Blackwell"]],
[/3090 ?ti/, ["RTX 3090 Ti", 24, "Ampere"]],
[/3090/, ["RTX 3090", 24, "Ampere"]],
[/3080 ?ti/, ["RTX 3080 Ti", 12, "Ampere"]],
[/3080/, ["RTX 3080", 10, "Ampere"]],
[/3070/, ["RTX 3070", 8, "Ampere"]],
[/3060/, ["RTX 3060", 12, "Ampere"]],
[/2080 ?ti/, ["RTX 2080 Ti", 11, "Turing"]],
[/titan ?rtx/, ["Titan RTX", 24, "Turing"]],
[/a5000/, ["RTX A5000", 24, "Ampere"]],
[/a4000/, ["RTX A4000", 16, "Ampere"]],
[/\ba10\b/, ["A10", 24, "Ampere"]], // after A100 patterns: \b keeps "a100" safe
[/\bl4\b/, ["L4", 24, "Ada"]],
[/\bv100\b/, ["V100", 16, "Volta"]],
];
function canon(raw) {
const s = (raw || "").toLowerCase();
for (const [pat, out] of ALIASES) if (pat.test(s)) return out;
return null;
}
// GET/POST with retries. Retries network errors, 429 and 5xx with backoff;
// other 4xx are permanent, so they throw immediately with the response body.
async function fetchRetry(url, { json = null, ua = UA_API, headers = {}, timeoutMs = TIMEOUT_MS } = {}) {
let last;
for (let i = 0; i < RETRIES; i++) {
if (i) await new Promise(r => setTimeout(r, 1500 * i));
try {
const res = await fetch(url, {
method: json ? "POST" : "GET",
headers: { "User-Agent": ua, Accept: "*/*",
...(json && { "Content-Type": "application/json" }), ...headers },
body: json ? JSON.stringify(json) : undefined,
signal: AbortSignal.timeout(timeoutMs),
redirect: "follow",
});
if (!res.ok) {
const err = new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
if (res.status < 500 && res.status !== 429) { err.permanent = true; throw err; }
last = err;
continue;
}
return await res.text();
} catch (e) {
if (e.permanent) throw e;
last = e;
}
}
throw last;
}
const getJSON = async (url, opts) => JSON.parse(await fetchRetry(url, opts));
// Tag-stripped, entity-decoded, whitespace-collapsed page text, scripts removed.
const pageText = h => h
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/ /g, " ").replace(/€/g, "€").replace(/&/g, "&").replace(/</g, "<")
.replace(/>/g, ">").replace(/"/g, '"').replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n))
.replace(/\s+/g, " ");
// Outbound attribution: every source_url carries ref/utm params so providers
// can see gputable driving the traffic — in the app AND the API (the params
// are baked into the data itself, not added client-side). When you join a
// provider's referral program, drop the full referral URL in here and it
// replaces the plain link for that provider.
const REFERRALS = {
"Vast.ai": "https://cloud.vast.ai/?ref_id=675432",
"Runpod": "https://runpod.io?ref=kdye06s9",
"Runpod Community": "https://runpod.io?ref=kdye06s9",
};
function outLink(url, provider) {
if (REFERRALS[provider]) return REFERRALS[provider];
try {
const u = new URL(url);
u.searchParams.set("ref", "gputable");
u.searchParams.set("utm_source", "gputable");
return u.toString();
} catch { return url; }
}
function row(gpuRaw, provider, price, { count = 1, ptype = "on_demand", commit = null,
avail = null, url = null, vram = null } = {}) {
const c = canon(gpuRaw);
price = Number(price);
if (!c || !price) return null;
const [name, vramGb, arch] = c;
// A provider's "A100 SXM 40GB" or "PRO 6000 MIG 24GB" must not masquerade as
// the canonical 80/96GB card: drop rows whose reported VRAM disagrees.
if (vram && Math.abs(vram - vramGb) / vramGb > 0.25) return null;
return {
gpu: name, vram_gb: vramGb, architecture: arch,
provider, gpu_count: count,
price_per_hour_usd: Math.round(price * 1e4) / 1e4,
pricing_type: ptype, commitment_months: commit,
available: avail, source_url: url ? outLink(url, provider) : null,
};
}
// --------------------------------------------------------------------------
// Providers. Each returns rows (nulls are filtered by the caller).
// --------------------------------------------------------------------------
// Vast.ai marketplace search: POST the query object itself (NOT wrapped in
// {"q": ...} — that 400s) to /api/v0/bundles/. Public, no auth. The endpoint
// returns at most 64 offers per request no matter the limit, and sorting by
// price means cheap cards crowd out everything else — so query per price-band
// group of exact gpu_name values, as Vast spells them. Marketplace listings
// are individual people's machines; the page footer says so.
async function vast() {
const groups = [
["H100 SXM", "H100 PCIE", "H100 NVL"],
["H200", "H200 NVL"],
["B200"],
["A100 SXM4", "A100 PCIE"],
["MI300X", "MI325X"],
["L40S", "L40", "A40"],
["RTX PRO 6000 WS", "RTX PRO 6000 S", "RTX PRO 6000 Max-Q"],
["RTX A6000", "RTX 6000Ada"],
["RTX 4090", "RTX 5090"],
["L4", "Tesla V100"],
["RTX 3090", "RTX 3090 Ti", "RTX 3080", "RTX 3080 Ti", "RTX 3070"],
["RTX 3060", "RTX 2080 Ti", "A10", "RTX A5000", "RTX A4000", "TITAN RTX"],
];
const results = await Promise.allSettled(groups.map(names =>
getJSON("https://console.vast.ai/api/v0/bundles/", { json: {
rentable: { eq: true }, gpu_name: { in: names },
type: "on-demand", order: [["dph_total", "asc"]], limit: 64,
} })));
const offers = results.flatMap(r => r.status === "fulfilled" ? r.value.offers ?? [] : []);
const failed = results.filter(r => r.status === "rejected");
if (failed.length && !offers.length) throw failed[0].reason;
if (failed.length) console.error(`vast: ${failed.length}/${groups.length} queries failed (${failed[0].reason})`);
return offers.flatMap(o => {
const n = o.num_gpus || 1;
const opts = { count: n, vram: (o.gpu_ram || 0) / 1024 || null,
avail: !!o.rentable, url: "https://cloud.vast.ai/" };
return [
o.dph_total ? row(o.gpu_name, "Vast.ai", o.dph_total / n, opts) : null,
o.min_bid ? row(o.gpu_name, "Vast.ai", o.min_bid / n, { ...opts, ptype: "spot" }) : null,
];
});
}
// RunPod public GraphQL. Secure cloud is RunPod's own datacenters; community
// cloud is peer capacity, reported as its own provider so they aren't conflated.
async function runpod() {
const d = await getJSON("https://api.runpod.io/graphql", { json: { query: `{
gpuTypes { displayName memoryInGb
secure: lowestPrice(input:{gpuCount:1, secureCloud:true}) { uninterruptablePrice minimumBidPrice stockStatus }
community: lowestPrice(input:{gpuCount:1, secureCloud:false}) { uninterruptablePrice minimumBidPrice stockStatus } } }` } });
if (d.errors) throw new Error(`graphql: ${JSON.stringify(d.errors[0])}`);
return (d.data?.gpuTypes ?? []).flatMap(g =>
[["secure", "Runpod"], ["community", "Runpod Community"]].flatMap(([tier, label]) =>
[[g[tier]?.uninterruptablePrice, "on_demand"], [g[tier]?.minimumBidPrice, "spot"]].map(
([price, ptype]) => row(g.displayName, label, price,
{ ptype, vram: g.memoryInGb, avail: g[tier]?.stockStatus ? true : null,
url: "https://runpod.io/pricing" }))));
}
// Lambda's pricing table is server-rendered HTML on lambda.ai/instances
// (lambdalabs.com redirects there). Four <table>s, one per instance size, with
// matching 8x/4x/2x/1x tab buttons; rows are
// Plan | VRAM/GPU | vCPUs | RAM | STORAGE | PRICE/GPU/HR.
async function lambdaLabs() {
const page = await fetchRetry("https://lambda.ai/instances", { ua: UA_BROWSER });
const counts = [...page.matchAll(/id="tab-[^"]*"[^>]*>\s*(\d+)\s*x/g)].map(m => +m[1]);
const tables = page.match(/<table[\s\S]*?<\/table>/g) ?? [];
if (!tables.length) throw new Error("no pricing tables on lambda.ai/instances (layout changed?)");
const sizes = counts.length === tables.length ? counts : tables.map(() => 1);
return tables.flatMap((tbl, i) =>
[...tbl.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/g)].map(tr => {
const cells = [...tr[1].matchAll(/<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/g)]
.map(c => pageText(c[1]).trim());
if (cells.length < 6) return null;
const price = cells.at(-1).match(/\$\s*([\d.]+)/);
const vram = cells[1].match(/(\d+)\s*GB/);
return price && row(cells[0], "Lambda Labs", price[1],
{ count: sizes[i], vram: vram && +vram[1], url: "https://lambda.ai/instances" });
}));
}
// CoreWeave's pricing page renders spec tables server-side. Row text reads
// "NVIDIA HGX B200 8 180 128 2,048 61.44 $68.80": name, GPU count (sometimes
// with a footnote digit), VRAM, vCPUs, RAM, storage, instance $/hr.
async function coreweave() {
const t = pageText(await fetchRetry("https://www.coreweave.com/pricing", { ua: UA_BROWSER }));
const rows = [...t.matchAll(
/(?:NVIDIA|AMD)\s+((?:[A-Z][\w-]*\s+|\d{4}\s+){1,4}?)(\d{1,2})(?:\s*\^?\d)?\s+(\d{2,3})\s+[\d,]+\s+[\d,.]+\s+[\d,.]+\s+\$([\d,]+\.\d{2})/g,
)].map(m => row(m[1], "CoreWeave", parseFloat(m[4].replace(/,/g, "")) / +m[2],
{ count: +m[2], vram: +m[3], url: "https://www.coreweave.com/pricing" }));
if (!rows.length) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Nebius prices are a server-rendered table quoted per GPU-hour:
// "NVIDIA HGX H100 16 200 $2.15 $3.85" = name, vCPUs/GPU, RAM/GPU,
// committed price, on-demand price. HGX means full 8-GPU nodes.
async function nebius() {
const t = pageText(await fetchRetry("https://nebius.com/prices", { ua: UA_BROWSER }));
const rows = [...t.matchAll(
/NVIDIA\s+([A-Z][\w ]+?)\s+\d{1,3}\s+\d{2,4}\s+(\$[\d.]+|[–—-]+|Contact us)\s+(\$[\d.]+|[–—-]+|Contact us)/g,
)].flatMap(m => {
const count = /HGX/i.test(m[1]) ? 8 : 1;
const opts = { count, url: "https://nebius.com/prices" };
const price = s => (s.match(/\$([\d.]+)/) || [])[1];
return [
price(m[2]) ? row(m[1], "Nebius", price(m[2]), { ...opts, ptype: "reserved" }) : null,
price(m[3]) ? row(m[1], "Nebius", price(m[3]), opts) : null,
];
});
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Crusoe's pricing page: "NVIDIA H100 80GB HGX $3.90/GPU-hr Contact sales" =
// name, VRAM, form factor, on-demand $/GPU-hr, reserved (usually contact-only).
// HGX/SXM/OAM parts are sold as 8-GPU nodes.
async function crusoe() {
const t = pageText(await fetchRetry("https://crusoe.ai/cloud/pricing", { ua: UA_BROWSER }));
const rows = [...t.matchAll(
// the name must not swallow neighboring "Contact sales" rows
/(?:NVIDIA|AMD)\s+((?:(?!NVIDIA|AMD|Contact)[\w ])+?)\s+(\d{2,3})GB\s+(?:(HGX|SXM\d?|PCIe|OAM|NVL\d*)\s+)?\$([\d.]+)\/GPU-hr/g,
)].map(m => row(`${m[1]} ${m[3] ?? ""}`, "Crusoe", m[4],
{ count: /hgx|sxm|oam/i.test(m[3] ?? "") ? 8 : 1, vram: +m[2], url: "https://crusoe.ai/cloud/pricing" }));
if (!rows.length) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Salad's pricing table: "RTX 5090 32GB 8GB 4 vCPUs $0.250 $182.50" = GPU,
// VRAM, system RAM, vCPUs, $/hr (lowest priority), $/mo. All Salad capacity is
// interruptible community hardware — the page footer's caveat applies.
async function salad() {
const t = pageText(await fetchRetry("https://salad.com/pricing", { ua: UA_BROWSER }));
const rows = [...t.matchAll(
/\b((?:RTX|GTX)\s[\w ]*?)\s+(\d{1,3})GB\s+\d+\s*GB\s+\d+\s+vCPUs\s+\$([\d.]+)/g,
)].map(m => row(m[1], "Salad Cloud", m[3],
{ vram: +m[2], avail: true, url: "https://salad.com/pricing" }));
if (!rows.length) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Verda (formerly DataCrunch) has a public instance-types JSON. Prices are per
// instance; gpu.description reads like "2x GB300 SXM6 288GB" (VRAM per GPU).
async function verda() {
const d = await getJSON("https://api.datacrunch.io/v1/instance-types");
return d.flatMap(it => {
const n = it.gpu?.number_of_gpus || 1;
const desc = it.gpu?.description ?? "";
const vram = (desc.match(/(\d+)GB/) || [])[1];
const opts = { count: n, vram: vram && +vram, url: "https://verda.com/" }; // DataCrunch rebranded
return [
it.price_per_hour ? row(desc, "Verda", it.price_per_hour / n, opts) : null,
it.spot_price ? row(desc, "Verda", it.spot_price / n, { ...opts, ptype: "spot" }) : null,
];
});
}
// Azure's retail prices API is public JSON, per SKU per region; we keep the
// cheapest Linux region for each SKU. armSkuName encodes the GPU config —
// the map below decodes it (ND96asr is the 40GB A100; the VRAM guard drops it).
const AZURE_SKUS = [
[/^Standard_NC(\d+)ads_A100_v4/, m => ["A100 PCIe", +m[1] / 24, 80]],
[/^Standard_NCC?40ads_H100_v5/, () => ["H100 NVL", 1, 94]],
[/^Standard_NC80adis_H100_v5/, () => ["H100 NVL", 2, 94]],
[/^Standard_ND96\w*_H100_v5/, () => ["H100 SXM", 8, 80]],
[/^Standard_ND96\w*_H200_v5/, () => ["H200", 8, 141]],
[/^Standard_ND96\w*_MI300X_v5/, () => ["MI300X", 8, 192]],
[/^Standard_ND96asr_A100_v4/, () => ["A100 SXM", 8, 40]],
[/^Standard_ND96am\w*_A100_v4/, () => ["A100 SXM", 8, 80]],
[/^Standard_ND\d+isrf?_NDR_GB200_v6/, () => ["GB200", 4, 186]],
];
async function azure() {
const filter = "serviceName eq 'Virtual Machines' and priceType eq 'Consumption' " +
"and unitOfMeasure eq '1 Hour' and (" +
["H100", "H200", "B200", "A100", "MI300", "GB200"]
.map(g => `contains(armSkuName,'${g}')`).join(" or ") + ")";
let url = "https://prices.azure.com/api/retail/prices?$filter=" + encodeURIComponent(filter);
const prices = new Map(); // SKU|type -> all regional prices
for (let page = 0; url && page < 25; page++) {
const d = await getJSON(url);
for (const it of d.Items ?? []) {
if (it.productName?.includes("Windows") || it.meterName?.includes("Low Priority")) continue;
if (/^usgov|^usdod|^china/.test(it.armRegionName ?? "")) continue; // not publicly purchasable
if (!(it.retailPrice > 0)) continue;
const k = `${it.armSkuName}|${it.meterName?.includes("Spot") ? "spot" : "on_demand"}`;
(prices.get(k) ?? prices.set(k, []).get(k)).push(it.retailPrice);
}
url = d.NextPageLink;
}
// Cheapest *plausible* region: Azure's feed carries placeholder rows (e.g.
// ukwest listing an H100 VM at $0.01), so reject anything under 20% of that
// SKU's median price before taking the minimum.
const best = new Map();
for (const [k, list] of prices) {
const sorted = [...list].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
const sane = sorted.filter(p => p >= median * 0.2);
if (sane.length) best.set(k, sane[0]);
}
return [...best].map(([k, price]) => {
const [sku, ptype] = k.split("|");
for (const [pat, decode] of AZURE_SKUS) {
const m = sku.match(pat);
if (m) {
const [gpu, count, vram] = decode(m);
return row(gpu, "Azure", price / count, { count, ptype, vram,
url: "https://azure.microsoft.com/pricing/details/virtual-machines/linux/" });
}
}
return null;
});
}
// DigitalOcean GPU Droplets (the old Paperspace fleet). Server-rendered page
// with three sections — "12 Month Reserved Plans", "Spot Plans", "On-Demand
// Plans" — each listing "NVIDIA HGX H200 $3.40 /GPU/hour"-style rows. The
// nearest preceding section header decides the pricing type.
async function digitalocean() {
const t = pageText(await fetchRetry("https://www.digitalocean.com/pricing/gpu-droplets", { ua: UA_BROWSER }));
const sections = [...t.matchAll(/(12 Month Reserved|Spot|On-Demand) Plans/g)]
.map(m => ({ at: m.index, ptype: m[1] === "Spot" ? "spot" : m[1] === "On-Demand" ? "on_demand" : "reserved" }));
const rows = [...t.matchAll(/((?:NVIDIA|AMD)[\w™\- ]{2,45}?)\s+\$([\d.]+)\s*\/GPU\/hour/g)]
.map(m => {
const sec = sections.filter(s => s.at < m.index).at(-1);
if (!sec) return null;
return row(m[1], "DigitalOcean", m[2], {
ptype: sec.ptype, commit: sec.ptype === "reserved" ? 12 : null,
count: /HGX|Instinct/.test(m[1]) ? 8 : 1,
url: "https://www.digitalocean.com/pricing/gpu-droplets" });
});
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// AWS publishes small per-region on-demand JSON maps (the ones its own pricing
// pages read) and a public all-region spot price feed. We read us-east-1
// on-demand plus the cheapest spot region for the P/G GPU families.
const AWS_TYPES = {
"p4d.24xlarge": ["A100 SXM", 8, 40], "p4de.24xlarge": ["A100 SXM", 8, 80],
"p5.4xlarge": ["H100 SXM", 1, 80], "p5.48xlarge": ["H100 SXM", 8, 80],
"p5e.48xlarge": ["H200", 8, 141], "p5en.48xlarge": ["H200", 8, 141],
"p6-b200.48xlarge": ["B200", 8, 180], "p6-b300.48xlarge": ["B300", 8, 288],
"g6e.xlarge": ["L40S", 1, 48], "g6e.12xlarge": ["L40S", 4, 48],
"g6e.24xlarge": ["L40S", 4, 48], "g6e.48xlarge": ["L40S", 8, 48],
};
const AWS_URL = "https://aws.amazon.com/ec2/pricing/on-demand/";
async function aws() {
const od = await getJSON("https://b0.p.awsstatic.com/pricing/2.0/meteredUnitMaps/ec2/USD/current/" +
"ec2-ondemand-without-sec-sel/US%20East%20(N.%20Virginia)/Linux/index.json");
const rows = [];
for (const inst of Object.values(od.regions?.["US East (N. Virginia)"] ?? {})) {
const spec = AWS_TYPES[inst["Instance Type"]];
if (spec) rows.push(row(spec[0], "AWS", inst.price / spec[1],
{ count: spec[1], vram: spec[2], url: AWS_URL }));
}
if (!rows.some(Boolean)) throw new Error("parsed zero on-demand rows (format changed?)");
try { // spot feed is 3MB and best-effort; on-demand alone is still a result
const spot = await getJSON("https://website.spot.ec2.aws.a2z.com/spot.json");
const best = new Map();
for (const region of spot.config?.regions ?? [])
for (const it of region.instanceTypes ?? [])
for (const size of it.sizes ?? []) {
if (!AWS_TYPES[size.size]) continue;
const usd = parseFloat(size.valueColumns?.find(v => v.name === "linux")?.prices?.USD);
if (usd > 0 && (!best.has(size.size) || usd < best.get(size.size))) best.set(size.size, usd);
}
for (const [type, usd] of best) {
const [gpu, count, vram] = AWS_TYPES[type];
rows.push(row(gpu, "AWS", usd / count, { count, vram, ptype: "spot", url: AWS_URL }));
}
} catch (e) {
console.error(`aws: spot feed failed (${e}), on-demand only`);
}
return rows;
}
// Oracle's cost-estimator API is public JSON. GPU shapes appear as
// "OCI - Compute - GPU - H200" with a PAY_AS_YOU_GO price per GPU-hour.
const ORACLE_COUNTS = { GB200: 4, L40S: 4, A10: 1 };
async function oracle() {
const d = await getJSON("https://apexapps.oracle.com/pls/apex/cetools/api/v1/products/?currencyCode=USD");
return (d.items ?? []).map(it => {
const name = it.displayName ?? "";
const m = name.match(/^(?:OCI - )?Compute - GPU - (\w+)$/);
if (!m) return null;
const gpu = m[1].replace(/^H100T$/, "H100"); // their H100 SKU is "H100T"
const price = it.currencyCodeLocalizations?.[0]?.prices
?.find(p => p.model === "PAY_AS_YOU_GO")?.value;
return row(gpu, "Oracle Cloud", price, { count: ORACLE_COUNTS[gpu] ?? 8,
url: "https://www.oracle.com/cloud/compute/pricing/" });
});
}
// Together AI's GPU Clusters table: "NVIDIA HGX H100 $5.49 Contact sales" =
// name, on-demand $/GPU-hr, reserved (contact-only). Full HGX nodes.
async function together() {
const t = pageText(await fetchRetry("https://www.together.ai/pricing", { ua: UA_BROWSER }));
const rows = [...t.matchAll(
/NVIDIA\s+((?:HGX\s+)?[A-Z]{1,2}\d{3}(?:\s+NVL\d+)?)\s+(\$[\d.]+|Contact us)\s+(\$[\d.]+|Contact sales|Contact us)/g,
)].flatMap(m => {
const price = s => (s.match(/\$([\d.]+)/) || [])[1];
const opts = { count: 8, url: "https://www.together.ai/pricing" };
return [
price(m[2]) ? row(m[1], "Together AI", price(m[2]), opts) : null,
price(m[3]) ? row(m[1], "Together AI", price(m[3]), { ...opts, ptype: "reserved" }) : null,
];
});
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Modal prices serverless GPUs per second ("Nvidia H100 SXM5 $0.001097 / sec");
// multiply out to an hourly rate. Fine-grained autoscaling, so count is 1.
async function modal() {
const t = pageText(await fetchRetry("https://modal.com/pricing", { ua: UA_BROWSER }));
const rows = [...t.matchAll(/Nvidia\s+([\w ]{2,25}?)\s+\$([\d.]+)\s*\/\s*sec/gi)]
.map(m => row(m[1], "Modal", parseFloat(m[2]) * 3600,
{ avail: true, url: "https://modal.com/pricing" }));
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Jarvis Labs lists GPU cards as "H100 SXM Hopper · 80 GB $2.69" (name,
// architecture, VRAM, $/hr).
async function jarvis() {
const t = pageText(await fetchRetry("https://jarvislabs.ai/pricing", { ua: UA_BROWSER }));
const rows = [...t.matchAll(
/([A-Z][\w ]{1,20}?)\s+(?:Ada|Ampere|Hopper|Blackwell|Volta)\s+·\s+(\d+)\s*GB\s+\$([\d.]+)/g,
)].map(m => row(m[1], "Jarvis Labs", m[3],
{ vram: +m[2], avail: true, url: "https://jarvislabs.ai/pricing" }));
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Cudo Compute's machine-types API is public JSON with a per-GPU hourly price
// and live free-GPU counts per datacenter.
async function cudo() {
const d = await getJSON("https://rest.compute.cudo.org/v1/vms/machine-types");
return (d.machineTypes ?? []).map(mt => {
const vram = (mt.gpuModel?.match(/(\d+)GB/) || [])[1];
return row(mt.gpuModel, "Cudo Compute", mt.gpuPriceHr?.value, {
vram: vram && +vram, avail: (mt.totalGpuFree ?? 0) > 0,
url: "https://www.cudocompute.com/pricing" });
});
}
// EUR → USD via the ECB's daily reference rate, cached for 6 hours.
let fxCache = { t: 0, rate: null };
async function eurUsd() {
if (fxCache.rate && Date.now() - fxCache.t < 6 * 3600e3) return fxCache.rate;
const xml = await fetchRetry("https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml");
const m = xml.match(/currency=["']USD["']\s+rate=["']([\d.]+)["']/);
if (!m) throw new Error("ECB EUR-USD rate not found");
fxCache = { t: Date.now(), rate: parseFloat(m[1]) };
return fxCache.rate;
}
// Hyperstack's pricing page is server-rendered (their pricebook API went
// key-only): "NVIDIA H200 SXM 141 22 225 $3.99" = name, VRAM, pCPUs, RAM, $/GPU-hr.
async function hyperstack() {
const t = pageText(await fetchRetry("https://www.hyperstack.cloud/gpu-pricing", { ua: UA_BROWSER }));
const rows = [...t.matchAll(
/NVIDIA\s+((?:(?!NVIDIA)[\w .-])+?)\s+(\d{2,3})\s+\d{1,3}\s+\d{2,4}\s+\$([\d.]+)/g,
)].map(m => row(m[1].replace(/NVLink/i, "PCIe NVLink"), "Hyperstack", m[3],
{ vram: +m[2], url: "https://www.hyperstack.cloud/gpu-pricing" }));
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// fal's GPU fleet table: "B300 288GB $8.50/h $4.49/h" = GPU, VRAM, list
// price, committed "as low as" price.
async function fal() {
const t = pageText(await fetchRetry("https://fal.ai/pricing", { ua: UA_BROWSER }));
const rows = [...t.matchAll(
/([A-Z][\w]{1,12}(?: [A-Z\d][\w]{0,10})?)\s+(\d{2,3})GB\s+\$([\d.]+)\/h\s+\$([\d.]+)\/h/g,
)].flatMap(m => [
row(m[1], "fal", m[3], { vram: +m[2], url: "https://fal.ai/pricing" }),
row(m[1], "fal", m[4], { vram: +m[2], ptype: "reserved", url: "https://fal.ai/pricing" }),
]);
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Koyeb's instance table: "RTX-A6000 vRAM 48GB vRAM 48GB $ 0.75 /hour" —
// standard tier hourly rate per GPU.
async function koyeb() {
const t = pageText(await fetchRetry("https://www.koyeb.com/pricing", { ua: UA_BROWSER }));
const rows = [...t.matchAll(
/([A-Z][\w-]{1,17}?)\s+vRAM\s+(\d{2,3})GB\s+vRAM\s+\d{2,3}GB\s+\$\s*([\d.]+)\s*\/hour/g,
)].map(m => row(m[1].replace(/-/g, " "), "Koyeb", m[3],
{ vram: +m[2], url: "https://www.koyeb.com/pricing" }));
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Baseten prices per minute: "H100 80 GiB VRAM $0.10833" → ×60 for hourly.
async function baseten() {
const t = pageText(await fetchRetry("https://www.baseten.co/pricing/", { ua: UA_BROWSER }));
const rows = [...t.matchAll(/([A-Z][\w ]{1,16}?)\s+(\d{2,3})\s*GiB\s+VRAM\s+\$([\d.]+)/g)]
.map(m => row(m[1], "Baseten", parseFloat(m[3]) * 60,
{ vram: +m[2], avail: true, url: "https://www.baseten.co/pricing/" }));
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Civo's GPU tables: "Small 1 x NVIDIA L40S - 48GB ... $1.29 per hour ..." —
// instance total for N GPUs; the first hourly figure is on-demand (the rest
// are commitment tiers).
async function civo() {
const t = pageText(await fetchRetry("https://www.civo.com/pricing", { ua: UA_BROWSER }));
const rows = [...t.matchAll(
/(\d)\s*x\s+NVIDIA\s+([\w ]+?)\s*-\s*(\d{2,3})GB[^$]{0,80}\$([\d,.]+)\s*per hour/g,
)].map(m => row(m[2], "Civo", parseFloat(m[4].replace(/,/g, "")) / +m[1],
{ count: +m[1], vram: +m[3], url: "https://www.civo.com/pricing" }));
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Denvr Dataworks quotes per GPU: "NVIDIA H100 SXM 8 80 GB ... $2.45 / GPU"
// = name, GPU count, VRAM, per-GPU hourly. "Reserved only" rows have no price.
async function denvr() {
const t = pageText(await fetchRetry("https://www.denvrdata.com/pricing", { ua: UA_BROWSER }));
const rows = [...t.matchAll(
/NVIDIA\s+([\w ]+?)\s+(\d)\s+(\d{2,3})\s*GB\s+[^$]{0,80}\$([\d.]+)\s*\/\s*GPU/g,
)].map(m => row(m[1], "Denvr Dataworks", m[4],
{ count: +m[2], vram: +m[3], url: "https://www.denvrdata.com/pricing" }));
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Scaleway's instance products API is public JSON (EUR): servers keyed
// "H100-2-80G" with a gpu count and per-instance hourly_price. The plain
// H100-N-80G shapes are PCIe; H100-SXM-* are SXM.
async function scaleway() {
const fx = await eurUsd();
const rows = [];
for (let page = 1; page <= 3; page++) {
const d = await getJSON(`https://api.scaleway.com/instance/v1/zones/fr-par-2/products/servers?per_page=100&page=${page}`);
const servers = Object.entries(d.servers ?? {});
for (const [name, s] of servers) {
if (!s.gpu || !s.hourly_price) continue;
const raw = name.replace(/^H100-SXM/, "H100 SXM").replace(/^H100-/, "H100 PCIe-");
rows.push(row(raw, "Scaleway", (s.hourly_price * fx) / s.gpu, {
count: s.gpu, vram: +(name.match(/-(\d+)G$/)?.[1] ?? 0) || null,
url: "https://www.scaleway.com/en/pricing/gpu/" }));
}
if (servers.length < 100) break;
}
if (!rows.some(Boolean)) throw new Error("no GPU servers in catalog (format changed?)");
return rows;
}
// OVH's public order catalog (EUR, prices in 1e-8 units). The AI training /
// notebook addons are per-minute per instance, named "ai-training.h100-1-gpu".
async function ovh() {
const fx = await eurUsd();
const d = await getJSON("https://api.ovh.com/1.0/order/catalog/public/cloud?ovhSubsidiary=FR");
return (d.addons ?? []).map(a => {
const m = (a.planCode ?? "").match(/^ai-(?:training|notebook)\.(\w+?)-(\d+)-gpu\.minute\.consumption$/);
if (!m) return null;
const price = a.pricings?.find(p => p.price > 0)?.price;
if (!price) return null;
return row(m[1].replace(/^h100$/, "H100 PCIe"), // OVH's AI-instance H100s are PCIe
"OVHcloud", ((price / 1e8) * 60 * fx) / +m[2],
{ count: +m[2], url: "https://www.ovhcloud.com/en/public-cloud/prices/" });
});
}
// LeaderGPU rents dedicated monthly servers (EUR): "NVIDIA H100 SXM Popular
// 8x NVLink 80 GB HBM3 €1,488 €11,900" = name, count, VRAM, per-card monthly,
// total monthly. Reported as a 1-month commitment at monthly/730 per hour.
async function leadergpu() {
const fx = await eurUsd();
const t = pageText(await fetchRetry("https://www.leadergpu.com/", { ua: UA_BROWSER }));
const rows = [...t.matchAll(
/NVIDIA\s+((?:(?!NVIDIA)[\w ])+?)\s+(?:New|Popular)?\s*(\d)x\s+(?:NVLink\s+)?(\d{2,3})\s*GB\s+\w+(?:\s+ECC)?\s+€([\d,]+)/g,
)].map(m => row(m[1], "LeaderGPU", (parseFloat(m[4].replace(/,/g, "")) / 730) * fx,
{ count: +m[2], vram: +m[3], ptype: "reserved", commit: 1,
url: "https://www.leadergpu.com/" }));
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Seeweb quotes per GPU-hour in EUR: "CLOUD GPU NVIDIA H200 1 2 4 8 GPU SXM
// | 141 GB GPU RAM ... Hourly Cost 2.60 €". No VRAM hint — the GPU RAM figure
// is sometimes the multi-GPU total.
async function seeweb() {
const fx = await eurUsd();
const t = pageText(await fetchRetry("https://www.seeweb.it/en/products/cloud-server-gpu", { ua: UA_BROWSER }));
const rows = [...t.matchAll(
/CLOUD GPU\s+(?:NVIDIA|AMD)\s+([\w ]+?)\s+1 2 4 8 GPU[^€]{0,140}?Hourly Cost\s+([\d.,]+)\s*€/g,
)].map(m => row(m[1], "Seeweb", parseFloat(m[2].replace(",", ".")) * fx,
{ url: "https://www.seeweb.it/en/products/cloud-server-gpu" }));
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Hot Aisle sells one thing (MI300X) via structured spec cards: "VM Small 1x
// MI300x $2.99/GPU/hr", "Bare metal Large 8x MI300x $3.39/GPU/hr". Anchoring
// on the "Nx MI300x $P/GPU/hr" card shape skips the marketing prose around
// it (new-customer rates, grandfathering notes). Bare-metal cards are
// one-month-minimum, so they land as reserved.
async function hotaisle() {
const t = pageText(await fetchRetry("https://hotaisle.xyz/pricing/", { ua: UA_BROWSER }));
const rows = [...t.matchAll(/(\d)x(?:\s*&\s*\d+x)?\s+MI300x\s+\$([\d.]+)\s*\/GPU\/hr/gi)]
.map(m => {
const bareMetal = /bare ?metal/i.test(t.slice(Math.max(0, m.index - 60), m.index));
return row("MI300X", "Hot Aisle", m[2], {
count: +m[1], vram: 192, avail: true,
ptype: bareMetal ? "reserved" : "on_demand",
commit: bareMetal ? 1 : null,
url: "https://hotaisle.xyz/pricing/" });
});
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Voltage Park's headline rate lives in the pricing-page FAQ: "You can rent
// one H100 GPU for 1 hour starting at $X.XX". One row, but a notable one.
async function voltagepark() {
const t = pageText(await fetchRetry("https://www.voltagepark.com/pricing", { ua: UA_BROWSER }));
const m = t.match(/rent one H100 GPU for 1 hour starting at\s+\$([\d.]+)/i);
if (!m) throw new Error("H100 rate sentence not found (layout changed?)");
return [row("H100 SXM", "Voltage Park", m[1],
{ avail: true, url: "https://www.voltagepark.com/pricing" })];
}
// Lium is a Bittensor-based GPU marketplace; its landing page server-renders
// the live pod list: "4 X NVIDIA RTX A6000 DinD ... $1.68 /HOUR" (pod total).
async function lium() {
const t = pageText(await fetchRetry("https://lium.io/", { ua: UA_BROWSER }));
// Pod rows: "2 X NVIDIA L40 $0.66 /HOUR $0.33 /GPU" — per-GPU price given.
const rows = [...t.matchAll(/(\d+)\s*X\s*NVIDIA\s+([\w ]+?)\s+\$([\d.]+)\s*\/\s*HOUR\s+\$([\d.]+)\s*\/\s*GPU/gi)]
.map(m => row(m[2], "Lium.io", m[4],
{ count: +m[1], avail: true, url: "https://lium.io/" }));
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Thunder Compute: "H100 PCIe VRAM 80 GB vCPUs ... GPU/hr (*) $3.20".
async function thunder() {
const t = pageText(await fetchRetry("https://www.thundercompute.com/pricing", { ua: UA_BROWSER }));
const rows = [...t.matchAll(/([A-Z][\w ]{1,16}?)\s+VRAM\s+(\d{2,3})\s*GB[^$]{5,160}?GPU\/hr\s*\(\*\)\s*\$([\d.]+)/g)]
.map(m => row(m[1], "Thunder Compute", m[3],
{ vram: +m[2], avail: true, url: "https://www.thundercompute.com/pricing" }));
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Spheron cards: "H100 Hopper 80 GB ... $2.98 /hr Spot $2.10 /hr" — prices
// bounded per card so one GPU's spot doesn't leak into the next.
async function spheron() {
const t = pageText(await fetchRetry("https://www.spheron.network/pricing", { ua: UA_BROWSER }));
const cards = [...t.matchAll(/([A-Z][\w]+(?:\s[A-Z\d][\w]*)?)\s+(?:Blackwell|Hopper|Ampere|Ada|CDNA\w*)\s+(\d{2,3}) GB\b/g)];
const rows = cards.flatMap((m, i) => {
const block = t.slice(m.index, cards[i + 1]?.index ?? m.index + 240);
return [...block.matchAll(/(Spot\s+)?\$([\d.]+)\s*\/hr/g)].map(p =>
row(m[1], "Spheron", p[2], { vram: +m[2], ptype: p[1] ? "spot" : "on_demand",
url: "https://www.spheron.network/pricing" }));
});
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Beam prices serverless GPUs per second: "H100 PCIE 80 GB VRAM 80 GB $0.000986 /sec".
async function beam() {
const t = pageText(await fetchRetry("https://www.beam.cloud/pricing", { ua: UA_BROWSER }));
const rows = [...t.matchAll(/([A-Z][\w]+(?:\s[A-Z\d][\w]*){0,2}?)\s+(\d{2,3}) GB VRAM\s+\d{2,3} GB\s+\$([\d.]+)\s*\/sec/g)]
.map(m => row(m[1], "Beam", parseFloat(m[3]) * 3600,
{ vram: +m[2], avail: true, url: "https://www.beam.cloud/pricing" }));
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Massed Compute cards: a GPU name ("B200 SXM6 × 8", "RTX PRO 6000 Blackwell
// (96GB) × 1") followed by "× N <specs> $XX.XX /hr" rows priced per instance.
async function massed() {
const t = pageText(await fetchRetry("https://massedcompute.com/pricing/", { ua: UA_BROWSER }));
const names = [...t.matchAll(/((?:RTX|GTX|H\d{3}|B\d{3}|A\d{3}|L4\dS?|GH200|GB\d{3}|MI\d{3}X?)[\w \-]{0,26}?)\s*(?:\((\d{2,3})GB\))?\s*×/g)];
const rows = [...t.matchAll(/×\s*(\d)\s+[^$×]{0,80}?\$([\d,.]+)\s*\/hr/g)].map(m => {
const nm = names.filter(n => n.index <= m.index).at(-1);
if (!nm) return null;
return row(nm[1], "Massed Compute", parseFloat(m[2].replace(/,/g, "")) / +m[1],
{ count: +m[1], vram: nm[2] ? +nm[2] : null, url: "https://massedcompute.com/pricing/" });
});
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// GPU.ai routes to partner datacenters with live rates:
// "H100 SXM 80G high availability $1.87 /hr", "8× A100 80G ... $1.05 /hr".
async function gpuai() {
const t = pageText(await fetchRetry("https://gpu.ai/", { ua: UA_BROWSER }));
const rows = [...t.matchAll(
/(?:(\d)×\s*)?([A-Z][\w]+(?:\s[A-Z\d][\w]*)?)\s+(\d{2,3})G\b\s*(high availability|limited availability|available|out of stock)?\s*\$([\d.]+)\s*\/hr/g,
)].map(m => row(m[2], "GPU.ai", m[5], { count: m[1] ? +m[1] : 1, vram: +m[3],
avail: m[4] ? !/out of stock/.test(m[4]) : null, url: "https://gpu.ai/" }));
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Latitude.sh dedicated GPU metal: "Starting At $24/hr $17,520/mo Plan
// g3.h100.small GPU 1 x NVIDIA H100 80GB" — instance totals.
async function latitude() {
const t = pageText(await fetchRetry("https://www.latitude.sh/pricing", { ua: UA_BROWSER }));
const rows = [...t.matchAll(
/\$([\d,]+(?:\.\d+)?)\/hr\s+\$[\d,]+(?:\.\d+)?\/mo\s+Plan\s+[\w.]+\s+GPU\s+(\d+)\s*x\s+NVIDIA\s+([\w ]+?)\s+(\d{2,3})GB/g,
)].map(m => row(m[3], "Latitude.sh", parseFloat(m[1].replace(/,/g, "")) / +m[2],
{ count: +m[2], vram: +m[4], url: "https://www.latitude.sh/pricing" }));
if (!rows.some(Boolean)) throw new Error("parsed zero rows (layout changed?)");
return rows;
}
// Google Cloud via the Cloud Billing Catalog API (needs the GCP_API_KEY
// secret — the pricing pages themselves render in uncrawlable iframes).
// Compute Engine's catalog is ~35k SKUs over ~7 pages; GPU SKUs carry per-GPU
// hourly rates per region, and we keep the cheapest region per model and
// usage type. List prices change rarely, so this source shares the rendered
// providers' 6-hour TTL instead of re-pulling 8MB every sweep.
let GCP_KEY = null; // set by scrape() from env
async function gcp() {
if (!GCP_KEY) throw new Error("set GCP_API_KEY (Cloud Billing Catalog key)");
const best = new Map();
let token = "";
for (let page = 0; page < 12; page++) {
const d = await getJSON("https://cloudbilling.googleapis.com/v1/services/6F81-5844-456A/skus" +
`?pageSize=5000&key=${GCP_KEY}` + (token ? `&pageToken=${token}` : ""));
for (const s of d.skus ?? []) {
if (s.category?.resourceGroup !== "GPU") continue;
const desc = s.description ?? "";
// "(1 gpu slice)" is GCP's per-GPU rate for A4/B200-class nodes — keep it.
if (/Commitment|DWS|Sole Tenancy|vGPU|Reserved/i.test(desc)) continue;
const type = { OnDemand: "on_demand", Preemptible: "spot" }[s.category.usageType];
if (!type) continue;
const expr = s.pricingInfo?.[0]?.pricingExpression;
const unit = expr?.tieredRates?.at(-1)?.unitPrice;
if (expr?.usageUnit !== "h" || !unit) continue;
const price = Number(unit.units ?? 0) + (unit.nanos ?? 0) / 1e9;
if (!(price > 0)) continue;
const model = desc.split(" running in ")[0];
const k = model + "|" + type;
if (!best.has(k) || price < best.get(k).price) best.set(k, { model, type, price });
}
token = d.nextPageToken;
if (!token) break;
}
const rows = [...best.values()].map(({ model, type, price }) => row(model, "Google Cloud", price, {
ptype: type,
// "Nvidia Tesla A100" with no size in the name is the 40GB part — hint it
// so the VRAM guard drops it rather than passing it off as the 80GB card.
vram: +(model.match(/(\d+)\s*GB/)?.[1] ?? 0) || (/\bA100\b/.test(model) ? 40 : null),
url: "https://cloud.google.com/compute/gpus-pricing" }));
if (!rows.some(Boolean)) throw new Error("no GPU SKUs parsed (catalog changed?)");
return rows;
}
// Octa (octa.space; API on api.octa.computer) — a token-based peer compute
// network. Their /network feed lists per-model USD average prices and live
// unit counts, matching what their own homepage renders ("From $0.12 per
// hour"). Prices run below datacenter economics because node operators earn
// token emissions; strictly marketplace tier.
async function octa() {
const d = await getJSON("https://api.octa.computer/network", { ua: UA_BROWSER });
const rows = Object.entries(d.marketplace?.gpus ?? {}).map(([name, g]) =>
row(name, "Octa", g.avg_price, {
vram: +(name.match(/(\d+)\s*GB/)?.[1] ?? 0) || null, // "A100-SXM4-40GB" must not pass as the 80GB card
avail: (g.count ?? 0) > 0,
url: "https://octa.space/" }));
if (!rows.some(Boolean)) throw new Error("no marketplace GPUs parsed (API changed?)");
return rows;
}
// Akash, the decentralized compute marketplace, publishes an official
// aggregate price feed: USD min/avg/max per GPU model with live availability
// and the interface (SXM4/PCIe) for disambiguation. We list the cheapest
// current bid. Settlement is in AKT under the hood, but the feed itself is
// USD-denominated; marketplace tier, same caveats as Vast/Salad.
async function akash() {
const d = await getJSON("https://console-api.akash.network/v1/gpu-prices");
const rows = (d.models ?? []).map(m => row(
`${m.vendor ?? ""} ${m.model ?? ""} ${m.interface ?? ""}`, "Akash", m.price?.min, {
vram: +(String(m.ram ?? "").match(/(\d+)/)?.[1] ?? 0) || null,
avail: (m.availability?.available ?? 0) > 0,
url: "https://console.akash.network/" }));
if (!rows.some(Boolean)) throw new Error("no models parsed (API changed?)");
return rows;
}
// Prime Intellect (needs the PRIME_API_KEY secret; read-only). A marketplace
// that resells capacity from Lambda, Nebius, Massed Compute and even Vultr —
// prices are what you'd actually pay booking through PI, quoted per GPU-hour.
// The socket field (SXM5/PCIe) disambiguates H100/A100 variants for canon().
let PI_KEY = null; // set by scrape() from env
async function primeintellect() {
if (!PI_KEY) throw new Error("set PRIME_API_KEY (Prime Intellect, read-only)");
const d = await getJSON("https://api.primeintellect.ai/api/v1/availability/",
{ headers: { Authorization: `Bearer ${PI_KEY}` } });
const rows = [];
for (const offers of Object.values(d ?? {}))
for (const o of offers ?? []) {
const price = o.prices?.onDemand;
if (!price) continue;
rows.push(row(`${(o.gpuType ?? "").replace(/_/g, " ")} ${o.socket ?? ""}`,
"Prime Intellect", price, {
count: o.gpuCount ?? 1, vram: o.gpuMemory ?? null,
avail: o.stockStatus ? !/unavailable|out/i.test(o.stockStatus) : null,
url: "https://app.primeintellect.ai/dashboard/create-cluster" }));
}
if (!rows.some(Boolean)) throw new Error("no offers parsed (API changed?)");
return rows;
}
// TensorDock v2 (needs the TENSORDOCK_API_KEY secret). Written against their
// documented GET /api/v2/locations schema, but NOT registered in PROVIDERS
// yet: as of 2026-08-30 the endpoint returns zero locations even for a
// logged-in dashboard session — the account likely needs a prepaid deposit
// before marketplace stock is visible. Once `/api/v2/locations` returns data
// for the key, add `tensordock: { names: ["TensorDock"], fn: tensordock }`.
let TD_KEY = null; // set by scrape() from env
// eslint-disable-next-line no-unused-vars
async function tensordock() {
if (!TD_KEY) throw new Error("set TENSORDOCK_API_KEY");
const d = await getJSON("https://dashboard.tensordock.com/api/v2/locations",
{ headers: { Authorization: `Bearer ${TD_KEY}` } });
const rows = (d.data?.locations ?? []).flatMap(loc =>
(loc.gpus ?? []).map(g => row(g.displayName ?? g.v0Name, "TensorDock", g.price_per_hr, {
count: 1, vram: +((g.displayName ?? "").match(/(\d+)GB/)?.[1] ?? 0) || null,
avail: (g.max_count ?? 0) > 0,
url: "https://dashboard.tensordock.com/deploy" })));
if (!rows.some(Boolean)) throw new Error("no locations visible (deposit required?)");
return rows;
}
// --------------------------------------------------------------------------
// Browser-rendered providers, via Cloudflare Browser Rendering (REST).
// --------------------------------------------------------------------------
let RENDER_CREDS = null; // set by scrape() from env
async function renderPage(url) {
if (!RENDER_CREDS) throw new Error(
"browser rendering not configured — set CF_ACCOUNT_ID and CF_API_TOKEN");
const res = await fetchRetry(
`https://api.cloudflare.com/client/v4/accounts/${RENDER_CREDS.accountId}/browser-rendering/content`,
// "load" + a settle delay beats networkidle0: analytics long-polls keep
// some pages from ever going network-idle.
{ json: { url, gotoOptions: { waitUntil: "load", timeout: 45000 }, waitForTimeout: 4000 },
headers: { Authorization: `Bearer ${RENDER_CREDS.token}` },
timeoutMs: 75000 });
const d = JSON.parse(res);
if (!d.success) throw new Error(`browser rendering: ${JSON.stringify(d.errors ?? d).slice(0, 200)}`);
return pageText(d.result);
}
// Parsers take rendered page text, so they can be tested without credentials.
export const renderParsers = {
// "gpu-h100 $ 0.001525 /sec $ 5.49 /hr GPU 1x ..."; multi-GPU sizes in
// the "Additional hardware" list note "committed spend contracts".
replicate(t) {
return [...t.matchAll(/gpu-([a-z0-9]+)(?:-large)?(?:-(\d)x)?\s+\$\s*[\d.]+\s*\/\s*sec\s+\$\s*([\d.]+)\s*\/\s*hr/g)]
.map(m => {
const next = t.indexOf("gpu-", m.index + 4); // this entry's text only
return row(m[1], "Replicate", parseFloat(m[3]) / (+m[2] || 1), {
count: +m[2] || 1, avail: true,
ptype: /committed spend/.test(t.slice(m.index, next < 0 ? m.index + 240 : next))
? "reserved" : "on_demand",
url: "https://replicate.com/pricing" });
});
},
// Novita: "H100 SXM 80GB 80 GB VRAM 3.39/hr/GPU 1.70/hr/GPU" (spot is "—"
// when absent).
novita(t) {
return [...t.matchAll(/([A-Z][\w ]{1,14}?)\s+(\d{2,3})GB\s+\d{2,3} GB VRAM\s+([\d.]+)\/hr\/GPU\s+(?:([\d.]+)\/hr\/GPU|—)/g)]
.flatMap(m => [
row(m[1], "Novita", m[3], { vram: +m[2], url: "https://novita.ai/gpus" }),
m[4] ? row(m[1], "Novita", m[4], { vram: +m[2], ptype: "spot", url: "https://novita.ai/gpus" }) : null,
]);
},
};
const replicate = async () => renderParsers.replicate(await renderPage("https://replicate.com/pricing"));
const novita = async () => renderParsers.novita(await renderPage("https://novita.ai/gpus"));
export const PROVIDERS = {
vast: { names: ["Vast.ai"], fn: vast },
runpod: { names: ["Runpod", "Runpod Community"], fn: runpod },
lambda: { names: ["Lambda Labs"], fn: lambdaLabs },
coreweave: { names: ["CoreWeave"], fn: coreweave },
nebius: { names: ["Nebius"], fn: nebius },
crusoe: { names: ["Crusoe"], fn: crusoe },
salad: { names: ["Salad Cloud"], fn: salad },
verda: { names: ["Verda"], fn: verda },
azure: { names: ["Azure"], fn: azure },
aws: { names: ["AWS"], fn: aws },
oracle: { names: ["Oracle Cloud"], fn: oracle },
digitalocean: { names: ["DigitalOcean"], fn: digitalocean },
together: { names: ["Together AI"], fn: together },
modal: { names: ["Modal"], fn: modal },
jarvis: { names: ["Jarvis Labs"], fn: jarvis },
cudo: { names: ["Cudo Compute"], fn: cudo },
voltagepark: { names: ["Voltage Park"], fn: voltagepark },
hotaisle: { names: ["Hot Aisle"], fn: hotaisle },
hyperstack: { names: ["Hyperstack"], fn: hyperstack },
fal: { names: ["fal"], fn: fal },
koyeb: { names: ["Koyeb"], fn: koyeb },
baseten: { names: ["Baseten"], fn: baseten },
civo: { names: ["Civo"], fn: civo },
denvr: { names: ["Denvr Dataworks"], fn: denvr },
scaleway: { names: ["Scaleway"], fn: scaleway },
ovh: { names: ["OVHcloud"], fn: ovh },
leadergpu: { names: ["LeaderGPU"], fn: leadergpu },
seeweb: { names: ["Seeweb"], fn: seeweb },
lium: { names: ["Lium.io"], fn: lium },
thunder: { names: ["Thunder Compute"], fn: thunder },
spheron: { names: ["Spheron"], fn: spheron },
beam: { names: ["Beam"], fn: beam },
massed: { names: ["Massed Compute"], fn: massed },
gpuai: { names: ["GPU.ai"], fn: gpuai },
latitude: { names: ["Latitude.sh"], fn: latitude },
replicate: { names: ["Replicate"], fn: replicate, render: true },