Compare commits
10
Commits
775645f600
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d737946aac | ||
|
|
077b3e2af4 | ||
|
|
e6d9640112 | ||
|
|
2e013188b1 | ||
|
|
02e0f1803c | ||
|
|
1f20c725cd | ||
|
|
cd6f7def83 | ||
|
|
8113da6c12 | ||
|
|
1a45cf5075 | ||
|
|
54e140e4ed |
@@ -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*
|
||||
@@ -43,3 +46,5 @@ Thumbs.db
|
||||
# Project Interview Guide & Docs
|
||||
project.md
|
||||
PROJECT.md
|
||||
model_router_project.md
|
||||
docs/model_router_project.md
|
||||
|
||||
+33
@@ -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"]
|
||||
@@ -1,5 +1,9 @@
|
||||
# Model Router
|
||||
|
||||
[](https://pypi.org/project/model-router-cli/)
|
||||
[](https://pypi.org/project/model-router-cli/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
Intelligent, explainable, cost- and latency-aware LLM request routing platform and AI Traffic Control Room.
|
||||
|
||||
---
|
||||
@@ -19,7 +23,7 @@ The platform is designed local-first, allowing full local development and testin
|
||||
- **Provider Abstraction**: Decoupled adapters for Mock (simulation), Ollama (local), OpenAI, Anthropic, and Google Gemini.
|
||||
- **Resilience and Tiered Fallback**: Automated retry classification for transient errors (timeouts, HTTP 429, 503) and tiered fallback to local/mock alternatives.
|
||||
- **Budget Control Guards**: Real-time spend tracking with automated threshold interventions (80% cost optimization, 95% local-only saver, 100% block).
|
||||
- **Traffic Control Room UI**: Real-time dark operational interface featuring live topology graphs, playground inspector, SSE live request stream, visual rules builder, and cost savings simulator.
|
||||
- **Traffic Control Room UI**: Real-time operational interface with seamless dark/light mode switching, featuring live topology graphs, playground inspector, SSE live request stream, telemetry export as CSV/JSON, visual rules builder, and cost savings simulator.
|
||||
- **Developer CLI**: Terminal diagnostics (`doctor`), routing dry-run (`route`), execution (`run`), model catalog (`models`), and analytics (`analytics`).
|
||||
|
||||
---
|
||||
@@ -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,30 @@ 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).
|
||||
You can append `--json` or `-j` to most commands to output raw, pipeable JSON instead of Rich formatted tables:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -194,16 +228,35 @@ curl -X POST http://127.0.0.1:8000/api/generate \
|
||||
curl http://127.0.0.1:8000/api/models
|
||||
```
|
||||
|
||||
### 4. Export Historical Traffic (`GET /api/traffic/export`)
|
||||
Export all persisted traffic records for auditing, accounting, or latency analysis:
|
||||
|
||||
```bash
|
||||
# Export as JSON
|
||||
curl -OJ "http://127.0.0.1:8000/api/traffic/export?format=json"
|
||||
|
||||
# Export as CSV
|
||||
curl -OJ "http://127.0.0.1:8000/api/traffic/export?format=csv"
|
||||
```
|
||||
|
||||
Each export includes the timestamp, request ID, prompt preview, task type, complexity, selected model, input/output/total tokens, cost saved, and total latency. The `format` query parameter accepts only `csv` or `json`.
|
||||
|
||||
The same export is available in the frontend under **Traffic**. Select `CSV` or `JSON` beside **Export Telemetry**, then click the button to download the complete historical traffic dataset.
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
The test suite includes 15 automated unit, integration, and end-to-end tests covering prompt heuristics, candidate pruning, scoring weights, provider execution, error fallbacks, and REST endpoints:
|
||||
The test suite includes 18 automated unit, integration, and end-to-end tests covering prompt heuristics, candidate pruning, scoring weights, provider execution, error fallbacks, REST endpoints, and CSV/JSON traffic exports:
|
||||
|
||||
```bash
|
||||
# Run backend test suite
|
||||
# Run the backend test suite from the repository root
|
||||
pytest backend/tests
|
||||
|
||||
# Or run it from the backend directory
|
||||
cd backend
|
||||
python -m pytest tests
|
||||
|
||||
# Run frontend production build test
|
||||
cd frontend
|
||||
npm run build
|
||||
@@ -282,6 +335,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)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import uuid
|
||||
import time
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
@@ -386,6 +389,60 @@ async def get_traffic_feed(limit: int = 50, db: AsyncSession = Depends(get_db)):
|
||||
return res.scalars().all()
|
||||
|
||||
|
||||
@router.get("/api/traffic/export")
|
||||
async def export_traffic(
|
||||
format: str = Query("json", pattern="^(csv|json)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
res = await db.execute(select(RequestRecord).order_by(desc(RequestRecord.timestamp)))
|
||||
records = res.scalars().all()
|
||||
fields = [
|
||||
"timestamp",
|
||||
"request_id",
|
||||
"prompt_preview",
|
||||
"task_type",
|
||||
"complexity",
|
||||
"selected_model",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
"cost_saved",
|
||||
"total_latency_ms",
|
||||
]
|
||||
rows = [
|
||||
{
|
||||
"timestamp": record.timestamp.isoformat() if record.timestamp else "",
|
||||
"request_id": record.request_id,
|
||||
"prompt_preview": record.prompt[:200],
|
||||
"task_type": record.task_type,
|
||||
"complexity": record.complexity,
|
||||
"selected_model": record.selected_model,
|
||||
"input_tokens": record.input_tokens,
|
||||
"output_tokens": record.output_tokens,
|
||||
"total_tokens": record.total_tokens,
|
||||
"cost_saved": record.cost_saved,
|
||||
"total_latency_ms": record.total_latency_ms,
|
||||
}
|
||||
for record in records
|
||||
]
|
||||
|
||||
if format == "json":
|
||||
return JSONResponse(
|
||||
content=rows,
|
||||
headers={"Content-Disposition": "attachment; filename=traffic-export.json"},
|
||||
)
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=fields)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=traffic-export.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/decisions/{decision_id_or_request_id}")
|
||||
async def get_decision_details(decision_id_or_request_id: str, db: AsyncSession = Depends(get_db)):
|
||||
res_dec = await db.execute(
|
||||
|
||||
+194
-90
@@ -1,8 +1,10 @@
|
||||
import sys
|
||||
import sys
|
||||
import os
|
||||
import asyncio
|
||||
import httpx
|
||||
import typer
|
||||
import json
|
||||
from typing import Any, Callable
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
@@ -29,62 +31,90 @@ from sqlalchemy import select
|
||||
|
||||
app = typer.Typer(help="Model Router: Intelligent LLM Request Routing Platform")
|
||||
console = Console(safe_box=True)
|
||||
err_console = Console(stderr=True, safe_box=True)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _render_output(json_output: bool, data: Any, render_fn: Callable[[], None]) -> None:
|
||||
if json_output:
|
||||
if hasattr(data, "model_dump_json"):
|
||||
print(data.model_dump_json(indent=2))
|
||||
elif isinstance(data, list) and all(hasattr(x, "model_dump") for x in data):
|
||||
print(json.dumps([m.model_dump(mode="json") for m in data], indent=2))
|
||||
else:
|
||||
print(json.dumps(data, indent=2))
|
||||
else:
|
||||
render_fn()
|
||||
|
||||
|
||||
@app.command()
|
||||
def doctor():
|
||||
def doctor(
|
||||
json_output: bool = typer.Option(False, "--json", "-j", help="Output raw JSON instead of formatted tables"),
|
||||
):
|
||||
"""Run environment, connectivity, and dependency health checks."""
|
||||
console.print(Panel.fit("[bold cyan]Model Router Diagnostics (modelrouter doctor)[/bold cyan]"))
|
||||
|
||||
# 1. Python Check
|
||||
py_ver = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||||
console.print(f" [green][OK][/green] Python: [bold]{py_ver}[/bold]")
|
||||
|
||||
# 2. Database Check
|
||||
data = {
|
||||
"python": {"version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", "ok": True},
|
||||
"database": {"ok": False, "message": ""},
|
||||
"ollama": {"ok": False, "models": [], "message": ""},
|
||||
"mock_engine": {"ok": True},
|
||||
"providers": {
|
||||
"openai": {"configured": bool(settings.OPENAI_API_KEY)},
|
||||
"anthropic": {"configured": bool(settings.ANTHROPIC_API_KEY)},
|
||||
"gemini": {"configured": bool(settings.GEMINI_API_KEY)},
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
asyncio.run(init_db())
|
||||
console.print(" [green][OK][/green] SQLite Database: [bold]Initialized & Ready[/bold]")
|
||||
data["database"]["ok"] = True
|
||||
data["database"]["message"] = "Initialized & Ready"
|
||||
except Exception as exc:
|
||||
console.print(f" [red][FAIL][/red] Database Error: {exc}")
|
||||
data["database"]["message"] = str(exc)
|
||||
|
||||
# 3. Ollama Connectivity Check
|
||||
ollama_ok = False
|
||||
try:
|
||||
r = httpx.get(f"{settings.OLLAMA_BASE_URL}/api/tags", timeout=1.5)
|
||||
if r.status_code == 200:
|
||||
models = [m.get("name") for m in r.json().get("models", [])]
|
||||
console.print(f" [green][OK][/green] Ollama (Local): [bold]Connected[/bold] ({len(models)} local models found)")
|
||||
ollama_ok = True
|
||||
data["ollama"]["ok"] = True
|
||||
data["ollama"]["models"] = models
|
||||
except Exception:
|
||||
pass
|
||||
if not ollama_ok:
|
||||
console.print(f" [yellow][!][/yellow] Ollama (Local): [dim]Not running on {settings.OLLAMA_BASE_URL} (Optional, local fallback available)[/dim]")
|
||||
|
||||
if not data["ollama"]["ok"]:
|
||||
data["ollama"]["message"] = f"Not running on {settings.OLLAMA_BASE_URL}"
|
||||
|
||||
# 4. Mock Engine
|
||||
console.print(" [green][OK][/green] Mock Engine: [bold]Ready (Zero API keys required)[/bold]")
|
||||
def _render() -> None:
|
||||
console.print(Panel.fit("[bold cyan]Model Router Diagnostics (modelrouter doctor)[/bold cyan]"))
|
||||
|
||||
console.print(f" [green][OK][/green] Python: [bold]{data['python']['version']}[/bold]")
|
||||
|
||||
if data["database"]["ok"]:
|
||||
console.print(" [green][OK][/green] SQLite Database: [bold]Initialized & Ready[/bold]")
|
||||
else:
|
||||
console.print(f" [red][FAIL][/red] Database Error: {data['database']['message']}")
|
||||
|
||||
if data["ollama"]["ok"]:
|
||||
console.print(f" [green][OK][/green] Ollama (Local): [bold]Connected[/bold] ({len(data['ollama']['models'])} local models found)")
|
||||
else:
|
||||
console.print(f" [yellow][!][/yellow] Ollama (Local): [dim]Not running on {settings.OLLAMA_BASE_URL} (Optional, local fallback available)[/dim]")
|
||||
|
||||
console.print(" [green][OK][/green] Mock Engine: [bold]Ready (Zero API keys required)[/bold]")
|
||||
|
||||
for prov, name in [("openai", "OpenAI"), ("anthropic", "Anthropic"), ("gemini", "Gemini")]:
|
||||
if data["providers"][prov]["configured"]:
|
||||
console.print(f" [green][OK][/green] {name} Provider: [bold]API Key Configured in .env[/bold]")
|
||||
else:
|
||||
console.print(f" [yellow][!][/yellow] {name} Provider: [dim]Not configured (Optional)[/dim]")
|
||||
|
||||
console.print("\n[bold green]Ready for local-first intelligent routing![/bold green]\n")
|
||||
|
||||
# 5. External Providers
|
||||
if settings.OPENAI_API_KEY:
|
||||
console.print(" [green][OK][/green] OpenAI Provider: [bold]API Key Configured in .env[/bold]")
|
||||
else:
|
||||
console.print(" [yellow][!][/yellow] OpenAI Provider: [dim]Not configured (Optional)[/dim]")
|
||||
|
||||
if settings.ANTHROPIC_API_KEY:
|
||||
console.print(" [green][OK][/green] Anthropic Provider: [bold]API Key Configured in .env[/bold]")
|
||||
else:
|
||||
console.print(" [yellow][!][/yellow] Anthropic Provider: [dim]Not configured (Optional)[/dim]")
|
||||
|
||||
if settings.GEMINI_API_KEY:
|
||||
console.print(" [green][OK][/green] Gemini Provider: [bold]API Key Configured in .env[/bold]")
|
||||
else:
|
||||
console.print(" [yellow][!][/yellow] Gemini Provider: [dim]Not configured (Optional)[/dim]")
|
||||
|
||||
console.print("\n[bold green]Ready for local-first intelligent routing![/bold green]\n")
|
||||
_render_output(json_output, data, _render)
|
||||
|
||||
|
||||
@app.command()
|
||||
def models():
|
||||
def models(
|
||||
json_output: bool = typer.Option(False, "--json", "-j", help="Output raw JSON instead of formatted tables"),
|
||||
):
|
||||
"""List all registered candidate models and their capability scores."""
|
||||
async def _list():
|
||||
await init_db()
|
||||
@@ -92,27 +122,31 @@ def models():
|
||||
res = await db.execute(select(ModelRecord).order_by(ModelRecord.tier, ModelRecord.name))
|
||||
records = res.scalars().all()
|
||||
|
||||
table = Table(title="Model Router Registry", border_style="bright_blue")
|
||||
table.add_column("Model ID", style="cyan", no_wrap=True)
|
||||
table.add_column("Tier", style="magenta")
|
||||
table.add_column("Provider", style="green")
|
||||
table.add_column("Context", style="yellow")
|
||||
table.add_column("Quality", style="blue")
|
||||
table.add_column("Speed", style="cyan")
|
||||
table.add_column("Cost / 1K In/Out", style="white")
|
||||
def _render() -> None:
|
||||
table = Table(title="Model Router Registry", border_style="bright_blue")
|
||||
table.add_column("Model ID", style="cyan", no_wrap=True)
|
||||
table.add_column("Tier", style="magenta")
|
||||
table.add_column("Provider", style="green")
|
||||
table.add_column("Context", style="yellow")
|
||||
table.add_column("Quality", style="blue")
|
||||
table.add_column("Speed", style="cyan")
|
||||
table.add_column("Cost / 1K In/Out", style="white")
|
||||
|
||||
for m in records:
|
||||
cost_str = f"${m.cost_per_input_token*1000:.4f} / ${m.cost_per_output_token*1000:.4f}" if m.cost_per_input_token > 0 else "Free ($0)"
|
||||
table.add_row(
|
||||
m.id,
|
||||
m.tier,
|
||||
m.provider,
|
||||
f"{m.context_window:,}",
|
||||
f"{m.quality_score*100:.0f}%",
|
||||
f"{m.speed_score*100:.0f}%",
|
||||
cost_str,
|
||||
)
|
||||
console.print(table)
|
||||
for m in records:
|
||||
cost_str = f"${m.cost_per_input_token*1000:.4f} / ${m.cost_per_output_token*1000:.4f}" if m.cost_per_input_token > 0 else "Free ($0)"
|
||||
table.add_row(
|
||||
m.id,
|
||||
m.tier,
|
||||
m.provider,
|
||||
f"{m.context_window:,}",
|
||||
f"{m.quality_score*100:.0f}%",
|
||||
f"{m.speed_score*100:.0f}%",
|
||||
cost_str,
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
data = [db_model_to_meta(m) for m in records]
|
||||
_render_output(json_output, data, _render)
|
||||
|
||||
asyncio.run(_list())
|
||||
|
||||
@@ -121,6 +155,7 @@ def models():
|
||||
def route(
|
||||
prompt: str = typer.Argument(..., help="Prompt text to route"),
|
||||
policy: str = typer.Option("balanced", "--policy", "-p", help="Routing policy to use"),
|
||||
json_output: bool = typer.Option(False, "--json", "-j", help="Output raw JSON instead of formatted tables"),
|
||||
):
|
||||
"""Analyze prompt and explain why the optimal model was selected (Dry Run)."""
|
||||
async def _route():
|
||||
@@ -148,24 +183,27 @@ def route(
|
||||
policy_name=policy,
|
||||
)
|
||||
|
||||
console.print(Panel.fit(
|
||||
f"[bold white]Prompt:[/bold white] \"{prompt}\"\n"
|
||||
f"[bold cyan]Task Type:[/bold cyan] {analysis.task_type.value} | [bold cyan]Complexity:[/bold cyan] {analysis.complexity_label.value} ({analysis.complexity:.2f})\n"
|
||||
f"[bold green]Selected Model:[/bold green] [bold yellow]{decision.selected_model_name}[/bold yellow] ({decision.selected_model})\n"
|
||||
f"[bold magenta]Confidence:[/bold magenta] {decision.confidence * 100:.0f}%\n"
|
||||
f"[bold blue]Estimated Latency:[/bold blue] {decision.estimated_latency_ms:.0f}ms | [bold blue]Estimated Cost:[/bold blue] ${decision.estimated_cost_usd:.6f}",
|
||||
title="ROUTING DECISION",
|
||||
border_style="bright_blue",
|
||||
))
|
||||
def _render() -> None:
|
||||
console.print(Panel.fit(
|
||||
f"[bold white]Prompt:[/bold white] \"{prompt}\"\n"
|
||||
f"[bold cyan]Task Type:[/bold cyan] {analysis.task_type.value} | [bold cyan]Complexity:[/bold cyan] {analysis.complexity_label.value} ({analysis.complexity:.2f})\n"
|
||||
f"[bold green]Selected Model:[/bold green] [bold yellow]{decision.selected_model_name}[/bold yellow] ({decision.selected_model})\n"
|
||||
f"[bold magenta]Confidence:[/bold magenta] {decision.confidence * 100:.0f}%\n"
|
||||
f"[bold blue]Estimated Latency:[/bold blue] {decision.estimated_latency_ms:.0f}ms | [bold blue]Estimated Cost:[/bold blue] ${decision.estimated_cost_usd:.6f}",
|
||||
title="ROUTING DECISION",
|
||||
border_style="bright_blue",
|
||||
))
|
||||
|
||||
console.print("[bold cyan]Why this model?[/bold cyan]")
|
||||
for r in decision.reasons:
|
||||
console.print(f" [green]+[/green] {r}")
|
||||
console.print("[bold cyan]Why this model?[/bold cyan]")
|
||||
for r in decision.reasons:
|
||||
console.print(f" [green]+[/green] {r}")
|
||||
|
||||
if decision.rejected_candidates:
|
||||
console.print("\n[bold red]Rejected Candidates:[/bold red]")
|
||||
for cid, reason in decision.rejected_candidates.items():
|
||||
console.print(f" [dim]- {cid}: {reason}[/dim]")
|
||||
if decision.rejected_candidates:
|
||||
console.print("\n[bold red]Rejected Candidates:[/bold red]")
|
||||
for cid, reason in decision.rejected_candidates.items():
|
||||
console.print(f" [dim]- {cid}: {reason}[/dim]")
|
||||
|
||||
_render_output(json_output, decision, _render)
|
||||
|
||||
asyncio.run(_route())
|
||||
|
||||
@@ -174,6 +212,7 @@ def route(
|
||||
def run(
|
||||
prompt: str = typer.Argument(..., help="Prompt to route and execute"),
|
||||
policy: str = typer.Option("balanced", "--policy", "-p", help="Routing policy to use"),
|
||||
json_output: bool = typer.Option(False, "--json", "-j", help="Output raw JSON instead of formatted tables"),
|
||||
):
|
||||
"""Route request and execute inference through the chosen provider."""
|
||||
async def _run():
|
||||
@@ -191,7 +230,11 @@ def run(
|
||||
policy_name=policy,
|
||||
)
|
||||
|
||||
console.print(f"[bold cyan]Routing to:[/bold cyan] {decision.selected_model} via provider: {decision.provider}...")
|
||||
routing_msg = f"[bold cyan]Routing to:[/bold cyan] {decision.selected_model} via provider: {decision.provider}..."
|
||||
if json_output:
|
||||
err_console.print(routing_msg)
|
||||
else:
|
||||
console.print(routing_msg)
|
||||
|
||||
resp, fallback_used, orig_m, fb_reason = await execute_with_fallback(
|
||||
prompt=prompt,
|
||||
@@ -200,12 +243,32 @@ def run(
|
||||
all_models=models,
|
||||
)
|
||||
|
||||
console.print(Panel(
|
||||
resp.content,
|
||||
title=f"RESPONSE from {resp.model} ({'FALLBACK: ' + orig_m if fallback_used else 'PRIMARY'})",
|
||||
subtitle=f"Latency: {resp.provider_latency_ms:.0f}ms | Tokens: {resp.total_tokens}",
|
||||
border_style="green" if not fallback_used else "yellow",
|
||||
))
|
||||
def _render() -> None:
|
||||
console.print(Panel(
|
||||
resp.content,
|
||||
title=f"RESPONSE from {resp.model} ({'FALLBACK: ' + orig_m if fallback_used else 'PRIMARY'})",
|
||||
subtitle=f"Latency: {resp.provider_latency_ms:.0f}ms | Tokens: {resp.total_tokens}",
|
||||
border_style="green" if not fallback_used else "yellow",
|
||||
))
|
||||
|
||||
data = {
|
||||
"decision": json.loads(decision.model_dump_json()) if hasattr(decision, "model_dump_json") else (decision.model_dump() if hasattr(decision, "model_dump") else decision),
|
||||
"response": json.loads(resp.model_dump_json()) if hasattr(resp, "model_dump_json") else {
|
||||
"content": getattr(resp, "content", ""),
|
||||
"model": getattr(resp, "model", ""),
|
||||
"provider_latency_ms": getattr(resp, "provider_latency_ms", 0),
|
||||
"total_tokens": getattr(resp, "total_tokens", 0),
|
||||
},
|
||||
"fallback": {
|
||||
"used": fallback_used,
|
||||
"original_model": orig_m,
|
||||
"reason": fb_reason,
|
||||
}
|
||||
}
|
||||
if hasattr(resp, "model_dump") and not hasattr(resp, "model_dump_json"):
|
||||
data["response"] = resp.model_dump()
|
||||
|
||||
_render_output(json_output, data, _render)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
@@ -245,25 +308,66 @@ def traffic(limit: int = 10):
|
||||
|
||||
|
||||
@app.command()
|
||||
def analytics():
|
||||
def analytics(
|
||||
json_output: bool = typer.Option(False, "--json", "-j", help="Output raw JSON instead of formatted tables"),
|
||||
):
|
||||
"""Display system-wide routing performance and cost savings analytics."""
|
||||
async def _analytics():
|
||||
await init_db()
|
||||
from app.analytics.service import get_system_analytics
|
||||
async with AsyncSessionLocal() as db:
|
||||
data = await get_system_analytics(db)
|
||||
console.print(Panel.fit(
|
||||
f"[bold cyan]Total Routed Requests:[/bold cyan] {data['total_requests']}\n"
|
||||
f"[bold green]Average Latency:[/bold green] {data['avg_latency_ms']}ms (Routing overhead: {data['avg_routing_latency_ms']}ms)\n"
|
||||
f"[bold yellow]Total Cost:[/bold yellow] ${data['total_cost_usd']:.6f}\n"
|
||||
f"[bold magenta]Cost Saved vs Baseline:[/bold magenta] ${data['savings']['cost_saved_usd']:.6f} ({data['savings']['savings_percentage']}%)\n"
|
||||
f"[bold blue]Fallback Rate:[/bold blue] {data['fallback_rate_percent']}%\n",
|
||||
title="MODEL ROUTER ANALYTICS OVERVIEW",
|
||||
border_style="bright_blue",
|
||||
))
|
||||
|
||||
def _render() -> None:
|
||||
console.print(Panel.fit(
|
||||
f"[bold cyan]Total Routed Requests:[/bold cyan] {data['total_requests']}\n"
|
||||
f"[bold green]Average Latency:[/bold green] {data['avg_latency_ms']}ms (Routing overhead: {data['avg_routing_latency_ms']}ms)\n"
|
||||
f"[bold yellow]Total Cost:[/bold yellow] ${data['total_cost_usd']:.6f}\n"
|
||||
f"[bold magenta]Cost Saved vs Baseline:[/bold magenta] ${data['savings']['cost_saved_usd']:.6f} ({data['savings']['savings_percentage']}%)\n"
|
||||
f"[bold blue]Fallback Rate:[/bold blue] {data['fallback_rate_percent']}%\n",
|
||||
title="MODEL ROUTER ANALYTICS OVERVIEW",
|
||||
border_style="bright_blue",
|
||||
))
|
||||
|
||||
_render_output(json_output, data, _render)
|
||||
|
||||
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"),
|
||||
open_browser: bool = typer.Option(False, "--open-browser", help="Automatically open dashboard in default web browser"),
|
||||
):
|
||||
"""Launch the Model Router API Gateway & AI Traffic Control Room UI."""
|
||||
import uvicorn
|
||||
import webbrowser
|
||||
import threading
|
||||
import time
|
||||
|
||||
url = f"http://{host}:{port}"
|
||||
message = (
|
||||
f"[bold cyan]Starting Model Router AI Traffic Control Room[/bold cyan]\n"
|
||||
f"Web Dashboard: [bold green]{url}[/bold green]\n"
|
||||
f"API Documentation: [bold green]{url}/docs[/bold green]"
|
||||
)
|
||||
console.print(Panel.fit(
|
||||
message,
|
||||
title="MODEL ROUTER UI LAUNCHER",
|
||||
border_style="cyan"
|
||||
))
|
||||
|
||||
if open_browser:
|
||||
def _open():
|
||||
time.sleep(1.2)
|
||||
webbrowser.open(url)
|
||||
threading.Thread(target=_open, daemon=True).start()
|
||||
|
||||
from app.server import app as fastapi_app
|
||||
uvicorn.run(fastapi_app, host=host, port=port, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import json
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
import datetime
|
||||
@@ -27,7 +27,7 @@ def log_router_event(
|
||||
payload = {
|
||||
"event": event_name,
|
||||
"request_id": request_id,
|
||||
"timestamp": datetime.datetime.utcnow().isoformat(),
|
||||
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"model": model,
|
||||
"provider": provider,
|
||||
"duration_ms": duration_ms,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import uuid
|
||||
import uuid
|
||||
import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from app.models.schemas import (
|
||||
@@ -64,7 +64,7 @@ def route_request(
|
||||
) -> RoutingDecision:
|
||||
req_id = request_id or str(uuid.uuid4())
|
||||
decision_id = f"dec_{uuid.uuid4().hex[:12]}"
|
||||
now_iso = datetime.datetime.utcnow().isoformat()
|
||||
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
|
||||
# 1. Rule evaluation
|
||||
rule_action, rule_target, rule_name = None, None, None
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
from app.config.settings import get_settings
|
||||
from app.storage.database import init_db, AsyncSessionLocal
|
||||
from app.storage.models import RequestRecord
|
||||
from app.api.routes import router as api_router
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Initialize SQLite database and seed defaults
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Model Router",
|
||||
description="Intelligent LLM Request Routing Platform — AI Traffic Control Room",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS configuration
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
|
||||
@app.get("/api/traffic/stream")
|
||||
async def stream_live_traffic():
|
||||
"""
|
||||
SSE Live Stream endpoint for the Traffic Control Room visualization.
|
||||
Pushes newly persisted requests to connected frontend clients.
|
||||
"""
|
||||
async def event_generator():
|
||||
last_seen_id = None
|
||||
while True:
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
res = await session.execute(
|
||||
select(RequestRecord).order_by(desc(RequestRecord.timestamp)).limit(1)
|
||||
)
|
||||
latest = res.scalar_one_or_none()
|
||||
if latest and latest.request_id != last_seen_id:
|
||||
last_seen_id = latest.request_id
|
||||
payload = {
|
||||
"request_id": latest.request_id,
|
||||
"timestamp": latest.timestamp.isoformat() if latest.timestamp else "",
|
||||
"prompt": latest.prompt,
|
||||
"task_type": latest.task_type,
|
||||
"complexity": latest.complexity,
|
||||
"selected_model": latest.selected_model,
|
||||
"provider": latest.provider,
|
||||
"status": latest.status,
|
||||
"total_latency_ms": latest.total_latency_ms,
|
||||
"estimated_cost": latest.estimated_cost,
|
||||
}
|
||||
yield {
|
||||
"event": "traffic_event",
|
||||
"data": json.dumps(payload),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
return EventSourceResponse(event_generator())
|
||||
|
||||
|
||||
# Serve Bundled Frontend (SPA) if static directory exists
|
||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "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",
|
||||
}
|
||||
|
||||
+35
-8
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
import uvicorn
|
||||
from app.server import app, settings
|
||||
|
||||
from app.config.settings import get_settings
|
||||
from app.storage.database import init_db, AsyncSessionLocal
|
||||
@@ -80,15 +82,40 @@ 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)
|
||||
|
||||
uvicorn.run("app.server:app", host=settings.HOST, port=settings.PORT, reload=settings.DEBUG)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import json
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
from app.cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
def test_cli_doctor_json():
|
||||
result = runner.invoke(app, ["doctor", "--json"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert "python" in data
|
||||
assert "database" in data
|
||||
assert "mock_engine" in data
|
||||
|
||||
def test_cli_models_json():
|
||||
result = runner.invoke(app, ["models", "--json"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert isinstance(data, list)
|
||||
if len(data) > 0:
|
||||
assert "id" in data[0]
|
||||
assert "provider" in data[0]
|
||||
|
||||
def test_cli_route_json():
|
||||
result = runner.invoke(app, ["route", "test prompt", "--json"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert "selected_model" in data
|
||||
assert "estimated_latency_ms" in data
|
||||
|
||||
def test_cli_run_json():
|
||||
result = runner.invoke(app, ["run", "test prompt", "--json"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert "decision" in data
|
||||
assert "response" in data
|
||||
assert "fallback" in data
|
||||
|
||||
def test_cli_analytics_json():
|
||||
result = runner.invoke(app, ["analytics", "--json"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert "total_requests" in data
|
||||
assert "avg_latency_ms" in data
|
||||
assert "savings" in data
|
||||
@@ -64,3 +64,64 @@ async def test_api_analytics():
|
||||
data = res.json()
|
||||
assert "total_requests" in data
|
||||
assert "savings" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_traffic_export_json_contains_audit_fields():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
generate_res = await client.post(
|
||||
"/api/generate",
|
||||
json={"prompt": "Write a Python function for a traffic export"},
|
||||
)
|
||||
assert generate_res.status_code == 200
|
||||
|
||||
export_res = await client.get("/api/traffic/export?format=json")
|
||||
|
||||
assert export_res.status_code == 200
|
||||
assert export_res.headers["content-type"].startswith("application/json")
|
||||
assert "attachment" in export_res.headers["content-disposition"]
|
||||
records = export_res.json()
|
||||
assert records
|
||||
assert {
|
||||
"timestamp",
|
||||
"request_id",
|
||||
"prompt_preview",
|
||||
"task_type",
|
||||
"complexity",
|
||||
"selected_model",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
"cost_saved",
|
||||
"total_latency_ms",
|
||||
}.issubset(records[0])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_traffic_export_csv_returns_downloadable_rows():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
generate_res = await client.post(
|
||||
"/api/generate",
|
||||
json={"prompt": "Summarize this traffic record"},
|
||||
)
|
||||
assert generate_res.status_code == 200
|
||||
|
||||
export_res = await client.get("/api/traffic/export?format=csv")
|
||||
|
||||
assert export_res.status_code == 200
|
||||
assert export_res.headers["content-type"].startswith("text/csv")
|
||||
assert "traffic-export.csv" in export_res.headers["content-disposition"]
|
||||
lines = export_res.text.splitlines()
|
||||
assert lines[0].startswith("timestamp,request_id,prompt_preview,")
|
||||
assert len(lines) >= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_traffic_export_rejects_unknown_format():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
export_res = await client.get("/api/traffic/export?format=xml")
|
||||
|
||||
assert export_res.status_code == 422
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved === 'light' || saved === 'dark') return saved;
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||||
return 'light';
|
||||
}
|
||||
return 'dark';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => (prev === 'dark' ? 'light' : 'dark'));
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React from 'react';
|
||||
import {
|
||||
Activity,
|
||||
Compass,
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
Cpu,
|
||||
BarChart3,
|
||||
Sliders,
|
||||
Radio
|
||||
Radio,
|
||||
Sun,
|
||||
Moon
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../ThemeContext';
|
||||
|
||||
interface NavbarProps {
|
||||
activeTab: string;
|
||||
@@ -16,6 +19,7 @@ interface NavbarProps {
|
||||
}
|
||||
|
||||
export const Navbar: React.FC<NavbarProps> = ({ activeTab, setActiveTab, onlineStatus = true }) => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Control Room', icon: Activity },
|
||||
{ id: 'playground', label: 'Playground', icon: Compass },
|
||||
@@ -27,7 +31,7 @@ export const Navbar: React.FC<NavbarProps> = ({ activeTab, setActiveTab, onlineS
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-[#1F2833] bg-[#07090C]/90 backdrop-blur-md">
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border-subtle bg-bg-primary/90 backdrop-blur-md">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between gap-4">
|
||||
|
||||
{/* Brand / Logo */}
|
||||
@@ -35,7 +39,7 @@ export const Navbar: React.FC<NavbarProps> = ({ activeTab, setActiveTab, onlineS
|
||||
onClick={() => setActiveTab('dashboard')}
|
||||
className="flex items-center gap-3 cursor-pointer select-none shrink-0 group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-[#0D1117] border border-accent-primary/40 flex items-center justify-center overflow-hidden shadow-[0_0_18px_rgba(56,148,255,0.25)] group-hover:border-accent-primary transition-all p-1">
|
||||
<div className="w-10 h-10 rounded-xl bg-bg-secondary border border-accent-primary/40 flex items-center justify-center overflow-hidden shadow-[0_0_18px_rgba(56,148,255,0.25)] group-hover:border-accent-primary transition-all p-1">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Model Router Logo"
|
||||
@@ -59,7 +63,7 @@ export const Navbar: React.FC<NavbarProps> = ({ activeTab, setActiveTab, onlineS
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<nav className="hidden lg:flex items-center gap-1.5 p-1 rounded-xl bg-[#0D1117]/80 border border-[#1F2833]/80">
|
||||
<nav className="hidden lg:flex items-center gap-1.5 p-1 rounded-xl bg-bg-secondary/80 border border-border-subtle/80">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
@@ -70,7 +74,7 @@ export const Navbar: React.FC<NavbarProps> = ({ activeTab, setActiveTab, onlineS
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-mono font-semibold transition-all duration-150 whitespace-nowrap ${
|
||||
isActive
|
||||
? 'bg-accent-primary text-bg-primary shadow-[0_0_12px_rgba(56,148,255,0.4)] font-bold'
|
||||
: 'text-text-muted hover:text-text-primary hover:bg-[#141920]'
|
||||
: 'text-text-muted hover:text-text-primary hover:bg-bg-card'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-3.5 h-3.5 ${isActive ? 'text-bg-primary stroke-[2.5]' : 'text-text-muted stroke-[2]'}`} />
|
||||
@@ -104,7 +108,15 @@ export const Navbar: React.FC<NavbarProps> = ({ activeTab, setActiveTab, onlineS
|
||||
|
||||
{/* Status Indicator */}
|
||||
<div className="hidden sm:flex items-center gap-2 shrink-0">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-[#0D1117] border border-[#1F2833] font-mono text-xs shadow-inner">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-1.5 rounded-xl bg-bg-secondary border border-border-subtle text-text-muted hover:text-text-primary hover:border-border-active transition-all"
|
||||
title="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-bg-secondary border border-border-subtle font-mono text-xs shadow-inner">
|
||||
<span className={`w-2 h-2 rounded-full ${onlineStatus ? 'bg-accent-success shadow-[0_0_8px_#10B981]' : 'bg-accent-error'}`} />
|
||||
<span className="text-[11px] font-bold tracking-wide text-text-primary whitespace-nowrap">
|
||||
ROUTER READY
|
||||
|
||||
+43
-19
@@ -1,17 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-bg-primary: #07090C;
|
||||
--color-bg-secondary: #0D1117;
|
||||
--color-bg-card: #121820;
|
||||
--color-bg-card-hover: #17202B;
|
||||
--color-bg-primary: var(--theme-bg-primary);
|
||||
--color-bg-secondary: var(--theme-bg-secondary);
|
||||
--color-bg-card: var(--theme-bg-card);
|
||||
--color-bg-card-hover: var(--theme-bg-card-hover);
|
||||
|
||||
--color-border-subtle: #1F2833;
|
||||
--color-border-active: #324050;
|
||||
--color-border-subtle: var(--theme-border-subtle);
|
||||
--color-border-active: var(--theme-border-active);
|
||||
|
||||
--color-text-primary: #F0F4F8;
|
||||
--color-text-muted: #8B98A5;
|
||||
--color-text-dim: #546270;
|
||||
--color-text-primary: var(--theme-text-primary);
|
||||
--color-text-muted: var(--theme-text-muted);
|
||||
--color-text-dim: var(--theme-text-dim);
|
||||
|
||||
--color-accent-primary: #3894FF;
|
||||
--color-accent-secondary: #F59E0B;
|
||||
@@ -24,19 +24,43 @@
|
||||
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--theme-bg-primary: #F8FAFC;
|
||||
--theme-bg-secondary: #F1F5F9;
|
||||
--theme-bg-card: #FFFFFF;
|
||||
--theme-bg-card-hover: #F8FAFC;
|
||||
--theme-border-subtle: #E2E8F0;
|
||||
--theme-border-active: #CBD5E1;
|
||||
--theme-text-primary: #0F172A;
|
||||
--theme-text-muted: #475569;
|
||||
--theme-text-dim: #64748B;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--theme-bg-primary: #07090C;
|
||||
--theme-bg-secondary: #0D1117;
|
||||
--theme-bg-card: #121820;
|
||||
--theme-bg-card-hover: #17202B;
|
||||
--theme-border-subtle: #1F2833;
|
||||
--theme-border-active: #324050;
|
||||
--theme-text-primary: #F0F4F8;
|
||||
--theme-text-muted: #8B98A5;
|
||||
--theme-text-dim: #546270;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #07090C;
|
||||
color: #F0F4F8;
|
||||
background-color: var(--theme-bg-primary);
|
||||
color: var(--theme-text-primary);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Custom glow borders and glass effects */
|
||||
.glass-panel {
|
||||
background: rgba(18, 24, 32, 0.75);
|
||||
background: color-mix(in srgb, var(--theme-bg-card) 75%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(40, 52, 68, 0.6);
|
||||
border: 1px solid color-mix(in srgb, var(--theme-border-active) 60%, transparent);
|
||||
}
|
||||
|
||||
.glass-panel-hover:hover {
|
||||
@@ -48,8 +72,8 @@ body {
|
||||
.grid-bg {
|
||||
background-size: 28px 28px;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(31, 40, 51, 0.4) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(31, 40, 51, 0.4) 1px, transparent 1px);
|
||||
linear-gradient(to right, color-mix(in srgb, var(--theme-border-subtle) 40%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, color-mix(in srgb, var(--theme-border-subtle) 40%, transparent) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
.radar-glow {
|
||||
@@ -83,12 +107,12 @@ body {
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #07090C;
|
||||
background: var(--theme-bg-primary);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #1F2833;
|
||||
background: var(--theme-border-subtle);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #324050;
|
||||
background: var(--theme-border-active);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { ThemeProvider } from './ThemeContext'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Sliders, Shield, DollarSign, Cpu, CheckCircle } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Sliders, Shield, DollarSign, Cpu, CheckCircle, Sun, Moon } from 'lucide-react';
|
||||
import { fetchApi } from '../lib/api';
|
||||
import { useTheme } from '../ThemeContext';
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [budget, setBudget] = useState<{
|
||||
daily_limit: number;
|
||||
monthly_limit: number;
|
||||
@@ -17,14 +19,24 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-4xl mx-auto pb-16 font-mono">
|
||||
<div className="border-b border-border-subtle pb-4">
|
||||
<h2 className="text-xl font-bold text-text-primary tracking-wide flex items-center gap-2">
|
||||
<Sliders className="w-5 h-5 text-accent-primary" />
|
||||
SYSTEM CONFIGURATION & BUDGET GUARDS
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Configure daily and monthly spend limits, routing threshold interventions, and environment parameters.
|
||||
</p>
|
||||
<div className="border-b border-border-subtle pb-4 flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-text-primary tracking-wide flex items-center gap-2">
|
||||
<Sliders className="w-5 h-5 text-accent-primary" />
|
||||
SYSTEM CONFIGURATION & BUDGET GUARDS
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Configure daily and monthly spend limits, routing threshold interventions, and environment parameters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bg-secondary border border-border-subtle text-text-muted hover:text-text-primary hover:border-border-active transition-all text-xs font-bold"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
{theme === 'dark' ? 'LIGHT MODE' : 'DARK MODE'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Budget Management */}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Radio, RefreshCw, Layers, Clock, DollarSign, Cpu } from 'lucide-react';
|
||||
import { Download, Radio, RefreshCw } from 'lucide-react';
|
||||
import { fetchApi } from '../lib/api';
|
||||
import { TrafficItem } from '../types';
|
||||
|
||||
export const Traffic: React.FC = () => {
|
||||
const [traffic, setTraffic] = useState<TrafficItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
|
||||
|
||||
const loadTraffic = async () => {
|
||||
setLoading(true);
|
||||
@@ -19,6 +20,13 @@ export const Traffic: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const exportTraffic = () => {
|
||||
window.open(
|
||||
`http://localhost:8000/api/traffic/export?format=${exportFormat}`,
|
||||
'_blank',
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTraffic();
|
||||
const eventSource = new EventSource('http://localhost:8000/api/traffic/stream');
|
||||
@@ -49,12 +57,30 @@ export const Traffic: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadTraffic}
|
||||
className="p-2 rounded-lg border border-border-subtle bg-bg-card text-text-muted hover:text-text-primary font-mono text-xs flex items-center gap-1.5"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} /> Refresh
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={exportFormat}
|
||||
onChange={(event) => setExportFormat(event.target.value as 'csv' | 'json')}
|
||||
aria-label="Export format"
|
||||
className="rounded-lg border border-border-subtle bg-bg-card px-2 py-2 text-xs font-mono text-text-muted outline-none"
|
||||
>
|
||||
<option value="csv">CSV</option>
|
||||
<option value="json">JSON</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={exportTraffic}
|
||||
className="p-2 rounded-lg border border-border-subtle bg-bg-card text-text-muted hover:text-text-primary font-mono text-xs flex items-center gap-1.5"
|
||||
title="Export telemetry"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> Export Telemetry
|
||||
</button>
|
||||
<button
|
||||
onClick={loadTraffic}
|
||||
className="p-2 rounded-lg border border-border-subtle bg-bg-card text-text-muted hover:text-text-primary font-mono text-xs flex items-center gap-1.5"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border-subtle bg-bg-card p-5 space-y-4 overflow-x-auto">
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "model-router-cli"
|
||||
version = "1.0.2"
|
||||
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/*"]
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user