diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f51980b --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ +*.egg-info/ +build/ +dist/ + +# Local environments +.venv/ + +# Ralph local runtime state +.ralph-tui/ diff --git a/.scratch/distributed-inference-network/prd.json b/.scratch/distributed-inference-network/prd.json index b5109ca..aee07ca 100644 --- a/.scratch/distributed-inference-network/prd.json +++ b/.scratch/distributed-inference-network/prd.json @@ -20,9 +20,10 @@ "Commit only this story's code changes with a focused conventional commit message" ], "priority": 1, - "passes": false, + "passes": true, "notes": "Source issue: .scratch/distributed-inference-network/issues/01-monorepo-scaffold.md", - "dependsOn": [] + "dependsOn": [], + "completionNotes": "Completed by Ralph iteration 7b260695; verified by pytest and editable package installs." }, { "id": "US-002", diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..ffb9a89 --- /dev/null +++ b/conftest.py @@ -0,0 +1,19 @@ +"""Root conftest: add all package source directories to sys.path for pytest discovery.""" + +import pathlib +import sys + +_root = pathlib.Path(__file__).parent +_packages = [ + "packages/node", + "packages/gateway", + "packages/tracker", + "packages/sdk", + "packages/contracts", + "packages/p2p", +] + +for _pkg in _packages: + _path = str(_root / _pkg) + if _path not in sys.path: + sys.path.insert(0, _path) diff --git a/packages/contracts/meshnet_contracts/__init__.py b/packages/contracts/meshnet_contracts/__init__.py new file mode 100644 index 0000000..8e7e51a --- /dev/null +++ b/packages/contracts/meshnet_contracts/__init__.py @@ -0,0 +1,3 @@ +"""meshnet_contracts — Solana smart contract wrappers for the Distributed Inference Network.""" + +__version__ = "0.1.0" diff --git a/packages/contracts/pyproject.toml b/packages/contracts/pyproject.toml new file mode 100644 index 0000000..ca0061f --- /dev/null +++ b/packages/contracts/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "meshnet-contracts" +version = "0.1.0" +description = "Distributed Inference Network Solana smart contract wrappers" +requires-python = ">=3.10" + +[tool.setuptools.packages.find] +where = ["."] +include = ["meshnet_contracts*"] diff --git a/packages/gateway/meshnet_gateway/__init__.py b/packages/gateway/meshnet_gateway/__init__.py new file mode 100644 index 0000000..e548f5d --- /dev/null +++ b/packages/gateway/meshnet_gateway/__init__.py @@ -0,0 +1,3 @@ +"""meshnet_gateway — Distributed Inference Network HTTP gateway.""" + +__version__ = "0.1.0" diff --git a/packages/gateway/meshnet_gateway/cli.py b/packages/gateway/meshnet_gateway/cli.py new file mode 100644 index 0000000..5795e6b --- /dev/null +++ b/packages/gateway/meshnet_gateway/cli.py @@ -0,0 +1,45 @@ +"""meshnet-gateway CLI entry point.""" + +import argparse +import sys + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="meshnet-gateway", + description="Distributed Inference Network HTTP gateway", + ) + subparsers = parser.add_subparsers(dest="command") + + start_cmd = subparsers.add_parser("start", help="Start the gateway server") + start_cmd.add_argument("--tracker", default="http://localhost:8080", help="Tracker URL") + start_cmd.add_argument( + "--node-url", + default="http://localhost:7000", + help="Node URL to route to until tracker routing is implemented", + ) + start_cmd.add_argument("--port", type=int, default=8080, help="Port to listen on") + + args = parser.parse_args() + + if args.command == "start": + from meshnet_gateway.server import GatewayServer + + gateway = GatewayServer(node_url=args.node_url, port=args.port) + port = gateway.start() + print(f"meshnet-gateway listening on port {port}") + print(f"Tracker URL: {args.tracker}") + print(f"Routing to node URL: {args.node_url}") + try: + import time + while True: + time.sleep(1) + except KeyboardInterrupt: + gateway.stop() + sys.exit(0) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/packages/gateway/meshnet_gateway/server.py b/packages/gateway/meshnet_gateway/server.py new file mode 100644 index 0000000..7256f93 --- /dev/null +++ b/packages/gateway/meshnet_gateway/server.py @@ -0,0 +1,104 @@ +"""Gateway HTTP server — accepts OpenAI-format requests and routes to inference nodes.""" + +import http.server +import json +import threading +import time +import urllib.request + + +class _GatewayHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, fmt, *args): # noqa: suppress request logs in tests + pass + + def do_POST(self): + if self.path == "/v1/chat/completions": + self._handle_chat_completions() + else: + self.send_response(404) + self.end_headers() + + def _handle_chat_completions(self): + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) + + node_url = self.server.node_url # type: ignore[attr-defined] + payload = json.dumps({"messages": body.get("messages", [])}).encode() + req = urllib.request.Request( + f"{node_url}/v1/infer", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req) as r: + node_resp = json.loads(r.read()) + + response_body = json.dumps({ + "id": "chatcmpl-stub", + "object": "chat.completion", + "created": int(time.time()), + "model": body.get("model", "stub-model"), + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": node_resp["text"], + }, + "finish_reason": "stop", + }], + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + }, + }).encode() + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(response_body))) + self.end_headers() + self.wfile.write(response_body) + + +class _GatewayHTTPServer(http.server.HTTPServer): + def __init__(self, addr, handler, node_url: str): + super().__init__(addr, handler) + self.node_url = node_url + + +class GatewayServer: + """HTTP gateway that forwards /v1/chat/completions to a registered node.""" + + def __init__(self, node_url: str, host: str = "127.0.0.1", port: int = 0): + self._node_url = node_url + self._host = host + self._requested_port = port + self._server: _GatewayHTTPServer | None = None + self._thread: threading.Thread | None = None + self.port: int | None = None + + def start(self) -> int: + if self._server is not None: + raise RuntimeError("GatewayServer is already running") + + self._server = _GatewayHTTPServer( + (self._host, self._requested_port), + _GatewayHandler, + self._node_url, + ) + self.port = self._server.server_address[1] + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + return self.port + + def stop(self) -> None: + if self._server is None: + return + + self._server.shutdown() + self._server.server_close() + if self._thread is not None: + self._thread.join(timeout=1) + self._server = None + self._thread = None + self.port = None diff --git a/packages/gateway/pyproject.toml b/packages/gateway/pyproject.toml new file mode 100644 index 0000000..0523889 --- /dev/null +++ b/packages/gateway/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "meshnet-gateway" +version = "0.1.0" +description = "Distributed Inference Network HTTP gateway" +requires-python = ">=3.10" + +[project.scripts] +meshnet-gateway = "meshnet_gateway.cli:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["meshnet_gateway*"] diff --git a/packages/node/meshnet_node/__init__.py b/packages/node/meshnet_node/__init__.py new file mode 100644 index 0000000..1a5a17a --- /dev/null +++ b/packages/node/meshnet_node/__init__.py @@ -0,0 +1,3 @@ +"""meshnet_node — Distributed Inference Network node client.""" + +__version__ = "0.1.0" diff --git a/packages/node/meshnet_node/cli.py b/packages/node/meshnet_node/cli.py new file mode 100644 index 0000000..67d2ab5 --- /dev/null +++ b/packages/node/meshnet_node/cli.py @@ -0,0 +1,39 @@ +"""meshnet-node CLI entry point.""" + +import argparse +import sys + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="meshnet-node", + description="Distributed Inference Network node client", + ) + subparsers = parser.add_subparsers(dest="command") + + start_cmd = subparsers.add_parser("start", help="Start the node server") + start_cmd.add_argument("--tracker", default="http://localhost:8080", help="Tracker URL") + start_cmd.add_argument("--port", type=int, default=7000, help="Port to listen on") + + args = parser.parse_args() + + if args.command == "start": + from meshnet_node.server import StubNodeServer + + node = StubNodeServer(port=args.port) + port = node.start() + print(f"meshnet-node listening on port {port}") + print(f"Tracker URL: {args.tracker}") + try: + import time + while True: + time.sleep(1) + except KeyboardInterrupt: + node.stop() + sys.exit(0) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/packages/node/meshnet_node/server.py b/packages/node/meshnet_node/server.py new file mode 100644 index 0000000..3f647fd --- /dev/null +++ b/packages/node/meshnet_node/server.py @@ -0,0 +1,59 @@ +"""Stub node HTTP server for the Distributed Inference Network.""" + +import http.server +import json +import threading + + +class _StubHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, fmt, *args): # noqa: suppress request logs in tests + pass + + def do_POST(self): + if self.path == "/v1/infer": + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) + messages = body.get("messages", []) + last_content = messages[-1]["content"] if messages else "" + payload = json.dumps({"text": f"stub response to: {last_content}"}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + else: + self.send_response(404) + self.end_headers() + + +class StubNodeServer: + """Single-process stub node that returns fixed inference responses.""" + + def __init__(self, host: str = "127.0.0.1", port: int = 0): + self._host = host + self._requested_port = port + self._server: http.server.HTTPServer | None = None + self._thread: threading.Thread | None = None + self.port: int | None = None + + def start(self) -> int: + if self._server is not None: + raise RuntimeError("StubNodeServer is already running") + + self._server = http.server.HTTPServer((self._host, self._requested_port), _StubHandler) + self.port = self._server.server_address[1] + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + return self.port + + def stop(self) -> None: + if self._server is None: + return + + self._server.shutdown() + self._server.server_close() + if self._thread is not None: + self._thread.join(timeout=1) + self._server = None + self._thread = None + self.port = None diff --git a/packages/node/pyproject.toml b/packages/node/pyproject.toml new file mode 100644 index 0000000..68b4cf3 --- /dev/null +++ b/packages/node/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "meshnet-node" +version = "0.1.0" +description = "Distributed Inference Network node client" +requires-python = ">=3.10" + +[project.scripts] +meshnet-node = "meshnet_node.cli:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["meshnet_node*"] diff --git a/packages/p2p/meshnet_p2p/__init__.py b/packages/p2p/meshnet_p2p/__init__.py new file mode 100644 index 0000000..487ea00 --- /dev/null +++ b/packages/p2p/meshnet_p2p/__init__.py @@ -0,0 +1,3 @@ +"""meshnet_p2p — Gossip protocol and shard swarm for the Distributed Inference Network.""" + +__version__ = "0.1.0" diff --git a/packages/p2p/pyproject.toml b/packages/p2p/pyproject.toml new file mode 100644 index 0000000..378d633 --- /dev/null +++ b/packages/p2p/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "meshnet-p2p" +version = "0.1.0" +description = "Distributed Inference Network gossip and shard swarm" +requires-python = ">=3.10" + +[tool.setuptools.packages.find] +where = ["."] +include = ["meshnet_p2p*"] diff --git a/packages/sdk/meshnet/__init__.py b/packages/sdk/meshnet/__init__.py new file mode 100644 index 0000000..7e1c258 --- /dev/null +++ b/packages/sdk/meshnet/__init__.py @@ -0,0 +1,3 @@ +"""meshnet — Distributed Inference Network Python SDK.""" + +__version__ = "0.1.0" diff --git a/packages/sdk/pyproject.toml b/packages/sdk/pyproject.toml new file mode 100644 index 0000000..3bf664e --- /dev/null +++ b/packages/sdk/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "meshnet" +version = "0.1.0" +description = "Distributed Inference Network Python SDK" +requires-python = ">=3.10" + +[tool.setuptools.packages.find] +where = ["."] +include = ["meshnet*"] diff --git a/packages/tracker/meshnet_tracker/__init__.py b/packages/tracker/meshnet_tracker/__init__.py new file mode 100644 index 0000000..5faebba --- /dev/null +++ b/packages/tracker/meshnet_tracker/__init__.py @@ -0,0 +1,3 @@ +"""meshnet_tracker — Distributed Inference Network node registry and route selection.""" + +__version__ = "0.1.0" diff --git a/packages/tracker/meshnet_tracker/cli.py b/packages/tracker/meshnet_tracker/cli.py new file mode 100644 index 0000000..2ca9237 --- /dev/null +++ b/packages/tracker/meshnet_tracker/cli.py @@ -0,0 +1,32 @@ +"""meshnet-tracker CLI entry point.""" + +import argparse +import sys + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="meshnet-tracker", + description="Distributed Inference Network node registry and route selection", + ) + subparsers = parser.add_subparsers(dest="command") + + start_cmd = subparsers.add_parser("start", help="Start the tracker server") + start_cmd.add_argument("--port", type=int, default=8080, help="Port to listen on") + + args = parser.parse_args() + + if args.command == "start": + print(f"meshnet-tracker starting on port {args.port}") + try: + import time + while True: + time.sleep(1) + except KeyboardInterrupt: + sys.exit(0) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/packages/tracker/pyproject.toml b/packages/tracker/pyproject.toml new file mode 100644 index 0000000..24738d7 --- /dev/null +++ b/packages/tracker/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "meshnet-tracker" +version = "0.1.0" +description = "Distributed Inference Network node registry and route selection" +requires-python = ">=3.10" + +[project.scripts] +meshnet-tracker = "meshnet_tracker.cli:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["meshnet_tracker*"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3ea7f6b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "distributed-inference-network" +version = "0.1.0" +description = "Distributed Inference Network monorepo root" +requires-python = ">=3.10" + +[project.optional-dependencies] +dev = ["pytest>=8"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..c543eb9 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,53 @@ +"""US-001 smoke test: gateway → single stub node → valid OpenAI-format response.""" + +import json +import urllib.request + +from meshnet_node.server import StubNodeServer +from meshnet_gateway.server import GatewayServer + + +def test_single_node_smoke(): + node = StubNodeServer() + node_port = node.start() + + gateway = GatewayServer(node_url=f"http://127.0.0.1:{node_port}") + gateway_port = gateway.start() + + try: + payload = json.dumps({ + "model": "stub-model", + "messages": [{"role": "user", "content": "hello"}], + }).encode() + + req = urllib.request.Request( + f"http://127.0.0.1:{gateway_port}/v1/chat/completions", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + + with urllib.request.urlopen(req) as resp: + assert resp.status == 200 + assert resp.headers["Content-Type"] == "application/json" + body = json.loads(resp.read()) + + assert body["id"] == "chatcmpl-stub" + assert body["object"] == "chat.completion" + assert isinstance(body["created"], int) + assert body["model"] == "stub-model" + assert "choices" in body + assert len(body["choices"]) == 1 + choice = body["choices"][0] + assert choice["index"] == 0 + assert choice["message"]["role"] == "assistant" + assert choice["message"]["content"] == "stub response to: hello" + assert choice["finish_reason"] == "stop" + assert body["usage"] == { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + } + finally: + gateway.stop() + node.stop()