From 2e013188b1bbf63e00bdc58215a28e7c3a2ad51f Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Fri, 11 Sep 2026 13:14:33 +0530 Subject: [PATCH] feat: package distribution with PyPI support, embedded UI, Dockerfile, and CLI enhancements --- .gitignore | 3 +++ Dockerfile | 33 +++++++++++++++++++++++ README.md | 48 ++++++++++++++++++++++++++++----- backend/app/cli/main.py | 20 +++++++++++++- backend/main.py | 40 +++++++++++++++++++++------ pyproject.toml | 58 ++++++++++++++++++++++++++++++++++++++++ scripts/build_package.py | 51 +++++++++++++++++++++++++++++++++++ 7 files changed, 237 insertions(+), 16 deletions(-) create mode 100644 Dockerfile create mode 100644 pyproject.toml create mode 100644 scripts/build_package.py diff --git a/.gitignore b/.gitignore index 0a4fa53..c3caece 100644 --- a/.gitignore +++ b/.gitignore @@ -21,11 +21,14 @@ model_router.db-journal .pytest_cache/ .coverage htmlcov/ +build/ +*.egg-info/ # Node / Frontend node_modules/ dist/ .dist/ +backend/app/static/ npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e81e74e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Stage 1: Build React Frontend UI +FROM node:20-alpine AS frontend-builder +WORKDIR /app/frontend + +COPY frontend/package*.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build + +# Stage 2: Python Backend Runtime +FROM python:3.11-slim +WORKDIR /app + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PORT=8000 +ENV HOST=0.0.0.0 + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy backend application code +COPY backend/ ./backend/ + +# Copy compiled static frontend into backend/app/static +COPY --from=frontend-builder /app/frontend/dist ./backend/app/static + +EXPOSE 8000 + +CMD ["python", "backend/main.py"] diff --git a/README.md b/README.md index 89cefc8..2bc7a92 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Model Router +[![PyPI - Version](https://img.shields.io/pypi/v/model-router-cli?logo=pypi&logoColor=white&color=blue)](https://pypi.org/project/model-router-cli/) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/model-router-cli?logo=python&logoColor=white)](https://pypi.org/project/model-router-cli/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + Intelligent, explainable, cost- and latency-aware LLM request routing platform and AI Traffic Control Room. --- @@ -66,9 +70,32 @@ The platform is designed local-first, allowing full local development and testin - Node.js 18+ and npm - (Optional) [Ollama](https://ollama.com/) for local model inference +## 📦 Quick Installation (via PyPI) + +Install Model Router and its embedded **AI Traffic Control Room** dashboard directly from PyPI: + +```bash +pip install model-router-cli +``` + +### Quick Commands: +```bash +# Run system diagnostics & environment checks +modelrouter doctor + +# Launch the Traffic Control Room Dashboard & API Gateway in your browser +modelrouter ui --open-browser + +# Route a prompt and inspect the explainability decision (Dry Run) +modelrouter route "Write an optimized async task worker in Python" + +# Route and execute inference through the optimal provider +modelrouter run "Explain the difference between zero-shot and few-shot prompting" +``` + --- -## Installation & Setup +## Installation & Setup (from Source) ### 1. Clone the Repository ```bash @@ -152,23 +179,29 @@ npm run dev ## CLI Usage -The built-in Typer CLI provides terminal commands for inspection, diagnostics, and testing: +The built-in Typer CLI provides terminal commands for inspection, diagnostics, and testing (use `modelrouter` if installed via pip, or `python backend/app/cli/main.py` from source): ```bash +# Launch the AI Traffic Control Room Web UI and API Gateway +modelrouter ui --open-browser + # Run system diagnostics & provider health checks -python backend/app/cli/main.py doctor +modelrouter doctor # Inspect routing decision for a prompt without executing (Dry Run) -python backend/app/cli/main.py route "Write a Python function to parse JSON" +modelrouter route "Write a Python function to parse JSON" # Route and execute a query through the selected model -python backend/app/cli/main.py run "Debug this distributed async deadlock in worker pool" +modelrouter run "Debug this distributed async deadlock in worker pool" # List all registered models in the catalog -python backend/app/cli/main.py models +modelrouter models + +# View recent routed traffic logs +modelrouter traffic # View system-wide routing performance and cost savings analytics -python backend/app/cli/main.py analytics +modelrouter analytics ``` --- @@ -301,6 +334,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file Maintained and architected by **PicadoLabs**. +- **Maintainer**: [@Kaap10](https://github.com/Kaap10) - **Organization**: [PicadoLabs](https://github.com/PicadoLabs) - **Website**: [https://picadolabs.me](https://picadolabs.me) - **Contact**: [picadolabs@gmail.com](mailto:picadolabs@gmail.com) diff --git a/backend/app/cli/main.py b/backend/app/cli/main.py index 988b3ba..2f22f95 100644 --- a/backend/app/cli/main.py +++ b/backend/app/cli/main.py @@ -1,4 +1,4 @@ -import sys +import sys import os import asyncio import httpx @@ -265,5 +265,23 @@ def analytics(): asyncio.run(_analytics()) +@app.command() +def ui( + host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host address to bind the web server"), + port: int = typer.Option(8000, "--port", "-p", help="Port to run the Model Router Control Room on"), +): + """Launch the Model Router API Gateway & AI Traffic Control Room UI.""" + import uvicorn + console.print(Panel.fit( + f"[bold cyan]Starting Model Router AI Traffic Control Room[/bold cyan]\n" + f"Web Dashboard: [bold green]http://{host}:{port}[/bold green]\n" + f"API Documentation: [bold green]http://{host}:{port}/docs[/bold green]", + title="MODEL ROUTER UI LAUNCHER", + border_style="cyan" + )) + uvicorn.run("main:app", host=host, port=port, reload=False, app_dir=os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + + if __name__ == "__main__": app() + diff --git a/backend/main.py b/backend/main.py index 88edf71..8569f4d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,4 +1,4 @@ -import asyncio +import asyncio import json from contextlib import asynccontextmanager from fastapi import FastAPI @@ -80,15 +80,39 @@ async def stream_live_traffic(): return EventSourceResponse(event_generator()) -@app.get("/") -async def root(): - return { - "message": "Model Router AI Control Room Backend is active.", - "docs": "/docs", - "health": "/api/health", - } +# Serve Bundled Frontend (SPA) if static directory exists +import os +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from fastapi import HTTPException + +STATIC_DIR = os.path.join(os.path.dirname(__file__), "app", "static") + +if os.path.exists(STATIC_DIR): + assets_dir = os.path.join(STATIC_DIR, "assets") + if os.path.exists(assets_dir): + app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") + + @app.get("/{full_path:path}") + async def serve_spa(full_path: str): + if full_path.startswith("api/") or full_path.startswith("docs") or full_path.startswith("openapi.json"): + raise HTTPException(status_code=404, detail="Not Found") + + target_file = os.path.join(STATIC_DIR, full_path) + if os.path.exists(target_file) and os.path.isfile(target_file): + return FileResponse(target_file) + return FileResponse(os.path.join(STATIC_DIR, "index.html")) +else: + @app.get("/") + async def root(): + return { + "message": "Model Router AI Control Room Backend is active.", + "docs": "/docs", + "health": "/api/health", + } if __name__ == "__main__": import uvicorn uvicorn.run("main:app", host=settings.HOST, port=settings.PORT, reload=settings.DEBUG) + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5b0abcc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "model-router-cli" +version = "1.0.0" +description = "Intelligent, explainable LLM request routing platform & AI Traffic Control Room" +readme = "README.md" +requires-python = ">=3.10" +authors = [ + { name = "PicadoLabs", email = "picadolabs@gmail.com" } +] +maintainers = [ + { name = "Kaap10", email = "picadolabs@gmail.com" } +] +license = { text = "MIT" } +keywords = ["llm", "ai", "router", "fastapi", "ollama", "openai", "cost-optimization"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development :: Libraries :: Application Frameworks", +] +dependencies = [ + "fastapi>=0.110.0", + "uvicorn[standard]>=0.28.0", + "pydantic>=2.6.0", + "pydantic-settings>=2.2.0", + "sqlalchemy>=2.0.28", + "aiosqlite>=0.20.0", + "httpx>=0.27.0", + "typer>=0.12.0", + "rich>=13.7.0", + "sse-starlette>=2.0.0", + "python-multipart>=0.0.9", + "python-dotenv>=1.0.1", + "tiktoken>=0.6.0" +] + +[project.urls] +Homepage = "https://picadolabs.me" +Repository = "https://github.com/PicadoLabs/ai-model-router" +Issues = "https://github.com/PicadoLabs/ai-model-router/issues" + +[project.scripts] +modelrouter = "app.cli.main:app" + +[tool.setuptools.packages.find] +where = ["backend"] + +[tool.setuptools.package-data] +"app" = ["static/**/*", "static/*"] diff --git a/scripts/build_package.py b/scripts/build_package.py new file mode 100644 index 0000000..35cefc1 --- /dev/null +++ b/scripts/build_package.py @@ -0,0 +1,51 @@ +import os +import sys +import shutil +import subprocess + +def build(): + print("==================================================") + print("STEP 1: Compiling React Frontend (Traffic Control Room)") + print("==================================================") + frontend_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "frontend")) + result = subprocess.run(["npm", "run", "build"], cwd=frontend_dir, shell=True) + if result.returncode != 0: + print("[ERROR] Frontend build failed!") + sys.exit(1) + + print("\n==================================================") + print("STEP 2: Bundling UI Assets into backend/app/static") + print("==================================================") + dist_dir = os.path.join(frontend_dir, "dist") + static_dest = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "backend", "app", "static")) + + if os.path.exists(static_dest): + shutil.rmtree(static_dest) + shutil.copytree(dist_dir, static_dest) + print(f"[OK] Successfully copied static UI files from {dist_dir} to {static_dest}") + + print("\n==================================================") + print("STEP 3: Building Python Distribution (.tar.gz and .whl)") + print("==================================================") + root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + + # Ensure build is installed + subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", "build", "twine", "wheel"], cwd=root_dir, check=True) + + # Build package + build_result = subprocess.run([sys.executable, "-m", "build"], cwd=root_dir) + if build_result.returncode != 0: + print("[ERROR] Python build failed!") + sys.exit(1) + + print("\n==================================================") + print("STEP 4: Validating Artifacts with Twine Check") + print("==================================================") + dist_artifacts = os.path.join(root_dir, "dist", "*") + subprocess.run([sys.executable, "-m", "twine", "check", dist_artifacts], cwd=root_dir, shell=True) + + print("\n[SUCCESS] ai-model-router package successfully built!") + print(f"Artifacts ready in: {os.path.join(root_dir, 'dist')}") + +if __name__ == "__main__": + build()