mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
Removes all occurrences of maintainer's real name and handle from tracked files in this PUBLIC repository. Replaces with generic terms like "project maintainer" / "the maintainer". Files scrubbed: - CLAUDE.md (2 instances) - ALIGNMENT.md (Auditor field) - docs/adr/0002, 0003, 0004 (Authors/Deciders fields + body text) - memory/constitution.md (1 instance) - docs/superpowers/plans/2026-04-10-lan-mode.md (Users/taodeng paths → $HOME) Also: - Replaces specific machine hostnames (Taos-Mac-mini, Taos-MacBook-Pro) with role-based names (home-mac, travel-macbook) in ADR 0001 (if committed; currently untracked). - Replaces /Users/taodeng/ paths with $HOME/ in old plan doc. NOTE: git history still contains the personal info. See postmortem (issue TBD) for whether to rewrite history via git-filter-repo. Co-authored-by: Tao Deng <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1179 lines
37 KiB
Markdown
1179 lines
37 KiB
Markdown
# 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 $HOME/.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
|
|
<!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:
|
|
|
|
```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 <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**
|
|
|
|
```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=<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:
|
|
|
|
```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
|
|
<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:
|
|
|
|
```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://<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**
|
|
|
|
```bash
|
|
cd $HOME/.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="<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**
|
|
|
|
```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 `<!DOCTYPE html>`
|
|
|
|
- [ ] **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** |
|