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, policy |
PATCH |
/v1/orgs/{org_id}/workers/{worker_id}/policy |
owner/admin | Set a worker's availability window and batch-size limit — see Provider policy |
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,
"vram_largest_gpu_gb": 24.0,
"ram_total_gb": 768.0,
"models_servable": [
{"id": "llama3.1-8b", "display_name": "Llama 3.1 8B", "parameter_size": "8B", "loaded": true}
],
"as_of": 1756400000
}
models_servableis what the scheduler would dispatch, not what the catalogue lists: an entry appears only when some online worker hosts the artifact, satisfies that runtime's fit rule, and is inside its provider's availability window, decided byscheduler.can_serve— the same predicatePOST /workers/pollmatches on.loadedsays whether some online worker already has the artifact in memory (schedulable rows, digest enforced — the scheduler's own predicate, so a drift row does not count): a servable-but-not-loaded model costs a load on first use.vram_total_gbis the fleet sum;vram_largest_gpu_gbis what one job can actually have. A batch runs on a single worker, and on a single-device runtime on a single card, so the sum describes the size of the pool and nothing about what will fit. A pool totalling 312 GB whose biggest card is 24 GB cannot run a 40 GB model.- 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. - The hardware figures are
null—vram_total_gb,vram_largest_gpu_gb,ram_total_gbandgpus_online— for 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. They travel together for that reason. 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 |
GET |
/v1/models/quarantine |
superadmin — worker-reported hashes the platform won't schedule (unregistered / drift), grouped by hash |
POST |
/v1/models/adopt |
superadmin — promote a quarantined hash into a catalogue entry (status: unverified); its worker rows become schedulable at once |
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,
"ram_available_gb": 41.2,
"loaded_models": ["mistral-7b"],
"inventory": [
{"local_name": "mistral-7b", "sha256": "7485fe…", "size_bytes": 2497280256,
"loaded": true, "runtime": "ollama",
"details": {"quantization": "Q4_K_M", "parameter_size": "7.2B",
"context_length": 32768, "family": "llama"}}
],
"runtimes": [
{"type": "ollama", "status": "ready"},
{"type": "vllm", "status": "unavailable"}
],
"uptime_seconds": 3600
}
loaded_models and inventory[].loaded both mean resident in VRAM right
now, not present on disk — Ollama reads them from /api/ps, vLLM from the
models it serves. loaded_models carries no runtime tag, so on a mixed worker
the backend can only apply it as a union; inventory[].loaded is tagged and
therefore authoritative, and a daemon that sends no inventory keeps the union.
runtimes is each configured runtime's reachability, probed on the beat that
carries it. Startup readiness cannot answer it: a provider who stops one
server and starts another changes which models the worker can serve without
restarting the daemon, and can_serve reads worker_runtimes.status to
decide. A reported unavailable takes the runtime out of the pool on that
beat; ready puts it back. draining is an operator decision rather than an
observation, so a report never overwrites it, and an empty list means the
daemon does not report liveness at all — the rows are then left alone, which
is not the same as claiming every runtime is down.
A runtime whose liveness is reported but whose inventory is empty for
two consecutive beats has its runtime_models rows marked missing, so
a runtime that is up and holding nothing stops advertising what it used to
hold. One beat is not enough: a single slow model listing would otherwise
flap the pool. The count resets the moment inventory returns.
Two conditions gate it. The runtime must be named in runtimes — from a
daemon that inventories one runtime and says nothing about the others,
silence is not evidence. And some runtime must have reported artifacts
on that beat: a payload carrying none at all reads the same as inventory
reporting having broken, and de-listing every runtime on it costs a whole
worker's capacity. One runtime reporting is enough to judge the rest.
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. details is what the runtime knows about the artifact — it pre-fills adopt
and lets auto-adopt register a discovered model without a human. Ollama
fills it from /api/tags + /api/show (cached per hash, one call per
model, not per beat). vLLM fills it from the HF hub cache's config.json
and adds source_ref / source_revision (repo + commit) — the cache is
content-addressed, so sha256 is the first weight's blob hash — the repo's
own weight_map order when it has one, else shard number — and files
lists every weight ([{file, sha256, size_bytes}]); models served from a path
outside the cache stay hash-less. The commit is only guessed when it is
unambiguous: one snapshot dir in the cache is always served (vLLM can only
run what is cached), several require a valid refs/main — otherwise the
row reports no identity rather than pin the wrong commit.
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 scheduler offers a batch only to a worker that hosts the artifact and satisfies the fit rule of the runtime hosting it (
scheduler.FIT_RULES), then prefers workers that already have the model loaded.Fit is per-card, not per-machine: a single-device runtime needs one GPU large enough on its own, so a 2 × 12 GB worker is never offered a 20 GB model. Per-GPU sizes arrive at registration, so fit works before the first heartbeat; the machine aggregate is used only when a worker reports no per-GPU inventory. A worker that has stated no capacity at all — no GPU rows and no heartbeat — is not offered work, since every worker that can state one does so at registration (by probing, or via
DAEMON_VRAM_GB).
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 / ram_available_gb from heartbeats (null = unknown, not zero). ram_total_gb is a registration snapshot. Per-card sizes live on worker_gpus — that is what the fit rule reads, not the aggregate. Provider policy: window_start_min / window_end_min / window_tz, max_batch_prompts (null = no constraint) |
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 (scheduler.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.
Metadata¶
POST /v1/batches accepts OpenAI's metadata: up to 16 string→string pairs
(keys ≤ 64 chars, values ≤ 512). It is stored as given and returned on
GET /v1/batches/{id} and the list. The scheduler ignores it; it exists so a
client can find batches it submitted together — the dashboard's grid mode tags
each arm with grid_id.
Runtime capabilities¶
Some body fields only some runtimes honour. Each daemon advertises its
runtimes' capabilities at registration (logprobs, completions,
prompt_scoring); the validator records which of them a file's rows ask for
on the batch, and the scheduler offers that batch only to a runtime that
advertises all of them.
| Rows that… | Need | vLLM | llama.cpp | Ollama |
|---|---|---|---|---|
set logprobs / top_logprobs > 0 |
logprobs |
yes | yes | ≥ 0.12.11 |
use POST /v1/completions |
completions |
yes | yes | no |
set echo: true on completions (prompt scoring) |
prompt_scoring |
yes | no | no |
If an online worker hosts the model but none of those hosts advertises the
capability, validation fails with unsupported_capability, naming the field.
If no host is online at all, the batch queues as usual and waits for a capable
one. Daemons older than this contract advertise nothing and are never offered
such batches.
Provider policy¶
An organisation owner or admin can limit what one of its workers is given:
// PATCH /v1/orgs/{org_id}/workers/{worker_id}/policy
{
"window_start": "18:00",
"window_end": "08:00",
"window_tz": "Asia/Kolkata",
"max_batch_prompts": 500
}
Only the fields sent change, and null clears one. The response, and each
worker in GET /v1/orgs/{org_id}/workers, carries the result as policy.
- Availability window. Outside it the worker is not offered a batch, and it
drops out of
models_servable. A batch already running when the window closes finishes. Times are local towindow_tz(an IANA name); a start later than the end wraps past midnight. The three fields are set together or cleared together, and start must differ from end — otherwise400. max_batch_prompts. A batch with more requests than this is never offered to the worker. At least1;nullis no limit.
Both are checked in scheduler.can_serve, but they reach pool capacity
differently. The window does: a worker outside it drops out of
models_servable. The size cap does not, because capacity asks about a model
without a batch size — a model servable for small batches stays listed even if
every worker caps below yours. That is what the validation check below is for.
When every online worker that hosts a file's model caps below its size,
validation fails with batch_too_large and names the largest limit; when none
is online the batch queues. A worker outside its window still counts for that
check — it is off for now, not unable.
Capacity is served from the 15-second cache above, so a window opening or
closing, or a policy edit, reaches models_servable within 15 seconds. Dispatch
reads the policy live and is never stale.
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, batch_too_large, …), 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)
├── scheduler.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