From 603a6b2e04e7b762e6754b9828fc855e4b13cf31 Mon Sep 17 00:00:00 2001 From: rickychen-infinirc Date: Sun, 18 Jan 2026 12:36:59 +0800 Subject: [PATCH 01/16] feat: worker notifies server on graceful shutdown --- worker/agent.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/worker/agent.py b/worker/agent.py index 3fbe87a..c76a1f6 100644 --- a/worker/agent.py +++ b/worker/agent.py @@ -212,12 +212,43 @@ async def heartbeat_loop(self): except httpx.HTTPError as e: logger.warning(f"Heartbeat error: {e}") + async def _notify_offline(self): + """Notify server that this worker is going offline.""" + if not self.worker_id: + return + + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post( + f"{self.server_url}/api/workers/heartbeat", + json={ + "worker_id": self.worker_id, + "status": "offline", + "gpu_info": [], + "system_info": {}, + }, + ) + if response.status_code == 200: + logger.info("Notified server of shutdown") + else: + logger.warning(f"Failed to notify server: {response.text}") + except Exception as e: + logger.warning(f"Could not notify server of shutdown: {e}") + def shutdown(self): """Shutdown the agent.""" self._running = False if self._heartbeat_task: self._heartbeat_task.cancel() + # Notify server we're going offline (run in new event loop if needed) + try: + loop = asyncio.get_running_loop() + loop.create_task(self._notify_offline()) + except RuntimeError: + # No running loop, create one + asyncio.run(self._notify_offline()) + # Global agent instance agent: Optional[WorkerAgent] = None From 32853b092bcd7f02fd8771f22eceee17acbfd270 Mon Sep 17 00:00:00 2001 From: rickychen-infinirc Date: Sun, 18 Jan 2026 12:39:39 +0800 Subject: [PATCH 02/16] fix: allow worker reconnection with same token even if name changed --- backend/app/api/workers.py | 48 +++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/backend/app/api/workers.py b/backend/app/api/workers.py index 7e2e047..3daad26 100644 --- a/backend/app/api/workers.py +++ b/backend/app/api/workers.py @@ -129,46 +129,48 @@ async def create_worker( ) original_worker = original_worker_result.scalar_one_or_none() - if existing_worker and token.used_by_worker_id == existing_worker.id: - # Allow reconnection - update existing worker with real IP + if original_worker is not None: + # Allow reconnection - update existing worker with new info + # Worker name may have changed (e.g., new container ID), update it client_ip = _get_client_ip(request) reported_port = "52001" if ":" in worker_in.address: reported_port = worker_in.address.split(":")[-1] - existing_worker.address = f"{client_ip}:{reported_port}" - existing_worker.gpu_info = ( + original_worker.name = worker_in.name # Update name in case it changed + original_worker.address = f"{client_ip}:{reported_port}" + original_worker.gpu_info = ( [gpu.model_dump() for gpu in worker_in.gpu_info] if worker_in.gpu_info else None ) - existing_worker.system_info = ( + original_worker.system_info = ( worker_in.system_info.model_dump() if worker_in.system_info else None ) - existing_worker.status = WorkerStatus.ONLINE.value - existing_worker.last_heartbeat = datetime.now(UTC) + original_worker.status = WorkerStatus.ONLINE.value + original_worker.last_heartbeat = datetime.now(UTC) # Update labels to mark as local if token is for local worker if token.is_local: - worker_labels = dict(existing_worker.labels) if existing_worker.labels else {} + worker_labels = dict(original_worker.labels) if original_worker.labels else {} worker_labels["type"] = "local" - existing_worker.labels = worker_labels + original_worker.labels = worker_labels await db.commit() - await db.refresh(existing_worker) + await db.refresh(original_worker) return WorkerResponse( - id=existing_worker.id, - name=existing_worker.name, - address=existing_worker.address, - description=existing_worker.description, - labels=existing_worker.labels, - status=existing_worker.status, - gpu_info=existing_worker.gpu_info, - system_info=existing_worker.system_info, - created_at=existing_worker.created_at, - updated_at=existing_worker.updated_at, - last_heartbeat=existing_worker.last_heartbeat, + id=original_worker.id, + name=original_worker.name, + address=original_worker.address, + description=original_worker.description, + labels=original_worker.labels, + status=original_worker.status, + gpu_info=original_worker.gpu_info, + system_info=original_worker.system_info, + created_at=original_worker.created_at, + updated_at=original_worker.updated_at, + last_heartbeat=original_worker.last_heartbeat, deployment_count=0, ) - elif original_worker is None: + else: # Original worker was deleted, allow re-registration with new worker # Reset token for reuse token.is_used = False @@ -176,8 +178,6 @@ async def create_worker( token.used_by_worker_id = None await db.commit() # Continue to create new worker below - else: - raise HTTPException(status_code=401, detail="Registration token has already been used") if not token.is_valid: raise HTTPException(status_code=401, detail="Registration token has expired") From b3a4274dc665fabf521d32257aaff259aea7aed9 Mon Sep 17 00:00:00 2001 From: rickychen-infinirc Date: Sun, 18 Jan 2026 12:45:12 +0800 Subject: [PATCH 03/16] fix: sync worker/deployment/app status on startup, fix shutdown notification --- backend/app/main.py | 13 +++ backend/app/services/worker_sync.py | 118 ++++++++++++++++++++++++++++ worker/agent.py | 20 +++-- 3 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 backend/app/services/worker_sync.py diff --git a/backend/app/main.py b/backend/app/main.py index 703a75f..28b7c44 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,6 +19,7 @@ from app.models.worker import Worker, WorkerStatus from app.services.app_sync import app_sync_service from app.services.deployment_sync import deployment_sync_service +from app.services.worker_sync import worker_sync_service # Configure logging logging.basicConfig( @@ -149,6 +150,18 @@ async def lifespan(app: FastAPI): await init_db() logger.info("Database initialized") + # Check all workers' status first + try: + logger.info("Checking worker status...") + worker_stats = await worker_sync_service.sync_all_workers() + if worker_stats["total"] > 0: + logger.info( + f"Worker sync complete: {worker_stats['online']} online, " + f"{worker_stats['offline']} offline" + ) + except Exception as e: + logger.error(f"Failed to sync workers on startup: {e}") + # Synchronize deployment status with actual container state # This is important after system reboot try: diff --git a/backend/app/services/worker_sync.py b/backend/app/services/worker_sync.py new file mode 100644 index 0000000..660572b --- /dev/null +++ b/backend/app/services/worker_sync.py @@ -0,0 +1,118 @@ +"""Worker Sync Service + +Checks all workers' online status by pinging their health endpoints. +This is important on startup to ensure database status matches reality. +""" + +import asyncio +import logging + +import httpx +from sqlalchemy import select + +from app.database import async_session_maker +from app.models.worker import Worker, WorkerStatus + +logger = logging.getLogger(__name__) + + +class WorkerSyncService: + """Service for synchronizing worker status with actual state.""" + + HEALTH_CHECK_TIMEOUT = 5 # seconds + MAX_CONCURRENT_CHECKS = 10 + + async def sync_all_workers(self) -> dict: + """Check all workers' online status. + + Returns: + dict with sync statistics + """ + logger.info("Starting worker status synchronization...") + + stats = { + "total": 0, + "online": 0, + "offline": 0, + "errors": 0, + } + + async with async_session_maker() as db: + # Get all workers that are marked as online + result = await db.execute( + select(Worker).where(Worker.status == WorkerStatus.ONLINE.value) + ) + workers = result.scalars().all() + stats["total"] = len(workers) + + if not workers: + logger.info("No online workers to check") + return stats + + logger.info(f"Checking {len(workers)} workers...") + + # Check workers with limited concurrency + semaphore = asyncio.Semaphore(self.MAX_CONCURRENT_CHECKS) + + async def check_with_semaphore(worker: Worker): + async with semaphore: + return await self._check_worker(worker, db) + + tasks = [check_with_semaphore(w) for w in workers] + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Aggregate results + for result in results: + if isinstance(result, Exception): + logger.error(f"Worker check failed: {result}") + stats["errors"] += 1 + elif result == "online": + stats["online"] += 1 + elif result == "offline": + stats["offline"] += 1 + + await db.commit() + + logger.info( + f"Worker sync complete: {stats['online']} online, " + f"{stats['offline']} offline, {stats['errors']} errors" + ) + + return stats + + async def _check_worker(self, worker: Worker, db) -> str: + """Check a single worker's status by pinging its health endpoint. + + Returns: + "online" or "offline" + """ + # Skip local workers that don't have a real address + if not worker.address or worker.address.startswith("local"): + return "online" + + try: + async with httpx.AsyncClient(timeout=self.HEALTH_CHECK_TIMEOUT) as client: + response = await client.get(f"http://{worker.address}/health") + if response.status_code == 200: + logger.debug(f"Worker {worker.name}: online") + return "online" + else: + logger.warning( + f"Worker {worker.name}: unhealthy (status {response.status_code})" + ) + worker.status = WorkerStatus.OFFLINE.value + return "offline" + + except (httpx.ConnectError, httpx.ConnectTimeout): + logger.warning(f"Worker {worker.name}: offline (unreachable)") + worker.status = WorkerStatus.OFFLINE.value + return "offline" + + except Exception as e: + logger.error(f"Error checking worker {worker.name}: {e}") + worker.status = WorkerStatus.OFFLINE.value + return "offline" + + +# Global instance +worker_sync_service = WorkerSyncService() diff --git a/worker/agent.py b/worker/agent.py index c76a1f6..a512502 100644 --- a/worker/agent.py +++ b/worker/agent.py @@ -212,14 +212,17 @@ async def heartbeat_loop(self): except httpx.HTTPError as e: logger.warning(f"Heartbeat error: {e}") - async def _notify_offline(self): - """Notify server that this worker is going offline.""" + def _notify_offline_sync(self): + """Synchronously notify server that this worker is going offline.""" if not self.worker_id: return try: - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.post( + # Use synchronous httpx to ensure we wait for the response + import httpx as httpx_sync + + with httpx_sync.Client(timeout=5.0) as client: + response = client.post( f"{self.server_url}/api/workers/heartbeat", json={ "worker_id": self.worker_id, @@ -241,13 +244,8 @@ def shutdown(self): if self._heartbeat_task: self._heartbeat_task.cancel() - # Notify server we're going offline (run in new event loop if needed) - try: - loop = asyncio.get_running_loop() - loop.create_task(self._notify_offline()) - except RuntimeError: - # No running loop, create one - asyncio.run(self._notify_offline()) + # Notify server we're going offline synchronously + self._notify_offline_sync() # Global agent instance From bd4ce94fadeb4d668d66f20ca916e806800e3d77 Mon Sep 17 00:00:00 2001 From: rickychen-infinirc Date: Sun, 18 Jan 2026 13:37:08 +0800 Subject: [PATCH 04/16] feat: refresh deployment/app status when worker comes back online --- backend/app/api/workers.py | 123 ++++++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 3 deletions(-) diff --git a/backend/app/api/workers.py b/backend/app/api/workers.py index 3daad26..492f91b 100644 --- a/backend/app/api/workers.py +++ b/backend/app/api/workers.py @@ -1,15 +1,17 @@ """Worker API routes""" +import logging from datetime import UTC, datetime -from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.core.deps import require_operator, require_viewer -from app.database import get_db -from app.models.deployment import Deployment +from app.database import async_session_maker, get_db +from app.models.app import App, AppStatus +from app.models.deployment import Deployment, DeploymentStatus from app.models.registration_token import RegistrationToken from app.models.user import User from app.models.worker import Worker, WorkerStatus @@ -26,6 +28,8 @@ ) from app.services.local_worker import get_local_hostname, spawn_docker_worker, stop_docker_worker +logger = logging.getLogger(__name__) + router = APIRouter() @@ -348,6 +352,7 @@ async def delete_worker( @router.post("/heartbeat") async def worker_heartbeat( heartbeat: WorkerHeartbeat, + background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), ): """Receive heartbeat from worker""" @@ -357,6 +362,10 @@ async def worker_heartbeat( if not worker: raise HTTPException(status_code=404, detail="Worker not found") + # Check if worker is coming back online + was_offline = worker.status == WorkerStatus.OFFLINE.value + is_now_online = heartbeat.status == WorkerStatus.ONLINE + worker.last_heartbeat = datetime.now(UTC) worker.status = heartbeat.status.value @@ -368,6 +377,10 @@ async def worker_heartbeat( await db.commit() + # If worker came back online, refresh deployments and apps status + if was_offline and is_now_online: + background_tasks.add_task(_refresh_worker_resources, worker.id) + return {"status": "ok"} @@ -614,3 +627,107 @@ async def delete_registration_token( await db.delete(token) await db.commit() + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +async def _refresh_worker_resources(worker_id: int): + """Refresh status of deployments and apps on a worker that just came online. + + This is called as a background task when a worker's heartbeat indicates + it has come back online after being offline. + """ + import httpx + + logger.info(f"Refreshing resources for worker {worker_id} after coming online") + + async with async_session_maker() as db: + # Get the worker + result = await db.execute(select(Worker).where(Worker.id == worker_id)) + worker = result.scalar_one_or_none() + if not worker: + return + + # Refresh deployments on this worker + dep_result = await db.execute( + select(Deployment).where( + Deployment.worker_id == worker_id, + Deployment.status.in_( + [ + DeploymentStatus.ERROR.value, + DeploymentStatus.STARTING.value, + DeploymentStatus.RUNNING.value, + ] + ), + ) + ) + deployments = dep_result.scalars().all() + + for deployment in deployments: + if not deployment.container_id: + continue + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get( + f"http://{worker.address}/containers/{deployment.container_id}" + ) + if response.status_code == 200: + container_info = response.json() + state = container_info.get("state", "").lower() + if state == "running": + deployment.status = DeploymentStatus.RUNNING.value + deployment.status_message = "Model ready" + elif state in ("exited", "dead"): + deployment.status = DeploymentStatus.STOPPED.value + deployment.status_message = f"Container {state}" + elif response.status_code == 404: + deployment.status = DeploymentStatus.ERROR.value + deployment.status_message = "Container not found" + except Exception as e: + logger.warning(f"Failed to check deployment {deployment.id}: {e}") + + # Refresh apps on this worker + app_result = await db.execute( + select(App).where( + App.worker_id == worker_id, + App.status.in_( + [ + AppStatus.ERROR.value, + AppStatus.STARTING.value, + AppStatus.RUNNING.value, + ] + ), + ) + ) + apps = app_result.scalars().all() + + for app in apps: + if not app.container_id: + continue + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get( + f"http://{worker.address}/containers/{app.container_id}" + ) + if response.status_code == 200: + container_info = response.json() + state = container_info.get("state", "").lower() + if state == "running": + app.status = AppStatus.RUNNING.value + app.status_message = None + elif state in ("exited", "dead"): + app.status = AppStatus.STOPPED.value + app.status_message = f"Container {state}" + elif response.status_code == 404: + app.status = AppStatus.ERROR.value + app.status_message = "Container not found" + except Exception as e: + logger.warning(f"Failed to check app {app.id}: {e}") + + await db.commit() + logger.info( + f"Refreshed {len(deployments)} deployments and {len(apps)} apps for worker {worker_id}" + ) From c638a65a746792c2f2da9092c9984f09cb1cb29a Mon Sep 17 00:00:00 2001 From: rickychen-infinirc Date: Sun, 18 Jan 2026 13:43:44 +0800 Subject: [PATCH 05/16] feat: refresh deployment/app status on backend startup --- backend/app/main.py | 5 +- backend/app/services/worker_sync.py | 196 ++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 1 deletion(-) diff --git a/backend/app/main.py b/backend/app/main.py index 28b7c44..9464b7d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -150,7 +150,7 @@ async def lifespan(app: FastAPI): await init_db() logger.info("Database initialized") - # Check all workers' status first + # Check all workers' status first, then refresh resources on online workers try: logger.info("Checking worker status...") worker_stats = await worker_sync_service.sync_all_workers() @@ -159,6 +159,9 @@ async def lifespan(app: FastAPI): f"Worker sync complete: {worker_stats['online']} online, " f"{worker_stats['offline']} offline" ) + # Refresh resources on online workers + if worker_stats["online"] > 0: + await worker_sync_service.refresh_online_workers_resources() except Exception as e: logger.error(f"Failed to sync workers on startup: {e}") diff --git a/backend/app/services/worker_sync.py b/backend/app/services/worker_sync.py index 660572b..706beb3 100644 --- a/backend/app/services/worker_sync.py +++ b/backend/app/services/worker_sync.py @@ -9,8 +9,11 @@ import httpx from sqlalchemy import select +from sqlalchemy.orm import selectinload from app.database import async_session_maker +from app.models.app import App, AppStatus +from app.models.deployment import Deployment, DeploymentStatus from app.models.worker import Worker, WorkerStatus logger = logging.getLogger(__name__) @@ -113,6 +116,199 @@ async def _check_worker(self, worker: Worker, db) -> str: worker.status = WorkerStatus.OFFLINE.value return "offline" + async def refresh_online_workers_resources(self) -> dict: + """Refresh deployment and app status for all online workers. + + This should be called after worker sync to update resource status. + + Returns: + dict with refresh statistics + """ + logger.info("Refreshing resources on online workers...") + + stats = { + "workers_checked": 0, + "deployments_updated": 0, + "apps_updated": 0, + "errors": 0, + } + + async with async_session_maker() as db: + # Get all online workers + result = await db.execute( + select(Worker).where(Worker.status == WorkerStatus.ONLINE.value) + ) + workers = result.scalars().all() + + if not workers: + logger.info("No online workers to refresh") + return stats + + stats["workers_checked"] = len(workers) + worker_ids = [w.id for w in workers] + + # Refresh deployments on these workers + deploy_result = await db.execute( + select(Deployment) + .where( + Deployment.worker_id.in_(worker_ids), + Deployment.status.in_( + [ + DeploymentStatus.RUNNING.value, + DeploymentStatus.STARTING.value, + ] + ), + ) + .options(selectinload(Deployment.worker)) + ) + deployments = deploy_result.scalars().all() + + for deployment in deployments: + try: + updated = await self._refresh_deployment_status(deployment, db) + if updated: + stats["deployments_updated"] += 1 + except Exception as e: + logger.error(f"Error refreshing deployment {deployment.id}: {e}") + stats["errors"] += 1 + + # Refresh apps on these workers + app_result = await db.execute( + select(App) + .where( + App.worker_id.in_(worker_ids), + App.status.in_( + [ + AppStatus.RUNNING.value, + AppStatus.STARTING.value, + AppStatus.PULLING.value, + ] + ), + ) + .options(selectinload(App.worker)) + ) + apps = app_result.scalars().all() + + for app in apps: + try: + updated = await self._refresh_app_status(app, db) + if updated: + stats["apps_updated"] += 1 + except Exception as e: + logger.error(f"Error refreshing app {app.id}: {e}") + stats["errors"] += 1 + + await db.commit() + + logger.info( + f"Resource refresh complete: {stats['deployments_updated']} deployments, " + f"{stats['apps_updated']} apps updated, {stats['errors']} errors" + ) + + return stats + + async def _refresh_deployment_status(self, deployment: Deployment, db) -> bool: + """Refresh a single deployment's status by checking container. + + Returns: + True if status was updated, False otherwise + """ + if not deployment.worker or not deployment.container_id: + return False + + try: + async with httpx.AsyncClient(timeout=self.HEALTH_CHECK_TIMEOUT) as client: + response = await client.get( + f"http://{deployment.worker.address}/containers/{deployment.container_id}" + ) + + if response.status_code == 404: + # Container doesn't exist + if deployment.status != DeploymentStatus.ERROR.value: + deployment.status = DeploymentStatus.ERROR.value + deployment.status_message = "Container not found. Please redeploy." + logger.warning(f"Deployment {deployment.name}: container not found") + return True + return False + + if response.status_code == 200: + container_info = response.json() + state = container_info.get("state", "").lower() + + if state == "running": + # Container is running, status is valid + logger.debug(f"Deployment {deployment.name}: container running") + return False + + elif state in ("exited", "dead"): + if deployment.status != DeploymentStatus.ERROR.value: + deployment.status = DeploymentStatus.ERROR.value + deployment.status_message = f"Container {state}. Please restart." + logger.warning(f"Deployment {deployment.name}: container {state}") + return True + return False + + except httpx.ConnectError: + # Worker unreachable - don't change status, worker sync handles this + logger.debug(f"Deployment {deployment.name}: worker unreachable") + return False + except Exception as e: + logger.error(f"Error checking deployment {deployment.name}: {e}") + return False + + return False + + async def _refresh_app_status(self, app: App, db) -> bool: + """Refresh a single app's status by checking container. + + Returns: + True if status was updated, False otherwise + """ + if not app.worker or not app.container_id: + return False + + try: + async with httpx.AsyncClient(timeout=self.HEALTH_CHECK_TIMEOUT) as client: + response = await client.get( + f"http://{app.worker.address}/containers/{app.container_id}" + ) + + if response.status_code == 404: + # Container doesn't exist + if app.status != AppStatus.ERROR.value: + app.status = AppStatus.ERROR.value + app.status_message = "Container not found. Please redeploy." + logger.warning(f"App {app.name}: container not found") + return True + return False + + if response.status_code == 200: + container_info = response.json() + state = container_info.get("state", "").lower() + + if state == "running": + # Container is running, status is valid + logger.debug(f"App {app.name}: container running") + return False + + elif state in ("exited", "dead"): + if app.status != AppStatus.STOPPED.value: + app.status = AppStatus.STOPPED.value + app.status_message = f"Container {state}" + logger.warning(f"App {app.name}: container {state}") + return True + return False + + except httpx.ConnectError: + # Worker unreachable - don't change status, worker sync handles this + logger.debug(f"App {app.name}: worker unreachable") + return False + except Exception as e: + logger.error(f"Error checking app {app.name}: {e}") + return False + + return False + # Global instance worker_sync_service = WorkerSyncService() From dbffb6cecb527642e8e4d68d4b7244a65692aaa4 Mon Sep 17 00:00:00 2001 From: rickychen-infinirc Date: Sun, 18 Jan 2026 13:53:58 +0800 Subject: [PATCH 06/16] fix: immediately update deployment/app status when worker goes offline --- backend/app/api/workers.py | 56 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/backend/app/api/workers.py b/backend/app/api/workers.py index 492f91b..6c2ca6f 100644 --- a/backend/app/api/workers.py +++ b/backend/app/api/workers.py @@ -375,6 +375,13 @@ async def worker_heartbeat( if heartbeat.system_info: worker.system_info = heartbeat.system_info.model_dump() + # Check if worker is going offline + is_going_offline = heartbeat.status == WorkerStatus.OFFLINE + + # If worker is going offline, immediately update all deployments and apps + if is_going_offline: + await _mark_worker_resources_offline(db, worker.id, worker.name) + await db.commit() # If worker came back online, refresh deployments and apps status @@ -634,6 +641,55 @@ async def delete_registration_token( # ============================================================================= +async def _mark_worker_resources_offline(db: AsyncSession, worker_id: int, worker_name: str): + """Mark all deployments and apps on an offline worker as unavailable. + + This is called synchronously when a worker sends an offline heartbeat. + """ + # Update deployments on this worker + dep_result = await db.execute( + select(Deployment).where( + Deployment.worker_id == worker_id, + Deployment.status.in_( + [ + DeploymentStatus.RUNNING.value, + DeploymentStatus.STARTING.value, + ] + ), + ) + ) + deployments = dep_result.scalars().all() + + for deployment in deployments: + deployment.status = DeploymentStatus.ERROR.value + deployment.status_message = f"Worker {worker_name} is offline" + + # Update apps on this worker + app_result = await db.execute( + select(App).where( + App.worker_id == worker_id, + App.status.in_( + [ + AppStatus.RUNNING.value, + AppStatus.STARTING.value, + AppStatus.PULLING.value, + ] + ), + ) + ) + apps = app_result.scalars().all() + + for app in apps: + app.status = AppStatus.ERROR.value + app.status_message = f"Worker {worker_name} is offline" + + if deployments or apps: + logger.info( + f"Marked {len(deployments)} deployments and {len(apps)} apps as offline " + f"for worker {worker_name}" + ) + + async def _refresh_worker_resources(worker_id: int): """Refresh status of deployments and apps on a worker that just came online. From 7595b4d1e2db5321d03a1a69502ed697b88caef8 Mon Sep 17 00:00:00 2001 From: rickychen-infinirc Date: Sun, 18 Jan 2026 13:55:15 +0800 Subject: [PATCH 07/16] fix: refresh deployment/app status when worker reconnects --- backend/app/api/workers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/app/api/workers.py b/backend/app/api/workers.py index 6c2ca6f..398fd20 100644 --- a/backend/app/api/workers.py +++ b/backend/app/api/workers.py @@ -101,6 +101,7 @@ def _get_client_ip(request: Request) -> str: async def create_worker( worker_in: WorkerCreate | WorkerRegisterWithToken, request: Request, + background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), ): """Register a new worker (requires registration token)""" @@ -160,6 +161,9 @@ async def create_worker( await db.commit() await db.refresh(original_worker) + # Refresh deployments and apps status on this worker + background_tasks.add_task(_refresh_worker_resources, original_worker.id) + return WorkerResponse( id=original_worker.id, name=original_worker.name, From 92dd16cdbd012af5eaeb5eeb41e39fed705544f9 Mon Sep 17 00:00:00 2001 From: rickychen-infinirc Date: Sun, 18 Jan 2026 16:53:20 +0800 Subject: [PATCH 08/16] fix: skip syncing deployments/apps that are still starting --- backend/app/services/app_sync.py | 5 +++++ backend/app/services/deployment_sync.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/backend/app/services/app_sync.py b/backend/app/services/app_sync.py index b8ddaef..f46fd0e 100644 --- a/backend/app/services/app_sync.py +++ b/backend/app/services/app_sync.py @@ -109,6 +109,11 @@ async def _check_and_update_app(self, app: App, db) -> str: return "skipped" if not app.container_id: + # If app is still being deployed (STARTING/PULLING), skip it + if app.status in (AppStatus.STARTING.value, AppStatus.PULLING.value): + logger.debug(f"App {app.id} is still deploying, skipping") + return "skipped" + # Only mark as error if app claims to be RUNNING but has no container logger.warning(f"App {app.id} has no container_id, marking as error") app.status = AppStatus.ERROR.value app.status_message = "Container ID missing" diff --git a/backend/app/services/deployment_sync.py b/backend/app/services/deployment_sync.py index d99b7e0..3e9e55d 100644 --- a/backend/app/services/deployment_sync.py +++ b/backend/app/services/deployment_sync.py @@ -135,6 +135,11 @@ async def _check_and_update_deployment(self, deployment: Deployment, db) -> str: return "skipped" if not deployment.container_id: + # If deployment is still starting, skip it + if deployment.status == DeploymentStatus.STARTING.value: + logger.debug(f"Deployment {deployment.id} is still starting, skipping") + return "skipped" + # Only mark as error if deployment claims to be RUNNING but has no container logger.warning(f"Deployment {deployment.id} has no container_id, marking as error") deployment.status = DeploymentStatus.ERROR.value deployment.status_message = "Container ID missing after restart" From 870d3cd135da32fcc6a4989b93e7ec3bf1204c3d Mon Sep 17 00:00:00 2001 From: rickychen-infinirc Date: Sun, 18 Jan 2026 17:37:00 +0800 Subject: [PATCH 09/16] feat: add Semantic Router integration for intelligent model routing - Add SEMANTIC_ROUTER app type for deploying vllm-sr container - Create semantic router config generator service - Add config hot-reload support when deployments change - Add API Gateway support for model='MoM'/'auto' semantic routing - Add /api/semantic-router endpoints for status and config management - Add worker API for writing files to Docker volumes --- backend/app/api/__init__.py | 4 + backend/app/api/apps/deployment.py | 121 ++++++++++++- backend/app/api/gateway.py | 149 ++++++++++++++++ backend/app/api/semantic_router.py | 111 ++++++++++++ backend/app/models/app.py | 19 ++ backend/app/services/deployer.py | 14 ++ backend/app/services/semantic_router.py | 222 ++++++++++++++++++++++++ frontend/index.html | 4 +- frontend/public/favicon-192.png | Bin 0 -> 35747 bytes frontend/public/favicon-512.png | Bin 0 -> 177838 bytes frontend/public/favicon.ico | Bin 0 -> 3525 bytes frontend/public/favicon.png | Bin 0 -> 4736 bytes worker/routes/storage.py | 79 +++++++++ 13 files changed, 713 insertions(+), 10 deletions(-) create mode 100644 backend/app/api/semantic_router.py create mode 100644 backend/app/services/semantic_router.py create mode 100644 frontend/public/favicon-192.png create mode 100644 frontend/public/favicon-512.png create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/favicon.png diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 7c28f22..02fc736 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -16,6 +16,7 @@ model_files, models, ollama, + semantic_router, storage, system, workers, @@ -58,3 +59,6 @@ # Headscale VPN api_router.include_router(headscale.router, prefix="/headscale", tags=["headscale"]) + +# Semantic Router +api_router.include_router(semantic_router.router) diff --git a/backend/app/api/apps/deployment.py b/backend/app/api/apps/deployment.py index 4002c5d..f5bcfa3 100644 --- a/backend/app/api/apps/deployment.py +++ b/backend/app/api/apps/deployment.py @@ -4,6 +4,7 @@ - Image pulling with progress tracking - Container creation and health checking - Nginx proxy setup +- Semantic Router config generation """ import asyncio @@ -16,6 +17,7 @@ from app.models.app import App, AppStatus, AppType from app.models.worker import Worker from app.services.app_proxy_manager import get_proxy_manager +from app.services.semantic_router import semantic_router_service logger = logging.getLogger(__name__) @@ -362,12 +364,22 @@ async def deploy_app_background( await db.commit() return - # Phase 2: Create container + # Phase 2: Pre-deployment setup (e.g., config files) + if app_type == AppType.SEMANTIC_ROUTER: + set_deployment_progress(app_id, "starting", 0, "Generating config...") + try: + lmstack_api_url = "http://host.docker.internal:52000" + await write_semantic_router_config(worker_address, lmstack_api_url, db) + except Exception as e: + logger.warning(f"Failed to write semantic router config: {e}") + # Continue anyway, config can be updated later + + # Phase 3: Create container app.status = AppStatus.STARTING.value app.status_message = "Starting container..." await db.commit() - set_deployment_progress(app_id, "starting", 0, "Creating container...") + set_deployment_progress(app_id, "starting", 10, "Creating container...") container_id = await _create_container( worker_address, app_id, app_type, app_def, env_vars, port @@ -452,17 +464,33 @@ async def _create_container( } ) + # Build port mappings + ports = [ + { + "container_port": app_def["internal_port"], + "host_port": port, + "protocol": "tcp", + } + ] + + # Add additional ports (e.g., dashboard port for semantic router) + additional_ports = app_def.get("additional_ports", []) + for i, additional_port in enumerate(additional_ports): + # Map additional ports starting from port + 1 + host_port = port + 1 + i + ports.append( + { + "container_port": additional_port, + "host_port": host_port, + "protocol": "tcp", + } + ) + payload = { "name": container_name, "image": app_def["image"], "env": env_vars, - "ports": [ - { - "container_port": app_def["internal_port"], - "host_port": port, - "protocol": "tcp", - } - ], + "ports": ports, "volumes": volumes, "restart_policy": "unless-stopped", "labels": { @@ -526,3 +554,78 @@ async def _setup_nginx_proxy( except Exception as e: logger.warning(f"Failed to setup nginx proxy: {e}") # Continue anyway, user can access directly via worker IP + + +# ============================================================================= +# Semantic Router Config +# ============================================================================= + + +async def write_semantic_router_config( + worker_address: str, + lmstack_api_url: str, + db, +) -> None: + """Write semantic router config.yaml to the worker volume. + + Args: + worker_address: Worker address (host:port) + lmstack_api_url: LMStack API URL for the semantic router to call + db: Database session + """ + # Generate config + config = await semantic_router_service.generate_config(db, lmstack_api_url) + config_yaml = semantic_router_service.config_to_yaml(config) + + # Write to worker volume + volume_name = "lmstack-app-semantic-router-semantic-router-config" + + async with httpx.AsyncClient(timeout=CONTAINER_ACTION_TIMEOUT) as client: + response = await client.post( + f"http://{worker_address}/storage/volumes/write-file", + json={ + "volume_name": volume_name, + "file_path": "config.yaml", + "content": config_yaml, + }, + ) + if response.status_code >= 400: + raise Exception(f"Failed to write config: {response.text}") + + logger.info(f"Wrote semantic router config to {volume_name}/config.yaml") + + +async def update_semantic_router_config_if_deployed(db) -> bool: + """Update semantic router config if it's deployed. + + This should be called when deployments change (add/remove models). + + Args: + db: Database session + + Returns: + True if config was updated, False if semantic router not deployed + """ + + # Check if semantic router is deployed + app = await semantic_router_service.get_semantic_router_app(db) + if not app: + return False + + # Get worker address + result = await db.execute(select(Worker).where(Worker.id == app.worker_id)) + worker = result.scalar_one_or_none() + if not worker: + return False + + # Build LMStack API URL + # Use host.docker.internal since semantic router runs in a container + lmstack_api_url = "http://host.docker.internal:52000" + + try: + await write_semantic_router_config(worker.address, lmstack_api_url, db) + logger.info("Updated semantic router config with latest deployments") + return True + except Exception as e: + logger.error(f"Failed to update semantic router config: {e}") + return False diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py index c814267..82dfdc4 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -17,6 +17,10 @@ from app.database import async_session_maker, get_db from app.models.deployment import Deployment from app.services.gateway import gateway_service +from app.services.semantic_router import semantic_router_service + +# Special model names that trigger semantic routing +SEMANTIC_ROUTER_MODEL_NAMES = {"mom", "mixture-of-models", "auto", "semantic-router"} logger = logging.getLogger(__name__) @@ -177,6 +181,15 @@ async def chat_completions( }, ) + # Check if using semantic routing (model="MoM", "auto", etc.) + if model_name.lower() in SEMANTIC_ROUTER_MODEL_NAMES: + return await _proxy_to_semantic_router( + db=db, + body=body, + api_key=api_key, + endpoint="/v1/chat/completions", + ) + # Find deployment for model result = await gateway_service.find_deployment_for_model(db, model_name) if not result: @@ -1011,3 +1024,139 @@ async def responses( logger.info(f"Responses API response: {json.dumps(responses_response)[:500]}") return JSONResponse(content=responses_response) + + +# ============================================================================= +# Semantic Router Proxy +# ============================================================================= + + +async def _proxy_to_semantic_router( + db: AsyncSession, + body: dict, + api_key, + endpoint: str, +) -> JSONResponse | StreamingResponse: + """Proxy request to Semantic Router for intelligent model selection. + + Args: + db: Database session + body: Request body + api_key: Validated API key + endpoint: API endpoint (e.g., /v1/chat/completions) + + Returns: + Response from Semantic Router + """ + # Check if Semantic Router is deployed + router_url = await semantic_router_service.get_semantic_router_url(db) + if not router_url: + raise HTTPException( + status_code=503, + detail={ + "error": { + "message": "Semantic Router is not deployed. Deploy it from the Apps page to use automatic model routing.", + "type": "service_unavailable", + "hint": "Use a specific model name instead, or deploy Semantic Router first.", + } + }, + ) + + # Remove the special model name and let Semantic Router decide + # The router will use its config to select the best model + body_copy = body.copy() + body_copy.pop("model", None) # Let Semantic Router handle model selection + + upstream_url = f"{router_url}{endpoint}" + is_streaming = body.get("stream", False) + + if is_streaming: + return await _proxy_semantic_router_streaming(upstream_url, body_copy, api_key.id) + else: + return await _proxy_semantic_router_request(upstream_url, body_copy, api_key.id) + + +async def _proxy_semantic_router_request( + upstream_url: str, + body: dict, + api_key_id: int, +) -> JSONResponse: + """Proxy non-streaming request to Semantic Router.""" + try: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: + response = await client.post( + upstream_url, + json=body, + headers={"Content-Type": "application/json"}, + ) + return JSONResponse(content=response.json(), status_code=response.status_code) + + except httpx.TimeoutException: + raise HTTPException( + status_code=504, + detail={ + "error": { + "message": "Request to Semantic Router timed out", + "type": "timeout_error", + } + }, + ) + except httpx.RequestError as e: + logger.error(f"Semantic Router request error: {e}") + raise HTTPException( + status_code=502, + detail={ + "error": { + "message": "Failed to connect to Semantic Router", + "type": "connection_error", + } + }, + ) + + +async def _proxy_semantic_router_streaming( + upstream_url: str, + body: dict, + api_key_id: int, +) -> StreamingResponse: + """Proxy streaming request to Semantic Router.""" + + async def stream_generator() -> AsyncGenerator[bytes, None]: + try: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: + async with client.stream( + "POST", + upstream_url, + json=body, + headers={"Content-Type": "application/json"}, + ) as response: + async for chunk in response.aiter_bytes(): + yield chunk + + except httpx.TimeoutException: + error_data = { + "error": { + "message": "Request to Semantic Router timed out", + "type": "timeout_error", + } + } + yield f"data: {json.dumps(error_data)}\n\n".encode() + except httpx.RequestError as e: + logger.error(f"Semantic Router streaming error: {e}") + error_data = { + "error": { + "message": f"Connection error: {e}", + "type": "connection_error", + } + } + yield f"data: {json.dumps(error_data)}\n\n".encode() + + return StreamingResponse( + stream_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/app/api/semantic_router.py b/backend/app/api/semantic_router.py new file mode 100644 index 0000000..b97f68c --- /dev/null +++ b/backend/app/api/semantic_router.py @@ -0,0 +1,111 @@ +"""Semantic Router API endpoints. + +Provides endpoints for: +- Checking if Semantic Router is deployed +- Updating Semantic Router config +- Getting Semantic Router status +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.apps.deployment import update_semantic_router_config_if_deployed +from app.database import get_db +from app.services.semantic_router import semantic_router_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/semantic-router", tags=["semantic-router"]) + + +class SemanticRouterStatus(BaseModel): + """Semantic Router deployment status.""" + + deployed: bool + url: str | None = None + dashboard_url: str | None = None + message: str | None = None + + +class ConfigUpdateResponse(BaseModel): + """Response for config update.""" + + success: bool + message: str + + +@router.get("/status", response_model=SemanticRouterStatus) +async def get_semantic_router_status( + db: AsyncSession = Depends(get_db), +): + """Check if Semantic Router is deployed and get its URLs.""" + app = await semantic_router_service.get_semantic_router_app(db) + + if not app: + return SemanticRouterStatus( + deployed=False, + message="Semantic Router is not deployed. Deploy it from the Apps page to enable intelligent model routing.", + ) + + if not app.worker: + return SemanticRouterStatus( + deployed=False, + message="Semantic Router worker not found.", + ) + + # Build URLs + worker_host = app.worker.address.split(":")[0] + api_url = f"http://{worker_host}:{app.port}" + dashboard_url = f"http://{worker_host}:{app.port + 1}" # Dashboard is on port + 1 + + return SemanticRouterStatus( + deployed=True, + url=api_url, + dashboard_url=dashboard_url, + message="Semantic Router is running. Use model='MoM' for automatic routing.", + ) + + +@router.post("/update-config", response_model=ConfigUpdateResponse) +async def update_semantic_router_config( + db: AsyncSession = Depends(get_db), +): + """Update Semantic Router config with latest deployments. + + This endpoint regenerates the config.yaml with current running models + and writes it to the Semantic Router volume. The router will automatically + reload the config (hot-reload supported). + """ + try: + updated = await update_semantic_router_config_if_deployed(db) + + if updated: + return ConfigUpdateResponse( + success=True, + message="Semantic Router config updated successfully. Changes will take effect automatically.", + ) + else: + return ConfigUpdateResponse( + success=False, + message="Semantic Router is not deployed. Deploy it first from the Apps page.", + ) + + except Exception as e: + logger.error(f"Failed to update semantic router config: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/config-preview") +async def preview_semantic_router_config( + db: AsyncSession = Depends(get_db), +): + """Preview the Semantic Router config that would be generated. + + This is useful for debugging or understanding how the config is built. + """ + lmstack_api_url = "http://host.docker.internal:52000" + config = await semantic_router_service.generate_config(db, lmstack_api_url) + return config diff --git a/backend/app/models/app.py b/backend/app/models/app.py index 845d1af..b12e596 100644 --- a/backend/app/models/app.py +++ b/backend/app/models/app.py @@ -22,6 +22,7 @@ class AppType(str, Enum): FLOWISE = "flowise" ANYTHINGLLM = "anythingllm" LOBECHAT = "lobechat" + SEMANTIC_ROUTER = "semantic-router" class AppStatus(str, Enum): @@ -109,6 +110,24 @@ class AppStatus(str, Enum): }, "volumes": [], # LobeChat is stateless by default }, + AppType.SEMANTIC_ROUTER: { + "name": "Semantic Router", + "description": "Intelligent LLM router that automatically selects the best model based on query intent", + "image": "ghcr.io/vllm-project/semantic-router/vllm-sr:latest", + "internal_port": 8801, # Main OpenAI-compatible API port + "additional_ports": [8700], # Dashboard port + "env_template": { + "ENVOY_LISTEN_PORT": "8801", + "DASHBOARD_PORT": "8700", + "HF_TOKEN": "{hf_token}", # Optional: for gated models + }, + "volumes": [ + {"name": "semantic-router-config", "destination": "/app/config"}, + {"name": "semantic-router-models", "destination": "/app/models"}, + ], + "requires_config": True, # Indicates this app needs dynamic config generation + "singleton": True, # Only one instance should be deployed per cluster + }, } diff --git a/backend/app/services/deployer.py b/backend/app/services/deployer.py index aff7a4c..bf4d62d 100644 --- a/backend/app/services/deployer.py +++ b/backend/app/services/deployer.py @@ -18,6 +18,17 @@ settings = get_settings() +async def _update_semantic_router_config_background(): + """Background task to update semantic router config after deployment changes.""" + try: + from app.api.apps.deployment import update_semantic_router_config_if_deployed + + async with async_session_maker() as db: + await update_semantic_router_config_if_deployed(db) + except Exception as e: + logger.debug(f"Failed to update semantic router config: {e}") + + class DeployerService: """Service for deploying models to workers""" @@ -201,6 +212,9 @@ async def deploy(self, deployment_id: int) -> None: deployment.status = DeploymentStatus.RUNNING.value deployment.status_message = "Model ready" + # Update semantic router config if deployed + asyncio.create_task(_update_semantic_router_config_background()) + except httpx.ConnectError: deployment.status = DeploymentStatus.ERROR.value deployment.status_message = ( diff --git a/backend/app/services/semantic_router.py b/backend/app/services/semantic_router.py new file mode 100644 index 0000000..5601622 --- /dev/null +++ b/backend/app/services/semantic_router.py @@ -0,0 +1,222 @@ +"""Semantic Router Configuration Service + +Generates and manages config.yaml for the Semantic Router app. +Supports hot-reload by updating the config file when models change. +""" + +import logging +from typing import Any + +import yaml +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.app import App, AppStatus, AppType +from app.models.deployment import Deployment, DeploymentStatus + +logger = logging.getLogger(__name__) + + +class SemanticRouterService: + """Service for managing Semantic Router configuration.""" + + # Default categories for semantic routing + DEFAULT_CATEGORIES = [ + {"name": "math", "description": "Mathematics and quantitative reasoning"}, + {"name": "coding", "description": "Programming and software development"}, + {"name": "science", "description": "Scientific questions and research"}, + {"name": "creative", "description": "Creative writing and brainstorming"}, + {"name": "general", "description": "General knowledge and conversation"}, + ] + + async def generate_config( + self, + db: AsyncSession, + lmstack_api_url: str, + ) -> dict[str, Any]: + """Generate semantic router config.yaml content. + + Args: + db: Database session + lmstack_api_url: LMStack API URL (e.g., http://host.docker.internal:52000) + + Returns: + Config dictionary ready to be serialized to YAML + """ + # Get all running deployments with their models + result = await db.execute( + select(Deployment) + .where(Deployment.status == DeploymentStatus.RUNNING.value) + .options(selectinload(Deployment.model), selectinload(Deployment.worker)) + ) + deployments = result.scalars().all() + + # Build vllm_endpoints from deployments + vllm_endpoints = [] + model_configs = {} + + for deployment in deployments: + if not deployment.model or not deployment.worker: + continue + + # Use LMStack gateway as the endpoint (semantic router will call LMStack API) + # Parse host and port from lmstack_api_url + endpoint_name = f"lmstack-{deployment.model.name}".replace("/", "-").replace(":", "-") + + # Extract host and port from URL + url_parts = lmstack_api_url.replace("http://", "").replace("https://", "").split(":") + host = url_parts[0] + port = int(url_parts[1].split("/")[0]) if len(url_parts) > 1 else 52000 + + vllm_endpoints.append( + { + "name": endpoint_name, + "address": host, + "port": port, + "weight": 1, + } + ) + + # Map model name to endpoint + model_configs[deployment.model.name] = { + "preferred_endpoints": [endpoint_name], + } + + # If no deployments, add a placeholder + if not vllm_endpoints: + vllm_endpoints.append( + { + "name": "placeholder", + "address": "localhost", + "port": 8000, + "weight": 1, + } + ) + + # Build config + config = { + # Response API + "response_api": { + "enabled": True, + "store_backend": "memory", + "ttl_seconds": 86400, + "max_responses": 1000, + }, + # Semantic cache + "semantic_cache": { + "enabled": True, + "backend_type": "memory", + "similarity_threshold": 0.85, + "max_entries": 1000, + "ttl_seconds": 3600, + "embedding_model": "qwen3", + }, + # Prompt guard (jailbreak protection) + "prompt_guard": { + "enabled": True, + "threshold": 0.7, + "use_cpu": True, + }, + # Classifier + "classifier": { + "category_model": { + "model_id": "models/mom-domain-classifier", + "threshold": 0.6, + "use_cpu": True, + }, + }, + # vLLM endpoints (pointing to LMStack) + "vllm_endpoints": vllm_endpoints, + # Model configs + "model_config": model_configs, + # Categories + "categories": self.DEFAULT_CATEGORIES, + # Routing strategy + "strategy": "priority", + # Default model (use first available) + "default_model": list(model_configs.keys())[0] if model_configs else "default", + # Decisions (routing rules) + "decisions": self._generate_decisions(list(model_configs.keys())), + # Embedding models + "embedding_models": { + "qwen3_model_path": "models/mom-embedding-pro", + "use_cpu": True, + }, + # Observability + "observability": { + "metrics": {"enabled": True}, + "tracing": {"enabled": False}, + }, + } + + return config + + def _generate_decisions(self, model_names: list[str]) -> list[dict]: + """Generate routing decisions based on available models. + + For now, creates a simple default decision that routes all requests + to the first available model. Users can customize this later. + """ + if not model_names: + return [] + + default_model = model_names[0] + + return [ + { + "name": "default_decision", + "description": "Default routing for all queries", + "priority": 50, + "rules": { + "operator": "AND", + "conditions": [{"type": "domain", "name": "general"}], + }, + "modelRefs": [{"model": default_model, "use_reasoning": False}], + "plugins": [ + { + "type": "system_prompt", + "configuration": {"system_prompt": "You are a helpful assistant."}, + }, + { + "type": "semantic-cache", + "configuration": {"enabled": True, "similarity_threshold": 0.85}, + }, + ], + } + ] + + def config_to_yaml(self, config: dict) -> str: + """Convert config dict to YAML string.""" + return yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False) + + async def get_semantic_router_app(self, db: AsyncSession) -> App | None: + """Get the deployed Semantic Router app if exists.""" + result = await db.execute( + select(App) + .where( + App.app_type == AppType.SEMANTIC_ROUTER.value, + App.status == AppStatus.RUNNING.value, + ) + .options(selectinload(App.worker)) + ) + return result.scalar_one_or_none() + + async def is_semantic_router_deployed(self, db: AsyncSession) -> bool: + """Check if Semantic Router is deployed and running.""" + app = await self.get_semantic_router_app(db) + return app is not None + + async def get_semantic_router_url(self, db: AsyncSession) -> str | None: + """Get the Semantic Router API URL if deployed.""" + app = await self.get_semantic_router_app(db) + if not app or not app.worker: + return None + + # Return the worker address with semantic router port + worker_address = app.worker.address.split(":")[0] + return f"http://{worker_address}:{app.port}" + + +# Global instance +semantic_router_service = SemanticRouterService() diff --git a/frontend/index.html b/frontend/index.html index 4b972d0..51b85bb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,9 @@ - + + + LMStack - LLM Deployment Platform diff --git a/frontend/public/favicon-192.png b/frontend/public/favicon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..988ac20be93a10cfeb07ffe07f85e6729005fb6b GIT binary patch literal 35747 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 literal 0 HcmV?d00001 diff --git a/frontend/public/favicon-512.png b/frontend/public/favicon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..5ba963fcef5378884d82ac0f8055d747757d3393 GIT binary patch literal 177838 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

^8JD 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 z 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 literal 0 HcmV?d00001 diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6a966ba1a1f31631a446d3ebfe600f378921e267 GIT binary patch literal 3525 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 O0000 {full_path}", + ], + volumes={request.volume_name: {"bind": "/volume", "mode": "rw"}}, + remove=True, + ) + + logger.info(f"Wrote file {file_path} to volume {request.volume_name} (container)") + return {"status": "success", "volume": request.volume_name, "file": file_path} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to write file to volume: {e}") + raise HTTPException(status_code=500, detail=str(e)) From 0f69651d5b05480f19b0529d6b0db7a568f69d7f Mon Sep 17 00:00:00 2001 From: rickychen-infinirc Date: Sun, 18 Jan 2026 18:12:38 +0800 Subject: [PATCH 10/16] fix: semantic router config path and VRAM display formatting - Override entrypoint to create symlink for config file - Add entrypoint support in app deployment - Fix VRAM display showing too many decimal places --- backend/app/api/apps/deployment.py | 6 ++++++ backend/app/models/app.py | 6 ++++++ frontend/src/components/HuggingFaceModelPicker.tsx | 2 +- frontend/src/components/ModelCompatibilityCheck.tsx | 2 +- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/backend/app/api/apps/deployment.py b/backend/app/api/apps/deployment.py index f5bcfa3..d485806 100644 --- a/backend/app/api/apps/deployment.py +++ b/backend/app/api/apps/deployment.py @@ -502,6 +502,12 @@ async def _create_container( "extra_hosts": {"host.docker.internal": "host-gateway"}, } + # Add entrypoint/command if specified (e.g., for semantic router config symlink) + if app_def.get("entrypoint"): + payload["entrypoint"] = app_def["entrypoint"] + if app_def.get("command"): + payload["command"] = app_def["command"] + # Add Linux capabilities if specified (e.g., SYS_ADMIN for AnythingLLM) if app_def.get("cap_add"): payload["cap_add"] = app_def["cap_add"] diff --git a/backend/app/models/app.py b/backend/app/models/app.py index b12e596..0509b4e 100644 --- a/backend/app/models/app.py +++ b/backend/app/models/app.py @@ -125,6 +125,12 @@ class AppStatus(str, Enum): {"name": "semantic-router-config", "destination": "/app/config"}, {"name": "semantic-router-models", "destination": "/app/models"}, ], + # Override entrypoint to create symlink before starting supervisord + # (supervisord.conf hardcodes /app/config.yaml path) + "entrypoint": ["/bin/sh", "-c"], + "command": [ + "ln -sf /app/config/config.yaml /app/config.yaml && exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf" + ], "requires_config": True, # Indicates this app needs dynamic config generation "singleton": True, # Only one instance should be deployed per cluster }, diff --git a/frontend/src/components/HuggingFaceModelPicker.tsx b/frontend/src/components/HuggingFaceModelPicker.tsx index a1efe73..2533866 100644 --- a/frontend/src/components/HuggingFaceModelPicker.tsx +++ b/frontend/src/components/HuggingFaceModelPicker.tsx @@ -627,7 +627,7 @@ export default function HuggingFaceModelPicker({ : "success" } format={() => - `${vramEstimate.estimated_vram_gb.toFixed(1)} / ${gpuMemoryGb} GB` + `${vramEstimate.estimated_vram_gb.toFixed(1)} / ${gpuMemoryGb?.toFixed(1) ?? "N/A"} GB` } /> diff --git a/frontend/src/components/ModelCompatibilityCheck.tsx b/frontend/src/components/ModelCompatibilityCheck.tsx index 61c0832..cd8edf8 100644 --- a/frontend/src/components/ModelCompatibilityCheck.tsx +++ b/frontend/src/components/ModelCompatibilityCheck.tsx @@ -319,7 +319,7 @@ export default function ModelCompatibilityCheck({ : "success" } format={() => - `${vramEstimate.estimated_vram_gb.toFixed(1)} / ${gpuMemoryGb} GB` + `${vramEstimate.estimated_vram_gb.toFixed(1)} / ${gpuMemoryGb?.toFixed(1) ?? "N/A"} GB` } /> From 2a7b683dcc71e680eeb8d0e7cf887089090afcd1 Mon Sep 17 00:00:00 2001 From: rickychen-infinirc Date: Sun, 18 Jan 2026 18:17:18 +0800 Subject: [PATCH 11/16] fix: use v0.1 config format for semantic router --- backend/app/services/semantic_router.py | 243 +++++++++++++++++------- 1 file changed, 178 insertions(+), 65 deletions(-) diff --git a/backend/app/services/semantic_router.py b/backend/app/services/semantic_router.py index 5601622..47b48e8 100644 --- a/backend/app/services/semantic_router.py +++ b/backend/app/services/semantic_router.py @@ -21,13 +21,33 @@ class SemanticRouterService: """Service for managing Semantic Router configuration.""" - # Default categories for semantic routing - DEFAULT_CATEGORIES = [ - {"name": "math", "description": "Mathematics and quantitative reasoning"}, - {"name": "coding", "description": "Programming and software development"}, - {"name": "science", "description": "Scientific questions and research"}, - {"name": "creative", "description": "Creative writing and brainstorming"}, - {"name": "general", "description": "General knowledge and conversation"}, + # Default domains for semantic routing + DEFAULT_DOMAINS = [ + { + "name": "math", + "description": "Mathematics and quantitative reasoning", + "mmlu_categories": ["math"], + }, + { + "name": "coding", + "description": "Programming and software development", + "mmlu_categories": ["computer_science"], + }, + { + "name": "science", + "description": "Scientific questions and research", + "mmlu_categories": ["physics", "chemistry", "biology"], + }, + { + "name": "creative", + "description": "Creative writing and brainstorming", + "mmlu_categories": ["other"], + }, + { + "name": "general", + "description": "General knowledge and conversation", + "mmlu_categories": ["other"], + }, ] async def generate_config( @@ -37,6 +57,8 @@ async def generate_config( ) -> dict[str, Any]: """Generate semantic router config.yaml content. + Uses the new v0.1 config format with version, listeners, providers, etc. + Args: db: Database session lmstack_api_url: LMStack API URL (e.g., http://host.docker.internal:52000) @@ -52,50 +74,68 @@ async def generate_config( ) deployments = result.scalars().all() - # Build vllm_endpoints from deployments - vllm_endpoints = [] - model_configs = {} + # Extract host and port from lmstack_api_url + url_parts = lmstack_api_url.replace("http://", "").replace("https://", "").split(":") + host = url_parts[0] + port = int(url_parts[1].split("/")[0]) if len(url_parts) > 1 else 52000 + + # Build models list for providers + models = [] + model_names = [] for deployment in deployments: if not deployment.model or not deployment.worker: continue - # Use LMStack gateway as the endpoint (semantic router will call LMStack API) - # Parse host and port from lmstack_api_url - endpoint_name = f"lmstack-{deployment.model.name}".replace("/", "-").replace(":", "-") + model_name = deployment.model.name.replace("/", "-").replace(":", "-") + endpoint_name = f"lmstack-{model_name}" + model_names.append(model_name) - # Extract host and port from URL - url_parts = lmstack_api_url.replace("http://", "").replace("https://", "").split(":") - host = url_parts[0] - port = int(url_parts[1].split("/")[0]) if len(url_parts) > 1 else 52000 - - vllm_endpoints.append( + models.append( { - "name": endpoint_name, - "address": host, - "port": port, - "weight": 1, + "name": model_name, + "endpoints": [ + { + "name": endpoint_name, + "weight": 1, + "endpoint": f"{host}:{port}", + "protocol": "http", + } + ], } ) - # Map model name to endpoint - model_configs[deployment.model.name] = { - "preferred_endpoints": [endpoint_name], - } - - # If no deployments, add a placeholder - if not vllm_endpoints: - vllm_endpoints.append( + # If no deployments, add a placeholder model + if not models: + models.append( { - "name": "placeholder", - "address": "localhost", - "port": 8000, - "weight": 1, + "name": "default", + "endpoints": [ + { + "name": "placeholder", + "weight": 1, + "endpoint": f"{host}:{port}", + "protocol": "http", + } + ], } ) + model_names.append("default") + + default_model = model_names[0] - # Build config + # Build config in new v0.1 format config = { + "version": "v0.1", + # Listener configuration + "listeners": [ + { + "name": "http-8888", + "address": "0.0.0.0", + "port": 8888, + "timeout": "300s", + } + ], # Response API "response_api": { "enabled": True, @@ -110,11 +150,16 @@ async def generate_config( "similarity_threshold": 0.85, "max_entries": 1000, "ttl_seconds": 3600, - "embedding_model": "qwen3", + "eviction_policy": "fifo", + "use_hnsw": True, + "hnsw_m": 16, + "hnsw_ef_construction": 200, + "embedding_model": "bert", }, # Prompt guard (jailbreak protection) "prompt_guard": { "enabled": True, + "model_id": "models/mom-jailbreak-classifier", "threshold": 0.7, "use_cpu": True, }, @@ -125,48 +170,114 @@ async def generate_config( "threshold": 0.6, "use_cpu": True, }, + "pii_model": { + "model_id": "models/mom-pii-classifier", + "threshold": 0.9, + "use_cpu": True, + }, }, - # vLLM endpoints (pointing to LMStack) - "vllm_endpoints": vllm_endpoints, - # Model configs - "model_config": model_configs, - # Categories - "categories": self.DEFAULT_CATEGORIES, - # Routing strategy - "strategy": "priority", - # Default model (use first available) - "default_model": list(model_configs.keys())[0] if model_configs else "default", - # Decisions (routing rules) - "decisions": self._generate_decisions(list(model_configs.keys())), - # Embedding models - "embedding_models": { - "qwen3_model_path": "models/mom-embedding-pro", - "use_cpu": True, + # Hallucination mitigation (disabled by default) + "hallucination_mitigation": { + "enabled": False, + }, + # Signals (domains for routing) + "signals": { + "domains": self.DEFAULT_DOMAINS, }, - # Observability - "observability": { - "metrics": {"enabled": True}, - "tracing": {"enabled": False}, + # Decisions (routing rules) + "decisions": self._generate_decisions(model_names, default_model), + # Providers (models and endpoints) + "providers": { + "models": models, + "default_model": default_model, + "reasoning_families": {}, + "default_reasoning_effort": "high", }, } return config - def _generate_decisions(self, model_names: list[str]) -> list[dict]: + def _generate_decisions(self, model_names: list[str], default_model: str) -> list[dict]: """Generate routing decisions based on available models. - For now, creates a simple default decision that routes all requests - to the first available model. Users can customize this later. + Creates decisions for each domain that route to available models. """ if not model_names: return [] - default_model = model_names[0] + decisions = [] + + # Math decision - use reasoning if available + decisions.append( + { + "name": "math_decision", + "description": "Mathematics and quantitative reasoning", + "priority": 100, + "rules": { + "operator": "AND", + "conditions": [{"type": "domain", "name": "math"}], + }, + "modelRefs": [{"model": default_model, "use_reasoning": True}], + "plugins": [ + { + "type": "system_prompt", + "configuration": { + "system_prompt": "You are a mathematics expert. Provide step-by-step solutions with clear reasoning." + }, + }, + ], + } + ) - return [ + # Coding decision + decisions.append( { - "name": "default_decision", - "description": "Default routing for all queries", + "name": "coding_decision", + "description": "Programming and software development", + "priority": 100, + "rules": { + "operator": "AND", + "conditions": [{"type": "domain", "name": "coding"}], + }, + "modelRefs": [{"model": default_model, "use_reasoning": False}], + "plugins": [ + { + "type": "system_prompt", + "configuration": { + "system_prompt": "You are a programming expert. Provide clean, well-documented code with explanations." + }, + }, + ], + } + ) + + # Science decision + decisions.append( + { + "name": "science_decision", + "description": "Scientific questions and research", + "priority": 100, + "rules": { + "operator": "AND", + "conditions": [{"type": "domain", "name": "science"}], + }, + "modelRefs": [{"model": default_model, "use_reasoning": True}], + "plugins": [ + { + "type": "system_prompt", + "configuration": { + "system_prompt": "You are a science expert. Explain concepts clearly with scientific accuracy." + }, + }, + ], + } + ) + + # General/default decision (lowest priority) + decisions.append( + { + "name": "general_decision", + "description": "General knowledge and miscellaneous", "priority": 50, "rules": { "operator": "AND", @@ -184,7 +295,9 @@ def _generate_decisions(self, model_names: list[str]) -> list[dict]: }, ], } - ] + ) + + return decisions def config_to_yaml(self, config: dict) -> str: """Convert config dict to YAML string.""" From f6fe4638ad836891b37b05515db65f155e504709 Mon Sep 17 00:00:00 2001 From: rickychen-infinirc Date: Sun, 18 Jan 2026 18:57:20 +0800 Subject: [PATCH 12/16] feat: add HuggingFace token support for Semantic Router deployment --- backend/app/api/apps/deployment.py | 16 ++- backend/app/api/apps/routes.py | 5 + backend/app/main.py | 22 ++-- backend/app/models/app.py | 4 +- backend/app/schemas/app.py | 4 + backend/app/services/app_sync.py | 75 +++++++++++- backend/app/services/semantic_router.py | 14 +-- frontend/src/api/apps.ts | 1 + frontend/src/assets/apps/semantic-router.webp | Bin 0 -> 6426 bytes frontend/src/pages/DeployApps.tsx | 112 +++++++++++++++++- 10 files changed, 221 insertions(+), 32 deletions(-) create mode 100644 frontend/src/assets/apps/semantic-router.webp diff --git a/backend/app/api/apps/deployment.py b/backend/app/api/apps/deployment.py index d485806..817222e 100644 --- a/backend/app/api/apps/deployment.py +++ b/backend/app/api/apps/deployment.py @@ -279,14 +279,18 @@ async def _verify_http_access( app_url: str, app_id: int, ) -> bool: - """Verify app is accessible via HTTP.""" + """Verify app is accessible via HTTP. + + Returns True if the app responds to HTTP requests (any status code). + A 500 error still means the app is running and accepting connections, + just that it may be initializing or the endpoint doesn't exist. + """ try: http_check = await client.get(app_url, timeout=10.0) - if http_check.status_code < 500: - logger.info(f"App {app_id} HTTP check passed: {http_check.status_code}") - return True - else: - logger.warning(f"App {app_id} HTTP check failed: {http_check.status_code}") + # Any HTTP response (including 500) means the app is running + # Only connection errors should be treated as failures + logger.info(f"App {app_id} HTTP check passed: {http_check.status_code}") + return True except Exception as http_err: logger.warning(f"App {app_id} HTTP check error: {http_err}") return False diff --git a/backend/app/api/apps/routes.py b/backend/app/api/apps/routes.py index f694a9f..411c87a 100644 --- a/backend/app/api/apps/routes.py +++ b/backend/app/api/apps/routes.py @@ -211,6 +211,7 @@ async def deploy_app( full_key=full_key, port=port, db=db, + hf_token=deploy_request.hf_token, ) # Initialize progress @@ -293,6 +294,7 @@ async def _build_env_vars( full_key: str, port: int, db: AsyncSession, + hf_token: str | None = None, ) -> dict: """Build environment variables for the app container.""" # Always use backend API port (52000), not the frontend port from request @@ -337,6 +339,9 @@ async def _build_env_vars( env_vars[key] = app_secret_key elif value == "{model_list}": env_vars[key] = model_list + elif value == "{hf_token}": + # HuggingFace token - use provided token or empty string + env_vars[key] = hf_token or "" else: env_vars[key] = value diff --git a/backend/app/main.py b/backend/app/main.py index 9464b7d..b8cae25 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -127,10 +127,13 @@ async def check_app_health(): stats = await app_sync_service.sync_all_apps() if stats["total"] > 0: - logger.debug( - f"App health check: {stats['running_verified']} healthy, " - f"{stats['container_missing']} missing" - ) + log_parts = [ + f"{stats['running_verified']} healthy", + f"{stats['container_missing']} missing", + ] + if stats.get("proxy_repaired", 0) > 0: + log_parts.append(f"{stats['proxy_repaired']} proxy repaired") + logger.debug(f"App health check: {', '.join(log_parts)}") except asyncio.CancelledError: logger.info("App health check task cancelled") @@ -184,10 +187,13 @@ async def lifespan(app: FastAPI): logger.info("Synchronizing app status...") app_stats = await app_sync_service.sync_all_apps() if app_stats["total"] > 0: - logger.info( - f"App sync complete: {app_stats['running_verified']} running, " - f"{app_stats['container_missing']} missing" - ) + log_parts = [ + f"{app_stats['running_verified']} running", + f"{app_stats['container_missing']} missing", + ] + if app_stats.get("proxy_repaired", 0) > 0: + log_parts.append(f"{app_stats['proxy_repaired']} proxy repaired") + logger.info(f"App sync complete: {', '.join(log_parts)}") except Exception as e: logger.error(f"Failed to sync apps on startup: {e}") diff --git a/backend/app/models/app.py b/backend/app/models/app.py index 0509b4e..fd3b38f 100644 --- a/backend/app/models/app.py +++ b/backend/app/models/app.py @@ -114,10 +114,10 @@ class AppStatus(str, Enum): "name": "Semantic Router", "description": "Intelligent LLM router that automatically selects the best model based on query intent", "image": "ghcr.io/vllm-project/semantic-router/vllm-sr:latest", - "internal_port": 8801, # Main OpenAI-compatible API port + "internal_port": 8888, # Main OpenAI-compatible API port (Envoy listens on 8888) "additional_ports": [8700], # Dashboard port "env_template": { - "ENVOY_LISTEN_PORT": "8801", + "ENVOY_LISTEN_PORT": "8888", "DASHBOARD_PORT": "8700", "HF_TOKEN": "{hf_token}", # Optional: for gated models }, diff --git a/backend/app/schemas/app.py b/backend/app/schemas/app.py index 2229d90..b9fd920 100644 --- a/backend/app/schemas/app.py +++ b/backend/app/schemas/app.py @@ -24,6 +24,10 @@ class AppDeploy(BaseModel): True, description="Use LMStack nginx proxy (recommended) or direct worker connection", ) + hf_token: str | None = Field( + None, + description="HuggingFace API token (required for Semantic Router)", + ) class AppResponse(BaseModel): diff --git a/backend/app/services/app_sync.py b/backend/app/services/app_sync.py index f46fd0e..3227407 100644 --- a/backend/app/services/app_sync.py +++ b/backend/app/services/app_sync.py @@ -2,10 +2,12 @@ Synchronizes app status with actual container state. This is important after system reboot to ensure database status matches reality. +Also verifies and repairs nginx proxy configuration for running apps. """ import asyncio import logging +from pathlib import Path import httpx from sqlalchemy import select @@ -13,6 +15,7 @@ from app.database import async_session_maker from app.models.app import App, AppStatus +from app.services.app_proxy_manager import NGINX_CONFD_PATH, get_proxy_manager logger = logging.getLogger(__name__) @@ -27,6 +30,8 @@ class AppSyncService: async def sync_all_apps(self) -> dict: """Synchronize all app statuses. + Also verifies and repairs nginx proxy configurations for running apps. + Returns: dict with sync statistics """ @@ -38,6 +43,7 @@ async def sync_all_apps(self) -> dict: "container_missing": 0, "errors": 0, "skipped": 0, + "proxy_repaired": 0, } async with async_session_maker() as db: @@ -89,13 +95,80 @@ async def check_with_semaphore(app: App): await db.commit() + # Verify and repair proxy configurations for running apps + proxy_repair_count = await self._verify_and_repair_proxies(apps) + stats["proxy_repaired"] = proxy_repair_count + logger.info( f"App sync complete: {stats['running_verified']} running, " - f"{stats['container_missing']} missing, {stats['errors']} errors" + f"{stats['container_missing']} missing, {stats['errors']} errors, " + f"{stats['proxy_repaired']} proxy configs repaired" ) return stats + async def _verify_and_repair_proxies(self, apps: list[App]) -> int: + """Verify and repair nginx proxy configurations for running apps. + + This ensures that all running apps with use_proxy=True have their + nginx proxy configuration file. If missing, creates the config and + restarts nginx. + + Args: + apps: List of apps to check + + Returns: + Number of proxy configs repaired + """ + # Filter to running apps that need proxy + running_apps_with_proxy = [ + app + for app in apps + if app.status == AppStatus.RUNNING.value and app.use_proxy and app.port and app.worker + ] + + if not running_apps_with_proxy: + return 0 + + # Check which apps are missing proxy configs + apps_missing_proxy = [] + confd_path = Path(NGINX_CONFD_PATH) + + for app in running_apps_with_proxy: + config_file = confd_path / f"app_{app.id}.conf" + if not config_file.exists(): + logger.warning( + f"App {app.id} ({app.app_type}) is running with use_proxy=True " + f"but missing proxy config file" + ) + apps_missing_proxy.append(app) + + if not apps_missing_proxy: + logger.debug("All running apps have proxy configs") + return 0 + + # Repair missing proxy configs + logger.info(f"Repairing {len(apps_missing_proxy)} missing proxy configs...") + proxy_manager = get_proxy_manager() + repaired = 0 + + for app in apps_missing_proxy: + try: + worker_host = app.worker.address.split(":")[0] + await proxy_manager.add_app_proxy( + app_id=app.id, + app_type=app.app_type, + listen_port=app.port, + worker_host=worker_host, + worker_port=app.port, + ) + logger.info(f"Repaired proxy config for app {app.id}: port {app.port}") + repaired += 1 + except Exception as e: + logger.error(f"Failed to repair proxy config for app {app.id}: {e}") + + return repaired + async def _check_and_update_app(self, app: App, db) -> str: """Check a single app and update its status. diff --git a/backend/app/services/semantic_router.py b/backend/app/services/semantic_router.py index 47b48e8..1ec7690 100644 --- a/backend/app/services/semantic_router.py +++ b/backend/app/services/semantic_router.py @@ -143,18 +143,10 @@ async def generate_config( "ttl_seconds": 86400, "max_responses": 1000, }, - # Semantic cache + # Semantic cache - disabled by default (requires embedding model) + # Enable and configure embedding_model if you have HF_TOKEN for gated models "semantic_cache": { - "enabled": True, - "backend_type": "memory", - "similarity_threshold": 0.85, - "max_entries": 1000, - "ttl_seconds": 3600, - "eviction_policy": "fifo", - "use_hnsw": True, - "hnsw_m": 16, - "hnsw_ef_construction": 200, - "embedding_model": "bert", + "enabled": False, }, # Prompt guard (jailbreak protection) "prompt_guard": { diff --git a/frontend/src/api/apps.ts b/frontend/src/api/apps.ts index a305adf..d8c57e3 100644 --- a/frontend/src/api/apps.ts +++ b/frontend/src/api/apps.ts @@ -36,6 +36,7 @@ export interface AppDeployRequest { worker_id: number; name?: string; use_proxy?: boolean; + hf_token?: string; } export interface DeployProgress { diff --git a/frontend/src/assets/apps/semantic-router.webp b/frontend/src/assets/apps/semantic-router.webp new file mode 100644 index 0000000000000000000000000000000000000000..183e1ddfaccaa40d25e0b7d06d03772e426a3d22 GIT binary patch literal 6426 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 literal 0 HcmV?d00001 diff --git a/frontend/src/pages/DeployApps.tsx b/frontend/src/pages/DeployApps.tsx index 96ec530..077ca79 100644 --- a/frontend/src/pages/DeployApps.tsx +++ b/frontend/src/pages/DeployApps.tsx @@ -20,6 +20,8 @@ import { Progress, Switch, Space, + Input, + Alert, } from "antd"; import { RocketOutlined, @@ -38,6 +40,7 @@ import { FullscreenOutlined, FullscreenExitOutlined, VerticalAlignBottomOutlined, + BranchesOutlined, } from "@ant-design/icons"; import { appsApi, workersApi } from "../services/api"; import type { Worker } from "../types"; @@ -55,6 +58,7 @@ import n8nLogo from "../assets/apps/n8n.png"; import flowiseLogo from "../assets/apps/flowise-icon.png"; import anythingllmLogo from "../assets/apps/anythingllm.jpeg"; import lobechatLogo from "../assets/apps/lobechat.webp"; +import semanticRouterLogo from "../assets/apps/semantic-router.webp"; const { Title, Text, Paragraph } = Typography; @@ -65,6 +69,7 @@ const appLogos: Record = { flowise: flowiseLogo, anythingllm: anythingllmLogo, lobechat: lobechatLogo, + "semantic-router": semanticRouterLogo, }; const REFRESH_INTERVAL = 5000; @@ -111,6 +116,7 @@ export default function DeployApps() { const [selectedApp, setSelectedApp] = useState(null); const [selectedWorker, setSelectedWorker] = useState(null); const [useProxy, setUseProxy] = useState(true); + const [hfToken, setHfToken] = useState(""); const [deploying, setDeploying] = useState(false); const [progressMap, setProgressMap] = useState< Record @@ -231,18 +237,26 @@ export default function DeployApps() { return; } + // Semantic Router requires HF token + if (selectedApp.type === "semantic-router" && !hfToken.trim()) { + message.error("HuggingFace Token is required for Semantic Router"); + return; + } + setDeploying(true); try { await appsApi.deploy({ app_type: selectedApp.type, worker_id: selectedWorker, use_proxy: useProxy, + hf_token: hfToken.trim() || undefined, }); message.success(`${selectedApp.name} deployment started`); setDeployModalOpen(false); setSelectedApp(null); setSelectedWorker(null); setUseProxy(true); + setHfToken(""); fetchData(); } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } }; @@ -392,7 +406,7 @@ export default function DeployApps() { marginBottom: 12, }} > - {appLogos[app.type] && ( + {appLogos[app.type] ? ( {app.name} - )} + ) : app.type === "semantic-router" ? ( +

+ +
+ ) : null} {app.name} @@ -487,7 +518,7 @@ export default function DeployApps() { gap: 12, }} > - {appLogos[app.app_type] && ( + {appLogos[app.app_type] ? ( {app.name} - )} + ) : app.app_type === "semantic-router" ? ( +
+ +
+ ) : null}
{app.name} @@ -672,6 +720,7 @@ export default function DeployApps() { setSelectedApp(null); setSelectedWorker(null); setUseProxy(true); + setHfToken(""); }} okText="Deploy" okButtonProps={{ loading: deploying, disabled: !selectedWorker }} @@ -739,6 +788,61 @@ export default function DeployApps() { ); })()} + {/* HuggingFace Token - required for Semantic Router */} + {selectedApp?.type === "semantic-router" && ( + <div style={{ marginBottom: 16 }}> + <Alert + message="HuggingFace Token Required" + description={ + <div> + <p style={{ margin: "8px 0" }}> + Semantic Router requires a HuggingFace Token to download + required AI models. + </p> + <ol style={{ margin: 0, paddingLeft: 20 }}> + <li> + Go to{" "} + <a + href="https://huggingface.co/settings/tokens" + target="_blank" + rel="noopener noreferrer" + > + HuggingFace Token Settings + </a> + </li> + <li> + Create a token with <strong>Read</strong> permission + </li> + <li> + Visit{" "} + <a + href="https://huggingface.co/google/embeddinggemma-300m" + target="_blank" + rel="noopener noreferrer" + > + google/embeddinggemma-300m + </a>{" "} + and accept the license agreement + </li> + </ol> + </div> + } + type="info" + showIcon + style={{ marginBottom: 12 }} + /> + <Text strong> + HuggingFace Token <Text type="danger">*</Text> + </Text> + <Input.Password + style={{ width: "100%", marginTop: 8 }} + placeholder="hf_xxxxxxxxxxxxxxxxxxxx" + value={hfToken} + onChange={(e) => setHfToken(e.target.value)} + /> + </div> + )} + {workers.length === 0 && ( <div style={{ marginTop: 16 }}> <Text type="danger"> From 1464ace373f7f9cf4a4b5285af7106e8bbbf8bba Mon Sep 17 00:00:00 2001 From: rickychen-infinirc <ricky.chen@infinirc.com> Date: Sun, 18 Jan 2026 21:02:16 +0800 Subject: [PATCH 13/16] feat: add monitoring services for Semantic Router --- backend/app/api/apps/deployment.py | 38 +- backend/app/api/apps/lifecycle.py | 30 + backend/app/api/apps/monitoring.py | 1003 +++++++++++++++++++ backend/app/api/apps/routes.py | 36 +- backend/app/api/apps/utils.py | 56 +- backend/app/models/app.py | 80 +- backend/app/schemas/app.py | 13 +- backend/app/services/app_proxy_manager.py | 16 +- backend/app/services/semantic_router.py | 37 +- backend/migrations/008_add_app_parent_id.py | 54 + frontend/src/api/apps.ts | 52 + frontend/src/api/index.ts | 2 + frontend/src/pages/DeployApps.tsx | 261 ++++- 13 files changed, 1603 insertions(+), 75 deletions(-) create mode 100644 backend/app/api/apps/monitoring.py create mode 100644 backend/migrations/008_add_app_parent_id.py diff --git a/backend/app/api/apps/deployment.py b/backend/app/api/apps/deployment.py index 817222e..1411aa4 100644 --- a/backend/app/api/apps/deployment.py +++ b/backend/app/api/apps/deployment.py @@ -413,7 +413,7 @@ async def deploy_app_background( # Phase 4: Setup proxy if use_proxy: - await _setup_nginx_proxy(app_id, app_type, worker_address, port) + await _setup_nginx_proxy(app_id, app_type, worker_address, port, app_def) else: logger.info(f"Proxy disabled for app {app_id}, using direct worker connection") @@ -479,12 +479,17 @@ async def _create_container( # Add additional ports (e.g., dashboard port for semantic router) additional_ports = app_def.get("additional_ports", []) - for i, additional_port in enumerate(additional_ports): + for i, additional_port_info in enumerate(additional_ports): + # Handle both old format (int) and new format (dict with container_port and name) + if isinstance(additional_port_info, dict): + container_port = additional_port_info["container_port"] + else: + container_port = additional_port_info # Map additional ports starting from port + 1 host_port = port + 1 + i ports.append( { - "container_port": additional_port, + "container_port": container_port, "host_port": host_port, "protocol": "tcp", } @@ -546,13 +551,16 @@ async def _setup_nginx_proxy( app_type: AppType, worker_address: str, port: int, + app_def: dict | None = None, ) -> None: - """Setup nginx proxy for app.""" + """Setup nginx proxy for app and its additional ports.""" set_deployment_progress(app_id, "starting", 95, "Setting up proxy...") try: proxy_manager = get_proxy_manager() proxy_worker_host = worker_address.split(":")[0] + + # Setup main port proxy await proxy_manager.add_app_proxy( app_id=app_id, app_type=app_type.value, @@ -560,7 +568,27 @@ async def _setup_nginx_proxy( worker_host=proxy_worker_host, worker_port=port, ) - logger.info(f"Nginx proxy configured for app {app_id}") + logger.info(f"Nginx proxy configured for app {app_id} main port {port}") + + # Setup additional port proxies (e.g., dashboard for semantic router) + if app_def: + additional_ports = app_def.get("additional_ports", []) + for i, port_info in enumerate(additional_ports): + if isinstance(port_info, dict): + port_name = port_info.get("name", f"port{i+1}") + else: + port_name = f"port{i+1}" + + host_port = port + 1 + i + await proxy_manager.add_app_proxy( + app_id=app_id * 1000 + i + 1, # Unique ID for additional port + app_type=f"{app_type.value}-{port_name.lower()}", + listen_port=host_port, + worker_host=proxy_worker_host, + worker_port=host_port, + ) + logger.info(f"Nginx proxy configured for app {app_id} {port_name} port {host_port}") + except Exception as e: logger.warning(f"Failed to setup nginx proxy: {e}") # Continue anyway, user can access directly via worker IP diff --git a/backend/app/api/apps/lifecycle.py b/backend/app/api/apps/lifecycle.py index 53ff99c..78cd7b1 100644 --- a/backend/app/api/apps/lifecycle.py +++ b/backend/app/api/apps/lifecycle.py @@ -70,6 +70,7 @@ async def stop_app( app.status = AppStatus.STOPPED.value await db.commit() + await db.refresh(app, ["worker"]) except Exception as e: logger.exception(f"Failed to stop app: {e}") @@ -124,6 +125,7 @@ async def start_app( app.status = AppStatus.RUNNING.value app.status_message = None await db.commit() + await db.refresh(app, ["worker"]) except Exception as e: logger.exception(f"Failed to start app: {e}") @@ -150,6 +152,34 @@ async def delete_app( await db.refresh(app, ["worker", "api_key"]) + # Delete child apps (monitoring services) first + child_result = await db.execute(select(App).where(App.parent_app_id == app_id)) + child_apps = child_result.scalars().all() + + for child in child_apps: + # Remove child container + if child.container_id and app.worker and app.worker.status == "online": + try: + await call_worker_api( + app.worker, + "DELETE", + f"/containers/{child.container_id}", + params={"force": True, "volumes": False}, + ) + except Exception as e: + logger.warning(f"Failed to remove child container {child.app_type}: {e}") + + # Remove child nginx proxy + if child.use_proxy: + try: + proxy_manager = get_proxy_manager() + await proxy_manager.remove_app_proxy(child.id) + except Exception as e: + logger.warning(f"Failed to remove child nginx proxy: {e}") + + # Delete child app record + await db.delete(child) + # Try to remove container if it exists if app.container_id and app.worker and app.worker.status == "online": try: diff --git a/backend/app/api/apps/monitoring.py b/backend/app/api/apps/monitoring.py new file mode 100644 index 0000000..e7ca6fa --- /dev/null +++ b/backend/app/api/apps/monitoring.py @@ -0,0 +1,1003 @@ +"""API routes for app monitoring services. + +Manages Grafana, Prometheus, and Jaeger as sub-services of apps like Semantic Router. +""" + +import asyncio +import base64 +import logging + +import httpx +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.apps.deployment import ( + pull_image_with_progress, + set_deployment_progress, + wait_for_container_healthy, +) +from app.api.apps.utils import CONTAINER_ACTION_TIMEOUT +from app.core.deps import require_operator, require_viewer +from app.database import get_db +from app.models.app import APP_DEFINITIONS, MONITORING_DEFINITIONS, App, AppStatus, AppType +from app.models.user import User +from app.models.worker import Worker +from app.services.app_proxy_manager import get_proxy_manager + +logger = logging.getLogger(__name__) +router = APIRouter() + + +# ============================================================================= +# Schemas +# ============================================================================= + + +class MonitoringServiceStatus(BaseModel): + """Status of a single monitoring service.""" + + name: str + type: str # grafana, prometheus, jaeger + status: str + port: int | None = None + url: str | None = None + + +class MonitoringStatus(BaseModel): + """Overall monitoring status for an app.""" + + enabled: bool + services: list[MonitoringServiceStatus] + + +class MonitoringDeployRequest(BaseModel): + """Request to deploy monitoring services.""" + + services: list[str] | None = None # ["grafana", "prometheus", "jaeger"], None = all + + +# ============================================================================= +# Routes +# ============================================================================= + + +@router.get("/{app_id}/monitoring", response_model=MonitoringStatus) +async def get_monitoring_status( + app_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_viewer), +): + """Get monitoring status for an app.""" + # Get parent app + result = await db.execute(select(App).where(App.id == app_id)) + app = result.scalar_one_or_none() + if not app: + raise HTTPException(status_code=404, detail="App not found") + + # Check if app supports monitoring + try: + app_type = AppType(app.app_type) + app_def = APP_DEFINITIONS.get(app_type, {}) + if not app_def.get("has_monitoring"): + raise HTTPException(status_code=400, detail="This app does not support monitoring") + except ValueError: + raise HTTPException(status_code=400, detail="Invalid app type") + + # Get child monitoring apps + result = await db.execute(select(App).where(App.parent_app_id == app_id)) + child_apps = result.scalars().all() + + # Get worker for URL building + await db.refresh(app, ["worker"]) + worker_host = app.worker.address.split(":")[0] + + # Use browser hostname if available + host = request.headers.get("host", "").split(":")[0] + if not host or host in ("localhost", "127.0.0.1"): + host = worker_host + + services = [] + for child in child_apps: + url = ( + f"http://{host}:{child.port}" + if child.port and child.status == AppStatus.RUNNING.value + else None + ) + services.append( + MonitoringServiceStatus( + name=MONITORING_DEFINITIONS.get(child.app_type, {}).get("name", child.app_type), + type=child.app_type, + status=child.status, + port=child.port, + url=url, + ) + ) + + return MonitoringStatus( + enabled=len(services) > 0, + services=services, + ) + + +@router.post("/{app_id}/monitoring", response_model=MonitoringStatus) +async def deploy_monitoring( + app_id: int, + deploy_request: MonitoringDeployRequest, + request: Request, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_operator), +): + """Deploy monitoring services for an app.""" + # Get parent app + result = await db.execute(select(App).where(App.id == app_id)) + app = result.scalar_one_or_none() + if not app: + raise HTTPException(status_code=404, detail="App not found") + + # Check if app supports monitoring + try: + app_type = AppType(app.app_type) + app_def = APP_DEFINITIONS.get(app_type, {}) + if not app_def.get("has_monitoring"): + raise HTTPException(status_code=400, detail="This app does not support monitoring") + except ValueError: + raise HTTPException(status_code=400, detail="Invalid app type") + + # Determine which services to deploy + services_to_deploy = deploy_request.services or list(MONITORING_DEFINITIONS.keys()) + + # Validate services + for svc in services_to_deploy: + if svc not in MONITORING_DEFINITIONS: + raise HTTPException(status_code=400, detail=f"Unknown monitoring service: {svc}") + + # Check for already deployed services + result = await db.execute(select(App).where(App.parent_app_id == app_id)) + existing = {a.app_type: a for a in result.scalars().all()} + + # Get worker + await db.refresh(app, ["worker"]) + worker = app.worker + + # Find available ports starting from parent app's port + 10 + base_port = (app.port or 9000) + 10 + result = await db.execute( + select(App.port).where(App.worker_id == worker.id, App.port.isnot(None)) + ) + used_ports = {row[0] for row in result.fetchall()} + + created_apps = [] + port = base_port + for svc_type in services_to_deploy: + if svc_type in existing: + # Already deployed, skip + created_apps.append(existing[svc_type]) + continue + + # Find next available port + while port in used_ports: + port += 1 + + svc_def = MONITORING_DEFINITIONS[svc_type] + svc_app = App( + app_type=svc_type, + name=f"{svc_def['name']} ({app.name})", + worker_id=worker.id, + parent_app_id=app_id, + status=AppStatus.PENDING.value, + proxy_path=f"/apps/{app.app_type}/monitoring/{svc_type}", + port=port, + use_proxy=app.use_proxy, + ) + db.add(svc_app) + await db.flush() + created_apps.append(svc_app) + used_ports.add(port) + port += 1 + + await db.commit() + + # Find prometheus port for Grafana configuration + prometheus_port = None + for svc_app in created_apps: + if svc_app.app_type == "prometheus": + prometheus_port = svc_app.port + break + + # Start background deployment for new services + # Deploy in order: prometheus first (Grafana needs it), then others + deploy_order = ["prometheus", "grafana", "jaeger"] + sorted_apps = sorted( + created_apps, + key=lambda a: deploy_order.index(a.app_type) if a.app_type in deploy_order else 99, + ) + + for svc_app in sorted_apps: + if svc_app.status == AppStatus.PENDING.value: + svc_def = MONITORING_DEFINITIONS[svc_app.app_type] + background_tasks.add_task( + deploy_monitoring_background, + app_id=svc_app.id, + parent_app_id=app_id, + svc_type=svc_app.app_type, + worker_address=worker.address, + port=svc_app.port, + svc_def=svc_def, + use_proxy=svc_app.use_proxy, + parent_app_port=app.port, + prometheus_port=prometheus_port, + ) + + # Return status + host = request.headers.get("host", "").split(":")[0] + worker_host = worker.address.split(":")[0] + if not host or host in ("localhost", "127.0.0.1"): + host = worker_host + + services = [] + for svc_app in created_apps: + await db.refresh(svc_app) + url = ( + f"http://{host}:{svc_app.port}" + if svc_app.port and svc_app.status == AppStatus.RUNNING.value + else None + ) + services.append( + MonitoringServiceStatus( + name=MONITORING_DEFINITIONS.get(svc_app.app_type, {}).get("name", svc_app.app_type), + type=svc_app.app_type, + status=svc_app.status, + port=svc_app.port, + url=url, + ) + ) + + return MonitoringStatus(enabled=True, services=services) + + +@router.delete("/{app_id}/monitoring") +async def remove_monitoring( + app_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_operator), +): + """Remove all monitoring services for an app.""" + # Get parent app + result = await db.execute(select(App).where(App.id == app_id)) + app = result.scalar_one_or_none() + if not app: + raise HTTPException(status_code=404, detail="App not found") + + # Get child monitoring apps + result = await db.execute(select(App).where(App.parent_app_id == app_id)) + child_apps = result.scalars().all() + + if not child_apps: + return {"message": "No monitoring services to remove"} + + # Get worker + await db.refresh(app, ["worker"]) + worker = app.worker + + # Stop and remove containers + async with httpx.AsyncClient(timeout=CONTAINER_ACTION_TIMEOUT) as client: + for child in child_apps: + if child.container_id: + try: + # Stop container + await client.post( + f"http://{worker.address}/containers/{child.container_id}/stop" + ) + # Remove container + await client.delete(f"http://{worker.address}/containers/{child.container_id}") + except Exception as e: + logger.warning(f"Failed to remove container for {child.app_type}: {e}") + + # Remove nginx proxy + if child.use_proxy: + try: + proxy_manager = get_proxy_manager() + await proxy_manager.remove_app_proxy(child.id) + except Exception as e: + logger.warning(f"Failed to remove proxy for {child.app_type}: {e}") + + # Delete from database + await db.delete(child) + + await db.commit() + + return {"message": f"Removed {len(child_apps)} monitoring service(s)"} + + +@router.delete("/{app_id}/monitoring/{service_type}") +async def remove_monitoring_service( + app_id: int, + service_type: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_operator), +): + """Remove a specific monitoring service.""" + # Get parent app + result = await db.execute(select(App).where(App.id == app_id)) + app = result.scalar_one_or_none() + if not app: + raise HTTPException(status_code=404, detail="App not found") + + # Get specific monitoring app + result = await db.execute( + select(App).where(App.parent_app_id == app_id, App.app_type == service_type) + ) + child = result.scalar_one_or_none() + if not child: + raise HTTPException(status_code=404, detail=f"Monitoring service {service_type} not found") + + # Get worker + await db.refresh(app, ["worker"]) + worker = app.worker + + # Stop and remove container + if child.container_id: + async with httpx.AsyncClient(timeout=CONTAINER_ACTION_TIMEOUT) as client: + try: + await client.post(f"http://{worker.address}/containers/{child.container_id}/stop") + await client.delete(f"http://{worker.address}/containers/{child.container_id}") + except Exception as e: + logger.warning(f"Failed to remove container for {service_type}: {e}") + + # Remove nginx proxy + if child.use_proxy: + try: + proxy_manager = get_proxy_manager() + await proxy_manager.remove_app_proxy(child.id) + except Exception as e: + logger.warning(f"Failed to remove proxy for {service_type}: {e}") + + # Delete from database + await db.delete(child) + await db.commit() + + return {"message": f"Removed {service_type} monitoring service"} + + +# ============================================================================= +# Background Tasks +# ============================================================================= + + +async def deploy_monitoring_background( + app_id: int, + parent_app_id: int, + svc_type: str, + worker_address: str, + port: int, + svc_def: dict, + use_proxy: bool, + parent_app_port: int | None = None, + prometheus_port: int | None = None, +) -> None: + """Background task to deploy a monitoring service. + + Args: + app_id: Monitoring service app ID + parent_app_id: Parent app (Semantic Router) ID + svc_type: Service type (grafana, prometheus, jaeger) + worker_address: Worker address + port: Host port for this service + svc_def: Service definition + use_proxy: Whether to setup nginx proxy + parent_app_port: Parent app's port (for calculating metrics port) + prometheus_port: Prometheus port (for Grafana datasource config) + """ + from app.database import async_session_maker + + async with async_session_maker() as db: + try: + # Get app + result = await db.execute(select(App).where(App.id == app_id)) + app = result.scalar_one_or_none() + if not app: + logger.error(f"Monitoring app {app_id} not found") + return + + # Get worker + result = await db.execute(select(Worker).where(Worker.id == app.worker_id)) + worker = result.scalar_one_or_none() + if not worker: + logger.error(f"Worker not found for monitoring app {app_id}") + app.status = AppStatus.ERROR.value + app.status_message = "Worker not found" + await db.commit() + return + + # Get parent app for config + parent_app = None + if parent_app_id: + result = await db.execute(select(App).where(App.id == parent_app_id)) + parent_app = result.scalar_one_or_none() + + # Phase 1: Pull image + app.status = AppStatus.PULLING.value + app.status_message = "Pulling image..." + await db.commit() + + try: + await pull_image_with_progress(worker, svc_def["image"], app_id) + except Exception as e: + app.status = AppStatus.ERROR.value + app.status_message = f"Failed to pull image: {e}" + await db.commit() + return + + # Phase 2: Setup configuration (Prometheus needs config file) + if svc_type == "prometheus" and parent_app: + set_deployment_progress(app_id, "starting", 5, "Creating Prometheus config...") + await _create_prometheus_config(worker_address, parent_app, port) + + # Phase 3: Create container + app.status = AppStatus.STARTING.value + app.status_message = "Starting container..." + await db.commit() + + set_deployment_progress(app_id, "starting", 10, "Creating container...") + + container_id = await _create_monitoring_container( + worker_address=worker_address, + app_id=app_id, + svc_type=svc_type, + svc_def=svc_def, + port=port, + parent_app=parent_app, + prometheus_port=prometheus_port, + ) + if not container_id: + return + + app.container_id = container_id + await db.commit() + + # Phase 4: Wait for health + set_deployment_progress(app_id, "starting", 50, "Waiting for service to start...") + + await wait_for_container_healthy( + worker_address=worker_address, + container_id=container_id, + app_id=app_id, + port=port, + ) + + # Phase 5: Post-deployment setup (Grafana needs datasource and dashboard) + if svc_type == "grafana" and prometheus_port: + set_deployment_progress(app_id, "starting", 80, "Configuring Grafana datasource...") + await _configure_grafana(worker_address, port, prometheus_port) + + # Phase 6: Setup proxy + if use_proxy: + await _setup_monitoring_proxy(app_id, svc_type, worker_address, port) + + # Update parent app's environment with monitoring URLs + await _update_parent_monitoring_urls(db, parent_app_id) + + # Mark as running + app.status = AppStatus.RUNNING.value + app.status_message = None + await db.commit() + + set_deployment_progress(app_id, "running", 100, f"{svc_def['name']} deployed") + logger.info(f"Monitoring service {svc_type} deployed for app {parent_app_id}") + + except Exception as e: + logger.exception(f"Failed to deploy monitoring {svc_type}: {e}") + try: + result = await db.execute(select(App).where(App.id == app_id)) + app = result.scalar_one_or_none() + if app: + app.status = AppStatus.ERROR.value + app.status_message = str(e) + await db.commit() + except Exception as db_error: + logger.error(f"Failed to update monitoring error status: {db_error}") + + set_deployment_progress(app_id, "error", 0, str(e)) + + +async def _create_monitoring_container( + worker_address: str, + app_id: int, + svc_type: str, + svc_def: dict, + port: int, + parent_app: App | None = None, + prometheus_port: int | None = None, +) -> str | None: + """Create monitoring container on worker. + + Args: + worker_address: Worker address + app_id: Monitoring app ID + svc_type: Service type (grafana, prometheus, jaeger) + svc_def: Service definition from MONITORING_DEFINITIONS + port: Host port for this service + parent_app: Parent app (Semantic Router) for getting metrics endpoint + prometheus_port: Prometheus port (needed for Grafana datasource config) + """ + from app.database import async_session_maker + + container_name = f"lmstack-monitoring-{svc_type}" + worker_host = worker_address.split(":")[0] + + # Build volumes + volumes = [] + for vol in svc_def.get("volumes", []): + volumes.append( + { + "source": f"{container_name}-{vol['name']}", + "destination": vol["destination"], + "mode": "rw", + } + ) + + # Build port mappings + ports = [ + { + "container_port": svc_def["internal_port"], + "host_port": port, + "protocol": "tcp", + } + ] + + # Build env vars + env_vars = dict(svc_def.get("env_template", {})) + + # Service-specific configuration + command = None + + if svc_type == "prometheus" and parent_app: + # Prometheus needs to know where to scrape metrics from + # Semantic Router exposes metrics on port 9190 + parent_metrics_port = ( + parent_app.port + 2 if parent_app.port else 9192 + ) # Main port + 2 = metrics + + # For local workers, use host.docker.internal + # For remote workers, use the worker's actual address + if worker_host in ("localhost", "127.0.0.1"): + metrics_target = f"host.docker.internal:{parent_metrics_port}" + else: + metrics_target = f"{worker_host}:{parent_metrics_port}" + + # Prometheus config passed via command line args + # We use a minimal config that scrapes the semantic router + command = [ + "--config.file=/etc/prometheus/prometheus.yml", + "--storage.tsdb.path=/prometheus", + "--web.console.libraries=/usr/share/prometheus/console_libraries", + "--web.console.templates=/usr/share/prometheus/consoles", + "--web.enable-lifecycle", + ] + + # We'll need to create the config file via init container or bind mount + # For now, use file_configs volume approach - create config on worker + env_vars["ROUTER_TARGET"] = metrics_target + + elif svc_type == "grafana" and prometheus_port: + # Grafana needs to know where Prometheus is + if worker_host in ("localhost", "127.0.0.1"): + prometheus_url = f"http://host.docker.internal:{prometheus_port}" + else: + prometheus_url = f"http://{worker_host}:{prometheus_port}" + + # Use Grafana's environment-based datasource provisioning + env_vars["GF_DATASOURCES_DEFAULT_NAME"] = "Prometheus" + env_vars["GF_DATASOURCES_DEFAULT_TYPE"] = "prometheus" + env_vars["GF_DATASOURCES_DEFAULT_URL"] = prometheus_url + env_vars["GF_DATASOURCES_DEFAULT_ACCESS"] = "proxy" + env_vars["GF_DATASOURCES_DEFAULT_ISDEFAULT"] = "true" + + payload = { + "name": container_name, + "image": svc_def["image"], + "env": env_vars, + "ports": ports, + "volumes": volumes, + "restart_policy": "unless-stopped", + "labels": { + "lmstack.monitoring": "true", + "lmstack.monitoring.type": svc_type, + "lmstack.monitoring.app_id": str(app_id), + }, + "extra_hosts": {"host.docker.internal": "host-gateway"}, + } + + if command: + payload["command"] = command + + try: + async with httpx.AsyncClient(timeout=CONTAINER_ACTION_TIMEOUT) as client: + response = await client.post( + f"http://{worker_address}/containers", + json=payload, + ) + if response.status_code >= 400: + raise Exception(f"Failed to create container: {response.text}") + + container_data = response.json() + return container_data.get("id") + + except Exception as e: + async with async_session_maker() as db: + result = await db.execute(select(App).where(App.id == app_id)) + app = result.scalar_one_or_none() + if app: + app.status = AppStatus.ERROR.value + app.status_message = f"Failed to create container: {e}" + await db.commit() + + set_deployment_progress(app_id, "error", 0, str(e)) + return None + + +async def _setup_monitoring_proxy( + app_id: int, + svc_type: str, + worker_address: str, + port: int, +) -> None: + """Setup nginx proxy for monitoring service.""" + set_deployment_progress(app_id, "starting", 95, "Setting up proxy...") + + try: + proxy_manager = get_proxy_manager() + proxy_worker_host = worker_address.split(":")[0] + + await proxy_manager.add_app_proxy( + app_id=app_id, + app_type=f"monitoring-{svc_type}", + listen_port=port, + worker_host=proxy_worker_host, + worker_port=port, + ) + logger.info(f"Nginx proxy configured for monitoring {svc_type} on port {port}") + + except Exception as e: + logger.warning(f"Failed to setup nginx proxy for monitoring: {e}") + + +async def _update_parent_monitoring_urls(db: AsyncSession, parent_app_id: int) -> None: + """Update parent app's environment with monitoring URLs. + + This updates the Semantic Router container with the URLs of deployed monitoring services. + When all monitoring services are running, it restarts the parent container with new env vars. + """ + # Get parent app + result = await db.execute(select(App).where(App.id == parent_app_id)) + parent_app = result.scalar_one_or_none() + if not parent_app: + return + + # Get all monitoring services + result = await db.execute(select(App).where(App.parent_app_id == parent_app_id)) + monitoring_apps = list(result.scalars().all()) + + # Build monitoring URLs + await db.refresh(parent_app, ["worker"]) + worker_host = parent_app.worker.address.split(":")[0] + + monitoring_urls = {} + all_running = True + for mon_app in monitoring_apps: + if mon_app.status == AppStatus.RUNNING.value and mon_app.port: + if mon_app.app_type == "grafana": + monitoring_urls["grafana_url"] = f"http://{worker_host}:{mon_app.port}" + elif mon_app.app_type == "prometheus": + monitoring_urls["prometheus_url"] = f"http://{worker_host}:{mon_app.port}" + elif mon_app.app_type == "jaeger": + monitoring_urls["jaeger_url"] = f"http://{worker_host}:{mon_app.port}" + elif mon_app.status not in (AppStatus.ERROR.value, AppStatus.STOPPED.value): + all_running = False + + # Store in parent app's config for reference + config = parent_app.config or {} + config["monitoring_urls"] = monitoring_urls + parent_app.config = config + await db.commit() + + logger.info(f"Updated monitoring URLs for app {parent_app_id}: {monitoring_urls}") + + # If all monitoring services are running, restart parent app to pick up new URLs + if all_running and monitoring_urls and parent_app.status == AppStatus.RUNNING.value: + logger.info( + f"All monitoring services running, restarting parent app {parent_app_id} to apply URLs" + ) + await _restart_parent_with_monitoring_urls(db, parent_app, monitoring_urls) + + +async def _restart_parent_with_monitoring_urls( + db: AsyncSession, + parent_app: App, + monitoring_urls: dict, +) -> None: + """Restart parent app container with monitoring URLs injected into environment. + + This stops the old container, creates a new one with updated env vars, and starts it. + """ + from app.api.apps.deployment import wait_for_container_healthy + from app.models.app import APP_DEFINITIONS, AppType + + if not parent_app.container_id: + return + + try: + app_type = AppType(parent_app.app_type) + app_def = APP_DEFINITIONS.get(app_type) + if not app_def: + return + except ValueError: + return + + await db.refresh(parent_app, ["worker"]) + worker = parent_app.worker + worker_address = worker.address + + logger.info(f"Restarting {parent_app.name} with monitoring URLs: {monitoring_urls}") + + async with httpx.AsyncClient(timeout=CONTAINER_ACTION_TIMEOUT) as client: + # Stop and remove old container + try: + await client.post(f"http://{worker_address}/containers/{parent_app.container_id}/stop") + await client.delete(f"http://{worker_address}/containers/{parent_app.container_id}") + except Exception as e: + logger.warning(f"Failed to stop/remove old container: {e}") + + # Build env vars from template, injecting monitoring URLs + new_env = {} + for key, value in app_def.get("env_template", {}).items(): + if value == "{grafana_url}": + new_env[key] = monitoring_urls.get("grafana_url", "") + elif value == "{prometheus_url}": + new_env[key] = monitoring_urls.get("prometheus_url", "") + elif value == "{jaeger_url}": + new_env[key] = monitoring_urls.get("jaeger_url", "") + elif value == "{hf_token}": + # Try to get HF_TOKEN from app config if stored, otherwise empty + app_config = parent_app.config or {} + new_env[key] = app_config.get("hf_token", "") + elif value.startswith("{") and value.endswith("}"): + # Other placeholders - use empty or defaults + new_env[key] = "" + else: + # Static values + new_env[key] = value + + # Rebuild container with same config but new env + container_name = f"lmstack-app-{app_type.value}" + + volumes = [] + for vol in app_def.get("volumes", []): + volumes.append( + { + "source": f"{container_name}-{vol['name']}", + "destination": vol["destination"], + "mode": "rw", + } + ) + + ports = [ + { + "container_port": app_def["internal_port"], + "host_port": parent_app.port, + "protocol": "tcp", + } + ] + + # Add additional ports + for i, port_info in enumerate(app_def.get("additional_ports", [])): + if isinstance(port_info, dict): + container_port = port_info["container_port"] + else: + container_port = port_info + ports.append( + { + "container_port": container_port, + "host_port": parent_app.port + 1 + i, + "protocol": "tcp", + } + ) + + payload = { + "name": container_name, + "image": app_def["image"], + "env": new_env, + "ports": ports, + "volumes": volumes, + "restart_policy": "unless-stopped", + "labels": { + "lmstack.app": "true", + "lmstack.app.type": app_type.value, + "lmstack.app.id": str(parent_app.id), + }, + "extra_hosts": {"host.docker.internal": "host-gateway"}, + } + + if app_def.get("entrypoint"): + payload["entrypoint"] = app_def["entrypoint"] + if app_def.get("command"): + payload["command"] = app_def["command"] + if app_def.get("cap_add"): + payload["cap_add"] = app_def["cap_add"] + + try: + resp = await client.post(f"http://{worker_address}/containers", json=payload) + if resp.status_code >= 400: + logger.error(f"Failed to create new container: {resp.text}") + return + + new_container_id = resp.json().get("id") + parent_app.container_id = new_container_id + await db.commit() + + logger.info(f"Created new container {new_container_id} with monitoring URLs") + + # Wait for container to be healthy + await wait_for_container_healthy( + worker_address=worker_address, + container_id=new_container_id, + app_id=parent_app.id, + port=parent_app.port, + ) + + logger.info(f"Parent app {parent_app.id} restarted successfully with monitoring URLs") + + except Exception as e: + logger.error(f"Failed to restart parent app: {e}") + + +async def _create_prometheus_config( + worker_address: str, + parent_app: App, + prometheus_port: int, +) -> None: + """Create Prometheus configuration file on worker. + + Uses a helper container to write the prometheus.yml config to the volume. + """ + worker_host = worker_address.split(":")[0] + + # Calculate metrics port: Semantic Router main port + 2 = metrics port (9190 internally mapped) + # The router exposes :8888 (API), :8700 (Dashboard), and :9190 (metrics) + # We map them as: port, port+1, port+2 + metrics_port = parent_app.port + 2 if parent_app.port else 9192 + + # Determine metrics target + if worker_host in ("localhost", "127.0.0.1"): + metrics_target = f"host.docker.internal:{metrics_port}" + else: + metrics_target = f"{worker_host}:{metrics_port}" + + # Prometheus configuration YAML - using cat with heredoc to avoid quote issues + prometheus_config = f"""global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: semantic-router + static_configs: + - targets: ["{metrics_target}"] + metrics_path: /metrics + scrape_interval: 5s +""" + + volume_name = "lmstack-monitoring-prometheus-prometheus-config" + + # Use a helper container to write the config file + # Using base64 encoding to avoid shell quoting issues + config_b64 = base64.b64encode(prometheus_config.encode()).decode() + + helper_payload = { + "name": "lmstack-prometheus-config-helper", + "image": "alpine:latest", + "command": [ + "sh", + "-c", + f"mkdir -p /etc/prometheus && echo '{config_b64}' | base64 -d > /etc/prometheus/prometheus.yml && cat /etc/prometheus/prometheus.yml", + ], + "volumes": [ + { + "source": volume_name, + "destination": "/etc/prometheus", + "mode": "rw", + } + ], + "restart_policy": "no", + "labels": {"lmstack.helper": "true"}, + } + + async with httpx.AsyncClient(timeout=CONTAINER_ACTION_TIMEOUT) as client: + try: + # Create and run helper container + resp = await client.post( + f"http://{worker_address}/containers", + json=helper_payload, + ) + if resp.status_code >= 400: + logger.warning(f"Failed to create prometheus config helper: {resp.text}") + return + + helper_id = resp.json().get("id") + logger.info(f"Created prometheus config with helper container {helper_id}") + + # Wait a moment for the container to finish + await asyncio.sleep(2) + + # Clean up helper container + try: + await client.delete( + f"http://{worker_address}/containers/{helper_id}", + params={"force": True}, + ) + except Exception: + pass + + except Exception as e: + logger.warning(f"Failed to create prometheus config: {e}") + + +async def _configure_grafana( + worker_address: str, + grafana_port: int, + prometheus_port: int, +) -> None: + """Configure Grafana datasource and dashboard via API. + + Grafana needs a moment to start, so we retry a few times. + """ + worker_host = worker_address.split(":")[0] + + # Determine Prometheus URL from Grafana's perspective + if worker_host in ("localhost", "127.0.0.1"): + prometheus_url = f"http://host.docker.internal:{prometheus_port}" + grafana_url = f"http://localhost:{grafana_port}" + else: + prometheus_url = f"http://{worker_host}:{prometheus_port}" + grafana_url = f"http://{worker_host}:{grafana_port}" + + # Grafana datasource payload + datasource_payload = { + "name": "Prometheus", + "type": "prometheus", + "url": prometheus_url, + "access": "proxy", + "isDefault": True, + } + + # Try to add datasource (Grafana needs time to start) + max_retries = 5 + for i in range(max_retries): + try: + async with httpx.AsyncClient(timeout=30.0) as client: + # Check if Grafana is ready + health_resp = await client.get(f"{grafana_url}/api/health") + if health_resp.status_code != 200: + raise Exception("Grafana not ready") + + # Add datasource + ds_resp = await client.post( + f"{grafana_url}/api/datasources", + json=datasource_payload, + auth=("admin", "admin"), + ) + + if ds_resp.status_code in (200, 409): # 409 = already exists + logger.info(f"Grafana datasource configured: {ds_resp.status_code}") + return + else: + logger.warning(f"Failed to add Grafana datasource: {ds_resp.text}") + + except Exception as e: + logger.debug(f"Grafana not ready yet (attempt {i+1}/{max_retries}): {e}") + await asyncio.sleep(3) + + logger.warning("Failed to configure Grafana datasource after retries") diff --git a/backend/app/api/apps/routes.py b/backend/app/api/apps/routes.py index 411c87a..642f244 100644 --- a/backend/app/api/apps/routes.py +++ b/backend/app/api/apps/routes.py @@ -17,6 +17,7 @@ set_deployment_progress, ) from app.api.apps.lifecycle import router as lifecycle_router +from app.api.apps.monitoring import router as monitoring_router from app.api.apps.utils import ( API_KEY_PREFIX, app_to_response, @@ -48,6 +49,9 @@ # Include lifecycle routes (start/stop/delete/logs) router.include_router(lifecycle_router) +# Include monitoring routes (grafana/prometheus/jaeger for semantic router) +router.include_router(monitoring_router) + # ============================================================================= # List & Discovery Endpoints @@ -67,6 +71,7 @@ async def list_available_apps( name=definition["name"], description=definition["description"], image=definition["image"], + has_monitoring=definition.get("has_monitoring", False), ) ) return AvailableAppsResponse(items=items) @@ -80,12 +85,24 @@ async def list_apps( db: AsyncSession = Depends(get_db), current_user: User = Depends(require_viewer), ): - """List all deployed apps (requires viewer+).""" - # Count total - total = await db.scalar(select(func.count()).select_from(App)) + """List all deployed apps (requires viewer+). + + Note: Child apps (monitoring services) are filtered out - they appear + in their parent app's monitoring section instead. + """ + # Count total (excluding child apps) + total = await db.scalar( + select(func.count()).select_from(App).where(App.parent_app_id.is_(None)) + ) - # Get paginated results with worker relationship - result = await db.execute(select(App).offset(skip).limit(limit).order_by(App.created_at.desc())) + # Get paginated results, excluding child apps (monitoring services) + result = await db.execute( + select(App) + .where(App.parent_app_id.is_(None)) + .offset(skip) + .limit(limit) + .order_by(App.created_at.desc()) + ) apps = result.scalars().all() # Load worker relationships @@ -188,6 +205,11 @@ async def deploy_app( use_proxy = False logger.info(f"Auto-disabled proxy for localhost worker {worker.name}") + # Store deployment config for later use (e.g., when restarting with monitoring URLs) + app_config = {} + if deploy_request.hf_token: + app_config["hf_token"] = deploy_request.hf_token + # Create app record app = App( app_type=app_type.value, @@ -198,6 +220,7 @@ async def deploy_app( proxy_path=proxy_path, port=port, use_proxy=use_proxy, + config=app_config if app_config else None, ) db.add(app) await db.commit() @@ -342,6 +365,9 @@ async def _build_env_vars( elif value == "{hf_token}": # HuggingFace token - use provided token or empty string env_vars[key] = hf_token or "" + elif value in ("{grafana_url}", "{prometheus_url}", "{jaeger_url}"): + # Optional monitoring URLs - leave empty if not configured + env_vars[key] = "" else: env_vars[key] = value diff --git a/backend/app/api/apps/utils.py b/backend/app/api/apps/utils.py index 36b52b2..50b220c 100644 --- a/backend/app/api/apps/utils.py +++ b/backend/app/api/apps/utils.py @@ -13,9 +13,9 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.models.app import App, AppStatus +from app.models.app import APP_DEFINITIONS, App, AppStatus, AppType from app.models.worker import Worker -from app.schemas.app import AppResponse +from app.schemas.app import AppPortInfo, AppResponse logger = logging.getLogger(__name__) @@ -202,18 +202,48 @@ def app_to_response(app: App, request: Request) -> AppResponse: if app.status == AppStatus.RUNNING.value and app.proxy_path: proxy_url = str(request.base_url).rstrip("/") + app.proxy_path + # Determine host for URL building + if app.use_proxy: + # Use LMStack host (nginx proxy) + url_host = request.headers.get("host", "localhost:52000").split(":")[0] + else: + # Direct connection to worker + url_host = worker_address.split(":")[0] if worker_address else None + # Build access URL based on proxy setting access_url = None - if app.status == AppStatus.RUNNING.value and app.port: - if app.use_proxy: - # Use LMStack host with app port (nginx proxy) - host = request.headers.get("host", "localhost:52000").split(":")[0] - access_url = f"http://{host}:{app.port}" - else: - # Direct connection to worker - if worker_address: - worker_host = worker_address.split(":")[0] - access_url = f"http://{worker_host}:{app.port}" + if app.status == AppStatus.RUNNING.value and app.port and url_host: + access_url = f"http://{url_host}:{app.port}" + + # Build additional URLs for apps with multiple ports + additional_urls = None + has_monitoring = False + try: + app_type = AppType(app.app_type) + app_def = APP_DEFINITIONS.get(app_type, {}) + has_monitoring = app_def.get("has_monitoring", False) + + if app.status == AppStatus.RUNNING.value and app.port and url_host: + additional_ports = app_def.get("additional_ports", []) + + if additional_ports: + additional_urls = [] + for i, port_info in enumerate(additional_ports): + if isinstance(port_info, dict): + port_name = port_info.get("name", f"Port {i + 1}") + else: + port_name = f"Port {i + 1}" + + host_port = app.port + 1 + i + additional_urls.append( + AppPortInfo( + name=port_name, + port=host_port, + url=f"http://{url_host}:{host_port}", + ) + ) + except (ValueError, KeyError): + pass # Invalid app type, skip additional URLs return AppResponse( id=app.id, @@ -230,7 +260,9 @@ def app_to_response(app: App, request: Request) -> AppResponse: proxy_url=proxy_url, use_proxy=app.use_proxy, access_url=access_url, + additional_urls=additional_urls, api_key_id=app.api_key_id, + has_monitoring=has_monitoring, created_at=app.created_at, updated_at=app.updated_at, ) diff --git a/backend/app/models/app.py b/backend/app/models/app.py index fd3b38f..798d539 100644 --- a/backend/app/models/app.py +++ b/backend/app/models/app.py @@ -115,11 +115,25 @@ class AppStatus(str, Enum): "description": "Intelligent LLM router that automatically selects the best model based on query intent", "image": "ghcr.io/vllm-project/semantic-router/vllm-sr:latest", "internal_port": 8888, # Main OpenAI-compatible API port (Envoy listens on 8888) - "additional_ports": [8700], # Dashboard port + "port_name": "API", # Name for the main port + "additional_ports": [ + {"container_port": 8700, "name": "Dashboard"}, + {"container_port": 9190, "name": "Metrics"}, + ], "env_template": { "ENVOY_LISTEN_PORT": "8888", "DASHBOARD_PORT": "8700", "HF_TOKEN": "{hf_token}", # Optional: for gated models + # Redirect HuggingFace cache to the models volume for persistence + "HF_HOME": "/app/models", + "TRANSFORMERS_CACHE": "/app/models", + # Router API URL (internal) + "TARGET_ROUTER_API_URL": "http://localhost:8080", + "TARGET_ROUTER_METRICS_URL": "http://localhost:9190/metrics", + # Optional: Monitoring URLs (leave empty to disable) + "TARGET_GRAFANA_URL": "{grafana_url}", + "TARGET_PROMETHEUS_URL": "{prometheus_url}", + "TARGET_JAEGER_URL": "{jaeger_url}", }, "volumes": [ {"name": "semantic-router-config", "destination": "/app/config"}, @@ -133,6 +147,59 @@ class AppStatus(str, Enum): ], "requires_config": True, # Indicates this app needs dynamic config generation "singleton": True, # Only one instance should be deployed per cluster + "has_monitoring": True, # Supports optional monitoring stack + }, +} + + +# Internal monitoring service definitions (not user-deployable, only as sub-services) +MONITORING_DEFINITIONS = { + "grafana": { + "name": "Grafana", + "description": "Metrics visualization dashboard", + "image": "grafana/grafana:latest", + "internal_port": 3000, + "env_template": { + "GF_SECURITY_ADMIN_PASSWORD": "admin", + "GF_USERS_ALLOW_SIGN_UP": "false", + "GF_AUTH_ANONYMOUS_ENABLED": "true", + "GF_AUTH_ANONYMOUS_ORG_ROLE": "Viewer", + # Allow embedding in iframes (for Semantic Router dashboard) + "GF_SECURITY_ALLOW_EMBEDDING": "true", + "GF_AUTH_ANONYMOUS_ORG_NAME": "Main Org.", + # Disable features we don't need + "GF_ALERTING_ENABLED": "false", + "GF_UNIFIED_ALERTING_ENABLED": "false", + }, + "volumes": [ + {"name": "grafana-data", "destination": "/var/lib/grafana"}, + # Provisioning directories will be mounted + ], + # Grafana needs special provisioning - see monitoring.py + "requires_provisioning": True, + }, + "prometheus": { + "name": "Prometheus", + "description": "Metrics collection and alerting", + "image": "prom/prometheus:latest", + "internal_port": 9090, + "env_template": {}, + "volumes": [ + {"name": "prometheus-data", "destination": "/prometheus"}, + {"name": "prometheus-config", "destination": "/etc/prometheus"}, + ], + # Prometheus needs custom config - see monitoring.py + "requires_config": True, + }, + "jaeger": { + "name": "Jaeger", + "description": "Distributed tracing", + "image": "jaegertracing/all-in-one:latest", + "internal_port": 16686, + "env_template": { + "COLLECTOR_OTLP_ENABLED": "true", + }, + "volumes": [], }, } @@ -153,6 +220,11 @@ class App(Base): # Worker where app is deployed worker_id: Mapped[int] = mapped_column(Integer, ForeignKey("workers.id"), nullable=False) + # Parent app (for monitoring services linked to Semantic Router, etc.) + parent_app_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("apps.id", ondelete="CASCADE"), nullable=True + ) + # Associated API key (auto-created for the app) api_key_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("api_keys.id"), nullable=True @@ -184,6 +256,12 @@ class App(Base): # Relationships worker: Mapped["Worker"] = relationship("Worker") api_key: Mapped[Optional["ApiKey"]] = relationship("ApiKey") + parent_app: Mapped[Optional["App"]] = relationship( + "App", remote_side="App.id", back_populates="child_apps" + ) + child_apps: Mapped[list["App"]] = relationship( + "App", back_populates="parent_app", cascade="all, delete-orphan" + ) def __repr__(self) -> str: return f"<App(id={self.id}, type='{self.app_type}', status='{self.status}')>" diff --git a/backend/app/schemas/app.py b/backend/app/schemas/app.py index b9fd920..3c68546 100644 --- a/backend/app/schemas/app.py +++ b/backend/app/schemas/app.py @@ -12,6 +12,7 @@ class AppDefinition(BaseModel): name: str description: str image: str + has_monitoring: bool = False # Whether app supports monitoring stack class AppDeploy(BaseModel): @@ -30,6 +31,14 @@ class AppDeploy(BaseModel): ) +class AppPortInfo(BaseModel): + """Information about an app port""" + + name: str # e.g., "API", "Dashboard" + port: int # Host port + url: str | None = None # Access URL + + class AppResponse(BaseModel): """App response schema""" @@ -46,8 +55,10 @@ class AppResponse(BaseModel): proxy_path: str proxy_url: str | None = None use_proxy: bool = True - access_url: str | None = None # The URL to access the app + access_url: str | None = None # The URL to access the app (main port) + additional_urls: list[AppPortInfo] | None = None # Additional ports/URLs api_key_id: int | None = None + has_monitoring: bool = False # Whether app supports monitoring stack created_at: datetime updated_at: datetime diff --git a/backend/app/services/app_proxy_manager.py b/backend/app/services/app_proxy_manager.py index e831179..05ffc34 100644 --- a/backend/app/services/app_proxy_manager.py +++ b/backend/app/services/app_proxy_manager.py @@ -146,22 +146,18 @@ async def ensure_running(self) -> bool: logger.info(f"Pulling {NGINX_IMAGE}...") self.client.images.pull(NGINX_IMAGE) - # Get all ports from existing configs - ports = self._get_used_ports() - port_bindings = {f"{p}/tcp": ("0.0.0.0", p) for p in ports} - - # Start container + # Start container with host network so it can access worker IPs logger.info("Creating nginx proxy container") try: self.client.containers.run( NGINX_IMAGE, name=NGINX_CONTAINER_NAME, detach=True, + network_mode="host", # Use host network to access worker IPs volumes={ NGINX_CONF_PATH: {"bind": "/etc/nginx/nginx.conf", "mode": "ro"}, NGINX_CONFD_PATH: {"bind": "/etc/nginx/conf.d", "mode": "ro"}, }, - ports=port_bindings, restart_policy={"Name": "unless-stopped"}, ) return True @@ -200,20 +196,19 @@ async def add_app_proxy( container.stop() container.remove() - # Start with all port bindings + # Start with host network ports = self._get_used_ports() - port_bindings = {f"{p}/tcp": ("0.0.0.0", p) for p in ports} try: self.client.containers.run( NGINX_IMAGE, name=NGINX_CONTAINER_NAME, detach=True, + network_mode="host", # Use host network to access worker IPs volumes={ NGINX_CONF_PATH: {"bind": "/etc/nginx/nginx.conf", "mode": "ro"}, NGINX_CONFD_PATH: {"bind": "/etc/nginx/conf.d", "mode": "ro"}, }, - ports=port_bindings, restart_policy={"Name": "unless-stopped"}, ) logger.info(f"Nginx proxy container started with ports: {list(ports)}") @@ -241,17 +236,16 @@ async def remove_app_proxy(self, app_id: int) -> bool: logger.info("No more app proxies, nginx container removed") return True - port_bindings = {f"{p}/tcp": ("0.0.0.0", p) for p in ports} try: self.client.containers.run( NGINX_IMAGE, name=NGINX_CONTAINER_NAME, detach=True, + network_mode="host", # Use host network to access worker IPs volumes={ NGINX_CONF_PATH: {"bind": "/etc/nginx/nginx.conf", "mode": "ro"}, NGINX_CONFD_PATH: {"bind": "/etc/nginx/conf.d", "mode": "ro"}, }, - ports=port_bindings, restart_policy={"Name": "unless-stopped"}, ) return True diff --git a/backend/app/services/semantic_router.py b/backend/app/services/semantic_router.py index 1ec7690..9de56fc 100644 --- a/backend/app/services/semantic_router.py +++ b/backend/app/services/semantic_router.py @@ -143,35 +143,10 @@ async def generate_config( "ttl_seconds": 86400, "max_responses": 1000, }, - # Semantic cache - disabled by default (requires embedding model) - # Enable and configure embedding_model if you have HF_TOKEN for gated models - "semantic_cache": { - "enabled": False, - }, - # Prompt guard (jailbreak protection) - "prompt_guard": { - "enabled": True, - "model_id": "models/mom-jailbreak-classifier", - "threshold": 0.7, - "use_cpu": True, - }, - # Classifier - "classifier": { - "category_model": { - "model_id": "models/mom-domain-classifier", - "threshold": 0.6, - "use_cpu": True, - }, - "pii_model": { - "model_id": "models/mom-pii-classifier", - "threshold": 0.9, - "use_cpu": True, - }, - }, - # Hallucination mitigation (disabled by default) - "hallucination_mitigation": { - "enabled": False, - }, + # Note: The following features require ML models to be downloaded. + # They are omitted from config to avoid startup errors. + # To enable: prompt_guard, classifier, semantic_cache, hallucination_mitigation + # See: https://vllm-semantic-router.com/docs/installation/configuration # Signals (domains for routing) "signals": { "domains": self.DEFAULT_DOMAINS, @@ -281,10 +256,6 @@ def _generate_decisions(self, model_names: list[str], default_model: str) -> lis "type": "system_prompt", "configuration": {"system_prompt": "You are a helpful assistant."}, }, - { - "type": "semantic-cache", - "configuration": {"enabled": True, "similarity_threshold": 0.85}, - }, ], } ) diff --git a/backend/migrations/008_add_app_parent_id.py b/backend/migrations/008_add_app_parent_id.py new file mode 100644 index 0000000..a31d275 --- /dev/null +++ b/backend/migrations/008_add_app_parent_id.py @@ -0,0 +1,54 @@ +""" +Migration: Add parent_app_id column to apps table for monitoring services + +Run with: python -m migrations.008_add_app_parent_id +""" + +import asyncio +import sys +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +from app.config import get_settings + + +async def column_exists(conn, table_name: str, column_name: str) -> bool: + """Check if a column exists in a table (SQLite compatible)""" + result = await conn.execute(text(f"PRAGMA table_info({table_name})")) + columns = [row[1] for row in result.fetchall()] + return column_name in columns + + +async def migrate(): + settings = get_settings() + engine = create_async_engine(settings.database_url, echo=True) + + async with engine.begin() as conn: + # Add parent_app_id column if not exists + if not await column_exists(conn, "apps", "parent_app_id"): + print("Adding 'parent_app_id' column to apps table...") + await conn.execute( + text( + """ + ALTER TABLE apps ADD COLUMN parent_app_id INTEGER REFERENCES apps(id) ON DELETE CASCADE + """ + ) + ) + print("'parent_app_id' column added successfully!") + else: + print("'parent_app_id' column already exists") + + print("\n" + "=" * 50) + print("Migration completed successfully!") + print("=" * 50) + + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(migrate()) diff --git a/frontend/src/api/apps.ts b/frontend/src/api/apps.ts index d8c57e3..3f4e373 100644 --- a/frontend/src/api/apps.ts +++ b/frontend/src/api/apps.ts @@ -9,6 +9,13 @@ export interface AppDefinition { name: string; description: string; image: string; + has_monitoring?: boolean; +} + +export interface AppPortInfo { + name: string; + port: number; + url?: string; } export interface DeployedApp { @@ -26,11 +33,26 @@ export interface DeployedApp { proxy_url?: string; use_proxy: boolean; access_url?: string; + additional_urls?: AppPortInfo[]; api_key_id?: number; + has_monitoring?: boolean; created_at: string; updated_at: string; } +export interface MonitoringServiceStatus { + name: string; + type: string; + status: string; + port?: number; + url?: string; +} + +export interface MonitoringStatus { + enabled: boolean; + services: MonitoringServiceStatus[]; +} + export interface AppDeployRequest { app_type: string; worker_id: number; @@ -98,4 +120,34 @@ export const appsApi = { }); return response.data; }, + + // Monitoring endpoints + getMonitoringStatus: async (id: number): Promise<MonitoringStatus> => { + const response = await api.get<MonitoringStatus>(`/apps/${id}/monitoring`); + return response.data; + }, + + deployMonitoring: async ( + id: number, + services?: string[], + ): Promise<MonitoringStatus> => { + const response = await api.post<MonitoringStatus>( + `/apps/${id}/monitoring`, + { + services, + }, + ); + return response.data; + }, + + removeMonitoring: async (id: number): Promise<void> => { + await api.delete(`/apps/${id}/monitoring`); + }, + + removeMonitoringService: async ( + id: number, + serviceType: string, + ): Promise<void> => { + await api.delete(`/apps/${id}/monitoring/${serviceType}`); + }, }; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index f7974c2..ed5f88e 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -102,6 +102,8 @@ export type { DeployedApp, AppDeployRequest, DeployProgress, + MonitoringServiceStatus, + MonitoringStatus, } from "./apps"; // Types - Headscale diff --git a/frontend/src/pages/DeployApps.tsx b/frontend/src/pages/DeployApps.tsx index 077ca79..3940ce8 100644 --- a/frontend/src/pages/DeployApps.tsx +++ b/frontend/src/pages/DeployApps.tsx @@ -41,6 +41,9 @@ import { FullscreenExitOutlined, VerticalAlignBottomOutlined, BranchesOutlined, + DashboardOutlined, + PlusOutlined, + MinusOutlined, } from "@ant-design/icons"; import { appsApi, workersApi } from "../services/api"; import type { Worker } from "../types"; @@ -48,6 +51,8 @@ import type { AppDefinition, DeployedApp, DeployProgress, + MonitoringStatus, + MonitoringServiceStatus, } from "../services/api"; import { useResponsive } from "../hooks"; import Loading from "../components/Loading"; @@ -133,6 +138,14 @@ export default function DeployApps() { const logsRef = useRef<HTMLPreElement>(null); const { isMobile } = useResponsive(); + // Monitoring state + const [monitoringStatus, setMonitoringStatus] = useState< + Record<number, MonitoringStatus> + >({}); + const [monitoringLoading, setMonitoringLoading] = useState< + Record<number, boolean> + >({}); + const fetchData = useCallback(async () => { try { const [availableRes, deployedRes, workersRes] = await Promise.all([ @@ -156,6 +169,23 @@ export default function DeployApps() { return () => clearInterval(interval); }, [fetchData]); + // Fetch monitoring status for apps that support it + useEffect(() => { + const appsWithMonitoring = deployedApps.filter( + (app) => app.has_monitoring && app.status === "running", + ); + appsWithMonitoring.forEach((app) => { + if (!monitoringStatus[app.id]) { + appsApi + .getMonitoringStatus(app.id) + .then((status) => { + setMonitoringStatus((prev) => ({ ...prev, [app.id]: status })); + }) + .catch(console.error); + } + }); + }, [deployedApps]); + // Poll progress for deploying apps useEffect(() => { const deployingApps = deployedApps.filter((app) => @@ -341,6 +371,66 @@ export default function DeployApps() { } }; + // Monitoring handlers + const fetchMonitoringStatus = async (appId: number) => { + try { + const status = await appsApi.getMonitoringStatus(appId); + setMonitoringStatus((prev) => ({ ...prev, [appId]: status })); + } catch (error) { + console.error("Failed to fetch monitoring status:", error); + } + }; + + const handleDeployMonitoring = async (app: DeployedApp) => { + setMonitoringLoading((prev) => ({ ...prev, [app.id]: true })); + try { + const status = await appsApi.deployMonitoring(app.id); + setMonitoringStatus((prev) => ({ ...prev, [app.id]: status })); + message.success("Monitoring deployment started"); + // Poll for status updates + const pollInterval = setInterval(async () => { + const newStatus = await appsApi.getMonitoringStatus(app.id); + setMonitoringStatus((prev) => ({ ...prev, [app.id]: newStatus })); + // Stop polling when all services are running or errored + const allDone = newStatus.services.every( + (s) => + s.status === "running" || + s.status === "error" || + s.status === "stopped", + ); + if (allDone) { + clearInterval(pollInterval); + setMonitoringLoading((prev) => ({ ...prev, [app.id]: false })); + } + }, 3000); + } catch (error: unknown) { + const err = error as { response?: { data?: { detail?: string } } }; + message.error( + err.response?.data?.detail || "Failed to deploy monitoring", + ); + setMonitoringLoading((prev) => ({ ...prev, [app.id]: false })); + } + }; + + const handleRemoveMonitoring = async (app: DeployedApp) => { + setMonitoringLoading((prev) => ({ ...prev, [app.id]: true })); + try { + await appsApi.removeMonitoring(app.id); + setMonitoringStatus((prev) => ({ + ...prev, + [app.id]: { enabled: false, services: [] }, + })); + message.success("Monitoring removed"); + } catch (error: unknown) { + const err = error as { response?: { data?: { detail?: string } } }; + message.error( + err.response?.data?.detail || "Failed to remove monitoring", + ); + } finally { + setMonitoringLoading((prev) => ({ ...prev, [app.id]: false })); + } + }; + if (loading) { return ( <div @@ -630,7 +720,14 @@ export default function DeployApps() { )} {app.status === "running" && appUrl && ( - <div> + <div + style={{ + display: "flex", + gap: 8, + flexWrap: "wrap", + alignItems: "center", + }} + > <Button type="link" icon={<LinkOutlined />} @@ -638,13 +735,30 @@ export default function DeployApps() { target="_blank" style={{ padding: 0, height: "auto" }} > - Open {app.name} + {app.additional_urls && + app.additional_urls.length > 0 + ? `API (${app.port})` + : `Open ${app.name}`} </Button> - {app.port && ( - <Text - type="secondary" - style={{ fontSize: 12, marginLeft: 8 }} - > + {app.additional_urls && + app.additional_urls.length > 0 && ( + <> + {app.additional_urls.map((portInfo) => ( + <Button + key={portInfo.port} + type="link" + icon={<LinkOutlined />} + href={`http://${window.location.hostname}:${portInfo.port}`} + target="_blank" + style={{ padding: 0, height: "auto" }} + > + {portInfo.name} ({portInfo.port}) + </Button> + ))} + </> + )} + {!app.additional_urls && app.port && ( + <Text type="secondary" style={{ fontSize: 12 }}> Port: {app.port} </Text> )} @@ -701,6 +815,139 @@ export default function DeployApps() { </Button> </Popconfirm> </div> + + {/* Monitoring Section for apps that support it */} + {app.has_monitoring && app.status === "running" && ( + <div + style={{ + marginTop: 12, + paddingTop: 12, + borderTop: "1px solid rgba(0,0,0,0.06)", + }} + > + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 8, + }} + > + <Text strong style={{ fontSize: 13 }}> + <DashboardOutlined style={{ marginRight: 6 }} /> + Monitoring + </Text> + {!monitoringStatus[app.id]?.enabled ? ( + <Button + type="primary" + size="small" + icon={<PlusOutlined />} + loading={monitoringLoading[app.id]} + onClick={() => { + fetchMonitoringStatus(app.id); + handleDeployMonitoring(app); + }} + > + Deploy + </Button> + ) : ( + <Popconfirm + title="Remove monitoring?" + description="This will stop and remove all monitoring services." + onConfirm={() => handleRemoveMonitoring(app)} + okText="Remove" + okButtonProps={{ danger: true }} + > + <Button + size="small" + icon={<MinusOutlined />} + loading={monitoringLoading[app.id]} + danger + > + Remove + </Button> + </Popconfirm> + )} + </div> + + {monitoringStatus[app.id]?.services && + monitoringStatus[app.id].services.length > 0 && ( + <div + style={{ + display: "flex", + flexDirection: "column", + gap: 6, + }} + > + {monitoringStatus[app.id].services.map( + (svc: MonitoringServiceStatus) => ( + <div + key={svc.type} + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + fontSize: 12, + }} + > + <span> + {svc.name} + <Tag + color={ + svc.status === "running" + ? "success" + : svc.status === "error" + ? "error" + : "processing" + } + style={{ + marginLeft: 8, + fontSize: 10, + }} + > + {svc.status === "running" + ? "Running" + : svc.status === "error" + ? "Error" + : svc.status === "pulling" + ? "Pulling" + : "Starting"} + </Tag> + </span> + {svc.status === "running" && svc.port && ( + <Button + type="link" + size="small" + icon={<LinkOutlined />} + href={`http://${window.location.hostname}:${svc.port}`} + target="_blank" + style={{ + padding: 0, + height: "auto", + fontSize: 12, + }} + > + Open ({svc.port}) + </Button> + )} + </div> + ), + )} + </div> + )} + + {!monitoringStatus[app.id] && ( + <Button + type="link" + size="small" + onClick={() => fetchMonitoringStatus(app.id)} + style={{ padding: 0, fontSize: 12 }} + > + Check status + </Button> + )} + </div> + )} </div> </Card> </Col> From 9386d01bd1aac67baccbb91b48e6ab837de37ccc Mon Sep 17 00:00:00 2001 From: rickychen-infinirc <ricky.chen@infinirc.com> Date: Sun, 18 Jan 2026 21:10:41 +0800 Subject: [PATCH 14/16] fix: use fresh db session for monitoring URL update --- backend/app/api/apps/monitoring.py | 87 +++++++++++++++++------------- 1 file changed, 50 insertions(+), 37 deletions(-) diff --git a/backend/app/api/apps/monitoring.py b/backend/app/api/apps/monitoring.py index e7ca6fa..58e35b8 100644 --- a/backend/app/api/apps/monitoring.py +++ b/backend/app/api/apps/monitoring.py @@ -672,47 +672,60 @@ async def _update_parent_monitoring_urls(db: AsyncSession, parent_app_id: int) - This updates the Semantic Router container with the URLs of deployed monitoring services. When all monitoring services are running, it restarts the parent container with new env vars. """ - # Get parent app - result = await db.execute(select(App).where(App.id == parent_app_id)) - parent_app = result.scalar_one_or_none() - if not parent_app: - return - - # Get all monitoring services - result = await db.execute(select(App).where(App.parent_app_id == parent_app_id)) - monitoring_apps = list(result.scalars().all()) - - # Build monitoring URLs - await db.refresh(parent_app, ["worker"]) - worker_host = parent_app.worker.address.split(":")[0] - - monitoring_urls = {} - all_running = True - for mon_app in monitoring_apps: - if mon_app.status == AppStatus.RUNNING.value and mon_app.port: - if mon_app.app_type == "grafana": - monitoring_urls["grafana_url"] = f"http://{worker_host}:{mon_app.port}" - elif mon_app.app_type == "prometheus": - monitoring_urls["prometheus_url"] = f"http://{worker_host}:{mon_app.port}" - elif mon_app.app_type == "jaeger": - monitoring_urls["jaeger_url"] = f"http://{worker_host}:{mon_app.port}" - elif mon_app.status not in (AppStatus.ERROR.value, AppStatus.STOPPED.value): - all_running = False - - # Store in parent app's config for reference - config = parent_app.config or {} - config["monitoring_urls"] = monitoring_urls - parent_app.config = config - await db.commit() + # IMPORTANT: Use a fresh session to see commits from other parallel background tasks + # Each background task has its own isolated session, so we need a new one to get + # the current state of all monitoring services + from app.database import async_session_maker - logger.info(f"Updated monitoring URLs for app {parent_app_id}: {monitoring_urls}") + async with async_session_maker() as fresh_db: + # Get parent app + result = await fresh_db.execute(select(App).where(App.id == parent_app_id)) + parent_app = result.scalar_one_or_none() + if not parent_app: + return - # If all monitoring services are running, restart parent app to pick up new URLs - if all_running and monitoring_urls and parent_app.status == AppStatus.RUNNING.value: + # Get all monitoring services + result = await fresh_db.execute(select(App).where(App.parent_app_id == parent_app_id)) + monitoring_apps = list(result.scalars().all()) + + # Build monitoring URLs + await fresh_db.refresh(parent_app, ["worker"]) + worker_host = parent_app.worker.address.split(":")[0] + + monitoring_urls = {} + all_running = True + for mon_app in monitoring_apps: + if mon_app.status == AppStatus.RUNNING.value and mon_app.port: + if mon_app.app_type == "grafana": + monitoring_urls["grafana_url"] = f"http://{worker_host}:{mon_app.port}" + elif mon_app.app_type == "prometheus": + monitoring_urls["prometheus_url"] = f"http://{worker_host}:{mon_app.port}" + elif mon_app.app_type == "jaeger": + monitoring_urls["jaeger_url"] = f"http://{worker_host}:{mon_app.port}" + elif mon_app.status not in (AppStatus.ERROR.value, AppStatus.STOPPED.value): + all_running = False + + # Store in parent app's config for reference + config = parent_app.config or {} + config["monitoring_urls"] = monitoring_urls + parent_app.config = config + await fresh_db.commit() + + logger.info(f"Updated monitoring URLs for app {parent_app_id}: {monitoring_urls}") logger.info( - f"All monitoring services running, restarting parent app {parent_app_id} to apply URLs" + f"Restart check: all_running={all_running}, has_urls={bool(monitoring_urls)}, " + f"parent_status={parent_app.status}, services={[(m.app_type, m.status) for m in monitoring_apps]}" ) - await _restart_parent_with_monitoring_urls(db, parent_app, monitoring_urls) + + # If all monitoring services are running, restart parent app to pick up new URLs + if all_running and monitoring_urls and parent_app.status == AppStatus.RUNNING.value: + logger.info( + f"All monitoring services running, restarting parent app {parent_app_id} to apply URLs" + ) + try: + await _restart_parent_with_monitoring_urls(fresh_db, parent_app, monitoring_urls) + except Exception as e: + logger.exception(f"Failed to restart parent app with monitoring URLs: {e}") async def _restart_parent_with_monitoring_urls( From 9ac9f65f3f4b9740d2decb60f3dd409b34e5a0fb Mon Sep 17 00:00:00 2001 From: rickychen-infinirc <ricky.chen@infinirc.com> Date: Sun, 18 Jan 2026 21:17:48 +0800 Subject: [PATCH 15/16] fix: use host.docker.internal for monitoring URLs --- backend/app/api/apps/monitoring.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/app/api/apps/monitoring.py b/backend/app/api/apps/monitoring.py index 58e35b8..f39aae5 100644 --- a/backend/app/api/apps/monitoring.py +++ b/backend/app/api/apps/monitoring.py @@ -689,19 +689,24 @@ async def _update_parent_monitoring_urls(db: AsyncSession, parent_app_id: int) - monitoring_apps = list(result.scalars().all()) # Build monitoring URLs + # These URLs are accessed from INSIDE the Semantic Router container + # So we use host.docker.internal to access services on the same host await fresh_db.refresh(parent_app, ["worker"]) - worker_host = parent_app.worker.address.split(":")[0] + + # Use host.docker.internal for container-to-host communication + # This works regardless of the worker's external IP + internal_host = "host.docker.internal" monitoring_urls = {} all_running = True for mon_app in monitoring_apps: if mon_app.status == AppStatus.RUNNING.value and mon_app.port: if mon_app.app_type == "grafana": - monitoring_urls["grafana_url"] = f"http://{worker_host}:{mon_app.port}" + monitoring_urls["grafana_url"] = f"http://{internal_host}:{mon_app.port}" elif mon_app.app_type == "prometheus": - monitoring_urls["prometheus_url"] = f"http://{worker_host}:{mon_app.port}" + monitoring_urls["prometheus_url"] = f"http://{internal_host}:{mon_app.port}" elif mon_app.app_type == "jaeger": - monitoring_urls["jaeger_url"] = f"http://{worker_host}:{mon_app.port}" + monitoring_urls["jaeger_url"] = f"http://{internal_host}:{mon_app.port}" elif mon_app.status not in (AppStatus.ERROR.value, AppStatus.STOPPED.value): all_running = False From 2e025e030e3a210201a99ac54beeb2f4f86866d8 Mon Sep 17 00:00:00 2001 From: rickychen-infinirc <ricky.chen@infinirc.com> Date: Mon, 19 Jan 2026 13:10:43 +0800 Subject: [PATCH 16/16] rename Intelligent Router to Semantic Router --- backend/app/api/api_keys.py | 111 +++++++ backend/app/api/apps/deployment.py | 172 ++++++++++- backend/app/api/apps/lifecycle.py | 4 + backend/app/api/apps/monitoring.py | 126 ++++++-- backend/app/api/apps/routes.py | 37 ++- backend/app/api/deployments.py | 8 +- backend/app/api/gateway.py | 185 ++++++++++- backend/app/api/semantic_router.py | 178 ++++++++++- backend/app/models/app.py | 21 +- backend/app/schemas/api_key.py | 9 +- backend/app/services/gateway.py | 14 + backend/app/services/semantic_router.py | 43 ++- frontend/src/api/apiKeys.ts | 23 ++ frontend/src/api/index.ts | 11 +- frontend/src/api/semanticRouter.ts | 27 ++ frontend/src/pages/ApiKeys.tsx | 388 +++++++++++++++++++++--- frontend/src/pages/Chat.tsx | 273 ++++++++++++++--- frontend/src/pages/DeployApps.tsx | 198 +++++------- frontend/src/types/apiKey.ts | 4 +- frontend/src/types/chat.ts | 1 + frontend/vite.config.ts | 16 + 21 files changed, 1582 insertions(+), 267 deletions(-) create mode 100644 frontend/src/api/semanticRouter.ts diff --git a/backend/app/api/api_keys.py b/backend/app/api/api_keys.py index 0841785..64f4cc7 100644 --- a/backend/app/api/api_keys.py +++ b/backend/app/api/api_keys.py @@ -265,3 +265,114 @@ async def get_all_api_keys_stats( ), "per_key_stats": per_key_stats, } + + +@router.get("/stats/by-model") +async def get_stats_by_model( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_viewer), +): + """Get usage statistics grouped by model (requires viewer+)""" + from datetime import timedelta + + from sqlalchemy.orm import selectinload + + from app.models.api_key import Usage + from app.models.deployment import Deployment + from app.models.llm_model import LLMModel + + thirty_days_ago = datetime.utcnow() - timedelta(days=30) + + # Get per-model stats + model_result = await db.execute( + select( + Usage.model_id, + func.sum(Usage.request_count).label("requests"), + func.sum(Usage.prompt_tokens).label("prompt_tokens"), + func.sum(Usage.completion_tokens).label("completion_tokens"), + ) + .where(Usage.date >= thirty_days_ago) + .where(Usage.model_id.isnot(None)) + .group_by(Usage.model_id) + ) + + # Get model info + models_result = await db.execute(select(LLMModel)) + models_map = {m.id: m for m in models_result.scalars().all()} + + # Get running deployments to know which models are active + deployments_result = await db.execute( + select(Deployment) + .options(selectinload(Deployment.model)) + .where(Deployment.status == "running") + ) + running_deployments = deployments_result.scalars().all() + running_model_ids = {d.model_id for d in running_deployments} + + per_model_stats = [] + for row in model_result: + model = models_map.get(row.model_id) + if model: + per_model_stats.append( + { + "model_id": row.model_id, + "model_name": model.name, + "model_source": model.source, + "requests": row.requests or 0, + "prompt_tokens": row.prompt_tokens or 0, + "completion_tokens": row.completion_tokens or 0, + "total_tokens": (row.prompt_tokens or 0) + (row.completion_tokens or 0), + "is_running": row.model_id in running_model_ids, + } + ) + + # Add models with no usage but are running + for deployment in running_deployments: + if deployment.model_id not in {s["model_id"] for s in per_model_stats}: + model = deployment.model + if model: + per_model_stats.append( + { + "model_id": model.id, + "model_name": model.name, + "model_source": model.source, + "requests": 0, + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + "is_running": True, + } + ) + + # Get stats for MoM (Semantic Router) - model_id is NULL + mom_result = await db.execute( + select( + func.sum(Usage.request_count).label("requests"), + func.sum(Usage.prompt_tokens).label("prompt_tokens"), + func.sum(Usage.completion_tokens).label("completion_tokens"), + ) + .where(Usage.date >= thirty_days_ago) + .where(Usage.model_id.is_(None)) + ) + mom_row = mom_result.first() + + mom_stats = None + if mom_row and (mom_row.requests or 0) > 0: + mom_stats = { + "model_id": None, + "model_name": "MoM (Semantic Router)", + "model_source": "semantic-router", + "requests": mom_row.requests or 0, + "prompt_tokens": mom_row.prompt_tokens or 0, + "completion_tokens": mom_row.completion_tokens or 0, + "total_tokens": (mom_row.prompt_tokens or 0) + (mom_row.completion_tokens or 0), + "is_running": True, # Check from Semantic Router status + } + + # Sort by total tokens descending + per_model_stats.sort(key=lambda x: x["total_tokens"], reverse=True) + + return { + "models": per_model_stats, + "mom_stats": mom_stats, + } diff --git a/backend/app/api/apps/deployment.py b/backend/app/api/apps/deployment.py index 1411aa4..261d920 100644 --- a/backend/app/api/apps/deployment.py +++ b/backend/app/api/apps/deployment.py @@ -311,6 +311,7 @@ async def deploy_app_background( app_def: dict, lmstack_port: str, use_proxy: bool = True, + lmstack_host: str | None = None, ) -> None: """Background task to deploy an app. @@ -330,6 +331,7 @@ async def deploy_app_background( app_def: App definition from APP_DEFINITIONS lmstack_port: LMStack API port use_proxy: Whether to setup nginx proxy + lmstack_host: LMStack API host (for semantic router config) """ from app.database import async_session_maker @@ -372,8 +374,22 @@ async def deploy_app_background( if app_type == AppType.SEMANTIC_ROUTER: set_deployment_progress(app_id, "starting", 0, "Generating config...") try: - lmstack_api_url = "http://host.docker.internal:52000" - await write_semantic_router_config(worker_address, lmstack_api_url, db) + # Use lmstack_host parameter, or fallback to LMSTACK_BACKEND_URL env var + import os + + if lmstack_host: + lmstack_api_url = f"http://{lmstack_host}:{lmstack_port}" + else: + backend_url = os.environ.get("LMSTACK_BACKEND_URL") + if backend_url: + lmstack_api_url = backend_url.rstrip("/") + else: + # Last resort: use worker host (may not work from container) + lmstack_api_url = f"http://{worker_host}:{lmstack_port}" + logger.info(f"Semantic router will use LMStack API: {lmstack_api_url}") + # Get API key from app config + api_key = (app.config or {}).get("api_key") + await write_semantic_router_config(worker_address, lmstack_api_url, db, api_key) except Exception as e: logger.warning(f"Failed to write semantic router config: {e}") # Continue anyway, config can be updated later @@ -425,6 +441,11 @@ async def deploy_app_background( set_deployment_progress(app_id, "running", 100, "App deployed successfully") logger.info(f"App {app_id} deployed successfully") + # Auto-deploy monitoring services for apps that support it + if app_def.get("has_monitoring"): + logger.info(f"Auto-deploying monitoring services for app {app_id}") + await _auto_deploy_monitoring(db, app, worker, port) + except Exception as e: logger.exception(f"Failed to deploy app {app_id}: {e}") try: @@ -603,6 +624,7 @@ async def write_semantic_router_config( worker_address: str, lmstack_api_url: str, db, + api_key: str | None = None, ) -> None: """Write semantic router config.yaml to the worker volume. @@ -610,9 +632,10 @@ async def write_semantic_router_config( worker_address: Worker address (host:port) lmstack_api_url: LMStack API URL for the semantic router to call db: Database session + api_key: LMStack API key for authentication """ - # Generate config - config = await semantic_router_service.generate_config(db, lmstack_api_url) + # Generate config with API key + config = await semantic_router_service.generate_config(db, lmstack_api_url, api_key) config_yaml = semantic_router_service.config_to_yaml(config) # Write to worker volume @@ -644,6 +667,7 @@ async def update_semantic_router_config_if_deployed(db) -> bool: Returns: True if config was updated, False if semantic router not deployed """ + import os # Check if semantic router is deployed app = await semantic_router_service.get_semantic_router_app(db) @@ -657,13 +681,147 @@ async def update_semantic_router_config_if_deployed(db) -> bool: return False # Build LMStack API URL - # Use host.docker.internal since semantic router runs in a container - lmstack_api_url = "http://host.docker.internal:52000" + # Priority: 1) stored lmstack_host, 2) LMSTACK_BACKEND_URL env var, 3) worker IP + app_config = app.config or {} + lmstack_host = app_config.get("lmstack_host") + + if lmstack_host: + lmstack_api_url = f"http://{lmstack_host}:52000" + else: + backend_url = os.environ.get("LMSTACK_BACKEND_URL") + if backend_url: + lmstack_api_url = backend_url.rstrip("/") + else: + # Fallback: use worker IP (may not work from container) + worker_host = worker.address.split(":")[0] + lmstack_api_url = f"http://{worker_host}:52000" + + logger.info(f"Updating semantic router config with LMStack API: {lmstack_api_url}") + + # Get API key from app config + api_key = app_config.get("api_key") try: - await write_semantic_router_config(worker.address, lmstack_api_url, db) + await write_semantic_router_config(worker.address, lmstack_api_url, db, api_key) logger.info("Updated semantic router config with latest deployments") + + # Restart envoy to apply new config + if app.container_id: + await _restart_semantic_router_envoy(worker.address, app.container_id) + return True except Exception as e: logger.error(f"Failed to update semantic router config: {e}") return False + + +async def _restart_semantic_router_envoy(worker_address: str, container_id: str) -> None: + """Restart envoy process inside semantic router container to apply new config. + + Args: + worker_address: Worker address (host:port) + container_id: Semantic router container ID + """ + try: + async with httpx.AsyncClient(timeout=CONTAINER_ACTION_TIMEOUT) as client: + # Execute supervisorctl restart envoy inside the container + response = await client.post( + f"http://{worker_address}/containers/{container_id}/exec", + json={ + "command": ["supervisorctl", "restart", "envoy"], + }, + ) + if response.status_code >= 400: + logger.warning(f"Failed to restart envoy: {response.text}") + else: + logger.info("Restarted semantic router envoy to apply new config") + except Exception as e: + logger.warning(f"Failed to restart semantic router envoy: {e}") + # Don't raise - config was updated, envoy restart is best-effort + + +# ============================================================================= +# Auto-deploy Monitoring +# ============================================================================= + + +async def _auto_deploy_monitoring( + db, + parent_app: App, + worker: Worker, + parent_port: int, +) -> None: + """Auto-deploy monitoring services (Grafana, Prometheus, Jaeger) for apps that support it. + + This is called automatically after Semantic Router deployment completes. + Deploys services sequentially: Prometheus first (Grafana needs it), then Grafana, then Jaeger. + """ + from app.api.apps.monitoring import deploy_monitoring_background + from app.models.app import MONITORING_DEFINITIONS + + logger.info(f"Starting auto-deployment of monitoring services for app {parent_app.id}") + + # Find available ports starting from parent app's port + 10 + base_port = parent_port + 10 + result = await db.execute( + select(App.port).where(App.worker_id == worker.id, App.port.isnot(None)) + ) + used_ports = {row[0] for row in result.fetchall()} + + # Create monitoring app records + services_to_deploy = ["prometheus", "grafana", "jaeger"] + created_apps = [] + port = base_port + + for svc_type in services_to_deploy: + # Find next available port + while port in used_ports: + port += 1 + + svc_def = MONITORING_DEFINITIONS[svc_type] + svc_app = App( + app_type=svc_type, + name=f"{svc_def['name']} ({parent_app.name})", + worker_id=worker.id, + parent_app_id=parent_app.id, + status=AppStatus.PENDING.value, + proxy_path=f"/apps/{parent_app.app_type}/monitoring/{svc_type}", + port=port, + use_proxy=parent_app.use_proxy, + ) + db.add(svc_app) + await db.flush() + created_apps.append((svc_app, svc_def)) + used_ports.add(port) + port += 1 + + await db.commit() + + # Find prometheus port for Grafana configuration + prometheus_port = None + for svc_app, _ in created_apps: + if svc_app.app_type == "prometheus": + prometheus_port = svc_app.port + break + + # Deploy services sequentially (not in parallel) to ensure proper ordering + # Prometheus must be ready before Grafana tries to configure its datasource + for svc_app, svc_def in created_apps: + logger.info(f"Deploying monitoring service: {svc_app.app_type}") + try: + await deploy_monitoring_background( + app_id=svc_app.id, + parent_app_id=parent_app.id, + svc_type=svc_app.app_type, + worker_address=worker.address, + port=svc_app.port, + svc_def=svc_def, + use_proxy=svc_app.use_proxy, + parent_app_port=parent_port, + prometheus_port=prometheus_port, + ) + except Exception as e: + logger.error(f"Failed to deploy monitoring service {svc_app.app_type}: {e}") + # Continue with other services even if one fails + + logger.info(f"Completed auto-deployment of monitoring services for app {parent_app.id}") diff --git a/backend/app/api/apps/lifecycle.py b/backend/app/api/apps/lifecycle.py index 78cd7b1..d41ef3e 100644 --- a/backend/app/api/apps/lifecycle.py +++ b/backend/app/api/apps/lifecycle.py @@ -70,6 +70,8 @@ async def stop_app( app.status = AppStatus.STOPPED.value await db.commit() + # Refresh the entire app object to ensure all attributes are loaded + await db.refresh(app) await db.refresh(app, ["worker"]) except Exception as e: @@ -125,6 +127,8 @@ async def start_app( app.status = AppStatus.RUNNING.value app.status_message = None await db.commit() + # Refresh the entire app object to ensure all attributes are loaded + await db.refresh(app) await db.refresh(app, ["worker"]) except Exception as e: diff --git a/backend/app/api/apps/monitoring.py b/backend/app/api/apps/monitoring.py index f39aae5..d353834 100644 --- a/backend/app/api/apps/monitoring.py +++ b/backend/app/api/apps/monitoring.py @@ -478,10 +478,7 @@ async def deploy_monitoring_background( if use_proxy: await _setup_monitoring_proxy(app_id, svc_type, worker_address, port) - # Update parent app's environment with monitoring URLs - await _update_parent_monitoring_urls(db, parent_app_id) - - # Mark as running + # Mark as running FIRST (before checking if all monitoring services are ready) app.status = AppStatus.RUNNING.value app.status_message = None await db.commit() @@ -489,6 +486,10 @@ async def deploy_monitoring_background( set_deployment_progress(app_id, "running", 100, f"{svc_def['name']} deployed") logger.info(f"Monitoring service {svc_type} deployed for app {parent_app_id}") + # Update parent app's environment with monitoring URLs + # This must be called AFTER marking current service as running + await _update_parent_monitoring_urls(db, parent_app_id) + except Exception as e: logger.exception(f"Failed to deploy monitoring {svc_type}: {e}") try: @@ -597,6 +598,12 @@ async def _create_monitoring_container( env_vars["GF_DATASOURCES_DEFAULT_ACCESS"] = "proxy" env_vars["GF_DATASOURCES_DEFAULT_ISDEFAULT"] = "true" + # CRITICAL: Set Grafana's root URL so redirects work correctly + # Without this, Grafana redirects to localhost:3000 which breaks iframe embedding + env_vars["GF_SERVER_ROOT_URL"] = f"http://{worker_host}:{port}" + # Also set serve_from_sub_path since we access via proxy + env_vars["GF_SERVER_SERVE_FROM_SUB_PATH"] = "false" + payload = { "name": container_name, "image": svc_def["image"], @@ -689,24 +696,24 @@ async def _update_parent_monitoring_urls(db: AsyncSession, parent_app_id: int) - monitoring_apps = list(result.scalars().all()) # Build monitoring URLs - # These URLs are accessed from INSIDE the Semantic Router container - # So we use host.docker.internal to access services on the same host + # These URLs are used by the Semantic Router DASHBOARD (runs in user's browser) + # to render iframes for Grafana/Jaeger. Since the browser can't resolve + # host.docker.internal, we must use the worker's external IP. await fresh_db.refresh(parent_app, ["worker"]) - # Use host.docker.internal for container-to-host communication - # This works regardless of the worker's external IP - internal_host = "host.docker.internal" + # Use the worker's external IP for browser-accessible URLs + worker_host = parent_app.worker.address.split(":")[0] monitoring_urls = {} all_running = True for mon_app in monitoring_apps: if mon_app.status == AppStatus.RUNNING.value and mon_app.port: if mon_app.app_type == "grafana": - monitoring_urls["grafana_url"] = f"http://{internal_host}:{mon_app.port}" + monitoring_urls["grafana_url"] = f"http://{worker_host}:{mon_app.port}" elif mon_app.app_type == "prometheus": - monitoring_urls["prometheus_url"] = f"http://{internal_host}:{mon_app.port}" + monitoring_urls["prometheus_url"] = f"http://{worker_host}:{mon_app.port}" elif mon_app.app_type == "jaeger": - monitoring_urls["jaeger_url"] = f"http://{internal_host}:{mon_app.port}" + monitoring_urls["jaeger_url"] = f"http://{worker_host}:{mon_app.port}" elif mon_app.status not in (AppStatus.ERROR.value, AppStatus.STOPPED.value): all_running = False @@ -882,18 +889,14 @@ async def _create_prometheus_config( Uses a helper container to write the prometheus.yml config to the volume. """ - worker_host = worker_address.split(":")[0] - # Calculate metrics port: Semantic Router main port + 2 = metrics port (9190 internally mapped) # The router exposes :8888 (API), :8700 (Dashboard), and :9190 (metrics) # We map them as: port, port+1, port+2 metrics_port = parent_app.port + 2 if parent_app.port else 9192 - # Determine metrics target - if worker_host in ("localhost", "127.0.0.1"): - metrics_target = f"host.docker.internal:{metrics_port}" - else: - metrics_target = f"{worker_host}:{metrics_port}" + # Prometheus runs in a container, so it must use host.docker.internal + # to access services on the same host (regardless of worker's external IP) + metrics_target = f"host.docker.internal:{metrics_port}" # Prometheus configuration YAML - using cat with heredoc to avoid quote issues prometheus_config = f"""global: @@ -972,14 +975,19 @@ async def _configure_grafana( Grafana needs a moment to start, so we retry a few times. """ + import json + from pathlib import Path + worker_host = worker_address.split(":")[0] - # Determine Prometheus URL from Grafana's perspective + # Grafana runs in a container, so it must use host.docker.internal + # to access Prometheus on the same host + prometheus_url = f"http://host.docker.internal:{prometheus_port}" + + # For API calls from the backend, use the actual host if worker_host in ("localhost", "127.0.0.1"): - prometheus_url = f"http://host.docker.internal:{prometheus_port}" grafana_url = f"http://localhost:{grafana_port}" else: - prometheus_url = f"http://{worker_host}:{prometheus_port}" grafana_url = f"http://{worker_host}:{grafana_port}" # Grafana datasource payload @@ -991,6 +999,8 @@ async def _configure_grafana( "isDefault": True, } + datasource_uid = None + # Try to add datasource (Grafana needs time to start) max_retries = 5 for i in range(max_retries): @@ -1008,9 +1018,21 @@ async def _configure_grafana( auth=("admin", "admin"), ) - if ds_resp.status_code in (200, 409): # 409 = already exists - logger.info(f"Grafana datasource configured: {ds_resp.status_code}") - return + if ds_resp.status_code == 200: + ds_data = ds_resp.json() + datasource_uid = ds_data.get("datasource", {}).get("uid") + logger.info(f"Grafana datasource created: uid={datasource_uid}") + break + elif ds_resp.status_code == 409: # Already exists + # Get existing datasource UID + get_ds_resp = await client.get( + f"{grafana_url}/api/datasources/name/Prometheus", + auth=("admin", "admin"), + ) + if get_ds_resp.status_code == 200: + datasource_uid = get_ds_resp.json().get("uid") + logger.info(f"Grafana datasource exists: uid={datasource_uid}") + break else: logger.warning(f"Failed to add Grafana datasource: {ds_resp.text}") @@ -1018,4 +1040,56 @@ async def _configure_grafana( logger.debug(f"Grafana not ready yet (attempt {i+1}/{max_retries}): {e}") await asyncio.sleep(3) - logger.warning("Failed to configure Grafana datasource after retries") + if not datasource_uid: + logger.warning("Failed to configure Grafana datasource after retries") + return + + # Import LLM Router dashboard + dashboard_path = Path( + "/home/rickychen/Desktop/llm/lmstack/semantic-router/deploy/docker-compose/addons/llm-router-dashboard.json" + ) + if not dashboard_path.exists(): + logger.warning(f"Dashboard file not found: {dashboard_path}") + return + + try: + dashboard_json = json.loads(dashboard_path.read_text()) + + # Replace datasource variable with actual UID + dashboard_str = json.dumps(dashboard_json) + dashboard_str = dashboard_str.replace("${DS_PROMETHEUS}", datasource_uid) + dashboard_str = dashboard_str.replace('"uid": "prometheus"', f'"uid": "{datasource_uid}"') + dashboard_json = json.loads(dashboard_str) + + # Remove id to create new dashboard + dashboard_json.pop("id", None) + dashboard_json["uid"] = "llm-router-metrics" + + # Import dashboard + import_payload = { + "dashboard": dashboard_json, + "overwrite": True, + "inputs": [ + { + "name": "DS_PROMETHEUS", + "type": "datasource", + "pluginId": "prometheus", + "value": datasource_uid, + } + ], + } + + async with httpx.AsyncClient(timeout=30.0) as client: + dash_resp = await client.post( + f"{grafana_url}/api/dashboards/import", + json=import_payload, + auth=("admin", "admin"), + ) + + if dash_resp.status_code == 200: + logger.info("LLM Router dashboard imported successfully") + else: + logger.warning(f"Failed to import dashboard: {dash_resp.text}") + + except Exception as e: + logger.exception(f"Failed to import Grafana dashboard: {e}") diff --git a/backend/app/api/apps/routes.py b/backend/app/api/apps/routes.py index 642f244..9509e42 100644 --- a/backend/app/api/apps/routes.py +++ b/backend/app/api/apps/routes.py @@ -210,6 +210,13 @@ async def deploy_app( if deploy_request.hf_token: app_config["hf_token"] = deploy_request.hf_token + # Store API key in config for apps that need it (e.g., Semantic Router config generation) + if full_key: + app_config["api_key"] = full_key + + # Store LMStack host for apps that need to call back to API Gateway + app_config["lmstack_host"] = get_host_ip(request, worker) + # Create app record app = App( app_type=app_type.value, @@ -243,6 +250,9 @@ async def deploy_app( # Always use backend API port (52000) lmstack_port = "52000" + # Get correct LMStack host for apps that need to call back to API Gateway + lmstack_host = get_host_ip(request, worker) + # Start background deployment background_tasks.add_task( deploy_app_background, @@ -255,6 +265,7 @@ async def deploy_app( app_def=app_def, lmstack_port=lmstack_port, use_proxy=use_proxy, + lmstack_host=lmstack_host, ) return app_to_response(app, request) @@ -297,11 +308,31 @@ async def _create_api_key_if_needed( async def _find_available_port(db: AsyncSession, worker_id: int) -> int: - """Find an available port on the worker.""" + """Find an available port on the worker. + + Also considers additional ports used by apps (e.g., Semantic Router uses + main_port, main_port+1 for dashboard, main_port+2 for metrics). + """ + from app.models.app import APP_DEFINITIONS, AppType + result = await db.execute( - select(App.port).where(App.worker_id == worker_id, App.port.isnot(None)) + select(App.port, App.app_type).where(App.worker_id == worker_id, App.port.isnot(None)) ) - used_ports = {row[0] for row in result.fetchall()} + + used_ports = set() + for row in result.fetchall(): + port, app_type = row + used_ports.add(port) + + # Add additional ports for apps that use them + try: + app_type_enum = AppType(app_type) + app_def = APP_DEFINITIONS.get(app_type_enum, {}) + additional_ports = app_def.get("additional_ports", []) + for i in range(len(additional_ports)): + used_ports.add(port + 1 + i) + except (ValueError, KeyError): + pass port = 9000 # Start from 9000 to avoid conflicts with dev servers while port in used_ports: diff --git a/backend/app/api/deployments.py b/backend/app/api/deployments.py index e974213..bec1a31 100644 --- a/backend/app/api/deployments.py +++ b/backend/app/api/deployments.py @@ -26,7 +26,7 @@ ModelSummary, WorkerSummary, ) -from app.services.deployer import DeployerService +from app.services.deployer import DeployerService, _update_semantic_router_config_background from app.services.gateway import gateway_service logger = logging.getLogger(__name__) @@ -279,6 +279,9 @@ async def delete_deployment( await db.delete(deployment) await db.commit() + # Update semantic router config to remove this model + background_tasks.add_task(_update_semantic_router_config_background) + @router.post("/{deployment_id}/stop", response_model=DeploymentResponse) async def stop_deployment( @@ -314,6 +317,9 @@ async def stop_deployment( await db.commit() await db.refresh(deployment) + # Update semantic router config to remove this model + background_tasks.add_task(_update_semantic_router_config_background) + return deployment_to_response(deployment) diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py index 82dfdc4..02be605 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -91,6 +91,47 @@ async def list_models( models = await gateway_service.get_available_models(db, api_key) + # Add Semantic Router "MoM" model if deployed and running + # Only show if api_key has access to MoM + router_app = await semantic_router_service.get_semantic_router_app(db) + if router_app and router_app.status == "running": + # Check MoM access if api_key is provided + show_mom = True + if api_key: + show_mom = await gateway_service.check_mom_access(api_key) + + if show_mom: + created_timestamp = ( + int(router_app.created_at.timestamp()) if router_app.created_at else 0 + ) + models.append( + { + "id": "MoM", + "object": "model", + "created": created_timestamp, + "owned_by": "lmstack-semantic-router", + "root": "Mixture-of-Models", + "parent": None, + "description": "Semantic Router that automatically selects the best model for each request", + "permission": [ + { + "id": "modelperm-semantic-router", + "object": "model_permission", + "created": created_timestamp, + "allow_create_engine": False, + "allow_sampling": True, + "allow_logprobs": True, + "allow_search_indices": False, + "allow_view": True, + "allow_fine_tuning": False, + "organization": "*", + "group": None, + "is_blocking": False, + } + ], + } + ) + return { "object": "list", "data": models, @@ -183,6 +224,17 @@ async def chat_completions( # Check if using semantic routing (model="MoM", "auto", etc.) if model_name.lower() in SEMANTIC_ROUTER_MODEL_NAMES: + # Check MoM access + if not await gateway_service.check_mom_access(api_key): + raise HTTPException( + status_code=403, + detail={ + "error": { + "message": "API key does not have access to MoM (Semantic Router)", + "type": "permission_error", + } + }, + ) return await _proxy_to_semantic_router( db=db, body=body, @@ -420,8 +472,8 @@ async def proxy_request( async def record_usage_background( api_key_id: int, - model_id: int, - deployment_id: int, + model_id: int | None, + deployment_id: int | None, prompt_tokens: int, completion_tokens: int, ) -> None: @@ -1049,8 +1101,8 @@ async def _proxy_to_semantic_router( Response from Semantic Router """ # Check if Semantic Router is deployed - router_url = await semantic_router_service.get_semantic_router_url(db) - if not router_url: + router_app = await semantic_router_service.get_semantic_router_app(db) + if not router_app or router_app.status != "running": raise HTTPException( status_code=503, detail={ @@ -1062,34 +1114,98 @@ async def _proxy_to_semantic_router( }, ) - # Remove the special model name and let Semantic Router decide - # The router will use its config to select the best model + router_url = await semantic_router_service.get_semantic_router_url(db) + if not router_url: + raise HTTPException( + status_code=503, + detail={ + "error": { + "message": "Semantic Router URL not available", + "type": "service_unavailable", + } + }, + ) + + # Get API key from router app config for Semantic Router authentication + router_config = router_app.config or {} + sr_api_key = router_config.get("api_key") + + # Set a default model - Semantic Router requires a model field body_copy = body.copy() - body_copy.pop("model", None) # Let Semantic Router handle model selection + model_name = body_copy.get("model", "") + if not model_name or model_name.lower() in SEMANTIC_ROUTER_MODEL_NAMES: + # Get the first running deployment's model name + from sqlalchemy.orm import selectinload + + from app.models.deployment import Deployment, DeploymentStatus + + result = await db.execute( + select(Deployment) + .where(Deployment.status == DeploymentStatus.RUNNING.value) + .options(selectinload(Deployment.model)) + .limit(1) + ) + deployment = result.scalar_one_or_none() + if deployment and deployment.model: + default_model = deployment.model.name.replace("/", "-").replace(":", "-") + else: + default_model = "default" + body_copy["model"] = default_model upstream_url = f"{router_url}{endpoint}" is_streaming = body.get("stream", False) + # Use the caller's API key for usage tracking + # This allows users to see their MoM usage in their own API Key dashboard + caller_api_key_id = api_key.id if api_key else None + if is_streaming: - return await _proxy_semantic_router_streaming(upstream_url, body_copy, api_key.id) + return await _proxy_semantic_router_streaming( + upstream_url, body_copy, caller_api_key_id, db, sr_api_key + ) else: - return await _proxy_semantic_router_request(upstream_url, body_copy, api_key.id) + return await _proxy_semantic_router_request( + upstream_url, body_copy, caller_api_key_id, db, sr_api_key + ) async def _proxy_semantic_router_request( upstream_url: str, body: dict, - api_key_id: int, + api_key_id: int | None, + db: AsyncSession, + sr_api_key: str | None = None, ) -> JSONResponse: """Proxy non-streaming request to Semantic Router.""" + headers = {"Content-Type": "application/json"} + if sr_api_key: + headers["Authorization"] = f"Bearer {sr_api_key}" + try: async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: response = await client.post( upstream_url, json=body, - headers={"Content-Type": "application/json"}, + headers=headers, ) - return JSONResponse(content=response.json(), status_code=response.status_code) + response_data = response.json() + + # Record usage for Semantic Router + if api_key_id: + usage = response_data.get("usage", {}) + prompt_tokens = usage.get("prompt_tokens", 0) + completion_tokens = usage.get("completion_tokens", 0) + + await gateway_service.record_usage( + db=db, + api_key_id=api_key_id, + model_id=None, # No specific model for Semantic Router + deployment_id=None, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + ) + + return JSONResponse(content=response_data, status_code=response.status_code) except httpx.TimeoutException: raise HTTPException( @@ -1117,9 +1233,16 @@ async def _proxy_semantic_router_request( async def _proxy_semantic_router_streaming( upstream_url: str, body: dict, - api_key_id: int, + api_key_id: int | None, + db: AsyncSession, + sr_api_key: str | None = None, ) -> StreamingResponse: """Proxy streaming request to Semantic Router.""" + headers = {"Content-Type": "application/json"} + if sr_api_key: + headers["Authorization"] = f"Bearer {sr_api_key}" + + usage_info = {"prompt_tokens": 0, "completion_tokens": 0} async def stream_generator() -> AsyncGenerator[bytes, None]: try: @@ -1128,11 +1251,27 @@ async def stream_generator() -> AsyncGenerator[bytes, None]: "POST", upstream_url, json=body, - headers={"Content-Type": "application/json"}, + headers=headers, ) as response: async for chunk in response.aiter_bytes(): yield chunk + # Try to extract usage from final chunk + try: + chunk_str = chunk.decode("utf-8") + for line in chunk_str.split("\n"): + if line.startswith("data: ") and line != "data: [DONE]": + data = json.loads(line[6:]) + if "usage" in data: + usage_info["prompt_tokens"] = data["usage"].get( + "prompt_tokens", 0 + ) + usage_info["completion_tokens"] = data["usage"].get( + "completion_tokens", 0 + ) + except (json.JSONDecodeError, UnicodeDecodeError): + pass + except httpx.TimeoutException: error_data = { "error": { @@ -1151,6 +1290,24 @@ async def stream_generator() -> AsyncGenerator[bytes, None]: } yield f"data: {json.dumps(error_data)}\n\n".encode() + # Estimate tokens if not provided + if usage_info["prompt_tokens"] == 0: + messages = body.get("messages", []) + for msg in messages: + content = msg.get("content", "") + if isinstance(content, str): + usage_info["prompt_tokens"] += len(content) // 4 + + # Record usage with background task + if api_key_id: + await record_usage_background( + api_key_id=api_key_id, + model_id=None, + deployment_id=None, + prompt_tokens=usage_info["prompt_tokens"], + completion_tokens=usage_info["completion_tokens"], + ) + return StreamingResponse( stream_generator(), media_type="text/event-stream", diff --git a/backend/app/api/semantic_router.py b/backend/app/api/semantic_router.py index b97f68c..74fce22 100644 --- a/backend/app/api/semantic_router.py +++ b/backend/app/api/semantic_router.py @@ -4,20 +4,30 @@ - Checking if Semantic Router is deployed - Updating Semantic Router config - Getting Semantic Router status +- Proxying chat requests to Semantic Router (for logged-in users) """ +import json import logging +from collections.abc import AsyncGenerator -from fastapi import APIRouter, Depends, HTTPException +import httpx +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import StreamingResponse from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.api.apps.deployment import update_semantic_router_config_if_deployed +from app.core.deps import require_viewer from app.database import get_db +from app.models.user import User from app.services.semantic_router import semantic_router_service logger = logging.getLogger(__name__) +# Timeout for chat requests (5 minutes for long model responses) +CHAT_PROXY_TIMEOUT = 300.0 + router = APIRouter(prefix="/semantic-router", tags=["semantic-router"]) @@ -106,6 +116,170 @@ async def preview_semantic_router_config( This is useful for debugging or understanding how the config is built. """ - lmstack_api_url = "http://host.docker.internal:52000" + # Try to get lmstack_host from deployed Semantic Router config, fallback to placeholder + app = await semantic_router_service.get_semantic_router_app(db) + if app: + app_config = app.config or {} + lmstack_host = app_config.get("lmstack_host") + if lmstack_host: + lmstack_api_url = f"http://{lmstack_host}:52000" + elif app.worker: + # Fallback to worker IP (may not be correct for container access) + worker_host = app.worker.address.split(":")[0] + lmstack_api_url = f"http://{worker_host}:52000" + else: + lmstack_api_url = "http://<lmstack-host>:52000" + else: + lmstack_api_url = "http://<lmstack-host>:52000" config = await semantic_router_service.generate_config(db, lmstack_api_url) return config + + +@router.post("/chat") +async def proxy_semantic_router_chat( + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_viewer), +): + """Proxy chat requests to Semantic Router (requires viewer+). + + This endpoint allows logged-in users to chat with the Semantic Router + using JWT authentication instead of API keys. The Semantic Router will + intelligently select the best model for each request. + """ + # Check if Semantic Router is deployed + router_app = await semantic_router_service.get_semantic_router_app(db) + if not router_app or router_app.status != "running": + raise HTTPException( + status_code=503, + detail="Semantic Router is not deployed or not running. Deploy it from the Apps page.", + ) + + router_url = await semantic_router_service.get_semantic_router_url(db) + if not router_url: + raise HTTPException(status_code=503, detail="Semantic Router URL not available") + + # Get request body + try: + body = await request.json() + except (json.JSONDecodeError, ValueError): + raise HTTPException(status_code=400, detail="Invalid JSON body") + + # Get the default model from running deployments + # Semantic Router requires a model field, it will route based on domain detection + model_name = body.get("model", "") + if not model_name or model_name.lower() in ("mom", "mom (intelligent router)"): + # Query the first running deployment to get a valid model name + from sqlalchemy import select + from sqlalchemy.orm import selectinload + + from app.models.deployment import Deployment, DeploymentStatus + + result = await db.execute( + select(Deployment) + .where(Deployment.status == DeploymentStatus.RUNNING.value) + .options(selectinload(Deployment.model)) + .limit(1) + ) + deployment = result.scalar_one_or_none() + if deployment and deployment.model: + # Use the model name format that matches Semantic Router config + default_model = deployment.model.name.replace("/", "-").replace(":", "-") + else: + default_model = "default" + body["model"] = default_model + + # Get API key from app config for Semantic Router authentication + app_config = router_app.config or {} + api_key = app_config.get("api_key") + + # Check if streaming + is_streaming = body.get("stream", False) + + chat_endpoint = f"{router_url}/v1/chat/completions" + + if is_streaming: + return await _proxy_streaming_chat(chat_endpoint, body, api_key) + else: + return await _proxy_chat(chat_endpoint, body, api_key) + + +async def _proxy_chat(upstream_url: str, body: dict, api_key: str | None = None) -> dict: + """Proxy a non-streaming chat request.""" + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + try: + async with httpx.AsyncClient(timeout=CHAT_PROXY_TIMEOUT) as client: + response = await client.post( + upstream_url, + json=body, + headers=headers, + ) + return response.json() + + except httpx.TimeoutException: + raise HTTPException(status_code=504, detail="Request to Semantic Router timed out") + except httpx.ConnectError: + raise HTTPException(status_code=502, detail="Failed to connect to Semantic Router") + except httpx.RequestError as e: + logger.error(f"Chat proxy request error: {e}") + raise HTTPException(status_code=502, detail=f"Request error: {str(e)}") + + +async def _proxy_streaming_chat( + upstream_url: str, body: dict, api_key: str | None = None +) -> StreamingResponse: + """Proxy a streaming chat request.""" + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + async def stream_generator() -> AsyncGenerator[bytes, None]: + try: + timeout = httpx.Timeout(CHAT_PROXY_TIMEOUT, connect=10.0) + async with httpx.AsyncClient(timeout=timeout) as client: + async with client.stream( + "POST", + upstream_url, + json=body, + headers=headers, + ) as response: + # Stream each line separately for better real-time delivery + async for line in response.aiter_lines(): + if line: + yield (line + "\n").encode() + + except httpx.TimeoutException: + logger.error(f"Streaming timeout for {upstream_url}") + error_data = { + "error": { + "message": "Request to Semantic Router timed out", + "type": "timeout_error", + } + } + yield f"data: {json.dumps(error_data)}\n\n".encode() + except httpx.ConnectError: + logger.error(f"Connection error for {upstream_url}") + error_data = { + "error": { + "message": "Failed to connect to Semantic Router", + "type": "connection_error", + } + } + yield f"data: {json.dumps(error_data)}\n\n".encode() + except httpx.RequestError as e: + logger.error(f"Streaming request error: {e}") + error_data = {"error": {"message": f"Request error: {str(e)}", "type": "request_error"}} + yield f"data: {json.dumps(error_data)}\n\n".encode() + + return StreamingResponse( + stream_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/app/models/app.py b/backend/app/models/app.py index 798d539..661af3f 100644 --- a/backend/app/models/app.py +++ b/backend/app/models/app.py @@ -124,6 +124,8 @@ class AppStatus(str, Enum): "ENVOY_LISTEN_PORT": "8888", "DASHBOARD_PORT": "8700", "HF_TOKEN": "{hf_token}", # Optional: for gated models + # LMStack API key for authentication (stored in config.yaml, not used as env var) + "LMSTACK_API_KEY": "{api_key}", # Redirect HuggingFace cache to the models volume for persistence "HF_HOME": "/app/models", "TRANSFORMERS_CACHE": "/app/models", @@ -141,9 +143,22 @@ class AppStatus(str, Enum): ], # Override entrypoint to create symlink before starting supervisord # (supervisord.conf hardcodes /app/config.yaml path) + # Patch router-defaults.yaml to enable metrics BEFORE supervisord starts + # (patching router-config.yaml doesn't work because it gets regenerated on restart) "entrypoint": ["/bin/sh", "-c"], "command": [ - "ln -sf /app/config/config.yaml /app/config.yaml && exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf" + # Patch the defaults file to enable metrics + 'python3 -c "' + "import yaml; " + "f='/app/cli/templates/router-defaults.yaml'; " + "c=yaml.safe_load(open(f)); " + "c.setdefault('observability',{}).setdefault('metrics',{})['enabled']=True; " + "yaml.dump(c,open(f,'w'),default_flow_style=False); " + "print('Patched router-defaults.yaml to enable metrics')\" && " + # Create symlink for config + "ln -sf /app/config/config.yaml /app/config.yaml && " + # Start supervisord + "exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf" ], "requires_config": True, # Indicates this app needs dynamic config generation "singleton": True, # Only one instance should be deployed per cluster @@ -167,6 +182,10 @@ class AppStatus(str, Enum): # Allow embedding in iframes (for Semantic Router dashboard) "GF_SECURITY_ALLOW_EMBEDDING": "true", "GF_AUTH_ANONYMOUS_ORG_NAME": "Main Org.", + # Allow any origin for live websocket (needed when accessed via proxy) + "GF_LIVE_ALLOWED_ORIGINS": "*", + # Don't enforce strict origin checks + "GF_SECURITY_COOKIE_SAMESITE": "disabled", # Disable features we don't need "GF_ALERTING_ENABLED": "false", "GF_UNIFIED_ALERTING_ENABLED": "false", diff --git a/backend/app/schemas/api_key.py b/backend/app/schemas/api_key.py index 3da3719..7c5625f 100644 --- a/backend/app/schemas/api_key.py +++ b/backend/app/schemas/api_key.py @@ -10,7 +10,8 @@ class ApiKeyCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) description: str | None = None - allowed_model_ids: list[int] | None = None # None = all models allowed + # Can include model IDs (int) and "mom" (str) for MoM access + allowed_model_ids: list[int | str] | None = None # None = all models allowed monthly_token_limit: int | None = None # None = unlimited expires_in_days: int | None = None # None = never expires @@ -20,7 +21,8 @@ class ApiKeyUpdate(BaseModel): name: str | None = None description: str | None = None - allowed_model_ids: list[int] | None = None + # Can include model IDs (int) and "mom" (str) for MoM access + allowed_model_ids: list[int | str] | None = None monthly_token_limit: int | None = None @@ -31,7 +33,8 @@ class ApiKeyResponse(BaseModel): name: str description: str | None access_key: str - allowed_model_ids: list[int] | None + # Can include model IDs (int) and "mom" (str) for MoM access + allowed_model_ids: list[int | str] | None monthly_token_limit: int | None expires_at: datetime | None created_at: datetime diff --git a/backend/app/services/gateway.py b/backend/app/services/gateway.py index a1dc05e..b4a033d 100644 --- a/backend/app/services/gateway.py +++ b/backend/app/services/gateway.py @@ -81,6 +81,20 @@ async def check_model_access( return model_id in api_key.allowed_model_ids + @staticmethod + async def check_mom_access(api_key: ApiKey) -> bool: + """Check if API key has access to MoM (Semantic Router). + + MoM access is granted if: + - allowed_model_ids is empty/null (all models allowed) + - "mom" is explicitly in allowed_model_ids + """ + if not api_key.allowed_model_ids: + # No restrictions, allow all including MoM + return True + + return "mom" in api_key.allowed_model_ids + @staticmethod async def find_deployment_for_model( db: AsyncSession, diff --git a/backend/app/services/semantic_router.py b/backend/app/services/semantic_router.py index 9de56fc..5e17ed0 100644 --- a/backend/app/services/semantic_router.py +++ b/backend/app/services/semantic_router.py @@ -54,6 +54,7 @@ async def generate_config( self, db: AsyncSession, lmstack_api_url: str, + api_key: str | None = None, ) -> dict[str, Any]: """Generate semantic router config.yaml content. @@ -61,7 +62,8 @@ async def generate_config( Args: db: Database session - lmstack_api_url: LMStack API URL (e.g., http://host.docker.internal:52000) + lmstack_api_url: LMStack API URL (e.g., http://192.168.201.16:52000) + api_key: LMStack API key for authentication Returns: Config dictionary ready to be serialized to YAML @@ -91,19 +93,21 @@ async def generate_config( endpoint_name = f"lmstack-{model_name}" model_names.append(model_name) - models.append( - { - "name": model_name, - "endpoints": [ - { - "name": endpoint_name, - "weight": 1, - "endpoint": f"{host}:{port}", - "protocol": "http", - } - ], - } - ) + model_config = { + "name": model_name, + "endpoints": [ + { + "name": endpoint_name, + "weight": 1, + "endpoint": f"{host}:{port}", + "protocol": "http", + } + ], + } + # Add API key for authentication if provided + if api_key: + model_config["access_key"] = api_key + models.append(model_config) # If no deployments, add a placeholder model if not models: @@ -136,11 +140,18 @@ async def generate_config( "timeout": "300s", } ], - # Response API + # Observability (metrics endpoint) + "observability": { + "metrics": { + "enabled": True, + "port": 9190, + } + }, + # Response API caching for faster repeated queries "response_api": { "enabled": True, "store_backend": "memory", - "ttl_seconds": 86400, + "ttl_seconds": 3600, "max_responses": 1000, }, # Note: The following features require ML models to be downloaded. diff --git a/frontend/src/api/apiKeys.ts b/frontend/src/api/apiKeys.ts index 5cf968a..a691fcf 100644 --- a/frontend/src/api/apiKeys.ts +++ b/frontend/src/api/apiKeys.ts @@ -22,6 +22,22 @@ export interface ApiKeyStats { per_key_stats: Record<number, { requests: number; tokens: number }>; } +export interface ModelUsageStats { + model_id: number | null; + model_name: string; + model_source: string; + requests: number; + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + is_running: boolean; +} + +export interface ModelStatsResponse { + models: ModelUsageStats[]; + mom_stats: ModelUsageStats | null; +} + export const apiKeysApi = { list: async (params?: ApiKeyListParams): Promise<ListResponse<ApiKey>> => { const response = await api.get<ListResponse<ApiKey>>("/api-keys", { @@ -53,4 +69,11 @@ export const apiKeysApi = { const response = await api.get<ApiKeyStats>("/api-keys/stats/summary"); return response.data; }, + + getModelStats: async (): Promise<ModelStatsResponse> => { + const response = await api.get<ModelStatsResponse>( + "/api-keys/stats/by-model", + ); + return response.data; + }, }; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index ed5f88e..48f8ec6 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -30,6 +30,7 @@ export { ollamaApi } from "./ollama"; export { conversationsApi } from "./conversations"; export { appsApi } from "./apps"; export { headscaleApi } from "./headscale"; +export { semanticRouterApi } from "./semanticRouter"; // Types - Workers export type { WorkerListParams } from "./workers"; @@ -50,7 +51,12 @@ export type { ChangePasswordRequest } from "./auth"; export type { UserListParams } from "./users"; // Types - API Keys -export type { ApiKeyListParams, ApiKeyStats } from "./apiKeys"; +export type { + ApiKeyListParams, + ApiKeyStats, + ModelUsageStats, + ModelStatsResponse, +} from "./apiKeys"; // Types - Images export type { ImageListParams, ImageSearchResult } from "./images"; @@ -113,3 +119,6 @@ export type { PreauthKeyResponse, HeadscaleProgress, } from "./headscale"; + +// Types - Semantic Router +export type { SemanticRouterStatus } from "./semanticRouter"; diff --git a/frontend/src/api/semanticRouter.ts b/frontend/src/api/semanticRouter.ts new file mode 100644 index 0000000..7542748 --- /dev/null +++ b/frontend/src/api/semanticRouter.ts @@ -0,0 +1,27 @@ +/** + * Semantic Router API + */ +import { api } from "./client"; + +export interface SemanticRouterStatus { + deployed: boolean; + url?: string; + dashboard_url?: string; + message?: string; +} + +export const semanticRouterApi = { + getStatus: async (): Promise<SemanticRouterStatus> => { + const response = await api.get<SemanticRouterStatus>( + "/semantic-router/status", + ); + return response.data; + }, + + updateConfig: async (): Promise<{ success: boolean; message: string }> => { + const response = await api.post<{ success: boolean; message: string }>( + "/semantic-router/update-config", + ); + return response.data; + }, +}; diff --git a/frontend/src/pages/ApiKeys.tsx b/frontend/src/pages/ApiKeys.tsx index 561d8d9..44f17f7 100644 --- a/frontend/src/pages/ApiKeys.tsx +++ b/frontend/src/pages/ApiKeys.tsx @@ -39,12 +39,17 @@ import { CheckCircleOutlined, EyeOutlined, EyeInvisibleOutlined, + RobotOutlined, + EditOutlined, } from "@ant-design/icons"; import { apiKeysApi, deploymentsApi, modelsApi, + semanticRouterApi, type ApiKeyStats, + type ModelStatsResponse, + type SemanticRouterStatus, } from "../services/api"; import type { ApiKey, ApiKeyCreate, Deployment, LLMModel } from "../types"; import { useAppTheme, useResponsive } from "../hooks"; @@ -299,12 +304,17 @@ export default function ApiKeys() { [], ); const [stats, setStats] = useState<ApiKeyStats | null>(null); + const [modelStats, setModelStats] = useState<ModelStatsResponse | null>(null); + const [semanticRouterStatus, setSemanticRouterStatus] = + useState<SemanticRouterStatus | null>(null); const [loading, setLoading] = useState(true); const [createModalOpen, setCreateModalOpen] = useState(false); + const [editModalKey, setEditModalKey] = useState<ApiKey | null>(null); const [codeModalKey, setCodeModalKey] = useState<ApiKey | null>(null); const [newKeyModal, setNewKeyModal] = useState<NewKeyModalData | null>(null); const [revealedKeys, setRevealedKeys] = useState<Set<number>>(new Set()); const [createForm] = Form.useForm(); + const [editForm] = Form.useForm(); const { isMobile } = useResponsive(); const { isDark } = useAppTheme(); const { canEdit } = useAuth(); @@ -317,11 +327,20 @@ export default function ApiKeys() { const fetchData = useCallback(async () => { try { - const [keysRes, modelsRes, deploymentsRes, statsRes] = await Promise.all([ + const [ + keysRes, + modelsRes, + deploymentsRes, + statsRes, + modelStatsRes, + srStatusRes, + ] = await Promise.all([ apiKeysApi.list(), modelsApi.list(), deploymentsApi.list(), apiKeysApi.getStats().catch(() => null), + apiKeysApi.getModelStats().catch(() => null), + semanticRouterApi.getStatus().catch(() => null), ]); setApiKeys(keysRes.items); setModels(modelsRes.items); @@ -330,6 +349,8 @@ export default function ApiKeys() { deploymentsRes.items.filter((d) => d.status === "running"), ); setStats(statsRes); + setModelStats(modelStatsRes); + setSemanticRouterStatus(srStatusRes); } catch (error) { console.error("Failed to fetch data:", error); } finally { @@ -366,6 +387,30 @@ export default function ApiKeys() { } }; + const handleUpdate = async (values: Partial<ApiKeyCreate>) => { + if (!editModalKey) return; + try { + await apiKeysApi.update(editModalKey.id, values); + message.success("API key updated"); + setEditModalKey(null); + editForm.resetFields(); + fetchData(); + } catch (error: unknown) { + const err = error as { response?: { data?: { detail?: string } } }; + message.error(err.response?.data?.detail || "Failed to update API key"); + } + }; + + const openEditModal = (record: ApiKey) => { + setEditModalKey(record); + editForm.setFieldsValue({ + name: record.name, + description: record.description, + allowed_model_ids: record.allowed_model_ids, + monthly_token_limit: record.monthly_token_limit, + }); + }; + const copyToClipboard = async (text: string, label = "Copied") => { try { // Try modern clipboard API first @@ -465,7 +510,7 @@ export default function ApiKeys() { { title: "", key: "actions", - width: 80, + width: 100, render: (_: unknown, record: ApiKey) => ( <Space direction="vertical" size={4}> <Button @@ -475,20 +520,28 @@ export default function ApiKeys() { onClick={() => setCodeModalKey(record)} /> {canEdit && ( - <Popconfirm - title="Delete API key?" - description="Applications using this key will stop working." - onConfirm={() => handleDelete(record.id)} - okText="Delete" - okButtonProps={{ danger: true }} - > + <> <Button type="text" size="small" - danger - icon={<DeleteOutlined />} + icon={<EditOutlined />} + onClick={() => openEditModal(record)} /> - </Popconfirm> + <Popconfirm + title="Delete API key?" + description="Applications using this key will stop working." + onConfirm={() => handleDelete(record.id)} + okText="Delete" + okButtonProps={{ danger: true }} + > + <Button + type="text" + size="small" + danger + icon={<DeleteOutlined />} + /> + </Popconfirm> + </> )} </Space> ), @@ -619,7 +672,7 @@ export default function ApiKeys() { { title: "", key: "actions", - width: 120, + width: 160, render: (_: unknown, record: ApiKey) => ( <Space> <Tooltip title="View Code"> @@ -630,17 +683,26 @@ export default function ApiKeys() { /> </Tooltip> {canEdit && ( - <Popconfirm - title="Delete API key?" - description="Applications using this key will stop working." - onConfirm={() => handleDelete(record.id)} - okText="Delete" - okButtonProps={{ danger: true }} - > - <Tooltip title="Delete"> - <Button type="text" danger icon={<DeleteOutlined />} /> + <> + <Tooltip title="Edit"> + <Button + type="text" + icon={<EditOutlined />} + onClick={() => openEditModal(record)} + /> </Tooltip> - </Popconfirm> + <Popconfirm + title="Delete API key?" + description="Applications using this key will stop working." + onConfirm={() => handleDelete(record.id)} + okText="Delete" + okButtonProps={{ danger: true }} + > + <Tooltip title="Delete"> + <Button type="text" danger icon={<DeleteOutlined />} /> + </Tooltip> + </Popconfirm> + </> )} </Space> ), @@ -651,10 +713,18 @@ export default function ApiKeys() { const runningModelIds = new Set(runningDeployments.map((d) => d.model_id)); const availableModels = models.filter((m) => runningModelIds.has(m.id)); - const modelOptions = availableModels.map((m) => ({ - label: m.name, - value: m.id, - })); + // Build model options including MoM when Semantic Router is deployed + const modelOptions = [ + // Add MoM option if Semantic Router is deployed + ...(semanticRouterStatus?.deployed + ? [{ label: "MoM (Semantic Router)", value: "mom" }] + : []), + // Add regular running models + ...availableModels.map((m) => ({ + label: m.name, + value: m.id, + })), + ]; // Use first running model name for code examples const defaultModel = @@ -850,6 +920,169 @@ const client = new OpenAI({ baseURL: '${baseUrl}/v1', apiKey: 'YOUR_API_KEY' }); </Card> )} + {/* Available Models */} + <Card + style={{ marginBottom: 24, borderRadius: 12 }} + bodyStyle={{ padding: 0 }} + > + <div + style={{ padding: "16px 24px", borderBottom: "1px solid #27272a" }} + > + <Space> + <RobotOutlined /> + <Text strong>Available Models</Text> + {semanticRouterStatus?.deployed && ( + <Tag color="gold" style={{ marginLeft: 8 }}> + <ThunderboltOutlined /> MoM Enabled + </Tag> + )} + </Space> + </div> + <div style={{ padding: 16 }}> + {/* MoM Row - shown when Semantic Router is deployed */} + {semanticRouterStatus?.deployed && ( + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "12px 16px", + background: isDark + ? "rgba(250, 173, 20, 0.1)" + : "rgba(250, 173, 20, 0.05)", + borderRadius: 8, + marginBottom: 12, + border: `1px solid ${isDark ? "rgba(250, 173, 20, 0.3)" : "rgba(250, 173, 20, 0.2)"}`, + }} + > + <div style={{ display: "flex", alignItems: "center", gap: 12 }}> + <div + style={{ + width: 36, + height: 36, + borderRadius: 8, + background: + "linear-gradient(135deg, #faad14 0%, #fa8c16 100%)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <ThunderboltOutlined + style={{ color: "#fff", fontSize: 18 }} + /> + </div> + <div> + <Text strong style={{ display: "block" }}> + MoM (Semantic Router) + </Text> + <Text type="secondary" style={{ fontSize: 12 }}> + Auto-selects the best model for each request + </Text> + </div> + </div> + <div style={{ textAlign: "right" }}> + {modelStats?.mom_stats ? ( + <> + <Text style={{ display: "block" }}> + {modelStats.mom_stats.requests.toLocaleString()} requests + </Text> + <Text type="secondary" style={{ fontSize: 12 }}> + {modelStats.mom_stats.total_tokens.toLocaleString()}{" "} + tokens + </Text> + </> + ) : ( + <Text type="secondary" style={{ fontSize: 12 }}> + No usage yet + </Text> + )} + </div> + </div> + )} + + {/* Regular Models */} + {modelStats?.models && modelStats.models.length > 0 ? ( + <div style={{ display: "flex", flexDirection: "column", gap: 8 }}> + {modelStats.models.map((model) => ( + <div + key={model.model_id} + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "12px 16px", + background: isDark ? "#18181b" : "#f8fafc", + borderRadius: 8, + border: `1px solid ${isDark ? "#27272a" : "#e2e8f0"}`, + }} + > + <div + style={{ display: "flex", alignItems: "center", gap: 12 }} + > + <div + style={{ + width: 36, + height: 36, + borderRadius: 8, + background: isDark ? "#27272a" : "#e2e8f0", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <RobotOutlined + style={{ + color: isDark ? "#a1a1aa" : "#64748b", + fontSize: 18, + }} + /> + </div> + <div> + <Text strong style={{ display: "block" }}> + {model.model_name} + </Text> + <Space size={4}> + <Tag + color={model.is_running ? "success" : "default"} + style={{ margin: 0, fontSize: 11 }} + > + {model.is_running ? "Running" : "Stopped"} + </Tag> + <Text type="secondary" style={{ fontSize: 11 }}> + {model.model_source} + </Text> + </Space> + </div> + </div> + <div style={{ textAlign: "right" }}> + <Text style={{ display: "block" }}> + {model.requests.toLocaleString()} requests + </Text> + <Text type="secondary" style={{ fontSize: 12 }}> + {model.total_tokens.toLocaleString()} tokens + </Text> + </div> + </div> + ))} + </div> + ) : ( + !semanticRouterStatus?.deployed && ( + <div + style={{ + textAlign: "center", + padding: "24px 0", + color: isDark ? "#71717a" : "#64748b", + }} + > + No models deployed yet. Deploy a model from the Deployments + page. + </div> + ) + )} + </div> + </Card> + {/* API Keys Table */} <Card style={{ borderRadius: 12 }}> <Table @@ -901,22 +1134,22 @@ const client = new OpenAI({ baseURL: '${baseUrl}/v1', apiKey: 'YOUR_API_KEY' }); name="allowed_model_ids" label="Model Access" extra={ - availableModels.length > 0 - ? "Leave empty for all running models" - : "No models are currently running" + modelOptions.length > 0 + ? "Leave empty for all available models (including MoM if deployed)" + : "No models are currently available" } > <Select mode="multiple" placeholder={ - availableModels.length > 0 - ? "All running models" - : "No running models" + modelOptions.length > 0 + ? "All available models" + : "No available models" } options={modelOptions} allowClear size="large" - disabled={availableModels.length === 0} + disabled={modelOptions.length === 0} /> </Form.Item> @@ -964,6 +1197,93 @@ const client = new OpenAI({ baseURL: '${baseUrl}/v1', apiKey: 'YOUR_API_KEY' }); </Form> </Modal> + {/* Edit Modal */} + <Modal + title={null} + open={!!editModalKey} + onCancel={() => { + setEditModalKey(null); + editForm.resetFields(); + }} + footer={null} + width={isMobile ? "100%" : 480} + style={ + isMobile ? { top: 20, maxWidth: "100%", margin: "0 8px" } : undefined + } + > + <div style={{ marginBottom: 24 }}> + <h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}> + Edit API Key + </h2> + <Text type="secondary">Update key settings and permissions</Text> + </div> + + <Form form={editForm} layout="vertical" onFinish={handleUpdate}> + <Form.Item + name="name" + label="Name" + rules={[{ required: true, message: "Enter a name" }]} + > + <Input placeholder="e.g., Production API" size="large" /> + </Form.Item> + + <Form.Item name="description" label="Description"> + <Input.TextArea placeholder="Optional description" rows={2} /> + </Form.Item> + + <Form.Item + name="allowed_model_ids" + label="Model Access" + extra={ + modelOptions.length > 0 + ? "Leave empty for all available models (including MoM if deployed)" + : "No models are currently available" + } + > + <Select + mode="multiple" + placeholder={ + modelOptions.length > 0 + ? "All available models" + : "No available models" + } + options={modelOptions} + allowClear + size="large" + disabled={modelOptions.length === 0} + /> + </Form.Item> + + <Form.Item + name="monthly_token_limit" + label="Monthly Token Limit (tokens)" + extra="Leave empty for unlimited" + > + <InputNumber<number> + min={1000} + placeholder="Unlimited" + style={{ width: "100%" }} + size="large" + formatter={(value) => + value ? `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : "" + } + parser={(value) => (value ? Number(value.replace(/,/g, "")) : 0)} + /> + </Form.Item> + + <Form.Item style={{ marginBottom: 0, marginTop: 24 }}> + <Space style={{ width: "100%", justifyContent: "flex-end" }}> + <Button size="large" onClick={() => setEditModalKey(null)}> + Cancel + </Button> + <Button type="primary" htmlType="submit" size="large"> + Save Changes + </Button> + </Space> + </Form.Item> + </Form> + </Modal> + {/* New Key Modal */} <Modal title={null} diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index 963e5ea..c083a6e 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -17,6 +17,7 @@ import { MessageOutlined, MenuFoldOutlined, MenuUnfoldOutlined, + ThunderboltOutlined, } from "@ant-design/icons"; import { useTheme, @@ -25,7 +26,12 @@ import { MessageContent, generateMessageId, } from "../components/chat"; -import { deploymentsApi, conversationsApi } from "../services/api"; +import { + deploymentsApi, + conversationsApi, + semanticRouterApi, +} from "../services/api"; +import type { SemanticRouterStatus } from "../services/api"; import type { Deployment, ChatMessage } from "../types"; import type { Conversation as ApiConversation, @@ -95,7 +101,12 @@ export default function Chat() { const [messages, setMessages] = useState<ChatMessage[]>([]); const [inputValue, setInputValue] = useState(""); const [isStreaming, setIsStreaming] = useState(false); + const isStreamingRef = useRef(false); // Sync ref for streaming state + const justFinishedStreamingRef = useRef(false); const [loading, setLoading] = useState(true); + const [semanticRouterStatus, setSemanticRouterStatus] = + useState<SemanticRouterStatus | null>(null); + const [isMoMSelected, setIsMoMSelected] = useState(false); // Refs const messagesEndRef = useRef<HTMLDivElement>(null); @@ -121,12 +132,27 @@ export default function Chat() { // Load conversation messages when switching conversations useEffect(() => { - if (isStreaming) return; // Don't sync during streaming + // Use ref for sync check (state might not be updated yet after await) + if (isStreamingRef.current || isStreaming) return; + + // Skip reload right after streaming ends to preserve model field + if (justFinishedStreamingRef.current) { + justFinishedStreamingRef.current = false; + return; + } + if (currentConversationId) { const loadMessages = async () => { + // Double-check streaming state before loading + if (isStreamingRef.current) return; + try { const conv = await conversationsApi.get(currentConversationId); const converted = convertApiConversation(conv); + + // Check again before setting messages (streaming might have started) + if (isStreamingRef.current) return; + setMessages(converted.messages); // Update the conversation in the list with full message data setConversations((prev) => @@ -134,7 +160,9 @@ export default function Chat() { ); } catch (error) { console.error("Failed to load conversation:", error); - setMessages([]); + if (!isStreamingRef.current) { + setMessages([]); + } } }; loadMessages(); @@ -215,6 +243,23 @@ export default function Chat() { return () => clearInterval(interval); }, [fetchDeployments]); + // Fetch semantic router status + const fetchSemanticRouterStatus = useCallback(async () => { + try { + const status = await semanticRouterApi.getStatus(); + setSemanticRouterStatus(status); + } catch (error) { + console.error("Failed to fetch semantic router status:", error); + setSemanticRouterStatus(null); + } + }, []); + + useEffect(() => { + fetchSemanticRouterStatus(); + const interval = setInterval(fetchSemanticRouterStatus, 10000); + return () => clearInterval(interval); + }, [fetchSemanticRouterStatus]); + // Handle scroll detection - check if user scrolled up const handleScroll = useCallback(() => { const container = messagesContainerRef.current; @@ -281,8 +326,15 @@ export default function Chat() { /** * Get endpoint URL for deployment (uses backend proxy to handle Docker networking) */ - const getEndpointUrl = (deployment: Deployment): string | null => { - if (deployment.status !== "running") { + const getEndpointUrl = ( + deployment: Deployment | null, + useMoM: boolean, + ): string | null => { + if (useMoM) { + // Use Semantic Router chat proxy endpoint (accepts JWT auth) + return `/api/semantic-router/chat`; + } + if (!deployment || deployment.status !== "running") { return null; } // Use backend proxy endpoint instead of direct model URL @@ -296,9 +348,15 @@ export default function Chat() { const handleSend = useCallback( async (content?: string) => { const messageContent = content || inputValue.trim(); - if (!messageContent || !selectedDeployment || isStreaming) return; + // Allow sending when MoM is selected (no deployment needed) or when a deployment is selected + if ( + !messageContent || + (!isMoMSelected && !selectedDeployment) || + isStreaming + ) + return; - const endpoint = getEndpointUrl(selectedDeployment); + const endpoint = getEndpointUrl(selectedDeployment, isMoMSelected); if (!endpoint) { message.error( "Deployment is not ready. Please wait for it to be running.", @@ -306,7 +364,8 @@ export default function Chat() { return; } - // Set streaming first to prevent useEffect from clearing messages + // Set streaming ref FIRST (sync) to prevent useEffect from clearing messages + isStreamingRef.current = true; setIsStreaming(true); setInputValue(""); @@ -319,7 +378,8 @@ export default function Chat() { (messageContent.length > 30 ? "..." : ""); const newConv = await conversationsApi.create({ title, - deployment_id: selectedDeployment.id, + // Use deployment_id if available, otherwise null for MoM + deployment_id: selectedDeployment?.id, }); const converted = convertApiConversation(newConv); setConversations((prev) => [converted, ...prev]); @@ -328,6 +388,7 @@ export default function Chat() { } catch (error) { console.error("Failed to create conversation:", error); message.error("Failed to create conversation"); + isStreamingRef.current = false; setIsStreaming(false); return; } @@ -353,6 +414,10 @@ export default function Chat() { abortControllerRef.current = new AbortController(); const token = localStorage.getItem(STORAGE_KEYS.TOKEN); + // Use "MoM" as model name for Semantic Router, otherwise use deployment model + const modelName = isMoMSelected + ? "MoM" + : selectedDeployment?.model?.model_id || "default"; const response = await fetch(endpoint, { method: "POST", headers: { @@ -360,7 +425,7 @@ export default function Chat() { ...(token && { Authorization: `Bearer ${token}` }), }, body: JSON.stringify({ - model: selectedDeployment.model?.model_id || "default", + model: modelName, messages: [ ...messages.map((m) => ({ role: m.role, content: m.content })), { role: "user", content: messageContent }, @@ -384,19 +449,26 @@ export default function Chat() { if (!reader) throw new Error("No response body"); let accumulatedContent = ""; + let responseModel: string | undefined = undefined; + let buffer = ""; // Buffer for incomplete SSE lines // eslint-disable-next-line no-constant-condition while (true) { const { done, value } = await reader.read(); if (done) break; - const chunk = decoder.decode(value); - const lines = chunk - .split("\n") - .filter((line) => line.trim().startsWith("data:")); + // Use stream: true for proper multi-byte character handling + buffer += decoder.decode(value, { stream: true }); + + // Split by newlines but keep incomplete lines in buffer + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; // Keep last incomplete line in buffer for (const line of lines) { - const data = line.replace("data: ", "").trim(); + const trimmedLine = line.trim(); + if (!trimmedLine.startsWith("data:")) continue; + + const data = trimmedLine.slice(5).trim(); // Remove "data:" prefix if (data === "[DONE]") continue; try { @@ -404,10 +476,19 @@ export default function Chat() { const deltaContent = parsed.choices?.[0]?.delta?.content || ""; accumulatedContent += deltaContent; + // Extract model name from response (for MoM to show which model answered) + if (!responseModel && parsed.model) { + responseModel = parsed.model; + } + setMessages((prev) => prev.map((m) => m.id === assistantMessage.id - ? { ...m, content: accumulatedContent } + ? { + ...m, + content: accumulatedContent, + model: responseModel, + } : m, ), ); @@ -417,6 +498,35 @@ export default function Chat() { } } + // Process any remaining data in buffer + if (buffer.trim()) { + const trimmedLine = buffer.trim(); + if (trimmedLine.startsWith("data:")) { + const data = trimmedLine.slice(5).trim(); + if (data !== "[DONE]") { + try { + const parsed = JSON.parse(data); + const deltaContent = parsed.choices?.[0]?.delta?.content || ""; + accumulatedContent += deltaContent; + if (!responseModel && parsed.model) { + responseModel = parsed.model; + } + } catch { + // Skip invalid JSON + } + } + } + } + + // Final update to ensure model is preserved after streaming ends + setMessages((prev) => + prev.map((m) => + m.id === assistantMessage.id + ? { ...m, content: accumulatedContent, model: responseModel } + : m, + ), + ); + // Save messages to database after streaming completes if (convId && accumulatedContent) { try { @@ -457,6 +567,8 @@ export default function Chat() { ); } } finally { + justFinishedStreamingRef.current = true; + isStreamingRef.current = false; setIsStreaming(false); abortControllerRef.current = null; } @@ -467,6 +579,7 @@ export default function Chat() { isStreaming, messages, currentConversationId, + isMoMSelected, ], ); @@ -503,7 +616,7 @@ export default function Chat() { } // Model dropdown menu items - const modelMenuItems = deployments.map((d) => ({ + const deploymentMenuItems = deployments.map((d) => ({ key: d.id.toString(), label: ( <div @@ -524,9 +637,41 @@ export default function Chat() { onClick: () => { const deployment = deployments.find((dep) => dep.id === d.id); setSelectedDeployment(deployment || null); + setIsMoMSelected(false); }, })); + // Add MoM option if Semantic Router is deployed + const modelMenuItems = semanticRouterStatus?.deployed + ? [ + { + key: "mom", + label: ( + <div + style={{ + display: "flex", + alignItems: "center", + gap: 8, + padding: "4px 0", + }} + > + <ThunderboltOutlined style={{ color: "#faad14" }} /> + <span>MoM (Semantic Router)</span> + <span style={{ color: colors.textMuted, fontSize: 12 }}> + Auto-select best model + </span> + </div> + ), + onClick: () => { + setSelectedDeployment(null); + setIsMoMSelected(true); + }, + }, + { type: "divider" as const }, + ...deploymentMenuItems, + ] + : deploymentMenuItems; + return ( <div className="chat-page" @@ -568,7 +713,9 @@ export default function Chat() { modelMenuItems={modelMenuItems} onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)} sidebarCollapsed={sidebarCollapsed} + isDark={isDark} colors={colors} + isMoMSelected={isMoMSelected} /> {/* Messages Area */} @@ -589,6 +736,7 @@ export default function Chat() { onSend={handleSend} colors={colors} isMobile={isMobile} + isMoMSelected={isMoMSelected} /> ) : ( <MessageList @@ -636,7 +784,7 @@ export default function Chat() { onSend={() => handleSend()} onStop={handleStop} isStreaming={isStreaming} - disabled={!selectedDeployment} + disabled={!selectedDeployment && !isMoMSelected} isDark={isDark} colors={colors} /> @@ -655,14 +803,19 @@ export default function Chat() { */ interface ChatHeaderProps { selectedDeployment: Deployment | null; - modelMenuItems: { - key: string; - label: React.ReactNode; - onClick: () => void; - }[]; + modelMenuItems: ( + | { + key: string; + label: React.ReactNode; + onClick: () => void; + } + | { type: "divider" } + )[]; onToggleSidebar: () => void; sidebarCollapsed: boolean; + isDark: boolean; colors: ReturnType<typeof useTheme>["colors"]; + isMoMSelected: boolean; } function ChatHeader({ @@ -670,7 +823,9 @@ function ChatHeader({ modelMenuItems, onToggleSidebar, sidebarCollapsed, + isDark, colors, + isMoMSelected, }: ChatHeaderProps) { return ( <div @@ -702,7 +857,19 @@ function ChatHeader({ </Tooltip> {/* Model Selector */} - <Dropdown menu={{ items: modelMenuItems }} trigger={["click"]}> + <Dropdown + menu={{ + items: modelMenuItems, + style: { + background: isDark ? "#1f1f1f" : "#ffffff", + borderRadius: 12, + boxShadow: isDark + ? "0 6px 16px rgba(0, 0, 0, 0.4)" + : "0 6px 16px rgba(0, 0, 0, 0.12)", + }, + }} + trigger={["click"]} + > <Button type="text" style={{ @@ -719,8 +886,16 @@ function ChatHeader({ color: colors.text, }} > - <RobotOutlined style={{ fontSize: 16 }} /> - <span>{selectedDeployment?.model?.name || "Select Model"}</span> + {isMoMSelected ? ( + <ThunderboltOutlined style={{ fontSize: 16, color: "#faad14" }} /> + ) : ( + <RobotOutlined style={{ fontSize: 16 }} /> + )} + <span> + {isMoMSelected + ? "MoM (Semantic Router)" + : selectedDeployment?.model?.name || "Select Model"} + </span> <DownOutlined style={{ fontSize: 10, color: colors.textMuted }} /> </Button> </Dropdown> @@ -737,6 +912,7 @@ interface EmptyStateProps { onSend: (content: string) => void; colors: ReturnType<typeof useTheme>["colors"]; isMobile?: boolean; + isMoMSelected?: boolean; } function EmptyState({ @@ -744,7 +920,13 @@ function EmptyState({ onSend, colors, isMobile, + isMoMSelected, }: EmptyStateProps) { + const hasModel = selectedDeployment || isMoMSelected; + const modelName = isMoMSelected + ? "MoM (Semantic Router)" + : selectedDeployment?.model?.name || "AI"; + return ( <div style={{ @@ -768,9 +950,7 @@ function EmptyState({ textAlign: "center", }} > - {selectedDeployment - ? `Chat with ${selectedDeployment.model?.name || "AI"}` - : "Select a model to start"} + {hasModel ? `Chat with ${modelName}` : "Select a model to start"} </h2> <p style={{ @@ -780,12 +960,14 @@ function EmptyState({ textAlign: "center", }} > - {selectedDeployment - ? "Ask me anything" + {hasModel + ? isMoMSelected + ? "I'll automatically select the best model for your request" + : "Ask me anything" : "Choose a deployed model from the dropdown above"} </p> - {selectedDeployment && ( + {hasModel && ( <div style={{ display: "grid", @@ -933,12 +1115,27 @@ function MessageRow({ {message.content} </div> ) : ( - <MessageContent - content={message.content} - isStreaming={showStreaming} - isDark={isDark} - colors={colors} - /> + <> + <MessageContent + content={message.content} + isStreaming={showStreaming} + isDark={isDark} + colors={colors} + /> + {/* Show model name for MoM responses */} + {message.model && ( + <div + style={{ + marginTop: 8, + fontSize: 11, + color: colors.textSecondary, + opacity: 0.7, + }} + > + via {message.model} + </div> + )} + </> )} </div> diff --git a/frontend/src/pages/DeployApps.tsx b/frontend/src/pages/DeployApps.tsx index 3940ce8..565b5c0 100644 --- a/frontend/src/pages/DeployApps.tsx +++ b/frontend/src/pages/DeployApps.tsx @@ -10,7 +10,6 @@ import { Modal, Tag, message, - Popconfirm, Row, Col, Typography, @@ -22,6 +21,7 @@ import { Space, Input, Alert, + Popconfirm, } from "antd"; import { RocketOutlined, @@ -42,8 +42,6 @@ import { VerticalAlignBottomOutlined, BranchesOutlined, DashboardOutlined, - PlusOutlined, - MinusOutlined, } from "@ant-design/icons"; import { appsApi, workersApi } from "../services/api"; import type { Worker } from "../types"; @@ -142,9 +140,6 @@ export default function DeployApps() { const [monitoringStatus, setMonitoringStatus] = useState< Record<number, MonitoringStatus> >({}); - const [monitoringLoading, setMonitoringLoading] = useState< - Record<number, boolean> - >({}); const fetchData = useCallback(async () => { try { @@ -169,21 +164,31 @@ export default function DeployApps() { return () => clearInterval(interval); }, [fetchData]); - // Fetch monitoring status for apps that support it + // Fetch and poll monitoring status for apps that support it useEffect(() => { const appsWithMonitoring = deployedApps.filter( (app) => app.has_monitoring && app.status === "running", ); - appsWithMonitoring.forEach((app) => { - if (!monitoringStatus[app.id]) { - appsApi - .getMonitoringStatus(app.id) - .then((status) => { - setMonitoringStatus((prev) => ({ ...prev, [app.id]: status })); - }) - .catch(console.error); + + if (appsWithMonitoring.length === 0) return; + + // Fetch monitoring status + const fetchAllStatus = async () => { + for (const app of appsWithMonitoring) { + try { + const status = await appsApi.getMonitoringStatus(app.id); + setMonitoringStatus((prev) => ({ ...prev, [app.id]: status })); + } catch (error) { + console.error("Failed to fetch monitoring status:", error); + } } - }); + }; + + fetchAllStatus(); + + // Poll every 5 seconds (reasonable interval) + const interval = setInterval(fetchAllStatus, 5000); + return () => clearInterval(interval); }, [deployedApps]); // Poll progress for deploying apps @@ -371,66 +376,6 @@ export default function DeployApps() { } }; - // Monitoring handlers - const fetchMonitoringStatus = async (appId: number) => { - try { - const status = await appsApi.getMonitoringStatus(appId); - setMonitoringStatus((prev) => ({ ...prev, [appId]: status })); - } catch (error) { - console.error("Failed to fetch monitoring status:", error); - } - }; - - const handleDeployMonitoring = async (app: DeployedApp) => { - setMonitoringLoading((prev) => ({ ...prev, [app.id]: true })); - try { - const status = await appsApi.deployMonitoring(app.id); - setMonitoringStatus((prev) => ({ ...prev, [app.id]: status })); - message.success("Monitoring deployment started"); - // Poll for status updates - const pollInterval = setInterval(async () => { - const newStatus = await appsApi.getMonitoringStatus(app.id); - setMonitoringStatus((prev) => ({ ...prev, [app.id]: newStatus })); - // Stop polling when all services are running or errored - const allDone = newStatus.services.every( - (s) => - s.status === "running" || - s.status === "error" || - s.status === "stopped", - ); - if (allDone) { - clearInterval(pollInterval); - setMonitoringLoading((prev) => ({ ...prev, [app.id]: false })); - } - }, 3000); - } catch (error: unknown) { - const err = error as { response?: { data?: { detail?: string } } }; - message.error( - err.response?.data?.detail || "Failed to deploy monitoring", - ); - setMonitoringLoading((prev) => ({ ...prev, [app.id]: false })); - } - }; - - const handleRemoveMonitoring = async (app: DeployedApp) => { - setMonitoringLoading((prev) => ({ ...prev, [app.id]: true })); - try { - await appsApi.removeMonitoring(app.id); - setMonitoringStatus((prev) => ({ - ...prev, - [app.id]: { enabled: false, services: [] }, - })); - message.success("Monitoring removed"); - } catch (error: unknown) { - const err = error as { response?: { data?: { detail?: string } } }; - message.error( - err.response?.data?.detail || "Failed to remove monitoring", - ); - } finally { - setMonitoringLoading((prev) => ({ ...prev, [app.id]: false })); - } - }; - if (loading) { return ( <div @@ -817,6 +762,7 @@ export default function DeployApps() { </div> {/* Monitoring Section for apps that support it */} + {/* Monitoring is auto-deployed with Semantic Router */} {app.has_monitoring && app.status === "running" && ( <div style={{ @@ -837,37 +783,6 @@ export default function DeployApps() { <DashboardOutlined style={{ marginRight: 6 }} /> Monitoring </Text> - {!monitoringStatus[app.id]?.enabled ? ( - <Button - type="primary" - size="small" - icon={<PlusOutlined />} - loading={monitoringLoading[app.id]} - onClick={() => { - fetchMonitoringStatus(app.id); - handleDeployMonitoring(app); - }} - > - Deploy - </Button> - ) : ( - <Popconfirm - title="Remove monitoring?" - description="This will stop and remove all monitoring services." - onConfirm={() => handleRemoveMonitoring(app)} - okText="Remove" - okButtonProps={{ danger: true }} - > - <Button - size="small" - icon={<MinusOutlined />} - loading={monitoringLoading[app.id]} - danger - > - Remove - </Button> - </Popconfirm> - )} </div> {monitoringStatus[app.id]?.services && @@ -879,6 +794,26 @@ export default function DeployApps() { gap: 6, }} > + {/* Show overall progress if any service is deploying */} + {monitoringStatus[app.id].services.some( + (svc) => + svc.status === "pending" || + svc.status === "pulling" || + svc.status === "starting", + ) && ( + <div style={{ marginBottom: 4 }}> + <Progress + percent={100} + size="small" + status="active" + showInfo={false} + strokeColor={{ + from: "#108ee9", + to: "#87d068", + }} + /> + </div> + )} {monitoringStatus[app.id].services.map( (svc: MonitoringServiceStatus) => ( <div @@ -890,7 +825,34 @@ export default function DeployApps() { fontSize: 12, }} > - <span> + <span + style={{ + display: "flex", + alignItems: "center", + gap: 4, + }} + > + {svc.status === "pulling" && ( + <CloudDownloadOutlined + style={{ color: "#1890ff" }} + /> + )} + {(svc.status === "pending" || + svc.status === "starting") && ( + <LoadingOutlined + style={{ color: "#1890ff" }} + /> + )} + {svc.status === "running" && ( + <CheckCircleOutlined + style={{ color: "#52c41a" }} + /> + )} + {svc.status === "error" && ( + <CloseCircleOutlined + style={{ color: "#ff4d4f" }} + /> + )} {svc.name} <Tag color={ @@ -901,7 +863,7 @@ export default function DeployApps() { : "processing" } style={{ - marginLeft: 8, + marginLeft: 4, fontSize: 10, }} > @@ -910,8 +872,10 @@ export default function DeployApps() { : svc.status === "error" ? "Error" : svc.status === "pulling" - ? "Pulling" - : "Starting"} + ? "Pulling..." + : svc.status === "pending" + ? "Pending" + : "Starting..."} </Tag> </span> {svc.status === "running" && svc.port && ( @@ -937,14 +901,10 @@ export default function DeployApps() { )} {!monitoringStatus[app.id] && ( - <Button - type="link" - size="small" - onClick={() => fetchMonitoringStatus(app.id)} - style={{ padding: 0, fontSize: 12 }} - > - Check status - </Button> + <Text type="secondary" style={{ fontSize: 12 }}> + <LoadingOutlined style={{ marginRight: 4 }} /> + Loading monitoring status... + </Text> )} </div> )} diff --git a/frontend/src/types/apiKey.ts b/frontend/src/types/apiKey.ts index c6f84be..9968079 100644 --- a/frontend/src/types/apiKey.ts +++ b/frontend/src/types/apiKey.ts @@ -7,7 +7,7 @@ export interface ApiKey { name: string; description?: string; access_key: string; - allowed_model_ids?: number[]; + allowed_model_ids?: (number | string)[]; // Can include model IDs and "mom" for MoM access monthly_token_limit?: number; expires_at?: string; created_at: string; @@ -17,7 +17,7 @@ export interface ApiKey { export interface ApiKeyCreate { name: string; description?: string; - allowed_model_ids?: number[]; + allowed_model_ids?: (number | string)[]; // Can include model IDs and "mom" for MoM access monthly_token_limit?: number; expires_in_days?: number; } diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts index 9bacf31..b745469 100644 --- a/frontend/src/types/chat.ts +++ b/frontend/src/types/chat.ts @@ -9,4 +9,5 @@ export interface ChatMessage { role: MessageRole; content: string; timestamp: Date; + model?: string; // The model that generated this response (for MoM) } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 6fd133f..33c0cf0 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -20,6 +20,22 @@ export default defineConfig({ proxyReq.setHeader("X-Forwarded-Proto", "http"); } }); + // Disable buffering for SSE/streaming responses + proxy.on("proxyRes", (proxyRes, req, res) => { + // Check if this is a streaming response + const contentType = proxyRes.headers["content-type"] || ""; + if ( + contentType.includes("text/event-stream") || + contentType.includes("application/stream") || + req.url?.includes("/chat") + ) { + // Disable compression and buffering for streaming + res.setHeader("X-Accel-Buffering", "no"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + // Flush headers immediately + res.flushHeaders?.(); + } + }); }, }, "/v1": {