Open-source LLM benchmarking and workload orchestration backend. SSHes into GPU nodes, runs vLLM benchmarks or launches Jupyter Lab, streams live logs via SSE, and stores results in PostgreSQL with a full leaderboard API.
Licence: Apache-2.0 for this repository's source. The workload container images are publicly pullable — see Workload Images. The AI models this tool benchmarks are not covered by our licence and several require you to request access from their publisher — see MODEL-LICENSES.md.
Linux / Mac:
curl -sL https://raw.githubusercontent.com/corespan/aistudio-server/master/install.sh | bashWindows (PowerShell):
Invoke-WebRequest -Uri https://raw.githubusercontent.com/corespan/aistudio-server/master/install.ps1 -OutFile install.ps1; .\install.ps1| Tool | Install |
|---|---|
| Docker Desktop | https://docs.docker.com/get-docker/ |
make (Linux / Mac) |
Usually pre-installed — check: make --version |
make (Windows) |
Use WSL: sudo apt update && sudo apt install make |
Windows: Run all
makecommands in a WSL terminal (Win+R→wsl), not PowerShell or CMD.
git clone https://github.com/corespan/aistudio-server.git
cd aistudio-server
cp .env.example .env
make setupmake setup vendors the demo-ui frontend assets, builds containers, runs DB
migrations, and seeds the workload catalog in one shot.
Without make (Linux / WSL):
./scripts/vendor_frontend_assets.sh --if-missing # fonts + Chart.js, one time
docker compose up --build -d
docker compose exec api alembic upgrade head
docker compose exec api python -m app.services.catalog_seederWhy the vendoring step: the dashboard's fonts and Chart.js are served from this repo rather than a CDN — CDN assets fail outright in air-gapped clusters, and loading fonts from Google discloses visitor IPs. The binaries are gitignored to keep them out of the history, so they are fetched once at setup instead of on every page load. Skipping the step costs you charts and custom fonts; nothing else. See Licensing.
Without make (Windows PowerShell):
copy .env.example .env
docker compose up --build -d
docker compose exec api alembic upgrade head
docker compose exec api python -m app.services.catalog_seederVerify the server is up:
curl http://localhost:8002/health
# → {"status":"healthy","database":"ok"}Interactive API docs: http://localhost:8002/docs
Each GPU machine must be prepared once before it can receive benchmark jobs.
Linux / WSL:
ssh-keygen -t rsa -f ~/.ssh/id_rsa
# Press Enter twice for no passphraseWindows (PowerShell):
ssh-keygen -t rsa -f $env:USERPROFILE\.ssh\id_rsa
# Press Enter twice for no passphraseLinux / WSL:
ssh-copy-id -i ~/.ssh/id_rsa.pub drut@<node-ip>
# Enter the node's password once — last time you'll need itWindows (PowerShell — ssh-copy-id not available):
type $env:USERPROFILE\.ssh\id_rsa.pub | ssh drut@<node-ip> "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && chmod 700 ~/.ssh"Linux / WSL:
ssh -i ~/.ssh/id_rsa drut@<node-ip> "echo OK"
# Should print: OK (no password prompt)Windows (PowerShell):
ssh -i $env:USERPROFILE\.ssh\id_rsa drut@<node-ip> "echo OK"
# Should print: OK (no password prompt)If you see Permission denied, fix permissions on the node:
chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keysSSH_KEY_PATH=/root/.ssh/id_rsa # container-side path (bind-mounted from host)
SSH_DEFAULT_USER=drut| Requirement | How to check |
|---|---|
| Docker installed and runnable as the SSH user | docker ps |
| NVIDIA Container Toolkit | docker run --gpus all --rm nvidia/cuda:12.0-base nvidia-smi |
~/.aistudio/env with HF_TOKEN — only for gated models |
make check-node-env NODE=<host> |
| Port 8000 open (inbound) | vLLM benchmark server |
| Port 8899 open (inbound) | Jupyter Lab |
| Sufficient VRAM | TinyLlama: ~3 GB · Llama-3-8B: ~16 GB · Llama-3-70B: 4×A100 |
curl -X POST http://localhost:8002/api/v1/benchmarks/start \
-H "Content-Type: application/json" \
-d '{
"model_name": "TinyLlama/TinyLlama-1.1B-Chat-v1.0",
"node_ips": ["10.6.12.26"],
"config": {
"concurrency": 4,
"input_tokens": 512,
"output_tokens": 128,
"precision": "fp16",
"max_model_len": 2048,
"gpu_count": 1
}
}'
# → {"status":"success","task_id":"wl-20260708-a3f9bc"}curl http://localhost:8002/api/v1/benchmarks/wl-20260708-a3f9bc/status
# → {"task_id":"...","state":"RUNNING","error_message":null}States: CREATED → VALIDATING → VALIDATED → INSTALLING → READY → RUNNING → READY (or FAILED)
curl -N http://localhost:8002/api/v1/benchmarks/wl-20260708-a3f9bc/logs/stream
# Streams node validation → dependency install → benchmark output in real timecurl -X POST http://localhost:8002/api/v1/jupyter/launch \
-H "Content-Type: application/json" \
-d '{"node_ip": "10.6.12.26"}'
# → {"status":"success","task_id":"jup-20260708-dc4f70"}
# Poll until READY, then open the URL in your browser
curl http://localhost:8002/api/v1/jupyter/jup-20260708-dc4f70/status
# Without nginx proxy: {"state":"READY","jupyter_url":"http://10.6.12.26:8899/lab"}
# With nginx proxy: {"state":"READY","jupyter_url":"http://corespan.ddnsgeek.com/jupyter/T4/jup-20260708-dc4f70/lab"}JupyterLab with built-in AI assistance — write, execute, and visualize code with LLMs for faster experimentation and code writing.
- GPUs with CUDA compute capability ≥ 7
- Docker installed with Nvidia Container Toolkit
sudo docker run --restart=always --gpus all \
-v ~/.cache/huggingface:/root/.cache/huggingface \
--network host -p 8000:8000 --ipc=host \
vllm/<vllm_docker_image> \
--model <model_name> \
--dtype half \
--gpu-memory-utilization 0.95 \
--trust-remote-code \
--tensor-parallel-size <NUMBER_OF_GPUS> \
--max-model-len 131072 \
--max-num-seqs 8 \
--host 0.0.0.0Replace <NUMBER_OF_GPUS> with the number of GPUs available on your machine.
- Open the Settings tab in JupyterLab.
- Go to AI Settings → click Add Secret.
- Set Secret Name to
HOSTED_VLLM_API_BASEand Value to your vLLM server URL:http://<MACHINE_IP>:8000/v1/ - Update the Chat Model to:
hosted_vllm/Qwen/Qwen2.5-7B-Instruct-1M
The AI assistant is now ready to use inside your notebooks.
# Full leaderboard
curl http://localhost:8002/api/v1/benchmarks
# Filter by model or GPU type
curl "http://localhost:8002/api/v1/benchmarks?model=tinyllama%2Ftinyllama-1.1b-chat-v1.0"
curl "http://localhost:8002/api/v1/benchmarks?gpu_type=p40"
# Compare two runs
curl "http://localhost:8002/api/v1/benchmarks/compare?run_a=wl-20260708-a3f9bc&run_b=wl-20260708-x9k2mn"On the GPU node, each run creates an identifiable container — use docker ps or docker logs vllm-wl-20260708-a3f9bc to inspect a live run.
Checklist to verify a fresh deployment is working end to end.
1. API health
curl http://localhost:8002/health
# Expected: {"status":"healthy","database":"ok"}2. Workload catalog seeded
curl http://localhost:8002/api/v1/workload-types
# Expected: JSON array with at least one entry3. Models endpoint populated
curl http://localhost:8002/api/v1/models
# Expected: ["tinyllama/tinyllama-1.1b-chat-v1.0","llama3-8b-instruct",...]4. SSH reaches the node
ssh -i ~/.ssh/id_rsa drut@10.6.12.26 "echo OK"
# Expected: OK (no password prompt)5. Node validation passes
curl -X POST http://localhost:8002/api/v1/benchmarks/start \
-H "Content-Type: application/json" \
-d '{"model_name":"tinyllama/tinyllama-1.1b-chat-v1.0","node_ips":["10.6.12.26"],"config":{}}'
# Note the task_id, then:
curl -N http://localhost:8002/api/v1/benchmarks/<task_id>/logs/stream
# Expected in stream: "✓ Node 10.6.12.26 validated."6. Metrics ingest
curl -X POST http://localhost:8002/api/v1/metrics \
-H "Content-Type: application/json" \
-d '{
"run_id": "test-run-001",
"model_name": "tinyllama/tinyllama-1.1b-chat-v1.0",
"node_ips": ["10.6.12.26"],
"gpu_type": "p40",
"gpu_count": 1,
"precision": "fp16",
"input_tokens": 512,
"output_tokens": 128,
"concurrency": 4,
"status": "success",
"total_token_throughput": 320.5,
"mean_ttft_ms": 45.2,
"mean_e2el_ms": 980.0
}'
# Expected: {"status":"success","run_id":"test-run-001","message":"..."}
curl http://localhost:8002/api/v1/benchmarks
# Expected: test-run-001 appears in results7. Test suite
make test
# or: docker compose exec api pytest tests/ -v
# Expected: 79 tests passedmake setup # First-time setup: build + migrate + seed
make up # Start services (no rebuild)
make down # Stop services
make logs # Tail API + worker logs
make migrate # Run Alembic migrations only
make seed # Re-seed workload_types from catalog.json
make test # Run the test suite
make shell # Python shell inside the API container
make spec # Export OpenAPI spec to openapi.jsonLicence compliance — see Licensing:
make compliance # Run every check CI runs: licence files, pins, inventory, CDN refs
make deps-lock # Re-pin requirements.txt from requirements.in (after editing .in)
make third-party # Regenerate THIRD-PARTY-NOTICES.md from the pinned set
make vendor-assets # Re-vendor demo-ui fonts and Chart.js from npm
make sbom # Inventory the workload images (needs syft + registry access)
make check-models # Report gate status and licence for every model referenced
make check-node-env NODE=<host> # Confirm HF_TOKEN reaches a GPU node| Container | Internal | External | Purpose |
|---|---|---|---|
api |
8001 | 8002 | FastAPI REST + SSE |
postgres |
5432 | 5433 | PostgreSQL |
rabbitmq (AMQP) |
5672 | 5672 | Celery broker |
rabbitmq (UI) |
15672 | 15672 | RabbitMQ admin |
pgadmin |
80 | 5050 | DB admin |
demo-ui |
80 | 3000 | Benchmark dashboard |
worker |
— | — | No port; connects outbound only |
Browser / UI (localhost:3000)
│ REST + SSE
FastAPI API (localhost:8002)
│ Celery task dispatch
RabbitMQ ←→ Celery Worker
│ Paramiko SSH
GPU Node(s)
└─ docker run vllm-<run_id> / jupyter-<run_id>
│ results POSTed to /api/v1/metrics
PostgreSQL (localhost:5433)
| Component | Purpose |
|---|---|
| FastAPI | REST API + SSE log streaming |
| Celery worker | SSHes into GPU nodes, runs benchmarks / launches Jupyter |
| ManifestBuilder | Builds shell commands executed on remote nodes |
| SSHExecutor | Runs remote commands, streams stdout into TaskLog |
| PostgreSQL | Workloads, tasks, task logs, benchmark results |
| RabbitMQ | Task broker for Celery |
No local builds on the GPU node — images are pulled automatically on each run.
| Image | Used for |
|---|---|
llminference:2.3.1-nvidia |
vLLM inference server — also used as the benchmark client via docker exec |
jupyternotebook:2.2.0-nvidia |
Jupyter Lab (supports --ServerApp.base_url for nginx subpath proxy) |
Hosted in GCP Artifact Registry at
us-docker.pkg.dev/aimlworkbench/aistudio/.
The images are public. Verify before setting up a node:
docker pull us-docker.pkg.dev/aimlworkbench/aistudio/llminference:2.3.1-nvidiaIf that succeeds without gcloud auth, no credential is needed. No ~/gcr.json
service-account key is required.
If the pull returns
UNAUTHORIZED, the registry's public access binding has been lost. That is a bug — please open an issue. Public pullability is a supported property of this project, not a convenience.
These images are prebuilt artifacts that Corespan Systems, Inc distributes, so third-party notice and redistribution obligations attach to us rather than to the projects whose code they contain. Each contains vLLM, PyTorch, Jupyter, several hundred Python wheels, the CUDA or ROCm userspace, and a Debian base layer.
Generate the inventory with make sbom (requires
syft and pull access). Output lands in
sbom/, with sbom/REPORT.md flagging anything that needs a licence decision.
Change it in catalog.json and run make seed — no code change needed. Then
re-run make sbom so the inventory still describes what users actually pull.
By default, jupyter_url in the launch response is a direct http://<node-ip>:<port>/lab link. Enabling the nginx proxy routes all Jupyter sessions through a single public domain (corespan.ddnsgeek.com) so GPU node IPs are never exposed to users.
URL format with proxy enabled:
http://corespan.ddnsgeek.com/jupyter/<GPU_TYPE>/<task_id>/lab
When NGINX_ENABLED=true the worker:
- Writes a location block to
NGINX_CONF_DIR/jupyter-<task_id>.confon the nginx host. - Passes
JUPYTER_BASE_URL=/jupyter/<GPU_TYPE>/<task_id>/to the container so JupyterLab serves assets from the correct subpath. - Reloads nginx via
NGINX_RELOAD_CMD.
Location files are deleted automatically when a session ends.
Run these once on the machine running nginx (10.6.12.15):
# Install nginx if not present
sudo apt install -y nginx
# Create the directory for per-instance location files
sudo mkdir -p /etc/nginx/jupyter-locations
# Create the main server block (only needed once)
sudo tee /etc/nginx/conf.d/aistudio-jupyter.conf > /dev/null << 'EOF'
server {
listen 80;
listen [::]:80;
server_name corespan.ddnsgeek.com;
include /etc/nginx/jupyter-locations/*.conf;
}
EOF
# Remove the default nginx site to avoid server_name conflicts
sudo rm -f /etc/nginx/sites-enabled/default
# Test and reload
sudo nginx -t && sudo nginx -s reloadNGINX_ENABLED=true
PROXY_BASE_URL=http://corespan.ddnsgeek.com
# Path that is bind-mounted from the nginx host into the worker container
NGINX_CONF_DIR=/etc/nginx/jupyter-locations
# If nginx is on the same host as the worker
NGINX_RELOAD_CMD=nginx -s reload
# If nginx is on a separate machine (e.g. master node at 10.6.12.15)
NGINX_RELOAD_CMD=ssh -i /root/.ssh/id_rsa -o StrictHostKeyChecking=no drut@10.6.12.15 sudo nginx -s reloadAlso update docker-compose.yml to bind-mount the nginx locations directory into the worker container:
worker:
volumes:
- /etc/nginx/jupyter-locations:/etc/nginx/jupyter-locations
- ~/.ssh:/root/.ssh:roCopy .env.example to .env and fill in the values below.
| Variable | Default | Description |
|---|---|---|
SSH_KEY_PATH |
/root/.ssh/id_rsa |
Container-side path to SSH private key |
SSH_DEFAULT_USER |
drut |
SSH username on GPU nodes |
GCP_REGISTRY_URL |
us-docker.pkg.dev |
Artifact Registry hostname |
GCP_PROJECT_ID |
aimlworkbench |
GCP project ID |
GCP_REPOSITORY |
aistudio |
Artifact Registry repo |
WORKLOAD_IMAGE_TAG |
2.3.1-nvidia |
Fallback image tag (overridden by catalog.json) |
JUPYTER_IMAGE_TAG |
2.2.0-nvidia |
Tag for the jupyternotebook image |
MODEL_STORAGE_MODE |
huggingface |
huggingface, local, or gcs |
MODEL_LOCAL_PATH |
/home/ubuntu/models |
Used when MODEL_STORAGE_MODE=local |
NGINX_ENABLED |
false |
Set to true to route Jupyter through nginx (hides GPU node IPs) |
PROXY_BASE_URL |
(empty) | Public domain with scheme and no trailing slash — e.g. http://corespan.ddnsgeek.com. Required when NGINX_ENABLED=true. |
NGINX_CONF_DIR |
/etc/nginx/jupyter-locations |
Directory where the worker writes per-instance location files. Must be bind-mounted in docker-compose.yml. |
NGINX_RELOAD_CMD |
nginx -s reload |
Command to reload nginx after a config is written. When nginx runs on a separate host, use an SSH command — e.g. ssh -i /root/.ssh/id_rsa -o StrictHostKeyChecking=no user@master-node sudo nginx -s reload. |
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/benchmarks/start |
Start a benchmark — returns task_id |
| GET | /api/v1/benchmarks/{task_id}/status |
Poll workload state |
| GET | /api/v1/benchmarks/{task_id}/logs/stream |
SSE live log stream |
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/jupyter/launch |
Launch Jupyter Lab — returns task_id |
| GET | /api/v1/jupyter/{task_id}/status |
Poll state; returns jupyter_url when READY |
| GET | /api/v1/jupyter/{task_id}/logs/stream |
SSE live log stream |
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/benchmarks |
Filterable leaderboard (model, gpu_type, node_ip, precision, concurrency, input_tokens, output_tokens, status, date) |
| GET | /api/v1/benchmarks/{run_id} |
Full detail for one run |
| GET | /api/v1/benchmarks/compare?run_a=&run_b= |
Side-by-side comparison |
| Method | Path | Description |
|---|---|---|
| DELETE | /api/v1/benchmarks/{run_id} |
Delete a single run |
| DELETE | /api/v1/benchmarks/bulk |
Delete multiple — body: {"run_ids":[...]} |
| DELETE | /api/v1/benchmarks/filter |
Delete by filter — at least one filter required |
| DELETE | /api/v1/benchmarks/all?confirm=true |
Wipe all results |
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/metrics |
Ingest a benchmark result (upsert by run_id) |
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/models |
Catalog models + historical model names |
| GET | /api/v1/gpu-types |
Distinct GPU types from past runs |
| GET | /api/v1/nodes |
Distinct node IPs from past runs |
| GET | /api/v1/concurrencies |
Distinct concurrency levels |
| GET | /api/v1/models/config |
Recommended vLLM config for a model |
| Method | Path | Description |
|---|---|---|
| GET | /health |
Readiness probe — checks DB connectivity |
| GET | /api/v1/workload-types |
List supported workload types |
Full interactive docs: http://localhost:8002/docs
Apache-2.0. See LICENSE for the full text and NOTICE for the copyright and attribution summary.
Apache-2.0 Section 6 grants no trademark rights. "CoreSpan" and the CoreSpan logo are trademarks of Corespan Systems, Inc.
The Apache-2.0 grant covers CoreSpan's own source. It does not extend to the software and weights this tool pulls in at run time. Three separate inventories cover those:
| What | Where | Covers |
|---|---|---|
| Python dependencies | THIRD-PARTY-NOTICES.md | Every package in requirements.txt, with the three reciprocal licences called out |
| Models and datasets | MODEL-LICENSES.md | Per-model terms, which models are gated, how to obtain access |
| Workload images | sbom/ — run make sbom |
Contents of the distributed containers, including the CUDA EULA question |
| Frontend assets | demo-ui/vendor/NOTICE | Self-hosted fonts (OFL-1.1) and Chart.js (MIT) |
If you plan to run gated models — all the Meta Llama entries in
catalog.json — read MODEL-LICENSES.md first. You must
request access from the publisher and set HF_TOKEN on the GPU node. CoreSpan
cannot grant that access on your behalf.
Check which models in your catalog are gated:
python3 scripts/check_model_access.pymake compliance # licence files, pinned deps, inventory freshness, no CDN refs
make sbom # workload image inventory (needs syft + registry access)
make check-models # gate status and licence for every model referencedCI runs the same checks on every push — see .github/workflows/compliance.yml.
Open an issue, or email legal@corespan.ai if it concerns a third party's rights. We will respond within five working days.