API reference¶
The control plane's HTTP contract: what each endpoint does, who may call it, and what comes back. This is a reference — look things up here rather than reading it front to back.
Verified against code: 2026-08-26.
To get a backend running first, see Change the code. Every environment variable it reads is in Configuration.
Default Admin¶
Created automatically on first startup:
- Email: admin@platform.com
- Password: admin
- ⚠️ Must change password on first login (must_change_password: true)
Authentication¶
Three credentials, strictly separated (spec §8.0/§17):
| Credential | Header | Used by | Accepted on |
|---|---|---|---|
| JWT token | Authorization: Bearer eyJ... |
Frontend (after login) | /v1/* dashboard endpoints |
| Personal API key | Authorization: Bearer gk-xxx |
Programmatic user access | /v1/files, /v1/batches (via get_human_context) |
| Org worker API key | Authorization: Bearer gk-xxx |
Worker daemons | /workers/* only (via get_worker_context) |
There is no provider role and no per-user key column: every user gets a
Personal Org (with owner membership) at signup; worker keys belong to
organizations and are managed from the dashboard. The backend never issues
keys during worker registration — it derives the owning org from the key.
API Endpoints¶
Auth — /v1/auth¶
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/v1/auth/signup |
None | Register; creates the user + their Personal Org |
POST |
/v1/auth/login |
None | Returns JWT (platform_role, must_change_password) |
GET |
/v1/auth/me |
JWT | Current user profile + org memberships |
POST |
/v1/auth/change-password |
JWT | Change password |
POST |
/v1/auth/api-keys/regenerate |
JWT/personal key | Regenerate the caller's personal API key |
POST |
/v1/auth/forgot-password |
None | Email a reset token |
POST |
/v1/auth/reset-password |
None | Redeem a reset token |
GET |
/v1/admin/users |
superadmin | List all users |
GET |
/v1/admin/workers |
superadmin | List all workers across orgs |
GET |
/v1/admin/organizations |
superadmin | List all organizations |
POST /v1/auth/signup¶
// Request — no role field; platform_role defaults to "user"
{
"email": "user@example.com",
"password": "secret123",
"full_name": "John Doe"
}
// Response (200) — no api_key; keys are created separately
{
"id": "user-abc123",
"email": "user@example.com",
"full_name": "John Doe",
"platform_role": "user",
"is_active": true,
"must_change_password": false,
"created_at": 1780000000
}
Personal API keys — /v1/users/me/api-keys¶
| Method | Endpoint | Description |
|---|---|---|
POST |
/v1/users/me/api-keys |
Create a personal key (raw key returned once) |
GET |
/v1/users/me/api-keys |
List own keys (prefix only) |
PUT |
/v1/users/me/api-keys/{key_id} |
Rename / set expiry / revoke |
DELETE |
/v1/users/me/api-keys/{key_id} |
Revoke |
Organizations — /v1/orgs (JWT)¶
| Method | Endpoint | Access | Description |
|---|---|---|---|
GET |
/v1/orgs |
member | Orgs the caller belongs to |
GET |
/v1/orgs/{org_id}/api-keys |
member | List org worker keys (prefix only) |
POST |
/v1/orgs/{org_id}/api-keys |
owner/admin | Create a worker key; the raw gk-… is returned once and never again |
DELETE |
/v1/orgs/{org_id}/api-keys/{key_id} |
owner/admin | Revoke a key — every daemon using it stops registering immediately |
POST |
/v1/orgs/{org_id}/api-keys/regenerate |
owner/admin | Rotate the org worker key |
GET |
/v1/orgs/{org_id}/workers |
member | Org's workers incl. status, activity, live VRAM, loaded models |
Files — /v1/files (OpenAI-compatible; JWT or personal key)¶
| Method | Endpoint | Description |
|---|---|---|
POST |
/v1/files |
Upload a .jsonl batch input (multipart: file, purpose="batch") |
GET |
/v1/files/{file_id}/content |
Download raw file (also used by workers for job input) |
Batches — /v1/batches (OpenAI-compatible; JWT or personal key)¶
| Method | Endpoint | Description |
|---|---|---|
POST |
/v1/batches |
Create a batch; async JSONL validation kicks off |
GET |
/v1/batches/{batch_id}/events |
SSE stream of validation events |
GET |
/v1/batches/{batch_id} |
Batch detail (owner or superadmin) |
GET |
/v1/batches |
user sees own batches; superadmin sees all |
Model handling:
body.modelis a model catalogue id (a platform slug fromGET /v1/models), not a raw runtime string. It must be consistent across all JSONL lines, and validation rejects anybody.modelnot in the catalogue (unsupported_model). See Model catalogue.
Models — /v1/models (OpenAI-compatible; JWT or personal key)¶
| Method | Endpoint | Description |
|---|---|---|
GET |
/v1/models |
Selectable catalogue entries (public + caller's org) |
Pool — /v1/pool (auth optional)¶
| Method | Endpoint | Description |
|---|---|---|
GET |
/v1/pool/capacity |
Live pool capacity, aggregate only |
The only endpoint that serves anonymous callers a view of the fleet. It
exists so a batch user can answer "is anyone online, and can they run my
model?" without being shown — or being able to infer — whose machine is
whose. Per-worker rows stay on the superadmin GET /v1/admin/workers.
{
"object": "pool.capacity",
"workers_online": 6,
"workers_idle": 3,
"workers_busy": 3,
"gpus_online": 14,
"vram_total_gb": 312.0,
"models_servable": [
{"id": "llama3.1-8b", "display_name": "Llama 3.1 8B", "parameter_size": "8B"}
],
"as_of": 1756400000
}
models_servableis what the scheduler would dispatch, not what the catalogue lists: an entry appears only when some online worker both hosts the artifact and fits itsvram_gb, decided byprovider_picker.can_serve— the same predicatePOST /workers/pollmatches on.- Online means
status = "online"and a heartbeat withinHEARTBEAT_TIMEOUT_SECONDS. The sweeper only flips the column once a minute, so this endpoint applies the cutoff at read time as well. vram_total_gbandgpus_onlinearenullfor anonymous callers, and for everyone while fewer thanMIN_WORKERS_FOR_HARDWARE(3) workers are online — on a thin pool "141 GB" names a specific machine, which is the disclosure the aggregate exists to prevent. GPU count is aggregate and travels with VRAM; GPU names are never returned at all.- Org-private catalogue entries appear only for members of that org
(superadmins see all), matching
GET /v1/models. - Never returned: hostnames, GPU names, worker ids, org ids.
- Responses are served from a 15-second in-process cache, so polling — and an unauthenticated caller hammering it — costs a constant number of queries. Poll no faster than the 30s heartbeat; anything quicker re-renders numbers that have not changed.
Workers — /workers (org worker key required)¶
| Method | Endpoint | Description |
|---|---|---|
POST |
/workers/register |
Register/re-register; backend assigns worker_id |
POST |
/workers/{worker_id}/heartbeat |
Unified heartbeat: liveness + activity + dynamic capabilities |
POST |
/workers/poll |
Claim the best-matching validated batch |
POST |
/workers/progress |
Live prompt counts (time-throttled — see DAEMON_PROGRESS_INTERVAL_SECONDS) |
POST |
/workers/model-progress |
Model download progress (logged; liveness) |
POST |
/workers/upload-results |
Upload output JSONL + real completed/failed counts |
POST |
/workers/report-failure |
Report failure → batch is requeued (up to 3 attempts) |
All endpoints verify ownership: the worker must belong to the key's org, and result/progress/failure reports must come from the worker the batch is assigned to (403 otherwise).
POST /workers/{worker_id}/heartbeat¶
// activity: idle | busy | downloading_model (validated).
// Liveness (workers.status online/offline) is server-managed.
{
"activity": "busy",
"current_job_id": "batch-abc123",
"progress": {"total_prompts": 100, "completed_prompts": 40, "failed_prompts": 1},
"gpu_utilization": 87.5,
"gpu_memory_used_gb": 14.2,
"vram_total_gb": 24.0,
"vram_available_gb": 9.8,
"loaded_models": ["mistral-7b"],
"inventory": [
{"local_name": "mistral-7b", "sha256": "7485fe…", "size_bytes": 2497280256,
"loaded": true, "runtime": "ollama"}
],
"uptime_seconds": 3600
}
inventory (additive; older daemons omit it) is the full on-disk artifact
list with FILE hashes — Ollama manifest-layer digests, which equal the GGUF
file's sha256 and join against model_catalog.digest (#116). It is resent
whole every beat, so a model pulled manually on the box surfaces on the
next heartbeat. Registration's runtimes[].inventory carries the same
shape, so availability rows are born identity-carrying.
Consequence: a worker advertises everything its runtime holds, not only
DAEMON_MODELS — every on-disk Ollama model, every model vLLM serves — so a
batch for a catalogued model may be routed to a box whose operator never
listed it. Limiting what a box offers is the provider-control work in #104.
POST /workers/poll¶
// Request
{"worker_id": "worker-abc123"}
// Response — job found
{
"job": {
"job_id": "batch-abc123",
"input_file_id": "file-abc123",
"input_path": "/v1/files/file-abc123/content",
"model": "mistral-7b"
}
}
// Response — nothing compatible
{"job": null}
Matching: the picker filters by VRAM (from heartbeats) and prefers workers that already have the model loaded. A worker that has never heartbeated only receives batches whose model it advertised at registration — never an arbitrary batch.
POST /workers/upload-results¶
Content-Type: multipart/form-data
Fields:
- job_id: "batch-abc123"
- worker_id: "worker-abc123" (must hold the assignment)
- completed: 98 (optional — real success count)
- failed: 2 (optional — real failure count)
- file: (binary) output .jsonl
POST /workers/report-failure¶
// Request
{"job_id": "batch-abc123", "worker_id": "worker-abc123", "error": "OOM"}
// Response — requeued until attempts hit the max (3), then terminal
{"status": "validated", "batch_id": "batch-abc123", "attempts": 1, "error": "OOM"}
Fault Tolerance (spec §12)¶
report-failurerequeues the batch (statusback tovalidated,attempts += 1, assignment voided); after 3 attempts it is markedfailedterminally.- A background sweeper (started at app startup, 60s interval) marks
workers
offlineafter 120s without a heartbeat and requeues their in-flight batches — a crashed daemon never strands a batch.
See sweeper.py for the thresholds.
Database Schema¶
Postgres with SQLAlchemy ORM. Tables (see models.py):
| Table | Purpose / notable columns |
|---|---|
users |
platform_role (user/superadmin), must_change_password — no role/api_key columns |
organizations |
Ownership boundary; owner derived from memberships |
organization_memberships |
role: owner / admin / viewer |
api_keys |
Hashed keys; key_type: worker (org-scoped) or personal; prefix for UI |
workers |
Static specs; status (liveness, server-managed) vs activity (daemon-reported); aggregate vram_total_gb / vram_available_gb from heartbeats |
worker_runtimes |
Inference engines a worker exposes (spec §8.2): engine, base_url, status |
runtime_models |
Per-worker availability rows (spec §8.3): name, runtime_model_id, digest, status (on-disk) + loaded (in VRAM, heartbeat-updated). Lean by design — descriptive metadata lives on model_catalog. |
worker_gpus |
Physical GPUs per worker (spec §8.4): vendor, name, vram_gb, driver, cuda |
model_catalog |
Curated, pinned models users select for a batch (identity). See Model catalogue. |
files |
Uploaded inputs and generated outputs |
batches |
Lifecycle status, request counts, attempts (requeue counter) |
batch_assignments |
Which worker holds which batch (FK → workers.id, ON DELETE SET NULL — history survives worker removal) |
password_reset_tokens |
Forgot-password flow |
Inventory is fully normalized per spec §8.2–8.4 — one row per runtime, model, and GPU rather than JSON blobs on
workers.
Model catalogue — /v1/models¶
body.model is a catalogue id (a stable platform slug), not a raw
runtime tag. Each model_catalog row is one pinned artifact — weights +
quantization + runtime — so a batch never silently swaps precision or
runtime (reproducibility). The raw runtime string (mistral:7b, an HF repo
id) lives only in runtime_model_id; the backend hands the daemon that at
poll time. See Model catalogue for
the full design, scheduling, and curation runbook.
model_catalog columns: id (slug, = body.model), display_name,
runtime, runtime_model_id, digest (reproducibility pin / match key),
quantization, parameter_size, context_length, vram_gb (scheduling
requirement), size_gb, task_type, source_*/homepage_url (provenance),
org_id (NULL = public), status, enabled.
Scheduling (provider_picker.py): poll resolves batch.model → entry,
then matches a worker that fits vram_gb and hosts runtime_model_id,
enforcing digest equality when both sides carry a digest (same tag +
different digest ⇒ not matched); prefers a worker already serving it.
Curation: entries are seeded from backend/catalog/models.yaml at startup
(upserted). Fill real digests + metadata from a live Ollama with
python -m scripts.capture_catalog (see the runbook). Validation rejects a
body.model not in the catalogue (unsupported_model).
Batch Status Lifecycle¶
validating → validated → in_progress → completed
↓ ↑ ↓
failed └────────────┘ requeue (failure/offline worker,
max 3 attempts) → failed
The backend enforces this rather than merely describing it —
VALID_TRANSITIONS in backend/routers/workers.py rejects anything else:
| From | May become |
|---|---|
validating |
validated, failed |
validated |
in_progress |
in_progress |
completed, failed, validated (the last is a requeue) |
completed |
— terminal |
failed |
— terminal |
There are exactly five. cancelling, cancelled, expired and
finalizing exist in OpenAI's batch API and the dashboard styles badges for
them, but this backend never produces them — there is no cancel endpoint.
Requeue (requeue_or_fail_batch in backend/sweeper.py) increments
attempts, deletes the batch's assignment so another worker can claim it, and
stores the error truncated to 2000 characters. On the third attempt
(MAX_BATCH_ATTEMPTS = 3) the batch goes terminal: failed, completed_at
stamped, and request_counts_failed set to the total.
Two things trigger it — a worker calling POST /workers/report-failure, and the
sweeper finding a worker that has stopped heartbeating
(HEARTBEAT_TIMEOUT_SECONDS = 120, checked every SWEEP_INTERVAL_SECONDS = 60),
which marks the worker offline and releases whatever it was holding.
Validation errors¶
Validation is asynchronous: POST /v1/batches returns immediately with
validating, and the batch reaches validated or failed on its own. Watch it
with GET /v1/batches/{batch_id}/events (SSE) rather than polling.
Each error carries a code (missing_field, invalid_json,
duplicate_custom_id, unknown_fields, unsupported_model, …), the line it
occurred on, and the field where applicable — null for whole-line JSON parse
failures.
Stored errors are capped at MAX_STORED_ERRORS = 100
(backend/services/batch_validator.py), but the reported total is the true
count. A batch can therefore report thousands of errors while listing a hundred;
that is the cap, not the truth about the file.
Migration¶
Base.metadata.create_all() creates any missing tables on startup.
No formal migration tool (Alembic) yet, and create_all() will not
alter a table that already exists — a new column on an existing model
reaches an existing database only if you add it by hand. For dev, drop
and recreate the database and restart.
Project Structure¶
backend/
├── main.py # App entry, CORS, sweeper + admin startup
├── database.py # Postgres engine + session
├── models.py # SQLAlchemy models
├── schemas.py # Pydantic request/response models
├── auth.py # JWT, hashing, key contexts (worker/personal/human)
├── provider_picker.py # Job-to-worker matching (VRAM + loaded models)
├── sweeper.py # Requeue logic + stale-worker sweeper (spec §12)
├── rate_limit.py # Key-creation rate limiting
├── requirements.txt
├── routers/
│ ├── auth.py # /v1/auth/*, /v1/admin/*
│ ├── users.py # /v1/users/me/api-keys (personal keys)
│ ├── organizations.py # /v1/orgs/* (org keys, org workers)
│ ├── files.py # /v1/files
│ ├── batches.py # /v1/batches (+ SSE validation events)
│ └── workers.py # /workers/* (daemon-facing)
└── services/
├── batch_validator.py # Async JSONL validation
├── sse_manager.py # Validation event stream
└── email_service.py # Password-reset mail