diff --git a/src/base/compute/lium.py b/src/base/compute/lium.py index 121945a60..2d7644f41 100644 --- a/src/base/compute/lium.py +++ b/src/base/compute/lium.py @@ -175,12 +175,18 @@ async def provision( ) template_id = await self._resolve_template(spec) + # InstanceSpec.gpu_count is a minimum-need filter for offer selection. + # Sending it as the rent gpu_count requests a *partial* split; Lium + # returns HTTP 400 "Provider doesn't allow GPU splitting" on most + # multi-GPU executors. Rent the full selected offer capacity instead + # (omit the field only when the offer itself has no gpu_count). rent_body: dict[str, Any] = { "pod_name": spec.name, "user_public_key": list(spec.ssh_public_keys), "termination_hours": int(lifetime), - "gpu_count": spec.gpu_count, } + if selected.gpu_count and selected.gpu_count > 0: + rent_body["gpu_count"] = selected.gpu_count if template_id is not None: rent_body["template_id"] = template_id if spec.dockerfile_content is not None: diff --git a/tests/unit/test_compute_lium_client.py b/tests/unit/test_compute_lium_client.py index 60a32d064..0188c069c 100644 --- a/tests/unit/test_compute_lium_client.py +++ b/tests/unit/test_compute_lium_client.py @@ -198,6 +198,30 @@ async def test_provision_sends_termination_hours_and_ssh_key() -> None: assert body["template_id"] == "tpl-1" +@respx.mock +async def test_provision_rents_full_offer_gpu_count_not_spec_minimum() -> None: + """Lium rejects partial GPU rents on non-splittable executors. + + Live API evidence (2026-07): POST .../rent with gpu_count=1 on an 8-GPU + machine returns HTTP 400 "Provider doesn't allow GPU splitting.". + ``InstanceSpec.gpu_count`` is a minimum-need filter; the rent body must + request the selected offering's full gpu_count (or omit the field). + """ + routes = _mock_happy_path() + multi = Offer( + id="exec-1", + gpu_type="RTX A4000", + gpu_count=8, + price_per_hour=0.12, + ) + await LiumClient("k").provision(_spec(gpu_count=1), offer=multi) + body = json.loads(routes["rent"].calls.last.request.content) + assert body.get("gpu_count") != 1 + assert body.get("gpu_count") in (8, None) + if "gpu_count" in body: + assert body["gpu_count"] == multi.gpu_count + + @respx.mock async def test_provision_rejects_overpriced_offer_without_rent() -> None: rent = respx.post(f"{BASE}/executors/exec-1/rent") diff --git a/tests/unit/test_master_weights_sealer.py b/tests/unit/test_master_weights_sealer.py index 00c8ac489..d2ea2334a 100644 --- a/tests/unit/test_master_weights_sealer.py +++ b/tests/unit/test_master_weights_sealer.py @@ -320,10 +320,14 @@ async def test_zero_miner_empty_slate_still_seals( async def test_concurrent_get_seal_race_safe( session_factory: async_sessionmaker, ) -> None: + # MasterWeightsResponse.expires_at is validated against wall-clock now() + # (not FakeClock). freshness_seconds=1 flakes under concurrent DB load on + # CI when seal latency > 1s. Keep TTL large vs wall time, expire via clock. clock = FakeClock() + freshness = 120 service = _service( session_factory, - freshness_seconds=1, + freshness_seconds=freshness, epoch_interval_seconds=60, clock=clock, ) @@ -350,7 +354,7 @@ async def one_get() -> Any: assert all(r.uids == [0] and r.weights == [1.0] for r in results) # Expire + concurrent heal reseals once. - clock.advance(5) + clock.advance(freshness + 5) healed = await asyncio.gather(*[one_get() for _ in range(6)]) healed_ids = {r.vector_id for r in healed} assert len(healed_ids) == 1