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.
@@ -0,0 +1,4489 @@
1
+ """GPU providers — one contract, many clouds, no lock-in.
2
+
3
+ WHY THIS EXISTS IN THIS SHAPE
4
+ On 2026-08-21 a pod billed for hours because wake created it under one name and stop looked
5
+ for another. That was not a typo in a helper — it was an ASYMMETRY that the code's shape
6
+ allowed: `start` and `stop` were written separately per provider, so nothing forced them to
7
+ agree on how an instance is identified.
8
+
9
+ Adding more providers multiplies that risk, and the risk is worse than it looks because every
10
+ provider spells the label differently: RunPod and Lambda call it `name`, Vast.ai calls it
11
+ `label`. A per-provider `start`/`stop` pair is three more chances to get it wrong, silently,
12
+ in a direction that costs money and reports success.
13
+
14
+ So a provider does NOT implement start/stop. It implements three narrow operations:
15
+
16
+ find(label) -> the live instances carrying this label
17
+ create(label, spec) -> create ONE instance carrying this label
18
+ destroy(id) -> terminate it
19
+
20
+ and `start`/`stop` are written ONCE, here, on top of them. The symmetry is then structural:
21
+ both sides go through the same `find(label)`, so they cannot disagree about what to look for.
22
+ `contract_violations()` and the shared test suite check each provider actually honours it.
23
+
24
+ WHAT A PROVIDER MUST GET RIGHT
25
+ `create` MUST apply the label the caller passed, not one from its own spec. That single rule is
26
+ the whole bug. It is stated on the method, tested for every provider, and is the first thing to
27
+ check when adding a fourth.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ import time
33
+ import contextlib
34
+ import contextvars
35
+ import hashlib
36
+ import hmac
37
+ import json
38
+ import logging
39
+ import uuid
40
+ from typing import Awaitable, Callable, Dict, List, Optional, Protocol, Set
41
+ from urllib.parse import quote as _urlquote
42
+
43
+ import httpx
44
+
45
+ from .envvars import env as envvar
46
+
47
+ log = logging.getLogger("frutebox.gpuproviders")
48
+
49
+
50
+ class Instance(dict):
51
+ """A live instance: at minimum ``{"id": str, "label": str}``."""
52
+
53
+
54
+ class GpuProvider(Protocol):
55
+ """Three required operations, plus two OPTIONAL enumerations. start/stop are NOT part of
56
+ this — they are derived.
57
+
58
+ WHY ENUMERATION IS THREE METHODS AND NOT ONE (owner ruling, 2026-09-24). "Is this slot
59
+ serving?" and "is anything of mine still billing here?" are DIFFERENT QUESTIONS, and a
60
+ single `find()` has to pick one answer for both. Vast picked capacity — it filters
61
+ `actual_status == "exited"` out — and on 2026-09-24 an exited-but-contracted instance was
62
+ therefore invisible to the pool census AND to the teardown at the same time, while the
63
+ account was billed for it. So the questions are asked separately now, and a provider that
64
+ genuinely cannot tell them apart says so by not implementing the split.
65
+
66
+ AND SAYS SO OUT LOUD, not merely by omission (owner ruling, 2026-09-24, second half). Not
67
+ implementing the split makes the fallback silent: every caller then gets the one answer the
68
+ provider has, dressed as the answer it asked for. `DISTINGUISHES_SERVING` is that omission
69
+ written down, so `stop()`, the placement confirmation and the pool census can REPORT the
70
+ ambiguity instead of resolving it toward "clean".
71
+ """
72
+
73
+ name: str
74
+
75
+ #: Can this provider separate "an instance is PRESENT" from "an instance is SERVING"?
76
+ #:
77
+ #: DECLARED, never inferred from behaviour. It is a statement about the provider's own status
78
+ #: vocabulary — which only the author of the adapter can make — and the default is True
79
+ #: because every provider that implements `find_live`/`find_all` has made it. A provider that
80
+ #: falls back to `find` for both MUST set this False; `test_gpuproviders.py` fails any
81
+ #: registered provider whose declaration and whose methods disagree, so the optimistic
82
+ #: default cannot be inherited by accident.
83
+ DISTINGUISHES_SERVING: bool = True
84
+
85
+ def configured(self) -> bool:
86
+ """Is there a credential for this provider? Never raises."""
87
+
88
+ async def find(self, label: str) -> List[Instance]:
89
+ """Live (non-terminated) instances carrying this label.
90
+
91
+ THE BASE PRIMITIVE, and deliberately NOT the one a caller with a question should reach
92
+ for: see `find_live` / `find_all` below. It is kept because `start`'s idempotency check
93
+ wants exactly this — "is anything of mine already here", answered as conservatively as
94
+ each provider can answer it.
95
+ """
96
+
97
+ async def find_live(self, label: str) -> List[Instance]:
98
+ """OPTIONAL. CAPACITY: instances carrying this label that are actually SERVING.
99
+
100
+ Optional because not every provider can tell. One asked through the module-level
101
+ `find_live()` helper, which falls back to `list_live` and then to `find`.
102
+ """
103
+
104
+ async def find_all(self, label: str) -> List[Instance]:
105
+ """OPTIONAL. TEARDOWN: every instance carrying this label that could still BILL.
106
+
107
+ Optional, same as `find_live`, and asked through the module-level `find_all()` helper.
108
+ """
109
+
110
+ async def create(self, label: str, spec: dict) -> bool:
111
+ """Create ONE instance carrying THIS label.
112
+
113
+ The label argument wins over anything in `spec`. A provider that lets its spec's name
114
+ through is the 2026-08-21 billing bug, reintroduced.
115
+ """
116
+
117
+ async def destroy(self, instance_id: str) -> bool:
118
+ """Terminate it. True when it is gone (or was already)."""
119
+
120
+
121
+ # ---- the two questions, asked separately ---------------------------------------------------
122
+ #
123
+ # THE DEFECT THESE REPLACE (2026-09-24, real money). `find()` answered both "what is serving?"
124
+ # and "what is still billing?", so each provider's author had to choose which one to be wrong
125
+ # about. Vast chose: it drops `exited` instances, which is RIGHT for capacity — an exited pod
126
+ # must not hold the pool's only slot and block its own replacement — and WRONG for teardown,
127
+ # where `nodewake._provider_has`'s own docstring already required the opposite ("a
128
+ # RUNNING-but-runtimeless pod serves nothing and bills anyway, and must still be found").
129
+ # A Vast instance that took the contract and then exited was consequently invisible to BOTH
130
+ # readers at once: `/control/pool` said 0 and the teardown said `terminated: []`, while the
131
+ # account was charged.
132
+ #
133
+ # Neither helper invents an answer a provider cannot give. A provider that cannot distinguish
134
+ # the two states falls back to the one enumeration it has, which is no worse than before — and
135
+ # `test_gpuproviders.py` pins, per provider, which of them can.
136
+ #
137
+ # THE FALLBACK IS NOT FREE, AND IT IS NO LONGER SILENT (owner ruling, 2026-09-24, second half):
138
+ # "where DataCrunch cannot distinguish intent from reality, resolve the ambiguity toward
139
+ # suspected_billing rather than toward clean. It is the EU half of Phase 1, so a provider that
140
+ # cannot answer the teardown question must err on the side of telling you it might be spending."
141
+ # The fallback ANSWER stays what it was — inventing a finer one would mean guessing a status
142
+ # vocabulary, which is the thing the previous pass refused to do and still refuses. What changes
143
+ # is that the answer now travels with `distinguishes_serving()`, and the three places that would
144
+ # otherwise have reported a clean result off it say "presence only" instead.
145
+
146
+
147
+ def distinguishes_serving(provider: GpuProvider) -> bool:
148
+ """Can this provider tell a SERVING instance from one that is merely PRESENT?
149
+
150
+ Read from the provider's own declaration (`DISTINGUISHES_SERVING`), defaulting to True. It is
151
+ not inferred from which methods exist, because inference would silently relabel every test
152
+ double and every future adapter the moment it grew or lost a method — and because the claim
153
+ is about a vendor's status vocabulary, which no amount of introspection can see. The
154
+ declaration is kept honest by a contract test over `PROVIDERS`, not by this function.
155
+
156
+ False means: every answer this provider gives about the label is "listed / not listed". A
157
+ caller that reports success on that basis is reporting something it did not verify.
158
+ """
159
+ return bool(getattr(provider, "DISTINGUISHES_SERVING", True))
160
+
161
+
162
+ def presence_only_note(provider_name: str, label: str, question: str, detail: str,
163
+ terminated: "Optional[List[str]]" = None) -> dict:
164
+ """The ambiguity, in the shape the money channel already carries.
165
+
166
+ Same keys as `nodewake._abandon_unconfirmed`'s note — provider/name/suspected_billing/
167
+ terminated/detail — ON PURPOSE: `scale_to` already collects those into `unconfirmed` and
168
+ lifts the flagged ones into `suspected_billing`, which is precisely the channel the ruling
169
+ asks this to resolve toward. A second, parallel reporting path would have to be wired into
170
+ every reader again, and the 2026-09-17 orphan bucket is what a recorded-but-unread channel
171
+ is worth.
172
+ """
173
+ return {
174
+ "provider": provider_name,
175
+ "name": label,
176
+ "suspected_billing": True,
177
+ "presence_only": question,
178
+ "terminated": [str(i) for i in (terminated or [])],
179
+ "detail": detail,
180
+ }
181
+
182
+
183
+ async def find_live(provider: GpuProvider, label: str) -> List[Instance]:
184
+ """CAPACITY: instances under this label that are actually SERVING.
185
+
186
+ The question the pool census and the scale plan ask. A corpse answered "yes" here holds a
187
+ slot forever and no replacement is ever created.
188
+
189
+ WHICH WAY THE FALLBACK ERRS, AND WHY IT IS THE OPPOSITE OF THE REPORTING DIRECTION. For a
190
+ provider that cannot distinguish, "present" is returned as live. Counting it dead instead
191
+ would create a REPLACEMENT while the ambiguous instance is still contracted — two instances,
192
+ two bills, on exactly the evidence that was too weak to classify one. Money is irreversible
193
+ and a short pool is not. `start()` already makes the same choice for the same reason.
194
+
195
+ AND IT IS BOUNDED, WHICH THIS FUNCTION CANNOT DO. "Present counts as live" held forever would
196
+ deadlock a provider that can never tell: DataCrunch would look permanently full and never
197
+ scale. The bound is a clock and therefore belongs with the pool census, not with a stateless
198
+ enumeration — `nodewake._provider_has` holds an ambiguous slot only for the confirmation
199
+ window and then RELEASES it while RECORDING suspected_billing (owner ruling, 2026-09-24).
200
+ Both halves are required: a release without the record is the silent failure this whole line
201
+ of work exists to remove, and a record without the release deadlocks the provider.
202
+ """
203
+ own = getattr(provider, "find_live", None)
204
+ if callable(own):
205
+ return list(await own(label))
206
+ # `list_live` is RunPod's older name for the same question, and a test fake may define only
207
+ # that. Honoured before `find` so the narrower answer always wins.
208
+ list_live = getattr(provider, "list_live", None)
209
+ if callable(list_live):
210
+ return [Instance(id=p.get("id"), label=p.get("name"))
211
+ for p in await list_live() if str(p.get("name", "")) == label]
212
+ return list(await provider.find(label))
213
+
214
+
215
+ async def find_all(provider: GpuProvider, label: str) -> List[Instance]:
216
+ """TEARDOWN: every instance under this label that could still be BILLING, in any state.
217
+
218
+ The question `stop()` and the disarm sweep ask. A false "no" here is money: it is the
219
+ 2026-08-21 fail-to-kill and the 2026-09-24 invisible Vast contract, both.
220
+ """
221
+ own = getattr(provider, "find_all", None)
222
+ if callable(own):
223
+ return list(await own(label))
224
+ return list(await provider.find(label))
225
+
226
+
227
+ # ---- the derived lifecycle: written once, shared by every provider -------------------------
228
+
229
+ async def start(provider: GpuProvider, label: str, spec: dict) -> dict:
230
+ """Idempotent create-on-wake.
231
+
232
+ Both the "already running?" check and the create go through the SAME label, which is what
233
+ makes a second wake a no-op rather than a second bill.
234
+
235
+ DELIBERATELY `find`, not `find_live`: this check exists to stop a SECOND BILL, so the
236
+ conservative answer is the safe one. An instance that exists but is not yet serving (still
237
+ booting) must read as "already here" — treating it as absent is how you buy two. Whether a
238
+ slot COUNTS as capacity is a different question, asked with `find_live` by the pool plan,
239
+ which allocates the replacement a fresh name rather than colliding with the corpse.
240
+ """
241
+ if not provider.configured():
242
+ return {"ok": False, "error": f"{provider.name} has no credential configured"}
243
+ try:
244
+ existing = await provider.find(label)
245
+ except Exception as e:
246
+ return {"ok": False, "error": f"{provider.name} could not be reached: {type(e).__name__}"}
247
+ if existing:
248
+ return {"ok": True, "already": [i.get("id") for i in existing]}
249
+ try:
250
+ ok = await provider.create(label, spec)
251
+ except Exception as e:
252
+ return {"ok": False, "error": f"{provider.name} create failed: {type(e).__name__}"}
253
+ return {"ok": bool(ok)} if ok else {
254
+ "ok": False,
255
+ "error": f"{provider.name} accepted no instance for '{label}' — usually out of stock or over quota",
256
+ }
257
+
258
+
259
+ #: How long the provider-neutral teardown waits for an instance to actually disappear, and how
260
+ #: often it re-asks. RunPod's own `terminate` uses 120s/5s and is the model; this is the same
261
+ #: discipline for every other provider, which had none.
262
+ DESTROY_VERIFY_TIMEOUT_S = float(envvar("DESTROY_VERIFY_TIMEOUT_S", "90"))
263
+ DESTROY_VERIFY_POLL_S = float(envvar("DESTROY_VERIFY_POLL_S", "5"))
264
+
265
+
266
+ async def destroy_verified(provider: GpuProvider, instance_id: str,
267
+ label: str) -> "tuple[bool, str]":
268
+ """Terminate one instance AND CONFIRM IT IS GONE, for ANY provider. Returns (gone, reason).
269
+
270
+ THE DEFECT THIS CLOSES, and it is money. `RunPodProvider.destroy` delegates to its own
271
+ `terminate`, whose docstring states the rule in as many words: *"The API's 2xx is never the
272
+ evidence — REST DELETE returns 2xx without stopping the pod, and it keeps BILLING."* Three of
273
+ the four providers in this file then did exactly that. Vast:
274
+
275
+ r = await c.delete(f"{self._BASE}/instances/{instance_id}/", ...)
276
+ return r.status_code < 300 or r.status_code == 404
277
+
278
+ and DataCrunch and Lambda are the same shape. So `stop()` reported
279
+ ``{"ok": True, "terminated": [id]}`` on the strength of an HTTP status, the pool believed the
280
+ slot was free, and nobody looked again. The rule was written down once, enforced for one
281
+ provider, and the other three inherited the wrong half of it.
282
+
283
+ PROVIDER-NEUTRAL BY CONSTRUCTION. It uses only what the `GpuProvider` protocol requires —
284
+ `destroy` to ask and `find_all`/`find` to look — so a provider added tomorrow is covered
285
+ without implementing anything, which is the opposite of how `terminate` ended up RunPod-only.
286
+ `find_all` is the right question here for the same reason `stop` already uses it: teardown
287
+ must see an instance that bills without serving.
288
+
289
+ THE FIRST CHECK IS IMMEDIATE, so the common case — it really is gone — costs one extra API
290
+ call and no waiting, including on RunPod where this is a second opinion on a verification that
291
+ already happened. Re-asking `destroy` each round is deliberate and copied from RunPod's
292
+ terminate, which records that RunPod "sometimes needs it more than once"; a vendor that
293
+ accepted a delete and did nothing is exactly the case being defended against.
294
+
295
+ A TIMEOUT IS NOT A SUCCESS. The return is `(False, reason)`, and `stop` turns that into a
296
+ suspected-billing record rather than into a clean teardown — "I could not confirm" must never
297
+ print like "it is gone".
298
+ """
299
+ import asyncio as _a
300
+
301
+ # Read from the module, not from parameters. They were keyword-only overrides that only the TESTS
302
+ # ever supplied — "a promise that callers will name it", with no production caller, which is dead
303
+ # code behind a live signature (`test_no_unsupplied_keyword_args`). An operator who needs to move
304
+ # these sets the env vars the constants come from.
305
+ timeout = DESTROY_VERIFY_TIMEOUT_S
306
+ poll = DESTROY_VERIFY_POLL_S
307
+ iid = str(instance_id)
308
+
309
+ async def _still_there() -> "bool | None":
310
+ """True/False, or None when the question itself could not be asked."""
311
+ try:
312
+ return any(str(i.get("id")) == iid for i in await find_all(provider, label))
313
+ except Exception as e:
314
+ log.warning("%s: could not re-check %s after destroy: %s",
315
+ provider.name, iid, type(e).__name__)
316
+ return None
317
+
318
+ try:
319
+ asked = await provider.destroy(iid)
320
+ except Exception as e:
321
+ return False, f"destroy raised {type(e).__name__}"
322
+ if not asked:
323
+ return False, "the provider refused the destroy"
324
+
325
+ waited = 0.0
326
+ while True:
327
+ present = await _still_there()
328
+ if present is False:
329
+ return True, "confirmed gone"
330
+ if present is None:
331
+ # NOT counted as gone. A provider we cannot read is a provider we cannot clear, and
332
+ # this is the same distinction `nodewake.TeardownBlocked` draws one layer up.
333
+ return False, "destroy was accepted but the instance could not be re-checked"
334
+ if waited >= timeout:
335
+ log.error("PANIC: %s instance %s still present %.0fs after an accepted destroy — "
336
+ "MAY BE BILLING", provider.name, iid, timeout)
337
+ return False, (f"still present {timeout:.0f}s after the provider accepted the "
338
+ f"destroy — may still be billing")
339
+ await _a.sleep(poll)
340
+ waited += poll
341
+ try:
342
+ await provider.destroy(iid) # RunPod sometimes needs it more than once
343
+ except Exception as e:
344
+ log.warning("%s: re-issued destroy for %s raised %s",
345
+ provider.name, iid, type(e).__name__)
346
+
347
+
348
+ async def stop(provider: GpuProvider, label: str) -> dict:
349
+ """Terminate everything carrying this label.
350
+
351
+ Reports WHAT it terminated. "ok with nothing terminated" is a legitimate answer only when
352
+ nothing was running — and the caller can now tell the difference, which they could not
353
+ before.
354
+
355
+ A PROVIDER THAT CANNOT ANSWER THE TEARDOWN QUESTION DOES NOT GET TO REPORT A CLEAN TEARDOWN
356
+ (owner ruling, 2026-09-24). Where `distinguishes_serving()` is False, every id this reached
357
+ was chosen by PRESENCE, and "nothing else of mine is billing there" was never verified —
358
+ it was one enumeration answering a question it cannot answer. `ok` stays honest about the
359
+ terminates that did happen (flipping it would push a real teardown into `failed` and hide
360
+ the ids), and the ambiguity rides out beside it in `unverified`, flagged suspected_billing.
361
+ """
362
+ if not provider.configured():
363
+ return {"ok": False, "error": f"{provider.name} has no credential configured"}
364
+ try:
365
+ # `find_all`, NOT `find`: teardown must reach an instance that bills without serving.
366
+ # Asking the capacity question here is what let an exited-but-contracted Vast instance
367
+ # survive a teardown that reported success (2026-09-24).
368
+ live = await find_all(provider, label)
369
+ except Exception as e:
370
+ return {"ok": False, "error": f"{provider.name} could not be reached: {type(e).__name__}"}
371
+ terminated, failed, unconfirmed = [], [], []
372
+ for inst in live:
373
+ iid = str(inst.get("id"))
374
+ # VERIFIED, for every provider. This used to be `await provider.destroy(iid)` and the
375
+ # boolean was the whole evidence — which for Vast, DataCrunch and Lambda meant an HTTP
376
+ # status, since their `destroy` returns `status < 300 or 404`. RunPod was the only one
377
+ # whose `destroy` confirmed anything, because it delegates to its own verifying
378
+ # `terminate`. See `destroy_verified`.
379
+ gone, why = await destroy_verified(provider, iid, label)
380
+ if gone:
381
+ terminated.append(iid)
382
+ elif why == "the provider refused the destroy" or why.startswith("destroy raised"):
383
+ # It never got started. That is a plain failure and already reported as one.
384
+ failed.append(iid)
385
+ else:
386
+ # ACCEPTED AND NOT CONFIRMED GONE: the dangerous case, and the one that used to read
387
+ # as success. It goes to the money channel, not to `terminated`.
388
+ unconfirmed.append({"id": iid, "reason": why})
389
+ res = {"ok": not failed and not unconfirmed, "terminated": terminated,
390
+ **({"failed": failed} if failed else {})}
391
+ if unconfirmed:
392
+ # `ok` is False here, unlike the presence-only note below. The difference is real: there,
393
+ # the instance IS gone as far as anything can tell and only the "nothing ELSE is billing"
394
+ # claim is weak. Here a specific instance was still listed after its own destroy was
395
+ # accepted, so there is a named thing that may be spending money right now.
396
+ res["unconfirmed_destroys"] = unconfirmed
397
+ res["suspected_billing"] = True
398
+ ids = [u["id"] for u in unconfirmed]
399
+ log.error("%s: %d instance(s) under %r accepted a destroy and are STILL LISTED: %s — "
400
+ "treating the teardown as NOT clean", provider.name, len(ids), label, ids)
401
+ _SUSPECTED.extend(
402
+ presence_only_note(
403
+ provider.name, label, "destroy-unconfirmed",
404
+ f"instance {u['id']} {u['reason']}. The provider accepted the destroy, so a "
405
+ f"caller trusting that answer would have recorded a clean teardown. Check by "
406
+ f"hand: python -m frutebox.gpuproviders stop-label {label} {provider.name}",
407
+ [u["id"]])
408
+ for u in unconfirmed)
409
+ if not distinguishes_serving(provider):
410
+ res["unverified"] = presence_only_note(
411
+ provider.name, label, "teardown",
412
+ (f"{provider.name} cannot tell an instance that is SERVING from one that is merely "
413
+ f"PRESENT — it has a single enumeration and answers both questions with it. This "
414
+ f"teardown terminated {len(terminated)} instance(s) under {label!r} on presence "
415
+ f"alone, so 'nothing is billing under that label on {provider.name} any more' is "
416
+ f"NOT a verified statement: it is the only answer that provider can give. Treat it "
417
+ f"as possibly still spending until checked by hand — python -m "
418
+ f"frutebox.gpuproviders stop-label {label} {provider.name}"),
419
+ terminated)
420
+ return res
421
+
422
+
423
+ # ---- RunPod --------------------------------------------------------------------------------
424
+ #
425
+ # THE SINGLE DOOR (KB §0). Every RunPod touch in this codebase goes through this class — the SDK
426
+ # import, the proxy hostname, the GraphQL endpoint for prices, all of it lives HERE and nowhere
427
+ # else, enforced by tests/test_gpu_single_door.py. Almost every RunPod incident we have had
428
+ # traces to the same root cause: many call sites, each implemented ad hoc, so a fix in one path
429
+ # never reached the others. A new GPU type or a different start is a `PodSpec` value handed to the
430
+ # same proven code — never a new function that re-implements create/wait/terminate.
431
+
432
+ from dataclasses import dataclass, field
433
+
434
+
435
+ @dataclass(frozen=True)
436
+ class PodSpec:
437
+ """Everything a bring-up needs, as VALUES. New hardware = a new PodSpec, never new code."""
438
+ name: str
439
+ image: str
440
+ serving_port: int = 11434
441
+ #: (gpu_type_id, cloud_type) or (gpu_type_id, cloud_type, data_center_id), tried in order.
442
+ #: SECURE first: Community publishes no uptime SLA and is where "placed but never scheduled"
443
+ #: clusters — but it stays in the list, because an available Community pod beats no pod.
444
+ #: NOTE: when `network_volume_id` is set, the VOLUME's datacenter overrides any entry's — a
445
+ #: RunPod network volume is datacenter-locked, and deploying elsewhere cannot mount it.
446
+ gpu_fallbacks: tuple = (
447
+ ("NVIDIA RTX A6000", "SECURE"),
448
+ ("NVIDIA RTX A5000", "SECURE"),
449
+ ("NVIDIA A40", "SECURE"),
450
+ ("NVIDIA RTX A6000", "COMMUNITY"),
451
+ )
452
+ #: GPUs IN ONE POD. `create_pod` hardcoded 1, and that made the tier-S placement the whole
453
+ #: unit-economics story rests on unreachable through this door: file 01-B2's default is
454
+ #: Qwen3-235B-A22B FP8 on **2x H200** (~$7.18/hr Community) and `serving.gpu_plan` returns
455
+ #: exactly that multi-GPU config — which this module could then only ask RunPod for one card
456
+ #: of. A 235 GB model on one 141 GB H200 boots, bills, and loads forever: the 2026-08-30 root
457
+ #: cause with a different card. Tensor parallelism also needs the count to be a power of two,
458
+ #: which `serving_spec` already enforces when it builds the plan.
459
+ gpu_count: int = 1
460
+ container_disk_gb: int = 40
461
+ volume_gb: int = 0
462
+ network_volume_id: str = ""
463
+ #: A volume implies its datacenter; naming it makes the pin explicit in the request so a
464
+ #: mismatch fails loudly instead of the API inferring something else.
465
+ data_center_id: str = ""
466
+ docker_args: str = ""
467
+ #: RunPod's id for a stored container-registry credential. Our node image is PRIVATE, so a pod
468
+ #: cannot pull it without one — a pod created without this is rented and exits in ~2 seconds
469
+ #: with `runtime: null`, which is what happened on 2026-09-20 before this existed.
470
+ #: `runpod.create_pod` has no parameter for it (measured against 1.12.0, the latest release),
471
+ #: so it travels via a template — see `_template_id_for`.
472
+ container_registry_auth_id: str = ""
473
+ #: WHICH PORTS RUNPOD EXPOSES, as RunPod's own "8081/http,11434/http" string. Distinct from
474
+ #: `serving_port`, which is the ONE port the control plane dials for readiness (`serves()`);
475
+ #: this is everything the pod should reach the outside on.
476
+ #:
477
+ #: It exists because the operator's `ports` was being thrown away. `from_operator_spec` never
478
+ #: read it and every outgoing call rebuilt `f"{serving_port}/http"`, so `_WARM`'s declared
479
+ #: "8080/http,8081/http" had been silently reduced to "11434/http" for as long as it has been
480
+ #: set. The cost was not abstract: the node image runs a boot-status sidecar on :8081 built
481
+ #: precisely so a slow model pull and a dead container are distinguishable, and on 2026-09-21
482
+ #: a pod ran 15 minutes without registering and the sidecar could not be reached to say why.
483
+ ports: str = ""
484
+ #: THE PATH the readiness probe asks for on `serving_port`. Default `/` because that is what
485
+ #: Ollama and llama.cpp answer (both have a root page) and this door was built for them.
486
+ #:
487
+ #: IT IS A FIELD BECAUSE SGLang HAS NO `/` ROUTE. Its FastAPI app serves `/health`,
488
+ #: `/get_model_info`, `/v1/models` and the completion endpoints — a probe on `/` 404s forever,
489
+ #: which is a readiness predicate that CANNOT PASS: the pod boots, serves, and is reaped at the
490
+ #: timeout as dead. That is the 2026-08-30 incident in this KB (a predicate reading a field the
491
+ #: query never returned) with a different cause and the same cost, and the tier-S recipe would
492
+ #: have hit it on its first armed run.
493
+ health_path: str = "/"
494
+ env: dict = field(default_factory=dict)
495
+ #: ONE NAMED VARIANT = ONE TEMPLATE OF ITS OWN, carrying its per-pod env.
496
+ #:
497
+ #: A POOL spec deploys N interchangeable slots that differ only by `STEVAI_NODE_ID`, so its
498
+ #: template must be SHARED or every slot mints one (`_PER_POD_ENV`, below). A VARIANT is one
499
+ #: deliberate pod — `frutebox-media` — whose identity and advertised modalities ARE the spec.
500
+ #: Stripping its per-pod env to share a template with nothing is how the media node booted as
501
+ #: `runpod-node-1` and advertised text only: the values that make it a media node never
502
+ #: reached the container at all.
503
+ #:
504
+ #: Bounded by construction: one template per configured variant, not one per pod or per
505
+ #: attempt. The name is a pure function of the full env, so a re-arm of the same variant
506
+ #: FINDS its template instead of minting another.
507
+ dedicated_template: bool = False
508
+ #: Sized to the documented COLD START (image + model pull), not the serving SLA — confusing
509
+ #: the two once reaped six healthy pods mid-boot.
510
+ ready_timeout_s: int = int(envvar("POD_READY_TIMEOUT_S", "780"))
511
+ poll_s: int = int(envvar("POD_READY_POLL_S", "10"))
512
+
513
+ @staticmethod
514
+ def from_operator_spec(spec: dict, name: str = "") -> "PodSpec":
515
+ """Adapt a RUNPOD_POD_SPEC-shaped dict (the operator's config format) into a PodSpec."""
516
+ env = spec.get("env", {})
517
+ # A spec that PINS GPUs (gpuTypeId/gpuTypeIds — e.g. the frontier variant pinning H100)
518
+ # must drive the fallback walk with those pins, not with the default tier-A list: the
519
+ # default would happily deploy an A6000 where the operator specified frontier hardware.
520
+ pins = [g for g in ([spec.get("gpuTypeId")] + list(spec.get("gpuTypeIds") or [])) if g]
521
+ cloud = str(spec.get("cloudType", "") or "").upper()
522
+ clouds = [cloud] if cloud in ("SECURE", "COMMUNITY") else ["SECURE", "COMMUNITY"]
523
+ pinned_fallbacks = tuple((g, c) for c in clouds for g in pins)
524
+ return PodSpec(
525
+ name=name or str(spec.get("name") or "frutebox-runpod-1"),
526
+ **({"gpu_fallbacks": pinned_fallbacks} if pinned_fallbacks else {}),
527
+ image=str(spec.get("imageName") or spec.get("image") or "ollama/ollama:latest"),
528
+ serving_port=int(envvar("SERVING_PORT", "11434")),
529
+ # The operator's spec has carried `gpuCount` since the frontier example was
530
+ # written (it asks for 8x H100); it was read by nothing.
531
+ gpu_count=max(1, int(spec.get("gpuCount", 1) or 1)),
532
+ container_disk_gb=int(spec.get("containerDiskInGb", 40) or 40),
533
+ volume_gb=int(spec.get("volumeInGb", 0) or 0),
534
+ # ENV FIRST, spec second: where money is spent is an OPERATOR decision, not a
535
+ # per-spec preference — the operator's configured volume overrides whatever a spec
536
+ # happens to carry.
537
+ network_volume_id=str(os.environ.get("RUNPOD_NETWORK_VOLUME_ID", "").strip()
538
+ or spec.get("networkVolumeId") or ""),
539
+ data_center_id=str(os.environ.get("RUNPOD_VOLUME_DATACENTER", "").strip()
540
+ or spec.get("dataCenterId") or ""),
541
+ docker_args=str(spec.get("dockerArgs", "") or ""),
542
+ container_registry_auth_id=str(spec.get("containerRegistryAuthId", "") or ""),
543
+ dedicated_template=bool(spec.get("dedicatedTemplate", False)),
544
+ ports=str(spec.get("ports", "") or ""),
545
+ env=env if isinstance(env, dict) else {},
546
+ )
547
+
548
+
549
+ def ports_string(spec: PodSpec) -> str:
550
+ """What RunPod should expose, as its "port/http,port/http" string. ONE definition, because
551
+ three calls need the identical answer.
552
+
553
+ The template's ports and the pod's ports must agree, and the template NAME is a hash of the
554
+ template's ports — so a helper that drifted from its callers would mint a template per call
555
+ and then hand the pod a different set than the one it was named for.
556
+
557
+ THE SERVING PORT IS ALWAYS PRESENT, even when the operator's string omits it. The control
558
+ plane dials `serving_port` to decide a pod is ready (`serves()`); a spec that left it out
559
+ would produce a pod that boots correctly, cannot be reached, and is reaped as dead — paid for
560
+ and useless. Adding it back is cheaper than that, and saying so in the log means the spec
561
+ still gets fixed rather than the mistake staying invisible.
562
+ """
563
+ want = f"{spec.serving_port}/http"
564
+ declared = [p.strip() for p in (spec.ports or "").split(",") if p.strip()]
565
+ if not declared:
566
+ return want
567
+ if not any(p.split("/", 1)[0] == str(spec.serving_port) for p in declared):
568
+ log.warning("spec %s declares ports %r but not the serving port %s that readiness dials "
569
+ "— adding it; fix the spec", spec.name, spec.ports, spec.serving_port)
570
+ declared.append(want)
571
+ return ",".join(declared)
572
+
573
+
574
+ #: Usable VRAM per GPU type, GB. Conservative round-downs of the marketing numbers. Used by the
575
+ #: model-fit assertion below; a type not listed is treated as unknown and only the disk check runs.
576
+ GPU_VRAM_GB = {
577
+ "NVIDIA A40": 48, "NVIDIA RTX A6000": 48, "NVIDIA RTX 6000 Ada Generation": 48,
578
+ "NVIDIA RTX A5000": 24, "NVIDIA RTX A4000": 16,
579
+ "NVIDIA H100 80GB HBM3": 80, "NVIDIA H100 PCIe": 80, "NVIDIA H200": 141,
580
+ "NVIDIA A100 80GB PCIe": 80, "NVIDIA A100-SXM4-80GB": 80,
581
+ "NVIDIA L40S": 48, "NVIDIA GeForce RTX 4090": 24,
582
+ }
583
+
584
+ #: Does the card have NATIVE FP8 tensor cores? Ampere and older do not; Ada, Hopper and Blackwell
585
+ #: do. This is an ARCHITECTURE fact, not a second opinion about our inventory — `serving.py`
586
+ #: carries the same flag for the subset of cards it prices, and
587
+ #: `test_the_fp8_table_agrees_with_serving_and_covers_every_card` fails if the two ever disagree
588
+ #: or if a card is added to one table and not the other. That test is the point: a hand-kept list
589
+ #: is correct the day it is written and quietly wrong afterwards, which is how the single-door
590
+ #: guard came to cover one vendor.
591
+ GPU_FP8 = {
592
+ "NVIDIA A40": False, "NVIDIA RTX A6000": False,
593
+ "NVIDIA RTX A5000": False, "NVIDIA RTX A4000": False,
594
+ "NVIDIA A100 80GB PCIe": False, "NVIDIA A100-SXM4-80GB": False,
595
+ "NVIDIA H100 80GB HBM3": True, "NVIDIA H100 PCIe": True, "NVIDIA H200": True,
596
+ "NVIDIA RTX 6000 Ada Generation": True, "NVIDIA L40S": True,
597
+ "NVIDIA GeForce RTX 4090": True,
598
+ }
599
+
600
+
601
+ #: serving.py's inventory name -> RunPod's `gpuTypeId`. THE MISSING HALF OF A COSTED PLACEMENT.
602
+ #:
603
+ #: `serving.gpu_plan()` answers "which hardware fits this model, cheapest first" in its own
604
+ #: vocabulary ("H200 SXM"), and this module asks RunPod for hardware in RunPod's ("NVIDIA H200").
605
+ #: Nothing translated between them, so a cost-ranked plan could be computed and never requested —
606
+ #: which is one reason the tier-S recipe had no launcher at all. The translation belongs here
607
+ #: because RunPod's id vocabulary is this door's business and nothing else's (KB §0).
608
+ #:
609
+ #: An empty value means DELIBERATELY UNMAPPED: the card is priced by `serving.py` but this door has
610
+ #: no id it can vouch for, so a placement on it is skipped rather than guessed. A guessed id is not
611
+ #: a near miss — RunPod refuses it, and the refusal reads exactly like a capacity shortage.
612
+ RUNPOD_GPU_TYPE_ID = {
613
+ "RTX A4000": "NVIDIA RTX A4000",
614
+ "RTX A5000": "NVIDIA RTX A5000",
615
+ "A40": "NVIDIA A40",
616
+ "RTX A6000": "NVIDIA RTX A6000",
617
+ "A100 PCIe": "NVIDIA A100 80GB PCIe",
618
+ "A100 SXM": "NVIDIA A100-SXM4-80GB",
619
+ "H100 SXM": "NVIDIA H100 80GB HBM3",
620
+ "H200 SXM": "NVIDIA H200",
621
+ # RunPod's own id for the Blackwell workstation card is not one we have seen in a reply from
622
+ # the account, and `GPU_VRAM_GB`/`GPU_FP8` do not carry it either. Unmapped on purpose.
623
+ "RTX PRO 6000": "",
624
+ }
625
+
626
+
627
+ def runpod_gpu_type_id(serving_gpu_type: str) -> str:
628
+ """RunPod's id for a `serving.GpuConfig.gpu_type`, or "" when this door cannot vouch for one.
629
+
630
+ KeyError would be the wrong answer for an unknown name: the caller is walking a cost-ranked
631
+ list and should skip a card it cannot ask for, not abort a bring-up over the cheapest entry.
632
+ """
633
+ return RUNPOD_GPU_TYPE_ID.get(serving_gpu_type, "")
634
+
635
+
636
+ def model_fit_error(model: str, gpu_type_id: str, gpu_count: int, disk_gb: int) -> str:
637
+ """PURE. Why this model cannot run on this hardware — or "" when it fits.
638
+
639
+ THE ROOT CAUSE THIS ENCODES (owner-diagnosed from the console System Logs, 2026-08-30): the
640
+ default pool spec carried the tier-S Kimi config, so every A40 boot tried to download and load
641
+ a ~595 GB model onto 48 GB of VRAM and a small disk. llama-server's /health said "loading"
642
+ forever, the boot script looped for two hours, the node never registered, and the pod billed
643
+ while "running". Ten-plus pods died that way across three days, and it looked exactly like a
644
+ provider scheduling fault. A doomed combination must be refused BEFORE money is spent —
645
+ clearly, naming the numbers.
646
+ """
647
+ try:
648
+ from . import serving
649
+ ms = serving.serving_spec(model)
650
+ except Exception:
651
+ return "" # unknown model: nothing to assert against
652
+ need_vram = float(getattr(ms, "vram_gb", 0) or 0)
653
+ if not need_vram:
654
+ return ""
655
+ # The download lands on the container disk first (plus image + headroom).
656
+ need_disk = need_vram * 1.1 + 12
657
+ if disk_gb and need_disk > disk_gb:
658
+ return (f"model {model!r} needs ~{need_vram:.0f}GB downloaded (≥{need_disk:.0f}GB disk with "
659
+ f"headroom) but the spec gives {disk_gb}GB — this pod would bill while failing")
660
+ per_gpu = GPU_VRAM_GB.get(gpu_type_id)
661
+ if per_gpu is not None and need_vram > per_gpu * max(1, gpu_count):
662
+ return (f"model {model!r} needs ~{need_vram:.0f}GB VRAM but {gpu_count}x {gpu_type_id} "
663
+ f"has {per_gpu * max(1, gpu_count)}GB — this pod would bill while failing")
664
+ # ENOUGH VRAM IS NOT ENOUGH. An FP8 model on an Ampere card has the memory and still cannot
665
+ # load: 6x A40 is 288GB, more than the 235GB the weights need, and the pod would bill while
666
+ # the server failed to start. `serving.gpu_plan(require_fp8=True)` already refuses the
667
+ # placement when the plan is BUILT; this is the check on the path that refuses a doomed
668
+ # combination someone SUPPLIED, which is where a hand-written variant spec arrives.
669
+ # `engine == "sglang"` is the FP8 path — the only branch `serving_spec` sends there.
670
+ if getattr(ms, "engine", "") == "sglang" and GPU_FP8.get(gpu_type_id) is False:
671
+ return (f"model {model!r} is served FP8 and {gpu_type_id} has no native FP8 tensor cores "
672
+ f"(Ampere or older) — {gpu_count}x gives {per_gpu * max(1, gpu_count) if per_gpu else '?'}GB, "
673
+ f"enough memory and still unable to load; this pod would bill while failing")
674
+ return ""
675
+
676
+
677
+
678
+ # ---- THE REAPER ----------------------------------------------------------------------------
679
+ #
680
+ # THE RULE THIS ENCODES (2026-09-17, after a 13h20m orphan billed $6.54):
681
+ #
682
+ # A TIMEOUT ON A CALL THAT CREATES A RESOURCE IS NOT EVIDENCE THAT NOTHING WAS CREATED.
683
+ # It is evidence that you do not know. Treat it as "may have succeeded" and reconcile
684
+ # against the provider's own list before moving on.
685
+ #
686
+ # What happened: `create_pod` wrapped the SDK call in `wait_for(..., timeout=180)`. The call
687
+ # timed out, the `except` logged "create REFUSED" and returned None, and the caller moved to the
688
+ # next fallback. But `wait_for` cannot cancel a blocking call running in a worker thread — the
689
+ # request was still in flight, and RunPod created the pod minutes later, after the process had
690
+ # exited. Nothing owned it, so nothing reaped it. It billed overnight with `runtime: None`.
691
+ #
692
+ # Two properties make this reaper different from the teardown that already existed:
693
+ #
694
+ # 1. The reap set comes from RUNPOD'S LIST, never from the ids we think we created. An orphan
695
+ # we never saw an id for is exactly the case that costs money, and it is invisible to any
696
+ # bookkeeping built on our own return values.
697
+ # 2. It runs in a `finally`, so an exception, a timeout, or a KeyboardInterrupt on the wake
698
+ # path cannot skip it.
699
+
700
+
701
+ #: Reconcile timings. Module-level so tests drive the real code paths at speed rather than
702
+ #: mocking them out — the 2026-09-17 bug lived in a path that had never once been executed.
703
+ _AMBIGUOUS_WINDOW_S = 120.0 # how long to watch for a pod after an ambiguous create
704
+ _AMBIGUOUS_POLL_S = 10.0
705
+ _SETTLE_POLL_S = 10.0 # re-list interval while waiting for late pods to settle
706
+ _SETTLE_S = 180.0 # how long an ambiguous create keeps the exit reap watching
707
+ #: An unowned pod older than this is no longer "someone else's pod, probably fine" — it is a
708
+ #: suspected orphan and must escalate past a log line. 30 min is well past any legitimate
709
+ #: bring-up (the documented cold start is ~94 min, but a DIAG pod is watched by a human from the
710
+ #: first minute); the 2026-09-17 orphan sat for 13h20m inside a "reported" bucket.
711
+ _ORPHAN_SUSPECT_AGE_H = 0.5
712
+
713
+
714
+ class PodTracker:
715
+ """The set of pods a guarded window is allowed to leave running.
716
+
717
+ `saw()` is bookkeeping for the log. `keep()` is the only thing that spares a pod: a pod is
718
+ reaped unless the caller explicitly confirmed it wants it alive. Silence never means keep.
719
+ """
720
+
721
+ def __init__(self, owns: str):
722
+ self.owns = owns
723
+ self.seen: Set[str] = set()
724
+ self.kept: Set[str] = set()
725
+ #: names whose create call ended ambiguously (timeout/transport error). Their pods may
726
+ #: still materialise AFTER the call returned, so the exit reap waits for them to settle.
727
+ self.ambiguous: Set[str] = set()
728
+
729
+ def saw(self, pod_id: str) -> None:
730
+ if pod_id:
731
+ self.seen.add(pod_id)
732
+
733
+ def keep(self, pod_id: str) -> None:
734
+ """Confirm this pod is intentionally alive — the caller owns it from here."""
735
+ if pod_id:
736
+ self.kept.add(pod_id)
737
+ self.seen.add(pod_id)
738
+
739
+
740
+ #: Suspected orphans seen by any guard in this process. A wake that runs after one was found can
741
+ #: read this and refuse to add more pods to an account nobody has cleaned up.
742
+ _SUSPECTED: "List[dict]" = []
743
+
744
+
745
+ def suspected_orphans() -> "List[dict]":
746
+ """Unowned pods past the suspect age that a guard has seen in this process. Not empty means
747
+ somebody must look at the account before more pods are armed."""
748
+ return list(_SUSPECTED)
749
+
750
+
751
+ #: The guard active on this task, so `create_pod` can register what it learns without every
752
+ #: call site having to thread a tracker through. None = unguarded (the reaper then warns).
753
+ _TRACKER: "contextvars.ContextVar[Optional[PodTracker]]" = contextvars.ContextVar(
754
+ "runpod_tracker", default=None)
755
+
756
+
757
+ def _pod_age_hours(pod: dict) -> "float | None":
758
+ """Best effort age in hours, or None when RunPod gave us no timestamp.
759
+
760
+ Deliberately tolerant: a missing or unparseable timestamp must never stop a reap. Age is for
761
+ the LOG — so an orphan leaves a trace saying how long it had been burning — not for the
762
+ decision about whether to kill it.
763
+ """
764
+ import datetime as _dt
765
+ for key in ("createdAt", "created_at", "lastStatusChange", "podCreatedAt"):
766
+ raw = pod.get(key)
767
+ if not raw:
768
+ continue
769
+ text = str(raw)
770
+ # "Rented by user: Mon Sep 15 2026 17:05:17 GMT+0000" and friends.
771
+ if ":" in text and not text[:4].isdigit():
772
+ text = text.split(":", 1)[1].strip()
773
+ for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S",
774
+ "%a %b %d %Y %H:%M:%S GMT%z", "%a %b %d %Y %H:%M:%S"):
775
+ try:
776
+ when = _dt.datetime.strptime(text[:len(_dt.datetime.now().strftime(fmt)) + 8]
777
+ if "%z" not in fmt else text, fmt)
778
+ except Exception:
779
+ continue
780
+ if when.tzinfo is None:
781
+ when = when.replace(tzinfo=_dt.timezone.utc)
782
+ return (_dt.datetime.now(_dt.timezone.utc) - when).total_seconds() / 3600.0
783
+ return None
784
+
785
+
786
+ def _pool_scope(name: str) -> str:
787
+ """The ownership prefix for a pod name — 'frutebox-runpod-1' owns 'frutebox-runpod'.
788
+
789
+ Scoping by the POOL BASE, not the exact name, is deliberate: a create that lands under a
790
+ slightly different name (the 2026-08-21 asymmetry) is still ours and must still be reapable.
791
+ """
792
+ import re as _re
793
+ return _re.sub(r"-\d+$", "", str(name or "")) or str(name or "")
794
+
795
+
796
+ def _describe(pod: dict) -> str:
797
+ age = _pod_age_hours(pod)
798
+ return (f"id={pod.get('id')} name={pod.get('name')!r} "
799
+ f"desired={pod.get('desiredStatus')} runtime={'yes' if pod.get('runtime') else 'None'} "
800
+ f"gpus={pod.get('gpuCount')} age={'%.2fh' % age if age is not None else 'unknown'} "
801
+ f"cost/hr={pod.get('costPerHr')}")
802
+
803
+
804
+ # ---- §A9: credentials come from the registry, not the environment ---------------------------
805
+ #
806
+ # THE SPLIT THIS CLOSES (plan 28 §A9, done-criteria at 28:108). The encrypted registry in Neon was
807
+ # built, populated and correct — and had exactly one caller, the provider-doctor. Every path that
808
+ # actually launched a machine read `os.environ`. So the diagnostic read the database and the
809
+ # launcher read the environment, and the two could disagree indefinitely: a provider reported
810
+ # healthy while nothing could launch. `directory_control.py` warns about that exact shape.
811
+ #
812
+ # Precedence is the section's: DATABASE FIRST, env as the cold-start/local-dev fallback. A stale
813
+ # env var outranking a rotated DB credential is what §A9 exists to remove.
814
+
815
+ #: Rotation window. `_key()` is called per operation via `_sdk()`, so an uncached read would be an
816
+ #: HTTP round trip per SDK call. Short enough that 28:108's "rotating a credential causes no
817
+ #: downtime" means minutes, not "whenever this process restarts".
818
+ _CREDENTIAL_TTL_S = float(envvar("CREDENTIAL_TTL_S", "60"))
819
+
820
+ #: provider_id -> (fetched_at, {name: value}). Process-local by design: the directory is the only
821
+ #: service that launches, and a shared cache would be a second source of truth.
822
+ _CRED_CACHE: "Dict[str, tuple]" = {}
823
+
824
+
825
+ def _credential_cache_clear() -> None:
826
+ """Drop everything. Tests and an explicit operator refresh."""
827
+ _CRED_CACHE.clear()
828
+
829
+
830
+ def _credential_cache_expire_now() -> None:
831
+ """Age every entry past the TTL without discarding its value — so a refresh is attempted and
832
+ the LAST GOOD VALUE is still there to fall back on if that refresh fails."""
833
+ for pid, (_, creds) in list(_CRED_CACHE.items()):
834
+ _CRED_CACHE[pid] = (0.0, creds)
835
+
836
+
837
+ def _resolve_provider_credentials(provider_id: str) -> Dict[str, str]:
838
+ """The boundary: one registry read for one provider. Patched wholesale in tests.
839
+
840
+ Kept as a module-level function rather than inlined so there is exactly one place the registry
841
+ is consulted from the launch path — the same reason this file is the single door for RunPod.
842
+ """
843
+ from . import providers as _reg
844
+ row = next((r for r in _reg.load_seed() if r.id == provider_id), None)
845
+ if row is None:
846
+ return {}
847
+ return _reg.resolve_credentials(row, _reg.fetch_credentials(provider_id))
848
+
849
+
850
+ def _provider_credential(provider_id: str, name: str, *, env_name: "str | None" = None) -> str:
851
+ """One credential, database first, env second, cached for `_CREDENTIAL_TTL_S`.
852
+
853
+ A FAILED OR EMPTY REFRESH NEVER EVICTS A GOOD VALUE. `fetch_credentials` returns `{}` on any
854
+ failure by design — including failures it cannot distinguish from "no rows" — so an empty
855
+ answer is not evidence that a credential was deleted. Treating it as such would turn a network
856
+ blip into "every provider unconfigured", silently, which is the degradation shape this
857
+ codebase keeps finding. The cache is therefore last-known-good, not a mirror.
858
+ """
859
+ env_name = env_name or name
860
+ now = time.monotonic()
861
+ cached = _CRED_CACHE.get(provider_id)
862
+ if cached is not None and (now - cached[0]) < _CREDENTIAL_TTL_S:
863
+ creds = cached[1]
864
+ else:
865
+ try:
866
+ fresh = _resolve_provider_credentials(provider_id) or {}
867
+ except Exception as e:
868
+ log.warning("credential refresh for %s failed (%s) — serving the last known value",
869
+ provider_id, type(e).__name__)
870
+ fresh = {}
871
+ if fresh:
872
+ _CRED_CACHE[provider_id] = (now, fresh)
873
+ creds = fresh
874
+ elif cached is not None:
875
+ # Keep the value, reset the clock so we do not hammer a failing source every call.
876
+ _CRED_CACHE[provider_id] = (now, cached[1])
877
+ creds = cached[1]
878
+ else:
879
+ creds = {}
880
+ value = str(creds.get(name, "") or "").strip()
881
+ if value:
882
+ return value
883
+ # §A9.3 / §O2: the env read survives as the cold-start / local-dev fallback. What is removed
884
+ # is the env OUTRANKING the database, not the env existing.
885
+ #
886
+ # THIS GATE WAS ABSENT AND THAT WAS LOAD-BEARING. Until §O1 landed, every registry read failed
887
+ # with EnvelopeError, so this unconditional fallback was the only reason the platform launched
888
+ # anything at all. With the registry path working, an ungated fallback becomes the bug it was
889
+ # written to prevent: a credential rotated in the database would never take effect, because a
890
+ # stale environment variable would answer first and nothing would say so.
891
+ #
892
+ # `providers.ENV_FALLBACK` is read at CALL time, not bound at import, so the flag can be
893
+ # changed (and tested) without reimporting the module.
894
+ from . import providers as _reg_flag
895
+ if not getattr(_reg_flag, "ENV_FALLBACK", False):
896
+ return ""
897
+ return os.environ.get(env_name, "").strip()
898
+
899
+
900
+ def provider_credential(provider_id: str, name: str, *, env_name: "str | None" = None) -> str:
901
+ """THE PUBLIC DOOR for one provider credential: database first, env second, cached.
902
+
903
+ WHY THIS EXISTS AS A SEPARATE NAME (BACKEND ask, 2026-10-17). `_provider_credential` below is
904
+ the implementation and its leading underscore says "internal to this module" — but three
905
+ modules outside it were already reaching past that underscore (`serving.py`, `nodewake.py`,
906
+ `runpod_templates.py`). A private name with external callers is not private; it is an
907
+ undocumented API with a discouraging prefix, and the next person to refactor the private one
908
+ breaks three files with no signal that they were a contract.
909
+
910
+ So the contract is named. This is what code outside `gpuproviders` should call, and the §O
911
+ precedence it guarantees is the whole point of routing through here rather than reading
912
+ `os.environ`: file 28 §O made the DATABASE the source of truth, so a direct env read answers
913
+ "absent" for any deployment whose credential lives in the DB — which is the silent degradation
914
+ `tests/test_provider_credential_env_bypass_guard.py` exists to prevent.
915
+
916
+ IT DELEGATES, AND IT MUST NOT BECOME AN ALIAS. `provider_credential = _provider_credential`
917
+ would bind the implementation object at import time, so the four suites that monkeypatch
918
+ `gpuproviders._provider_credential` (alibaba, phala, and others) would patch the private name
919
+ while this public one went on calling the original — tests that pass while testing nothing.
920
+ One extra stack frame buys that correctness; please leave it.
921
+ """
922
+ return _provider_credential(provider_id, name, env_name=env_name)
923
+
924
+
925
+ #: The most GPUs one pod may be created with. RunPod's own ceiling is 8 for the multi-GPU types
926
+ #: this account uses; the cap is here so a mis-typed `gpuCount` cannot become an hourly bill.
927
+ MAX_GPU_COUNT = int(envvar("POD_MAX_GPU_COUNT", "8"))
928
+
929
+ #: Env keys that are PER POD and must never reach the template hash.
930
+ #:
931
+ #: This was the bug that stopped the fleet booting. `nodewake` injects `STEVAI_NODE_ID` into each
932
+ #: pod's env, so the projection hashed to a DIFFERENT name for every pod: `find_template_id` could
933
+ #: never match, `create_template` was attempted with a brand-new name every time, and the function
934
+ #: minted a template per attempt — the exact thing its own docstring says must not happen. The
935
+ #: leftover `frutebox-node-*` templates on the account are that residue. When template creation
936
+ #: then started failing, the guard refused every create and NOTHING COULD START.
937
+ #:
938
+ #: The rest of the env stays in the hash for the reason the docstring gives: a changed gateway URL
939
+ #: or node credential must not resolve to a template carrying the old one. Only the values that
940
+ #: are per-pod by construction come out.
941
+ _PER_POD_ENV = ("STEVAI_NODE_ID", "FRUTEBOX_NODE_ID")
942
+
943
+
944
+ def _template_env(env: dict, dedicated: bool = False) -> dict:
945
+ """The env a TEMPLATE carries: everything except what differs between two pods of one spec.
946
+
947
+ `dedicated=True` (a NAMED VARIANT — see `PodSpec.dedicated_template`) keeps the per-pod keys,
948
+ because a variant is one pod whose identity is the point of it. One variant = one template.
949
+
950
+ WHICH LAYER WINS IS NO LONGER A QUESTION ANYONE HAS TO ANSWER. `create_pod` sends `env=` and
951
+ `template_id=` together and RunPod does not document which it prefers; the old code put
952
+ DIFFERENT values on the two layers and therefore depended on that answer. Now this one
953
+ function is the single source for the template's env, and it is used BOTH to name the template
954
+ and to create it, so the two layers either agree or the template is not reused. Whichever
955
+ RunPod prefers, it prefers the same value.
956
+
957
+ THE BUG THAT MADE THIS NECESSARY: the name hashed `_template_env(...)` while the content was
958
+ created from the FULL `spec.env`. A template's baked env therefore did not match its own name,
959
+ so a template minted by one pod was reused by another whose per-pod values differed — and the
960
+ media pod matched a pre-existing template created without any `STEVAI_NODE_ID`, booted on
961
+ `entrypoint.sh`'s `runpod-node-1` default, collided with the live node and advertised text.
962
+ """
963
+ if dedicated:
964
+ return dict(env or {})
965
+ return {k: v for k, v in (env or {}).items() if k not in _PER_POD_ENV}
966
+
967
+
968
+ #: The last error each GPU type's create returned, so a refusal can say WHY rather than guess.
969
+ #: Keyed by type id; overwritten per attempt, which is all a diagnostic needs.
970
+ _LAST_CREATE_ERROR: Dict[str, str] = {}
971
+
972
+
973
+ class RunPodProvider:
974
+ name = "runpod"
975
+ #: GraphQL endpoint — used ONLY for the price query, for which the SDK has no call. Everything
976
+ #: with a lifecycle (create/get/terminate) goes through the SDK: hand-rolling the pod query
977
+ #: once produced a readiness predicate that could never pass (KB incident 2026-08-30).
978
+ _GQL = "https://api.runpod.io/graphql"
979
+
980
+ def _key(self) -> str:
981
+ return _provider_credential("runpod", "RUNPOD_API_KEY")
982
+
983
+ def configured(self) -> bool:
984
+ return bool(self._key())
985
+
986
+ def _sdk(self):
987
+ """The configured SDK, or None. None is just another reason a bring-up cannot happen —
988
+ it must never crash the control plane."""
989
+ try:
990
+ import runpod as _rp
991
+ except Exception:
992
+ log.warning("runpod SDK not installed")
993
+ return None
994
+ key = self._key()
995
+ if not key:
996
+ return None
997
+ _rp.api_key = key
998
+ return _rp
999
+
1000
+ # -- observations ------------------------------------------------------------------------
1001
+
1002
+ async def get_pod(self, pod_id: str) -> "dict | None":
1003
+ """One pod. None means RunPod no longer knows it — the signal a reap waits for.
1004
+
1005
+ A transport/SDK error returns {} instead: "I could not ask" must never be mistaken for
1006
+ "it is gone", or a reaper declares a billing pod dead on a network blip.
1007
+ """
1008
+ rp = self._sdk()
1009
+ if rp is None:
1010
+ return {}
1011
+ try:
1012
+ import asyncio as _a
1013
+ return await _a.wait_for(_a.to_thread(rp.get_pod, pod_id), timeout=45)
1014
+ except Exception as e:
1015
+ msg = str(e).lower()
1016
+ if "not found" in msg or "does not exist" in msg:
1017
+ return None
1018
+ log.warning("runpod get_pod %s: %s", pod_id, type(e).__name__)
1019
+ return {}
1020
+
1021
+ async def list_pods(self) -> List[dict]:
1022
+ """Every pod on the account, whatever its state. For planning and teardown."""
1023
+ rp = self._sdk()
1024
+ if rp is None:
1025
+ return []
1026
+ try:
1027
+ import asyncio as _a
1028
+ return list(await _a.wait_for(_a.to_thread(rp.get_pods), timeout=45) or [])
1029
+ except Exception as e:
1030
+ log.warning("runpod get_pods: %s", type(e).__name__)
1031
+ return []
1032
+
1033
+ async def list_live(self) -> List[dict]:
1034
+ """Pods that are actually UP: desiredStatus RUNNING **and** a runtime object.
1035
+
1036
+ `desiredStatus` alone is a statement of intent RunPod sets the moment a record exists —
1037
+ counting on it is how a corpse held the pool's only slot for a night.
1038
+ """
1039
+ return [p for p in await self.list_pods()
1040
+ if str(p.get("desiredStatus", "")).upper() == "RUNNING" and p.get("runtime")]
1041
+
1042
+ async def _list_pods_or_none(self) -> "Optional[List[dict]]":
1043
+ """The account's pods, or None when the ASK ITSELF failed.
1044
+
1045
+ `list_pods` flattens a transport error to `[]`, which is right for planning and fatal for
1046
+ reaping: an empty list would read as "no orphans" on exactly the network blip where you
1047
+ most need to look again. The reaper must be able to tell "nothing there" from "I could
1048
+ not see".
1049
+ """
1050
+ rp = self._sdk()
1051
+ if rp is None:
1052
+ return None
1053
+ try:
1054
+ import asyncio as _a
1055
+ return list(await _a.wait_for(_a.to_thread(rp.get_pods), timeout=45) or [])
1056
+ except Exception as e:
1057
+ log.error("reap: could not list pods (%s) — CANNOT conclude the account is clean",
1058
+ type(e).__name__)
1059
+ return None
1060
+
1061
+ # -- the reaper ---------------------------------------------------------------------------
1062
+
1063
+ async def reap(self, tracker: "Optional[PodTracker]" = None, *, owns: str = "",
1064
+ dry_run: bool = False, reap_unowned: bool = False,
1065
+ settle_s: float = 0.0) -> dict:
1066
+ """Reconcile against RunPod's own list and terminate what nothing is holding.
1067
+
1068
+ `tracker` supplies the kept set; without one, NOTHING is kept — that is the standalone
1069
+ sweep. `owns` scopes which names may be terminated (see the ownership note below);
1070
+ `reap_unowned=True` drops that scope and kills everything unkept.
1071
+
1072
+ `settle_s` > 0 re-lists until it stops finding new pods, for the case this whole class
1073
+ exists for: a create whose call timed out, whose pod appears a minute later.
1074
+
1075
+ OWNERSHIP. By default the reaper only TERMINATES pods whose name starts with `owns`;
1076
+ anything else it reports loudly but leaves alone. That protects the two kinds of pod a
1077
+ blanket reap would destroy: `diag_create` pods, which exist precisely so the owner can
1078
+ read boot failures in the console (the API exposes no pod logs), and anything the owner
1079
+ started by hand. Unowned pods are never silently ignored — they are logged as ORPHAN? and
1080
+ returned in `unowned`, so a sweep still surfaces them.
1081
+ """
1082
+ import asyncio as _a
1083
+ kept = set(tracker.kept) if tracker else set()
1084
+ scope = owns or (tracker.owns if tracker else "")
1085
+
1086
+ pods = await self._list_pods_or_none()
1087
+ if pods is None:
1088
+ return {"ok": False, "error": "could not list pods", "terminated": [],
1089
+ "kept": sorted(kept), "unowned": []}
1090
+
1091
+ # Settle: an ambiguous create can land AFTER the call that made it returned.
1092
+ if settle_s > 0:
1093
+ deadline = time.monotonic() + settle_s
1094
+ known = {str(x.get("id")) for x in pods}
1095
+ while time.monotonic() < deadline:
1096
+ await _a.sleep(_SETTLE_POLL_S)
1097
+ again = await self._list_pods_or_none()
1098
+ if again is None:
1099
+ continue
1100
+ fresh = {str(x.get("id")) for x in again} - known
1101
+ pods = again
1102
+ if fresh:
1103
+ log.warning("reap: %d pod(s) appeared AFTER the create returned: %s",
1104
+ len(fresh), ", ".join(sorted(fresh)))
1105
+ known |= fresh
1106
+ deadline = time.monotonic() + settle_s # something moved; watch again
1107
+
1108
+ terminated, failed, unowned, suspected = [], [], [], []
1109
+ for pod in pods:
1110
+ pid, pname = str(pod.get("id") or ""), str(pod.get("name") or "")
1111
+ if not pid or pid in kept:
1112
+ continue
1113
+ if scope and not reap_unowned and not pname.startswith(scope):
1114
+ age = _pod_age_hours(pod)
1115
+ # An unowned pod is left alone (a diag pod is the point of the exception) — but
1116
+ # "left alone" must never mean "quietly tolerated". Past the suspect age it is
1117
+ # escalated: ERROR, and carried out in `suspected_orphans` so the caller's exit
1118
+ # status can reflect it. A 13-hour pod in a reported bucket is how this cost $6.54.
1119
+ old_enough = age is None or age >= _ORPHAN_SUSPECT_AGE_H
1120
+ entry = {"id": pid, "name": pname, "age_h": age}
1121
+ if old_enough:
1122
+ log.error("reap: SUSPECTED ORPHAN — not mine, %s old, LEAVING IT but this "
1123
+ "needs a human: %s. Reap it with: python -m frutebox.podreaper "
1124
+ "--sweep --all",
1125
+ ("unknown age" if age is None else "%.2fh" % age), _describe(pod))
1126
+ suspected.append(entry)
1127
+ else:
1128
+ log.warning("reap: not mine, leaving it (only %.2fh old): %s",
1129
+ age, _describe(pod))
1130
+ unowned.append(entry)
1131
+ continue
1132
+ if dry_run:
1133
+ log.warning("reap: WOULD TERMINATE (dry run): %s", _describe(pod))
1134
+ terminated.append(pid)
1135
+ continue
1136
+ log.error("reap: TERMINATING untracked pod: %s", _describe(pod))
1137
+ (terminated if await self.terminate(pid) else failed).append(pid)
1138
+
1139
+ if terminated and not dry_run:
1140
+ log.error("reap: terminated %d untracked pod(s): %s — a pod reached this point "
1141
+ "because nothing confirmed it was wanted", len(terminated),
1142
+ ", ".join(terminated))
1143
+ if suspected:
1144
+ log.error("reap: %d SUSPECTED ORPHAN(S) older than %.0f min left running: %s",
1145
+ len(suspected), _ORPHAN_SUSPECT_AGE_H * 60,
1146
+ ", ".join(x["id"] for x in suspected))
1147
+ return {"ok": not failed, "terminated": terminated, "kept": sorted(kept),
1148
+ "unowned": unowned, "suspected_orphans": suspected,
1149
+ **({"failed": failed} if failed else {})}
1150
+
1151
+ @contextlib.asynccontextmanager
1152
+ async def creation_guard(self, owns: str, *, dry_run: bool = False):
1153
+ """Wrap every window in which a pod may be created. Yields a `PodTracker`.
1154
+
1155
+ The reap runs in a `finally`: an exception, a timeout, a cancelled task or a
1156
+ KeyboardInterrupt cannot skip it. Anything the caller did not `keep()` dies.
1157
+
1158
+ async with rp.creation_guard("frutebox-runpod") as t:
1159
+ pod_id = await rp.create_pod(spec, gpu, cloud)
1160
+ if pod_id and await rp.wait_ready(pod_id, spec):
1161
+ t.keep(pod_id) # <- the ONLY thing that spares it
1162
+ """
1163
+ tracker = PodTracker(owns)
1164
+ token = _TRACKER.set(tracker)
1165
+ try:
1166
+ yield tracker
1167
+ finally:
1168
+ _TRACKER.reset(token)
1169
+ try:
1170
+ # An ambiguous create means a pod may still be on its way: wait for it.
1171
+ settle = _SETTLE_S if tracker.ambiguous else 0.0
1172
+ result = await self.reap(tracker, dry_run=dry_run, settle_s=settle)
1173
+ if result.get("terminated"):
1174
+ log.error("creation_guard(%s): reaped %s", owns, result["terminated"])
1175
+ # A suspected orphan is not this window's to kill, but the NEXT wake must not be
1176
+ # able to proceed as if the account were clean. Recorded where the caller sees it.
1177
+ if result.get("suspected_orphans"):
1178
+ _SUSPECTED.extend(result["suspected_orphans"])
1179
+ log.error("creation_guard(%s): %d suspected orphan(s) still running — "
1180
+ "BILLING. %s", owns, len(result["suspected_orphans"]),
1181
+ ", ".join(x["id"] for x in result["suspected_orphans"]))
1182
+ except Exception as e: # a failing reaper must still be LOUD
1183
+ log.error("creation_guard(%s): REAP FAILED (%s) — pods MAY BE BILLING, sweep "
1184
+ "with: python -m frutebox.podreaper --sweep", owns, type(e).__name__)
1185
+
1186
+
1187
+ @staticmethod
1188
+ def pod_runtime_ready(pod: "dict | None") -> bool:
1189
+ """PURE. A live `runtime` with mapped ports — never `desiredStatus`.
1190
+
1191
+ Deliberately reads ONLY fields the SDK's pod query selects (`runtime { ports {...} }`).
1192
+ A predicate that once required `runtime.uptimeInSeconds` — a field the query cannot
1193
+ return — was structurally impossible to satisfy and reaped six healthy pods in a night.
1194
+ The guard test asserts every field read here appears in the SDK's own query text.
1195
+ """
1196
+ if not pod:
1197
+ return False
1198
+ rt = pod.get("runtime") or {}
1199
+ return bool(rt) and bool(rt.get("ports"))
1200
+
1201
+ @staticmethod
1202
+ def proxy_url(pod_id: str, port: int) -> str:
1203
+ return f"https://{pod_id}-{port}.proxy.runpod.net"
1204
+
1205
+ async def serves(self, pod_id: str, port: int, path: str = "/") -> bool:
1206
+ """A REAL request to the served port — not a TCP probe.
1207
+
1208
+ The User-Agent matters: RunPod's proxy rejects requests that do not look like a browser
1209
+ or curl, which once made a stream of perfectly healthy pods look dead.
1210
+
1211
+ `path` comes from `PodSpec.health_path`, because "what counts as answering" is a property
1212
+ of the SERVER, not of this door — see that field for the engine that has no `/`.
1213
+ """
1214
+ try:
1215
+ async with httpx.AsyncClient(timeout=20) as c:
1216
+ r = await c.get(f"{self.proxy_url(pod_id, port)}{path or '/'}",
1217
+ headers={"user-agent": "curl/8.4.0"})
1218
+ return r.status_code == 200
1219
+ except Exception:
1220
+ return False
1221
+
1222
+ # -- lifecycle ---------------------------------------------------------------------------
1223
+
1224
+ async def wait_ready(self, pod_id: str, spec: PodSpec,
1225
+ extra_ready: "Optional[Callable[[], bool]]" = None) -> bool:
1226
+ """Poll until the pod has a live runtime AND serves, or give up.
1227
+
1228
+ `extra_ready` lets the CALLER add its own success signal (the pool passes "a node
1229
+ registered with the directory", the strongest possible proof for our own image) without
1230
+ this module knowing anything about directories.
1231
+
1232
+ Returns False rather than raising: the caller's next move is reap-and-fallback, and an
1233
+ exception here would skip the reap and leave a billing pod behind.
1234
+ """
1235
+ import asyncio as _a
1236
+ waited = 0.0
1237
+ while waited < spec.ready_timeout_s:
1238
+ pod = await self.get_pod(pod_id)
1239
+ if pod is None:
1240
+ log.warning("pod %s vanished while waiting for readiness", pod_id)
1241
+ return False
1242
+ status = str(pod.get("desiredStatus", "")).upper()
1243
+ if status and status != "RUNNING":
1244
+ log.warning("pod %s left RUNNING (%s) before becoming ready", pod_id, status)
1245
+ return False
1246
+ if self.pod_runtime_ready(pod):
1247
+ if extra_ready is not None and extra_ready():
1248
+ log.info("pod %s READY after %.0fs (registered)", pod_id, waited)
1249
+ return True
1250
+ if await self.serves(pod_id, spec.serving_port, spec.health_path):
1251
+ log.info("pod %s READY after %.0fs (runtime live and serving)", pod_id, waited)
1252
+ return True
1253
+ await _a.sleep(spec.poll_s)
1254
+ waited += spec.poll_s
1255
+ log.warning("pod %s NOT ready after %.0fs. Reaping.", pod_id, spec.ready_timeout_s)
1256
+ return False
1257
+
1258
+ async def terminate(self, pod_id: str, verify_timeout_s: float = 120, poll_s: float = 5) -> bool:
1259
+ """Terminate AND VERIFY the pod is gone. The API's 2xx is never the evidence — REST
1260
+ DELETE returns 2xx without stopping the pod, and it keeps BILLING. The terminate is
1261
+ re-issued each round until the pod query stops returning it."""
1262
+ import asyncio as _a
1263
+ rp = self._sdk()
1264
+ if rp is None:
1265
+ return False
1266
+
1267
+ async def _issue():
1268
+ try:
1269
+ await _a.wait_for(_a.to_thread(rp.terminate_pod, pod_id), timeout=45)
1270
+ except Exception as e:
1271
+ log.warning("runpod terminate_pod %s: %s", pod_id, type(e).__name__)
1272
+
1273
+ await _issue()
1274
+ waited = 0.0
1275
+ while waited < verify_timeout_s:
1276
+ pod = await self.get_pod(pod_id)
1277
+ if pod is None:
1278
+ log.info("pod %s terminated and verified gone", pod_id)
1279
+ return True
1280
+ await _a.sleep(poll_s)
1281
+ waited += poll_s
1282
+ await _issue() # RunPod sometimes needs it more than once
1283
+ log.error("PANIC: pod %s still present %.0fs after terminate — MAY BE BILLING",
1284
+ pod_id, verify_timeout_s)
1285
+ return False
1286
+
1287
+ async def _template_id_for(self, spec: PodSpec) -> "str | None":
1288
+ """The RunPod template that carries this spec's registry credential, found or created.
1289
+
1290
+ NO CACHE, DELIBERATELY. The name is a hash of the spec's own contents, so the same spec
1291
+ always resolves to the same name; we re-find the template by ASKING RunPod rather than by
1292
+ remembering an id. A remembered id would be a second place holding something derived from
1293
+ the spec, and it would drift the moment the spec changed and the memory did not.
1294
+
1295
+ Measured 2026-09-20: `create_template` with a name that already exists FAILS with
1296
+ "Template name must be unique." — it does not return the existing id and does not make a
1297
+ duplicate. So the find MUST come first, templates cannot leak, and the count is bounded
1298
+ server-side by the number of distinct specs.
1299
+ """
1300
+ import asyncio as _a
1301
+ from . import runpod_templates as rt
1302
+
1303
+ rp = self._sdk()
1304
+ if rp is None:
1305
+ return None
1306
+ # The projection: only what the template actually carries. Not the pod name, not the GPU —
1307
+ # those vary per attempt and would mint a template per attempt.
1308
+ projection = {
1309
+ "image_name": spec.image,
1310
+ "container_disk_in_gb": spec.container_disk_gb,
1311
+ "ports": ports_string(spec),
1312
+ "registry_auth_id": spec.container_registry_auth_id,
1313
+ # THE ENV GOES ON THE TEMPLATE TOO, and it is part of the name.
1314
+ #
1315
+ # `create_pod` sends `env=` and `template_id=` in the same call, and which layer wins
1316
+ # is not documented. That is not a question worth renting a pod to answer: putting the
1317
+ # env on BOTH means the node boots with its credential and its gateway URL either way.
1318
+ # Whichever layer RunPod prefers, it prefers the same values.
1319
+ #
1320
+ # It belongs in the hash for the same reason everything else here does — a node
1321
+ # credential or a gateway URL that changed while the name stayed put would resolve to
1322
+ # a template carrying the OLD one, and the pod would boot with stale identity. The
1323
+ # hash is sha256 and only the digest is ever used, so no value is exposed by it.
1324
+ "env": _template_env(spec.env, spec.dedicated_template),
1325
+ }
1326
+ name = rt.template_name(projection)
1327
+
1328
+ found = await _a.to_thread(rt.find_template_id, name)
1329
+ if found:
1330
+ log.info("template %s found (%s)", name, found)
1331
+ return found
1332
+ try:
1333
+ res = await _a.wait_for(_a.to_thread(lambda: rp.create_template(
1334
+ name=name,
1335
+ image_name=spec.image,
1336
+ container_disk_in_gb=spec.container_disk_gb,
1337
+ ports=ports_string(spec),
1338
+ registry_auth_id=spec.container_registry_auth_id,
1339
+ # THE SAME PROJECTION THAT NAMED IT. Creating with the full env while naming with
1340
+ # a stripped one is what let a template's content drift from its own name.
1341
+ env=_template_env(spec.env, spec.dedicated_template),
1342
+ )), timeout=60)
1343
+ except Exception as e:
1344
+ # Someone may have created it between our find and our create. Ask once more before
1345
+ # giving up — the uniqueness error is exactly what a race looks like.
1346
+ log.warning("template %s create failed (%s: %s); re-checking",
1347
+ name, type(e).__name__, str(e)[:160])
1348
+ return await _a.to_thread(rt.find_template_id, name)
1349
+ tid = (res or {}).get("id")
1350
+ log.info("template %s created (%s)", name, tid)
1351
+ return str(tid) if tid else None
1352
+
1353
+ async def create_pod(self, spec: PodSpec, gpu_type_id: str, cloud_type: str,
1354
+ data_center_id: str = "") -> "str | None":
1355
+ """One create attempt. Every refusal is surfaced with RunPod's own reply — a silent None
1356
+ once cost three armed windows spent guessing."""
1357
+ import asyncio as _a
1358
+ rp = self._sdk()
1359
+ if rp is None:
1360
+ return None
1361
+ gpu_count = max(1, min(int(spec.gpu_count), MAX_GPU_COUNT))
1362
+ if gpu_count != int(spec.gpu_count):
1363
+ # A spec asking for more cards than the cap gets the cap, loudly. Silently honouring
1364
+ # an absurd count is how one typo becomes an hourly bill nobody chose.
1365
+ log.error("spec %s asked for %s GPUs; capped at %s (STEVAI_POD_MAX_GPU_COUNT)",
1366
+ spec.name, spec.gpu_count, gpu_count)
1367
+ if gpu_count > 1:
1368
+ # SAY IT. `from_operator_spec` now reads the operator's `gpuCount`, which it ignored
1369
+ # for as long as it existed — so a configured frontier spec that always produced a
1370
+ # 1-GPU pod suddenly produces the 8-GPU pod it always asked for. That is the intent
1371
+ # (a 1-GPU frontier pod cannot load the model at all), and it is an 8x hourly cost
1372
+ # change, so it belongs in the log at the moment the money is committed.
1373
+ log.warning("MULTI-GPU POD: %s x %s — hourly cost scales with the count",
1374
+ gpu_count, gpu_type_id)
1375
+ kwargs = dict(
1376
+ name=spec.name,
1377
+ image_name=spec.image,
1378
+ gpu_type_id=gpu_type_id,
1379
+ cloud_type=cloud_type,
1380
+ gpu_count=gpu_count,
1381
+ container_disk_in_gb=spec.container_disk_gb,
1382
+ volume_in_gb=spec.volume_gb,
1383
+ ports=ports_string(spec),
1384
+ docker_args=spec.docker_args,
1385
+ support_public_ip=True,
1386
+ env=dict(spec.env),
1387
+ # THE FIX for "RUNNING but no runtime, ever": without a CUDA filter the pod can land
1388
+ # on an old-driver host where the container fails OCI-create BEFORE any process starts
1389
+ # (unsatisfied condition: cuda>=12.x) — billing, empty logs, no runtime.
1390
+ allowed_cuda_versions=[v.strip() for v in envvar("ALLOWED_CUDA", "12.4,12.5,12.6,12.7,12.8").split(",") if v.strip()],
1391
+ )
1392
+ # A PRIVATE image needs a registry credential, and create_pod takes none — it travels as
1393
+ # a template. Only when the spec carries one: a public image (the PROBE spec pulls
1394
+ # ollama/ollama:latest from Docker Hub) must go out with no template and no credential.
1395
+ if spec.container_registry_auth_id:
1396
+ template_id = await self._template_id_for(spec)
1397
+ if not template_id:
1398
+ log.error("create REFUSED name=%s: the spec needs a registry credential but no "
1399
+ "template could be found or created — a pod without one is rented and "
1400
+ "exits in seconds", spec.name)
1401
+ return None
1402
+ kwargs["template_id"] = template_id
1403
+ if spec.network_volume_id:
1404
+ kwargs["network_volume_id"] = spec.network_volume_id
1405
+ # A network volume is DATACENTER-LOCKED: when one is attached, the volume's datacenter
1406
+ # overrides whatever the fallback entry says — deploying anywhere else cannot mount it,
1407
+ # which is a pod that bills and serves nothing. Without a volume, the entry's DC (if any)
1408
+ # applies, else placement stays free.
1409
+ dc = (spec.data_center_id if spec.network_volume_id else "") \
1410
+ or data_center_id or spec.data_center_id
1411
+ if dc:
1412
+ kwargs["data_center_id"] = dc
1413
+ log.info("create attempt: name=%s gpu=%sx%s cloud=%s dc=%s",
1414
+ spec.name, kwargs["gpu_count"], gpu_type_id, cloud_type,
1415
+ kwargs.get("data_center_id", "-"))
1416
+ # Who was on the account BEFORE we asked. The only way to tell an ambiguous create's pod
1417
+ # from someone else's is to know what was already there.
1418
+ before = {str(x.get("id")) for x in (await self._list_pods_or_none() or [])}
1419
+ tracker = _TRACKER.get()
1420
+ if tracker is None:
1421
+ log.warning("create_pod(%s) is running OUTSIDE a creation_guard — if this call times "
1422
+ "out, nothing will reap what it may have created", spec.name)
1423
+ try:
1424
+ # BOUNDED. On 2026-08-30 a create call hung inside the SDK with no timeout, holding
1425
+ # the reconcile lock — one pool-plan log line, then silence, while every tick skipped
1426
+ # as busy and the operator had no window into why. A stuck transport call is a
1427
+ # REFUSAL, loudly, never a wedge.
1428
+ pod = await _a.wait_for(_a.to_thread(lambda: rp.create_pod(**kwargs)), timeout=180)
1429
+ except Exception as e:
1430
+ # NOT "nothing was created". `wait_for` cancels the AWAIT, not the blocking SDK call
1431
+ # running in the worker thread — that request is still in flight and RunPod may honour
1432
+ # it seconds or minutes from now. On 2026-09-17 this exact path returned None, the
1433
+ # caller moved to the next fallback, the process exited, and the pod that arrived
1434
+ # afterwards billed for 13h20m. Reconcile against RunPod's list; do not guess.
1435
+ _LAST_CREATE_ERROR[gpu_type_id] = f"{type(e).__name__}: {str(e)[:160]}"
1436
+ log.error("create AMBIGUOUS gpu=%s cloud=%s: %s: %s — a pod MAY have been created; "
1437
+ "reconciling against RunPod's list", gpu_type_id, cloud_type,
1438
+ type(e).__name__, str(e)[:300])
1439
+ if tracker is not None:
1440
+ tracker.ambiguous.add(spec.name)
1441
+ pod_id = await self._find_created_after(spec.name, before)
1442
+ if pod_id:
1443
+ log.error("create AMBIGUOUS but a pod DID appear: %s — adopting it so the normal "
1444
+ "ready-or-reap path owns it", pod_id)
1445
+ if tracker is not None:
1446
+ tracker.saw(pod_id)
1447
+ return pod_id
1448
+ # Still nothing. The guard's exit reap re-checks with a settle window, because it can
1449
+ # still show up after we return.
1450
+ return None
1451
+ pod_id = (pod or {}).get("id")
1452
+ if not pod_id:
1453
+ log.warning("create refused gpu=%s cloud=%s reply=%s",
1454
+ gpu_type_id, cloud_type, str(pod)[:300])
1455
+ elif tracker is not None:
1456
+ tracker.saw(pod_id)
1457
+ return pod_id
1458
+
1459
+ async def _find_created_after(self, name: str, before: "Set[str]",
1460
+ window_s: "float | None" = None,
1461
+ poll_s: "float | None" = None) -> "str | None":
1462
+ """Poll RunPod for a pod named `name` that was not on the account before we asked.
1463
+
1464
+ Bounded, because the alternative to waiting is guessing, and guessing here costs money
1465
+ per hour. A pod that appears later than this window is caught by the guard's exit reap.
1466
+ """
1467
+ import asyncio as _a
1468
+ window_s = _AMBIGUOUS_WINDOW_S if window_s is None else window_s
1469
+ poll_s = _AMBIGUOUS_POLL_S if poll_s is None else poll_s
1470
+ waited = 0.0
1471
+ while waited <= window_s:
1472
+ pods = await self._list_pods_or_none()
1473
+ if pods is not None:
1474
+ for pod in pods:
1475
+ pid = str(pod.get("id") or "")
1476
+ if pid and pid not in before and str(pod.get("name") or "") == name:
1477
+ return pid
1478
+ await _a.sleep(poll_s)
1479
+ waited += poll_s
1480
+ return None
1481
+
1482
+ async def create_ready(self, spec: PodSpec,
1483
+ extra_ready: "Optional[Callable[[], bool]]" = None) -> "str | None":
1484
+ """Deploy → verify → reap → next candidate, across the spec's GPU/cloud fallbacks.
1485
+
1486
+ Returns the pod id only once it is genuinely serving. Anything that does not get there is
1487
+ terminated (with verification) before the next attempt, so a run of failures cannot leave
1488
+ a trail of billing corpses. None after the whole list = a genuine provider shortage — the
1489
+ caller escalates, never spins.
1490
+ """
1491
+ # EVERY create in this method happens inside the guard, so no exit path — exhausted
1492
+ # fallbacks, an exception, a cancelled task — can leave a pod nothing is holding. The
1493
+ # only pod that survives is the one we explicitly `keep()` below.
1494
+ async with self.creation_guard(_pool_scope(spec.name)) as tracker:
1495
+ return await self._create_ready_guarded(spec, tracker, extra_ready)
1496
+
1497
+ async def _create_ready_guarded(self, spec: PodSpec, tracker: PodTracker,
1498
+ extra_ready: "Optional[Callable[[], bool]]" = None) -> "str | None":
1499
+ model = str(spec.env.get("STEVAI_OLLAMA_MODEL", "") or "")
1500
+ # LIVE STOCK FIRST. The static list is the candidate SET; which of them can actually be
1501
+ # rented right now, and in what order, is a question only the provider can answer. Fails
1502
+ # open to the static order — see `rank_by_availability`.
1503
+ ordered = await self.rank_by_availability(spec.gpu_fallbacks, model=model,
1504
+ disk_gb=spec.container_disk_gb)
1505
+ for entry in ordered:
1506
+ gpu_type_id, cloud_type = entry[0], entry[1]
1507
+ entry_dc = entry[2] if len(entry) > 2 else ""
1508
+ # A doomed combination is refused BEFORE money is spent. This is the assertion form of
1509
+ # the 2026-08-30 root cause: a tier-S model on an A40 booted, billed, and "loaded"
1510
+ # forever — and looked exactly like a provider fault.
1511
+ if model:
1512
+ err = model_fit_error(model, gpu_type_id,
1513
+ spec.gpu_count, spec.container_disk_gb)
1514
+ if err:
1515
+ log.error("REFUSING create gpu=%s cloud=%s: %s", gpu_type_id, cloud_type, err)
1516
+ continue
1517
+ pod_id = await self.create_pod(spec, gpu_type_id, cloud_type, data_center_id=entry_dc)
1518
+ if not pod_id:
1519
+ continue
1520
+ log.info("placed pod=%s gpu=%s cloud=%s — verifying it actually serves",
1521
+ pod_id, gpu_type_id, cloud_type)
1522
+ if await self.wait_ready(pod_id, spec, extra_ready=extra_ready):
1523
+ log.info("READY pod=%s gpu=%s cloud=%s", pod_id, gpu_type_id, cloud_type)
1524
+ tracker.keep(pod_id) # the ONLY thing that spares it from the reaper
1525
+ return pod_id
1526
+ log.warning("not ready in time; reaping pod=%s gpu=%s cloud=%s",
1527
+ pod_id, gpu_type_id, cloud_type)
1528
+ await self.terminate(pod_id)
1529
+ log.error("all fallbacks exhausted — genuine RunPod capacity shortage, not a retry loop")
1530
+ return None
1531
+
1532
+ # -- diagnostics (owner-driven boot debugging) ---------------------------------------------
1533
+
1534
+ async def rank_by_availability(self, candidates, *, model: str = "",
1535
+ disk_gb: int = 40) -> list:
1536
+ """Order (gpu, cloud[, dc]) candidates by LIVE STOCK and price; drop what cannot be rented.
1537
+
1538
+ WHY THIS EXISTS. `gpu_fallbacks` is a static list written when it was written. On
1539
+ 2026-09-28 every card the WARM spec pins — A40, RTX A5000, RTX A6000, L40S — was out of
1540
+ stock simultaneously, so a wake tried four combinations, spent ~75s on each (RunPod's
1541
+ "no instances available" is ambiguous, so the provider reconciles against the live pod list
1542
+ before giving up), and produced nothing. The same thing happening at 3am to a standby that
1543
+ has died is a network with no capacity and nobody watching.
1544
+
1545
+ `lowestPrice` is null for a card with no stock, so this is a real availability signal.
1546
+ Capacity also MOVES: L40S went from no-stock to $0.79 inside two minutes the same day,
1547
+ which is why this is queried per wake and never cached.
1548
+
1549
+ IT FAILS OPEN, DELIBERATELY. If the price query cannot be answered — no credential, the
1550
+ vendor is down — this returns the caller's original order untouched. The alternative is a
1551
+ wake that refuses to try because it could not read a price list, which turns a degraded
1552
+ signal into no capacity at all. Same for the case where every candidate reports no stock:
1553
+ the static order is still attempted, because a stale "no stock" must not be the reason
1554
+ nothing is tried.
1555
+ """
1556
+ original = [tuple(c) for c in candidates]
1557
+ try:
1558
+ types = await self.gpu_prices()
1559
+ except Exception as e: # noqa: BLE001
1560
+ log.warning("availability: price query failed (%s) — keeping the static order",
1561
+ type(e).__name__)
1562
+ return original
1563
+ if not types:
1564
+ log.warning("availability: no price data — keeping the static order")
1565
+ return original
1566
+
1567
+ price: dict = {}
1568
+ for t in types:
1569
+ lp = t.get("lowestPrice") or {}
1570
+ best = lp.get("uninterruptablePrice") or lp.get("minimumBidPrice")
1571
+ if best is not None:
1572
+ price[str(t.get("id") or "")] = float(best)
1573
+
1574
+ in_stock, out = [], []
1575
+ for c in original:
1576
+ gpu = str(c[0])
1577
+ if gpu not in price:
1578
+ out.append(gpu)
1579
+ continue
1580
+ if model:
1581
+ err = model_fit_error(model, gpu, 1, disk_gb)
1582
+ if err:
1583
+ log.info("availability: skipping %s — %s", gpu, err)
1584
+ continue
1585
+ in_stock.append(c)
1586
+
1587
+ if not in_stock:
1588
+ log.warning("availability: none of %d candidate(s) report stock (%s) — trying the "
1589
+ "static order anyway", len(original), ", ".join(sorted(set(out))) or "-")
1590
+ return original
1591
+
1592
+ # Cheapest first. SECURE before COMMUNITY at equal price: Community publishes no uptime
1593
+ # SLA, which is the existing preference in `gpu_fallbacks` and not ours to reverse here.
1594
+ in_stock.sort(key=lambda c: (price[str(c[0])], 0 if str(c[1]).upper() == "SECURE" else 1))
1595
+ if out:
1596
+ log.info("availability: %d candidate(s) have no stock (%s)", len(out),
1597
+ ", ".join(sorted(set(out))))
1598
+ log.info("availability: trying %s first at $%.2f/hr",
1599
+ in_stock[0][0], price[str(in_stock[0][0])])
1600
+ return in_stock
1601
+
1602
+ async def diag_create(self, spec: PodSpec, gpu_type_id: str = "",
1603
+ cloud_type: str = "") -> dict:
1604
+ """Create ONE pod and DO NOT verify or reap it — the owner reads its System Logs in the
1605
+ RunPod console, which is the ONLY place boot failures are visible (the API/SDK exposes no
1606
+ pod logs; open feature request). The pod is named outside the pool pattern so no reconcile
1607
+ or teardown can ever touch it: it lives until the owner (or an explicit diag terminate)
1608
+ ends it.
1609
+
1610
+ IT WALKS THE SPEC'S OWN FALLBACKS, like the real create path does. It used to hardcode
1611
+ `NVIDIA A40` + `SECURE` and ignore `spec.gpu_fallbacks` entirely, so on 2026-09-27 a
1612
+ diagnostic wake returned `{"pod_id": null}` against RunPod's "no longer any instances
1613
+ available with the requested specifications" — while the spec it was handed listed FOUR
1614
+ GPU types across two cloud types, seven of which were never tried. A diagnostic that fails
1615
+ where production would have succeeded is worse than no diagnostic: it reads as "the node
1616
+ cannot start" and sends you hunting the node.
1617
+
1618
+ `model_fit_error` is applied for the same reason the real path applies it: a model that
1619
+ cannot fit the card boots, bills, and "loads" forever, which looks exactly like a provider
1620
+ fault. Refusing before money is spent is the point.
1621
+ """
1622
+ # An explicit override still wins — that is how a caller pins one combination on purpose.
1623
+ if gpu_type_id:
1624
+ fallbacks = ((gpu_type_id, cloud_type or "SECURE", ""),)
1625
+ else:
1626
+ fallbacks = tuple(spec.gpu_fallbacks) or (("NVIDIA A40", "SECURE", ""),)
1627
+
1628
+ # INSIDE A CREATION GUARD, AND THIS ONE IS PAID FOR. This path used to create pods with
1629
+ # nothing holding them, and the warning it logged said so. On 2026-09-27 the caller's HTTP
1630
+ # request hit Cloud Run's 300s cap mid-walk, the task was cancelled, and a pod appeared
1631
+ # AFTER the caller had already reported "no pod created" and run its own account check —
1632
+ # so it billed at $0.79/hr, unseen, until a manual account read found it.
1633
+ #
1634
+ # The guard reaps in a `finally`, so an exception, a timeout or a CANCELLED TASK cannot
1635
+ # skip it, and when a create was ambiguous it re-lists until pods stop appearing — which is
1636
+ # exactly the late-arriving pod that got away. `owns` is the diag pod's own name, so this
1637
+ # can never touch a pool pod.
1638
+ #
1639
+ # A DIAG POD MUST SURVIVE its own guard: it exists so the owner can read boot failures in
1640
+ # the provider console, which is the only place they are visible. `keep()` is what spares
1641
+ # it, and it is called ONLY for the pod actually created.
1642
+ model = str(spec.env.get("STEVAI_OLLAMA_MODEL", "") or "")
1643
+ # Same live-stock ordering as the real wake path. A diagnostic that searches differently
1644
+ # from production is a diagnostic that lies about production.
1645
+ if not gpu_type_id:
1646
+ fallbacks = await self.rank_by_availability(fallbacks, model=model,
1647
+ disk_gb=spec.container_disk_gb)
1648
+ refused: list = []
1649
+ async with self.creation_guard(_pool_scope(spec.name)) as tracker:
1650
+ for entry in fallbacks:
1651
+ gt, ct = entry[0], entry[1]
1652
+ dc = entry[2] if len(entry) > 2 else ""
1653
+ if model:
1654
+ err = model_fit_error(model, gt, spec.gpu_count, spec.container_disk_gb)
1655
+ if err:
1656
+ log.error("diag REFUSING gpu=%s cloud=%s: %s", gt, ct, err)
1657
+ refused.append({"gpu": gt, "cloud": ct, "reason": err})
1658
+ continue
1659
+ pod_id = await self.create_pod(spec, gt, ct, data_center_id=dc)
1660
+ if pod_id:
1661
+ tracker.keep(pod_id) # the ONLY thing that spares it from the reap
1662
+ info = await self.diag_info(pod_id)
1663
+ return {"pod_id": pod_id, "gpu": gt, "cloud": ct,
1664
+ **({"refused": refused} if refused else {}), **info}
1665
+ # SAY WHICH. "no capacity" is a claim about the MARKET; a rejected request is a
1666
+ # claim about US, and they send a reader to opposite places. Measured 2026-09-29:
1667
+ # nineteen creates were reported as capacity refusals while RunPod was actually
1668
+ # answering `ValueError: No GPU found with the specified ID` — the caller had
1669
+ # passed a DISPLAY name (`A40`) where the API wants a type id (`NVIDIA A40`). Two
1670
+ # hours went into the account balance, datacenter pinning and the stock table
1671
+ # because this line named the wrong subsystem with confidence.
1672
+ refused.append({"gpu": gt, "cloud": ct,
1673
+ "reason": _LAST_CREATE_ERROR.get(gt) or "no capacity or create refused"})
1674
+ # Every combination refused. Say WHICH, so the next attempt is informed rather than a
1675
+ # repeat — a silent None here once cost three armed windows spent guessing. Nothing was
1676
+ # kept, so the guard's reap on the way out cleans up any ambiguous create.
1677
+ log.error("diag create exhausted %d combination(s) for %s", len(fallbacks), spec.name)
1678
+ return {"pod_id": None, "tried": len(fallbacks), "refused": refused}
1679
+
1680
+ async def diag_info(self, pod_id: str) -> dict:
1681
+ """Everything observable about one pod, including the datacenter (which the SDK's own pod
1682
+ query does not select — this is a door-internal GraphQL query, allowed here and only
1683
+ here)."""
1684
+ pod = await self.get_pod(pod_id) or {}
1685
+ out = {
1686
+ "desiredStatus": pod.get("desiredStatus"),
1687
+ "costPerHr": pod.get("costPerHr"),
1688
+ "runtime": pod.get("runtime"),
1689
+ "machine": pod.get("machine"),
1690
+ "lastStatusChange": pod.get("lastStatusChange"),
1691
+ }
1692
+ try:
1693
+ q = ('query($id:String!){pod(input:{podId:$id}){machineId machine{dataCenterId '
1694
+ 'gpuDisplayName}}}')
1695
+ async with httpx.AsyncClient(timeout=30) as c:
1696
+ r = await c.post(self._GQL, json={"query": q, "variables": {"id": pod_id}},
1697
+ headers={"Authorization": f"Bearer {self._key()}",
1698
+ "content-type": "application/json"})
1699
+ d = ((r.json().get("data") or {}).get("pod")) or {}
1700
+ out["machineId"] = d.get("machineId")
1701
+ out["dataCenter"] = (d.get("machine") or {}).get("dataCenterId")
1702
+ out["gpu"] = (d.get("machine") or {}).get("gpuDisplayName")
1703
+ except Exception as e:
1704
+ out["dataCenter"] = f"<query failed: {type(e).__name__}>"
1705
+ return out
1706
+
1707
+ # -- prices (the ONE sanctioned GraphQL call — the SDK has no price query) -----------------
1708
+
1709
+ async def gpu_prices(self) -> List[dict]:
1710
+ """Live per-GPU pricing, for `gpuprices.py`. Returns [] on any failure."""
1711
+ if not self.configured():
1712
+ return []
1713
+ query = ("query{gpuTypes{id displayName memoryInGb securePrice communityPrice "
1714
+ "lowestPrice(input:{gpuCount:1}){minimumBidPrice uninterruptablePrice}}}")
1715
+ try:
1716
+ async with httpx.AsyncClient(timeout=30) as c:
1717
+ r = await c.post(self._GQL, json={"query": query},
1718
+ headers={"Authorization": f"Bearer {self._key()}",
1719
+ "content-type": "application/json"})
1720
+ if r.status_code >= 300:
1721
+ log.warning("runpod price fetch HTTP %s", r.status_code)
1722
+ return []
1723
+ return ((r.json().get("data") or {}).get("gpuTypes")) or []
1724
+ except Exception as e:
1725
+ log.warning("runpod price fetch: %s", type(e).__name__)
1726
+ return []
1727
+
1728
+ # -- the generic provider contract (find/create/destroy for start/stop above) --------------
1729
+
1730
+ async def find(self, label: str) -> List[Instance]:
1731
+ return await self.find_all(label)
1732
+
1733
+ async def find_all(self, label: str) -> List[Instance]:
1734
+ """TEARDOWN breadth. Everything RunPod has not TERMINATED still bills, including a pod
1735
+ whose `desiredStatus` is RUNNING with no runtime — the most expensive shape there is."""
1736
+ return [Instance(id=p.get("id"), label=p.get("name"))
1737
+ for p in await self.list_pods()
1738
+ if str(p.get("name", "")) == label
1739
+ and str(p.get("desiredStatus", "")).upper() != "TERMINATED"]
1740
+
1741
+ async def find_live(self, label: str) -> List[Instance]:
1742
+ """CAPACITY. `list_live` keyed by name: RUNNING **and** carrying a runtime object.
1743
+
1744
+ RunPod is the provider that can answer both questions honestly, because `desiredStatus`
1745
+ and `runtime` are two separate facts — intent and reality.
1746
+ """
1747
+ return [Instance(id=p.get("id"), label=p.get("name"))
1748
+ for p in await self.list_live() if str(p.get("name", "")) == label]
1749
+
1750
+ async def create(self, label: str, spec: dict) -> bool:
1751
+ pod_spec = PodSpec.from_operator_spec(spec, name=label)
1752
+ return bool(await self.create_ready(pod_spec))
1753
+
1754
+ async def destroy(self, instance_id: str) -> bool:
1755
+ return await self.terminate(instance_id)
1756
+
1757
+
1758
+ # ---- Vast.ai -------------------------------------------------------------------------------
1759
+
1760
+ class VastProvider:
1761
+ """Vast.ai. NOTE it spells the label `label`, not `name` — the exact kind of difference
1762
+ that made a hand-written start/stop pair per provider unsafe."""
1763
+
1764
+ name = "vast"
1765
+ _BASE = "https://console.vast.ai/api/v0"
1766
+
1767
+ def _key(self) -> str:
1768
+ return _provider_credential("vast", "VAST_API_KEY")
1769
+
1770
+ def configured(self) -> bool:
1771
+ return bool(self._key())
1772
+
1773
+ def _headers(self) -> dict:
1774
+ return {"Authorization": f"Bearer {self._key()}", "content-type": "application/json"}
1775
+
1776
+ #: `actual_status` values that mean the CONTAINER IS NOT SERVING. The contract, and the
1777
+ #: bill, outlive the container: an `exited` instance is still rented until it is destroyed.
1778
+ #: That is the whole 2026-09-24 lesson — "not serving" and "not billing" are not the same
1779
+ #: fact, and one list cannot answer both.
1780
+ _NOT_SERVING = frozenset({"exited"})
1781
+
1782
+ async def _labelled(self, label: str) -> List[dict]:
1783
+ """Every instance on the account carrying this label, in WHATEVER state.
1784
+
1785
+ Raw and unfiltered on purpose. Which states count is the CALLER's question; a
1786
+ pre-filtered list is exactly what hid a paid-for corpse from both readers at once.
1787
+ Instances Vast has actually destroyed stop appearing here at all, so "listed" already
1788
+ means "still contracted".
1789
+ """
1790
+ async with httpx.AsyncClient(timeout=30) as c:
1791
+ r = await c.get(f"{self._BASE}/instances/", headers=self._headers(),
1792
+ params={"owner": "me"})
1793
+ if r.status_code >= 300:
1794
+ return []
1795
+ return [i for i in ((r.json() or {}).get("instances") or [])
1796
+ if i.get("label") == label]
1797
+
1798
+ async def find(self, label: str) -> List[Instance]:
1799
+ return await self.find_live(label)
1800
+
1801
+ async def find_live(self, label: str) -> List[Instance]:
1802
+ """CAPACITY. Excludes `exited` — an exited instance serves nothing, and counting it
1803
+ would let it hold the pool's only slot and block its own replacement."""
1804
+ return [Instance(id=i.get("id"), label=i.get("label"))
1805
+ for i in await self._labelled(label)
1806
+ if str(i.get("actual_status", "")).lower() not in self._NOT_SERVING]
1807
+
1808
+ async def find_all(self, label: str) -> List[Instance]:
1809
+ """TEARDOWN breadth. EVERY listed instance under this label, exited ones included.
1810
+
1811
+ This is the method whose absence cost money: `create` took the contract, the container
1812
+ exited, and the only enumeration Vast had filtered the result out of the census and the
1813
+ teardown together. Nothing here is a guess — an instance Vast still lists is an instance
1814
+ Vast is still charging for.
1815
+ """
1816
+ return [Instance(id=i.get("id"), label=i.get("label"))
1817
+ for i in await self._labelled(label)]
1818
+
1819
+ async def _cheapest_offer(self, spec: dict) -> Optional[int]:
1820
+ """Vast is a MARKETPLACE: you rent a specific offer, so one must be chosen first."""
1821
+ query = {
1822
+ "limit": 20, "type": "ondemand",
1823
+ "rentable": {"eq": True}, "verified": {"eq": True},
1824
+ "num_gpus": {"gte": int(spec.get("gpuCount", 1) or 1)},
1825
+ "reliability": {"gte": 0.95},
1826
+ }
1827
+ if spec.get("minVramGb"):
1828
+ query["gpu_ram"] = {"gte": int(spec["minVramGb"]) * 1024}
1829
+ async with httpx.AsyncClient(timeout=30) as c:
1830
+ # TRAILING SLASH, as the API reference writes it (`POST .../api/v0/bundles/`, verified
1831
+ # 2026-10-12). Vast's other paths are spelled with one here too (`/asks/{id}/`,
1832
+ # `/instances/{id}/`); this one was not, and a missing slash on that API redirects or
1833
+ # 404s — which `_cheapest_offer` would report as "no offers" and the race would read as
1834
+ # "no capacity", not as a broken URL.
1835
+ r = await c.post(f"{self._BASE}/bundles/", headers=self._headers(), json=query)
1836
+ if r.status_code >= 300:
1837
+ return None
1838
+ offers = (r.json() or {}).get("offers") or []
1839
+ if not offers:
1840
+ return None
1841
+ offers.sort(key=lambda o: float(o.get("dph_total") or 1e9)) # cheapest that fits
1842
+ return offers[0].get("id")
1843
+
1844
+ async def create(self, label: str, spec: dict) -> bool:
1845
+ offer = await self._cheapest_offer(spec)
1846
+ if offer is None:
1847
+ return False
1848
+ body = {
1849
+ "image": spec.get("imageName") or spec.get("image") or "",
1850
+ "label": label, # the caller's label, never the spec's
1851
+ "disk": float(spec.get("containerDiskInGb") or 40),
1852
+ "env": spec.get("env") or {},
1853
+ "onstart": spec.get("dockerArgs") or "",
1854
+ "runtype": "args",
1855
+ }
1856
+ async with httpx.AsyncClient(timeout=60) as c:
1857
+ r = await c.put(f"{self._BASE}/asks/{offer}/", headers=self._headers(), json=body)
1858
+ if r.status_code >= 300:
1859
+ return False
1860
+ return bool((r.json() or {}).get("new_contract") or (r.json() or {}).get("success"))
1861
+
1862
+ async def destroy(self, instance_id: str) -> bool:
1863
+ async with httpx.AsyncClient(timeout=30) as c:
1864
+ r = await c.delete(f"{self._BASE}/instances/{instance_id}/", headers=self._headers())
1865
+ return r.status_code < 300 or r.status_code == 404
1866
+
1867
+
1868
+ # ---- Verda, ex-DataCrunch (the Phase-1 EU-sovereign provider) ------------------------------
1869
+ #
1870
+ # Finland-HQ, EU data residency, self-serve. Owner decision 2026-10-16 promotes this and Nebius
1871
+ # to REPLACE RunPod, so the EU pool is exactly as real as this class is.
1872
+ #
1873
+ # THE PROVIDER ID STAYS `datacrunch`. The company renamed itself to Verda in Nov 2025, but the id
1874
+ # is a PRIMARY KEY: it is the seed row's `id`, the key in `PROVIDERS`, the key in `_FETCHERS`, the
1875
+ # `providerId` on every credential row already in the database, and the prefix
1876
+ # `regionmap.provider_of_node` attributes a running node by. Renaming the id would orphan the
1877
+ # stored credential and silently un-attribute every live EU node — a rename of a key is a
1878
+ # migration, not a tidy-up. The class docstring, the log lines and the operator-facing text say
1879
+ # Verda; the id says `datacrunch`, deliberately.
1880
+ #
1881
+ # WHAT IS DIFFERENT HERE, AND WHY IT IS WRITTEN OUT
1882
+ # * AUTH IS OAuth2 CLIENT-CREDENTIALS, not a bearer key. Every other provider in this file
1883
+ # holds a static key; this one exchanges an id+secret for a short-lived access token. So the
1884
+ # token is CACHED — minting one per find/create/destroy would triple every call's latency and
1885
+ # hammer the auth endpoint during a wake storm.
1886
+ # * THE LABEL IS THE `hostname`. Vast spells it `label`, RunPod spells it `name`, this spells it
1887
+ # `hostname` — the precise class of difference that made a hand-rolled start/stop pair per
1888
+ # provider unsafe, which is why they all sit behind one interface.
1889
+ # * DELETE IS A `PUT` with `{"action": "delete"}`, not a DELETE verb.
1890
+ #
1891
+ # RE-VERIFIED 2026-10-16 against the vendor's OWN SOURCE — the official `verda-cloud/sdk-python`
1892
+ # package and the docs changelog — not against memory. Four things had moved since the 2026-09-07
1893
+ # transcription, and two of them cost money:
1894
+ #
1895
+ # 1. THE API HOST IS `api.verda.com` NOW. The SDK's `VerdaClient` default moved there. The old
1896
+ # `api.datacrunch.io` host still answers, so this was not yet an outage — it is a host that
1897
+ # is one vendor decision away from being one, and the default below follows the vendor.
1898
+ #
1899
+ # 2. `location_code` IS REQUIRED SINCE 2026-03-23, and omitting it is a 400 rather than the old
1900
+ # silent default to FIN-01. The previous `create` sent `"location_code": ""` whenever the
1901
+ # spec did not carry one — which is WORSE than omitting it, and meant every create without
1902
+ # an explicit location had been failing with a 400 since March. `create` now REFUSES without
1903
+ # a location instead of sending an empty string, for the same reason it already refuses
1904
+ # without an instance type: an under-specified create that 400s is a launch that silently
1905
+ # never happens, and `start()` reads the False as "no capacity".
1906
+ #
1907
+ # 3. A DELETE THAT NAMES NO VOLUMES LEAVES THE OS VOLUME BILLING. The vendor's own words: "By
1908
+ # default, no storage is selected for deletion." So the previous `destroy` reported a
1909
+ # successful teardown and left a paid NVMe volume behind every single time. `destroy` now
1910
+ # reads the instance's `os_volume_id` first and passes it with `delete_permanently`, which
1911
+ # is the same leak — and the same fix — as `DeleteWithInstance=false` on Alibaba. TERMINATE
1912
+ # MEANS NOTHING IS LEFT SPENDING, not "the compute stopped".
1913
+ #
1914
+ # 4. THE STATUS VOCABULARY IS NOW PUBLISHED, so this class can finally answer the two questions
1915
+ # separately — see `DISTINGUISHES_SERVING` below. `offline` BILLS AND DOES NOT SERVE, which
1916
+ # is precisely the state the old presence-only answer could not express.
1917
+ #
1918
+ # EU-ONLY IS NARROWER THAN "VERDA" (owner constraint, this lane). Verda's locations are FIN-01/02/03
1919
+ # and ICE-01, and ICE-01 is in ICELAND — EEA, not EU. An EU-residency promise served out of
1920
+ # Reykjavik is a promise this product did not make, so `EU_LOCATIONS` holds the Finnish codes only
1921
+ # and `create` refuses a location outside it. That refusal is in the adapter and not only in the
1922
+ # registry row because the row says which REGION the provider serves and the location code is a
1923
+ # finer thing the row cannot see.
1924
+
1925
+ #: Verda datacenter codes this product treats as EU. FIN-* only: `ICE-01` is Iceland, which is EEA
1926
+ #: but NOT an EU member state, and `regionmap.residency_providers` promises EU residency off this
1927
+ #: provider's `hq_country: FI`. Read from the vendor's `Locations` constants 2026-10-16.
1928
+ #:
1929
+ #: OPERATOR-OVERRIDABLE, because a new Finnish or EU datacenter must not need a release — but the
1930
+ #: DEFAULT is the conservative set, so a code the vendor adds later is refused until someone says
1931
+ #: it is in the EU rather than admitted because it was new.
1932
+ #: The DEFAULT set, as a string. The live answer comes from `verda_eu_locations()`.
1933
+ _VERDA_EU_LOCATIONS_DEFAULT = "FIN-01,FIN-02,FIN-03"
1934
+
1935
+
1936
+ def verda_eu_locations() -> frozenset:
1937
+ """The EU location codes, READ PER CALL.
1938
+
1939
+ A FUNCTION RATHER THAN A MODULE CONSTANT, and the reason is worth recording because the
1940
+ alternative cost half an hour. A constant is frozen at import, so the only way to test the
1941
+ operator override is `importlib.reload(gpuproviders)` — and a reload REBINDS this module's
1942
+ `PROVIDERS` dict while every module that did `from .gpuproviders import PROVIDERS` keeps the
1943
+ old one. `podreaper` does exactly that, so two tests of mine reloading this module desynchronised
1944
+ the reaper's registry from the real one and `test_pod_reaper` started asking a REAL RunPod
1945
+ client for the account — an AuthenticationError 90 test-files later, in a different file,
1946
+ reported as somebody else's flake.
1947
+
1948
+ It is also the better shape on its own terms: `region_providers()` and `warmpool_providers()`
1949
+ are call-time readers for the stated reason that "an operator can change the preference order
1950
+ with a restart rather than a rebuild", and a datacenter allow-list is the same kind of thing.
1951
+ """
1952
+ raw = envvar("VERDA_EU_LOCATIONS", _VERDA_EU_LOCATIONS_DEFAULT)
1953
+ return frozenset(c.strip().upper() for c in raw.split(",") if c.strip())
1954
+
1955
+
1956
+ def verda_api_base() -> str:
1957
+ """The Verda API host, read per call, new vendor name first.
1958
+
1959
+ Same reasoning as `verda_eu_locations()`: testable without a module reload. `_BASE` on the
1960
+ class is initialised from this and remains the attribute `gpuprices` and the auth probe read,
1961
+ so nothing else changes.
1962
+ """
1963
+ return (os.environ.get("VERDA_API_BASE")
1964
+ or os.environ.get("DATACRUNCH_API_BASE")
1965
+ or "https://api.verda.com/v1").rstrip("/")
1966
+
1967
+ #: The scope the token endpoint ISSUES for a Public-API client-credentials token (docs changelog
1968
+ #: 2026-03-20). It is NOT sent on the request — the vendor's own SDK sends no `scope` parameter,
1969
+ #: and adding one would be inventing a protocol. It is checked on the RESPONSE: a token minted with
1970
+ #: some other scope will 403 with "Insufficient scope" on the first real call, and finding that out
1971
+ #: at the auth step with a named reason beats finding it out at create time as an opaque refusal.
1972
+ VERDA_EXPECTED_SCOPE = "cloud-api-v1"
1973
+
1974
+
1975
+ class DataCrunchProvider:
1976
+ """Verda (ex-DataCrunch). OAuth2 client-credentials; instances keyed by `hostname`.
1977
+
1978
+ `name` is `datacrunch` on purpose — see the id note above this class.
1979
+ """
1980
+
1981
+ name = "datacrunch"
1982
+ #: Default moved to `api.verda.com` 2026-10-16, following the vendor's own SDK default.
1983
+ #: `DATACRUNCH_API_BASE` still overrides it, and `VERDA_API_BASE` is accepted too so an
1984
+ #: operator reaching for the new name is not silently ignored.
1985
+ _BASE = verda_api_base()
1986
+
1987
+ #: IT DISTINGUISHES NOW, AND THAT IS A READING RATHER THAN A DECISION TO BE BRAVER.
1988
+ #:
1989
+ #: This was the ONE provider declaring False, and its own comment said what it would take to
1990
+ #: change: "Classifying it would need a verified status list from the vendor, which is a ruling
1991
+ #: and an API check, not a guess made here." That list is now published — `verda/constants.py`
1992
+ #: in the vendor's official SDK enumerates every `InstanceStatus` — and the vendor's docs say
1993
+ #: which of them bill: "Shutdown instances continue to charge your account."
1994
+ #:
1995
+ #: So the two questions have different answers here and the class can give them:
1996
+ #: SERVING -> `running` only (`_SERVING`)
1997
+ #: BILLING -> everything that is not gone (`find`, minus `_DEAD`)
1998
+ #: and `offline` — which bills and serves nothing — is the state that used to be reported as a
1999
+ #: clean "present, therefore live" to all three readers at once.
2000
+ #:
2001
+ #: `test_gpuproviders.py` enforces the declaration against the methods, so this True is only
2002
+ #: legal while both enumerations below exist.
2003
+ DISTINGUISHES_SERVING = True
2004
+
2005
+ #: Statuses that mean the instance is GONE. Everything else — including `offline` — counts as
2006
+ #: live for TEARDOWN. That direction is deliberate on both paths: an instance we cannot
2007
+ #: classify must not cause `start()` to create a second one, and must not be skipped by
2008
+ #: `stop()`. Guessing "probably dead" is how something keeps billing after a teardown reported
2009
+ #: success.
2010
+ #:
2011
+ #: `hibernating`/`starting_hibernation`/`restoring` are NOT here even though the vendor has
2012
+ #: REMOVED hibernation as a feature ("it was the same as Delete while keeping all storage").
2013
+ #: A state the vendor retired is exactly the state an old instance could still be sitting in,
2014
+ #: and treating a retired state as "gone" would make such an instance invisible to teardown
2015
+ #: while its storage bills. Retired by the vendor is not the same as absent from the account.
2016
+ _DEAD = frozenset({"deleted", "terminated", "destroyed", "discontinued"})
2017
+
2018
+ #: The ONLY status that means this instance is actually SERVING. A deliberately narrow set:
2019
+ #: `provisioning` and `ordered` are not serving yet, `error` is not serving at all, and
2020
+ #: `offline` is a shut-down instance that still charges the account. Anything the vendor adds
2021
+ #: later is NOT serving until someone adds it here, which is the safe direction for a capacity
2022
+ #: answer — an unknown status holds no pool slot and so cannot block its own replacement, while
2023
+ #: `find`/`find_all` still see it and still tear it down.
2024
+ _SERVING = frozenset({"running"})
2025
+
2026
+ def __init__(self) -> None:
2027
+ self._token = ""
2028
+ self._token_expires = 0.0
2029
+
2030
+ def _creds(self) -> tuple:
2031
+ """The client id and secret, under EITHER naming.
2032
+
2033
+ TWO NAMES, NEW ONE FIRST. The credential was stored under `DATACRUNCH_*` before the vendor
2034
+ renamed itself; an operator seeding it today reaches for `VERDA_*`. Which of the two is in
2035
+ the database is not knowable from here — so BOTH are read, the new name wins, and the old
2036
+ one keeps working. The alternative is a rename migration that strands a working credential
2037
+ and reports the provider unconfigured, which is the 2026-09-07 RunPod key failure shape:
2038
+ present in the registry, invisible to the code, "configured: true" in the doctor.
2039
+
2040
+ A PAIR IS NOT MIXED ACROSS NAMINGS. An id read from `VERDA_CLIENT_ID` is never paired with
2041
+ a secret read from `DATACRUNCH_CLIENT_SECRET`: half of a rotated pair and half of a retired
2042
+ one authenticate as neither, and the resulting 401 looks like a revoked key rather than a
2043
+ seeding mistake. So each naming is resolved whole, and the first COMPLETE pair wins.
2044
+ """
2045
+ for cid_name, sec_name in (("VERDA_CLIENT_ID", "VERDA_CLIENT_SECRET"),
2046
+ ("DATACRUNCH_CLIENT_ID", "DATACRUNCH_CLIENT_SECRET")):
2047
+ cid = _provider_credential("datacrunch", cid_name)
2048
+ secret = _provider_credential("datacrunch", sec_name)
2049
+ if cid and secret:
2050
+ return (cid, secret)
2051
+ return ("", "")
2052
+
2053
+ def credential_names(self) -> "tuple[str, str]":
2054
+ """WHICH naming resolved, for the doctor and the probe. Names only, never values — the name
2055
+ of an env var is not secret (file 28 §O) and its contents never leave `_creds`."""
2056
+ for cid_name, sec_name in (("VERDA_CLIENT_ID", "VERDA_CLIENT_SECRET"),
2057
+ ("DATACRUNCH_CLIENT_ID", "DATACRUNCH_CLIENT_SECRET")):
2058
+ if _provider_credential("datacrunch", cid_name) and _provider_credential("datacrunch", sec_name):
2059
+ return (cid_name, sec_name)
2060
+ return ("", "")
2061
+
2062
+ def configured(self) -> bool:
2063
+ cid, secret = self._creds()
2064
+ return bool(cid and secret)
2065
+
2066
+ async def _access_token(self) -> str:
2067
+ """A cached OAuth2 access token, refreshed a minute before it actually expires.
2068
+
2069
+ The margin matters: a token that passes the check here and expires in flight produces a
2070
+ 401 on the CREATE call, and a create that fails ambiguously is the one failure mode that
2071
+ can leave an instance running that nobody recorded.
2072
+
2073
+ NO `scope` IS SENT. The vendor's own SDK sends `grant_type`, `client_id` and
2074
+ `client_secret` and nothing else; `cloud-api-v1` is what the server PUTS IN THE RESPONSE
2075
+ (docs changelog 2026-03-20). The response scope is checked and a mismatch is LOGGED rather
2076
+ than refused — the vendor is free to rename or widen its own scope string, and refusing a
2077
+ token the server just issued would ground the EU pool over a label. The log is what turns
2078
+ the later "Insufficient scope" 403 from a mystery into a lookup.
2079
+ """
2080
+ now = time.time()
2081
+ if self._token and now < self._token_expires:
2082
+ return self._token
2083
+ cid, secret = self._creds()
2084
+ if not (cid and secret):
2085
+ return ""
2086
+ try:
2087
+ async with httpx.AsyncClient(timeout=30) as c:
2088
+ r = await c.post(f"{self._BASE}/oauth2/token",
2089
+ json={"grant_type": "client_credentials",
2090
+ "client_id": cid, "client_secret": secret})
2091
+ except Exception as e:
2092
+ # A TRANSPORT FAILURE HERE USED TO RAISE OUT OF EVERY METHOD ON THIS CLASS, which
2093
+ # contradicts the contract `_headers()` states one function below in as many words:
2094
+ # "Callers check for None and fail the way the contract says — 'nothing running' /
2095
+ # 'did not create' — never by raising."
2096
+ #
2097
+ # It was unwrapped from before this lane existed, and the reachable consequences are
2098
+ # real: `create()` has no try/except of its own, so a DNS blip or a connection reset
2099
+ # on the token endpoint propagated out of a provider method as an exception rather
2100
+ # than as "no capacity". `find()` has the same shape. Nebius's exchange was already
2101
+ # wrapped; this is Verda catching up.
2102
+ #
2103
+ # The TYPE only — never the exception's text. A client error can quote the request,
2104
+ # and this request carries the client secret.
2105
+ log.warning("verda: token endpoint unreachable (%s) — treating as no token",
2106
+ type(e).__name__)
2107
+ return ""
2108
+ if r.status_code >= 300:
2109
+ log.warning("verda: token endpoint refused the client credentials (HTTP %s)",
2110
+ r.status_code)
2111
+ return ""
2112
+ data = r.json() or {}
2113
+ token = str(data.get("access_token") or "")
2114
+ if not token:
2115
+ return ""
2116
+ scope = str(data.get("scope") or "")
2117
+ if scope and VERDA_EXPECTED_SCOPE not in scope.split():
2118
+ log.warning("verda: token issued with scope %r, expected %r — Public API calls may "
2119
+ "return 403 'Insufficient scope'", scope, VERDA_EXPECTED_SCOPE)
2120
+ self._token = token
2121
+ self._token_expires = now + max(0.0, float(data.get("expires_in") or 3600)) - 60
2122
+ return token
2123
+
2124
+ async def _headers(self) -> "Optional[dict]":
2125
+ """Auth headers, or None when no token could be obtained.
2126
+
2127
+ None rather than an empty bearer: `Authorization: Bearer ` is an ILLEGAL header value and
2128
+ httpx raises `LocalProtocolError` on it, so a simple credential problem would surface as
2129
+ a protocol crash from inside the transport. Callers check for None and fail the way the
2130
+ contract says — "nothing running" / "did not create" — never by raising.
2131
+ """
2132
+ token = await self._access_token()
2133
+ if not token:
2134
+ return None
2135
+ return {"Authorization": f"Bearer {token}", "content-type": "application/json"}
2136
+
2137
+ async def _instances(self) -> "Optional[List[dict]]":
2138
+ """Every instance on the account, or None when the account could not be ASKED.
2139
+
2140
+ None IS NOT AN EMPTY LIST, and that distinction is this codebase's most expensive recurring
2141
+ bug: an unreadable list read as "nothing is there". `find` converts None to `[]` because
2142
+ its contract says so, but `destroy` needs the difference — it must not conclude an instance
2143
+ has no OS volume because the list call failed.
2144
+ """
2145
+ headers = await self._headers()
2146
+ if headers is None:
2147
+ return None
2148
+ try:
2149
+ async with httpx.AsyncClient(timeout=30) as c:
2150
+ r = await c.get(f"{self._BASE}/instances", headers=headers)
2151
+ except Exception as e:
2152
+ log.warning("verda: could not list instances (%s)", type(e).__name__)
2153
+ return None
2154
+ if r.status_code >= 300:
2155
+ log.warning("verda: instance list HTTP %s", r.status_code)
2156
+ return None
2157
+ body = r.json() or []
2158
+ rows = body if isinstance(body, list) else (body.get("instances") or body.get("data") or [])
2159
+ return [i for i in rows if isinstance(i, dict)]
2160
+
2161
+ def _matching(self, rows: "List[dict]", label: str, statuses: "Optional[frozenset]" = None,
2162
+ *, exclude_dead: bool = True) -> List[Instance]:
2163
+ """The rows carrying `label`, narrowed to `statuses` when one is given."""
2164
+ out: List[Instance] = []
2165
+ for i in rows:
2166
+ if i.get("hostname") != label:
2167
+ continue
2168
+ status = str(i.get("status", "")).lower()
2169
+ if exclude_dead and status in self._DEAD:
2170
+ continue
2171
+ if statuses is not None and status not in statuses:
2172
+ continue
2173
+ out.append(Instance(id=i.get("id"), label=i.get("hostname"), status=status))
2174
+ return out
2175
+
2176
+ async def find(self, label: str) -> List[Instance]:
2177
+ """THE BASE PRIMITIVE: anything carrying this label that is not GONE.
2178
+
2179
+ What `start()`'s idempotency check wants — "is anything of mine already here" — answered as
2180
+ conservatively as the vendor's vocabulary allows.
2181
+ """
2182
+ rows = await self._instances()
2183
+ return self._matching(rows or [], label)
2184
+
2185
+ async def find_live(self, label: str) -> List[Instance]:
2186
+ """CAPACITY: only instances that are actually SERVING (`running`).
2187
+
2188
+ This is the half the presence-only answer got wrong in the expensive direction. An
2189
+ `offline` instance was reported live, so it held its pool slot, no replacement was ever
2190
+ created, and the account was charged for a machine serving nothing.
2191
+ """
2192
+ rows = await self._instances()
2193
+ return self._matching(rows or [], label, self._SERVING)
2194
+
2195
+ async def find_all(self, label: str) -> List[Instance]:
2196
+ """TEARDOWN: everything under this label that could still BILL — `offline` included.
2197
+
2198
+ Identical to `find` today, and NOT an alias for it: they answer different questions and
2199
+ the vendor could add a status that is billing-but-not-live-for-idempotency. Writing it out
2200
+ keeps `DISTINGUISHES_SERVING = True` honest and keeps the two call sites independent.
2201
+ """
2202
+ rows = await self._instances()
2203
+ return self._matching(rows or [], label)
2204
+
2205
+ async def status(self, label: str) -> dict:
2206
+ """The vendor's own status for each instance under this label, unflattened.
2207
+
2208
+ For operators and for the live-proof log: `find*` return the classification and this
2209
+ returns what was classified. A teardown that has to be defended later needs the raw word
2210
+ the vendor used, not our bucket for it.
2211
+
2212
+ `{"ok": False}` when the account could not be asked — never an empty `instances` list,
2213
+ which would read as "the label is clear".
2214
+ """
2215
+ rows = await self._instances()
2216
+ if rows is None:
2217
+ return {"ok": False, "provider": self.name, "label": label,
2218
+ "detail": "could not list instances — this is NOT 'the label is clear'"}
2219
+ mine = [i for i in rows if i.get("hostname") == label]
2220
+ return {
2221
+ "ok": True,
2222
+ "provider": self.name,
2223
+ "label": label,
2224
+ "instances": [{"id": i.get("id"), "status": str(i.get("status", "")).lower(),
2225
+ "location": i.get("location") or i.get("location_code") or "",
2226
+ "os_volume_id": i.get("os_volume_id") or "",
2227
+ "price_per_hour": i.get("price_per_hour"),
2228
+ "serving": str(i.get("status", "")).lower() in self._SERVING,
2229
+ "billing": str(i.get("status", "")).lower() not in self._DEAD}
2230
+ for i in mine],
2231
+ }
2232
+
2233
+ async def list_account(self) -> "Optional[List[dict]]":
2234
+ """EVERYTHING on the account, label-agnostic, normalised. `None` when it could not be asked.
2235
+
2236
+ THE QUESTION `find(label)` CANNOT ANSWER, and the reason `podreaper` exists at all: the
2237
+ guard in this module only covers instances created inside a guarded window, and the
2238
+ 2026-09-17 orphan was created by a process that had already exited believing it had failed.
2239
+ Something has to be able to ask "what is actually on this account" with no memory of what
2240
+ was supposed to be there — and that question has no label to filter on.
2241
+
2242
+ `None` rather than `[]` on failure, loudly, because `podreaper`'s whole discipline is that
2243
+ a list it could not read is NOT "the account is clean".
2244
+ """
2245
+ rows = await self._instances()
2246
+ if rows is None:
2247
+ return None
2248
+ return [{"id": str(i.get("id") or ""),
2249
+ "label": str(i.get("hostname") or ""),
2250
+ "status": str(i.get("status", "")).lower(),
2251
+ "created_at": i.get("created_at") or "",
2252
+ "price_per_hour": i.get("price_per_hour"),
2253
+ "location": i.get("location") or i.get("location_code") or "",
2254
+ "billing": str(i.get("status", "")).lower() not in self._DEAD}
2255
+ for i in rows]
2256
+
2257
+ async def list_volumes(self) -> "Optional[List[dict]]":
2258
+ """Every non-deleted volume on the account, normalised. `None` when it could not be asked.
2259
+
2260
+ WHY THIS EXISTS, AND IT IS A BILL THAT IS ALREADY RUNNING. Until 2026-10-16 `destroy` did
2261
+ not name the volume ids, and the vendor deletes no storage unless the delete names it ("by
2262
+ default, no storage is selected for deletion"). So every Verda instance this platform has
2263
+ ever torn down very likely left a detached, paid NVMe volume behind. Closing the leak does
2264
+ not reclaim the residue, and nothing else in this repo can see it: `podreaper --providers`
2265
+ enumerates INSTANCES, and `frutebox/volumes.py` is a cost model, not an inventory.
2266
+
2267
+ IT READS AND NOTHING ELSE. There is no delete anywhere on this path, deliberately — a
2268
+ detached volume may be somebody's deliberately-kept dataset, and this code cannot tell that
2269
+ from an orphan. `test_verda_orphan_volumes.py` asserts the absence over the syntax tree.
2270
+
2271
+ NO `?status=detached`, THOUGH THE VENDOR OFFERS IT. The filter would make "0 detached" and
2272
+ "the call did not work" the same output, which is the mistake this codebase keeps paying
2273
+ for. Everything is fetched and classified here, so a zero always arrives with the total it
2274
+ was drawn from — and a volume whose `status` the vendor words differently than we expect is
2275
+ still seen, instead of being filtered out of existence upstream.
2276
+ """
2277
+ headers = await self._headers()
2278
+ if headers is None:
2279
+ return None
2280
+ try:
2281
+ async with httpx.AsyncClient(timeout=30) as c:
2282
+ r = await c.get(f"{self._BASE}/volumes", headers=headers)
2283
+ except Exception as e:
2284
+ log.warning("verda: could not list volumes (%s)", type(e).__name__)
2285
+ return None
2286
+ if r.status_code >= 300:
2287
+ log.warning("verda: volume list HTTP %s", r.status_code)
2288
+ return None
2289
+ body = r.json() or []
2290
+ rows = body if isinstance(body, list) else (body.get("volumes") or body.get("data") or [])
2291
+ out: List[dict] = []
2292
+ for v in rows:
2293
+ if not isinstance(v, dict):
2294
+ continue
2295
+ out.append({
2296
+ "id": str(v.get("id") or ""),
2297
+ "name": str(v.get("name") or ""),
2298
+ "status": str(v.get("status", "")).lower(),
2299
+ "type": str(v.get("type") or ""),
2300
+ "size_gb": v.get("size"),
2301
+ "is_os_volume": bool(v.get("is_os_volume")),
2302
+ # None when detached, per the vendor's own field documentation.
2303
+ "instance_id": v.get("instance_id") or None,
2304
+ "location": str(v.get("location") or v.get("location_code") or "").upper(),
2305
+ "created_at": v.get("created_at") or "",
2306
+ "deleted_at": v.get("deleted_at") or None,
2307
+ # THE NUMBERS THAT MAKE THIS WORTH READING. The vendor returns them per volume, so
2308
+ # the report can say what the residue COSTS rather than how many rows it has.
2309
+ "hourly_cost": v.get("base_hourly_cost"),
2310
+ "monthly_price": v.get("monthly_price"),
2311
+ "currency": str(v.get("currency") or ""),
2312
+ })
2313
+ return out
2314
+
2315
+ async def create(self, label: str, spec: dict) -> bool:
2316
+ location = str(spec.get("locationCode") or spec.get("location_code") or "").strip().upper()
2317
+ body = {
2318
+ # The CALLER's label, never the spec's. A provider that lets the spec's name through
2319
+ # is the 2026-08-21 billing bug reintroduced: the wake cannot find what it started.
2320
+ "hostname": label,
2321
+ "description": spec.get("description") or label,
2322
+ "instance_type": spec.get("instanceType") or spec.get("instance_type") or "",
2323
+ "image": spec.get("image") or spec.get("imageName") or "",
2324
+ "location_code": location,
2325
+ }
2326
+ if not body["instance_type"] or not body["image"]:
2327
+ return False # an under-specified create would 400 anyway; fail honestly
2328
+ if not location:
2329
+ # REQUIRED SINCE 2026-03-23 — omitted is a 400, and the empty string this used to send
2330
+ # is worse than omitted. Refusing here names the missing field; sending "" produced an
2331
+ # opaque 400 that `start()` read as "no capacity in the EU".
2332
+ log.error("verda: refusing to create %r without a location_code. It has been REQUIRED "
2333
+ "since 2026-03-23 (an absent one is HTTP 400, not a default to FIN-01). Set "
2334
+ "it in the launch spec; EU codes are %s",
2335
+ label, ",".join(sorted(verda_eu_locations())))
2336
+ return False
2337
+ if location not in verda_eu_locations():
2338
+ # ICE-01 is Iceland: EEA, not EU. See `verda_eu_locations`.
2339
+ log.error("verda: refusing to create %r in %s — not an EU location. Permitted: %s",
2340
+ label, location, ",".join(sorted(verda_eu_locations())))
2341
+ return False
2342
+ ssh = spec.get("sshKeyIds") or spec.get("ssh_key_ids")
2343
+ if ssh:
2344
+ body["ssh_key_ids"] = list(ssh)
2345
+ script = spec.get("startupScriptId") or spec.get("startup_script_id")
2346
+ if script:
2347
+ body["startup_script_id"] = script
2348
+ for optional in ("contract", "is_spot", "os_volume", "volumes", "existing_volumes",
2349
+ "pricing"):
2350
+ if spec.get(optional) is not None:
2351
+ body[optional] = spec[optional]
2352
+ headers = await self._headers()
2353
+ if headers is None:
2354
+ return False
2355
+ async with httpx.AsyncClient(timeout=90) as c:
2356
+ r = await c.post(f"{self._BASE}/instances", headers=headers, json=body)
2357
+ if r.status_code >= 300:
2358
+ log.error("verda: create %r HTTP %s", label, r.status_code)
2359
+ return False
2360
+ return True
2361
+
2362
+ async def destroy(self, instance_id: str) -> bool:
2363
+ """Terminate — AND take the OS volume with it. 404 counts as success.
2364
+
2365
+ THE VOLUME IS THE POINT OF THIS METHOD'S SHAPE. The vendor: "By default, no storage is
2366
+ selected for deletion." So the obvious `{"action": "delete", "id": X}` deletes the compute,
2367
+ reports 200, and leaves a paid NVMe volume on the account forever — a teardown that
2368
+ reports success and keeps spending, which is the exact failure `destroy_verified` exists to
2369
+ catch and which no amount of checking the HTTP status would have revealed.
2370
+
2371
+ So this reads the instance's `os_volume_id` first and names it, with `delete_permanently`.
2372
+
2373
+ A FAILED LOOKUP DOES NOT BECOME "NO VOLUME". If the account cannot be listed, the delete
2374
+ still goes ahead — leaving a running GPU to bill because a list call failed would be the
2375
+ worse trade — but it is LOGGED as a teardown whose storage was not accounted for, so the
2376
+ residue is discoverable instead of silent.
2377
+
2378
+ THERE IS NO VOLUME SWEEP TO FALL BACK ON, and this docstring said there was. `volumes.py`
2379
+ is PURE POLICY — "should a network volume exist, given cold-start frequency and cost" —
2380
+ with no I/O and no CLI at all, so `python -m frutebox.volumes --sweep` was a command that
2381
+ does not exist, pointed at an operator in a log line they would only ever read while
2382
+ chasing a bill. Checked, not assumed; see the WARNING text below for what it says now.
2383
+ """
2384
+ headers = await self._headers()
2385
+ if headers is None:
2386
+ return False
2387
+
2388
+ volume_ids: List[str] = []
2389
+ rows = await self._instances()
2390
+ if rows is None:
2391
+ log.warning("verda: could not read instance %s before deleting it — its OS volume is "
2392
+ "NOT accounted for and may keep billing. Nothing in this repo lists Verda "
2393
+ "volumes; check `GET %s/volumes` with the same credential, or the "
2394
+ "Storage page in the Verda console, and delete any detached volume whose "
2395
+ "instance is gone.", instance_id, self._BASE)
2396
+ else:
2397
+ for i in rows:
2398
+ if str(i.get("id")) != str(instance_id):
2399
+ continue
2400
+ if i.get("os_volume_id"):
2401
+ volume_ids.append(str(i["os_volume_id"]))
2402
+ for v in (i.get("volumes") or []):
2403
+ vid = v.get("id") if isinstance(v, dict) else v
2404
+ if vid:
2405
+ volume_ids.append(str(vid))
2406
+ break
2407
+
2408
+ body: Dict[str, object] = {"action": "delete", "id": instance_id}
2409
+ if volume_ids:
2410
+ # Both keys together: `delete_permanently` is documented as applying only when
2411
+ # `volume_ids` is also present, so sending it alone would be a no-op that reads like a
2412
+ # guarantee.
2413
+ body["volume_ids"] = volume_ids
2414
+ body["delete_permanently"] = True
2415
+ async with httpx.AsyncClient(timeout=60) as c:
2416
+ r = await c.put(f"{self._BASE}/instances", headers=headers, json=body)
2417
+ return r.status_code < 300 or r.status_code == 404
2418
+
2419
+
2420
+ #: Volume statuses that mean the volume is GONE and cannot be billing. Everything else counts as
2421
+ #: present — including a status we have not seen, for the same reason `_DEAD` is a small set on the
2422
+ #: instance side: an unclassifiable volume must be REPORTED, never filtered into non-existence.
2423
+ VERDA_VOLUME_DEAD = frozenset({"deleted", "deleting"})
2424
+
2425
+
2426
+ def verda_detached_volumes(rows: "List[dict]") -> "List[dict]":
2427
+ """The volumes nobody is holding: present, and attached to no instance.
2428
+
2429
+ DETACHED IS READ FROM `instance_id`, NOT FROM `status`, AND BOTH ARE CONSULTED. The vendor
2430
+ documents `instance_id` as "None if detached", which is the fact we actually care about —
2431
+ whether anything is using it — while `status` is a lifecycle word whose vocabulary we have only
2432
+ partially verified. A volume with no instance is detached whatever its status says, and a
2433
+ volume the vendor explicitly calls `detached` is detached even if some future response carries
2434
+ a stale instance id. Either signal is enough; neither is required to agree with the other.
2435
+
2436
+ THIS IS NOT "ORPHANED". A detached volume may be a dataset somebody keeps on purpose, and
2437
+ nothing here can tell that from residue — which is exactly why the caller only ever LISTS.
2438
+ """
2439
+ out = []
2440
+ for v in rows:
2441
+ if str(v.get("status", "")).lower() in VERDA_VOLUME_DEAD or v.get("deleted_at"):
2442
+ continue
2443
+ if v.get("instance_id") and str(v.get("status", "")).lower() != "detached":
2444
+ continue
2445
+ out.append(v)
2446
+ return out
2447
+
2448
+
2449
+ # ---- POD SPEC DERIVATION: the cheapest EU single-GPU box that can run the proof ------------
2450
+ #
2451
+ # SUPERVISOR DECISION 2026-10-17: "POD_SPECs are not an owner decision — set the defaults to the
2452
+ # cheapest EU single-GPU type in each provider's LIVE catalog that fits the proof model; log the
2453
+ # choice + price."
2454
+ #
2455
+ # WHY THIS IS A DERIVATION AND NOT A DEFAULT STRING, which is the one design call in here. The
2456
+ # instruction says "in each provider's LIVE catalog", and a catalog is not readable without a
2457
+ # credential — Owners 33/34 are open, so no catalog has been read on this machine. Writing
2458
+ # `1V100.6V` into a constant today would therefore be a GUESS wearing the costume of a default:
2459
+ # the 2026-09-24 `VAST_POD_SPEC` ruling is that a provider which guesses the spec deploys the
2460
+ # wrong machine and bills for it, and a guess that happens to be in a registry file is no better
2461
+ # than one in an adapter.
2462
+ #
2463
+ # So the DEFAULT IS THE DERIVATION. `FRUTEBOX_<NAME>_POD_SPEC` still wins when an operator sets it
2464
+ # — that is what makes a deliberate choice possible — and when it is unset these functions read
2465
+ # the vendor's live catalog and choose. The consequence is the property the instruction actually
2466
+ # wants: the moment a credential resolves, the cheapest qualifying EU box is picked with no edit
2467
+ # and no redeploy, and the choice and its price are LOGGED rather than assumed.
2468
+ #
2469
+ # WHAT "FITS THE PROOF MODEL" MEANS, in numbers rather than adjectives. The live proof boots our
2470
+ # node image and serves `gemma3:4b` through ollama. That is a ~4B-parameter model at q4 — roughly
2471
+ # 3 GB of weights, plus a KV cache and ollama's overhead — so the floor below is generous on
2472
+ # purpose: every current datacenter GPU clears it, which keeps this a FILTER rather than a
2473
+ # constraint that quietly excludes the cheap end of the catalog and overspends.
2474
+ #
2475
+ # SINGLE GPU IS A HARD FILTER, NOT A PREFERENCE. A multi-GPU box can be cheaper per card and is
2476
+ # never cheaper per hour, and the proof rents by the hour. `gpu_count == 1` is also what makes the
2477
+ # €10 cap meaningful: an 8-GPU instance at the same per-card price is eight times the bill.
2478
+
2479
+ #: VRAM floor for the live-proof model, in GB per card. Overridable because the proof model is
2480
+ #: itself a choice; the default is sized for `gemma3:4b` with room, not measured against it.
2481
+ PROOF_MIN_VRAM_GB = int(envvar("PROOF_MIN_VRAM_GB", "16"))
2482
+
2483
+
2484
+ def _cheapest_fitting(candidates: "List[dict]") -> "Optional[dict]":
2485
+ """The cheapest candidate, with ties broken by name so the choice is REPRODUCIBLE.
2486
+
2487
+ A tie broken by dict order would make the derivation pick differently on two runs against the
2488
+ same catalog, which turns "the choice was logged" into a claim nobody can check later.
2489
+ """
2490
+ if not candidates:
2491
+ return None
2492
+ return sorted(candidates, key=lambda c: (round(float(c["usd_per_hr"]), 6), c["name"]))[0]
2493
+
2494
+
2495
+ #: Image flavours the node must NOT boot. Each one is a preinstalled stack that fights the node's
2496
+ #: own provisioning: `jupyter` ships a notebook server, `kubernetes` a kubelet expecting a cluster,
2497
+ #: `docker` a daemon we neither need nor configure. `node-startup.sh` installs ollama and the agent
2498
+ #: onto a bare host, so a plain OS+CUDA image is the only one it is written for.
2499
+ _VERDA_IMAGE_REJECT = ("jupyter", "kubernetes", "k8s", "docker")
2500
+
2501
+
2502
+ def _verda_pick_image(candidates: "List[str]") -> str:
2503
+ """The newest PLAIN Ubuntu+CUDA image, by parsed version rather than by string order.
2504
+
2505
+ THE BUG THIS REPLACES COST A BILLED CREATE. The previous line was `sorted(cuda)[-1]`, with the
2506
+ comment "newest by name; the catalog carries no explicit version order". Measured against the
2507
+ live catalog on 2026-10-18, that picked `jupyter.cuda.12.8` out of a list whose real newest was
2508
+ `26.04.cuda13.4` — because `'j'` sorts after `'2'`. So "newest by name" was not newest, it was
2509
+ last alphabetically, and it chose a notebook image for a headless node. The create then failed
2510
+ for an unrelated reason and hid it; it would have booted the wrong machine otherwise.
2511
+
2512
+ Versions are compared as TUPLES OF INTEGERS (`26.04` + `cuda13.4` -> `(26, 4, 13, 4)`), so
2513
+ `cuda13.4` beats `cuda12.9` — which string order gets wrong too, since `'2' < '3'` but
2514
+ `'12' > '13'` lexicographically only after the first differing digit. A name we cannot parse
2515
+ sorts LAST rather than being dropped: it stays selectable if nothing else qualifies, but never
2516
+ outranks a version we can actually read.
2517
+ """
2518
+ import re
2519
+
2520
+ def version_key(name: str):
2521
+ nums = tuple(int(n) for n in re.findall(r"\d+", name))
2522
+ return (1, nums) if nums else (0, ())
2523
+
2524
+ plain = [c for c in candidates
2525
+ if not any(bad in c.lower() for bad in _VERDA_IMAGE_REJECT)]
2526
+ if not plain:
2527
+ return ""
2528
+ return sorted(plain, key=version_key)[-1]
2529
+
2530
+
2531
+ async def verda_candidates(*, min_vram_gb: "Optional[int]" = None) -> "Optional[List[dict]]":
2532
+ """Every qualifying EU single-GPU offering, CHEAPEST FIRST. `None` when unaskable.
2533
+
2534
+ WHY A LIST AND NOT JUST THE WINNER. `/instance-availability` says a type is in stock, and that
2535
+ is NOT a promise it can still be created a second later: measured 2026-10-20, a `1L40S.20V`
2536
+ the catalog listed as available in FIN-02 answered `503 "No capacity available"` on create.
2537
+ A derivation that returns one winner leaves its caller with nothing to do about that except
2538
+ fail, which is how a launch reports "no EU capacity" while five other types are rentable.
2539
+ `nodewake._create_cheapest_first` already walks fallbacks for the fleet; this gives the live
2540
+ proof and any other caller the same ability.
2541
+
2542
+ ONE FILTER, SHARED. `derive_datacrunch_pod_spec` calls this and takes the head, so the two can
2543
+ never disagree about what "qualifying" means — a second copy of the gpu-count/VRAM/stock
2544
+ predicate is the drift this repo keeps paying for.
2545
+ """
2546
+ floor = PROOF_MIN_VRAM_GB if min_vram_gb is None else int(min_vram_gb)
2547
+ prov = PROVIDERS.get("datacrunch")
2548
+ if prov is None or not prov.configured():
2549
+ log.error("verda: cannot read the catalog without a credential — Owner 33, not a code "
2550
+ "change.")
2551
+ return None
2552
+ headers = await prov._headers()
2553
+ if headers is None:
2554
+ log.error("verda: the credential was refused a token")
2555
+ return None
2556
+ try:
2557
+ async with httpx.AsyncClient(timeout=30) as c:
2558
+ types_r = await c.get(f"{prov._BASE}/instance-types", headers=headers)
2559
+ avail_r = await c.get(f"{prov._BASE}/instance-availability", headers=headers)
2560
+ except Exception as e:
2561
+ log.error("verda: catalog read failed (%s)", type(e).__name__)
2562
+ return None
2563
+ if types_r.status_code >= 300 or avail_r.status_code >= 300:
2564
+ log.error("verda: catalog read HTTP %s/%s", types_r.status_code, avail_r.status_code)
2565
+ return None
2566
+
2567
+ eu = verda_eu_locations()
2568
+ stock: Dict[str, List[str]] = {}
2569
+ for loc in (avail_r.json() or []):
2570
+ if not isinstance(loc, dict):
2571
+ continue
2572
+ code = str(loc.get("location_code") or "").upper()
2573
+ if code not in eu:
2574
+ continue
2575
+ for t in loc.get("availabilities") or []:
2576
+ stock.setdefault(str(t), []).append(code)
2577
+
2578
+ out: List[dict] = []
2579
+ for t in (types_r.json() or []):
2580
+ if not isinstance(t, dict):
2581
+ continue
2582
+ name = str(t.get("instance_type") or "").strip()
2583
+ if not name or name not in stock:
2584
+ continue
2585
+ gpu = t.get("gpu") or {}
2586
+ if int(gpu.get("number_of_gpus") or 0) != 1:
2587
+ continue
2588
+ vram = int((t.get("gpu_memory") or {}).get("size_in_gigabytes") or 0)
2589
+ if vram < floor:
2590
+ continue
2591
+ try:
2592
+ price = float(t.get("price_per_hour") or 0)
2593
+ except (TypeError, ValueError):
2594
+ continue
2595
+ if price <= 0:
2596
+ continue
2597
+ # EVERY EU LOCATION WITH STOCK, not just the first: a 503 is often per-location, so the
2598
+ # same type elsewhere is the cheapest retry available.
2599
+ for loc_code in sorted(stock[name]):
2600
+ out.append({"name": name, "usd_per_hr": price, "vram_gb": vram,
2601
+ "location": loc_code,
2602
+ "currency": str(t.get("currency") or "").lower(),
2603
+ "gpu": str(gpu.get("description") or "")})
2604
+ out.sort(key=lambda c: (round(c["usd_per_hr"], 6), c["name"], c["location"]))
2605
+ return out
2606
+
2607
+
2608
+ async def derive_datacrunch_pod_spec(*, min_vram_gb: "Optional[int]" = None) -> "Optional[dict]":
2609
+ """Verda's cheapest in-stock EU single-GPU instance type, as a complete launch spec.
2610
+
2611
+ THE HEAD OF `verda_candidates()`, plus the image and SSH keys a create also needs. The filter
2612
+ lives in exactly one place: this function used to carry its own copy of the
2613
+ gpu-count/VRAM/stock predicate, and its docstring claimed the refactor that removed it before
2614
+ the refactor existed — `test_no_orphan_implementations` caught the lie by noticing
2615
+ `verda_candidates` had no caller.
2616
+
2617
+ `None` on any failure, with the reason LOGGED — never a partial spec. A spec missing its image
2618
+ or its location is a create that 400s, and `start()` reads that as "no EU capacity".
2619
+ """
2620
+ floor = PROOF_MIN_VRAM_GB if min_vram_gb is None else int(min_vram_gb)
2621
+ prov = PROVIDERS.get("datacrunch")
2622
+ cands = await verda_candidates(min_vram_gb=floor)
2623
+ if cands is None:
2624
+ return None
2625
+ best = cands[0] if cands else None
2626
+ if best is None:
2627
+ log.error("verda: no in-stock EU single-GPU type with >=%dGB VRAM in the catalog. NOT "
2628
+ "falling back to a guess.", floor)
2629
+ return None
2630
+ headers = await prov._headers()
2631
+ if headers is None:
2632
+ return None
2633
+
2634
+ # The image is a second catalog read, scoped to the chosen type so the answer is one the
2635
+ # vendor says can actually boot on it.
2636
+ try:
2637
+ async with httpx.AsyncClient(timeout=30) as c:
2638
+ img_r = await c.get(f"{prov._BASE}/images", headers=headers,
2639
+ params={"instance_type": best["name"]})
2640
+ images = (img_r.json() or []) if img_r.status_code < 300 else []
2641
+ except Exception as e:
2642
+ log.error("verda: image catalog read failed (%s)", type(e).__name__)
2643
+ return None
2644
+ cuda = [str(i.get("image_type") or "") for i in images
2645
+ if isinstance(i, dict) and "cuda" in str(i.get("image_type") or "").lower()]
2646
+ if not cuda:
2647
+ log.error("verda: no CUDA image offered for %s (%d image(s) listed) — refusing to guess "
2648
+ "an image name, which would boot a machine with no driver and bill for it",
2649
+ best["name"], len(images))
2650
+ return None
2651
+ image = _verda_pick_image(cuda)
2652
+ if not image:
2653
+ log.error("verda: %d CUDA image(s) offered for %s but none is a PLAIN OS image (every one "
2654
+ "is a jupyter/kubernetes/docker variant) — refusing to pick one, because the "
2655
+ "node needs a bare host it can install onto", len(cuda), best["name"])
2656
+ return None
2657
+
2658
+ # SSH KEYS ARE NOT OPTIONAL HERE, and the vendor's first error message said otherwise.
2659
+ # `POST /instances` without them answers 400 "SSH keys can be an array of UUID's, a single
2660
+ # UUID string, null value or not defined" — which lists `not defined` as acceptable and is
2661
+ # misleading: sending null and sending nothing both fail, and the SECOND message is the true
2662
+ # one, "SSH keys must be provided if image is not an OS volume". Measured 2026-10-18.
2663
+ #
2664
+ # DISCOVERED, NOT CREATED. A derivation reads the account; minting a key is a write and a
2665
+ # credential, which is the live proof's job (it makes an ephemeral one and removes it). If the
2666
+ # account has none, this says so rather than returning a spec whose create cannot succeed.
2667
+ ssh_ids = await _verda_ssh_key_ids(prov, headers)
2668
+ if ssh_ids is None:
2669
+ log.error("verda: could not read the account's SSH keys — not deriving a spec, because a "
2670
+ "create without them is a guaranteed 400")
2671
+ return None
2672
+ if not ssh_ids:
2673
+ log.error("verda: the account has NO SSH keys, and Verda refuses an image boot without one "
2674
+ "(\"SSH keys must be provided if image is not an OS volume\"). Add one, or let "
2675
+ "the live proof mint an ephemeral key. Not returning a spec that cannot launch.")
2676
+ return None
2677
+
2678
+ # `sym` and the candidate count come from the chosen row and the list it was chosen from —
2679
+ # both now owned by `verda_candidates`, which is the point of the refactor.
2680
+ sym = {"eur": "EUR", "usd": "USD"}.get(best["currency"], best["currency"].upper() or "?")
2681
+ log.info("verda: derived pod spec — instance_type=%s gpu=%s vram=%dGB location=%s "
2682
+ "image=%s PRICE=%.4f %s/hr (cheapest in-stock EU single-GPU over %dGB, chosen from "
2683
+ "%d qualifying offering(s))",
2684
+ best["name"], best["gpu"], best["vram_gb"], best["location"], image,
2685
+ best["usd_per_hr"], sym, floor, len(cands))
2686
+ return {
2687
+ "instance_type": best["name"],
2688
+ "image": image,
2689
+ "location_code": best["location"],
2690
+ "ssh_key_ids": ssh_ids,
2691
+ "_derived": {"price_per_hr": best["usd_per_hr"], "currency": sym,
2692
+ "vram_gb": best["vram_gb"], "gpu": best["gpu"],
2693
+ "qualifying": len(cands),
2694
+ "source": "verda /instance-types + /instance-availability + /images"},
2695
+ }
2696
+
2697
+
2698
+ async def _verda_ssh_key_ids(prov, headers: dict) -> "Optional[List[str]]":
2699
+ """The account's SSH key ids, or `None` when the account could not be ASKED.
2700
+
2701
+ `/ssh-keys` is the CURRENT path; `/sshkeys` is a deprecated alias the vendor's own SDK still
2702
+ uses (docs changelog 2026-09-21). The current one is used here, and the deprecated one is not
2703
+ tried as a fallback: if the current path stops answering, that is news rather than something to
2704
+ paper over.
2705
+
2706
+ `None` is not `[]`. An unreadable key list means "do not launch"; an empty one means "the
2707
+ account genuinely has no key", and the two lead to different messages and different fixes.
2708
+ """
2709
+ try:
2710
+ async with httpx.AsyncClient(timeout=30) as c:
2711
+ r = await c.get(f"{prov._BASE}/ssh-keys", headers=headers)
2712
+ except Exception as e:
2713
+ log.warning("verda: could not list SSH keys (%s)", type(e).__name__)
2714
+ return None
2715
+ if r.status_code >= 300:
2716
+ log.warning("verda: SSH key list HTTP %s", r.status_code)
2717
+ return None
2718
+ rows = r.json() or []
2719
+ return [str(k.get("id")) for k in rows if isinstance(k, dict) and k.get("id")]
2720
+
2721
+
2722
+ async def derive_nebius_pod_spec(*, min_vram_gb: "Optional[int]" = None) -> "Optional[dict]":
2723
+ """Nebius's cheapest EU single-GPU preset, as a complete launch spec.
2724
+
2725
+ WHERE "CHEAPEST" COMES FROM, after one correction. `/compute/v1/platforms` publishes
2726
+ platforms, presets and GPU counts and NO money, and on that basis I previously wrote that the
2727
+ vendor publishes no price and required the operator's `FRUTEBOX_NEBIUS_PRICES` table. That was
2728
+ wrong: Nebius publishes no price LIST, but
2729
+ `/billing/v1alpha1/calculators/estimate` prices a SPECIFIC platform+preset, which is exactly
2730
+ the question here. So the order is now the operator's table FIRST (it is what the account is
2731
+ contracted at) and the vendor's calculator second (list price for the configuration), and the
2732
+ chosen row records WHICH answered — reporting one as the other would be a false provenance on
2733
+ a figure that gates spending.
2734
+
2735
+ With neither source answering, "cheapest" is unanswerable and this says so instead of picking
2736
+ — except where exactly one preset fits at all, when there is nothing to be cheapest among.
2737
+
2738
+ The subnet and the image are DISCOVERED (§A5), not configured: a guessed subnet is a machine on
2739
+ the wrong network or an unreachable one, and a guessed image family is a box with no driver.
2740
+ """
2741
+ floor = PROOF_MIN_VRAM_GB if min_vram_gb is None else int(min_vram_gb)
2742
+ prov = PROVIDERS.get("nebius")
2743
+ if prov is None or not prov.configured():
2744
+ log.error("nebius: cannot derive a pod spec without an authorized key and a project — "
2745
+ "the catalog is an authenticated read. This is Owner 34, not a code change.")
2746
+ return None
2747
+ project = await prov._resolve_project()
2748
+ if not project:
2749
+ log.error("nebius: no project for region %s, so there is no catalog to derive from",
2750
+ NEBIUS_REGION)
2751
+ return None
2752
+ if not await prov._assert_eu_project(project):
2753
+ # The EU gate fails CLOSED here exactly as it does in `create`: an unverified jurisdiction
2754
+ # is not an EU one, and deriving a spec for a non-EU project would hand `create` something
2755
+ # it must then refuse.
2756
+ return None
2757
+ headers = await prov._headers()
2758
+ if headers is None:
2759
+ return None
2760
+
2761
+ try:
2762
+ async with httpx.AsyncClient(timeout=30) as c:
2763
+ plat_r = await c.get(f"{prov._BASE}/compute/v1/platforms", headers=headers,
2764
+ params={"parentId": project})
2765
+ subnet_r = await c.get(f"{prov._BASE}/vpc/v1/subnets", headers=headers,
2766
+ params={"parentId": project})
2767
+ except Exception as e:
2768
+ log.error("nebius: catalog read failed (%s) — not deriving a spec", type(e).__name__)
2769
+ return None
2770
+ if plat_r.status_code >= 300 or subnet_r.status_code >= 300:
2771
+ log.error("nebius: catalog read HTTP %s/%s — not deriving a spec",
2772
+ plat_r.status_code, subnet_r.status_code)
2773
+ return None
2774
+
2775
+ prices = nebius_price_table()
2776
+
2777
+ fitting: List[dict] = []
2778
+ for plat in ((plat_r.json() or {}).get("items") or []):
2779
+ if not isinstance(plat, dict):
2780
+ continue
2781
+ pname = str(((plat.get("metadata") or {}).get("name") or "")).strip()
2782
+ pspec = plat.get("spec") or {}
2783
+ for preset in (pspec.get("presets") or []):
2784
+ res = preset.get("resources") or {}
2785
+ if int(res.get("gpuCount") or 0) != 1:
2786
+ continue
2787
+ # ABSENT VRAM IS UNKNOWN, NOT ZERO — and treating it as zero was my own instance of
2788
+ # this codebase's most expensive habit. Measured 2026-10-18: this project's catalog
2789
+ # publishes `gpu-b300-sxm/1gpu-24vcpu-346gb` with NO `gpuMemoryGibibytes` at either the
2790
+ # preset or the platform level, so `or 0` scored a B300 (288GB of HBM3e) as 0GB and the
2791
+ # derivation reported "no EU single-GPU preset with >=16GB VRAM" — refusing the only
2792
+ # qualifying GPU on the account for lack of a field.
2793
+ #
2794
+ # So an unpublished figure means the floor CANNOT BE APPLIED, and that is said out loud
2795
+ # rather than resolved either way silently. It is admitted because `gpuCount == 1` on a
2796
+ # named GPU platform is already a strong filter and the alternative discards real
2797
+ # capacity; the log line is what stops that being an invisible assumption.
2798
+ raw_vram = res.get("gpuMemoryGibibytes") or pspec.get("gpuMemoryGibibytes")
2799
+ vram = int(raw_vram) if raw_vram else 0
2800
+ if raw_vram and vram < floor:
2801
+ continue
2802
+ if not raw_vram:
2803
+ log.warning("nebius: %s/%s publishes no gpuMemoryGibibytes, so the >=%dGB floor "
2804
+ "could NOT be checked for it — admitted on gpuCount=1 alone",
2805
+ pname, preset.get("name"), floor)
2806
+ key = f"{pname}/{preset.get('name')}"
2807
+ fitting.append({"name": key, "platform": pname, "preset": str(preset.get("name")),
2808
+ "vram_gb": vram, "usd_per_hr": prices.get(key)})
2809
+
2810
+ if not fitting:
2811
+ log.error("nebius: no EU single-GPU preset with >=%dGB VRAM in the project's catalog. "
2812
+ "NOT falling back to a guess.", floor)
2813
+ return None
2814
+
2815
+ # THE VENDOR DOES PRICE A CONFIGURATION, and I previously wrote that it does not. What Nebius
2816
+ # publishes is no price LIST — `/compute/v1/platforms` carries hardware and no money — but
2817
+ # `/billing/v1alpha1/calculators/estimate` prices a SPECIFIC platform+preset, which is exactly
2818
+ # the question being asked here. Measured 2026-10-18: the 1×B300 preset returns
2819
+ # `hourlyCost.general.total.cost = 9.5`. An operator table is now an OVERRIDE rather than the
2820
+ # only source, so the cap and the merit order get a real number with no configuration at all.
2821
+ for f in fitting:
2822
+ f["price_source"] = "FRUTEBOX_NEBIUS_PRICES" if f["usd_per_hr"] is not None else ""
2823
+ if f["usd_per_hr"] is None:
2824
+ f["usd_per_hr"] = await _nebius_estimate_hourly(prov, headers, project,
2825
+ f["platform"], f["preset"])
2826
+ if f["usd_per_hr"] is not None:
2827
+ f["price_source"] = "vendor calculator (/billing/v1alpha1/calculators/estimate)"
2828
+ priced = [f for f in fitting if f["usd_per_hr"] is not None]
2829
+ if priced:
2830
+ best = _cheapest_fitting(priced)
2831
+ elif len(fitting) == 1:
2832
+ best = fitting[0]
2833
+ log.warning("nebius: one qualifying preset (%s), and neither the calculator nor "
2834
+ "FRUTEBOX_NEBIUS_PRICES gave a price — choosing it because there is nothing to "
2835
+ "be cheapest among, but the live-proof cap cannot be checked against it.",
2836
+ best["name"])
2837
+ else:
2838
+ log.error("nebius: %d qualifying presets and NO price from the calculator or "
2839
+ "FRUTEBOX_NEBIUS_PRICES (%s). 'Cheapest' is unanswerable and picking one would "
2840
+ "be spending on a coin flip.", len(fitting), ", ".join(f["name"] for f in fitting))
2841
+ return None
2842
+
2843
+ subnets = [s for s in ((subnet_r.json() or {}).get("items") or []) if isinstance(s, dict)]
2844
+ subnet_id = ""
2845
+ for s in subnets:
2846
+ sid = str(((s.get("metadata") or {}).get("id") or ""))
2847
+ if sid:
2848
+ subnet_id = sid
2849
+ break
2850
+ if not subnet_id:
2851
+ log.error("nebius: the project has no subnet to place an instance in (%d listed) — "
2852
+ "refusing to guess a subnet id", len(subnets))
2853
+ return None
2854
+ if len(subnets) > 1:
2855
+ log.warning("nebius: project %s has %d subnets; using %s. Set FRUTEBOX_NEBIUS_POD_SPEC if "
2856
+ "that is the wrong network.", project, len(subnets), subnet_id)
2857
+
2858
+ family = await _nebius_image_family(prov, headers, best["platform"], best["preset"],
2859
+ region=prov._project_region.get(project, ""))
2860
+ if not family:
2861
+ return None
2862
+
2863
+ # WHERE THE NUMBER CAME FROM, NAMED. It is one of two sources and they are not
2864
+ # interchangeable: the operator's table is what this account is contracted at, while the
2865
+ # vendor's calculator is list price for the configuration. Reporting one as the other is a
2866
+ # false provenance on a figure that gates spending — and this line did exactly that for one
2867
+ # commit, claiming FRUTEBOX_NEBIUS_PRICES for a number the calculator had produced.
2868
+ #
2869
+ # NEITHER CARRIES A CURRENCY. The calculator's v1alpha1 response is a bare number and the
2870
+ # operator's table is whatever they typed, so the unit is not asserted here. The v1 batch
2871
+ # endpoint does take a `currency`, and answered 500 for this spec.
2872
+ src_txt = best.get("price_source") or "unknown source"
2873
+ price_txt = (("%.4f/hr (unit unstated by the source; from %s)" % (best["usd_per_hr"], src_txt))
2874
+ if best["usd_per_hr"] is not None else "UNKNOWN — no price from either source")
2875
+ log.info("nebius: derived pod spec — platform=%s preset=%s vram=%dGB subnet=%s "
2876
+ "imageFamily=%s PRICE=%s (cheapest of %d qualifying EU single-GPU preset(s))",
2877
+ best["platform"], best["preset"], best["vram_gb"], subnet_id, family, price_txt,
2878
+ len(fitting))
2879
+ return {
2880
+ "platform": best["platform"],
2881
+ "preset": best["preset"],
2882
+ "imageFamily": family,
2883
+ "subnetId": subnet_id,
2884
+ "_derived": {"price_per_hr": best["usd_per_hr"],
2885
+ "price_source": src_txt,
2886
+ "currency": "unstated by the source",
2887
+ "vram_gb": best["vram_gb"],
2888
+ "vram_checked": bool(best["vram_gb"]),
2889
+ "qualifying": len(fitting),
2890
+ "source": "nebius /compute/v1/platforms + /vpc/v1/subnets"
2891
+ " + /compute/v1/images/list-public"},
2892
+ }
2893
+
2894
+
2895
+ async def _nebius_estimate_hourly(prov, headers: dict, project: str,
2896
+ platform: str, preset: str) -> "Optional[float]":
2897
+ """What the vendor says this platform+preset costs per hour, or `None`.
2898
+
2899
+ THE REQUEST SHAPE WAS FOUND BY ASKING, because the published schema does not give it: the
2900
+ OpenAPI document types `billing.v1.ResourceSpec.spec` as an unexpanded `any`, so three shapes
2901
+ were tried against the live API 2026-10-18 and its own errors named the problem — the first two
2902
+ returned "container id is required: invalid container id". The one it accepts nests the project
2903
+ the same way a create does, `{computeInstanceSpec: {metadata: {parentId}, spec: {resources}}}`.
2904
+
2905
+ `None` on any failure, never a zero: a zero price is the cheapest thing in any catalog and
2906
+ would win every comparison it entered, which is the phantom-cheapest-row failure this file
2907
+ guards against elsewhere.
2908
+
2909
+ THE CURRENCY IS THE ACCOUNT'S and is not asserted here. The v1alpha1 response carries a bare
2910
+ number with no currency field; the v1 batch endpoint takes a `currency` parameter but answered
2911
+ 500 for this spec. So the figure is reported as the vendor's own unit rather than relabelled
2912
+ into euros, for the same reason Verda's price is logged as USD when the catalog says USD.
2913
+ """
2914
+ body = {"resourceSpec": {"computeInstanceSpec": {
2915
+ "metadata": {"parentId": project},
2916
+ "spec": {"resources": {"platform": platform, "preset": preset}}}}}
2917
+ try:
2918
+ async with httpx.AsyncClient(timeout=30) as c:
2919
+ r = await c.post(f"{prov._BASE}/billing/v1alpha1/calculators/estimate",
2920
+ headers=headers, json=body)
2921
+ except Exception as e:
2922
+ log.warning("nebius: price estimate for %s/%s failed (%s)", platform, preset,
2923
+ type(e).__name__)
2924
+ return None
2925
+ if r.status_code >= 300:
2926
+ log.warning("nebius: price estimate for %s/%s HTTP %s", platform, preset, r.status_code)
2927
+ return None
2928
+ try:
2929
+ cost = (((r.json() or {}).get("hourlyCost") or {}).get("general") or {}
2930
+ ).get("total", {}).get("cost")
2931
+ return float(cost) if cost is not None else None
2932
+ except (TypeError, ValueError, AttributeError):
2933
+ log.warning("nebius: price estimate for %s/%s returned an unreadable body shape",
2934
+ platform, preset)
2935
+ return None
2936
+
2937
+
2938
+ async def _nebius_image_family(prov, headers: dict, platform: str, preset: str,
2939
+ *, region: str = "") -> str:
2940
+ """A public image family the vendor says can boot on this platform/preset.
2941
+
2942
+ `ImageSpec` carries `recommendedPlatforms` and `unsupportedPresets`, so this is a READ rather
2943
+ than a guess: an image the vendor marks unsupported for the chosen preset is excluded, and one
2944
+ that recommends the chosen platform is preferred over one that merely does not forbid it.
2945
+ """
2946
+ if not region:
2947
+ # The catalog cannot be read without it, and inventing one would ask a different region's
2948
+ # catalog than the project actually launches in.
2949
+ log.error("nebius: no region established for the project, so the public image catalog "
2950
+ "cannot be queried (it requires one) — refusing to guess an image family")
2951
+ return ""
2952
+ try:
2953
+ async with httpx.AsyncClient(timeout=30) as c:
2954
+ r = await c.get(f"{prov._BASE}/compute/v1/images/list-public", headers=headers,
2955
+ params={"region": region})
2956
+ except Exception as e:
2957
+ log.error("nebius: public image list failed (%s)", type(e).__name__)
2958
+ return ""
2959
+ if r.status_code >= 300:
2960
+ log.error("nebius: public image list HTTP %s", r.status_code)
2961
+ return ""
2962
+ recommended, allowed = [], []
2963
+ for img in ((r.json() or {}).get("items") or []):
2964
+ if not isinstance(img, dict):
2965
+ continue
2966
+ spec = img.get("spec") or {}
2967
+ family = str(spec.get("imageFamily") or "").strip()
2968
+ if not family:
2969
+ continue
2970
+ if any(str(u.get("preset")) == preset and str(u.get("platform")) == platform
2971
+ for u in (spec.get("unsupportedPresets") or []) if isinstance(u, dict)):
2972
+ continue
2973
+ if platform in [str(p) for p in (spec.get("recommendedPlatforms") or [])]:
2974
+ recommended.append(family)
2975
+ elif platform not in [str(p) for p in (spec.get("unsupportedPlatforms") or [])]:
2976
+ allowed.append(family)
2977
+ # CUDA first within each tier: the node runs ollama on a GPU, and an image with no driver
2978
+ # boots a machine that bills and cannot serve.
2979
+ for tier in (recommended, allowed):
2980
+ cuda = sorted(f for f in tier if "cuda" in f.lower())
2981
+ if cuda:
2982
+ return cuda[-1]
2983
+ if recommended:
2984
+ log.warning("nebius: no CUDA image family recommended for %s; using %s, which may need a "
2985
+ "driver install in cloud-init", platform, sorted(recommended)[-1])
2986
+ return sorted(recommended)[-1]
2987
+ log.error("nebius: no image family the vendor permits on %s/%s — refusing to guess one",
2988
+ platform, preset)
2989
+ return ""
2990
+
2991
+
2992
+ # ---- Lambda Cloud --------------------------------------------------------------------------
2993
+
2994
+ class LambdaProvider:
2995
+ name = "lambda"
2996
+ _BASE = "https://cloud.lambdalabs.com/api/v1"
2997
+
2998
+ def _key(self) -> str:
2999
+ return _provider_credential("lambda", "LAMBDA_API_KEY")
3000
+
3001
+ def configured(self) -> bool:
3002
+ return bool(self._key())
3003
+
3004
+ def _headers(self) -> dict:
3005
+ return {"Authorization": f"Bearer {self._key()}", "content-type": "application/json"}
3006
+
3007
+ #: Gone: no longer rented, nothing left to terminate.
3008
+ _GONE = frozenset({"terminated"})
3009
+
3010
+ #: Rented but NOT serving. `unhealthy` is Lambda's billing corpse — the VM is up and charged
3011
+ #: for, and nothing on it works. `terminating` is already leaving, so it must not be counted
3012
+ #: as capacity a new request could land on. Both still belong to TEARDOWN.
3013
+ _NOT_SERVING = _GONE | frozenset({"unhealthy", "terminating"})
3014
+
3015
+ async def _labelled(self, label: str) -> List[dict]:
3016
+ async with httpx.AsyncClient(timeout=30) as c:
3017
+ r = await c.get(f"{self._BASE}/instances", headers=self._headers())
3018
+ if r.status_code >= 300:
3019
+ return []
3020
+ return [i for i in ((r.json() or {}).get("data") or [])
3021
+ if i.get("name") == label]
3022
+
3023
+ async def find(self, label: str) -> List[Instance]:
3024
+ return await self.find_all(label)
3025
+
3026
+ async def find_all(self, label: str) -> List[Instance]:
3027
+ """TEARDOWN breadth: everything Lambda has not terminated is still rented by the hour."""
3028
+ return [Instance(id=i.get("id"), label=i.get("name"))
3029
+ for i in await self._labelled(label)
3030
+ if str(i.get("status", "")).lower() not in self._GONE]
3031
+
3032
+ async def find_live(self, label: str) -> List[Instance]:
3033
+ """CAPACITY: booting or active only.
3034
+
3035
+ Lambda rents whole VMs, so it cannot see inside the container the way RunPod's `runtime`
3036
+ does — an `active` VM whose workload died still reads as live here. What it CAN tell is
3037
+ `unhealthy`, and that one must not hold a pool slot.
3038
+ """
3039
+ return [Instance(id=i.get("id"), label=i.get("name"))
3040
+ for i in await self._labelled(label)
3041
+ if str(i.get("status", "")).lower() not in self._NOT_SERVING]
3042
+
3043
+ async def create(self, label: str, spec: dict) -> bool:
3044
+ region = spec.get("region") or os.environ.get("LAMBDA_REGION", "")
3045
+ itype = spec.get("instanceType") or os.environ.get("LAMBDA_INSTANCE_TYPE", "")
3046
+ ssh = [k for k in (os.environ.get("LAMBDA_SSH_KEYS", "").split(",")) if k]
3047
+
3048
+ # §A5 option 1 (owner ruling 2026-09-22): the operator named the instance type — the
3049
+ # decision that costs money — so discover the account-scoped rest of it. Only what they
3050
+ # left unset is filled; an explicit value always wins, and nothing here picks an
3051
+ # instance type. Without this, an unset region silently became "us-west-1", which fails
3052
+ # for a type that has capacity only elsewhere.
3053
+ if itype and (not region or not ssh):
3054
+ derived = await derive_lambda_launch(instance_type=itype)
3055
+ region = region or derived.get("LAMBDA_REGION", "")
3056
+ ssh = ssh or [k for k in derived.get("LAMBDA_SSH_KEYS", "").split(",") if k]
3057
+
3058
+ body = {
3059
+ "region_name": region or "us-west-1",
3060
+ "instance_type_name": itype,
3061
+ "ssh_key_names": ssh,
3062
+ "name": label, # the caller's label, never the spec's
3063
+ "quantity": 1,
3064
+ }
3065
+ if not body["instance_type_name"]:
3066
+ return False
3067
+ async with httpx.AsyncClient(timeout=60) as c:
3068
+ r = await c.post(f"{self._BASE}/instance-operations/launch",
3069
+ headers=self._headers(), json=body)
3070
+ if r.status_code >= 300:
3071
+ return False
3072
+ return bool(((r.json() or {}).get("data") or {}).get("instance_ids"))
3073
+
3074
+ async def destroy(self, instance_id: str) -> bool:
3075
+ async with httpx.AsyncClient(timeout=30) as c:
3076
+ r = await c.post(f"{self._BASE}/instance-operations/terminate",
3077
+ headers=self._headers(), json={"instance_ids": [instance_id]})
3078
+ return r.status_code < 300 or r.status_code == 404
3079
+
3080
+
3081
+ # ---- Nebius, the SECOND EU provider behind Verda (owner decision 7, 2026-10-14) -------------
3082
+ #
3083
+ # PROMOTED 2026-10-16: with RunPod being replaced, this and Verda ARE the EU pool.
3084
+ #
3085
+ # WHAT THIS CLASS USED TO BE, AND WHAT CHANGED. It was a scaffold that refused: `create` raised
3086
+ # rather than POST to a guessed path, because `api.nebius.cloud` answering 404 to an
3087
+ # unauthenticated root GET proves the host resolves and nothing about the operations. That refusal
3088
+ # was right, and the reason for it is now GONE rather than overruled — Nebius publishes an OpenAPI
3089
+ # document at `https://api.nebius.cloud/openapi.json` (443 paths, fetched 2026-10-16), so the paths
3090
+ # below were READ, not derived from the gRPC service names.
3091
+ #
3092
+ # REST, NOT THE gRPC SDK — the choice, and why it is not laziness. Every other adapter in this file
3093
+ # speaks HTTP through `httpx`. The `nebius` pip package is gRPC plus generated protobufs: a large
3094
+ # new dependency and a second concurrency model, bought to obtain paths that are now published. The
3095
+ # protos in `nebius/api` carry NO `google.api.http` annotations, so the OpenAPI document — not the
3096
+ # proto — is the authority for REST, and it is what was used. If a future operation turns out to be
3097
+ # gRPC-only, that is the moment to take the dependency, for that operation, with a reason.
3098
+ #
3099
+ # AUTH IS A TWO-STEP EXCHANGE, unlike every other provider here. There is no static API key: a
3100
+ # service account holds an AUTHORIZED KEY (a key id + the service account id + an RSA private key),
3101
+ # the client signs a short-lived JWT with it, and POSTs that to the IAM token endpoint to get a
3102
+ # 12-hour access token. Signed with `cryptography`, which is already a declared dependency —
3103
+ # PyJWT would be a new one for fifteen lines, and this file already has an RS256 precedent in
3104
+ # `tee.py`.
3105
+ #
3106
+ # THE REGION IS A PROPERTY OF THE PROJECT, NOT OF THE INSTANCE, and that is the single most
3107
+ # important thing about placing EU work here. `CreateInstanceRequest` has no region field at all —
3108
+ # `metadata.parentId` names a PROJECT, and the project's own `spec.region` decides where the
3109
+ # hardware is. So "EU only" cannot be enforced by inspecting a create body; it is enforced by
3110
+ # CHECKING THE PROJECT, which `_assert_eu_project` does against `GET /iam/v2/projects/{id}` before
3111
+ # anything is created. A product that promised EU residency off an unchecked `parentId` would be
3112
+ # promising whatever project id happened to be configured.
3113
+ #
3114
+ # RESIDENCY: NO LONGER HELD. The seed row carried `residency_hold: true` — a Dutch-incorporated
3115
+ # provider with a Yandex lineage, excluded from legally pinned EU work until the owner decided.
3116
+ # The owner decided on 2026-10-20: Nebius may run EU-locked work, so the flag is gone from the
3117
+ # seed row and `regionmap.residency_providers` stops excluding it. The hold was about corporate
3118
+ # lineage; the EU-region check above is a DIFFERENT thing and it still applies, because it is the
3119
+ # one that is checked rather than declared — `_assert_eu_project` reads the project's region from
3120
+ # IAM before any create, and that is what makes "EU only" a fact instead of a promise.
3121
+ #: THE EU REGION NEBIUS WORK RUNS IN (owner rule, 2026-10-21): "Point Nebius EU at the eu-north1
3122
+ #: project (GPUs actually start there), not eu-west2."
3123
+ #:
3124
+ #: Measured 2026-10-20, which is why it is this one: this account holds one project per region and
3125
+ #: their GPU CATALOGUES DIFFER. eu-west2 publishes `gpu-b300-sxm` and nothing else, so "the
3126
+ #: cheapest EU single-GPU preset" could only ever be a 1xB300 - and that preset reverts
3127
+ #: STARTING -> STOPPED after ~5m20s with no status message, no operation record and no serial
3128
+ #: console. eu-north1 publishes 17 GPU presets (L40S/H100/H200/GB300) and an L40S reached RUNNING
3129
+ #: in 78 seconds on the identical code path.
3130
+ NEBIUS_REGION = envvar("NEBIUS_REGION", "eu-north1")
3131
+
3132
+ NEBIUS_BASE = envvar("NEBIUS_API_BASE", "https://api.nebius.cloud").rstrip("/")
3133
+
3134
+ #: The IAM token-exchange endpoint. EU by deliberate choice of host, not only of region: the
3135
+ #: exchange is where the service-account assertion is presented, so sending it to a non-EU auth
3136
+ #: host would route the credential itself outside the bloc.
3137
+ NEBIUS_AUTH_URL = envvar("NEBIUS_AUTH_URL", "https://auth.eu.nebius.com/oauth2/token/exchange")
3138
+
3139
+ #: Compute REST paths, READ from the published OpenAPI document 2026-10-16 (not derived):
3140
+ #: POST /compute/v1/instances create
3141
+ #: GET /compute/v1/instances list (?parentId=&pageSize=&pageToken=)
3142
+ #: GET /compute/v1/instances/{id} get
3143
+ #: DELETE /compute/v1/instances/{id} delete
3144
+ #: `POST /compute/v1/instances/start` and `/stop` also exist and are NEVER called from here —
3145
+ #: TERMINATE NEVER STOP, and a stopped instance keeps its disks and its bill.
3146
+ NEBIUS_INSTANCES_PATH = "/compute/v1/instances"
3147
+ NEBIUS_PROJECTS_PATH = "/iam/v2/projects"
3148
+
3149
+ #: Nebius regions this product treats as EU, from the vendor's region list (2026-10-16):
3150
+ #: `eu-north1` Finland, `eu-west1` and `eu-west2` France.
3151
+ #:
3152
+ #: `eu-north2` IS ICELAND and is deliberately absent — EEA, not EU, exactly like Verda's ICE-01 —
3153
+ #: and `us-central1` is Kansas City. The `eu-` prefix is the vendor's NAME for a region, not a
3154
+ #: statement about EU membership, so this is an allow-list of codes and never a prefix match.
3155
+ NEBIUS_EU_REGIONS = frozenset(
3156
+ r.strip().lower() for r in envvar("NEBIUS_EU_REGIONS", "eu-north1,eu-west1,eu-west2").split(",")
3157
+ if r.strip()
3158
+ )
3159
+
3160
+ #: Instance states, READ from the OpenAPI enum `nebius.compute.v1.InstanceStatus.InstanceState`:
3161
+ #: UNSPECIFIED, CREATING, UPDATING, STARTING, RUNNING, STOPPING, STOPPED, DELETING, ERROR.
3162
+ #:
3163
+ #: SERVING is `RUNNING` alone. Everything the list returns still exists and can still bill, so
3164
+ #: there is no `_DEAD` set here at all: for Nebius, GONE means ABSENT FROM THE LIST. `DELETING` is
3165
+ #: deliberately counted as still-present — a deletion in flight is not a deletion observed, and the
3166
+ #: teardown's own verification is what establishes the difference.
3167
+ NEBIUS_SERVING_STATES = frozenset({"RUNNING"})
3168
+
3169
+
3170
+ def nebius_price_table() -> "Dict[str, float]":
3171
+ """The operator's `platform/preset -> price per hour` table, parsed. `{}` when unset.
3172
+
3173
+ ONE PARSER AND ONE ENV READ, shared by `gpuprices._fetch_nebius` (the merit order) and
3174
+ `derive_nebius_pod_spec` (the live proof's default). It lives here rather than in `gpuprices`
3175
+ because that module already imports this one, so the other direction would be a cycle.
3176
+
3177
+ WHY IT IS NOT TWO FUNCTIONS. The second copy was written before this one existed, and the two
3178
+ read the same variable with the same JSON shape for the same purpose — the exact duplication
3179
+ this repo's rule 12 is about, and the kind that drifts: a fix to one copy's number handling
3180
+ would silently not reach the other, so the price the race ranks on and the price the cap is
3181
+ checked against could disagree.
3182
+
3183
+ A ROW THAT WILL NOT PARSE IS DROPPED, NOT ZEROED. A zero price is the cheapest thing in any
3184
+ catalog and would win every race; an absent one simply does not compete.
3185
+ """
3186
+ raw = (envvar("NEBIUS_PRICES", "") or "").strip()
3187
+ if not raw:
3188
+ return {}
3189
+ try:
3190
+ parsed = json.loads(raw)
3191
+ except Exception as e:
3192
+ log.error("nebius: FRUTEBOX_NEBIUS_PRICES is not valid JSON (%s) — no operator prices",
3193
+ type(e).__name__)
3194
+ return {}
3195
+ if not isinstance(parsed, dict):
3196
+ log.error("nebius: FRUTEBOX_NEBIUS_PRICES must be a JSON object of "
3197
+ "'platform/preset': price_per_hour — no operator prices")
3198
+ return {}
3199
+ out: Dict[str, float] = {}
3200
+ for k, v in parsed.items():
3201
+ try:
3202
+ price = float(v)
3203
+ except (TypeError, ValueError):
3204
+ continue
3205
+ if price > 0:
3206
+ out[str(k)] = price
3207
+ return out
3208
+
3209
+
3210
+ def _b64url(raw: bytes) -> str:
3211
+ """JWS base64url: no padding. A padded segment is rejected by the token endpoint."""
3212
+ import base64
3213
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
3214
+
3215
+
3216
+ def nebius_jwt(*, key_id: str, service_account_id: str, private_key_pem: str,
3217
+ now: "Optional[float]" = None, ttl_s: int = 300) -> str:
3218
+ """The signed assertion that is exchanged for an IAM token. RS256, five-minute life.
3219
+
3220
+ Claims, from the vendor's own documented flow: `kid` (the AUTHORIZED KEY's id) in the header,
3221
+ `iss` and `sub` both the SERVICE ACCOUNT's id, and `exp`. Short-lived on purpose — it is used
3222
+ once, immediately, to obtain the real token, so a longer life buys nothing and widens the
3223
+ window in which a captured assertion is useful.
3224
+
3225
+ A MODULE-LEVEL FUNCTION rather than a method, so the signing can be tested against a generated
3226
+ key pair without constructing a provider or touching a credential store — and so it is
3227
+ obviously one pure function of its inputs. It takes the PEM as a string and never a path: the
3228
+ key reaches this process through the credential store, not off a disk this code chose.
3229
+ """
3230
+ from cryptography.hazmat.primitives import hashes, serialization
3231
+ from cryptography.hazmat.primitives.asymmetric import padding, rsa
3232
+
3233
+ key = serialization.load_pem_private_key(private_key_pem.encode(), password=None)
3234
+ if not isinstance(key, rsa.RSAPrivateKey):
3235
+ # RS256 is what the vendor documents. An EC key here would sign something the endpoint
3236
+ # cannot verify, and the resulting 401 would look like a wrong key rather than a wrong
3237
+ # ALGORITHM — a diagnosis worth hours.
3238
+ raise ValueError("nebius: the authorized key's private key is not RSA; RS256 is required")
3239
+ issued = int(now if now is not None else time.time())
3240
+ header = {"alg": "RS256", "typ": "JWT", "kid": key_id}
3241
+ claims = {"iss": service_account_id, "sub": service_account_id,
3242
+ "iat": issued, "exp": issued + ttl_s}
3243
+ signing_input = f"{_b64url(json.dumps(header).encode())}.{_b64url(json.dumps(claims).encode())}"
3244
+ sig = key.sign(signing_input.encode(), padding.PKCS1v15(), hashes.SHA256())
3245
+ return f"{signing_input}.{_b64url(sig)}"
3246
+
3247
+
3248
+ class NebiusProvider:
3249
+ """Nebius AI Cloud GPU, through the same `GpuProvider` protocol as every other backend.
3250
+
3251
+ Second in the EU preference order, behind Verda.
3252
+ """
3253
+
3254
+ name = "nebius"
3255
+ _BASE = NEBIUS_BASE
3256
+
3257
+ #: IT DISTINGUISHES, and this is a reading rather than a hope. The previous `False` said the
3258
+ #: list response had not been read; it has been now — the OpenAPI document publishes the
3259
+ #: `InstanceState` enum, so `RUNNING` is separable from `STOPPED` and from a creation still in
3260
+ #: flight. `test_gpuproviders.py`'s contract test refuses a `True` the code does not back up,
3261
+ #: so this is only legal while both enumerations exist below.
3262
+ DISTINGUISHES_SERVING = True
3263
+
3264
+ def __init__(self) -> None:
3265
+ self._token = ""
3266
+ self._token_expires = 0.0
3267
+ #: The project whose region has already been checked, so the check is one call per project
3268
+ #: per process and not one per create. Cleared with the token.
3269
+ self._eu_checked: Set[str] = set()
3270
+ #: The project `_resolve_project` found for `NEBIUS_REGION`, once. Empty until it has run
3271
+ #: and empty if it could not read the account — never a stand-in for one it did not find.
3272
+ self._discovered_project: str = ""
3273
+ #: project id -> the region `_assert_eu_project` read for it. Kept because the image
3274
+ #: catalog REQUIRES a region ("validation error: region: value is required") and the EU
3275
+ #: gate has already established it — asking IAM twice for the same fact, or worse guessing
3276
+ #: it from the project's NAME, would be two ways of being wrong about jurisdiction.
3277
+ self._project_region: Dict[str, str] = {}
3278
+
3279
+ # ---- credential ------------------------------------------------------------------------
3280
+
3281
+ def _key(self) -> dict:
3282
+ """The authorized key as a dict, or `{}`.
3283
+
3284
+ ONE ENV VAR HOLDING A JSON DOCUMENT, which is unlike every other provider here, so it is
3285
+ parsed in one place. A malformed key is `{}` — not a partial dict — because a half-read
3286
+ credential that still looks truthy is how a provider reports ready and then cannot sign.
3287
+
3288
+ FIELD NAMES ARE THE VENDOR'S OWN, as they appear in the key file IAM issues: `id` is the
3289
+ AUTHORIZED KEY's id (the JWT's `kid`), `service_account_id` is the account it belongs to,
3290
+ and `private_key` is the PEM. `subject-credentials` wrappers are not unwrapped here: a key
3291
+ file in a shape we have not seen is reported as missing fields rather than guessed at.
3292
+ """
3293
+ raw = _provider_credential("nebius", "NEBIUS_SERVICE_ACCOUNT_KEY")
3294
+ if not raw:
3295
+ return self._key_from_parts()
3296
+ try:
3297
+ doc = json.loads(raw)
3298
+ except Exception:
3299
+ # NOT the contents. A parse error on a credential must not put the credential in a log.
3300
+ log.error("nebius: NEBIUS_SERVICE_ACCOUNT_KEY is not valid JSON — refusing to use it")
3301
+ return {}
3302
+ if not isinstance(doc, dict):
3303
+ return {}
3304
+ # The three fields an authorized key must have to be signable. Reported as names only.
3305
+ missing = [f for f in ("id", "service_account_id", "private_key") if not doc.get(f)]
3306
+ if missing:
3307
+ log.error("nebius: authorized key is missing %s", ",".join(missing))
3308
+ return {}
3309
+ return doc
3310
+
3311
+ def _key_from_parts(self) -> dict:
3312
+ """The same authorized key, assembled from the three parts under their own names.
3313
+
3314
+ WHY THIS EXISTS. `NEBIUS_SERVICE_ACCOUNT_KEY` is one env var holding a JSON document, and
3315
+ IAM does not hand you that document — it hands you a key id, an account id, and a PEM, in
3316
+ three places. So using this provider required hand-building JSON around a private key,
3317
+ which is both the easiest thing to get wrong and the easiest thing to leave in a shell
3318
+ history. On 2026-10-20 the estate file held Nebius credentials and this provider still
3319
+ reported itself unconfigured, because nothing could assemble that document from the parts
3320
+ that actually existed on the machine.
3321
+
3322
+ The JSON form stays primary (it is what IAM's downloadable key file becomes, and it is one
3323
+ secret instead of three). This is the fallback, and it reports WHICH part is missing: with
3324
+ one var the only possible answer was "no credential", which is indistinguishable from
3325
+ "three quarters of a credential".
3326
+
3327
+ `NEBIUS_PRIVATE_KEY_FILE` names a path rather than carrying the PEM, which is how the key
3328
+ reaches an operator's shell in the first place. `nebius_jwt` still takes the PEM as a
3329
+ string and never a path — the disk this reads is the one the OPERATOR named, not one this
3330
+ code went looking for.
3331
+ """
3332
+ key_id = _provider_credential("nebius", "NEBIUS_PUBLIC_KEY_ID")
3333
+ sa_id = _provider_credential("nebius", "NEBIUS_SERVICE_ACCOUNT_ID")
3334
+ pem = _provider_credential("nebius", "NEBIUS_PRIVATE_KEY")
3335
+ if not pem:
3336
+ path = _provider_credential("nebius", "NEBIUS_PRIVATE_KEY_FILE")
3337
+ if path:
3338
+ try:
3339
+ with open(os.path.expanduser(path), "r", encoding="utf-8") as fh:
3340
+ pem = fh.read()
3341
+ except Exception as e:
3342
+ # The PATH may be logged; what is at the end of it may not.
3343
+ log.error("nebius: NEBIUS_PRIVATE_KEY_FILE could not be read (%s)",
3344
+ type(e).__name__)
3345
+ return {}
3346
+ if not (key_id or sa_id or pem):
3347
+ # Nothing of this provider is configured. THAT is not the same as a broken key, so it
3348
+ # is not reported as one — but say so if the one credential in the estate is the one
3349
+ # that cannot sign, because that diagnosis took a day to make once.
3350
+ access_key = _provider_credential("nebius", "NEBIUS_KEY_ID")
3351
+ if access_key:
3352
+ log.error(
3353
+ "nebius: NEBIUS_KEY_ID/NEBIUS_SECRET_KEY are an OBJECT-STORAGE access key "
3354
+ "(IAM rejects the id as 'Invalid nid format'); the compute API needs a "
3355
+ "service-account authorized key — set NEBIUS_SERVICE_ACCOUNT_ID, "
3356
+ "NEBIUS_PUBLIC_KEY_ID and NEBIUS_PRIVATE_KEY_FILE")
3357
+ return {}
3358
+ missing = [n for n, v in (("NEBIUS_PUBLIC_KEY_ID", key_id),
3359
+ ("NEBIUS_SERVICE_ACCOUNT_ID", sa_id),
3360
+ ("NEBIUS_PRIVATE_KEY/_FILE", pem)) if not v]
3361
+ if missing:
3362
+ log.error("nebius: authorized key is incomplete, missing %s", ",".join(missing))
3363
+ return {}
3364
+ return {"id": key_id, "service_account_id": sa_id, "private_key": pem}
3365
+
3366
+ def _project_id(self) -> str:
3367
+ """The project every call is scoped to — `metadata.parentId` on create, `parentId` on list.
3368
+
3369
+ ACCOUNT-SCOPED, NOT A SECRET (§A5: the owner supplies the credential, the code discovers
3370
+ the account's resources). It used to be required configuration on the reasoning that
3371
+ "picking one for the operator would be choosing where the money is spent and which
3372
+ jurisdiction the data lands in" — and that reasoning was right about the CHOICE and wrong
3373
+ about who had to type it. The owner has now made the choice, by region: EU work runs in
3374
+ `NEBIUS_REGION` (eu-north1). Which project id that is, in this account, is an account
3375
+ resource — exactly the kind of thing §A5 says the code discovers.
3376
+
3377
+ So: explicit configuration still wins, and otherwise `_resolve_project` finds the project
3378
+ whose own region IS the named one. Nothing is guessed either way; a region with no project
3379
+ resolves to "" and every caller refuses, which is what it did before.
3380
+ """
3381
+ explicit = _provider_credential("nebius", "NEBIUS_PROJECT_ID").strip()
3382
+ return explicit or self._discovered_project
3383
+
3384
+ async def _resolve_project(self) -> str:
3385
+ """The project id for `NEBIUS_REGION`, discovered once and remembered.
3386
+
3387
+ One listing, cached for the process: the account's project set changes on the timescale of
3388
+ someone creating a project, and re-asking per create would put an IAM round trip on the
3389
+ spend path for an answer that does not move.
3390
+
3391
+ A FAILED LISTING IS NOT "NO PROJECT". It leaves the cache empty and returns "", so the
3392
+ caller refuses — the same answer an unconfigured deployment gets, and never a different
3393
+ region's project standing in for the one we could not read.
3394
+ """
3395
+ explicit = _provider_credential("nebius", "NEBIUS_PROJECT_ID").strip()
3396
+ if explicit or self._discovered_project:
3397
+ return explicit or self._discovered_project
3398
+ want = (NEBIUS_REGION or "").strip().lower()
3399
+ if not want:
3400
+ return ""
3401
+ headers = await self._headers()
3402
+ if headers is None:
3403
+ return ""
3404
+ try:
3405
+ async with httpx.AsyncClient(timeout=30) as c:
3406
+ t = await c.get(f"{self._BASE}/iam/v1/tenants", headers=headers)
3407
+ if t.status_code >= 300:
3408
+ log.error("nebius: tenant listing HTTP %s — cannot resolve the %s project",
3409
+ t.status_code, want)
3410
+ return ""
3411
+ for ten in ((t.json() or {}).get("items") or []):
3412
+ tid = str(((ten.get("metadata") or {}).get("id") or ""))
3413
+ if not tid:
3414
+ continue
3415
+ r = await c.get(f"{self._BASE}{NEBIUS_PROJECTS_PATH}", headers=headers,
3416
+ params={"parentId": tid})
3417
+ if r.status_code >= 300:
3418
+ log.error("nebius: project listing HTTP %s for tenant — cannot resolve "
3419
+ "the %s project", r.status_code, want)
3420
+ return ""
3421
+ for pr in ((r.json() or {}).get("items") or []):
3422
+ pid = str(((pr.get("metadata") or {}).get("id") or ""))
3423
+ regn = str(((pr.get("status") or {}).get("region")
3424
+ or (pr.get("spec") or {}).get("region") or "")).lower()
3425
+ if pid and regn:
3426
+ self._project_region[pid] = regn
3427
+ if pid and regn == want:
3428
+ self._discovered_project = pid
3429
+ log.info("nebius: EU work scoped to %s (region %s), discovered from "
3430
+ "the account rather than configured", pid, regn)
3431
+ return pid
3432
+ except Exception as e:
3433
+ log.error("nebius: project discovery failed (%s) — refusing rather than scoping "
3434
+ "calls to a project nobody chose", type(e).__name__)
3435
+ return ""
3436
+ log.error("nebius: the account holds no project in %s, so there is nothing to scope EU "
3437
+ "work to. Set FRUTEBOX_NEBIUS_PROJECT_ID or create the project.", want)
3438
+ return ""
3439
+
3440
+ def configured(self) -> bool:
3441
+ """A parseable authorized key AND a project to scope calls to — or a region to find one in.
3442
+
3443
+ A key alone is not the ability to call anything: every compute operation needs a
3444
+ `parentId`, so reporting `True` without one is how a provider wins a race it cannot serve.
3445
+ That invariant is unchanged; what changed is that the project may now be DISCOVERED from
3446
+ `NEBIUS_REGION` instead of typed, and discovery needs an async call that cannot happen
3447
+ inside this sync predicate. Returning False until it had run would mean it never ran — no
3448
+ caller asks an unconfigured provider anything — so a named region counts here.
3449
+
3450
+ The race argument survives intact one level down: `create` resolves the project first and
3451
+ refuses when it cannot, before anything is POSTed or billed. So the worst case is a
3452
+ provider that enters a race and declines it for a named reason, which is what a stock-out
3453
+ already looks like.
3454
+ """
3455
+ if not self._key():
3456
+ return False
3457
+ return bool(self._project_id() or (NEBIUS_REGION or "").strip())
3458
+
3459
+ # ---- auth ------------------------------------------------------------------------------
3460
+
3461
+ async def _access_token(self) -> str:
3462
+ """A cached IAM token, refreshed a minute before it actually expires.
3463
+
3464
+ The margin is the same one Verda's token has and for the same reason: a token that passes
3465
+ the check here and expires in flight produces a 401 on the CREATE call, and a create that
3466
+ fails ambiguously is the one failure mode that can leave an instance running that nobody
3467
+ recorded.
3468
+
3469
+ Returns `""` rather than raising, for every failure — a missing credential, an unsignable
3470
+ key, a rejected assertion. Callers fail the way the contract says ("nothing running" /
3471
+ "did not create"), never by raising out of a provider method.
3472
+ """
3473
+ now = time.time()
3474
+ if self._token and now < self._token_expires:
3475
+ return self._token
3476
+ key = self._key()
3477
+ if not key:
3478
+ return ""
3479
+ try:
3480
+ assertion = nebius_jwt(key_id=str(key["id"]),
3481
+ service_account_id=str(key["service_account_id"]),
3482
+ private_key_pem=str(key["private_key"]))
3483
+ except Exception as e:
3484
+ # The TYPE only. A signing failure must not put the private key in a log.
3485
+ log.error("nebius: could not sign the token assertion (%s) — check that the "
3486
+ "authorized key's private_key is an RSA PEM", type(e).__name__)
3487
+ return ""
3488
+ try:
3489
+ async with httpx.AsyncClient(timeout=30) as c:
3490
+ r = await c.post(NEBIUS_AUTH_URL, data={
3491
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
3492
+ "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
3493
+ "subject_token": assertion,
3494
+ "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
3495
+ })
3496
+ except Exception as e:
3497
+ log.warning("nebius: token exchange unreachable (%s)", type(e).__name__)
3498
+ return ""
3499
+ if r.status_code >= 300:
3500
+ # The STATUS, never the body: an auth error body can echo the assertion back.
3501
+ log.error("nebius: token exchange refused the service-account assertion (HTTP %s)",
3502
+ r.status_code)
3503
+ return ""
3504
+ token = str((r.json() or {}).get("access_token") or "")
3505
+ if not token:
3506
+ return ""
3507
+ self._token = token
3508
+ self._token_expires = now + max(0.0, float((r.json() or {}).get("expires_in") or 43200)) - 60
3509
+ return token
3510
+
3511
+ async def _headers(self) -> "Optional[dict]":
3512
+ """Auth headers, or None when no token could be obtained.
3513
+
3514
+ None rather than an empty bearer: `Authorization: Bearer ` is an ILLEGAL header value and
3515
+ httpx raises `LocalProtocolError` on it, so a credential problem would surface as a
3516
+ protocol crash from inside the transport.
3517
+ """
3518
+ token = await self._access_token()
3519
+ if not token:
3520
+ return None
3521
+ return {"Authorization": f"Bearer {token}", "content-type": "application/json"}
3522
+
3523
+ # ---- the EU check ----------------------------------------------------------------------
3524
+
3525
+ async def _assert_eu_project(self, project_id: str) -> bool:
3526
+ """Is this project's hardware in the EU? Checked, once per project, before any create.
3527
+
3528
+ WHY THIS EXISTS AT ALL. `CreateInstanceRequest` carries no region: `metadata.parentId`
3529
+ names a project and the PROJECT's `spec.region` decides where the metal is. So there is no
3530
+ field in a create body that an EU gate could inspect — the only honest check is to ask IAM
3531
+ what region the project is in, which is what this does.
3532
+
3533
+ A FAILED CHECK IS NOT A PASS. If the project cannot be read, this returns False and the
3534
+ create is refused. That is the opposite of the usual "degrade toward launching" trade in
3535
+ this file, and deliberately so: everywhere else a failed read costs capacity, while here it
3536
+ would mean placing EU-labelled work on hardware whose jurisdiction was never established.
3537
+ "We could not tell" is not a defence to a regulator — the same call `regionmap`'s residency
3538
+ gate and `provider_of_node` already make.
3539
+ """
3540
+ if project_id in self._eu_checked:
3541
+ return True
3542
+ headers = await self._headers()
3543
+ if headers is None:
3544
+ return False
3545
+ try:
3546
+ async with httpx.AsyncClient(timeout=30) as c:
3547
+ r = await c.get(f"{self._BASE}{NEBIUS_PROJECTS_PATH}/{_urlquote(project_id)}",
3548
+ headers=headers)
3549
+ except Exception as e:
3550
+ log.error("nebius: could not read project %s to establish its region (%s) — refusing "
3551
+ "to create. An unverified jurisdiction is not an EU one.",
3552
+ project_id, type(e).__name__)
3553
+ return False
3554
+ if r.status_code >= 300:
3555
+ log.error("nebius: could not read project %s to establish its region (HTTP %s) — "
3556
+ "refusing to create.", project_id, r.status_code)
3557
+ return False
3558
+ region = str(((r.json() or {}).get("spec") or {}).get("region") or "").strip().lower()
3559
+ if not region:
3560
+ log.error("nebius: project %s reports no region — refusing to create.", project_id)
3561
+ return False
3562
+ if region not in NEBIUS_EU_REGIONS:
3563
+ log.error("nebius: project %s is in region %r, which is not EU. Permitted: %s. "
3564
+ "(eu-north2 is ICELAND — EEA, not EU.)",
3565
+ project_id, region, ",".join(sorted(NEBIUS_EU_REGIONS)))
3566
+ return False
3567
+ self._eu_checked.add(project_id)
3568
+ self._project_region[project_id] = region
3569
+ log.info("nebius: project %s confirmed in EU region %s", project_id, region)
3570
+ return True
3571
+
3572
+ # ---- enumeration -----------------------------------------------------------------------
3573
+
3574
+ async def _list(self) -> "Optional[List[dict]]":
3575
+ """Every instance in the project, across pages, or None when it could not be ASKED.
3576
+
3577
+ None IS NOT AN EMPTY LIST. An unreadable list read as "nothing is there" is this
3578
+ codebase's most expensive recurring mistake, and here it would mean a teardown reporting a
3579
+ clear label while instances bill.
3580
+
3581
+ PAGINATION IS FOLLOWED, which matters more than it looks: a truncated first page read as
3582
+ the whole fleet is the same defect wearing a different hat — the pool would miss its own
3583
+ instances and `start()` would buy duplicates. The page loop is bounded so a server that
3584
+ keeps handing back the same token cannot spin here forever.
3585
+ """
3586
+ headers = await self._headers()
3587
+ if headers is None:
3588
+ return None
3589
+ project = await self._resolve_project()
3590
+ if not project:
3591
+ return None
3592
+ out: List[dict] = []
3593
+ token = ""
3594
+ try:
3595
+ async with httpx.AsyncClient(timeout=30) as c:
3596
+ for _ in range(50):
3597
+ params = {"parentId": project, "pageSize": "100"}
3598
+ if token:
3599
+ params["pageToken"] = token
3600
+ r = await c.get(f"{self._BASE}{NEBIUS_INSTANCES_PATH}", headers=headers,
3601
+ params=params)
3602
+ if r.status_code >= 300:
3603
+ log.warning("nebius: instance list HTTP %s", r.status_code)
3604
+ return None
3605
+ body = r.json() or {}
3606
+ out.extend(i for i in (body.get("items") or []) if isinstance(i, dict))
3607
+ token = str(body.get("nextPageToken") or "")
3608
+ if not token:
3609
+ return out
3610
+ except Exception as e:
3611
+ log.warning("nebius: could not list instances (%s)", type(e).__name__)
3612
+ return None
3613
+ log.warning("nebius: instance list did not terminate after 50 pages — returning what was "
3614
+ "read so far rather than looping, but treat this fleet view as partial")
3615
+ return out
3616
+
3617
+ @staticmethod
3618
+ def _state(row: dict) -> str:
3619
+ return str(((row.get("status") or {}).get("state") or "")).strip().upper()
3620
+
3621
+ @staticmethod
3622
+ def _label(row: dict) -> str:
3623
+ return str(((row.get("metadata") or {}).get("name") or ""))
3624
+
3625
+ @staticmethod
3626
+ def _id(row: dict) -> str:
3627
+ return str(((row.get("metadata") or {}).get("id") or ""))
3628
+
3629
+ def _matching(self, rows: "List[dict]", label: str,
3630
+ states: "Optional[frozenset]" = None) -> List[Instance]:
3631
+ out: List[Instance] = []
3632
+ for row in rows:
3633
+ if self._label(row) != label:
3634
+ continue
3635
+ state = self._state(row)
3636
+ if states is not None and state not in states:
3637
+ continue
3638
+ out.append(Instance(id=self._id(row), label=label, status=state))
3639
+ return out
3640
+
3641
+ async def find(self, label: str) -> List[Instance]:
3642
+ """THE BASE PRIMITIVE: everything in the project carrying this label.
3643
+
3644
+ GONE MEANS ABSENT. There is no status this treats as dead — every state the list returns is
3645
+ an instance that exists and can bill, `DELETING` included, because a deletion in flight is
3646
+ not a deletion observed.
3647
+ """
3648
+ rows = await self._list()
3649
+ return self._matching(rows or [], label)
3650
+
3651
+ async def find_live(self, label: str) -> List[Instance]:
3652
+ """CAPACITY: only `RUNNING`. A `STOPPED` instance keeps its disks and serves nothing, and a
3653
+ `CREATING` one cannot take a turn yet — counting either as capacity holds a pool slot that
3654
+ nothing is serving from."""
3655
+ rows = await self._list()
3656
+ return self._matching(rows or [], label, NEBIUS_SERVING_STATES)
3657
+
3658
+ async def find_all(self, label: str) -> List[Instance]:
3659
+ """TEARDOWN: everything that could still bill — which here is everything that is listed."""
3660
+ rows = await self._list()
3661
+ return self._matching(rows or [], label)
3662
+
3663
+ async def status(self, label: str) -> dict:
3664
+ """The vendor's own state for each instance under this label, unflattened.
3665
+
3666
+ `{"ok": False}` when the project could not be asked — never an empty `instances` list,
3667
+ which would read as "the label is clear".
3668
+ """
3669
+ rows = await self._list()
3670
+ if rows is None:
3671
+ return {"ok": False, "provider": self.name, "label": label,
3672
+ "detail": "could not list instances — this is NOT 'the label is clear'"}
3673
+ mine = [r for r in rows if self._label(r) == label]
3674
+ return {
3675
+ "ok": True,
3676
+ "provider": self.name,
3677
+ "label": label,
3678
+ "project": self._project_id(),
3679
+ "instances": [{"id": self._id(r), "state": self._state(r),
3680
+ "serving": self._state(r) in NEBIUS_SERVING_STATES,
3681
+ "billing": True,
3682
+ "boot_disk": (((r.get("spec") or {}).get("bootDisk") or {})
3683
+ .get("existingDisk", {}) or {}).get("id", ""),
3684
+ "platform": (((r.get("spec") or {}).get("resources") or {})
3685
+ .get("platform", "")),
3686
+ "preset": (((r.get("spec") or {}).get("resources") or {})
3687
+ .get("preset", ""))}
3688
+ for r in mine],
3689
+ }
3690
+
3691
+ async def list_account(self) -> "Optional[List[dict]]":
3692
+ """Every instance in the PROJECT, label-agnostic, normalised. `None` when unaskable.
3693
+
3694
+ "Account" is the project here, because that is the widest thing one credential can
3695
+ enumerate — Nebius scopes every compute read to a `parentId`. SO THIS IS NOT A WHOLE-TENANT
3696
+ SWEEP, and saying so matters: an orphan in a DIFFERENT project of the same tenant is
3697
+ invisible to it, and would stay invisible while billing. `podreaper` reports the project it
3698
+ swept for exactly that reason.
3699
+ """
3700
+ rows = await self._list()
3701
+ if rows is None:
3702
+ return None
3703
+ return [{"id": self._id(r),
3704
+ "label": self._label(r),
3705
+ "status": self._state(r),
3706
+ "created_at": ((r.get("metadata") or {}).get("createdAt") or ""),
3707
+ "price_per_hour": None, # the vendor publishes no price on the instance
3708
+ "location": self._project_id(),
3709
+ "billing": True} # listed means present means billable
3710
+ for r in rows]
3711
+
3712
+ # ---- create / destroy ------------------------------------------------------------------
3713
+
3714
+ async def create(self, label: str, spec: dict) -> bool:
3715
+ """Create ONE instance carrying THIS label, in a project proven to be in the EU.
3716
+
3717
+ THE SPEC COMES FROM THE OPERATOR, like `RUNPOD_POD_SPEC` and `ALIBABA_ECS_SPEC` (the
3718
+ 2026-09-24 `VAST_POD_SPEC` ruling): `platform`, `preset`, the boot image and the subnet are
3719
+ the decisions that pick hardware and cost money, and a provider that guesses them deploys
3720
+ the wrong machine and bills for it. This refuses without them rather than inventing an
3721
+ H100 preset.
3722
+
3723
+ THE BOOT DISK IS VM-MANAGED ON PURPOSE, and it is a money decision rather than a style one.
3724
+ The vendor: "If VM-managed disks are attached to your VM, they are deleted along with the
3725
+ VM automatically" — while standalone disks are not. So a managed boot disk makes `destroy`
3726
+ complete by construction, which is the opposite of Verda, where the volume has to be named
3727
+ explicitly. `forbidDeletion` is never set, for the same reason.
3728
+ """
3729
+ project = await self._resolve_project()
3730
+ if not project:
3731
+ log.error("nebius: refusing to create %r without NEBIUS_PROJECT_ID — every compute "
3732
+ "call is scoped to a project, and the project decides the region", label)
3733
+ return False
3734
+
3735
+ platform = str(spec.get("platform") or "").strip()
3736
+ preset = str(spec.get("preset") or "").strip()
3737
+ image_family = str(spec.get("imageFamily") or spec.get("image_family") or "").strip()
3738
+ image_id = str(spec.get("imageId") or spec.get("image_id") or "").strip()
3739
+ subnet = str(spec.get("subnetId") or spec.get("subnet_id") or "").strip()
3740
+ if not platform or not preset:
3741
+ log.error("nebius: refusing to create %r without a platform AND a preset. These pick "
3742
+ "the hardware and the bill; set FRUTEBOX_NEBIUS_POD_SPEC.", label)
3743
+ return False
3744
+ if not (image_family or image_id):
3745
+ log.error("nebius: refusing to create %r without a boot image (imageFamily or "
3746
+ "imageId) — there is no sane default for what the machine boots.", label)
3747
+ return False
3748
+ if not subnet:
3749
+ log.error("nebius: refusing to create %r without a subnetId — a guessed subnet is a "
3750
+ "machine on the wrong network, or an unreachable one.", label)
3751
+ return False
3752
+
3753
+ # THE EU CHECK IS BEFORE THE CREATE, not after and not alongside. See `_assert_eu_project`.
3754
+ if not await self._assert_eu_project(project):
3755
+ return False
3756
+
3757
+ # `managedDisk` IS A RESOURCE, NOT A SPEC, and only a live call could say so. The published
3758
+ # OpenAPI flattens `AttachedDiskSpec.managedDisk` to `any`, so the fields went straight in —
3759
+ # and the vendor answered (measured 2026-10-18):
3760
+ # spec.boot_disk.managed_disk.name: value is required
3761
+ # spec.boot_disk.managed_disk.spec: value is required
3762
+ # i.e. it wants a NAMED disk wrapping its own spec, the same metadata/spec shape every
3763
+ # other Nebius resource has. Named after the instance so a stray disk is traceable to the
3764
+ # thing that made it.
3765
+ disk_spec: Dict[str, object] = {
3766
+ "type": str(spec.get("diskType") or spec.get("disk_type") or "NETWORK_SSD"),
3767
+ "sizeGibibytes": str(int(spec.get("diskGib") or spec.get("disk_gib") or 128)),
3768
+ }
3769
+ if image_id:
3770
+ disk_spec["sourceImageId"] = image_id
3771
+ else:
3772
+ disk_spec["sourceImageFamily"] = {"imageFamily": image_family}
3773
+ disk: Dict[str, object] = {"name": f"{label}-boot", "spec": disk_spec}
3774
+
3775
+ body: Dict[str, object] = {
3776
+ "metadata": {
3777
+ "parentId": project,
3778
+ # The CALLER's label, never the spec's. A provider that lets the spec's name
3779
+ # through is the 2026-08-21 billing bug reintroduced: the wake cannot find what it
3780
+ # started. Nebius spells the label `metadata.name`.
3781
+ "name": label,
3782
+ },
3783
+ "spec": {
3784
+ "resources": {"platform": platform, "preset": preset},
3785
+ # managedDisk, so the vendor deletes it with the instance. See the docstring.
3786
+ "bootDisk": {"attachMode": "READ_WRITE", "managedDisk": disk},
3787
+ "networkInterfaces": [{"name": "eth0", "subnetId": subnet,
3788
+ "ipAddress": {}}],
3789
+ },
3790
+ }
3791
+ if spec.get("cloudInitUserData") or spec.get("cloud_init_user_data"):
3792
+ body["spec"]["cloudInitUserData"] = str( # type: ignore[index]
3793
+ spec.get("cloudInitUserData") or spec.get("cloud_init_user_data"))
3794
+ if spec.get("publicIp") or spec.get("public_ip"):
3795
+ # An EXPLICIT opt-in. A public address is a cost and an exposure, and the node reaches
3796
+ # the gateway outbound, so nothing here needs one by default.
3797
+ body["spec"]["networkInterfaces"][0]["publicIpAddress"] = {} # type: ignore[index]
3798
+ if spec.get("serviceAccountId"):
3799
+ body["spec"]["serviceAccountId"] = str(spec["serviceAccountId"]) # type: ignore[index]
3800
+
3801
+ headers = await self._headers()
3802
+ if headers is None:
3803
+ return False
3804
+ try:
3805
+ async with httpx.AsyncClient(timeout=90) as c:
3806
+ r = await c.post(f"{self._BASE}{NEBIUS_INSTANCES_PATH}", headers=headers, json=body)
3807
+ except Exception as e:
3808
+ # AN AMBIGUOUS CREATE IS THE WORST OUTCOME IN THIS FILE — the 2026-09-17 orphan was a
3809
+ # create that timed out, was concluded not to have happened, and was honoured
3810
+ # afterwards. So this says so loudly enough for the reaper to be run.
3811
+ log.error("nebius: create %r FAILED IN FLIGHT (%s). An instance may exist that nothing "
3812
+ "recorded — list the project before retrying.", label, type(e).__name__)
3813
+ return False
3814
+ if r.status_code >= 300:
3815
+ log.error("nebius: create %r HTTP %s", label, r.status_code)
3816
+ return False
3817
+ return True
3818
+
3819
+ async def destroy(self, instance_id: str) -> bool:
3820
+ """Terminate. 404 counts as success — already gone is the state we wanted.
3821
+
3822
+ TERMINATE NEVER STOP: this calls DELETE, and `/compute/v1/instances/stop` is never called
3823
+ from this module at all. A stopped Nebius instance keeps its disks and keeps billing, which
3824
+ is the same trap as a stopped RunPod pod keeping its slot.
3825
+
3826
+ NO VOLUME BOOKKEEPING IS NEEDED HERE, and that is a property of the create rather than an
3827
+ omission: `create` always attaches a VM-MANAGED boot disk, which the vendor deletes with
3828
+ the instance. A disk attached by hand, or an `existingDisk` someone configures later, would
3829
+ survive. There is no volume sweep in this repo to fall back on (`volumes.py` is pure
3830
+ policy, not an inventory), but the spec path that could introduce a standalone disk is not
3831
+ reachable from `create` — it always attaches a managed one — so this is a gap only for a
3832
+ disk somebody attaches by hand.
3833
+ """
3834
+ headers = await self._headers()
3835
+ if headers is None:
3836
+ return False
3837
+ try:
3838
+ async with httpx.AsyncClient(timeout=60) as c:
3839
+ r = await c.delete(
3840
+ f"{self._BASE}{NEBIUS_INSTANCES_PATH}/{_urlquote(str(instance_id))}",
3841
+ headers=headers)
3842
+ except Exception as e:
3843
+ log.error("nebius: delete %s failed (%s) — reporting False so the caller looks, "
3844
+ "rather than a teardown that did not happen", instance_id, type(e).__name__)
3845
+ return False
3846
+ return r.status_code < 300 or r.status_code == 404
3847
+
3848
+
3849
+ # ---- Alibaba Cloud ECS, INTERNATIONAL only (28 §A, 2026-10-12) -----------------------------
3850
+ #
3851
+ # PRC-INCORPORATED, SO IT IS REGION-LOCKED BY CONSTRUCTION. The seed row carries
3852
+ # `region_locked: true` and `regions: [ap-southeast]`, and `providers.eligible()` therefore excludes
3853
+ # it from every EU/UK/US launch AND from every launch whose region is unstated. That second half is
3854
+ # the one that needed fixing: `eligible()` skips region narrowing entirely when no region is given
3855
+ # (the warm pool reconciles that way by design), so `regions` alone left this provider eligible for
3856
+ # any unlabelled job. "Unstated" is not "permitted" — see `ProviderRow.region_locked`.
3857
+ #
3858
+ # TARGET IS ALIBABA CLOUD INTERNATIONAL, not mainland China: `ecs.ap-southeast-1.aliyuncs.com`
3859
+ # (Singapore), verified against the regional endpoint list 2026-10-12. The credential is a RAM
3860
+ # user's, never the root account's, and it is read through `_provider_credential` like every other.
3861
+ #
3862
+ # TERMINATE, NEVER STOP — AND THE API MAKES THAT A DELIBERATE CHOICE. `DeleteInstance` with
3863
+ # `Force=false` REQUIRES the instance to be in the Stopped state first, and a stopped-but-unreleased
3864
+ # ECS instance still bills for its disks. So this adapter always passes `Force=true`, which releases
3865
+ # a RUNNING instance directly and never calls `StopInstance` at all — there is no code path here
3866
+ # that stops anything, and `test_alibaba_provider.py` asserts the string is absent from the module.
3867
+ # Alibaba's own wording: "After an instance is released, all physical resources used by the instance
3868
+ # are reclaimed."
3869
+ #
3870
+ # TWO BILLING LEAKS THE DOCS NAME, both closed at create time rather than hoped about:
3871
+ # * a disk with `DeleteWithInstance=false` survives the instance as a pay-as-you-go disk, so the
3872
+ # spec's system disk is never allowed to set it false;
3873
+ # * an EIP is NOT released with the instance, so no public IP is allocated
3874
+ # (`InternetMaxBandwidthOut=0`) and there is nothing left behind to release separately.
3875
+ #
3876
+ # THE SPEC COMES FROM THE OPERATOR, like `RUNPOD_POD_SPEC`: image, instance type, security group and
3877
+ # vSwitch are the decisions that cost money and pick hardware, and a provider that guesses them
3878
+ # deploys the wrong machine and bills for it (the 2026-09-24 `VAST_POD_SPEC` ruling). `create`
3879
+ # refuses without them rather than inventing a gn-series type.
3880
+ ALIBABA_ENDPOINT = envvar("ALIBABA_ENDPOINT", "ecs.ap-southeast-1.aliyuncs.com").strip()
3881
+ ALIBABA_REGION_ID = envvar("ALIBABA_REGION_ID", "ap-southeast-1").strip()
3882
+ #: ECS API version, from the operation reference (`api-ecs-2014-05-26-*`).
3883
+ ALIBABA_API_VERSION = "2014-05-26"
3884
+ #: SHA-256 of the empty body, which every GET/RPC call here sends. Named because it appears in both
3885
+ #: the canonical request and the `x-acs-content-sha256` header and the two must agree.
3886
+ _EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
3887
+
3888
+
3889
+ def _alibaba_spec() -> dict:
3890
+ """The operator's launch spec as JSON, or `{}` when unset."""
3891
+ raw = (envvar("ALIBABA_ECS_SPEC", "") or "").strip()
3892
+ if not raw:
3893
+ return {}
3894
+ try:
3895
+ spec = json.loads(raw)
3896
+ except Exception:
3897
+ log.error("ALIBABA_ECS_SPEC is not valid JSON — refusing to launch on a half-read spec")
3898
+ return {}
3899
+ return spec if isinstance(spec, dict) else {}
3900
+
3901
+
3902
+ def alibaba_signature(method: str, params: Dict[str, str], *, access_key_id: str,
3903
+ access_key_secret: str, host: str, action: str, nonce: str,
3904
+ date: str) -> Dict[str, str]:
3905
+ """Sign an RPC-style request with ACS3-HMAC-SHA256 and return the headers to send.
3906
+
3907
+ A PURE FUNCTION, deliberately: it takes the credential rather than reading it, so the signer can
3908
+ be tested against a known vector without a key in the environment and without a network call.
3909
+ The algorithm is from Alibaba's own V3 request-structure reference (read 2026-10-12):
3910
+
3911
+ canonical request = METHOD \n CanonicalURI \n CanonicalQueryString \n CanonicalHeaders \n
3912
+ SignedHeaders \n HashedPayload
3913
+ string to sign = "ACS3-HMAC-SHA256" \n hex(sha256(canonical request))
3914
+ signature = hex(hmac_sha256(secret, string to sign))
3915
+ Authorization = ACS3-HMAC-SHA256 Credential=<id>,SignedHeaders=<h>,Signature=<sig>
3916
+
3917
+ CanonicalURI is "/" for RPC APIs, which every ECS operation used here is. Query parameters are
3918
+ sorted by name and encoded per RFC 3986 with `*` as %2A, space as %20 and `~` left alone —
3919
+ `quote(safe="-_.~")` gives exactly that, and getting it wrong is a 403 that reads like a bad key.
3920
+ """
3921
+ canonical_query = "&".join(
3922
+ f"{_urlquote(k, safe='-_.~')}={_urlquote(str(v), safe='-_.~')}"
3923
+ for k, v in sorted(params.items()))
3924
+ headers = {
3925
+ "host": host,
3926
+ "x-acs-action": action,
3927
+ "x-acs-content-sha256": _EMPTY_SHA256,
3928
+ "x-acs-date": date,
3929
+ "x-acs-signature-nonce": nonce,
3930
+ "x-acs-version": ALIBABA_API_VERSION,
3931
+ }
3932
+ signed_names = sorted(headers)
3933
+ canonical_headers = "".join(f"{n}:{headers[n].strip()}\n" for n in signed_names)
3934
+ signed_headers = ";".join(signed_names)
3935
+ canonical_request = "\n".join([
3936
+ method.upper(), "/", canonical_query, canonical_headers, signed_headers, _EMPTY_SHA256,
3937
+ ])
3938
+ string_to_sign = "ACS3-HMAC-SHA256\n" + hashlib.sha256(
3939
+ canonical_request.encode()).hexdigest()
3940
+ signature = hmac.new(access_key_secret.encode(), string_to_sign.encode(),
3941
+ hashlib.sha256).hexdigest()
3942
+ return {
3943
+ **headers,
3944
+ "Authorization": (f"ACS3-HMAC-SHA256 Credential={access_key_id},"
3945
+ f"SignedHeaders={signed_headers},Signature={signature}"),
3946
+ }
3947
+
3948
+
3949
+ class AlibabaProvider:
3950
+ """Alibaba Cloud ECS (gn-series GPU), international regions only.
3951
+
3952
+ `region_locked` in the seed row is what keeps it out of EU/UK/US and unlabelled work; this class
3953
+ does not re-implement that gate, for the reason `providers.eligible` gives — one filter, in one
3954
+ place, or the two spellings drift.
3955
+ """
3956
+
3957
+ name = "alibaba"
3958
+
3959
+ #: ECS reports `Status` per instance (Running / Stopped / Starting / Stopping / Pending), so this
3960
+ #: provider CAN tell serving from merely existing — and must, because a Stopped instance still
3961
+ #: bills for its disks. Declared True, and both enumerations are implemented below; declaring it
3962
+ #: and not implementing them is what `test_gpuproviders.py`'s contract test refuses.
3963
+ DISTINGUISHES_SERVING = True
3964
+
3965
+ #: Statuses that are NOT serving but ARE still billing. The same distinction Vast's `exited`
3966
+ #: taught: "not serving" and "not billing" are different facts and one list cannot answer both.
3967
+ _NOT_SERVING = frozenset({"stopped", "stopping", "pending", "starting"})
3968
+
3969
+ def _key(self) -> "tuple[str, str]":
3970
+ return (_provider_credential("alibaba", "ALIBABA_CLOUD_ACCESS_KEY_ID"),
3971
+ _provider_credential("alibaba", "ALIBABA_CLOUD_ACCESS_KEY_SECRET"))
3972
+
3973
+ def configured(self) -> bool:
3974
+ """BOTH halves of the credential AND an operator spec. An access key id with no secret is
3975
+ not a credential, and either one alone reporting `configured: true` is how a provider wins a
3976
+ race it cannot start."""
3977
+ kid, secret = self._key()
3978
+ return bool(kid and secret and _alibaba_spec())
3979
+
3980
+ def _headers(self, action: str, params: Dict[str, str]) -> dict:
3981
+ kid, secret = self._key()
3982
+ return alibaba_signature(
3983
+ "GET", params, access_key_id=kid, access_key_secret=secret,
3984
+ host=ALIBABA_ENDPOINT, action=action,
3985
+ nonce=uuid.uuid4().hex,
3986
+ date=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
3987
+
3988
+ async def _call(self, action: str, params: Dict[str, str]) -> dict:
3989
+ """One signed RPC call. `{}` on any failure — a provider that cannot be read is skipped by
3990
+ the race, never treated as empty-and-therefore-idle."""
3991
+ kid, secret = self._key()
3992
+ if not (kid and secret):
3993
+ return {}
3994
+ q = {"RegionId": ALIBABA_REGION_ID, **params}
3995
+ headers = self._headers(action, q)
3996
+ async with httpx.AsyncClient(timeout=30) as c:
3997
+ r = await c.get(f"https://{ALIBABA_ENDPOINT}/", headers=headers, params=q)
3998
+ if r.status_code >= 300:
3999
+ # The STATUS and the action, never the body: an Alibaba error echoes the request,
4000
+ # and this is the kind of line that ends up pasted into an issue.
4001
+ log.warning("alibaba %s: HTTP %s", action, r.status_code)
4002
+ return {}
4003
+ try:
4004
+ return r.json() or {}
4005
+ except Exception:
4006
+ return {}
4007
+
4008
+ async def _listed(self, label: str) -> List[dict]:
4009
+ """Every instance whose name carries this label, in WHATEVER state.
4010
+
4011
+ Raw and unfiltered, like Vast's `_labelled`: which statuses count is the CALLER's question,
4012
+ and a pre-filtered list is what hid a paid-for instance from the census and the teardown at
4013
+ the same time.
4014
+ """
4015
+ data = await self._call("DescribeInstances",
4016
+ {"InstanceName": label, "MaxResults": "100"})
4017
+ return list(((data.get("Instances") or {}).get("Instance")) or [])
4018
+
4019
+ async def find(self, label: str) -> List[Instance]:
4020
+ return await self.find_live(label)
4021
+
4022
+ async def find_live(self, label: str) -> List[Instance]:
4023
+ """CAPACITY: Running only. A Stopped instance serves nothing, and counting it would let it
4024
+ hold the pool's only slot and block its own replacement."""
4025
+ return [Instance(id=str(i.get("InstanceId") or ""), label=label)
4026
+ for i in await self._listed(label)
4027
+ if str(i.get("Status", "")).lower() not in self._NOT_SERVING]
4028
+
4029
+ async def find_all(self, label: str) -> List[Instance]:
4030
+ """TEARDOWN breadth: everything ECS still lists, Stopped included — because a stopped
4031
+ instance still bills for its disks, which is exactly the leak `destroy` exists to close."""
4032
+ return [Instance(id=str(i.get("InstanceId") or ""), label=label)
4033
+ for i in await self._listed(label)]
4034
+
4035
+ async def create(self, label: str, spec: dict) -> "dict | bool":
4036
+ """RunInstances. REFUSES without an operator spec rather than inventing hardware."""
4037
+ base = _alibaba_spec()
4038
+ if not base:
4039
+ raise RuntimeError(
4040
+ "alibaba: no launch spec — set FRUTEBOX_ALIBABA_ECS_SPEC to a JSON object with at "
4041
+ "least ImageId, InstanceType, SecurityGroupId and VSwitchId. Refusing to guess an "
4042
+ "instance type for a provider that bills.")
4043
+ kid, secret = self._key()
4044
+ if not (kid and secret):
4045
+ return False
4046
+ params = {
4047
+ "InstanceName": label, # the caller's label, never the spec's
4048
+ "Amount": "1",
4049
+ # NO PUBLIC IP. An EIP is not released with the instance, so allocating one leaves a
4050
+ # billable resource behind that teardown cannot see.
4051
+ "InternetMaxBandwidthOut": "0",
4052
+ **{k: str(v) for k, v in base.items() if k != "InstanceName"},
4053
+ # LAST, so a spec cannot turn it off: a system disk kept past the instance becomes a
4054
+ # pay-as-you-go disk that nothing in this codebase is looking for.
4055
+ "SystemDisk.DeleteWithInstance": "true",
4056
+ }
4057
+ data = await self._call("RunInstances", params)
4058
+ ids = list((data.get("InstanceIdSets") or {}).get("InstanceIdSet") or [])
4059
+ if not ids:
4060
+ return False
4061
+ return {"id": str(ids[0]), "name": label}
4062
+
4063
+ async def destroy(self, instance_id: str) -> bool:
4064
+ """DeleteInstance with `Force=true` — release a RUNNING instance directly.
4065
+
4066
+ THE FORCE FLAG IS THE WHOLE POINT. With `Force=false` Alibaba requires the instance to be
4067
+ Stopped first, which would mean calling `StopInstance` — and a stopped-but-unreleased ECS
4068
+ instance still bills for its disks. So this never stops anything; it releases, and Alibaba
4069
+ reclaims the physical resources.
4070
+ """
4071
+ data = await self._call("DeleteInstance",
4072
+ {"InstanceId": str(instance_id), "Force": "true"})
4073
+ # A successful RPC returns a RequestId. `{}` is the failure shape from `_call`.
4074
+ return bool(data.get("RequestId"))
4075
+
4076
+
4077
+ # ---- Phala (the TEE lane's confidential-compute provider, plan 08 / 28 §A3) -----------------
4078
+ #
4079
+ # ITS OWN ADAPTER AND ITS OWN SPEC, never borrowed. The 2026-09-24 ruling on `VAST_POD_SPEC` is the
4080
+ # reason: "a provider that borrows another's spec deploys the wrong machine and bills for it." A TEE
4081
+ # provider borrowing a GPU provider's spec would be that failure with an attestation claim on top.
4082
+ #
4083
+ # THE API SHAPE, now from Phala's own OpenAPI rather than from guesswork (read 2026-10-07, and the base
4084
+ # supplied by the owner the same day). Every one of these is quoted from the spec embedded in
4085
+ # `docs.phala.com/api-reference/cvms/*`:
4086
+ #
4087
+ # POST /api/v1/cvms/provision provision-dstack-app -> caches the compose, returns compose_hash
4088
+ # POST /api/v1/cvms create-cvm-from-provision (the COMMIT) -> requires app_id + compose_hash
4089
+ # GET /api/v1/cvms list-cvms
4090
+ # DELETE /api/v1/cvms/{cvm_id} delete-cvm -> id in the PATH, not the body
4091
+ #
4092
+ # Authentication is the header `X-API-Key`. The earlier revision of this class refused to act because the
4093
+ # paths were unconfirmed; they are confirmed now, so the defaults below are real and no operator
4094
+ # configuration is needed to reach the API. The override survives because the base is the one thing that
4095
+ # has already been wrong twice: the registry row said `api.phala.network` (an API that is not there) and
4096
+ # `cloud.phala.com` is the DASHBOARD. Phala's own SDK calls this override `PHALA_CLOUD_API_PREFIX`.
4097
+ #
4098
+ # `configured()` STILL REQUIRES BOTH a credential and a known shape. That is unchanged on purpose: it is
4099
+ # what stops an operator who blanks the base from having a provider that reports ready and cannot call
4100
+ # anything.
4101
+ PHALA_BASE = envvar("PHALA_API_BASE", "https://cloud-api.phala.com/api/v1").rstrip("/")
4102
+ #: `provision,commit,list,delete` — relative to `PHALA_BASE`, in that order. `{cvm_id}` is substituted.
4103
+ PHALA_PATHS = [p.strip() for p in
4104
+ envvar("PHALA_PATHS", "/cvms/provision,/cvms,/cvms,/cvms/{cvm_id}").split(",")
4105
+ if p.strip()]
4106
+
4107
+
4108
+ class PhalaProvider:
4109
+ """Phala confidential VMs, through the same `GpuProvider` protocol as every other backend.
4110
+
4111
+ `class: tee` in `registry/providers.yaml`, so §A4's class gate keeps it out of the GPU race whatever
4112
+ the warm-pool allow-list says, and the §16 lane toggle (`FRUTEBOX_TEE_LANE`, default OFF) keeps the
4113
+ whole lane unreachable until an operator asks for it. Attestation does NOT go through our verifier:
4114
+ `tee.DIRECT_NRAS_PROVIDERS` already lists `phala`, so its evidence goes straight to NVIDIA's NRAS.
4115
+ """
4116
+
4117
+ name = "phala"
4118
+
4119
+ #: Phala answers one question about an instance: is it listed. It cannot distinguish a CVM that is
4120
+ #: SERVING from one that merely exists, so the ambiguity is declared rather than discovered — the
4121
+ #: same declaration DataCrunch makes, and `nodewake` resolves it toward suspected_billing.
4122
+ DISTINGUISHES_SERVING = False
4123
+
4124
+ def _key(self) -> str:
4125
+ return _provider_credential("phala", "PHALA_API_KEY")
4126
+
4127
+ def _shape_known(self) -> bool:
4128
+ """Is the API shape confirmed? Four paths and a base, or nothing."""
4129
+ return bool(PHALA_BASE) and len(PHALA_PATHS) == 4
4130
+
4131
+ def configured(self) -> bool:
4132
+ """A credential AND a confirmed API shape. A key alone is not the ability to call anything, and
4133
+ reporting `True` on a key alone is how a provider wins a race it cannot serve."""
4134
+ return bool(self._key()) and self._shape_known()
4135
+
4136
+ def _headers(self) -> dict:
4137
+ return {"X-API-Key": self._key(), "content-type": "application/json"}
4138
+
4139
+ def _path(self, which: str) -> str:
4140
+ order = ("provision", "commit", "list", "delete")
4141
+ return f"{PHALA_BASE}{PHALA_PATHS[order.index(which)]}"
4142
+
4143
+ async def _labelled(self, label: str) -> List[dict]:
4144
+ if not self.configured():
4145
+ return []
4146
+ async with httpx.AsyncClient(timeout=30) as c:
4147
+ r = await c.get(self._path("list"), headers=self._headers())
4148
+ if r.status_code >= 300:
4149
+ return []
4150
+ body = r.json() or {}
4151
+ rows = body if isinstance(body, list) else (body.get("items") or body.get("data") or [])
4152
+ return [i for i in rows if str(i.get("name") or i.get("app_name") or "") == label]
4153
+
4154
+ async def find(self, label: str) -> List[Instance]:
4155
+ """THE ONE ENUMERATION THIS API GIVES, and deliberately the only one implemented.
4156
+
4157
+ No `find_live` and no `find_all`: `DISTINGUISHES_SERVING = False` says this provider cannot tell a
4158
+ CVM that is SERVING from one that merely exists, and the module-level `find_live`/`find_all` already
4159
+ fall back to this single question for exactly that case. Implementing both here would contradict the
4160
+ declaration — `test_a_provider_that_cannot_answer_BOTH_questions_must_declare_it` catches precisely
4161
+ that, because a declaration the code does not back up tells callers something untrue — and it would
4162
+ duplicate a fallback the dispatch layer owns. DataCrunch has the same shape for the same reason.
4163
+ """
4164
+ return [{"id": str(i.get("app_id") or i.get("id") or ""), "name": label,
4165
+ "status": str(i.get("status") or "")}
4166
+ for i in await self._labelled(label)]
4167
+
4168
+ async def create(self, label: str, spec: dict) -> "dict | bool":
4169
+ """Provision, then commit — Phala's two-phase deployment, which is why this is not one POST.
4170
+
4171
+ REFUSES rather than guesses when the API shape is unconfirmed. A create that posts to an invented
4172
+ path either 404s (a confusing failure) or, worse, succeeds against something unintended.
4173
+ """
4174
+ if not self._shape_known():
4175
+ raise RuntimeError(
4176
+ "phala: API shape not configured — set FRUTEBOX_PHALA_API_BASE and FRUTEBOX_PHALA_PATHS "
4177
+ "(provision,commit,list,delete) from Phala's API reference. Refusing to guess endpoint "
4178
+ "paths for a provider that bills.")
4179
+ if not self._key():
4180
+ return False
4181
+ body = dict(spec or {})
4182
+ body.setdefault("name", label)
4183
+ async with httpx.AsyncClient(timeout=120) as c:
4184
+ pr = await c.post(self._path("provision"), headers=self._headers(), json=body)
4185
+ if pr.status_code >= 300:
4186
+ return False
4187
+ prov = pr.json() or {}
4188
+ # THE COMMIT'S REQUIRED PAIR, per `create-cvm-from-provision`: `app_id` and `compose_hash`.
4189
+ #
4190
+ # `app_env_encrypt_pubkey` is deliberately NOT sent. It is a provision OUTPUT — the key an
4191
+ # operator encrypts environment variables WITH, producing the optional `encrypted_env` — not a
4192
+ # commit field. Sending it would be inventing a parameter the spec does not have. It is carried
4193
+ # back to the caller instead, so whoever adds env encryption has it; this class does not
4194
+ # encrypt anything, and pretending otherwise would be worse than the gap.
4195
+ commit = {"app_id": prov.get("app_id") or body.get("app_id"),
4196
+ "compose_hash": prov.get("compose_hash")}
4197
+ cr = await c.post(self._path("commit"), headers=self._headers(), json=commit)
4198
+ if cr.status_code >= 300:
4199
+ return False
4200
+ made = cr.json() or {}
4201
+ return {"id": str(made.get("id") or made.get("vm_uuid") or made.get("app_id") or ""),
4202
+ "name": label,
4203
+ # For whoever adds environment encryption: this is what it is encrypted WITH.
4204
+ "app_env_encrypt_pubkey": prov.get("app_env_encrypt_pubkey")}
4205
+
4206
+ async def destroy(self, instance_id: str) -> bool:
4207
+ """`DELETE /cvms/{cvm_id}` — the id is a PATH parameter, not a body field.
4208
+
4209
+ An earlier revision of this sent `{"app_id": ...}` as a DELETE body, which the spec does not
4210
+ describe: it would have deleted nothing and reported success on whatever the base path answered.
4211
+ `destroy_verified` in the dispatch layer is what would have caught it in the end, by re-reading the
4212
+ listing — but a teardown that silently deletes the wrong thing is not a thing to leave to a backstop.
4213
+ """
4214
+ if not self._shape_known():
4215
+ return False
4216
+ url = self._path("delete").replace("{cvm_id}", str(instance_id))
4217
+ async with httpx.AsyncClient(timeout=60) as c:
4218
+ r = await c.request("DELETE", url, headers=self._headers())
4219
+ return r.status_code < 300 or r.status_code == 404
4220
+
4221
+
4222
+ async def derive_lambda_launch(api_key: Optional[str] = None, *,
4223
+ instance_type: str = "") -> Dict[str, str]:
4224
+ """28 §A5 — discover Lambda's account-scoped launch details FROM the key.
4225
+
4226
+ OPTION 1 SHAPE (owner ruling 2026-09-22), the same as RunPod's: the operator makes the
4227
+ decision that costs money — here `LAMBDA_INSTANCE_TYPE`, which `create` already refuses to
4228
+ launch without — and the code discovers the account-scoped consequences of it. This NEVER
4229
+ chooses an instance type.
4230
+
4231
+ WHAT IT FIXES. `create` falls back to `region_name="us-west-1"` when `LAMBDA_REGION` is
4232
+ unset. That is the same defect class as pairing volume X's id with volume Y's datacenter:
4233
+ an instance type that has capacity only in `us-east-1` is launched into `us-west-1` and
4234
+ fails — and a region and a list of SSH key names are account RESOURCES, exactly the thing
4235
+ §A5 says a person should not be copying out of a console.
4236
+
4237
+ Returns {} rather than raising: a missing derivation is a launch that proceeds on whatever
4238
+ the operator did set, not a reason to refuse to provision.
4239
+ """
4240
+ key = api_key or _provider_credential("lambda", "LAMBDA_API_KEY")
4241
+ if not key or not instance_type:
4242
+ return {}
4243
+ base, headers = LambdaProvider._BASE, {"Authorization": f"Bearer {key}"}
4244
+ out: Dict[str, str] = {}
4245
+ try:
4246
+ async with httpx.AsyncClient(timeout=20.0) as client:
4247
+ types = await client.get(f"{base}/instance-types", headers=headers)
4248
+ keys = await client.get(f"{base}/ssh-keys", headers=headers)
4249
+ except Exception:
4250
+ return {}
4251
+
4252
+ # Shapes are read DEFENSIVELY — every lookup tolerates a missing key and an unexpected
4253
+ # container — because this has not been run against a live Lambda account and a wrong guess
4254
+ # here must degrade to {} rather than to a confident wrong region.
4255
+ try:
4256
+ if types.status_code == 200:
4257
+ data = (types.json() or {}).get("data") or {}
4258
+ entry = data.get(instance_type) if isinstance(data, dict) else None
4259
+ regions = (entry or {}).get("regions_with_capacity_available") or []
4260
+ names = [str(r.get("name")) for r in regions if isinstance(r, dict) and r.get("name")]
4261
+ if names:
4262
+ # First with capacity. Not a cost choice — Lambda prices an instance type the
4263
+ # same everywhere; this only answers "where can it actually start".
4264
+ out["LAMBDA_REGION"] = names[0]
4265
+ except Exception:
4266
+ pass
4267
+ try:
4268
+ if keys.status_code == 200:
4269
+ rows = (keys.json() or {}).get("data") or []
4270
+ names = [str(k.get("name")) for k in rows if isinstance(k, dict) and k.get("name")]
4271
+ if names:
4272
+ out["LAMBDA_SSH_KEYS"] = ",".join(names)
4273
+ except Exception:
4274
+ pass
4275
+ return out
4276
+
4277
+
4278
+ async def derive_runpod_volume(api_key: Optional[str] = None, *,
4279
+ volume_id: str = "") -> Dict[str, str]:
4280
+ """28 §A5 — discover RunPod's account-scoped volume ids FROM the key.
4281
+
4282
+ The owner supplies a credential; the code discovers the rest. A network volume id and its
4283
+ datacenter are account RESOURCES, not secrets, and asking a person to copy them out of a
4284
+ console is how they end up wrong — pointing a pod at a volume in another region fails at
4285
+ launch, after the GPU is already billing.
4286
+
4287
+ Lives here because this is the single RunPod door (KB §0): every RunPod call in the codebase
4288
+ goes through this module, and `tests/test_gpu_single_door.py` fails if one appears
4289
+ elsewhere. Returns {} rather than raising — a missing volume is a slower cold start, not a
4290
+ reason to refuse to provision.
4291
+
4292
+ PASS `volume_id` TO ASK ABOUT ONE SPECIFIC VOLUME. Without it this guesses — "seed" in the
4293
+ name, else the largest — which is the right guess when nothing is known and the WRONG one
4294
+ when the operator has already named a volume. Deriving a datacenter by heuristic and pairing
4295
+ it with an id the operator chose is how volume X ends up pinned to volume Y's datacenter: a
4296
+ launch that fails after the GPU is already billing. Asked about a specific volume, this
4297
+ returns that volume's own datacenter or nothing at all.
4298
+ """
4299
+ key = api_key or _provider_credential("runpod", "RUNPOD_API_KEY")
4300
+ if not key:
4301
+ return {}
4302
+ try:
4303
+ async with httpx.AsyncClient(timeout=20.0) as client:
4304
+ r = await client.get(
4305
+ "https://rest.runpod.io/v1/networkvolumes",
4306
+ headers={"Authorization": f"Bearer {key}"},
4307
+ )
4308
+ if r.status_code != 200:
4309
+ return {}
4310
+ payload = r.json()
4311
+ except Exception:
4312
+ return {}
4313
+
4314
+ volumes = payload if isinstance(payload, list) else (payload.get("data") or [])
4315
+ if not volumes:
4316
+ return {}
4317
+
4318
+ # The seeded-weights volume is the one worth using: it is what turns a ~94-minute cold
4319
+ # download into a ~90-second load. Prefer it by name, else fall back to the largest, which is
4320
+ # the only other defensible guess about which volume holds model weights.
4321
+ if volume_id:
4322
+ match = [v for v in volumes if str(v.get("id") or "") == volume_id]
4323
+ if not match:
4324
+ # The named volume is not in this account. Say nothing rather than guess: a wrong
4325
+ # datacenter here is a failed launch, and silence leaves the API to infer it.
4326
+ return {}
4327
+ chosen = match[0]
4328
+ else:
4329
+ seeded = [v for v in volumes if "seed" in str(v.get("name", "")).lower()]
4330
+ chosen = (seeded or sorted(volumes, key=lambda v: int(v.get("size") or 0), reverse=True))[0]
4331
+ out = {}
4332
+ if chosen.get("id"):
4333
+ out["RUNPOD_NETWORK_VOLUME_ID"] = str(chosen["id"])
4334
+ if chosen.get("dataCenterId"):
4335
+ out["RUNPOD_VOLUME_DATACENTER"] = str(chosen["dataCenterId"])
4336
+ return out
4337
+
4338
+
4339
+ PROVIDERS: Dict[str, GpuProvider] = {
4340
+ # Phala is `class: tee` in `registry/providers.yaml`, so §A4's class gate keeps it out of the GPU
4341
+ # race whatever the warm-pool allow-list says, and the §16 lane toggle keeps the lane unreachable
4342
+ # by default. It is registered here so the capability query (`providers doctor`) can REPORT it: a
4343
+ # provider missing from the registry does not report as broken, it does not report at all.
4344
+ p.name: p for p in (RunPodProvider(), VastProvider(), LambdaProvider(),
4345
+ DataCrunchProvider(), PhalaProvider(), NebiusProvider(),
4346
+ # Registered so `providers doctor` can REPORT it. Its residency fence is the
4347
+ # seed row's `region_locked`, enforced in `providers.eligible` — not here,
4348
+ # and not twice.
4349
+ AlibabaProvider()) # type: ignore[misc]
4350
+ }
4351
+
4352
+
4353
+ def configured_providers() -> List[str]:
4354
+ """Which providers this deployment can actually use. For ops and for honest errors."""
4355
+ return [n for n, p in PROVIDERS.items() if p.configured()]
4356
+
4357
+
4358
+ # ---- ops CLI — so no shell script ever hand-rolls a RunPod call again ----------------------
4359
+ #
4360
+ # `scripts/bench_run.sh` used to carry its own curl to REST + a GraphQL podTerminate — an
4361
+ # UNVERIFIED terminate, the exact §3 wrong pattern, living in an ops script where no test looks.
4362
+ # Usage: RUNPOD_API_KEY=... python -m frutebox.gpuproviders <list|stop-label LABEL [PROVIDER]|terminate POD_ID>
4363
+
4364
+ def _cli() -> int:
4365
+ import asyncio as _a
4366
+ import sys
4367
+
4368
+ rp = PROVIDERS.get("runpod")
4369
+ cmd = sys.argv[1] if len(sys.argv) > 1 else "list"
4370
+ if cmd == "list":
4371
+ # `_list_pods_or_none`, NOT `list_pods`. The difference is the whole point of this command.
4372
+ #
4373
+ # OBSERVED 2026-09-28 from the backend lane: with no resolvable credential this printed
4374
+ # "0 pod(s)" and exited 0. That is the reading an operator takes to mean "the account is
4375
+ # clean, nothing is billing" — and it is produced by the one condition under which we know
4376
+ # nothing at all. `list_pods` flattens a transport or auth failure to `[]` by design
4377
+ # (right for planning, where an empty answer means "plan for none"); a human asking what is
4378
+ # on the account must be told the ask failed.
4379
+ pods = _a.run(rp._list_pods_or_none())
4380
+ if pods is None:
4381
+ print("CANNOT ENUMERATE: no resolvable credential or the provider call failed. "
4382
+ "This is NOT 'no pods' — pods may be running and BILLING. Check the provider "
4383
+ "console.", file=sys.stderr)
4384
+ return 3
4385
+ for p in pods:
4386
+ print(f"{p.get('id')} {p.get('name')} {p.get('desiredStatus')} "
4387
+ f"runtime={'yes' if p.get('runtime') else 'no'}")
4388
+ print(f"{len(pods)} pod(s)")
4389
+ return 0
4390
+ if cmd == "stop-label" and len(sys.argv) > 2:
4391
+ # OPTIONAL PROVIDER, defaulting to runpod. Hand-terminating by label is what an operator
4392
+ # is told to do when the wake path reports a suspected-billing instance
4393
+ # (`nodewake._abandon_unconfirmed`), and that instance is on whichever provider won the
4394
+ # race — which since merit order shipped is routinely not RunPod. A repair instruction
4395
+ # that can only reach one provider is not a repair instruction.
4396
+ target = PROVIDERS.get(sys.argv[3]) if len(sys.argv) > 3 else rp
4397
+ if target is None:
4398
+ print(f"unknown provider {sys.argv[3]!r}; known: {', '.join(sorted(PROVIDERS))}",
4399
+ file=sys.stderr)
4400
+ return 2
4401
+ res = _a.run(stop(target, sys.argv[2]))
4402
+ print(res)
4403
+ return 0 if res.get("ok") else 1
4404
+ if cmd == "terminate" and len(sys.argv) > 2:
4405
+ return 0 if _a.run(rp.terminate(sys.argv[2])) else 1
4406
+ if cmd == "stop-all":
4407
+ # OURS ONLY. This used to terminate EVERY pod on the account, and the account is SHARED
4408
+ # with another project (owner, 2026-09-28) — so the bench script's cost-safety trap would
4409
+ # have destroyed a third party's running work, from a `trap ... EXIT`, with no confirmation
4410
+ # and no way to undo it. The reaper elsewhere in this file already refuses to touch names
4411
+ # outside our scope for exactly this reason; this path predated that rule and never got it.
4412
+ #
4413
+ # `--everything` still exists because a genuine "kill absolutely all billing" exists as an
4414
+ # intent — but it has to be ASKED FOR, out loud, and it prints what it is about to do.
4415
+ everything = "--everything" in sys.argv[2:]
4416
+ prefix = "frutebox-"
4417
+
4418
+ # AND THE LISTING ITSELF MUST BE HONEST. Built on `list_pods`, an unauthenticated or failed
4419
+ # listing returned `[]`: the loop terminated nothing, the function returned True and the trap
4420
+ # exited 0 — a successful teardown reported for an account it could not see. Scoping the
4421
+ # sweep and knowing whether you can see the account are two different safety properties, and
4422
+ # this command needs both: one bounds what it kills, the other bounds what it CLAIMS.
4423
+ async def _all():
4424
+ pods = await rp._list_pods_or_none()
4425
+ if pods is None:
4426
+ log.error("stop-all CANNOT LIST the account — nothing was terminated and NOTHING "
4427
+ "IS PROVEN STOPPED. Pods may still be BILLING.")
4428
+ return False
4429
+ ok = True
4430
+ mine = [p for p in pods
4431
+ if everything or str(p.get("name") or "").startswith(prefix)]
4432
+ skipped = len(pods) - len(mine)
4433
+ if skipped:
4434
+ print(f"leaving {skipped} pod(s) alone — not named {prefix}* "
4435
+ f"(shared account; use --everything to override)")
4436
+ for pod in mine:
4437
+ pid = str(pod.get("id") or "")
4438
+ if pid:
4439
+ print(f"terminating {pid} {pod.get('name')!r}")
4440
+ ok = await rp.terminate(pid) and ok
4441
+ return ok
4442
+ return 0 if _a.run(_all()) else 1
4443
+ if cmd == "availability":
4444
+ # WHICH CARDS CAN ACTUALLY BE RENTED RIGHT NOW, and what they cost.
4445
+ #
4446
+ # Without this, finding capacity means ATTEMPTING a create per card, and every miss costs
4447
+ # ~75s because RunPod's "no instances available" reply is ambiguous (a pod may have been
4448
+ # created) so the provider reconciles against the live pod list before giving up. Eight
4449
+ # combinations is ~10 minutes, which does not fit inside Cloud Run's 300s request cap —
4450
+ # that is what made the fallback walk unusable on 2026-09-28.
4451
+ #
4452
+ # `lowestPrice` comes back null for a GPU with no stock, so this is a real availability
4453
+ # signal and not just a price list. Read-only: it rents nothing.
4454
+ async def _avail():
4455
+ types = await rp.gpu_prices()
4456
+ if not types:
4457
+ print("no answer from the provider (credential missing or unreachable)",
4458
+ file=sys.stderr)
4459
+ return 1
4460
+ rows = []
4461
+ for t in types:
4462
+ lp = t.get("lowestPrice") or {}
4463
+ rows.append((
4464
+ t.get("displayName") or t.get("id"),
4465
+ t.get("memoryInGb"),
4466
+ t.get("securePrice"),
4467
+ t.get("communityPrice"),
4468
+ lp.get("uninterruptablePrice"),
4469
+ lp.get("minimumBidPrice"),
4470
+ ))
4471
+ rows.sort(key=lambda r: (r[4] is None, r[4] if r[4] is not None else 9e9))
4472
+ print(f"{'gpu':<28}{'vram':>6}{'secure':>9}{'comm':>9}"
4473
+ f"{'on-demand':>11}{'spot':>9} available")
4474
+ for name, vram, sec, comm, od, bid in rows:
4475
+ avail = "YES" if (od or bid) else "no stock"
4476
+ def f(x): return "-" if x is None else f"{x:g}"
4477
+ print(f"{str(name):<28}{str(vram or '-'):>6}{f(sec):>9}{f(comm):>9}"
4478
+ f"{f(od):>11}{f(bid):>9} {avail}")
4479
+ return 0
4480
+ return _a.run(_avail())
4481
+
4482
+ print("usage: python -m frutebox.gpuproviders "
4483
+ "<list|availability|stop-label LABEL [PROVIDER]|terminate POD_ID|stop-all [--everything]>",
4484
+ file=sys.stderr)
4485
+ return 2
4486
+
4487
+
4488
+ if __name__ == "__main__":
4489
+ raise SystemExit(_cli())