Important
This repository is archived. Development has moved to
ditto-assistant/ditto-subnet,
with the maintained API and dashboard in
apps/platform.
Use the monorepo for current documentation, issues, and pull requests.
The API server for Ditto, Bittensor Subnet 118 (SN118).
Ditto Platform is the central, team-operated service that sits between miners
(who submit agent-memory harnesses) and validators (who evaluate them and set
weights on chain). It owns miner intake, on-chain payment verification, object
storage for submissions, the evaluation job queue, the score ledger, and the
operational state machine that moves a submission from uploaded to live.
The source in this repository is open under the MIT License. The production service remains centrally operated. Publishing the source does not publish private miner artifacts, operator review evidence, credentials, or protected operational data, and it does not grant access to admin endpoints.
The chain is the settlement layer (weights, stake, payments). This platform is the workflow layer the chain can't hold — queues, leases, payment replay-protection, submission status, and the public score ledger.
┌─────────────┐ upload (HTTP) ┌──────────────────┐ poll / score (HTTP) ┌─────────────┐
│ miner CLI │ ─────────────────▶ │ Ditto Platform │ ◀────────────────────── │ validators │
│ ditto-subnet│ │ (this repo) │ │ ditto-subnet│
└─────────────┘ └──────────────────┘ └──────┬──────┘
│ │ │ │ │ put_weights
Postgres │ MinIO/S3 ▼
Pylon (subtensor) Bittensor chain
- Miner side & validator daemon:
ditto-subnet - Platform-operated screening worker:
ditto-screener - Reference memory harness (what miners fork):
ditto-harness - This repo is the platform/API only. It is intentionally split out so it can be deployed and scaled independently of the miner/validator code.
The miner/validator contract is the OpenAPI schema served at /docs
(Swagger) and /openapi.json. The screener additionally shares only the
dependency-light ditto-screening-protocol package, pinned to an exact commit.
| Method & path | Purpose |
|---|---|
GET /health |
Liveness + DB/chain readiness + running vs checked-out commit |
GET /metrics |
Prometheus metrics |
GET /api/v1/upload/eval-pricing |
Quote the upload fee in rao (CoinGecko TAO/USD oracle) |
POST /api/v1/upload/check |
Pre-payment validation (signature, registration, size, accidental identical-upload detection) |
POST /api/v1/upload/agent |
Verified submission: assign payment/credit → store unique tarball → write agents + evaluation_payments atomically; an accidental paid identical upload becomes a reusable credit |
GET /api/v1/retrieval/agent-by-hotkey |
Look up a miner's latest agent |
GET /api/v1/retrieval/agent/{id}/status |
Poll a submission's lifecycle status |
/healthand/metricsare unprefixed; all other routes are versioned under/api/v1.
| Method & path | Purpose |
|---|---|
POST /api/v1/validator/job |
Lease a scoring ticket (seed, dataset_sha256, run_size, deadline) |
POST /api/v1/validator/heartbeat |
Submit a signed runtime heartbeat with optional coarse system health |
GET /api/v1/public/validators |
Read the public-safe validator fleet view |
GET /api/v1/public/screeners |
Read the public-safe platform screener fleet view |
GET /api/v1/public/bench/config |
The frozen benchmark setup: locked model, judge-free grading, seed derivation, mirror |
GET /api/v1/validator/agent/{id}/artifact |
Presigned download URL for an agent tarball |
POST /api/v1/validator/agent/{id}/score |
Submit a signed DittoBench score (→ scores table) |
Screening is a platform-operated pre-evaluation gate: a dedicated host the team
runs (not the validators). It drains uploaded agents, docker builds and
health-checks each crate in isolation, and promotes passes to evaluating,
rejects deterministic submission failures, and records retryable infrastructure
failures as screening_failed for another claim. A crate that does not compile
never costs a full benchmark.
It authenticates with a dedicated screener credential (an allowlisted
hotkey plus a bearer token), not a validator permit, so the screener key holds no
stake. The authoritative worker is deployed from the public, MIT-licensed
ditto-screener repository;
it remains platform-operated, and validators do not run it.
| Method & path | Purpose |
|---|---|
GET /api/v1/screener/queue |
List agents awaiting screening (status uploaded), oldest first |
POST /api/v1/screener/heartbeat |
Submit a dedicated-auth signed screener health report |
GET /api/v1/screener/agent/{id}/artifact |
Presigned download URL for the crate tarball |
POST /api/v1/screener/agent/{id}/result |
Signed pass/fail verdict that promotes the agent |
Weight/score aggregation (/scoring/*) and /admin/*. See
docs/VALIDATOR.md
in ditto-subnet for validator scoring design and operations.
- API: FastAPI + Uvicorn (Python 3.11+)
- Wire models: Pydantic (
ditto/api_models) — the only place Pydantic is used - Database: PostgreSQL via SQLAlchemy 2.0 async + asyncpg, migrations with Alembic
- Object storage: S3-compatible via aioboto3 (MinIO locally)
- Chain reads: Pylon +
async-substrate-interface - Pricing: CoinGecko oracle with in-process cache + stale-guard
- Observability: Prometheus metrics, structured request-id logging
- Tooling:
uv(deps/venv),ruff(lint/format),mypy,pytest
uv(Python toolchain + venv)- Node.js 22 and npm (copy lint only)
- Docker + Docker Compose (Postgres, MinIO, Pylon)
- Python 3.11 or 3.12
cp .env.example .envThen edit .env and set DITTO_UPLOAD_PAYMENT_ADDRESS to a real SS58 address
— the server validates it at boot and refuses to start with the placeholder. All
other defaults match the local Docker stack.
uv sync # install dependencies into .venv
make stack-up # postgres + pylon + minio (waits until healthy)
make migrate # apply alembic migrations
make api-up # run the FastAPI app on :8000 (foreground)In another terminal:
make smoke-api # curl /health
open http://localhost:8000/docs # interactive API docsmake stack-down stops the Docker services. Postgres state persists in a named
volume across restarts; docker compose down -v for a hard reset.
Pylon runs on host port 8001 so the API can own 8000.
The API is a long-lived process; we run it under pm2 on the host (the database and object store stay in Docker). Logs, restarts, and scripted updates are first-class.
npm install -g pm2 # one-time, if not present
./scripts/start.sh # infra up + migrate + start API under pm2
pm2 logs ditto-api # tail logs
pm2 status # process state
./scripts/update.sh # git pull + uv sync + migrate + pm2 reload + health gate
./scripts/stop.sh # stop the API process- pm2 config:
scripts/ecosystem.config.js - Logs are written to
./logs/ditto-api.{out,err}.logand viapm2 logs. pm2 startup+pm2 savewill resurrect the process across host reboots.- Updates are not zero-downtime. The app runs as a single
fork-mode pm2 process, sopm2 reloadhas no second instance to shift traffic onto and degrades to a stop/start: expect ~6s of refused connections per deploy (measured). Cluster mode would close that gap and is an open operator decision, not something the reload command papers over today. pm2 reloaddoes not reconcile how a process is launched.script,interpreter,interpreter_args,exec_mode, andcwdare kept from pm2's saved dump even whenecosystem.config.jschanges them;argsand env are reconciled. Changingscriptand reloading therefore runs the OLD program with the NEW args, which is how a deploy once left the API inwaiting restartwith pid 0 while the site served 502.scripts/update.shnow diffs the running launch identity against the config (scripts/pm2_deploy_plan.js) and doespm2 delete+pm2 startfor that app when they differ, so this is handled; if you ever bypassupdate.sh, recreate the app rather than reloading it.update.shfails the deploy if the app does not come back. After starting or reloading it polls/healthon the local port and exits non-zero with the tail oflogs/ditto-api.err.logif the API is not serving withinDITTO_HEALTH_TIMEOUT(default 120s). The one-shot image-cleanup job is cron-driven withautorestart: false, sostoppedis its correct state and is not treated as a failure.- A deploy only passes when the process reports the commit that was checked
out. Being checked out and being in effect are different facts.
/healthcarries the commit resolved at the process's boot, and the deploy gate compares it againstgit rev-parse HEAD; a 200 from a build older than the checkout fails the deploy./healthalso reportschecked_out_commitandcommit_driftso the question "is this host running what it has checked out?" can be answered at any time, not just during a deploy. Drift is reported, never enforced — it does not change the HTTP status, because pulling a serving host out of rotation over stale code turns a deploy problem into an outage. - Divergent Alembic heads stop the deploy in preflight, not mid-sequence.
Two migrations that each extend the same parent and merge independently make
alembic upgrade headrefuse to run.update.shnow asserts a single head beforeuv syncor the database is touched, usingscripts/check_migration_order.py --head(stdlib only, no venv), and prints every head plus the exactalembic mergethat reconciles them.heads(plural) was deliberately not adopted: applying two unreconciled branches in an order nobody reviewed can produce a schema neither branch intended. - A deploy that fails before pm2 restarts rolls the checkout back. The old
process is still serving in that window, so the checkout is reset to the
revision
/healthreports and dependencies are re-synced — the host stops claiming a deploy that never took effect. After pm2 has been restarted the checkout is left alone; going back from there is a deploy of the previous revision, not agit reset. Every attempt is recorded inlogs/last-deploy.json(result, stage, target, rollback), which is also the fallback rollback target when the API cannot answer for itself.
| Target | Description |
|---|---|
make lint |
ruff format --check + ruff check |
make lint-copy |
lint public dashboard copy with Faircopy |
make format |
ruff format + ruff check --fix |
make typecheck |
mypy ditto/ |
make test |
unit test suite (pytest) |
make test-integration |
integration tests against the live stack |
make api-up |
run the API in the foreground against the local stack |
make smoke-api |
curl /health to confirm reachability |
make smoke-pylon |
exercise the chain client against live Pylon |
make stack-up / make stack-down |
bring Docker services up / down |
make migrate / make migrate-down |
apply / roll back one migration |
make migrate-history / make migrate-current |
alembic history / current head |
ditto/
api_server/ FastAPI app
endpoints/ health · metrics · upload · retrieval · validator
middleware/ request-id · auth pass-through · error envelope
payment_verifier/ on-chain payment proof verification
pricing/ CoinGecko oracle + upload-fee config
storage/ S3/MinIO client
config.py env-driven ApiServerConfig
factory.py create_api_server() + lifespan
__main__.py process entry point (argparse + uvicorn)
api_models/ Pydantic wire shapes (the client contract)
agent_status.py canonical AgentStatus lifecycle enum
chain/ Pylon-backed ChainClient
db/ SQLAlchemy models + queries + engine/session factory
tests/ unit + integration tests
alembic/ database migrations
scripts/ pm2 ecosystem + start/stop/update + smoke_pylon
All configuration is environment-driven; see .env.example for the
full annotated list. Key groups: API (API_HOST/PORT/LOG_LEVEL), Pylon/chain
(PYLON_URL, PYLON_OPEN_ACCESS_TOKEN, NETUID, SUBTENSOR_NETWORK), Postgres
(POSTGRES_*), upload/pricing (DITTO_UPLOAD_PAYMENT_ADDRESS,
DITTO_UPLOAD_FEE_USD, DITTO_UPLOAD_FEE_BUFFER), and object storage
(STORAGE_*). The server validates config at boot and exits non-zero on a bad value
so a supervisor restarts cleanly.
make test # the whole suite; nothing to set up first
make test-integration # narrows to the integration tier
make test-chain # the two tests that need a live subtensoruv run pytest is green on a fresh clone with no .env and nothing
exported. Postgres and MinIO are ambient containers the harness starts on
demand (ditto/tests/pgharness.py, ditto/tests/minioharness.py), on test-only
ports so they cannot be confused with the compose stack. The environment
variables the config parsers require — DITTO_UPLOAD_PAYMENT_ADDRESS,
PYLON_OPEN_ACCESS_TOKEN, STORAGE_* — are defaulted to obviously-fake
fixtures by ditto/tests/env_defaults.py.
Those defaults are test-only, and deliberately so: the server itself still
refuses to boot on a missing or placeholder payment address, because a platform
that silently accepts one is far worse than a red test. Anything already set —
your .env, an exported variable, CI's explicit block — always wins. Adding a
newly-required variable without a test default fails
ditto/tests/test_env_defaults.py, which names the variable.
slow, localnet, and needs_chain are excluded by default; integration and
e2e are not. The suite uses all available CPU cores through pytest-xdist. CI
runs ruff, mypy, faircopy, and pytest on every PR and on main.
main (protected, release) ← dev (integration) ← feature branches
(name/topic, e.g. dan/api_init). Open PRs into dev; dev merges to main
via PR.