Skip to main content

Model Studio SDK

Typed Python client for the Model Studio REST API.

pip install modelstudio-sdk          # or: pip install 'modelstudio-sdk[pandas]'
from modelstudio import ModelStudioClient

client = ModelStudioClient.from_env()
project = client.project("<project-uuid>")

for d in project.datasets():                 # DatasetModel records
    print(d.name, d.version, d.has_uncommitted_changes)

Configuration

from_env() reads:

Variable Required Purpose
MODEL_STUDIO_API_URL yes API root, e.g. https://model-studio-api.elements.dev.privateer.com
MODEL_STUDIO_JWT in practice Keycloak bearer token
MODEL_STUDIO_USER_ID no Dev-mode X-User-Id for an API running without OIDC

Your organization comes from the JWT, by token introspection. There is no organization header or parameter, and a token carrying no organization gets a 403.

eval "$(scripts/get-token.sh)"     # fetches a JWT and exports both variables

Four things that will surprise you

The API changed shape in ways that no amount of guessing will recover. In rough order of how often they bite:

1. Everything is addressed through its parent

There is no flat route to any project-scoped resource — no /api/v1/datasets/{id}, and no lookup that resolves a dataset from its id alone.

ds = client.project(project_id).dataset(dataset_id)
ds = client.dataset(project_id, dataset_id)      # identical shorthand

A wrong project_id returns 404, not 403 — org scoping is enforced by making a resource you cannot see indistinguishable from one that does not exist.

2. A dataset needs an ontology before it can exist

Categories live in versioned ontologies, not on datasets. A dataset pins exactly one ontology version, and that version's dense 1..N ordinals are the class index used by annotations, mask runs and exported COCO alike.

lineage = project.ontologies.list(in_use=False)[0]
dataset_model = project.create_dataset(
    name="harbor-train",
    ontology_version_id=lineage.ontology_version_id,   # required
)
ds = project.dataset(str(dataset_model.id))            # the resource you call methods on

list() defaults to in_use=True, which hides lineages with no pinned datasets — including one you just created. Pass in_use=False for a create-then-pick flow. Every new project gets a Default lineage, so there is always something to pin.

Category ids are per-version: every edit except a colour change mints a new version with fresh ids. Re-read after any edit rather than caching them.

3. Training reads a commit, not the dataset

Datasets are never locked — they stay editable forever. Immutability lives in commits, which are git-style snapshots that a run pins.

commit = ds.commits.create(message="rebalanced train/val")   # blocks until READY
experiment = project.experiment(exp_id)
run_model = experiment.runs.create(CreateRunRequest(
    name="baseline",
    model_architecture="faster-rcnn",
    dataset_id=ds.dataset_id,
    dataset_commit_id=commit.id,        # required before submit
    spec={"train": {"num_epochs": 50}},
))
run = experiment.run(str(run_model.id))
run.submit()

submit() raises ValidationError until a READY commit is pinned. There is no auto-commit. Only one commit may be BUILDING at a time, and while one is it blocks every mutation on that dataset.

4. Most mutations are asynchronous

Roughly nineteen whole-dataset mutations return 202 Accepted and a queued operation. The SDK blocks by default and hands back the result:

summary = ds.filter(request)                 # blocks, returns result_summary

op = ds.filter(request, wait=False)          # or drive it yourself
op.status      # QUEUED / RUNNING / SUCCEEDED / FAILED / CANCELED
op.progress    # 0-100
op.wait(timeout=600)
op.cancel()
op.retry()                                   # one retry per operation

Only one non-import operation may be active per dataset. A second one raises OperationConflictError, which names the operation already in flight:

try:
    ds.redistribute({"train": 0.8, "val": 0.2})
except OperationConflictError as exc:
    ds.operation(exc.op_id).wait()           # poll the blocker, don't spin

Imports gate per split instead, so imports into different splits run concurrently — and alongside a whole-dataset mutation.


Working with data

Listing

Listings are paginated and typed. iter_* walks every page for you.

page = ds.images(split_id=split_id, size=100, search="harbor")
page.content[0].annotation_count
page.has_next

for image in ds.iter_images(split_id=split_id):
    ...

ds.images() is the one read gated on the operations control plane — it raises OperationConflictError while a mutation is in flight rather than serving a half-populated listing.

Importing

Two phases. Validate first; the import needs the resulting validation_id.

split = ds.split(split_id)

validation = split.validate_import("s3", S3ImportRequest(
    connection_id=conn_id, bucket="my-bucket", prefix="datasets/harbor/"))

if validation.can_proceed:
    split.import_from("s3", "object-detection-coco", S3ImportRequest(
        connection_id=conn_id, bucket="my-bucket", prefix="datasets/harbor/",
        validation_id=validation.validation_id))

Sources are s3, labelbox and dms; dataset types are object-detection-coco and semantic-segmentation-indexed. A validation_id is reaped after roughly 15 minutes.

Metrics

Two tiers, both served from a snapshot cache and never computed on the request path — so they cannot time out and are not blocked by a running mutation.

overview = ds.overview()          # Tier 1: cheap, always live
if overview.is_empty:
    ...                           # first-ever call; a background compute was enqueued
overview.payload.num_images
overview.is_stale                 # a mutation landed after this snapshot

ds.compute_deep_stats()           # Tier 2 stays empty until you ask for it
ds.deep_split_metrics()

Live events

One SSE stream per dataset, multiplexing six channels. It is also the only list surface for operations — there is no REST list route.

Every subscribe replays the latest snapshot of each channel, so no seed request is needed. That is what makes snapshot() terminate — it reads one frame per channel and disconnects:

state = ds.snapshot()                      # current state of all six channels
for row in state["operations"]["operations"]:
    print(row["op_type"], row["status"], row["progress"])

events() follows the stream and does not return on its own, so bound it:

for event in ds.events(channels=["operations"], max_events=10):
    print(event.event, event.json())

The two metrics_* channels are freshness signals only and carry no values.

Logs

for line in run.stream_logs("main", tail_lines=200):
    print(line)

Failures on a log stream arrive inside the stream (the response commits 200 before the work starts) and are raised as the equivalent typed exception.


Beyond training

# Export a checkpoint, then ship it
action_model = run.actions.create(CreatePostRunActionRequest(
    name="onnx", action_type="export", checkpoint_id=checkpoint_id))
action = run.action(str(action_model.id))
action.submit()

outputs = project.deployable_outputs(target="pono")
deployment_model = project.deployments.create(CreateDeploymentRequest(
    name="release-v1", target="pono",
    action_output_id=outputs[0].output_id, pono_device_id="alpha-01"))
project.deployment(str(deployment_model.id)).submit()

Action types are export, evaluate, prune, distill and inference. Deployment targets are pono and elements.

Flat platform surfaces hang off the client: client.schemas, client.storage, client.integrations, client.dms, client.media, client.pretrained_weights, client.registration_catalog.


Errors

Exceptions map from the API's {"error", "message"} envelope, dispatching on the error code first and the status second — several 409s and 429s carry a branchable code instead of the generic status name.

from modelstudio import ValidationError, OperationConflictError, ServerBusyError

try:
    run.submit()
except ValidationError as exc:
    print(exc)                 # renders the per-field `details` breakdown
Exception Status Notes
BadRequestError 400
AuthenticationError 401
ForbiddenError 403 Also: token carries no organization
NotFoundError 404 Or the resource belongs to another org — indistinguishable by design
ConflictError 409
OperationConflictError 409 Carries .op_id — the operation already in flight
SchemaConflictError 409 Check .already_current: a byte-identical republish is a no-op
OntologyScopeConflictError 409 Carries .impact — re-render a picker straight from the error
OntologyForkRequiredError 409 Fork the version and re-issue
ValidationError 422 Carries .details
ServerBusyError 429 A bounded pool is full; retry with backoff
BadGatewayError / ServiceUnavailableError 502 / 503 Upstream errored vs. unreachable-or-unconfigured
TransportError — The request never reached the API
TimeoutError — The SDK stopped waiting; the work continues server-side

Never branch on message — it is prose and will change.


pandas

Optional, behind the pandas extra. Nested objects are flattened into dotted columns.

ds.images_df(split_id=split_id)     # -> image.file_name, image.width, ...
ds.annotations_df()
ds.categories_df()
ds.commits_df()

Reference and development

  • docs/api-reference.md — every SDK method and the route it calls
  • contracts/API_PIN.md — which API build the SDK is aligned to, and how to re-sync
  • Tutorials — runnable notebooks, canonical in model-studio-notebooks under tutorials/, since that repo ships them to users. Clone it alongside this one and ./develop.sh --mode docker mounts them.
./develop.sh --mode setup             # conda env + deps
./develop.sh --mode test              # unit tests
./develop.sh --mode lint              # ruff + mypy + the contract check
./develop.sh --mode test-integration  # live tests against elements-dev
./develop.sh --mode notebook-server   # Jupyter Server for the custom notebook UI

scripts/check_contract.py validates every route the SDK calls against a committed OpenAPI snapshot. It is what catches upstream drift before a notebook does — run it, and re-snapshot with scripts/fetch_openapi.sh, whenever the API moves.

Requirements

Python 3.10+, httpx, pydantic v2. Sync only — notebooks are synchronous.

Release files for modelstudio-sdk 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for modelstudio-sdk 1.0.0
File Size Uploaded
modelstudio_sdk-1.0.0.tar.gz 163.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for modelstudio-sdk 1.0.0
File Interpreter ABI Platform
modelstudio_sdk-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 281.6 kB

Release files / modelstudio_sdk-1.0.0.tar.gz

Download URL modelstudio_sdk-1.0.0.tar.gz
Size 163.1 kB
Tags Source
SHA-256 checksum
How to use checksums
2edb3f6c7040c15a874003f9ec209dd3cf7f94139fafe342441b9457a5214bc0
BLAKE2b-256 checksum
How to use checksums
ad837312e99becb53d47f03509ede2a411a6e99b1679961de49a413d7d5985a3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.10.21

Release files / modelstudio_sdk-1.0.0-py3-none-any.whl

Download URL modelstudio_sdk-1.0.0-py3-none-any.whl
Size 118.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
018eb9746e5df8a153f7d155483de2e67ff39c2d7f355cb41e06704f6dd478b7
BLAKE2b-256 checksum
How to use checksums
059e6f39f014969cee06592c1f3c618946349e959ebe9488b5bfdf2bbc9562f1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.10.21

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page