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 @@ {: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} +
+
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..5e3e332 --- /dev/null +++ b/litellm/docker-compose.yml @@ -0,0 +1,33 @@ +services: + litellm: + image: ghcr.io/berriai/litellm:main-latest + container_name: litellm + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" + env_file: + - .env + environment: + - LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:-} + ports: + - "127.0.0.1:${LITELLM_PORT:-4005}:4005" + volumes: + - ./config:/config:ro + networks: + - nas-dashboard_internal + command: + - "--config" + - "/config/litellm.yaml" + - "--host" + - "0.0.0.0" + - "--port" + - "4005" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +networks: + nas-dashboard_internal: + external: true