Files
ocp/docs/superpowers/plans/2026-04-10-lan-mode.md
T
2026-04-10 21:00:22 +10:00

37 KiB

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

cd /Users/taodeng/.openclaw/projects/claude-proxy
npm install better-sqlite3
  • Step 2: Bump version to 3.4.0

In package.json, change:

"version": "3.4.0",
  • Step 3: Commit
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

// 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
node -e "import('./keys.mjs').then(k => { console.log('OK'); k.closeDb(); })"

Expected: OK — database file created at ~/.ocp/ocp.db

  • Step 3: Commit
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:

import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb } from "./keys.mjs";

Add new config vars after line 95 (BREAKER_HALF_OPEN_MAX):

// ── 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:

  // ── 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:

  // 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:

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

  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:

  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:

  closeDb();
  • Step 7: Commit
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:

  // ── 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
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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OCP Dashboard</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 1.5rem; }
  h1 { font-size: 1.5rem; margin-bottom: 1rem; color: #38bdf8; }
  h2 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; color: #94a3b8; }
  .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
  .card { background: #1e293b; border-radius: 8px; padding: 1rem; }
  .card .label { font-size: 0.75rem; color: #64748b; text-transform: uppercase; }
  .card .value { font-size: 1.5rem; font-weight: 700; margin-top: 0.25rem; }
  .card .sub { font-size: 0.8rem; color: #94a3b8; margin-top: 0.25rem; }
  table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
  th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #334155; font-size: 0.85rem; }
  th { color: #64748b; font-weight: 600; }
  .tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; }
  .tag-ok { background: #065f46; color: #6ee7b7; }
  .tag-err { background: #7f1d1d; color: #fca5a5; }
  .btn { background: #2563eb; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
  .btn:hover { background: #1d4ed8; }
  .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
  .btn-danger { background: #dc2626; }
  .btn-danger:hover { background: #b91c1c; }
  input { background: #1e293b; border: 1px solid #334155; color: #e2e8f0; padding: 0.4rem 0.75rem; border-radius: 6px; font-size: 0.85rem; }
  .flex { display: flex; gap: 0.5rem; align-items: center; }
  .mono { font-family: "SF Mono", "Fira Code", monospace; font-size: 0.8rem; }
  .bar-bg { background: #334155; border-radius: 4px; height: 8px; overflow: hidden; }
  .bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s; }
  .bar-blue { background: #3b82f6; }
  .bar-amber { background: #f59e0b; }
  .bar-red { background: #ef4444; }
  #refresh-indicator { font-size: 0.75rem; color: #475569; }
  .section { margin-bottom: 2rem; }
</style>
</head>
<body>

<div class="flex" style="justify-content: space-between; margin-bottom: 1.5rem;">
  <h1>OCP Dashboard</h1>
  <div class="flex">
    <span id="refresh-indicator"></span>
    <button class="btn btn-sm" onclick="refreshAll()">Refresh</button>
  </div>
</div>

<!-- Status cards -->
<div class="grid" id="status-cards"></div>

<!-- Plan usage -->
<div class="section">
  <h2>Plan Usage</h2>
  <div class="grid" id="plan-cards"></div>
</div>

<!-- Usage by Key -->
<div class="section">
  <h2>Usage by Key</h2>
  <table id="key-usage-table">
    <thead><tr><th>Key</th><th>Requests</th><th>OK</th><th>Err</th><th>Avg Time</th><th>Last Request</th></tr></thead>
    <tbody></tbody>
  </table>
</div>

<!-- Key Management (admin only) -->
<div class="section" id="key-mgmt-section" style="display:none">
  <h2>API Keys</h2>
  <div class="flex" style="margin-bottom: 0.75rem;">
    <input type="text" id="new-key-name" placeholder="Key name (e.g. wife-laptop)">
    <button class="btn btn-sm" onclick="addKey()">Create Key</button>
  </div>
  <table id="keys-table">
    <thead><tr><th>Name</th><th>Key</th><th>Created</th><th>Status</th><th></th></tr></thead>
    <tbody></tbody>
  </table>
</div>

<!-- Recent Requests -->
<div class="section">
  <h2>Recent Requests</h2>
  <table id="recent-table">
    <thead><tr><th>Time</th><th>Key</th><th>Model</th><th>Prompt</th><th>Response</th><th>Time</th><th>Status</th></tr></thead>
    <tbody></tbody>
  </table>
</div>

<script>
const BASE = window.location.origin;
const headers = {};
// If a token is stored, use it
const storedToken = localStorage.getItem("ocp_token");
if (storedToken) headers["Authorization"] = `Bearer ${storedToken}`;

async function api(path) {
  const resp = await fetch(BASE + path, { headers });
  if (resp.status === 401 || resp.status === 403) {
    const token = prompt("Enter your OCP admin key:");
    if (token) {
      localStorage.setItem("ocp_token", token);
      headers["Authorization"] = `Bearer ${token}`;
      return api(path);
    }
  }
  return resp.json();
}

async function apiPost(path, body) {
  const resp = await fetch(BASE + path, {
    method: "POST", headers: { ...headers, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  return resp.json();
}

async function apiDelete(path) {
  const resp = await fetch(BASE + path, { method: "DELETE", headers });
  return resp.json();
}

function fmtTime(ms) {
  if (!ms) return "-";
  return ms > 1000 ? (ms/1000).toFixed(1) + "s" : Math.round(ms) + "ms";
}

function fmtChars(n) {
  if (!n) return "-";
  return n > 1000 ? (n/1000).toFixed(0) + "K" : n;
}

function barColor(pct) {
  if (pct >= 80) return "bar-red";
  if (pct >= 50) return "bar-amber";
  return "bar-blue";
}

async function refreshStatus() {
  const data = await api("/status");
  const p = data.proxy || {};
  const r = data.requests || {};

  document.getElementById("status-cards").innerHTML = `
    <div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${p.status || '?'}</span></div><div class="sub">v${p.version || '?'}</div></div>
    <div class="card"><div class="label">Uptime</div><div class="value">${p.uptime || '?'}</div></div>
    <div class="card"><div class="label">Requests</div><div class="value">${r.total || 0}</div><div class="sub">${r.active || 0} active</div></div>
    <div class="card"><div class="label">Errors</div><div class="value">${r.errors || 0}</div><div class="sub">${r.timeouts || 0} timeouts</div></div>
    <div class="card"><div class="label">Sessions</div><div class="value">${p.activeSessions || 0}</div></div>
  `;

  // Plan usage
  const plan = data.plan;
  if (plan && !plan.error) {
    const s = plan.currentSession || {};
    const w = plan.weeklyLimits?.allModels || {};
    const sPct = Math.round((s.utilization || 0) * 100);
    const wPct = Math.round((w.utilization || 0) * 100);
    document.getElementById("plan-cards").innerHTML = `
      <div class="card">
        <div class="label">Session (5h)</div>
        <div class="value">${s.percent || '?'}</div>
        <div class="bar-bg"><div class="bar-fill ${barColor(sPct)}" style="width:${sPct}%"></div></div>
        <div class="sub">Resets in ${s.resetsIn || '?'}</div>
      </div>
      <div class="card">
        <div class="label">Weekly (7d)</div>
        <div class="value">${w.percent || '?'}</div>
        <div class="bar-bg"><div class="bar-fill ${barColor(wPct)}" style="width:${wPct}%"></div></div>
        <div class="sub">Resets in ${w.resetsIn || '?'}</div>
      </div>
    `;
  }
}

async function refreshUsage() {
  try {
    const data = await api("/api/usage");
    // By key
    const tbody = document.querySelector("#key-usage-table tbody");
    tbody.innerHTML = (data.byKey || []).map(k => `
      <tr>
        <td>${k.key_name}</td>
        <td>${k.requests}</td>
        <td>${k.successes}</td>
        <td>${k.errors}</td>
        <td>${fmtTime(k.avg_elapsed_ms)}</td>
        <td class="mono">${k.last_request || '-'}</td>
      </tr>
    `).join("") || '<tr><td colspan="6" style="color:#475569">No usage data yet</td></tr>';

    // Recent
    const rtbody = document.querySelector("#recent-table tbody");
    rtbody.innerHTML = (data.recent || []).slice(0, 20).map(r => `
      <tr>
        <td class="mono">${r.created_at?.slice(11, 19) || '?'}</td>
        <td>${r.key_name}</td>
        <td>${r.model}</td>
        <td>${fmtChars(r.prompt_chars)}</td>
        <td>${fmtChars(r.response_chars)}</td>
        <td>${fmtTime(r.elapsed_ms)}</td>
        <td><span class="tag ${r.success ? 'tag-ok' : 'tag-err'}">${r.success ? 'ok' : 'err'}</span></td>
      </tr>
    `).join("") || '<tr><td colspan="7" style="color:#475569">No requests yet</td></tr>';
  } catch(e) {
    console.warn("Usage API not available:", e);
  }
}

async function refreshKeys() {
  try {
    const data = await api("/api/keys");
    document.getElementById("key-mgmt-section").style.display = "";
    const tbody = document.querySelector("#keys-table tbody");
    tbody.innerHTML = (data.keys || []).map(k => `
      <tr>
        <td>${k.name}</td>
        <td class="mono">${k.keyPreview}</td>
        <td class="mono">${k.created_at}</td>
        <td><span class="tag ${k.revoked ? 'tag-err' : 'tag-ok'}">${k.revoked ? 'revoked' : 'active'}</span></td>
        <td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" onclick="revokeKeyUI('${k.name}')">Revoke</button>`}</td>
      </tr>
    `).join("");
  } catch(e) {
    // Key management not available (not admin or not multi mode)
  }
}

async function addKey() {
  const name = document.getElementById("new-key-name").value.trim();
  if (!name) return alert("Enter a key name");
  const result = await apiPost("/api/keys", { name });
  if (result.key) {
    alert("New API Key (copy it now — you won't see it again):\n\n" + result.key);
    document.getElementById("new-key-name").value = "";
    refreshKeys();
  }
}

async function revokeKeyUI(name) {
  if (!confirm(`Revoke key "${name}"?`)) return;
  await apiDelete(`/api/keys/${encodeURIComponent(name)}`);
  refreshKeys();
}

async function refreshAll() {
  document.getElementById("refresh-indicator").textContent = "Refreshing...";
  await Promise.all([refreshStatus(), refreshUsage(), refreshKeys()]);
  document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
}

refreshAll();
setInterval(refreshAll, 30000);
</script>
</body>
</html>
  • Step 2: Add dashboard route to server.mjs

Before the 404 handler, add:

  // 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
  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
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:

# ── keys ────────────────────────────────────────────────────────────────
cmd_keys_help() {
  cat <<'EOF'
ocp keys — Manage API keys (multi-key auth mode)

Usage:
  ocp keys                     List all keys
  ocp keys add <name>          Create a new key
  ocp keys revoke <name|id>    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 <name>"; 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 <name|id>"; 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 <name>')
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
# ── 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=<your-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:

# 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:

  keys)     cmd_keys "${1:-}" "${2:-}" ;;
  lan)      cmd_lan ;;

Update cmd_help():

  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
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:

    <key>CLAUDE_BIND</key>
    <string>${BIND_ADDRESS}</string>
    <key>CLAUDE_AUTH_MODE</key>
    <string>${AUTH_MODE_CONFIG}</string>

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:

const BIND_ADDRESS = opt("bind", "127.0.0.1");
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
  • Step 2: Commit
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:

## 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:

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

# 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://<your-ip>:3456/v1
OPENAI_API_KEY=ocp_<their-key>

Run ocp lan to see your IP and connection guide.

Web Dashboard

Access the usage dashboard at: http://<your-ip>: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

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)
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
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
# Save the key from step 3
KEY="<the-ocp-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
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
curl -s http://127.0.0.1:3456/dashboard | head -5

Expected: HTML starting with <!DOCTYPE html>

  • Step 7: Test auth rejection (no key in multi mode)
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
pkill -f "server.mjs" || true
  • Step 9: Commit integration test results as passing
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