Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<service-account-id>"
export CENTML_SERVICE_ACCOUNT_SECRET="<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="<cluster-id>"
export CENTML_HARDWARE_INSTANCE_ID="<hardware-instance-id>"
export CENTML_ENDPOINT_BEARER_TOKEN="<new-endpoint-token>"
# Required only for gated Hugging Face models:
export HF_TOKEN="<hugging-face-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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: also here too for billing

delete the deployment automatically.

### Un-installation

To uninstall `centml`, simply do:
Expand Down
10 changes: 10 additions & 0 deletions centml/sdk/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
CreateInferenceV3DeploymentRequest,
CreateComputeDeploymentRequest,
CreateCServeV3DeploymentRequest,
CreateDynamoDeploymentRequest,
CreateJobDeploymentRequest,
CreateHardwareInstanceRequest,
ApiException,
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand All @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions examples/sdk/create_dynamo.py
Original file line number Diff line number Diff line change
@@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: you don't need to mention billable since we don't charge credit. maybe more that it's active

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. agree

"""

import os

from centml.sdk import CreateDynamoDeploymentRequest
from centml.sdk.api import get_centml_client


def required_env(name: str) -> str:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a future TODO to make this function shared so we can enforce better env var checks

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make sense. for now as an example script this should be fine (allowing doc-reader to copy and paste from single file).

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()
1 change: 1 addition & 0 deletions examples/sdk/manage_hardware_instances.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
35 changes: 35 additions & 0 deletions examples/sdk/validate_auth.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
96 changes: 93 additions & 3 deletions tests/test_sdk_api.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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)]
Expand Down
Loading