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/adminheader.py +59 -0
- frutebox/attest.py +43 -0
- frutebox/audiogen.py +364 -0
- frutebox/config.py +367 -0
- frutebox/deltasync.py +211 -0
- frutebox/economics.py +270 -0
- frutebox/engine_guard.py +718 -0
- frutebox/envelope.py +77 -0
- frutebox/envvars.py +167 -0
- frutebox/gpuprices.py +641 -0
- frutebox/gpuproviders.py +4489 -0
- frutebox/gui.py +270 -0
- frutebox/imagegen.py +152 -0
- frutebox/inference.py +624 -0
- frutebox/manifest.py +218 -0
- frutebox/mediaserving.py +603 -0
- frutebox/packaging.py +542 -0
- frutebox/pqctransport.py +660 -0
- frutebox/provider.py +1867 -0
- frutebox/providers.py +942 -0
- frutebox/registry/__init__.py +189 -0
- frutebox/registry/generate.py +311 -0
- frutebox/registry/generate_providers.py +120 -0
- frutebox/registry/models.yaml +1759 -0
- frutebox/registry/providers.yaml +518 -0
- frutebox/registry/registry.gen.json +2043 -0
- frutebox/registry_gen.py +3 -0
- frutebox/runpod_templates.py +143 -0
- frutebox/serving.py +691 -0
- frutebox/signedconfig.py +259 -0
- frutebox/signing.py +208 -0
- frutebox/thinkbudget.py +155 -0
- frutebox/videogen.py +118 -0
- frutebox_node-0.1.0.dist-info/METADATA +100 -0
- frutebox_node-0.1.0.dist-info/RECORD +36 -0
- frutebox_node-0.1.0.dist-info/WHEEL +4 -0
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]
|