diff --git a/cc-connect/README.md b/cc-connect/README.md
new file mode 100644
index 0000000..a07ae75
--- /dev/null
+++ b/cc-connect/README.md
@@ -0,0 +1,30 @@
+# cc-connect (Phase 1)
+
+Mobile bridge service for interacting with coding agents through chat, with LiteLLM as model gateway.
+
+## Scope
+- LiteLLM remains the unified cloud-model endpoint.
+- cc-connect remains the chat/mobile bridge.
+- Start with a single platform (Discord) for lower operational risk.
+
+## Files
+- `config.example.toml`: non-secret template.
+- `docker-compose.yml`: optional always-on service shape.
+
+## Setup (NAS)
+1. Copy template and fill secrets locally:
+ - `cp cc-connect/config.example.toml cc-connect/config.toml`
+ - create `cc-connect/.env` with platform token(s)
+2. Keep secrets local only (`.env` is gitignored).
+3. Start service:
+ - `docker compose -f cc-connect/docker-compose.yml up -d`
+
+## Security defaults
+- Single platform enabled.
+- Confirm/permission-gated command mode.
+- Keep shell/file-write capabilities disabled initially.
+- Restrict users/channels with explicit allowlists.
+
+## Notes
+- This repo only includes templates and orchestration shape.
+- Exact runtime keys/fields can vary by cc-connect release; adjust `config.toml` to your installed version.
diff --git a/cc-connect/config.example.toml b/cc-connect/config.example.toml
new file mode 100644
index 0000000..4754212
--- /dev/null
+++ b/cc-connect/config.example.toml
@@ -0,0 +1,27 @@
+# cc-connect config template
+# Keep one platform enabled at a time for minimal attack surface.
+# This template starts with Discord only.
+
+[service]
+name = "nas-cc-connect"
+log_level = "info"
+
+[bridge]
+provider = "litellm"
+base_url = "http://host.docker.internal:4005"
+model = "claude-arch"
+request_timeout_sec = 30
+
+[platform.discord]
+enabled = true
+bot_token = ""
+allowed_user_ids = []
+allowed_channel_ids = []
+
+[security]
+# Conservative defaults for initial rollout.
+permission_mode = "confirm"
+allow_yolo_mode = false
+allow_shell = false
+allow_file_write = false
+max_session_minutes = 30
diff --git a/cc-connect/docker-compose.yml b/cc-connect/docker-compose.yml
new file mode 100644
index 0000000..7ef97b6
--- /dev/null
+++ b/cc-connect/docker-compose.yml
@@ -0,0 +1,21 @@
+services:
+ cc-connect:
+ image: chenhg5/cc-connect:latest
+ container_name: cc-connect
+ restart: unless-stopped
+ labels:
+ - "com.centurylinklabs.watchtower.enable=true"
+ env_file:
+ - .env
+ volumes:
+ - ./config.toml:/app/config.toml:ro
+ command:
+ - "--config"
+ - "/app/config.toml"
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py
index 80aee94..46814f5 100644
--- a/dashboard/backend/config.py
+++ b/dashboard/backend/config.py
@@ -44,6 +44,10 @@ CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://
# Chat Summary
CHAT_SUMMARY_DB = os.environ.get("CHAT_SUMMARY_DB", "/app/data/chat-summarizer/chat_summary.db")
+# LiteLLM
+LITELLM_URL = os.environ.get("LITELLM_URL", "http://127.0.0.1:4005")
+LITELLM_HEALTH_API_KEY = os.environ.get("LITELLM_HEALTH_API_KEY", "")
+
# OpenClaw
OPENCLAW_GATEWAY_TOKEN = os.environ.get("OPENCLAW_GATEWAY_TOKEN", "")
diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py
index 569f389..924a929 100644
--- a/dashboard/backend/main.py
+++ b/dashboard/backend/main.py
@@ -6,7 +6,7 @@ from fastapi.responses import JSONResponse
from slowapi import Limiter
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
-from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp
+from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm
import auth as auth_module
from rbac import require_page, require_write
@@ -179,6 +179,8 @@ app.include_router(files.router, prefix="/api/files",
dependencies=[Depends(_inject_user), Depends(require_page("files")), Depends(require_write())])
app.include_router(system.router, prefix="/api/system",
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
+app.include_router(litellm.router, prefix="/api/litellm",
+ dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
app.include_router(chat_summary.router, prefix="/api/chat-summary",
dependencies=[Depends(_inject_user), Depends(require_page("chat-digest"))])
app.include_router(security.router, prefix="/api/security",
diff --git a/dashboard/backend/routers/litellm.py b/dashboard/backend/routers/litellm.py
new file mode 100644
index 0000000..7bc9514
--- /dev/null
+++ b/dashboard/backend/routers/litellm.py
@@ -0,0 +1,62 @@
+import time
+import httpx
+from fastapi import APIRouter
+from config import LITELLM_URL, LITELLM_HEALTH_API_KEY
+
+router = APIRouter()
+
+
+@router.get("/health")
+async def litellm_health():
+ timeout = httpx.Timeout(2.0)
+ checks = ("/health", "/v1/models")
+
+ api_key = (LITELLM_HEALTH_API_KEY or "").strip()
+ headers = {}
+ if api_key:
+ headers = {
+ "Authorization": f"Bearer {api_key}",
+ "X-API-Key": api_key,
+ }
+
+ last_error = None
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ for path in checks:
+ url = f"{LITELLM_URL.rstrip('/')}{path}"
+ started = time.perf_counter()
+ try:
+ response = await client.get(url, headers=headers)
+ latency_ms = round((time.perf_counter() - started) * 1000, 1)
+
+ if response.is_success:
+ return {
+ "ok": True,
+ "status": "up",
+ "latency_ms": latency_ms,
+ "endpoint": path,
+ "http_status": response.status_code,
+ }
+
+ if response.status_code == 401 and not api_key:
+ return {
+ "ok": True,
+ "status": "auth_required",
+ "latency_ms": latency_ms,
+ "endpoint": path,
+ "http_status": response.status_code,
+ }
+
+ if response.status_code == 401 and api_key:
+ last_error = f"{path} returned 401 with configured health auth"
+ else:
+ last_error = f"{path} returned {response.status_code}"
+ except Exception as exc:
+ last_error = str(exc)
+
+ return {
+ "ok": False,
+ "status": "down",
+ "latency_ms": None,
+ "endpoint": None,
+ "error": last_error or "health check failed",
+ }
diff --git a/dashboard/docker-compose.dev.yml b/dashboard/docker-compose.dev.yml
index 4bac144..025d1e3 100644
--- a/dashboard/docker-compose.dev.yml
+++ b/dashboard/docker-compose.dev.yml
@@ -21,6 +21,8 @@ services:
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
- TELEGRAM_PROXY=${TELEGRAM_PROXY:-}
- CORS_ORIGINS=https://dev.nas.jimmygan.com,https://dev.nas.jimmygan.com:8443
+ - LITELLM_URL=http://litellm:4005
+ - LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-}
volumes:
- /volume1:/volume1
- /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro
diff --git a/dashboard/docker-compose.yml b/dashboard/docker-compose.yml
index ca93f8e..3808068 100644
--- a/dashboard/docker-compose.yml
+++ b/dashboard/docker-compose.yml
@@ -43,6 +43,7 @@ services:
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
- TELEGRAM_PROXY=${TELEGRAM_PROXY:-}
- CORS_ORIGINS=https://nas.jimmygan.com
+ - LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-}
volumes:
- /volume1:/volume1
- /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro
diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte
index 4ac539d..16a0870 100644
--- a/dashboard/frontend/src/App.svelte
+++ b/dashboard/frontend/src/App.svelte
@@ -9,6 +9,7 @@
import Settings from "./routes/Settings.svelte";
import ChatSummary from "./routes/ChatSummary.svelte";
import Security from "./routes/Security.svelte";
+ import LiteLLM from "./routes/LiteLLM.svelte";
import Login from "./routes/Login.svelte";
import { onMount } from "svelte";
import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser, getPreferences, savePreferences } from "./lib/api.js";
@@ -153,6 +154,8 @@
Gateway health and connectivity status
+Failed to fetch LiteLLM health
+{requestError}
+OK
+{String(Boolean(health.ok))}
+Latency
+{health.latency_ms === null || health.latency_ms === undefined ? "—" : `${health.latency_ms} ms`}
+Endpoint
+{valueOrDash(health.endpoint)}
+HTTP Status
+{valueOrDash(health.http_status)}
+Error
+{valueOrDash(health.error)}
+No health data available.
+ {/if} +