From 3286f77e278d09def1f27c54bd52011c42a106d4 Mon Sep 17 00:00:00 2001 From: Dobromir Popov Date: Mon, 29 Jun 2026 10:29:19 +0300 Subject: [PATCH] feat: add meshnet python sdk --- .../distributed-inference-network/prd.json | 5 +- packages/gateway/meshnet_gateway/server.py | 403 ++++++++++++++---- packages/sdk/meshnet/__init__.py | 16 + packages/sdk/meshnet/__init__.pyi | 10 + packages/sdk/meshnet/client.py | 167 ++++++++ packages/sdk/meshnet/client.pyi | 64 +++ packages/sdk/meshnet/py.typed | 1 + packages/sdk/pyproject.toml | 4 + packages/tracker/meshnet_tracker/server.py | 124 +++++- tests/test_meshnet_sdk.py | 143 +++++++ 10 files changed, 850 insertions(+), 87 deletions(-) create mode 100644 packages/sdk/meshnet/__init__.pyi create mode 100644 packages/sdk/meshnet/client.py create mode 100644 packages/sdk/meshnet/client.pyi create mode 100644 packages/sdk/meshnet/py.typed create mode 100644 tests/test_meshnet_sdk.py diff --git a/.scratch/distributed-inference-network/prd.json b/.scratch/distributed-inference-network/prd.json index 927a209..c1dd83a 100644 --- a/.scratch/distributed-inference-network/prd.json +++ b/.scratch/distributed-inference-network/prd.json @@ -251,12 +251,13 @@ "Commit only this story's code changes with a focused conventional commit message" ], "priority": 10, - "passes": false, + "passes": true, "notes": "Source issue: .scratch/distributed-inference-network/issues/10-meshnet-sdk.md", "dependsOn": [ "US-005", "US-006" - ] + ], + "completionNotes": "Completed by fresh Ralph/Codex session ef4eea0e; verified by pytest, compileall, editable SDK install, and diff check." } ], "metadata": { diff --git a/packages/gateway/meshnet_gateway/server.py b/packages/gateway/meshnet_gateway/server.py index e117bdc..77961f1 100644 --- a/packages/gateway/meshnet_gateway/server.py +++ b/packages/gateway/meshnet_gateway/server.py @@ -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) diff --git a/packages/sdk/meshnet/__init__.py b/packages/sdk/meshnet/__init__.py index 7e1c258..ffd9400 100644 --- a/packages/sdk/meshnet/__init__.py +++ b/packages/sdk/meshnet/__init__.py @@ -1,3 +1,19 @@ """meshnet — Distributed Inference Network Python SDK.""" +from .client import ( + Client, + CostEstimate, + RedundantRequestResult, + TopUpQuote, + WalletBalance, +) + __version__ = "0.1.0" + +__all__ = [ + "Client", + "CostEstimate", + "RedundantRequestResult", + "TopUpQuote", + "WalletBalance", +] diff --git a/packages/sdk/meshnet/__init__.pyi b/packages/sdk/meshnet/__init__.pyi new file mode 100644 index 0000000..2d3b307 --- /dev/null +++ b/packages/sdk/meshnet/__init__.pyi @@ -0,0 +1,10 @@ +from .client import ( + Client as Client, + CostEstimate as CostEstimate, + RedundantRequestResult as RedundantRequestResult, + TopUpQuote as TopUpQuote, + WalletBalance as WalletBalance, +) + +__version__: str +__all__: list[str] diff --git a/packages/sdk/meshnet/client.py b/packages/sdk/meshnet/client.py new file mode 100644 index 0000000..c8234fa --- /dev/null +++ b/packages/sdk/meshnet/client.py @@ -0,0 +1,167 @@ +"""Typed Python SDK for the meshnet OpenAI-compatible gateway.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import urllib.parse +import urllib.request +from typing import Any + +import openai +from openai.types.chat import ChatCompletion + + +@dataclass(frozen=True) +class WalletBalance: + """API-key balance in client-facing currencies.""" + + sol: float + usdc: float + + +@dataclass(frozen=True) +class TopUpQuote: + """Payment details for funding an API key account.""" + + payment_address: str + qr: str + amount_sol: float + amount_usdc: float + + +@dataclass(frozen=True) +class CostEstimate: + """Preflight cost estimate for a request.""" + + model: str + tokens: int + lamports: int + sol: float + + +@dataclass(frozen=True) +class RedundantRequestResult: + """Result of sending a prompt through multiple independent routes.""" + + redundancy: int + majority_response: ChatCompletion + responses: list[ChatCompletion] + + +class Client: + """meshnet SDK client. + + ``chat`` is the official OpenAI SDK chat resource, configured against the + meshnet gateway. Network-specific controls live on ``wallet``, ``models``, + ``estimate_cost``, and ``request``. + """ + + def __init__(self, *, api_key: str, base_url: str = "https://api.meshnet.ai") -> None: + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self._openai = openai.OpenAI( + api_key=api_key, + base_url=_openai_base_url(self.base_url), + ) + self.chat = self._openai.chat + self.wallet = _WalletClient(self) + self.models = _ModelsClient(self) + + def estimate_cost(self, *, model: str, tokens: int) -> CostEstimate: + query = urllib.parse.urlencode({"model": model, "tokens": tokens}) + data = self._get_json(f"/v1/estimate_cost?{query}") + return CostEstimate( + model=str(data["model"]), + tokens=int(data["tokens"]), + lamports=int(data["lamports"]), + sol=float(data["sol"]), + ) + + def request( + self, + *, + model: str, + messages: list[dict[str, Any]], + redundancy: int = 1, + **params: Any, + ) -> RedundantRequestResult: + payload: dict[str, Any] = { + "model": model, + "messages": messages, + "redundancy": redundancy, + **params, + } + data = self._post_json("/v1/request", payload) + return RedundantRequestResult( + redundancy=int(data["redundancy"]), + majority_response=_chat_completion(data["majority_response"]), + responses=[_chat_completion(item) for item in data["responses"]], + ) + + def _get_json(self, path: str) -> dict[str, Any]: + req = urllib.request.Request( + f"{self.base_url}{path}", + headers=self._headers(), + method="GET", + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read()) + + def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + data = json.dumps(payload).encode() + req = urllib.request.Request( + f"{self.base_url}{path}", + data=data, + headers={**self._headers(), "Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read()) + + def _headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self.api_key}"} + + +class _WalletClient: + def __init__(self, client: Client) -> None: + self._client = client + + def balance(self) -> WalletBalance: + data = self._client._get_json("/v1/wallet/balance") + return WalletBalance(sol=float(data["sol"]), usdc=float(data["usdc"])) + + def top_up(self, *, amount_sol: float = 0.0, amount_usdc: float = 0.0) -> TopUpQuote: + data = self._client._post_json( + "/v1/wallet/top_up", + {"amount_sol": amount_sol, "amount_usdc": amount_usdc}, + ) + return TopUpQuote( + payment_address=str(data["payment_address"]), + qr=str(data["qr"]), + amount_sol=float(data["amount_sol"]), + amount_usdc=float(data["amount_usdc"]), + ) + + +class _ModelsClient: + def __init__(self, client: Client) -> None: + self._client = client + + def available(self) -> list[dict[str, float | str]]: + data = self._client._get_json("/v1/models/available") + return [ + { + "id": str(model["id"]), + "shard_coverage_percentage": float(model["shard_coverage_percentage"]), + } + for model in data["data"] + ] + + +def _openai_base_url(base_url: str) -> str: + return base_url if base_url.endswith("/v1") else f"{base_url}/v1" + + +def _chat_completion(data: dict[str, Any]) -> ChatCompletion: + return ChatCompletion.model_validate(data) diff --git a/packages/sdk/meshnet/client.pyi b/packages/sdk/meshnet/client.pyi new file mode 100644 index 0000000..e13a379 --- /dev/null +++ b/packages/sdk/meshnet/client.pyi @@ -0,0 +1,64 @@ +from typing import Any + +from openai.resources.chat.chat import Chat +from openai.types.chat import ChatCompletion + +class WalletBalance: + sol: float + usdc: float + def __init__(self, sol: float, usdc: float) -> None: ... + +class TopUpQuote: + payment_address: str + qr: str + amount_sol: float + amount_usdc: float + def __init__( + self, + payment_address: str, + qr: str, + amount_sol: float, + amount_usdc: float, + ) -> None: ... + +class CostEstimate: + model: str + tokens: int + lamports: int + sol: float + def __init__(self, model: str, tokens: int, lamports: int, sol: float) -> None: ... + +class RedundantRequestResult: + redundancy: int + majority_response: ChatCompletion + responses: list[ChatCompletion] + def __init__( + self, + redundancy: int, + majority_response: ChatCompletion, + responses: list[ChatCompletion], + ) -> None: ... + +class _WalletClient: + def balance(self) -> WalletBalance: ... + def top_up(self, *, amount_sol: float = 0.0, amount_usdc: float = 0.0) -> TopUpQuote: ... + +class _ModelsClient: + def available(self) -> list[dict[str, float | str]]: ... + +class Client: + api_key: str + base_url: str + chat: Chat + wallet: _WalletClient + models: _ModelsClient + def __init__(self, *, api_key: str, base_url: str = "https://api.meshnet.ai") -> None: ... + def estimate_cost(self, *, model: str, tokens: int) -> CostEstimate: ... + def request( + self, + *, + model: str, + messages: list[dict[str, Any]], + redundancy: int = 1, + **params: Any, + ) -> RedundantRequestResult: ... diff --git a/packages/sdk/meshnet/py.typed b/packages/sdk/meshnet/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/sdk/meshnet/py.typed @@ -0,0 +1 @@ + diff --git a/packages/sdk/pyproject.toml b/packages/sdk/pyproject.toml index 3bf664e..4df10d8 100644 --- a/packages/sdk/pyproject.toml +++ b/packages/sdk/pyproject.toml @@ -7,7 +7,11 @@ name = "meshnet" version = "0.1.0" description = "Distributed Inference Network Python SDK" requires-python = ">=3.10" +dependencies = ["openai>=1"] [tool.setuptools.packages.find] where = ["."] include = ["meshnet*"] + +[tool.setuptools.package-data] +meshnet = ["py.typed", "*.pyi"] diff --git a/packages/tracker/meshnet_tracker/server.py b/packages/tracker/meshnet_tracker/server.py index 110c6b9..c8b8992 100644 --- a/packages/tracker/meshnet_tracker/server.py +++ b/packages/tracker/meshnet_tracker/server.py @@ -16,6 +16,9 @@ HTTP API contract: - GET /v1/route?model= Response 200: {"route": ["http://node-a", "http://node-b"]} Response 400/404/503: {"error": str} +- GET /v1/routes?model=&redundancy= + Response 200: {"routes": [{"route": [...], "nodes": [...]}]} + Response 400/404/503: {"error": str} """ import http.server @@ -89,6 +92,36 @@ def _select_route( return route, "" +def _coverage_percentage( + nodes: list[_NodeEntry], + required_start: int, + required_end: int, +) -> float: + required_layers = required_end - required_start + 1 + if required_layers <= 0: + return 0.0 + + intervals = sorted( + ( + (max(required_start, node.shard_start), min(required_end, node.shard_end)) + for node in nodes + if node.shard_end >= required_start and node.shard_start <= required_end + ), + key=lambda interval: interval[0], + ) + covered = 0 + covered_until = required_start - 1 + for start, end in intervals: + if end <= covered_until: + continue + next_start = max(start, covered_until + 1) + if next_start > end: + continue + covered += end - next_start + 1 + covered_until = end + return round((covered / required_layers) * 100, 2) + + def _registration_ban_error(contracts: Any | None, wallet_address: str | None) -> str | None: if contracts is None or not wallet_address: return None @@ -168,6 +201,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): parsed = urllib.parse.urlparse(self.path) if parsed.path == "/v1/route": self._handle_route(parsed) + elif parsed.path == "/v1/routes": + self._handle_routes(parsed) elif parsed.path == "/v1/nodes/assign": self._handle_assign(parsed) elif parsed.path == "/v1/models": @@ -179,10 +214,29 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): def _handle_models(self): server: _TrackerHTTPServer = self.server # type: ignore[assignment] created = int(time.time()) - data = [ - {"id": name, "object": "model", "created": created, "owned_by": "meshnet"} - for name in server.model_presets - ] + with server.lock: + self._purge_expired_nodes() + alive = list(server.registry.values()) + if server.contracts is not None: + alive = [ + node for node in alive + if not node.wallet_address or not server.contracts.registry.get_wallet(node.wallet_address).banned + ] + data = [] + for name, preset in server.model_presets.items(): + model_nodes = [node for node in alive if node.model == name] + coverage = _coverage_percentage( + model_nodes, + preset["layers_start"], + preset["layers_end"], + ) + data.append({ + "id": name, + "object": "model", + "created": created, + "owned_by": "meshnet", + "shard_coverage_percentage": coverage, + }) self._send_json(200, {"object": "list", "data": data}) def _handle_register(self): @@ -400,6 +454,68 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): ], }) + def _handle_routes(self, parsed: urllib.parse.ParseResult): + server: _TrackerHTTPServer = self.server # type: ignore[assignment] + params = urllib.parse.parse_qs(parsed.query) + model_list = params.get("model") + if not model_list: + self._send_json(400, {"error": "missing 'model' query parameter"}) + return + + try: + redundancy = int(params.get("redundancy", ["1"])[0]) + except ValueError: + self._send_json(400, {"error": "redundancy must be an integer"}) + return + if redundancy < 1: + self._send_json(400, {"error": "redundancy must be at least 1"}) + return + + model = model_list[0] + preset = server.model_presets.get(model) + if preset is None: + self._send_json(404, {"error": f"unknown model preset: {model!r}"}) + return + + required_start: int = preset["layers_start"] + required_end: int = preset["layers_end"] + + with server.lock: + self._purge_expired_nodes() + candidates = [node for node in server.registry.values() if node.model == model] + if server.contracts is not None: + candidates = [ + node for node in candidates + if not node.wallet_address or not server.contracts.registry.get_wallet(node.wallet_address).banned + ] + + routes = [] + remaining = list(candidates) + for _ in range(redundancy): + route, error = _select_route(remaining, required_start, required_end) + if error: + self._send_json(503, {"error": error}) + return + route_endpoints = {node.endpoint for node in route} + remaining = [node for node in remaining if node.endpoint not in route_endpoints] + routes.append({ + "route": [e.endpoint for e in route], + "nodes": [ + { + "endpoint": e.endpoint, + "wallet_address": e.wallet_address, + "shard_start": e.shard_start, + "shard_end": e.shard_end, + "model": e.model, + "shard_checksum": e.shard_checksum, + "score": e.score, + } + for e in route + ], + }) + + self._send_json(200, {"routes": routes}) + class TrackerServer: """HTTP tracker that manages node registration and resolves inference routes. diff --git a/tests/test_meshnet_sdk.py b/tests/test_meshnet_sdk.py new file mode 100644 index 0000000..50d4174 --- /dev/null +++ b/tests/test_meshnet_sdk.py @@ -0,0 +1,143 @@ +"""US-010 integration tests: typed meshnet SDK over local gateway/tracker/nodes.""" + +import json +import urllib.request + +from meshnet import Client +from meshnet_contracts import LocalSolanaContracts +from meshnet_gateway.server import GatewayServer, _payment_address_for_api_key +from meshnet_node.server import StubNodeServer +from meshnet_tracker.server import TrackerServer, _NodeEntry, _coverage_percentage + + +def test_sdk_support_helpers_are_deterministic_without_network(): + """Non-socket coverage for SDK-facing gateway/tracker helper behavior.""" + address = _payment_address_for_api_key("sdk-key") + nodes = [ + _NodeEntry("a", "http://node-a", 0, 15, "stub-model", None, {}, None, 1.0), + _NodeEntry("b", "http://node-b", 16, 31, "stub-model", None, {}, None, 1.0), + ] + + assert 32 <= len(address) <= 44 + assert _coverage_percentage(nodes, 0, 31) == 100.0 + + +def _post_json(url: str, payload: dict) -> dict: + data = json.dumps(payload).encode() + req = urllib.request.Request( + url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req) as r: + return json.loads(r.read()) + + +def _register_node( + tracker_url: str, + endpoint: str, + shard_start: int, + shard_end: int, + *, + wallet_address: str, +) -> None: + _post_json( + f"{tracker_url}/v1/nodes/register", + { + "endpoint": endpoint, + "shard_start": shard_start, + "shard_end": shard_end, + "hardware_profile": {}, + "wallet_address": wallet_address, + "score": 1.0, + }, + ) + + +def test_meshnet_sdk_methods_against_local_network(): + """SDK covers OpenAI chat passthrough plus meshnet wallet/model/cost/request APIs.""" + contracts = LocalSolanaContracts(starting_credit_lamports=0) + contracts.payment.fund_api_key("sdk-key", lamports=2_000_000_000, usdc_micro=123_000_000) + + tracker = TrackerServer(contracts=contracts) + tracker_port = tracker.start() + tracker_url = f"http://127.0.0.1:{tracker_port}" + + nodes = [ + StubNodeServer(shard_start=0, shard_end=15, is_last_shard=False), + StubNodeServer(shard_start=16, shard_end=31, is_last_shard=True), + StubNodeServer(shard_start=0, shard_end=15, is_last_shard=False), + StubNodeServer(shard_start=16, shard_end=31, is_last_shard=True), + ] + node_ports = [node.start() for node in nodes] + + for index, (port, shard_start, shard_end) in enumerate( + [ + (node_ports[0], 0, 15), + (node_ports[1], 16, 31), + (node_ports[2], 0, 15), + (node_ports[3], 16, 31), + ], + start=1, + ): + _register_node( + tracker_url, + f"http://127.0.0.1:{port}", + shard_start, + shard_end, + wallet_address=f"wallet-{index}", + ) + + gateway = GatewayServer(tracker_url=tracker_url, contracts=contracts) + gateway_port = gateway.start() + gateway_url = f"http://127.0.0.1:{gateway_port}" + + try: + client = Client(api_key="sdk-key", base_url=gateway_url) + + chat_response = client.chat.completions.create( + model="stub-model", + messages=[{"role": "user", "content": "hello sdk"}], + ) + assert chat_response.choices[0].message.content == "stub response to: hello sdk" + + balance = client.wallet.balance() + assert balance.sol == 2.0 + assert balance.usdc == 123.0 + + top_up = client.wallet.top_up(amount_sol=1.0) + assert top_up.amount_sol == 1.0 + assert 32 <= len(top_up.payment_address) <= 44 + assert top_up.qr.startswith("solana:") + + available_models = client.models.available() + assert available_models == [ + { + "id": "stub-model", + "shard_coverage_percentage": 100.0, + } + ] + + estimate = client.estimate_cost(model="stub-model", tokens=1_000) + assert estimate.model == "stub-model" + assert estimate.tokens == 1_000 + assert estimate.sol == 0.000032 + + redundant = client.request( + model="stub-model", + messages=[{"role": "user", "content": "redundant"}], + redundancy=2, + ) + assert redundant.redundancy == 2 + assert redundant.majority_response.choices[0].message.content == ( + "stub response to: redundant" + ) + assert len(redundant.responses) == 2 + assert nodes[1].received_activations + assert nodes[3].received_activations + finally: + gateway.stop() + for node in nodes: + node.stop() + tracker.stop()