mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
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:
@@ -1,6 +1,6 @@
|
||||
# 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.**
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
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 --by-key Per-key usage breakdown (LAN mode)
|
||||
ocp status Quick overview
|
||||
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 <k> <v> Update a setting at runtime
|
||||
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 |
|
||||
| `/logs` | GET | Recent log entries (`?n=20&level=error`) |
|
||||
| `/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
|
||||
|
||||
@@ -241,6 +356,9 @@ If you installed OCP before v3.1.0, the auto-start service used names that OpenC
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `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_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
|
||||
| `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_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
|
||||
| `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
|
||||
|
||||
- **Localhost only** — binds to `127.0.0.1`, not exposed to the network
|
||||
- **Bearer token auth (optional)** — set `PROXY_API_KEY` to require auth
|
||||
- **Localhost by default** — binds to `127.0.0.1`; set `CLAUDE_BIND=0.0.0.0` to enable LAN access
|
||||
- **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
|
||||
- **Keys stored locally** — `~/.ocp/ocp.db` (SQLite), never sent to external services
|
||||
- **Auto-start** — launchd (macOS) / systemd (Linux)
|
||||
|
||||
## License
|
||||
|
||||
+245
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -8,6 +8,23 @@ set -euo pipefail
|
||||
|
||||
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; }
|
||||
|
||||
_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
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
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."
|
||||
}
|
||||
|
||||
# ── 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 ──────────────────────────────────────────────────────────────
|
||||
cmd_restart_help() {
|
||||
cat <<'EOF'
|
||||
@@ -442,7 +599,7 @@ ocp — OpenClaw Proxy CLI
|
||||
Usage: ocp <command> [args]
|
||||
|
||||
Commands:
|
||||
usage Plan usage limits
|
||||
usage Plan usage limits (--by-key for per-key stats)
|
||||
status Quick overview (usage + health)
|
||||
health Proxy diagnostics
|
||||
settings View or update tunable settings
|
||||
@@ -450,6 +607,8 @@ Commands:
|
||||
models Available models
|
||||
sessions Active sessions
|
||||
clear Clear all sessions
|
||||
keys Manage API keys (add/list/revoke)
|
||||
lan LAN mode setup guide
|
||||
restart Restart proxy
|
||||
restart gateway Restart gateway
|
||||
update Update OCP to latest version
|
||||
@@ -484,7 +643,7 @@ for arg in "$@"; do
|
||||
done
|
||||
|
||||
case "$subcmd" in
|
||||
usage) cmd_usage ;;
|
||||
usage) cmd_usage "${1:-}" ;;
|
||||
status) cmd_status ;;
|
||||
health) cmd_health ;;
|
||||
settings) cmd_settings "${1:-}" "${2:-}" ;;
|
||||
@@ -492,6 +651,8 @@ case "$subcmd" in
|
||||
models) cmd_models ;;
|
||||
sessions) cmd_sessions ;;
|
||||
clear) cmd_clear ;;
|
||||
keys) cmd_keys "${1:-}" "${2:-}" ;;
|
||||
lan) cmd_lan ;;
|
||||
restart) cmd_restart "${1:-}" ;;
|
||||
update) cmd_update "${1:-}" ;;
|
||||
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
|
||||
|
||||
Generated
+20
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
+113
-11
@@ -33,6 +33,7 @@ import { readFileSync, accessSync, constants } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
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 _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_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 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 START_TIME = Date.now();
|
||||
@@ -514,7 +522,7 @@ function callClaude(model, messages, conversationId) {
|
||||
// ── Call claude CLI (real streaming) ─────────────────────────────────────
|
||||
// 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.
|
||||
function callClaudeStreaming(model, messages, conversationId, res) {
|
||||
function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) {
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -569,6 +577,7 @@ function callClaudeStreaming(model, messages, conversationId, res) {
|
||||
|
||||
if (code !== 0) {
|
||||
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) });
|
||||
trackError(stderr.slice(0, 300) || `claude exit ${code}`);
|
||||
handleSessionFailure();
|
||||
@@ -586,6 +595,7 @@ function callClaudeStreaming(model, messages, conversationId, res) {
|
||||
} else {
|
||||
recordModelSuccess(cliModel, elapsed);
|
||||
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" });
|
||||
|
||||
if (!headersSent) ensureHeaders();
|
||||
@@ -995,14 +1005,18 @@ async function handleChatCompletions(req, res) {
|
||||
|
||||
if (stream) {
|
||||
// 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 {
|
||||
const content = await callClaude(model, messages, conversationId);
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
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) {
|
||||
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}`);
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) {
|
||||
try { res.end(); } catch {}
|
||||
@@ -1016,24 +1030,58 @@ async function handleChatCompletions(req, res) {
|
||||
|
||||
// ── HTTP server ─────────────────────────────────────────────────────────
|
||||
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 isLocalhost = /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin);
|
||||
res.setHeader("Access-Control-Allow-Origin", isLocalhost ? origin : `http://127.0.0.1:${PORT}`);
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
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 : `http://127.0.0.1:${PORT}`);
|
||||
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; }
|
||||
|
||||
// Bearer token auth (skip for /health and when PROXY_API_KEY is not set)
|
||||
if (PROXY_API_KEY && req.url !== "/health") {
|
||||
// 3-mode auth: none | shared | multi
|
||||
const isPublicEndpoint = req.url === "/health" || req.url === "/dashboard";
|
||||
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") {
|
||||
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") {
|
||||
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
|
||||
if (req.url === "/v1/models" && req.method === "GET") {
|
||||
@@ -1129,7 +1177,57 @@ const server = createServer(async (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
|
||||
clearInterval(sessionCleanupInterval);
|
||||
clearInterval(authCheckInterval);
|
||||
closeDb();
|
||||
|
||||
// 3. Kill all active child processes
|
||||
for (const proc of activeProcesses) {
|
||||
@@ -1186,8 +1285,9 @@ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||
|
||||
// ── Start ───────────────────────────────────────────────────────────────
|
||||
server.listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`openclaw-claude-proxy v${VERSION} listening on http://127.0.0.1:${PORT}`);
|
||||
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}`);
|
||||
console.log(`Architecture: on-demand spawning (no pool)`);
|
||||
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
||||
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 (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 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(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
|
||||
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`);
|
||||
|
||||
@@ -36,6 +36,8 @@ const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku
|
||||
const DRY_RUN = flag("dry-run");
|
||||
const SKIP_START = flag("no-start");
|
||||
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 = {
|
||||
opus: "claude-opus-4-6",
|
||||
@@ -363,6 +365,10 @@ if (!DRY_RUN) {
|
||||
<dict>
|
||||
<key>CLAUDE_PROXY_PORT</key>
|
||||
<string>${PORT}</string>
|
||||
<key>CLAUDE_BIND</key>
|
||||
<string>${BIND_ADDRESS}</string>
|
||||
<key>CLAUDE_AUTH_MODE</key>
|
||||
<string>${AUTH_MODE_CONFIG}</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
@@ -399,6 +405,8 @@ After=network.target
|
||||
[Service]
|
||||
ExecStart=${nodeBin} ${serverPath}
|
||||
Environment=CLAUDE_PROXY_PORT=${PORT}
|
||||
Environment=CLAUDE_BIND=${BIND_ADDRESS}
|
||||
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:${logPath}
|
||||
|
||||
Reference in New Issue
Block a user