diff --git a/crates/prism-lium-types/src/types.rs b/crates/prism-lium-types/src/types.rs index 85a24b4f8..c9487febd 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 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 30febc1b6..4a0c125b5 100644 --- a/crates/prism-lium/src/client.rs +++ b/crates/prism-lium/src/client.rs @@ -815,10 +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); - if pref.matches_pin("RTX 5090") && rent_gpu_count >= 8 && spec.gpu_count < 8 { - return Err(LiumError::Api(format!( - "abort: refusing {rent_gpu_count}× 5090 rent (no 8×5090 fallback)" - ))); + // 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 { + last_err = format!( + "abort: refusing {rent_gpu_count}× {} rent (requested {}; no whole-host upsell)", + selected.gpu_type, spec.gpu_count + ); + continue 'offers; } loop { info!( @@ -1557,7 +1561,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 +1582,117 @@ mod tests { .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "pod-ncu"})), ) + .expect(0) + .mount(&server) + .await; + 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-8x"})), + ) + .expect(0) + .mount(&server) + .await; + let c = LiumClient::with_base_url("test-key", server.uri()).unwrap(); + 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] + 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-ncu")) + .and(path("/pods/pod-split-1")) .respond_with( ResponseTemplate::new(200) - .set_body_json(serde_json::json!({"id": "pod-ncu", "status": "RUNNING"})), + .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(); - // 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); + assert_eq!(inst.id, "pod-split-1"); } #[tokio::test] @@ -1683,9 +1783,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}" ); }