diff --git a/README.md b/README.md index d14fa8f..c99ede7 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,46 @@ Alternatively to build from source, clone this repo then inside the project's ba pip install . ``` +### Authentication + +For interactive use, authenticate once with the CLI. SDK examples reuse the stored +credentials and refresh them when needed. + +```bash +centml login +python examples/sdk/validate_auth.py +``` + +For service-to-service use, provide both service-account environment variables: + +```bash +export CENTML_SERVICE_ACCOUNT_ID="" +export CENTML_SERVICE_ACCOUNT_SECRET="" +python examples/sdk/validate_auth.py +``` + +`CENTML_PLATFORM_API_URL` can be set when targeting a non-production API. + +### Dynamo SDK example + +The Dynamo example uses SDK authentication separately from the bearer token that +protects the deployed inference endpoint: + +```bash +export CENTML_CLUSTER_ID="" +export CENTML_HARDWARE_INSTANCE_ID="" +export CENTML_ENDPOINT_BEARER_TOKEN="" +# Required only for gated Hugging Face models: +export HF_TOKEN="" + +python examples/sdk/create_dynamo.py +``` + +Use `python examples/sdk/get_clusters.py` and +`python examples/sdk/manage_hardware_instances.py` to discover the required IDs. +Creating the example reserves GPU capacity and may incur usage charges. It does not +delete the deployment automatically. + ### Un-installation To uninstall `centml`, simply do: diff --git a/centml/sdk/api.py b/centml/sdk/api.py index 4fe0ecc..493a750 100644 --- a/centml/sdk/api.py +++ b/centml/sdk/api.py @@ -7,6 +7,7 @@ CreateInferenceV3DeploymentRequest, CreateComputeDeploymentRequest, CreateCServeV3DeploymentRequest, + CreateDynamoDeploymentRequest, CreateJobDeploymentRequest, CreateHardwareInstanceRequest, ApiException, @@ -80,6 +81,9 @@ def get_cserve(self, id): # For other errors (auth, network, etc.), raise immediately raise + def get_dynamo(self, id): + return self._api.get_dynamo_deployment_deployments_dynamo_deployment_id_get(id) + def create_inference(self, request: CreateInferenceV3DeploymentRequest): return self._api.create_inference_v3_deployment_deployments_inference_v3_post(request) @@ -92,6 +96,9 @@ def create_job(self, request: CreateJobDeploymentRequest): def create_cserve(self, request: CreateCServeV3DeploymentRequest): return self._api.create_cserve_v3_deployment_deployments_cserve_v3_post(request) + def create_dynamo(self, request: CreateDynamoDeploymentRequest): + return self._api.create_dynamo_deployment_deployments_dynamo_post(request) + def update_inference(self, deployment_id: int, request: CreateInferenceV3DeploymentRequest): return self._api.update_inference_v3_deployment_deployments_inference_v3_put(deployment_id, request) @@ -101,6 +108,9 @@ def update_compute(self, deployment_id: int, request: CreateComputeDeploymentReq def update_cserve(self, deployment_id: int, request: CreateCServeV3DeploymentRequest): return self._api.update_cserve_v3_deployment_deployments_cserve_v3_put(deployment_id, request) + def update_dynamo(self, deployment_id: int, request: CreateDynamoDeploymentRequest): + return self._api.update_dynamo_deployment_deployments_dynamo_put(deployment_id, request) + def _update_status(self, id, new_status): status_req = platform_api_python_client.DeploymentStatusRequest(status=new_status) self._api.update_deployment_status_deployments_status_deployment_id_put(id, status_req) diff --git a/examples/sdk/create_dynamo.py b/examples/sdk/create_dynamo.py new file mode 100644 index 0000000..765dd43 --- /dev/null +++ b/examples/sdk/create_dynamo.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Create and inspect a fixed-replica NVIDIA Dynamo deployment. + +Run `centml login` or configure service-account credentials first. This example +creates a billable GPU deployment and intentionally leaves cleanup to the user. +""" + +import os + +from centml.sdk import CreateDynamoDeploymentRequest +from centml.sdk.api import get_centml_client + + +def required_env(name: str) -> str: + value = os.getenv(name) + if not value: + raise SystemExit(f"Set {name} before running this example.") + return value + + +def build_request() -> CreateDynamoDeploymentRequest: + hf_token = os.getenv("HF_TOKEN") + return CreateDynamoDeploymentRequest( + name=os.getenv("CENTML_DEPLOYMENT_NAME", "qwen-dynamo"), + cluster_id=int(required_env("CENTML_CLUSTER_ID")), + hardware_instance_id=int(required_env("CENTML_HARDWARE_INSTANCE_ID")), + model=os.getenv("DYNAMO_MODEL", "Qwen/Qwen3-0.6B"), + min_replicas=1, + max_replicas=1, + hf_token=hf_token or None, + endpoint_bearer_token=required_env("CENTML_ENDPOINT_BEARER_TOKEN"), + ) + + +def validate_target(client, request: CreateDynamoDeploymentRequest) -> None: + clusters = {cluster.id for cluster in client.get_clusters().results} + if request.cluster_id not in clusters: + raise SystemExit(f"Cluster {request.cluster_id} is not accessible to the authenticated identity.") + + hardware_instances = {hardware.id for hardware in client.get_hardware_instances(cluster_id=request.cluster_id)} + if request.hardware_instance_id not in hardware_instances: + raise SystemExit( + f"Hardware instance {request.hardware_instance_id} is not available in cluster {request.cluster_id}." + ) + + +def main(): + request = build_request() + + with get_centml_client() as client: + validate_target(client, request) + + response = client.create_dynamo(request) + deployment = client.get_dynamo(response.id) + + # Lifecycle helpers are type-independent: + # client.pause(deployment.id) + # client.resume(deployment.id) + # client.delete(deployment.id) + # + # To update a Dynamo deployment, construct a new + # CreateDynamoDeploymentRequest and call: + # client.update_dynamo(deployment.id, updated_request) + + print(f"Created Dynamo deployment {deployment.id}: {deployment.endpoint_url}") + print(f"Model: {deployment.model}") + print(f"Replicas: {deployment.min_replicas}-{deployment.max_replicas}") + print(f"Status: {deployment.status.value}") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk/manage_hardware_instances.py b/examples/sdk/manage_hardware_instances.py index 3ba57ff..3d159ff 100644 --- a/examples/sdk/manage_hardware_instances.py +++ b/examples/sdk/manage_hardware_instances.py @@ -29,6 +29,7 @@ def list_hardware_instances(): for hw in sorted(instances, key=lambda x: x.id): cluster = clusters.get(hw.cluster_id) cluster_name = cluster.display_name if cluster else f"cluster {hw.cluster_id}" + print(f"ID: {hw.id}") print(f"Name: {hw.name}") print(f"Cluster: {cluster_name}") print(f"GPU Type: {hw.gpu_type}") diff --git a/examples/sdk/validate_auth.py b/examples/sdk/validate_auth.py new file mode 100644 index 0000000..ec8b619 --- /dev/null +++ b/examples/sdk/validate_auth.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Validate CentML SDK authentication with a read-only API request.""" + +from centml.sdk import ApiException +from centml.sdk.api import get_centml_client + + +def validate_auth(): + """Return accessible clusters after validating the configured credentials.""" + with get_centml_client() as client: + return client.get_clusters().results + + +def main(): + try: + clusters = validate_auth() + except SystemExit as error: + raise SystemExit( + "SDK authentication failed. Run `centml login` or set both " + "CENTML_SERVICE_ACCOUNT_ID and CENTML_SERVICE_ACCOUNT_SECRET." + ) from error + except ApiException as error: + if error.status in (401, 403): + raise SystemExit( + "The Platform API rejected the SDK credentials. Run `centml login` " + "or verify the service-account credentials and API URL." + ) from error + raise + + print("SDK authentication succeeded.") + print(f"Accessible clusters: {len(clusters)}") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index 5889979..123f832 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,5 @@ pyjwt>=2.8.0 cryptography==48.0.1 websockets>=16.0 pyte>=0.8.0 -platform-api-python-client==4.15.0 +platform-api-python-client==4.23.1 click>=8.4.1 diff --git a/tests/test_sdk_api.py b/tests/test_sdk_api.py index 97fcda3..1e2a642 100644 --- a/tests/test_sdk_api.py +++ b/tests/test_sdk_api.py @@ -1,10 +1,17 @@ from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch -from platform_api_python_client import CreateJobDeploymentRequest, CreateHardwareInstanceRequest +import platform_api_python_client +from platform_api_python_client import ( + CreateDynamoDeploymentRequest, + CreateHardwareInstanceRequest, + CreateJobDeploymentRequest, + DeploymentType, +) from centml.sdk import ApiException -from centml.sdk.api import CentMLClient +from centml.sdk.api import CentMLClient, get_centml_client +from centml.sdk.config import settings def test_get_status_uses_v3_endpoint(): @@ -74,6 +81,89 @@ def test_create_job_delegates_to_platform_client(): api.create_job_deployment_deployments_job_post.assert_called_once_with(request) +def _dynamo_request(): + return CreateDynamoDeploymentRequest( + name="test-dynamo", + cluster_id=1, + hardware_instance_id=2, + model="Qwen/Qwen3-0.6B", + endpoint_bearer_token="test-only", + ) + + +def test_generated_client_exposes_dynamo_contract(): + assert DeploymentType.DYNAMO.value == "dynamo" + assert hasattr(platform_api_python_client.EXTERNALApi, "get_dynamo_deployment_deployments_dynamo_deployment_id_get") + assert hasattr(platform_api_python_client.EXTERNALApi, "create_dynamo_deployment_deployments_dynamo_post") + assert hasattr(platform_api_python_client.EXTERNALApi, "update_dynamo_deployment_deployments_dynamo_put") + + +def test_get_dynamo_delegates_to_platform_client(): + api = MagicMock() + expected_response = MagicMock() + api.get_dynamo_deployment_deployments_dynamo_deployment_id_get.return_value = expected_response + client = CentMLClient(api) + + response = client.get_dynamo(123) + + assert response is expected_response + api.get_dynamo_deployment_deployments_dynamo_deployment_id_get.assert_called_once_with(123) + + +def test_create_dynamo_delegates_to_platform_client(): + api = MagicMock() + expected_response = MagicMock() + api.create_dynamo_deployment_deployments_dynamo_post.return_value = expected_response + request = _dynamo_request() + client = CentMLClient(api) + + response = client.create_dynamo(request) + + assert response is expected_response + api.create_dynamo_deployment_deployments_dynamo_post.assert_called_once_with(request) + + +def test_update_dynamo_delegates_to_platform_client(): + api = MagicMock() + expected_response = MagicMock() + api.update_dynamo_deployment_deployments_dynamo_put.return_value = expected_response + request = _dynamo_request() + client = CentMLClient(api) + + response = client.update_dynamo(123, request) + + assert response is expected_response + api.update_dynamo_deployment_deployments_dynamo_put.assert_called_once_with(123, request) + + +def test_get_centml_client_uses_authenticated_generated_client(): + configuration = MagicMock() + api_client_context = MagicMock() + generated_api_client = MagicMock() + generated_external_api = MagicMock() + expected_clusters = MagicMock() + generated_external_api.get_clusters_clusters_get.return_value = expected_clusters + api_client_context.__enter__.return_value = generated_api_client + + with ( + patch("centml.sdk.api.auth.get_centml_token", return_value="test-access-token") as get_token, + patch( + "centml.sdk.api.platform_api_python_client.Configuration", return_value=configuration + ) as configuration_cls, + patch("centml.sdk.api.platform_api_python_client.ApiClient", return_value=api_client_context) as api_client_cls, + patch( + "centml.sdk.api.platform_api_python_client.EXTERNALApi", return_value=generated_external_api + ) as external_api_cls, + ): + with get_centml_client() as client: + assert client.get_clusters() is expected_clusters + + get_token.assert_called_once_with() + configuration_cls.assert_called_once_with(host=settings.CENTML_PLATFORM_API_URL, access_token="test-access-token") + api_client_cls.assert_called_once_with(configuration) + external_api_cls.assert_called_once_with(generated_api_client) + + def test_get_hardware_instances_returns_results(): api = MagicMock() expected_results = [SimpleNamespace(id=1), SimpleNamespace(id=2)]