Using the REST API
Everything the CLI does, the REST API does. Use it when the CLI can’t be installed: Windows without WSL, an environment that permits no new binaries, or when your work is already scripted in Python.
Both create the same entities on the same platform, accept the same config overrides, and identify each entity by the same UUID. A project can move between them freely, since an id from one works in the other.
The complete endpoint schema is at api.distillabs.ai/docs. This page is the working guide.
Prerequisites
Section titled “Prerequisites”pip install requests pyyaml
export DL_USERNAME="you@example.com"
export DL_PASSWORD="…"
The API is https://api.distillabs.ai, with Cognito client id 4569nvlkn8dm0iedo54nbta6fd in
eu-central-1.
Access tokens last one hour, which is shorter than a generation or training run, so the helpers below re-authenticate per request rather than holding a token.
Helpers
Section titled “Helpers”Every example on this page assumes this block.
import json
import os
import time
from pathlib import Path
import requests
import yaml
PLATFORM_URL = os.getenv("DL_PLATFORM_URL", "https://api.distillabs.ai")
COGNITO_CLIENT_ID = os.getenv("DL_COGNITO_CLIENT_ID", "4569nvlkn8dm0iedo54nbta6fd")
COGNITO_URL = "https://cognito-idp.eu-central-1.amazonaws.com"
POLL_INTERVAL_SECONDS = 20
# A staging response names each file by extension; a create body names it by
# field. These maps are the only place that difference is spelled out.
STAGING_FIELDS = {
"train_data": "train_data_jsonl",
"test_data": "test_data_jsonl",
"config": "config_yaml",
"job_description": "job_description_json",
"unstructured_data": "unstructured_data_jsonl",
"model": "model_tar",
}
PREPARED_TRACES_FIELDS = {
"traces_jsonl": "traces_jsonl",
"config": "config_yaml",
"job_description_json": "job_description_json",
"test_jsonl": "test_jsonl",
}
def auth():
response = requests.post(
COGNITO_URL,
headers={
"X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth",
"Content-Type": "application/x-amz-json-1.1",
},
data=json.dumps({
"AuthParameters": {
"USERNAME": os.environ["DL_USERNAME"],
"PASSWORD": os.environ["DL_PASSWORD"],
},
"AuthFlow": "USER_PASSWORD_AUTH",
"ClientId": COGNITO_CLIENT_ID,
}),
)
response.raise_for_status()
return {"Authorization": response.json()["AuthenticationResult"]["AccessToken"]}
def raise_with_body(response):
"""raise_for_status hides the response body, which is where validation
errors live. Print it before raising."""
try:
response.raise_for_status()
except requests.exceptions.HTTPError:
print(f"{response.status_code}: {response.text[:2000]}")
raise
def get(path):
response = requests.get(f"{PLATFORM_URL}{path}", headers=auth())
raise_with_body(response)
return response.json()
def post(path, body):
response = requests.post(
f"{PLATFORM_URL}{path}",
data=json.dumps(body),
headers={"content-type": "application/json", **auth()},
)
raise_with_body(response)
return response.json()
def stage(staging_route, files, fields=STAGING_FIELDS):
"""PUT each local file to its presigned URL and return the create body."""
urls = get(staging_route)
body = {}
for field, filepath in files.items():
url = urls[fields[field]]
requests.put(url, data=Path(filepath).read_bytes()).raise_for_status()
body[field] = url
return body
def config_of(collection, entity_id):
"""Fetch a parent's whole config as a dict. An override replaces the config
rather than merging into it, so every override starts here. Costs nothing."""
urls = get(f"/{collection}/{entity_id}/download-metadata")
if urls["config_url"] is None:
status = get(f"/{collection}/{entity_id}/status")["status"]
raise RuntimeError(f"{collection}/{entity_id}: not available yet (status {status})")
response = requests.get(urls["config_url"])
response.raise_for_status()
return yaml.safe_load(response.text)
def poll(collection, entity_id, timeout_seconds, status_field="status"):
deadline = time.time() + timeout_seconds
while time.time() < deadline:
status = get(f"/{collection}/{entity_id}/status")[status_field]
if status == "JOB_SUCCESS":
return
if status in ("JOB_FAILURE", "JOB_STOPPED"):
logs = get(f"/{collection}/{entity_id}/logs")["logs"]
raise RuntimeError(f"{collection}/{entity_id} {status}:\n{logs[-4000:]}")
print(f"{collection}/{entity_id}: {status}", flush=True)
time.sleep(POLL_INTERVAL_SECONDS)
raise TimeoutError(f"{collection}/{entity_id} did not finish in {timeout_seconds}s")
Keep raise_with_body. Validation errors arrive in the response body, and raise_for_status()
throws them away, so a 400 that names the exact row and field at fault reads as a bare
HTTPError.
The entity model
Section titled “The entity model”| Stage | Submit | Read |
|---|---|---|
| Trace processing | POST /prepared-traces, then POST /seed-datasets/from-prepared-traces |
GET /seed-datasets/<id>/{status,logs,metrics,download,download-metadata} |
| Job input | POST /seed-datasets |
GET /seed-datasets/<id>/{status,logs,metrics,download,download-metadata} |
| Teacher evaluation | POST /teacher-evaluations/from-seed-datasets |
GET /teacher-evaluations/<id>/{status,logs,metrics,download-metadata} |
| Synthetic data generation | POST /training-datasets/from-seed-datasets |
GET /training-datasets/<id>/{status,logs,metrics,sample,download,download-metadata} |
| Model training | POST /slms/from-training-datasets |
GET /slms/<id>/{status,logs,metrics,download,download-metadata} |
| Deployment | POST /deployments/from-slms |
GET /deployments/<id>/{status,endpoint,logs}, DELETE /deployments/<id> |
The /uploads, /staging-uploads-s3-urls, /teacher-evaluations/from-uploads and
/training-datasets/from-uploads paths answer 410 Gone, naming the route to use instead. A 410
means the path is the problem, not the payload.
Staging files
Section titled “Staging files”Staging is a three-step exchange: GET /staging-<kind>-s3-urls returns a presigned PUT URL per
file, each file is PUT to its URL, and those URLs are posted back as the create body. The
stage() helper does all three.
The staging response names each file by extension (train_data_jsonl) while the create body names
it by field (train_data). The helper hides that difference.
Staged bundles expire after seven days if they never become an entity.
Creating a seed dataset
Section titled “Creating a seed dataset”body = stage("/staging-seed-datasets-s3-urls", {
"train_data": "train.jsonl",
"test_data": "test.jsonl",
"config": "config.yaml",
"job_description": "job_description.json",
})
seed_dataset_id = post("/seed-datasets", body)["id"]
Omit unstructured_data for tasks that don’t use it.
POST /seed-datasets validates the bundle and returns 400 with the validation error when it
fails. That’s this backend’s dry run. The route is metered, but only a successful create spends
a credit, so validating a broken bundle repeatedly is free. A 402 here means the balance was
already zero before the bundle was read.
Trace processing
Section titled “Trace processing”body = stage("/staging-prepared-traces-s3-urls", {
"traces_jsonl": "traces.jsonl",
"config": "config.yaml",
"job_description_json": "job_description.json",
# Add "test_jsonl" only for a curated test set - supplying one replaces
# the generated test split and makes num_traces_as_testing_base inert.
}, PREPARED_TRACES_FIELDS)
prepared_traces_id = post("/prepared-traces", body)["id"]
seed_dataset_id = post(
"/seed-datasets/from-prepared-traces", {"from": prepared_traces_id}
)["id"]
poll("seed-datasets", seed_dataset_id, 60 * 45)
Submitting the rest of the pipeline
Section titled “Submitting the rest of the pipeline”Every job is created by posting the id of the entity before it. Nothing uploads at this point - the parent’s files are already on the platform.
te_id = post("/teacher-evaluations/from-seed-datasets", {"from": seed_dataset_id})["id"]
poll("teacher-evaluations", te_id, 60 * 30)
dataset_id = post("/training-datasets/from-seed-datasets", {"from": seed_dataset_id})["id"]
poll("training-datasets", dataset_id, 60 * 90)
slm_id = post("/slms/from-training-datasets", {"from": dataset_id})["id"]
poll("slms", slm_id, 60 * 90)
deployment_id = post("/deployments/from-slms", {"from": slm_id})["id"]
poll("deployments", deployment_id, 60 * 40, "deployment_status")
| Stage | Collection | Typical timeout |
|---|---|---|
| Trace processing | seed-datasets |
45 min |
| Teacher evaluation | teacher-evaluations |
30 min |
| Synthetic data generation | training-datasets |
90 min |
| Model training | slms |
90 min |
| Deployment | deployments |
40 min |
Overrides
Section titled “Overrides”The four job creates each accept optional config and job_description objects inline, alongside
from. There’s no staging step for either.
config = config_of("seed-datasets", seed_dataset_id)
config["synthgen"]["generation_target"] = 64
dataset_id = post("/training-datasets/from-seed-datasets", {
"from": seed_dataset_id,
"config": config,
})["id"]
Each override replaces the parent’s file whole. Anything you leave out reverts to a library default rather than the parent’s value, so an override is always read-edit-resend, never hand-built. Two shapes to know:
{"config": {"base": {"student_model_name": …}}}is a 400.baseis required and has no default, so a config naming only one field under it is not a config.- A config carrying a valid
basebut omittingsynthgenortuningis accepted, and those sections silently take defaults. Dropping one field from a section you do send reverts that field the same way. Nothing errors.
GET /<collection>/<id>/download-metadata returns presigned URLs for an entity’s config.yaml
and job_description.json at no credit cost. It fills only once the entity’s own job has
finished - while it runs, both are null. The config_of helper raises a message naming the
entity and its status rather than passing a None to requests.get, which otherwise fails with
Invalid URL 'None': No scheme supplied and sends you hunting for a bug in your URL building.
GET /slms/<id>/download-metadata returns a third field, model_client_url. Every other entity
returns exactly two.
Credits
Section titled “Credits”balances = get("/credits/endpoints")["balances"]
print(balances["training_datasets_from_seed_datasets_post"])
The read is free and answers at zero balance. Routes the platform never charges for are absent
from the response, so read an unfamiliar key with .get() rather than assuming a zero. A
submission against an exhausted route fails 402.
The route table and starting balances: How the platform works.
Reading results
Section titled “Reading results”teacher = get(f"/teacher-evaluations/{te_id}/metrics")["teacher_performance"]
slm_metrics = get(f"/slms/{slm_id}/metrics")
base, tuned = slm_metrics["base_model_performance"], slm_metrics["tuned_model_performance"]
Each metrics response also carries a presigned *_download_url for the per-example predictions.
Those URLs expire after an hour.
Reading a training dataset:
rows = get(f"/training-datasets/{dataset_id}/sample")["rows"] # free, 128 rows max
size = get(f"/training-datasets/{dataset_id}/metrics")["train_data_size_bytes"]
sample returns at most 128 train rows drawn from the first 384, and never test rows.
GET /training-datasets/<id>/download returns them all and is metered on
training_datasets_download_get, which starts at zero.
What the numbers mean: Metrics.
Deployment
Section titled “Deployment”endpoint = get(f"/deployments/{deployment_id}/endpoint")
url, api_key = endpoint["url"], endpoint["api_key"]
# ... use it ...
requests.delete(f"{PLATFORM_URL}/deployments/{deployment_id}", headers=auth())
Before the job reaches JOB_SUCCESS, endpoint answers {"url": null, "api_key": null}, so
poll the status rather than probing the endpoint.
A deployment is a session, not a permanent endpoint. It stops after six hours, or after one hour with no traffic, and it can’t be restarted. It bills until that idle timeout, so delete it when you’re finished.
See distil labs inference for querying it.