diff --git a/crates/basilica-sdk-python/python/basilica/rl.py b/crates/basilica-sdk-python/python/basilica/rl.py index eef75dbc2..a485e87d0 100644 --- a/crates/basilica-sdk-python/python/basilica/rl.py +++ b/crates/basilica-sdk-python/python/basilica/rl.py @@ -110,6 +110,18 @@ def fleet(count: int) -> dict: def get_cluster(self, name: str) -> dict: return json.loads(self._core.rl_get_cluster(name)) + def delete_cluster(self, name: str) -> dict: + """Delete a cluster. Refused (ValueError) while a job is active — + the error names the blocking job; delete the job first (that IS the + cancel path). Deleting the namespace's last cluster also tears down + its RL prerequisites server-side.""" + return json.loads(self._core.rl_delete_cluster(name)) + + def delete_job(self, name: str) -> dict: + """Delete a job — valid in any phase. Deleting a running job IS the + cancel path (pods are torn down by the operator's stop ladder).""" + return json.loads(self._core.rl_delete_job(name)) + def wait_cluster( self, name: str, timeout_s: float = 1800.0, poll_s: float = 15.0 ) -> dict: diff --git a/crates/basilica-sdk-python/src/lib.rs b/crates/basilica-sdk-python/src/lib.rs index 49a00e5c2..a48d3f703 100644 --- a/crates/basilica-sdk-python/src/lib.rs +++ b/crates/basilica-sdk-python/src/lib.rs @@ -124,6 +124,32 @@ impl BasilicaClient { serde_json::to_string(&response).map_err(|e| PyRuntimeError::new_err(e.to_string())) } + /// Delete an RL cluster (refused with an actionable error while a job + /// is active — delete the job first; that is the cancel path). + fn rl_delete_cluster(&self, py: Python, name: String) -> PyResult { + let client = Arc::clone(&self.inner); + let response = py + .detach(|| { + self.runtime + .block_on(async move { client.delete_rl_cluster(&name).await }) + }) + .map_err(|e| self.map_error_to_python(e))?; + serde_json::to_string(&response).map_err(|e| PyRuntimeError::new_err(e.to_string())) + } + + /// Delete an RL job — valid in any phase; deleting a running job IS the + /// cancel path. + fn rl_delete_job(&self, py: Python, name: String) -> PyResult { + let client = Arc::clone(&self.inner); + let response = py + .detach(|| { + self.runtime + .block_on(async move { client.delete_rl_job(&name).await }) + }) + .map_err(|e| self.map_error_to_python(e))?; + serde_json::to_string(&response).map_err(|e| PyRuntimeError::new_err(e.to_string())) + } + /// Get an RL cluster's status (phase, modelLoaded, activeJobName). fn rl_get_cluster(&self, py: Python, name: String) -> PyResult { let client = Arc::clone(&self.inner); diff --git a/crates/basilica-sdk/src/client.rs b/crates/basilica-sdk/src/client.rs index 9793afda2..4987313f6 100644 --- a/crates/basilica-sdk/src/client.rs +++ b/crates/basilica-sdk/src/client.rs @@ -44,7 +44,8 @@ use crate::{ }, rl::{ CreateRlClusterRequest, CreateRlClusterResponse, CreateRlJobRequest, CreateRlJobResponse, - RlClusterStatusResponse, RlJobStatusResponse, RlManifestRequest, RlManifestResponse, + DeleteRlClusterResponse, DeleteRlJobResponse, RlClusterStatusResponse, RlJobStatusResponse, + RlManifestRequest, RlManifestResponse, }, types::{ ApiKeyInfo, ApiKeyResponse, ApiListRentalsResponse, BalanceResponse, CardPurchaseResponse, @@ -383,6 +384,22 @@ impl BasilicaClient { self.get(&format!("/rl/jobs/{}", name)).await } + /// Delete a cluster. Refused with an actionable 400 while a job is + /// active (the error names the blocking job) — delete the job first; + /// that IS the cancel path. Deleting the namespace's last cluster also + /// tears down its RL prerequisites server-side. + pub async fn delete_rl_cluster(&self, name: &str) -> Result { + Self::validate_rl_name(name)?; + self.delete(&format!("/rl/clusters/{}", name)).await + } + + /// Delete a job — valid in any phase. Deleting a running job IS the + /// cancel path: the operator's stop ladder owns pod teardown. + pub async fn delete_rl_job(&self, name: &str) -> Result { + Self::validate_rl_name(name)?; + self.delete(&format!("/rl/jobs/{}", name)).await + } + /// Submit a declarative manifest (renders a cluster and/or a job). pub async fn submit_rl_manifest( &self, diff --git a/crates/basilica-sdk/src/rl.rs b/crates/basilica-sdk/src/rl.rs index bdd616db3..98711c1c1 100644 --- a/crates/basilica-sdk/src/rl.rs +++ b/crates/basilica-sdk/src/rl.rs @@ -248,6 +248,22 @@ pub struct RlManifestResponse { pub job: Option, } +/// Response after deleting a cluster (`DELETE /rl/clusters/{name}`). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteRlClusterResponse { + /// The deleted cluster's name. + pub name: String, +} + +/// Response after deleting a job (`DELETE /rl/jobs/{name}`). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteRlJobResponse { + /// The deleted job's name. + pub name: String, +} + #[cfg(test)] mod tests { use super::*; @@ -296,6 +312,17 @@ mod tests { assert!(v.get("lr").is_none()); } + #[test] + fn delete_response_wire_shape() { + // The server serializes camelCase; both delete responses carry only + // `name`. Pin the deserialization so a server-side field rename is + // caught here, not by a user. + let c: DeleteRlClusterResponse = serde_json::from_str(r#"{"name":"my-pool"}"#).unwrap(); + assert_eq!(c.name, "my-pool"); + let j: DeleteRlJobResponse = serde_json::from_str(r#"{"name":"my-pool-job"}"#).unwrap(); + assert_eq!(j.name, "my-pool-job"); + } + #[test] fn cluster_request_wire_shape() { let req = CreateRlClusterRequest {