Merge feature/lan-mode: LAN sharing with multi-key auth, dashboard, CLI

Add LAN mode to share one Claude Pro/Max subscription across a household:
- 3-tier auth: none / shared / multi (per-user API keys)
- SQLite-backed key management and per-request usage tracking
- Embedded web dashboard with real-time monitoring
- CLI commands: keys, lan, usage --by-key
- Zero external dependencies (uses node:sqlite)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 21:45:17 +10:00
co-authored by Claude Opus 4.6
9 changed files with 2019 additions and 23 deletions
+126 -4
View File
@@ -1,6 +1,6 @@
# OCP — Open Claude Proxy # OCP — Open Claude Proxy
> **Status: Stable (v3.3.1)** — Feature-complete. Bug fixes only. > **Status: Stable (v3.4.0)** — Feature-complete. Bug fixes only.
> **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.** > **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.**
@@ -55,6 +55,112 @@ curl http://127.0.0.1:3456/v1/models
# Returns: claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4 # Returns: claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4
``` ```
## LAN Mode — Share with Family
OCP can serve your entire household from a single machine. One Claude Pro/Max subscription, shared across all devices on your network.
```
Wife's laptop ──┐
Son's iPad ───┼──→ OCP :3456 (your Mac) ──→ Claude subscription
Your Pi server ───┤
Your desktop ──┘
```
### Step 1: Enable LAN Access
**Quick start (temporary, until restart):**
```bash
export CLAUDE_BIND=0.0.0.0
export CLAUDE_AUTH_MODE=multi # per-user keys
export OCP_ADMIN_KEY=your-secret-admin-key
ocp restart
```
**Permanent (survives reboot):**
```bash
node setup.mjs --bind 0.0.0.0 --auth-mode multi
```
Then set your admin key in the launchd/systemd environment, or save it to a file:
```bash
echo "your-secret-admin-key" > ~/.ocp/admin-key
chmod 600 ~/.ocp/admin-key
```
### Step 2: Create Keys for Family Members
```bash
# Set admin key for CLI (or save to ~/.ocp/admin-key)
export OCP_ADMIN_KEY=your-secret-admin-key
# Create a key for each person/device
ocp keys add wife-laptop
# ✓ Key created for "wife-laptop"
# API Key: ocp_xDYzOB9ZKYzn...
# Copy this key now — you won't see it again.
ocp keys add son-ipad
ocp keys add pi-server
```
### Step 3: Share Connection Info
Run `ocp lan` to see your IP and ready-to-share instructions:
```
$ ocp lan
OCP LAN Setup
─────────────────────────────────────
Your IP: 192.168.1.100
Port: 3456
For IDE users, set:
OPENAI_BASE_URL=http://192.168.1.100:3456/v1
OPENAI_API_KEY=<their-key>
Dashboard: http://192.168.1.100:3456/dashboard
Status: ✓ LAN-accessible
```
Give each family member their key and these two settings:
```bash
export OPENAI_BASE_URL=http://192.168.1.100:3456/v1
export OPENAI_API_KEY=ocp_<their-key>
```
### Step 4: Monitor Usage
```bash
# Per-key usage stats
ocp usage --by-key
# Key Reqs OK Err Avg Time
# wife-laptop 5 5 0 8.0s
# son-ipad 3 3 0 6.2s
# Manage keys
ocp keys # List all keys
ocp keys revoke son-ipad # Revoke a key
```
**Web Dashboard:** Open `http://<your-ip>:3456/dashboard` in any browser for real-time monitoring — per-key usage, request history, plan utilization, and system health. No login needed.
### Auth Modes
| Mode | Env | Use Case |
|------|-----|----------|
| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed |
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys with usage tracking (recommended) |
### Important Notes
- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly)
- `ocp usage` shows how much quota remains
- Keys are stored in `~/.ocp/ocp.db` (SQLite, zero external dependencies)
- Admin key is required for key management API endpoints
- The dashboard (`/dashboard`) and health check (`/health`) are always public
## Built-in Usage Monitoring ## Built-in Usage Monitoring
Check your subscription usage from the terminal: Check your subscription usage from the terminal:
@@ -85,8 +191,13 @@ Proxy: up 6h 32m | 23 reqs | 0 err | 0 timeout
``` ```
ocp usage Plan usage limits & model stats ocp usage Plan usage limits & model stats
ocp usage --by-key Per-key usage breakdown (LAN mode)
ocp status Quick overview ocp status Quick overview
ocp health Proxy diagnostics ocp health Proxy diagnostics
ocp keys List all API keys (multi mode)
ocp keys add <name> Create a new API key
ocp keys revoke <name> Revoke an API key
ocp lan Show LAN connection info & IP
ocp settings View tunable settings ocp settings View tunable settings
ocp settings <k> <v> Update a setting at runtime ocp settings <k> <v> Update a setting at runtime
ocp logs [N] [level] Recent logs (default: 20, error) ocp logs [N] [level] Recent logs (default: 20, error)
@@ -160,6 +271,10 @@ OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p
| `/settings` | GET/PATCH | View or update settings at runtime | | `/settings` | GET/PATCH | View or update settings at runtime |
| `/logs` | GET | Recent log entries (`?n=20&level=error`) | | `/logs` | GET | Recent log entries (`?n=20&level=error`) |
| `/sessions` | GET/DELETE | List or clear active sessions | | `/sessions` | GET/DELETE | List or clear active sessions |
| `/dashboard` | GET | Web dashboard (always public) |
| `/api/keys` | GET/POST | List or create API keys (admin only) |
| `/api/keys/:id` | DELETE | Revoke an API key (admin only) |
| `/api/usage` | GET | Per-key usage stats (`?since=&until=&hours=&limit=`) |
## OpenClaw Integration ## OpenClaw Integration
@@ -241,6 +356,9 @@ If you installed OCP before v3.1.0, the auto-start service used names that OpenC
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port | | `CLAUDE_PROXY_PORT` | `3456` | Listen port |
| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) |
| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` |
| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) |
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary | | `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
| `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) | | `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes | | `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
@@ -248,13 +366,17 @@ If you installed OCP before v3.1.0, the auto-start service used names that OpenC
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) | | `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve | | `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks | | `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication | | `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
## Security ## Security
- **Localhost only** — binds to `127.0.0.1`, not exposed to the network - **Localhost by default** — binds to `127.0.0.1`; set `CLAUDE_BIND=0.0.0.0` to enable LAN access
- **Bearer token auth (optional)** — set `PROXY_API_KEY` to require auth - **3-tier auth** — `none` (trusted network), `shared` (single key), `multi` (per-user keys with usage tracking)
- **Timing-safe key comparison** — prevents timing attacks on API keys and admin keys
- **Admin-only key management** — creating, listing, and revoking keys requires the admin key
- **Public endpoints** — `/health` and `/dashboard` are always accessible without auth
- **No API keys needed** — authentication goes through Claude CLI's OAuth session - **No API keys needed** — authentication goes through Claude CLI's OAuth session
- **Keys stored locally** — `~/.ocp/ocp.db` (SQLite), never sent to external services
- **Auto-start** — launchd (macOS) / systemd (Linux) - **Auto-start** — launchd (macOS) / systemd (Linux)
## License ## License
+245
View File
@@ -0,0 +1,245 @@
<!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; margin-top: 0.5rem; }
.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>
<div class="grid" id="status-cards"></div>
<div class="section">
<h2>Plan Usage</h2>
<div class="grid" id="plan-cards"></div>
</div>
<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>
<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>
<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 = {};
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" : String(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>
`;
const plan = data.plan;
if (plan && typeof plan === 'object' && !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");
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>';
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) { /* not admin */ }
}
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>
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
// keys.mjs — API key management and usage tracking for OCP LAN mode
// Uses Node.js built-in SQLite (node:sqlite) — zero external dependencies.
import { DatabaseSync } from "node:sqlite";
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 DatabaseSync(DB_PATH);
db.exec("PRAGMA journal_mode = WAL");
db.exec("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(({ key, ...rest }) => ({
...rest,
keyPreview: key.slice(0, 8) + "..." + 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; }
}
+164 -3
View File
@@ -8,6 +8,23 @@ set -euo pipefail
PROXY="http://127.0.0.1:3456" PROXY="http://127.0.0.1:3456"
# Auth header for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
_AUTH_HEADER=""
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
_AUTH_HEADER="-H \"Authorization: Bearer $OCP_ADMIN_KEY\""
elif [[ -f "$HOME/.ocp/admin-key" ]]; then
_AUTH_HEADER="-H \"Authorization: Bearer $(cat "$HOME/.ocp/admin-key")\""
fi
# Wrapper: curl with optional auth
_curl() {
if [[ -n "$_AUTH_HEADER" ]]; then
eval curl "$_AUTH_HEADER" "$@"
else
curl "$@"
fi
}
_json() { python3 -m json.tool 2>/dev/null || cat; } _json() { python3 -m json.tool 2>/dev/null || cat; }
_bar() { _bar() {
@@ -29,11 +46,36 @@ Displays current session utilization, weekly limits, extra usage status,
and proxy request statistics. Data is fetched from the Anthropic API and proxy request statistics. Data is fetched from the Anthropic API
via a minimal probe call (cached for 5 minutes). via a minimal probe call (cached for 5 minutes).
Usage: ocp usage Usage: ocp usage [--by-key]
Options:
--by-key Show per-key usage statistics
EOF EOF
} }
cmd_usage() { 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
local data local data
data=$(curl -sf --max-time 15 "$PROXY/usage" 2>&1) || { echo "Error: proxy unreachable"; exit 1; } data=$(curl -sf --max-time 15 "$PROXY/usage" 2>&1) || { echo "Error: proxy unreachable"; exit 1; }
@@ -213,6 +255,121 @@ cmd_clear() {
echo "Cleared $count sessions." echo "Cleared $count sessions."
} }
# ── 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)
cmd_keys_help
;;
"")
_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
}
# ── 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 || ipconfig getifaddr en1 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown")
local port=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 ""
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
}
# ── restart ────────────────────────────────────────────────────────────── # ── restart ──────────────────────────────────────────────────────────────
cmd_restart_help() { cmd_restart_help() {
cat <<'EOF' cat <<'EOF'
@@ -442,7 +599,7 @@ ocp — OpenClaw Proxy CLI
Usage: ocp <command> [args] Usage: ocp <command> [args]
Commands: Commands:
usage Plan usage limits usage Plan usage limits (--by-key for per-key stats)
status Quick overview (usage + health) status Quick overview (usage + health)
health Proxy diagnostics health Proxy diagnostics
settings View or update tunable settings settings View or update tunable settings
@@ -450,6 +607,8 @@ Commands:
models Available models models Available models
sessions Active sessions sessions Active sessions
clear Clear all sessions clear Clear all sessions
keys Manage API keys (add/list/revoke)
lan LAN mode setup guide
restart Restart proxy restart Restart proxy
restart gateway Restart gateway restart gateway Restart gateway
update Update OCP to latest version update Update OCP to latest version
@@ -484,7 +643,7 @@ for arg in "$@"; do
done done
case "$subcmd" in case "$subcmd" in
usage) cmd_usage ;; usage) cmd_usage "${1:-}" ;;
status) cmd_status ;; status) cmd_status ;;
health) cmd_health ;; health) cmd_health ;;
settings) cmd_settings "${1:-}" "${2:-}" ;; settings) cmd_settings "${1:-}" "${2:-}" ;;
@@ -492,6 +651,8 @@ case "$subcmd" in
models) cmd_models ;; models) cmd_models ;;
sessions) cmd_sessions ;; sessions) cmd_sessions ;;
clear) cmd_clear ;; clear) cmd_clear ;;
keys) cmd_keys "${1:-}" "${2:-}" ;;
lan) cmd_lan ;;
restart) cmd_restart "${1:-}" ;; restart) cmd_restart "${1:-}" ;;
update) cmd_update "${1:-}" ;; update) cmd_update "${1:-}" ;;
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;; *) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
+20
View File
@@ -0,0 +1,20 @@
{
"name": "openclaw-claude-proxy",
"version": "3.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openclaw-claude-proxy",
"version": "3.4.0",
"license": "MIT",
"bin": {
"ocp": "ocp",
"openclaw-claude-proxy": "server.mjs"
},
"engines": {
"node": ">=18"
}
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "openclaw-claude-proxy", "name": "openclaw-claude-proxy",
"version": "3.3.1", "version": "3.4.0",
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.", "description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
"type": "module", "type": "module",
"bin": { "bin": {
+117 -15
View File
@@ -33,6 +33,7 @@ import { readFileSync, accessSync, constants } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb } from "./keys.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")); const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
@@ -92,6 +93,13 @@ const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "6",
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10); const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10);
const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10); const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10);
const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX || "2", 10); const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX || "2", 10);
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none");
const ADMIN_KEY = process.env.OCP_ADMIN_KEY || "";
if (AUTH_MODE === "shared" && !PROXY_API_KEY) {
console.warn("WARNING: AUTH_MODE=shared but PROXY_API_KEY is not set — all requests will pass unauthenticated");
}
const VERSION = _pkg.version; const VERSION = _pkg.version;
const START_TIME = Date.now(); const START_TIME = Date.now();
@@ -514,7 +522,7 @@ function callClaude(model, messages, conversationId) {
// ── Call claude CLI (real streaming) ───────────────────────────────────── // ── Call claude CLI (real streaming) ─────────────────────────────────────
// Pipes stdout from the claude process directly to SSE chunks as they arrive. // Pipes stdout from the claude process directly to SSE chunks as they arrive.
// Each data chunk becomes a proper SSE event with delta content in real time. // Each data chunk becomes a proper SSE event with delta content in real time.
function callClaudeStreaming(model, messages, conversationId, res) { function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) {
const id = `chatcmpl-${randomUUID()}`; const id = `chatcmpl-${randomUUID()}`;
const created = Math.floor(Date.now() / 1000); const created = Math.floor(Date.now() / 1000);
@@ -569,6 +577,7 @@ function callClaudeStreaming(model, messages, conversationId, res) {
if (code !== 0) { if (code !== 0) {
recordModelError(cliModel, false); recordModelError(cliModel, false);
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: 0, elapsedMs: elapsed, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) }); logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
trackError(stderr.slice(0, 300) || `claude exit ${code}`); trackError(stderr.slice(0, 300) || `claude exit ${code}`);
handleSessionFailure(); handleSessionFailure();
@@ -586,6 +595,7 @@ function callClaudeStreaming(model, messages, conversationId, res) {
} else { } else {
recordModelSuccess(cliModel, elapsed); recordModelSuccess(cliModel, elapsed);
breakerRecordSuccess(cliModel); breakerRecordSuccess(cliModel);
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: totalChars, elapsedMs: elapsed, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" }); logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
if (!headersSent) ensureHeaders(); if (!headersSent) ensureHeaders();
@@ -995,14 +1005,18 @@ async function handleChatCompletions(req, res) {
if (stream) { if (stream) {
// Real streaming: pipe stdout from claude process directly as SSE chunks // Real streaming: pipe stdout from claude process directly as SSE chunks
return callClaudeStreaming(model, messages, conversationId, res); return callClaudeStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName });
} }
const t0Usage = Date.now();
const promptChars = messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0);
try { try {
const content = await callClaude(model, messages, conversationId); const content = await callClaude(model, messages, conversationId);
const id = `chatcmpl-${randomUUID()}`; const id = `chatcmpl-${randomUUID()}`;
completionResponse(res, id, model, content); completionResponse(res, id, model, content);
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
} catch (err) { } catch (err) {
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
console.error(`[proxy] error: ${err.message}`); console.error(`[proxy] error: ${err.message}`);
if (res.headersSent || res.writableEnded || res.destroyed) { if (res.headersSent || res.writableEnded || res.destroyed) {
try { res.end(); } catch {} try { res.end(); } catch {}
@@ -1016,25 +1030,59 @@ async function handleChatCompletions(req, res) {
// ── HTTP server ───────────────────────────────────────────────────────── // ── HTTP server ─────────────────────────────────────────────────────────
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
// Dynamic CORS: only allow localhost origins // Dynamic CORS: allow localhost and LAN origins
const origin = req.headers["origin"] || ""; const origin = req.headers["origin"] || "";
const isLocalhost = /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(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", isLocalhost ? origin : `http://127.0.0.1:${PORT}`); res.setHeader("Access-Control-Allow-Origin", isAllowedOrigin ? origin : `http://127.0.0.1:${PORT}`);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); 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"); 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; } if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
// Bearer token auth (skip for /health and when PROXY_API_KEY is not set) // 3-mode auth: none | shared | multi
if (PROXY_API_KEY && req.url !== "/health") { const isPublicEndpoint = req.url === "/health" || req.url === "/dashboard";
let authKeyName = "local";
let authKeyId = null;
if (!isPublicEndpoint) {
const auth = req.headers["authorization"] || ""; const auth = req.headers["authorization"] || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
const tokenBuf = Buffer.from(token);
const keyBuf = Buffer.from(PROXY_API_KEY); if (AUTH_MODE === "shared") {
if (tokenBuf.length !== keyBuf.length || !timingSafeEqual(tokenBuf, keyBuf)) { if (PROXY_API_KEY) {
return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } }); 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") {
if (!token) {
return jsonResponse(res, 401, { error: { message: "Unauthorized: Bearer token required", type: "auth_error" } });
}
let isAdminToken = false;
if (ADMIN_KEY) {
const adminBuf = Buffer.from(ADMIN_KEY);
const tokenBuf2 = Buffer.from(token);
if (adminBuf.length === tokenBuf2.length && timingSafeEqual(adminBuf, tokenBuf2)) {
authKeyName = "admin";
isAdminToken = true;
}
}
if (!isAdminToken) {
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;
}
} }
} }
req._authKeyName = authKeyName;
req._authKeyId = authKeyId;
// GET /v1/models // GET /v1/models
if (req.url === "/v1/models" && req.method === "GET") { if (req.url === "/v1/models" && req.method === "GET") {
return jsonResponse(res, 200, { return jsonResponse(res, 200, {
@@ -1129,7 +1177,57 @@ const server = createServer(async (req, res) => {
return handleSettings(req, res); return handleSettings(req, res);
} }
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" }); // ── Key management API ──
const isAdmin = AUTH_MODE !== "multi" || authKeyName === "admin";
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);
}
if (req.url === "/api/keys" && req.method === "GET") {
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
return jsonResponse(res, 200, { keys: listKeys() });
}
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 });
}
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: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720) }),
recent: getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500)),
});
}
// 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;
}
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" });
}); });
@@ -1149,6 +1247,7 @@ function gracefulShutdown(signal) {
// 2. Clear intervals/timers // 2. Clear intervals/timers
clearInterval(sessionCleanupInterval); clearInterval(sessionCleanupInterval);
clearInterval(authCheckInterval); clearInterval(authCheckInterval);
closeDb();
// 3. Kill all active child processes // 3. Kill all active child processes
for (const proc of activeProcesses) { for (const proc of activeProcesses) {
@@ -1186,8 +1285,9 @@ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT")); process.on("SIGINT", () => gracefulShutdown("SIGINT"));
// ── Start ─────────────────────────────────────────────────────────────── // ── Start ───────────────────────────────────────────────────────────────
server.listen(PORT, "127.0.0.1", () => { server.listen(PORT, BIND_ADDRESS, () => {
console.log(`openclaw-claude-proxy v${VERSION} listening on http://127.0.0.1:${PORT}`); 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}`);
console.log(`Architecture: on-demand spawning (no pool)`); console.log(`Architecture: on-demand spawning (no pool)`);
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`); console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
console.log(`Claude binary: ${CLAUDE}`); console.log(`Claude binary: ${CLAUDE}`);
@@ -1198,6 +1298,8 @@ server.listen(PORT, "127.0.0.1", () => {
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`); if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
if (MCP_CONFIG) console.log(`MCP config: ${MCP_CONFIG}`); if (MCP_CONFIG) console.log(`MCP config: ${MCP_CONFIG}`);
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`); console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
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" : ""}`);
console.log(`---`); console.log(`---`);
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`); console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`); console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`);
+8
View File
@@ -36,6 +36,8 @@ const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku
const DRY_RUN = flag("dry-run"); const DRY_RUN = flag("dry-run");
const SKIP_START = flag("no-start"); const SKIP_START = flag("no-start");
const PROVIDER_NAME = opt("provider-name", "claude-local"); const PROVIDER_NAME = opt("provider-name", "claude-local");
const BIND_ADDRESS = opt("bind", "127.0.0.1");
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
const MODEL_ID_MAP = { const MODEL_ID_MAP = {
opus: "claude-opus-4-6", opus: "claude-opus-4-6",
@@ -363,6 +365,10 @@ if (!DRY_RUN) {
<dict> <dict>
<key>CLAUDE_PROXY_PORT</key> <key>CLAUDE_PROXY_PORT</key>
<string>${PORT}</string> <string>${PORT}</string>
<key>CLAUDE_BIND</key>
<string>${BIND_ADDRESS}</string>
<key>CLAUDE_AUTH_MODE</key>
<string>${AUTH_MODE_CONFIG}</string>
</dict> </dict>
<key>RunAtLoad</key> <key>RunAtLoad</key>
<true/> <true/>
@@ -399,6 +405,8 @@ After=network.target
[Service] [Service]
ExecStart=${nodeBin} ${serverPath} ExecStart=${nodeBin} ${serverPath}
Environment=CLAUDE_PROXY_PORT=${PORT} Environment=CLAUDE_PROXY_PORT=${PORT}
Environment=CLAUDE_BIND=${BIND_ADDRESS}
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}
Restart=always Restart=always
RestartSec=5 RestartSec=5
StandardOutput=append:${logPath} StandardOutput=append:${logPath}