feat: add meshnet python sdk

This commit is contained in:
Dobromir Popov
2026-06-29 10:29:19 +03:00
parent e9c9bf63bc
commit 3286f77e27
10 changed files with 850 additions and 87 deletions

View File

@@ -1,7 +1,9 @@
"""Gateway HTTP server — accepts OpenAI-format requests and routes to inference nodes."""
import http.server
import hashlib
import json
from collections import Counter
import threading
import time
import urllib.error
@@ -21,6 +23,7 @@ class _GatewayHTTPServer(http.server.HTTPServer):
model_presets: list[str] | None,
contracts: Any | None,
minimum_stake: int,
cost_per_layer_token_lamport: int,
):
super().__init__(addr, handler)
self.inference_route = inference_route
@@ -28,6 +31,7 @@ class _GatewayHTTPServer(http.server.HTTPServer):
self.model_presets = model_presets
self.contracts = contracts
self.minimum_stake = minimum_stake
self.cost_per_layer_token_lamport = cost_per_layer_token_lamport
class _GatewayHandler(http.server.BaseHTTPRequestHandler):
@@ -35,17 +39,29 @@ class _GatewayHandler(http.server.BaseHTTPRequestHandler):
pass
def do_GET(self):
if self.path == "/v1/health":
parsed = urllib.parse.urlparse(self.path)
if parsed.path == "/v1/health":
self._handle_health()
elif self.path == "/v1/models":
elif parsed.path == "/v1/models":
self._handle_models()
elif parsed.path == "/v1/models/available":
self._handle_available_models()
elif parsed.path == "/v1/wallet/balance":
self._handle_wallet_balance()
elif parsed.path == "/v1/estimate_cost":
self._handle_estimate_cost(parsed)
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
if self.path == "/v1/chat/completions":
parsed = urllib.parse.urlparse(self.path)
if parsed.path == "/v1/chat/completions":
self._handle_chat_completions()
elif parsed.path == "/v1/wallet/top_up":
self._handle_wallet_top_up()
elif parsed.path == "/v1/request":
self._handle_meshnet_request()
else:
self.send_response(404)
self.end_headers()
@@ -71,6 +87,18 @@ class _GatewayHandler(http.server.BaseHTTPRequestHandler):
}
})
def _read_json_body(self) -> dict | None:
length = int(self.headers.get("Content-Length", 0))
try:
body = json.loads(self.rfile.read(length) or b"{}")
except (json.JSONDecodeError, ValueError):
self._send_json_error(400, "invalid JSON body")
return None
if not isinstance(body, dict):
self._send_json_error(400, "JSON body must be an object")
return None
return body
def _handle_health(self) -> None:
self._send_json(200, {"status": "ok"})
@@ -92,76 +120,236 @@ class _GatewayHandler(http.server.BaseHTTPRequestHandler):
]
self._send_json(200, {"object": "list", "data": data})
def _handle_available_models(self) -> None:
server: _GatewayHTTPServer = self.server # type: ignore[assignment]
if server.tracker_url is not None:
try:
models_resp = _get_json(f"{server.tracker_url}/v1/models")
except Exception:
self._send_json_error(503, "tracker unavailable")
return
data = [
{
"id": model["id"],
"shard_coverage_percentage": float(model.get("shard_coverage_percentage", 0.0)),
}
for model in models_resp.get("data", [])
if isinstance(model, dict) and isinstance(model.get("id"), str)
]
self._send_json(200, {"data": data})
return
presets = server.model_presets or ["stub-model"]
self._send_json(200, {
"data": [
{"id": model, "shard_coverage_percentage": 100.0}
for model in presets
]
})
def _handle_wallet_balance(self) -> None:
server: _GatewayHTTPServer = self.server # type: ignore[assignment]
api_key = _api_key_from_authorization(self.headers.get("Authorization"))
if not api_key:
self._send_json_error(401, "missing API key")
return
if server.contracts is None:
self._send_json(200, {"sol": 0.0, "usdc": 0.0})
return
balance = server.contracts.payment.get_balance(api_key)
self._send_json(200, {
"sol": _lamports_to_sol(balance.lamports),
"usdc": balance.usdc_micro / 1_000_000,
})
def _handle_wallet_top_up(self) -> None:
api_key = _api_key_from_authorization(self.headers.get("Authorization"))
if not api_key:
self._send_json_error(401, "missing API key")
return
body = self._read_json_body()
if body is None:
return
try:
amount_sol = float(body.get("amount_sol", 0.0))
amount_usdc = float(body.get("amount_usdc", 0.0))
except (TypeError, ValueError):
self._send_json_error(400, "top-up amount must be numeric")
return
if amount_sol <= 0 and amount_usdc <= 0:
self._send_json_error(400, "top-up amount must be positive")
return
payment_address = _payment_address_for_api_key(api_key)
query = urllib.parse.urlencode({
"amount": amount_sol,
"spl-token": "USDC" if amount_usdc > 0 else "SOL",
})
self._send_json(200, {
"payment_address": payment_address,
"qr": f"solana:{payment_address}?{query}",
"amount_sol": amount_sol,
"amount_usdc": amount_usdc,
})
def _handle_estimate_cost(self, parsed: urllib.parse.ParseResult) -> None:
server: _GatewayHTTPServer = self.server # type: ignore[assignment]
params = urllib.parse.parse_qs(parsed.query)
model = params.get("model", [""])[0]
try:
tokens = int(params.get("tokens", ["0"])[0])
except ValueError:
self._send_json_error(400, "tokens must be an integer")
return
if not model:
self._send_json_error(400, "model is required")
return
if tokens <= 0:
self._send_json_error(400, "tokens must be positive")
return
layer_count = 32
if server.tracker_url is None and server.model_presets is not None and model not in server.model_presets:
self._send_json_error(404, "model not available")
return
lamports = layer_count * tokens * server.cost_per_layer_token_lamport
self._send_json(200, {
"model": model,
"tokens": tokens,
"lamports": lamports,
"sol": _lamports_to_sol(lamports),
})
def _handle_chat_completions(self):
server: _GatewayHTTPServer = self.server # type: ignore[assignment]
length = int(self.headers.get("Content-Length", 0))
try:
body = json.loads(self.rfile.read(length))
except (json.JSONDecodeError, ValueError):
self._send_openai_error(400, "invalid JSON body", "invalid_request_error")
body = self._read_json_body()
if body is None:
return
streaming = bool(body.get("stream", False))
if server.tracker_url is not None:
model = body.get("model", "stub-model")
tracker_path = f"{server.tracker_url}/v1/route?model={urllib.parse.quote(model)}"
try:
tracker_resp = _get_json(tracker_path)
route = tracker_resp["route"]
if (
not isinstance(route, list)
or not route
or not all(isinstance(node_url, str) and node_url for node_url in route)
):
raise ValueError("invalid tracker route")
route_nodes = tracker_resp.get("nodes", [])
if route_nodes and not _valid_route_nodes(route_nodes):
raise ValueError("invalid tracker route metadata")
banned_wallet = _banned_route_wallet(server.contracts, route_nodes)
if banned_wallet is not None:
self._send_openai_error(
503,
f"model not available: route includes banned wallet {banned_wallet}",
"model_not_available",
)
return
except urllib.error.HTTPError as exc:
error_body = _safe_error_body(exc)
self._send_openai_error(
503,
error_body.get("error", "model not available"),
"model_not_available",
)
return
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, TypeError, ValueError):
self._send_json_error(503, "tracker unavailable")
return
else:
route = server.inference_route # type: ignore[assignment]
route_nodes = []
messages = body.get("messages", [])
try:
# Send messages to the first node in the inference route.
node_resp = _post_json(f"{route[0]}/v1/infer", {"messages": messages})
# Forward activations through remaining nodes in the route.
for node_url in route[1:]:
node_resp = _post_json(f"{node_url}/v1/infer", {"activations": node_resp["activations"]})
completion = self._build_completion(body)
except _ModelUnavailable as exc:
self._send_openai_error(503, exc.message, "model_not_available")
return
except _TrackerUnavailable:
self._send_json_error(503, "tracker unavailable")
return
except (urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError) as exc:
self._send_openai_error(503, f"inference pipeline error: {exc}", "pipeline_error")
return
if streaming:
text = completion["choices"][0]["message"]["content"]
self._send_sse_response(text, completion["model"])
else:
self._send_json(200, completion)
def _handle_meshnet_request(self) -> None:
body = self._read_json_body()
if body is None:
return
try:
redundancy = int(body.get("redundancy", 1))
except (TypeError, ValueError):
self._send_json_error(400, "redundancy must be an integer")
return
if redundancy < 1:
self._send_json_error(400, "redundancy must be at least 1")
return
try:
routes = self._resolve_routes(str(body.get("model", "stub-model")), redundancy)
responses = [self._build_completion(body, route_payload=route_payload) for route_payload in routes]
except _ModelUnavailable as exc:
self._send_openai_error(503, exc.message, "model_not_available")
return
except _TrackerUnavailable:
self._send_json_error(503, "tracker unavailable")
return
except (urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError) as exc:
self._send_openai_error(503, f"inference pipeline error: {exc}", "pipeline_error")
return
majority_response = _majority_response(responses)
self._send_json(200, {
"redundancy": redundancy,
"majority_response": majority_response,
"responses": responses,
})
def _build_completion(self, body: dict, route_payload: dict | None = None) -> dict:
server: _GatewayHTTPServer = self.server # type: ignore[assignment]
if route_payload is None:
route_payload = self._resolve_route(str(body.get("model", "stub-model")))
route = route_payload["route"]
route_nodes = route_payload.get("nodes", [])
messages = body.get("messages", [])
node_resp = _post_json(f"{route[0]}/v1/infer", {"messages": messages})
for node_url in route[1:]:
node_resp = _post_json(f"{node_url}/v1/infer", {"activations": node_resp["activations"]})
text = node_resp["text"]
model = body.get("model", "stub-model")
self._record_inference(model, messages, text, route_nodes)
return _completion_response(str(model), text)
def _resolve_route(self, model: str) -> dict:
server: _GatewayHTTPServer = self.server # type: ignore[assignment]
if server.tracker_url is not None:
tracker_path = f"{server.tracker_url}/v1/route?model={urllib.parse.quote(model)}"
try:
tracker_resp = _get_json(tracker_path)
_validate_route_payload(tracker_resp)
banned_wallet = _banned_route_wallet(server.contracts, tracker_resp.get("nodes", []))
if banned_wallet is not None:
raise _ModelUnavailable(f"model not available: route includes banned wallet {banned_wallet}")
return tracker_resp
except urllib.error.HTTPError as exc:
error_body = _safe_error_body(exc)
raise _ModelUnavailable(error_body.get("error", "model not available")) from exc
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
raise _TrackerUnavailable() from exc
return {"route": server.inference_route, "nodes": []}
def _resolve_routes(self, model: str, redundancy: int) -> list[dict]:
server: _GatewayHTTPServer = self.server # type: ignore[assignment]
if server.tracker_url is None:
return [self._resolve_route(model) for _ in range(redundancy)]
tracker_path = (
f"{server.tracker_url}/v1/routes?"
f"model={urllib.parse.quote(model)}&redundancy={redundancy}"
)
try:
tracker_resp = _get_json(tracker_path)
routes = tracker_resp["routes"]
if not isinstance(routes, list) or len(routes) != redundancy:
raise ValueError("invalid tracker routes")
for route_payload in routes:
_validate_route_payload(route_payload)
banned_wallet = _banned_route_wallet(server.contracts, route_payload.get("nodes", []))
if banned_wallet is not None:
raise _ModelUnavailable(f"model not available: route includes banned wallet {banned_wallet}")
return routes
except urllib.error.HTTPError as exc:
error_body = _safe_error_body(exc)
raise _ModelUnavailable(error_body.get("error", "model not available")) from exc
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
raise _TrackerUnavailable() from exc
def _record_inference(
self,
model: object,
messages: object,
text: str,
route_nodes: object,
) -> None:
server: _GatewayHTTPServer = self.server # type: ignore[assignment]
session_id = str(uuid.uuid4())
if server.contracts is not None and route_nodes:
if server.contracts is not None and isinstance(route_nodes, list):
api_key = _api_key_from_authorization(self.headers.get("Authorization"))
if api_key:
tokens = max(1, _estimate_token_count(messages, text))
for node in route_nodes:
wallet_address = node.get("wallet_address")
wallet_address = node.get("wallet_address") if isinstance(node, dict) else None
if not wallet_address:
continue
server.contracts.payment.record_attribution(
@@ -176,35 +364,12 @@ class _GatewayHandler(http.server.BaseHTTPRequestHandler):
if server.contracts is not None:
server.contracts.validation.record_completed_inference(
session_id=session_id,
model=model,
model=str(model),
messages=messages if isinstance(messages, list) else [],
observed_output=text,
route_nodes=route_nodes,
route_nodes=route_nodes if isinstance(route_nodes, list) else [],
)
if streaming:
self._send_sse_response(text, model)
else:
self._send_json(200, {
"id": "chatcmpl-stub",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": text,
},
"finish_reason": "stop",
}],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
},
})
def _send_sse_response(self, text: str, model: str) -> None:
chunk_id = "chatcmpl-stub"
created = int(time.time())
@@ -282,6 +447,19 @@ def _valid_route_nodes(route_nodes: object) -> bool:
return True
def _validate_route_payload(route_payload: dict) -> None:
route = route_payload["route"]
if (
not isinstance(route, list)
or not route
or not all(isinstance(node_url, str) and node_url for node_url in route)
):
raise ValueError("invalid tracker route")
route_nodes = route_payload.get("nodes", [])
if route_nodes and not _valid_route_nodes(route_nodes):
raise ValueError("invalid tracker route metadata")
def _banned_route_wallet(contracts: Any | None, route_nodes: object) -> str | None:
if contracts is None or not isinstance(route_nodes, list):
return None
@@ -308,6 +486,66 @@ def _estimate_token_count(messages: object, completion_text: str) -> int:
return 1
def _lamports_to_sol(lamports: int) -> float:
return lamports / 1_000_000_000
def _payment_address_for_api_key(api_key: str) -> str:
alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
value = int.from_bytes(hashlib.sha256(f"meshnet:{api_key}".encode()).digest(), "big")
chars = []
while value:
value, remainder = divmod(value, 58)
chars.append(alphabet[remainder])
return "".join(reversed(chars))[:44].rjust(32, "1")
def _completion_response(model: str, text: str) -> dict:
return {
"id": "chatcmpl-stub",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": text,
},
"finish_reason": "stop",
}],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
},
}
def _majority_response(responses: list[dict]) -> dict:
if not responses:
raise ValueError("responses must not be empty")
contents = [
response["choices"][0]["message"]["content"]
for response in responses
]
majority_content, _ = Counter(contents).most_common(1)[0]
for response in responses:
if response["choices"][0]["message"]["content"] == majority_content:
return response
return responses[0]
class _ModelUnavailable(Exception):
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
class _TrackerUnavailable(Exception):
pass
def _post_json(url: str, payload: dict, timeout: float = 5.0) -> dict:
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
@@ -341,6 +579,7 @@ class GatewayServer:
model_presets: list[str] | None = None,
contracts: Any | None = None,
minimum_stake: int = 0,
cost_per_layer_token_lamport: int = 1,
host: str = "127.0.0.1",
port: int = 0,
):
@@ -360,6 +599,7 @@ class GatewayServer:
self._model_presets: list[str] | None = model_presets
self._contracts = contracts
self._minimum_stake = minimum_stake
self._cost_per_layer_token_lamport = cost_per_layer_token_lamport
self._host = host
self._requested_port = port
self._server: _GatewayHTTPServer | None = None
@@ -378,6 +618,7 @@ class GatewayServer:
self._model_presets,
self._contracts,
self._minimum_stake,
self._cost_per_layer_token_lamport,
)
self.port = self._server.server_address[1]
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)