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

@@ -251,12 +251,13 @@
"Commit only this story's code changes with a focused conventional commit message" "Commit only this story's code changes with a focused conventional commit message"
], ],
"priority": 10, "priority": 10,
"passes": false, "passes": true,
"notes": "Source issue: .scratch/distributed-inference-network/issues/10-meshnet-sdk.md", "notes": "Source issue: .scratch/distributed-inference-network/issues/10-meshnet-sdk.md",
"dependsOn": [ "dependsOn": [
"US-005", "US-005",
"US-006" "US-006"
] ],
"completionNotes": "Completed by fresh Ralph/Codex session ef4eea0e; verified by pytest, compileall, editable SDK install, and diff check."
} }
], ],
"metadata": { "metadata": {

View File

@@ -1,7 +1,9 @@
"""Gateway HTTP server — accepts OpenAI-format requests and routes to inference nodes.""" """Gateway HTTP server — accepts OpenAI-format requests and routes to inference nodes."""
import http.server import http.server
import hashlib
import json import json
from collections import Counter
import threading import threading
import time import time
import urllib.error import urllib.error
@@ -21,6 +23,7 @@ class _GatewayHTTPServer(http.server.HTTPServer):
model_presets: list[str] | None, model_presets: list[str] | None,
contracts: Any | None, contracts: Any | None,
minimum_stake: int, minimum_stake: int,
cost_per_layer_token_lamport: int,
): ):
super().__init__(addr, handler) super().__init__(addr, handler)
self.inference_route = inference_route self.inference_route = inference_route
@@ -28,6 +31,7 @@ class _GatewayHTTPServer(http.server.HTTPServer):
self.model_presets = model_presets self.model_presets = model_presets
self.contracts = contracts self.contracts = contracts
self.minimum_stake = minimum_stake self.minimum_stake = minimum_stake
self.cost_per_layer_token_lamport = cost_per_layer_token_lamport
class _GatewayHandler(http.server.BaseHTTPRequestHandler): class _GatewayHandler(http.server.BaseHTTPRequestHandler):
@@ -35,17 +39,29 @@ class _GatewayHandler(http.server.BaseHTTPRequestHandler):
pass pass
def do_GET(self): def do_GET(self):
if self.path == "/v1/health": parsed = urllib.parse.urlparse(self.path)
if parsed.path == "/v1/health":
self._handle_health() self._handle_health()
elif self.path == "/v1/models": elif parsed.path == "/v1/models":
self._handle_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: else:
self.send_response(404) self.send_response(404)
self.end_headers() self.end_headers()
def do_POST(self): 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() 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: else:
self.send_response(404) self.send_response(404)
self.end_headers() 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: def _handle_health(self) -> None:
self._send_json(200, {"status": "ok"}) self._send_json(200, {"status": "ok"})
@@ -92,76 +120,236 @@ class _GatewayHandler(http.server.BaseHTTPRequestHandler):
] ]
self._send_json(200, {"object": "list", "data": data}) 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): def _handle_chat_completions(self):
server: _GatewayHTTPServer = self.server # type: ignore[assignment] server: _GatewayHTTPServer = self.server # type: ignore[assignment]
length = int(self.headers.get("Content-Length", 0)) body = self._read_json_body()
try: if body is None:
body = json.loads(self.rfile.read(length))
except (json.JSONDecodeError, ValueError):
self._send_openai_error(400, "invalid JSON body", "invalid_request_error")
return return
streaming = bool(body.get("stream", False)) 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: try:
tracker_resp = _get_json(tracker_path) completion = self._build_completion(body)
route = tracker_resp["route"] except _ModelUnavailable as exc:
if ( self._send_openai_error(503, exc.message, "model_not_available")
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 return
except urllib.error.HTTPError as exc: except _TrackerUnavailable:
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") self._send_json_error(503, "tracker unavailable")
return 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"]})
except (urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError) as exc: except (urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError) as exc:
self._send_openai_error(503, f"inference pipeline error: {exc}", "pipeline_error") self._send_openai_error(503, f"inference pipeline error: {exc}", "pipeline_error")
return 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"] text = node_resp["text"]
model = body.get("model", "stub-model") 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()) 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")) api_key = _api_key_from_authorization(self.headers.get("Authorization"))
if api_key: if api_key:
tokens = max(1, _estimate_token_count(messages, text)) tokens = max(1, _estimate_token_count(messages, text))
for node in route_nodes: 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: if not wallet_address:
continue continue
server.contracts.payment.record_attribution( server.contracts.payment.record_attribution(
@@ -176,35 +364,12 @@ class _GatewayHandler(http.server.BaseHTTPRequestHandler):
if server.contracts is not None: if server.contracts is not None:
server.contracts.validation.record_completed_inference( server.contracts.validation.record_completed_inference(
session_id=session_id, session_id=session_id,
model=model, model=str(model),
messages=messages if isinstance(messages, list) else [], messages=messages if isinstance(messages, list) else [],
observed_output=text, 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: def _send_sse_response(self, text: str, model: str) -> None:
chunk_id = "chatcmpl-stub" chunk_id = "chatcmpl-stub"
created = int(time.time()) created = int(time.time())
@@ -282,6 +447,19 @@ def _valid_route_nodes(route_nodes: object) -> bool:
return True 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: def _banned_route_wallet(contracts: Any | None, route_nodes: object) -> str | None:
if contracts is None or not isinstance(route_nodes, list): if contracts is None or not isinstance(route_nodes, list):
return None return None
@@ -308,6 +486,66 @@ def _estimate_token_count(messages: object, completion_text: str) -> int:
return 1 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: def _post_json(url: str, payload: dict, timeout: float = 5.0) -> dict:
data = json.dumps(payload).encode() data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") 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, model_presets: list[str] | None = None,
contracts: Any | None = None, contracts: Any | None = None,
minimum_stake: int = 0, minimum_stake: int = 0,
cost_per_layer_token_lamport: int = 1,
host: str = "127.0.0.1", host: str = "127.0.0.1",
port: int = 0, port: int = 0,
): ):
@@ -360,6 +599,7 @@ class GatewayServer:
self._model_presets: list[str] | None = model_presets self._model_presets: list[str] | None = model_presets
self._contracts = contracts self._contracts = contracts
self._minimum_stake = minimum_stake self._minimum_stake = minimum_stake
self._cost_per_layer_token_lamport = cost_per_layer_token_lamport
self._host = host self._host = host
self._requested_port = port self._requested_port = port
self._server: _GatewayHTTPServer | None = None self._server: _GatewayHTTPServer | None = None
@@ -378,6 +618,7 @@ class GatewayServer:
self._model_presets, self._model_presets,
self._contracts, self._contracts,
self._minimum_stake, self._minimum_stake,
self._cost_per_layer_token_lamport,
) )
self.port = self._server.server_address[1] self.port = self._server.server_address[1]
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)

View File

@@ -1,3 +1,19 @@
"""meshnet — Distributed Inference Network Python SDK.""" """meshnet — Distributed Inference Network Python SDK."""
from .client import (
Client,
CostEstimate,
RedundantRequestResult,
TopUpQuote,
WalletBalance,
)
__version__ = "0.1.0" __version__ = "0.1.0"
__all__ = [
"Client",
"CostEstimate",
"RedundantRequestResult",
"TopUpQuote",
"WalletBalance",
]

View File

@@ -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]

View File

@@ -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)

View File

@@ -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: ...

View File

@@ -0,0 +1 @@

View File

@@ -7,7 +7,11 @@ name = "meshnet"
version = "0.1.0" version = "0.1.0"
description = "Distributed Inference Network Python SDK" description = "Distributed Inference Network Python SDK"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = ["openai>=1"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
include = ["meshnet*"] include = ["meshnet*"]
[tool.setuptools.package-data]
meshnet = ["py.typed", "*.pyi"]

View File

@@ -16,6 +16,9 @@ HTTP API contract:
- GET /v1/route?model=<preset> - GET /v1/route?model=<preset>
Response 200: {"route": ["http://node-a", "http://node-b"]} Response 200: {"route": ["http://node-a", "http://node-b"]}
Response 400/404/503: {"error": str} Response 400/404/503: {"error": str}
- GET /v1/routes?model=<preset>&redundancy=<n>
Response 200: {"routes": [{"route": [...], "nodes": [...]}]}
Response 400/404/503: {"error": str}
""" """
import http.server import http.server
@@ -89,6 +92,36 @@ def _select_route(
return 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: def _registration_ban_error(contracts: Any | None, wallet_address: str | None) -> str | None:
if contracts is None or not wallet_address: if contracts is None or not wallet_address:
return None return None
@@ -168,6 +201,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
if parsed.path == "/v1/route": if parsed.path == "/v1/route":
self._handle_route(parsed) self._handle_route(parsed)
elif parsed.path == "/v1/routes":
self._handle_routes(parsed)
elif parsed.path == "/v1/nodes/assign": elif parsed.path == "/v1/nodes/assign":
self._handle_assign(parsed) self._handle_assign(parsed)
elif parsed.path == "/v1/models": elif parsed.path == "/v1/models":
@@ -179,10 +214,29 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
def _handle_models(self): def _handle_models(self):
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
created = int(time.time()) created = int(time.time())
data = [ with server.lock:
{"id": name, "object": "model", "created": created, "owned_by": "meshnet"} self._purge_expired_nodes()
for name in server.model_presets 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}) self._send_json(200, {"object": "list", "data": data})
def _handle_register(self): 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: class TrackerServer:
"""HTTP tracker that manages node registration and resolves inference routes. """HTTP tracker that manages node registration and resolves inference routes.

143
tests/test_meshnet_sdk.py Normal file
View File

@@ -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()