feat: add meshnet python sdk
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
10
packages/sdk/meshnet/__init__.pyi
Normal file
10
packages/sdk/meshnet/__init__.pyi
Normal 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]
|
||||
167
packages/sdk/meshnet/client.py
Normal file
167
packages/sdk/meshnet/client.py
Normal 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)
|
||||
64
packages/sdk/meshnet/client.pyi
Normal file
64
packages/sdk/meshnet/client.pyi
Normal 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: ...
|
||||
1
packages/sdk/meshnet/py.typed
Normal file
1
packages/sdk/meshnet/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -16,6 +16,9 @@ HTTP API contract:
|
||||
- GET /v1/route?model=<preset>
|
||||
Response 200: {"route": ["http://node-a", "http://node-b"]}
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user