frutebox-node 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
frutebox/gpuprices.py ADDED
@@ -0,0 +1,641 @@
1
+ """LIVE GPU prices — what an hour of each GPU class actually costs, per provider (file 01-A).
2
+
3
+ WHY THIS MODULE EXISTS
4
+ `selection.GPU_TIERS` priced tier S at **$2.50/hr**. Reality for the 5xH200 pod we actually
5
+ serve frontier models on is **$17.95-22.95/hr**. Every "cheaper than the frontier APIs at >=75%
6
+ margin" figure computed from that table understated frontier cost by roughly **8x** — which
7
+ means the guarantee the whole pricing model rests on was arithmetically false, not marginally
8
+ off. Static numbers in source also rot silently: a provider re-prices, the table does not, and
9
+ nothing fails loudly.
10
+
11
+ So prices are fetched live per provider and cached. The static tables remain, but ONLY as the
12
+ cold-boot fallback.
13
+
14
+ WHERE THE CACHE LIVES (and why not here)
15
+ This process is deliberately stateless across restarts — the control plane holds no database
16
+ credentials (`directory_control.py`: the web app is the only service with database access). A
17
+ price cache in module memory would be empty after every deploy, and Cloud Run redeploys often.
18
+ So the cache is a Postgres table owned by the web app, read and written over
19
+ `/api/internal/gpu-prices` with the shared admin token, exactly as the armed flag already
20
+ works. Module state here is a hot cache in front of that.
21
+
22
+ THE RULE THAT SHAPES EVERY FUNCTION BELOW: **a price lookup must never block dispatch and must
23
+ never raise.** A provider being down, the web app being slow, an empty cache on a cold boot —
24
+ each degrades to the next-best number (live -> cached -> static fallback) and logs. Pricing a
25
+ job slightly wrong is a bookkeeping error; failing to answer a user's prompt because a price
26
+ API timed out is an outage.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import json
32
+ import time
33
+ from dataclasses import dataclass
34
+ from typing import Dict, List, Optional, Tuple
35
+
36
+ import httpx
37
+
38
+ from .config import CONFIG
39
+ from .envvars import env as envvar, env_required
40
+ from . import adminheader
41
+
42
+ # --- Static fallback -------------------------------------------------------------------------
43
+ # Honest static values, corrected from the pre-file-01 table (which had S=2.50, A=0.80). These
44
+ # are what the tiers cost when we cannot reach a single provider or the cache. Sources: RunPod
45
+ # Community on-demand observed this project — A40 0.35-0.44/hr, 5xH200 17.95/hr.
46
+ #
47
+ # `static_fallback` is carried in the row itself so anything reporting a margin can say whether
48
+ # the number it used was measured or assumed. A margin computed from a guess should not look
49
+ # identical to one computed from a live price.
50
+ TIER_STATIC: Dict[str, dict] = {
51
+ "C": {"vram_gb": 0, "usd_per_hr": 0.10, "example": "CPU / shared tiny GPU", "static_fallback": True},
52
+ "B": {"vram_gb": 16, "usd_per_hr": 0.40, "example": "RTX A4000 / A40 slice", "static_fallback": True},
53
+ "A": {"vram_gb": 48, "usd_per_hr": 0.60, "example": "A40 / A6000", "static_fallback": True},
54
+ "S": {"vram_gb": 80, "usd_per_hr": 18.00, "example": "H200 pool (5x = $17.95/hr)", "static_fallback": True},
55
+ }
56
+
57
+ #: Minimum usable VRAM (GB) a card must have to serve a tier. Tier C is CPU-or-anything.
58
+ TIER_MIN_VRAM_GB: Dict[str, int] = {t: int(v["vram_gb"]) for t, v in TIER_STATIC.items()}
59
+
60
+ TTL_S = float(envvar("GPUPRICE_TTL_S", "3600"))
61
+
62
+
63
+ #: RESOLVED LAZILY, AND THAT IS NOT A STYLE CHOICE — the same finding `frutebox/deltasync.py` records
64
+ #: above its own `web_url()`, in a second module, with a second victim.
65
+ #:
66
+ #: At module scope this raised `MissingConfig` ON IMPORT, and the import chain is long:
67
+ #: `router.route()` -> `selection` -> `gpuprices`. So **file 11-E2's CI latency gate could not run at
68
+ #: all** without `FRUTEBOX_WEB_URL` — `python scripts/perf_harness.py` died in the import, before it
69
+ #: measured anything, on any machine or CI job that did not happen to set a variable a latency harness
70
+ #: has no business needing. A gate that cannot start is not a gate, and it fails in the one direction
71
+ #: nobody investigates: the run is red for a reason that looks like configuration.
72
+ #:
73
+ #: Only the two cache calls need the URL, and they are the only places that now pay for it — at call
74
+ #: time, where an unset variable is a real error about a real request rather than an import failure
75
+ #: three modules away from the thing that wanted it.
76
+ def web_url() -> str:
77
+ return env_required("WEB_URL").rstrip("/")
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Price:
82
+ provider: str
83
+ gpu_type: str
84
+ usd_per_hr: float
85
+ vram_gb: int = 0
86
+ region: str = ""
87
+ secure: bool = False
88
+ reliability: Optional[float] = None
89
+ available: bool = True
90
+
91
+
92
+ # Hot cache. `fetched_at` 0.0 means "never loaded" — distinct from "loaded and found nothing",
93
+ # which is a real answer (no configured providers) and must not trigger a refetch storm.
94
+ _CACHE: Dict[str, object] = {"prices": [], "region_rates": {}, "fetched_at": 0.0}
95
+
96
+
97
+ def _log(msg: str) -> None:
98
+ print(f"[gpuprices] {msg}")
99
+
100
+
101
+ def cached() -> List[Price]:
102
+ return list(_CACHE["prices"]) # type: ignore[arg-type]
103
+
104
+
105
+ def stale() -> bool:
106
+ return (time.time() - float(_CACHE["fetched_at"])) > TTL_S # type: ignore[arg-type]
107
+
108
+
109
+ # --- Provider fetchers -----------------------------------------------------------------------
110
+ # Each returns [] on any failure rather than raising: one provider being unreachable must not
111
+ # stop the other two from refreshing. Field names verified against each provider's current API
112
+ # (RunPod GraphQL GpuType, Vast /bundles offers, Lambda /instance-types).
113
+
114
+ async def _fetch_runpod() -> List[Price]:
115
+ # Through the ONE door (KB §0): the provider owns the endpoint, the auth header and the query.
116
+ # This module only maps the reply into Price rows.
117
+ from .gpuproviders import PROVIDERS
118
+ types = await PROVIDERS["runpod"].gpu_prices()
119
+
120
+ out: List[Price] = []
121
+ for g in types:
122
+ name = str(g.get("displayName") or g.get("id") or "").strip()
123
+ vram = int(g.get("memoryInGb") or 0)
124
+ if not name:
125
+ continue
126
+ # Community and Secure are genuinely different products at different prices, not two
127
+ # quotes for the same thing: the warm pool may use Community, `spec.secure` may not.
128
+ # Storing them as one row would make the pool look cheaper than a secure job can buy.
129
+ for field, secure in (("communityPrice", False), ("securePrice", True)):
130
+ p = g.get(field)
131
+ if isinstance(p, (int, float)) and p > 0:
132
+ out.append(Price("runpod", name, float(p), vram, "", secure))
133
+ return out
134
+
135
+
136
+ async def _fetch_vast() -> List[Price]:
137
+ # THE CREDENTIAL COMES FROM THE REGISTRY, NOT THE ENVIRONMENT (§A9 / §O).
138
+ #
139
+ # This line read VAST_API_KEY straight off the process environment, which bypassed the DB
140
+ # precedence §O decided on a path nobody was auditing — `FRUTEBOX_PROVIDER_ENV_FALLBACK` is
141
+ # false, so with the key held only in the DB this returned `[]` and NOTHING FAILED. The cost
142
+ # is silent and it is the same shape as the merit-order defect closed one level up: no Vast
143
+ # rows means `rank()` has no Vast price to compare, and cheapest-first quietly degrades into
144
+ # "whatever order `WARMPOOL_PROVIDERS` happens to be in". A merit order that has stopped
145
+ # being a merit order still returns an answer.
146
+ #
147
+ # Imported inside the function, like `_fetch_runpod` and `_fetch_datacrunch` above: this
148
+ # module is imported by `selection` and thence by `gateway`, and the lazy edge onto the
149
+ # single door is the established way that stays safe regardless of import order.
150
+ from .gpuproviders import provider_credential
151
+ key = provider_credential("vast", "VAST_API_KEY")
152
+ if not key:
153
+ return []
154
+ try:
155
+ async with httpx.AsyncClient(timeout=30) as c:
156
+ r = await c.post(
157
+ "https://console.vast.ai/api/v0/bundles",
158
+ headers={"Authorization": f"Bearer {key}", "content-type": "application/json"},
159
+ json={"limit": 200, "type": "ondemand", "rentable": {"eq": True},
160
+ "verified": {"eq": True}},
161
+ )
162
+ if r.status_code >= 300:
163
+ _log(f"vast fetch HTTP {r.status_code}")
164
+ return []
165
+ offers = (r.json() or {}).get("offers") or []
166
+ except Exception as e:
167
+ _log(f"vast fetch error: {type(e).__name__}")
168
+ return []
169
+
170
+ # Vast is a marketplace: many offers of the same GPU at different prices. Keep the cheapest
171
+ # per GPU type — that is the one the wake path would actually rent.
172
+ best: Dict[str, Price] = {}
173
+ for o in offers:
174
+ name = str(o.get("gpu_name") or "").strip()
175
+ dph = o.get("dph_total")
176
+ n = int(o.get("num_gpus") or 1) or 1
177
+ if not name or not isinstance(dph, (int, float)) or dph <= 0:
178
+ continue
179
+ per_gpu = float(dph) / n
180
+ rel = o.get("reliability2")
181
+ # gpu_ram is reported in MB.
182
+ vram = int(float(o.get("gpu_ram") or 0) / 1024)
183
+ cur = best.get(name)
184
+ if cur is None or per_gpu < cur.usd_per_hr:
185
+ best[name] = Price("vast", name, per_gpu, vram, "", False,
186
+ float(rel) if isinstance(rel, (int, float)) else None)
187
+ return list(best.values())
188
+
189
+
190
+ async def _fetch_lambda() -> List[Price]:
191
+ # THE SECOND BYPASS, IN THE SAME FILE, AND NOBODY HAD COUNTED IT. `_fetch_vast` was found by
192
+ # accident during a sweep; this one sat four lines of reasoning away and was invisible until
193
+ # the whole class was enumerated rather than the one instance. Identical failure, identical
194
+ # silence — see `_fetch_vast` above, and `tests/test_provider_credential_env_bypass_guard.py`
195
+ # for the guard that now fails when a third one appears.
196
+ from .gpuproviders import provider_credential
197
+ key = provider_credential("lambda", "LAMBDA_API_KEY")
198
+ if not key:
199
+ return []
200
+ try:
201
+ from .gpuproviders import LambdaProvider # reuse the base URL; one source for it
202
+ base = LambdaProvider._BASE
203
+ async with httpx.AsyncClient(timeout=30) as c:
204
+ r = await c.get(f"{base}/instance-types",
205
+ headers={"Authorization": f"Bearer {key}"})
206
+ if r.status_code >= 300:
207
+ _log(f"lambda fetch HTTP {r.status_code}")
208
+ return []
209
+ data = (r.json() or {}).get("data") or {}
210
+ except Exception as e:
211
+ _log(f"lambda fetch error: {type(e).__name__}")
212
+ return []
213
+
214
+ out: List[Price] = []
215
+ # `data` is keyed by instance-type name; each value holds the type plus its regions.
216
+ for entry in (data.values() if isinstance(data, dict) else []):
217
+ it = (entry or {}).get("instance_type") or {}
218
+ cents = it.get("price_cents_per_hour")
219
+ name = str(it.get("gpu_description") or it.get("name") or "").strip()
220
+ if not name or not isinstance(cents, (int, float)) or cents <= 0:
221
+ continue
222
+ regions = [str(r.get("name")) for r in ((entry or {}).get("regions_with_capacity_available") or [])
223
+ if isinstance(r, dict) and r.get("name")]
224
+ # Lambda quotes per INSTANCE, which may hold several GPUs; divide so every provider's
225
+ # rows mean the same thing (price of one card-hour) and are therefore comparable.
226
+ n = 1
227
+ desc = str(it.get("description") or "")
228
+ for k in (8, 4, 2):
229
+ if f"{k}x" in name or f"{k}x" in desc:
230
+ n = k
231
+ break
232
+ per_gpu = (float(cents) / 100.0) / n
233
+ # No capacity anywhere = a real price for something you cannot rent. Keep it, but mark
234
+ # it unavailable so it never wins a ranking and then fails to place.
235
+ out.append(Price("lambda", name, per_gpu, 0, regions[0] if regions else "",
236
+ True, None, bool(regions)))
237
+ return out
238
+
239
+
240
+ async def _fetch_datacrunch() -> List[Price]:
241
+ """DataCrunch / Verda prices — the Phase-1 EU provider, so the EU half of the merit order
242
+ only exists once this returns rows.
243
+
244
+ Auth goes through `DataCrunchProvider._access_token()` rather than a second OAuth exchange
245
+ written here: one place mints DataCrunch tokens, the same way `_fetch_lambda` borrows
246
+ Lambda's base URL instead of restating it.
247
+
248
+ Two calls, because the vendor separates them: `/instance-types` carries the PRICE and
249
+ `/instance-availability` carries the STOCK. A price with no stock anywhere is a real number
250
+ for something you cannot rent, so it is kept but marked unavailable — identical to the Lambda
251
+ treatment, and it stops a phantom cheapest-row winning a ranking and then failing to place.
252
+ """
253
+ from .gpuproviders import PROVIDERS
254
+ prov = PROVIDERS.get("datacrunch")
255
+ if prov is None or not prov.configured():
256
+ return []
257
+ try:
258
+ headers = await prov._headers()
259
+ if headers is None:
260
+ _log("datacrunch auth failed")
261
+ return []
262
+ base = prov._BASE
263
+ async with httpx.AsyncClient(timeout=30) as c:
264
+ r = await c.get(f"{base}/instance-types", headers=headers)
265
+ if r.status_code >= 300:
266
+ _log(f"datacrunch instance-types HTTP {r.status_code}")
267
+ return []
268
+ types = r.json() or []
269
+ a = await c.get(f"{base}/instance-availability", headers=headers)
270
+ avail_rows = (a.json() or []) if a.status_code < 300 else []
271
+ except Exception as e:
272
+ _log(f"datacrunch fetch error: {type(e).__name__}")
273
+ return []
274
+
275
+ # {instance_type: first location offering it}
276
+ in_stock: Dict[str, str] = {}
277
+ for loc in avail_rows if isinstance(avail_rows, list) else []:
278
+ if not isinstance(loc, dict):
279
+ continue
280
+ code = str(loc.get("location_code") or "")
281
+ for t in loc.get("availabilities") or []:
282
+ in_stock.setdefault(str(t), code)
283
+
284
+ out: List[Price] = []
285
+ for t in types if isinstance(types, list) else []:
286
+ if not isinstance(t, dict):
287
+ continue
288
+ name = str(t.get("instance_type") or "").strip()
289
+ if not name:
290
+ continue
291
+ try:
292
+ # The vendor quotes price as a STRING ("3.17"); float() it rather than trusting a
293
+ # numeric type that is not there.
294
+ total = float(t.get("price_per_hour") or 0)
295
+ except (TypeError, ValueError):
296
+ continue
297
+ if total <= 0:
298
+ continue
299
+ gpu = t.get("gpu") or {}
300
+ n = int(gpu.get("number_of_gpus") or 1) or 1
301
+ vram = int((t.get("gpu_memory") or {}).get("size_in_gigabytes") or 0)
302
+ # Per CARD-hour, so a row here means the same thing as a RunPod/Vast/Lambda row and the
303
+ # merit order is comparing like with like. `vram` is likewise per card.
304
+ out.append(Price("datacrunch", str(gpu.get("description") or t.get("model") or name),
305
+ total / n, vram // n if vram and n else vram,
306
+ in_stock.get(name, ""), True, None, name in in_stock))
307
+ return out
308
+
309
+
310
+ #: Nebius $/hr per PRESET, operator-supplied — `{"gpu-h100-sxm/1gpu-16vcpu-200gb": 2.95}`.
311
+ #:
312
+ #: WHY THE PRICE IS CONFIGURATION HERE AND DISCOVERED EVERYWHERE ELSE. Every other provider in
313
+ #: this file publishes a price list and the fetcher reads it. Nebius does NOT: its REST surface
314
+ #: offers `/compute/v1/platforms` (platforms, presets, gpu counts, VRAM — no money) and
315
+ #: `/billing/v1/pricing-policies` (an ACCOUNT's policy, not a catalog). Checked against the
316
+ #: published OpenAPI document 2026-10-16, which is the whole 443-path surface.
317
+ #:
318
+ #: So there are three options and only one of them is honest. Inventing a number puts a phantom
319
+ #: cheapest row in front of the merit order — the 8x-wrong static table this module was written to
320
+ #: delete. Scraping the marketing page makes the fleet's placement depend on a web page's markup.
321
+ #: Or the operator states the price they are actually contracted at, which for a provider whose
322
+ #: GPU pricing is committed-use is the only number that was ever true for this account.
323
+ #:
324
+ #: UNSET IS NOT AN ERROR. Nebius then contributes no price rows, and
325
+ #: `nodewake._create_cheapest_first` already handles that case deliberately: priced providers sort
326
+ #: first, then "any allowed provider we have no price for — an unpriced provider is still worth
327
+ #: trying before giving up entirely". So Nebius stays reachable and simply sorts last. THAT IS A
328
+ #: REAL COST and it is written down rather than hidden: until this is set, the preference that
329
+ #: says "prefer Nebius over RunPod" is outranked by every provider that quotes a number.
330
+ async def _fetch_nebius() -> List[Price]:
331
+ """Nebius prices — the operator's $/hr per preset, with the SHAPE discovered from the vendor.
332
+
333
+ THE SPLIT IS THE POINT, and it is §A5's ("the owner supplies what only they know, the code
334
+ discovers the rest") applied to a price rather than a credential:
335
+
336
+ * the operator states `platform/preset -> $/hr`, because only the account knows its rate;
337
+ * the code reads `/compute/v1/platforms` for the preset's gpuCount and VRAM, because those
338
+ are facts about the hardware and a wrong guess at them is a wrong placement.
339
+
340
+ A PRICE FOR A PRESET THE VENDOR DOES NOT OFFER IS KEPT AND MARKED UNAVAILABLE, exactly as the
341
+ Verda and Lambda fetchers treat a priced-but-unstocked type. Dropping it silently would make a
342
+ typo in the operator's table look like a provider with no capacity; `available=False` keeps it
343
+ out of `rank()` while leaving it visible to anyone reading the cache.
344
+ """
345
+ from .gpuproviders import PROVIDERS, NEBIUS_INSTANCES_PATH # noqa: F401 (one door)
346
+ prov = PROVIDERS.get("nebius")
347
+ if prov is None or not prov.configured():
348
+ return []
349
+
350
+ # ONE PARSER, in `gpuproviders` — see `nebius_price_table`. This module used to parse the
351
+ # table itself, so there were two copies of the same env read and the same JSON handling for
352
+ # the same purpose: the price the merit order ranks on and the price the live proof's cap is
353
+ # checked against could have drifted apart on any fix to either copy. The parser NAMES a
354
+ # malformed table on `frutebox.gpuproviders`' logger, because a silent {} is
355
+ # indistinguishable from "the operator set no prices" and the consequence — Nebius sorting
356
+ # last forever — looks like a provider problem rather than a typo.
357
+ from .gpuproviders import nebius_price_table
358
+ table = nebius_price_table()
359
+
360
+ # The catalog, through the provider's own auth (one door). A failure here does NOT discard the
361
+ # operator's prices: the shape is then unknown, which costs a VRAM filter, while discarding the
362
+ # price costs the provider its place in the merit order entirely.
363
+ shapes: Dict[str, tuple] = {}
364
+ headers = None
365
+ try:
366
+ headers = await prov._headers()
367
+ if headers is None:
368
+ _log("nebius auth failed")
369
+ return []
370
+ project = await prov._resolve_project()
371
+ if not project:
372
+ _log("nebius has no project for its region — pricing without a discovered shape")
373
+ raise RuntimeError("no project")
374
+ async with httpx.AsyncClient(timeout=30) as c:
375
+ r = await c.get(f"{prov._BASE}/compute/v1/platforms", headers=headers,
376
+ params={"parentId": project})
377
+ if r.status_code < 300:
378
+ for plat in (r.json() or {}).get("items") or []:
379
+ pname = str(((plat.get("metadata") or {}).get("name") or "")).strip()
380
+ pspec = plat.get("spec") or {}
381
+ for preset in pspec.get("presets") or []:
382
+ res = preset.get("resources") or {}
383
+ shapes[f"{pname}/{preset.get('name')}"] = (
384
+ int(res.get("gpuCount") or 0),
385
+ int(res.get("gpuMemoryGibibytes")
386
+ or pspec.get("gpuMemoryGibibytes") or 0),
387
+ )
388
+ else:
389
+ _log(f"nebius platforms HTTP {r.status_code} — pricing without a discovered shape")
390
+ except Exception as e:
391
+ _log(f"nebius platforms fetch error: {type(e).__name__} — pricing without a shape")
392
+
393
+ # THE OPERATOR'S TABLE IS NO LONGER REQUIRED, and requiring it kept Nebius out of the merit
394
+ # order entirely. This returned `[]` whenever `FRUTEBOX_NEBIUS_PRICES` was unset, so with a
395
+ # working key, a real catalog and real capacity, Nebius contributed ZERO rows to `rank()` and
396
+ # could never be chosen — measured 2026-10-20: "refreshed 79 prices from 1 provider(s)" with
397
+ # both providers configured. `derive_nebius_pod_spec` had already stopped depending on the
398
+ # table (the vendor prices a configuration through its calculator); this is the same
399
+ # correction applied to the other reader of the same fact, so the price the race ranks on and
400
+ # the price the cap is checked against still come from the same two sources in the same order.
401
+ #
402
+ # ORDER, AND WHY: the operator's table FIRST because it is what the account is contracted at,
403
+ # the calculator SECOND because it is list price for the configuration. Same precedence as the
404
+ # derivation — a provider that ranked on list price while spending at a contracted rate would
405
+ # be mis-ranked in whichever direction the discount runs.
406
+ gpu_keys = [k for k, (gpus, _v) in shapes.items() if gpus >= 1]
407
+ need = [k for k in gpu_keys if k not in table]
408
+ estimated: Dict[str, float] = {}
409
+ if need and headers is not None:
410
+ from .gpuproviders import _nebius_estimate_hourly
411
+ project = await prov._resolve_project()
412
+ async def _one(key: str):
413
+ platform, _, preset = key.partition("/")
414
+ return key, await _nebius_estimate_hourly(prov, headers, project, platform, preset)
415
+ # Concurrently, because this is one round trip per preset on a refresh path with a TTL —
416
+ # 17 presets served serially at 30s timeouts each is a price cache that never finishes.
417
+ for key, val in await asyncio.gather(*(_one(k) for k in need)):
418
+ if val is not None and val > 0:
419
+ estimated[key] = val
420
+
421
+ #: The project's region, so an EU-locked job can see that these rows ARE EU. It was left blank,
422
+ #: which reads as "region unknown" to every filter downstream — and a Nebius row that cannot
423
+ #: prove its region is one an EU-locked job must skip, so blank here costs the provider the
424
+ #: work the owner just said it may do.
425
+ #:
426
+ #: `_assert_eu_project` is what populates `_project_region`, so it is ASKED rather than assumed:
427
+ #: the region is a property of the PROJECT at Nebius, not of the instance. Its verdict is not
428
+ #: used to drop prices — an unverified region means these rows carry no region and are filtered
429
+ #: out downstream, which is the fail-closed direction already.
430
+ region = ""
431
+ try:
432
+ pid = await prov._resolve_project()
433
+ await prov._assert_eu_project(pid)
434
+ region = prov._project_region.get(pid, "") or ""
435
+ except Exception as e:
436
+ _log(f"nebius project region unresolved ({type(e).__name__}) — rows carry no region")
437
+
438
+ out: List[Price] = []
439
+ for key in sorted(set(table) | set(estimated)):
440
+ raw = table.get(key, estimated.get(key))
441
+ try:
442
+ total = float(raw)
443
+ except (TypeError, ValueError):
444
+ continue
445
+ if total <= 0:
446
+ continue
447
+ gpus, vram = shapes.get(str(key), (0, 0))
448
+ n = gpus or 1
449
+ out.append(Price(
450
+ "nebius", str(key),
451
+ # Per CARD-hour, so a nebius row means the same thing as a runpod/verda row and the
452
+ # merit order compares like with like.
453
+ total / n,
454
+ vram // n if vram and n else vram,
455
+ region, True, None,
456
+ # Known to the vendor's catalog? An unknown preset is a typo in the operator's table,
457
+ # not capacity. Kept and flagged rather than dropped. A calculator-priced row is in the
458
+ # catalog by construction — that is where its key came from.
459
+ str(key) in shapes if shapes else True,
460
+ ))
461
+ if not out:
462
+ _log("nebius priced nothing: operator table empty and the calculator answered for no "
463
+ "preset — NOT the same as 'no capacity'")
464
+ return out
465
+
466
+
467
+ _FETCHERS = {"runpod": _fetch_runpod, "vast": _fetch_vast, "lambda": _fetch_lambda,
468
+ "datacrunch": _fetch_datacrunch, "nebius": _fetch_nebius}
469
+
470
+
471
+ async def fetch_all() -> List[Price]:
472
+ """Fetch from every configured provider concurrently. Never raises."""
473
+ results = await asyncio.gather(*(f() for f in _FETCHERS.values()), return_exceptions=True)
474
+ out: List[Price] = []
475
+ for name, res in zip(_FETCHERS, results):
476
+ if isinstance(res, Exception):
477
+ _log(f"{name} fetch raised: {type(res).__name__}")
478
+ continue
479
+ out.extend(res) # type: ignore[arg-type]
480
+ return out
481
+
482
+
483
+ # --- Web-owned persistence -------------------------------------------------------------------
484
+
485
+ def _headers() -> dict:
486
+ return adminheader.send(CONFIG.admin_token)
487
+
488
+
489
+ async def persist(prices: List[Price]) -> bool:
490
+ if not prices:
491
+ return False
492
+ body = {"prices": [{"provider": p.provider, "gpuType": p.gpu_type, "region": p.region,
493
+ "usdPerHr": p.usd_per_hr, "vramGb": p.vram_gb, "secure": p.secure,
494
+ "reliability": p.reliability, "available": p.available}
495
+ for p in prices]}
496
+ try:
497
+ async with httpx.AsyncClient(timeout=30) as c:
498
+ r = await c.post(f"{web_url()}/api/internal/gpu-prices", headers=_headers(), json=body)
499
+ return r.status_code < 300
500
+ except Exception as e:
501
+ _log(f"persist error: {type(e).__name__}")
502
+ return False
503
+
504
+
505
+ async def load_from_web() -> bool:
506
+ """Populate the hot cache from the web-owned table. Used on boot, before any fetch."""
507
+ try:
508
+ async with httpx.AsyncClient(timeout=30) as c:
509
+ r = await c.get(f"{web_url()}/api/internal/gpu-prices", headers=_headers())
510
+ if r.status_code >= 300:
511
+ return False
512
+ data = r.json() or {}
513
+ except Exception as e:
514
+ _log(f"load error: {type(e).__name__}")
515
+ return False
516
+
517
+ prices = [Price(str(p.get("provider")), str(p.get("gpu_type")), float(p.get("usd_per_hr") or 0),
518
+ int(p.get("vram_gb") or 0), str(p.get("region") or ""), bool(p.get("secure")),
519
+ p.get("reliability"), p.get("available") is not False)
520
+ for p in (data.get("prices") or [])
521
+ if p.get("provider") and p.get("gpu_type") and float(p.get("usd_per_hr") or 0) > 0]
522
+ _CACHE["prices"] = prices
523
+ _CACHE["region_rates"] = data.get("region_rates") or {}
524
+ if prices:
525
+ _CACHE["fetched_at"] = time.time()
526
+ return bool(prices)
527
+
528
+
529
+ async def refresh(force: bool = False) -> int:
530
+ """Fetch -> persist -> update the hot cache. Returns how many prices are now cached.
531
+
532
+ On a total fetch failure the previous cache is left untouched and a warning logged: a stale
533
+ price is always better than no price, because the alternative is falling back to the static
534
+ table that was 8x wrong.
535
+
536
+ TTL-GATED, so a caller on the wake path can call it every time without a fetch every time.
537
+ `nodewake._create_cheapest_first` is that caller: this is the only thing that puts a price in
538
+ the cache, and `rank()` reads the cache, so without it the merit order has nothing to order
539
+ by and the "cheapest provider with stock wins" race silently degrades to allow-list order.
540
+
541
+ THE WEB-OWNED TABLE IS READ ON EVERY REFRESH, not only when a fetch fails. It carries two
542
+ things: the cached prices AND the admin-edited `region_rates` that `economics.rates_for()`
543
+ prefers over its seed file (01-D2, "owner-editable via admin"). Loading it only in the
544
+ failure branch meant that on a deployment where the provider fetches WORK — the normal case —
545
+ an owner's rate edit could never reach settlement at all.
546
+ """
547
+ if not force and not stale():
548
+ return len(cached())
549
+ had = float(_CACHE["fetched_at"]) # type: ignore[arg-type]
550
+ await load_from_web()
551
+ fresh = await fetch_all()
552
+ if not fresh:
553
+ _log("no prices fetched from any provider — keeping previous cache")
554
+ # Reading the fallback is not a successful refresh. Leaving the clock where it was means
555
+ # the next wake retries the providers instead of treating the DB copy as fresh for a
556
+ # whole TTL and never noticing that every provider fetch is failing.
557
+ _CACHE["fetched_at"] = had
558
+ return len(cached())
559
+ await persist(fresh)
560
+ _CACHE["prices"] = fresh
561
+ _CACHE["fetched_at"] = time.time()
562
+ _log(f"refreshed {len(fresh)} prices from {len({p.provider for p in fresh})} provider(s)")
563
+ return len(fresh)
564
+
565
+
566
+ # --- The read path (sync, non-blocking, never raises) ----------------------------------------
567
+
568
+ def _eligible(tier: str, region: str = "", *, secure: bool = False) -> List[Price]:
569
+ need = TIER_MIN_VRAM_GB.get(tier, 0)
570
+ out = []
571
+ for p in cached():
572
+ if not p.available or p.usd_per_hr <= 0:
573
+ continue
574
+ if secure and not p.secure:
575
+ continue
576
+ # vram_gb 0 means the provider did not report it; do not exclude on unknown, except
577
+ # where the tier actually needs a big card and we would be guessing.
578
+ if need and p.vram_gb and p.vram_gb < need:
579
+ continue
580
+ if need and not p.vram_gb:
581
+ continue
582
+ if region and p.region and p.region != region:
583
+ continue
584
+ if p.reliability is not None and p.reliability < 0.95:
585
+ continue
586
+ out.append(p)
587
+ return out
588
+
589
+
590
+ def price_for(tier: str, region: str = "", *, secure: bool = False) -> float:
591
+ """Cheapest live $/hr for one card of a class that can serve `tier`.
592
+
593
+ Falls back to the static table when no live price qualifies. Tier C has no VRAM floor, so
594
+ it takes the cheapest card of any size.
595
+ """
596
+ live = _eligible(tier, region, secure=secure)
597
+ if live:
598
+ return round(min(p.usd_per_hr for p in live), 4)
599
+ return float(TIER_STATIC.get(tier, TIER_STATIC["B"])["usd_per_hr"])
600
+
601
+
602
+ def is_live(tier: str, region: str = "", *, secure: bool = False) -> bool:
603
+ """Whether `price_for` answered from a measured price or from the static fallback — so a
604
+ reported margin can say which it was instead of presenting a guess as a measurement."""
605
+ return bool(_eligible(tier, region, secure=secure))
606
+
607
+
608
+ def rank(spec: dict, region: str = "") -> List[Tuple[str, str, float]]:
609
+ """Cheapest-first [(provider, gpu_type, usd_per_hr)] for a wake spec.
610
+
611
+ `spec` accepts `minVramGb`/`vram_gb`, `secure`, and `providers` (an allow-list that also
612
+ sets the tie-break order for equal prices).
613
+ """
614
+ need = int(spec.get("minVramGb") or spec.get("vram_gb") or 0)
615
+ secure = bool(spec.get("secure"))
616
+ allow = [p for p in (spec.get("providers") or []) if p]
617
+ order = {name: i for i, name in enumerate(allow)}
618
+
619
+ rows = []
620
+ for p in cached():
621
+ if not p.available or p.usd_per_hr <= 0:
622
+ continue
623
+ if secure and not p.secure:
624
+ continue
625
+ if need and (not p.vram_gb or p.vram_gb < need):
626
+ continue
627
+ if region and p.region and p.region != region:
628
+ continue
629
+ if p.reliability is not None and p.reliability < 0.95:
630
+ continue
631
+ if allow and p.provider not in order:
632
+ continue
633
+ rows.append(p)
634
+
635
+ rows.sort(key=lambda p: (round(p.usd_per_hr, 4), order.get(p.provider, 99), p.gpu_type))
636
+ return [(p.provider, p.gpu_type, round(p.usd_per_hr, 4)) for p in rows]
637
+
638
+
639
+ def region_rates() -> dict:
640
+ """Per-region bandwidth + electricity rates, from the web cache when present."""
641
+ return dict(_CACHE["region_rates"]) # type: ignore[arg-type]