From 31f7b58c2aa1ee5f18350586cb46d6ff730d95bd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 09:29:42 +0000 Subject: [PATCH 1/2] fix(prism-lium): abort rent when rent_gpu_count > 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refuse POST /rent when rent_count exceeds the requested width so an 8× B200 NCU/whole-host upsell cannot run on a 1× Proof harvest pin. Co-authored-by: Mathis --- crates/prism-lium-types/src/types.rs | 6 +-- crates/prism-lium/src/client.rs | 65 ++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/crates/prism-lium-types/src/types.rs b/crates/prism-lium-types/src/types.rs index 85a24b4f8..b992c62c6 100644 --- a/crates/prism-lium-types/src/types.rs +++ b/crates/prism-lium-types/src/types.rs @@ -184,9 +184,9 @@ impl Offer { /// /// Accepts an exact-width host, a larger host when `requested > 1`, or a /// multi-GPU host that advertises (or omits-min) GPU splitting. A 1-GPU - /// pin never takes a non-split 8× pack — except NCU, which is whole-host - /// only and is the live B200 champion path. 8×5090 is never a silent - /// fallback. + /// pin never takes a non-split 8× pack. NCU is whole-host only, so + /// `rent_count` may exceed the pin; the client aborts before POST when + /// that happens. 8×5090 is never a silent fallback. #[must_use] pub fn matches_gpu_count(&self, requested: u32) -> bool { if self.allows_split_for(requested) { diff --git a/crates/prism-lium/src/client.rs b/crates/prism-lium/src/client.rs index 30febc1b6..501b50f2d 100644 --- a/crates/prism-lium/src/client.rs +++ b/crates/prism-lium/src/client.rs @@ -815,9 +815,12 @@ impl EvalJobBackend for LiumClient { prism_lium_types::effective_gpu_count(selected.gpu_count, &selected.gpu_type); // Split hosts: requested width. NCU / non-split: whole host. let rent_gpu_count = selected.rent_count(spec.gpu_count); - if pref.matches_pin("RTX 5090") && rent_gpu_count >= 8 && spec.gpu_count < 8 { + // Proof harvest default is 1× (`HarvestLimits.gpu_count`). Never POST + // an 8× B200 whole-host / NCU upsell, and keep the 5090 8× abort. + if rent_gpu_count > spec.gpu_count { return Err(LiumError::Api(format!( - "abort: refusing {rent_gpu_count}× 5090 rent (no 8×5090 fallback)" + "abort: refusing {rent_gpu_count}× {} rent (requested {}; no whole-host upsell)", + selected.gpu_type, spec.gpu_count ))); } loop { @@ -1557,7 +1560,7 @@ mod tests { } #[tokio::test] - async fn provision_rents_whole_host_on_ncu_2x_b200() { + async fn provision_refuses_ncu_2x_b200_when_requesting_one() { let server = MockServer::start().await; mount_common( &server, @@ -1578,21 +1581,53 @@ mod tests { .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "pod-ncu"})), ) + .expect(0) .mount(&server) .await; - Mock::given(method("GET")) - .and(path("/pods/pod-ncu")) + let c = LiumClient::with_base_url("test-key", server.uri()).unwrap(); + // HarvestLimits.gpu_count stays 1; rent_count would upsize NCU to 2. + let err = c.provision(&provision_spec()).await.unwrap_err(); + assert!( + err.to_string().contains("abort:") && err.to_string().contains("requested 1"), + "got {err}" + ); + assert_eq!(provision_spec().gpu_count, 1); + } + + #[tokio::test] + async fn provision_refuses_8x_b200_when_requesting_one() { + let server = MockServer::start().await; + mount_common( + &server, + serde_json::json!([{ + "id": "eight-b200", + "machine_name": "NVIDIA B200", + "gpu_count": 8, + "available_gpu_count": 8, + "min_gpu_count_for_rental": 1, + "ncu_profiling_enabled": true, + "price_per_gpu": 6.52 + }]), + ) + .await; + Mock::given(method("POST")) + .and(path("/executors/eight-b200/rent")) + .and(body_partial_json(serde_json::json!({"gpu_count": 8}))) .respond_with( - ResponseTemplate::new(200) - .set_body_json(serde_json::json!({"id": "pod-ncu", "status": "RUNNING"})), + ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "pod-8x"})), ) + .expect(0) .mount(&server) .await; let c = LiumClient::with_base_url("test-key", server.uri()).unwrap(); - // HarvestLimits.gpu_count stays 1; rent_count upsizes NCU to 2. - let inst = c.provision(&provision_spec()).await.unwrap(); - assert_eq!(inst.id, "pod-ncu"); assert_eq!(provision_spec().gpu_count, 1); + let err = c.provision(&provision_spec()).await.unwrap_err(); + assert!( + err.to_string().contains("abort:") + && err.to_string().contains("requested 1") + && (err.to_string().contains("8×") || err.to_string().contains("8x")), + "got {err}" + ); } #[tokio::test] @@ -1683,9 +1718,13 @@ mod tests { spec.gpu_count = 4; let err = c.provision(&spec).await.unwrap_err(); assert!( - err.to_string().contains("8×5090") - || err.to_string().contains("8x5090") - || err.to_string().contains("no 8"), + err.to_string().contains("abort:") + && err.to_string().contains("requested 4") + && (err.to_string().contains("8×") + || err.to_string().contains("8x") + || err.to_string().contains("8×5090") + || err.to_string().contains("8x5090") + || err.to_string().contains("no 8")), "got {err}" ); } From 525e8af70ac45edde05dfa69da5b926f547104f0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 09:38:21 +0000 Subject: [PATCH 2/2] fix(prism-lium): skip oversize rent, keep later 1x offers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Do not POST rent_gpu_count > requested, but continue the candidate loop so a later split 8× B200 can still rent 1×. Co-authored-by: Mathis --- crates/prism-lium-types/src/types.rs | 4 +- crates/prism-lium/src/client.rs | 73 ++++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/crates/prism-lium-types/src/types.rs b/crates/prism-lium-types/src/types.rs index b992c62c6..c9487febd 100644 --- a/crates/prism-lium-types/src/types.rs +++ b/crates/prism-lium-types/src/types.rs @@ -185,8 +185,8 @@ impl Offer { /// Accepts an exact-width host, a larger host when `requested > 1`, or a /// multi-GPU host that advertises (or omits-min) GPU splitting. A 1-GPU /// pin never takes a non-split 8× pack. NCU is whole-host only, so - /// `rent_count` may exceed the pin; the client aborts before POST when - /// that happens. 8×5090 is never a silent fallback. + /// `rent_count` may exceed the pin; the client skips that offer before + /// POST and tries later split-capable rows. 8×5090 is never posted. #[must_use] pub fn matches_gpu_count(&self, requested: u32) -> bool { if self.allows_split_for(requested) { diff --git a/crates/prism-lium/src/client.rs b/crates/prism-lium/src/client.rs index 501b50f2d..4a0c125b5 100644 --- a/crates/prism-lium/src/client.rs +++ b/crates/prism-lium/src/client.rs @@ -815,13 +815,14 @@ impl EvalJobBackend for LiumClient { prism_lium_types::effective_gpu_count(selected.gpu_count, &selected.gpu_type); // Split hosts: requested width. NCU / non-split: whole host. let rent_gpu_count = selected.rent_count(spec.gpu_count); - // Proof harvest default is 1× (`HarvestLimits.gpu_count`). Never POST - // an 8× B200 whole-host / NCU upsell, and keep the 5090 8× abort. + // Never POST more GPUs than requested (Proof harvest default 1×). + // Skip this candidate — a later split 8× B200 can still rent 1×. if rent_gpu_count > spec.gpu_count { - return Err(LiumError::Api(format!( + last_err = format!( "abort: refusing {rent_gpu_count}× {} rent (requested {}; no whole-host upsell)", selected.gpu_type, spec.gpu_count - ))); + ); + continue 'offers; } loop { info!( @@ -1630,6 +1631,70 @@ mod tests { ); } + #[tokio::test] + async fn provision_skips_ncu_upsell_and_rents_split_1x() { + let server = MockServer::start().await; + mount_common( + &server, + serde_json::json!([ + { + "id": "ncu-2x", + "machine_name": "NVIDIA B200", + "gpu_count": 2, + "available_gpu_count": 2, + "min_gpu_count_for_rental": 1, + "ncu_profiling_enabled": true, + "price_per_gpu": 5.5 + }, + { + "id": "eight-b200-idle", + "machine_name": "NVIDIA B200", + "gpu_count": 8, + "available_gpu_count": 8, + "price_per_gpu": 6.52 + } + ]), + ) + .await; + Mock::given(method("POST")) + .and(path("/executors/ncu-2x/rent")) + .and(body_partial_json(serde_json::json!({"gpu_count": 2}))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "pod-ncu"})), + ) + .expect(0) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/executors/eight-b200-idle/rent")) + .and(body_partial_json(serde_json::json!({"gpu_count": 8}))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "pod-8x"})), + ) + .expect(0) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/executors/eight-b200-idle/rent")) + .and(body_partial_json(serde_json::json!({"gpu_count": 1}))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "pod-split-1"})), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/pods/pod-split-1")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"id": "pod-split-1", "status": "RUNNING"})), + ) + .mount(&server) + .await; + let c = LiumClient::with_base_url("test-key", server.uri()).unwrap(); + let inst = c.provision(&provision_spec()).await.unwrap(); + assert_eq!(inst.id, "pod-split-1"); + } + #[tokio::test] async fn provision_ncu_over_price_is_named_error_not_split() { let server = MockServer::start().await;