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 dockermounts 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)
| File | Size | Uploaded | |
|---|---|---|---|
| modelstudio_sdk-1.0.0.tar.gz | 163.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|