mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69b20815fa | ||
|
|
3eecca35ce | ||
|
|
9f1a21f7ad | ||
|
|
a0f9268af5 | ||
|
|
087e26346f | ||
|
|
ed53c5e0c5 | ||
|
|
18cb759359 | ||
|
|
cc745aa5d9 | ||
|
|
1983f9372d | ||
|
|
471bda2c40 | ||
|
|
566a01a6bd | ||
|
|
43daf8162c | ||
|
|
aa0adab784 | ||
|
|
4f4e1edf16 | ||
|
|
ea57db6ceb | ||
|
|
5fbeaed568 | ||
|
|
2b18884d2a | ||
|
|
4f72f4844e | ||
|
|
bc17b27f2b | ||
|
|
3e8ff7a509 | ||
|
|
b6b33d2623 | ||
|
|
8796a5fc19 | ||
|
|
609ceb28b0 | ||
|
|
3843ec8fe6 | ||
|
|
afe0c6e1be | ||
|
|
a6007393a3 | ||
|
|
8592150f7a | ||
|
|
eb76971ffc | ||
|
|
d54e73ef89 | ||
|
|
3f10b459f5 | ||
|
|
b72e449337 | ||
|
|
2b05558b8b | ||
|
|
d9b7c0d905 | ||
|
|
27cd8b1735 | ||
|
|
9dd35c90ae | ||
|
|
c005a8fd27 | ||
|
|
77a3f48cfd | ||
|
|
7390a19ec6 | ||
|
|
04643217ae | ||
|
|
3de1868313 | ||
|
|
75cff38f4c | ||
|
|
a8a0af43c9 | ||
|
|
77a6f8ed59 | ||
|
|
b5be1c04f4 | ||
|
|
1f1b3871da | ||
|
|
f6f4f68ecd |
@@ -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.
|
||||||
@@ -1,45 +1,234 @@
|
|||||||
# 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.5.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` |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client** (connects to a server, zero dependencies).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Server (always-on device) ─────────────────────────────┐
|
||||||
|
│ Mac mini / NAS / Raspberry Pi / Desktop │
|
||||||
|
│ Claude CLI + OCP server → bound to 0.0.0.0:3456 │
|
||||||
|
└───────────────────────┬─────────────────────────────────┘
|
||||||
|
│ LAN
|
||||||
|
┌───────────────────┼───────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
Laptop Phone/Tablet Pi / Server
|
||||||
|
(client) (browser) (client)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Server Setup
|
||||||
|
|
||||||
|
> **Recommended:** Install OCP on a device that stays powered on — Mac mini, NAS, Raspberry Pi, or a desktop that doesn't sleep. This ensures all clients always have access.
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Node.js 18+
|
||||||
|
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) installed and authenticated (`claude auth login`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone and run setup
|
||||||
|
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)
|
||||||
|
4. Symlink `ocp` to `/usr/local/bin` for CLI access
|
||||||
|
|
||||||
|
**Single-machine use** — just set your IDE to use the proxy:
|
||||||
|
```bash
|
||||||
|
export OPENAI_BASE_URL=http://127.0.0.1:3456/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
**LAN mode** — share with other devices on your network:
|
||||||
|
```bash
|
||||||
|
# Enable LAN access with per-user auth (recommended)
|
||||||
|
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||||
|
```
|
||||||
|
|
||||||
|
Then create API keys for each person/device:
|
||||||
|
```bash
|
||||||
|
export OCP_ADMIN_KEY=your-secret-admin-key
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `ocp lan` to see your IP and ready-to-share instructions.
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:3456/v1/models
|
||||||
|
# Returns: claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Client Setup
|
||||||
|
|
||||||
|
> Clients do **not** need to install Node.js, Claude CLI, or the OCP repo. Only `curl` and `python3` are required (pre-installed on most Linux/Mac systems).
|
||||||
|
|
||||||
|
**One-command setup** — download the lightweight `ocp-connect` script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
|
||||||
|
chmod +x ocp-connect
|
||||||
|
./ocp-connect <server-ip> --key <your-api-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or as a one-liner (no file saved):
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect | bash -s -- <server-ip> --key <your-api-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
$ ./ocp-connect 192.168.1.100 --key ocp_xDYzOB9ZKYzn
|
||||||
|
|
||||||
|
OCP Connect
|
||||||
|
─────────────────────────────────────
|
||||||
|
Remote: http://192.168.1.100:3456
|
||||||
|
|
||||||
|
Checking connectivity...
|
||||||
|
✓ Connected
|
||||||
|
|
||||||
|
Remote OCP v3.5.0 (auth: multi)
|
||||||
|
|
||||||
|
Testing API access...
|
||||||
|
✓ API accessible (3 models available)
|
||||||
|
|
||||||
|
Written to /home/user/.bashrc:
|
||||||
|
OPENAI_BASE_URL=http://192.168.1.100:3456/v1
|
||||||
|
OPENAI_API_KEY=ocp_xDYz...
|
||||||
|
|
||||||
|
Running smoke test...
|
||||||
|
✓ Smoke test passed: OK
|
||||||
|
|
||||||
|
Done. Reload your shell to apply:
|
||||||
|
source /home/user/.bashrc
|
||||||
|
```
|
||||||
|
|
||||||
|
After running, reload your shell (`source ~/.bashrc` or `source ~/.zshrc`) and your IDE will automatically use the remote OCP.
|
||||||
|
|
||||||
|
**Manual setup** — if you prefer not to use the script:
|
||||||
|
```bash
|
||||||
|
export OPENAI_BASE_URL=http://<server-ip>:3456/v1
|
||||||
|
export OPENAI_API_KEY=ocp_<your-key>
|
||||||
|
```
|
||||||
|
Add these lines to `~/.bashrc` or `~/.zshrc` to persist across sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Monitoring (Server-side)
|
||||||
|
|
||||||
|
```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://<server-ip>:3456/dashboard` in any browser for real-time monitoring — per-key usage, request history, plan utilization, and system health.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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
|
$ ocp usage
|
||||||
Plan Usage Limits
|
Plan Usage Limits
|
||||||
─────────────────────────────────────
|
─────────────────────────────────────
|
||||||
Current session 3% used
|
Current session 21% used
|
||||||
Resets in 4h 32m (Tue, Mar 24, 10:00 PM)
|
Resets in 3h 12m (Tue, Mar 28, 10:00 PM)
|
||||||
|
|
||||||
Weekly (all models) 3% used
|
Weekly (all models) 45% used
|
||||||
Resets in 6d 6h (Tue, Mar 31, 12:00 AM)
|
Resets in 4d 2h (Tue, Mar 31, 12:00 AM)
|
||||||
|
|
||||||
Extra usage off
|
Extra usage off
|
||||||
|
|
||||||
Model Stats
|
Model Stats
|
||||||
Model Req OK Er AvgT MaxT AvgP MaxP
|
Model Req OK Er AvgT MaxT AvgP MaxP
|
||||||
──────────────────────────────────────────────────────
|
──────────────────────────────────────────────────────
|
||||||
haiku 1 1 0 6s 6s 0K 0K
|
opus 5 5 0 32s 87s 42K 43K
|
||||||
opus 2 2 0 20s 26s 42K 43K
|
sonnet 18 18 0 20s 45s 36K 56K
|
||||||
sonnet 2 2 0 24s 24s 41K 41K
|
Total 23
|
||||||
Total 5
|
|
||||||
|
|
||||||
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 Plan usage limits & model stats
|
||||||
|
ocp usage --by-key Per-key usage breakdown (LAN mode)
|
||||||
ocp status Quick overview
|
ocp status Quick overview
|
||||||
ocp health Proxy diagnostics
|
ocp health Proxy diagnostics
|
||||||
|
ocp keys List all API keys (multi mode)
|
||||||
|
ocp keys add <name> Create a new API key
|
||||||
|
ocp keys revoke <name> Revoke an API key
|
||||||
|
ocp connect <ip> One-command LAN client setup
|
||||||
|
ocp lan Show LAN connection info & IP
|
||||||
ocp settings View tunable settings
|
ocp settings View tunable settings
|
||||||
ocp settings <k> <v> Update a setting at runtime
|
ocp settings <k> <v> Update a setting at runtime
|
||||||
ocp logs [N] [level] Recent logs (default: 20, error)
|
ocp logs [N] [level] Recent logs (default: 20, error)
|
||||||
@@ -48,109 +237,89 @@ ocp sessions Active sessions
|
|||||||
ocp clear Clear all sessions
|
ocp clear Clear all sessions
|
||||||
ocp restart Restart proxy
|
ocp restart Restart proxy
|
||||||
ocp restart gateway Restart gateway
|
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)
|
### 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
|
$ ocp settings maxPromptChars 200000
|
||||||
✓ maxPromptChars = 200000
|
✓ maxPromptChars = 200000
|
||||||
|
|
||||||
$ ocp settings maxConcurrent 999
|
$ ocp settings maxConcurrent 4
|
||||||
✗ maxConcurrent: value 999 out of range [1, 32]
|
✓ 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
|
## 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
|
| Model ID | Notes |
|
||||||
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git
|
|----------|-------|
|
||||||
cd openclaw-claude-proxy
|
| `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
|
## API Endpoints
|
||||||
node setup.mjs
|
|
||||||
```
|
|
||||||
|
|
||||||
The setup script will:
|
| Endpoint | Method | Description |
|
||||||
1. Verify Claude CLI is installed and authenticated
|
|----------|--------|-------------|
|
||||||
2. Add `claude-local` provider to `openclaw.json`
|
| `/v1/models` | GET | List available models |
|
||||||
3. Start the proxy and install auto-start (launchd on macOS, systemd on Linux)
|
| `/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:
|
## OpenClaw Integration
|
||||||
```bash
|
|
||||||
openclaw config set agents.defaults.model.primary "claude-local/claude-sonnet-4-6"
|
|
||||||
openclaw gateway restart
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
### Install the Gateway Plugin
|
||||||
# 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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -r ocp-plugin/ ~/.openclaw/extensions/ocp/
|
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`:
|
Add to `~/.openclaw/openclaw.json`:
|
||||||
@@ -163,133 +332,99 @@ Add to `~/.openclaw/openclaw.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart the gateway: `openclaw gateway restart`
|
Restart: `openclaw gateway restart`
|
||||||
|
|
||||||
## API Endpoints
|
### Telegram / Discord Usage
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
After installing the gateway plugin, use `/ocp` slash commands in your chat:
|
||||||
|----------|--------|-------------|
|
|
||||||
| `/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 |
|
|
||||||
|
|
||||||
## Available Models
|
```
|
||||||
|
/ocp status — Quick overview
|
||||||
|
/ocp usage — Plan usage limits & model stats
|
||||||
|
/ocp models — Available models
|
||||||
|
/ocp health — Proxy diagnostics
|
||||||
|
/ocp keys — List all API keys (multi mode)
|
||||||
|
/ocp keys add <name> — Create a new key
|
||||||
|
/ocp keys revoke <name> — Revoke a key
|
||||||
|
```
|
||||||
|
|
||||||
| Model ID | Claude CLI Model | Notes |
|
> **Note:** Terminal CLI uses `ocp <command>`, Telegram/Discord uses `/ocp <command>`.
|
||||||
|----------|-----------------|-------|
|
|
||||||
| `claude-opus-4-6` | claude-opus-4-6 | Most capable, slower |
|
## Troubleshooting
|
||||||
| `claude-sonnet-4-6` | claude-sonnet-4-6 | Good balance of speed/quality |
|
|
||||||
| `claude-haiku-4` | claude-haiku-4-5-20251001 | Fastest, lightweight |
|
### Requests fail with exit 143 / SIGTERM after ~60 seconds
|
||||||
|
|
||||||
|
**Symptom:** Claude returns errors or stops responding after about 60 seconds, especially during tool use (Bash, Read, etc.).
|
||||||
|
|
||||||
|
**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
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
||||||
|
| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) |
|
||||||
|
| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` |
|
||||||
|
| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) |
|
||||||
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
||||||
| `CLAUDE_TIMEOUT` | `300000` | Overall request timeout (ms) |
|
| `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
|
||||||
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `90000` | Base first-byte timeout (ms) |
|
|
||||||
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
|
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
|
||||||
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
|
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
|
||||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
|
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
|
||||||
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
|
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
|
||||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
||||||
| `CLAUDE_SYSTEM_PROMPT` | *(empty)* | System prompt appended to all requests |
|
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
|
||||||
| `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`).
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- **Localhost only** — binds to `127.0.0.1`, not exposed to the network
|
- **Localhost by default** — binds to `127.0.0.1`; set `CLAUDE_BIND=0.0.0.0` to enable LAN access
|
||||||
- **Bearer token auth (optional)** — set `PROXY_API_KEY` to require auth on all requests except `/health`
|
- **3-tier auth** — `none` (trusted network), `shared` (single key), `multi` (per-user keys with usage tracking)
|
||||||
- **No API keys** — authentication to Anthropic goes through Claude CLI's OAuth session
|
- **Timing-safe key comparison** — prevents timing attacks on API keys and admin keys
|
||||||
- **Auto-start** — launchd (macOS) / systemd (Linux) via `node setup.mjs`
|
- **Admin-only key management** — creating, listing, and revoking keys requires the admin key
|
||||||
- **Remove auto-start**: `node uninstall.mjs`
|
- **Public endpoints** — `/health` and `/dashboard` are always accessible without auth
|
||||||
|
- **No API keys needed** — authentication goes through Claude CLI's OAuth session
|
||||||
## Architecture
|
- **Keys stored locally** — `~/.ocp/ocp.db` (SQLite), never sent to external services
|
||||||
|
- **Auto-start** — launchd (macOS) / systemd (Linux)
|
||||||
| | 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
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+247
@@ -0,0 +1,247 @@
|
|||||||
|
<!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 urlToken = new URLSearchParams(window.location.search).get("token");
|
||||||
|
const storedToken = urlToken || localStorage.getItem("ocp_token");
|
||||||
|
if (urlToken) { localStorage.setItem("ocp_token", urlToken); history.replaceState(null, "", "/dashboard"); }
|
||||||
|
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>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
|||||||
|
// keys.mjs — API key management and usage tracking for OCP LAN mode
|
||||||
|
// Uses Node.js built-in SQLite (node:sqlite) — zero external dependencies.
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
|
||||||
|
const OCP_DIR = join(homedir(), ".ocp");
|
||||||
|
mkdirSync(OCP_DIR, { recursive: true });
|
||||||
|
const DB_PATH = join(OCP_DIR, "ocp.db");
|
||||||
|
|
||||||
|
let db;
|
||||||
|
|
||||||
|
export function getDb() {
|
||||||
|
if (!db) {
|
||||||
|
db = new DatabaseSync(DB_PATH);
|
||||||
|
db.exec("PRAGMA journal_mode = WAL");
|
||||||
|
db.exec("PRAGMA foreign_keys = ON");
|
||||||
|
initSchema();
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSchema() {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
key TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
revoked INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS usage_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
key_id INTEGER,
|
||||||
|
key_name TEXT NOT NULL DEFAULT 'anonymous',
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
prompt_chars INTEGER NOT NULL DEFAULT 0,
|
||||||
|
response_chars INTEGER NOT NULL DEFAULT 0,
|
||||||
|
elapsed_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (key_id) REFERENCES api_keys(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_log(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_key ON usage_log(key_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key CRUD ──
|
||||||
|
|
||||||
|
export function createKey(name) {
|
||||||
|
const key = "ocp_" + randomBytes(24).toString("base64url");
|
||||||
|
const d = getDb();
|
||||||
|
const stmt = d.prepare("INSERT INTO api_keys (key, name) VALUES (?, ?)");
|
||||||
|
const result = stmt.run(key, name);
|
||||||
|
return { id: result.lastInsertRowid, key, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listKeys() {
|
||||||
|
const d = getDb();
|
||||||
|
return d.prepare(
|
||||||
|
"SELECT id, key, name, created_at, revoked FROM api_keys ORDER BY created_at DESC"
|
||||||
|
).all().map(({ key, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
keyPreview: key.slice(0, 8) + "..." + key.slice(-4),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeKey(idOrName) {
|
||||||
|
const d = getDb();
|
||||||
|
const stmt = d.prepare(
|
||||||
|
"UPDATE api_keys SET revoked = 1 WHERE (id = ? OR name = ?) AND revoked = 0"
|
||||||
|
);
|
||||||
|
return stmt.run(idOrName, idOrName).changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateKey(key) {
|
||||||
|
const d = getDb();
|
||||||
|
const row = d.prepare(
|
||||||
|
"SELECT id, name FROM api_keys WHERE key = ? AND revoked = 0"
|
||||||
|
).get(key);
|
||||||
|
return row || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Usage recording ──
|
||||||
|
|
||||||
|
export function recordUsage({ keyId, keyName, model, promptChars, responseChars, elapsedMs, success }) {
|
||||||
|
const d = getDb();
|
||||||
|
d.prepare(`
|
||||||
|
INSERT INTO usage_log (key_id, key_name, model, prompt_chars, response_chars, elapsed_ms, success)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(keyId ?? null, keyName || "anonymous", model, promptChars, responseChars, elapsedMs, success ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Usage queries ──
|
||||||
|
|
||||||
|
export function getUsageByKey({ since, until } = {}) {
|
||||||
|
const d = getDb();
|
||||||
|
let where = "WHERE 1=1";
|
||||||
|
const params = [];
|
||||||
|
if (since) { where += " AND created_at >= ?"; params.push(since); }
|
||||||
|
if (until) { where += " AND created_at <= ?"; params.push(until); }
|
||||||
|
|
||||||
|
return d.prepare(`
|
||||||
|
SELECT
|
||||||
|
key_name,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successes,
|
||||||
|
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as errors,
|
||||||
|
SUM(prompt_chars) as total_prompt_chars,
|
||||||
|
SUM(response_chars) as total_response_chars,
|
||||||
|
SUM(elapsed_ms) as total_elapsed_ms,
|
||||||
|
AVG(elapsed_ms) as avg_elapsed_ms,
|
||||||
|
MIN(created_at) as first_request,
|
||||||
|
MAX(created_at) as last_request
|
||||||
|
FROM usage_log
|
||||||
|
${where}
|
||||||
|
GROUP BY key_name
|
||||||
|
ORDER BY requests DESC
|
||||||
|
`).all(...params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUsageTimeline({ keyName, hours = 24 } = {}) {
|
||||||
|
const d = getDb();
|
||||||
|
const since = new Date(Date.now() - hours * 3600000).toISOString();
|
||||||
|
let where = "WHERE created_at >= ?";
|
||||||
|
const params = [since];
|
||||||
|
if (keyName) { where += " AND key_name = ?"; params.push(keyName); }
|
||||||
|
|
||||||
|
return d.prepare(`
|
||||||
|
SELECT
|
||||||
|
strftime('%Y-%m-%dT%H:00:00', created_at) as hour,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
SUM(prompt_chars) as prompt_chars,
|
||||||
|
SUM(response_chars) as response_chars,
|
||||||
|
AVG(elapsed_ms) as avg_elapsed_ms
|
||||||
|
FROM usage_log
|
||||||
|
${where}
|
||||||
|
GROUP BY hour
|
||||||
|
ORDER BY hour
|
||||||
|
`).all(...params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentUsage(limit = 50) {
|
||||||
|
const d = getDb();
|
||||||
|
return d.prepare(`
|
||||||
|
SELECT key_name, model, prompt_chars, response_chars, elapsed_ms, success, created_at
|
||||||
|
FROM usage_log
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeDb() {
|
||||||
|
if (db) { db.close(); db = null; }
|
||||||
|
}
|
||||||
@@ -8,6 +8,23 @@ set -euo pipefail
|
|||||||
|
|
||||||
PROXY="http://127.0.0.1:3456"
|
PROXY="http://127.0.0.1:3456"
|
||||||
|
|
||||||
|
# Auth header for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
||||||
|
_AUTH_HEADER=""
|
||||||
|
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
|
||||||
|
_AUTH_HEADER="-H \"Authorization: Bearer $OCP_ADMIN_KEY\""
|
||||||
|
elif [[ -f "$HOME/.ocp/admin-key" ]]; then
|
||||||
|
_AUTH_HEADER="-H \"Authorization: Bearer $(cat "$HOME/.ocp/admin-key")\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wrapper: curl with optional auth
|
||||||
|
_curl() {
|
||||||
|
if [[ -n "$_AUTH_HEADER" ]]; then
|
||||||
|
eval curl "$_AUTH_HEADER" "$@"
|
||||||
|
else
|
||||||
|
curl "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
_json() { python3 -m json.tool 2>/dev/null || cat; }
|
_json() { python3 -m json.tool 2>/dev/null || cat; }
|
||||||
|
|
||||||
_bar() {
|
_bar() {
|
||||||
@@ -29,11 +46,36 @@ Displays current session utilization, weekly limits, extra usage status,
|
|||||||
and proxy request statistics. Data is fetched from the Anthropic API
|
and proxy request statistics. Data is fetched from the Anthropic API
|
||||||
via a minimal probe call (cached for 5 minutes).
|
via a minimal probe call (cached for 5 minutes).
|
||||||
|
|
||||||
Usage: ocp usage
|
Usage: ocp usage [--by-key]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--by-key Show per-key usage statistics
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_usage() {
|
cmd_usage() {
|
||||||
|
if [[ "${1:-}" == "--by-key" ]]; then
|
||||||
|
local data
|
||||||
|
data=$(_curl -sf --max-time 15 "$PROXY/api/usage" 2>&1) || { echo "Error: proxy unreachable or usage API not available"; exit 1; }
|
||||||
|
echo "$data" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
d = json.loads(sys.stdin.read())
|
||||||
|
by_key = d.get('byKey', [])
|
||||||
|
if not by_key:
|
||||||
|
print('No usage data yet.')
|
||||||
|
else:
|
||||||
|
print('Usage by Key')
|
||||||
|
print('─────────────────────────────────────────────────────────────────')
|
||||||
|
hdr = f' {\"Key\":<20} {\"Reqs\":>5} {\"OK\":>4} {\"Err\":>4} {\"Avg Time\":>9} {\"Last Request\":<20}'
|
||||||
|
print(hdr)
|
||||||
|
print(' ' + '─' * (len(hdr) - 2))
|
||||||
|
for k in by_key:
|
||||||
|
avg_t = f'{k[\"avg_elapsed_ms\"]/1000:.1f}s' if k['avg_elapsed_ms'] else '-'
|
||||||
|
print(f' {k[\"key_name\"]:<20} {k[\"requests\"]:>5} {k[\"successes\"]:>4} {k[\"errors\"]:>4} {avg_t:>9} {k[\"last_request\"]:<20}')
|
||||||
|
"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
local data
|
local data
|
||||||
data=$(curl -sf --max-time 15 "$PROXY/usage" 2>&1) || { echo "Error: proxy unreachable"; exit 1; }
|
data=$(curl -sf --max-time 15 "$PROXY/usage" 2>&1) || { echo "Error: proxy unreachable"; exit 1; }
|
||||||
|
|
||||||
@@ -213,6 +255,318 @@ cmd_clear() {
|
|||||||
echo "Cleared $count sessions."
|
echo "Cleared $count sessions."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── keys ────────────────────────────────────────────────────────────────
|
||||||
|
cmd_keys_help() {
|
||||||
|
cat <<'EOF'
|
||||||
|
ocp keys — Manage API keys (multi-key auth mode)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ocp keys List all keys
|
||||||
|
ocp keys add <name> Create a new key
|
||||||
|
ocp keys revoke <name|id> Revoke a key
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
ocp keys add wife-laptop
|
||||||
|
ocp keys add son-ipad
|
||||||
|
ocp keys revoke wife-laptop
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_keys() {
|
||||||
|
case "${1:-}" in
|
||||||
|
add)
|
||||||
|
if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys add <name>"; return 1; fi
|
||||||
|
local result
|
||||||
|
result=$(_curl -sf --max-time 5 -X POST "$PROXY/api/keys" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"name\": \"$2\"}" 2>&1) || { echo "Error: proxy unreachable or unauthorized"; exit 1; }
|
||||||
|
echo "$result" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
d = json.loads(sys.stdin.read())
|
||||||
|
if 'key' in d:
|
||||||
|
print(f'✓ Key created for \"{d[\"name\"]}\"')
|
||||||
|
print(f'')
|
||||||
|
print(f' API Key: {d[\"key\"]}')
|
||||||
|
print(f'')
|
||||||
|
print(f' Copy this key now — you won\\'t see it again.')
|
||||||
|
print(f' Configure in IDE: OPENAI_API_KEY={d[\"key\"]}')
|
||||||
|
else:
|
||||||
|
print(f'✗ {d.get(\"error\", \"Unknown error\")}')
|
||||||
|
"
|
||||||
|
;;
|
||||||
|
revoke)
|
||||||
|
if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys revoke <name|id>"; return 1; fi
|
||||||
|
_curl -sf --max-time 5 -X DELETE "$PROXY/api/keys/$2" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
d = json.loads(sys.stdin.read())
|
||||||
|
if d.get('revoked'):
|
||||||
|
print(f'✓ Key \"{d[\"idOrName\"]}\" revoked.')
|
||||||
|
else:
|
||||||
|
print(f'✗ Key not found or already revoked.')
|
||||||
|
"
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
cmd_keys_help
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
_curl -sf --max-time 5 "$PROXY/api/keys" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
d = json.loads(sys.stdin.read())
|
||||||
|
keys = d.get('keys', [])
|
||||||
|
if not keys:
|
||||||
|
print('No API keys configured.')
|
||||||
|
print('Create one: ocp keys add <name>')
|
||||||
|
else:
|
||||||
|
print('API Keys')
|
||||||
|
print('─────────────────────────────────────────────────')
|
||||||
|
for k in keys:
|
||||||
|
status = '✗ revoked' if k['revoked'] else '✓ active'
|
||||||
|
print(f' {k[\"name\"]:<20} {k[\"keyPreview\"]:<20} {status} {k[\"created_at\"]}')
|
||||||
|
" 2>/dev/null || { echo "Error: proxy unreachable or key management not available"; exit 1; }
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown subcommand: $1"; cmd_keys_help; return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── connect ─────────────────────────────────────────────────────────────
|
||||||
|
cmd_connect_help() {
|
||||||
|
cat <<'EOF'
|
||||||
|
ocp connect — Connect this machine to a remote OCP as a LAN client
|
||||||
|
|
||||||
|
Configures OPENAI_BASE_URL (and optionally OPENAI_API_KEY) in your shell
|
||||||
|
rc file so tools like Claude Code point to a remote OCP instance.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ocp connect <host-ip> [--port PORT] [--key API_KEY]
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
host-ip IP address of the machine running OCP
|
||||||
|
--port PORT Port OCP listens on (default: 3456)
|
||||||
|
--key API_KEY API key to use (prompted if remote requires auth)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
ocp connect 192.168.1.10
|
||||||
|
ocp connect 192.168.1.10 --port 8080
|
||||||
|
ocp connect 192.168.1.10 --key sk-abc123
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_connect() {
|
||||||
|
local host="" port=3456 key=""
|
||||||
|
|
||||||
|
# Parse args
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--port) port="${2:?'--port requires a value'}"; shift 2 ;;
|
||||||
|
--key) key="${2:?'--key requires a value'}"; shift 2 ;;
|
||||||
|
--help|-h) cmd_connect_help; return 0 ;;
|
||||||
|
-*) echo "Unknown option: $1"; cmd_connect_help; return 1 ;;
|
||||||
|
*) host="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$host" ]]; then
|
||||||
|
echo "Error: host IP is required."
|
||||||
|
echo ""
|
||||||
|
cmd_connect_help
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
||||||
|
echo "Error: invalid host '$host'"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local base_url="http://$host:$port"
|
||||||
|
|
||||||
|
echo "OCP Connect"
|
||||||
|
echo "─────────────────────────────────────"
|
||||||
|
echo " Remote: $base_url"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 1: Test connectivity via /health
|
||||||
|
echo " Checking connectivity..."
|
||||||
|
local health_json
|
||||||
|
health_json=$(curl -sf --max-time 10 "$base_url/health" 2>/dev/null) || {
|
||||||
|
echo " ✗ Cannot reach $base_url/health"
|
||||||
|
echo " Make sure OCP is running on $host and bound to 0.0.0.0 (LAN mode)."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
echo " ✓ Connected"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 2: Show remote info
|
||||||
|
local remote_version auth_mode
|
||||||
|
remote_version=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('version','?'))" 2>/dev/null || echo "?")
|
||||||
|
auth_mode=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('authMode','none'))" 2>/dev/null || echo "none")
|
||||||
|
|
||||||
|
echo " Remote OCP v$remote_version (auth: $auth_mode)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 3: Determine if key is needed
|
||||||
|
local needs_key=0
|
||||||
|
if [[ "$auth_mode" != "none" ]]; then
|
||||||
|
needs_key=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $needs_key -eq 1 && -z "$key" ]]; then
|
||||||
|
echo " Remote requires authentication."
|
||||||
|
echo " Ask the admin to run: ocp keys add <name>"
|
||||||
|
printf " Enter API key (or press Enter to skip): "
|
||||||
|
read -rs key </dev/tty
|
||||||
|
echo
|
||||||
|
if [[ -z "$key" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo " ✗ No key provided — cannot connect to an auth-required remote without a key."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 4: Test API access via /v1/models
|
||||||
|
echo " Testing API access..."
|
||||||
|
local models_out models_ok=0
|
||||||
|
if [[ -n "$key" ]]; then
|
||||||
|
models_out=$(curl -sf --max-time 10 \
|
||||||
|
-H "Authorization: Bearer $key" \
|
||||||
|
"$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||||
|
else
|
||||||
|
models_out=$(curl -sf --max-time 10 "$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $models_ok -eq 0 ]]; then
|
||||||
|
echo " ✗ API access failed — key may be invalid or revoked."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local model_count
|
||||||
|
model_count=$(echo "$models_out" | python3 -c "import sys,json; print(len(json.loads(sys.stdin.read()).get('data',[])))" 2>/dev/null || echo "?")
|
||||||
|
echo " ✓ API accessible ($model_count models available)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 5: Detect shell rc file
|
||||||
|
local rc_file
|
||||||
|
if [[ "${SHELL:-}" == */zsh ]]; then
|
||||||
|
rc_file="$HOME/.zshrc"
|
||||||
|
else
|
||||||
|
rc_file="$HOME/.bashrc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 6: Remove any previously written OCP LAN lines
|
||||||
|
if [[ -f "$rc_file" ]]; then
|
||||||
|
local tmp_rc
|
||||||
|
tmp_rc=$(mktemp)
|
||||||
|
if python3 - "$rc_file" "$tmp_rc" <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
src, dst = sys.argv[1], sys.argv[2]
|
||||||
|
lines = open(src).readlines()
|
||||||
|
out = []
|
||||||
|
skip = False
|
||||||
|
for line in lines:
|
||||||
|
s = line.rstrip('\n')
|
||||||
|
if s == '# OCP LAN (added by ocp connect)':
|
||||||
|
skip = True
|
||||||
|
continue
|
||||||
|
if skip and (s == '' or s.startswith('export OPENAI_BASE_URL=') or s.startswith('export OPENAI_API_KEY=')):
|
||||||
|
continue
|
||||||
|
skip = False
|
||||||
|
out.append(line)
|
||||||
|
open(dst, 'w').writelines(out)
|
||||||
|
PYEOF
|
||||||
|
then
|
||||||
|
cp "$tmp_rc" "$rc_file"
|
||||||
|
fi
|
||||||
|
rm -f "$tmp_rc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 7: Append new config
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# OCP LAN (added by ocp connect)"
|
||||||
|
echo "export OPENAI_BASE_URL=$base_url/v1"
|
||||||
|
if [[ -n "$key" ]]; then
|
||||||
|
echo "export OPENAI_API_KEY=$key"
|
||||||
|
fi
|
||||||
|
} >> "$rc_file"
|
||||||
|
|
||||||
|
echo " Written to $rc_file:"
|
||||||
|
echo " OPENAI_BASE_URL=$base_url/v1"
|
||||||
|
if [[ -n "$key" ]]; then
|
||||||
|
echo " OPENAI_API_KEY=${key:0:8}..."
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 8: Quick smoke test — send a minimal chat completion
|
||||||
|
echo " Running smoke test..."
|
||||||
|
local chat_payload='{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":"Reply with OK only."}],"max_tokens":10}'
|
||||||
|
local chat_out chat_ok=0
|
||||||
|
if [[ -n "$key" ]]; then
|
||||||
|
chat_out=$(curl -sf --max-time 30 \
|
||||||
|
-H "Authorization: Bearer $key" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$chat_payload" \
|
||||||
|
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
||||||
|
else
|
||||||
|
chat_out=$(curl -sf --max-time 30 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$chat_payload" \
|
||||||
|
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $chat_ok -eq 1 ]]; then
|
||||||
|
local reply
|
||||||
|
reply=$(echo "$chat_out" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d['choices'][0]['message']['content'].strip())" 2>/dev/null || echo "(response received)")
|
||||||
|
echo " ✓ Smoke test passed: $reply"
|
||||||
|
else
|
||||||
|
echo " ⚠ Smoke test failed (proxy is reachable but chat completion did not succeed)."
|
||||||
|
echo " The env vars have still been written. Check the remote OCP logs."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Done. Reload your shell to apply:"
|
||||||
|
echo " source $rc_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── lan ─────────────────────────────────────────────────────────────────
|
||||||
|
cmd_lan_help() {
|
||||||
|
cat <<'EOF'
|
||||||
|
ocp lan — Quick LAN mode setup guide
|
||||||
|
|
||||||
|
Shows current network configuration and connection instructions
|
||||||
|
for other devices on the same network.
|
||||||
|
|
||||||
|
Usage: ocp lan
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_lan() {
|
||||||
|
local ip
|
||||||
|
ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown")
|
||||||
|
local port=3456
|
||||||
|
|
||||||
|
echo "OCP LAN Setup"
|
||||||
|
echo "─────────────────────────────────────"
|
||||||
|
echo ""
|
||||||
|
echo " Your IP: $ip"
|
||||||
|
echo " Port: $port"
|
||||||
|
echo ""
|
||||||
|
echo " For IDE users, set:"
|
||||||
|
echo " OPENAI_BASE_URL=http://$ip:$port/v1"
|
||||||
|
echo " OPENAI_API_KEY=<your-key> (if auth enabled)"
|
||||||
|
echo ""
|
||||||
|
echo " Dashboard: http://$ip:$port/dashboard"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if curl -sf --max-time 2 "http://$ip:$port/health" > /dev/null 2>&1; then
|
||||||
|
echo " Status: ✓ LAN-accessible"
|
||||||
|
else
|
||||||
|
echo " Status: ✗ Not LAN-accessible (bound to localhost only)"
|
||||||
|
echo ""
|
||||||
|
echo " To enable LAN mode, set env var and restart:"
|
||||||
|
echo " CLAUDE_BIND=0.0.0.0"
|
||||||
|
echo " ocp restart"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ── restart ──────────────────────────────────────────────────────────────
|
# ── restart ──────────────────────────────────────────────────────────────
|
||||||
cmd_restart_help() {
|
cmd_restart_help() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
@@ -231,12 +585,27 @@ cmd_restart() {
|
|||||||
openclaw gateway restart 2>&1
|
openclaw gateway restart 2>&1
|
||||||
else
|
else
|
||||||
echo "Restarting proxy..."
|
echo "Restarting proxy..."
|
||||||
launchctl kickstart -k gui/501/ai.openclaw.proxy 2>/dev/null || {
|
# Try current service name, then legacy, then manual restart
|
||||||
echo "launchctl failed, trying kill + restart..."
|
local uid
|
||||||
pkill -f "claude-proxy/server.mjs" 2>/dev/null || true
|
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
|
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
|
sleep 3
|
||||||
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
|
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
|
||||||
echo "✓ Proxy restarted successfully."
|
echo "✓ Proxy restarted successfully."
|
||||||
@@ -323,6 +692,102 @@ for e in d.get('errors', []):
|
|||||||
fi
|
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 ─────────────────────────────────────────────────────────────────
|
# ── help ─────────────────────────────────────────────────────────────────
|
||||||
cmd_help() {
|
cmd_help() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
@@ -331,7 +796,7 @@ ocp — OpenClaw Proxy CLI
|
|||||||
Usage: ocp <command> [args]
|
Usage: ocp <command> [args]
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
usage Plan usage limits
|
usage Plan usage limits (--by-key for per-key stats)
|
||||||
status Quick overview (usage + health)
|
status Quick overview (usage + health)
|
||||||
health Proxy diagnostics
|
health Proxy diagnostics
|
||||||
settings View or update tunable settings
|
settings View or update tunable settings
|
||||||
@@ -339,8 +804,13 @@ Commands:
|
|||||||
models Available models
|
models Available models
|
||||||
sessions Active sessions
|
sessions Active sessions
|
||||||
clear Clear all sessions
|
clear Clear all sessions
|
||||||
|
keys Manage API keys (add/list/revoke)
|
||||||
|
lan LAN mode setup guide
|
||||||
|
connect <ip> Connect to a remote OCP (sets env vars in rc file)
|
||||||
restart Restart proxy
|
restart Restart proxy
|
||||||
restart gateway Restart gateway
|
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.
|
Run 'ocp <command> --help' for details on a specific command.
|
||||||
EOF
|
EOF
|
||||||
@@ -371,7 +841,7 @@ for arg in "$@"; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
case "$subcmd" in
|
case "$subcmd" in
|
||||||
usage) cmd_usage ;;
|
usage) cmd_usage "${1:-}" ;;
|
||||||
status) cmd_status ;;
|
status) cmd_status ;;
|
||||||
health) cmd_health ;;
|
health) cmd_health ;;
|
||||||
settings) cmd_settings "${1:-}" "${2:-}" ;;
|
settings) cmd_settings "${1:-}" "${2:-}" ;;
|
||||||
@@ -379,6 +849,10 @@ case "$subcmd" in
|
|||||||
models) cmd_models ;;
|
models) cmd_models ;;
|
||||||
sessions) cmd_sessions ;;
|
sessions) cmd_sessions ;;
|
||||||
clear) cmd_clear ;;
|
clear) cmd_clear ;;
|
||||||
|
keys) cmd_keys "${1:-}" "${2:-}" ;;
|
||||||
|
lan) cmd_lan ;;
|
||||||
|
connect) cmd_connect "$@" ;;
|
||||||
restart) cmd_restart "${1:-}" ;;
|
restart) cmd_restart "${1:-}" ;;
|
||||||
|
update) cmd_update "${1:-}" ;;
|
||||||
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
|
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
Executable
+227
@@ -0,0 +1,227 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ocp-connect — Lightweight client script to connect to a remote OCP instance
|
||||||
|
# No dependencies beyond curl and python3 (available on most Linux/Mac systems)
|
||||||
|
#
|
||||||
|
# Install:
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
|
||||||
|
# chmod +x ocp-connect
|
||||||
|
#
|
||||||
|
# Or run directly:
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect | bash -s -- <host-ip> --key <key>
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
cat <<'EOF'
|
||||||
|
ocp-connect — Connect this machine to a remote OCP (Open Claude Proxy)
|
||||||
|
|
||||||
|
Configures OPENAI_BASE_URL and OPENAI_API_KEY in your shell rc file
|
||||||
|
so tools like Claude Code, Cline, Aider, etc. point to the remote OCP.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ocp-connect <host-ip> [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--port PORT Port OCP listens on (default: 3456)
|
||||||
|
--key API_KEY API key (prompted interactively if remote requires auth)
|
||||||
|
--help, -h Show this help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
ocp-connect 192.168.1.10
|
||||||
|
ocp-connect 192.168.1.10 --port 8080
|
||||||
|
ocp-connect 192.168.1.10 --key ocp_abc123
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
1. Tests connectivity to the remote OCP
|
||||||
|
2. Verifies your API key (if auth is enabled)
|
||||||
|
3. Writes OPENAI_BASE_URL and OPENAI_API_KEY to ~/.bashrc or ~/.zshrc
|
||||||
|
4. Runs a smoke test to confirm everything works
|
||||||
|
|
||||||
|
After running, reload your shell: source ~/.bashrc (or ~/.zshrc)
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local host="" port=3456 key=""
|
||||||
|
|
||||||
|
# Parse args
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--port) port="${2:?'--port requires a value'}"; shift 2 ;;
|
||||||
|
--key) key="${2:?'--key requires a value'}"; shift 2 ;;
|
||||||
|
--help|-h) show_help; exit 0 ;;
|
||||||
|
-*) echo "Unknown option: $1"; show_help; exit 1 ;;
|
||||||
|
*) host="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$host" ]]; then
|
||||||
|
echo "Error: host IP is required."
|
||||||
|
echo ""
|
||||||
|
show_help
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
||||||
|
echo "Error: invalid host '$host'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check dependencies
|
||||||
|
for cmd in curl python3; do
|
||||||
|
if ! command -v "$cmd" &>/dev/null; then
|
||||||
|
echo "Error: '$cmd' is required but not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
local base_url="http://$host:$port"
|
||||||
|
|
||||||
|
echo "OCP Connect"
|
||||||
|
echo "─────────────────────────────────────"
|
||||||
|
echo " Remote: $base_url"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 1: Test connectivity via /health
|
||||||
|
echo " Checking connectivity..."
|
||||||
|
local health_json
|
||||||
|
health_json=$(curl -sf --max-time 10 "$base_url/health" 2>/dev/null) || {
|
||||||
|
echo " ✗ Cannot reach $base_url/health"
|
||||||
|
echo " Make sure OCP is running on $host and bound to 0.0.0.0 (LAN mode)."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo " ✓ Connected"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 2: Show remote info
|
||||||
|
local remote_version auth_mode
|
||||||
|
remote_version=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('version','?'))" 2>/dev/null || echo "?")
|
||||||
|
auth_mode=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('authMode','none'))" 2>/dev/null || echo "none")
|
||||||
|
|
||||||
|
echo " Remote OCP v$remote_version (auth: $auth_mode)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 3: Determine if key is needed
|
||||||
|
if [[ "$auth_mode" != "none" && -z "$key" ]]; then
|
||||||
|
echo " Remote requires authentication."
|
||||||
|
echo " Ask the OCP admin to run: ocp keys add <name>"
|
||||||
|
printf " Enter API key (or press Enter to skip): "
|
||||||
|
read -rs key </dev/tty
|
||||||
|
echo
|
||||||
|
if [[ -z "$key" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo " ✗ No key provided — cannot connect to an auth-required remote without a key."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 4: Test API access via /v1/models
|
||||||
|
echo " Testing API access..."
|
||||||
|
local models_out models_ok=0
|
||||||
|
if [[ -n "$key" ]]; then
|
||||||
|
models_out=$(curl -sf --max-time 10 \
|
||||||
|
-H "Authorization: Bearer $key" \
|
||||||
|
"$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||||
|
else
|
||||||
|
models_out=$(curl -sf --max-time 10 "$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $models_ok -eq 0 ]]; then
|
||||||
|
echo " ✗ API access failed — key may be invalid or revoked."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
local model_count
|
||||||
|
model_count=$(echo "$models_out" | python3 -c "import sys,json; print(len(json.loads(sys.stdin.read()).get('data',[])))" 2>/dev/null || echo "?")
|
||||||
|
echo " ✓ API accessible ($model_count models available)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 5: Detect shell rc file
|
||||||
|
local rc_file
|
||||||
|
if [[ "${SHELL:-}" == */zsh ]]; then
|
||||||
|
rc_file="$HOME/.zshrc"
|
||||||
|
elif [[ "${SHELL:-}" == */fish ]]; then
|
||||||
|
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
||||||
|
rc_file="$HOME/.bashrc"
|
||||||
|
else
|
||||||
|
rc_file="$HOME/.bashrc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 6: Remove any previously written OCP LAN lines (idempotent)
|
||||||
|
if [[ -f "$rc_file" ]]; then
|
||||||
|
local tmp_rc
|
||||||
|
tmp_rc=$(mktemp)
|
||||||
|
if python3 - "$rc_file" "$tmp_rc" <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
src, dst = sys.argv[1], sys.argv[2]
|
||||||
|
lines = open(src).readlines()
|
||||||
|
out = []
|
||||||
|
skip = False
|
||||||
|
for line in lines:
|
||||||
|
s = line.rstrip('\n')
|
||||||
|
if s == '# OCP LAN (added by ocp connect)':
|
||||||
|
skip = True
|
||||||
|
continue
|
||||||
|
if skip and (s == '' or s.startswith('export OPENAI_BASE_URL=') or s.startswith('export OPENAI_API_KEY=')):
|
||||||
|
continue
|
||||||
|
skip = False
|
||||||
|
out.append(line)
|
||||||
|
open(dst, 'w').writelines(out)
|
||||||
|
PYEOF
|
||||||
|
then
|
||||||
|
cp "$tmp_rc" "$rc_file"
|
||||||
|
fi
|
||||||
|
rm -f "$tmp_rc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 7: Append new config
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# OCP LAN (added by ocp connect)"
|
||||||
|
echo "export OPENAI_BASE_URL=$base_url/v1"
|
||||||
|
if [[ -n "$key" ]]; then
|
||||||
|
echo "export OPENAI_API_KEY=$key"
|
||||||
|
fi
|
||||||
|
} >> "$rc_file"
|
||||||
|
|
||||||
|
echo " Written to $rc_file:"
|
||||||
|
echo " OPENAI_BASE_URL=$base_url/v1"
|
||||||
|
if [[ -n "$key" ]]; then
|
||||||
|
echo " OPENAI_API_KEY=${key:0:8}..."
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 8: Quick smoke test
|
||||||
|
echo " Running smoke test..."
|
||||||
|
# Pick the first available model from /v1/models
|
||||||
|
local smoke_model
|
||||||
|
smoke_model=$(echo "$models_out" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d['data'][0]['id'])" 2>/dev/null || echo "claude-haiku-4-5-20251001")
|
||||||
|
local chat_payload="{\"model\":\"$smoke_model\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with OK only.\"}],\"max_tokens\":10}"
|
||||||
|
local chat_out chat_ok=0
|
||||||
|
if [[ -n "$key" ]]; then
|
||||||
|
chat_out=$(curl -sf --max-time 30 \
|
||||||
|
-H "Authorization: Bearer $key" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$chat_payload" \
|
||||||
|
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
||||||
|
else
|
||||||
|
chat_out=$(curl -sf --max-time 30 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$chat_payload" \
|
||||||
|
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $chat_ok -eq 1 ]]; then
|
||||||
|
local reply
|
||||||
|
reply=$(echo "$chat_out" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d['choices'][0]['message']['content'].strip())" 2>/dev/null || echo "(response received)")
|
||||||
|
echo " ✓ Smoke test passed: $reply"
|
||||||
|
else
|
||||||
|
echo " ⚠ Smoke test failed (proxy is reachable but chat completion did not succeed)."
|
||||||
|
echo " The env vars have still been written. Check the remote OCP logs."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Done. Reload your shell to apply:"
|
||||||
|
echo " source $rc_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+95
-11
@@ -31,19 +31,34 @@ function fmtChars(c) {
|
|||||||
|
|
||||||
async function cmdUsage() {
|
async function cmdUsage() {
|
||||||
const d = await fetchJSON("/usage");
|
const d = await fetchJSON("/usage");
|
||||||
const s = d.plan.currentSession;
|
const plan = d.plan || {};
|
||||||
const w = d.plan.weeklyLimits.allModels;
|
const s = plan.currentSession || {};
|
||||||
const e = d.plan.extraUsage;
|
const w = plan.weeklyLimits?.allModels || {};
|
||||||
|
const e = plan.extraUsage || {};
|
||||||
const px = d.proxy;
|
const px = d.proxy;
|
||||||
const models = d.models || {};
|
const models = d.models || {};
|
||||||
|
|
||||||
let out = "Plan Usage Limits\n";
|
let out = "";
|
||||||
out += "─────────────────────────────\n";
|
|
||||||
out += `Current session ${bar(s.utilization)} ${s.percent}\n`;
|
// Show subscription info if available
|
||||||
out += ` Resets in ${s.resetsIn} (${s.resetsAtHuman})\n\n`;
|
if (plan.subscription) {
|
||||||
out += `Weekly (all) ${bar(w.utilization)} ${w.percent}\n`;
|
out += `Plan: ${plan.subscription} (${plan.rateLimitTier || "default"})\n`;
|
||||||
out += ` Resets in ${w.resetsIn} (${w.resetsAtHuman})\n\n`;
|
}
|
||||||
out += `Extra usage ${e.status === "allowed" ? "on" : "off"}\n\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();
|
const modelNames = Object.keys(models).sort();
|
||||||
if (modelNames.length > 0) {
|
if (modelNames.length > 0) {
|
||||||
@@ -151,6 +166,67 @@ async function cmdClear() {
|
|||||||
return `Cleared ${d.cleared} sessions.`;
|
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) {
|
async function cmdLogs(args) {
|
||||||
const parts = (args || "").trim().split(/\s+/);
|
const parts = (args || "").trim().split(/\s+/);
|
||||||
const n = parseInt(parts[0]) || 20;
|
const n = parseInt(parts[0]) || 20;
|
||||||
@@ -174,7 +250,12 @@ function cmdHelp() {
|
|||||||
/ocp logs [N] [level] Recent logs (default: 20, error)
|
/ocp logs [N] [level] Recent logs (default: 20, error)
|
||||||
/ocp models Available models
|
/ocp models Available models
|
||||||
/ocp sessions Active sessions
|
/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 ──────────────────────────────────────────────────
|
// ── Plugin entry point ──────────────────────────────────────────────────
|
||||||
@@ -202,6 +283,9 @@ export default function (api) {
|
|||||||
case "models": text = await cmdModels(); break;
|
case "models": text = await cmdModels(); break;
|
||||||
case "sessions": text = await cmdSessions(); break;
|
case "sessions": text = await cmdSessions(); break;
|
||||||
case "clear": text = await cmdClear(); 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 "logs": text = await cmdLogs(subargs); break;
|
||||||
case "help": case "--help": case "-h": case "":
|
case "help": case "--help": case "-h": case "":
|
||||||
text = cmdHelp(); break;
|
text = cmdHelp(); break;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "ocp",
|
"id": "ocp",
|
||||||
"name": "OCP Commands",
|
"name": "OCP Commands",
|
||||||
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
|
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
|
||||||
"version": "1.0.0",
|
"version": "3.3.1",
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ocp",
|
"name": "ocp",
|
||||||
"version": "1.0.0",
|
"version": "3.3.1",
|
||||||
"description": "Slash commands for the OpenClaw Proxy",
|
"description": "Slash commands for the OpenClaw Proxy",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
Generated
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-claude-proxy",
|
||||||
|
"version": "3.4.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "openclaw-claude-proxy",
|
||||||
|
"version": "3.4.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"ocp": "ocp",
|
||||||
|
"openclaw-claude-proxy": "server.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "openclaw-claude-proxy",
|
"name": "openclaw-claude-proxy",
|
||||||
"version": "3.0.0",
|
"version": "3.5.0",
|
||||||
"description": "OpenAI-compatible proxy for Claude CLI — plan usage monitoring, runtime settings, prompt truncation, OCP CLI",
|
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openclaw-claude-proxy": "./server.mjs",
|
"openclaw-claude-proxy": "./server.mjs",
|
||||||
@@ -24,6 +24,6 @@
|
|||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/dtzp555-max/openclaw-claude-proxy"
|
"url": "https://github.com/dtzp555-max/ocp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+163
-89
@@ -1,30 +1,19 @@
|
|||||||
#!/usr/bin/env node
|
#!/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,
|
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
|
||||||
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
|
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
|
||||||
*
|
*
|
||||||
* v2.5.0:
|
* Timeout design: single CLAUDE_TIMEOUT (default 600s / 10 min).
|
||||||
* - Sliding-window circuit breaker: uses time-windowed failure rate instead of
|
* No separate first-byte or idle timeout — Claude tool-use causes long pauses
|
||||||
* consecutive-count, preventing multi-agent burst scenarios from tripping the
|
* in the token stream (30s-5min) that make fine-grained timeouts unreliable.
|
||||||
* breaker too aggressively. Half-open state allows configurable probe requests.
|
* This matches LiteLLM, OpenAI SDK, and other major LLM proxies.
|
||||||
* - 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
|
|
||||||
*
|
*
|
||||||
* Env vars:
|
* Env vars:
|
||||||
* CLAUDE_PROXY_PORT — listen port (default: 3456)
|
* CLAUDE_PROXY_PORT — listen port (default: 3456)
|
||||||
* CLAUDE_BIN — path to claude binary (default: auto-detect)
|
* CLAUDE_BIN — path to claude binary (default: auto-detect)
|
||||||
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 300000)
|
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 600000)
|
||||||
* CLAUDE_FIRST_BYTE_TIMEOUT — base first-byte timeout in ms (default: 90000)
|
|
||||||
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
||||||
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
|
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
|
||||||
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
* 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 { readFileSync, accessSync, constants } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb } from "./keys.mjs";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||||
@@ -88,8 +79,7 @@ function resolveClaude() {
|
|||||||
// Settings marked with `let` can be changed at runtime via PATCH /settings.
|
// Settings marked with `let` can be changed at runtime via PATCH /settings.
|
||||||
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
||||||
const CLAUDE = resolveClaude();
|
const CLAUDE = resolveClaude();
|
||||||
let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10);
|
let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "600000", 10);
|
||||||
let BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "90000", 10);
|
|
||||||
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
|
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
|
||||||
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
|
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
|
||||||
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
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_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10);
|
||||||
const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10);
|
const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10);
|
||||||
const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX || "2", 10);
|
const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX || "2", 10);
|
||||||
|
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
|
||||||
|
const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none");
|
||||||
|
const ADMIN_KEY = process.env.OCP_ADMIN_KEY || "";
|
||||||
|
|
||||||
|
if (AUTH_MODE === "shared" && !PROXY_API_KEY) {
|
||||||
|
console.warn("WARNING: AUTH_MODE=shared but PROXY_API_KEY is not set — all requests will pass unauthenticated");
|
||||||
|
}
|
||||||
|
|
||||||
const VERSION = _pkg.version;
|
const VERSION = _pkg.version;
|
||||||
const START_TIME = Date.now();
|
const START_TIME = Date.now();
|
||||||
@@ -357,26 +354,13 @@ function messagesToPrompt(messages) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model tier multipliers for first-byte timeout.
|
// Model tier — used for logging only (no timeout logic).
|
||||||
// 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
|
|
||||||
};
|
|
||||||
|
|
||||||
function getModelTier(cliModel) {
|
function getModelTier(cliModel) {
|
||||||
if (cliModel.includes("opus")) return "opus";
|
if (cliModel.includes("opus")) return "opus";
|
||||||
if (cliModel.includes("haiku")) return "haiku";
|
if (cliModel.includes("haiku")) return "haiku";
|
||||||
return "sonnet";
|
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) ─────────────────────────────────────
|
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
||||||
// Resolves session logic, builds CLI args, spawns the process, and sets up
|
// Resolves session logic, builds CLI args, spawns the process, and sets up
|
||||||
// timeouts. Returns context object or throws synchronously.
|
// timeouts. Returns context object or throws synchronously.
|
||||||
@@ -436,7 +420,6 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
activeProcesses.add(proc);
|
activeProcesses.add(proc);
|
||||||
|
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
|
|
||||||
let gotFirstByte = false;
|
let gotFirstByte = false;
|
||||||
let cleaned = false;
|
let cleaned = false;
|
||||||
|
|
||||||
@@ -444,7 +427,6 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
if (cleaned) return;
|
if (cleaned) return;
|
||||||
cleaned = true;
|
cleaned = true;
|
||||||
clearTimeout(overallTimer);
|
clearTimeout(overallTimer);
|
||||||
clearTimeout(firstByteTimer);
|
|
||||||
stats.activeRequests--;
|
stats.activeRequests--;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +440,6 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
function markFirstByte() {
|
function markFirstByte() {
|
||||||
if (!gotFirstByte) {
|
if (!gotFirstByte) {
|
||||||
gotFirstByte = true;
|
gotFirstByte = true;
|
||||||
clearTimeout(firstByteTimer);
|
|
||||||
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
|
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,27 +449,17 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
proc.stdin.end();
|
proc.stdin.end();
|
||||||
|
|
||||||
recordModelRequest(cliModel, prompt.length);
|
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
|
// Single request timeout — no separate first-byte timer.
|
||||||
const firstByteTimer = setTimeout(() => {
|
// Claude tool-use causes long pauses in the token stream (30s-5min),
|
||||||
if (!gotFirstByte && !cleaned) {
|
// making first-byte/idle timeouts unreliable. One generous timeout is simpler and correct.
|
||||||
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
|
|
||||||
const overallTimer = setTimeout(() => {
|
const overallTimer = setTimeout(() => {
|
||||||
if (!cleaned) {
|
if (!cleaned) {
|
||||||
stats.timeouts++;
|
stats.timeouts++;
|
||||||
recordModelError(cliModel, true);
|
recordModelError(cliModel, true);
|
||||||
breakerRecordTimeout(cliModel);
|
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 {}
|
try { proc.kill("SIGTERM"); } catch {}
|
||||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||||
}
|
}
|
||||||
@@ -551,7 +522,7 @@ function callClaude(model, messages, conversationId) {
|
|||||||
// ── Call claude CLI (real streaming) ─────────────────────────────────────
|
// ── Call claude CLI (real streaming) ─────────────────────────────────────
|
||||||
// Pipes stdout from the claude process directly to SSE chunks as they arrive.
|
// Pipes stdout from the claude process directly to SSE chunks as they arrive.
|
||||||
// Each data chunk becomes a proper SSE event with delta content in real time.
|
// Each data chunk becomes a proper SSE event with delta content in real time.
|
||||||
function callClaudeStreaming(model, messages, conversationId, res) {
|
function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) {
|
||||||
const id = `chatcmpl-${randomUUID()}`;
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
const created = Math.floor(Date.now() / 1000);
|
const created = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
@@ -606,6 +577,7 @@ function callClaudeStreaming(model, messages, conversationId, res) {
|
|||||||
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
recordModelError(cliModel, false);
|
recordModelError(cliModel, false);
|
||||||
|
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: 0, elapsedMs: elapsed, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||||
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
||||||
trackError(stderr.slice(0, 300) || `claude exit ${code}`);
|
trackError(stderr.slice(0, 300) || `claude exit ${code}`);
|
||||||
handleSessionFailure();
|
handleSessionFailure();
|
||||||
@@ -623,6 +595,7 @@ function callClaudeStreaming(model, messages, conversationId, res) {
|
|||||||
} else {
|
} else {
|
||||||
recordModelSuccess(cliModel, elapsed);
|
recordModelSuccess(cliModel, elapsed);
|
||||||
breakerRecordSuccess(cliModel);
|
breakerRecordSuccess(cliModel);
|
||||||
|
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: totalChars, elapsedMs: elapsed, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||||
logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
|
logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
|
||||||
|
|
||||||
if (!headersSent) ensureHeaders();
|
if (!headersSent) ensureHeaders();
|
||||||
@@ -687,6 +660,15 @@ let usageCache = { data: null, fetchedAt: 0 };
|
|||||||
const USAGE_CACHE_TTL = 300000; // 5 min
|
const USAGE_CACHE_TTL = 300000; // 5 min
|
||||||
|
|
||||||
function getOAuthToken() {
|
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 {
|
try {
|
||||||
const raw = execFileSync("security", [
|
const raw = execFileSync("security", [
|
||||||
"find-generic-password", "-s", "Claude Code-credentials", "-w"
|
"find-generic-password", "-s", "Claude Code-credentials", "-w"
|
||||||
@@ -919,31 +901,18 @@ async function handleStatus(_req, res) {
|
|||||||
//
|
//
|
||||||
// Tunable keys and their types/ranges:
|
// Tunable keys and their types/ranges:
|
||||||
const SETTINGS_SCHEMA = {
|
const SETTINGS_SCHEMA = {
|
||||||
timeout: { type: "number", min: 30000, max: 600000, unit: "ms", desc: "Overall request timeout" },
|
timeout: { type: "number", min: 30000, max: 1800000, unit: "ms", desc: "Request timeout (default: 600s)" },
|
||||||
firstByteTimeout: { type: "number", min: 15000, max: 300000, unit: "ms", desc: "Base first-byte timeout" },
|
|
||||||
maxConcurrent: { type: "number", min: 1, max: 32, unit: "", desc: "Max concurrent claude processes" },
|
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" },
|
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" },
|
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() {
|
function getSettings() {
|
||||||
return {
|
return {
|
||||||
timeout: { value: TIMEOUT, ...SETTINGS_SCHEMA.timeout },
|
timeout: { value: TIMEOUT, ...SETTINGS_SCHEMA.timeout },
|
||||||
firstByteTimeout: { value: BASE_FIRST_BYTE_TIMEOUT, ...SETTINGS_SCHEMA.firstByteTimeout },
|
|
||||||
maxConcurrent: { value: MAX_CONCURRENT, ...SETTINGS_SCHEMA.maxConcurrent },
|
maxConcurrent: { value: MAX_CONCURRENT, ...SETTINGS_SCHEMA.maxConcurrent },
|
||||||
sessionTTL: { value: SESSION_TTL, ...SETTINGS_SCHEMA.sessionTTL },
|
sessionTTL: { value: SESSION_TTL, ...SETTINGS_SCHEMA.sessionTTL },
|
||||||
maxPromptChars: { value: MAX_PROMPT_CHARS, ...SETTINGS_SCHEMA.maxPromptChars },
|
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) {
|
switch (key) {
|
||||||
case "timeout": TIMEOUT = value; break;
|
case "timeout": TIMEOUT = value; break;
|
||||||
case "firstByteTimeout": BASE_FIRST_BYTE_TIMEOUT = value; break;
|
|
||||||
case "maxConcurrent": MAX_CONCURRENT = value; break;
|
case "maxConcurrent": MAX_CONCURRENT = value; break;
|
||||||
case "sessionTTL": SESSION_TTL = value; break;
|
case "sessionTTL": SESSION_TTL = value; break;
|
||||||
case "maxPromptChars": MAX_PROMPT_CHARS = 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`;
|
default: return `${key}: not implemented`;
|
||||||
}
|
}
|
||||||
logEvent("info", "setting_changed", { key, value });
|
logEvent("info", "setting_changed", { key, value });
|
||||||
@@ -1043,14 +1005,18 @@ async function handleChatCompletions(req, res) {
|
|||||||
|
|
||||||
if (stream) {
|
if (stream) {
|
||||||
// Real streaming: pipe stdout from claude process directly as SSE chunks
|
// Real streaming: pipe stdout from claude process directly as SSE chunks
|
||||||
return callClaudeStreaming(model, messages, conversationId, res);
|
return callClaudeStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const t0Usage = Date.now();
|
||||||
|
const promptChars = messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0);
|
||||||
try {
|
try {
|
||||||
const content = await callClaude(model, messages, conversationId);
|
const content = await callClaude(model, messages, conversationId);
|
||||||
const id = `chatcmpl-${randomUUID()}`;
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
completionResponse(res, id, model, content);
|
completionResponse(res, id, model, content);
|
||||||
|
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||||
console.error(`[proxy] error: ${err.message}`);
|
console.error(`[proxy] error: ${err.message}`);
|
||||||
if (res.headersSent || res.writableEnded || res.destroyed) {
|
if (res.headersSent || res.writableEnded || res.destroyed) {
|
||||||
try { res.end(); } catch {}
|
try { res.end(); } catch {}
|
||||||
@@ -1064,25 +1030,79 @@ async function handleChatCompletions(req, res) {
|
|||||||
|
|
||||||
// ── HTTP server ─────────────────────────────────────────────────────────
|
// ── HTTP server ─────────────────────────────────────────────────────────
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
// Dynamic CORS: only allow localhost origins
|
// Dynamic CORS: allow localhost and LAN origins
|
||||||
const origin = req.headers["origin"] || "";
|
const origin = req.headers["origin"] || "";
|
||||||
const isLocalhost = /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin);
|
const isAllowedOrigin = /^https?:\/\/(127\.0\.0\.1|localhost|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|10\.\d+\.\d+\.\d+)(:\d+)?$/.test(origin);
|
||||||
res.setHeader("Access-Control-Allow-Origin", isLocalhost ? origin : `http://127.0.0.1:${PORT}`);
|
res.setHeader("Access-Control-Allow-Origin", isAllowedOrigin ? origin : `http://127.0.0.1:${PORT}`);
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS, PATCH");
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id");
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id");
|
||||||
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
||||||
|
|
||||||
// Bearer token auth (skip for /health and when PROXY_API_KEY is not set)
|
// 3-mode auth: none | shared | multi
|
||||||
if (PROXY_API_KEY && req.url !== "/health") {
|
const pathname = req.url.split("?")[0];
|
||||||
|
const isPublicEndpoint = pathname === "/health" || pathname === "/dashboard";
|
||||||
|
const remoteAddr = req.socket.remoteAddress || "";
|
||||||
|
const isLocalhost = remoteAddr === "127.0.0.1" || remoteAddr === "::1" || remoteAddr === "::ffff:127.0.0.1";
|
||||||
|
let authKeyName = isLocalhost ? "local" : "remote";
|
||||||
|
let authKeyId = null;
|
||||||
|
|
||||||
|
if (!isPublicEndpoint) {
|
||||||
const auth = req.headers["authorization"] || "";
|
const auth = req.headers["authorization"] || "";
|
||||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
||||||
const tokenBuf = Buffer.from(token);
|
|
||||||
const keyBuf = Buffer.from(PROXY_API_KEY);
|
if (isLocalhost) {
|
||||||
if (tokenBuf.length !== keyBuf.length || !timingSafeEqual(tokenBuf, keyBuf)) {
|
// Localhost always allowed — try to identify key if provided, but never reject
|
||||||
return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } });
|
if (token) {
|
||||||
|
if (ADMIN_KEY) {
|
||||||
|
const adminBuf = Buffer.from(ADMIN_KEY);
|
||||||
|
const tokenBuf = Buffer.from(token);
|
||||||
|
if (adminBuf.length === tokenBuf.length && timingSafeEqual(adminBuf, tokenBuf)) {
|
||||||
|
authKeyName = "admin";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (authKeyName !== "admin") {
|
||||||
|
const keyInfo = validateKey(token);
|
||||||
|
if (keyInfo) { authKeyName = keyInfo.name; authKeyId = keyInfo.id; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else 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 a token is provided, validate it; if not, allow as anonymous
|
||||||
|
if (token) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
authKeyName = "anonymous";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req._authKeyName = authKeyName;
|
||||||
|
req._authKeyId = authKeyId;
|
||||||
|
|
||||||
// GET /v1/models
|
// GET /v1/models
|
||||||
if (req.url === "/v1/models" && req.method === "GET") {
|
if (req.url === "/v1/models" && req.method === "GET") {
|
||||||
return jsonResponse(res, 200, {
|
return jsonResponse(res, 200, {
|
||||||
@@ -1123,10 +1143,10 @@ const server = createServer(async (req, res) => {
|
|||||||
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
|
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
|
||||||
claudeBinary: CLAUDE,
|
claudeBinary: CLAUDE,
|
||||||
claudeBinaryOk: binaryOk,
|
claudeBinaryOk: binaryOk,
|
||||||
|
authMode: AUTH_MODE,
|
||||||
auth: authStatus,
|
auth: authStatus,
|
||||||
config: {
|
config: {
|
||||||
timeout: TIMEOUT,
|
timeout: TIMEOUT,
|
||||||
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
|
|
||||||
maxConcurrent: MAX_CONCURRENT,
|
maxConcurrent: MAX_CONCURRENT,
|
||||||
sessionTTL: SESSION_TTL,
|
sessionTTL: SESSION_TTL,
|
||||||
circuitBreaker: "disabled",
|
circuitBreaker: "disabled",
|
||||||
@@ -1178,7 +1198,57 @@ const server = createServer(async (req, res) => {
|
|||||||
return handleSettings(req, res);
|
return handleSettings(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET /usage, GET /status, GET /logs, GET|PATCH /settings, GET|DELETE /sessions" });
|
// ── Key management API ──
|
||||||
|
const isAdmin = AUTH_MODE !== "multi" || authKeyName === "admin" || isLocalhost;
|
||||||
|
|
||||||
|
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 (pathname === "/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 +1268,7 @@ function gracefulShutdown(signal) {
|
|||||||
// 2. Clear intervals/timers
|
// 2. Clear intervals/timers
|
||||||
clearInterval(sessionCleanupInterval);
|
clearInterval(sessionCleanupInterval);
|
||||||
clearInterval(authCheckInterval);
|
clearInterval(authCheckInterval);
|
||||||
|
closeDb();
|
||||||
|
|
||||||
// 3. Kill all active child processes
|
// 3. Kill all active child processes
|
||||||
for (const proc of activeProcesses) {
|
for (const proc of activeProcesses) {
|
||||||
@@ -1235,18 +1306,21 @@ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|||||||
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||||
|
|
||||||
// ── Start ───────────────────────────────────────────────────────────────
|
// ── Start ───────────────────────────────────────────────────────────────
|
||||||
server.listen(PORT, "127.0.0.1", () => {
|
server.listen(PORT, BIND_ADDRESS, () => {
|
||||||
console.log(`openclaw-claude-proxy v${VERSION} listening on http://127.0.0.1:${PORT}`);
|
const bindMsg = BIND_ADDRESS === "0.0.0.0" ? `http://0.0.0.0:${PORT} (LAN mode)` : `http://127.0.0.1:${PORT}`;
|
||||||
|
console.log(`openclaw-claude-proxy v${VERSION} listening on ${bindMsg}`);
|
||||||
console.log(`Architecture: on-demand spawning (no pool)`);
|
console.log(`Architecture: on-demand spawning (no pool)`);
|
||||||
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
||||||
console.log(`Claude binary: ${CLAUDE}`);
|
console.log(`Claude binary: ${CLAUDE}`);
|
||||||
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(`Circuit breaker: disabled`);
|
||||||
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
||||||
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
||||||
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
|
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
|
||||||
if (MCP_CONFIG) console.log(`MCP config: ${MCP_CONFIG}`);
|
if (MCP_CONFIG) console.log(`MCP config: ${MCP_CONFIG}`);
|
||||||
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
|
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
|
||||||
|
console.log(`Auth mode: ${AUTH_MODE}${AUTH_MODE === "shared" ? " (PROXY_API_KEY)" : AUTH_MODE === "multi" ? " (per-user keys)" : " (open)"}`);
|
||||||
|
console.log(`Bind: ${BIND_ADDRESS}${BIND_ADDRESS === "0.0.0.0" ? " ⚠ LAN-accessible" : ""}`);
|
||||||
console.log(`---`);
|
console.log(`---`);
|
||||||
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
|
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
|
||||||
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`);
|
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* 4. Creates start.sh for easy launch
|
* 4. Creates start.sh for easy launch
|
||||||
* 5. Optionally starts the proxy
|
* 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 { execSync } from "node:child_process";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { homedir } from "node:os";
|
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 DRY_RUN = flag("dry-run");
|
||||||
const SKIP_START = flag("no-start");
|
const SKIP_START = flag("no-start");
|
||||||
const PROVIDER_NAME = opt("provider-name", "claude-local");
|
const PROVIDER_NAME = opt("provider-name", "claude-local");
|
||||||
|
const BIND_ADDRESS = opt("bind", "127.0.0.1");
|
||||||
|
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
|
||||||
|
|
||||||
const MODEL_ID_MAP = {
|
const MODEL_ID_MAP = {
|
||||||
opus: "claude-opus-4-6",
|
opus: "claude-opus-4-6",
|
||||||
@@ -169,6 +171,19 @@ for (const [key, val] of Object.entries(MODEL_ALIASES)) {
|
|||||||
}
|
}
|
||||||
log(`Model aliases added to agents.defaults.models`);
|
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);
|
writeJSON(CONFIG_PATH, config);
|
||||||
log(`Config saved`);
|
log(`Config saved`);
|
||||||
|
|
||||||
@@ -285,26 +300,62 @@ if (!DRY_RUN) {
|
|||||||
console.log("\n🔄 Installing auto-start on login...\n");
|
console.log("\n🔄 Installing auto-start on login...\n");
|
||||||
|
|
||||||
const platform = process.platform;
|
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
|
// Ensure logs dir exists
|
||||||
const logsDir = join(OPENCLAW_DIR, "logs");
|
const logsDir = join(OPENCLAW_DIR, "logs");
|
||||||
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
|
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") {
|
if (platform === "darwin") {
|
||||||
// macOS: launchd
|
// macOS: launchd
|
||||||
const plistDir = join(HOME, "Library", "LaunchAgents");
|
const plistDir = join(HOME, "Library", "LaunchAgents");
|
||||||
if (!existsSync(plistDir)) mkdirSync(plistDir, { recursive: true });
|
if (!existsSync(plistDir)) mkdirSync(plistDir, { recursive: true });
|
||||||
|
|
||||||
const plistPath = join(plistDir, "ai.openclaw.proxy.plist");
|
const plistPath = join(plistDir, "dev.ocp.proxy.plist");
|
||||||
const logPath = join(logsDir, "proxy.log");
|
const logPath = join(ocpLogsDir, "proxy.log");
|
||||||
|
|
||||||
const plistXml = `<?xml version="1.0" encoding="UTF-8"?>
|
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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>Label</key>
|
<key>Label</key>
|
||||||
<string>ai.openclaw.proxy</string>
|
<string>dev.ocp.proxy</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>${nodeBin}</string>
|
<string>${nodeBin}</string>
|
||||||
@@ -314,6 +365,10 @@ if (!DRY_RUN) {
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CLAUDE_PROXY_PORT</key>
|
<key>CLAUDE_PROXY_PORT</key>
|
||||||
<string>${PORT}</string>
|
<string>${PORT}</string>
|
||||||
|
<key>CLAUDE_BIND</key>
|
||||||
|
<string>${BIND_ADDRESS}</string>
|
||||||
|
<key>CLAUDE_AUTH_MODE</key>
|
||||||
|
<string>${AUTH_MODE_CONFIG}</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
@@ -330,27 +385,30 @@ if (!DRY_RUN) {
|
|||||||
writeFileSync(plistPath, plistXml);
|
writeFileSync(plistPath, plistXml);
|
||||||
log(`Plist written: ${plistPath}`);
|
log(`Plist written: ${plistPath}`);
|
||||||
|
|
||||||
// Unload first (in case it was already loaded) then load
|
// Bootout first (in case it was already loaded) then bootstrap
|
||||||
try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
||||||
execSync(`launchctl load "${plistPath}"`);
|
execSync(`launchctl bootstrap gui/$(id -u) "${plistPath}"`);
|
||||||
log(`launchctl loaded ai.openclaw.proxy`);
|
log(`launchctl loaded dev.ocp.proxy`);
|
||||||
|
|
||||||
} else if (platform === "linux") {
|
} else if (platform === "linux") {
|
||||||
// Linux: systemd user service
|
// Linux: systemd user service
|
||||||
const systemdDir = join(HOME, ".config", "systemd", "user");
|
const systemdDir = join(HOME, ".config", "systemd", "user");
|
||||||
if (!existsSync(systemdDir)) mkdirSync(systemdDir, { recursive: true });
|
if (!existsSync(systemdDir)) mkdirSync(systemdDir, { recursive: true });
|
||||||
|
|
||||||
const servicePath = join(systemdDir, "openclaw-proxy.service");
|
const servicePath = join(systemdDir, "ocp-proxy.service");
|
||||||
const logPath = join(logsDir, "proxy.log");
|
const logPath = join(ocpLogsDir, "proxy.log");
|
||||||
|
|
||||||
const serviceUnit = `[Unit]
|
const serviceUnit = `[Unit]
|
||||||
Description=OpenClaw Claude Proxy
|
Description=OCP — Open Claude Proxy
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart=${nodeBin} ${serverPath}
|
ExecStart=${nodeBin} ${serverPath}
|
||||||
Environment=CLAUDE_PROXY_PORT=${PORT}
|
Environment=CLAUDE_PROXY_PORT=${PORT}
|
||||||
|
Environment=CLAUDE_BIND=${BIND_ADDRESS}
|
||||||
|
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}
|
||||||
Restart=always
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
StandardOutput=append:${logPath}
|
StandardOutput=append:${logPath}
|
||||||
StandardError=append:${logPath}
|
StandardError=append:${logPath}
|
||||||
|
|
||||||
@@ -362,8 +420,8 @@ WantedBy=default.target
|
|||||||
log(`Service file written: ${servicePath}`);
|
log(`Service file written: ${servicePath}`);
|
||||||
|
|
||||||
execSync(`systemctl --user daemon-reload`);
|
execSync(`systemctl --user daemon-reload`);
|
||||||
execSync(`systemctl --user enable openclaw-proxy`);
|
execSync(`systemctl --user enable ocp-proxy`);
|
||||||
execSync(`systemctl --user start openclaw-proxy`);
|
execSync(`systemctl --user start ocp-proxy`);
|
||||||
log(`systemd user service enabled and started`);
|
log(`systemd user service enabled and started`);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
PORT=${CLAUDE_PROXY_PORT:-3456}
|
PORT=${CLAUDE_PROXY_PORT:-3456}
|
||||||
if ! lsof -i :$PORT -sTCP:LISTEN &>/dev/null; then
|
if ! lsof -i :$PORT -sTCP:LISTEN &>/dev/null; then
|
||||||
unset CLAUDECODE
|
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" \
|
>> "/Users/taodeng/.openclaw/logs/claude-proxy.log" \
|
||||||
2>> "/Users/taodeng/.openclaw/logs/claude-proxy.err.log" &
|
2>> "/Users/taodeng/.openclaw/logs/claude-proxy.err.log" &
|
||||||
echo "claude-proxy started on port $PORT (pid $!)"
|
echo "claude-proxy started on port $PORT (pid $!)"
|
||||||
|
|||||||
+35
-23
@@ -3,6 +3,9 @@
|
|||||||
* openclaw-claude-proxy uninstaller
|
* openclaw-claude-proxy uninstaller
|
||||||
*
|
*
|
||||||
* Stops and removes the launchd (macOS) or systemd (Linux) auto-start entry.
|
* 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
|
* Run: node uninstall.mjs
|
||||||
*/
|
*/
|
||||||
import { existsSync, unlinkSync } from "node:fs";
|
import { existsSync, unlinkSync } from "node:fs";
|
||||||
@@ -15,43 +18,52 @@ const HOME = homedir();
|
|||||||
function log(msg) { console.log(` ✓ ${msg}`); }
|
function log(msg) { console.log(` ✓ ${msg}`); }
|
||||||
function warn(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;
|
const platform = process.platform;
|
||||||
|
|
||||||
if (platform === "darwin") {
|
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)) {
|
if (existsSync(plistPath)) {
|
||||||
try {
|
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
||||||
execSync(`launchctl unload "${plistPath}" 2>/dev/null`);
|
|
||||||
log("launchd service stopped and unloaded");
|
|
||||||
} catch {
|
|
||||||
warn("launchctl unload failed (service may not have been running)");
|
|
||||||
}
|
|
||||||
unlinkSync(plistPath);
|
unlinkSync(plistPath);
|
||||||
log(`Plist removed: ${plistPath}`);
|
log(`Removed: ${plistPath}`);
|
||||||
} else {
|
}
|
||||||
warn(`Plist not found: ${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") {
|
} else if (platform === "linux") {
|
||||||
const servicePath = join(HOME, ".config", "systemd", "user", "openclaw-proxy.service");
|
// Remove current service
|
||||||
|
const servicePath = join(HOME, ".config", "systemd", "user", "ocp-proxy.service");
|
||||||
try { execSync(`systemctl --user stop openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
|
try { execSync(`systemctl --user stop ocp-proxy 2>/dev/null`); } catch { /* ignore */ }
|
||||||
log("systemd service stopped");
|
try { execSync(`systemctl --user disable ocp-proxy 2>/dev/null`); } catch { /* ignore */ }
|
||||||
|
|
||||||
try { execSync(`systemctl --user disable openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
|
|
||||||
log("systemd service disabled");
|
|
||||||
|
|
||||||
if (existsSync(servicePath)) {
|
if (existsSync(servicePath)) {
|
||||||
unlinkSync(servicePath);
|
unlinkSync(servicePath);
|
||||||
log(`Service file removed: ${servicePath}`);
|
log(`Removed: ${servicePath}`);
|
||||||
} else {
|
}
|
||||||
warn(`Service file not found: ${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 */ }
|
try { execSync(`systemctl --user daemon-reload`); } catch { /* ignore */ }
|
||||||
|
log("systemd daemon reloaded");
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
warn(`Auto-start not supported on ${platform} — nothing to remove`);
|
warn(`Auto-start not supported on ${platform} — nothing to remove`);
|
||||||
|
|||||||
Reference in New Issue
Block a user