From ed4090a91000c9540482eba05e044286f183c836 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Mon, 2 Mar 2026 01:08:12 +0800 Subject: [PATCH 1/6] feat: add LiteLLM gateway scaffold and dashboard health probe Introduce a localhost-only LiteLLM service template and expose a protected dashboard health endpoint for operational visibility, while adding cc-connect mobile bridge templates with conservative defaults. Co-Authored-By: Claude Opus 4.6 (1M context) --- cc-connect/README.md | 30 +++++++++++++++++++++ cc-connect/config.example.toml | 27 +++++++++++++++++++ cc-connect/docker-compose.yml | 22 +++++++++++++++ dashboard/backend/config.py | 3 +++ dashboard/backend/main.py | 4 ++- dashboard/backend/routers/litellm.py | 40 ++++++++++++++++++++++++++++ litellm/.env.example | 10 +++++++ litellm/config/litellm.yaml | 30 +++++++++++++++++++++ litellm/docker-compose.yml | 28 +++++++++++++++++++ 9 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 cc-connect/README.md create mode 100644 cc-connect/config.example.toml create mode 100644 cc-connect/docker-compose.yml create mode 100644 dashboard/backend/routers/litellm.py create mode 100644 litellm/.env.example create mode 100644 litellm/config/litellm.yaml create mode 100644 litellm/docker-compose.yml 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..bf79065 --- /dev/null +++ b/cc-connect/docker-compose.yml @@ -0,0 +1,22 @@ +services: + cc-connect: + image: chenhg5/cc-connect:latest + container_name: cc-connect + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" + env_file: + - path: .env + required: false + 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..8ce7d1f 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -44,6 +44,9 @@ 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") + # 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..8ec5e72 --- /dev/null +++ b/dashboard/backend/routers/litellm.py @@ -0,0 +1,40 @@ +import time +import httpx +from fastapi import APIRouter +from config import LITELLM_URL + +router = APIRouter() + + +@router.get("/health") +async def litellm_health(): + timeout = httpx.Timeout(2.0) + checks = ("/health", "/v1/models") + + 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) + 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, + } + 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/litellm/.env.example b/litellm/.env.example new file mode 100644 index 0000000..e6b691c --- /dev/null +++ b/litellm/.env.example @@ -0,0 +1,10 @@ +# Copy to .env on NAS and fill real values. +ANTHROPIC_API_KEY= +OPENAI_API_KEY= +GEMINI_API_KEY= + +# Optional gateway auth key for internal clients. +LITELLM_MASTER_KEY= + +# Host bind port for docker-compose mapping. +LITELLM_PORT=4005 diff --git a/litellm/config/litellm.yaml b/litellm/config/litellm.yaml new file mode 100644 index 0000000..5fa95c4 --- /dev/null +++ b/litellm/config/litellm.yaml @@ -0,0 +1,30 @@ +model_list: + - model_name: claude-arch + litellm_params: + model: anthropic/claude-sonnet-4-6 + api_key: os.environ/ANTHROPIC_API_KEY + timeout: 30 + max_retries: 1 + + - model_name: codex-exec + litellm_params: + model: openai/gpt-4.1 + api_key: os.environ/OPENAI_API_KEY + timeout: 30 + max_retries: 1 + + - model_name: gemini-large + litellm_params: + model: gemini/gemini-1.5-pro + api_key: os.environ/GEMINI_API_KEY + timeout: 30 + max_retries: 1 + +general_settings: + # Optional - leave empty in .env to disable gateway auth. + master_key: os.environ/LITELLM_MASTER_KEY + +router_settings: + timeout: 30 + num_retries: 1 + retry_after: 1 diff --git a/litellm/docker-compose.yml b/litellm/docker-compose.yml new file mode 100644 index 0000000..693c366 --- /dev/null +++ b/litellm/docker-compose.yml @@ -0,0 +1,28 @@ +services: + litellm: + image: ghcr.io/berriai/litellm:main-latest + container_name: litellm + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" + env_file: + - path: .env + required: false + environment: + - LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:-} + ports: + - "127.0.0.1:${LITELLM_PORT:-4005}:4005" + volumes: + - ./config:/config:ro + command: + - "--config" + - "/config/litellm.yaml" + - "--host" + - "0.0.0.0" + - "--port" + - "4005" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" -- 2.52.0 From 76c10a6c07657280b95a019167c4830d0a17f3a3 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Mon, 2 Mar 2026 01:11:58 +0800 Subject: [PATCH 2/6] fix: use compose-compatible env_file format Switch env_file declarations back to string list syntax so Synology's docker compose parser accepts the LiteLLM and cc-connect compose files. Co-Authored-By: Claude Opus 4.6 (1M context) --- cc-connect/docker-compose.yml | 3 +-- litellm/docker-compose.yml | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/cc-connect/docker-compose.yml b/cc-connect/docker-compose.yml index bf79065..7ef97b6 100644 --- a/cc-connect/docker-compose.yml +++ b/cc-connect/docker-compose.yml @@ -6,8 +6,7 @@ services: labels: - "com.centurylinklabs.watchtower.enable=true" env_file: - - path: .env - required: false + - .env volumes: - ./config.toml:/app/config.toml:ro command: diff --git a/litellm/docker-compose.yml b/litellm/docker-compose.yml index 693c366..d9c7400 100644 --- a/litellm/docker-compose.yml +++ b/litellm/docker-compose.yml @@ -6,8 +6,7 @@ services: labels: - "com.centurylinklabs.watchtower.enable=true" env_file: - - path: .env - required: false + - .env environment: - LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:-} ports: -- 2.52.0 From 6cabbd6c59c0a6d4f72aff0476d6d87861af5510 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Mon, 2 Mar 2026 03:23:01 +0800 Subject: [PATCH 3/6] fix: set dev dashboard LiteLLM URL to host gateway Point dashboard-dev LiteLLM health checks at host.docker.internal so the container can reach the NAS LiteLLM service bound on localhost. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/docker-compose.dev.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/dashboard/docker-compose.dev.yml b/dashboard/docker-compose.dev.yml index 4bac144..0c47268 100644 --- a/dashboard/docker-compose.dev.yml +++ b/dashboard/docker-compose.dev.yml @@ -21,6 +21,7 @@ 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://host.docker.internal:4005 volumes: - /volume1:/volume1 - /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro -- 2.52.0 From 9bae5a62a5a96d95d4277e70791a0a7a0fc6b720 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Mon, 2 Mar 2026 03:31:44 +0800 Subject: [PATCH 4/6] fix: attach litellm to shared dashboard network Connect LiteLLM to nas-dashboard_internal and point dashboard-dev to the litellm container hostname so health probes work without host-loopback routing. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/docker-compose.dev.yml | 2 +- litellm/docker-compose.yml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/dashboard/docker-compose.dev.yml b/dashboard/docker-compose.dev.yml index 0c47268..493b70c 100644 --- a/dashboard/docker-compose.dev.yml +++ b/dashboard/docker-compose.dev.yml @@ -21,7 +21,7 @@ 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://host.docker.internal:4005 + - LITELLM_URL=http://litellm:4005 volumes: - /volume1:/volume1 - /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro diff --git a/litellm/docker-compose.yml b/litellm/docker-compose.yml index d9c7400..5e3e332 100644 --- a/litellm/docker-compose.yml +++ b/litellm/docker-compose.yml @@ -13,6 +13,8 @@ services: - "127.0.0.1:${LITELLM_PORT:-4005}:4005" volumes: - ./config:/config:ro + networks: + - nas-dashboard_internal command: - "--config" - "/config/litellm.yaml" @@ -25,3 +27,7 @@ services: options: max-size: "10m" max-file: "3" + +networks: + nas-dashboard_internal: + external: true -- 2.52.0 From 56743d1cf4b6d79bf5b40844a9660153623b93f5 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Mon, 2 Mar 2026 03:38:25 +0800 Subject: [PATCH 5/6] fix: make LiteLLM health probe auth-aware Allow the dashboard health check to include optional LiteLLM auth headers and treat unauthenticated 401 responses as an auth-required healthy state. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/backend/config.py | 1 + dashboard/backend/routers/litellm.py | 28 +++++++++++++++++++++++++--- dashboard/docker-compose.dev.yml | 1 + dashboard/docker-compose.yml | 1 + 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py index 8ce7d1f..46814f5 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -46,6 +46,7 @@ CHAT_SUMMARY_DB = os.environ.get("CHAT_SUMMARY_DB", "/app/data/chat-summarizer/c # 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/routers/litellm.py b/dashboard/backend/routers/litellm.py index 8ec5e72..7bc9514 100644 --- a/dashboard/backend/routers/litellm.py +++ b/dashboard/backend/routers/litellm.py @@ -1,7 +1,7 @@ import time import httpx from fastapi import APIRouter -from config import LITELLM_URL +from config import LITELLM_URL, LITELLM_HEALTH_API_KEY router = APIRouter() @@ -11,14 +11,23 @@ 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) + response = await client.get(url, headers=headers) latency_ms = round((time.perf_counter() - started) * 1000, 1) + if response.is_success: return { "ok": True, @@ -27,7 +36,20 @@ async def litellm_health(): "endpoint": path, "http_status": response.status_code, } - last_error = f"{path} returned {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) diff --git a/dashboard/docker-compose.dev.yml b/dashboard/docker-compose.dev.yml index 493b70c..025d1e3 100644 --- a/dashboard/docker-compose.dev.yml +++ b/dashboard/docker-compose.dev.yml @@ -22,6 +22,7 @@ services: - 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 -- 2.52.0 From 910ed3f7e619f217cb3ee90fe4eff9a4a52e8b23 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Mon, 2 Mar 2026 03:50:56 +0800 Subject: [PATCH 6/6] feat: add LiteLLM health page to dashboard Expose LiteLLM operational status in the NAS Dashboard UI so auth-required and outage states are visible without calling backend APIs directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/frontend/src/App.svelte | 3 + .../frontend/src/components/Sidebar.svelte | 3 + dashboard/frontend/src/lib/api.js | 4 + dashboard/frontend/src/routes/LiteLLM.svelte | 108 ++++++++++++++++++ 4 files changed, 118 insertions(+) create mode 100644 dashboard/frontend/src/routes/LiteLLM.svelte 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 @@ {:else if page === "security" && hasPageAccess("security")} + {:else if page === "litellm" && hasPageAccess("dashboard")} + {:else} {/if} diff --git a/dashboard/frontend/src/components/Sidebar.svelte b/dashboard/frontend/src/components/Sidebar.svelte index 7242961..bb69cf4 100644 --- a/dashboard/frontend/src/components/Sidebar.svelte +++ b/dashboard/frontend/src/components/Sidebar.svelte @@ -16,6 +16,7 @@ const defaultLinks = [ { id: "dashboard", label: "Overview", icon: "grid" }, + { id: "litellm", label: "LiteLLM", icon: "bolt" }, { id: "docker", label: "Docker", icon: "box" }, { id: "files", label: "Files", icon: "folder" }, { id: "terminal", label: "Terminal", icon: "terminal" }, @@ -228,6 +229,8 @@ {:else if icon === "box"} + {:else if icon === "bolt"} + {:else if icon === "folder"} {:else if icon === "terminal"} diff --git a/dashboard/frontend/src/lib/api.js b/dashboard/frontend/src/lib/api.js index 316e0c2..39242aa 100644 --- a/dashboard/frontend/src/lib/api.js +++ b/dashboard/frontend/src/lib/api.js @@ -139,6 +139,10 @@ export function savePreferences(data) { return request("/auth/preferences", { method: "PUT", json: data }); } +export function getLiteLLMHealth() { + return get("/litellm/health"); +} + export function put(path, data) { return request(path, { method: "PUT", json: data }); } diff --git a/dashboard/frontend/src/routes/LiteLLM.svelte b/dashboard/frontend/src/routes/LiteLLM.svelte new file mode 100644 index 0000000..d60a5ef --- /dev/null +++ b/dashboard/frontend/src/routes/LiteLLM.svelte @@ -0,0 +1,108 @@ + + +
+
+
+

LiteLLM

+

Gateway health and connectivity status

+
+ up + auth_required + down +
+
+ +
+ + {#if requestError} +
+

Failed to fetch LiteLLM health

+

{requestError}

+
+ {/if} + +
+ {#if loading && !health} +
+
+
+ {#each Array(6) as _} +
+ {/each} +
+
+ {:else if health} +
+

Health Status

+ {valueOrDash(health.status)} +
+ +
+
+

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)}

+
+
+ {:else} +

No health data available.

+ {/if} +
+
-- 2.52.0