mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 21:45:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,42 +1,89 @@
|
|||||||
# 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.3.1)** — Feature-complete. Bug fixes only.
|
||||||
|
|
||||||
A lightweight, zero-dependency proxy that lets [OpenClaw](https://github.com/openclaw/openclaw) agents talk to Claude through your existing subscription. One command to set up, one file to run. Now with built-in plan usage monitoring, runtime settings, and a CLI.
|
> **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.**
|
||||||
|
|
||||||
## What's New in v3.0.0
|
OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing.
|
||||||
|
|
||||||
### `/ocp` — Your Proxy Command Center
|
```
|
||||||
|
Cline ──┐
|
||||||
|
OpenCode ───┤
|
||||||
|
Aider ───┼──→ OCP :3456 ──→ Claude CLI ──→ Your subscription
|
||||||
|
Continue.dev ───┤
|
||||||
|
OpenClaw ───┘
|
||||||
|
```
|
||||||
|
|
||||||
Full management interface available from Telegram, Discord, or any terminal.
|
One proxy. Multiple IDEs. All models. **$0 API cost.**
|
||||||
|
|
||||||
|
## Supported Tools
|
||||||
|
|
||||||
|
Any tool that accepts `OPENAI_BASE_URL` works with OCP:
|
||||||
|
|
||||||
|
| Tool | Configuration |
|
||||||
|
|------|--------------|
|
||||||
|
| **Cline** | Settings → `OPENAI_BASE_URL=http://127.0.0.1:3456/v1` |
|
||||||
|
| **OpenCode** | `OPENAI_BASE_URL=http://127.0.0.1:3456/v1` |
|
||||||
|
| **Aider** | `aider --openai-api-base http://127.0.0.1:3456/v1` |
|
||||||
|
| **Continue.dev** | config.json → `apiBase: "http://127.0.0.1:3456/v1"` |
|
||||||
|
| **OpenClaw** | `setup.mjs` auto-configures |
|
||||||
|
| **Any OpenAI client** | Set base URL to `http://127.0.0.1:3456/v1` |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/dtzp555-max/ocp.git
|
||||||
|
cd ocp
|
||||||
|
node setup.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The setup script will:
|
||||||
|
1. Verify Claude CLI is installed and authenticated
|
||||||
|
2. Start the proxy on port 3456
|
||||||
|
3. Install auto-start (launchd on macOS, systemd on Linux)
|
||||||
|
|
||||||
|
Then point your IDE to the proxy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OPENAI_BASE_URL=http://127.0.0.1:3456/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:3456/v1/models
|
||||||
|
# Returns: claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 status Quick overview
|
ocp status Quick overview
|
||||||
ocp health Proxy diagnostics
|
ocp health Proxy diagnostics
|
||||||
@@ -48,109 +95,85 @@ 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 |
|
||||||
|
|
||||||
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,30 +186,55 @@ Add to `~/.openclaw/openclaw.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart the gateway: `openclaw gateway restart`
|
Restart: `openclaw gateway restart`
|
||||||
|
|
||||||
## API Endpoints
|
## Troubleshooting
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
### Requests fail with exit 143 / SIGTERM after ~60 seconds
|
||||||
|----------|--------|-------------|
|
|
||||||
| `/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
|
**Symptom:** Claude returns errors or stops responding after about 60 seconds, especially during tool use (Bash, Read, etc.).
|
||||||
|
|
||||||
| Model ID | Claude CLI Model | Notes |
|
**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.
|
||||||
|----------|-----------------|-------|
|
|
||||||
| `claude-opus-4-6` | claude-opus-4-6 | Most capable, slower |
|
**Fix:** `setup.mjs` (v3.2.1+) sets this automatically. If you installed an older version, add this to `~/.openclaw/openclaw.json`:
|
||||||
| `claude-sonnet-4-6` | claude-sonnet-4-6 | Good balance of speed/quality |
|
|
||||||
| `claude-haiku-4` | claude-haiku-4-5-20251001 | Fastest, lightweight |
|
```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
|
||||||
|
|
||||||
@@ -194,102 +242,20 @@ Restart the gateway: `openclaw gateway restart`
|
|||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
||||||
| `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 |
|
|
||||||
| `CLAUDE_MCP_CONFIG` | *(empty)* | Path to MCP server config JSON |
|
|
||||||
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication |
|
| `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 only** — binds to `127.0.0.1`, not exposed to the network
|
||||||
- **Bearer token auth (optional)** — set `PROXY_API_KEY` to require auth on all requests except `/health`
|
- **Bearer token auth (optional)** — set `PROXY_API_KEY` to require auth
|
||||||
- **No API keys** — authentication to Anthropic goes through Claude CLI's OAuth session
|
- **No API keys needed** — authentication goes through Claude CLI's OAuth session
|
||||||
- **Auto-start** — launchd (macOS) / systemd (Linux) via `node setup.mjs`
|
- **Auto-start** — launchd (macOS) / systemd (Linux)
|
||||||
- **Remove auto-start**: `node uninstall.mjs`
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
| | v1.x (pool) | v2.0+ (on-demand) |
|
|
||||||
|---|---|---|
|
|
||||||
| Process lifecycle | Pre-spawn idle workers | Spawn per request |
|
|
||||||
| Crash handling | Backoff spiral | No crash loops |
|
|
||||||
| Session support | None | `--resume` with tracking |
|
|
||||||
| Tool access | 6 hardcoded | Configurable, expanded |
|
|
||||||
| Prompt guard | None | Truncation at 150K chars |
|
|
||||||
| Monitoring | Basic `/health` | `/usage`, `/status`, `/settings`, `/logs` |
|
|
||||||
| CLI | None | `ocp` command |
|
|
||||||
| Gateway plugin | None | `/ocp` slash command |
|
|
||||||
|
|
||||||
## Coexistence with Claude Code
|
|
||||||
|
|
||||||
OCP and Claude Code interactive mode are completely independent:
|
|
||||||
|
|
||||||
| | OCP (this proxy) | Claude Code |
|
|
||||||
|---|---|---|
|
|
||||||
| Protocol | HTTP (localhost:3456) | MCP (in-process) |
|
|
||||||
| Process model | Per-request spawn | Persistent session |
|
|
||||||
| Lifecycle | Daemon (auto-start) | Requires terminal |
|
|
||||||
| Use case | Automated agent work | Human-in-the-loop |
|
|
||||||
|
|
||||||
Both run on the same machine simultaneously. No shared state, no port conflicts.
|
|
||||||
|
|
||||||
## Recovery After OpenClaw Upgrade
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/.openclaw/projects/claude-proxy
|
|
||||||
git pull
|
|
||||||
node setup.mjs
|
|
||||||
openclaw gateway restart
|
|
||||||
```
|
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
### v3.0.0 (2026-03-24)
|
|
||||||
- **`/ocp` CLI** — full management from terminal (`ocp usage`, `ocp settings`, etc.)
|
|
||||||
- **`/ocp` gateway plugin** — native slash command in Telegram/Discord
|
|
||||||
- **Plan usage monitoring** — real-time session/weekly limits via Anthropic API rate-limit headers
|
|
||||||
- **Per-model stats** — request count, avg/max elapsed time, avg/max prompt size
|
|
||||||
- **Runtime settings** — `PATCH /settings` to tune timeouts, concurrency, prompt limits without restart
|
|
||||||
- **Prompt truncation** — auto-truncate prompts exceeding 150K chars to prevent timeout cascades
|
|
||||||
- **Circuit breaker removed** — caused more harm than good in CLI-proxy architecture
|
|
||||||
- **Timeout increases** — Opus 150s, Sonnet 120s, Haiku 45s (base first-byte)
|
|
||||||
- **New endpoints** — `/usage`, `/status`, `/settings`, `/logs`
|
|
||||||
|
|
||||||
### v2.5.0 (2026-03-22)
|
|
||||||
- Sliding-window circuit breaker (replaced consecutive-count)
|
|
||||||
- Graduated backoff, multi-probe half-open
|
|
||||||
- Increased default timeouts for large agent prompts
|
|
||||||
|
|
||||||
### v2.0.0
|
|
||||||
- On-demand spawning (replaced pool architecture)
|
|
||||||
- Session management with `--resume`
|
|
||||||
- Full tool access, system prompt, MCP config support
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -231,12 +231,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 +338,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'
|
||||||
@@ -341,6 +452,8 @@ Commands:
|
|||||||
clear Clear all sessions
|
clear Clear all sessions
|
||||||
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
|
||||||
@@ -380,5 +493,6 @@ case "$subcmd" in
|
|||||||
sessions) cmd_sessions ;;
|
sessions) cmd_sessions ;;
|
||||||
clear) cmd_clear ;;
|
clear) cmd_clear ;;
|
||||||
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
|
||||||
|
|||||||
+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",
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "openclaw-claude-proxy",
|
"name": "openclaw-claude-proxy",
|
||||||
"version": "3.0.0",
|
"version": "3.3.1",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-74
@@ -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,7 @@ 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";
|
||||||
|
|
||||||
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 +78,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 ||
|
||||||
@@ -357,26 +346,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 +412,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 +419,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 +432,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 +441,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);
|
||||||
}
|
}
|
||||||
@@ -687,6 +650,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 +891,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 +914,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 });
|
||||||
@@ -1126,7 +1078,6 @@ const server = createServer(async (req, res) => {
|
|||||||
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",
|
||||||
@@ -1240,7 +1191,7 @@ server.listen(PORT, "127.0.0.1", () => {
|
|||||||
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`);
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -169,6 +169,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 +298,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>
|
||||||
@@ -330,27 +379,28 @@ 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}
|
||||||
Restart=always
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
StandardOutput=append:${logPath}
|
StandardOutput=append:${logPath}
|
||||||
StandardError=append:${logPath}
|
StandardError=append:${logPath}
|
||||||
|
|
||||||
@@ -362,8 +412,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