Compare commits

...
40 Commits
Author SHA1 Message Date
taodengandClaude Opus 4.6 18cb759359 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>
2026-04-10 21:45:17 +10:00
taodengandClaude Opus 4.6 cc745aa5d9 docs: update README with complete LAN mode reference
Add LAN CLI commands (keys, lan, usage --by-key), new API endpoints
(dashboard, keys, usage), LAN env vars (CLAUDE_BIND, CLAUDE_AUTH_MODE,
OCP_ADMIN_KEY), and expanded security section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 21:45:07 +10:00
taodengandClaude Opus 4.6 1983f9372d fix: CLI auth support for multi-key mode + fix LAN IP detection
- Add _curl wrapper that reads OCP_ADMIN_KEY env or ~/.ocp/admin-key file
- All admin API calls (keys, usage) use _curl for auth
- Fix ocp lan IP detection to try en0 and en1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 21:36:16 +10:00
taodengandClaude Opus 4.6 471bda2c40 fix: make /dashboard a public endpoint (no auth required)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 21:21:36 +10:00
taodengandClaude Opus 4.6 566a01a6bd refactor: switch from better-sqlite3 to node:sqlite (zero dependencies)
better-sqlite3 native addon fails on Node v25. Node.js built-in SQLite
(node:sqlite) has an identical synchronous API and requires no compilation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 21:20:24 +10:00
taodeng 43daf8162c docs: add LAN mode documentation and family sharing guide 2026-04-10 21:17:25 +10:00
taodeng aa0adab784 feat: add LAN mode config options to setup.mjs 2026-04-10 21:17:19 +10:00
taodengandClaude Sonnet 4.6 4f4e1edf16 feat: add ocp keys, ocp lan, and ocp usage --by-key CLI commands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:16:25 +10:00
taodeng ea57db6ceb feat: add embedded web dashboard with real-time usage display 2026-04-10 21:15:44 +10:00
taodengandClaude Sonnet 4.6 5fbeaed568 security: fix key exposure, timing-safe admin auth, remove admin bypass, cap params, harden usage recording
- Fix 1 (keys.mjs): listKeys() no longer leaks full API key field in response
- Fix 2 (server.mjs): Remove BIND_ADDRESS admin bypass from isAdmin check
- Fix 3 (server.mjs): Admin key comparison now uses timingSafeEqual
- Fix 4 (server.mjs): Cap limit (max 500) and hours (max 720) in GET /api/usage
- Fix 5 (server.mjs): Streaming error path now computes promptChars from messages
- Fix 6 (server.mjs): Warn at startup if AUTH_MODE=shared but PROXY_API_KEY is empty
- Fix 7 (server.mjs): All recordUsage calls wrapped in try/catch to prevent DB errors crashing server
- Fix 8 (server.mjs): CORS fallback changed from "*" to specific origin (http://127.0.0.1:<PORT>)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:13:17 +10:00
taodengandClaude Sonnet 4.6 2b18884d2a feat: add LAN bind, 3-mode auth, usage recording, key/usage API endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:07:22 +10:00
taodeng 4f72f4844e feat: add keys.mjs — API key management and SQLite usage store 2026-04-10 21:04:00 +10:00
taodeng bc17b27f2b chore: add better-sqlite3 dependency, bump to v3.4.0 2026-04-10 21:02:40 +10:00
taodengandClaude Opus 4.6 3e8ff7a509 docs: add LAN mode implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 21:00:22 +10:00
taodengandClaude Opus 4.6 b6b33d2623 chore: add MIT LICENSE file
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 20:37:11 +10:00
taodengandClaude Opus 4.6 8796a5fc19 docs: update README version to v3.3.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 07:49:54 +10:00
taodengandClaude Opus 4.6 609ceb28b0 fix: use stable node symlink path in macOS plist, avoid Cellar breakage
setup.mjs now resolves /opt/homebrew/Cellar/node/X.Y.Z/ to
/opt/homebrew/opt/node/ so the LaunchAgent plist survives node upgrades.
Bump to v3.3.1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 10:01:54 +10:00
taodengandClaude Opus 4.6 3843ec8fe6 refactor: simplify timeout to single CLAUDE_TIMEOUT (default 600s)
Remove firstByteTimeout, adaptive tier algorithm, and MODEL_TIMEOUT_TIERS.
Claude tool-use causes 30s-5min pauses in token streams, making fine-grained
timeouts unreliable — they repeatedly killed valid requests. A single generous
timeout (10 min, matching LiteLLM/OpenAI SDK defaults) is simpler and correct.

Breaking: CLAUDE_FIRST_BYTE_TIMEOUT env var removed, default timeout changed
from 300s to 600s. Bump to v3.3.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 09:46:12 +10:00
taodengandClaude Opus 4.6 afe0c6e1be fix: computeFirstByteTimeout now respects CLAUDE_FIRST_BYTE_TIMEOUT env var
The adaptive timeout calculation ignored BASE_FIRST_BYTE_TIMEOUT (set via
CLAUDE_FIRST_BYTE_TIMEOUT env), always using the tier base (120s for Sonnet).
Now uses whichever is larger: adaptive or env override. Bump to v3.2.2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 09:26:01 +10:00
taodengandClaude Opus 4.6 a6007393a3 fix: auto-configure idleTimeoutSeconds=0 to prevent tool-call timeouts
setup.mjs now sets agents.defaults.llm.idleTimeoutSeconds=0 in
openclaw.json during installation. Without this, OpenClaw's default
60s idle timeout kills Claude connections during tool use (Bash, Read,
etc.), causing exit 143 errors and stuck sessions.

Also adds troubleshooting section to README. Bump to v3.2.1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:50:19 +10:00
taodengandClaude Opus 4.6 8592150f7a docs: update README for v3.2.0 — add update command, upgrade notes
- Document ocp update / ocp update --check
- Add Self-Update section
- Add Upgrading from v3.0.x migration notes
- Remove Known Issues (resolved upstream)
- Note neutral service names in OpenClaw Integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:22:55 +10:00
taodengandClaude Opus 4.6 eb76971ffc fix: resolve symlinks in ocp update/restart for linked installs
BASH_SOURCE returns the symlink path, not the target. This broke
ocp update on systems where ocp is installed as a symlink
(e.g. ~/.local/bin/ocp → repo/ocp).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:20:26 +10:00
taodengandClaude Opus 4.6 d54e73ef89 fix: prevent git fetch failure from aborting ocp update --check
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:19:41 +10:00
taodengandClaude Opus 4.6 3f10b459f5 feat: add ocp update command + fix restart for new service names
- `ocp update`: pulls latest from GitHub, syncs plugin, restarts proxy
- `ocp update --check`: shows current vs latest version without applying
- `ocp restart`: now handles both dev.ocp.proxy and legacy ai.openclaw.proxy
  service names on macOS/Linux
- Bumps to v3.2.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:18:41 +10:00
taodengandClaude Opus 4.6 b72e449337 fix: use neutral service names to avoid OpenClaw gateway detection
OpenClaw's gateway scans LaunchAgent plists and systemd units for
"openclaw" markers and flags them as conflicting gateway-like services.
Renamed service identifiers to avoid false positives:

- macOS: ai.openclaw.proxy → dev.ocp.proxy
- Linux: openclaw-proxy.service → ocp-proxy.service
- Logs: ~/.openclaw/logs/ → ~/.ocp/logs/

Setup auto-removes legacy service names on upgrade. Uninstall handles
both old and new names. Also bumps version to 3.1.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:13:55 +10:00
taodengandClaude Opus 4.6 2b05558b8b rebrand: Open Claude Proxy — multi-IDE positioning
Renamed from OpenClaw Control Plane to Open Claude Proxy. README
rewritten to position OCP as a universal proxy for any IDE that
speaks the OpenAI protocol, not just OpenClaw.

Added: supported tools table (Cline, OpenCode, Aider, Continue.dev),
simplified Quick Start, architecture diagram showing multi-IDE flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:06:41 +10:00
taodengandClaude Opus 4.6 d9b7c0d905 docs: add Known Issues — /ocp Unknown skill workaround
Documented the intermittent "Unknown skill: ocp" issue caused by
OpenClaw gateway session routing bug (#26895). Workaround: /new
followed by /ocp restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:50:17 +10:00
taodengandClaude Opus 4.6 27cd8b1735 fix: correct server.mjs path in start.sh
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:38:18 +10:00
taodengandClaude Opus 4.6 9dd35c90ae docs: remove premature Known Issues section
The slash session workaround was incomplete — root cause still under
investigation. Removed to avoid misleading users. Will add back with
accurate info once the issue is fully resolved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:04:38 +10:00
taodengandClaude Opus 4.6 c005a8fd27 remove backends command — OCP is stable v3, no Phase 2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:57:58 +10:00
taodengandClaude Opus 4.6 77a3f48cfd feat: add restart, version, test, backends commands to /ocp plugin
New subcommands:
- /ocp restart [gateway|all] — restart proxy, gateway, or both
- /ocp version — show version, uptime, platform info
- /ocp test — end-to-end proxy test (sends haiku request)
- /ocp backends — list registered backends with health status

Also includes clean-slash-sessions.sh workaround for OpenClaw #26895.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 09:09:23 +10:00
taodengandClaude Opus 4.6 7390a19ec6 docs: add prominent warning about slash session bug near plugin install
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:37:10 +10:00
taodengandClaude Opus 4.6 04643217ae docs: add known issue — slash session hijacks plugin commands
Document OpenClaw gateway bug (#26895, #54485) where telegram:slash
sessions interfere with registerCommand plugin routing. Include
workaround script and warning not to add ocp to agent skills lists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:36:42 +10:00
taodengandClaude Opus 4.6 3de1868313 fix: read OAuth token from Linux credentials file as fallback
On Linux (no macOS keychain), read ~/.claude/.credentials.json first.
Falls through to macOS keychain if file not found. Fixes /ocp usage
showing "No OAuth token" on Linux servers and RPi.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:42:16 +10:00
taodengandClaude Opus 4.6 75cff38f4c docs: improve CLI install instructions for cloud/Linux users
Common issue: ocp not in PATH after clone. Added explicit symlink
instructions with absolute paths and a troubleshooting note.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:52:22 +10:00
taodengandClaude Opus 4.6 a8a0af43c9 docs: mark OCP as stable v3.0.0, update repo name and description
- README: add "Status: Stable" badge, rename title to OCP — OpenClaw Control Plane
- package.json: update description and repo URL to dtzp555-max/ocp
- No code changes. Feature-complete, bug fixes only going forward.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:49:16 +10:00
taodengandClaude Opus 4.6 77a6f8ed59 revert: rollback Phase 1 and OAuth fix — broke plan usage functionality
Reverts b5be1c0 and 1f1b387. These changes broke the OAuth token
authentication for fetching plan usage data from Anthropic API,
causing /ocp usage to show 0% with "unknown" reset times.

Root cause: code changes inadvertently altered the fetchUsageFromApi
flow, breaking the existing working OAuth x-api-key authentication.

This restores server.mjs to the last known working state (f6f4f68).
Phase 1 modular files removed — will be re-implemented in a separate
branch with proper isolation testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:16:04 +10:00
taodengandClaude Opus 4.6 b5be1c04f4 fix: auto-refresh expired OAuth token for plan usage data (v3.0.1)
When the Claude Code OAuth token expires, /ocp usage showed 0% for all
plan limits. OCP now detects expiry and triggers a refresh via Claude CLI,
then re-reads the updated token from keychain. The 5-minute usage cache
prevents excessive refresh attempts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:18:15 +10:00
taodengandClaude Opus 4.6 1f1b3871da feat: Phase 1 — modular backend architecture (v4.0-alpha)
Internal refactor: extract BackendAdapter, ModelRegistry, AgentRouter,
SessionManager, and StatsCollector as standalone modules. Claude CLI
logic encapsulated in ClaudeCliAdapter.

External API completely unchanged — all existing endpoints behave
identically. Legacy request path (callClaude/callClaudeStreaming)
untouched. v4 modules load in parallel for validation.

New endpoints:
  GET /backends  — backend health and status
  GET /routing   — agent routing table

Architecture:
  unified-chunk.mjs    — streaming protocol (delta/done/error)
  backends/base.mjs    — BackendAdapter interface
  backends/claude-cli.mjs — Claude CLI adapter (core)
  model-registry.mjs   — model → backend mapping (Layer 1)
  agent-router.mjs     — agent policy routing (Layer 2)
  session-manager.mjs  — conversation session management
  stats-collector.mjs  — per-model/backend/agent metrics

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:02:53 +10:00
taodengandClaude Opus 4.6 f6f4f68ecd Add upgrade guide for v2.x skill-based /ocp users
Explains removing old skills/ocp/SKILL.md to avoid conflict
with the new plugin-based command handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:07:29 +10:00
15 changed files with 2546 additions and 352 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Tao Deng
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+289 -201
View File
@@ -1,45 +1,203 @@
# openclaw-claude-proxy (OCP)
# OCP — Open Claude Proxy
> **Already paying for Claude Pro/Max? Use it as your OpenClaw model provider — $0 extra API cost.**
> **Status: Stable (v3.4.0)** — Feature-complete. Bug fixes only.
A lightweight, zero-dependency proxy that lets [OpenClaw](https://github.com/openclaw/openclaw) agents talk to Claude through your existing subscription. One command to set up, one file to run. Now with built-in plan usage monitoring, runtime settings, and a CLI.
> **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.**
## What's New in v3.0.0
OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing.
### `/ocp` — Your Proxy Command Center
```
Cline ──┐
OpenCode ───┤
Aider ───┼──→ OCP :3456 ──→ Claude CLI ──→ Your subscription
Continue.dev ───┤
OpenClaw ───┘
```
Full management interface available from Telegram, Discord, or any terminal.
One proxy. Multiple IDEs. All models. **$0 API cost.**
## Supported Tools
Any tool that accepts `OPENAI_BASE_URL` works with OCP:
| Tool | Configuration |
|------|--------------|
| **Cline** | Settings → `OPENAI_BASE_URL=http://127.0.0.1:3456/v1` |
| **OpenCode** | `OPENAI_BASE_URL=http://127.0.0.1:3456/v1` |
| **Aider** | `aider --openai-api-base http://127.0.0.1:3456/v1` |
| **Continue.dev** | config.json → `apiBase: "http://127.0.0.1:3456/v1"` |
| **OpenClaw** | `setup.mjs` auto-configures |
| **Any OpenAI client** | Set base URL to `http://127.0.0.1:3456/v1` |
## Quick Start
```bash
git clone https://github.com/dtzp555-max/ocp.git
cd ocp
node setup.mjs
```
The setup script will:
1. Verify Claude CLI is installed and authenticated
2. Start the proxy on port 3456
3. Install auto-start (launchd on macOS, systemd on Linux)
Then point your IDE to the proxy:
```bash
export OPENAI_BASE_URL=http://127.0.0.1:3456/v1
```
### Verify
```bash
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:
```
$ ocp usage
Plan Usage Limits
─────────────────────────────────────
Current session 3% used
Resets in 4h 32m (Tue, Mar 24, 10:00 PM)
Current session 21% used
Resets in 3h 12m (Tue, Mar 28, 10:00 PM)
Weekly (all models) 3% used
Resets in 6d 6h (Tue, Mar 31, 12:00 AM)
Weekly (all models) 45% used
Resets in 4d 2h (Tue, Mar 31, 12:00 AM)
Extra usage off
Model Stats
Model Req OK Er AvgT MaxT AvgP MaxP
──────────────────────────────────────────────────────
haiku 1 1 0 6s 6s 0K 0K
opus 2 2 0 20s 26s 42K 43K
sonnet 2 2 0 24s 24s 41K 41K
Total 5
opus 5 5 0 32s 87s 42K 43K
sonnet 18 18 0 20s 45s 36K 56K
Total 23
Proxy: up 0h 37m | 5 reqs | 0 err | 0 timeout
Proxy: up 6h 32m | 23 reqs | 0 err | 0 timeout
```
**All commands:**
### All Commands
```
$ ocp --help
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)
@@ -48,109 +206,89 @@ ocp sessions Active sessions
ocp clear Clear all sessions
ocp restart Restart proxy
ocp restart gateway Restart gateway
ocp update Update to latest version
ocp update --check Check for updates without applying
ocp --help Command reference
```
In **Telegram/Discord**, use `/ocp usage`, `/ocp settings`, etc. — registered as a native slash command via the OCP gateway plugin.
### Install the CLI
```bash
# Symlink to PATH (recommended)
sudo ln -sf $(pwd)/ocp /usr/local/bin/ocp
# Verify
ocp --help
```
> **Cloud/Linux servers:** If `ocp: command not found`, the binary isn't in PATH. Full path: `~/.openclaw/projects/ocp/ocp`
### Self-Update
```bash
# Check if a new version is available
ocp update --check
# Pull latest, sync plugin, restart proxy — one command
ocp update
```
### Runtime Settings (No Restart Needed)
```
$ ocp settings
OCP Settings
─────────────────────────────────────
timeout 300000 ms Overall request timeout
firstByteTimeout 90000 ms Base first-byte timeout
maxConcurrent 8 Max concurrent claude processes
sessionTTL 3600000 ms Session idle expiry
maxPromptChars 150000 chars Prompt truncation limit
Timeout Tiers (first-byte):
opus base=150000ms perChar=0.0005
sonnet base=120000ms perChar=0.0005
haiku base= 45000ms perChar=0.0001
```
Change any setting live:
```
$ ocp settings maxPromptChars 200000
✓ maxPromptChars = 200000
$ ocp settings maxConcurrent 999
maxConcurrent: value 999 out of range [1, 32]
$ ocp settings maxConcurrent 4
maxConcurrent = 4
```
### Circuit Breaker Removed
The v2.5.0 circuit breaker has been **removed entirely**. It was designed for direct API connections but caused cascading failures in the CLI-proxy architecture — once API got briefly slow, the breaker blocked ALL agents for 120s+, making the problem worse. With CLI spawning, timeouts are transient and don't benefit from back-off.
### Prompt Truncation Guard
New safety valve prevents runaway context from conversation history accumulation (a recurring issue where prompts balloon from 40K to 400K+ chars).
- Default limit: **150K characters** (configurable via `maxPromptChars`)
- When exceeded: keeps system messages + as many recent messages as fit
- Logs `prompt_truncated` events for monitoring
### Increased Timeouts
| Model | Old Base | New Base | Per 100K chars |
|-------|----------|----------|----------------|
| Opus | 90s | **150s** | +50s |
| Sonnet | 60s | **120s** | +50s |
| Haiku | 30s | **45s** | +10s |
---
## How It Works
```
OpenClaw Gateway → proxy (localhost:3456) → claude -p CLI → Anthropic (via OAuth)
Your IDE → OCP (localhost:3456) → claude -p CLI → Anthropic (via subscription)
```
The proxy translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p` CLI calls. Anthropic sees normal Claude Code usage under your subscription — no API billing, no separate key.
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
## Quick Start
## Available Models
```bash
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git
cd openclaw-claude-proxy
| Model ID | Notes |
|----------|-------|
| `claude-opus-4-6` | Most capable, slower |
| `claude-sonnet-4-6` | Good balance of speed/quality |
| `claude-haiku-4` | Fastest, lightweight |
# Auto-configure OpenClaw + start proxy + install auto-start
node setup.mjs
```
## API Endpoints
The setup script will:
1. Verify Claude CLI is installed and authenticated
2. Add `claude-local` provider to `openclaw.json`
3. Start the proxy and install auto-start (launchd on macOS, systemd on Linux)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/v1/models` | GET | List available models |
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
| `/health` | GET | Comprehensive health check |
| `/usage` | GET | Plan usage limits + per-model stats |
| `/status` | GET | Combined overview (usage + health) |
| `/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=`) |
Then set your preferred model:
```bash
openclaw config set agents.defaults.model.primary "claude-local/claude-sonnet-4-6"
openclaw gateway restart
```
## OpenClaw Integration
### Install the CLI
OCP was originally built for [OpenClaw](https://github.com/openclaw/openclaw) and includes deep integration:
The `ocp` command is included in the repo:
- **`setup.mjs`** auto-configures the `claude-local` provider in `openclaw.json`
- **Gateway plugin** registers `/ocp` as a native slash command in Telegram/Discord
- **Multi-agent** — 8 concurrent requests sharing one subscription
- **No conflicts** — uses neutral service names (`dev.ocp.proxy` / `ocp-proxy`) that don't trigger OpenClaw's gateway-like service detection
```bash
# Option 1: symlink to PATH
ln -sf $(pwd)/ocp /usr/local/bin/ocp
# Option 2: npm link (if installed globally)
npm link
```
### Install the Gateway Plugin (for Telegram/Discord)
Copy the plugin to the OpenClaw extensions directory:
### Install the Gateway Plugin
```bash
cp -r ocp-plugin/ ~/.openclaw/extensions/ocp/
# Or into the bundled extensions:
cp -r ocp-plugin/ /opt/homebrew/lib/node_modules/openclaw/dist/extensions/ocp/
```
Add to `~/.openclaw/openclaw.json`:
@@ -163,133 +301,83 @@ Add to `~/.openclaw/openclaw.json`:
}
```
Restart the gateway: `openclaw gateway restart`
Restart: `openclaw gateway restart`
## API Endpoints
## Troubleshooting
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/v1/models` | GET | List available models |
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
| `/health` | GET | Comprehensive health check |
| `/usage` | GET | Plan usage limits + per-model stats |
| `/status` | GET | Combined overview (usage + health) |
| `/settings` | GET | View tunable settings |
| `/settings` | PATCH | Update settings at runtime |
| `/logs` | GET | Recent log entries (`?n=20&level=error`) |
| `/sessions` | GET | List active sessions |
| `/sessions` | DELETE | Clear all sessions |
### Requests fail with exit 143 / SIGTERM after ~60 seconds
## Available Models
**Symptom:** Claude returns errors or stops responding after about 60 seconds, especially during tool use (Bash, Read, etc.).
| Model ID | Claude CLI Model | Notes |
|----------|-----------------|-------|
| `claude-opus-4-6` | claude-opus-4-6 | Most capable, slower |
| `claude-sonnet-4-6` | claude-sonnet-4-6 | Good balance of speed/quality |
| `claude-haiku-4` | claude-haiku-4-5-20251001 | Fastest, lightweight |
**Cause:** OpenClaw's gateway has a default `idleTimeoutSeconds` of 60 seconds. When Claude calls tools, the token stream pauses while the tool executes — if that takes longer than 60s, the gateway kills the connection.
**Fix:** `setup.mjs` (v3.2.1+) sets this automatically. If you installed an older version, add this to `~/.openclaw/openclaw.json`:
```json
{
"agents": {
"defaults": {
"llm": {
"idleTimeoutSeconds": 0
}
}
}
}
```
Then restart: `openclaw gateway restart`
### Agents stuck in "typing" but never respond
Usually caused by stuck sessions from previous timeout errors. Fix:
```bash
# Clear all sessions
ocp clear
# Restart both services
ocp restart
openclaw gateway restart
```
If that doesn't help, manually clear the session store:
```bash
# Find and reset stuck Telegram sessions
cat ~/.openclaw/agents/main/sessions/sessions.json
# Remove entries with "telegram" channel, then restart gateway
```
## Upgrading from v3.0.x
If you installed OCP before v3.1.0, the auto-start service used names that OpenClaw's gateway detected as conflicting (`ai.openclaw.proxy` on macOS, `openclaw-proxy` on Linux). Running `node setup.mjs` or `ocp update` will automatically migrate to the new neutral names.
## Environment Variables
| 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` | `300000` | Overall request timeout (ms) |
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `90000` | Base first-byte timeout (ms) |
| `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
| `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 |
| `CLAUDE_SYSTEM_PROMPT` | *(empty)* | System prompt appended to all requests |
| `CLAUDE_MCP_CONFIG` | *(empty)* | Path to MCP server config JSON |
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication |
## Session Management
Multi-turn conversations use `--resume` to avoid resending full history on every request.
Include a `session_id` field in the request body or `X-Session-Id` header:
```json
{
"model": "claude-sonnet-4-6",
"session_id": "conv-abc-123",
"messages": [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi!"},
{"role": "user", "content": "What did I just say?"}
]
}
```
Sessions expire after 1 hour of inactivity (configurable via `CLAUDE_SESSION_TTL`).
| `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 on all requests except `/health`
- **No API keys** — authentication to Anthropic goes through Claude CLI's OAuth session
- **Auto-start** — launchd (macOS) / systemd (Linux) via `node setup.mjs`
- **Remove auto-start**: `node uninstall.mjs`
## Architecture
| | v1.x (pool) | v2.0+ (on-demand) |
|---|---|---|
| Process lifecycle | Pre-spawn idle workers | Spawn per request |
| Crash handling | Backoff spiral | No crash loops |
| Session support | None | `--resume` with tracking |
| Tool access | 6 hardcoded | Configurable, expanded |
| Prompt guard | None | Truncation at 150K chars |
| Monitoring | Basic `/health` | `/usage`, `/status`, `/settings`, `/logs` |
| CLI | None | `ocp` command |
| Gateway plugin | None | `/ocp` slash command |
## Coexistence with Claude Code
OCP and Claude Code interactive mode are completely independent:
| | OCP (this proxy) | Claude Code |
|---|---|---|
| Protocol | HTTP (localhost:3456) | MCP (in-process) |
| Process model | Per-request spawn | Persistent session |
| Lifecycle | Daemon (auto-start) | Requires terminal |
| Use case | Automated agent work | Human-in-the-loop |
Both run on the same machine simultaneously. No shared state, no port conflicts.
## Recovery After OpenClaw Upgrade
```bash
cd ~/.openclaw/projects/claude-proxy
git pull
node setup.mjs
openclaw gateway restart
```
## Changelog
### v3.0.0 (2026-03-24)
- **`/ocp` CLI** — full management from terminal (`ocp usage`, `ocp settings`, etc.)
- **`/ocp` gateway plugin** — native slash command in Telegram/Discord
- **Plan usage monitoring** — real-time session/weekly limits via Anthropic API rate-limit headers
- **Per-model stats** — request count, avg/max elapsed time, avg/max prompt size
- **Runtime settings** — `PATCH /settings` to tune timeouts, concurrency, prompt limits without restart
- **Prompt truncation** — auto-truncate prompts exceeding 150K chars to prevent timeout cascades
- **Circuit breaker removed** — caused more harm than good in CLI-proxy architecture
- **Timeout increases** — Opus 150s, Sonnet 120s, Haiku 45s (base first-byte)
- **New endpoints** — `/usage`, `/status`, `/settings`, `/logs`
### v2.5.0 (2026-03-22)
- Sliding-window circuit breaker (replaced consecutive-count)
- Graduated backoff, multi-probe half-open
- Increased default timeouts for large agent prompts
### v2.0.0
- On-demand spawning (replaced pool architecture)
- Session management with `--resume`
- Full tool access, system prompt, MCP config support
- **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
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; }
}
+283 -8
View File
@@ -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'
@@ -231,12 +388,27 @@ cmd_restart() {
openclaw gateway restart 2>&1
else
echo "Restarting proxy..."
launchctl kickstart -k gui/501/ai.openclaw.proxy 2>/dev/null || {
echo "launchctl failed, trying kill + restart..."
pkill -f "claude-proxy/server.mjs" 2>/dev/null || true
# Try current service name, then legacy, then manual restart
local uid
uid=$(id -u)
if launchctl kickstart -k "gui/$uid/dev.ocp.proxy" 2>/dev/null; then
true
elif launchctl kickstart -k "gui/$uid/ai.openclaw.proxy" 2>/dev/null; then
true
elif systemctl --user restart ocp-proxy 2>/dev/null; then
true
elif systemctl --user restart openclaw-proxy 2>/dev/null; then
true
else
echo "Service restart failed, trying kill + restart..."
pkill -f "server.mjs.*CLAUDE_PROXY" 2>/dev/null || pkill -f "claude-proxy/server.mjs" 2>/dev/null || true
sleep 1
cd "$HOME/.openclaw/projects/claude-proxy" && nohup node server.mjs >> "$HOME/.openclaw/logs/proxy.log" 2>&1 &
}
local self_r script_dir
self_r="${BASH_SOURCE[0]}"
while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done
script_dir="$(cd "$(dirname "$self_r")" && pwd)"
nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
fi
sleep 3
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
echo "✓ Proxy restarted successfully."
@@ -323,6 +495,102 @@ for e in d.get('errors', []):
fi
}
# ── update ──────────────────────────────────────────────────────────────
cmd_update_help() {
cat <<'EOF'
ocp update — Update OCP to the latest version
Pulls the latest code from GitHub, restarts the proxy service,
and optionally syncs the plugin to the OpenClaw extensions directory.
Usage:
ocp update Pull latest and restart
ocp update --check Check for updates without applying
EOF
}
cmd_update() {
local script_dir self
self="${BASH_SOURCE[0]}"
# Resolve symlinks (e.g. ~/.local/bin/ocp → real location)
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
script_dir="$(cd "$(dirname "$self")" && pwd)"
# Check-only mode
if [[ "${1:-}" == "--check" ]]; then
cd "$script_dir"
git fetch origin main --quiet 2>/dev/null || true
local local_ver remote_ver
local_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
remote_ver=$(git show origin/main:package.json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['version'])" 2>/dev/null || echo "?")
local behind
behind=$(git rev-list HEAD..origin/main --count 2>/dev/null || echo "?")
echo "OCP Update Check"
echo "─────────────────────────────────────"
echo " Current: v$local_ver"
echo " Latest: v$remote_ver"
if [[ "$behind" == "0" ]]; then
echo " Status: ✓ Up to date"
else
echo " Status: $behind commit(s) behind"
echo ""
echo " Run 'ocp update' to apply."
fi
return 0
fi
echo "Updating OCP..."
echo ""
# 1. Pull latest
cd "$script_dir"
local old_ver
old_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
echo " Pulling latest from GitHub..."
if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then
echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir"
return 1
fi
local new_ver
new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
if [[ "$old_ver" == "$new_ver" ]]; then
echo " ✓ Already at latest (v$new_ver)"
else
echo " ✓ Updated v$old_ver → v$new_ver"
fi
# 2. Sync plugin to extensions dir
local ext_dir="$HOME/.openclaw/extensions/ocp"
if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then
echo ""
echo " Syncing OCP plugin..."
cp "$script_dir/ocp-plugin/index.js" "$ext_dir/index.js" 2>/dev/null
cp "$script_dir/ocp-plugin/package.json" "$ext_dir/package.json" 2>/dev/null
cp "$script_dir/ocp-plugin/openclaw.plugin.json" "$ext_dir/openclaw.plugin.json" 2>/dev/null
echo " ✓ Plugin synced to $ext_dir"
fi
# 3. Restart proxy
echo ""
echo " Restarting proxy..."
cmd_restart > /dev/null 2>&1
sleep 2
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
local running_ver
running_ver=$(curl -sf --max-time 5 "$PROXY/health" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['version'])" 2>/dev/null || echo "?")
echo " ✓ Proxy running (v$running_ver)"
else
echo " ⚠ Proxy not responding — check: ocp health"
fi
echo ""
echo "Done."
}
# ── help ─────────────────────────────────────────────────────────────────
cmd_help() {
cat <<'EOF'
@@ -331,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
@@ -339,8 +607,12 @@ 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
update --check Check for updates
Run 'ocp <command> --help' for details on a specific command.
EOF
@@ -371,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:-}" ;;
@@ -379,6 +651,9 @@ 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 ;;
esac
+95 -11
View File
@@ -31,19 +31,34 @@ function fmtChars(c) {
async function cmdUsage() {
const d = await fetchJSON("/usage");
const s = d.plan.currentSession;
const w = d.plan.weeklyLimits.allModels;
const e = d.plan.extraUsage;
const plan = d.plan || {};
const s = plan.currentSession || {};
const w = plan.weeklyLimits?.allModels || {};
const e = plan.extraUsage || {};
const px = d.proxy;
const models = d.models || {};
let out = "Plan Usage Limits\n";
out += "─────────────────────────────\n";
out += `Current session ${bar(s.utilization)} ${s.percent}\n`;
out += ` Resets in ${s.resetsIn} (${s.resetsAtHuman})\n\n`;
out += `Weekly (all) ${bar(w.utilization)} ${w.percent}\n`;
out += ` Resets in ${w.resetsIn} (${w.resetsAtHuman})\n\n`;
out += `Extra usage ${e.status === "allowed" ? "on" : "off"}\n\n`;
let out = "";
// Show subscription info if available
if (plan.subscription) {
out += `Plan: ${plan.subscription} (${plan.rateLimitTier || "default"})\n`;
}
if (s.utilization !== null && s.utilization !== undefined) {
// Full plan usage data available (API key mode)
out += "Plan Usage Limits\n";
out += "─────────────────────────────\n";
out += `Current session ${bar(s.utilization)} ${s.percent}\n`;
out += ` Resets in ${s.resetsIn} (${s.resetsAtHuman})\n\n`;
out += `Weekly (all) ${bar(w.utilization)} ${w.percent}\n`;
out += ` Resets in ${w.resetsIn} (${w.resetsAtHuman})\n\n`;
out += `Extra usage ${e.status === "allowed" ? "on" : "off"}\n\n`;
} else {
// No API key — show note
out += "Session/weekly %: claude.ai/settings\n";
out += "(Anthropic OAuth API doesn't expose limits yet)\n\n";
}
const modelNames = Object.keys(models).sort();
if (modelNames.length > 0) {
@@ -151,6 +166,67 @@ async function cmdClear() {
return `Cleared ${d.cleared} sessions.`;
}
async function cmdVersion() {
const d = await fetchJSON("/health");
return `OCP v${d.version || "?"}\nUptime: ${d.uptimeHuman || "?"}\nNode: ${process.version}\nPlatform: ${process.platform} ${process.arch}`;
}
async function cmdTest() {
const t0 = Date.now();
try {
const resp = await fetch(`${PROXY}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "claude-haiku-4-5-20251001",
messages: [{ role: "user", content: "say ok" }],
max_tokens: 5,
}),
signal: AbortSignal.timeout(30000),
});
const d = await resp.json();
const elapsed = Date.now() - t0;
if (d.choices?.[0]?.message?.content) {
return `✓ Proxy OK (${elapsed}ms)\n Model: haiku\n Response: "${d.choices[0].message.content.slice(0, 50)}"`;
}
return `✗ Unexpected response: ${JSON.stringify(d).slice(0, 100)}`;
} catch (e) {
return `✗ Test failed (${Date.now() - t0}ms): ${e.message}`;
}
}
async function cmdRestart(args) {
const target = (args || "").trim().toLowerCase();
const { execSync } = await import("node:child_process");
try {
if (target === "gateway") {
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
return "✓ Gateway restarted";
} else if (target === "all") {
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
// Gateway restart will kill this plugin too, so do it last
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
return "✓ Proxy + Gateway restarted";
} else {
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
return "✓ Proxy restarted";
}
} catch (e) {
// Try systemd for Linux
try {
if (target === "gateway") {
execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 });
return "✓ Gateway restarted";
} else {
execSync("systemctl --user restart openclaw-proxy 2>/dev/null || pkill -f 'node.*server.mjs' && sleep 2 && cd ~/.openclaw/projects/*/; node server.mjs &", { timeout: 15000, shell: true });
return "✓ Proxy restarted";
}
} catch (e2) {
return `✗ Restart failed: ${e2.message?.slice(0, 100)}`;
}
}
}
async function cmdLogs(args) {
const parts = (args || "").trim().split(/\s+/);
const n = parseInt(parts[0]) || 20;
@@ -174,7 +250,12 @@ function cmdHelp() {
/ocp logs [N] [level] Recent logs (default: 20, error)
/ocp models Available models
/ocp sessions Active sessions
/ocp clear Clear all sessions`;
/ocp clear Clear all sessions
/ocp restart Restart proxy
/ocp restart gateway Restart gateway
/ocp restart all Restart both
/ocp version Version & platform info
/ocp test End-to-end proxy test`;
}
// ── Plugin entry point ──────────────────────────────────────────────────
@@ -202,6 +283,9 @@ export default function (api) {
case "models": text = await cmdModels(); break;
case "sessions": text = await cmdSessions(); break;
case "clear": text = await cmdClear(); break;
case "restart": text = await cmdRestart(subargs); break;
case "version": text = await cmdVersion(); break;
case "test": text = await cmdTest(); break;
case "logs": text = await cmdLogs(subargs); break;
case "help": case "--help": case "-h": case "":
text = cmdHelp(); break;
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "ocp",
"name": "OCP Commands",
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
"version": "1.0.0",
"version": "3.3.1",
"configSchema": {
"type": "object",
"additionalProperties": false,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ocp",
"version": "1.0.0",
"version": "3.3.1",
"description": "Slash commands for the OpenClaw Proxy",
"main": "index.js",
"type": "module",
+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"
}
}
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "openclaw-claude-proxy",
"version": "3.0.0",
"description": "OpenAI-compatible proxy for Claude CLI — plan usage monitoring, runtime settings, prompt truncation, OCP CLI",
"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": {
"openclaw-claude-proxy": "./server.mjs",
@@ -24,6 +24,6 @@
},
"repository": {
"type": "git",
"url": "https://github.com/dtzp555-max/openclaw-claude-proxy"
"url": "https://github.com/dtzp555-max/ocp"
}
}
+142 -89
View File
@@ -1,30 +1,19 @@
#!/usr/bin/env node
/**
* openclaw-claude-proxy v2.5.0 — OpenAI-compatible proxy for Claude CLI
* openclaw-claude-proxy — OpenAI-compatible proxy for Claude CLI
*
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
*
* v2.5.0:
* - Sliding-window circuit breaker: uses time-windowed failure rate instead of
* consecutive-count, preventing multi-agent burst scenarios from tripping the
* breaker too aggressively. Half-open state allows configurable probe requests.
* - Graduated backoff: cooldown doubles on each re-open (capped at 5min),
* resets fully on success.
* - Health endpoint now exposes per-model breaker state and sliding window stats.
* - Increased default timeout tiers for Opus/Sonnet to handle large agent prompts.
*
* v2.4.0:
* - Per-model circuit breaker: consecutive timeouts temporarily mark a model as degraded
* - Adaptive first-byte timeout: scales by model tier + prompt size
* - Structured JSON logging for key events (easier to parse/alert on)
* - On-demand spawning (no pool), session management, full tool access
* Timeout design: single CLAUDE_TIMEOUT (default 600s / 10 min).
* No separate first-byte or idle timeout — Claude tool-use causes long pauses
* in the token stream (30s-5min) that make fine-grained timeouts unreliable.
* This matches LiteLLM, OpenAI SDK, and other major LLM proxies.
*
* Env vars:
* CLAUDE_PROXY_PORT — listen port (default: 3456)
* CLAUDE_BIN — path to claude binary (default: auto-detect)
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 300000)
* CLAUDE_FIRST_BYTE_TIMEOUT — base first-byte timeout in ms (default: 90000)
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 600000)
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
@@ -43,6 +32,8 @@ import { randomUUID, timingSafeEqual } from "node:crypto";
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"));
@@ -88,8 +79,7 @@ function resolveClaude() {
// Settings marked with `let` can be changed at runtime via PATCH /settings.
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
const CLAUDE = resolveClaude();
let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10);
let BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "90000", 10);
let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "600000", 10);
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
@@ -103,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();
@@ -357,26 +354,13 @@ function messagesToPrompt(messages) {
return result;
}
// Model tier multipliers for first-byte timeout.
// Opus is much slower to produce first token, especially with large contexts.
let MODEL_TIMEOUT_TIERS = {
"opus": { base: 150000, perPromptChar: 0.00050 }, // 150s base + ~50s per 100k chars
"sonnet": { base: 120000, perPromptChar: 0.00050 }, // 120s base + ~50s per 100k chars
"haiku": { base: 45000, perPromptChar: 0.00010 }, // 45s base + ~10s per 100k chars
};
// Model tier — used for logging only (no timeout logic).
function getModelTier(cliModel) {
if (cliModel.includes("opus")) return "opus";
if (cliModel.includes("haiku")) return "haiku";
return "sonnet";
}
function computeFirstByteTimeout(cliModel, promptLength) {
const tier = MODEL_TIMEOUT_TIERS[getModelTier(cliModel)];
const timeout = tier.base + Math.floor(promptLength * tier.perPromptChar);
return Math.min(timeout, Math.max(TIMEOUT - 5000, 10000));
}
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
// Resolves session logic, builds CLI args, spawns the process, and sets up
// timeouts. Returns context object or throws synchronously.
@@ -436,7 +420,6 @@ function spawnClaudeProcess(model, messages, conversationId) {
activeProcesses.add(proc);
const t0 = Date.now();
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
let gotFirstByte = false;
let cleaned = false;
@@ -444,7 +427,6 @@ function spawnClaudeProcess(model, messages, conversationId) {
if (cleaned) return;
cleaned = true;
clearTimeout(overallTimer);
clearTimeout(firstByteTimer);
stats.activeRequests--;
}
@@ -458,7 +440,6 @@ function spawnClaudeProcess(model, messages, conversationId) {
function markFirstByte() {
if (!gotFirstByte) {
gotFirstByte = true;
clearTimeout(firstByteTimer);
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
}
}
@@ -468,27 +449,17 @@ function spawnClaudeProcess(model, messages, conversationId) {
proc.stdin.end();
recordModelRequest(cliModel, prompt.length);
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, timeout: TIMEOUT, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
// First-byte timeout
const firstByteTimer = setTimeout(() => {
if (!gotFirstByte && !cleaned) {
stats.timeouts++;
recordModelError(cliModel, true);
breakerRecordTimeout(cliModel);
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
}
}, firstByteTimeoutMs);
// Overall request timeout
// Single request timeout — no separate first-byte timer.
// Claude tool-use causes long pauses in the token stream (30s-5min),
// making first-byte/idle timeouts unreliable. One generous timeout is simpler and correct.
const overallTimer = setTimeout(() => {
if (!cleaned) {
stats.timeouts++;
recordModelError(cliModel, true);
breakerRecordTimeout(cliModel);
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT, elapsed: Date.now() - t0 });
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
}
@@ -551,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);
@@ -606,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();
@@ -623,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();
@@ -687,6 +660,15 @@ let usageCache = { data: null, fetchedAt: 0 };
const USAGE_CACHE_TTL = 300000; // 5 min
function getOAuthToken() {
// Try Linux file-based credentials first
try {
const credPath = join(homedir(), ".claude", ".credentials.json");
const creds = JSON.parse(readFileSync(credPath, "utf8"));
const token = creds?.claudeAiOauth?.accessToken;
if (token) return token;
} catch { /* fall through to macOS keychain */ }
// Try macOS keychain
try {
const raw = execFileSync("security", [
"find-generic-password", "-s", "Claude Code-credentials", "-w"
@@ -919,31 +901,18 @@ async function handleStatus(_req, res) {
//
// Tunable keys and their types/ranges:
const SETTINGS_SCHEMA = {
timeout: { type: "number", min: 30000, max: 600000, unit: "ms", desc: "Overall request timeout" },
firstByteTimeout: { type: "number", min: 15000, max: 300000, unit: "ms", desc: "Base first-byte timeout" },
timeout: { type: "number", min: 30000, max: 1800000, unit: "ms", desc: "Request timeout (default: 600s)" },
maxConcurrent: { type: "number", min: 1, max: 32, unit: "", desc: "Max concurrent claude processes" },
sessionTTL: { type: "number", min: 60000, max: 86400000, unit: "ms", desc: "Session idle expiry" },
maxPromptChars: { type: "number", min: 10000, max: 1000000, unit: "chars", desc: "Prompt truncation limit" },
"tiers.opus.base": { type: "number", min: 30000, max: 600000, unit: "ms", desc: "Opus base first-byte timeout" },
"tiers.opus.perChar": { type: "number", min: 0, max: 0.01, unit: "ms/char", desc: "Opus per-char timeout addition" },
"tiers.sonnet.base": { type: "number", min: 30000, max: 600000, unit: "ms", desc: "Sonnet base first-byte timeout" },
"tiers.sonnet.perChar": { type: "number", min: 0, max: 0.01, unit: "ms/char", desc: "Sonnet per-char timeout addition" },
"tiers.haiku.base": { type: "number", min: 15000, max: 300000, unit: "ms", desc: "Haiku base first-byte timeout" },
"tiers.haiku.perChar": { type: "number", min: 0, max: 0.01, unit: "ms/char", desc: "Haiku per-char timeout addition" },
};
function getSettings() {
return {
timeout: { value: TIMEOUT, ...SETTINGS_SCHEMA.timeout },
firstByteTimeout: { value: BASE_FIRST_BYTE_TIMEOUT, ...SETTINGS_SCHEMA.firstByteTimeout },
maxConcurrent: { value: MAX_CONCURRENT, ...SETTINGS_SCHEMA.maxConcurrent },
sessionTTL: { value: SESSION_TTL, ...SETTINGS_SCHEMA.sessionTTL },
maxPromptChars: { value: MAX_PROMPT_CHARS, ...SETTINGS_SCHEMA.maxPromptChars },
tiers: {
opus: { base: MODEL_TIMEOUT_TIERS.opus.base, perPromptChar: MODEL_TIMEOUT_TIERS.opus.perPromptChar },
sonnet: { base: MODEL_TIMEOUT_TIERS.sonnet.base, perPromptChar: MODEL_TIMEOUT_TIERS.sonnet.perPromptChar },
haiku: { base: MODEL_TIMEOUT_TIERS.haiku.base, perPromptChar: MODEL_TIMEOUT_TIERS.haiku.perPromptChar },
},
};
}
@@ -955,16 +924,9 @@ function applySettingUpdate(key, value) {
switch (key) {
case "timeout": TIMEOUT = value; break;
case "firstByteTimeout": BASE_FIRST_BYTE_TIMEOUT = value; break;
case "maxConcurrent": MAX_CONCURRENT = value; break;
case "sessionTTL": SESSION_TTL = value; break;
case "maxPromptChars": MAX_PROMPT_CHARS = value; break;
case "tiers.opus.base": MODEL_TIMEOUT_TIERS.opus.base = value; break;
case "tiers.opus.perChar": MODEL_TIMEOUT_TIERS.opus.perPromptChar = value; break;
case "tiers.sonnet.base": MODEL_TIMEOUT_TIERS.sonnet.base = value; break;
case "tiers.sonnet.perChar": MODEL_TIMEOUT_TIERS.sonnet.perPromptChar = value; break;
case "tiers.haiku.base": MODEL_TIMEOUT_TIERS.haiku.base = value; break;
case "tiers.haiku.perChar": MODEL_TIMEOUT_TIERS.haiku.perPromptChar = value; break;
default: return `${key}: not implemented`;
}
logEvent("info", "setting_changed", { key, value });
@@ -1043,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 {}
@@ -1064,25 +1030,59 @@ 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) : "";
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" } });
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") {
return jsonResponse(res, 200, {
@@ -1126,7 +1126,6 @@ const server = createServer(async (req, res) => {
auth: authStatus,
config: {
timeout: TIMEOUT,
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
maxConcurrent: MAX_CONCURRENT,
sessionTTL: SESSION_TTL,
circuitBreaker: "disabled",
@@ -1178,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" });
});
@@ -1198,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) {
@@ -1235,18 +1285,21 @@ 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}`);
console.log(`Timeout: ${TIMEOUT}ms (base first-byte: ${BASE_FIRST_BYTE_TIMEOUT}ms, adaptive by model/prompt) | Max concurrent: ${MAX_CONCURRENT}`);
console.log(`Timeout: ${TIMEOUT / 1000}s | Max concurrent: ${MAX_CONCURRENT}`);
console.log(`Circuit breaker: disabled`);
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
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)`);
+72 -14
View File
@@ -12,7 +12,7 @@
* 4. Creates start.sh for easy launch
* 5. Optionally starts the proxy
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
import { execSync } from "node:child_process";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
@@ -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",
@@ -169,6 +171,19 @@ for (const [key, val] of Object.entries(MODEL_ALIASES)) {
}
log(`Model aliases added to agents.defaults.models`);
// Set idleTimeoutSeconds to 0 — critical for Claude tool-use.
// When Claude calls tools (Bash, Read, etc.), the token stream pauses for 30-120s.
// OpenClaw's default idleTimeoutSeconds (60s) kills the connection mid-tool-call,
// causing exit 143 (SIGTERM) and stuck sessions. Setting to 0 disables the idle timer.
if (!config.agents.defaults.llm) config.agents.defaults.llm = {};
if (config.agents.defaults.llm.idleTimeoutSeconds === undefined ||
config.agents.defaults.llm.idleTimeoutSeconds > 0) {
config.agents.defaults.llm.idleTimeoutSeconds = 0;
log(`Set agents.defaults.llm.idleTimeoutSeconds = 0 (prevents tool-call timeouts)`);
} else {
log(`idleTimeoutSeconds already configured: ${config.agents.defaults.llm.idleTimeoutSeconds}`);
}
writeJSON(CONFIG_PATH, config);
log(`Config saved`);
@@ -285,26 +300,62 @@ if (!DRY_RUN) {
console.log("\n🔄 Installing auto-start on login...\n");
const platform = process.platform;
const nodeBin = process.execPath;
// Use stable symlink path instead of versioned Cellar path (e.g. /opt/homebrew/opt/node/bin/node
// instead of /opt/homebrew/Cellar/node/25.8.0/bin/node) so the plist survives node upgrades.
let nodeBin = process.execPath;
if (platform === "darwin" && nodeBin.includes("/Cellar/")) {
const stable = nodeBin.replace(/\/Cellar\/[^/]+\/[^/]+\//, "/opt/");
if (existsSync(stable)) {
nodeBin = stable;
log(`Using stable node path: ${nodeBin}`);
}
}
// Ensure logs dir exists
const logsDir = join(OPENCLAW_DIR, "logs");
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
// Use neutral service names to avoid OpenClaw gateway's extra-service detection.
// OpenClaw scans LaunchAgent plists and systemd units for "openclaw" / "clawdbot"
// markers and flags them as conflicting gateway-like services. Using "dev.ocp.*"
// and "ocp-proxy" keeps the proxy invisible to that heuristic.
const OCP_HOME = join(HOME, ".ocp");
const ocpLogsDir = join(OCP_HOME, "logs");
if (!existsSync(ocpLogsDir)) mkdirSync(ocpLogsDir, { recursive: true });
// Uninstall legacy service names if present (upgrade path)
if (platform === "darwin") {
const legacyPlist = join(HOME, "Library", "LaunchAgents", "ai.openclaw.proxy.plist");
if (existsSync(legacyPlist)) {
try { execSync(`launchctl bootout gui/$(id -u) "${legacyPlist}" 2>/dev/null`); } catch { /* ignore */ }
try { unlinkSync(legacyPlist); } catch { /* ignore */ }
log(`Removed legacy plist: ai.openclaw.proxy`);
}
} else if (platform === "linux") {
const legacyService = join(HOME, ".config", "systemd", "user", "openclaw-proxy.service");
if (existsSync(legacyService)) {
try { execSync(`systemctl --user stop openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
try { execSync(`systemctl --user disable openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
try { unlinkSync(legacyService); } catch { /* ignore */ }
try { execSync(`systemctl --user daemon-reload`); } catch { /* ignore */ }
log(`Removed legacy systemd service: openclaw-proxy`);
}
}
if (platform === "darwin") {
// macOS: launchd
const plistDir = join(HOME, "Library", "LaunchAgents");
if (!existsSync(plistDir)) mkdirSync(plistDir, { recursive: true });
const plistPath = join(plistDir, "ai.openclaw.proxy.plist");
const logPath = join(logsDir, "proxy.log");
const plistPath = join(plistDir, "dev.ocp.proxy.plist");
const logPath = join(ocpLogsDir, "proxy.log");
const plistXml = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>ai.openclaw.proxy</string>
<string>dev.ocp.proxy</string>
<key>ProgramArguments</key>
<array>
<string>${nodeBin}</string>
@@ -314,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/>
@@ -330,27 +385,30 @@ if (!DRY_RUN) {
writeFileSync(plistPath, plistXml);
log(`Plist written: ${plistPath}`);
// Unload first (in case it was already loaded) then load
try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
execSync(`launchctl load "${plistPath}"`);
log(`launchctl loaded ai.openclaw.proxy`);
// Bootout first (in case it was already loaded) then bootstrap
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
execSync(`launchctl bootstrap gui/$(id -u) "${plistPath}"`);
log(`launchctl loaded dev.ocp.proxy`);
} else if (platform === "linux") {
// Linux: systemd user service
const systemdDir = join(HOME, ".config", "systemd", "user");
if (!existsSync(systemdDir)) mkdirSync(systemdDir, { recursive: true });
const servicePath = join(systemdDir, "openclaw-proxy.service");
const logPath = join(logsDir, "proxy.log");
const servicePath = join(systemdDir, "ocp-proxy.service");
const logPath = join(ocpLogsDir, "proxy.log");
const serviceUnit = `[Unit]
Description=OpenClaw Claude Proxy
Description=OCP — Open Claude Proxy
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}
StandardError=append:${logPath}
@@ -362,8 +420,8 @@ WantedBy=default.target
log(`Service file written: ${servicePath}`);
execSync(`systemctl --user daemon-reload`);
execSync(`systemctl --user enable openclaw-proxy`);
execSync(`systemctl --user start openclaw-proxy`);
execSync(`systemctl --user enable ocp-proxy`);
execSync(`systemctl --user start ocp-proxy`);
log(`systemd user service enabled and started`);
} else {
+1 -1
View File
@@ -3,7 +3,7 @@
PORT=${CLAUDE_PROXY_PORT:-3456}
if ! lsof -i :$PORT -sTCP:LISTEN &>/dev/null; then
unset CLAUDECODE
nohup node "/Users/taodeng/.openclaw/projects/claude-proxy/openclaw-claude-proxy/server.mjs" \
nohup node "/Users/taodeng/.openclaw/projects/claude-proxy/server.mjs" \
>> "/Users/taodeng/.openclaw/logs/claude-proxy.log" \
2>> "/Users/taodeng/.openclaw/logs/claude-proxy.err.log" &
echo "claude-proxy started on port $PORT (pid $!)"
+35 -23
View File
@@ -3,6 +3,9 @@
* openclaw-claude-proxy uninstaller
*
* Stops and removes the launchd (macOS) or systemd (Linux) auto-start entry.
* Handles both legacy (ai.openclaw.proxy / openclaw-proxy) and current
* (dev.ocp.proxy / ocp-proxy) service names.
*
* Run: node uninstall.mjs
*/
import { existsSync, unlinkSync } from "node:fs";
@@ -15,43 +18,52 @@ const HOME = homedir();
function log(msg) { console.log(`${msg}`); }
function warn(msg) { console.log(`${msg}`); }
console.log("\n🗑 Uninstalling openclaw-claude-proxy auto-start...\n");
console.log("\n🗑 Uninstalling OCP auto-start...\n");
const platform = process.platform;
if (platform === "darwin") {
const plistPath = join(HOME, "Library", "LaunchAgents", "ai.openclaw.proxy.plist");
// Remove current service
const plistPath = join(HOME, "Library", "LaunchAgents", "dev.ocp.proxy.plist");
if (existsSync(plistPath)) {
try {
execSync(`launchctl unload "${plistPath}" 2>/dev/null`);
log("launchd service stopped and unloaded");
} catch {
warn("launchctl unload failed (service may not have been running)");
}
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
unlinkSync(plistPath);
log(`Plist removed: ${plistPath}`);
} else {
warn(`Plist not found: ${plistPath}`);
log(`Removed: ${plistPath}`);
}
// Remove legacy service
const legacyPath = join(HOME, "Library", "LaunchAgents", "ai.openclaw.proxy.plist");
if (existsSync(legacyPath)) {
try { execSync(`launchctl bootout gui/$(id -u) "${legacyPath}" 2>/dev/null`); } catch { /* ignore */ }
unlinkSync(legacyPath);
log(`Removed legacy: ${legacyPath}`);
}
if (!existsSync(plistPath) && !existsSync(legacyPath)) {
warn("No plist found (service may not have been installed)");
}
} else if (platform === "linux") {
const servicePath = join(HOME, ".config", "systemd", "user", "openclaw-proxy.service");
try { execSync(`systemctl --user stop openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
log("systemd service stopped");
try { execSync(`systemctl --user disable openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
log("systemd service disabled");
// Remove current service
const servicePath = join(HOME, ".config", "systemd", "user", "ocp-proxy.service");
try { execSync(`systemctl --user stop ocp-proxy 2>/dev/null`); } catch { /* ignore */ }
try { execSync(`systemctl --user disable ocp-proxy 2>/dev/null`); } catch { /* ignore */ }
if (existsSync(servicePath)) {
unlinkSync(servicePath);
log(`Service file removed: ${servicePath}`);
} else {
warn(`Service file not found: ${servicePath}`);
log(`Removed: ${servicePath}`);
}
// Remove legacy service
const legacyPath = join(HOME, ".config", "systemd", "user", "openclaw-proxy.service");
try { execSync(`systemctl --user stop openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
try { execSync(`systemctl --user disable openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
if (existsSync(legacyPath)) {
unlinkSync(legacyPath);
log(`Removed legacy: ${legacyPath}`);
}
try { execSync(`systemctl --user daemon-reload`); } catch { /* ignore */ }
log("systemd daemon reloaded");
} else {
warn(`Auto-start not supported on ${platform} — nothing to remove`);