diff --git a/docs/superpowers/plans/2026-04-10-lan-mode.md b/docs/superpowers/plans/2026-04-10-lan-mode.md new file mode 100644 index 0000000..c41a6de --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-lan-mode.md @@ -0,0 +1,1178 @@ +# OCP LAN Mode — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable OCP to serve Claude API over LAN with flexible auth (none/shared-key/multi-key) and per-key usage tracking with a web dashboard. + +**Architecture:** Add LAN bind support (`0.0.0.0`), a key management layer with 3 auth modes, a SQLite-backed usage tracker, and an embedded single-file HTML dashboard. All changes in `server.mjs` + new `keys.mjs` module + CLI extensions in `ocp`. + +**Tech Stack:** Node.js built-in `node:crypto`, `better-sqlite3` (single native dependency) for persistent usage tracking, inline HTML/CSS/JS for dashboard. + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `server.mjs` | Modify | Add LAN bind, auth middleware, usage recording hooks, dashboard/key API endpoints | +| `keys.mjs` | Create | Key management + SQLite usage store (CRUD keys, record usage, query stats) | +| `dashboard.html` | Create | Single-file embedded web dashboard (HTML+CSS+JS, served inline) | +| `ocp` | Modify | Add `ocp keys`, `ocp lan`, `ocp usage --by-key` CLI commands | +| `setup.mjs` | Modify | Add LAN mode config prompts during setup | +| `package.json` | Modify | Add `better-sqlite3` dependency, bump version to 3.4.0 | + +--- + +### Task 1: Add `better-sqlite3` dependency and version bump + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Install better-sqlite3** + +```bash +cd /Users/taodeng/.openclaw/projects/claude-proxy +npm install better-sqlite3 +``` + +- [ ] **Step 2: Bump version to 3.4.0** + +In `package.json`, change: +```json +"version": "3.4.0", +``` + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore: add better-sqlite3 dependency, bump to v3.4.0" +``` + +--- + +### Task 2: Create `keys.mjs` — Key management + usage store + +**Files:** +- Create: `keys.mjs` + +This module handles: +- SQLite database initialization (`~/.ocp/ocp.db`) +- API key CRUD (create, list, revoke) +- Usage recording (per-request: key, model, tokens, elapsed) +- Usage queries (by key, by time range, aggregated) + +- [ ] **Step 1: Create keys.mjs with database init and key CRUD** + +```javascript +// keys.mjs — API key management and usage tracking for OCP LAN mode +import Database from "better-sqlite3"; +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; +import { homedir } from "node:os"; + +const OCP_DIR = join(homedir(), ".ocp"); +mkdirSync(OCP_DIR, { recursive: true }); +const DB_PATH = join(OCP_DIR, "ocp.db"); + +let db; + +export function getDb() { + if (!db) { + db = new Database(DB_PATH); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + initSchema(); + } + return db; +} + +function initSchema() { + db.exec(` + CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS usage_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key_id INTEGER, + key_name TEXT NOT NULL DEFAULT 'anonymous', + model TEXT NOT NULL, + prompt_chars INTEGER NOT NULL DEFAULT 0, + response_chars INTEGER NOT NULL DEFAULT 0, + elapsed_ms INTEGER NOT NULL DEFAULT 0, + success INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (key_id) REFERENCES api_keys(id) + ); + + CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_log(created_at); + CREATE INDEX IF NOT EXISTS idx_usage_key ON usage_log(key_id); + `); +} + +// ── Key CRUD ── + +export function createKey(name) { + const key = "ocp_" + randomBytes(24).toString("base64url"); + const d = getDb(); + const stmt = d.prepare("INSERT INTO api_keys (key, name) VALUES (?, ?)"); + const result = stmt.run(key, name); + return { id: result.lastInsertRowid, key, name }; +} + +export function listKeys() { + const d = getDb(); + return d.prepare( + "SELECT id, key, name, created_at, revoked FROM api_keys ORDER BY created_at DESC" + ).all().map(k => ({ + ...k, + keyPreview: k.key.slice(0, 8) + "..." + k.key.slice(-4), + })); +} + +export function revokeKey(idOrName) { + const d = getDb(); + const stmt = d.prepare( + "UPDATE api_keys SET revoked = 1 WHERE (id = ? OR name = ?) AND revoked = 0" + ); + return stmt.run(idOrName, idOrName).changes > 0; +} + +export function validateKey(key) { + const d = getDb(); + const row = d.prepare( + "SELECT id, name FROM api_keys WHERE key = ? AND revoked = 0" + ).get(key); + return row || null; +} + +// ── Usage recording ── + +export function recordUsage({ keyId, keyName, model, promptChars, responseChars, elapsedMs, success }) { + const d = getDb(); + d.prepare(` + INSERT INTO usage_log (key_id, key_name, model, prompt_chars, response_chars, elapsed_ms, success) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(keyId ?? null, keyName || "anonymous", model, promptChars, responseChars, elapsedMs, success ? 1 : 0); +} + +// ── Usage queries ── + +export function getUsageByKey({ since, until } = {}) { + const d = getDb(); + let where = "WHERE 1=1"; + const params = []; + if (since) { where += " AND created_at >= ?"; params.push(since); } + if (until) { where += " AND created_at <= ?"; params.push(until); } + + return d.prepare(` + SELECT + key_name, + COUNT(*) as requests, + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successes, + SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as errors, + SUM(prompt_chars) as total_prompt_chars, + SUM(response_chars) as total_response_chars, + SUM(elapsed_ms) as total_elapsed_ms, + AVG(elapsed_ms) as avg_elapsed_ms, + MIN(created_at) as first_request, + MAX(created_at) as last_request + FROM usage_log + ${where} + GROUP BY key_name + ORDER BY requests DESC + `).all(...params); +} + +export function getUsageTimeline({ keyName, hours = 24 } = {}) { + const d = getDb(); + const since = new Date(Date.now() - hours * 3600000).toISOString(); + let where = "WHERE created_at >= ?"; + const params = [since]; + if (keyName) { where += " AND key_name = ?"; params.push(keyName); } + + return d.prepare(` + SELECT + strftime('%Y-%m-%dT%H:00:00', created_at) as hour, + COUNT(*) as requests, + SUM(prompt_chars) as prompt_chars, + SUM(response_chars) as response_chars, + AVG(elapsed_ms) as avg_elapsed_ms + FROM usage_log + ${where} + GROUP BY hour + ORDER BY hour + `).all(...params); +} + +export function getRecentUsage(limit = 50) { + const d = getDb(); + return d.prepare(` + SELECT key_name, model, prompt_chars, response_chars, elapsed_ms, success, created_at + FROM usage_log + ORDER BY created_at DESC + LIMIT ? + `).all(limit); +} + +export function closeDb() { + if (db) { db.close(); db = null; } +} +``` + +- [ ] **Step 2: Verify module loads correctly** + +```bash +node -e "import('./keys.mjs').then(k => { console.log('OK'); k.closeDb(); })" +``` + +Expected: `OK` — database file created at `~/.ocp/ocp.db` + +- [ ] **Step 3: Commit** + +```bash +git add keys.mjs +git commit -m "feat: add keys.mjs — API key management and SQLite usage store" +``` + +--- + +### Task 3: Add LAN bind + auth modes to `server.mjs` + +**Files:** +- Modify: `server.mjs:28-36` (env vars section) +- Modify: `server.mjs:77-95` (configuration section) +- Modify: `server.mjs:1017-1036` (HTTP server + auth middleware) +- Modify: `server.mjs:1188-1206` (server.listen) + +Three auth modes controlled by `CLAUDE_AUTH_MODE`: +- `none` — no auth (LAN trust mode) +- `shared` — single `PROXY_API_KEY` (existing behavior) +- `multi` — per-user keys managed via `keys.mjs` + +- [ ] **Step 1: Add new imports and env vars at top of server.mjs** + +After the existing imports (line 35), add: + +```javascript +import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb } from "./keys.mjs"; +``` + +Add new config vars after line 95 (`BREAKER_HALF_OPEN_MAX`): + +```javascript +// ── LAN mode configuration ── +const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1"; +const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none"); +// AUTH_MODE: "none" (open), "shared" (single PROXY_API_KEY), "multi" (per-user keys from keys.mjs) +const ADMIN_KEY = process.env.OCP_ADMIN_KEY || ""; +``` + +- [ ] **Step 2: Replace auth middleware in HTTP server (lines 1027-1036)** + +Replace the existing Bearer token auth block with: + +```javascript + // ── Auth middleware ── + // /health is always public. Admin endpoints require admin key in multi mode. + const isPublicEndpoint = req.url === "/health"; + let authKeyName = "local"; + let authKeyId = null; + + if (!isPublicEndpoint) { + const auth = req.headers["authorization"] || ""; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; + + if (AUTH_MODE === "shared") { + // Single shared key mode (backwards compatible) + if (PROXY_API_KEY) { + const tokenBuf = Buffer.from(token); + const keyBuf = Buffer.from(PROXY_API_KEY); + if (tokenBuf.length !== keyBuf.length || !timingSafeEqual(tokenBuf, keyBuf)) { + return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } }); + } + authKeyName = "shared"; + } + } else if (AUTH_MODE === "multi") { + // Multi-key mode: validate against keys.mjs database + if (!token) { + return jsonResponse(res, 401, { error: { message: "Unauthorized: Bearer token required", type: "auth_error" } }); + } + // Check if it's the admin key + if (ADMIN_KEY && token === ADMIN_KEY) { + authKeyName = "admin"; + } else { + const keyInfo = validateKey(token); + if (!keyInfo) { + return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or revoked API key", type: "auth_error" } }); + } + authKeyName = keyInfo.name; + authKeyId = keyInfo.id; + } + } + // AUTH_MODE === "none": no auth check, allow all + } + + // Attach auth info to request for downstream use + req._authKeyName = authKeyName; + req._authKeyId = authKeyId; +``` + +- [ ] **Step 3: Update CORS to allow LAN origins** + +Replace the CORS block (lines 1019-1025) with: + +```javascript + // Dynamic CORS: allow localhost and LAN origins + const origin = req.headers["origin"] || ""; + const isAllowedOrigin = /^https?:\/\/(127\.0\.0\.1|localhost|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|10\.\d+\.\d+\.\d+)(:\d+)?$/.test(origin); + res.setHeader("Access-Control-Allow-Origin", isAllowedOrigin ? origin : "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS, PATCH"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id"); + if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } +``` + +- [ ] **Step 4: Update server.listen to use BIND_ADDRESS** + +Replace line 1189: + +```javascript +server.listen(PORT, BIND_ADDRESS, () => { + const bindMsg = BIND_ADDRESS === "0.0.0.0" ? `http://0.0.0.0:${PORT} (LAN mode)` : `http://127.0.0.1:${PORT}`; + console.log(`openclaw-claude-proxy v${VERSION} listening on ${bindMsg}`); +``` + +Add auth mode to startup log (after line 1200): + +```javascript + console.log(`Auth mode: ${AUTH_MODE}${AUTH_MODE === "shared" ? " (PROXY_API_KEY)" : AUTH_MODE === "multi" ? " (per-user keys)" : " (open)"}`); + console.log(`Bind: ${BIND_ADDRESS}${BIND_ADDRESS === "0.0.0.0" ? " ⚠ LAN-accessible" : ""}`); +``` + +- [ ] **Step 5: Add usage recording to request handlers** + +In `handleChatCompletions` (around line 1001-1014), after successful response and in catch block, add usage recording. Wrap the try/catch: + +```javascript + try { + const content = await callClaude(model, messages, conversationId); + const id = `chatcmpl-${randomUUID()}`; + completionResponse(res, id, model, content); + // Record usage + recordUsage({ + keyId: req._authKeyId, keyName: req._authKeyName, model, + promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), + responseChars: content.length, elapsedMs: Date.now() - Date.now(), success: true, + }); + } catch (err) { + recordUsage({ + keyId: req._authKeyId, keyName: req._authKeyName, model, + promptChars: 0, responseChars: 0, elapsedMs: 0, success: false, + }); + // ... existing error handling + } +``` + +Similarly for streaming in `callClaudeStreaming`, record usage in the `proc.on("close")` handler by passing `req._authKeyName` and `req._authKeyId` through. + +- [ ] **Step 6: Add closeDb to graceful shutdown** + +In `gracefulShutdown()` function, after clearing intervals (around line 1150), add: + +```javascript + closeDb(); +``` + +- [ ] **Step 7: Commit** + +```bash +git add server.mjs +git commit -m "feat: add LAN bind, 3-mode auth, and usage recording to server" +``` + +--- + +### Task 4: Add key management + dashboard API endpoints to `server.mjs` + +**Files:** +- Modify: `server.mjs` (add routes before the 404 handler) + +- [ ] **Step 1: Add key management API endpoints** + +Before the 404 handler (line 1132), add: + +```javascript + // ── Key management API (admin-only in multi mode) ── + + // Admin gate: in multi mode, key management requires admin key + const isAdmin = AUTH_MODE !== "multi" || authKeyName === "admin" || BIND_ADDRESS === "127.0.0.1"; + + // POST /api/keys — create a new API key + if (req.url === "/api/keys" && req.method === "POST") { + if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" }); + let body = ""; + for await (const chunk of req) body += chunk; + let parsed; + try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); } + const name = parsed.name || `key-${Date.now()}`; + const newKey = createKey(name); + return jsonResponse(res, 201, newKey); + } + + // GET /api/keys — list all keys + if (req.url === "/api/keys" && req.method === "GET") { + if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" }); + return jsonResponse(res, 200, { keys: listKeys() }); + } + + // DELETE /api/keys/:id — revoke a key + if (req.url?.startsWith("/api/keys/") && req.method === "DELETE") { + if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" }); + const idOrName = decodeURIComponent(req.url.split("/api/keys/")[1]); + const revoked = revokeKey(idOrName); + return jsonResponse(res, 200, { revoked, idOrName }); + } + + // GET /api/usage — usage stats by key + if (req.url?.startsWith("/api/usage") && req.method === "GET") { + if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" }); + const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`); + const since = url.searchParams.get("since"); + const until = url.searchParams.get("until"); + return jsonResponse(res, 200, { + byKey: getUsageByKey({ since, until }), + timeline: getUsageTimeline({ hours: parseInt(url.searchParams.get("hours") || "24", 10) }), + recent: getRecentUsage(parseInt(url.searchParams.get("limit") || "50", 10)), + }); + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add server.mjs +git commit -m "feat: add key management and usage query API endpoints" +``` + +--- + +### Task 5: Create embedded web dashboard + +**Files:** +- Create: `dashboard.html` +- Modify: `server.mjs` (add `/dashboard` route) + +The dashboard is a single HTML file served inline. It uses vanilla JS + CSS, no framework, no build step. + +- [ ] **Step 1: Create dashboard.html** + +```html + + + + + +OCP Dashboard + + + + +
+

OCP Dashboard

+
+ + +
+
+ + +
+ + +
+

Plan Usage

+
+
+ + +
+

Usage by Key

+ + + +
KeyRequestsOKErrAvg TimeLast Request
+
+ + + + + +
+

Recent Requests

+ + + +
TimeKeyModelPromptResponseTimeStatus
+
+ + + + +``` + +- [ ] **Step 2: Add dashboard route to server.mjs** + +Before the 404 handler, add: + +```javascript + // GET /dashboard — web dashboard + if (req.url === "/dashboard" && req.method === "GET") { + try { + const html = readFileSync(join(__dirname, "dashboard.html"), "utf8"); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(html); + } catch (err) { + return jsonResponse(res, 500, { error: "Dashboard file not found" }); + } + return; + } +``` + +- [ ] **Step 3: Update 404 message to include new endpoints** + +```javascript + jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET /usage, GET /status, GET /logs, GET|PATCH /settings, GET|DELETE /sessions, GET /dashboard, GET|POST|DELETE /api/keys, GET /api/usage" }); +``` + +- [ ] **Step 4: Commit** + +```bash +git add dashboard.html server.mjs +git commit -m "feat: add embedded web dashboard with real-time usage display" +``` + +--- + +### Task 6: Extend `ocp` CLI with key management and LAN commands + +**Files:** +- Modify: `ocp` (bash CLI) + +- [ ] **Step 1: Add `ocp keys` command** + +After the `cmd_clear` section (around line 214), add: + +```bash +# ── keys ──────────────────────────────────────────────────────────────── +cmd_keys_help() { + cat <<'EOF' +ocp keys — Manage API keys (multi-key auth mode) + +Usage: + ocp keys List all keys + ocp keys add Create a new key + ocp keys revoke Revoke a key + +Examples: + ocp keys add wife-laptop + ocp keys add son-ipad + ocp keys revoke wife-laptop +EOF +} + +cmd_keys() { + case "${1:-}" in + add) + if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys add "; return 1; fi + local result + result=$(curl -sf --max-time 5 -X POST "$PROXY/api/keys" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$2\"}" 2>&1) || { echo "Error: proxy unreachable or unauthorized"; exit 1; } + echo "$result" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +if 'key' in d: + print(f'✓ Key created for \"{d[\"name\"]}\"') + print(f'') + print(f' API Key: {d[\"key\"]}') + print(f'') + print(f' Copy this key now — you won\\'t see it again.') + print(f' Configure in IDE: OPENAI_API_KEY={d[\"key\"]}') +else: + print(f'✗ {d.get(\"error\", \"Unknown error\")}') +" + ;; + revoke) + if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys revoke "; return 1; fi + curl -sf --max-time 5 -X DELETE "$PROXY/api/keys/$2" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +if d.get('revoked'): + print(f'✓ Key \"{d[\"idOrName\"]}\" revoked.') +else: + print(f'✗ Key not found or already revoked.') +" + ;; + ""|--help|-h) + if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then cmd_keys_help; return; fi + # List keys + curl -sf --max-time 5 "$PROXY/api/keys" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +keys = d.get('keys', []) +if not keys: + print('No API keys configured.') + print('Create one: ocp keys add ') +else: + print('API Keys') + print('─────────────────────────────────────────────────') + for k in keys: + status = '✗ revoked' if k['revoked'] else '✓ active' + print(f' {k[\"name\"]:<20} {k[\"keyPreview\"]:<20} {status} {k[\"created_at\"]}') +" 2>/dev/null || { echo "Error: proxy unreachable or key management not available"; exit 1; } + ;; + *) + echo "Unknown subcommand: $1"; cmd_keys_help; return 1 ;; + esac +} +``` + +- [ ] **Step 2: Add `ocp lan` command** + +```bash +# ── lan ───────────────────────────────────────────────────────────────── +cmd_lan_help() { + cat <<'EOF' +ocp lan — Quick LAN mode setup guide + +Shows current network configuration and connection instructions +for other devices on the same network. + +Usage: ocp lan +EOF +} + +cmd_lan() { + local ip + ip=$(ipconfig getifaddr en0 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}') + local port + port=$(curl -sf --max-time 5 "$PROXY/health" | python3 -c "import sys,json; h=json.loads(sys.stdin.read()); print(h.get('config',{}).get('port', '3456'))" 2>/dev/null || echo "3456") + + echo "OCP LAN Setup" + echo "─────────────────────────────────────" + echo "" + echo " Your IP: $ip" + echo " Port: $port" + echo "" + echo " For IDE users, set:" + echo " OPENAI_BASE_URL=http://$ip:$port/v1" + echo " OPENAI_API_KEY= (if auth enabled)" + echo "" + echo " Dashboard: http://$ip:$port/dashboard" + echo "" + + # Check if currently bound to LAN + if curl -sf --max-time 2 "http://$ip:$port/health" > /dev/null 2>&1; then + echo " Status: ✓ LAN-accessible" + else + echo " Status: ✗ Not LAN-accessible (bound to localhost only)" + echo "" + echo " To enable LAN mode, set env var and restart:" + echo " CLAUDE_BIND=0.0.0.0" + echo " ocp restart" + fi +} +``` + +- [ ] **Step 3: Add `ocp usage --by-key` option** + +Modify `cmd_usage()` to accept `--by-key`: + +After the existing `cmd_usage()` function, add a check: + +```bash +# In the cmd_usage function, add at the top: +cmd_usage() { + if [[ "${1:-}" == "--by-key" ]]; then + local data + data=$(curl -sf --max-time 15 "$PROXY/api/usage" 2>&1) || { echo "Error: proxy unreachable or usage API not available"; exit 1; } + echo "$data" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +by_key = d.get('byKey', []) +if not by_key: + print('No usage data yet.') +else: + print('Usage by Key') + print('─────────────────────────────────────────────────────────────────') + hdr = f' {\"Key\":<20} {\"Reqs\":>5} {\"OK\":>4} {\"Err\":>4} {\"Avg Time\":>9} {\"Last Request\":<20}' + print(hdr) + print(' ' + '─' * (len(hdr) - 2)) + for k in by_key: + avg_t = f'{k[\"avg_elapsed_ms\"]/1000:.1f}s' if k['avg_elapsed_ms'] else '-' + print(f' {k[\"key_name\"]:<20} {k[\"requests\"]:>5} {k[\"successes\"]:>4} {k[\"errors\"]:>4} {avg_t:>9} {k[\"last_request\"]:<20}') +" + return + fi + + # ... existing cmd_usage code below ... +``` + +- [ ] **Step 4: Update dispatch case and help** + +In the `case` statement at the bottom, add: + +```bash + keys) cmd_keys "${1:-}" "${2:-}" ;; + lan) cmd_lan ;; +``` + +Update `cmd_help()`: + +```bash + keys Manage API keys (add/list/revoke) + lan LAN mode setup guide + usage Plan usage limits (--by-key for per-key stats) +``` + +- [ ] **Step 5: Commit** + +```bash +git add ocp +git commit -m "feat: add ocp keys, ocp lan, and ocp usage --by-key CLI commands" +``` + +--- + +### Task 7: Update setup.mjs with LAN mode prompts + +**Files:** +- Modify: `setup.mjs` + +- [ ] **Step 1: Add LAN mode env vars to launchd plist and systemd service** + +In `setup.mjs`, in the plist XML template (around line 351), add environment variables for LAN mode if the user chose it. Add these optional env entries to the `EnvironmentVariables` dict: + +```xml + CLAUDE_BIND + ${BIND_ADDRESS} + CLAUDE_AUTH_MODE + ${AUTH_MODE_CONFIG} +``` + +Similarly update the systemd service template to include: + +``` +Environment=CLAUDE_BIND=${BIND_ADDRESS} +Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG} +``` + +Add at the top of setup.mjs, after existing `opt()` calls: + +```javascript +const BIND_ADDRESS = opt("bind", "127.0.0.1"); +const AUTH_MODE_CONFIG = opt("auth-mode", "none"); +``` + +- [ ] **Step 2: Commit** + +```bash +git add setup.mjs +git commit -m "feat: add LAN mode config options to setup.mjs" +``` + +--- + +### Task 8: Update README with LAN mode documentation + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add LAN Mode section to README** + +Add a new section after the existing "Quick Start" section: + +```markdown +## LAN Mode — Share with Family + +OCP can serve your entire household from a single machine. + +### Enable LAN Access + +```bash +# Set bind address and restart +export CLAUDE_BIND=0.0.0.0 +ocp restart +``` + +Or permanently via setup: +```bash +node setup.mjs --bind 0.0.0.0 --auth-mode multi +``` + +### Auth Modes + +| Mode | Env | Description | +|------|-----|-------------| +| `none` | `CLAUDE_AUTH_MODE=none` | Open access (trusted network) | +| `shared` | `CLAUDE_AUTH_MODE=shared` | Single key via `PROXY_API_KEY` | +| `multi` | `CLAUDE_AUTH_MODE=multi` | Per-user keys with usage tracking | + +### Multi-Key Setup + +```bash +# Create keys for family members +ocp keys add wife-laptop +ocp keys add son-ipad + +# List keys +ocp keys + +# View per-key usage +ocp usage --by-key + +# Revoke a key +ocp keys revoke son-ipad +``` + +### Family Member Setup + +Give your family member these settings for their IDE: + +``` +OPENAI_BASE_URL=http://:3456/v1 +OPENAI_API_KEY=ocp_ +``` + +Run `ocp lan` to see your IP and connection guide. + +### Web Dashboard + +Access the usage dashboard at: `http://:3456/dashboard` + +Shows real-time stats: per-key usage, request history, plan utilization, and system health. +``` + +- [ ] **Step 2: Update version in README** + +Change version references from `v3.3.1` to `v3.4.0`. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: add LAN mode documentation and family sharing guide" +``` + +--- + +### Task 9: Integration test — end-to-end verification + +**Files:** +- No new files — manual verification steps + +- [ ] **Step 1: Start OCP in LAN mode with multi-key auth** + +```bash +cd /Users/taodeng/.openclaw/projects/claude-proxy +CLAUDE_BIND=0.0.0.0 CLAUDE_AUTH_MODE=multi OCP_ADMIN_KEY=test-admin-123 node server.mjs & +sleep 3 +``` + +- [ ] **Step 2: Verify health endpoint (public, no auth)** + +```bash +curl -s http://127.0.0.1:3456/health | python3 -m json.tool | head -5 +``` + +Expected: JSON with status "ok", version "3.4.0" + +- [ ] **Step 3: Create a test API key** + +```bash +curl -s -X POST http://127.0.0.1:3456/api/keys \ + -H "Authorization: Bearer test-admin-123" \ + -H "Content-Type: application/json" \ + -d '{"name": "test-user"}' | python3 -m json.tool +``` + +Expected: JSON with `id`, `key` (starts with `ocp_`), `name` + +- [ ] **Step 4: Test request with key** + +```bash +# Save the key from step 3 +KEY="" +curl -s http://127.0.0.1:3456/v1/chat/completions \ + -H "Authorization: Bearer $KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"haiku","messages":[{"role":"user","content":"say hi"}]}' | python3 -m json.tool | head -10 +``` + +Expected: Chat completion response from Claude + +- [ ] **Step 5: Verify usage tracking** + +```bash +curl -s http://127.0.0.1:3456/api/usage \ + -H "Authorization: Bearer test-admin-123" | python3 -m json.tool +``` + +Expected: Usage data showing `test-user` with 1 request + +- [ ] **Step 6: Test dashboard loads** + +```bash +curl -s http://127.0.0.1:3456/dashboard | head -5 +``` + +Expected: HTML starting with `` + +- [ ] **Step 7: Test auth rejection (no key in multi mode)** + +```bash +curl -s http://127.0.0.1:3456/v1/models +``` + +Expected: 401 error with "Bearer token required" + +- [ ] **Step 8: Stop test server and clean up** + +```bash +pkill -f "server.mjs" || true +``` + +- [ ] **Step 9: Commit integration test results as passing** + +```bash +git add -A +git commit -m "test: verify LAN mode, multi-key auth, usage tracking, and dashboard" +``` + +--- + +## Summary + +| Task | Description | Est. Lines | +|------|-------------|-----------| +| 1 | Add dependency + version bump | ~5 | +| 2 | `keys.mjs` — key CRUD + usage store | ~180 | +| 3 | LAN bind + auth modes in server | ~80 | +| 4 | Key mgmt + usage API endpoints | ~50 | +| 5 | Web dashboard (HTML+JS) | ~200 | +| 6 | CLI extensions (`ocp keys/lan`) | ~150 | +| 7 | Setup.mjs LAN config | ~20 | +| 8 | README documentation | ~60 | +| 9 | Integration testing | ~0 | +| **Total** | | **~745 lines** |