From 64944921e2124cc90d8450a0441bafc1549f1f90 Mon Sep 17 00:00:00 2001 From: abulasa1 Date: Sun, 14 Jun 2026 22:08:36 +0800 Subject: [PATCH] Add generated miner OpenAPI docs --- docs/miner-api.html | 15 + docs/miner-openapi.json | 1345 +++++++++++++++++++++++++++++ docs/miner.md | 19 + scripts/generate_miner_openapi.py | 376 ++++++++ tests/test_miner_openapi.py | 23 + 5 files changed, 1778 insertions(+) create mode 100644 docs/miner-api.html create mode 100644 docs/miner-openapi.json create mode 100644 scripts/generate_miner_openapi.py create mode 100644 tests/test_miner_openapi.py diff --git a/docs/miner-api.html b/docs/miner-api.html new file mode 100644 index 00000000..f8dc9d1d --- /dev/null +++ b/docs/miner-api.html @@ -0,0 +1,15 @@ + + + + + Engram Miner HTTP API + + + + + + + + diff --git a/docs/miner-openapi.json b/docs/miner-openapi.json new file mode 100644 index 00000000..40f8c59a --- /dev/null +++ b/docs/miner-openapi.json @@ -0,0 +1,1345 @@ +{ + "components": { + "securitySchemes": { + "NamespaceSignature": { + "description": "Logical scheme: namespace ownership proof fields are sent in JSON request bodies.", + "in": "query", + "name": "namespace_hotkey/namespace_sig/namespace_timestamp_ms", + "type": "apiKey" + }, + "SignedBody": { + "description": "Logical scheme: sr25519 signature fields are sent in JSON request bodies.", + "in": "query", + "name": "hotkey/nonce/signature", + "type": "apiKey" + } + } + }, + "info": { + "description": "Machine-readable API contract for neurons/miner.py. Signed miner requests use hotkey, nonce, and signature fields in the JSON body. Namespace-scoped requests use namespace_hotkey, namespace_sig, and namespace_timestamp_ms.", + "title": "Engram Miner HTTP API", + "version": "0.1.0" + }, + "openapi": "3.1.0", + "paths": { + "/AttestNamespace": { + "post": { + "operationId": "post_AttestNamespace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "namespace": { + "type": "string" + }, + "owner_hotkey": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "timestamp_ms": { + "type": "integer" + } + }, + "required": [ + "namespace", + "owner_hotkey", + "signature", + "timestamp_ms" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "attested": { + "type": "boolean" + }, + "error": { + "nullable": true, + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Attest a namespace to a Bittensor hotkey.", + "tags": [ + "namespaces" + ] + } + }, + "/ChallengeSynapse": { + "post": { + "operationId": "post_ChallengeSynapse", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "cid": { + "description": "Content identifier.", + "type": "string" + }, + "endpoint": { + "description": "Logical endpoint name used when signing; implied by the route.", + "type": "string" + }, + "expires_at": { + "type": "integer" + }, + "hotkey": { + "description": "Optional Bittensor SS58 hotkey.", + "type": "string" + }, + "nonce": { + "description": "Unix timestamp in milliseconds.", + "type": "integer" + }, + "nonce_hex": { + "type": "string" + }, + "signature": { + "description": "sr25519 signature over nonce:endpoint:sha256(canonical body).", + "type": "string" + }, + "validator_hotkey_hex": { + "type": "string" + } + }, + "required": [ + "cid", + "nonce_hex", + "expires_at" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "embedding_hash": { + "type": "string" + }, + "error": { + "nullable": true, + "type": "string" + }, + "proof": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Return a storage proof for a validator challenge.", + "tags": [ + "synapses" + ] + } + }, + "/IngestSynapse": { + "post": { + "operationId": "post_IngestSynapse", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "endpoint": { + "description": "Logical endpoint name used when signing; implied by the route.", + "type": "string" + }, + "hotkey": { + "description": "Optional Bittensor SS58 hotkey.", + "type": "string" + }, + "metadata": { + "additionalProperties": true, + "type": "object" + }, + "model_version": { + "default": "v1", + "type": "string" + }, + "namespace": { + "nullable": true, + "type": "string" + }, + "namespace_hotkey": { + "type": "string" + }, + "namespace_key": { + "deprecated": true, + "nullable": true, + "type": "string" + }, + "namespace_sig": { + "description": "sr25519 signature over engram-ns:{namespace}:{namespace_timestamp_ms}.", + "type": "string" + }, + "namespace_timestamp_ms": { + "type": "integer" + }, + "nonce": { + "description": "Unix timestamp in milliseconds.", + "type": "integer" + }, + "raw_embedding": { + "items": { + "type": "number" + }, + "nullable": true, + "type": "array" + }, + "signature": { + "description": "sr25519 signature over nonce:endpoint:sha256(canonical body).", + "type": "string" + }, + "text": { + "nullable": true, + "type": "string" + } + }, + "required": [], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "cid": { + "description": "Content identifier.", + "type": "string" + }, + "error": { + "nullable": true, + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Store text or a raw embedding on this miner.", + "tags": [ + "synapses" + ] + } + }, + "/KeyShareRetrieve": { + "post": { + "operationId": "post_KeyShareRetrieve", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "namespace": { + "type": "string" + }, + "namespace_hotkey": { + "type": "string" + }, + "namespace_sig": { + "description": "sr25519 signature over engram-ns:{namespace}:{namespace_timestamp_ms}.", + "type": "string" + }, + "namespace_timestamp_ms": { + "type": "integer" + } + }, + "required": [ + "namespace" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "error": { + "nullable": true, + "type": "string" + }, + "share_hex": { + "type": "string" + }, + "share_index": { + "type": "integer" + }, + "threshold": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Retrieve this miner's key share for a namespace.", + "tags": [ + "key shares" + ] + } + }, + "/KeyShareSynapse": { + "post": { + "operationId": "post_KeyShareSynapse", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "namespace": { + "type": "string" + }, + "namespace_hotkey": { + "type": "string" + }, + "namespace_sig": { + "description": "sr25519 signature over engram-ns:{namespace}:{namespace_timestamp_ms}.", + "type": "string" + }, + "namespace_timestamp_ms": { + "type": "integer" + }, + "share_hex": { + "type": "string" + }, + "share_index": { + "type": "integer" + }, + "threshold": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "namespace", + "share_index", + "share_hex", + "threshold", + "total" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "error": { + "nullable": true, + "type": "string" + }, + "stored": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Store one Shamir key share for a namespace.", + "tags": [ + "key shares" + ] + } + }, + "/QuerySynapse": { + "post": { + "operationId": "post_QuerySynapse", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "endpoint": { + "description": "Logical endpoint name used when signing; implied by the route.", + "type": "string" + }, + "filter": { + "additionalProperties": true, + "type": "object" + }, + "hotkey": { + "description": "Optional Bittensor SS58 hotkey.", + "type": "string" + }, + "namespace": { + "nullable": true, + "type": "string" + }, + "namespace_hotkey": { + "type": "string" + }, + "namespace_key": { + "deprecated": true, + "nullable": true, + "type": "string" + }, + "namespace_sig": { + "description": "sr25519 signature over engram-ns:{namespace}:{namespace_timestamp_ms}.", + "type": "string" + }, + "namespace_timestamp_ms": { + "type": "integer" + }, + "nonce": { + "description": "Unix timestamp in milliseconds.", + "type": "integer" + }, + "query_text": { + "nullable": true, + "type": "string" + }, + "query_vector": { + "items": { + "type": "number" + }, + "nullable": true, + "type": "array" + }, + "signature": { + "description": "sr25519 signature over nonce:endpoint:sha256(canonical body).", + "type": "string" + }, + "top_k": { + "default": 10, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "required": [], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "error": { + "nullable": true, + "type": "string" + }, + "latency_ms": { + "nullable": true, + "type": "number" + }, + "results": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Query this miner for nearest stored memories.", + "tags": [ + "synapses" + ] + } + }, + "/RepairSynapse": { + "post": { + "operationId": "post_RepairSynapse", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "cid": { + "description": "Content identifier.", + "type": "string" + }, + "endpoint": { + "description": "Logical endpoint name used when signing; implied by the route.", + "type": "string" + }, + "hotkey": { + "description": "Optional Bittensor SS58 hotkey.", + "type": "string" + }, + "nonce": { + "description": "Unix timestamp in milliseconds.", + "type": "integer" + }, + "signature": { + "description": "sr25519 signature over nonce:endpoint:sha256(canonical body).", + "type": "string" + } + }, + "required": [ + "cid" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "cid": { + "description": "Content identifier.", + "type": "string" + }, + "embedding": { + "items": { + "type": "number" + }, + "type": "array" + }, + "metadata": { + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Return a public embedding for validator repair replication.", + "tags": [ + "synapses" + ] + } + }, + "/attestation/{namespace}": { + "get": { + "operationId": "get_attestation_namespace", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "namespace": { + "type": "string" + }, + "owner_hotkey": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Return namespace attestation details.", + "tags": [ + "namespaces" + ] + } + }, + "/chat-history": { + "post": { + "operationId": "post_chat-history", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "message": { + "type": "object" + }, + "user_id": { + "type": "string" + } + }, + "required": [], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "stored": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Append a chat history item.", + "tags": [ + "chat" + ] + } + }, + "/chat-history/{user_id}": { + "get": { + "operationId": "get_chat-history_user_id", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "messages": { + "items": { + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "List chat history for a user.", + "tags": [ + "chat" + ] + } + }, + "/commitment": { + "get": { + "operationId": "get_commitment", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "root": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Return miner Merkle commitment.", + "tags": [ + "proofs" + ] + } + }, + "/conversations": { + "post": { + "operationId": "post_conversations", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "title": { + "type": "string" + }, + "user_id": { + "type": "string" + } + }, + "required": [], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "conversation": { + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Create a conversation.", + "tags": [ + "chat" + ] + } + }, + "/conversations/{conv_id}": { + "delete": { + "operationId": "delete_conversations_conv_id", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "deleted": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Delete a conversation.", + "tags": [ + "chat" + ] + }, + "patch": { + "operationId": "patch_conversations_conv_id", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "metadata": { + "type": "object" + }, + "title": { + "type": "string" + } + }, + "required": [], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "conversation": { + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Update conversation metadata.", + "tags": [ + "chat" + ] + } + }, + "/conversations/{user_id}": { + "get": { + "operationId": "get_conversations_user_id", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "conversations": { + "items": { + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "List conversations for a user.", + "tags": [ + "chat" + ] + } + }, + "/health": { + "get": { + "operationId": "get_health", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Miner health check.", + "tags": [ + "status" + ] + } + }, + "/list": { + "post": { + "operationId": "post_list", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "filter": { + "additionalProperties": true, + "type": "object" + }, + "limit": { + "default": 50, + "maximum": 200, + "type": "integer" + }, + "namespace": { + "default": "__public__", + "type": "string" + }, + "namespace_hotkey": { + "type": "string" + }, + "namespace_sig": { + "description": "sr25519 signature over engram-ns:{namespace}:{namespace_timestamp_ms}.", + "type": "string" + }, + "namespace_timestamp_ms": { + "type": "integer" + }, + "offset": { + "default": 0, + "type": "integer" + } + }, + "required": [], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "items": { + "items": { + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "List stored memories with pagination and optional namespace filter.", + "tags": [ + "memory" + ] + } + }, + "/metagraph": { + "get": { + "operationId": "get_metagraph", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "neurons": { + "items": { + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Return cached subnet metagraph data.", + "tags": [ + "status" + ] + } + }, + "/metrics": { + "get": { + "operationId": "get_metrics", + "responses": { + "200": { + "description": "Prometheus text exposition." + } + }, + "summary": "Prometheus metrics text endpoint.", + "tags": [ + "status" + ] + } + }, + "/namespace": { + "post": { + "operationId": "post_namespace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "namespace": { + "type": "string" + }, + "namespace_hotkey": { + "type": "string" + }, + "namespace_sig": { + "description": "sr25519 signature over engram-ns:{namespace}:{namespace_timestamp_ms}.", + "type": "string" + }, + "namespace_timestamp_ms": { + "type": "integer" + } + }, + "required": [], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "namespace": { + "type": "string" + }, + "registered": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Create or register a namespace owner.", + "tags": [ + "namespaces" + ] + } + }, + "/prove-memory": { + "post": { + "operationId": "post_prove-memory", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "cid": { + "description": "Content identifier.", + "type": "string" + } + }, + "required": [ + "cid" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "error": { + "nullable": true, + "type": "string" + }, + "proof": { + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Return a proof for a stored memory.", + "tags": [ + "proofs" + ] + } + }, + "/retrieve/{cid}": { + "delete": { + "operationId": "delete_retrieve_cid", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "endpoint": { + "description": "Logical endpoint name used when signing; implied by the route.", + "type": "string" + }, + "hotkey": { + "description": "Optional Bittensor SS58 hotkey.", + "type": "string" + }, + "namespace_hotkey": { + "type": "string" + }, + "namespace_sig": { + "description": "sr25519 signature over engram-ns:{namespace}:{namespace_timestamp_ms}.", + "type": "string" + }, + "namespace_timestamp_ms": { + "type": "integer" + }, + "nonce": { + "description": "Unix timestamp in milliseconds.", + "type": "integer" + }, + "signature": { + "description": "sr25519 signature over nonce:endpoint:sha256(canonical body).", + "type": "string" + } + }, + "required": [], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "cid": { + "description": "Content identifier.", + "type": "string" + }, + "deleted": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Delete a stored CID.", + "tags": [ + "memory" + ] + }, + "get": { + "operationId": "get_retrieve_cid", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "cid": { + "description": "Content identifier.", + "type": "string" + }, + "metadata": { + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Return public metadata for a stored CID.", + "tags": [ + "memory" + ] + } + }, + "/stats": { + "get": { + "operationId": "get_stats", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "vectors": { + "type": "integer" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Miner runtime statistics.", + "tags": [ + "status" + ] + } + }, + "/wallet-stats": { + "get": { + "operationId": "get_wallet-stats", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "wallets": { + "items": { + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "List per-hotkey wallet activity.", + "tags": [ + "wallets" + ] + } + }, + "/wallet-stats/{hotkey}": { + "get": { + "operationId": "get_wallet-stats_hotkey", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "hotkey": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "JSON response" + } + }, + "summary": "Return activity for one hotkey.", + "tags": [ + "wallets" + ] + } + } + }, + "servers": [ + { + "description": "Local miner", + "url": "http://localhost:8091" + } + ], + "tags": [ + { + "name": "synapses" + }, + { + "name": "memory" + }, + { + "name": "namespaces" + }, + { + "name": "key shares" + }, + { + "name": "proofs" + }, + { + "name": "chat" + }, + { + "name": "wallets" + }, + { + "name": "status" + } + ] +} diff --git a/docs/miner.md b/docs/miner.md index 138f24b9..d8018002 100644 --- a/docs/miner.md +++ b/docs/miner.md @@ -7,6 +7,25 @@ This guide covers everything needed to run an Engram miner on testnet (subnet 45 --- +## HTTP API Reference + +The miner HTTP API is documented as OpenAPI in [`miner-openapi.json`](miner-openapi.json). +Open [`miner-api.html`](miner-api.html) to view the rendered Redoc reference. + +Regenerate the spec after changing `neurons/miner.py` routes: + +```bash +python scripts/generate_miner_openapi.py +``` + +CI can check that the committed spec still matches the aiohttp route table: + +```bash +python scripts/generate_miner_openapi.py --check +``` + +--- + ## Requirements | Resource | Minimum | Recommended | diff --git a/scripts/generate_miner_openapi.py b/scripts/generate_miner_openapi.py new file mode 100644 index 00000000..10bae880 --- /dev/null +++ b/scripts/generate_miner_openapi.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +"""Generate the miner OpenAPI spec from the aiohttp route table. + +The miner runtime imports Bittensor, FAISS, and other node dependencies, so this +script intentionally does not import ``neurons.miner``. It statically reads the +``app.router.add_*`` calls and overlays endpoint metadata that is stable enough +for SDKs and rendered docs. +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +MINER_PATH = ROOT / "neurons" / "miner.py" +DEFAULT_OUTPUT = ROOT / "docs" / "miner-openapi.json" + +ROUTE_RE = re.compile(r'app\.router\.add_(?P\w+)\("(?P[^"]+)"') + + +SIGNED_BODY_FIELDS: dict[str, Any] = { + "hotkey": {"type": "string", "description": "Optional Bittensor SS58 hotkey."}, + "nonce": {"type": "integer", "description": "Unix timestamp in milliseconds."}, + "signature": { + "type": "string", + "description": "sr25519 signature over nonce:endpoint:sha256(canonical body).", + }, +} + +NAMESPACE_AUTH_FIELDS: dict[str, Any] = { + "namespace_hotkey": {"type": "string"}, + "namespace_sig": { + "type": "string", + "description": "sr25519 signature over engram-ns:{namespace}:{namespace_timestamp_ms}.", + }, + "namespace_timestamp_ms": {"type": "integer"}, +} + + +def _json_body(properties: dict[str, Any], required: list[str] | None = None) -> dict[str, Any]: + return { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": properties, + "required": required or [], + "additionalProperties": True, + } + } + }, + } + + +def _json_response(properties: dict[str, Any]) -> dict[str, Any]: + return { + "description": "JSON response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": properties, + "additionalProperties": True, + } + } + }, + } + + +def extract_routes(source: Path = MINER_PATH) -> list[tuple[str, str]]: + """Return ``[(METHOD, /path), ...]`` from ``neurons/miner.py``.""" + routes: list[tuple[str, str]] = [] + for match in ROUTE_RE.finditer(source.read_text(encoding="utf-8")): + method = match.group("method").upper() + path = match.group("path") + routes.append((method, path)) + return routes + + +def route_metadata() -> dict[tuple[str, str], dict[str, Any]]: + signed = { + **SIGNED_BODY_FIELDS, + "endpoint": { + "type": "string", + "description": "Logical endpoint name used when signing; implied by the route.", + }, + } + cid = {"type": "string", "description": "Content identifier."} + error = {"type": "string", "nullable": True} + + return { + ("POST", "/IngestSynapse"): { + "summary": "Store text or a raw embedding on this miner.", + "tags": ["synapses"], + "requestBody": _json_body( + { + "text": {"type": "string", "nullable": True}, + "raw_embedding": {"type": "array", "items": {"type": "number"}, "nullable": True}, + "metadata": {"type": "object", "additionalProperties": True}, + "model_version": {"type": "string", "default": "v1"}, + "namespace": {"type": "string", "nullable": True}, + "namespace_key": {"type": "string", "nullable": True, "deprecated": True}, + **NAMESPACE_AUTH_FIELDS, + **signed, + } + ), + "responses": {"200": _json_response({"cid": cid, "error": error})}, + }, + ("POST", "/QuerySynapse"): { + "summary": "Query this miner for nearest stored memories.", + "tags": ["synapses"], + "requestBody": _json_body( + { + "query_text": {"type": "string", "nullable": True}, + "query_vector": {"type": "array", "items": {"type": "number"}, "nullable": True}, + "top_k": {"type": "integer", "minimum": 1, "maximum": 100, "default": 10}, + "namespace": {"type": "string", "nullable": True}, + "filter": {"type": "object", "additionalProperties": True}, + "namespace_key": {"type": "string", "nullable": True, "deprecated": True}, + **NAMESPACE_AUTH_FIELDS, + **signed, + } + ), + "responses": { + "200": _json_response( + { + "results": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, + "latency_ms": {"type": "number", "nullable": True}, + "error": error, + } + ) + }, + }, + ("POST", "/ChallengeSynapse"): { + "summary": "Return a storage proof for a validator challenge.", + "tags": ["synapses"], + "requestBody": _json_body( + { + "cid": cid, + "nonce_hex": {"type": "string"}, + "expires_at": {"type": "integer"}, + "validator_hotkey_hex": {"type": "string"}, + **signed, + }, + ["cid", "nonce_hex", "expires_at"], + ), + "responses": { + "200": _json_response( + {"embedding_hash": {"type": "string"}, "proof": {"type": "string"}, "error": error} + ) + }, + }, + ("POST", "/namespace"): { + "summary": "Create or register a namespace owner.", + "tags": ["namespaces"], + "requestBody": _json_body({"namespace": {"type": "string"}, **NAMESPACE_AUTH_FIELDS}), + "responses": {"200": _json_response({"namespace": {"type": "string"}, "registered": {"type": "boolean"}})}, + }, + ("POST", "/AttestNamespace"): { + "summary": "Attest a namespace to a Bittensor hotkey.", + "tags": ["namespaces"], + "requestBody": _json_body( + { + "namespace": {"type": "string"}, + "owner_hotkey": {"type": "string"}, + "signature": {"type": "string"}, + "timestamp_ms": {"type": "integer"}, + }, + ["namespace", "owner_hotkey", "signature", "timestamp_ms"], + ), + "responses": {"200": _json_response({"attested": {"type": "boolean"}, "error": error})}, + }, + ("GET", "/attestation/{namespace}"): { + "summary": "Return namespace attestation details.", + "tags": ["namespaces"], + "responses": {"200": _json_response({"namespace": {"type": "string"}, "owner_hotkey": {"type": "string"}})}, + }, + ("GET", "/chat-history/{user_id}"): { + "summary": "List chat history for a user.", + "tags": ["chat"], + "responses": {"200": _json_response({"messages": {"type": "array", "items": {"type": "object"}}})}, + }, + ("POST", "/chat-history"): { + "summary": "Append a chat history item.", + "tags": ["chat"], + "requestBody": _json_body({"user_id": {"type": "string"}, "message": {"type": "object"}}), + "responses": {"200": _json_response({"stored": {"type": "boolean"}})}, + }, + ("GET", "/conversations/{user_id}"): { + "summary": "List conversations for a user.", + "tags": ["chat"], + "responses": {"200": _json_response({"conversations": {"type": "array", "items": {"type": "object"}}})}, + }, + ("POST", "/conversations"): { + "summary": "Create a conversation.", + "tags": ["chat"], + "requestBody": _json_body({"user_id": {"type": "string"}, "title": {"type": "string"}}), + "responses": {"200": _json_response({"conversation": {"type": "object"}})}, + }, + ("PATCH", "/conversations/{conv_id}"): { + "summary": "Update conversation metadata.", + "tags": ["chat"], + "requestBody": _json_body({"title": {"type": "string"}, "metadata": {"type": "object"}}), + "responses": {"200": _json_response({"conversation": {"type": "object"}})}, + }, + ("DELETE", "/conversations/{conv_id}"): { + "summary": "Delete a conversation.", + "tags": ["chat"], + "responses": {"200": _json_response({"deleted": {"type": "boolean"}})}, + }, + ("GET", "/retrieve/{cid}"): { + "summary": "Return public metadata for a stored CID.", + "tags": ["memory"], + "responses": {"200": _json_response({"cid": cid, "metadata": {"type": "object"}})}, + }, + ("DELETE", "/retrieve/{cid}"): { + "summary": "Delete a stored CID.", + "tags": ["memory"], + "requestBody": _json_body({**NAMESPACE_AUTH_FIELDS, **signed}), + "responses": {"200": _json_response({"deleted": {"type": "boolean"}, "cid": cid})}, + }, + ("POST", "/RepairSynapse"): { + "summary": "Return a public embedding for validator repair replication.", + "tags": ["synapses"], + "requestBody": _json_body({"cid": cid, **signed}, ["cid"]), + "responses": { + "200": _json_response( + {"cid": cid, "embedding": {"type": "array", "items": {"type": "number"}}, "metadata": {"type": "object"}} + ) + }, + }, + ("POST", "/KeyShareSynapse"): { + "summary": "Store one Shamir key share for a namespace.", + "tags": ["key shares"], + "requestBody": _json_body( + { + "namespace": {"type": "string"}, + "share_index": {"type": "integer"}, + "share_hex": {"type": "string"}, + "threshold": {"type": "integer"}, + "total": {"type": "integer"}, + **NAMESPACE_AUTH_FIELDS, + }, + ["namespace", "share_index", "share_hex", "threshold", "total"], + ), + "responses": {"200": _json_response({"stored": {"type": "boolean"}, "error": error})}, + }, + ("POST", "/KeyShareRetrieve"): { + "summary": "Retrieve this miner's key share for a namespace.", + "tags": ["key shares"], + "requestBody": _json_body({"namespace": {"type": "string"}, **NAMESPACE_AUTH_FIELDS}, ["namespace"]), + "responses": { + "200": _json_response( + { + "share_index": {"type": "integer"}, + "share_hex": {"type": "string"}, + "threshold": {"type": "integer"}, + "total": {"type": "integer"}, + "error": error, + } + ) + }, + }, + ("POST", "/list"): { + "summary": "List stored memories with pagination and optional namespace filter.", + "tags": ["memory"], + "requestBody": _json_body( + { + "filter": {"type": "object", "additionalProperties": True}, + "limit": {"type": "integer", "default": 50, "maximum": 200}, + "offset": {"type": "integer", "default": 0}, + "namespace": {"type": "string", "default": "__public__"}, + **NAMESPACE_AUTH_FIELDS, + } + ), + "responses": {"200": _json_response({"items": {"type": "array", "items": {"type": "object"}}})}, + }, + ("GET", "/health"): {"summary": "Miner health check.", "tags": ["status"], "responses": {"200": _json_response({"ok": {"type": "boolean"}})}}, + ("GET", "/stats"): {"summary": "Miner runtime statistics.", "tags": ["status"], "responses": {"200": _json_response({"vectors": {"type": "integer"}})}}, + ("GET", "/metagraph"): {"summary": "Return cached subnet metagraph data.", "tags": ["status"], "responses": {"200": _json_response({"neurons": {"type": "array", "items": {"type": "object"}}})}}, + ("GET", "/metrics"): {"summary": "Prometheus metrics text endpoint.", "tags": ["status"], "responses": {"200": {"description": "Prometheus text exposition."}}}, + ("GET", "/wallet-stats"): {"summary": "List per-hotkey wallet activity.", "tags": ["wallets"], "responses": {"200": _json_response({"wallets": {"type": "array", "items": {"type": "object"}}})}}, + ("GET", "/wallet-stats/{hotkey}"): {"summary": "Return activity for one hotkey.", "tags": ["wallets"], "responses": {"200": _json_response({"hotkey": {"type": "string"}})}}, + ("GET", "/commitment"): {"summary": "Return miner Merkle commitment.", "tags": ["proofs"], "responses": {"200": _json_response({"root": {"type": "string"}})}}, + ("POST", "/prove-memory"): {"summary": "Return a proof for a stored memory.", "tags": ["proofs"], "requestBody": _json_body({"cid": cid}, ["cid"]), "responses": {"200": _json_response({"proof": {"type": "object"}, "error": error})}}, + } + + +def _openapi_path(path: str) -> str: + return path + + +def build_spec() -> dict[str, Any]: + routes = extract_routes() + meta = route_metadata() + paths: dict[str, Any] = {} + + for method, path in routes: + operation = meta.get((method, path), {}).copy() + if not operation: + operation = { + "summary": f"{method} {path}", + "tags": ["miner"], + "responses": {"200": _json_response({"error": {"type": "string", "nullable": True}})}, + } + operation.setdefault("operationId", f"{method.lower()}_{path.strip('/').replace('/', '_').replace('{', '').replace('}', '') or 'root'}") + operation.setdefault("responses", {"200": {"description": "Success"}}) + paths.setdefault(_openapi_path(path), {})[method.lower()] = operation + + return { + "openapi": "3.1.0", + "info": { + "title": "Engram Miner HTTP API", + "version": "0.1.0", + "description": ( + "Machine-readable API contract for neurons/miner.py. Signed miner requests " + "use hotkey, nonce, and signature fields in the JSON body. Namespace-scoped " + "requests use namespace_hotkey, namespace_sig, and namespace_timestamp_ms." + ), + }, + "servers": [{"url": "http://localhost:8091", "description": "Local miner"}], + "tags": [ + {"name": "synapses"}, + {"name": "memory"}, + {"name": "namespaces"}, + {"name": "key shares"}, + {"name": "proofs"}, + {"name": "chat"}, + {"name": "wallets"}, + {"name": "status"}, + ], + "components": { + "securitySchemes": { + "SignedBody": { + "type": "apiKey", + "in": "query", + "name": "hotkey/nonce/signature", + "description": "Logical scheme: sr25519 signature fields are sent in JSON request bodies.", + }, + "NamespaceSignature": { + "type": "apiKey", + "in": "query", + "name": "namespace_hotkey/namespace_sig/namespace_timestamp_ms", + "description": "Logical scheme: namespace ownership proof fields are sent in JSON request bodies.", + }, + } + }, + "paths": paths, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + parser.add_argument("--check", action="store_true", help="Fail if the output file is stale.") + args = parser.parse_args() + + rendered = json.dumps(build_spec(), indent=2, sort_keys=True) + "\n" + if args.check: + current = args.output.read_text(encoding="utf-8") + if current != rendered: + raise SystemExit(f"{args.output} is stale; run scripts/generate_miner_openapi.py") + return + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(rendered, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tests/test_miner_openapi.py b/tests/test_miner_openapi.py new file mode 100644 index 00000000..9f3ec538 --- /dev/null +++ b/tests/test_miner_openapi.py @@ -0,0 +1,23 @@ +import json + +from scripts.generate_miner_openapi import build_spec, extract_routes + + +def test_miner_openapi_paths_match_aiohttp_routes() -> None: + spec = build_spec() + spec_routes = { + (method.upper(), path) + for path, methods in spec["paths"].items() + for method in methods + } + + assert spec_routes == set(extract_routes()) + + +def test_checked_in_miner_openapi_is_current() -> None: + with open("docs/miner-openapi.json", encoding="utf-8") as fh: + checked_in = json.load(fh) + + assert checked_in == build_spec() + assert "SignedBody" in checked_in["components"]["securitySchemes"] + assert "NamespaceSignature" in checked_in["components"]["securitySchemes"]